Browse Source

playwright: account lifecycle test + host-iteration config

Add a 23-step single-session lifecycle test covering every code path a
real PRF-passkey user exercises:

  register → enrol passkey #1 → enrol passkey #2 on a second virtual
  authenticator → log in with passkey → lock + unlock with passkey →
  register a second-device context + "Log in with device" approval flow
  → enrol WebAuthn-2FA + TOTP-2FA → log in with passkey (server skips
  2FA on webauthn grant) → log in with MP + WebAuthn-2FA → lock + unlock
  → remove passkey #1 → bump KDF iterations (auto-logout) → re-login
  with WebAuthn-2FA → rotate account encryption keys (auto-logout) →
  re-login with MP + TOTP-2FA → lock + unlock with passkey #2 → remove
  passkey #2 → log in with WebAuthn-2FA (post-login sync refreshes the
  client cache so the lock-screen assertion sees the credential-free
  state) → unlock with MP → disable both 2FA providers → log in with MP
  alone.

A sibling `account-lifecycle-sso` project runs the same 23-step
lifecycle under `SSO_ENABLED=true`. Login flows that previously typed
MP at the prompt go through Keycloak + the bundled "Unlock vault"
MP-decrypt step instead, exercising the SSO + WebAuthn-2FA + PRF
passkey composition the original MP-only project couldn't cover. The
body of the lifecycle is shared between both projects via mode-
dispatch (`modeOps(sso)`); CDP virtual-authenticator wrangling, passkey
enrol/remove, lock/unlock, KDF + MP rotation, and the second-device
auth-request flow are extracted into
`tests/setups/account_lifecycle_helpers.ts`.

Notable wire-shape coverage:
  * `userDecryption.webAuthnPrfOptions` is populated only after PRF
    enrolment and emptied after passkey removal.
  * Rotation re-wraps each PRF credential's stored
    encryptedUserKey/encryptedPrivateKey; passkey #2 still unlocks the
    rotated user key.
  * KDF change auto-logs out via security-stamp rotation.
  * "Log in with device" is gated on `isKnownDevice` in the bundled web
    vault — the test asserts the affordance is absent on a fresh
    second-device context and surfaces after that context's first MP
    login.

Reuses `logUser`/`submitTwoFactor` from `setups/`; the only spec-local
helpers are CDP-specific (virtual-authenticator creation,
`withAuthenticatorDisabled` callback wrapper) or test-local
expectations (`expectLockScreenButtons`, `expectPostEmailPageNoPasskey`,
etc.).

Supporting changes for SSO mode:

- `enterEmailOnLoginPage`: SSO branch fills `.vw-email-sso` and clicks
  "Other" to reveal the MP-continue flow — the standard email-label
  selector matches the SCSS-hidden `.vw-email-continue` input under
  `SSO_ENABLED=true`.
- `sso.ts#logUser`: accepts a separate `kcPassword` for cases where vault
  MP and the IdP credential diverge (post-MP-rotation); accepts either
  /#/lock or /#/vault after 2FA so PRF auto-unlock via the lock screen's
  `promptBiometric=true` redirect is tolerated; uses
  `name: 'Unlock', exact: true` to disambiguate from the
  "Unlock with passkey" affordance when PRF is enrolled. The
  "Join organi[sz]ation" heading match is locale-tolerant (en vs en_GB).
- `2fa.ts#submitTwoFactor`: post-2FA URL waiter accepts /#/lock too;
  TOTP submission tracks its own `last_used` time-step and waits for
  the next period boundary when a repeat would land on a consumed
  step, so consecutive TOTP submissions in the same period don't trip
  vw's `last_used > current` rejection.
- `2fa.ts#ensure2FAProvider`: 5s probe (was 1s) for the default
  provider's input before falling through to the picker — under SSO
  mode the extra Keycloak round-trip can delay the connector iframe
  mount enough to race the switcher.
- `global-utils.ts#cleanLanding`: swallow "navigation interrupted" /
  `net::ERR_ABORTED` from `page.goto('/')` — the bundled web vault's
  `/` → `/#/login` hash-route redirect occasionally fires while the
  initial nav is still resolving under docker's slower I/O.
- `global-utils.ts#startVault`/`dbConfig`: register
  `account-lifecycle-sso` as a sqlite-backed project.
- `user.ts#logUser`: accepts `kcPassword` for option-shape parity with
  `sso.ts#logUser`. Ignored in MP-only mode.
- `global-setup.ts`: short-circuits the docker-compose build when
  `PW_USE_EXTERNAL_VAULT=1` — host-iteration runs against a cargo-run
  vw don't need the multi-minute release rebuild.

In SSO mode, login-with-passkey is left as MP-mode-only coverage (the
SSO project skips the two wire-shape probes for the same reason); the
two smaller tests check the same server endpoint shape and are mode-
invariant. The SSO lifecycle uses TOTP-2FA with
`withAuthenticatorDisabled` at the MP-fresh-required logins so the
lock screen waits for manual MP entry instead of auto-firing PRF
unlock.

Plumbing changes that come with this spec:
  * `playwright.config.ts` threads
    `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` into the `account-lifecycle`
    project's `launchOptions.executablePath` so the spec can run
    locally against a host-running Vaultwarden on systems where
    `npx playwright install chromium` is unsupported.
  * `compose/playwright/Dockerfile` installs Chromium alongside Firefox
    so the docker harness can run the `account-lifecycle` project too.
  * `account_lifecycle.spec.ts` honors `PW_USE_EXTERNAL_VAULT=1` to skip
    the docker startVault/stopVault hooks (host-mode iteration only; CI
    leaves it unset).

Run requires `LOGIN_RATELIMIT_MAX_BURST` raised above the 10/60s
default — multiple `connect/token` POSTs during the auth-request
approval + back-to-back 2FA cycles exhaust the limit otherwise.
Runtime: ~1.5min MP-mode, ~2min SSO-mode in docker; both well under
the 180s test budget.
pull/7297/head
Zaid Marji 2 weeks ago
parent
commit
6522923cdb
  1. 6
      playwright/global-setup.ts
  2. 17
      playwright/global-utils.ts
  3. 33
      playwright/playwright.config.ts
  4. 514
      playwright/tests/account_lifecycle.spec.ts
  5. 38
      playwright/tests/setups/2fa.ts
  6. 463
      playwright/tests/setups/account_lifecycle_helpers.ts
  7. 37
      playwright/tests/setups/sso.ts
  8. 4
      playwright/tests/setups/user.ts

6
playwright/global-setup.ts

@ -7,6 +7,12 @@ const utils = require('./global-utils');
utils.loadEnv();
async function globalSetup(config: FullConfig) {
// PW_USE_EXTERNAL_VAULT=1 points the spec at a Vaultwarden the operator
// is already running (host-side cargo, a remote dev box, etc.). The
// docker harness isn't used, so skipping the multi-minute image
// build keeps the local-iteration loop short.
if (process.env.PW_USE_EXTERNAL_VAULT === '1') return;
// Are we running in docker and the project is mounted ?
const path = (fs.existsSync("/project/playwright/playwright.config.ts") ? "/project/playwright" : ".");
execSync(`docker compose --project-directory ${path} --profile playwright --env-file test.env build VaultwardenPrebuild`, {

17
playwright/global-utils.ts

@ -166,6 +166,7 @@ function dbConfig(testInfo: TestInfo){
case "sqlite":
case "sso-sqlite":
case "account-lifecycle":
case "account-lifecycle-sso":
return { I_REALLY_WANT_VOLATILE_STORAGE: true };
default:
throw new Error(`Unknow database name: ${testInfo.project.name}`);
@ -193,6 +194,7 @@ export async function startVault(browser: Browser, testInfo: TestInfo, env = {},
case "sqlite":
case "sso-sqlite":
case "account-lifecycle":
case "account-lifecycle-sso":
wipeSqlite();
break;
default:
@ -233,7 +235,20 @@ export async function checkNotification(page: Page, hasText: string) {
}
export async function cleanLanding(page: Page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
// The bundled web vault redirects `/` → `/#/login` via Angular's
// hash router; under docker's slower I/O that redirect occasionally
// fires while `page.goto('/')` is still resolving. Two surface forms
// for the same race: "Navigation interrupted by another navigation"
// (Playwright wording) or `net::ERR_ABORTED` (Chromium netstack).
// Both leave the page on `/#/login`, which is what every caller
// expects — swallow them and let the visibility assertion below pin
// the final state.
try {
await page.goto('/', { waitUntil: 'domcontentloaded' });
} catch (e: any) {
const msg = String(e?.message ?? '');
if (!msg.includes('interrupted by another navigation') && !msg.includes('ERR_ABORTED')) throw e;
}
await expect(page.getByRole('button').nth(0)).toBeVisible();
const logged = await page.getByRole('button', { name: 'Log out' }).count();

33
playwright/playwright.config.ts

@ -109,6 +109,39 @@ export default defineConfig({
use: {
browserName: 'chromium',
locale: 'en',
launchOptions: {
// Local-iteration knob: when set, point Playwright at a
// non-bundled Chromium binary. The docker harness has the
// bundled Chromium (1194) baked into the image; on a host
// where Playwright's `install chromium` is unsupported
// (e.g. Ubuntu 26.04), set this env var to your system
// Chromium so `npx playwright test --project=account-lifecycle`
// can run locally against an external Vaultwarden.
...(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
? { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH }
: {}),
},
},
},
{
// SSO variant of the account lifecycle. Same spec file, same
// launch config; differs in `dependencies: ['sso-setup']` (brings
// up Keycloak before the test runs) and `account_lifecycle.spec.ts`
// detects the project name to switch its `beforeAll` env to
// `SSO_ENABLED=true SSO_ONLY=false` and its login choreography
// to SSO + MP-unlock.
name: 'account-lifecycle-sso',
testMatch: 'tests/account_lifecycle.spec.ts',
dependencies: ['sso-setup'],
use: {
browserName: 'chromium',
locale: 'en',
launchOptions: {
...(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
? { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH }
: {}),
},
},
},

514
playwright/tests/account_lifecycle.spec.ts

@ -1,7 +1,29 @@
import { test, expect, type Page, type TestInfo } from '@playwright/test';
import * as utils from '../global-utils';
import { createAccount } from './setups/user';
import { createAccount, logUser as logUserMP } from './setups/user';
import { logNewUser as ssoLogNewUser, logUser as logUserSSO } from './setups/sso';
import { activateTOTP, disableTOTP, type TwoFactor } from './setups/2fa';
import {
addVirtualAuthenticator,
changeKdfIterations,
changeMasterPassword,
clickLoginWithPasskey,
createNewDevice,
disableWebauthn2FA,
enrollLoginPasskey,
enrollWebauthn2FA,
enterEmailOnLoginPage,
expectLockScreenButtons,
lockVault,
loginWithDeviceAndApprove,
removeLoginPasskey,
resetVirtualAuthenticators,
type Test,
unlockWithMP,
unlockWithPasskey,
withAuthenticatorDisabled,
} from './setups/account_lifecycle_helpers';
/**
* End-to-end coverage of "Log in with passkey" enrolment, driven via the web
@ -11,92 +33,140 @@ import { createAccount } from './setups/user';
* `/api/sync` `userDecryption.webAuthnPrfOptions` is a plural array, always
* present, populated exactly with PRF-enabled login passkeys.
*
* Enrolment is driven through the real UI (the `Turn on` flow under Settings
* Security Master password). The post-enrolment `/api/sync` is then
* called directly from the page context with a sniffed bearer token, since
* the web vault aggressively caches sync state in IndexedDB and won't
* re-fetch on hash nav or reload.
* The lifecycle test is **parameterised by project**: it runs once under
* `account-lifecycle` (MP-mode login flows) and once under `account-lifecycle-sso`
* (SSO + MP-unlock flows). The two projects differ only in their
* `dependencies` (SSO depends on `sso-setup` to bring up Keycloak) and the
* `SSO_ENABLED` env passed to Vaultwarden. The lifecycle body itself is
* shared, with mode-specific branches limited to login choreography
* (sign-up, sign-in, affordance assertions).
*
* Runs only under the `account-lifecycle` project (Chromium + `en` locale + SQLite
* volatile), defined in `playwright.config.ts`. The CDP virtual authenticator
* with the `hmac-secret` (PRF) extension is Chromium-only; the test would
* fail immediately on Firefox.
* The two smaller tests (PRF-enrolment-populates-sync and
* no-PRF-leaves-sync-empty) use the MP registration path and are
* skipped under the SSO project they probe server wire shape, which
* is mode-invariant, so the MP-mode coverage is sufficient.
*
* The CDP virtual authenticator with the `hmac-secret` (PRF) extension is
* Chromium-only; both projects override `browserName: 'chromium'` and
* `locale: 'en'` (the bundled web vault renders different labels for the
* WebAuthn provider row under `en_GB`).
*/
utils.loadEnv();
let users = utils.loadEnv();
// Defence-in-depth: even if someone runs this spec under a non-`account-lifecycle`
// Defence-in-depth: even if someone runs this spec under a non-`account-lifecycle*`
// project, fail closed rather than crash on the CDP call.
test.skip(
({ browserName }) => browserName !== 'chromium',
'requires Chromium CDP virtual authenticator with hmac-secret/PRF',
);
// `PW_USE_EXTERNAL_VAULT=1` skips the docker startVault/stopVault hooks and
// runs the spec against whatever Vaultwarden is already serving on $DOMAIN.
// Local-iteration knob only; CI and the standard docker harness leave it
// unset and bring the vault up via docker compose as usual.
const useExternalVault = process.env.PW_USE_EXTERNAL_VAULT === '1';
function isSSOMode(testInfo: TestInfo): boolean {
return testInfo.project.name === 'account-lifecycle-sso';
}
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, {});
if (useExternalVault) return;
const env = isSSOMode(testInfo)
? { SSO_ENABLED: true, SSO_ONLY: false }
: {};
await utils.startVault(browser, testInfo, env);
});
test.afterAll('Teardown', async () => {
if (useExternalVault) return;
utils.stopVault();
});
// CDP sessions are bound to a specific Page; Playwright recycles the page
// between tests, so drop the cached session/authenticator IDs each time
// (the next `addVirtualAuthenticator` lazily re-establishes them).
test.beforeEach(() => resetVirtualAuthenticators());
const MP = 'Master Password';
const AUTHENTICATOR_OPTIONS = {
protocol: 'ctap2' as const,
ctap2Version: 'ctap2_1' as const,
transport: 'internal' as const,
hasResidentKey: true,
hasUserVerification: true,
hasPrf: true,
automaticPresenceSimulation: true,
isUserVerified: true,
defaultBackupEligibility: false,
defaultBackupState: false,
};
async function addVirtualAuthenticator(page: Page) {
const cdp = await page.context().newCDPSession(page);
await cdp.send('WebAuthn.enable');
await cdp.send('WebAuthn.addVirtualAuthenticator', { options: AUTHENTICATOR_OPTIONS });
type LifecycleUser = { email: string; name: string; password: string };
interface ModeOps {
/** Create the user account in vaultwarden. */
signUp(test: Test, page: Page, user: LifecycleUser): Promise<void>;
/**
* Log in an existing user. `options.twoFactor` selects a 2FA factor
* if enrolled. `options.kcPassword` overrides the Keycloak password
* (SSO mode only) for cases where vault MP and the SSO-provider
* credential have diverged e.g. after a vault-side MP rotation
* which leaves Keycloak's stored credential unaffected.
*/
signIn(test: Test, page: Page, user: LifecycleUser, options?: { twoFactor?: TwoFactor, kcPassword?: string }): Promise<void>;
}
function modeOps(sso: boolean): ModeOps {
return sso
? { signUp: ssoLogNewUser, signIn: logUserSSO }
: { signUp: createAccount, signIn: logUserMP };
}
async function enrollLoginPasskey(
page: Page,
mp: string,
credentialName: string,
{ useForEncryption }: { useForEncryption: boolean },
) {
await page.goto('/#/settings/security/password');
await page.waitForLoadState('networkidle');
// "Turn on" button's accessible name is "Turn on Log in with passkey".
await page.locator('button:has-text("Turn on")').click();
const mpInput = page.locator('input#masterPassword');
await mpInput.waitFor({ state: 'visible' });
await mpInput.fill(mp);
// Two `Continue` buttons coexist on this page; pressing Enter inside the
// password input submits the dialog form unambiguously.
await mpInput.press('Enter');
// The dialog re-renders with a `name` input + `useForEncryption` checkbox
// once the credential is created.
const nameInput = page.locator('input[formcontrolname="name"]');
await nameInput.waitFor({ state: 'visible' });
await nameInput.fill(credentialName);
// The `useForEncryption` checkbox is default-checked in the bundled web
// vault, so the disabled case has to set it explicitly.
const prfToggle = page.locator('input[formcontrolname="useForEncryption"]');
if (useForEncryption) {
await prfToggle.check();
/** Lifecycle user. MP mode synthesises a fresh per-run identity to keep the
* SQLite-volatile assumption from leaking on the off-chance a prior project
* ran without a DB wipe. SSO mode locks to the Keycloak-seeded `user1`
* (`test@example.com` / `test`) those credentials are pre-provisioned in
* the `test` realm by `compose/keycloak/setup.sh`. */
function lifecycleUser(sso: boolean): LifecycleUser {
if (sso) {
// `loadEnv()` types every field as `string | undefined` because it
// reads through `process.env`; the SSO fields are seeded by the
// `compose/keycloak/setup.sh` provisioning and are non-null in any
// env that actually runs this project — assert that, then return.
const { email, name, password } = users.user1;
if (!email || !name || !password) {
throw new Error('SSO lifecycle requires TEST_USER_MAIL/TEST_USER/TEST_USER_PASSWORD in test.env');
}
return { email, name, password };
}
return {
email: `e2e-lifecycle-${Date.now()}@example.com`,
name: 'Lifecycle E2E',
password: MP,
};
}
/**
* Negative+positive assertion suite for the unauthenticated /#/login page.
* "Log in with passkey" is always advertised; "Unlock with passkey" must
* never bleed onto the login page. "Use single sign-on" presence flips on
* `sso` present when `SSO_ENABLED=true`, absent otherwise.
*/
async function expectLoginPageButtons(page: Page, sso: boolean) {
await expect(page.getByRole('button', { name: /Log in with passkey/i })).toBeVisible();
if (sso) {
await expect(page.getByRole('button', { name: 'Use single sign-on' })).toBeVisible();
} else {
await prfToggle.uncheck();
await expect(page.getByRole('button', { name: 'Use single sign-on' })).toHaveCount(0);
}
await expect(page.getByRole('button', { name: /Unlock with passkey/i })).toHaveCount(0);
}
await page.locator('bit-dialog button[type="submit"]:has-text("Turn on")').click();
await expect(page.locator('bit-dialog')).toHaveCount(0);
/**
* Post-email login page when no passkey is enrolled and the current device
* IS already registered server-side. MP-login + "Log in with device" are
* offered; passkey/unlock affordances stay absent. SSO mode only changes
* the pre-email page (the SSO button + email-SSO input live there); the
* post-email page is the MP flow either way.
*/
async function expectPostEmailPageNoPasskey(page: Page, sso: boolean) {
await expect(page.getByRole('button', { name: 'Log in with master password' })).toBeVisible();
await expect(page.getByRole('button', { name: /Log in with device/i })).toBeVisible();
await expect(page.getByRole('button', { name: /Log in with passkey/i })).toHaveCount(0);
if (!sso) {
await expect(page.getByRole('button', { name: 'Use single sign-on' })).toHaveCount(0);
}
await expect(page.getByRole('button', { name: /Unlock with passkey/i })).toHaveCount(0);
}
/**
@ -124,12 +194,14 @@ async function fetchSyncWithToken(page: Page, token: string): Promise<any> {
return { status: res.status, body: await res.text() };
}, token);
if (result.status !== 200) {
throw new Error(`/api/sync returned ${result.status}: ${result.body.slice(0, 200)}`);
throw new Error(`/api/sync ${result.status}: ${result.body}`);
}
return JSON.parse(result.body);
}
test('Log in with passkey: PRF enrolment populates webAuthnPrfOptions in /api/sync', async ({ page }) => {
test('Log in with passkey: PRF enrolment populates webAuthnPrfOptions in /api/sync', async ({ page }, testInfo) => {
test.skip(isSSOMode(testInfo), 'wire-shape probe; MP coverage is sufficient');
// End-to-end proof that the lock-screen "Unlock with passkey" affordance
// has its server-side prerequisite. The web vault renders the button when
// `userDecryption.webAuthnPrfOptions` is non-empty in /sync; without the
@ -156,24 +228,32 @@ test('Log in with passkey: PRF enrolment populates webAuthnPrfOptions in /api/sy
// PascalCase: Bitwarden API responses keep model casing. The lock-screen
// option reads these wrapped-key blobs to derive the user key after the
// PRF assertion.
// PRF assertion. Matches the shape upstream
// `SyncResponseModel.UserDecryption.WebAuthnPrfOptions` returns
// (`bitwarden/server` `UserDecryptionResponseModel.cs`):
// `EncryptedPrivateKey`, `EncryptedUserKey`, `CredentialId`,
// `Transports`. The public key isn't part of the unlock payload —
// PRF unwrap only needs the private-key / user-key blobs.
const option = sync.userDecryption.webAuthnPrfOptions[0];
expect(option).toHaveProperty('EncryptedPrivateKey');
expect(option).toHaveProperty('EncryptedUserKey');
expect(option).toHaveProperty('CredentialId');
expect(typeof option.CredentialId, 'CredentialId must be a string').toBe('string');
expect(typeof option.EncryptedPrivateKey, 'EncryptedPrivateKey must be a string').toBe('string');
expect(typeof option.EncryptedUserKey, 'EncryptedUserKey must be a string').toBe('string');
});
test('Log in with passkey: enrolment without PRF leaves webAuthnPrfOptions empty', async ({ page }) => {
// The complementary case: a registered login passkey that is NOT
// PRF-enabled (the `useForEncryption` checkbox left unticked) must not
// appear in `webAuthnPrfOptions`. Together with the test above this pins
// both branches of the emission filter.
test('Log in with passkey: enrolment without PRF leaves webAuthnPrfOptions empty', async ({ page }, testInfo) => {
test.skip(isSSOMode(testInfo), 'wire-shape probe; MP coverage is sufficient');
// Mirror of the above: a passkey enrolled WITHOUT `useForEncryption`
// must produce an empty (but still present) `webAuthnPrfOptions`
// array. The web vault uses this signal to keep the lock-screen
// "Unlock with passkey" button hidden — a non-PRF passkey can be used
// for the login ceremony but can't decrypt the user key.
await addVirtualAuthenticator(page);
const bearer = attachBearerSniffer(page);
const user = {
email: `e2e-noprf-sync-${Date.now()}@example.com`,
name: 'No-PRF Sync E2E',
name: 'NoPRF Sync E2E',
password: MP,
};
@ -187,3 +267,287 @@ test('Log in with passkey: enrolment without PRF leaves webAuthnPrfOptions empty
expect(Array.isArray(sync.userDecryption.webAuthnPrfOptions)).toBe(true);
expect(sync.userDecryption.webAuthnPrfOptions).toEqual([]);
});
/**
* Comprehensive account lifecycle on a single Chromium session covers
* every code path that a real user actually exercises. Both passkeys are
* enrolled while MP is "fresh" (right after registration / MP login), so the
* web vault's user-verification gate uses the MP path; it would otherwise
* fall back to email-OTP, which would force a maildev round-trip.
*
* 1. register / SSO sign-up vault
* 2. enrol PRF passkey #1 (MP fresh from registration)
* 3. enrol PRF passkey #2 (MP still fresh)
* 4. log out, log back in *with passkey* (iframe ceremony in main page)
* 5. lock vault unlock with passkey the reported feature
* 6. register a second browser context's device via one-shot
* MP login (MP mode) or SSO + MP unlock (SSO mode) before the
* login, "Log in with device" must NOT surface (device unknown);
* after login + logout it MUST appear
* 7. from the second device, "Log in with device" + approve from the
* original context's "Review login request" banner
* 8. enrol WebAuthn-as-2FA + TOTP-as-2FA (MP fresh from step 5)
* 9. log out, log back in with passkey server skips 2FA on webauthn grant
* 10. log out, log back in with MP + WebAuthn-2FA (or SSO + WebAuthn-2FA + MP-unlock)
* 11. lock + unlock with passkey (both credentials still wrap the user key)
* 12. remove passkey #1
* 13. bump KDF iteration count (auto-logs out)
* 14. log back in with WebAuthn-2FA after KDF auto-logout
* 15. rotate account encryption keys (auto-logs out)
* 16. log back in with TOTP-2FA after rotation auto-logout
* 17. lock + unlock with passkey #2 (rotated/re-KDF'd key still PRF-unlocks)
* 18. remove passkey #2 back to no PRF unlock; lock screen shows MP only
* 19. log out, log back in with WebAuthn-2FA (refreshes client-side sync
* cache so the step-20 lock-screen assertion sees credential-free state)
* 20. lock + assert no passkey unlock affordance
* 21. unlock with master password (sanity before disabling 2FA)
* 22. disable both 2FA providers
* 23. log out, log back in no 2FA challenge
*
* Every WebAuthn ceremony primary (login-with-passkey) and secondary
* (WebAuthn-as-2FA) runs inside the same-origin /webauthn-connector.html
* iframe; the CDP-injected virtual authenticator satisfies them across the
* iframe boundary in current Chromium. Unlock-with-passkey runs
* navigator.credentials.get() in the main frame.
*/
test('Log in with passkey: lifecycle — enrol, login (PRF/MP), lock, unlock, 2FA, rotate, remove', async ({ page }, testInfo) => {
// 23 steps drive a full account lifecycle on a single Chromium session.
// Multi-second cost contributors that push past the default 120s budget:
// • client-side key rewrap during MP rotation + KDF bump (~10s each)
// • a fresh BrowserContext + login + auth-request approval flow (step 7)
// • lock/unlock/relogin cycles + 2FA enrolment + teardown
// Host-mode MP runs ~115s; 180s leaves headroom for docker and CI. If
// the SSO variant's Keycloak round-trips push real runs past ~150s,
// bump this; for now both modes land comfortably under.
test.setTimeout(180_000);
const sso = isSSOMode(testInfo);
const ops = modeOps(sso);
const user = lifecycleUser(sso);
// SSO mode tracks the Keycloak credential separately from the vault
// MP. They start equal (Keycloak's seeded password = the MP set
// during "Join organization"), but step 15 below rotates the MP
// server-side without touching Keycloak. After that point, SSO
// signIns must use this original value as `kcPassword` while
// `user.password` continues to reflect the rotated MP. MP mode
// ignores this entirely.
const kcPassword = user.password;
const first = 'lifecycle-key-1';
const second = 'lifecycle-key-2';
await addVirtualAuthenticator(page);
// 1. Register → vault.
await ops.signUp(test, page, user);
// 2. Enrol PRF passkey #1 (MP fresh from registration).
await enrollLoginPasskey(page, user.password, first, { useForEncryption: true });
// 3. Enrol PRF passkey #2 on a SECOND virtual authenticator. The server
// passes the existing credential in `excludeCredentials`; the first
// authenticator would refuse `credentials.create()` because it already
// holds a matching credential. Simulates a user adding a second device
// to their account.
await addVirtualAuthenticator(page);
await enrollLoginPasskey(page, user.password, second, { useForEncryption: true });
// 4. Log out, log back in WITH PASSKEY. Negative-assert the login-page
// affordances before clicking, so a regression that re-introduces
// (in MP mode) or hides (in SSO mode) the SSO button is caught here.
await utils.logout(test, page, user);
await utils.cleanLanding(page);
await expectLoginPageButtons(page, sso);
await clickLoginWithPasskey(page);
await expect(page).toHaveURL(/\/(vault|setup-extension)/, { timeout: 30_000 });
// 5. Lock vault, unlock with passkey. Assert the lock screen renders BOTH
// the passkey-unlock affordance and the MP one (and nothing else
// inappropriate).
await lockVault(page, user.name);
await expectLockScreenButtons(page, true);
await unlockWithPasskey(page);
// 6. On a fresh browser context (a "second device" from the server's
// perspective), register the device with a one-shot login. The bundled
// web vault gates "Log in with device" on `isKnownDevice` server-side,
// so before this login the button is absent on the post-email page;
// after it (and logout) the button surfaces.
const secondDevice = await createNewDevice(page);
await enterEmailOnLoginPage(secondDevice, user.email, { sso });
await expect(secondDevice.getByRole('button', { name: 'Log in with master password' })).toBeVisible();
await expect(secondDevice.getByRole('button', { name: /Log in with device/i })).toHaveCount(0);
await ops.signIn(test, secondDevice, user);
await utils.logout(test, secondDevice, user);
// 7. Device is now known. Click "Log in with device" — POSTs
// /api/auth-requests. The original context (still on /vault)
// surfaces the "Review login request" banner and confirms. 2FA
// isn't enrolled yet, so the secondDevice lands directly in /vault.
await enterEmailOnLoginPage(secondDevice, user.email, { sso });
await loginWithDeviceAndApprove(secondDevice, page);
await secondDevice.context().close();
// 8. Enrol WebAuthn-as-2FA + TOTP-as-2FA (MP still fresh from step 5).
await enrollWebauthn2FA(page, user.password, 'lifecycle-2fa-key');
const totp = await activateTOTP(test, page, user);
// 9. Log out, log in with passkey — the passkey IS the auth, so the
// server skips the 2FA challenge even though TOTP + WebAuthn-2FA are
// enabled. Assert on the grant RESPONSE (200 + access token, no
// TwoFactorProviders) rather than the landing URL: a regression that
// wrongly demanded a second factor would route through /#/2fa, where
// the still-attached virtual authenticator could auto-satisfy
// WebAuthn-2FA and mask the failure behind a /vault landing. The
// response check inspects the first grant, before any such detour.
await utils.logout(test, page, user);
const passkeyGrant = page.waitForResponse(
(r) => r.url().includes('/identity/connect/token') && r.request().method() === 'POST',
{ timeout: 30_000 },
);
await clickLoginWithPasskey(page);
const passkeyGrantRes = await passkeyGrant;
expect(passkeyGrantRes.status(), 'passkey grant must issue a token in one shot').toBe(200);
const passkeyGrantBody: any = await passkeyGrantRes.json();
expect(passkeyGrantBody.access_token, 'passkey grant must return an access token').toBeTruthy();
expect(passkeyGrantBody.TwoFactorProviders, 'passkey grant must not demand a second factor').toBeUndefined();
// With a token issued in one shot the PRF secret unlocks the vault inline,
// so the SPA lands directly in /vault without a 2FA detour.
await expect(page).toHaveURL(/\/(vault|setup-extension)/, { timeout: 30_000 });
// Mode-aware sign-in that guarantees MP is fresh afterwards. MP mode
// types MP at login, so MP is naturally fresh. SSO mode auths via
// Keycloak and then routes through `/#/lock?promptBiometric=true`;
// with a PRF passkey enrolled and the virtual authenticator available,
// the lock screen auto-fires `credentials.get()` and the user lands
// on /vault without ever typing MP — leaving subsequent MP-gated
// user-verification gates to fall back to email-OTP instead. Disable
// the authenticator across the SSO sign-in (and pick TOTP 2FA, since
// FIDO2 2FA would need the authenticator too) so the lock screen
// waits for manual MP entry and MP-fresh state survives.
async function signInFreshMp() {
if (sso) {
await withAuthenticatorDisabled(async () => {
await ops.signIn(test, page, user, { twoFactor: { kind: 'totp', totp } });
});
} else {
await ops.signIn(test, page, user, { twoFactor: { kind: 'fido2' } });
}
}
// 10. Log out, log in (mode-appropriate) + 2FA. MP mode tests
// FIDO2 + MP combo; SSO mode uses TOTP under
// withAuthenticatorDisabled to force manual MP unlock (see
// `signInFreshMp`).
await utils.logout(test, page, user);
await signInFreshMp();
// 11. Lock + unlock — both PRF credentials still wrap the user key.
await lockVault(page, user.name);
await expectLockScreenButtons(page, true);
await unlockWithPasskey(page);
// 12. Remove passkey #1 (MP fresh from step 10).
await removeLoginPasskey(page, user.password, first);
// 13. Bump KDF iterations. Auto-logs out all sessions (security stamp
// rotates); we re-login below. The form lives under Settings →
// Security → Keys ("Encryption key settings"); add 10k to the
// default to force a non-noop change.
await changeKdfIterations(page, user.password, 610_000);
// 14. Re-login after KDF auto-logout (mode-appropriate). MP mode
// picks FIDO here (vs TOTP) to avoid stamping a TOTP `last_used`
// step that step 16's TOTP submission could collide with — the
// rotation rewrap between 14 and 16 is well under 30s. SSO mode
// uses TOTP under withAuthenticatorDisabled (see `signInFreshMp`);
// it stamps `last_used` at step 14, but step 16 below regenerates
// a fresh TOTP code for the next period boundary, so no collision.
await signInFreshMp();
// 15. Rotate the account encryption keys (re-wraps each PRF credential's
// stored encryptedUserKey/encryptedPrivateKey using the existing PRF
// output for that credential, so passkey #2 must still unlock).
//
// The bundled web vault's "Change master password" form rejects
// `new == current`, so rotation requires picking a new MP. Mutate
// `user.password` so every subsequent step uses the rotated value
// without rethreading the variable through 8 more calls. The
// rotation also auto-logs-out, so the next step picks up from
// /#/login.
const rotatedMp = `${user.password}!`;
await changeMasterPassword(page, user.password, rotatedMp, true);
user.password = rotatedMp;
// 16. Log back in (mode-appropriate) + TOTP (re-establishes fresh MP
// for the upcoming remove-passkey + disable-2FA verification gates).
// The WebAuthn-2FA connector iframe on /#/2fa would otherwise
// auto-fire and race past the picker; the wrapper disables the
// virtual authenticators for the duration of the login. SSO mode
// additionally pins `kcPassword` to the original Keycloak
// credential — the MP rotated above does not propagate to the
// SSO provider.
await withAuthenticatorDisabled(async () => {
await ops.signIn(test, page, user, { twoFactor: { kind: 'totp', totp }, kcPassword });
});
// 17. Lock + unlock — passkey #2 still wraps the (rotated, re-KDF'd) user key.
await lockVault(page, user.name);
await expectLockScreenButtons(page, true);
await unlockWithPasskey(page);
// 18. Remove passkey #2. The bundled web vault caches
// `userDecryption.webAuthnPrfOptions` from sync and does NOT
// auto-refresh after a credential delete; step 19 (log out + log
// in with WebAuthn-2FA) below naturally triggers a fresh
// post-login sync that writes the credential-free state to
// client cache, so we do that before the lock-screen assertion
// in step 20.
await removeLoginPasskey(page, user.password, second);
// 19. Log out + log back in (mode-appropriate). The post-login sync
// refreshes the bundled web vault's cached `webAuthnPrfOptions`
// so the next lock-screen check sees the credential-free state.
// MP mode uses FIDO2-2FA for symmetry with the earlier MP-only
// coverage. SSO mode uses TOTP under withAuthenticatorDisabled:
// the 2FA picker dialog races with the WebAuthn-connector
// iframe's auto-fire under SSO mode's extra Keycloak round-trip
// latency (the dialog can transition while the test is in the
// middle of clicking through to the FIDO2 row, detaching the
// target element). TOTP sidesteps the picker entirely. SSO mode
// also pins `kcPassword` — post-rotation, the IdP credential
// remains the original.
await utils.logout(test, page, user);
if (sso) {
await withAuthenticatorDisabled(async () => {
await ops.signIn(test, page, user, { twoFactor: { kind: 'totp', totp }, kcPassword });
});
} else {
await ops.signIn(test, page, user, { twoFactor: { kind: 'fido2' } });
}
// 20. Lock + assert the passkey-unlock button is gone (only MP
// unlock remains now that passkey #2 has been removed).
await lockVault(page, user.name);
await expectLockScreenButtons(page, false);
// 21. Unlock with master password to re-enter the vault — passkey unlock
// is no longer available so the user must use MP. This is a sanity
// step before disabling the 2FA factors below.
await unlockWithMP(page, user.password);
// 22. Disable both 2FA providers.
await disableWebauthn2FA(page, user.password);
await disableTOTP(test, page, user);
// 23. Log out, log back in (mode-appropriate) — no 2FA challenge. Pin
// the post-email baseline before completing the login (in MP mode
// that's the only path; in SSO mode the post-email page is reached
// after the user picks the MP-flow entry).
await utils.logout(test, page, user);
if (!sso) {
await utils.cleanLanding(page);
await enterEmailOnLoginPage(page, user.email);
await expectPostEmailPageNoPasskey(page, sso);
}
await ops.signIn(test, page, user, { kcPassword });
});

38
playwright/tests/setups/2fa.ts

@ -37,7 +37,14 @@ async function ensure2FAProvider(page: Page, kind: TwoFactor['kind']) {
const probe = kind === 'fido2'
? page.locator('iframe[src*="webauthn-connector"]')
: page.getByLabel(/Verification code/);
if (await probe.first().isVisible({ timeout: 1_000 }).catch(() => false)) {
// Give the bundled web vault a few seconds to mount the default
// provider's input before deciding a picker switch is needed. The
// webauthn-connector iframe in particular can take a moment longer
// to attach under SSO mode (extra Keycloak round-trip means the
// `/#/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)) {
return;
}
const switcherText = /Select another method|Need a different method/i;
@ -64,7 +71,14 @@ async function ensure2FAProvider(page: Page, kind: TwoFactor['kind']) {
*
* For TOTP, the code is generated for the *next* period boundary to avoid
* server-side expiry races when the test submits near a 30-second tick.
* The helper also remembers the last-used time-step (module-scoped) and
* waits for the next period boundary if a repeat submission would land
* on a time-step the server has already consumed its `last_used`
* tracking rejects equal-or-earlier time-steps even when the code is
* arithmetically valid.
*/
let lastSubmittedTotpTimeStep: number | null = null;
export async function submitTwoFactor(test: Test, page: Page, twoFactor: TwoFactor): Promise<void> {
await test.step(`Submit 2FA (${twoFactor.kind})`, async () => {
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
@ -72,8 +86,19 @@ export async function submitTwoFactor(test: Test, page: Page, twoFactor: TwoFact
switch (twoFactor.kind) {
case 'totp': {
const { totp } = twoFactor;
const nowSec = Math.floor(Date.now() / 1000);
const timestamp = (nowSec + totp.period - (nowSec % totp.period) + 1) * 1000;
let nowSec = Math.floor(Date.now() / 1000);
let timestamp = (nowSec + totp.period - (nowSec % totp.period) + 1) * 1000;
let timeStep = Math.floor(timestamp / 1000 / totp.period);
if (lastSubmittedTotpTimeStep !== null && timeStep <= lastSubmittedTotpTimeStep) {
// Server's `last_used` would reject this code — sleep
// until the next period boundary and recompute.
const waitMs = (totp.period - (nowSec % totp.period) + 1) * 1000;
await page.waitForTimeout(waitMs);
nowSec = Math.floor(Date.now() / 1000);
timestamp = (nowSec + totp.period - (nowSec % totp.period) + 1) * 1000;
timeStep = Math.floor(timestamp / 1000 / totp.period);
}
lastSubmittedTotpTimeStep = timeStep;
await page.getByLabel(/Verification code/).fill(totp.generate({ timestamp }));
await page.getByRole('button', { name: 'Continue' }).click();
break;
@ -87,7 +112,12 @@ export async function submitTwoFactor(test: Test, page: Page, twoFactor: TwoFact
case 'fido2':
break;
}
await expect(page).toHaveURL(/\/(vault|setup-extension)/, { timeout: 30_000 });
// MP login + 2FA lands the user in /vault directly (MP unwrapped the
// user key at login). SSO + 2FA lands on /#/lock — the IdP doesn't
// carry the unwrap secret, so the SPA routes to the lock screen
// for MP/passkey unlock. Accept both so callers in either mode can
// pin their own post-2FA assertion.
await expect(page).toHaveURL(/#\/(vault|setup-extension|lock)\b/, { timeout: 30_000 });
});
}

463
playwright/tests/setups/account_lifecycle_helpers.ts

@ -0,0 +1,463 @@
/**
* Shared helpers for the account-lifecycle specs (MP-mode and SSO-mode
* lifecycles parameterised across `account-lifecycle` and `account-lifecycle-sso`
* Playwright projects). Mode-agnostic: nothing here knows whether the
* test signs the user in via master password or SSO. Login choreography
* is owned by the spec; these helpers cover the bits that don't vary:
*
* CDP virtual-authenticator wrangling (with `autoPresence` toggle).
* Login-passkey enrolment + removal (Settings Security Master
* password).
* WebAuthn-as-2FA enrolment + disable (Settings Security
* Two-step login).
* Lock vault + unlock helpers (passkey, MP).
* MP change + KDF iterations bump.
* Lock-screen-affordance baseline assertion.
* Fresh-context spawn + "Log in with device" + approve flow.
*
* Call `resetVirtualAuthenticators()` from a `test.beforeEach` so the
* module-scoped CDP session cache is dropped between tests in the same
* file (Playwright recycles the page each test, and a stale session
* would crash the next `send()`).
*/
import { test, expect, type CDPSession, type Page } from '@playwright/test';
import * as utils from '../../global-utils';
// `Test` is exported as a value in the playwright runtime but not in the
// .d.ts namespace; mirror the alias the other setups files use.
export type Test = typeof test;
export const AUTHENTICATOR_OPTIONS = {
protocol: 'ctap2' as const,
ctap2Version: 'ctap2_1' as const,
transport: 'internal' as const,
hasResidentKey: true,
hasUserVerification: true,
hasPrf: true,
automaticPresenceSimulation: true,
isUserVerified: true,
defaultBackupEligibility: false,
defaultBackupState: false,
};
/**
* Attach a CDP virtual authenticator. The first call also enables the
* WebAuthn domain on the session. Subsequent calls add another
* authenticator on the same session, simulating a user with multiple
* devices required for multi-credential enrolment, because the server
* passes `excludeCredentials` and any authenticator already holding a
* listed credential refuses to create another for the same user.
*
* Chrome enforces "at most one `internal` (platform) authenticator per
* environment", so the first authenticator is internal (Touch ID /
* Windows Hello-like) and additional ones use USB transport.
*/
let sharedCdpSession: CDPSession | null = null;
const virtualAuthenticatorIds: string[] = [];
export async function addVirtualAuthenticator(page: Page) {
if (!sharedCdpSession) {
sharedCdpSession = await page.context().newCDPSession(page);
await sharedCdpSession.send('WebAuthn.enable');
}
const isFirst = virtualAuthenticatorIds.length === 0;
const options = isFirst
? AUTHENTICATOR_OPTIONS
: { ...AUTHENTICATOR_OPTIONS, transport: 'usb' as const };
const { authenticatorId } = await sharedCdpSession.send('WebAuthn.addVirtualAuthenticator', { options });
virtualAuthenticatorIds.push(authenticatorId);
}
/**
* Detach a previously-added virtual authenticator by add-order index (0 =
* first added). Used when a test removes a credential server-side and must
* stop that credential's now-orphaned resident key from answering a later
* discoverable `credentials.get()`: with multiple authenticators holding
* resident credentials and an empty allow-list, which one responds is
* otherwise non-deterministic.
*/
export async function removeVirtualAuthenticator(index: number) {
if (!sharedCdpSession) {
throw new Error('removeVirtualAuthenticator called before addVirtualAuthenticator');
}
const authenticatorId = virtualAuthenticatorIds[index];
if (authenticatorId === undefined) {
throw new Error(`removeVirtualAuthenticator: no authenticator at index ${index}`);
}
await sharedCdpSession.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
virtualAuthenticatorIds.splice(index, 1);
}
/**
* Drop the cached CDP session + authenticator IDs. Call from
* `test.beforeEach`: CDP sessions are bound to a specific Page;
* Playwright recycles the page between tests in the same file, so a
* stale session would crash the next `send()` with "Target page,
* context or browser has been closed".
*/
export function resetVirtualAuthenticators() {
sharedCdpSession = null;
virtualAuthenticatorIds.length = 0;
}
/**
* Toggle automatic user-presence simulation across every attached
* virtual authenticator. See `withAuthenticatorDisabled` for the safer
* wrapper.
*/
export async function setAuthenticatorAutoPresence(enabled: boolean) {
if (!sharedCdpSession) {
// A silent no-op here would let `withAuthenticatorDisabled` run its
// body with auto-presence still live, reintroducing the iframe
// auto-fire race the wrapper exists to prevent. Fail loudly instead
// so a future call ordered before `addVirtualAuthenticator` surfaces.
throw new Error('setAuthenticatorAutoPresence called before addVirtualAuthenticator');
}
for (const authenticatorId of virtualAuthenticatorIds) {
await sharedCdpSession.send('WebAuthn.setAutomaticPresenceSimulation', {
authenticatorId,
enabled,
});
}
}
/**
* Run `body` with the virtual authenticators' auto-presence simulation
* disabled, restoring it (even on failure) when `body` returns. Needed
* when the test wants to click "Select another method" on /#/2fa the
* connector iframe otherwise auto-fires WebAuthn the instant it mounts
* and the page races to /vault before the picker is reachable.
*/
export async function withAuthenticatorDisabled<T>(body: () => Promise<T>): Promise<T> {
await setAuthenticatorAutoPresence(false);
try {
return await body();
} finally {
await setAuthenticatorAutoPresence(true);
}
}
/**
* Enrol a login passkey via Settings Security Master password. Two
* entry points open the same dialog: "Turn on" for the first credential,
* "New passkey" once login-with-passkey is already on.
*/
export async function enrollLoginPasskey(
page: Page,
mp: string,
credentialName: string,
{ useForEncryption }: { useForEncryption: boolean },
) {
await page.goto('/#/settings/security/password');
await page.waitForLoadState('networkidle');
const enrolButton = page
.getByRole('button', { name: /Turn on|New passkey/i })
.first();
await enrolButton.waitFor({ state: 'visible' });
await enrolButton.click();
const mpInput = page.locator('input#masterPassword');
await mpInput.waitFor({ state: 'visible' });
await mpInput.fill(mp);
// Two `Continue` buttons coexist on this page; pressing Enter inside
// the password input submits the dialog form unambiguously.
await mpInput.press('Enter');
const nameInput = page.locator('input[formcontrolname="name"]');
await nameInput.waitFor({ state: 'visible' });
await nameInput.fill(credentialName);
// `useForEncryption` is default-checked in the bundled web vault, so
// the disabled case has to set it explicitly.
const prfToggle = page.locator('input[formcontrolname="useForEncryption"]');
if (useForEncryption) {
await prfToggle.check();
} else {
await prfToggle.uncheck();
}
// Dialog submit — "Turn on" on first enrolment, "Save" on subsequent;
// both type=submit inside the bit-dialog.
await page.locator('bit-dialog button[type="submit"]').click();
await expect(page.locator('bit-dialog')).toHaveCount(0);
}
/**
* Remove a registered login passkey. The credentials list is a table;
* each row has a "Remove <credentialName>" action. Clicking it opens an
* MP user-verification gate; submitting MP both verifies and applies the
* removal no separate "Yes" confirm.
*/
export async function removeLoginPasskey(page: Page, mp: string, credentialName: string) {
await page.goto('/#/settings/security/password');
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: `Remove ${credentialName}`, exact: true }).click();
const mpInput = page.locator('input#masterPassword');
await mpInput.waitFor({ state: 'visible' });
await mpInput.fill(mp);
await mpInput.press('Enter');
// Anchor on the master-password dialog closing before the absence check,
// so the latter can't pass against the pre-removal DOM (or a still-open
// dialog after a rejected master password).
await expect(mpInput).toHaveCount(0);
await expect(page.getByText(credentialName, { exact: true })).toHaveCount(0);
}
/**
* Enrol the WebAuthn-as-2FA provider (Settings Security Two-step
* login Passkey row). Separate code path from "Log in with passkey":
* the credential is stored in `two_factor` (TwoFactor::Webauthn) rather
* than `web_authn_credentials`, used as a second factor during login.
*/
export async function enrollWebauthn2FA(page: Page, mp: string, credentialName: string) {
await page.goto('/#/settings/security/two-factor');
await page.waitForLoadState('networkidle');
await page.locator('bit-item').filter({ hasText: 'Passkey' }).first().getByRole('button').first().click();
const mpInput = page.locator('input#masterPassword');
await mpInput.waitFor({ state: 'visible' });
await mpInput.fill(mp);
await mpInput.press('Enter');
const nameInput = page.locator('input[formcontrolname="name"]');
await nameInput.waitFor({ state: 'visible' });
await nameInput.fill(credentialName);
await page.getByRole('button', { name: 'Read key' }).click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('bit-dialog')).toHaveCount(0);
}
/**
* Disable the WebAuthn-as-2FA provider (Settings Security Two-step
* login Passkey row Manage Deactivate all keys Yes). The
* bundled web vault uses "Deactivate all keys" rather than "Turn off"
* for the WebAuthn provider.
*/
export async function disableWebauthn2FA(page: Page, mp: string) {
await page.goto('/#/settings/security/two-factor');
await page.waitForLoadState('networkidle');
await page.locator('bit-item').filter({ hasText: 'Passkey' }).first().getByRole('button').first().click();
const mpInput = page.locator('input#masterPassword');
await mpInput.waitFor({ state: 'visible' });
await mpInput.fill(mp);
await mpInput.press('Enter');
await page.getByRole('button', { name: 'Deactivate all keys' }).click();
await page.getByRole('button', { name: 'Yes' }).click();
await utils.checkNotification(page, 'Two-step login provider turned off');
}
/**
* Click the avatar menu's "Lock now". Vault transitions to /lock.
* Cipher rows also expose `aria-haspopup="menu"` ellipsis buttons, so
* we anchor on the avatar's accessible name (the user's display name).
*/
export async function lockVault(page: Page, userName: string) {
await page.getByRole('button', { name: userName, exact: true }).click();
await page.getByRole('menuitem', { name: /^Lock/i }).first().click();
await expect(page).toHaveURL(/\/lock/, { timeout: 10_000 });
}
/**
* Click "Unlock with passkey" on the lock screen. The web vault performs
* WebAuthn.get() in the main frame (no iframe ceremony), so the virtual
* authenticator satisfies it. PRF output decrypts the user key locally
* from the wrapped-key blobs in /sync's webAuthnPrfOptions.
*/
export async function unlockWithPasskey(page: Page) {
await page.getByRole('button', { name: /Unlock with passkey/i }).click();
await expect(page).toHaveURL(/\/(vault|setup-extension)/, { timeout: 30_000 });
}
/** Unlock the (locked) vault by typing the master password. */
export async function unlockWithMP(page: Page, password: string) {
await page.getByLabel('Master password').fill(password);
await page.getByRole('button', { name: 'Unlock', exact: true }).click();
await expect(page).toHaveURL(/\/(vault|setup-extension)/, { timeout: 30_000 });
}
/**
* Click "Log in with passkey" on the unauthenticated login page. The
* web vault opens a same-origin /webauthn-connector.html iframe which
* immediately calls navigator.credentials.get() the CDP virtual
* authenticator attached to the page satisfies it across the iframe
* boundary in current Chromium.
*
* No URL assertion here on purpose: the caller knows whether 2FA is
* enrolled and asserts /vault vs /#/2fa accordingly.
*/
export async function clickLoginWithPasskey(page: Page) {
await utils.cleanLanding(page);
await page.getByRole('button', { name: /Log in with passkey/i }).click();
}
/**
* Drive the /#/login email-entry step to the master-password unlock
* page (where "Log in with master password" + the conditional "Log in
* with device" affordance live).
*
* Vaultwarden's CSS overrides hide a different email field per SSO
* mode, so the path to the same MP page differs:
* MP mode (`SSO_ENABLED=false`): `.vw-email-sso` and "Other" are
* hidden; `.vw-email-continue` + "Continue" are visible. Fill +
* Continue gets to MP page.
* SSO mode (`SSO_ENABLED=true`): `.vw-email-continue` + "Continue"
* are hidden; `.vw-email-sso` is the only visible email input and
* "Other" replaces "Continue" to switch into the MP branch. Fill
* the SSO input, click "Other" lands directly on MP page.
*/
export async function enterEmailOnLoginPage(page: Page, email: string, opts: { sso?: boolean } = {}) {
if (opts.sso) {
await page.locator('input[type=email].vw-email-sso').fill(email);
await page.getByRole('button', { name: 'Other' }).click();
} else {
await page.getByLabel(/Email address/).fill(email);
await page.getByRole('button', { name: 'Continue' }).click();
}
}
/**
* Lock-screen affordance baseline assertion: master-password unlock +
* log-out are always present; passkey-unlock conditional on
* `expectPasskeyUnlock`. Mode-agnostic the lock screen looks the same
* whether the user logged in via MP or SSO.
*/
export async function expectLockScreenButtons(page: Page, expectPasskeyUnlock: boolean) {
await expect(page.getByRole('button', { name: 'Unlock', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Log out' })).toBeVisible();
const unlock = page.getByRole('button', { name: /Unlock with passkey/i });
if (expectPasskeyUnlock) {
await expect(unlock).toBeVisible();
} else {
await expect(unlock).toHaveCount(0);
}
}
/**
* Drive Settings Security Master password "Change master password".
* Optionally also ticks "Also rotate my account's encryption key", which
* opens a confirmation dialog (must be acknowledged before submit).
*
* Bitwarden v2026 rotation is async: clicking submit kicks off a
* multi-second client-side rewrap (re-wraps user key + all PRF/passkey
* credentials) BEFORE the API call fires. Endpoints:
* non-rotation password change POST /api/accounts/password
* rotation POST /api/accounts/key-management/rotate-user-account-keys
* The bundled web vault auto-navigates to /#/login on success; returning
* before the POST goes out tears down the in-flight rewrap. Waits on the
* response so callers can assume rotation is durable.
*/
export async function changeMasterPassword(
page: Page,
currentMp: string,
newMp: string,
rotateEncryptionKey = false,
) {
await page.goto('/#/settings/security/password');
await page.waitForLoadState('networkidle');
// "Current master password" is unique. "New master password" overlaps
// with "Confirm new master password" under substring matching, so
// anchor those by formcontrolname.
await page.getByLabel('Current master password').first().fill(currentMp);
await page.locator('input[formcontrolname="newPassword"]').fill(newMp);
await page.locator('input[formcontrolname="newPasswordConfirm"]').fill(newMp);
if (rotateEncryptionKey) {
await page.getByLabel(/Also rotate my account's encryption key/i).check();
await page
.getByRole('dialog', { name: /Rotate encryption key/i })
.getByRole('button', { name: 'Yes' })
.click();
}
const submitResp = page.waitForResponse(r => {
const u = r.url();
return u.includes('/api/accounts/password')
|| u.includes('/api/accounts/key-management/rotate-user-account-keys');
}, { timeout: 60_000 });
await page.getByRole('button', { name: 'Change master password' }).click();
await submitResp;
}
/**
* Bump the user's KDF iteration count via Settings Security Keys.
* Submitting rotates the security stamp (auto-logout) so callers pick
* up from /#/login. The form requires MP verification before the actual
* `/api/accounts/kdf` POST fires.
*/
export async function changeKdfIterations(page: Page, mp: string, iterations: number) {
await page.goto('/#/settings/security/security-keys');
await page.waitForLoadState('networkidle');
const iterationsInput = page.getByLabel('KDF iterations');
await iterationsInput.waitFor({ state: 'visible' });
await iterationsInput.fill(String(iterations));
await iterationsInput.press('Tab');
await page.getByRole('button', { name: 'Update encryption settings' }).click();
// Confirmation dialog with an MP gate. The actual POST only fires
// after MP is supplied and "Update settings" inside the dialog is
// clicked, so register the waitForResponse here, not before the
// first click.
const dialog = page.getByRole('dialog', { name: 'Update your encryption settings' });
await dialog.getByLabel('Master password').fill(mp);
const kdfPosted = page.waitForResponse(
r => /\/api\/accounts\/kdf\b/.test(r.url()) && r.request().method() === 'POST',
{ timeout: 60_000 },
);
await dialog.getByRole('button', { name: 'Update settings' }).click();
await kdfPosted;
}
/**
* Spawn a fresh browser context (a "new device" from the server's
* perspective) and return its page, parked on /#/login. Caller is
* responsible for `page.context().close()` to dispose of it.
*/
export async function createNewDevice(existing: Page): Promise<Page> {
const ctx = await existing.context().browser()!.newContext({ ignoreHTTPSErrors: true });
const page = await ctx.newPage();
await page.goto(`${process.env.DOMAIN}/#/login`);
return page;
}
/**
* Drive the "Log in with device" passwordless flow against a context
* whose device is already known. Clicks "Log in with device" POSTs
* `/api/auth-requests` and parks the second device on /login-with-device
* polling for approval. The `approver` page (still authenticated)
* surfaces the "Review login request" banner via its periodic poll;
* clicking through and confirming the request lands the second device
* in /vault.
*/
export async function loginWithDeviceAndApprove(secondDevice: Page, approver: Page) {
const authRequestPosted = secondDevice.waitForResponse(
r => /\/api\/auth-requests\b/.test(r.url()) && r.request().method() === 'POST' && r.status() === 200,
{ timeout: 30_000 },
);
await secondDevice.getByRole('button', { name: /Log in with device/i }).click();
await authRequestPosted;
const reviewLink = approver.getByRole('link', { name: /Review login request/i });
await reviewLink.waitFor({ state: 'visible', timeout: 60_000 });
await reviewLink.click();
// Lands on Settings → Security → Devices. The pending request is a
// row with a "Request pending" badge; clicking the device link opens
// the approval dialog whose primary action is "Confirm access".
await approver.getByRole('row').filter({ hasText: /Request pending/i })
.getByRole('link').first().click();
await approver.getByRole('button', { name: 'Confirm access' }).click();
await expect(secondDevice).toHaveURL(/\/(vault|setup-extension)/, { timeout: 30_000 });
}

37
playwright/tests/setups/sso.ts

@ -32,7 +32,10 @@ export async function logNewUser(
});
await test.step('Create Vault account', async () => {
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
// Heading spelling tracks the active locale: `en` ("organization")
// vs. `en_GB` ("organisation"). Both project variants use this
// helper, so accept either.
await expect(page.getByRole('heading', { name: /Join organi[sz]ation/ })).toBeVisible();
await fillNewMasterPassword(page, user.password);
await page.getByRole('button', { name: 'Create account' }).click();
});
@ -67,6 +70,11 @@ export async function logUser(
options: {
mailBuffer?: MailBuffer,
twoFactor?: TwoFactor,
// Override for the Keycloak password when the vault MP and the
// SSO-provider credential have diverged (e.g. after a master-
// password rotation in vw, where Keycloak's stored credential
// is unaffected). Defaults to `user.password` — the common case.
kcPassword?: string,
} = {}
) {
let mailBuffer = options.mailBuffer;
@ -84,7 +92,7 @@ export async function logUser(
await test.step('Keycloak login', async () => {
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible();
await page.getByLabel(/Username/).fill(user.name);
await page.getByLabel('Password', { exact: true }).fill(user.password);
await page.getByLabel('Password', { exact: true }).fill(options.kcPassword ?? user.password);
await page.getByRole('button', { name: 'Sign In' }).click();
});
@ -93,10 +101,27 @@ export async function logUser(
}
await test.step('Unlock vault', async () => {
await expect(page).toHaveTitle('Vaultwarden Web');
await expect(page.getByRole('heading', { name: 'Your vault is locked' })).toBeVisible();
await page.getByLabel('Master password').fill(user.password);
await page.getByRole('button', { name: 'Unlock' }).click();
// After SSO + (optional) 2FA, the bundled web vault routes to
// `/#/lock?promptBiometric=true`. When a PRF passkey is
// enrolled and the user's authenticator can satisfy the
// assertion, the lock screen auto-fires
// `navigator.credentials.get()` on mount and the SPA unwraps
// the user key without manual interaction — the page lands on
// /#/vault directly. If no PRF credential is available (or it
// can't satisfy UV), the lock screen waits for MP. Accept
// either landing so this helper works for both shapes; the
// Default-vault-page step below pins the final state either way.
await page.waitForURL(/#\/(lock|vault|setup-extension)\b/, { timeout: 30_000 });
if (page.url().includes('#/lock')) {
await expect(page).toHaveTitle('Vaultwarden Web');
await expect(page.getByRole('heading', { name: 'Your vault is locked' })).toBeVisible();
await page.getByLabel('Master password').fill(user.password);
// `exact: true` because the lock screen surfaces an additional
// "Unlock with passkey" button when the user has a PRF-capable
// credential enrolled; a substring "Unlock" match would resolve
// to two elements and Playwright's strict mode would throw.
await page.getByRole('button', { name: 'Unlock', exact: true }).click();
}
});
await utils.ignoreExtension(page);

4
playwright/tests/setups/user.ts

@ -102,6 +102,10 @@ export async function logUser(
options: {
mailBuffer?: MailBuffer,
twoFactor?: TwoFactor,
// Accepted for option-shape parity with `./sso.ts#logUser`, which
// uses it to support cases where the SSO-provider credential and
// the vault MP have diverged. Ignored in the MP-only flow.
kcPassword?: string,
} = {},
) {
let mailBuffer = options.mailBuffer;

Loading…
Cancel
Save