diff --git a/.changeset/clever-ways-raise.md b/.changeset/clever-ways-raise.md new file mode 100644 index 00000000000..4d34aaa5e98 --- /dev/null +++ b/.changeset/clever-ways-raise.md @@ -0,0 +1,5 @@ +--- +"@clerk/backend": minor +--- + +Add M2M JWT token verification support diff --git a/packages/backend/src/__tests__/exports.test.ts b/packages/backend/src/__tests__/exports.test.ts index 5b24d6c619a..5eae512cebc 100644 --- a/packages/backend/src/__tests__/exports.test.ts +++ b/packages/backend/src/__tests__/exports.test.ts @@ -51,6 +51,8 @@ describe('subpath /internal exports', () => { "getAuthObjectFromJwt", "getMachineTokenType", "invalidTokenAuthObject", + "isM2MJwt", + "isMachineJwt", "isMachineToken", "isMachineTokenByPrefix", "isMachineTokenType", diff --git a/packages/backend/src/api/__tests__/M2MTokenApi.test.ts b/packages/backend/src/api/__tests__/M2MTokenApi.test.ts index b3ba1aed1c5..68286b3cdbd 100644 --- a/packages/backend/src/api/__tests__/M2MTokenApi.test.ts +++ b/packages/backend/src/api/__tests__/M2MTokenApi.test.ts @@ -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; + 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; + 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; + 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; + // 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', () => { diff --git a/packages/backend/src/api/endpoints/M2MTokenApi.ts b/packages/backend/src/api/endpoints/M2MTokenApi.ts index 1ba15555b53..75d692834b3 100644 --- a/packages/backend/src/api/endpoints/M2MTokenApi.ts +++ b/packages/backend/src/api/endpoints/M2MTokenApi.ts @@ -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. @@ -18,6 +25,10 @@ type CreateM2MTokenParams = { */ secondsUntilExpiration?: number | null; claims?: Record | null; + /** + * @default 'opaque' + */ + tokenFormat?: M2MTokenFormat; }; type RevokeM2MTokenParams = { @@ -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( { @@ -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, diff --git a/packages/backend/src/api/resources/M2MToken.ts b/packages/backend/src/api/resources/M2MToken.ts index 48ccf78bf1d..1bf213b0776 100644 --- a/packages/backend/src/api/resources/M2MToken.ts +++ b/packages/backend/src/api/resources/M2MToken.ts @@ -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. */ @@ -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) + ); + } } diff --git a/packages/backend/src/api/resources/__tests__/M2MToken.test.ts b/packages/backend/src/api/resources/__tests__/M2MToken.test.ts new file mode 100644 index 00000000000..931ced62f55 --- /dev/null +++ b/packages/backend/src/api/resources/__tests__/M2MToken.test.ts @@ -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(''); + }); + }); +}); diff --git a/packages/backend/src/fixtures/index.ts b/packages/backend/src/fixtures/index.ts index 9a9d148c6e3..7802d39dce7 100644 --- a/packages/backend/src/fixtures/index.ts +++ b/packages/backend/src/fixtures/index.ts @@ -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 = { diff --git a/packages/backend/src/fixtures/machine.ts b/packages/backend/src/fixtures/machine.ts index fb5c50cd5c5..e6cf6fe9158 100644 --- a/packages/backend/src/fixtures/machine.ts +++ b/packages/backend/src/fixtures/machine.ts @@ -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', +}; diff --git a/packages/backend/src/internal.ts b/packages/backend/src/internal.ts index 27861fd6e52..8b1d80d2c53 100644 --- a/packages/backend/src/internal.ts +++ b/packages/backend/src/internal.ts @@ -62,4 +62,6 @@ export { getMachineTokenType, isTokenTypeAccepted, isMachineToken, + isM2MJwt, + isMachineJwt, } from './tokens/machine'; diff --git a/packages/backend/src/tokens/__tests__/machine.test.ts b/packages/backend/src/tokens/__tests__/machine.test.ts index cdd3d5d09b4..1c797098a20 100644 --- a/packages/backend/src/tokens/__tests__/machine.test.ts +++ b/packages/backend/src/tokens/__tests__/machine.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { createJwt, mockOAuthAccessTokenJwtPayload } from '../../fixtures'; +import { createJwt, mockM2MJwtPayload, mockOAuthAccessTokenJwtPayload } from '../../fixtures'; import { mockSignedOAuthAccessTokenJwt, mockSignedOAuthAccessTokenJwtApplicationTyp } from '../../fixtures/machine'; import { API_KEY_PREFIX, getMachineTokenType, isJwtFormat, + isM2MJwt, + isMachineJwt, isMachineToken, isMachineTokenByPrefix, isMachineTokenType, @@ -68,16 +70,24 @@ describe('isMachineToken', () => { expect(isMachineToken(token)).toBe(true); }); + it('returns true for M2M JWT with mch_ subject', () => { + const token = createJwt({ + header: { typ: 'JWT', kid: 'ins_whatever' }, + payload: mockM2MJwtPayload, + }); + expect(isMachineToken(token)).toBe(true); + }); + it('returns false for tokens without a recognized prefix or OAuth JWT format', () => { expect(isMachineToken('unknown_prefix_token')).toBe(false); expect(isMachineToken('session_token_value')).toBe(false); expect(isMachineToken('jwt_token_value')).toBe(false); }); - it('returns false for regular JWT tokens (not OAuth JWT)', () => { + it('returns false for regular JWT tokens (not machine JWT)', () => { const regularJwt = createJwt({ header: { typ: 'JWT', kid: 'ins_whatever' }, - payload: mockOAuthAccessTokenJwtPayload, + payload: { ...mockOAuthAccessTokenJwtPayload, sub: 'user_123' }, }); expect(isMachineToken(regularJwt)).toBe(false); }); @@ -112,6 +122,14 @@ describe('getMachineTokenType', () => { expect(getMachineTokenType(token)).toBe('oauth_token'); }); + it('returns "m2m_token" for M2M JWT with mch_ subject', () => { + const token = createJwt({ + header: { typ: 'JWT', kid: 'ins_whatever' }, + payload: mockM2MJwtPayload, + }); + expect(getMachineTokenType(token)).toBe('m2m_token'); + }); + it('returns "api_key" for tokens with API key prefix', () => { expect(getMachineTokenType(`${API_KEY_PREFIX}some-token-value`)).toBe('api_key'); }); @@ -203,3 +221,59 @@ describe('isOAuthJwt', () => { expect(isOAuthJwt('not.a.jwt')).toBe(false); }); }); + +describe('isM2MJwt', () => { + it('returns true for JWT with sub starting with mch_', () => { + const token = createJwt({ + header: { typ: 'JWT', kid: 'ins_whatever' }, + payload: mockM2MJwtPayload, + }); + expect(isM2MJwt(token)).toBe(true); + }); + + it('returns false for OAuth JWT (different sub prefix)', () => { + expect(isM2MJwt(mockSignedOAuthAccessTokenJwt)).toBe(false); + }); + + it('returns false for regular JWT without mch_ sub', () => { + const token = createJwt({ + header: { typ: 'JWT', kid: 'ins_whatever' }, + payload: { ...mockM2MJwtPayload, sub: 'user_123' }, + }); + expect(isM2MJwt(token)).toBe(false); + }); + + it('returns false for non-JWT token', () => { + expect(isM2MJwt('mt_opaque_token')).toBe(false); + expect(isM2MJwt('not.a.jwt')).toBe(false); + expect(isM2MJwt('')).toBe(false); + }); +}); + +describe('isMachineJwt', () => { + it('returns true for OAuth JWT', () => { + expect(isMachineJwt(mockSignedOAuthAccessTokenJwt)).toBe(true); + }); + + it('returns true for M2M JWT', () => { + const token = createJwt({ + header: { typ: 'JWT', kid: 'ins_whatever' }, + payload: mockM2MJwtPayload, + }); + expect(isMachineJwt(token)).toBe(true); + }); + + it('returns false for regular session JWT', () => { + const token = createJwt({ + header: { typ: 'JWT', kid: 'ins_whatever' }, + payload: { sub: 'user_123', iat: 1666648250, exp: 1666648550 }, + }); + expect(isMachineJwt(token)).toBe(false); + }); + + it('returns false for opaque tokens', () => { + expect(isMachineJwt('mt_opaque_token')).toBe(false); + expect(isMachineJwt('oat_opaque_token')).toBe(false); + expect(isMachineJwt('ak_opaque_token')).toBe(false); + }); +}); diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index a0af6401f7f..0f7b0171d62 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -2,12 +2,21 @@ import { http, HttpResponse } from 'msw'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { APIKey, IdPOAuthAccessToken, M2MToken } from '../../api'; -import { createJwt, mockJwks, mockJwt, mockJwtPayload, mockOAuthAccessTokenJwtPayload } from '../../fixtures'; +import { + createJwt, + mockJwks, + mockJwt, + mockJwtPayload, + mockM2MJwtPayload, + mockOAuthAccessTokenJwtPayload, + signingJwks, +} from '../../fixtures'; import { mockSignedOAuthAccessTokenJwt, mockSignedOAuthAccessTokenJwtApplicationTyp, mockVerificationResults, } from '../../fixtures/machine'; +import { signJwt } from '../../jwt/signJwt'; import { server, validateHeaders } from '../../mock-server'; import { verifyMachineAuthToken, verifyToken } from '../verify'; @@ -21,6 +30,14 @@ function createOAuthJwt( }); } +async function createSignedM2MJwt(payload = mockM2MJwtPayload) { + const { data } = await signJwt(payload, signingJwks, { + algorithm: 'RS256', + header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' }, + }); + return data!; +} + describe('tokens.verify(token, options)', () => { beforeEach(() => { vi.useFakeTimers(); @@ -466,4 +483,92 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(result.errors?.[0].message).toContain('expired'); }); }); + + describe('verifyM2MToken with JWT', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(mockM2MJwtPayload.iat * 1000)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('verifies a valid M2M JWT', async () => { + server.use( + http.get( + 'https://api.clerk.test/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), + ); + + const m2mJwt = await createSignedM2MJwt(); + + const result = await verifyMachineAuthToken(m2mJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + }); + + expect(result.tokenType).toBe('m2m_token'); + expect(result.data).toBeDefined(); + expect(result.errors).toBeUndefined(); + + const data = result.data as M2MToken; + expect(data.id).toBe('mt_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE'); + expect(data.subject).toBe('mch_2vYVtestTESTtestTESTtestTESTtest'); + expect(data.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']); + }); + + it('rejects M2M JWT with alg: none', async () => { + server.use( + http.get( + 'https://api.clerk.test/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), + ); + + const m2mJwt = createJwt({ + header: { typ: 'JWT', alg: 'none', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' }, + payload: mockM2MJwtPayload, + }); + + const result = await verifyMachineAuthToken(m2mJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + }); + + expect(result.errors).toBeDefined(); + expect(result.errors?.[0].message).toContain('Invalid JWT algorithm'); + }); + + it('rejects expired M2M JWT', async () => { + server.use( + http.get( + 'https://api.clerk.test/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), + ); + + const expiredPayload = { + ...mockM2MJwtPayload, + exp: mockM2MJwtPayload.iat - 100, + }; + + const m2mJwt = await createSignedM2MJwt(expiredPayload); + + const result = await verifyMachineAuthToken(m2mJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + }); + + expect(result.errors).toBeDefined(); + expect(result.errors?.[0].message).toContain('expired'); + }); + }); }); diff --git a/packages/backend/src/tokens/machine.ts b/packages/backend/src/tokens/machine.ts index d57dd9f7aa4..b9e8f6b33ba 100644 --- a/packages/backend/src/tokens/machine.ts +++ b/packages/backend/src/tokens/machine.ts @@ -45,6 +45,36 @@ export function isOAuthJwt(token: string): boolean { } } +/** + * Checks if a token is an M2M JWT token. + * Validates the JWT format and verifies the payload 'sub' field starts with 'mch_'. + * + * @param token - The token string to check + * @returns true if the token is a valid M2M JWT token + */ +export function isM2MJwt(token: string): boolean { + if (!isJwtFormat(token)) { + return false; + } + try { + const { data, errors } = decodeJwt(token); + return !errors && !!data && typeof data.payload.sub === 'string' && data.payload.sub.startsWith('mch_'); + } catch { + return false; + } +} + +/** + * Checks if a token is a machine JWT (OAuth JWT or M2M JWT). + * Useful for rejecting machine JWTs when expecting session tokens. + * + * @param token - The token string to check + * @returns true if the token is an OAuth or M2M JWT + */ +export function isMachineJwt(token: string): boolean { + return isOAuthJwt(token) || isM2MJwt(token); +} + /** * Checks if a token is a machine token by looking at its prefix. * @@ -60,17 +90,17 @@ export function isMachineTokenByPrefix(token: string): boolean { } /** - * Checks if a token is a machine token by looking at its prefix or if it's an OAuth JWT access token (RFC 9068). + * Checks if a token is a machine token by looking at its prefix or if it's an OAuth/M2M JWT. * * @param token - The token string to check * @returns true if the token is a machine token */ export function isMachineToken(token: string): boolean { - return isMachineTokenByPrefix(token) || isOAuthJwt(token); + return isMachineTokenByPrefix(token) || isOAuthJwt(token) || isM2MJwt(token); } /** - * Gets the specific type of machine token based on its prefix. + * Gets the specific type of machine token based on its prefix or JWT claims. * * @remarks * In the future, this will support custom prefixes that can be prepended to the base prefixes @@ -78,13 +108,15 @@ export function isMachineToken(token: string): boolean { * * @param token - The token string to check * @returns The specific MachineTokenType - * @throws Error if the token doesn't match any known machine token prefix + * @throws Error if the token doesn't match any known machine token type */ export function getMachineTokenType(token: string): MachineTokenType { - if (token.startsWith(M2M_TOKEN_PREFIX)) { + // M2M: prefix OR JWT with mch_ subject + if (token.startsWith(M2M_TOKEN_PREFIX) || isM2MJwt(token)) { return TokenType.M2MToken; } + // OAuth: prefix OR JWT with at+jwt typ if (token.startsWith(OAUTH_TOKEN_PREFIX) || isOAuthJwt(token)) { return TokenType.OAuthToken; } @@ -119,6 +151,8 @@ export const isTokenTypeAccepted = ( return tokenTypes.includes(tokenType); }; +const MACHINE_TOKEN_TYPES = new Set([TokenType.ApiKey, TokenType.M2MToken, TokenType.OAuthToken]); + /** * Checks if a token type string is a machine token type (api_key, m2m_token, or oauth_token). * @@ -126,5 +160,5 @@ export const isTokenTypeAccepted = ( * @returns true if the type is a machine token type */ export function isMachineTokenType(type: string): type is MachineTokenType { - return type === TokenType.ApiKey || type === TokenType.M2MToken || type === TokenType.OAuthToken; + return MACHINE_TOKEN_TYPES.has(type); } diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index ccef1a8cc42..f37ad50609b 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -14,7 +14,7 @@ import { AuthErrorReason, handshake, signedIn, signedOut, signedOutInvalidToken import { createClerkRequest } from './clerkRequest'; import { getCookieName, getCookieValue } from './cookie'; import { HandshakeService } from './handshake'; -import { getMachineTokenType, isMachineToken, isOAuthJwt, isTokenTypeAccepted } from './machine'; +import { getMachineTokenType, isMachineJwt, isMachineToken, isTokenTypeAccepted } from './machine'; import { OrganizationMatcher } from './organizationMatcher'; import type { MachineTokenType, SessionTokenType } from './tokenTypes'; import { TokenType } from './tokenTypes'; @@ -411,11 +411,11 @@ export const authenticateRequest: AuthenticateRequest = (async ( async function authenticateRequestWithTokenInHeader() { const { tokenInHeader } = authenticateContext; - // Reject OAuth JWTs that may appear in headers when expecting session tokens. - // OAuth JWTs are valid Clerk-signed JWTs and will pass verifyToken() verification, + // Reject machine JWTs (OAuth or M2M) that may appear in headers when expecting session tokens. + // These are valid Clerk-signed JWTs and will pass verifyToken() verification, // but should not be accepted as session tokens. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (isOAuthJwt(tokenInHeader!)) { + if (isMachineJwt(tokenInHeader!)) { return signedOut({ tokenType: TokenType.SessionToken, authenticateContext, diff --git a/packages/backend/src/tokens/verify.ts b/packages/backend/src/tokens/verify.ts index 020e9a70644..f5206477182 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -1,7 +1,7 @@ import { isClerkAPIResponseError } from '@clerk/shared/error'; import type { Jwt, JwtPayload, Simplify } from '@clerk/shared/types'; -import { type APIKey, IdPOAuthAccessToken, type M2MToken } from '../api'; +import { type APIKey, IdPOAuthAccessToken, M2MToken } from '../api'; import { createBackendApiClient } from '../api/factory'; import { MachineTokenVerificationError, @@ -192,55 +192,19 @@ function handleClerkAPIError( }; } -async function verifyM2MToken( +/** + * Verifies a pre-decoded machine JWT using the provided key resolution options. + * Shared by M2M and OAuth JWT verification paths to eliminate duplication. + */ +async function verifyDecodedJwtMachineToken( token: string, - options: VerifyTokenOptions & { machineSecretKey?: string }, -): Promise> { - try { - const client = createBackendApiClient(options); - const verifiedToken = await client.m2m.verify({ token }); - return { data: verifiedToken, tokenType: TokenType.M2MToken, errors: undefined }; - } catch (err: any) { - return handleClerkAPIError(TokenType.M2MToken, err, 'Machine token not found'); - } -} - -async function verifyJwtOAuthToken( - accessToken: string, + decodedResult: Jwt, options: VerifyTokenOptions, -): Promise> { - let decoded: JwtReturnType; - try { - decoded = decodeJwt(accessToken); - } catch (e) { - return { - data: undefined, - tokenType: TokenType.OAuthToken, - errors: [ - new MachineTokenVerificationError({ - code: MachineTokenVerificationErrorCode.TokenInvalid, - message: (e as Error).message, - }), - ], - }; - } - - const { data: decodedResult, errors } = decoded; - if (errors) { - return { - data: undefined, - tokenType: TokenType.OAuthToken, - errors: [ - new MachineTokenVerificationError({ - code: MachineTokenVerificationErrorCode.TokenInvalid, - message: errors[0].message, - }), - ], - }; - } - - const { header } = decodedResult; - const { kid } = header; + tokenType: MachineTokenType, + fromPayload: (payload: JwtPayload, clockSkewInMs?: number) => T, + headerType?: string[], +): Promise> { + const { kid } = decodedResult.header; let key: JsonWebKey; try { @@ -251,7 +215,7 @@ async function verifyJwtOAuthToken( } else { return { data: undefined, - tokenType: TokenType.OAuthToken, + tokenType, errors: [ new MachineTokenVerificationError({ action: TokenVerificationErrorAction.SetClerkJWTKey, @@ -262,16 +226,16 @@ async function verifyJwtOAuthToken( }; } - const { data: payload, errors: verifyErrors } = await verifyJwt(accessToken, { + const { data: payload, errors: verifyErrors } = await verifyJwt(token, { ...options, key, - headerType: OAUTH_ACCESS_TOKEN_TYPES, + ...(headerType ? { headerType } : {}), }); if (verifyErrors) { return { data: undefined, - tokenType: TokenType.OAuthToken, + tokenType, errors: [ new MachineTokenVerificationError({ code: MachineTokenVerificationErrorCode.TokenVerificationFailed, @@ -281,12 +245,11 @@ async function verifyJwtOAuthToken( }; } - const token = IdPOAuthAccessToken.fromJwtPayload(payload, options.clockSkewInMs); - - return { data: token, tokenType: TokenType.OAuthToken, errors: undefined }; + return { data: fromPayload(payload, options.clockSkewInMs), tokenType, errors: undefined }; } catch (error) { return { - tokenType: TokenType.OAuthToken, + data: undefined, + tokenType, errors: [ new MachineTokenVerificationError({ code: MachineTokenVerificationErrorCode.TokenVerificationFailed, @@ -297,14 +260,23 @@ async function verifyJwtOAuthToken( } } +async function verifyM2MToken( + token: string, + options: VerifyTokenOptions, +): Promise> { + try { + const client = createBackendApiClient(options); + const verifiedToken = await client.m2m.verify({ token }); + return { data: verifiedToken, tokenType: TokenType.M2MToken, errors: undefined }; + } catch (err: any) { + return handleClerkAPIError(TokenType.M2MToken, err, 'Machine token not found'); + } +} + async function verifyOAuthToken( accessToken: string, options: VerifyTokenOptions, ): Promise> { - if (isJwtFormat(accessToken)) { - return verifyJwtOAuthToken(accessToken, options); - } - try { const client = createBackendApiClient(options); const verifiedToken = await client.idPOAuthAccessToken.verify(accessToken); @@ -328,16 +300,79 @@ async function verifyAPIKey( } /** - * Verifies any type of machine token by detecting its type from the prefix. + * Verifies any type of machine token by detecting its type from the prefix or JWT claims. + * For JWTs, decodes once and routes based on claims to avoid redundant decoding. * - * @param token - The token to verify (e.g. starts with "m2m_", "oauth_", "api_key_", etc.) + * @param token - The token to verify (e.g. starts with "mt_", "oat_", "ak_", or a JWT) * @param options - Options including secretKey for BAPI authorization */ export async function verifyMachineAuthToken(token: string, options: VerifyTokenOptions) { + // JWT format: decode once and route based on claims + if (isJwtFormat(token)) { + let decodedResult: Jwt; + try { + const { data, errors: decodeErrors } = decodeJwt(token); + if (decodeErrors) { + return { + data: undefined, + tokenType: TokenType.M2MToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenInvalid, + message: decodeErrors[0].message, + }), + ], + } as MachineTokenReturnType; + } + decodedResult = data; + } catch (e) { + return { + data: undefined, + tokenType: TokenType.M2MToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenInvalid, + message: (e as Error).message, + }), + ], + } as MachineTokenReturnType; + } + + // M2M JWT: sub starts with mch_ + if (decodedResult.payload.sub.startsWith('mch_')) { + return verifyDecodedJwtMachineToken(token, decodedResult, options, TokenType.M2MToken, M2MToken.fromJwtPayload); + } + + // OAuth JWT: typ is at+jwt or application/at+jwt + if (OAUTH_ACCESS_TOKEN_TYPES.includes(decodedResult.header.typ as string)) { + return verifyDecodedJwtMachineToken( + token, + decodedResult, + options, + TokenType.OAuthToken, + IdPOAuthAccessToken.fromJwtPayload, + OAUTH_ACCESS_TOKEN_TYPES, + ); + } + + // JWT format but unrecognized machine token type + return { + data: undefined, + tokenType: TokenType.OAuthToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: `Invalid JWT type: ${decodedResult.header.typ ?? 'missing'}. Expected one of: ${OAUTH_ACCESS_TOKEN_TYPES.join(', ')} for OAuth, or sub starting with 'mch_' for M2M`, + }), + ], + } as MachineTokenReturnType; + } + + // Opaque token routing by prefix if (token.startsWith(M2M_TOKEN_PREFIX)) { return verifyM2MToken(token, options); } - if (token.startsWith(OAUTH_TOKEN_PREFIX) || isJwtFormat(token)) { + if (token.startsWith(OAUTH_TOKEN_PREFIX)) { return verifyOAuthToken(token, options); } if (token.startsWith(API_KEY_PREFIX)) {