books/backend/Books.Api/EventFlow/ReadModels/ApiKeyReadModel.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

74 lines
2.6 KiB
C#

using System.ComponentModel.DataAnnotations.Schema;
using Books.Api.Domain.ApiKeys;
using Books.Api.Domain.ApiKeys.Events;
using EventFlow.Aggregates;
using EventFlow.PostgreSql.ReadStores.Attributes;
using EventFlow.ReadStores;
namespace Books.Api.EventFlow.ReadModels;
[Table("apikey_read_models")]
public class ApiKeyReadModel : IReadModel,
IAmReadModelFor<ApiKeyAggregate, ApiKeyId, ApiKeyCreatedEvent>,
IAmReadModelFor<ApiKeyAggregate, ApiKeyId, ApiKeyRevokedEvent>
{
[PostgreSqlReadModelIdentityColumn]
public string AggregateId { get; set; } = string.Empty;
public DateTimeOffset CreateTime { get; set; }
public DateTimeOffset UpdatedTime { get; set; }
[PostgreSqlReadModelVersionColumn]
public int LastAggregateSequenceNumber { get; set; }
public string Name { get; set; } = string.Empty;
public string KeyHash { get; set; } = string.Empty;
public string CompanyId { get; set; } = string.Empty;
public string CreatedBy { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
public DateTimeOffset? RevokedTime { get; set; }
public string? RevokedBy { get; set; }
public Task ApplyAsync(
IReadModelContext context,
IDomainEvent<ApiKeyAggregate, ApiKeyId, ApiKeyCreatedEvent> domainEvent,
CancellationToken cancellationToken)
{
var e = domainEvent.AggregateEvent;
AggregateId = domainEvent.AggregateIdentity.Value;
CreateTime = domainEvent.Timestamp;
UpdatedTime = domainEvent.Timestamp;
LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber;
Name = e.Name;
KeyHash = e.KeyHash;
CompanyId = e.CompanyId;
CreatedBy = e.CreatedBy;
IsActive = true;
return Task.CompletedTask;
}
public Task ApplyAsync(
IReadModelContext context,
IDomainEvent<ApiKeyAggregate, ApiKeyId, ApiKeyRevokedEvent> domainEvent,
CancellationToken cancellationToken)
{
var e = domainEvent.AggregateEvent;
UpdatedTime = domainEvent.Timestamp;
LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber;
IsActive = false;
RevokedTime = domainEvent.Timestamp;
RevokedBy = e.RevokedBy;
return Task.CompletedTask;
}
}
public class ApiKeyReadModelLocator : IReadModelLocator
{
public IEnumerable<string> GetReadModelIds(IDomainEvent domainEvent)
{
if (domainEvent.GetAggregateEvent() is IAggregateEvent<ApiKeyAggregate, ApiKeyId>)
{
yield return domainEvent.GetIdentity().Value;
}
}
}