Browse Source

playwright: disabled-account passkey login coverage

- passkey.spec.ts: add test exercising the post-verification
  `if !user.enabled` gate in webauthn_login with a cryptographically
  valid assertion. Complements the existing forged-handle test
  (pre-verification path) by driving a real enrolled credential through
  signature verification into the account-state gate; asserts on the
  grant response (4xx + no access_token) rather than UI wording so the
  check is robust against web-vault wording changes.
- 2fa.ts: rework `ensure2FAProvider`'s mount-grace probe to handle two
  problems with the previous `Locator.isVisible` check. First,
  `isVisible` is a one-shot check whose timeout only bounds the single
  resolve, so an early call falls through to the switcher path before
  the default input attaches. Second, the webauthn-connector iframe
  auto-fires the WebAuthn ceremony as soon as it mounts and a virtual
  authenticator with auto-presence completes it in milliseconds — so
  by the time the helper runs the page may already have navigated past
  `/#/2fa`, and both the iframe and the picker would time out on UI
  that's no longer in the DOM. Add a URL pre-check that short-circuits
  when the page has already left the 2FA route, and race the
  `waitFor({ state: 'visible' })` probe against `waitForURL` for the
  post-2FA landing so the helper catches both "iframe mounted" and
  "ceremony already finished mid-mount" outcomes. Refresh the fido2
  variant doc-comment to reflect that `submitTwoFactor` is now
  implemented.
pull/7297/head
Zaid Marji 2 weeks ago
parent
commit
e94b8556c8
  1. 92
      playwright/tests/passkey.spec.ts
  2. 35
      playwright/tests/setups/2fa.ts

92
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,
});
}
});
});

35
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<TwoFactor['kind'], RegExp> = {
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 })

Loading…
Cancel
Save