Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export async function verifyAuthenticationResponse(
expectedRPID: string | string[];
credential: WebAuthnCredential;
expectedType?: string | string[];
expectedTopOrigin?: string | string[];
requireUserVerification?: boolean;
advancedFIDOConfig?: {
userVerification?: UserVerificationRequirement;
Expand All @@ -54,6 +55,7 @@ export async function verifyAuthenticationResponse(
expectedOrigin,
expectedRPID,
expectedType,
expectedTopOrigin,
credential,
requireUserVerification = true,
advancedFIDOConfig,
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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)) {
Expand Down
15 changes: 15 additions & 0 deletions packages/server/src/helpers/decodeClientDataJSON.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
);
});
1 change: 1 addition & 0 deletions packages/server/src/helpers/decodeClientDataJSON.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type ClientDataJSON = {
challenge: string;
origin: string;
crossOrigin?: boolean;
topOrigin?: string;
tokenBinding?: {
id?: string;
status: 'present' | 'supported' | 'not-supported';
Expand Down
Loading