using Books.Api.Authentication; using Books.Api.Authorization; using Books.Api.EventFlow.Extensions; using Books.Api.EventFlow.Repositories; 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(); // 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(); services.AddSingleton(); // Read model repositories services.AddRepositories(); // HTTP context accessor (needed by CompanyAccessService and GraphQL resolvers) services.AddHttpContextAccessor(); // User company access repository and access service services.AddScoped(); services.AddScoped(); // Logging decorators services.DecorateAsyncEventHandlersWithLogging(); // GraphQL services.AddGraphQL(builder => builder .AddSchema() .AddSystemTextJson() .AddDataLoader() .AddGraphTypes(typeof(BooksSchema).Assembly) .AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = environment?.IsDevelopment() ?? false)); // 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() ?? ["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( 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( 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; }); } }