refactor(infrastructure): add provider guard and tenant ID validation to MicrosoftOAuth2TokenService; improve XML docs

This commit is contained in:
2026-08-17 15:05:57 +02:00
parent bb9ff9c9ed
commit 6ff01b364c

View File

@@ -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)