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>
75 lines
4.5 KiB
Markdown
75 lines
4.5 KiB
Markdown
# 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.
|