diff --git a/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/MicrosoftOAuth2TokenService.cs b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/MicrosoftOAuth2TokenService.cs index bd9cf0d..e777b5f 100644 --- a/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/MicrosoftOAuth2TokenService.cs +++ b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/MicrosoftOAuth2TokenService.cs @@ -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; /// /// 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. +/// +/// Required Azure App Registration permissions (Application, not Delegated): +/// +/// IMAP.AccessAsApp — read mail via IMAP +/// SMTP.SendAsApp — send mail via SMTP +/// POP.AccessAsApp — read mail via POP3 (optional) +/// +/// +/// +/// The single scope https://outlook.office365.com/.default is used intentionally. +/// The .default suffix instructs Azure AD to issue a token covering all +/// Application permissions that have been pre-consented in the App Registration, +/// so there is no need to list individual scopes here. +/// +/// +/// +/// OAuth2TenantId accepts either a tenant GUID, a domain name +/// (e.g. didaloghe or didaloghe.onmicrosoft.com), or "common". +/// /// public class MicrosoftOAuth2TokenService(ILogger Logger) : IOAuth2TokenService { + /// + /// .default requests all Application permissions pre-consented in Azure Portal. + /// This covers IMAP.AccessAsApp, SMTP.SendAsApp and POP.AccessAsApp in one token. + /// private static readonly string[] Scopes = ["https://outlook.office365.com/.default"]; private readonly ConcurrentDictionary _cache = new(); public async Task 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 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)