using Microsoft.IdentityModel.Protocols; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; using QuestionsApi.Models; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace QuestionsApi.Services; public interface IExternalAuthProvider { string Id { get; } string DisplayName { get; } bool IsConfigured { get; } string CallbackUrl { get; } string BuildAuthorizationUrl(ExternalAuthTransaction transaction); Task VerifyCallbackAsync( IQueryCollection query, ExternalAuthTransaction transaction, CancellationToken cancellationToken); } public sealed class SteamExternalAuthProvider( SteamOpenIdService openId, IConfiguration configuration) : IExternalAuthProvider { public string Id => ExternalAuthProviders.Steam; public string DisplayName => "Steam"; public bool IsConfigured => true; public string CallbackUrl => configuration["ExternalAuth:SteamReturnUrl"] ?? $"{Realm()}/api/Auth/external/steam/callback"; public string BuildAuthorizationUrl(ExternalAuthTransaction transaction) => SteamOpenIdService.BuildAuthenticationUrl(Realm(), CallbackUrlWithTransaction(transaction.Id)); public async Task VerifyCallbackAsync( IQueryCollection query, ExternalAuthTransaction transaction, CancellationToken cancellationToken) { var subject = await openId.VerifyCallbackAsync( query, CallbackUrlWithTransaction(transaction.Id), cancellationToken); return subject is null ? null : new ExternalProviderClaims(subject, null, false); } private string Realm() => (configuration["Steam:Realm"] ?? "https://s3q.ranaze.com").TrimEnd('/'); private string CallbackUrlWithTransaction(string transactionId) => $"{CallbackUrl}?transactionId={Uri.EscapeDataString(transactionId)}"; } public sealed class GoogleExternalAuthProvider : IExternalAuthProvider { private const string AuthorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"; private const string TokenEndpoint = "https://oauth2.googleapis.com/token"; private const string DiscoveryEndpoint = "https://accounts.google.com/.well-known/openid-configuration"; private readonly IHttpClientFactory _httpClients; private readonly IConfiguration _configuration; private readonly IConfigurationManager _discovery; public GoogleExternalAuthProvider(IHttpClientFactory httpClients, IConfiguration configuration) { _httpClients = httpClients; _configuration = configuration; _discovery = new ConfigurationManager( DiscoveryEndpoint, new OpenIdConnectConfigurationRetriever()); } public string Id => ExternalAuthProviders.Google; public string DisplayName => "Google"; public string CallbackUrl => _configuration["Google:ReturnUrl"] ?? ""; public bool IsConfigured => !string.IsNullOrWhiteSpace(ClientId) && !string.IsNullOrWhiteSpace(ClientSecret) && Uri.TryCreate(CallbackUrl, UriKind.Absolute, out var callback) && callback.Scheme == Uri.UriSchemeHttps; private string ClientId => _configuration["Google:ClientId"] ?? ""; private string ClientSecret => _configuration["Google:ClientSecret"] ?? ""; public string BuildAuthorizationUrl(ExternalAuthTransaction transaction) { var challenge = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(transaction.CodeVerifier ?? ""))); return AuthorizationEndpoint + "?" + Query(new Dictionary { ["client_id"] = ClientId, ["redirect_uri"] = CallbackUrl, ["response_type"] = "code", ["scope"] = "openid email profile", ["state"] = transaction.Id, ["nonce"] = transaction.Nonce ?? "", ["code_challenge"] = challenge, ["code_challenge_method"] = "S256", ["prompt"] = "select_account" }); } public async Task VerifyCallbackAsync( IQueryCollection query, ExternalAuthTransaction transaction, CancellationToken cancellationToken) { if (!IsConfigured || !string.IsNullOrWhiteSpace(query["error"]) || !CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(query["state"].ToString()), Encoding.UTF8.GetBytes(transaction.Id))) return null; var code = query["code"].ToString(); if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(transaction.CodeVerifier)) return null; var client = _httpClients.CreateClient(nameof(GoogleExternalAuthProvider)); client.Timeout = TimeSpan.FromSeconds(15); using var response = await client.PostAsync(TokenEndpoint, new FormUrlEncodedContent(new Dictionary { ["client_id"] = ClientId, ["client_secret"] = ClientSecret, ["code"] = code, ["code_verifier"] = transaction.CodeVerifier, ["grant_type"] = "authorization_code", ["redirect_uri"] = CallbackUrl }), cancellationToken); if (!response.IsSuccessStatusCode) return null; using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); if (!payload.RootElement.TryGetProperty("id_token", out var idTokenElement)) return null; var idToken = idTokenElement.GetString(); if (string.IsNullOrWhiteSpace(idToken)) return null; var oidc = await _discovery.GetConfigurationAsync(cancellationToken); var handler = new JwtSecurityTokenHandler { MapInboundClaims = false }; ClaimsPrincipal principal; try { principal = handler.ValidateToken(idToken, new TokenValidationParameters { ValidateIssuer = true, ValidIssuers = ["https://accounts.google.com", "accounts.google.com"], ValidateAudience = true, ValidAudience = ClientId, ValidateLifetime = true, ValidateIssuerSigningKey = true, IssuerSigningKeys = oidc.SigningKeys, ClockSkew = TimeSpan.FromMinutes(2) }, out _); } catch (SecurityTokenException) { return null; } var subject = principal.FindFirstValue("sub"); var nonce = principal.FindFirstValue("nonce"); var email = principal.FindFirstValue("email"); var verified = string.Equals(principal.FindFirstValue("email_verified"), "true", StringComparison.OrdinalIgnoreCase); if (string.IsNullOrWhiteSpace(subject) || string.IsNullOrWhiteSpace(nonce) || !CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(nonce), Encoding.UTF8.GetBytes(transaction.Nonce ?? ""))) return null; return new ExternalProviderClaims(subject, email, verified); } internal static string NewVerifier() => Base64Url(RandomNumberGenerator.GetBytes(48)); internal static string NewNonce() => Base64Url(RandomNumberGenerator.GetBytes(32)); private static string Query(Dictionary values) => string.Join("&", values.Select(pair => $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}")); private static string Base64Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); }