proposal-system/api/src/ProposalSystem.Api/Controllers/AuthController.cs
Adam Moussa f06ffb6fad
fix(api): log user id instead of email on cognito role sync (SEC-29)
Keep AuthResponse.Email unchanged so CodeQL alert 1 closes without altering the callback payload.
2026-08-20 12:15:57 -04:00

261 lines
10 KiB
C#

using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
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;
private readonly ILogger<AuthController> _logger;
public AuthController(ProposalDbContext db, IHttpClientFactory httpClientFactory, IConfiguration config, ILogger<AuthController> logger)
{
_db = db;
_httpClientFactory = httpClientFactory;
_config = config;
_logger = logger;
}
[HttpPost("callback")]
public async Task<ActionResult<AuthResponse>> Callback([FromBody] AuthCallbackRequest request, CancellationToken ct)
{
// Fix: API-H2 — validate redirectUri against allowlist to prevent open-redirect attacks
var allowedRedirectUris = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"https://proposals.seahaven.com/callback",
"https://d2yevct5e5uuz5.cloudfront.net/callback",
};
if (_config.GetValue<bool>("Auth:DevMode"))
allowedRedirectUris.Add("http://localhost:5173/callback");
if (!allowedRedirectUris.Contains(request.RedirectUri))
return BadRequest(new { message = "Invalid redirect URI" });
var domain = _config["Auth:CognitoDomain"];
var clientId = _config["Auth:ClientId"];
// Fix: API-M10 — return generic error to avoid leaking internal auth configuration details
if (string.IsNullOrEmpty(domain) || string.IsNullOrEmpty(clientId))
return StatusCode(500, new { message = "Authentication service unavailable" });
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 authority = _config["Auth:Authority"];
// Fix: API-M10 — return generic error to avoid leaking internal auth configuration details
if (string.IsNullOrEmpty(authority))
return StatusCode(500, new { message = "Authentication service unavailable" });
var configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
$"{authority}/.well-known/openid-configuration",
new OpenIdConnectConfigurationRetriever(),
new HttpDocumentRetriever());
var oidcConfig = await configManager.GetConfigurationAsync(ct);
var validationParams = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKeys = oidcConfig.SigningKeys,
ValidateIssuer = true,
ValidIssuer = authority,
ValidateAudience = true,
ValidAudience = clientId,
ValidateLifetime = true,
};
handler.ValidateToken(tokenResponse.IdToken, validationParams, out var validatedToken);
var idToken = (JwtSecurityToken)validatedToken;
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
{
var changed = false;
if (user.Email != email) { user.Email = email; changed = true; }
if (user.DisplayName != name) { user.DisplayName = name; changed = true; }
if (user.Role != role)
{
// Fix: API-M13 — log previous role on Cognito-synced role changes
_logger.LogInformation("User {UserId} role changed from {OldRole} to {NewRole} via Cognito sync",
user.Id, user.Role, role);
user.Role = role;
changed = true;
}
if (changed)
{
user.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
}
}
return Ok(new AuthResponse(
user.Id.ToString(),
user.Email,
user.DisplayName,
user.Role.ToString(),
tokenResponse.IdToken
));
}
[HttpPost("dev-login")]
public async Task<ActionResult<AuthResponse>> DevLogin([FromBody] DevLoginRequest request, CancellationToken ct)
{
var devMode = _config.GetValue<bool>("Auth:DevMode");
if (!devMode)
return NotFound();
var signingKey = _config["Auth:DevSigningKey"];
// Fix: API-M10 — return generic error to avoid leaking dev auth configuration details
if (string.IsNullOrEmpty(signingKey))
return StatusCode(500, new { message = "Authentication service unavailable" });
if (string.IsNullOrWhiteSpace(request.Email))
return BadRequest(new { message = "Email is required" });
if (!string.IsNullOrEmpty(request.Role) && !Enum.TryParse<UserRole>(request.Role, true, out _))
return BadRequest(new { message = $"Invalid role: '{request.Role}'. Valid roles are: Dispatcher, Admin, SysAdmin" });
var role = Enum.TryParse<UserRole>(request.Role, true, out var parsed) ? parsed : UserRole.Dispatcher;
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == request.Email, ct);
if (user == null)
{
user = new User
{
Id = Guid.NewGuid(),
CognitoSub = $"dev-{request.Email}",
Email = request.Email,
DisplayName = request.DisplayName ?? request.Email.Split('@')[0],
Role = role,
IsActive = true,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
_db.Users.Add(user);
await _db.SaveChangesAsync(ct);
}
else if (user.Role != role)
{
user.Role = role;
user.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
}
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new("sub", user.CognitoSub),
new("email", user.Email),
new("name", user.DisplayName),
new("cognito:groups", role.ToString().ToLower() + "s"),
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: "proposal-system-dev",
audience: "proposal-system-dev",
claims: claims,
expires: DateTime.UtcNow.AddHours(12),
signingCredentials: creds);
var tokenString = new JwtSecurityTokenHandler().WriteToken(token);
return Ok(new AuthResponse(
user.Id.ToString(),
user.Email,
user.DisplayName,
user.Role.ToString(),
tokenString
));
}
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 DevLoginRequest(string Email, string? DisplayName, string? Role);
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; }
}