using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.EntityFrameworkCore; using w4c_workflows.Data; using w4c_workflows.Models; namespace w4c_workflows.Services; /// Resolved identity of an operator-key bearer. public sealed record OperatorPrincipal(Guid KeyId, string TenantId, IReadOnlyList Scopes) { public bool HasScope(string scope) => Scopes.Contains(scope, StringComparer.Ordinal); } /// /// Mints, verifies, lists and revokes per-tenant operator API keys. Only the /// SHA-256 hash is persisted; the raw key is returned exactly once at mint time. /// public class ApiKeyService { private const string KeyPrefix = "w4c_"; /// /// LastUsedAt is informational, so it is written at most this often /// per key instead of on every authenticated request (which doubled the DB /// work on the auth hot path). /// private static readonly TimeSpan LastUsedWriteInterval = TimeSpan.FromMinutes(5); private readonly WorkflowsDbContext _db; public ApiKeyService(WorkflowsDbContext db) { _db = db; } /// /// Generates a fresh operator key, stores its hash, and returns the raw key /// (shown to the caller once — never persisted). /// public async Task<(Guid Id, string RawKey)> MintAsync( string tenantId, string label, IReadOnlyList scopes, CancellationToken ct) { var raw = KeyPrefix + Base64Url(RandomNumberGenerator.GetBytes(32)); var key = new ApiKey { Id = Guid.NewGuid(), TenantId = tenantId, KeyHash = Hash(raw), Label = label, ScopesJson = JsonSerializer.Serialize(scopes), CreatedAt = DateTime.UtcNow, }; _db.ApiKeys.Add(key); await _db.SaveChangesAsync(ct); return (key.Id, raw); } /// /// Resolves a bearer token to an active key's tenant + scopes, touching /// last_used_at. Returns null for unknown/revoked keys (caller → 401). /// public async Task VerifyAsync(string? authHeader, CancellationToken ct) { if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) return null; var raw = authHeader["Bearer ".Length..].Trim(); if (string.IsNullOrEmpty(raw)) return null; var hash = Hash(raw); var key = await _db.ApiKeys .FirstOrDefaultAsync(k => k.KeyHash == hash && k.RevokedAt == null, ct); if (key == null) return null; // Throttled: skip the write when the key was already seen recently. var now = DateTime.UtcNow; if (key.LastUsedAt is null || now - key.LastUsedAt.Value >= LastUsedWriteInterval) { key.LastUsedAt = now; await _db.SaveChangesAsync(ct); } var scopes = ParseScopes(key.ScopesJson); return new OperatorPrincipal(key.Id, key.TenantId, scopes); } public Task> ListAsync(string tenantId, CancellationToken ct) => _db.ApiKeys .Where(k => k.TenantId == tenantId) .OrderByDescending(k => k.CreatedAt) .ToListAsync(ct); public async Task RevokeAsync(string tenantId, Guid id, CancellationToken ct) { var key = await _db.ApiKeys .FirstOrDefaultAsync(k => k.Id == id && k.TenantId == tenantId, ct); if (key == null) return false; key.RevokedAt ??= DateTime.UtcNow; await _db.SaveChangesAsync(ct); return true; } public Task CountActiveAsync(string tenantId, CancellationToken ct) => _db.ApiKeys.CountAsync(k => k.TenantId == tenantId && k.RevokedAt == null, ct); internal static string Hash(string raw) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw))).ToLowerInvariant(); private static IReadOnlyList ParseScopes(string? json) { if (string.IsNullOrWhiteSpace(json)) return Array.Empty(); try { return JsonSerializer.Deserialize(json) ?? Array.Empty(); } catch { return Array.Empty(); } } private static string Base64Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); }