S-15c · Enforce MFA on the medewerker (Keycloak) realm #158

Merged
not merged 7 commits from feat/132-medewerker-mfa into main 2026-09-04 08:27:53 +00:00
Contributor

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

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

🤖 Generated with Claude Code

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)
not added this to the Iteration 3 — Beheer & Observability milestone 2026-09-03 07:11:55 +00:00
not added 3 commits 2026-09-03 07:11:55 +00:00
The keycloak smoke check now asserts that a password-only grant on the medewerker
realm is rejected and that a TOTP code completes it. The e2e medewerker logins move
to a shared helper that submits Keycloak's OTP challenge. Both fail against the
current realm export, which enforces no MFA.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every seeded medewerker carries a TOTP credential, so Keycloak's conditional-OTP
step in both the browser and direct-grant flows always challenges them; a password
alone no longer yields a token. CONFIGURE_TOTP becomes a default required action so
any medewerker added later must enrol before logging in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs(infra): document MFA on the medewerker realm + ADR-0031 (refs #132)
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
a87a32e269
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>
not added the area:infratype:slice labels 2026-09-03 07:12:13 +00:00
not added 1 commit 2026-09-04 06:48:03 +00:00
Merge branch 'main' into feat/132-medewerker-mfa
CI / build (pull_request) Successful in 1m3s
CI / lint (pull_request) Successful in 1m20s
CI / unit (pull_request) Successful in 1m24s
CI / frontend (pull_request) Successful in 3m11s
CI / mutation (pull_request) Successful in 6m17s
CI / verify-stack (pull_request) Failing after 9m38s
a16f811d50
not added 3 commits 2026-09-04 07:56:08 +00:00
Two beheer specs log in as bram-beheerder back to back, so both submit the
code for the same 30-second counter. Keycloak's otpPolicyCodeReusable
defaults to false, so it refuses the second one as invalid credentials and
the beheer portal never loads — which is how verify-e2e went red on #158.

Pins the counter choice as a pure function of "now" and the last counter
this medewerker spent, so the guard is checkable without a browser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keycloak refuses a TOTP code it has already accepted (otpPolicyCodeReusable
defaults to false), so the beheer specs — two serial logins as
bram-beheerder, well inside one 30-second window — sent the same code twice
and the second was rejected: the portal stayed on the OTP prompt and the
Catalogus heading never appeared. The Playwright retry ran inside the same
window too, so it failed identically.

loginMedewerker now spends the first counter the medewerker has left,
persisting it in tmpdir because Playwright restarts the worker process
between retries, and waits out the window when that counter is still ahead.

Verified against keycloak:26.1 with the real realm export: three
back-to-back logins as bram-beheerder now all succeed, where reusing one
code is refused with 401 invalid_grant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge remote-tracking branch 'origin/feat/132-medewerker-mfa' into feat/132-medewerker-mfa
CI / build (pull_request) Successful in 1m7s
CI / lint (pull_request) Successful in 1m22s
CI / unit (pull_request) Successful in 1m24s
CI / frontend (pull_request) Successful in 3m6s
CI / mutation (pull_request) Successful in 6m11s
CI / verify-stack (pull_request) Successful in 8m52s
b76035ebfe
not merged commit d0fb2b3e8c into main 2026-09-04 08:27:53 +00:00
Sign in to join this conversation.