Files
register-referentie/docs/architecture/adr-0010-bff-oidc.md
Niek Otten fea806848b arch(bff): ADR-0010 BFF OIDC validation + downstream boundaries (refs #8, #63)
The BFF is the portals' only backend (§8.3): it validates Keycloak digid-realm
JWTs on POST /self-service/registrations (extracting bsn → domain), leaves
GET /openbaar/register anonymous (public lookup, S-09), and fans out to the
domain and projection over typed HTTP clients. Tests mint tokens with a test
signing key; real Keycloak validation is a live-stack verify-bff check. Records
the container OIDC issuer-mismatch wrinkle. OpenAPI is generated + committed for
the S-08 client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:51:59 +02:00

4.5 KiB

ADR-0010: The BFF validates Keycloak tokens and is the portals' only backend

  • Status: Accepted
  • Date: 2026-07-01
  • Deciders: Respellion engineering
  • Relates to: S-07 (#8); proposal #63; builds on ADR-0001 (loose coupling, §8.3), S-02 (#3, Keycloak realms), S-05 (#6, Domain Service), S-06 (#7, read projection)

Context

S-07 (#8) adds the BFF (Backend-for-Frontend) — the single backend the Angular portals talk to (CLAUDE.md §8.3). For the walking skeleton it exposes two endpoints and fans out to services already built:

  • POST /self-service/registrations → Domain Service POST /registrations (S-05).
  • GET /openbaar/register?q=… → projection-api GET /register (S-06).

It must validate tokens issued by Keycloak (S-02). This is an ADR-worthy moment (§14): a new dependency (JWT bearer authentication) and two new service boundaries (BFF→domain, BFF→projection).

Decision

The BFF is the portals' only backend; it validates Keycloak digid-realm JWTs on the self-service endpoint, leaves the openbaar lookup anonymous, and fans out to the domain and projection over typed HTTP clients.

  • Auth model. POST /self-service/registrations requires a valid digid-realm bearer token; the BFF reads the bsn claim and forwards it to the domain. Missing / invalid / expired token → 401. GET /openbaar/register is anonymous — the openbaar register is a public lookup (S-09), so no token is required.
  • Portals talk only to the BFF (§8.3). They never call the Domain Service, ACL, projection, or OpenZaak directly. The BFF orchestrates via typed HttpClients whose base URLs come from config. Downstream calls are unauthenticated on the internal network for the walking skeleton; a service-to-service auth story (e.g. client-credentials) is a later slice, not this one.
  • Validation is Microsoft.AspNetCore.Authentication.JwtBearer pointed at the Keycloak digid realm authority. New dependency justification: it gives us standards-based OIDC/JWT validation (signature, issuer, expiry, audience) maintained by the framework; rolling our own JWT validation would be error-prone security code; the risk is a first-party ASP.NET Core package — minimal.
  • Tests mint their own tokens. WebApplicationFactory tests override the bearer options with a test signing key, so valid / invalid / expired tokens are minted in-process without a live Keycloak. Real Keycloak validation is exercised by a live-stack verify-bff check.
  • OpenAPI is generated and committed (services/bff/openapi.json) from .NET's built-in OpenAPI, so S-08's Angular client is generated from the spec, never hand-written (§10).

Known wrinkle — container OIDC issuer mismatch

Keycloak stamps tokens with an iss equal to its browser-facing URL (what the portal used to log in), which differs from the BFF's in-container authority (http://keycloak:8080/realms/digid). Strict issuer validation then rejects otherwise-valid tokens. Unit tests avoid this (test key). verify-bff handles it by aligning the configured authority/issuer with the token's iss (and, if needed, disabling metadata address rewriting). Recorded so it is not rediscovered each time.

Consequences

  • Positive: the walking skeleton gains its front door; §8.3 holds with all portal traffic going through one backend; token validation is standard and testable without infra; the committed OpenAPI unblocks S-08.
  • Negative / deferred:
    • Downstream service-to-service auth is deferred (internal-network trust for now).
    • The openbaar endpoint is anonymous; when public-safe field filtering tightens (S-09) it stays anonymous but the projection query narrows.
    • The issuer-mismatch handling is dev-oriented; a production reverse-proxy setup would align the browser and internal issuer URLs instead.

Alternatives considered

  • Token-gate the openbaar endpoint too — rejected: the openbaar register is public by design (S-09); requiring a login would contradict the slice's intent.
  • Validate tokens by calling Keycloak's introspection endpoint per request — rejected: adds a network hop per call and a Keycloak dependency on the hot path; local JWT signature validation via the realm's JWKS is the standard, faster choice.
  • Hand-written JWT parsing — rejected: security-sensitive code we shouldn't own when a first-party validator exists.
  • Generate the OpenAPI client by hand / keep the spec uncommitted — rejected: §10 requires a generated client from a committed spec.