From 30c5279e90f2d3e0336f078ad1db7ac521f97aef Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 3 Sep 2026 09:07:45 +0200 Subject: [PATCH 1/5] test(infra): medewerker login must be refused without a second factor (refs #132) The keycloak smoke check now asserts that a password-only grant on the medewerker realm is rejected and that a TOTP code completes it. The e2e medewerker logins move to a shared helper that submits Keycloak's OTP challenge. Both fail against the current realm export, which enforces no MFA. Co-Authored-By: Claude Opus 5 (1M context) --- infra/keycloak/check_realms.py | 51 ++++++++++++++++++++++++++-------- tests/e2e/catalogus.spec.ts | 8 +++--- tests/e2e/default-fill.spec.ts | 7 ++--- tests/e2e/medewerker-login.ts | 27 ++++++++++++++++++ tests/e2e/registration.spec.ts | 6 ++-- 5 files changed, 77 insertions(+), 22 deletions(-) create mode 100644 tests/e2e/medewerker-login.ts diff --git a/infra/keycloak/check_realms.py b/infra/keycloak/check_realms.py index e338d29..4cb0965 100644 --- a/infra/keycloak/check_realms.py +++ b/infra/keycloak/check_realms.py @@ -1,19 +1,25 @@ #!/usr/bin/env python3 """Smoke-check the Keycloak realms: each realm's OIDC login works (password grant) -and returns its expected identifying claim. Stdlib only. Exits non-zero on failure. +and returns its expected identifying claim. The medewerker realm additionally enforces +MFA (S-15c), so its login must be refused without a TOTP code. Stdlib only. +Exits non-zero on failure. """ -import base64, json, sys, urllib.error, urllib.parse, urllib.request +import base64, hashlib, hmac, json, struct, sys, time, urllib.error, urllib.parse, urllib.request BASE = "http://localhost:8180" CLIENT = "big-portal" PWD = "test123" -# realm, user, claim ("__roles__" => check realm_access.roles), expected-contains +# Fixture TOTP secret seeded into every medewerker in infra/keycloak/realms/medewerker-realm.json. +# Keycloak HMACs the raw secret bytes, so no base32 decoding is involved. +OTP_SECRET = b"BIGMEDEWERKEROTPSEED" + +# realm, user, claim ("__roles__" => check realm_access.roles), expected-contains, mfa-enforced CHECKS = [ - ("digid", "jan-burger", "bsn", "123456782"), - ("eherkenning", "acme-ondernemer", "kvk", "12345678"), - ("eidas", "pierre-dupont", "eidas_id", "FR/NL"), - ("medewerker", "merel-behandelaar", "__roles__", "behandelaar"), + ("digid", "jan-burger", "bsn", "123456782", False), + ("eherkenning", "acme-ondernemer", "kvk", "12345678", False), + ("eidas", "pierre-dupont", "eidas_id", "FR/NL", False), + ("medewerker", "merel-behandelaar", "__roles__", "behandelaar", True), ] @@ -23,10 +29,17 @@ def decode(jwt): return json.loads(base64.urlsafe_b64decode(p)) -def grant(realm, user): +def totp(secret=OTP_SECRET, period=30, digits=6): + """RFC 6238 code: HMAC-SHA1 over the 30-second counter, dynamically truncated.""" + mac = hmac.new(secret, struct.pack(">Q", int(time.time()) // period), hashlib.sha1).digest() + o = mac[-1] & 0x0F + return str((struct.unpack(">I", mac[o:o + 4])[0] & 0x7FFFFFFF) % 10 ** digits).zfill(digits) + + +def grant(realm, user, **extra): data = urllib.parse.urlencode({ "grant_type": "password", "client_id": CLIENT, - "username": user, "password": PWD, "scope": "openid", + "username": user, "password": PWD, "scope": "openid", **extra, }).encode() req = urllib.request.Request( f"{BASE}/realms/{realm}/protocol/openid-connect/token", data=data, @@ -35,11 +48,27 @@ def grant(realm, user): return json.loads(r.read()) +def second_factor_refused(realm, user): + """The password alone must not yield a token on an MFA-enforced realm.""" + try: + grant(realm, user) + except urllib.error.HTTPError as e: + return e.code in (400, 401) + return False + + def main(): ok = True - for realm, user, claim, expect in CHECKS: + for realm, user, claim, expect, mfa in CHECKS: + extra = {} + if mfa: + refused = second_factor_refused(realm, user) + ok = ok and refused + print(f"{realm:12} {user:18} password-only login refused " + f"[{'OK' if refused else 'MFA NOT ENFORCED'}]") + extra = {"totp": totp()} try: - at = decode(grant(realm, user)["access_token"]) + at = decode(grant(realm, user, **extra)["access_token"]) if claim == "__roles__": val = at.get("realm_access", {}).get("roles", []) good = expect in val diff --git a/tests/e2e/catalogus.spec.ts b/tests/e2e/catalogus.spec.ts index d7876c5..bb6e85f 100644 --- a/tests/e2e/catalogus.spec.ts +++ b/tests/e2e/catalogus.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test'; +import { loginMedewerker } from './medewerker-login'; // S-15a walking skeleton: a beheerder logs in to the beheer portal (medewerker realm) and sees the // read-only ZTC catalogus. The verify stack seeds and publishes the BIG-REGISTRATIE zaaktype (the @@ -7,10 +8,9 @@ import { expect, test } from '@playwright/test'; test('a beheerder sees the published zaaktypen in the catalogus', async ({ page }) => { await page.goto('http://beheer/'); - // The beheer portal redirects to the Keycloak medewerker realm login (same realm as behandel). - await page.locator('#username').fill('bram-beheerder'); - await page.locator('#password').fill('test123'); - await page.locator('#kc-login').click(); + // The beheer portal redirects to the Keycloak medewerker realm login (same realm as behandel), + // which enforces MFA: password, then a TOTP code. + await loginMedewerker(page, 'bram-beheerder'); await expect(page.getByRole('heading', { name: /Catalogus/i })).toBeVisible(); diff --git a/tests/e2e/default-fill.spec.ts b/tests/e2e/default-fill.spec.ts index 8ff8d03..637fe57 100644 --- a/tests/e2e/default-fill.spec.ts +++ b/tests/e2e/default-fill.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test'; +import { loginMedewerker } from './medewerker-login'; // S-15b: a beheerder edits the ACL default-fill in the beheer portal and gets a saved confirmation. // Runs against the shared verify stack; it edits + saves (the ACL store is in-memory, ADR-0026) and @@ -6,10 +7,8 @@ import { expect, test } from '@playwright/test'; test('a beheerder edits and saves the default-fill', async ({ page }) => { await page.goto('http://beheer/'); - // Keycloak medewerker-realm login (same realm as behandel). - await page.locator('#username').fill('bram-beheerder'); - await page.locator('#password').fill('test123'); - await page.locator('#kc-login').click(); + // Keycloak medewerker-realm login (same realm as behandel) — password + enforced TOTP. + await loginMedewerker(page, 'bram-beheerder'); await expect(page.getByRole('heading', { name: /Catalogus/i })).toBeVisible(); diff --git a/tests/e2e/medewerker-login.ts b/tests/e2e/medewerker-login.ts new file mode 100644 index 0000000..93bd879 --- /dev/null +++ b/tests/e2e/medewerker-login.ts @@ -0,0 +1,27 @@ +import { createHmac } from 'node:crypto'; +import type { Page } from '@playwright/test'; + +// The medewerker realm enforces MFA (S-15c), so a staff login is two steps: password, then a TOTP +// code. The realm export seeds every medewerker with this fixture secret — Keycloak HMACs the raw +// secret bytes — so the e2e can compute a valid code instead of enrolling an authenticator. +const OTP_SECRET = 'BIGMEDEWERKEROTPSEED'; + +// RFC 6238 TOTP: HMAC-SHA1 over the 30-second counter, dynamically truncated to 6 digits. +export function totp(secret = OTP_SECRET, at = Date.now()): string { + const counter = Buffer.alloc(8); + counter.writeBigUInt64BE(BigInt(Math.floor(at / 1000 / 30))); + const mac = createHmac('sha1', secret).update(counter).digest(); + const offset = mac[mac.length - 1] & 0x0f; + return String((mac.readUInt32BE(offset) & 0x7fffffff) % 1_000_000).padStart(6, '0'); +} + +export async function loginMedewerker(page: Page, username: string): Promise { + await page.locator('#username').fill(username); + await page.locator('#password').fill('test123'); + await page.locator('#kc-login').click(); + + // Keycloak's conditional-OTP step. Its lookAheadWindow accepts the neighbouring counters, so a + // code computed just before a 30-second boundary still validates — no retry needed. + await page.locator('#otp').fill(totp()); + await page.locator('#kc-login').click(); +} diff --git a/tests/e2e/registration.spec.ts b/tests/e2e/registration.spec.ts index 27167e5..a7a9c0e 100644 --- a/tests/e2e/registration.spec.ts +++ b/tests/e2e/registration.spec.ts @@ -1,4 +1,5 @@ import { expect, request, test } from '@playwright/test'; +import { loginMedewerker } from './medewerker-login'; // Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12 + S-10a + S-19b-2): a zorgprofessional // logs in via mock DigiD and submits through the self-service portal → BFF → domain; the entry @@ -76,9 +77,8 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt // — the S-12 flow that replaces the temporary admin endpoint. The staff tab switches to the // medewerker realm (a different Keycloak realm than the citizen's digid session). await staff.goto('http://behandel/'); - await staff.locator('#username').fill('merel-behandelaar'); - await staff.locator('#password').fill('test123'); - await staff.locator('#kc-login').click(); + // That realm enforces MFA (S-15c), so the behandelaar logs in with password + TOTP. + await loginMedewerker(staff, 'merel-behandelaar'); await expect(staff.getByRole('heading', { name: /Werkbak/i })).toBeVisible(); -- 2.54.0 From 3567bc1f42730cfaa887ca6f9f3be5c091201d8f Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 3 Sep 2026 09:08:50 +0200 Subject: [PATCH 2/5] feat(infra): enforce MFA on the medewerker realm (refs #132) Every seeded medewerker carries a TOTP credential, so Keycloak's conditional-OTP step in both the browser and direct-grant flows always challenges them; a password alone no longer yields a token. CONFIGURE_TOTP becomes a default required action so any medewerker added later must enrol before logging in. Co-Authored-By: Claude Opus 5 (1M context) --- infra/keycloak/realms/medewerker-realm.json | 40 +++++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/infra/keycloak/realms/medewerker-realm.json b/infra/keycloak/realms/medewerker-realm.json index fa739bc..f298d45 100644 --- a/infra/keycloak/realms/medewerker-realm.json +++ b/infra/keycloak/realms/medewerker-realm.json @@ -2,6 +2,16 @@ "realm": "medewerker", "enabled": true, "displayName": "Medewerkers", + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": true, + "priority": 10 + } + ], "roles": { "realm": [ { "name": "behandelaar", "description": "Behandelt registratieaanvragen" }, @@ -43,7 +53,15 @@ "lastName": "Behandelaar", "email": "merel@big.example.nl", "emailVerified": true, - "credentials": [{ "type": "password", "value": "test123", "temporary": false }], + "credentials": [ + { "type": "password", "value": "test123", "temporary": false }, + { + "type": "otp", + "userLabel": "seeded TOTP (fixture)", + "secretData": "{\"value\":\"BIGMEDEWERKEROTPSEED\"}", + "credentialData": "{\"subType\":\"totp\",\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\"}" + } + ], "realmRoles": ["behandelaar"] }, { @@ -53,7 +71,15 @@ "lastName": "Teamlead", "email": "tom@big.example.nl", "emailVerified": true, - "credentials": [{ "type": "password", "value": "test123", "temporary": false }], + "credentials": [ + { "type": "password", "value": "test123", "temporary": false }, + { + "type": "otp", + "userLabel": "seeded TOTP (fixture)", + "secretData": "{\"value\":\"BIGMEDEWERKEROTPSEED\"}", + "credentialData": "{\"subType\":\"totp\",\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\"}" + } + ], "realmRoles": ["behandelaar", "teamlead"] }, { @@ -63,7 +89,15 @@ "lastName": "Beheerder", "email": "bram@big.example.nl", "emailVerified": true, - "credentials": [{ "type": "password", "value": "test123", "temporary": false }], + "credentials": [ + { "type": "password", "value": "test123", "temporary": false }, + { + "type": "otp", + "userLabel": "seeded TOTP (fixture)", + "secretData": "{\"value\":\"BIGMEDEWERKEROTPSEED\"}", + "credentialData": "{\"subType\":\"totp\",\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\"}" + } + ], "realmRoles": ["beheerder"] } ] -- 2.54.0 From a87a32e269311678e6e202313f930f53bb236619 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 3 Sep 2026 09:11:03 +0200 Subject: [PATCH 3/5] docs(infra): document MFA on the medewerker realm + ADR-0031 (refs #132) Runbook gains an MFA section and how to get a code; synthetic-data lists the fixture TOTP secret and the extra grant parameter; demo-script gains the S-15c note and its staff logins now mention the second factor. check_realms.py grows an 'otp' argument that prints a current code for a manual demo. Co-Authored-By: Claude Opus 5 (1M context) --- .../adr-0031-mfa-on-the-medewerker-realm.md | 49 +++++++++++++++++++ docs/demo-script.md | 40 +++++++++++++-- docs/runbooks/keycloak.md | 30 ++++++++++++ docs/synthetic-data.md | 8 +++ infra/keycloak/check_realms.py | 7 ++- 5 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 docs/architecture/adr-0031-mfa-on-the-medewerker-realm.md diff --git a/docs/architecture/adr-0031-mfa-on-the-medewerker-realm.md b/docs/architecture/adr-0031-mfa-on-the-medewerker-realm.md new file mode 100644 index 0000000..0f8c8b7 --- /dev/null +++ b/docs/architecture/adr-0031-mfa-on-the-medewerker-realm.md @@ -0,0 +1,49 @@ +# ADR-0031 — MFA on the medewerker realm, with a fixture TOTP secret + +- **Status:** Accepted +- **Date:** 2026-09-03 +- **Slice:** S-15c (Gitea #132) + +## Context + +Staff (behandelaar, teamlead, beheerder) act on citizens' registrations and on the ACL's +default-fill: the highest-privilege logins in the platform. The medewerker realm protected +them with a password alone, while the citizen realms (digid, eherkenning, eidas) mock +brokers that carry their own assurance levels. A reference application that demonstrates a +government architecture should show MFA on the staff realm. + +Two things had to be decided: **how** to enforce OTP in a realm export, and **how the +automated checks and a human demo obtain a code** — the e2e drives a real browser login and +`make keycloak-smoke` drives a real password grant, so neither can scan a QR. + +## Decision + +**Enforce OTP by giving every seeded medewerker a TOTP credential**, rather than replacing +Keycloak's browser flow with a copy whose OTP execution is `REQUIRED`. + +Keycloak's stock `browser` and `direct grant` flows both contain a *conditional OTP* +subflow that fires when the user has an OTP credential. Seeding the credential therefore +turns the challenge on for every seeded user, in both flows, without duplicating ~40 lines +of flow JSON into the export. `CONFIGURE_TOTP` is additionally set as a **default required +action**, so a medewerker created later must enrol before their first login. + +**The seeded secret is a fixed, committed fixture** (`BIGMEDEWERKEROTPSEED`) shared by all +medewerkers. Codes are then computable: `infra/keycloak/check_realms.py` (Python, stdlib +`hmac`) and `tests/e2e/medewerker-login.ts` (Node `crypto`) each implement RFC 6238 in +about six lines — no OTP dependency on either side, and no enrolment step in the tests. + +## Consequences + +- A password alone no longer yields a token on the medewerker realm; `check_realms.py` + asserts that refusal, so the enforcement cannot silently regress. +- Every medewerker login in the e2e goes through `loginMedewerker()`, which submits the OTP + form. New staff specs must use it. +- **The secret is public.** It is a demo fixture and worthless outside this synthetic + stack, in the same class as the committed `test123` passwords and the mock DigiD broker. + A real deployment enrols per-user authenticators (or federates to DigiD Machtigen / + eHerkenning at the required assurance level) and seeds no credentials at all. +- Enforcement is *effectively* realm-wide but *technically* per-user: the conditional + subflow is what fires. A medewerker whose OTP credential were removed would fall back to + the required action at next login (enrol, then challenge) rather than skipping MFA — an + acceptable equivalence for this purpose, and the reason the required action is set. +- Reversal is a one-file edit: drop the `otp` credentials and the `requiredActions` block. diff --git a/docs/demo-script.md b/docs/demo-script.md index 25423aa..5172b33 100644 --- a/docs/demo-script.md +++ b/docs/demo-script.md @@ -140,7 +140,8 @@ zaaktype cache). Store is in-memory: an edit reverts to the configured env on re ```bash make up -# 1. Log in as bram-beheerder / test123 → "Default-fill" tab → change a value → Opslaan. +# 1. Log in as bram-beheerder / test123 + OTP (`python3 infra/keycloak/check_realms.py otp`) +# → "Default-fill" tab → change a value → Opslaan. open http://localhost:8143/default-fill # # 2. Automated: the ACL uses the current default-fill per zaak (unit) and the endpoints are behind the @@ -161,7 +162,8 @@ directly (ADR-0025); managing the default-fill config (S-15b) and MFA (S-15c) co ```bash make up -# 1. Log in as bram-beheerder / test123 → the catalogus lists the published zaaktypen. +# 1. Log in as bram-beheerder / test123 + OTP (`python3 infra/keycloak/check_realms.py otp`) +# → the catalogus lists the published zaaktypen. open http://localhost:8143 # # 2. Automated (a CI verify-stack e2e): a beheerder logs in and sees BIG-REGISTRATIE. @@ -304,7 +306,8 @@ make verify-local # → "OK — a fresh local stack completed the flow with # 3. Or by hand in the browser: log in at http://localhost:8140 (jan-burger / test123), submit + # upload a PDF, then approve it in the werkbak at http://localhost:8142 (merel-behandelaar / -# test123); it shows as INGESCHREVEN in the openbaar register at http://localhost:8141. +# test123 + OTP, see S-15c); it shows as INGESCHREVEN in the openbaar register at +# http://localhost:8141. ``` > The zaaktype is discovered by the ACL itself since S-27 (below); `local-seed`'s `acl.env` now @@ -589,7 +592,7 @@ or **afwijzen** — which also completes the Beoordelen task so the process adva ```text # 1. Open the behandel portal and log in as a behandelaar (medewerker realm): -# http://localhost:8142/ → merel-behandelaar / test123 +# http://localhost:8142/ → merel-behandelaar / test123 + OTP # # 2. The werkbak lists the registrations awaiting beoordeling (referentie / bsn / status). # Find the reference from the submit confirmation and click "Goedkeuren" on that row. @@ -812,3 +815,32 @@ make verify-domain # → "the timed-out registration's zaak was cancelled to `POST /annuleringen` → ZGW `resultaten` + `statussen` (Geannuleerd); the aggregate then moves to `Verlopen`. The ACL cancels the zaak **before** the aggregate is expired, so a failed ZGW call leaves the job for redelivery rather than diverging the two (ADR-0019). + +--- + +## S-15c — MFA on the medewerker realm (#132, ADR-0031) + +**Outcome:** staff logins (behandel + beheer portals) need a **second factor**. The medewerker realm +seeds every medewerker with a TOTP credential, so Keycloak's conditional-OTP step challenges them in +both the browser flow and the direct grant; a password alone no longer yields a token. `CONFIGURE_TOTP` +is a default required action, so a medewerker added later must enrol first. Citizen realms (digid, +eherkenning, eidas) are unchanged — they mock brokers that carry their own assurance. + +```bash +# 1. Manual: log in to the behandel portal. After username + password Keycloak asks for a code. +python3 infra/keycloak/check_realms.py otp # a valid code, right now +open http://localhost:8142 # merel-behandelaar / test123 + that code +# +# 2. Automated: the realm smoke check asserts the password alone is REFUSED, then that +# password + TOTP succeeds and still carries the behandelaar role: +make keycloak-smoke # → "medewerker merel-behandelaar password-only login refused [OK]" +# +# 3. End-to-end: every staff login in the e2e goes through the OTP prompt (loginMedewerker): +make verify-e2e # → registration.spec (behandelaar approves), catalogus.spec, default-fill.spec +``` + +**The path:** the seeded `otp` credential in `infra/keycloak/realms/medewerker-realm.json` activates +Keycloak's stock conditional-OTP subflow — no custom browser flow. The fixture secret is shared and +committed on purpose so the checks can compute codes; a real deployment enrols per-user authenticators +(ADR-0031). + diff --git a/docs/runbooks/keycloak.md b/docs/runbooks/keycloak.md index df43d93..976f95a 100644 --- a/docs/runbooks/keycloak.md +++ b/docs/runbooks/keycloak.md @@ -23,6 +23,9 @@ login per realm and asserts the identifying claim: | 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 @@ -35,3 +38,30 @@ All test users / credentials are in [../synthetic-data.md](../synthetic-data.md) - **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`. + +**Fixture only.** A shared, committed secret is a demo convenience, never a production +posture — see the ADR's consequences. diff --git a/docs/synthetic-data.md b/docs/synthetic-data.md index ccdd538..853ba5b 100644 --- a/docs/synthetic-data.md +++ b/docs/synthetic-data.md @@ -19,6 +19,11 @@ All test users share the password **`test123`**. | `eidas` | eIDAS (EU) | `pierre-dupont` | `eidas_id` = `FR/NL/AB-1234-5678` | | `medewerker` | Internal staff | `merel-behandelaar` | role `behandelaar` | | `medewerker` | Internal staff | `tom-teamlead` | roles `behandelaar`, `teamlead` | +| `medewerker` | Internal staff | `bram-beheerder` | role `beheerder` | + +`medewerker` users additionally need a **second factor**: that realm enforces MFA (S-15c, +ADR-0031). All three share the fixture TOTP secret `BIGMEDEWERKEROTPSEED`; print a current +code with `python3 infra/keycloak/check_realms.py otp`. The identifying claims are injected via OIDC protocol mappers on `big-portal` (user-attribute → token claim); `medewerker` roles appear in `realm_access.roles`. @@ -32,5 +37,8 @@ curl -s -X POST \ -d username=jan-burger -d password=test123 -d scope=openid | jq -r .access_token ``` +For a `medewerker` user, add `-d totp=$(python3 infra/keycloak/check_realms.py otp)` — +without it the grant is refused with `invalid_grant`. + Decode the JWT payload to see the `bsn` claim. `make keycloak-smoke` checks every realm automatically. diff --git a/infra/keycloak/check_realms.py b/infra/keycloak/check_realms.py index 4cb0965..240d22d 100644 --- a/infra/keycloak/check_realms.py +++ b/infra/keycloak/check_realms.py @@ -86,4 +86,9 @@ def main(): if __name__ == "__main__": - main() + # `check_realms.py otp` prints a current code for the fixture secret — what a human demoing + # the medewerker portals types at Keycloak's OTP prompt (docs/runbooks/keycloak.md). + if len(sys.argv) > 1 and sys.argv[1] == "otp": + print(totp()) + else: + main() -- 2.54.0 From 716b8d03e0e11c70c98f225c264d6c2522d313cb Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 4 Sep 2026 09:53:55 +0200 Subject: [PATCH 4/5] test(e2e): a medewerker login must not reuse a spent TOTP counter (refs #132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two beheer specs log in as bram-beheerder back to back, so both submit the code for the same 30-second counter. Keycloak's otpPolicyCodeReusable defaults to false, so it refuses the second one as invalid credentials and the beheer portal never loads — which is how verify-e2e went red on #158. Pins the counter choice as a pure function of "now" and the last counter this medewerker spent, so the guard is checkable without a browser. Co-Authored-By: Claude Opus 5 (1M context) --- tests/e2e/medewerker-login.spec.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 tests/e2e/medewerker-login.spec.ts diff --git a/tests/e2e/medewerker-login.spec.ts b/tests/e2e/medewerker-login.spec.ts new file mode 100644 index 0000000..b4e83bb --- /dev/null +++ b/tests/e2e/medewerker-login.spec.ts @@ -0,0 +1,14 @@ +import { expect, test } from '@playwright/test'; +import { OTP_PERIOD_MS, nextUnusedCounter } from './medewerker-login'; + +// Pure check of the TOTP counter guard in loginMedewerker — no browser, no stack. Keycloak refuses +// a code it has already accepted (its otpPolicyCodeReusable defaults to false), so two logins as +// the same medewerker inside one 30-second window must not spend the same counter twice (#132). +test('a login never spends a TOTP counter this medewerker already used', () => { + const now = 3 * OTP_PERIOD_MS + 1_000; // 1 second into counter 3 + + expect(nextUnusedCounter(now, -1)).toBe(3); // nothing spent yet → the current counter + expect(nextUnusedCounter(now, 3)).toBe(4); // the current counter is spent → the next one + expect(nextUnusedCounter(now, 4)).toBe(5); // two logins already in this window → the one after + expect(nextUnusedCounter(now + OTP_PERIOD_MS, 3)).toBe(4); // window moved on → current again +}); -- 2.54.0 From 984d2e9d545b34af3adad19a184b161b8baed84e Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 4 Sep 2026 09:55:58 +0200 Subject: [PATCH 5/5] fix(e2e): spend a fresh TOTP counter per medewerker login (refs #132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/runbooks/keycloak.md | 6 ++++++ tests/e2e/medewerker-login.ts | 38 +++++++++++++++++++++++++++++++---- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/docs/runbooks/keycloak.md b/docs/runbooks/keycloak.md index 976f95a..8d71bbf 100644 --- a/docs/runbooks/keycloak.md +++ b/docs/runbooks/keycloak.md @@ -63,5 +63,11 @@ 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. diff --git a/tests/e2e/medewerker-login.ts b/tests/e2e/medewerker-login.ts index 93bd879..53ba23a 100644 --- a/tests/e2e/medewerker-login.ts +++ b/tests/e2e/medewerker-login.ts @@ -1,4 +1,7 @@ import { createHmac } from 'node:crypto'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import type { Page } from '@playwright/test'; // The medewerker realm enforces MFA (S-15c), so a staff login is two steps: password, then a TOTP @@ -6,22 +9,49 @@ import type { Page } from '@playwright/test'; // secret bytes — so the e2e can compute a valid code instead of enrolling an authenticator. const OTP_SECRET = 'BIGMEDEWERKEROTPSEED'; +export const OTP_PERIOD_MS = 30_000; + // RFC 6238 TOTP: HMAC-SHA1 over the 30-second counter, dynamically truncated to 6 digits. export function totp(secret = OTP_SECRET, at = Date.now()): string { const counter = Buffer.alloc(8); - counter.writeBigUInt64BE(BigInt(Math.floor(at / 1000 / 30))); + counter.writeBigUInt64BE(BigInt(Math.floor(at / OTP_PERIOD_MS))); const mac = createHmac('sha1', secret).update(counter).digest(); const offset = mac[mac.length - 1] & 0x0f; return String((mac.readUInt32BE(offset) & 0x7fffffff) % 1_000_000).padStart(6, '0'); } +// Keycloak refuses a TOTP code it has already accepted (its otpPolicyCodeReusable defaults to +// false), so two logins as the same medewerker inside one 30-second window would both submit the +// same code and the second is rejected. Spend the first counter this medewerker has left. +export function nextUnusedCounter(now: number, spent: number): number { + return Math.max(Math.floor(now / OTP_PERIOD_MS), spent + 1); +} + +// The spent counter lives on disk rather than in module state: Playwright starts a fresh worker +// process for a retry, which would otherwise forget it and resubmit the rejected code. +function spendCounter(username: string): number { + const file = join(tmpdir(), `otp-counter-${username}`); + let spent = -1; + try { + spent = Number(readFileSync(file, 'utf8')) || -1; + } catch { + // first login as this medewerker in this run + } + const counter = nextUnusedCounter(Date.now(), spent); + writeFileSync(file, String(counter)); + return counter; +} + export async function loginMedewerker(page: Page, username: string): Promise { await page.locator('#username').fill(username); await page.locator('#password').fill('test123'); await page.locator('#kc-login').click(); - // Keycloak's conditional-OTP step. Its lookAheadWindow accepts the neighbouring counters, so a - // code computed just before a 30-second boundary still validates — no retry needed. - await page.locator('#otp').fill(totp()); + // Keycloak's conditional-OTP step. Wait out the rest of the window if the counter we may spend is + // still in the future; its lookAheadWindow would accept the code a moment early, but only by one + // counter — waiting keeps a third login in the same window valid too. + const counter = spendCounter(username); + await page.waitForTimeout(Math.max(0, counter * OTP_PERIOD_MS - Date.now())); + await page.locator('#otp').fill(totp(OTP_SECRET, counter * OTP_PERIOD_MS)); await page.locator('#kc-login').click(); } -- 2.54.0