using System.Security.Claims; using System.Text.Json; using System.Text.Json.Serialization; using Bff.Api; using Microsoft.AspNetCore.Authentication.JwtBearer; var builder = WebApplication.CreateBuilder(args); var keycloakAuthority = builder.Configuration["Keycloak:Authority"] ?? throw new InvalidOperationException("Missing configuration 'Keycloak:Authority'"); // Behandelaars authenticate against a *different* Keycloak realm (medewerker) than citizens (digid), // so the BFF validates a second issuer for the behandel endpoints (ADR-0013). var medewerkerAuthority = builder.Configuration["Keycloak:MedewerkerAuthority"] ?? throw new InvalidOperationException("Missing configuration 'Keycloak:MedewerkerAuthority'"); var domainBaseUrl = builder.Configuration["Downstream:Domain:BaseUrl"] ?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'"); var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"] ?? throw new InvalidOperationException("Missing configuration 'Downstream:Projection:BaseUrl'"); // Validate Keycloak-issued tokens (ADR-0010). Audience validation is off for the walking skeleton โ€” // Keycloak's audience mapping is a later hardening; signature/issuer/expiry are validated. builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.Authority = keycloakAuthority; options.RequireHttpsMetadata = false; options.TokenValidationParameters.ValidateAudience = false; }) // The medewerker realm โ€” behandel endpoints only. On validation we lift Keycloak's realm roles // (the nested realm_access.roles claim) into role claims so authorization policies can require them. .AddJwtBearer(BehandelAuth.Scheme, options => { options.Authority = medewerkerAuthority; options.RequireHttpsMetadata = false; options.TokenValidationParameters.ValidateAudience = false; options.Events = new JwtBearerEvents { OnTokenValidated = context => { BehandelAuth.AddRealmRoles(context.Principal); return Task.CompletedTask; }, }; }); builder.Services.AddAuthorization(options => options.AddPolicy(BehandelAuth.Policy, policy => policy .AddAuthenticationSchemes(BehandelAuth.Scheme) .RequireAuthenticatedUser() .RequireRole(BehandelAuth.BehandelaarRole))); // The BFF is the portals' only backend; it fans out to the domain and projection (ยง8.3). builder.Services.AddHttpClient(c => c.BaseAddress = new Uri(domainBaseUrl)); builder.Services.AddHttpClient(c => c.BaseAddress = new Uri(projectionBaseUrl)); builder.Services.AddHealthChecks(); // Clear the auto-populated `servers` block so the committed spec is stable regardless of the host // the doc was generated from (the client sets its own base URL). Keeps the drift guard deterministic. builder.Services.AddOpenApi(options => options.AddDocumentTransformer((document, _, _) => { document.Servers?.Clear(); return Task.CompletedTask; })); var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); app.MapHealthChecks("/health"); app.MapOpenApi(); // Self-service submit: requires a valid digid token; the bsn comes from the token, not the body, // and is forwarded to the domain (ADR-0010). Returns 202 โ€” the zaak is opened asynchronously (S-05). app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) => { var bsn = user.FindFirstValue("bsn"); if (string.IsNullOrWhiteSpace(bsn)) return Results.BadRequest("The token carries no bsn claim."); var accepted = await domain.SubmitRegistrationAsync(bsn, ct); return Results.Accepted($"/self-service/registrations/{accepted.RegistrationId}", accepted); }) .RequireAuthorization() .Produces(StatusCodes.Status202Accepted) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status401Unauthorized); // Openbaar register: an anonymous public lookup that exposes only public-safe fields (S-09). app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) => { var entries = await projection.GetRegisterAsync(ct); return Results.Ok(OpenbaarProjection.PublicView(entries, q)); }) .Produces>(StatusCodes.Status200OK); // Behandelaar's werkbak: registrations awaiting beoordeling. Reached only with a medewerker-realm // token carrying the behandelaar role; the BFF proxies the domain's werkbak (staff view, ADR-0013). app.MapGet("/behandel/werkbak", async (IDomainClient domain, CancellationToken ct) => Results.Ok(await domain.GetWerkbakAsync(ct))) .RequireAuthorization(BehandelAuth.Policy) .Produces>(StatusCodes.Status200OK) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden); app.Run(); // Behandel (medewerker-realm) authentication + authorization wiring (ADR-0013). internal static class BehandelAuth { public const string Scheme = "medewerker"; public const string Policy = "behandelaar"; public const string BehandelaarRole = "behandelaar"; /// Lift Keycloak's realm roles (the nested realm_access.roles claim) onto the /// principal as role claims, so RequireRole can authorize on them. public static void AddRealmRoles(ClaimsPrincipal? principal) { if (principal?.Identity is not ClaimsIdentity identity) return; var realmAccess = principal.FindFirst("realm_access")?.Value; if (string.IsNullOrEmpty(realmAccess)) return; var roles = JsonSerializer.Deserialize(realmAccess)?.Roles ?? []; foreach (var role in roles) identity.AddClaim(new Claim(identity.RoleClaimType, role)); } private sealed record RealmAccess([property: JsonPropertyName("roles")] string[] Roles); } // Exposed so the test host (WebApplicationFactory) can boot the app. public partial class Program;