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>
74 lines
3.3 KiB
Markdown
74 lines
3.3 KiB
Markdown
# 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`)
|
|
|
|
```bash
|
|
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](../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](../architecture/adr-0031-mfa-on-the-medewerker-realm.md).
|
|
|
|
### Getting a code
|
|
|
|
```bash
|
|
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.
|