using System.IdentityModel.Tokens.Jwt; using System.Net.Http.Headers; using System.Text.Json; using FrameworkDAL.DTO.SSO; using GB5Shared.Auth.Jwt; using GB5Shared.DTO.SSO; using GB5Shared.SSO; using Microsoft.IdentityModel.Tokens; using StackExchange.Redis; using FastEndpoints; namespace FrameworkSL.Endpoints.SSO { // H-18 fix: migrated off MVC ControllerBase (was SSOService.Callback under // [Route("SSO/Authorize")]) onto native FastEndpoints. See SSOLoginRedirect.cs for the shared // rationale (pre-auth/public, bypasses BaseEndpoint). public class SSOLoginCallback : Endpoint { private readonly OidcOptionsDTO _opts; private readonly OidcDiscovery _discovery; private readonly IHttpClientFactory _httpFactory; private readonly ILogger _logger; private readonly IDatabase _db; private readonly MultiTenantOidcJwksCache _jwksCache; private const string NONCE_PREFIX = "sso_nonce_"; private const string SESSION_PREFIX = "sso_session_"; public SSOLoginCallback( OidcOptionsDTO opts, OidcDiscovery discovery, IHttpClientFactory httpFactory, ILogger logger, IConnectionMultiplexer redis, MultiTenantOidcJwksCache jwksCache) { _opts = opts; _discovery = discovery; _httpFactory = httpFactory; _logger = logger; _db = redis.GetDatabase(); _jwksCache = jwksCache; } public override void Configure() { Get("/SSO/Authorize/UserLoginRedirectCallback"); AllowAnonymous(); } public class Request { [QueryParam] public string? Code { get; set; } [QueryParam] public string? State { get; set; } [QueryParam] public string? Error { get; set; } [QueryParam] public string? Error_description { get; set; } } // Authority is "{host}/realms/{realm}" — MultiTenantOidcJwksCache (built for the // multi-tenant JWT Bearer resolver, GB5Shared/Auth/Jwt) wants host and realm split, so this // (single fixed realm, unlike that resolver) derives them once from the same config value // rather than duplicating a second JWKS fetcher. private (string Host, string Realm) SplitAuthority() { int idx = _opts.Authority.IndexOf("/realms/", StringComparison.OrdinalIgnoreCase); return idx > 0 ? (_opts.Authority[..idx], _opts.Authority[(idx + "/realms/".Length)..]) : (_opts.Authority, _opts.DefaultRealm); } public override async Task HandleAsync(Request req, CancellationToken ct) { if (!string.IsNullOrEmpty(req.Error)) { _logger.LogError("SSO error: {e} {d}", req.Error, req.Error_description); await Send.StringAsync($"SSO failed: {req.Error}", 400, cancellation: ct); return; } if (string.IsNullOrWhiteSpace(req.Code) || string.IsNullOrWhiteSpace(req.State)) { await Send.StringAsync("Authorization code not received", 400, cancellation: ct); return; } var stateJson = SsoHelpers.Base64UrlDecode(req.State); using var stateDoc = JsonDocument.Parse(stateJson); var clientId = stateDoc.RootElement.GetProperty("clientId").GetString()!; var feCallback = stateDoc.RootElement.GetProperty("fe").GetString()!; var backendCallback = stateDoc.RootElement.GetProperty("backendCallback").GetString()!; var nonce = stateDoc.RootElement.GetProperty("nonce").GetString()!; // KeyDeleteAsync returns whether the key existed — check-and-consume in one atomic // round trip, so a nonce can never be validated twice even under concurrent replays. bool nonceValid = await _db.KeyDeleteAsync(NONCE_PREFIX + nonce); if (!nonceValid) { await Send.StringAsync("Invalid or expired state", 400, cancellation: ct); return; } var client = _opts.Clients.First(c => c.ClientId == clientId); // ---------------- TOKEN EXCHANGE ---------------- var tokenEndpoint = await _discovery.GetTokenEndpointAsync(ct); var http = _httpFactory.CreateClient("oidc"); var tokenResp = await http.PostAsync(tokenEndpoint, new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "authorization_code", ["code"] = req.Code!, ["redirect_uri"] = backendCallback, ["client_id"] = client.ClientId, ["client_secret"] = client.ClientSecret }), ct); var tokenBody = await tokenResp.Content.ReadAsStringAsync(ct); if (!tokenResp.IsSuccessStatusCode) { await Send.StringAsync(tokenBody, (int)tokenResp.StatusCode, cancellation: ct); return; } using var tokenDoc = JsonDocument.Parse(tokenBody); var accessToken = tokenDoc.RootElement.GetProperty("access_token").GetString()!; var idToken = tokenDoc.RootElement.GetProperty("id_token").GetString()!; var refreshToken = tokenDoc.RootElement.TryGetProperty("refresh_token", out var rt) ? rt.GetString() : null; // ---------------- ID TOKEN VALIDATION ---------------- // Validates the real RS256 signature against the realm's JWKS (fetched/cached via the // same MultiTenantOidcJwksCache the multi-tenant JWT Bearer resolver uses), in addition // to issuer/audience/lifetime. var (keycloakHost, realm) = SplitAuthority(); var (signingKeys, expectedIssuer) = await _jwksCache.GetSigningKeysAsync(keycloakHost, realm, ct); var handler = new JwtSecurityTokenHandler(); JwtSecurityToken validatedJwt; try { _ = handler.ValidateToken(idToken, new TokenValidationParameters { ValidateIssuerSigningKey = true, IssuerSigningKeys = signingKeys, ValidateIssuer = true, ValidIssuer = expectedIssuer, ValidateAudience = true, ValidAudience = client.ClientId, ValidateLifetime = true, ClockSkew = TimeSpan.FromMinutes(2) }, out var validatedToken); validatedJwt = (JwtSecurityToken)validatedToken; } catch (Exception ex) { _logger.LogWarning(ex, "SSO id_token signature/claims validation failed"); await Send.StringAsync("Invalid id_token", 400, cancellation: ct); return; } if (validatedJwt.Claims.FirstOrDefault(c => c.Type == "nonce")?.Value != nonce) { await Send.StringAsync("Invalid nonce", 400, cancellation: ct); return; } // ---------------- USER INFO ---------------- var userInfoEndpoint = await _discovery.GetUserInfoEndpointAsync(ct); var userReq = new HttpRequestMessage(System.Net.Http.HttpMethod.Get, userInfoEndpoint); userReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); var userRes = await http.SendAsync(userReq, ct); var userBody = await userRes.Content.ReadAsStringAsync(ct); if (!userRes.IsSuccessStatusCode) { await Send.StringAsync(userBody, (int)userRes.StatusCode, cancellation: ct); return; } using var userDoc = JsonDocument.Parse(userBody); var username = userDoc.RootElement.TryGetProperty("preferred_username", out var pu) ? pu.GetString()! : userDoc.RootElement.TryGetProperty("email", out var em) ? em.GetString()! : "user"; var email = userDoc.RootElement.TryGetProperty("email", out var em2) ? em2.GetString() : null; var name = userDoc.RootElement.TryGetProperty("name", out var nm) ? nm.GetString() : username; // ---------------- SESSION ---------------- var session = new SSOSessionDTO { UserCode = username.ToUpperInvariant(), UserName = name, Email = email, Realm = _opts.DefaultRealm, ClientId = client.ClientId, ClientSecret = client.ClientSecret, GB5BaseUri = _opts.GB5BaseUri, AccessToken = accessToken, RefreshToken = refreshToken, IdToken = idToken, AccessTokenExpiry = DateTime.UtcNow.AddMinutes(60), FrontendUrl = feCallback }; var shortToken = Guid.NewGuid().ToString("N"); await _db.StringSetAsync( SESSION_PREFIX + shortToken, JsonSerializer.Serialize(session), TimeSpan.FromMinutes(10)); await Send.RedirectAsync( $"{feCallback.TrimEnd('/')}" + $"?UserName={Uri.EscapeDataString(username)}&Token={shortToken}", allowRemoteRedirects: true); } } }