feat(infrastructure): add GoogleOAuth2TokenService, GoogleOAuth2AuthorizationService and OAuth2TokenServiceDispatcher

This commit is contained in:
2026-08-17 15:05:43 +02:00
parent ba8f701223
commit bb9ff9c9ed
3 changed files with 258 additions and 0 deletions

View File

@@ -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;
/// <summary>
/// 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 <see cref="EmailAccount.OAuth2RefreshToken"/> and reused by
/// <see cref="GoogleOAuth2TokenService"/> for all subsequent token acquisitions.
/// </summary>
public class GoogleOAuth2AuthorizationService(
ILogger<GoogleOAuth2AuthorizationService> 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<string> 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<GoogleTokenResponse>(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;
}
}

View File

@@ -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;
/// <summary>
/// Acquires OAuth2 access tokens for Google accounts (Gmail / Google Workspace)
/// using the OAuth2 client credentials flow against <c>https://oauth2.googleapis.com/token</c>.
/// Tokens are cached in-memory and reused until 5 minutes before expiry.
///
/// <para><b>Required Google Cloud configuration:</b></para>
/// <list type="bullet">
/// <item>Create a project in <see href="https://console.cloud.google.com/"/>.</item>
/// <item>Enable the <b>Gmail API</b>.</item>
/// <item>Create an <b>OAuth 2.0 Client ID</b> (type: Web application or Desktop).</item>
/// <item>Set <c>OAuth2ClientId</c> and <c>OAuth2ClientSecret</c> in configuration.</item>
/// <item>Leave <c>OAuth2TenantId</c> empty — Google does not use tenant IDs.</item>
/// </list>
///
/// <para>
/// Note: Google's OAuth2 for IMAP/SMTP requires user-level access (Delegated),
/// not application-level (Client Credentials). A valid <b>refresh token</b> must
/// be stored in <c>OAuth2ClientSecret</c> after the initial user authorization flow.
/// The token endpoint is used here to exchange the refresh token for an access token.
/// </para>
/// </summary>
public class GoogleOAuth2TokenService(
ILogger<GoogleOAuth2TokenService> Logger,
IHttpClientFactory HttpClientFactory) : IOAuth2TokenService
{
private const string TokenEndpoint = "https://oauth2.googleapis.com/token";
private readonly ConcurrentDictionary<int, (string Token, DateTimeOffset Expiry)> _cache = new();
public async Task<string> 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<GoogleTokenResponse>(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;
}
}

View File

@@ -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;
/// <summary>
/// Routes OAuth2 token requests to the correct provider-specific implementation
/// based on <see cref="EmailAccount.OAuth2Provider"/>.
/// Registered as the single <see cref="IOAuth2TokenService"/> in DI — all other
/// services depend on this dispatcher rather than on a concrete provider directly.
/// </summary>
public class OAuth2TokenServiceDispatcher(IServiceProvider ServiceProvider) : IOAuth2TokenService
{
public Task<string> GetAccessTokenAsync(EmailAccount account, CancellationToken cancellationToken = default)
{
var service = account.OAuth2Provider switch
{
OAuth2Provider.Microsoft => ServiceProvider.GetRequiredService<MicrosoftOAuth2TokenService>(),
OAuth2Provider.Google => (IOAuth2TokenService)ServiceProvider.GetRequiredService<GoogleOAuth2TokenService>(),
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<OAuth2Provider>())}")
};
return service.GetAccessTokenAsync(account, cancellationToken);
}
}