Browse Source
`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.
pull/7297/head
3 changed files with 472 additions and 0 deletions
@ -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<string>(); |
||||
|
const challenges = new Set<string>(); |
||||
|
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<string, string> = {}) { |
||||
|
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<string> { |
||||
|
// 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<string, string>)[field]; |
||||
|
const res = await request.post('/identity/connect/token', { |
||||
|
form: form as Record<string, string>, |
||||
|
}); |
||||
|
expect(res.status()).toBeGreaterThanOrEqual(400); |
||||
|
// Don't check the specific message: Vaultwarden says
|
||||
|
// "<field> 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<string, string> { |
||||
|
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<string> { |
||||
|
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); |
||||
|
}); |
||||
|
}); |
||||
Loading…
Reference in new issue