diff --git a/playwright/tests/passkey.spec.ts b/playwright/tests/passkey.spec.ts index 08fab773..da6bf7f1 100644 --- a/playwright/tests/passkey.spec.ts +++ b/playwright/tests/passkey.spec.ts @@ -895,4 +895,96 @@ test.describe('Passkey UI flows', () => { await unlockWithPasskey(page); await expect(page).toHaveURL(/\/(vault|setup-extension)/, { timeout: 30_000 }); }); + + test('Deleted passkey is refused at login', async ({ page }) => { + // Pins the security property that a credential removed server-side can + // no longer authenticate. The CDP authenticator keeps its resident key + // after the server-side delete, so it still presents a cryptographically + // valid assertion — the "remove first, second still unlocks" test above + // DETACHES it for determinism; here we deliberately leave it attached so + // the deleted credential DOES answer the discoverable `get()`, and assert + // the grant refuses it because the credential row is gone (lookup misses + // → 4xx, no token). + await addVirtualAuthenticator(page); + const user = freshUser('deleted-cred'); + + await createAccount(test, page, user); + await enrollLoginPasskey(page, user.password, 'deleted-key', { useForEncryption: true }); + + // Remove the only passkey server-side; the authenticator's resident + // credential is intentionally left in place so it still answers below. + await removeLoginPasskey(page, user.password, 'deleted-key'); + await utils.logout(test, page, user); + + // The authenticator presents a valid assertion for the now-deleted + // credential; the grant must come back 4xx with no token. Response is + // the oracle, robust against web-vault UI wording. + 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(), 'deleted-credential passkey grant must be rejected').toBeGreaterThanOrEqual(400); + const body: any = await res.json(); + expect(body.access_token, 'deleted credential must not receive an access token').toBeUndefined(); + }); + + test('Disabled account cannot complete a real passkey login', async ({ page, request }) => { + // Complements the request-level forged-handle test above. That one + // drives the PRE-verification path: a bogus assertion fails at + // `identify_discoverable_authentication` before any account-state + // check runs. This test drives a CRYPTOGRAPHICALLY VALID assertion + // from an enrolled credential all the way through signature + // verification and into the POST-verification `if !user.enabled` + // gate in `webauthn_login` (src/api/identity.rs), which must still + // reject the disabled account with the generic AUTH_FAILED (4xx). + // The unverified-email gate immediately below it shares this branch. + await addVirtualAuthenticator(page); + const user = freshUser('disabled'); + + await createAccount(test, page, user); + await enrollLoginPasskey(page, user.password, 'disabled-key', { useForEncryption: true }); + await utils.logout(test, page, user); + + // Disable the account while the real discoverable credential remains + // enrolled, so the assertion verifies but the account-state gate fires. + await adminLogin(request); + const adminUser = await adminGetUserByEmail(request, user.email); + const disableRes = await request.post(`/admin/users/${adminUser.id}/disable`, { + headers: { 'Content-Type': 'application/json' }, + failOnStatusCode: false, + }); + expect(disableRes.status()).toBe(200); + + try { + // The connector iframe POSTs the verified assertion to the + // webauthn grant; for a disabled user it must come back 4xx, + // never a token. Asserting on the response (rather than a toast + // string) keeps the check robust against web-vault UI wording. + const tokenResponse = page.waitForResponse( + (r) => r.url().includes('/identity/connect/token') && r.request().method() === 'POST', + { timeout: 30_000 }, + ); + await clickLoginWithPasskey(page); + + // A verified assertion for a disabled account must be rejected by + // the grant (4xx) with no token issued. Asserting on the response + // (rather than a toast string or post-failure page layout) keeps + // the check robust against web-vault UI wording; with no token the + // SPA has nothing to unlock with, so the response is the oracle. + const res = await tokenResponse; + expect(res.status(), 'disabled-account passkey grant must be rejected').toBeGreaterThanOrEqual(400); + const body: any = await res.json(); + expect(body.access_token, 'disabled account must not receive an access token').toBeUndefined(); + } finally { + // Re-enable so the shared default-config vault instance is left + // clean for any subsequent test in this describe. + await request.post(`/admin/users/${adminUser.id}/enable`, { + headers: { 'Content-Type': 'application/json' }, + failOnStatusCode: false, + }); + } + }); }); diff --git a/playwright/tests/setups/2fa.ts b/playwright/tests/setups/2fa.ts index 7f60b5e8..87194066 100644 --- a/playwright/tests/setups/2fa.ts +++ b/playwright/tests/setups/2fa.ts @@ -12,7 +12,9 @@ import { openAvatarMenu, submitMasterPasswordVerification } from './user'; * - `mail2fa` — email OTP; the helper retrieves the code from `mailBuffer` * - `fido2` — WebAuthn-as-2FA (the bundled web vault labels this * provider "FIDO2 WebAuthn" in en_GB / "Passkey" in en). - * Currently unimplemented; `submitTwoFactor` throws. + * The connector iframe auto-fires WebAuthn on mount, so + * `submitTwoFactor` just waits for the resulting navigation + * (the caller must have a virtual authenticator attached). */ export type TwoFactor = | { kind: 'totp', totp: OTPAuth.TOTP } @@ -28,12 +30,29 @@ const PICKER_LABEL: Record = { fido2: /Passkey|FIDO2/i, }; +/** URL pattern the SPA lands on after a successful 2FA — vault, lock + * screen (PRF unlock required), or the install-extension nudge. Used to + * short-circuit `ensure2FAProvider` when the webauthn-connector iframe + * auto-fires and navigates before we get a chance to probe its DOM. */ +const POST_TWO_FACTOR_URL = /#\/(vault|setup-extension|lock)\b/; + /** If the page isn't already showing the input for the requested 2FA * kind (i.e. some other provider is the default), click "Select another * method" → the target provider row. No-op when the requested kind's * input is already visible (single-provider case, or it was already the - * default). */ + * default), or when the FIDO2 auto-fire has already completed and the + * page has navigated past `/#/2fa`. */ async function ensure2FAProvider(page: Page, kind: TwoFactor['kind']) { + // Auto-fire short-circuit: the webauthn-connector iframe runs the + // WebAuthn ceremony as soon as it mounts, and a virtual authenticator + // with auto-presence finishes it in milliseconds. By the time this + // helper runs the page may already have left `/#/2fa`, so waiting for + // the iframe or the picker would time out on UI that's no longer in + // the DOM. Bail before probing. + if (POST_TWO_FACTOR_URL.test(page.url())) { + return; + } + const probe = kind === 'fido2' ? page.locator('iframe[src*="webauthn-connector"]') : page.getByLabel(/Verification code/); @@ -44,9 +63,19 @@ async function ensure2FAProvider(page: Page, kind: TwoFactor['kind']) { // `/#/2fa` mount happens after a navigation chain) and a too-short // probe would race into the switcher path, which can collide with // the connector's auto-fire when the default is already FIDO2. - if (await probe.first().isVisible({ timeout: 5_000 }).catch(() => false)) { + // + // For FIDO2 specifically, the auto-fire can also complete *during* + // the mount-grace window — racing the probe against the post-2FA + // navigation catches both "iframe mounted" and "ceremony already + // finished" outcomes. + const visible = probe.first().waitFor({ state: 'visible', timeout: 5_000 }) + .then(() => true).catch(() => false); + const complete = page.waitForURL(POST_TWO_FACTOR_URL, { timeout: 5_000 }) + .then(() => true).catch(() => false); + if (await Promise.race([visible, complete])) { return; } + const switcherText = /Select another method|Need a different method/i; const switcher = page .getByRole('button', { name: switcherText })