2026-01-18 11:49:29 +01:00
|
|
|
using Books.Api.Authentication;
|
2026-02-05 21:35:26 +01:00
|
|
|
using Books.Api.Authorization;
|
2026-01-18 02:52:30 +01:00
|
|
|
using Books.Api.EventFlow.Extensions;
|
2026-02-05 21:35:26 +01:00
|
|
|
using Books.Api.EventFlow.Repositories;
|
2026-01-18 02:52:30 +01:00
|
|
|
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;
|
2026-01-18 11:49:29 +01:00
|
|
|
using Microsoft.AspNetCore.Authentication;
|
|
|
|
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
|
|
|
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
2026-01-18 02:52:30 +01:00
|
|
|
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();
|
|
|
|
|
|
2026-02-05 21:35:26 +01:00
|
|
|
// HTTP context accessor (needed by CompanyAccessService and GraphQL resolvers)
|
|
|
|
|
services.AddHttpContextAccessor();
|
|
|
|
|
|
|
|
|
|
// User company access repository and access service
|
|
|
|
|
services.AddScoped<IUserCompanyAccessRepository, UserCompanyAccessRepository>();
|
|
|
|
|
services.AddScoped<ICompanyAccessService, CompanyAccessService>();
|
|
|
|
|
|
2026-01-18 02:52:30 +01:00
|
|
|
// Logging decorators
|
|
|
|
|
services.DecorateAsyncEventHandlersWithLogging();
|
|
|
|
|
|
|
|
|
|
// GraphQL
|
|
|
|
|
services.AddGraphQL(builder => builder
|
|
|
|
|
.AddSchema<BooksSchema>()
|
|
|
|
|
.AddSystemTextJson()
|
|
|
|
|
.AddDataLoader()
|
|
|
|
|
.AddGraphTypes(typeof(BooksSchema).Assembly)
|
2026-02-05 21:35:26 +01:00
|
|
|
.AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = environment?.IsDevelopment() ?? false));
|
2026-01-18 11:49:29 +01:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
});
|
2026-01-18 02:52:30 +01:00
|
|
|
}
|
|
|
|
|
}
|