feat(application): add OAuth2 authorization flow commands and queries (GetGoogleAuthorizationUrl, CompleteGoogleAuthorization)
This commit is contained in:
@@ -0,0 +1,72 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
using DigitalData.MessagingService.Domain.Exceptions;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.OAuth2.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exchanges a Google OAuth2 authorization code for a refresh token
|
||||||
|
/// and persists it on the email account.
|
||||||
|
/// Call this from the OAuth2 callback endpoint after the user grants consent.
|
||||||
|
/// </summary>
|
||||||
|
public record CompleteOAuth2AuthorizationCommand : IRequest<CompleteOAuth2AuthorizationResult>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ID of the email account being authorized.
|
||||||
|
/// </summary>
|
||||||
|
public required int AccountId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The authorization code received from the OAuth2 provider callback.
|
||||||
|
/// </summary>
|
||||||
|
public required string Code { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The redirect URI used in the original authorization request.
|
||||||
|
/// </summary>
|
||||||
|
public required string RedirectUri { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CompleteOAuth2AuthorizationResult
|
||||||
|
{
|
||||||
|
public required string Username { get; init; }
|
||||||
|
public required bool Success { get; init; }
|
||||||
|
public string? ErrorMessage { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CompleteOAuth2AuthorizationCommandHandler(
|
||||||
|
IRepository<EmailAccount> Repo,
|
||||||
|
IOAuth2AuthorizationService AuthService,
|
||||||
|
ILogger<CompleteOAuth2AuthorizationCommandHandler> Logger) : IRequestHandler<CompleteOAuth2AuthorizationCommand, CompleteOAuth2AuthorizationResult>
|
||||||
|
{
|
||||||
|
public async Task<CompleteOAuth2AuthorizationResult> Handle(CompleteOAuth2AuthorizationCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var account = await Repo.GetByIdAsync(request.AccountId, cancellationToken)
|
||||||
|
?? throw new NotFoundException($"No email account found with Id: {request.AccountId}.");
|
||||||
|
|
||||||
|
Logger.LogInformation("Exchanging OAuth2 authorization code for account '{Username}' (Id: {Id}).",
|
||||||
|
account.Username, account.Id);
|
||||||
|
|
||||||
|
var refreshToken = await AuthService.ExchangeCodeForRefreshTokenAsync(
|
||||||
|
account, request.Code, request.RedirectUri, cancellationToken);
|
||||||
|
|
||||||
|
// Set the refresh token directly on the tracked entity — no AutoMapper needed
|
||||||
|
account.OAuth2RefreshToken = refreshToken;
|
||||||
|
await Repo.UpdateSingleAsync(a => a.Id == account.Id, account, cancellationToken);
|
||||||
|
|
||||||
|
Logger.LogInformation("OAuth2 refresh token saved for account '{Username}' (Id: {Id}).",
|
||||||
|
account.Username, account.Id);
|
||||||
|
|
||||||
|
return new CompleteOAuth2AuthorizationResult
|
||||||
|
{
|
||||||
|
Username = account.Username,
|
||||||
|
Success = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
using DigitalData.MessagingService.Domain.Enums;
|
||||||
|
using DigitalData.MessagingService.Domain.Exceptions;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.OAuth2.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the authorization URL the user must visit to grant OAuth2 access.
|
||||||
|
/// Supported for providers that require user-delegated access (e.g. Google).
|
||||||
|
/// </summary>
|
||||||
|
public record GetOAuth2AuthorizationUrlQuery : IRequest<string>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ID of the email account to authorize.
|
||||||
|
/// </summary>
|
||||||
|
public required int AccountId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The redirect URI registered with the OAuth2 provider.
|
||||||
|
/// Must exactly match the URI configured in the provider's developer console.
|
||||||
|
/// </summary>
|
||||||
|
public required string RedirectUri { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GetOAuth2AuthorizationUrlQueryHandler(
|
||||||
|
IRepository<EmailAccount> Repo,
|
||||||
|
IOAuth2AuthorizationService AuthService) : IRequestHandler<GetOAuth2AuthorizationUrlQuery, string>
|
||||||
|
{
|
||||||
|
public async Task<string> Handle(GetOAuth2AuthorizationUrlQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var account = await Repo.GetByIdAsync(request.AccountId, cancellationToken)
|
||||||
|
?? throw new NotFoundException($"No email account found with Id: {request.AccountId}.");
|
||||||
|
|
||||||
|
if (account.OAuth2Provider == OAuth2Provider.None)
|
||||||
|
throw new BadRequestException(
|
||||||
|
$"Account '{account.Username}' (Id: {account.Id}) has no OAuth2 provider configured. " +
|
||||||
|
$"Set OAuth2Provider to a supported value (e.g. Google).");
|
||||||
|
|
||||||
|
return AuthService.GetAuthorizationUrl(account, request.RedirectUri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
Reference in New Issue
Block a user