From c489186e4f4765bb6e25ed30055ca535f7aacdf6 Mon Sep 17 00:00:00 2001 From: Zaid Marji Date: Sun, 24 May 2026 06:04:07 +0300 Subject: [PATCH] Add playwright tests for passkey login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `passkey.spec.ts` exercises the unauthenticated and authorization-required surface that doesn't need a virtual authenticator: - `GET /identity/accounts/webauthn/assertion-options` returns the documented shape (`Content-Type: application/json`, `options` + `userVerification` + a non-empty `token`). The token format is intentionally not pinned: Vaultwarden mints a UUID, upstream Bitwarden mints a `DataProtectorTokenable`; both are opaque from the client's view. - Five back-to-back calls return five distinct tokens AND five distinct challenges — a refactor that re-used either would let an attacker replay. - The `grant_type=webauthn` token endpoint returns a generic auth-failed message for an unknown token, a malformed deviceresponse, and a structurally-valid but unsignable assertion. The regex accepts both Vaultwarden's "Passkey authentication failed." and upstream Bitwarden's "Invalid credential." — the security contract is the byte-equality between failure branches (oracle defense), not the surface text. - Every webauthn-management endpoint (`GET /api/webauthn`, attestation / assertion options, finish, update, delete) rejects anonymous callers AND callers with a garbage Bearer with 401. - Missing-required-field requests to the webauthn grant are rejected before the handler body runs (token / deviceresponse / client_id / scope). The specific rejection text differs between projects so we only assert that the response is an error. - The web vault renders the "Log in with passkey" entry point. Security-gate coverage covers forged user-handle attempts against disabled and unverified accounts plus the SSO_ONLY webauthn grant gate. The forged-handle cases create real target users but submit intentionally unsignable assertions, asserting the response stays byte-equal to the unknown-user baseline before WebAuthn verification succeeds. Adds the docker-compose environment passthrough needed for per-describe vault restarts with SIGNUPS_VERIFY and SSO_ONLY test configs. README: document the Playwright image's bake-in behavior — the Dockerfile copies `tests/` in at build time, so local edits to `*.spec.ts` are not picked up by `docker compose run Playwright` until the image is rebuilt. Verified empirically: an in-place rename of a `test('…')` title is invisible to `run` until `build Playwright` is invoked, and absolute paths through the mounted `..:/project` volume don't override Playwright's config-derived `testDir`. Add a short note next to the existing "force a rebuild" command. The spec is Firefox-compatible and runs unmodified under the existing playwright project matrix. --- playwright/README.md | 7 + playwright/docker-compose.yml | 4 + playwright/tests/passkey.spec.ts | 461 +++++++++++++++++++++++++++++++ 3 files changed, 472 insertions(+) create mode 100644 playwright/tests/passkey.spec.ts diff --git a/playwright/README.md b/playwright/README.md index a27e6105..4ce3d412 100644 --- a/playwright/README.md +++ b/playwright/README.md @@ -33,6 +33,13 @@ To force a rebuild of the Playwright image: DOCKER_BUILDKIT=1 docker compose --env-file test.env build Playwright ``` +The `Playwright` image bakes the `tests/` directory in at build time +(`COPY tests ./tests` in [compose/playwright/Dockerfile](compose/playwright/Dockerfile)), +so local edits to `*.spec.ts` are **not** picked up by `docker compose run` +until the image is rebuilt. Run the `build` command above after every +spec change, or use the host-side workflow below (`npx playwright test ...`) +which reads specs from disk on every run. + To access the UI to easily run test individually and debug if needed (this will not work in docker): ```bash diff --git a/playwright/docker-compose.yml b/playwright/docker-compose.yml index f4402326..ffedbfd5 100644 --- a/playwright/docker-compose.yml +++ b/playwright/docker-compose.yml @@ -27,9 +27,13 @@ services: - I_REALLY_WANT_VOLATILE_STORAGE - LOG_LEVEL - LOGIN_RATELIMIT_MAX_BURST + - SIGNUPS_VERIFY - SMTP_HOST - SMTP_FROM - SMTP_DEBUG + - SSO_AUTHORITY + - SSO_CLIENT_ID + - SSO_CLIENT_SECRET - SSO_DEBUG_TOKENS - SSO_ENABLED - SSO_FRONTEND diff --git a/playwright/tests/passkey.spec.ts b/playwright/tests/passkey.spec.ts new file mode 100644 index 00000000..2b3b7f5c --- /dev/null +++ b/playwright/tests/passkey.spec.ts @@ -0,0 +1,461 @@ +import { test, expect, type TestInfo } from '@playwright/test'; + +import * as utils from '../global-utils'; +import { createAccount } from './setups/user'; + +let users = utils.loadEnv(); +const ADMIN_TOKEN = process.env.ADMIN_TOKEN!; + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + await utils.startVault(browser, testInfo, {}); +}); + +test.afterAll('Teardown', async () => { + utils.stopVault(); +}); + +// --------------------------------------------------------------------------- +// Unauthenticated API surface — `GET /identity/accounts/webauthn/assertion-options` +// is the only public passkey-login entry point. +// --------------------------------------------------------------------------- + +test.describe('Passkey login challenge endpoint', () => { + test('GET assertion-options returns the documented shape', async ({ request }) => { + const res = await request.get('/identity/accounts/webauthn/assertion-options'); + expect(res.status()).toBe(200); + expect(res.headers()['content-type']).toMatch(/application\/json/i); + const body = await res.json(); + + expect(body).toHaveProperty('options'); + expect(body).toHaveProperty('token'); + // `webAuthnLoginAssertionOptions` is also the upstream Bitwarden + // `WebAuthnLoginAssertionOptionsResponseModel.ResponseObj` constant. + expect(body).toHaveProperty('object', 'webAuthnLoginAssertionOptions'); + + // WEBAUTHN_LOGIN_CHALLENGE_TTL_SECONDS is server-side; the public + // contract is just that the options carry a challenge and a UV policy. + expect(body.options).toHaveProperty('challenge'); + expect(body.options).toHaveProperty('userVerification'); + + // Don't pin the token format: Vaultwarden mints a UUID, upstream + // Bitwarden mints a `DataProtectorTokenable` (signed string). + // Both are non-empty opaque strings as far as the client cares. + expect(typeof body.token).toBe('string'); + expect(body.token.length).toBeGreaterThan(0); + }); + + test('assertion-options returns a fresh token and challenge on every call', async ({ request }) => { + // Each call inserts a row in `web_authn_login_challenges`. Token AND + // challenge bytes must both be unique across calls; if a future + // refactor accidentally re-used either, an attacker could replay. + const tokens = new Set(); + const challenges = new Set(); + for (let i = 0; i < 5; i++) { + const body = await (await request.get('/identity/accounts/webauthn/assertion-options')).json(); + tokens.add(body.token); + challenges.add(body.options.challenge); + } + expect(tokens.size).toBe(5); + expect(challenges.size).toBe(5); + }); +}); + +// --------------------------------------------------------------------------- +// `POST /identity/connect/token grant=webauthn` is the unauthenticated login +// path. Every failure must surface the same generic message so an attacker +// cannot probe account state (the "oracle" defense). We cannot fully exercise +// the happy path without a virtual authenticator, but every documented +// failure branch is reachable with bad input. +// --------------------------------------------------------------------------- + +function webauthnLoginForm(overrides: Record = {}) { + return { + grant_type: 'webauthn', + client_id: 'web', + scope: 'api offline_access', + device_identifier: '00000000-0000-0000-0000-000000000000', + device_name: 'pw-test-device', + device_type: '9', + deviceresponse: '{}', + token: '00000000-0000-0000-0000-000000000000', + ...overrides, + }; +} + +async function failureMessage(res: any): Promise { + // Vaultwarden's `ApiErrorResponse` serializer puts the user-visible + // string in top-level `message` and again under `errorModel.message`. + // The OAuth2-style `error` / `error_description` fields are present + // but hardcoded to empty string, so we ignore them. + let body: any; + let raw: string | undefined; + try { + raw = await res.text(); + body = JSON.parse(raw); + } catch { + // Not JSON — return the raw text so the test failure is diagnosable. + return `[non-JSON ${res.status()}] ${raw ?? ''}`; + } + const msg = body?.message || body?.errorModel?.message; + if (!msg) { + // Attach the unexpected body to the test report (not stdout) so a + // future serializer-shape change is visible even when the assertion + // still passes against the raw body. + test.info().annotations.push({ + type: 'unexpected-error-shape', + description: `status=${res.status()} body=${raw}`, + }); + } + return msg || raw || `[empty body, status ${res.status()}]`; +} + +// Vaultwarden's `AUTH_FAILED` constant is "Passkey authentication failed." +// Upstream Bitwarden uses "Invalid credential." — same security contract +// (generic rejection that doesn't reveal which branch failed), different +// surface text. Accept either so this spec runs against either server. +const GENERIC_AUTH_FAILED = /(Passkey authentication failed|Invalid credential)/i; + +test.describe('Passkey grant rejects all bad input with the same message', () => { + test('returns a generic auth-failed message for an unknown token', async ({ request }) => { + const res = await request.post('/identity/connect/token', { + form: webauthnLoginForm(), + }); + expect(res.status()).toBeGreaterThanOrEqual(400); + expect(await failureMessage(res)).toMatch(GENERIC_AUTH_FAILED); + }); + + test('returns a generic auth-failed message for a malformed deviceresponse', async ({ request }) => { + // Fresh, valid token; garbage body. Server must still respond with + // the generic message — not a serde error or a different shape. + const { token } = await (await request.get('/identity/accounts/webauthn/assertion-options')).json(); + const res = await request.post('/identity/connect/token', { + form: webauthnLoginForm({ token, deviceresponse: 'not-json' }), + }); + expect(res.status()).toBeGreaterThanOrEqual(400); + expect(await failureMessage(res)).toMatch(GENERIC_AUTH_FAILED); + }); + + test('returns a generic auth-failed message for a structurally-valid but unsignable assertion', async ({ request }) => { + const { token } = await (await request.get('/identity/accounts/webauthn/assertion-options')).json(); + // A shape that parses as PublicKeyCredentialCopy but cannot identify + // any registered discoverable credential — same end state as garbage, + // but reaches a deeper branch in `webauthn_login`. + const fakeAssertion = JSON.stringify({ + id: 'AAAA', + rawId: 'AAAA', + type: 'public-key', + response: { + authenticatorData: 'AAAA', + clientDataJson: 'AAAA', + signature: 'AAAA', + userHandle: 'AAAA', + }, + }); + const res = await request.post('/identity/connect/token', { + form: webauthnLoginForm({ token, deviceresponse: fakeAssertion }), + }); + expect(res.status()).toBeGreaterThanOrEqual(400); + expect(await failureMessage(res)).toMatch(GENERIC_AUTH_FAILED); + }); + + test('the unknown-token branch and the malformed-body branch return identical messages', async ({ request }) => { + // The whole point of the generic auth-failed constant: a client + // must not be able to tell *why* the login failed. If these two + // messages ever diverge, that's an oracle — regardless of which + // string each server uses. + const unknown = await request.post('/identity/connect/token', { + form: webauthnLoginForm(), + }); + const { token } = await (await request.get('/identity/accounts/webauthn/assertion-options')).json(); + const malformed = await request.post('/identity/connect/token', { + form: webauthnLoginForm({ token, deviceresponse: 'not-json' }), + }); + + const unknownMessage = await failureMessage(unknown); + const malformedMessage = await failureMessage(malformed); + + // Assert both are the generic message before comparing them, so the + // test can't pass vacuously: two identical empty/degenerate bodies + // would satisfy a bare equality check while breaking the oracle + // contract this test exists to defend. + expect(unknownMessage).toMatch(GENERIC_AUTH_FAILED); + expect(malformedMessage).toMatch(GENERIC_AUTH_FAILED); + expect(unknownMessage).toBe(malformedMessage); + }); +}); + +// --------------------------------------------------------------------------- +// Authenticated webauthn-management endpoints must reject anonymous callers. +// Rocket's `Headers` guard short-circuits the handler body, so the asserted +// 401 is a property of the route attribute, not the handler logic. Worth +// pinning anyway: a refactor that swaps `Headers` for a non-required guard +// would silently widen the attack surface. +// --------------------------------------------------------------------------- + +test.describe('Passkey management endpoints require authentication', () => { + const cases = [ + { method: 'GET' as const, path: '/api/webauthn' }, + { method: 'POST' as const, path: '/api/webauthn/attestation-options', data: {} }, + { method: 'POST' as const, path: '/api/webauthn/assertion-options', data: {} }, + { method: 'POST' as const, path: '/api/webauthn', data: {} }, + { method: 'PUT' as const, path: '/api/webauthn', data: {} }, + { + method: 'POST' as const, + path: '/api/webauthn/00000000-0000-0000-0000-000000000000/delete', + data: {}, + }, + ]; + for (const c of cases) { + test(`${c.method} ${c.path} → 401 without bearer token`, async ({ request }) => { + const res = await request.fetch(c.path, { + method: c.method, + ...(c.data === undefined ? {} : { data: c.data }), + }); + expect(res.status()).toBe(401); + }); + + test(`${c.method} ${c.path} → 401 with a garbage bearer`, async ({ request }) => { + // A non-empty but unparseable Bearer must still be rejected by + // the JWT validator before the handler body runs. Upstream + // Bitwarden's `[Authorize]` and Vaultwarden's `Headers` guard + // both fail closed on bad tokens. + const headers = { Authorization: 'Bearer not-a-real-jwt' }; + const res = await request.fetch(c.path, { + method: c.method, + headers, + ...(c.data === undefined ? {} : { data: c.data }), + }); + expect(res.status()).toBe(401); + }); + } +}); + +// --------------------------------------------------------------------------- +// `/identity/connect/token grant=webauthn` rejects requests that are missing +// any of the required form fields before the webauthn handler body runs. +// Both Vaultwarden (`check_is_some(...)`) and upstream Bitwarden +// (`WebAuthnGrantValidator` early null checks) gate on these — the exact +// rejection text differs between projects, so we only assert that the +// response is an error and not the contentful happy path. +// --------------------------------------------------------------------------- + +test.describe('Passkey grant rejects requests with missing required form fields', () => { + for (const field of ['token', 'deviceresponse', 'client_id', 'scope']) { + test(`missing ${field} is rejected`, async ({ request }) => { + const form = webauthnLoginForm(); + delete (form as Record)[field]; + const res = await request.post('/identity/connect/token', { + form: form as Record, + }); + expect(res.status()).toBeGreaterThanOrEqual(400); + // Don't check the specific message: Vaultwarden says + // " cannot be blank", Bitwarden returns + // `TokenRequestErrors.InvalidGrant`. The contract being tested + // is that the missing-field input doesn't sneak through. + }); + } +}); + +// --------------------------------------------------------------------------- +// UI smoke — the web vault must surface the new login entry point. +// --------------------------------------------------------------------------- + +test.describe('Passkey UI surface', () => { + test('Login page exposes the "Log in with passkey" entry point', async ({ page }) => { + await utils.cleanLanding(page); + // The web vault renders the button conditionally; the + // `passkey_login` capability comes from the server's /api/config + // response, which is always-on for any non-SSO_ONLY deployment. + await expect(page.getByRole('button', { name: /Log in with passkey/i })).toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// Pre-verification user-handle handling inside `webauthn_login`. A forged +// assertion can name an existing user in `userHandle`, but until WebAuthn +// verification succeeds it must not produce a distinguishable response or be +// treated as a proven login attempt for that user. +// --------------------------------------------------------------------------- + +function base64url(s: string): string { + return Buffer.from(s, 'utf8').toString('base64') + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function webauthnGrantTargetingUser(token: string, userUuid: string): Record { + return { + grant_type: 'webauthn', + client_id: 'web', + scope: 'api offline_access', + device_identifier: '00000000-0000-0000-0000-000000000000', + device_name: 'pw-test-device', + device_type: '9', + deviceresponse: JSON.stringify({ + id: 'AAAA', + rawId: 'AAAA', + type: 'public-key', + response: { + authenticatorData: 'AAAA', + clientDataJson: 'AAAA', + signature: 'AAAA', + userHandle: base64url(userUuid), + }, + }), + token, + }; +} + +async function adminLogin(request: any) { + const res = await request.post('/admin', { + form: { token: ADMIN_TOKEN }, + maxRedirects: 0, + failOnStatusCode: false, + }); + expect([200, 302, 303]).toContain(res.status()); +} + +async function adminGetUserByEmail(request: any, email: string): Promise<{ id: string }> { + const res = await request.get(`/admin/users/by-mail/${encodeURIComponent(email)}`); + expect(res.status()).toBe(200); + return await res.json(); +} + +async function getFreshChallengeToken(request: any): Promise { + const res = await request.get('/identity/accounts/webauthn/assertion-options'); + expect(res.status()).toBe(200); + return (await res.json()).token; +} + +async function createUnverifiedAccount(request: any, user: { email?: string, name?: string, password?: string }) { + const res = await request.post('/identity/accounts/register', { + data: { + email: user.email, + name: user.name, + kdfType: 0, + kdfIterations: 600000, + userSymmetricKey: `test-key-${user.name}`, + masterPasswordHash: `test-master-password-hash-${user.name}`, + masterPasswordHint: null, + }, + }); + expect(res.status()).toBe(200); +} + +test.describe('Passkey login rejects forged disabled-user handles with the generic AUTH_FAILED', () => { + // The user is created in beforeAll and used by the test below; the + // file-level beforeAll already starts the default-config vault. + test.beforeAll('Create user1', async ({ browser }) => { + const ctx = await browser.newContext({ ignoreHTTPSErrors: true }); + const page = await ctx.newPage(); + await createAccount(test, page, users.user1); + await ctx.close(); + }); + + test('disabled target response is indistinguishable from unknown user before verification', async ({ request }) => { + await adminLogin(request); + const user = await adminGetUserByEmail(request, users.user1.email!); + + const disableRes = await request.post(`/admin/users/${user.id}/disable`, { + headers: { 'Content-Type': 'application/json' }, + failOnStatusCode: false, + }); + expect(disableRes.status()).toBe(200); + + try { + const baselineToken = await getFreshChallengeToken(request); + const baseline = await request.post('/identity/connect/token', { + form: webauthnGrantTargetingUser(baselineToken, '00000000-0000-0000-0000-000000000000'), + }); + const targetToken = await getFreshChallengeToken(request); + const target = await request.post('/identity/connect/token', { + form: webauthnGrantTargetingUser(targetToken, user.id), + }); + expect(target.status()).toBeGreaterThanOrEqual(400); + expect(target.status()).toBe(baseline.status()); + const targetBody: any = await target.json(); + const baselineBody: any = await baseline.json(); + expect(baselineBody?.message, 'baseline must carry a message').toBeTruthy(); + expect(targetBody?.message).toBe(baselineBody?.message); + } finally { + await request.post(`/admin/users/${user.id}/enable`, { + headers: { 'Content-Type': 'application/json' }, + failOnStatusCode: false, + }); + } + }); +}); + +test.describe('Passkey grant is rejected when SSO_ONLY is on', () => { + // Defends `check_sso_only` (deny-by-default whitelist). Restart the + // vault with SSO_ENABLED + SSO_ONLY for this describe's tests, then + // restart with default config in afterAll. + test.beforeAll('Start vault with SSO_ONLY', async ({ browser }, testInfo) => { + utils.stopVault(true); + await utils.startVault(browser, testInfo, { + SSO_ENABLED: 'true', + SSO_ONLY: 'true', + SSO_AUTHORITY: 'http://127.0.0.1:65535/realms/test', + SSO_CLIENT_ID: 'test', + SSO_CLIENT_SECRET: 'test', + }, false); + }); + + test.afterAll('Restore default vault', async ({ browser }, testInfo) => { + utils.stopVault(true); + await utils.startVault(browser, testInfo, {}, false); + }); + + test('webauthn grant denied with an SSO-mentioning message', async ({ request }) => { + // The SSO_ONLY gate fires BEFORE token validation, so a dummy + // token is fine — the dispatcher denies the grant outright. + const res = await request.post('/identity/connect/token', { + form: webauthnGrantTargetingUser('00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000000'), + }); + expect(res.status()).toBeGreaterThanOrEqual(400); + const body: any = await res.json(); + expect(body?.message ?? '').toMatch(/SSO sign-in is required/i); + }); +}); + +test.describe('Passkey login rejects forged unverified-email handles with the generic AUTH_FAILED', () => { + // Needs SIGNUPS_VERIFY=true + a configured (any) SMTP host so + // CONFIG.mail_enabled() returns true. Restart vault with that config; the + // new signup lands in DB with verified_at = NULL. + test.beforeAll('Start vault with SIGNUPS_VERIFY', async ({ browser }, testInfo) => { + utils.stopVault(true); + await utils.startVault(browser, testInfo, { + SIGNUPS_VERIFY: 'true', + // SMTP_HOST + SMTP_FROM are both required when mail is enabled; + // the address doesn't have to be reachable — the gate only + // checks that config is present. + SMTP_HOST: '127.0.0.1', + SMTP_FROM: 'test@example.invalid', + }, true); + }); + + test.afterAll('Restore default vault', async ({ browser }, testInfo) => { + utils.stopVault(true); + await utils.startVault(browser, testInfo, {}, true); + }); + + test('unverified target response is indistinguishable from unknown user before verification', async ({ request }) => { + await createUnverifiedAccount(request, users.user2); + await adminLogin(request); + const user = await adminGetUserByEmail(request, users.user2.email!); + + const baselineToken = await getFreshChallengeToken(request); + const baseline = await request.post('/identity/connect/token', { + form: webauthnGrantTargetingUser(baselineToken, '00000000-0000-0000-0000-000000000000'), + }); + const targetToken = await getFreshChallengeToken(request); + const target = await request.post('/identity/connect/token', { + form: webauthnGrantTargetingUser(targetToken, user.id), + }); + expect(target.status()).toBeGreaterThanOrEqual(400); + expect(target.status()).toBe(baseline.status()); + const targetBody: any = await target.json(); + const baselineBody: any = await baseline.json(); + expect(baselineBody?.message, 'baseline must carry a message').toBeTruthy(); + expect(targetBody?.message).toBe(baselineBody?.message); + }); +});