using System.IdentityModel.Tokens.Jwt; using System.Security.Cryptography.X509Certificates; using System.Text.Json; using FozFc.Api.Configuration; using Microsoft.IdentityModel.Tokens; namespace FozFc.Api.Infrastructure.Security; /// /// Verifies Firebase ID tokens (issued after Google sign-in on the frontend) /// without a service-account key: it validates the RS256 signature against /// Google's public x509 certs and checks issuer/audience/lifetime. /// public sealed class FirebaseTokenVerifier : IFirebaseTokenVerifier { private const string CertsUrl = "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com"; private readonly AppOptions _options; private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; private readonly SemaphoreSlim _certLock = new(1, 1); private Dictionary _certs = new(); private DateTimeOffset _certsExpireAt = DateTimeOffset.MinValue; public FirebaseTokenVerifier( AppOptions options, IHttpClientFactory httpClientFactory, ILogger logger) { _options = options; _httpClientFactory = httpClientFactory; _logger = logger; } public async Task VerifyAsync(string idToken, CancellationToken ct = default) { var handler = new JwtSecurityTokenHandler(); // Dev escape hatch: decode without verifying the signature. if (_options.AllowUnverifiedGoogle) { var jwt = handler.ReadJwtToken(idToken); return MapClaims(jwt); } if (string.IsNullOrEmpty(_options.FirebaseProjectId)) throw new InvalidOperationException("FIREBASE_PROJECT_ID is not configured; cannot verify Google tokens."); var certs = await GetCertsAsync(ct); var parameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = $"https://securetoken.google.com/{_options.FirebaseProjectId}", ValidateAudience = true, ValidAudience = _options.FirebaseProjectId, ValidateLifetime = true, ClockSkew = TimeSpan.FromMinutes(1), ValidateIssuerSigningKey = true, IssuerSigningKeys = certs.Values.Select(c => new X509SecurityKey(c)), }; var principal = handler.ValidateToken(idToken, parameters, out var validated); var validatedJwt = (JwtSecurityToken)validated; return MapClaims(validatedJwt); } private static GoogleUser MapClaims(JwtSecurityToken jwt) { string Claim(string type) => jwt.Claims.FirstOrDefault(c => c.Type == type)?.Value ?? string.Empty; var uid = Claim("user_id"); if (string.IsNullOrEmpty(uid)) uid = Claim("sub"); if (string.IsNullOrEmpty(uid)) throw new SecurityTokenException("Token is missing a subject."); var emailVerified = bool.TryParse(Claim("email_verified"), out var ev) && ev; return new GoogleUser(uid, Claim("email"), Claim("name"), Claim("picture"), emailVerified); } private async Task> GetCertsAsync(CancellationToken ct) { if (_certs.Count > 0 && DateTimeOffset.UtcNow < _certsExpireAt) return _certs; await _certLock.WaitAsync(ct); try { if (_certs.Count > 0 && DateTimeOffset.UtcNow < _certsExpireAt) return _certs; var client = _httpClientFactory.CreateClient(); using var response = await client.GetAsync(CertsUrl, ct); response.EnsureSuccessStatusCode(); var body = await response.Content.ReadAsStringAsync(ct); var map = JsonSerializer.Deserialize>(body) ?? new(); _certs = map.ToDictionary(kv => kv.Key, kv => X509Certificate2.CreateFromPem(kv.Value)); _certsExpireAt = response.Content.Headers.Expires ?? DateTimeOffset.UtcNow.AddHours(1); return _certs; } catch (Exception ex) { _logger.LogError(ex, "Failed to fetch Google signing certificates."); throw; } finally { _certLock.Release(); } } }