From 727fead3a06eabf477ae9abed2ff0328acba7a6f Mon Sep 17 00:00:00 2001 From: Zaid Marji Date: Tue, 2 Jun 2026 19:13:27 +0300 Subject: [PATCH] playwright: pin webauthn-grant 2FA-usability gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- playwright/tests/passkey.spec.ts | 108 ++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 1 deletion(-) diff --git a/playwright/tests/passkey.spec.ts b/playwright/tests/passkey.spec.ts index da6bf7f1..2b0bc049 100644 --- a/playwright/tests/passkey.spec.ts +++ b/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(); + } + }); +});