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..8d71bbf 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,36 @@ 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`. + +**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/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 e338d29..240d22d 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 @@ -57,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() 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"] } ] 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.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 +}); diff --git a/tests/e2e/medewerker-login.ts b/tests/e2e/medewerker-login.ts new file mode 100644 index 0000000..53ba23a --- /dev/null +++ b/tests/e2e/medewerker-login.ts @@ -0,0 +1,57 @@ +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 +// 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'; + +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 / 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. 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(); +} 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();