CI / build (pull_request) Successful in 1m10s
CI / lint (pull_request) Successful in 1m27s
CI / unit (pull_request) Successful in 1m24s
CI / frontend (pull_request) Successful in 3m27s
CI / mutation (pull_request) Successful in 6m29s
CI / verify-stack (pull_request) Successful in 9m41s
Runbook gains an MFA section and how to get a code; synthetic-data lists the fixture TOTP secret and the extra grant parameter; demo-script gains the S-15c note and its staff logins now mention the second factor. check_realms.py grows an 'otp' argument that prints a current code for a manual demo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
50 lines
2.8 KiB
Markdown
50 lines
2.8 KiB
Markdown
# ADR-0031 — MFA on the medewerker realm, with a fixture TOTP secret
|
|
|
|
- **Status:** Accepted
|
|
- **Date:** 2026-09-03
|
|
- **Slice:** S-15c (Gitea #132)
|
|
|
|
## Context
|
|
|
|
Staff (behandelaar, teamlead, beheerder) act on citizens' registrations and on the ACL's
|
|
default-fill: the highest-privilege logins in the platform. The medewerker realm protected
|
|
them with a password alone, while the citizen realms (digid, eherkenning, eidas) mock
|
|
brokers that carry their own assurance levels. A reference application that demonstrates a
|
|
government architecture should show MFA on the staff realm.
|
|
|
|
Two things had to be decided: **how** to enforce OTP in a realm export, and **how the
|
|
automated checks and a human demo obtain a code** — the e2e drives a real browser login and
|
|
`make keycloak-smoke` drives a real password grant, so neither can scan a QR.
|
|
|
|
## Decision
|
|
|
|
**Enforce OTP by giving every seeded medewerker a TOTP credential**, rather than replacing
|
|
Keycloak's browser flow with a copy whose OTP execution is `REQUIRED`.
|
|
|
|
Keycloak's stock `browser` and `direct grant` flows both contain a *conditional OTP*
|
|
subflow that fires when the user has an OTP credential. Seeding the credential therefore
|
|
turns the challenge on for every seeded user, in both flows, without duplicating ~40 lines
|
|
of flow JSON into the export. `CONFIGURE_TOTP` is additionally set as a **default required
|
|
action**, so a medewerker created later must enrol before their first login.
|
|
|
|
**The seeded secret is a fixed, committed fixture** (`BIGMEDEWERKEROTPSEED`) shared by all
|
|
medewerkers. Codes are then computable: `infra/keycloak/check_realms.py` (Python, stdlib
|
|
`hmac`) and `tests/e2e/medewerker-login.ts` (Node `crypto`) each implement RFC 6238 in
|
|
about six lines — no OTP dependency on either side, and no enrolment step in the tests.
|
|
|
|
## Consequences
|
|
|
|
- A password alone no longer yields a token on the medewerker realm; `check_realms.py`
|
|
asserts that refusal, so the enforcement cannot silently regress.
|
|
- Every medewerker login in the e2e goes through `loginMedewerker()`, which submits the OTP
|
|
form. New staff specs must use it.
|
|
- **The secret is public.** It is a demo fixture and worthless outside this synthetic
|
|
stack, in the same class as the committed `test123` passwords and the mock DigiD broker.
|
|
A real deployment enrols per-user authenticators (or federates to DigiD Machtigen /
|
|
eHerkenning at the required assurance level) and seeds no credentials at all.
|
|
- Enforcement is *effectively* realm-wide but *technically* per-user: the conditional
|
|
subflow is what fires. A medewerker whose OTP credential were removed would fall back to
|
|
the required action at next login (enrol, then challenge) rather than skipping MFA — an
|
|
acceptable equivalence for this purpose, and the reason the required action is set.
|
|
- Reversal is a one-file edit: drop the `otp` credentials and the `requiredActions` block.
|