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
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-portalis public withstandardFlowEnabled(browser redirect login) anddirectAccessGrantsEnabled(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);medewerkerroles come throughrealm_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#otpprompt after the password) and direct grant (atotpform field) alike. CONFIGURE_TOTPis 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.