Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,265 @@ Deno.test('should return user verified flag after successful auth', async () =>
assertFalse(verification.authenticationInfo?.userVerified);
});

Deno.test('should throw when crossOrigin is true, topOrigin is missing, and expectedTopOrigin is not specified', async () => {
const mockDecodeClientData = stub(
_decodeClientDataJSONInternals,
'stubThis',
returnsNext([
{
type: 'webauthn.get',
origin: assertionOrigin,
challenge: assertionChallenge,
crossOrigin: true,
},
]),
);

try {
await assertRejects(
() =>
verifyAuthenticationResponse({
response: assertionResponse,
expectedChallenge: assertionChallenge,
expectedOrigin: assertionOrigin,
expectedRPID: 'dev.dontneeda.pw',
credential,
}),
Error,
'Unexpected cross-origin request',
);
} 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 within "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 within "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,
'Unexpected cross-origin authentication within "https://top.origin.com", expected: undefined',
);
} finally {
mockDecodeClientData.restore();
}
});

Deno.test('should NOT check topOrigin when crossOrigin is false', async () => {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is an interesting one. According to the spec, topOrigin should only be set if crossOrigin is true:

It is set only if the call was made from context that is not same-origin with its ancestors, i.e. if crossOrigin is true.

https://w3c.github.io/webauthn/#dom-collectedclientdata-toporigin

Based on this I'd actually make the new code error out if crossOrigin is false but there's a topOrigin defined in clientDataJSON 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Please have a look.

const mockDecodeClientData = stub(
_decodeClientDataJSONInternals,
'stubThis',
returnsNext([
{
type: 'webauthn.get',
origin: assertionOrigin,
challenge: assertionChallenge,
crossOrigin: false,
topOrigin: 'https://some.top.origin.com',
},
]),
);

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();
}
});

/**
* 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,32 @@ 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 (Array.isArray(expectedTopOrigin)) {
if (!expectedTopOrigin.includes(topOrigin)) {
const joinedExpectedTopOrigin = expectedTopOrigin.join(', ');
throw new Error(
`Unexpected cross-origin authentication within "${topOrigin}", expected one of: ${joinedExpectedTopOrigin}`,
Comment thread
agektmr marked this conversation as resolved.
Outdated
);
}
} else {
if (topOrigin !== expectedTopOrigin) {
throw new Error(
`Unexpected cross-origin authentication within "${topOrigin}", expected: ${expectedTopOrigin}`,
);
}
}
} else if (!expectedTopOrigin) {
// If `expectedTopOrigin` is not set, this is an unexpected cross-origin request.
throw new Error(
'Unexpected cross-origin request',
Comment thread
agektmr marked this conversation as resolved.
Outdated
);
}
}
Comment thread
agektmr marked this conversation as resolved.

// 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