feat(api): add OAuth2Controller (Google authorize/callback), SyncController (on-demand trigger); register IHttpContextAccessor

This commit is contained in:
2026-08-17 15:06:38 +02:00
parent a9046e957b
commit 5746d36665
3 changed files with 133 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
using DigitalData.MessagingService.Application.OAuth2.Commands;
using DigitalData.MessagingService.Application.OAuth2.Queries;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace DigitalData.MessagingService.API.Controllers;
/// <summary>
/// Manages the OAuth2 authorization code flow for email accounts.
/// Use these endpoints to authorize Google accounts without manually
/// obtaining refresh tokens via external tools.
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class OAuth2Controller(IMediator mediator, IHttpContextAccessor httpContextAccessor) : ControllerBase
{
#region Google Authorization Flow
/// <summary>
/// Step 1: Redirects the user to Google's consent screen for the specified email account.
/// After consent, Google redirects to <c>/api/oauth2/google/callback</c> with an authorization code.
/// </summary>
/// <param name="accountId">ID of the email account to authorize.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>HTTP 302 redirect to Google consent screen.</returns>
[HttpGet("google/authorize/{accountId:int}")]
[ProducesResponseType(StatusCodes.Status302Found)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> AuthorizeGoogle(int accountId, CancellationToken cancellationToken)
{
var redirectUri = BuildCallbackUri();
var authUrl = await mediator.Send(new GetOAuth2AuthorizationUrlQuery
{
AccountId = accountId,
RedirectUri = redirectUri
}, cancellationToken);
return Redirect(authUrl);
}
/// <summary>
/// Step 2: Google callback endpoint. Exchanges the authorization code for a refresh token
/// and saves it to the email account. This endpoint is called automatically by Google
/// after the user grants consent — do not call it directly.
/// </summary>
/// <param name="code">Authorization code provided by Google.</param>
/// <param name="state">Account ID passed as state in the authorization request.</param>
/// <param name="error">Error message if the user denied access.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>HTTP 200 on success, HTTP 400 if access was denied.</returns>
[HttpGet("google/callback")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> GoogleCallback(
[FromQuery] string? code,
[FromQuery] string? state,
[FromQuery] string? error,
CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(error))
return BadRequest(new { Error = error, Message = "User denied access or an error occurred during Google OAuth2 authorization." });
if (string.IsNullOrWhiteSpace(code))
return BadRequest(new { Error = "missing_code", Message = "Authorization code not received from Google." });
if (!int.TryParse(state, out var accountId))
return BadRequest(new { Error = "invalid_state", Message = "Invalid state parameter — could not determine account ID." });
var redirectUri = BuildCallbackUri();
var result = await mediator.Send(new CompleteOAuth2AuthorizationCommand
{
AccountId = accountId,
Code = code,
RedirectUri = redirectUri
}, cancellationToken);
return Ok(new
{
result.Success,
result.Username,
Message = $"Google OAuth2 authorization completed. Refresh token saved for account '{result.Username}'."
});
}
#endregion
private string BuildCallbackUri()
{
var request = httpContextAccessor.HttpContext!.Request;
return $"{request.Scheme}://{request.Host}/api/oauth2/google/callback";
}
}

View File

@@ -0,0 +1,35 @@
using DigitalData.MessagingService.Infrastructure.Services.Background;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Hosting;
namespace DigitalData.MessagingService.API.Controllers;
/// <summary>
/// Controls email synchronization operations.
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class SyncController(IEnumerable<IHostedService> hostedServices) : ControllerBase
{
/// <summary>
/// Triggers an immediate email sync cycle for all configured accounts,
/// skipping the remaining interval wait.
/// If a sync is already in progress, the next cycle will start immediately after it completes.
/// </summary>
/// <returns>HTTP 202 Accepted.</returns>
[HttpPost("trigger")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
public IActionResult TriggerSync()
{
var syncWorker = hostedServices.OfType<EmailSyncWorker>().FirstOrDefault();
if (syncWorker is null)
return StatusCode(StatusCodes.Status503ServiceUnavailable,
new { Message = "EmailSyncWorker is not running." });
syncWorker.TriggerSync();
return Accepted(new { Message = "Sync triggered. The next cycle will start immediately." });
}
}

View File

@@ -71,6 +71,9 @@ try
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Required by OAuth2Controller to build callback URIs
builder.Services.AddHttpContextAccessor();
// Register Serilog.UI with SQLite provider for web log viewer
builder.Services.AddSerilogUi(options =>
{