Skip to content
Facteur

Playwright · 2FA

The code arrives by mail, or it is computed

A two-factor journey blocks a test suite in two places: the code sent by email, which the test cannot read, and the one from an authenticator app, which it cannot generate. Both are solvable, and not in the same way.

The code that arrives by email

Same mechanics as a sign-up: the request goes out before the click and answers when the message lands.

login-2fa.spec.tsts
import { expect, test } from '@playwright/test';

const KEY = process.env.FACTEUR_API_KEY!;
const INBOX = 'j3k9x2mq';

/** hasOtp aims at the extracted code, not at text quoting one. */
const waitForCode = (sentTo: string) =>
  fetch('https://api.facteur.eu/v1/messages/search', {
    method: 'POST',
    headers: {
      authorization: `Bearer ${KEY}`,
      'content-type': 'application/json',
    },
    body: JSON.stringify({ inbox: INBOX, sentTo, hasOtp: true, wait: 30_000 }),
  }).then((response) => response.json());

test('signing in asks for the code sent by email', async ({ page }) => {
  const tag = `login-${Date.now()}`;
  const arriving = waitForCode(tag);

  await page.goto('/login');
  await page.getByLabel('Email').fill(`${tag}@${INBOX}.inbox.facteur.eu`);
  await page.getByRole('button', { name: 'Continue' }).click();

  const { messages } = await arriving;

  // Six digits, extracted on reception.
  await page.getByLabel('Code').fill(messages[0].otp);
  await expect(page.getByText('Welcome')).toBeVisible();
});
  • A criterion that aims at the code, not at the text

    Asking for "the message carrying a code" avoids landing on an email that quotes an older code in its history.

  • The code lives in its own field

    Six digits, extracted on reception. Your test compares a value; it does not search HTML for a pattern.

The code from an authenticator app

Nobody can send you that one: it is computed from the secret your enrolment screen shows. Twenty lines of Node, no dependency, and the phone stays in the drawer.

totp.tsts
import { createHmac } from 'node:crypto';

const BASE32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

/** The secret your enrolment screen shows, in base32. */
function decode(secret: string): Buffer {
  let bits = 0;
  let value = 0;
  const out: number[] = [];
  for (const c of secret.replace(/=+$/, '').toUpperCase()) {
    value = (value << 5) | BASE32.indexOf(c);
    bits += 5;
    if (bits >= 8) {
      out.push((value >>> (bits - 8)) & 0xff);
      bits -= 8;
    }
  }
  return Buffer.from(out);
}

/** RFC 6238: SHA-1, six digits, a thirty-second step. */
export function totp(secret: string, at = Date.now()): string {
  const counter = Math.floor(at / 1000 / 30);
  const message = Buffer.alloc(8);
  message.writeUInt32BE(Math.floor(counter / 2 ** 32), 0);
  message.writeUInt32BE(counter % 2 ** 32, 4);

  const digest = createHmac('sha1', decode(secret)).update(message).digest();
  const offset = digest[digest.length - 1] & 0x0f;
  const binary =
    ((digest[offset] & 0x7f) << 24) |
    (digest[offset + 1] << 16) |
    (digest[offset + 2] << 8) |
    digest[offset + 3];

  return String(binary % 1_000_000).padStart(6, '0');
}

What it changes for a suite

The whole journey becomes testable

Enrolment, sign-in, backup codes: none of it depends on a physical device or on somebody’s personal mailbox any more.

The test stays readable

Two calls and an assertion. You do not extract the code, and the waiting is not a loop you maintain.

Parallel runs do not collide

Each test receives at its own address, so the code it reads belongs to its own journey.

What about a Playwright plugin?

Planned, not published. The test above expects nothing from it: it calls the API directly, and keeps working afterwards.

See the state of each integration

Unblock your 2FA tests

A test mailbox and a read-only key, and the whole journey is testable again. The free plan asks for no card.