Skip to content

Commit 292ba2a

Browse files
wobsorianocoderabbitai[bot]jfoshee
authored
chore(repo): Backport support for JWTs in OAuth token type (#7308) (#7394)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Jacob Foshee <jacob.foshee@clerk.dev>
1 parent 21a1cd9 commit 292ba2a

File tree

13 files changed

+627
-22
lines changed

13 files changed

+627
-22
lines changed

packages/backend/src/api/resources/IdPOAuthAccessToken.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
1+
import type { JwtPayload } from '@clerk/shared/types';
2+
13
import type { IdPOAuthAccessTokenJSON } from './JSON';
24

5+
type OAuthJwtPayload = JwtPayload & {
6+
jti?: string;
7+
client_id?: string;
8+
scope?: string;
9+
scp?: string[];
10+
};
11+
312
export class IdPOAuthAccessToken {
413
constructor(
514
readonly id: string,
@@ -30,4 +39,27 @@ export class IdPOAuthAccessToken {
3039
data.updated_at,
3140
);
3241
}
42+
43+
/**
44+
* Creates an IdPOAuthAccessToken from a JWT payload.
45+
* Maps standard JWT claims and OAuth-specific fields to token properties.
46+
*/
47+
static fromJwtPayload(payload: JwtPayload, clockSkewInMs = 5000): IdPOAuthAccessToken {
48+
const oauthPayload = payload as OAuthJwtPayload;
49+
50+
// Map JWT claims to IdPOAuthAccessToken fields
51+
return new IdPOAuthAccessToken(
52+
oauthPayload.jti ?? '',
53+
oauthPayload.client_id ?? '',
54+
'oauth_token',
55+
payload.sub,
56+
oauthPayload.scp ?? oauthPayload.scope?.split(' ') ?? [],
57+
false,
58+
null,
59+
payload.exp * 1000 <= Date.now() - clockSkewInMs,
60+
payload.exp,
61+
payload.iat,
62+
payload.iat,
63+
);
64+
}
3365
}

packages/backend/src/errors.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export const MachineTokenVerificationErrorCode = {
7474
TokenInvalid: 'token-invalid',
7575
InvalidSecretKey: 'secret-key-invalid',
7676
UnexpectedError: 'unexpected-error',
77+
TokenVerificationFailed: 'token-verification-failed',
7778
} as const;
7879

7980
export type MachineTokenVerificationErrorCode =
@@ -82,17 +83,29 @@ export type MachineTokenVerificationErrorCode =
8283
export class MachineTokenVerificationError extends Error {
8384
code: MachineTokenVerificationErrorCode;
8485
long_message?: string;
85-
status: number;
86+
status?: number;
87+
action?: TokenVerificationErrorAction;
8688

87-
constructor({ message, code, status }: { message: string; code: MachineTokenVerificationErrorCode; status: number }) {
89+
constructor({
90+
message,
91+
code,
92+
status,
93+
action,
94+
}: {
95+
message: string;
96+
code: MachineTokenVerificationErrorCode;
97+
status?: number;
98+
action?: TokenVerificationErrorAction;
99+
}) {
88100
super(message);
89101
Object.setPrototypeOf(this, MachineTokenVerificationError.prototype);
90102

91103
this.code = code;
92104
this.status = status;
105+
this.action = action;
93106
}
94107

95108
public getFullMessage() {
96-
return `${this.message} (code=${this.code}, status=${this.status})`;
109+
return `${this.message} (code=${this.code}, status=${this.status || 'n/a'})`;
97110
}
98111
}

packages/backend/src/fixtures/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ export const mockJwtPayload = {
3333
sub: 'user_2GIpXOEpVyJw51rkZn9Kmnc6Sxr',
3434
};
3535

36+
export const mockOAuthAccessTokenJwtPayload = {
37+
...mockJwtPayload,
38+
iss: 'https://clerk.oauth.example.test',
39+
sub: 'user_2vYVtestTESTtestTESTtestTESTtest',
40+
client_id: 'client_2VTWUzvGC5UhdJCNx6xG1D98edc',
41+
scope: 'read:foo write:bar',
42+
jti: 'oat_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE',
43+
exp: mockJwtPayload.iat + 300,
44+
iat: mockJwtPayload.iat,
45+
nbf: mockJwtPayload.iat - 10,
46+
};
47+
3648
export const mockRsaJwkKid = 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD';
3749

3850
export const mockRsaJwk = {

packages/backend/src/fixtures/machine.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,17 @@ export const mockMachineAuthResponses = {
6565
errorMessage: 'Machine token not found',
6666
},
6767
} as const;
68+
69+
// Valid OAuth access token JWT with typ: "at+jwt"
70+
// Header: {"alg":"RS256","kid":"ins_2GIoQhbUpy0hX7B2cVkuTMinXoD","typ":"at+jwt"}
71+
// Payload: {"client_id":"client_2VTWUzvGC5UhdJCNx6xG1D98edc","sub":"user_2vYVtestTESTtestTESTtestTESTtest","jti":"oat_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE","iat":1666648250,"exp":1666648550,"scope":"read:foo write:bar"}
72+
// Signed with signingJwks, verifiable with mockJwks
73+
export const mockSignedOAuthAccessTokenJwt =
74+
'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJhdCtqd3QifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsImV4cCI6MTY2NjY0ODU1MCwiaWF0IjoxNjY2NjQ4MjUwLCJpc3MiOiJodHRwczovL2NsZXJrLm9hdXRoLmV4YW1wbGUudGVzdCIsIm5iZiI6MTY2NjY0ODI0MCwic2lkIjoic2Vzc18yR2JEQjRlbk5kQ2E1dlMxenBDM1h6Zzl0SzkiLCJzdWIiOiJ1c2VyXzJ2WVZ0ZXN0VEVTVHRlc3RURVNUdGVzdFRFU1R0ZXN0IiwiY2xpZW50X2lkIjoiY2xpZW50XzJWVFdVenZHQzVVaGRKQ054NnhHMUQ5OGVkYyIsInNjb3BlIjoicmVhZDpmb28gd3JpdGU6YmFyIiwianRpIjoib2F0XzJ4S2E5Qmd2N054TVJERnlRdzhMcFozY1RtVTF2SGpFIn0.Wgw5L2u0nGkxF9Y-5Dje414UEkxq2Fu3_VePeh1-GehCugi0eIXV-QyiXp1ba4pxWWbCfIC_hihzKjwnVb5wrhzqyw8FJpvnvtrHEjt-zSijpS7WlO7ScJDY-PE8zgH-CICnS2CKYSkP3Rbzka9XY_Z6ieUzmBSFdA_0K8pQOdDHv70y04dnL1CjL6XToncnvezioL388Y1UTqlhll8b2Pm4EI7rGdHVKzLcKnKoYpgsBPZLmO7qGPJ5BkHvmg3gOSkmIiziFaEZkoXvjbvEUAt5qEqzaADSaFP6QhRYNtr1s4OD9uj0SK6QaoZTj69XYFuNMNnm7zN_WxvPBMTq9g';
75+
76+
// Valid OAuth access token JWT with typ: "application/at+jwt"
77+
// Header: {"alg":"RS256","kid":"ins_2GIoQhbUpy0hX7B2cVkuTMinXoD","typ":"application/at+jwt"}
78+
// Payload: {"client_id":"client_2VTWUzvGC5UhdJCNx6xG1D98edc","sub":"user_2vYVtestTESTtestTESTtestTESTtest","jti":"oat_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE","iat":1666648250,"exp":1666648550,"scope":"read:foo write:bar"}
79+
// Signed with signingJwks, verifiable with mockJwks
80+
export const mockSignedOAuthAccessTokenJwtApplicationTyp =
81+
'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJhcHBsaWNhdGlvbi9hdCtqd3QifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsImV4cCI6MTY2NjY0ODU1MCwiaWF0IjoxNjY2NjQ4MjUwLCJpc3MiOiJodHRwczovL2NsZXJrLm9hdXRoLmV4YW1wbGUudGVzdCIsIm5iZiI6MTY2NjY0ODI0MCwic2lkIjoic2Vzc18yR2JEQjRlbk5kQ2E1dlMxenBDM1h6Zzl0SzkiLCJzdWIiOiJ1c2VyXzJ2WVZ0ZXN0VEVTVHRlc3RURVNUdGVzdFRFU1R0ZXN0IiwiY2xpZW50X2lkIjoiY2xpZW50XzJWVFdVenZHQzVVaGRKQ054NnhHMUQ5OGVkYyIsInNjb3BlIjoicmVhZDpmb28gd3JpdGU6YmFyIiwianRpIjoib2F0XzJ4S2E5Qmd2N054TVJERnlRdzhMcFozY1RtVTF2SGpFIn0.GPTvB4doScjzQD0kRMhMebVDREjwcrMWK73OP_kFc3pl0gST29BlWrKMBi8wRxoSJBc2ukO10BPhGxnh15PxCNLyk6xQFWhFBA7XpVxY4T_VHPDU5FEOocPQuqcqZ4cA1GDJST-BH511fxoJnv4kfha46IvQiUMvWCacIj_w12qfZigeb208mTDIeoJQtlYb-sD9u__CVvB4uZOqGb0lIL5-cCbhMPFg-6GQ2DhZ-Eq5tw7oyO6lPrsAaFN9u-59SLvips364ieYNpgcr9Dbo5PDvUSltqxoIXTDFo4esWw6XwUjnGfqCh34LYAhv_2QF2U0-GASBEn4GK-Wfv3wXg';

packages/backend/src/jwt/__tests__/assertions.test.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,15 +106,47 @@ describe('assertAudienceClaim(audience?, aud?)', () => {
106106
});
107107
});
108108

109-
describe('assertHeaderType(typ?)', () => {
109+
describe('assertHeaderType(typ?, allowedTypes?)', () => {
110110
it('does not throw error if type is missing', () => {
111111
expect(() => assertHeaderType(undefined)).not.toThrow();
112+
expect(() => assertHeaderType(undefined, 'JWT')).not.toThrow();
113+
expect(() => assertHeaderType(undefined, ['JWT', 'at+jwt'])).not.toThrow();
112114
});
113115

114-
it('throws error if type is not JWT', () => {
116+
it('does not throw error if type matches default allowed type (JWT)', () => {
117+
expect(() => assertHeaderType('JWT')).not.toThrow();
118+
});
119+
120+
it('throws error if type is not JWT (default)', () => {
115121
expect(() => assertHeaderType('')).toThrow(`Invalid JWT type "". Expected "JWT".`);
116122
expect(() => assertHeaderType('Aloha')).toThrow(`Invalid JWT type "Aloha". Expected "JWT".`);
117123
});
124+
125+
it('does not throw error if type matches single custom allowed type', () => {
126+
expect(() => assertHeaderType('at+jwt', 'at+jwt')).not.toThrow();
127+
expect(() => assertHeaderType('application/at+jwt', 'application/at+jwt')).not.toThrow();
128+
});
129+
130+
it('throws error if type does not match single custom allowed type', () => {
131+
expect(() => assertHeaderType('JWT', 'at+jwt')).toThrow(`Invalid JWT type "JWT". Expected "at+jwt".`);
132+
expect(() => assertHeaderType('at+jwt', 'JWT')).toThrow(`Invalid JWT type "at+jwt". Expected "JWT".`);
133+
});
134+
135+
it('does not throw error if type matches array of allowed types', () => {
136+
expect(() => assertHeaderType('JWT', ['JWT', 'at+jwt'])).not.toThrow();
137+
expect(() => assertHeaderType('at+jwt', ['JWT', 'at+jwt'])).not.toThrow();
138+
expect(() => assertHeaderType('at+jwt', ['at+jwt', 'application/at+jwt'])).not.toThrow();
139+
expect(() => assertHeaderType('application/at+jwt', ['at+jwt', 'application/at+jwt'])).not.toThrow();
140+
});
141+
142+
it('throws error if type does not match any in array of allowed types', () => {
143+
expect(() => assertHeaderType('JWT', ['at+jwt', 'application/at+jwt'])).toThrow(
144+
`Invalid JWT type "JWT". Expected "at+jwt, application/at+jwt".`,
145+
);
146+
expect(() => assertHeaderType('invalid', ['at+jwt', 'application/at+jwt'])).toThrow(
147+
`Invalid JWT type "invalid". Expected "at+jwt, application/at+jwt".`,
148+
);
149+
});
118150
});
119151

120152
describe('assertHeaderAlgorithm(alg)', () => {

packages/backend/src/jwt/__tests__/verifyJwt.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22

33
import {
4+
createJwt,
45
mockJwks,
56
mockJwt,
67
mockJwtHeader,
78
mockJwtPayload,
9+
mockOAuthAccessTokenJwtPayload,
810
pemEncodedPublicKey,
911
publicJwks,
1012
signedJwt,
1113
someOtherPublicKey,
1214
} from '../../fixtures';
15+
import { mockSignedOAuthAccessTokenJwt, mockSignedOAuthAccessTokenJwtApplicationTyp } from '../../fixtures/machine';
1316
import { decodeJwt, hasValidSignature, verifyJwt } from '../verifyJwt';
1417

1518
const invalidTokenError = {
@@ -129,4 +132,89 @@ describe('verifyJwt(jwt, options)', () => {
129132
const { errors: [error] = [] } = await verifyJwt('invalid-jwt', inputVerifyJwtOptions);
130133
expect(error).toMatchObject(invalidTokenError);
131134
});
135+
136+
it('verifies JWT with default headerType (JWT)', async () => {
137+
const inputVerifyJwtOptions = {
138+
key: mockJwks.keys[0],
139+
issuer: mockJwtPayload.iss,
140+
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
141+
};
142+
const { data } = await verifyJwt(mockJwt, inputVerifyJwtOptions);
143+
expect(data).toEqual(mockJwtPayload);
144+
});
145+
146+
it('verifies JWT with explicit headerType as string', async () => {
147+
const inputVerifyJwtOptions = {
148+
key: mockJwks.keys[0],
149+
issuer: mockJwtPayload.iss,
150+
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
151+
headerType: 'JWT',
152+
};
153+
const { data } = await verifyJwt(mockJwt, inputVerifyJwtOptions);
154+
expect(data).toEqual(mockJwtPayload);
155+
});
156+
157+
it('verifies OAuth JWT with headerType as array including at+jwt', async () => {
158+
const inputVerifyJwtOptions = {
159+
key: mockJwks.keys[0],
160+
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
161+
headerType: ['at+jwt', 'application/at+jwt'],
162+
};
163+
const { data } = await verifyJwt(mockSignedOAuthAccessTokenJwt, inputVerifyJwtOptions);
164+
expect(data).toBeDefined();
165+
expect(data?.sub).toBe('user_2vYVtestTESTtestTESTtestTESTtest');
166+
});
167+
168+
it('verifies OAuth JWT with headerType as array including application/at+jwt', async () => {
169+
const inputVerifyJwtOptions = {
170+
key: mockJwks.keys[0],
171+
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
172+
headerType: ['at+jwt', 'application/at+jwt'],
173+
};
174+
const { data } = await verifyJwt(mockSignedOAuthAccessTokenJwtApplicationTyp, inputVerifyJwtOptions);
175+
expect(data).toBeDefined();
176+
expect(data?.sub).toBe('user_2vYVtestTESTtestTESTtestTESTtest');
177+
});
178+
179+
it('rejects JWT when headerType does not match', async () => {
180+
const inputVerifyJwtOptions = {
181+
key: mockJwks.keys[0],
182+
issuer: mockJwtPayload.iss,
183+
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
184+
headerType: 'at+jwt',
185+
};
186+
const { errors: [error] = [] } = await verifyJwt(mockJwt, inputVerifyJwtOptions);
187+
expect(error).toBeDefined();
188+
expect(error?.message).toContain('Invalid JWT type');
189+
expect(error?.message).toContain('Expected "at+jwt"');
190+
});
191+
192+
it('rejects OAuth JWT when headerType does not match', async () => {
193+
const inputVerifyJwtOptions = {
194+
key: mockJwks.keys[0],
195+
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
196+
headerType: 'JWT',
197+
};
198+
const { errors: [error] = [] } = await verifyJwt(mockSignedOAuthAccessTokenJwt, inputVerifyJwtOptions);
199+
expect(error).toBeDefined();
200+
expect(error?.message).toContain('Invalid JWT type');
201+
expect(error?.message).toContain('Expected "JWT"');
202+
});
203+
204+
it('rejects JWT when headerType array does not include the token type', async () => {
205+
const jwtWithCustomTyp = createJwt({
206+
header: { typ: 'custom-type', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
207+
payload: mockOAuthAccessTokenJwtPayload,
208+
});
209+
210+
const inputVerifyJwtOptions = {
211+
key: mockJwks.keys[0],
212+
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
213+
headerType: ['at+jwt', 'application/at+jwt'],
214+
};
215+
const { errors: [error] = [] } = await verifyJwt(jwtWithCustomTyp, inputVerifyJwtOptions);
216+
expect(error).toBeDefined();
217+
expect(error?.message).toContain('Invalid JWT type');
218+
expect(error?.message).toContain('Expected "at+jwt, application/at+jwt"');
219+
});
132220
});

packages/backend/src/jwt/assertions.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,16 +47,17 @@ export const assertAudienceClaim = (aud?: unknown, audience?: unknown) => {
4747
}
4848
};
4949

50-
export const assertHeaderType = (typ?: unknown) => {
50+
export const assertHeaderType = (typ?: unknown, allowedTypes: string | string[] = 'JWT') => {
5151
if (typeof typ === 'undefined') {
5252
return;
5353
}
5454

55-
if (typ !== 'JWT') {
55+
const allowed = Array.isArray(allowedTypes) ? allowedTypes : [allowedTypes];
56+
if (!allowed.includes(typ as string)) {
5657
throw new TokenVerificationError({
5758
action: TokenVerificationErrorAction.EnsureClerkJWT,
5859
reason: TokenVerificationErrorReason.TokenInvalid,
59-
message: `Invalid JWT type ${JSON.stringify(typ)}. Expected "JWT".`,
60+
message: `Invalid JWT type ${JSON.stringify(typ)}. Expected "${allowed.join(', ')}".`,
6061
});
6162
}
6263
};

packages/backend/src/jwt/verifyJwt.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,18 @@ export type VerifyJwtOptions = {
119119
* @internal
120120
*/
121121
key: JsonWebKey | string;
122+
/**
123+
* A string or list of allowed [header types](https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.9).
124+
* @default 'JWT'
125+
*/
126+
headerType?: string | string[];
122127
};
123128

124129
export async function verifyJwt(
125130
token: string,
126131
options: VerifyJwtOptions,
127132
): Promise<JwtReturnType<JwtPayload, TokenVerificationError>> {
128-
const { audience, authorizedParties, clockSkewInMs, key } = options;
133+
const { audience, authorizedParties, clockSkewInMs, key, headerType } = options;
129134
const clockSkew = clockSkewInMs || DEFAULT_CLOCK_SKEW_IN_MS;
130135

131136
const { data: decoded, errors } = decodeJwt(token);
@@ -138,7 +143,7 @@ export async function verifyJwt(
138143
// Header verifications
139144
const { typ, alg } = header;
140145

141-
assertHeaderType(typ);
146+
assertHeaderType(typ, headerType);
142147
assertHeaderAlgorithm(alg);
143148

144149
// Payload verifications

0 commit comments

Comments
 (0)