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