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('/', '_');
}