books/backend/Books.Api/Startup.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

172 lines
6.5 KiB
C#

using Books.Api.Authentication;
using Books.Api.EventFlow.Extensions;
using Books.Api.EventFlow.Infrastructure;
using Books.Api.GraphQL;
using Books.Api.Infrastructure;
using Books.Api.Logging;
using EventFlow;
using EventFlow.Configuration;
using EventFlow.Extensions;
using EventFlow.Hangfire.Extensions;
using EventFlow.PostgreSql.Connections;
using EventFlow.PostgreSql.Extensions;
using EventFlow.ReadStores;
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;
public static class Startup
{
public static void ConfigureServices(IServiceCollection services, IConfiguration config, IHostEnvironment? environment = null)
{
var connectionString = config.GetConnectionString("Default")
?? throw new InvalidOperationException("Connection string 'Default' not found");
// Run database migrations (skipped in Test environment where migrations run separately with test connection string)
var isTestEnvironment = environment?.EnvironmentName == "Test";
if (!isTestEnvironment)
{
DatabaseMigrator.Migrate(connectionString);
}
// PostgreSQL data source
var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
services.AddSingleton(dataSource);
// Hangfire
services.AddHangfire(c => c
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UsePostgreSqlStorage(o => o.UseNpgsqlConnection(connectionString)));
services.AddHangfireServer();
// Scheduler abstraction over Hangfire
services.AddSingleton<IScheduler, HangfireScheduler>();
// EventFlow
services.AddEventFlow(o => o
.UsePostgreSqlEventStore()
.ConfigurePostgreSql(PostgreSqlConfiguration.New.SetConnectionString(connectionString))
.AddDefaults(typeof(Startup).Assembly)
.AddReadModels()
.Configure(c => c.IsAsynchronousSubscribersEnabled = true)
.UseHangfireJobScheduler());
// Resilience strategies
services.AddSingleton<IDispatchToReadStoresResilienceStrategy, ReadStoresResilienceStrategy>();
services.AddSingleton<IDispatchToSubscriberResilienceStrategy, DispatchToSubscriberResilienceStrategy>();
// Read model repositories
services.AddRepositories();
// Logging decorators
services.DecorateAsyncEventHandlersWithLogging();
// GraphQL
services.AddGraphQL(builder => builder
.AddSchema<BooksSchema>()
.AddSystemTextJson()
.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;
});
}
}