diff --git a/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/GoogleOAuth2AuthorizationService.cs b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/GoogleOAuth2AuthorizationService.cs new file mode 100644 index 0000000..ca58c02 --- /dev/null +++ b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/GoogleOAuth2AuthorizationService.cs @@ -0,0 +1,119 @@ +using DigitalData.MessagingService.Application.Common.Interfaces; +using DigitalData.MessagingService.Domain.Entities; +using DigitalData.MessagingService.Domain.Enums; +using Microsoft.Extensions.Logging; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using System.Web; + +namespace DigitalData.MessagingService.Infrastructure.Services; + +/// +/// Implements the Google OAuth2 authorization code flow. +/// Generates consent URLs and exchanges authorization codes for refresh tokens. +/// This is a one-time setup operation per account — the resulting refresh token +/// is stored in and reused by +/// for all subsequent token acquisitions. +/// +public class GoogleOAuth2AuthorizationService( + ILogger Logger, + IHttpClientFactory HttpClientFactory) : IOAuth2AuthorizationService +{ + private const string AuthEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"; + private const string TokenEndpoint = "https://oauth2.googleapis.com/token"; + private const string Scope = "https://mail.google.com/"; + + public string GetAuthorizationUrl(EmailAccount account, string redirectUri) + { + if (account.OAuth2Provider != OAuth2Provider.Google) + throw new InvalidOperationException( + $"GoogleOAuth2AuthorizationService cannot handle provider '{account.OAuth2Provider}'. Expected '{OAuth2Provider.Google}'."); + + if (string.IsNullOrWhiteSpace(account.OAuth2ClientId)) + throw new InvalidOperationException($"OAuth2ClientId is not configured for account '{account.Username}' (Id: {account.Id})."); + + var query = HttpUtility.ParseQueryString(string.Empty); + query["client_id"] = account.OAuth2ClientId; + query["redirect_uri"] = redirectUri; + query["response_type"] = "code"; + query["scope"] = Scope; + query["access_type"] = "offline"; // ensures refresh_token is returned + query["prompt"] = "consent"; // forces refresh_token even if already authorized + query["state"] = account.Id.ToString(); + + var url = $"{AuthEndpoint}?{query}"; + + Logger.LogDebug("Generated Google OAuth2 authorization URL for account '{Username}' (Id: {Id}).", + account.Username, account.Id); + + return url; + } + + public async Task ExchangeCodeForRefreshTokenAsync( + EmailAccount account, + string code, + string redirectUri, + CancellationToken cancellationToken = default) + { + if (account.OAuth2Provider != OAuth2Provider.Google) + throw new InvalidOperationException( + $"GoogleOAuth2AuthorizationService cannot handle provider '{account.OAuth2Provider}'. Expected '{OAuth2Provider.Google}'."); + + if (string.IsNullOrWhiteSpace(account.OAuth2ClientId)) + throw new InvalidOperationException($"OAuth2ClientId is not configured for account '{account.Username}' (Id: {account.Id})."); + + if (string.IsNullOrWhiteSpace(account.OAuth2ClientSecret)) + throw new InvalidOperationException($"OAuth2ClientSecret is not configured for account '{account.Username}' (Id: {account.Id})."); + + Logger.LogDebug("Exchanging authorization code for refresh token. Account='{Username}' (Id: {Id}).", + account.Username, account.Id); + + var httpClient = HttpClientFactory.CreateClient(nameof(GoogleOAuth2AuthorizationService)); + + var requestBody = new FormUrlEncodedContent([ + new("client_id", account.OAuth2ClientId), + new("client_secret", account.OAuth2ClientSecret), + new("code", code), + new("redirect_uri", redirectUri), + new("grant_type", "authorization_code"), + ]); + + var response = await httpClient.PostAsync(TokenEndpoint, requestBody, cancellationToken); + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + throw new InvalidOperationException( + $"Google token endpoint returned {(int)response.StatusCode} while exchanging authorization code " + + $"for account '{account.Username}'. Response: {responseBody}"); + + var tokenResponse = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken) + ?? throw new InvalidOperationException($"Failed to deserialize Google token response for account '{account.Username}'."); + + if (string.IsNullOrWhiteSpace(tokenResponse.RefreshToken)) + throw new InvalidOperationException( + $"Google did not return a refresh_token for account '{account.Username}'. " + + "Ensure 'access_type=offline' and 'prompt=consent' are set in the authorization URL, " + + "and that the user has not previously authorized this app without revoking access."); + + Logger.LogInformation("Successfully obtained Google refresh token for account '{Username}' (Id: {Id}).", + account.Username, account.Id); + + return tokenResponse.RefreshToken; + } + + private sealed class GoogleTokenResponse + { + [JsonPropertyName("access_token")] + public string AccessToken { get; init; } = string.Empty; + + [JsonPropertyName("refresh_token")] + public string? RefreshToken { get; init; } + + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; init; } + + [JsonPropertyName("token_type")] + public string TokenType { get; init; } = string.Empty; + } +} diff --git a/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/GoogleOAuth2TokenService.cs b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/GoogleOAuth2TokenService.cs new file mode 100644 index 0000000..9f45853 --- /dev/null +++ b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/GoogleOAuth2TokenService.cs @@ -0,0 +1,105 @@ +using DigitalData.MessagingService.Application.Common.Interfaces; +using DigitalData.MessagingService.Domain.Entities; +using DigitalData.MessagingService.Domain.Enums; +using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Serialization; + +namespace DigitalData.MessagingService.Infrastructure.Services; + +/// +/// Acquires OAuth2 access tokens for Google accounts (Gmail / Google Workspace) +/// using the OAuth2 client credentials flow against https://oauth2.googleapis.com/token. +/// Tokens are cached in-memory and reused until 5 minutes before expiry. +/// +/// Required Google Cloud configuration: +/// +/// Create a project in . +/// Enable the Gmail API. +/// Create an OAuth 2.0 Client ID (type: Web application or Desktop). +/// Set OAuth2ClientId and OAuth2ClientSecret in configuration. +/// Leave OAuth2TenantId empty — Google does not use tenant IDs. +/// +/// +/// +/// Note: Google's OAuth2 for IMAP/SMTP requires user-level access (Delegated), +/// not application-level (Client Credentials). A valid refresh token must +/// be stored in OAuth2ClientSecret after the initial user authorization flow. +/// The token endpoint is used here to exchange the refresh token for an access token. +/// +/// +public class GoogleOAuth2TokenService( + ILogger Logger, + IHttpClientFactory HttpClientFactory) : IOAuth2TokenService +{ + private const string TokenEndpoint = "https://oauth2.googleapis.com/token"; + + private readonly ConcurrentDictionary _cache = new(); + + public async Task GetAccessTokenAsync(EmailAccount account, CancellationToken cancellationToken = default) + { + if (account.OAuth2Provider != OAuth2Provider.Google) + throw new InvalidOperationException( + $"GoogleOAuth2TokenService cannot handle provider '{account.OAuth2Provider}' " + + $"for account '{account.Username}'. Expected '{OAuth2Provider.Google}'."); + + if (_cache.TryGetValue(account.Id, out var cached) && cached.Expiry > DateTimeOffset.UtcNow.AddMinutes(5)) + { + Logger.LogDebug("Returning cached Google OAuth2 token for account {Username} (Id: {Id}).", account.Username, account.Id); + return cached.Token; + } + + if (string.IsNullOrWhiteSpace(account.OAuth2ClientId)) + throw new InvalidOperationException($"OAuth2ClientId is not configured for account '{account.Username}' (Id: {account.Id})."); + + if (string.IsNullOrWhiteSpace(account.OAuth2ClientSecret)) + throw new InvalidOperationException( + $"OAuth2ClientSecret is not configured for account '{account.Username}' (Id: {account.Id})."); + + if (string.IsNullOrWhiteSpace(account.OAuth2RefreshToken)) + throw new InvalidOperationException( + $"OAuth2RefreshToken is not configured for account '{account.Username}' (Id: {account.Id}). " + + "Obtain a refresh token via https://developers.google.com/oauthplayground " + + "using scope 'https://mail.google.com/' and set it in OAuth2RefreshToken."); + + Logger.LogDebug("Acquiring new Google OAuth2 token for account {Username} (Id: {Id}).", account.Username, account.Id); + + var httpClient = HttpClientFactory.CreateClient(nameof(GoogleOAuth2TokenService)); + + var requestBody = new FormUrlEncodedContent([ + new("client_id", account.OAuth2ClientId), + new("client_secret", account.OAuth2ClientSecret), + new("grant_type", "refresh_token"), + new("refresh_token", account.OAuth2RefreshToken), + ]); + + var response = await httpClient.PostAsync(TokenEndpoint, requestBody, cancellationToken); + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + throw new InvalidOperationException( + $"Google token endpoint returned {(int)response.StatusCode} for account '{account.Username}'. Response: {responseBody}"); + + var tokenResponse = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken) + ?? throw new InvalidOperationException($"Failed to deserialize Google token response for account '{account.Username}'."); + + var expiry = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn); + _cache[account.Id] = (tokenResponse.AccessToken, expiry); + + return tokenResponse.AccessToken; + } + + private sealed class GoogleTokenResponse + { + [JsonPropertyName("access_token")] + public string AccessToken { get; init; } = string.Empty; + + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; init; } + + [JsonPropertyName("token_type")] + public string TokenType { get; init; } = string.Empty; + } +} diff --git a/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/OAuth2TokenServiceDispatcher.cs b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/OAuth2TokenServiceDispatcher.cs new file mode 100644 index 0000000..7542286 --- /dev/null +++ b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/OAuth2TokenServiceDispatcher.cs @@ -0,0 +1,34 @@ +using DigitalData.MessagingService.Application.Common.Interfaces; +using DigitalData.MessagingService.Domain.Entities; +using DigitalData.MessagingService.Domain.Enums; +using Microsoft.Extensions.DependencyInjection; + +namespace DigitalData.MessagingService.Infrastructure.Services; + +/// +/// Routes OAuth2 token requests to the correct provider-specific implementation +/// based on . +/// Registered as the single in DI — all other +/// services depend on this dispatcher rather than on a concrete provider directly. +/// +public class OAuth2TokenServiceDispatcher(IServiceProvider ServiceProvider) : IOAuth2TokenService +{ + public Task GetAccessTokenAsync(EmailAccount account, CancellationToken cancellationToken = default) + { + var service = account.OAuth2Provider switch + { + OAuth2Provider.Microsoft => ServiceProvider.GetRequiredService(), + OAuth2Provider.Google => (IOAuth2TokenService)ServiceProvider.GetRequiredService(), + + OAuth2Provider.None => throw new InvalidOperationException( + $"Account '{account.Username}' (Id: {account.Id}) has OAuth2Provider = None. " + + "Set UseOAuth2 = false or configure a valid OAuth2Provider."), + + _ => throw new NotSupportedException( + $"OAuth2Provider '{account.OAuth2Provider}' is not supported. " + + $"Supported providers: {string.Join(", ", Enum.GetNames())}") + }; + + return service.GetAccessTokenAsync(account, cancellationToken); + } +}