# 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:** — `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.