diff --git a/.changeset/backend-issuer-validation.md b/.changeset/backend-issuer-validation.md new file mode 100644 index 00000000000..ea96ec4e812 --- /dev/null +++ b/.changeset/backend-issuer-validation.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': minor +--- + +`verifyToken()` and `verifyJwt()` now support an `issuer` option to validate a token's `iss` claim. Pass a string for an exact match, or a list of strings of which one must match. Validation is opt-in: when `issuer` is not provided, the `iss` claim is not checked and existing behavior is unchanged. The option only applies to session-token verification; machine tokens (M2M, OAuth access tokens, API keys) are unaffected. diff --git a/packages/backend/src/errors.ts b/packages/backend/src/errors.ts index 38bddae1b01..3ebc0bf9234 100644 --- a/packages/backend/src/errors.ts +++ b/packages/backend/src/errors.ts @@ -15,6 +15,7 @@ export const TokenVerificationErrorReason = { TokenInvalid: 'token-invalid', TokenInvalidAlgorithm: 'token-invalid-algorithm', TokenInvalidAuthorizedParties: 'token-invalid-authorized-parties', + TokenInvalidIssuer: 'token-invalid-issuer', TokenInvalidSignature: 'token-invalid-signature', TokenNotActiveYet: 'token-not-active-yet', TokenIatInTheFuture: 'token-iat-in-the-future', diff --git a/packages/backend/src/jwt/__tests__/assertions.test.ts b/packages/backend/src/jwt/__tests__/assertions.test.ts index 63ac0169296..a072b995652 100644 --- a/packages/backend/src/jwt/__tests__/assertions.test.ts +++ b/packages/backend/src/jwt/__tests__/assertions.test.ts @@ -8,6 +8,7 @@ import { assertHeaderAlgorithm, assertHeaderType, assertIssuedAtClaim, + assertIssuerClaim, assertSubClaim, } from '../assertions'; @@ -230,6 +231,43 @@ describe('assertAuthorizedPartiesClaim(azp?, authorizedParties?)', () => { }); }); +describe('assertIssuerClaim(iss, issuer?)', () => { + const iss = 'https://clerk.example.com'; + + it('does not throw if no issuer is provided (opt-in)', () => { + expect(() => assertIssuerClaim(iss)).not.toThrow(); + expect(() => assertIssuerClaim(iss, undefined)).not.toThrow(); + expect(() => assertIssuerClaim(undefined)).not.toThrow(); + expect(() => assertIssuerClaim(iss, '')).not.toThrow(); + expect(() => assertIssuerClaim(iss, [])).not.toThrow(); + }); + + it('does not throw if iss exactly matches the issuer string', () => { + expect(() => assertIssuerClaim(iss, iss)).not.toThrow(); + }); + + it('does not throw if iss is included in the issuer list', () => { + expect(() => assertIssuerClaim(iss, ['https://clerk.other.com', iss])).not.toThrow(); + }); + + it('throws if iss does not match the issuer string', () => { + expect(() => assertIssuerClaim(iss, 'https://clerk.evil.com')).toThrow( + `Invalid JWT issuer claim (iss) "https://clerk.example.com".`, + ); + }); + + it('throws if iss is not included in the issuer list', () => { + expect(() => assertIssuerClaim(iss, ['https://clerk.evil.com', 'https://clerk.other.com'])).toThrow( + `Invalid JWT issuer claim (iss) "https://clerk.example.com".`, + ); + }); + + it('throws if iss is missing or not a string when an issuer is required', () => { + expect(() => assertIssuerClaim(undefined, iss)).toThrow(`Invalid JWT issuer claim (iss) undefined.`); + expect(() => assertIssuerClaim(42, iss)).toThrow(`Invalid JWT issuer claim (iss) 42.`); + }); +}); + describe('assertExpirationClaim(exp, clockSkewInMs)', () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/packages/backend/src/jwt/__tests__/verifyJwt.test.ts b/packages/backend/src/jwt/__tests__/verifyJwt.test.ts index d14e536d2e7..0939aa1bc0b 100644 --- a/packages/backend/src/jwt/__tests__/verifyJwt.test.ts +++ b/packages/backend/src/jwt/__tests__/verifyJwt.test.ts @@ -106,16 +106,44 @@ describe('verifyJwt(jwt, options)', () => { expect(data).toEqual(mockJwtPayload); }); - it('returns the valid JWT payload if valid key & issuer method & azp is given', async () => { + it('returns the valid JWT payload if valid key & issuer list & azp is given', async () => { const inputVerifyJwtOptions = { key: mockJwks.keys[0], - issuer: (iss: string) => iss.startsWith('https://clerk'), + issuer: ['https://clerk.other-app.com', mockJwtPayload.iss], authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'], }; const { data } = await verifyJwt(mockJwt, inputVerifyJwtOptions); expect(data).toEqual(mockJwtPayload); }); + it('returns an error if the issuer string does not match the iss claim', async () => { + const { errors: [error] = [] } = await verifyJwt(mockJwt, { + key: mockJwks.keys[0], + issuer: 'https://clerk.another-app.com', + authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'], + }); + expect(error?.reason).toBe('token-invalid-issuer'); + expect(error?.message).toContain('Invalid JWT issuer claim (iss)'); + }); + + it('returns an error if no issuer in the list matches the iss claim', async () => { + const { errors: [error] = [] } = await verifyJwt(mockJwt, { + key: mockJwks.keys[0], + issuer: ['https://clerk.another-app.com', 'https://clerk.other-app.com'], + authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'], + }); + expect(error?.reason).toBe('token-invalid-issuer'); + }); + + it('does not validate the iss claim when no issuer is provided', async () => { + const { data, errors } = await verifyJwt(mockJwt, { + key: mockJwks.keys[0], + authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'], + }); + expect(errors).toBeUndefined(); + expect(data).toEqual(mockJwtPayload); + }); + it('returns the valid JWT payload if valid key & issuer & list of azp (with empty string) is given', async () => { const inputVerifyJwtOptions = { key: mockJwks.keys[0], diff --git a/packages/backend/src/jwt/assertions.ts b/packages/backend/src/jwt/assertions.ts index 074d3f14c30..b1541d1ee60 100644 --- a/packages/backend/src/jwt/assertions.ts +++ b/packages/backend/src/jwt/assertions.ts @@ -1,8 +1,6 @@ import { TokenVerificationError, TokenVerificationErrorAction, TokenVerificationErrorReason } from '../errors'; import { algs } from './algorithms'; -export type IssuerResolver = string | ((iss: string) => boolean); - const isArrayString = (s: unknown): s is string[] => { return Array.isArray(s) && s.length > 0 && s.every(a => typeof a === 'string'); }; @@ -96,6 +94,23 @@ export const assertAuthorizedPartiesClaim = (azp?: string, authorizedParties?: s } }; +export const assertIssuerClaim = (iss: unknown, issuer?: string | string[]) => { + // No issuer configured, skip validation. Preserves the default behavior, matching how + // the audience and authorized parties claims are only checked when an option is provided. + const issuerList = [issuer].flat().filter(i => !!i); + if (issuerList.length === 0) { + return; + } + + if (typeof iss !== 'string' || !issuerList.includes(iss)) { + throw new TokenVerificationError({ + action: TokenVerificationErrorAction.EnsureClerkJWT, + reason: TokenVerificationErrorReason.TokenInvalidIssuer, + message: `Invalid JWT issuer claim (iss) ${JSON.stringify(iss)}. Expected "${issuerList}".`, + }); + } +}; + export const assertExpirationClaim = (exp: number, clockSkewInMs: number) => { if (typeof exp !== 'number') { throw new TokenVerificationError({ diff --git a/packages/backend/src/jwt/verifyJwt.ts b/packages/backend/src/jwt/verifyJwt.ts index 6e5d89593c4..e7db3d402db 100644 --- a/packages/backend/src/jwt/verifyJwt.ts +++ b/packages/backend/src/jwt/verifyJwt.ts @@ -12,6 +12,7 @@ import { assertHeaderAlgorithm, assertHeaderType, assertIssuedAtClaim, + assertIssuerClaim, assertSubClaim, } from './assertions'; import { importKey } from './cryptoKeys'; @@ -120,6 +121,10 @@ export type VerifyJwtOptions = { * @default 5000 */ clockSkewInMs?: number; + /** + * The issuer to verify against the `iss` claim in the token. Accepts a string for an exact match, or a list of strings of which one must match exactly. If omitted, the `iss` claim is not validated. + */ + issuer?: string | string[]; /** * @internal */ @@ -135,7 +140,7 @@ export async function verifyJwt( token: string, options: VerifyJwtOptions, ): Promise> { - const { audience, authorizedParties, clockSkewInMs, key, headerType } = options; + const { audience, authorizedParties, clockSkewInMs, issuer, key, headerType } = options; const clockSkew = typeof clockSkewInMs === 'number' && Number.isFinite(clockSkewInMs) ? clockSkewInMs : DEFAULT_CLOCK_SKEW_IN_MS; @@ -183,11 +188,12 @@ export async function verifyJwt( // Payload verifications (only after signature is confirmed valid) try { - const { azp, sub, aud, iat, exp, nbf } = payload; + const { azp, sub, aud, iss, iat, exp, nbf } = payload; assertSubClaim(sub); assertAudienceClaim(aud, audience); assertAuthorizedPartiesClaim(azp, authorizedParties); + assertIssuerClaim(iss, issuer); assertExpirationClaim(exp, clockSkew); assertActivationClaim(nbf, clockSkew); assertIssuedAtClaim(iat, clockSkew); diff --git a/packages/backend/src/jwt/verifyMachineJwt.ts b/packages/backend/src/jwt/verifyMachineJwt.ts index 7af2d8af91f..731cb06b20d 100644 --- a/packages/backend/src/jwt/verifyMachineJwt.ts +++ b/packages/backend/src/jwt/verifyMachineJwt.ts @@ -52,9 +52,12 @@ async function resolveKeyAndVerifyJwt( }; } + // Pass only the options declared on JwtMachineVerifyOptions. Callers (e.g. authenticateRequest) + // hand us wider option objects whose session-token claim options (issuer, audience, + // authorizedParties) must not be asserted against machine tokens, which carry different claims. const { data: payload, errors: verifyErrors } = await verifyJwt(token, { - ...options, key, + clockSkewInMs: options.clockSkewInMs, ...(headerType ? { headerType } : {}), }); diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index 60a25ad3e48..7827f9bdfe4 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -68,6 +68,28 @@ describe('tokens.verify(token, options)', () => { expect(data).toEqual(mockJwtPayload); }); + it('rejects the token when the provided issuer option does not match the iss claim', async () => { + server.use( + http.get( + 'https://api.clerk.test/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), + ); + + const { data, errors } = await verifyToken(mockJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'], + issuer: 'https://clerk.another-app.com', + skipJwksCache: true, + }); + + expect(data).toBeUndefined(); + expect(errors?.[0].reason).toBe('token-invalid-issuer'); + }); + it('verifies the token by fetching the JWKs from Backend API when secretKey is provided', async () => { server.use( http.get( @@ -591,6 +613,31 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(data.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']); }); + it('ignores a session-token issuer option when verifying an M2M JWT', async () => { + server.use( + http.get( + 'https://api.clerk.test/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), + ); + + const m2mJwt = await createSignedM2MJwt(); + + // issuer targets session tokens; machine tokens carry a different iss and must not be + // rejected by it (claim options must not leak through verifyMachineAuthToken). + const result = await verifyMachineAuthToken(m2mJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + issuer: 'https://clerk.inspired.puma-74.lcl.dev', + }); + + expect(result.tokenType).toBe('m2m_token'); + expect(result.errors).toBeUndefined(); + expect(result.data).toBeDefined(); + }); + it('rejects M2M JWT with alg: none', async () => { server.use( http.get(