From fc72e72cc634110368cc18c69007e7eecfe24e71 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Sat, 16 May 2026 19:30:30 -0400 Subject: [PATCH] Add AuthController for Cognito code exchange and .env.example Backend endpoint POST /api/auth/callback exchanges the OAuth authorization code with Cognito's token endpoint, auto-provisions the user in the DB, and returns the access token to the frontend. --- .../Controllers/AuthController.cs | 131 ++++++++++++++++++ api/src/ProposalSystem.Api/Program.cs | 3 + api/src/ProposalSystem.Api/appsettings.json | 4 +- web/.env.example | 6 + 4 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 api/src/ProposalSystem.Api/Controllers/AuthController.cs create mode 100644 web/.env.example diff --git a/api/src/ProposalSystem.Api/Controllers/AuthController.cs b/api/src/ProposalSystem.Api/Controllers/AuthController.cs new file mode 100644 index 0000000..73fc9fa --- /dev/null +++ b/api/src/ProposalSystem.Api/Controllers/AuthController.cs @@ -0,0 +1,131 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using ProposalSystem.Domain.Entities; +using ProposalSystem.Infrastructure.Data; + +namespace ProposalSystem.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AuthController : ControllerBase +{ + private readonly ProposalDbContext _db; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IConfiguration _config; + + public AuthController(ProposalDbContext db, IHttpClientFactory httpClientFactory, IConfiguration config) + { + _db = db; + _httpClientFactory = httpClientFactory; + _config = config; + } + + [HttpPost("callback")] + public async Task> Callback([FromBody] AuthCallbackRequest request, CancellationToken ct) + { + var domain = _config["Auth:CognitoDomain"]; + var clientId = _config["Auth:ClientId"]; + + if (string.IsNullOrEmpty(domain) || string.IsNullOrEmpty(clientId)) + return StatusCode(500, new { message = "Auth not configured" }); + + var tokenResponse = await ExchangeCodeAsync(domain, clientId, request.Code, request.RedirectUri, ct); + if (tokenResponse == null) + return BadRequest(new { message = "Failed to exchange authorization code" }); + + var handler = new JwtSecurityTokenHandler(); + var idToken = handler.ReadJwtToken(tokenResponse.IdToken); + + var sub = idToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value + ?? throw new InvalidOperationException("No sub claim in ID token"); + var email = idToken.Claims.FirstOrDefault(c => c.Type == "email")?.Value ?? ""; + var name = idToken.Claims.FirstOrDefault(c => c.Type == "name")?.Value + ?? idToken.Claims.FirstOrDefault(c => c.Type == "cognito:username")?.Value + ?? email.Split('@')[0]; + var groups = idToken.Claims.Where(c => c.Type == "cognito:groups").Select(c => c.Value).ToList(); + + var role = groups.Contains("sysadmins") ? UserRole.SysAdmin + : groups.Contains("admins") ? UserRole.Admin + : UserRole.Dispatcher; + + var user = await _db.Users.FirstOrDefaultAsync(u => u.CognitoSub == sub, ct); + if (user == null) + { + user = new User + { + Id = Guid.NewGuid(), + CognitoSub = sub, + Email = email, + DisplayName = name, + Role = role, + IsActive = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + _db.Users.Add(user); + await _db.SaveChangesAsync(ct); + } + else if (user.Email != email || user.DisplayName != name) + { + user.Email = email; + user.DisplayName = name; + user.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + } + + return Ok(new AuthResponse( + user.Id.ToString(), + user.Email, + user.DisplayName, + user.Role.ToString(), + tokenResponse.AccessToken + )); + } + + private async Task ExchangeCodeAsync( + string domain, string clientId, string code, string redirectUri, CancellationToken ct) + { + var client = _httpClientFactory.CreateClient(); + var tokenUrl = $"https://{domain}/oauth2/token"; + + var content = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "authorization_code", + ["client_id"] = clientId, + ["code"] = code, + ["redirect_uri"] = redirectUri, + }); + + var response = await client.PostAsync(tokenUrl, content, ct); + if (!response.IsSuccessStatusCode) return null; + + var json = await response.Content.ReadAsStringAsync(ct); + return JsonSerializer.Deserialize(json); + } +} + +public record AuthCallbackRequest(string Code, string RedirectUri); + +public record AuthResponse(string Id, string Email, string DisplayName, string Role, string Token); + +internal class CognitoTokenResponse +{ + [System.Text.Json.Serialization.JsonPropertyName("access_token")] + public string AccessToken { get; set; } = ""; + + [System.Text.Json.Serialization.JsonPropertyName("id_token")] + public string IdToken { get; set; } = ""; + + [System.Text.Json.Serialization.JsonPropertyName("refresh_token")] + public string RefreshToken { get; set; } = ""; + + [System.Text.Json.Serialization.JsonPropertyName("token_type")] + public string TokenType { get; set; } = ""; + + [System.Text.Json.Serialization.JsonPropertyName("expires_in")] + public int ExpiresIn { get; set; } +} diff --git a/api/src/ProposalSystem.Api/Program.cs b/api/src/ProposalSystem.Api/Program.cs index 02ffc5c..ef31749 100644 --- a/api/src/ProposalSystem.Api/Program.cs +++ b/api/src/ProposalSystem.Api/Program.cs @@ -76,6 +76,9 @@ builder.Services.AddScoped(sp => return new SqsJobPublisher(sqsClient, queueUrl); }); +// HTTP client for Cognito token exchange +builder.Services.AddHttpClient(); + // Validation builder.Services.AddValidatorsFromAssemblyContaining(); diff --git a/api/src/ProposalSystem.Api/appsettings.json b/api/src/ProposalSystem.Api/appsettings.json index 38ef245..16c567b 100644 --- a/api/src/ProposalSystem.Api/appsettings.json +++ b/api/src/ProposalSystem.Api/appsettings.json @@ -7,7 +7,9 @@ }, "Auth": { "Authority": "", - "Audience": "" + "Audience": "", + "CognitoDomain": "proposal-system-seahaven.auth.us-east-1.amazoncognito.com", + "ClientId": "" }, "ConnectionStrings": { "DefaultConnection": "" diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..9084357 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,6 @@ +# Cognito Configuration +VITE_COGNITO_DOMAIN=proposal-system-seahaven.auth.us-east-1.amazoncognito.com +VITE_COGNITO_CLIENT_ID=your-cognito-web-client-id + +# API Base URL (only needed if not using Vite proxy in dev) +VITE_API_URL=/api