feat(bff): medewerker-realm auth + behandelaar policy + GET /behandel/werkbak (refs #13)
Adds a second JWT bearer scheme for the medewerker realm; on validation it lifts Keycloak's realm_access.roles onto the principal so the behandelaar policy can require the role. /behandel/werkbak proxies the domain werkbak behind that policy (401 without a token, 403 without the role). openapi.json + api-client regenerated; Keycloak__MedewerkerAuthority wired into compose. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bff.Api;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
|
||||
@@ -6,6 +8,10 @@ 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"]
|
||||
@@ -19,8 +25,28 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
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();
|
||||
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<IDomainClient, DomainClient>(c => c.BaseAddress = new Uri(domainBaseUrl));
|
||||
@@ -68,7 +94,42 @@ app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection,
|
||||
})
|
||||
.Produces<IReadOnlyList<OpenbaarEntry>>(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<IReadOnlyList<WerkbakItem>>(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";
|
||||
|
||||
/// <summary>Lift Keycloak's realm roles (the nested <c>realm_access.roles</c> claim) onto the
|
||||
/// principal as role claims, so <c>RequireRole</c> can authorize on them.</summary>
|
||||
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>(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<Program>) can boot the app.
|
||||
public partial class Program;
|
||||
|
||||
Reference in New Issue
Block a user