using System.Text.Json; using GB5Shared.DTO.SSO; using GB5Shared.SSO; using StackExchange.Redis; using FastEndpoints; namespace FrameworkSL.Endpoints.SSO { // H-18 fix (Authentication_Analysis.md): migrated off MVC ControllerBase // (was SSOService.StartLogin under [Route("SSO/Authorize")]) onto native FastEndpoints — // CLAUDE.md prohibits MVC controllers repo-wide. Pre-auth/public, so this bypasses // BaseEndpoint (which always serializes a ResponseStandardDTO body), same rationale // as FrameworkSL/Endpoints/KeyCloak/KeyCloakLoginRedirect.cs. public class SSOLoginRedirect : Endpoint { private readonly OidcOptionsDTO _opts; private readonly OidcDiscovery _discovery; private readonly IDatabase _db; private readonly ILogger _logger; private const string NONCE_PREFIX = "sso_nonce_"; public SSOLoginRedirect( OidcOptionsDTO opts, OidcDiscovery discovery, IConnectionMultiplexer redis, ILogger logger) { _opts = opts; _discovery = discovery; _db = redis.GetDatabase(); _logger = logger; } public override void Configure() { Get("/SSO/Authorize/UserLoginvalidation"); AllowAnonymous(); } public class Request { [QueryParam] public string ClientId { get; set; } = ""; [QueryParam] public string BackendCallback { get; set; } = ""; [QueryParam] public string FECallBackUrl { get; set; } = ""; } public override async Task HandleAsync(Request req, CancellationToken ct) { if (string.IsNullOrWhiteSpace(req.ClientId) || string.IsNullOrWhiteSpace(req.BackendCallback) || string.IsNullOrWhiteSpace(req.FECallBackUrl)) { await Send.StringAsync("Missing parameters", 400, cancellation: ct); return; } var client = _opts.Clients.FirstOrDefault(c => c.ClientId == req.ClientId); if (client == null) { await Send.StringAsync("Unknown client", 400, cancellation: ct); return; } if (!IsCallbackAllowed(client.RedirectUriPattern, req.BackendCallback)) { await Send.StringAsync("BackendCallback not allowed", 400, cancellation: ct); return; } var nonce = Guid.NewGuid().ToString("N"); var stateObj = new { clientId = req.ClientId, fe = req.FECallBackUrl, backendCallback = req.BackendCallback, nonce, ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds() }; var stateEncoded = SsoHelpers.Base64UrlEncode(JsonSerializer.Serialize(stateObj)); await _db.StringSetAsync(NONCE_PREFIX + nonce, "1", TimeSpan.FromMinutes(5)); var authorize = await _discovery.GetAuthorizeEndpointAsync(ct); var authUrl = $"{authorize}?" + $"client_id={Uri.EscapeDataString(req.ClientId)}&" + $"response_type=code&" + $"scope=openid&" + $"redirect_uri={Uri.EscapeDataString(req.BackendCallback)}&" + $"state={Uri.EscapeDataString(stateEncoded)}&" + $"nonce={nonce}"; _logger.LogInformation("SSO Redirect → {url}", authUrl); await Send.RedirectAsync(authUrl, allowRemoteRedirects: true); } private static bool IsCallbackAllowed(string pattern, string callback) { if (pattern.EndsWith("/*")) return callback.StartsWith(pattern[..^1], StringComparison.OrdinalIgnoreCase); return string.Equals( pattern.TrimEnd('/'), callback.TrimEnd('/'), StringComparison.OrdinalIgnoreCase); } } }