Skip to content

Commit 57aeba8

Browse files
Jacek Radkodominic-clerkclaudewobsoriano
authored
feat(backend): validate JWT issuer (iss) claim when an issuer option is provided (#8772)
Co-authored-by: Dominic Couture <dominic@clerk.dev> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: wobsoriano <sorianorobertc@gmail.com>
1 parent abec9c4 commit 57aeba8

8 files changed

Lines changed: 157 additions & 7 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/backend': minor
3+
---
4+
5+
Added an optional `issuer` option to `verifyJwt()`, `verifyToken()`, and `authenticateRequest()` to validate the JWT `iss` claim against a string or list of allowed strings. For `verifyToken()` and `authenticateRequest()`, this applies only to session tokens. Omitting `issuer` preserves existing behavior.

‎packages/backend/src/errors.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const TokenVerificationErrorReason = {
1515
TokenInvalid: 'token-invalid',
1616
TokenInvalidAlgorithm: 'token-invalid-algorithm',
1717
TokenInvalidAuthorizedParties: 'token-invalid-authorized-parties',
18+
TokenInvalidIssuer: 'token-invalid-issuer',
1819
TokenInvalidSignature: 'token-invalid-signature',
1920
TokenNotActiveYet: 'token-not-active-yet',
2021
TokenIatInTheFuture: 'token-iat-in-the-future',

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
assertHeaderAlgorithm,
99
assertHeaderType,
1010
assertIssuedAtClaim,
11+
assertIssuerClaim,
1112
assertOAuthAudienceClaim,
1213
assertSubClaim,
1314
} from '../assertions';
@@ -264,6 +265,43 @@ describe('assertAuthorizedPartiesClaim(azp?, authorizedParties?)', () => {
264265
});
265266
});
266267

268+
describe('assertIssuerClaim(iss, issuer?)', () => {
269+
const iss = 'https://clerk.example.com';
270+
271+
it('does not throw if no issuer is provided (opt-in)', () => {
272+
expect(() => assertIssuerClaim(iss)).not.toThrow();
273+
expect(() => assertIssuerClaim(iss, undefined)).not.toThrow();
274+
expect(() => assertIssuerClaim(undefined)).not.toThrow();
275+
expect(() => assertIssuerClaim(iss, '')).not.toThrow();
276+
expect(() => assertIssuerClaim(iss, [])).not.toThrow();
277+
});
278+
279+
it('does not throw if iss exactly matches the issuer string', () => {
280+
expect(() => assertIssuerClaim(iss, iss)).not.toThrow();
281+
});
282+
283+
it('does not throw if iss is included in the issuer list', () => {
284+
expect(() => assertIssuerClaim(iss, ['https://clerk.other.com', iss])).not.toThrow();
285+
});
286+
287+
it('throws if iss does not match the issuer string', () => {
288+
expect(() => assertIssuerClaim(iss, 'https://clerk.evil.com')).toThrow(
289+
`Invalid JWT issuer claim (iss) "https://clerk.example.com".`,
290+
);
291+
});
292+
293+
it('throws if iss is not included in the issuer list', () => {
294+
expect(() => assertIssuerClaim(iss, ['https://clerk.evil.com', 'https://clerk.other.com'])).toThrow(
295+
`Invalid JWT issuer claim (iss) "https://clerk.example.com".`,
296+
);
297+
});
298+
299+
it('throws if iss is missing or not a string when an issuer is required', () => {
300+
expect(() => assertIssuerClaim(undefined, iss)).toThrow(`Invalid JWT issuer claim (iss) undefined.`);
301+
expect(() => assertIssuerClaim(42, iss)).toThrow(`Invalid JWT issuer claim (iss) 42.`);
302+
});
303+
});
304+
267305
describe('assertExpirationClaim(exp, clockSkewInMs)', () => {
268306
beforeEach(() => {
269307
vi.useFakeTimers();

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,16 +119,44 @@ describe('verifyJwt(jwt, options)', () => {
119119
expect(data).toEqual(mockJwtPayload);
120120
});
121121

122-
it('returns the valid JWT payload if valid key & issuer method & azp is given', async () => {
122+
it('returns the valid JWT payload if valid key & issuer list & azp is given', async () => {
123123
const inputVerifyJwtOptions = {
124124
key: mockJwks.keys[0],
125-
issuer: (iss: string) => iss.startsWith('https://clerk'),
125+
issuer: ['https://clerk.other-app.com', mockJwtPayload.iss],
126126
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
127127
};
128128
const { data } = await verifyJwt(mockJwt, inputVerifyJwtOptions);
129129
expect(data).toEqual(mockJwtPayload);
130130
});
131131

132+
it('returns an error if the issuer string does not match the iss claim', async () => {
133+
const { errors: [error] = [] } = await verifyJwt(mockJwt, {
134+
key: mockJwks.keys[0],
135+
issuer: 'https://clerk.another-app.com',
136+
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
137+
});
138+
expect(error?.reason).toBe('token-invalid-issuer');
139+
expect(error?.message).toContain('Invalid JWT issuer claim (iss)');
140+
});
141+
142+
it('returns an error if no issuer in the list matches the iss claim', async () => {
143+
const { errors: [error] = [] } = await verifyJwt(mockJwt, {
144+
key: mockJwks.keys[0],
145+
issuer: ['https://clerk.another-app.com', 'https://clerk.other-app.com'],
146+
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
147+
});
148+
expect(error?.reason).toBe('token-invalid-issuer');
149+
});
150+
151+
it('does not validate the iss claim when no issuer is provided', async () => {
152+
const { data, errors } = await verifyJwt(mockJwt, {
153+
key: mockJwks.keys[0],
154+
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
155+
});
156+
expect(errors).toBeUndefined();
157+
expect(data).toEqual(mockJwtPayload);
158+
});
159+
132160
it('returns the valid JWT payload if valid key & issuer & list of azp (with empty string) is given', async () => {
133161
const inputVerifyJwtOptions = {
134162
key: mockJwks.keys[0],

‎packages/backend/src/jwt/assertions.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import { TokenVerificationError, TokenVerificationErrorAction, TokenVerificationErrorReason } from '../errors';
22
import { algs } from './algorithms';
33

4-
export type IssuerResolver = string | ((iss: string) => boolean);
5-
64
const isArrayString = (s: unknown): s is string[] => {
75
return Array.isArray(s) && s.length > 0 && s.every(a => typeof a === 'string');
86
};
@@ -126,6 +124,21 @@ export const assertAuthorizedPartiesClaim = (azp?: string, authorizedParties?: s
126124
}
127125
};
128126

127+
export const assertIssuerClaim = (iss: unknown, issuer?: string | string[]) => {
128+
const issuerList = [issuer].flat().filter(i => !!i);
129+
if (issuerList.length === 0) {
130+
return;
131+
}
132+
133+
if (typeof iss !== 'string' || !issuerList.includes(iss)) {
134+
throw new TokenVerificationError({
135+
action: TokenVerificationErrorAction.EnsureClerkJWT,
136+
reason: TokenVerificationErrorReason.TokenInvalidIssuer,
137+
message: `Invalid JWT issuer claim (iss) ${JSON.stringify(iss)}. Expected "${issuerList}".`,
138+
});
139+
}
140+
};
141+
129142
export const assertExpirationClaim = (exp: number, clockSkewInMs: number) => {
130143
if (typeof exp !== 'number') {
131144
throw new TokenVerificationError({

‎packages/backend/src/jwt/verifyJwt.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
assertHeaderAlgorithm,
1313
assertHeaderType,
1414
assertIssuedAtClaim,
15+
assertIssuerClaim,
1516
assertSubClaim,
1617
} from './assertions';
1718
import { importKey } from './cryptoKeys';
@@ -131,6 +132,7 @@ export type VerifyJwtOptions = {
131132
* @default 5000
132133
*/
133134
clockSkewInMs?: number;
135+
issuer?: string | string[];
134136
/**
135137
* @internal
136138
*/
@@ -146,7 +148,7 @@ export async function verifyJwt(
146148
token: string,
147149
options: VerifyJwtOptions,
148150
): Promise<JwtReturnType<JwtPayload, TokenVerificationError>> {
149-
const { audience, authorizedParties, clockSkewInMs, key, headerType } = options;
151+
const { audience, authorizedParties, clockSkewInMs, issuer, key, headerType } = options;
150152
const clockSkew =
151153
typeof clockSkewInMs === 'number' && Number.isFinite(clockSkewInMs) ? clockSkewInMs : DEFAULT_CLOCK_SKEW_IN_MS;
152154

@@ -194,11 +196,12 @@ export async function verifyJwt(
194196

195197
// Payload verifications (only after signature is confirmed valid)
196198
try {
197-
const { azp, sub, aud, iat, exp, nbf } = payload;
199+
const { azp, sub, aud, iss, iat, exp, nbf } = payload;
198200

199201
assertSubClaim(sub);
200202
assertAudienceClaim(aud, audience);
201203
assertAuthorizedPartiesClaim(azp, authorizedParties);
204+
assertIssuerClaim(iss, issuer);
202205
assertExpirationClaim(exp, clockSkew);
203206
assertActivationClaim(nbf, clockSkew);
204207
assertIssuedAtClaim(iat, clockSkew);

‎packages/backend/src/jwt/verifyMachineJwt.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,9 @@ async function resolveKeyAndVerifyJwt(
7272
}
7373

7474
const { data: payload, errors: verifyErrors } = await verifyJwt(token, {
75-
...options,
7675
key,
76+
audience: options.audience,
77+
clockSkewInMs: options.clockSkewInMs,
7778
...(headerType ? { headerType } : {}),
7879
});
7980

‎packages/backend/src/tokens/__tests__/verify.test.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,28 @@ describe('tokens.verify(token, options)', () => {
8686
expect(result.errors).toBeUndefined();
8787
});
8888

89+
it('rejects the token when the provided issuer option does not match the iss claim', async () => {
90+
server.use(
91+
http.get(
92+
'https://api.clerk.test/v1/jwks',
93+
validateHeaders(() => {
94+
return HttpResponse.json(mockJwks);
95+
}),
96+
),
97+
);
98+
99+
const { data, errors } = await verifyToken(mockJwt, {
100+
apiUrl: 'https://api.clerk.test',
101+
secretKey: 'a-valid-key',
102+
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
103+
issuer: 'https://clerk.another-app.com',
104+
skipJwksCache: true,
105+
});
106+
107+
expect(data).toBeUndefined();
108+
expect(errors?.[0].reason).toBe('token-invalid-issuer');
109+
});
110+
89111
it('verifies the token by fetching the JWKs from Backend API when secretKey is provided', async () => {
90112
server.use(
91113
http.get(
@@ -866,6 +888,45 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => {
866888
expect(result.errors).toBeUndefined();
867889
});
868890

891+
it('rejects an M2M JWT whose aud does not include the configured audience', async () => {
892+
server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks)));
893+
const token = await createSignedM2MJwt();
894+
895+
const result = await verifyMachineAuthToken(token, {
896+
apiUrl: 'https://api.clerk.test',
897+
secretKey: 'a-valid-key',
898+
audience: 'mch_3xxxxx',
899+
});
900+
901+
expect(result.tokenType).toBe('m2m_token');
902+
expect(result.data).toBeUndefined();
903+
expect(result.errors?.[0].code).toBe('token-verification-failed');
904+
expect(result.errors?.[0].message).toContain('Invalid JWT audience claim array (aud)');
905+
});
906+
907+
it('ignores a session-token issuer option when verifying an M2M JWT', async () => {
908+
server.use(
909+
http.get(
910+
'https://api.clerk.test/v1/jwks',
911+
validateHeaders(() => {
912+
return HttpResponse.json(mockJwks);
913+
}),
914+
),
915+
);
916+
917+
const m2mJwt = await createSignedM2MJwt();
918+
919+
const result = await verifyMachineAuthToken(m2mJwt, {
920+
apiUrl: 'https://api.clerk.test',
921+
secretKey: 'a-valid-key',
922+
issuer: 'https://clerk.inspired.puma-74.lcl.dev',
923+
});
924+
925+
expect(result.tokenType).toBe('m2m_token');
926+
expect(result.errors).toBeUndefined();
927+
expect(result.data).toBeDefined();
928+
});
929+
869930
it('rejects M2M JWT with alg: none', async () => {
870931
server.use(
871932
http.get(

0 commit comments

Comments
 (0)