diff --git a/packages/server/src/authentication/verifyAuthenticationResponse.test.ts b/packages/server/src/authentication/verifyAuthenticationResponse.test.ts index f74fe579..b52c2e01 100644 --- a/packages/server/src/authentication/verifyAuthenticationResponse.test.ts +++ b/packages/server/src/authentication/verifyAuthenticationResponse.test.ts @@ -578,6 +578,266 @@ Deno.test('should return user verified flag after successful auth', async () => assertFalse(verification.authenticationInfo?.userVerified); }); +Deno.test('should verify when crossOrigin is true, topOrigin is missing, and expectedTopOrigin is not specified (Safari workaround)', async () => { + const mockDecodeClientData = stub( + _decodeClientDataJSONInternals, + 'stubThis', + returnsNext([ + { + type: 'webauthn.get', + origin: assertionOrigin, + challenge: assertionChallenge, + crossOrigin: true, + }, + ]), + ); + + try { + const verification = await verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: assertionOrigin, + expectedRPID: 'dev.dontneeda.pw', + credential, + requireUserVerification: false, + }); + + assertEquals(verification.verified, true); + } finally { + mockDecodeClientData.restore(); + } +}); + +Deno.test('should verify when crossOrigin is true, topOrigin is missing, but expectedTopOrigin is specified (Safari workaround)', async () => { + const mockDecodeClientData = stub( + _decodeClientDataJSONInternals, + 'stubThis', + returnsNext([ + { + type: 'webauthn.get', + origin: assertionOrigin, + challenge: assertionChallenge, + crossOrigin: true, + }, + ]), + ); + + try { + const verification = await verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: assertionOrigin, + expectedRPID: 'dev.dontneeda.pw', + expectedTopOrigin: 'https://top.origin.com', + credential, + requireUserVerification: false, + }); + + assertEquals(verification.verified, true); + } finally { + mockDecodeClientData.restore(); + } +}); + +Deno.test('should verify when crossOrigin is true and topOrigin matches expectedTopOrigin (string)', async () => { + const mockDecodeClientData = stub( + _decodeClientDataJSONInternals, + 'stubThis', + returnsNext([ + { + type: 'webauthn.get', + origin: assertionOrigin, + challenge: assertionChallenge, + crossOrigin: true, + topOrigin: 'https://top.origin.com', + }, + ]), + ); + + try { + const verification = await verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: assertionOrigin, + expectedRPID: 'dev.dontneeda.pw', + expectedTopOrigin: 'https://top.origin.com', + credential, + requireUserVerification: false, + }); + + assertEquals(verification.verified, true); + } finally { + mockDecodeClientData.restore(); + } +}); + +Deno.test('should throw when crossOrigin is true and topOrigin does not match expectedTopOrigin (string)', async () => { + const mockDecodeClientData = stub( + _decodeClientDataJSONInternals, + 'stubThis', + returnsNext([ + { + type: 'webauthn.get', + origin: assertionOrigin, + challenge: assertionChallenge, + crossOrigin: true, + topOrigin: 'https://wrong.top.origin.com', + }, + ]), + ); + + try { + await assertRejects( + () => + verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: assertionOrigin, + expectedRPID: 'dev.dontneeda.pw', + expectedTopOrigin: 'https://top.origin.com', + credential, + }), + Error, + 'Unexpected cross-origin authentication response top origin of "https://wrong.top.origin.com", expected: https://top.origin.com', + ); + } finally { + mockDecodeClientData.restore(); + } +}); + +Deno.test('should verify when crossOrigin is true and topOrigin matches one of expectedTopOrigin (array)', async () => { + const mockDecodeClientData = stub( + _decodeClientDataJSONInternals, + 'stubThis', + returnsNext([ + { + type: 'webauthn.get', + origin: assertionOrigin, + challenge: assertionChallenge, + crossOrigin: true, + topOrigin: 'https://top.origin.com', + }, + ]), + ); + + try { + const verification = await verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: assertionOrigin, + expectedRPID: 'dev.dontneeda.pw', + expectedTopOrigin: ['https://other.origin.com', 'https://top.origin.com'], + credential, + requireUserVerification: false, + }); + + assertEquals(verification.verified, true); + } finally { + mockDecodeClientData.restore(); + } +}); + +Deno.test('should throw when crossOrigin is true and topOrigin does not match any of expectedTopOrigin (array)', async () => { + const mockDecodeClientData = stub( + _decodeClientDataJSONInternals, + 'stubThis', + returnsNext([ + { + type: 'webauthn.get', + origin: assertionOrigin, + challenge: assertionChallenge, + crossOrigin: true, + topOrigin: 'https://wrong.top.origin.com', + }, + ]), + ); + + try { + await assertRejects( + () => + verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: assertionOrigin, + expectedRPID: 'dev.dontneeda.pw', + expectedTopOrigin: ['https://top.origin.com', 'https://other.origin.com'], + credential, + }), + Error, + 'Unexpected cross-origin authentication response top origin of "https://wrong.top.origin.com", expected one of: https://top.origin.com, https://other.origin.com', + ); + } finally { + mockDecodeClientData.restore(); + } +}); + +Deno.test('should throw when crossOrigin is true but expectedTopOrigin is not specified', async () => { + const mockDecodeClientData = stub( + _decodeClientDataJSONInternals, + 'stubThis', + returnsNext([ + { + type: 'webauthn.get', + origin: assertionOrigin, + challenge: assertionChallenge, + crossOrigin: true, + topOrigin: 'https://top.origin.com', + }, + ]), + ); + + try { + await assertRejects( + () => + verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: assertionOrigin, + expectedRPID: 'dev.dontneeda.pw', + credential, + }), + Error, + 'Detected cross-origin authentication response from top origin of "https://top.origin.com", but a value for `expectedTopOrigin` was not specified when calling `verifyAuthenticationResponse()`', + ); + } finally { + mockDecodeClientData.restore(); + } +}); + +Deno.test('should throw when topOrigin is set despite crossOrigin being false', async () => { + const mockDecodeClientData = stub( + _decodeClientDataJSONInternals, + 'stubThis', + returnsNext([ + { + type: 'webauthn.get', + origin: assertionOrigin, + challenge: assertionChallenge, + crossOrigin: false, + topOrigin: 'https://some.top.origin.com', + }, + ]), + ); + + try { + await assertRejects( + () => + verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: assertionOrigin, + expectedRPID: 'dev.dontneeda.pw', + credential, + requireUserVerification: false, + }), + Error, + 'Unexpected top origin of "https://some.top.origin.com" within a non-cross-origin authentication response. This error should be reported to the browser vendor as a WebAuthn specification violation with a link to https://w3c.github.io/webauthn/#dom-collectedclientdata-toporigin', + ); + } finally { + mockDecodeClientData.restore(); + } +}); + /** * Assertion examples below */ diff --git a/packages/server/src/authentication/verifyAuthenticationResponse.ts b/packages/server/src/authentication/verifyAuthenticationResponse.ts index 86fce0ef..97e80263 100644 --- a/packages/server/src/authentication/verifyAuthenticationResponse.ts +++ b/packages/server/src/authentication/verifyAuthenticationResponse.ts @@ -42,6 +42,7 @@ export async function verifyAuthenticationResponse( expectedRPID: string | string[]; credential: WebAuthnCredential; expectedType?: string | string[]; + expectedTopOrigin?: string | string[]; requireUserVerification?: boolean; advancedFIDOConfig?: { userVerification?: UserVerificationRequirement; @@ -54,6 +55,7 @@ export async function verifyAuthenticationResponse( expectedOrigin, expectedRPID, expectedType, + expectedTopOrigin, credential, requireUserVerification = true, advancedFIDOConfig, @@ -87,7 +89,7 @@ export async function verifyAuthenticationResponse( const clientDataJSON = decodeClientDataJSON(assertionResponse.clientDataJSON); - const { type, origin, challenge, tokenBinding } = clientDataJSON; + const { type, origin, challenge, tokenBinding, crossOrigin, topOrigin } = clientDataJSON; // Make sure we're handling an authentication if (Array.isArray(expectedType)) { @@ -120,6 +122,50 @@ export async function verifyAuthenticationResponse( ); } + // Check that the authentication response is within an expected iframe + if (crossOrigin) { + /** + * TODO: Since Safari doesn't support `topOrigin` as of May 2026, only check this when + * `topOrigin` is available for now. + */ + if (topOrigin) { + if (!expectedTopOrigin) { + /** + * If `expectedTopOrigin` is not set, this should be considered an unexpected cross-origin + * request. Reject the response while helping the RP understand how they need to update this + * method call if they want to support verification of such WebAuthn authentication requests. + */ + throw new Error( + `Detected cross-origin authentication response from top origin of "${topOrigin}", but a value for \`expectedTopOrigin\` was not specified when calling \`verifyAuthenticationResponse()\``, + ); + } + + if (Array.isArray(expectedTopOrigin)) { + if (!expectedTopOrigin.includes(topOrigin)) { + const joinedExpectedTopOrigin = expectedTopOrigin.join(', '); + throw new Error( + `Unexpected cross-origin authentication response top origin of "${topOrigin}", expected one of: ${joinedExpectedTopOrigin}`, + ); + } + } else { + if (topOrigin !== expectedTopOrigin) { + throw new Error( + `Unexpected cross-origin authentication response top origin of "${topOrigin}", expected: ${expectedTopOrigin}`, + ); + } + } + } + } else { + if (topOrigin) { + /** + * If `topOrigin` is set despite `crossOrigin` being false, this is an unexpected request. + * + * See https://w3c.github.io/webauthn/#dom-collectedclientdata-toporigin. + */ + throw new Error(`Unexpected top origin of "${topOrigin}" within a non-cross-origin authentication response. This error should be reported to the browser vendor as a WebAuthn specification violation with a link to https://w3c.github.io/webauthn/#dom-collectedclientdata-toporigin`); + } + } + // Check that the origin is our site if (Array.isArray(expectedOrigin)) { if (!expectedOrigin.includes(origin)) { diff --git a/packages/server/src/helpers/decodeClientDataJSON.test.ts b/packages/server/src/helpers/decodeClientDataJSON.test.ts index eefecb0d..cd946995 100644 --- a/packages/server/src/helpers/decodeClientDataJSON.test.ts +++ b/packages/server/src/helpers/decodeClientDataJSON.test.ts @@ -15,3 +15,18 @@ Deno.test('should convert base64url-encoded attestation clientDataJSON to JSON', }, ); }); + +Deno.test('should convert base64url-encoded clientDataJSON with crossOrigin and topOrigin to JSON', () => { + assertEquals( + decodeClientDataJSON( + 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiY2hhbGxlbmdlIiwib3JpZ2luIjoiaHR0cHM6Ly9vcmlnaW4uY29tIiwiY3Jvc3NPcmlnaW4iOnRydWUsInRvcE9yaWdpbiI6Imh0dHBzOi8vdG9wLm9yaWdpbi5jb20ifQ', + ), + { + type: 'webauthn.get', + challenge: 'challenge', + origin: 'https://origin.com', + crossOrigin: true, + topOrigin: 'https://top.origin.com', + }, + ); +}); diff --git a/packages/server/src/helpers/decodeClientDataJSON.ts b/packages/server/src/helpers/decodeClientDataJSON.ts index 22fef212..171ae297 100644 --- a/packages/server/src/helpers/decodeClientDataJSON.ts +++ b/packages/server/src/helpers/decodeClientDataJSON.ts @@ -16,6 +16,7 @@ export type ClientDataJSON = { challenge: string; origin: string; crossOrigin?: boolean; + topOrigin?: string; tokenBinding?: { id?: string; status: 'present' | 'supported' | 'not-supported';