Skip to content
Draft
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/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