mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 12:23:14 +00:00
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.
This commit is contained in:
parent
378d0cc466
commit
fc72e72cc6
4 changed files with 143 additions and 1 deletions
131
api/src/ProposalSystem.Api/Controllers/AuthController.cs
Normal file
131
api/src/ProposalSystem.Api/Controllers/AuthController.cs
Normal file
|
|
@ -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<ActionResult<AuthResponse>> 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<CognitoTokenResponse?> 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<string, string>
|
||||
{
|
||||
["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<CognitoTokenResponse>(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; }
|
||||
}
|
||||
|
|
@ -76,6 +76,9 @@ builder.Services.AddScoped<IJobPublisher>(sp =>
|
|||
return new SqsJobPublisher(sqsClient, queueUrl);
|
||||
});
|
||||
|
||||
// HTTP client for Cognito token exchange
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
// Validation
|
||||
builder.Services.AddValidatorsFromAssemblyContaining<CreateProposalValidator>();
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@
|
|||
},
|
||||
"Auth": {
|
||||
"Authority": "",
|
||||
"Audience": ""
|
||||
"Audience": "",
|
||||
"CognitoDomain": "proposal-system-seahaven.auth.us-east-1.amazoncognito.com",
|
||||
"ClientId": ""
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": ""
|
||||
|
|
|
|||
6
web/.env.example
Normal file
6
web/.env.example
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue