Repository navigation
fix(auth): validate passkey verify request payloads - #8619
Conversation
The passkey authenticate and register routes accepted the WebAuthn response as an arbitrary object and then asserted it into the library's response type, so the request boundary did not check that the body was a credential response. Add zod schemas for the registration and authentication response fields the routes hand to @simplewebauthn/server, use them in the verify branch of both routes, and drop the double casts. The library keeps performing every cryptographic check: it decodes the client data, compares the challenge, origin and rpId, and verifies the signature against the stored public key. A body that is not a WebAuthn response gets the routes' existing 400 instead of reaching the verifier. Unknown keys are stripped, client extension outputs are normalized to an empty object when a native authenticator omits them, and any string user handle is accepted because the verifier ignores one it cannot use. Route tests cover a well-formed response, a body shaped like something else, and the native shape without extension results. The Playwright passkey spec exercises the same boundary against a real browser credential and keeps the wrong-challenge refusal by resending the captured assertion with a fresh challenge id.
Code Review SummaryStatus: No Issues Found | Recommendation: Merge Executive SummaryThe incremental change narrows the new passkey response schemas so native bridges that serialize absent optional WebAuthn members as Assumptions: Overview
Files Reviewed (2 files, incremental scope)
Scope: incremental review of Previous Review Summary (commit 7a0f49c)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit 7a0f49c)Status: No Issues Found | Recommendation: Merge Executive SummaryThe PR replaces the permissive Assumptions: Overview
Files Reviewed (6 files)
Notes: the schemas only narrow the untrusted input before Reviewed by deepseek-flash-latest · Input: 59.5K · Output: 11.8K · Cached: 553.6K Review guidance: REVIEW.md from base branch |
The mobile passkey bridge serializes absent optional fields as null. The response schemas now normalize null optional fields before verification, so a native registration or assertion reaches the verifier instead of being refused as an invalid request. Required credential members still reject null.
|
bot: Native verification found one real defect in the previous head and it is fixed here. Defect. The mobile passkey bridge serializes absent optional WebAuthn fields as Fix. Optional response members now normalize Verification on the fixed head.
Device coverage. Android cancellation and recovery paths were exercised through the real OS Credential Manager sheet on a Play-image emulator: the sheet appears from both the sign-in and registration entry points, dismissal shows the retryable message, and a retry reopens the sheet. A local credential cannot be created on that emulator because no Google account is signed in, so the platform offers only cross-device creation. The iOS simulator could not install a client: the supported install path requires a simulator claim owned by the workflow harness, which this workstation does not have, and I did not bypass that gate. Product may confirm the happy path on TestFlight. |
Evidence
Evidence: demonstrated — the passkey Playwright spec ran against the local cloud dev stack (Next.js on the VM, local Postgres, Chromium virtual authenticator): registration and sign-in with a real browser credential still succeed, a replayed assertion is refused with 401, a well-formed assertion signed for another challenge is refused with 401, and a body that is not a WebAuthn response gets 400 from both verify routes instead of reaching the verifier. Route unit tests in both route directories cover the malformed and well-formed bodies, and a schema test covers native responses whose absent optional members are serialized as
null.What changed
The passkey
authenticateandregisterroutes now validate the WebAuthn response they accept before handing it to the library, and theas unknown asassertions are gone.apps/web/src/lib/auth/passkey-response.tsexportsregistrationResponseSchemaandauthenticationResponseSchema, typed asz.ZodType<RegistrationResponseJSON>andz.ZodType<AuthenticationResponseJSON>. They require the credentialidandrawIdas non-empty strings,typeas'public-key',response.clientDataJSONplusattestationObject(registration) orauthenticatorDataandsignature(assertion) as non-empty strings, and the optional transports/public-key and user-handle members.nulland normalize it to absence. The iOS and Android passkey bridges emitnullfor an optional member they did not set, so a real native registration or assertion reaches the verifier instead of being refused as a bad request. A required member still rejectsnull.clientExtensionResultsis validated as an object when present and defaults to{}. A native security-key assertion omits it; this relying party's verification never reads extension outputs.authenticatorAttachmentis restricted toplatformorcross-platform; a stringuserHandleis accepted as-is because verification ignores a handle it cannot use.verifybranch of their existing discriminated union and callverifyAuthentication/verifyRegistrationwith the parsed value directly. A body that fails the schema returns the routes' existing400 { error: 'INVALID_REQUEST' }.Verification keeps belonging to
@simplewebauthn/server:verifyRegistrationResponseandverifyAuthenticationResponsedecode the client data, compare the challenge, origin and rpId, and check the signature against the stored public key. The existing defensive checks insideapps/web/src/lib/auth/passkey.tsare unchanged.Tests
apps/web/src/app/api/auth/passkey/authenticate/route.test.tsand.../register/route.test.ts(13 tests): well-formed response reaches the ceremony helper; a body that is not a WebAuthn response (inner response only, missingrawId, wrongtype, non-string members, missing attestation object) returns 400 and never reaches the helper; a native assertion without extension results is accepted and normalized; refused verification still returns 401 with the stable code.apps/web/src/lib/auth/passkey-response.test.ts: native responses whose optional members arenullnormalize to absence, and anullrequired member is refused.apps/web/tests/e2e/passkey.spec.ts: the registration boundary rejects a malformed attestation with 400, the sign-in flow still registers and signs in with a real credential, replay and wrong-challenge stay 401, and a malformed assertion body returns 400.Local checks:
jest src/lib/auth/passkey src/app/api/auth/passkey(58 passed),pnpm --filter web typecheck(clean),oxlintandoxfmt --checkon the changed files (clean).Proxy answers
Answer: Optional object, default {}
Confidence: 0.70
Answer: Strip them
Confidence: 0.70
Answer: Resend the captured body with the fresh challengeId
Confidence: 0.75
Answer: Route tests plus the Playwright passkey spec
Confidence: 0.85