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();
}
}
}