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>
This commit is contained in:
Nicolaj Hartmann 2026-01-18 11:49:29 +01:00
parent c4a27f0bac
commit 926085eeab
28 changed files with 849 additions and 24 deletions

View file

@ -0,0 +1,114 @@
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);
}

View file

@ -0,0 +1,7 @@
namespace Books.Api.Authentication;
public static class ApiKeyDefaults
{
public const string AuthenticationScheme = "ApiKey";
public const string HeaderName = "x-api-key";
}

View file

@ -0,0 +1,34 @@
using System.Security.Claims;
namespace Books.Api.Authentication;
public static class UserExtensions
{
public static UserContext? GetUserContext(this ClaimsPrincipal? principal)
{
if (principal?.Identity?.IsAuthenticated != true)
return null;
return new UserContext(
Id: principal.FindFirst(ClaimTypes.NameIdentifier)?.Value,
Email: principal.FindFirst(ClaimTypes.Email)?.Value
?? principal.FindFirst("preferred_username")?.Value,
Name: principal.FindFirst(ClaimTypes.GivenName)?.Value
?? principal.FindFirst(ClaimTypes.Name)?.Value,
CompanyId: principal.FindFirst("company_id")?.Value,
IsApiKey: principal.FindFirst(ClaimTypes.AuthenticationMethod)?.Value == "api_key"
);
}
public static string? GetClaimValue(this ClaimsPrincipal principal, string claimType)
{
return principal.FindFirst(claimType)?.Value;
}
}
public record UserContext(
string? Id,
string? Email,
string? Name,
string? CompanyId,
bool IsApiKey);

View file

@ -37,6 +37,9 @@
<!-- DI Decoration -->
<PackageReference Include="Scrutor" Version="5.0.2" />
<!-- Authentication -->
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.0" />
</ItemGroup>
<ItemGroup>

View file

@ -0,0 +1,30 @@
using Books.Api.Domain.ApiKeys;
using EventFlow.Commands;
namespace Books.Api.Commands.ApiKeys;
public class CreateApiKeyCommandHandler
: CommandHandler<ApiKeyAggregate, ApiKeyId, CreateApiKeyCommand>
{
public override Task ExecuteAsync(
ApiKeyAggregate aggregate,
CreateApiKeyCommand command,
CancellationToken cancellationToken)
{
aggregate.Create(command.Name, command.KeyHash, command.CompanyId, command.CreatedBy);
return Task.CompletedTask;
}
}
public class RevokeApiKeyCommandHandler
: CommandHandler<ApiKeyAggregate, ApiKeyId, RevokeApiKeyCommand>
{
public override Task ExecuteAsync(
ApiKeyAggregate aggregate,
RevokeApiKeyCommand command,
CancellationToken cancellationToken)
{
aggregate.Revoke(command.RevokedBy);
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,24 @@
using Books.Api.Domain.ApiKeys;
using EventFlow.Commands;
namespace Books.Api.Commands.ApiKeys;
public class CreateApiKeyCommand(
ApiKeyId aggregateId,
string name,
string keyHash,
string companyId,
string createdBy) : Command<ApiKeyAggregate, ApiKeyId>(aggregateId)
{
public string Name { get; } = name;
public string KeyHash { get; } = keyHash;
public string CompanyId { get; } = companyId;
public string CreatedBy { get; } = createdBy;
}
public class RevokeApiKeyCommand(
ApiKeyId aggregateId,
string revokedBy) : Command<ApiKeyAggregate, ApiKeyId>(aggregateId)
{
public string RevokedBy { get; } = revokedBy;
}

View file

@ -0,0 +1,41 @@
using Books.Api.Authentication;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Books.Api.Controllers;
[Route("api")]
[ApiController]
public class AuthController : ControllerBase
{
[HttpGet("login")]
[Authorize]
public IActionResult Login([FromQuery] string? returnUrl)
{
// The [Authorize] attribute triggers the OIDC challenge if not authenticated.
// If we reach here, the user is authenticated - redirect back to the app.
return Redirect(returnUrl ?? "/");
}
[HttpGet("logout")]
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Ok(new { message = "Logged out successfully" });
}
[HttpGet("profile")]
[Authorize]
public IActionResult Profile()
{
var userContext = User.GetUserContext();
if (userContext == null)
{
return Unauthorized();
}
return Ok(userContext);
}
}

View file

@ -0,0 +1,21 @@
-- API Keys table for programmatic access authentication
CREATE TABLE IF NOT EXISTS apikey_read_models (
aggregate_id VARCHAR(255) PRIMARY KEY,
create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_aggregate_sequence_number INT NOT NULL DEFAULT 1,
name VARCHAR(255) NOT NULL,
key_hash VARCHAR(64) NOT NULL,
company_id VARCHAR(255) NOT NULL,
created_by VARCHAR(255) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
revoked_time TIMESTAMPTZ,
revoked_by VARCHAR(255)
);
-- Index for looking up API keys by company
CREATE INDEX IF NOT EXISTS idx_apikey_company ON apikey_read_models(company_id);
-- Partial index for efficient lookups of active API keys
CREATE INDEX IF NOT EXISTS idx_apikey_active ON apikey_read_models(aggregate_id) WHERE is_active = true;

View file

@ -0,0 +1,49 @@
using Books.Api.Domain.ApiKeys.Events;
using EventFlow.Aggregates;
namespace Books.Api.Domain.ApiKeys;
public class ApiKeyAggregate : AggregateRoot<ApiKeyAggregate, ApiKeyId>,
IEmit<ApiKeyCreatedEvent>,
IEmit<ApiKeyRevokedEvent>
{
public new string Name { get; private set; } = string.Empty;
public string KeyHash { get; private set; } = string.Empty;
public string CompanyId { get; private set; } = string.Empty;
public string CreatedBy { get; private set; } = string.Empty;
public bool IsActive { get; private set; } = true;
public string? RevokedBy { get; private set; }
public ApiKeyAggregate(ApiKeyId id) : base(id) { }
public void Create(string name, string keyHash, string companyId, string createdBy)
{
if (!IsNew)
throw new DomainException("APIKEY_EXISTS", "API key already exists", "API nøgle eksisterer allerede");
Emit(new ApiKeyCreatedEvent(name, keyHash, companyId, createdBy));
}
public void Revoke(string revokedBy)
{
if (!IsActive)
throw new DomainException("APIKEY_REVOKED", "API key is already revoked", "API nøgle er allerede tilbagekaldt");
Emit(new ApiKeyRevokedEvent(revokedBy));
}
public void Apply(ApiKeyCreatedEvent e)
{
Name = e.Name;
KeyHash = e.KeyHash;
CompanyId = e.CompanyId;
CreatedBy = e.CreatedBy;
IsActive = true;
}
public void Apply(ApiKeyRevokedEvent e)
{
IsActive = false;
RevokedBy = e.RevokedBy;
}
}

View file

@ -0,0 +1,8 @@
using EventFlow.Core;
namespace Books.Api.Domain.ApiKeys;
public class ApiKeyId : Identity<ApiKeyId>
{
public ApiKeyId(string value) : base(value) { }
}

View file

@ -0,0 +1,15 @@
using EventFlow.Aggregates;
namespace Books.Api.Domain.ApiKeys.Events;
public class ApiKeyCreatedEvent(
string name,
string keyHash,
string companyId,
string createdBy) : AggregateEvent<ApiKeyAggregate, ApiKeyId>
{
public string Name { get; } = name;
public string KeyHash { get; } = keyHash;
public string CompanyId { get; } = companyId;
public string CreatedBy { get; } = createdBy;
}

View file

@ -0,0 +1,8 @@
using EventFlow.Aggregates;
namespace Books.Api.Domain.ApiKeys.Events;
public class ApiKeyRevokedEvent(string revokedBy) : AggregateEvent<ApiKeyAggregate, ApiKeyId>
{
public string RevokedBy { get; } = revokedBy;
}

View file

@ -14,16 +14,19 @@ public static class ReadModelRegistrationExtensions
{
return options
.UsePostgreSqlReadModel<CompanyReadModel, CompanyReadModelLocator>()
.RegisterServices( sr => sr.AddSingleton<IReadModelSqlGenerator>(new ReadModelSqlGenerator()));
.UsePostgreSqlReadModel<ApiKeyReadModel, ApiKeyReadModelLocator>()
.RegisterServices(sr => sr.AddSingleton<IReadModelSqlGenerator>(new ReadModelSqlGenerator()));
}
public static IServiceCollection AddRepositories(this IServiceCollection services)
{
// Register locators
services.AddTransient<CompanyReadModelLocator>();
services.AddTransient<ApiKeyReadModelLocator>();
// Register repositories
services.AddScoped<ICompanyRepository, CompanyRepository>();
services.AddScoped<IApiKeyRepository, ApiKeyRepository>();
return services;
}

View file

@ -0,0 +1,74 @@
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;
}
}
}

View file

@ -0,0 +1,55 @@
using Dapper;
using Npgsql;
namespace Books.Api.EventFlow.Repositories;
public class ApiKeyRepository(NpgsqlDataSource dataSource) : IApiKeyRepository
{
public async Task<ApiKeyValidationDto?> GetByIdForValidationAsync(
string apiKeyId,
CancellationToken cancellationToken = default)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
const string sql = """
SELECT
aggregate_id AS ApiKeyId,
name AS Name,
key_hash AS KeyHash,
company_id AS CompanyId,
is_active AS IsActive
FROM apikey_read_models
WHERE aggregate_id = @ApiKeyId
AND is_active = true
""";
return await connection.QuerySingleOrDefaultAsync<ApiKeyValidationDto>(
sql,
new { ApiKeyId = apiKeyId });
}
public async Task<IReadOnlyList<ApiKeyDto>> GetByCompanyIdAsync(
string companyId,
CancellationToken cancellationToken = default)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
const string sql = """
SELECT
aggregate_id AS Id,
name AS Name,
company_id AS CompanyId,
created_by AS CreatedBy,
create_time AS CreatedAt,
is_active AS IsActive,
revoked_time AS RevokedAt,
revoked_by AS RevokedBy
FROM apikey_read_models
WHERE company_id = @CompanyId
ORDER BY create_time DESC
""";
var result = await connection.QueryAsync<ApiKeyDto>(sql, new { CompanyId = companyId });
return result.ToList();
}
}

View file

@ -0,0 +1,24 @@
namespace Books.Api.EventFlow.Repositories;
public interface IApiKeyRepository
{
Task<ApiKeyValidationDto?> GetByIdForValidationAsync(string apiKeyId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ApiKeyDto>> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default);
}
public record ApiKeyValidationDto(
string ApiKeyId,
string Name,
string KeyHash,
string CompanyId,
bool IsActive);
public record ApiKeyDto(
string Id,
string Name,
string CompanyId,
string CreatedBy,
DateTime CreatedAt,
bool IsActive,
DateTime? RevokedAt,
string? RevokedBy);

View file

@ -23,6 +23,16 @@ if (app.Environment.IsDevelopment())
app.UseHttpsRedirection();
// CORS must come before auth
app.UseCors();
// Authentication & Authorization
app.UseAuthentication();
app.UseAuthorization();
// Map controllers (for AuthController)
app.MapControllers();
// GraphQL endpoint
app.UseGraphQL<BooksSchema>("/graphql");

View file

@ -14,7 +14,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7141;http://localhost:5142",
"applicationUrl": "https://localhost:5001;http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}

View file

@ -1,3 +1,4 @@
using Books.Api.Authentication;
using Books.Api.EventFlow.Extensions;
using Books.Api.EventFlow.Infrastructure;
using Books.Api.GraphQL;
@ -14,6 +15,9 @@ using EventFlow.Subscribers;
using GraphQL;
using Hangfire;
using Hangfire.PostgreSql;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Npgsql;
namespace Books.Api;
@ -73,5 +77,96 @@ public static class Startup
.AddDataLoader()
.AddGraphTypes(typeof(BooksSchema).Assembly)
.AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = true));
// Memory cache for API key caching
services.AddMemoryCache();
// Controllers (for AuthController)
services.AddControllers();
// CORS
SetupCors(services, config);
// Authentication & Authorization
SetupAuthentication(services, config);
}
private static void SetupCors(IServiceCollection services, IConfiguration config)
{
var allowedOrigins = config.GetSection("Cors:AllowedOrigins").Get<string[]>()
?? ["http://localhost:3000"];
services.AddCors(options =>
{
options.AddDefaultPolicy(builder =>
{
builder.WithOrigins(allowedOrigins)
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
});
});
}
private static void SetupAuthentication(IServiceCollection services, IConfiguration config)
{
services.AddAuthorization();
var keycloakClientSecret = config["Keycloak:ClientSecret"];
// If Keycloak not configured, use cookie-only auth (for development)
if (string.IsNullOrEmpty(keycloakClientSecret))
{
Console.WriteLine("[WARNING] Keycloak:ClientSecret not configured - OIDC authentication disabled");
services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
options.Cookie.Name = ".Books.Auth";
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Cookie.SameSite = SameSiteMode.Lax;
options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.SlidingExpiration = true;
})
.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(
ApiKeyDefaults.AuthenticationScheme, _ => { });
return;
}
// Full authentication with OIDC
services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
options.Cookie.Name = ".Books.Auth";
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.None;
options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.SlidingExpiration = true;
})
.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(
ApiKeyDefaults.AuthenticationScheme, _ => { })
.AddOpenIdConnect(options =>
{
options.GetClaimsFromUserInfoEndpoint = true;
options.MetadataAddress = config["Keycloak:MetadataAddress"]
?? "https://id.tatic.io/auth/realms/master/.well-known/openid-configuration";
options.ClientId = config["Keycloak:ClientId"] ?? "books";
options.SaveTokens = true;
options.ClientSecret = keycloakClientSecret;
options.ResponseType = "code";
options.Scope.Add("openid");
options.Scope.Add("email");
options.Scope.Add("profile");
options.CallbackPath = "/callback";
options.UsePkce = true;
});
}
}

View file

@ -10,5 +10,15 @@
"AllowedHosts": "*",
"ConnectionStrings": {
"Default": "Host=localhost;Database=books;Username=postgres;Password=postgres;Include Error Detail=true"
},
"Cors": {
"AllowedOrigins": [
"http://localhost:3000"
]
},
"Keycloak": {
"MetadataAddress": "https://id.tatic.io/auth/realms/master/.well-known/openid-configuration",
"ClientId": "books",
"ClientSecret": "3fd1679b-4505-4e2b-9d19-0be19d4ae297"
}
}