Files
register-referentie/docs/runbooks/keycloak.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

3.3 KiB

Keycloak runbook

Keycloak (infra/keycloak/docker-compose.yml) runs in dev mode with four realms imported at boot from infra/keycloak/realms/: digid, eherkenning, eidas, medewerker. It mocks the Dutch identity brokers so portals can do real OIDC logins locally. Host port :8180.

Quick test (make)

make keycloak-up      # start Keycloak + import realms (~30-60s first boot)
make keycloak-smoke   # start + verify every realm logs in and returns its claim
make keycloak-down    # stop + wipe

make keycloak-smoke runs infra/keycloak/check_realms.py, which does a password-grant login per realm and asserts the identifying claim:

Realm User Claim asserted
digid jan-burger bsn
eherkenning acme-ondernemer kvk
eidas pierre-dupont eidas_id
medewerker merel-behandelaar role behandelaar

The medewerker row also asserts that the password alone is refused — that realm enforces MFA (below).

All test users / credentials are in ../synthetic-data.md.

Notes

  • Admin console: http://localhost:8180/admin / admin (dev only).
  • Client big-portal is public with standardFlowEnabled (browser redirect login) and directAccessGrantsEnabled (password grant, used by the smoke test).
  • Dev store: in-memory H2 via start-dev; realms re-import on each boot, so changes made in the admin UI don't persist. Edit the realm JSONs to make durable changes.
  • Image pinned to quay.io/keycloak/keycloak:26.1.
  • Claims are injected by OIDC protocol mappers on big-portal (user attribute → token claim); medewerker roles come through realm_access.roles.

MFA on the medewerker realm (S-15c)

Staff logins (behandel + beheer portals) need a second factor; citizen/company realms (digid, eherkenning, eidas) do not. Two halves in medewerker-realm.json:

  • Every seeded medewerker carries a TOTP credential with the fixture secret BIGMEDEWERKEROTPSEED, so Keycloak's built-in conditional OTP step fires on every login — browser flow (an #otp prompt after the password) and direct grant (a totp form field) alike.
  • CONFIGURE_TOTP is a default required action, so any medewerker added later must enrol an authenticator before the first login.

See ../architecture/adr-0031-mfa-on-the-medewerker-realm.md.

Getting a code

python3 infra/keycloak/check_realms.py otp   # prints a valid 6-digit code right now

Or enrol a phone once: the secret in base32 is IJEUOTKFIRCVORKSJNCVET2UKBJUKRKE (otpauth://totp/medewerker?secret=IJEUOTKFIRCVORKSJNCVET2UKBJUKRKE). The e2e computes its own code in tests/e2e/medewerker-login.ts.

A code is single-use. Keycloak's otpPolicyCodeReusable defaults to false, so it refuses a code it has already accepted — a second login as the same medewerker inside the same 30-second window fails with invalid_grant / Invalid user credentials, even though the code is current. Nothing to fix in the realm: wait for the next window, or spend the following counter, which is what nextUnusedCounter in tests/e2e/medewerker-login.ts does for back-to-back specs.

Fixture only. A shared, committed secret is a demo convenience, never a production posture — see the ADR's consequences.