feat(infrastructure): add GoogleOAuth2TokenService, GoogleOAuth2AuthorizationService and OAuth2TokenServiceDispatcher
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user