Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/m2m-jwt-custom-claims.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 32 additions & 1 deletion packages/backend/src/api/__tests__/M2MTokenApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ describe('M2MToken', () => {
});
});

async function createSignedM2MJwt(payload = mockM2MJwtPayload) {
async function createSignedM2MJwt(payload: Record<string, unknown> = mockM2MJwtPayload) {
const { data } = await signJwt(payload, signingJwks, {
algorithm: 'RS256',
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
Expand Down Expand Up @@ -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 }),
Expand Down
26 changes: 25 additions & 1 deletion packages/backend/src/api/resources/M2MToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> | null {
const claims: Record<string, any> = {};
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.
*/
Expand Down Expand Up @@ -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,
Expand Down
40 changes: 39 additions & 1 deletion packages/backend/src/api/resources/__tests__/M2MToken.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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',
Expand Down
Loading