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