diff --git a/.changeset/m2m-cat-header-verification.md b/.changeset/m2m-cat-header-verification.md new file mode 100644 index 00000000000..976cf185126 --- /dev/null +++ b/.changeset/m2m-cat-header-verification.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': patch +--- + +M2M JWT verification now validates the token-category (`cat`) header and rejects M2M JWTs tagged as a different token class. M2M JWTs minted by Clerk carry the correct category and are unaffected; M2M JWTs without the header continue to verify. diff --git a/packages/backend/src/jwt/verifyMachineJwt.ts b/packages/backend/src/jwt/verifyMachineJwt.ts index 7af2d8af91f..91ac61c6a0c 100644 --- a/packages/backend/src/jwt/verifyMachineJwt.ts +++ b/packages/backend/src/jwt/verifyMachineJwt.ts @@ -11,7 +11,7 @@ import type { MachineTokenReturnType } from '../jwt/types'; import { verifyJwt } from '../jwt/verifyJwt'; import type { LoadClerkJWKFromRemoteOptions } from '../tokens/keys'; import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from '../tokens/keys'; -import { OAUTH_ACCESS_TOKEN_TYPES } from '../tokens/machine'; +import { JWT_CATEGORY_M2M_TOKEN, OAUTH_ACCESS_TOKEN_TYPES } from '../tokens/machine'; import { TokenType } from '../tokens/tokenTypes'; export type JwtMachineVerifyOptions = Pick & { @@ -86,6 +86,23 @@ export async function verifyM2MJwt( decoded: Jwt, options: JwtMachineVerifyOptions, ): Promise> { + // Reject JWTs of another class (e.g. session, jwt-template) signed by the same + // instance key. Absent `cat` is still accepted during the rollout window; tighten + // to strict equality once pre-rollout M2M JWTs have expired (USER-5437). + const cat = decoded.header.cat; + if (cat !== undefined && cat !== JWT_CATEGORY_M2M_TOKEN) { + return { + data: undefined, + tokenType: TokenType.M2MToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenInvalid, + message: 'Invalid M2M JWT category.', + }), + ], + }; + } + const result = await resolveKeyAndVerifyJwt(token, decoded.header.kid, options); if ('error' in result) { diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index 60a25ad3e48..bfe37774550 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -18,6 +18,7 @@ import { } from '../../fixtures/machine'; import { signJwt } from '../../jwt/signJwt'; import { server, validateHeaders } from '../../mock-server'; +import { JWT_CATEGORY_M2M_TOKEN } from '../machine'; import { verifyMachineAuthToken, verifyToken } from '../verify'; async function createSignedOAuthJwt( @@ -31,10 +32,10 @@ async function createSignedOAuthJwt( return data!; } -async function createSignedM2MJwt(payload = mockM2MJwtPayload) { +async function createSignedM2MJwt(payload = mockM2MJwtPayload, cat: string | undefined = JWT_CATEGORY_M2M_TOKEN) { const { data } = await signJwt(payload, signingJwks, { algorithm: 'RS256', - header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' }, + header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD', ...(cat !== undefined ? { cat } : {}) }, }); return data!; } @@ -640,5 +641,48 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(result.errors).toBeDefined(); expect(result.errors?.[0].message).toContain('expired'); }); + + it('verifies a valid M2M JWT with no cat header (rollout window)', async () => { + server.use( + http.get( + 'https://api.clerk.test/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), + ); + + const m2mJwt = await createSignedM2MJwt(mockM2MJwtPayload, undefined); + + const result = await verifyMachineAuthToken(m2mJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + }); + + expect(result.errors).toBeUndefined(); + expect(result.tokenType).toBe('m2m_token'); + }); + + describe.each([ + ['session-token', 'cl_B7d4PD111AAA'], + ['jwt-template', 'cl_B7d4PD222AAA'], + ['unknown', 'cl_some_future_unknown_cat'], + ])('rejects M2M JWT masquerading with a non-M2M cat', (label, cat) => { + it(`rejects cat=${label} without attempting signature verification`, async () => { + // No JWKS handler registered: if the cat check did not short-circuit, + // resolveKeyAndVerifyJwt would attempt a JWKS fetch and the failure + // message would differ from the category error below. + const m2mJwt = await createSignedM2MJwt(mockM2MJwtPayload, cat); + + const result = await verifyMachineAuthToken(m2mJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + }); + + expect(result.tokenType).toBe('m2m_token'); + expect(result.errors).toBeDefined(); + expect(result.errors?.[0].code).toBe('token-invalid'); + }); + }); }); }); diff --git a/packages/backend/src/tokens/machine.ts b/packages/backend/src/tokens/machine.ts index cfc055e96d3..cf878783095 100644 --- a/packages/backend/src/tokens/machine.ts +++ b/packages/backend/src/tokens/machine.ts @@ -8,6 +8,11 @@ export const M2M_SUBJECT_PREFIX = 'mch_'; export const OAUTH_TOKEN_PREFIX = 'oat_'; export const API_KEY_PREFIX = 'ak_'; +// Token-category tag in the protected JOSE header of instance-signed M2M JWTs, +// used to distinguish them from other JWT classes signed by the same instance +// key. Kept in sync with clerk_go (pkg/jwt) and cloudflare-workers. +export const JWT_CATEGORY_M2M_TOKEN = 'cl_B7d4PD333AAA'; + const MACHINE_TOKEN_PREFIXES = [M2M_TOKEN_PREFIX, OAUTH_TOKEN_PREFIX, API_KEY_PREFIX] as const; export const JwtFormatRegExp = /^[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+$/; diff --git a/packages/shared/src/types/jwtv2.ts b/packages/shared/src/types/jwtv2.ts index 7a93b63df01..da7e1de736a 100644 --- a/packages/shared/src/types/jwtv2.ts +++ b/packages/shared/src/types/jwtv2.ts @@ -27,6 +27,8 @@ export interface JwtHeader { x5c?: string | string[]; /** @internal - used by Session Minter for monotonic token freshness checks. Do not depend on this field. */ oiat?: number; + /** @internal - Clerk token-category tag written by the token minter. Do not depend on this field. */ + cat?: string; } declare global {