import { createHmac } from 'node:crypto'; import { readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { expect, type Page } from '@playwright/test'; // Every portal login in the suite goes through this module — citizen realms (mock DigiD) and the // medewerker realm alike — so the shared Keycloak form handling lives in exactly one place. // 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; /** * How long a Keycloak form gets to appear. Generous enough for a cold first browser launch and a * loaded stack, far short of the 90-second test timeout an auto-waiting action would otherwise eat. */ const FORM_TIMEOUT_MS = 20_000; const FORM_NEVER_APPEARED = 'the Keycloak login form never appeared — the portal did not reach Keycloak (check its ' + 'config.json fetch and the OIDC discovery on the authority it was built with)'; const OTP_NEVER_APPEARED = 'the Keycloak OTP form never appeared — the password step did not complete (check the ' + 'medewerker realm seeded this user with both a password and a TOTP credential)'; // 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; } /** * Fill Keycloak's login form. Every portal is guarded, so the first navigation redirects here; the * form ids are stable across themes. * * The form is asserted visible *before* it is filled. A portal that never reaches Keycloak — its * runtime `config.json` fetch or the OIDC discovery behind `authorize()` failed, so it never * bootstrapped and shows a blank page (main.ts only logs to the console) — would otherwise leave * `fill()` auto-waiting until the whole test times out: 90 seconds spent to report * `locator.fill: Test timeout of 90000ms exceeded`, naming the symptom and not the cause. That is * how #161's catalogus.spec burned 1.8 minutes. This fails in a quarter of the time and says which * step never happened. */ async function submitPassword(page: Page, username: string): Promise { await expect(page.locator('#username'), FORM_NEVER_APPEARED).toBeVisible({ timeout: FORM_TIMEOUT_MS }); await page.locator('#username').fill(username); await page.locator('#password').fill('test123'); await page.locator('#kc-login').click(); } /** A citizen login on a mock-DigiD realm — no second factor (ADR-0031). */ export async function loginBurger(page: Page, username: string): Promise { await submitPassword(page, username); } /** A staff login on the medewerker realm: password, then the enforced TOTP second factor. */ export async function loginMedewerker(page: Page, username: string): Promise { await submitPassword(page, username); // Keycloak's conditional-OTP step. Same reasoning as the password form above: assert it arrived // rather than letting `fill()` swallow the test timeout. await expect(page.locator('#otp'), OTP_NEVER_APPEARED).toBeVisible({ timeout: FORM_TIMEOUT_MS }); // Wait out the rest of the window if the counter we may spend is still in the future; Keycloak's // 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(); }