using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using Campaign_Tracker.Server.Audit; using Campaign_Tracker.Server.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Campaign_Tracker.Server.Controllers; [ApiController] [AllowAnonymous] [Route("api/auth/token")] public sealed class AuthTokenController : ControllerBase { private readonly IHostEnvironment _environment; private readonly IAuditService _auditService; private readonly IKeycloakTokenClient _tokenClient; public AuthTokenController( IKeycloakTokenClient tokenClient, IAuditService auditService, IHostEnvironment environment) { _auditService = auditService; _environment = environment; _tokenClient = tokenClient; } [HttpPost("exchange")] public async Task> Exchange( [FromBody] AuthorizationCodeExchangeRequest request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(request.Code) || string.IsNullOrWhiteSpace(request.RedirectUri)) { RecordTokenEvent( AuditEventType.SessionLoginFailure, "anonymous", "authentication/token/exchange", "invalid request"); return BadRequest(); } try { var tokens = await _tokenClient.ExchangeAuthorizationCodeAsync( request.Code, request.RedirectUri, cancellationToken); RecordTokenEvent( AuditEventType.SessionLogin, ExtractSubject(tokens.AccessToken), "authentication/token/exchange", "success"); return Ok(tokens); } catch (KeycloakTokenRequestException exception) { RecordTokenEvent( AuditEventType.SessionLoginFailure, "anonymous", "authentication/token/exchange", $"keycloak rejected request: {(int)exception.StatusCode}"); return Unauthorized(CreateTokenExchangeProblem(exception)); } } [HttpPost("refresh")] public async Task> Refresh( [FromBody] RefreshTokenRequest request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(request.RefreshToken)) { RecordTokenEvent( AuditEventType.SessionRefreshFailure, "anonymous", "authentication/token/refresh", "invalid request"); return BadRequest(); } try { var tokens = await _tokenClient.RefreshAccessTokenAsync( request.RefreshToken, cancellationToken); RecordTokenEvent( AuditEventType.SessionRefresh, ExtractSubject(tokens.AccessToken), "authentication/token/refresh", "success"); return Ok(tokens); } catch (KeycloakTokenRequestException exception) { RecordTokenEvent( AuditEventType.SessionRefreshFailure, "anonymous", "authentication/token/refresh", $"keycloak rejected request: {(int)exception.StatusCode}"); return Unauthorized(CreateTokenExchangeProblem(exception)); } } private void RecordTokenEvent( string eventType, string actorIdentity, string resource, string outcome) { _auditService.Record(new AuditEvent( eventType, actorIdentity, resource, outcome, HttpContext.TraceIdentifier, DateTimeOffset.UtcNow)); } private static string ExtractSubject(string accessToken) { try { var jwt = new JwtSecurityTokenHandler().ReadJwtToken(accessToken); return jwt.Claims.FirstOrDefault(claim => claim.Type == JwtRegisteredClaimNames.Sub || claim.Type == ClaimTypes.NameIdentifier || claim.Type == ClaimTypes.Name || claim.Type == "preferred_username") ?.Value ?? "unknown"; } catch { return "unknown"; } } private object CreateTokenExchangeProblem(KeycloakTokenRequestException exception) { if (!_environment.IsDevelopment()) { return new { error = "Keycloak token request failed." }; } return new { error = "Keycloak token request failed.", statusCode = (int)exception.StatusCode, keycloakResponse = exception.ResponseBody, }; } } public sealed record AuthorizationCodeExchangeRequest(string Code, string RedirectUri); public sealed record RefreshTokenRequest(string RefreshToken);