using System.Security.Claims; using System.Security.Cryptography; using System.Text; using System.Text.Encodings.Web; using Books.Api.EventFlow.Repositories; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; namespace Books.Api.Authentication; public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions { public string HeaderName { get; set; } = ApiKeyDefaults.HeaderName; public TimeSpan CacheDuration { get; set; } = TimeSpan.FromMinutes(5); } public class ApiKeyAuthenticationHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, IMemoryCache cache, IServiceProvider serviceProvider) : AuthenticationHandler(options, logger, encoder) { protected override async Task HandleAuthenticateAsync() { if (!Request.Headers.TryGetValue(Options.HeaderName, out var apiKeyHeader)) { return AuthenticateResult.NoResult(); } var apiKeyValue = apiKeyHeader.ToString(); if (string.IsNullOrWhiteSpace(apiKeyValue)) { return AuthenticateResult.NoResult(); } // Expected format: {keyId}.{secret} var parts = apiKeyValue.Split('.', 2); if (parts.Length != 2) { return AuthenticateResult.Fail("Invalid API key format. Expected: {keyId}.{secret}"); } var keyId = parts[0]; var secret = parts[1]; // Check cache first var cacheKey = $"apikey:{keyId}"; if (cache.TryGetValue(cacheKey, out CachedApiKeyInfo? cachedInfo) && cachedInfo != null) { var providedHash = HashSecret(secret); if (cachedInfo.KeyHash == providedHash) { return AuthenticateResult.Success( new AuthenticationTicket(cachedInfo.Principal, Scheme.Name)); } return AuthenticateResult.Fail("Invalid API key"); } // Cache miss - lookup in database using var scope = serviceProvider.CreateScope(); var repository = scope.ServiceProvider.GetService(); if (repository == null) { Logger.LogWarning("IApiKeyRepository not registered - API key authentication unavailable"); return AuthenticateResult.NoResult(); } var apiKey = await repository.GetByIdForValidationAsync(keyId); if (apiKey == null) { return AuthenticateResult.Fail("Invalid API key"); } // Verify hash var secretHash = HashSecret(secret); if (apiKey.KeyHash != secretHash) { return AuthenticateResult.Fail("Invalid API key"); } // Build claims var claims = new List { new(ClaimTypes.NameIdentifier, apiKey.ApiKeyId), new(ClaimTypes.Name, apiKey.Name), new("company_id", apiKey.CompanyId), new("preferred_username", $"apikey:{apiKey.Name}"), new(ClaimTypes.Role, "api_client"), new(ClaimTypes.AuthenticationMethod, "api_key") }; var identity = new ClaimsIdentity(claims, Scheme.Name); var principal = new ClaimsPrincipal(identity); // Cache the result var cacheInfo = new CachedApiKeyInfo(secretHash, principal); cache.Set(cacheKey, cacheInfo, Options.CacheDuration); return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)); } private static string HashSecret(string secret) { var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(secret)); return Convert.ToHexString(bytes).ToLowerInvariant(); } private sealed record CachedApiKeyInfo(string KeyHash, ClaimsPrincipal Principal); }