using System.Net.Http.Headers; using FrameworkDAL.DTO.KeyCloak; using GB5Shared.Vault; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using Newtonsoft.Json; using FastEndpoints; namespace FrameworkSL.Endpoints.KeyCloak { // H-17 fix: migrated off MVC ControllerBase (was // KeyCloakService.UserLoginRedirectCallback under [Route("Authorize")]) onto native // FastEndpoints. Pre-auth/public — see KeyCloakLoginRedirect.cs for the rationale on bypassing // BaseEndpoint here. public class KeyCloakLoginCallback : Endpoint { private readonly KeycloakOptions _keycloakOptions; private readonly IHttpClientFactory _httpClientFactory; private readonly IVaultService _vaultService; private readonly IMemoryCache _cache; private readonly ILogger _logger; private const string ONE_TIME_CODE_PREFIX = "kc_login_code_"; public KeyCloakLoginCallback( IOptions keycloakOptions, IHttpClientFactory httpClientFactory, IVaultService vaultService, IMemoryCache cache, ILogger logger) { _keycloakOptions = keycloakOptions.Value; _httpClientFactory = httpClientFactory; _vaultService = vaultService; _cache = cache; _logger = logger; } public override void Configure() { Get("/Authorize/UserLoginRedirectCallback"); AllowAnonymous(); } public class Request { [QueryParam] public string Code { get; set; } = ""; [QueryParam] public string State { get; set; } = ""; } // Per-realm, per-client Vault path for the OAuth confidential-client secret. Distinct from // KeyCloakDAL's admin-cli credential (keycloak/admin-username|password) — this is the // secret for the GB5WEB-style client itself, and must be per-realm since the `realm` here // can be any tenant, each potentially with its own separately-provisioned client. private static string ClientSecretVaultPath(string realm, string clientId) => $"keycloak/{realm}/{clientId}-client-secret"; public override async Task HandleAsync(Request req, CancellationToken ct) { if (string.IsNullOrEmpty(req.Code) || string.IsNullOrEmpty(req.State)) { await Send.StringAsync("Missing required parameters.", 400, cancellation: ct); return; } var parts = req.State.Split('|'); if (parts.Length < 4) { await Send.StringAsync("Malformed state parameter.", 400, cancellation: ct); return; } string realm = parts[0]; string clientId = parts[1]; string backendCallback = parts[2]; string feCallback = parts[3]; string clientSecret; try { clientSecret = await _vaultService.GetSecretAsync(ClientSecretVaultPath(realm, clientId)); } catch (Exception ex) { _logger.LogError(ex, "KeyCloakLoginCallback: failed to resolve client secret for realm '{Realm}' client '{ClientId}'", realm, clientId); await Send.StringAsync("Unable to resolve Keycloak client configuration for this tenant.", 500, cancellation: ct); return; } string tokenEndpoint = $"{_keycloakOptions.KeycloakHost}/realms/{realm}/protocol/openid-connect/token"; var client = _httpClientFactory.CreateClient("oidc"); var form = new Dictionary { { "grant_type", "authorization_code" }, { "code", req.Code }, { "redirect_uri", backendCallback }, { "client_id", clientId }, { "client_secret", clientSecret } }; var response = await client.PostAsync(tokenEndpoint, new FormUrlEncodedContent(form), ct); if (!response.IsSuccessStatusCode) { _logger.LogWarning("KeyCloakLoginCallback: token exchange failed for realm '{Realm}'", realm); await Send.StringAsync("Token exchange failed.", 500, cancellation: ct); return; } var tokenJson = await response.Content.ReadAsStringAsync(ct); var tokenData = JsonConvert.DeserializeObject>(tokenJson); string accessToken = tokenData?["access_token"]?.ToString()!; var userInfoRequest = new HttpRequestMessage( System.Net.Http.HttpMethod.Get, $"{_keycloakOptions.KeycloakHost}/realms/{realm}/protocol/openid-connect/userinfo"); userInfoRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); var userInfoRes = await client.SendAsync(userInfoRequest, ct); var userInfoJson = await userInfoRes.Content.ReadAsStringAsync(ct); var userInfo = JsonConvert.DeserializeObject>(userInfoJson); string username = userInfo?["preferred_username"]?.ToString() ?? ""; // Opaque, random, single-use, short-lived — the browser only ever sees this code, never // the real access token. Redeemed (and immediately invalidated) by AuthenticateUserToken. string oneTimeCode = Guid.NewGuid().ToString("N"); _cache.Set(ONE_TIME_CODE_PREFIX + oneTimeCode, new KeycloakOneTimeLoginDTO { AccessToken = accessToken, Username = username, Realm = realm }, TimeSpan.FromMinutes(2)); string finalUrl = $"{feCallback}?UserName={Uri.EscapeDataString(username)}&Code={oneTimeCode}"; await Send.RedirectAsync(finalUrl, allowRemoteRedirects: true); } } }