Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 () => {
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}`,
);
}
} 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',
);
}
}

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