# 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 `HttpClient`s 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.