diff --git a/.changeset/m2m-jwt-custom-claims.md b/.changeset/m2m-jwt-custom-claims.md new file mode 100644 index 00000000000..ef38bd26bde --- /dev/null +++ b/.changeset/m2m-jwt-custom-claims.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': patch +--- + +Preserve custom claims when verifying JWT-format M2M tokens. `M2MToken.fromJwtPayload` previously hardcoded `claims` to `null`, so `client.m2m.verify()` (and request-level `auth()`) dropped any custom claims embedded in the token. Custom claims are now reconstructed from the verified payload by stripping only the structural claims the backend adds when minting the token (`iss`, `sub`, `exp`, `nbf`, `iat`, `jti`). User-supplied claims such as `aud` are preserved. Tokens without custom claims still return `claims: null`, consistent with the opaque-token path. diff --git a/packages/backend/src/api/__tests__/M2MTokenApi.test.ts b/packages/backend/src/api/__tests__/M2MTokenApi.test.ts index 463366dbe37..82ccbb6def5 100644 --- a/packages/backend/src/api/__tests__/M2MTokenApi.test.ts +++ b/packages/backend/src/api/__tests__/M2MTokenApi.test.ts @@ -417,7 +417,7 @@ describe('M2MToken', () => { }); }); - async function createSignedM2MJwt(payload = mockM2MJwtPayload) { + async function createSignedM2MJwt(payload: Record = mockM2MJwtPayload) { const { data } = await signJwt(payload, signingJwks, { algorithm: 'RS256', header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' }, @@ -455,6 +455,37 @@ describe('M2MToken', () => { expect(result.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']); }); + it('preserves custom claims embedded in a JWT M2M token', async () => { + const m2mApi = new M2MTokenApi( + buildRequest({ apiUrl: 'https://api.clerk.test', skipApiVersionInUrl: true, requireSecretKey: false }), + { secretKey: 'sk_test_xxxxx', apiUrl: 'https://api.clerk.test', skipJwksCache: true }, + ); + + server.use( + http.get( + 'https://api.clerk.test/v1/jwks', + validateHeaders(() => HttpResponse.json(mockJwks)), + ), + ); + + const jwtToken = await createSignedM2MJwt({ + ...mockM2MJwtPayload, + permissions: ['read:users', 'read:orders'], + role: 'service', + }); + const result = await m2mApi.verify({ token: jwtToken }); + + // `aud` and `scopes` from the token are user-supplied custom claims and are + // preserved in `claims`; `scopes` additionally seeds the dedicated field. + expect(result.claims).toEqual({ + aud: ['mch_1xxxxx', 'mch_2xxxxx'], + scopes: 'mch_1xxxxx mch_2xxxxx', + permissions: ['read:users', 'read:orders'], + role: 'service', + }); + expect(result.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']); + }); + it('throws when JWT signature cannot be verified', async () => { const m2mApi = new M2MTokenApi( buildRequest({ apiUrl: 'https://api.clerk.test', skipApiVersionInUrl: true, requireSecretKey: false }), diff --git a/packages/backend/src/api/resources/M2MToken.ts b/packages/backend/src/api/resources/M2MToken.ts index e3253329056..a9ad2b1d2e4 100644 --- a/packages/backend/src/api/resources/M2MToken.ts +++ b/packages/backend/src/api/resources/M2MToken.ts @@ -12,6 +12,30 @@ type M2MJwtPayload = { [key: string]: unknown; }; +// Structural claims that Clerk's machine-token service always adds when it mints +// an M2M JWT. These are mapped onto dedicated `M2MToken` fields, so they are +// stripped from `claims`. Everything else is a user-supplied custom claim and is +// surfaced through `claims`, including `aud` and `scopes`, which the backend +// treats as custom claims (they are neither reserved nor auto-added). +const M2M_RESERVED_JWT_CLAIMS = new Set(['iss', 'sub', 'exp', 'nbf', 'iat', 'jti']); + +/** + * Reconstructs the custom claims that were attached at token creation by + * stripping the structural claims (see `M2M_RESERVED_JWT_CLAIMS`) from the + * verified payload. Returns `null` when no custom claims are present, matching + * the opaque-token path where a token created without claims verifies back to + * `claims: null`. + */ +function extractCustomClaims(payload: M2MJwtPayload): Record | null { + const claims: Record = {}; + for (const key of Object.keys(payload)) { + if (!M2M_RESERVED_JWT_CLAIMS.has(key)) { + claims[key] = payload[key]; + } + } + return Object.keys(claims).length > 0 ? claims : null; +} + /** * The Backend `M2MToken` object holds information about a machine-to-machine token. */ @@ -51,7 +75,7 @@ export class M2MToken { payload.jti ?? '', // jti should always be present in Clerk-issued M2M JWTs payload.sub, payload.scopes?.split(' ') ?? payload.aud ?? [], - null, + extractCustomClaims(payload), false, null, payload.exp * 1000 <= Date.now() - clockSkewInMs, diff --git a/packages/backend/src/api/resources/__tests__/M2MToken.test.ts b/packages/backend/src/api/resources/__tests__/M2MToken.test.ts index ca158ae2e37..a0b440430f8 100644 --- a/packages/backend/src/api/resources/__tests__/M2MToken.test.ts +++ b/packages/backend/src/api/resources/__tests__/M2MToken.test.ts @@ -29,7 +29,9 @@ describe('M2MToken', () => { expect(token.id).toBe('mt_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE'); expect(token.subject).toBe('mch_2vYVtestTESTtestTESTtestTESTtest'); expect(token.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']); - expect(token.claims).toBeNull(); + // `aud` is a user-supplied custom claim (the backend does not auto-add it), + // so it is surfaced through `claims` while also seeding the `scopes` field. + expect(token.claims).toEqual({ aud: ['mch_1xxxxx', 'mch_2xxxxx'] }); expect(token.revoked).toBe(false); expect(token.revocationReason).toBeNull(); expect(token.expired).toBe(false); @@ -38,6 +40,42 @@ describe('M2MToken', () => { expect(token.updatedAt).toBe(1666648250 * 1000); }); + it('preserves custom claims (including aud and scopes) and strips only structural claims', () => { + const payload = { + iss: 'https://clerk.m2m.example.test', + sub: 'mch_2vYVtestTESTtestTESTtestTESTtest', + aud: ['mch_1xxxxx'], + exp: 1666648550, + iat: 1666648250, + nbf: 1666648240, + jti: 'mt_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE', + scopes: 'scope1 scope2', + permissions: ['read:users', 'read:orders'], + role: 'service', + }; + + const token = M2MToken.fromJwtPayload(payload); + + // `aud` and `scopes` are user-supplied custom claims in Clerk-issued M2M + // tokens (the backend neither reserves nor auto-adds them), so they are + // preserved in `claims` alongside any other custom claims. + expect(token.claims).toEqual({ + aud: ['mch_1xxxxx'], + scopes: 'scope1 scope2', + permissions: ['read:users', 'read:orders'], + role: 'service', + }); + // Structural claims are mapped to dedicated fields, not leaked into `claims`. + expect(token.claims).not.toHaveProperty('iss'); + expect(token.claims).not.toHaveProperty('sub'); + expect(token.claims).not.toHaveProperty('exp'); + expect(token.claims).not.toHaveProperty('nbf'); + expect(token.claims).not.toHaveProperty('iat'); + expect(token.claims).not.toHaveProperty('jti'); + // `scopes` is still derived onto the dedicated `scopes` field. + expect(token.scopes).toEqual(['scope1', 'scope2']); + }); + it('prefers scopes claim over aud when both are present', () => { const payload = { sub: 'mch_test',