Browse Source

playwright: pin webauthn-grant 2FA-usability gate

Drive a cryptographically valid passkey assertion into the gate at
`webauthn_login` (src/api/identity.rs:1648) by enrolling a PRF passkey
plus Email 2FA, then clearing SMTP via `/admin/config` so
`_enable_email_2fa` flips to false. The Email 2FA row stays in the DB
(still policy-relevant), but `is_twofactor_provider_usable` for Email
returns false — the gate must refuse the grant rather than silently
issue a token that would bypass the second factor the user configured.

Pinned via the response (status >= 400 + access_token undefined +
post-failure URL doesn't reach /vault), parallel to the existing
"Disabled account cannot complete a real passkey login" test.
pull/7297/head
Zaid Marji 2 weeks ago
parent
commit
727fead3a0
  1. 108
      playwright/tests/passkey.spec.ts

108
playwright/tests/passkey.spec.ts

@ -2,7 +2,8 @@ import { test, expect, type TestInfo } from '@playwright/test';
import * as utils from '../global-utils';
import { createAccount } from './setups/user';
import { resetTotpTimeStep } from './setups/2fa';
import { activateEmail, resetTotpTimeStep } from './setups/2fa';
import { MailDev } from 'maildev';
import {
addVirtualAuthenticator,
clickLoginWithPasskey,
@ -988,3 +989,108 @@ test.describe('Passkey UI flows', () => {
}
});
});
test.describe('Passkey UI flows: webauthn grant gates on runtime 2FA-usability', () => {
// Pins the gate in `webauthn_login` (src/api/identity.rs) that rejects
// the passkey grant when the user has policy-relevant 2FA enrolled but no
// currently-usable provider — e.g. Email 2FA enrolled, then the operator
// clears SMTP via `/admin/config`. Without this gate a passkey holder would
// silently bypass the second factor the user configured. A comment in that
// function documents the fingerprinting defense; this test pins the
// behaviour against silent regression.
test.skip(
({ browserName }) => browserName !== 'chromium',
'requires Chromium CDP virtual authenticator with hmac-secret/PRF',
);
let mailserver: any;
test.beforeAll('Start maildev + enable SMTP via /admin/config', async ({ request }) => {
test.skip(useExternalVault, 'mutating SMTP config via /admin would touch the externally-running vault');
mailserver = new MailDev({
port: Number(process.env.MAILDEV_SMTP_PORT),
web: { port: Number(process.env.MAILDEV_HTTP_PORT) },
});
await mailserver.listen();
await adminLogin(request);
const r = await request.post('/admin/config', {
data: {
_enable_smtp: true,
smtp_host: process.env.MAILDEV_HOST,
smtp_port: Number(process.env.MAILDEV_SMTP_PORT),
smtp_security: 'off',
smtp_from: process.env.PW_SMTP_FROM,
smtp_from_name: 'Vaultwarden',
},
failOnStatusCode: false,
});
expect(r.status()).toBe(200);
});
test.afterAll('Restore SMTP-off + stop maildev', async ({ request, browserName }) => {
// Mirror the describe-level skip: under the non-chromium DB projects the
// tests (and beforeAll) are skipped, but afterAll still runs — so guard
// it the same way to avoid pointless /admin/config writes there.
if (browserName !== 'chromium') return;
if (useExternalVault) return;
await adminLogin(request).catch(() => {});
// The shared default vault posture is `_enable_smtp=true` (config
// default) with no smtp_host/smtp_from env vars set. Restore to that
// by clearing the runtime overrides and bouncing _enable_smtp off
// and back on — the second POST is the validator-safe path since
// smtp_host=None && smtp_from='' satisfies the `is_some() == is_empty()`
// equality check in src/config.rs (false == true → ok).
await request.post('/admin/config', {
data: { _enable_smtp: false, smtp_host: '', smtp_from: '', smtp_from_name: '' },
failOnStatusCode: false,
});
await request.post('/admin/config', {
data: { _enable_smtp: true },
failOnStatusCode: false,
});
if (mailserver) await mailserver.close();
});
test('Email 2FA enrolled then runtime-disabled blocks passkey-grant login', async ({ page, request }) => {
await addVirtualAuthenticator(page);
const user = freshUser('twofa-runtime');
const mailBuffer = mailserver.buffer(user.email);
try {
await createAccount(test, page, user);
await enrollLoginPasskey(page, user.password, 'twofa-key', { useForEncryption: true });
await activateEmail(test, page, user, mailBuffer);
await utils.logout(test, page, user);
// Disable Email 2FA at runtime by flipping `_enable_smtp=false`.
// `_enable_email_2fa` is auto-derived from
// `_enable_smtp && (smtp_host || use_sendmail)` (src/config.rs),
// so this flips `is_twofactor_provider_usable(Email)` to false at
// the next handler invocation. The Email 2FA row stays in the DB
// (still policy-relevant), but no longer usable — exactly the
// state the gate is supposed to refuse. POSTing only
// `_enable_smtp: false` (rather than blanking smtp_host/smtp_from)
// bypasses the validator block in src/config.rs entirely;
// a POST that clears smtp_host while smtp_from is still set would
// trip the `is_some() == is_empty()` check there.
await adminLogin(request);
const flip = await request.post('/admin/config', {
data: { _enable_smtp: false },
failOnStatusCode: false,
});
expect(flip.status()).toBe(200);
const tokenResponse = page.waitForResponse(
(r) => r.url().includes('/identity/connect/token') && r.request().method() === 'POST',
{ timeout: 30_000 },
);
await clickLoginWithPasskey(page);
const res = await tokenResponse;
expect(res.status(), 'runtime-2FA-unusable passkey grant must be rejected').toBeGreaterThanOrEqual(400);
const body: any = await res.json();
expect(body.access_token, 'must not receive an access token').toBeUndefined();
} finally {
mailBuffer.close();
}
});
});

Loading…
Cancel
Save