books/backend/Books.Api/Authentication/ApiKeyAuthenticationHandler.cs
Nicolaj Hartmann 926085eeab Add OpenID Connect + API Key authentication
Backend:
- Cookie + OIDC + API Key authentication schemes
- ApiKeyAuthenticationHandler with SHA-256 validation and 24h cache
- AuthController with login/logout/profile endpoints
- API Key domain model (EventFlow aggregate, events, commands)
- ApiKeyReadModel and repository for key validation
- Database migration 002_ApiKeys.sql
- CORS configuration for frontend

Frontend:
- authService.ts for login/logout/profile API calls
- authStore.ts (Zustand) for user context state
- ProtectedRoute component for route guards
- Header updated with user display and logout
- GraphQL client with credentials: include

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 11:49:29 +01:00

114 lines
3.8 KiB
C#

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.FromHours(24);
}
public class ApiKeyAuthenticationHandler(
IOptionsMonitor<ApiKeyAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IMemoryCache cache,
IServiceProvider serviceProvider)
: AuthenticationHandler<ApiKeyAuthenticationOptions>(options, logger, encoder)
{
protected override async Task<AuthenticateResult> 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<IApiKeyRepository>();
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<Claim>
{
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);
}