diff --git a/playwright/global-setup.ts b/playwright/global-setup.ts index 89405f12..540d4074 100644 --- a/playwright/global-setup.ts +++ b/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`, { diff --git a/playwright/global-utils.ts b/playwright/global-utils.ts index 1e238f75..97f5aee0 100644 --- a/playwright/global-utils.ts +++ b/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(); diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts index 9caebd76..eda32033 100644 --- a/playwright/playwright.config.ts +++ b/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 } + : {}), + }, }, }, diff --git a/playwright/tests/account_lifecycle.spec.ts b/playwright/tests/account_lifecycle.spec.ts index 5f72b192..5d25fcd6 100644 --- a/playwright/tests/account_lifecycle.spec.ts +++ b/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; + /** + * 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; +} + +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 { 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 }); +}); diff --git a/playwright/tests/setups/2fa.ts b/playwright/tests/setups/2fa.ts index a060ad4e..b1b9681a 100644 --- a/playwright/tests/setups/2fa.ts +++ b/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 { 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 }); }); } diff --git a/playwright/tests/setups/account_lifecycle_helpers.ts b/playwright/tests/setups/account_lifecycle_helpers.ts new file mode 100644 index 00000000..a77bd00d --- /dev/null +++ b/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(body: () => Promise): Promise { + 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 " 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 { + 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 }); +} diff --git a/playwright/tests/setups/sso.ts b/playwright/tests/setups/sso.ts index 54f3432b..9be971ac 100644 --- a/playwright/tests/setups/sso.ts +++ b/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); diff --git a/playwright/tests/setups/user.ts b/playwright/tests/setups/user.ts index 289e2041..d661193a 100644 --- a/playwright/tests/setups/user.ts +++ b/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;