using System.Collections.Concurrent; using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.WebUtilities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using SIFinance.Shared; using SIFinance.Shared.Entities; namespace SIFinance.Api; // Le backend pilote lui-même le flux OAuth2 "authorization code + PKCE" avec Entra ID // (plutôt que MSAL.js côté frontend) : /api/auth/microsoft redirige vers Microsoft, // /api/auth/callback échange le code, résout l'utilisateur (déjà provisionné par // EntraSyncService) et émet le jeton "maison" (JwtTokenService) consommé par le // frontend pour tous les appels API suivants. public static class EntraAuthEndpoints { // État PKCE en mémoire (code_verifier par "state"), expire après 10 min — un seul // process API tourne en dev ; à revoir (cache distribué) si on scale horizontalement. private static readonly ConcurrentDictionary PkceStore = new(); public static IEndpointRouteBuilder MapEntraAuthEndpoints(this IEndpointRouteBuilder app) { app.MapGet("/api/auth/microsoft", (IOptions azureOptions) => { var opts = azureOptions.Value; var state = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)); var codeVerifier = Base64UrlEncode(RandomNumberGenerator.GetBytes(32)); var codeChallenge = Base64UrlEncode(SHA256.HashData(Encoding.UTF8.GetBytes(codeVerifier))); CleanupExpired(); PkceStore[state] = (codeVerifier, DateTime.UtcNow.AddMinutes(10)); var url = QueryHelpers.AddQueryString( $"https://login.microsoftonline.com/{opts.TenantId}/oauth2/v2.0/authorize", new Dictionary { ["client_id"] = opts.ClientId, ["response_type"] = "code", ["redirect_uri"] = opts.RedirectUri, ["response_mode"] = "query", ["scope"] = "openid profile email User.Read", ["state"] = state, ["prompt"] = "select_account", ["code_challenge"] = codeChallenge, ["code_challenge_method"] = "S256", }); return Results.Redirect(url); }); app.MapGet("/api/auth/callback", async ( string? code, string? state, string? error, IOptions azureOptions, IHttpClientFactory httpClientFactory, SIFinanceDbContext db, JwtTokenService jwtService, IConfiguration config, ILogger logger, CancellationToken ct) => { var frontendUrl = (config["FrontendUrl"] ?? "http://localhost:5173").TrimEnd('/'); if (!string.IsNullOrEmpty(error)) return Results.Redirect($"{frontendUrl}/login?error={Uri.EscapeDataString(error)}"); if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state) || !PkceStore.TryRemove(state, out var entry) || entry.Expiry < DateTime.UtcNow) return Results.Redirect($"{frontendUrl}/login?error=invalid_state"); var opts = azureOptions.Value; var http = httpClientFactory.CreateClient(); var tokenResponse = await http.PostAsync( $"https://login.microsoftonline.com/{opts.TenantId}/oauth2/v2.0/token", new FormUrlEncodedContent(new Dictionary { ["client_id"] = opts.ClientId, ["client_secret"] = opts.ClientSecret, ["code"] = code, ["redirect_uri"] = opts.RedirectUri, ["grant_type"] = "authorization_code", ["code_verifier"] = entry.CodeVerifier, }), ct); if (!tokenResponse.IsSuccessStatusCode) { var body = await tokenResponse.Content.ReadAsStringAsync(ct); logger.LogWarning("Échange du code OAuth2 échoué ({Status}) : {Body}", tokenResponse.StatusCode, body); return Results.Redirect($"{frontendUrl}/login?error=token_exchange_failed"); } var tokenJson = JsonDocument.Parse(await tokenResponse.Content.ReadAsStringAsync(ct)).RootElement; var accessToken = tokenJson.GetProperty("access_token").GetString(); var meRequest = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com/v1.0/me"); meRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); var meResponse = await http.SendAsync(meRequest, ct); if (!meResponse.IsSuccessStatusCode) { var body = await meResponse.Content.ReadAsStringAsync(ct); logger.LogWarning("Appel Graph /me échoué ({Status}) : {Body}", meResponse.StatusCode, body); return Results.Redirect($"{frontendUrl}/login?error=graph_me_failed"); } var me = JsonDocument.Parse(await meResponse.Content.ReadAsStringAsync(ct)).RootElement; var email = GetStr(me, "mail") ?? GetStr(me, "userPrincipalName"); if (string.IsNullOrWhiteSpace(email)) return Results.Redirect($"{frontendUrl}/login?error=no_email"); var user = await db.Utilisateurs .FirstOrDefaultAsync(u => u.Email == email && u.Actif, ct); // Pas de création à la volée ici : l'utilisateur doit être membre du groupe // Entra ID synchronisé par EntraSyncService pour pouvoir se connecter. if (user is null) { logger.LogWarning("Connexion refusée — aucun utilisateur actif trouvé pour l'email {Email}.", email); return Results.Redirect($"{frontendUrl}/login?error=user_not_found"); } user.DateDerniereConnexion = DateTime.UtcNow; await db.SaveChangesAsync(ct); var roles = await db.UtilisateurRoles .AsNoTracking() .Where(r => r.AzureOid == user.AzureOid) .Select(r => r.Role) .ToListAsync(ct); var estResponsable = await db.Utilisateurs.AsNoTracking().AnyAsync(u => u.ResponsableId == user.Id, ct); var jwt = jwtService.GenerateToken(user, roles, estResponsable); return Results.Redirect($"{frontendUrl}/auth/callback?token={Uri.EscapeDataString(jwt)}"); }); return app; } private static string Base64UrlEncode(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); private static string? GetStr(JsonElement e, string name) => e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null; private static void CleanupExpired() { foreach (var key in PkceStore.Where(kv => kv.Value.Expiry < DateTime.UtcNow).Select(kv => kv.Key).ToList()) PkceStore.TryRemove(key, out _); } }