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>
2.8 KiB
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.pyasserts 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
test123passwords 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
otpcredentials and therequiredActionsblock.