Files
register-referentie/docs/architecture/adr-0031-mfa-on-the-medewerker-realm.md
not d0fb2b3e8c
CI / build (push) Successful in 1m7s
CI / lint (push) Successful in 1m22s
CI / unit (push) Successful in 1m24s
CI / frontend (push) Successful in 3m5s
CI / mutation (push) Successful in 6m13s
CI / verify-stack (push) Successful in 8m39s
S-15c · Enforce MFA on the medewerker (Keycloak) realm (#158)
Closes #132.

Staff logins (behandel + beheer portals) now need a second factor; the citizen realms are unchanged.

**How:** every seeded medewerker carries a TOTP credential, which activates Keycloak's stock *conditional OTP* step in both the browser flow and the direct grant — no custom browser-flow JSON in the export. `CONFIGURE_TOTP` is a default required action so a medewerker added later must enrol first. ADR-0031 records the choice and, explicitly, that the shared fixture secret is a demo posture only.

**Tests (red first, 30c5279):**
- `check_realms.py` asserts the medewerker password-only grant is **refused**, then that password + TOTP succeeds and still carries the `behandelaar` role. It failed with `[MFA NOT ENFORCED]` against the old export.
- The three medewerker e2e logins move to `loginMedewerker()` (`tests/e2e/medewerker-login.ts`), which submits Keycloak's OTP prompt. Both TOTP implementations (Python `hmac`, Node `crypto`) are ~6 lines of RFC 6238 — no new dependency.

Verified locally against Keycloak 26.1: password-only → `invalid_grant`, password + code → 200, and the browser flow's `#otp` prompt accepts a computed code and issues an auth code.

## Definition of Done
- [x] Failing test/verify committed first; implementation makes it pass.
- [x] Conventional Commits referencing the issue (`refs #132`).
- [ ] CI green (verify-stack compose smoke + relevant checks).
- [x] `docker compose up` reaches green health within 3 minutes (Keycloak change is import-time only).
- [x] Docs touched (runbook, synthetic-data, demo-script) + ADR-0031 + demo note.
- [x] Closed by the merging PR (`closes #132`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #158
2026-09-04 08:27:52 +00:00

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.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.