Files
register-referentie/docs/runbooks/keycloak.md
T
notandClaude Opus 5 984d2e9d54 fix(e2e): spend a fresh TOTP counter per medewerker login (refs #132)
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>
2026-09-04 09:55:58 +02: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.