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-cat-header-verification.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 18 additions & 1 deletion packages/backend/src/jwt/verifyMachineJwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LoadClerkJWKFromRemoteOptions, 'secretKey' | 'apiUrl' | 'skipJwksCache'> & {
Expand Down Expand Up @@ -86,6 +86,23 @@ export async function verifyM2MJwt(
decoded: Jwt,
options: JwtMachineVerifyOptions,
): Promise<MachineTokenReturnType<M2MToken, MachineTokenVerificationError>> {
// 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) {
Expand Down
48 changes: 46 additions & 2 deletions packages/backend/src/tokens/__tests__/verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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!;
}
Expand Down Expand Up @@ -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');
});
});
});
});
5 changes: 5 additions & 0 deletions packages/backend/src/tokens/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\-_]+$/;
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/types/jwtv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading