Skip to content
Draft
5 changes: 5 additions & 0 deletions .changeset/clever-ways-raise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/backend": minor
---

Add M2M JWT token verification support
2 changes: 2 additions & 0 deletions packages/backend/src/__tests__/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ describe('subpath /internal exports', () => {
"getAuthObjectFromJwt",
"getMachineTokenType",
"invalidTokenAuthObject",
"isM2MJwt",
"isMachineJwt",
"isMachineToken",
"isMachineTokenByPrefix",
"isMachineTokenType",
Expand Down
124 changes: 124 additions & 0 deletions packages/backend/src/api/__tests__/M2MTokenApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,130 @@ describe('M2MToken', () => {
'The provided Machine Secret Key is invalid. Make sure that your Machine Secret Key is correct.',
);
});

it('creates a jwt format m2m token', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
machineSecretKey: 'ak_xxxxx',
});

server.use(
http.post(
'https://api.clerk.test/m2m_tokens',
validateHeaders(async ({ request }) => {
expect(request.headers.get('Authorization')).toBe('Bearer ak_xxxxx');
const body = (await request.json()) as Record<string, unknown>;
expect(body.token_format).toBe('jwt');
return HttpResponse.json({
...mockM2MToken,
token:
'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Imluc194eHh4eCJ9.eyJqdGkiOiJtdF94eHh4eCIsInN1YiI6Im1jaF94eHh4eCIsImF1ZCI6WyJtY2hfMXh4eHh4IiwibWNoXzJ4eHh4eCJdLCJzY29wZXMiOiJtY2hfMXh4eHh4IG1jaF8yeHh4eHgiLCJpYXQiOjE3NTM3NDMzMTYsImV4cCI6MTc1Mzc0NjkxNn0.signature',
});
}),
),
);

const response = await apiClient.m2m.createToken({
tokenFormat: 'jwt',
});
expect(response.id).toBe(m2mId);
expect(response.token).toMatch(/^[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+$/);
expect(response.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']);
});

it('creates a jwt m2m token with custom claims and scopes', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
machineSecretKey: 'ak_xxxxx',
});

const customClaims = {
role: 'service',
tier: 'gold',
};

server.use(
http.post(
'https://api.clerk.test/m2m_tokens',
validateHeaders(async ({ request }) => {
expect(request.headers.get('Authorization')).toBe('Bearer ak_xxxxx');
const body = (await request.json()) as Record<string, unknown>;
expect(body.token_format).toBe('jwt');
expect(body.claims).toEqual(customClaims);
return HttpResponse.json({
...mockM2MToken,
claims: customClaims,
token:
'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Imluc194eHh4eCJ9.eyJqdGkiOiJtdF94eHh4eCIsInN1YiI6Im1jaF94eHh4eCIsImF1ZCI6WyJtY2hfMXh4eHh4IiwibWNoXzJ4eHh4eCJdLCJzY29wZXMiOiJtY2hfMXh4eHh4IG1jaF8yeHh4eHgiLCJyb2xlIjoic2VydmljZSIsInRpZXIiOiJnb2xkIiwiaWF0IjoxNzUzNzQzMzE2LCJleHAiOjE3NTM3NDY5MTZ9.signature',
});
}),
),
);

const response = await apiClient.m2m.createToken({
tokenFormat: 'jwt',
claims: customClaims,
});

expect(response.id).toBe(m2mId);
expect(response.token).toMatch(/^[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+$/);
expect(response.claims).toEqual(customClaims);
expect(response.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']);
});

it('creates an opaque format m2m token when explicitly specified', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
machineSecretKey: 'ak_xxxxx',
});

server.use(
http.post(
'https://api.clerk.test/m2m_tokens',
validateHeaders(async ({ request }) => {
expect(request.headers.get('Authorization')).toBe('Bearer ak_xxxxx');
const body = (await request.json()) as Record<string, unknown>;
expect(body.token_format).toBe('opaque');
return HttpResponse.json(mockM2MToken);
}),
),
);

const response = await apiClient.m2m.createToken({
tokenFormat: 'opaque',
});

expect(response.id).toBe(m2mId);
expect(response.token).toBe(m2mSecret);
expect(response.token).toMatch(/^mt_.+$/);
});

it('creates an opaque m2m token by default when tokenFormat is omitted', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
machineSecretKey: 'ak_xxxxx',
});

server.use(
http.post(
'https://api.clerk.test/m2m_tokens',
validateHeaders(async ({ request }) => {
expect(request.headers.get('Authorization')).toBe('Bearer ak_xxxxx');
const body = (await request.json()) as Record<string, unknown>;
// tokenFormat should be undefined, BAPI will default to opaque
expect(body.token_format).toBeUndefined();
return HttpResponse.json(mockM2MToken);
}),
),
);

const response = await apiClient.m2m.createToken({
secondsUntilExpiration: 3600,
});

expect(response.id).toBe(m2mId);
expect(response.token).toBe(m2mSecret);
});
});

describe('revoke', () => {
Expand Down
15 changes: 14 additions & 1 deletion packages/backend/src/api/endpoints/M2MTokenApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ import { AbstractAPI } from './AbstractApi';

const basePath = '/m2m_tokens';

/**
* Format of the M2M token to create.
* - 'opaque': Opaque token with mt_ prefix
* - 'jwt': JWT signed with instance keys
*/
export type M2MTokenFormat = 'opaque' | 'jwt';

type CreateM2MTokenParams = {
/**
* Custom machine secret key for authentication.
Expand All @@ -18,6 +25,10 @@ type CreateM2MTokenParams = {
*/
secondsUntilExpiration?: number | null;
claims?: Record<string, unknown> | null;
/**
* @default 'opaque'
*/
tokenFormat?: M2MTokenFormat;
};

type RevokeM2MTokenParams = {
Expand Down Expand Up @@ -59,7 +70,7 @@ export class M2MTokenApi extends AbstractAPI {
}

async createToken(params?: CreateM2MTokenParams) {
const { claims = null, machineSecretKey, secondsUntilExpiration = null } = params || {};
const { claims = null, machineSecretKey, secondsUntilExpiration = null, tokenFormat } = params || {};

const requestOptions = this.#createRequestOptions(
{
Expand All @@ -68,6 +79,8 @@ export class M2MTokenApi extends AbstractAPI {
bodyParams: {
secondsUntilExpiration,
claims,
// Only send tokenFormat if explicitly specified; BAPI defaults to 'opaque'
...(tokenFormat !== undefined ? { tokenFormat } : {}),
},
},
machineSecretKey,
Expand Down
31 changes: 31 additions & 0 deletions packages/backend/src/api/resources/M2MToken.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import type { M2MTokenJSON } from './JSON';

// Minimal JWT claims present in M2M tokens. M2M tokens are not session JWTs
// and do not carry session-specific claims like `sid` or `__raw`.
type M2MJwtPayload = {
sub: string;
exp: number;
iat: number;
jti?: string;
aud?: string[];
scopes?: string;
[key: string]: unknown;
};

/**
* The Backend `M2MToken` object holds information about a machine-to-machine token.
*/
Expand Down Expand Up @@ -33,4 +45,23 @@ export class M2MToken {
data.token,
);
}

/**
* Creates an M2MToken from a JWT payload.
* Maps standard JWT claims to token properties.
*/
static fromJwtPayload(payload: M2MJwtPayload, clockSkewInMs = 5000): M2MToken {
return new M2MToken(
payload.jti ?? '',
payload.sub,
payload.scopes?.split(' ') ?? payload.aud ?? [],
null,
false,
null,
payload.exp * 1000 <= Date.now() - clockSkewInMs,
payload.exp, // seconds (raw JWT exp claim)
payload.iat, // seconds (raw JWT iat claim)
payload.iat, // seconds (raw JWT iat claim)
);
}
}
95 changes: 95 additions & 0 deletions packages/backend/src/api/resources/__tests__/M2MToken.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { M2MToken } from '../M2MToken';

describe('M2MToken', () => {
describe('fromJwtPayload', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(1666648250 * 1000)); // Same as iat
});

afterEach(() => {
vi.useRealTimers();
});

it('creates M2MToken from JWT payload', () => {
const payload = {
iss: 'https://clerk.m2m.example.test',
sub: 'mch_2vYVtestTESTtestTESTtestTESTtest',
aud: ['mch_1xxxxx', 'mch_2xxxxx'],
exp: 1666648550,
iat: 1666648250,
nbf: 1666648240,
jti: 'mt_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE',
};

const token = M2MToken.fromJwtPayload(payload);

expect(token.id).toBe('mt_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE');
expect(token.subject).toBe('mch_2vYVtestTESTtestTESTtestTESTtest');
expect(token.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']);
expect(token.claims).toBeNull();
expect(token.revoked).toBe(false);
expect(token.revocationReason).toBeNull();
expect(token.expired).toBe(false);
expect(token.expiration).toBe(1666648550);
expect(token.createdAt).toBe(1666648250);
expect(token.updatedAt).toBe(1666648250);
});

it('parses scopes from space-separated string when aud is missing', () => {
const payload = {
sub: 'mch_test',
exp: 1666648550,
iat: 1666648250,
jti: 'mt_test',
scopes: 'scope1 scope2 scope3',
};

const token = M2MToken.fromJwtPayload(payload);

expect(token.scopes).toEqual(['scope1', 'scope2', 'scope3']);
});

it('returns empty scopes when neither aud nor scopes present', () => {
const payload = {
sub: 'mch_test',
exp: 1666648550,
iat: 1666648250,
jti: 'mt_test',
};

const token = M2MToken.fromJwtPayload(payload);

expect(token.scopes).toEqual([]);
});

it('marks token as expired when exp is in the past', () => {
vi.setSystemTime(new Date(1666648600 * 1000)); // After exp

const payload = {
sub: 'mch_test',
exp: 1666648550,
iat: 1666648250,
jti: 'mt_test',
};

const token = M2MToken.fromJwtPayload(payload);

expect(token.expired).toBe(true);
});

it('handles missing jti gracefully', () => {
const payload = {
sub: 'mch_test',
exp: 1666648550,
iat: 1666648250,
};

const token = M2MToken.fromJwtPayload(payload);

expect(token.id).toBe('');
});
});
});
12 changes: 12 additions & 0 deletions packages/backend/src/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@ export const mockOAuthAccessTokenJwtPayload = {
nbf: mockJwtPayload.iat - 10,
};

// M2M JWT payload for testing - distinguished by 'sub' claim starting with 'mch_'
export const mockM2MJwtPayload = {
iss: 'https://clerk.m2m.example.test',
sub: 'mch_2vYVtestTESTtestTESTtestTESTtest',
aud: ['mch_1xxxxx', 'mch_2xxxxx'],
exp: mockJwtPayload.iat + 300,
iat: mockJwtPayload.iat,
nbf: mockJwtPayload.iat - 10,
jti: 'mt_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE',
scopes: 'mch_1xxxxx mch_2xxxxx',
};

export const mockRsaJwkKid = 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD';

export const mockRsaJwk = {
Expand Down
14 changes: 14 additions & 0 deletions packages/backend/src/fixtures/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,17 @@ export const mockSignedOAuthAccessTokenJwt =
// Signed with signingJwks, verifiable with mockJwks
export const mockSignedOAuthAccessTokenJwtApplicationTyp =
'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJhcHBsaWNhdGlvbi9hdCtqd3QifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsImV4cCI6MTY2NjY0ODU1MCwiaWF0IjoxNjY2NjQ4MjUwLCJpc3MiOiJodHRwczovL2NsZXJrLm9hdXRoLmV4YW1wbGUudGVzdCIsIm5iZiI6MTY2NjY0ODI0MCwic2lkIjoic2Vzc18yR2JEQjRlbk5kQ2E1dlMxenBDM1h6Zzl0SzkiLCJzdWIiOiJ1c2VyXzJ2WVZ0ZXN0VEVTVHRlc3RURVNUdGVzdFRFU1R0ZXN0IiwiY2xpZW50X2lkIjoiY2xpZW50XzJWVFdVenZHQzVVaGRKQ054NnhHMUQ5OGVkYyIsInNjb3BlIjoicmVhZDpmb28gd3JpdGU6YmFyIiwianRpIjoib2F0XzJ4S2E5Qmd2N054TVJERnlRdzhMcFozY1RtVTF2SGpFIn0.GPTvB4doScjzQD0kRMhMebVDREjwcrMWK73OP_kFc3pl0gST29BlWrKMBi8wRxoSJBc2ukO10BPhGxnh15PxCNLyk6xQFWhFBA7XpVxY4T_VHPDU5FEOocPQuqcqZ4cA1GDJST-BH511fxoJnv4kfha46IvQiUMvWCacIj_w12qfZigeb208mTDIeoJQtlYb-sD9u__CVvB4uZOqGb0lIL5-cCbhMPFg-6GQ2DhZ-Eq5tw7oyO6lPrsAaFN9u-59SLvips364ieYNpgcr9Dbo5PDvUSltqxoIXTDFo4esWw6XwUjnGfqCh34LYAhv_2QF2U0-GASBEn4GK-Wfv3wXg';

// M2M JWT payload for testing
// Uses same timestamps as mockOAuthAccessTokenJwtPayload for consistency
// The key distinguisher for M2M JWTs is the 'sub' claim starting with 'mch_'
export const mockM2MJwtPayload = {
iss: 'https://clerk.m2m.example.test',
sub: 'mch_2vYVtestTESTtestTESTtestTESTtest',
aud: ['mch_1xxxxx', 'mch_2xxxxx'],
exp: 1666648550,
iat: 1666648250,
nbf: 1666648240,
jti: 'mt_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE',
scopes: 'mch_1xxxxx mch_2xxxxx',
};
2 changes: 2 additions & 0 deletions packages/backend/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,6 @@ export {
getMachineTokenType,
isTokenTypeAccepted,
isMachineToken,
isM2MJwt,
isMachineJwt,
} from './tokens/machine';
Loading