Skip to content

Commit cd0cfad

Browse files
authored
Merge pull request #773 from MasterKale/feat/cross-origin-authentication-verification
Cross-origin authentication verification (cherry-pick from master)
2 parents d508514 + f0ddba1 commit cd0cfad

4 files changed

Lines changed: 323 additions & 1 deletion

File tree

‎packages/server/src/authentication/verifyAuthenticationResponse.test.ts‎

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,266 @@ Deno.test('should return user verified flag after successful auth', async () =>
578578
assertFalse(verification.authenticationInfo?.userVerified);
579579
});
580580

581+
Deno.test('should verify when crossOrigin is true, topOrigin is missing, and expectedTopOrigin is not specified (Safari workaround)', async () => {
582+
const mockDecodeClientData = stub(
583+
_decodeClientDataJSONInternals,
584+
'stubThis',
585+
returnsNext([
586+
{
587+
type: 'webauthn.get',
588+
origin: assertionOrigin,
589+
challenge: assertionChallenge,
590+
crossOrigin: true,
591+
},
592+
]),
593+
);
594+
595+
try {
596+
const verification = await verifyAuthenticationResponse({
597+
response: assertionResponse,
598+
expectedChallenge: assertionChallenge,
599+
expectedOrigin: assertionOrigin,
600+
expectedRPID: 'dev.dontneeda.pw',
601+
credential,
602+
requireUserVerification: false,
603+
});
604+
605+
assertEquals(verification.verified, true);
606+
} finally {
607+
mockDecodeClientData.restore();
608+
}
609+
});
610+
611+
Deno.test('should verify when crossOrigin is true, topOrigin is missing, but expectedTopOrigin is specified (Safari workaround)', async () => {
612+
const mockDecodeClientData = stub(
613+
_decodeClientDataJSONInternals,
614+
'stubThis',
615+
returnsNext([
616+
{
617+
type: 'webauthn.get',
618+
origin: assertionOrigin,
619+
challenge: assertionChallenge,
620+
crossOrigin: true,
621+
},
622+
]),
623+
);
624+
625+
try {
626+
const verification = await verifyAuthenticationResponse({
627+
response: assertionResponse,
628+
expectedChallenge: assertionChallenge,
629+
expectedOrigin: assertionOrigin,
630+
expectedRPID: 'dev.dontneeda.pw',
631+
expectedTopOrigin: 'https://top.origin.com',
632+
credential,
633+
requireUserVerification: false,
634+
});
635+
636+
assertEquals(verification.verified, true);
637+
} finally {
638+
mockDecodeClientData.restore();
639+
}
640+
});
641+
642+
Deno.test('should verify when crossOrigin is true and topOrigin matches expectedTopOrigin (string)', async () => {
643+
const mockDecodeClientData = stub(
644+
_decodeClientDataJSONInternals,
645+
'stubThis',
646+
returnsNext([
647+
{
648+
type: 'webauthn.get',
649+
origin: assertionOrigin,
650+
challenge: assertionChallenge,
651+
crossOrigin: true,
652+
topOrigin: 'https://top.origin.com',
653+
},
654+
]),
655+
);
656+
657+
try {
658+
const verification = await verifyAuthenticationResponse({
659+
response: assertionResponse,
660+
expectedChallenge: assertionChallenge,
661+
expectedOrigin: assertionOrigin,
662+
expectedRPID: 'dev.dontneeda.pw',
663+
expectedTopOrigin: 'https://top.origin.com',
664+
credential,
665+
requireUserVerification: false,
666+
});
667+
668+
assertEquals(verification.verified, true);
669+
} finally {
670+
mockDecodeClientData.restore();
671+
}
672+
});
673+
674+
Deno.test('should throw when crossOrigin is true and topOrigin does not match expectedTopOrigin (string)', async () => {
675+
const mockDecodeClientData = stub(
676+
_decodeClientDataJSONInternals,
677+
'stubThis',
678+
returnsNext([
679+
{
680+
type: 'webauthn.get',
681+
origin: assertionOrigin,
682+
challenge: assertionChallenge,
683+
crossOrigin: true,
684+
topOrigin: 'https://wrong.top.origin.com',
685+
},
686+
]),
687+
);
688+
689+
try {
690+
await assertRejects(
691+
() =>
692+
verifyAuthenticationResponse({
693+
response: assertionResponse,
694+
expectedChallenge: assertionChallenge,
695+
expectedOrigin: assertionOrigin,
696+
expectedRPID: 'dev.dontneeda.pw',
697+
expectedTopOrigin: 'https://top.origin.com',
698+
credential,
699+
}),
700+
Error,
701+
'Unexpected cross-origin authentication response top origin of "https://wrong.top.origin.com", expected: https://top.origin.com',
702+
);
703+
} finally {
704+
mockDecodeClientData.restore();
705+
}
706+
});
707+
708+
Deno.test('should verify when crossOrigin is true and topOrigin matches one of expectedTopOrigin (array)', async () => {
709+
const mockDecodeClientData = stub(
710+
_decodeClientDataJSONInternals,
711+
'stubThis',
712+
returnsNext([
713+
{
714+
type: 'webauthn.get',
715+
origin: assertionOrigin,
716+
challenge: assertionChallenge,
717+
crossOrigin: true,
718+
topOrigin: 'https://top.origin.com',
719+
},
720+
]),
721+
);
722+
723+
try {
724+
const verification = await verifyAuthenticationResponse({
725+
response: assertionResponse,
726+
expectedChallenge: assertionChallenge,
727+
expectedOrigin: assertionOrigin,
728+
expectedRPID: 'dev.dontneeda.pw',
729+
expectedTopOrigin: ['https://other.origin.com', 'https://top.origin.com'],
730+
credential,
731+
requireUserVerification: false,
732+
});
733+
734+
assertEquals(verification.verified, true);
735+
} finally {
736+
mockDecodeClientData.restore();
737+
}
738+
});
739+
740+
Deno.test('should throw when crossOrigin is true and topOrigin does not match any of expectedTopOrigin (array)', async () => {
741+
const mockDecodeClientData = stub(
742+
_decodeClientDataJSONInternals,
743+
'stubThis',
744+
returnsNext([
745+
{
746+
type: 'webauthn.get',
747+
origin: assertionOrigin,
748+
challenge: assertionChallenge,
749+
crossOrigin: true,
750+
topOrigin: 'https://wrong.top.origin.com',
751+
},
752+
]),
753+
);
754+
755+
try {
756+
await assertRejects(
757+
() =>
758+
verifyAuthenticationResponse({
759+
response: assertionResponse,
760+
expectedChallenge: assertionChallenge,
761+
expectedOrigin: assertionOrigin,
762+
expectedRPID: 'dev.dontneeda.pw',
763+
expectedTopOrigin: ['https://top.origin.com', 'https://other.origin.com'],
764+
credential,
765+
}),
766+
Error,
767+
'Unexpected cross-origin authentication response top origin of "https://wrong.top.origin.com", expected one of: https://top.origin.com, https://other.origin.com',
768+
);
769+
} finally {
770+
mockDecodeClientData.restore();
771+
}
772+
});
773+
774+
Deno.test('should throw when crossOrigin is true but expectedTopOrigin is not specified', async () => {
775+
const mockDecodeClientData = stub(
776+
_decodeClientDataJSONInternals,
777+
'stubThis',
778+
returnsNext([
779+
{
780+
type: 'webauthn.get',
781+
origin: assertionOrigin,
782+
challenge: assertionChallenge,
783+
crossOrigin: true,
784+
topOrigin: 'https://top.origin.com',
785+
},
786+
]),
787+
);
788+
789+
try {
790+
await assertRejects(
791+
() =>
792+
verifyAuthenticationResponse({
793+
response: assertionResponse,
794+
expectedChallenge: assertionChallenge,
795+
expectedOrigin: assertionOrigin,
796+
expectedRPID: 'dev.dontneeda.pw',
797+
credential,
798+
}),
799+
Error,
800+
'Detected cross-origin authentication response from top origin of "https://top.origin.com", but a value for `expectedTopOrigin` was not specified when calling `verifyAuthenticationResponse()`',
801+
);
802+
} finally {
803+
mockDecodeClientData.restore();
804+
}
805+
});
806+
807+
Deno.test('should throw when topOrigin is set despite crossOrigin being false', async () => {
808+
const mockDecodeClientData = stub(
809+
_decodeClientDataJSONInternals,
810+
'stubThis',
811+
returnsNext([
812+
{
813+
type: 'webauthn.get',
814+
origin: assertionOrigin,
815+
challenge: assertionChallenge,
816+
crossOrigin: false,
817+
topOrigin: 'https://some.top.origin.com',
818+
},
819+
]),
820+
);
821+
822+
try {
823+
await assertRejects(
824+
() =>
825+
verifyAuthenticationResponse({
826+
response: assertionResponse,
827+
expectedChallenge: assertionChallenge,
828+
expectedOrigin: assertionOrigin,
829+
expectedRPID: 'dev.dontneeda.pw',
830+
credential,
831+
requireUserVerification: false,
832+
}),
833+
Error,
834+
'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',
835+
);
836+
} finally {
837+
mockDecodeClientData.restore();
838+
}
839+
});
840+
581841
/**
582842
* Assertion examples below
583843
*/

‎packages/server/src/authentication/verifyAuthenticationResponse.ts‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export async function verifyAuthenticationResponse(
4242
expectedRPID: string | string[];
4343
credential: WebAuthnCredential;
4444
expectedType?: string | string[];
45+
expectedTopOrigin?: string | string[];
4546
requireUserVerification?: boolean;
4647
advancedFIDOConfig?: {
4748
userVerification?: UserVerificationRequirement;
@@ -54,6 +55,7 @@ export async function verifyAuthenticationResponse(
5455
expectedOrigin,
5556
expectedRPID,
5657
expectedType,
58+
expectedTopOrigin,
5759
credential,
5860
requireUserVerification = true,
5961
advancedFIDOConfig,
@@ -87,7 +89,7 @@ export async function verifyAuthenticationResponse(
8789

8890
const clientDataJSON = decodeClientDataJSON(assertionResponse.clientDataJSON);
8991

90-
const { type, origin, challenge, tokenBinding } = clientDataJSON;
92+
const { type, origin, challenge, tokenBinding, crossOrigin, topOrigin } = clientDataJSON;
9193

9294
// Make sure we're handling an authentication
9395
if (Array.isArray(expectedType)) {
@@ -120,6 +122,50 @@ export async function verifyAuthenticationResponse(
120122
);
121123
}
122124

125+
// Check that the authentication response is within an expected iframe
126+
if (crossOrigin) {
127+
/**
128+
* TODO: Since Safari doesn't support `topOrigin` as of May 2026, only check this when
129+
* `topOrigin` is available for now.
130+
*/
131+
if (topOrigin) {
132+
if (!expectedTopOrigin) {
133+
/**
134+
* If `expectedTopOrigin` is not set, this should be considered an unexpected cross-origin
135+
* request. Reject the response while helping the RP understand how they need to update this
136+
* method call if they want to support verification of such WebAuthn authentication requests.
137+
*/
138+
throw new Error(
139+
`Detected cross-origin authentication response from top origin of "${topOrigin}", but a value for \`expectedTopOrigin\` was not specified when calling \`verifyAuthenticationResponse()\``,
140+
);
141+
}
142+
143+
if (Array.isArray(expectedTopOrigin)) {
144+
if (!expectedTopOrigin.includes(topOrigin)) {
145+
const joinedExpectedTopOrigin = expectedTopOrigin.join(', ');
146+
throw new Error(
147+
`Unexpected cross-origin authentication response top origin of "${topOrigin}", expected one of: ${joinedExpectedTopOrigin}`,
148+
);
149+
}
150+
} else {
151+
if (topOrigin !== expectedTopOrigin) {
152+
throw new Error(
153+
`Unexpected cross-origin authentication response top origin of "${topOrigin}", expected: ${expectedTopOrigin}`,
154+
);
155+
}
156+
}
157+
}
158+
} else {
159+
if (topOrigin) {
160+
/**
161+
* If `topOrigin` is set despite `crossOrigin` being false, this is an unexpected request.
162+
*
163+
* See https://w3c.github.io/webauthn/#dom-collectedclientdata-toporigin.
164+
*/
165+
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`);
166+
}
167+
}
168+
123169
// Check that the origin is our site
124170
if (Array.isArray(expectedOrigin)) {
125171
if (!expectedOrigin.includes(origin)) {

‎packages/server/src/helpers/decodeClientDataJSON.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,18 @@ Deno.test('should convert base64url-encoded attestation clientDataJSON to JSON',
1515
},
1616
);
1717
});
18+
19+
Deno.test('should convert base64url-encoded clientDataJSON with crossOrigin and topOrigin to JSON', () => {
20+
assertEquals(
21+
decodeClientDataJSON(
22+
'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiY2hhbGxlbmdlIiwib3JpZ2luIjoiaHR0cHM6Ly9vcmlnaW4uY29tIiwiY3Jvc3NPcmlnaW4iOnRydWUsInRvcE9yaWdpbiI6Imh0dHBzOi8vdG9wLm9yaWdpbi5jb20ifQ',
23+
),
24+
{
25+
type: 'webauthn.get',
26+
challenge: 'challenge',
27+
origin: 'https://origin.com',
28+
crossOrigin: true,
29+
topOrigin: 'https://top.origin.com',
30+
},
31+
);
32+
});

‎packages/server/src/helpers/decodeClientDataJSON.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export type ClientDataJSON = {
1616
challenge: string;
1717
origin: string;
1818
crossOrigin?: boolean;
19+
topOrigin?: string;
1920
tokenBinding?: {
2021
id?: string;
2122
status: 'present' | 'supported' | 'not-supported';

0 commit comments

Comments
 (0)