@@ -2,6 +2,7 @@ import { http, HttpResponse } from 'msw';
22import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest' ;
33
44import type { APIKey , IdPOAuthAccessToken , M2MToken } from '../../api' ;
5+ import { MachineTokenVerificationError , MachineTokenVerificationErrorCode } from '../../errors' ;
56import {
67 createJwt ,
78 mockJwks ,
@@ -32,7 +33,10 @@ async function createSignedOAuthJwt(
3233 return data ! ;
3334}
3435
35- async function createSignedM2MJwt ( payload = mockM2MJwtPayload , cat : string | undefined = JWT_CATEGORY_M2M_TOKEN ) {
36+ async function createSignedM2MJwt (
37+ payload : Record < string , unknown > = mockM2MJwtPayload ,
38+ cat : string | undefined = JWT_CATEGORY_M2M_TOKEN ,
39+ ) {
3640 const { data } = await signJwt ( payload , signingJwks , {
3741 algorithm : 'RS256' ,
3842 header : { typ : 'JWT' , kid : 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' , ...( cat !== undefined ? { cat } : { } ) } ,
@@ -69,6 +73,19 @@ describe('tokens.verify(token, options)', () => {
6973 expect ( data ) . toEqual ( mockJwtPayload ) ;
7074 } ) ;
7175
76+ it ( 'continues accepting session JWTs without aud when audience is configured' , async ( ) => {
77+ server . use ( http . get ( 'https://api.clerk.test/v1/jwks' , ( ) => HttpResponse . json ( mockJwks ) ) ) ;
78+
79+ const result = await verifyToken ( mockJwt , {
80+ apiUrl : 'https://api.clerk.test' ,
81+ secretKey : 'a-valid-key' ,
82+ audience : 'https://resource.example.com' ,
83+ } ) ;
84+
85+ expect ( result . data ) . toEqual ( mockJwtPayload ) ;
86+ expect ( result . errors ) . toBeUndefined ( ) ;
87+ } ) ;
88+
7289 it ( 'verifies the token by fetching the JWKs from Backend API when secretKey is provided' , async ( ) => {
7390 server . use (
7491 http . get (
@@ -263,6 +280,101 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => {
263280 expect ( data . aud ) . toEqual ( aud ) ;
264281 } ) ;
265282
283+ describe . each ( [ 'opaque' , 'at+jwt' , 'application/at+jwt' ] as const ) ( '%s OAuth token audience verification' , format => {
284+ const audience = 'https://resource.example.com' ;
285+ const otherAudience = 'https://other.example.com' ;
286+
287+ beforeEach ( ( ) => {
288+ vi . setSystemTime ( new Date ( mockOAuthAccessTokenJwtPayload . iat * 1000 ) ) ;
289+ } ) ;
290+
291+ async function verifyWithAudience ( aud : unknown , audience ?: string | string [ ] ) {
292+ let token : string ;
293+ if ( format === 'opaque' ) {
294+ token = 'oat_8XOIucKvqHVr5tYP123456789abcdefghij' ;
295+ server . use (
296+ http . post ( 'https://api.clerk.test/oauth_applications/access_tokens/verify' , ( ) => {
297+ return HttpResponse . json ( { ...mockVerificationResults . oauth_token , aud } ) ;
298+ } ) ,
299+ ) ;
300+ } else {
301+ server . use ( http . get ( 'https://api.clerk.test/v1/jwks' , ( ) => HttpResponse . json ( mockJwks ) ) ) ;
302+ token = await createSignedOAuthJwt ( { ...mockOAuthAccessTokenJwtPayload , aud } , format ) ;
303+ }
304+
305+ return verifyMachineAuthToken ( token , {
306+ apiUrl : 'https://api.clerk.test' ,
307+ secretKey : 'a-valid-key' ,
308+ audience,
309+ } ) ;
310+ }
311+
312+ it . each ( [
313+ { aud : audience , audience } ,
314+ { aud : audience , audience : [ otherAudience , audience ] } ,
315+ { aud : [ otherAudience , audience ] , audience } ,
316+ { aud : [ otherAudience , audience ] , audience : [ audience ] } ,
317+ { aud : undefined , audience : undefined } ,
318+ { aud : undefined , audience : '' } ,
319+ { aud : undefined , audience : [ ] } ,
320+ { aud : '' , audience : undefined } ,
321+ { aud : [ ] , audience : undefined } ,
322+ { aud : otherAudience , audience : undefined } ,
323+ { aud : otherAudience , audience : '' } ,
324+ { aud : otherAudience , audience : [ ] } ,
325+ ] ) ( 'accepts aud=$aud with audience=$audience' , async ( { aud, audience } ) => {
326+ const result = await verifyWithAudience ( aud , audience ) ;
327+
328+ expect ( result . tokenType ) . toBe ( 'oauth_token' ) ;
329+ expect ( result . data ) . toBeDefined ( ) ;
330+ expect ( ( result . data as IdPOAuthAccessToken ) . aud ) . toEqual ( aud ) ;
331+ expect ( result . errors ) . toBeUndefined ( ) ;
332+ } ) ;
333+
334+ it . each ( [
335+ { aud : otherAudience , audience } ,
336+ { aud : otherAudience , audience : [ audience ] } ,
337+ { aud : [ otherAudience ] , audience } ,
338+ { aud : [ otherAudience ] , audience : [ audience ] } ,
339+ { aud : `${ audience } /other` , audience } ,
340+ { aud : audience . toUpperCase ( ) , audience } ,
341+ ] ) ( 'rejects aud=$aud with audience=$audience' , async ( { aud, audience } ) => {
342+ const result = await verifyWithAudience ( aud , audience ) ;
343+
344+ expect ( result . tokenType ) . toBe ( 'oauth_token' ) ;
345+ expect ( result . data ) . toBeUndefined ( ) ;
346+ expect ( result . errors ) . toHaveLength ( 1 ) ;
347+ expect ( result . errors ! [ 0 ] ) . toBeInstanceOf ( MachineTokenVerificationError ) ;
348+ expect ( result . errors ! [ 0 ] ) . toMatchObject ( {
349+ code : MachineTokenVerificationErrorCode . TokenVerificationFailed ,
350+ message : expect . stringContaining ( 'Invalid JWT audience claim' ) ,
351+ } ) ;
352+ } ) ;
353+
354+ it . each ( [ undefined , null , '' , [ ] , [ '' ] , [ audience , '' ] , 42 , true , { } , [ audience , 42 ] ] . map ( aud => ( { aud } ) ) ) (
355+ 'rejects missing, empty, or malformed aud=$aud when audience is configured' ,
356+ async ( { aud } ) => {
357+ const result = await verifyWithAudience ( aud , audience ) ;
358+
359+ expect ( result . tokenType ) . toBe ( 'oauth_token' ) ;
360+ expect ( result . data ) . toBeUndefined ( ) ;
361+ expect ( result . errors ) . toHaveLength ( 1 ) ;
362+ expect ( result . errors ! [ 0 ] ) . toBeInstanceOf ( MachineTokenVerificationError ) ;
363+ expect ( result . errors ! [ 0 ] ) . toMatchObject ( {
364+ code : MachineTokenVerificationErrorCode . TokenVerificationFailed ,
365+ message : expect . stringContaining ( 'Invalid OAuth audience claim' ) ,
366+ } ) ;
367+ } ,
368+ ) ;
369+
370+ it ( 'rejects missing aud when audience is an array' , async ( ) => {
371+ const result = await verifyWithAudience ( undefined , [ audience ] ) ;
372+
373+ expect ( result . data ) . toBeUndefined ( ) ;
374+ expect ( result . errors ?. [ 0 ] . code ) . toBe ( MachineTokenVerificationErrorCode . TokenVerificationFailed ) ;
375+ } ) ;
376+ } ) ;
377+
266378 describe ( 'handles API errors for API keys' , ( ) => {
267379 it ( 'handles invalid token' , async ( ) => {
268380 const token = 'ak_invalid_token' ;
@@ -677,6 +789,21 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => {
677789 expect ( data . scopes ) . toEqual ( [ 'mch_1xxxxx' , 'mch_2xxxxx' ] ) ;
678790 } ) ;
679791
792+ it ( 'continues accepting M2M JWTs without aud when audience is configured' , async ( ) => {
793+ server . use ( http . get ( 'https://api.clerk.test/v1/jwks' , ( ) => HttpResponse . json ( mockJwks ) ) ) ;
794+ const token = await createSignedM2MJwt ( { ...mockM2MJwtPayload , aud : undefined } ) ;
795+
796+ const result = await verifyMachineAuthToken ( token , {
797+ apiUrl : 'https://api.clerk.test' ,
798+ secretKey : 'a-valid-key' ,
799+ audience : 'https://resource.example.com' ,
800+ } ) ;
801+
802+ expect ( result . tokenType ) . toBe ( 'm2m_token' ) ;
803+ expect ( result . data ) . toBeDefined ( ) ;
804+ expect ( result . errors ) . toBeUndefined ( ) ;
805+ } ) ;
806+
680807 it ( 'rejects M2M JWT with alg: none' , async ( ) => {
681808 server . use (
682809 http . get (
0 commit comments