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(); }