refactor(infrastructure): add provider guard and tenant ID validation to MicrosoftOAuth2TokenService; improve XML docs
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using DigitalData.MessagingService.Domain.Enums;
|
||||
using Microsoft.Identity.Client;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
@@ -8,17 +9,45 @@ namespace DigitalData.MessagingService.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Acquires OAuth2 access tokens using Microsoft Identity (MSAL) with the client credentials flow.
|
||||
/// Supports Microsoft 365 / Exchange Online accounts.
|
||||
/// Supports Microsoft 365 / Exchange Online accounts (IMAP, POP3, SMTP via XOAUTH2).
|
||||
/// Tokens are cached in-memory and reused until 5 minutes before expiry.
|
||||
///
|
||||
/// <para><b>Required Azure App Registration permissions (Application, not Delegated):</b></para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>IMAP.AccessAsApp</c> — read mail via IMAP</item>
|
||||
/// <item><c>SMTP.SendAsApp</c> — send mail via SMTP</item>
|
||||
/// <item><c>POP.AccessAsApp</c> — read mail via POP3 (optional)</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// The single scope <c>https://outlook.office365.com/.default</c> is used intentionally.
|
||||
/// The <c>.default</c> suffix instructs Azure AD to issue a token covering <em>all</em>
|
||||
/// Application permissions that have been pre-consented in the App Registration,
|
||||
/// so there is no need to list individual scopes here.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <c>OAuth2TenantId</c> accepts either a tenant GUID, a domain name
|
||||
/// (e.g. <c>didaloghe</c> or <c>didaloghe.onmicrosoft.com</c>), or <c>"common"</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class MicrosoftOAuth2TokenService(ILogger<MicrosoftOAuth2TokenService> Logger) : IOAuth2TokenService
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>.default</c> requests all Application permissions pre-consented in Azure Portal.
|
||||
/// This covers IMAP.AccessAsApp, SMTP.SendAsApp and POP.AccessAsApp in one token.
|
||||
/// </summary>
|
||||
private static readonly string[] Scopes = ["https://outlook.office365.com/.default"];
|
||||
|
||||
private readonly ConcurrentDictionary<int, (string Token, DateTimeOffset Expiry)> _cache = new();
|
||||
|
||||
public async Task<string> GetAccessTokenAsync(EmailAccount account, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (account.OAuth2Provider != OAuth2Provider.Microsoft)
|
||||
throw new InvalidOperationException(
|
||||
$"MicrosoftOAuth2TokenService cannot handle provider '{account.OAuth2Provider}' " +
|
||||
$"for account '{account.Username}'. Expected '{OAuth2Provider.Microsoft}'.");
|
||||
|
||||
if (_cache.TryGetValue(account.Id, out var cached) && cached.Expiry > DateTimeOffset.UtcNow.AddMinutes(5))
|
||||
{
|
||||
Logger.LogDebug("Returning cached OAuth2 token for account {Username} (Id: {Id}).", account.Username, account.Id);
|
||||
@@ -33,6 +62,19 @@ public class MicrosoftOAuth2TokenService(ILogger<MicrosoftOAuth2TokenService> Lo
|
||||
|
||||
var tenantId = string.IsNullOrWhiteSpace(account.OAuth2TenantId) ? "common" : account.OAuth2TenantId;
|
||||
|
||||
// Azure AD accepts: a tenant GUID, the full domain (e.g. "contoso.onmicrosoft.com"
|
||||
// or a verified custom domain), "common", or "organizations".
|
||||
// Short names like "contoso" without a TLD are NOT valid and will cause AADSTS900023.
|
||||
if (!tenantId.Equals("common", StringComparison.OrdinalIgnoreCase) &&
|
||||
!tenantId.Equals("organizations", StringComparison.OrdinalIgnoreCase) &&
|
||||
!Guid.TryParse(tenantId, out _) &&
|
||||
!tenantId.Contains('.'))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"OAuth2TenantId '{tenantId}' for account '{account.Username}' is not a valid Azure AD tenant identifier. " +
|
||||
$"Use the full domain (e.g. '{tenantId}.onmicrosoft.com'), a tenant GUID, or 'common'.");
|
||||
}
|
||||
|
||||
var app = ConfidentialClientApplicationBuilder
|
||||
.Create(account.OAuth2ClientId)
|
||||
.WithClientSecret(account.OAuth2ClientSecret)
|
||||
|
||||
Reference in New Issue
Block a user