Files
register-referentie/tests/e2e/medewerker-login.ts
T
notandClaude Opus 5 984d2e9d54 fix(e2e): spend a fresh TOTP counter per medewerker login (refs #132)
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) <noreply@anthropic.com>
2026-09-04 09:55:58 +02:00

58 lines
2.7 KiB
TypeScript

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