From d2822398942d6b178230f74a9a79e96ebd1b7a52 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 18 Feb 2026 16:15:08 -0800 Subject: [PATCH 1/8] feat(backend): Add M2M JWT token verification support Add support for verifying M2M tokens in JWT format, mirroring the existing OAuth JWT verification pattern. Changes: - Add isM2MJwt() to detect M2M JWTs by checking sub claim starts with 'mch_' - Add isMachineJwt() helper to check for any machine JWT (OAuth or M2M) - Update isMachineToken() and getMachineTokenType() to recognize M2M JWTs - Add M2MToken.fromJwtPayload() to create M2MToken from verified JWT payload - Add verifyJwtM2MToken() for local JWT verification using JWKS - Update verifyM2MToken() to route JWT vs opaque token verification - Update request.ts to reject machine JWTs when expecting session tokens - Export isM2MJwt and isMachineJwt from internal.ts --- .../2026-02-18-m2m-jwt-support-design.md | 191 ++++ docs/plans/2026-02-18-m2m-jwt-support.md | 1005 +++++++++++++++++ .../backend/src/__tests__/exports.test.ts | 2 + .../backend/src/api/resources/M2MToken.ts | 34 + .../api/resources/__tests__/M2MToken.test.ts | 95 ++ packages/backend/src/fixtures/index.ts | 12 + packages/backend/src/fixtures/machine.ts | 14 + packages/backend/src/internal.ts | 2 + .../src/tokens/__tests__/machine.test.ts | 80 +- .../src/tokens/__tests__/verify.test.ts | 107 +- packages/backend/src/tokens/machine.ts | 42 +- packages/backend/src/tokens/request.ts | 8 +- packages/backend/src/tokens/verify.ts | 116 +- 13 files changed, 1690 insertions(+), 18 deletions(-) create mode 100644 docs/plans/2026-02-18-m2m-jwt-support-design.md create mode 100644 docs/plans/2026-02-18-m2m-jwt-support.md create mode 100644 packages/backend/src/api/resources/__tests__/M2MToken.test.ts diff --git a/docs/plans/2026-02-18-m2m-jwt-support-design.md b/docs/plans/2026-02-18-m2m-jwt-support-design.md new file mode 100644 index 00000000000..8291f549edd --- /dev/null +++ b/docs/plans/2026-02-18-m2m-jwt-support-design.md @@ -0,0 +1,191 @@ +# M2M JWT Token Support Design + +## Overview + +Add JWT format support for M2M (machine-to-machine) tokens in the JavaScript SDK. This mirrors the existing OAuth JWT support pattern and aligns with backend changes in clerk_go (#16849) and cloudflare-workers (#1579). + +## Background + +- **Current state**: M2M tokens only support opaque format (`mt_` prefix), verified via BAPI +- **OAuth pattern**: Supports both opaque (`oat_` prefix) and JWT format (detected by `typ: at+jwt` header) +- **New M2M JWT format**: Identified by `sub` claim starting with `mch_` (machine ID) + +## Design Decisions + +### Detection Strategy + +M2M JWTs are identified by checking the `sub` claim prefix (`mch_`), unlike OAuth JWTs which use the header `typ` field. This follows the pattern established by the backend implementation. + +**Detection order in `getMachineTokenType()`** (optimized for performance): + +1. `mt_` prefix OR `isM2MJwt()` → M2MToken (grouped together) +2. `oat_` prefix OR `isOAuthJwt()` → OAuthToken (grouped together) +3. `ak_` prefix → ApiKey + +Prefix checks run first (fast string comparison), JWT decode only as fallback. + +### Verification Flow + +JWT format M2M tokens are verified locally using JWKS (same as session tokens and OAuth JWTs). Opaque tokens continue to verify via BAPI. + +## Implementation + +### 1. Detection Logic (`packages/backend/src/tokens/machine.ts`) + +**New functions:** + +```typescript +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; + } +} + +export function isMachineJwt(token: string): boolean { + return isOAuthJwt(token) || isM2MJwt(token); +} +``` + +**Updated functions:** + +```typescript +export function isMachineToken(token: string): boolean { + return isMachineTokenByPrefix(token) || isOAuthJwt(token) || isM2MJwt(token); +} + +export function getMachineTokenType(token: string): MachineTokenType { + if (token.startsWith(M2M_TOKEN_PREFIX) || isM2MJwt(token)) { + return TokenType.M2MToken; + } + if (token.startsWith(OAUTH_TOKEN_PREFIX) || isOAuthJwt(token)) { + return TokenType.OAuthToken; + } + if (token.startsWith(API_KEY_PREFIX)) { + return TokenType.ApiKey; + } + throw new Error('Unknown machine token type'); +} +``` + +### 2. M2MToken Resource (`packages/backend/src/api/resources/M2MToken.ts`) + +**Add `fromJwtPayload()` static method:** + +```typescript +type M2MJwtPayload = JwtPayload & { + jti?: string; + scopes?: string; + aud?: string[]; +}; + +static fromJwtPayload(payload: JwtPayload, clockSkewInMs = 5000): M2MToken { + const m2mPayload = payload as M2MJwtPayload; + + return new M2MToken( + m2mPayload.jti ?? '', + payload.sub, + m2mPayload.aud ?? m2mPayload.scopes?.split(' ') ?? [], + null, // claims - not extracted from JWT + false, // revoked (JWT tokens can't be revoked) + null, // revocationReason + payload.exp * 1000 <= Date.now() - clockSkewInMs, + payload.exp, + payload.iat, + payload.iat, + ); +} +``` + +### 3. Verification Logic (`packages/backend/src/tokens/verify.ts`) + +**New `verifyJwtM2MToken()` function** (mirrors `verifyJwtOAuthToken()`): + +- Decode JWT +- Load JWK from PEM or remote +- Verify signature +- Return `M2MToken.fromJwtPayload()` + +**Updated `verifyM2MToken()`:** + +- If `isJwtFormat(token)` → call `verifyJwtM2MToken()` +- Else → verify via BAPI (existing behavior) + +### 4. Request Handling (`packages/backend/src/tokens/request.ts`) + +**Update `authenticateRequestWithTokenInHeader()`:** + +```typescript +// Reject machine JWTs (OAuth/M2M) when expecting session tokens. +if (isMachineJwt(tokenInHeader!)) { + return signedOut({ + tokenType: TokenType.SessionToken, + authenticateContext, + reason: AuthErrorReason.TokenTypeMismatch, + message: '', + }); +} +``` + +### 5. Test Fixtures (`packages/backend/src/fixtures/machine.ts`) + +**New M2M JWT fixtures:** + +```typescript +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', +}; + +export const mockSignedM2MJwt = '...'; // Generated and signed with signingJwks +``` + +## Files to Modify + +1. `packages/backend/src/tokens/machine.ts` - Add `isM2MJwt()`, `isMachineJwt()`, update detection functions +2. `packages/backend/src/api/resources/M2MToken.ts` - Add `fromJwtPayload()` method +3. `packages/backend/src/tokens/verify.ts` - Add `verifyJwtM2MToken()`, update `verifyM2MToken()` +4. `packages/backend/src/tokens/request.ts` - Update to use `isMachineJwt()` +5. `packages/backend/src/fixtures/machine.ts` - Add M2M JWT test fixtures +6. `packages/backend/src/tokens/__tests__/machine.test.ts` - Add unit tests for new functions +7. `packages/backend/src/tokens/__tests__/verify.test.ts` - Add M2M JWT verification tests + +## Testing Plan + +### Unit Tests (`machine.test.ts`) + +- `isM2MJwt()`: true for JWT with `sub` starting with `mch_`, false for OAuth JWT/regular JWT/non-JWT +- `isMachineJwt()`: true for both OAuth and M2M JWTs +- `isMachineToken()`: true for M2M JWT +- `getMachineTokenType()`: returns `m2m_token` for M2M JWT + +### Unit Tests (`verify.test.ts`) + +- `verifyMachineAuthToken()` with valid M2M JWT +- M2M JWT with invalid signature → error +- M2M JWT expired → error +- M2M JWT with `alg: none` → rejected + +### Unit Tests (`M2MToken.test.ts`) + +- `M2MToken.fromJwtPayload()` correctly maps JWT claims + +## Future Work + +- **USER-4713**: Backend verification endpoint for M2M JWT tokens. Once implemented, may require updates to align with BAPI response format. + +## Related PRs + +- clerk_go #16849 - Internal endpoint to fetch instance's primary domain (for JWT `iss` claim) +- cloudflare-workers #1579 - M2M token creation with JWT format support diff --git a/docs/plans/2026-02-18-m2m-jwt-support.md b/docs/plans/2026-02-18-m2m-jwt-support.md new file mode 100644 index 00000000000..ea4d15d738e --- /dev/null +++ b/docs/plans/2026-02-18-m2m-jwt-support.md @@ -0,0 +1,1005 @@ +# M2M JWT Token Support Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add JWT format support for M2M tokens, mirroring the existing OAuth JWT pattern. + +**Architecture:** Extend the machine token detection and verification system to recognize M2M JWTs by their `sub` claim prefix (`mch_`). JWT M2M tokens are verified locally using JWKS, while opaque tokens continue to use BAPI verification. + +**Tech Stack:** TypeScript, Vitest, JWT (RS256) + +--- + +### Task 1: Add M2M JWT Test Fixtures + +**Files:** + +- Modify: `packages/backend/src/fixtures/machine.ts` +- Modify: `packages/backend/src/fixtures/index.ts` (if needed for exports) + +**Step 1: Add M2M JWT payload fixture** + +In `packages/backend/src/fixtures/machine.ts`, add: + +```typescript +// M2M JWT payload for testing +// Uses same timestamps as mockOAuthAccessTokenJwtPayload for consistency +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', +}; +``` + +**Step 2: Add signed M2M JWT fixture** + +Check `packages/backend/src/fixtures/index.ts` to understand how `createJwt` works, then add to `machine.ts`: + +```typescript +// Import createJwt from fixtures if not already available +// The mockSignedM2MJwt should be created using the same signing keys as OAuth + +// Valid M2M JWT token +// Header: {"alg":"RS256","kid":"ins_2GIoQhbUpy0hX7B2cVkuTMinXoD","typ":"JWT"} +// Payload: mockM2MJwtPayload +// Signed with signingJwks, verifiable with mockJwks +export const mockSignedM2MJwt = + 'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2NsZXJrLm0ybS5leGFtcGxlLnRlc3QiLCJzdWIiOiJtY2hfMnZZVnRlc3RURVNUVGVZDFRFU1R0ZXN0VEVTVHRlc3QiLCJhdWQiOlsibWNoXzF4eHh4eCIsIm1jaF8yeHh4eHgiXSwiZXhwIjoxNjY2NjQ4NTUwLCJpYXQiOjE2NjY2NDgyNTAsIm5iZiI6MTY2NjY0ODI0MCwianRpIjoibXRfMnhLYTlCZ3Y3TnhNUkRGeVF3OExwWjNjVG1VMXZIakUiLCJzY29wZXMiOiJtY2hfMXh4eHh4IG1jaF8yeHh4eHgifQ.placeholder'; +``` + +**Note:** The actual signed JWT needs to be generated. Check how `mockSignedOAuthAccessTokenJwt` was created and follow the same pattern. You may need to use `createJwt` from fixtures and sign with the test signing keys. + +**Step 3: Export the new fixtures** + +Ensure exports at end of `machine.ts`: + +```typescript +// Should already export mockSignedOAuthAccessTokenJwt, add M2M exports +``` + +**Step 4: Commit** + +```bash +git add packages/backend/src/fixtures/machine.ts +git commit -m "test(backend): Add M2M JWT test fixtures" +``` + +--- + +### Task 2: Add `isM2MJwt()` Function with Tests + +**Files:** + +- Modify: `packages/backend/src/tokens/machine.ts` +- Modify: `packages/backend/src/tokens/__tests__/machine.test.ts` + +**Step 1: Write the failing tests for `isM2MJwt()`** + +In `packages/backend/src/tokens/__tests__/machine.test.ts`, add: + +```typescript +import { mockM2MJwtPayload, mockSignedM2MJwt } from '../../fixtures/machine'; + +// Add to imports at top +import { + // ... existing imports + isM2MJwt, +} from '../machine'; + +// Add new describe block +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 true for signed M2M JWT', () => { + expect(isM2MJwt(mockSignedM2MJwt)).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); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd packages/backend && pnpm test -- --run machine.test.ts +``` + +Expected: FAIL with "isM2MJwt is not exported" + +**Step 3: Implement `isM2MJwt()`** + +In `packages/backend/src/tokens/machine.ts`, add: + +```typescript +/** + * 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; + } +} +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd packages/backend && pnpm test -- --run machine.test.ts +``` + +Expected: All `isM2MJwt` tests PASS + +**Step 5: Commit** + +```bash +git add packages/backend/src/tokens/machine.ts packages/backend/src/tokens/__tests__/machine.test.ts +git commit -m "feat(backend): Add isM2MJwt() function to detect M2M JWT tokens" +``` + +--- + +### Task 3: Add `isMachineJwt()` Helper with Tests + +**Files:** + +- Modify: `packages/backend/src/tokens/machine.ts` +- Modify: `packages/backend/src/tokens/__tests__/machine.test.ts` + +**Step 1: Write the failing tests** + +In `machine.test.ts`, add: + +```typescript +// Add to imports +import { + // ... existing imports + isMachineJwt, +} from '../machine'; + +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); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd packages/backend && pnpm test -- --run machine.test.ts +``` + +Expected: FAIL with "isMachineJwt is not exported" + +**Step 3: Implement `isMachineJwt()`** + +In `machine.ts`, add: + +```typescript +/** + * 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); +} +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd packages/backend && pnpm test -- --run machine.test.ts +``` + +Expected: All `isMachineJwt` tests PASS + +**Step 5: Commit** + +```bash +git add packages/backend/src/tokens/machine.ts packages/backend/src/tokens/__tests__/machine.test.ts +git commit -m "feat(backend): Add isMachineJwt() helper function" +``` + +--- + +### Task 4: Update `isMachineToken()` with Tests + +**Files:** + +- Modify: `packages/backend/src/tokens/machine.ts` +- Modify: `packages/backend/src/tokens/__tests__/machine.test.ts` + +**Step 1: Add test for M2M JWT in `isMachineToken`** + +In `machine.test.ts`, find the `describe('isMachineToken')` block and add: + +```typescript +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 true for signed M2M JWT', () => { + expect(isMachineToken(mockSignedM2MJwt)).toBe(true); +}); +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd packages/backend && pnpm test -- --run machine.test.ts +``` + +Expected: FAIL - M2M JWT not recognized as machine token + +**Step 3: Update `isMachineToken()`** + +In `machine.ts`, update: + +```typescript +/** + * 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) || isM2MJwt(token); +} +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd packages/backend && pnpm test -- --run machine.test.ts +``` + +Expected: All tests PASS + +**Step 5: Commit** + +```bash +git add packages/backend/src/tokens/machine.ts packages/backend/src/tokens/__tests__/machine.test.ts +git commit -m "feat(backend): Update isMachineToken() to recognize M2M JWTs" +``` + +--- + +### Task 5: Update `getMachineTokenType()` with Tests + +**Files:** + +- Modify: `packages/backend/src/tokens/machine.ts` +- Modify: `packages/backend/src/tokens/__tests__/machine.test.ts` + +**Step 1: Add tests for M2M JWT detection** + +In `machine.test.ts`, find `describe('getMachineTokenType')` and add: + +```typescript +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 "m2m_token" for signed M2M JWT', () => { + expect(getMachineTokenType(mockSignedM2MJwt)).toBe('m2m_token'); +}); +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd packages/backend && pnpm test -- --run machine.test.ts +``` + +Expected: FAIL with "Unknown machine token type" + +**Step 3: Update `getMachineTokenType()`** + +In `machine.ts`, update: + +```typescript +/** + * Gets the specific type of machine token based on its prefix or JWT claims. + * + * @param token - The token string to check + * @returns The specific MachineTokenType + * @throws Error if the token doesn't match any known machine token type + */ +export function getMachineTokenType(token: string): MachineTokenType { + // 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; + } + + if (token.startsWith(API_KEY_PREFIX)) { + return TokenType.ApiKey; + } + + throw new Error('Unknown machine token type'); +} +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd packages/backend && pnpm test -- --run machine.test.ts +``` + +Expected: All tests PASS + +**Step 5: Commit** + +```bash +git add packages/backend/src/tokens/machine.ts packages/backend/src/tokens/__tests__/machine.test.ts +git commit -m "feat(backend): Update getMachineTokenType() to return m2m_token for M2M JWTs" +``` + +--- + +### Task 6: Add `M2MToken.fromJwtPayload()` with Tests + +**Files:** + +- Modify: `packages/backend/src/api/resources/M2MToken.ts` +- Create or modify: `packages/backend/src/api/resources/__tests__/M2MToken.test.ts` + +**Step 1: Write the failing test** + +Create or modify `packages/backend/src/api/resources/__tests__/M2MToken.test.ts`: + +```typescript +import { describe, expect, it, vi, beforeEach, afterEach } 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(''); + }); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +```bash +cd packages/backend && pnpm test -- --run M2MToken.test.ts +``` + +Expected: FAIL with "fromJwtPayload is not a function" + +**Step 3: Implement `fromJwtPayload()`** + +In `packages/backend/src/api/resources/M2MToken.ts`, add: + +```typescript +import type { JwtPayload } from '@clerk/types'; + +// Add at top of file after imports +type M2MJwtPayload = JwtPayload & { + jti?: string; + scopes?: string; + aud?: string[]; +}; + +// Add inside the M2MToken class +/** + * Creates an M2MToken from a JWT payload. + * Maps standard JWT claims to token properties. + */ +static fromJwtPayload(payload: JwtPayload, clockSkewInMs = 5000): M2MToken { + const m2mPayload = payload as M2MJwtPayload; + + return new M2MToken( + m2mPayload.jti ?? '', + payload.sub, + m2mPayload.aud ?? m2mPayload.scopes?.split(' ') ?? [], + null, + false, + null, + payload.exp * 1000 <= Date.now() - clockSkewInMs, + payload.exp, + payload.iat, + payload.iat, + ); +} +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd packages/backend && pnpm test -- --run M2MToken.test.ts +``` + +Expected: All tests PASS + +**Step 5: Commit** + +```bash +git add packages/backend/src/api/resources/M2MToken.ts packages/backend/src/api/resources/__tests__/M2MToken.test.ts +git commit -m "feat(backend): Add M2MToken.fromJwtPayload() for JWT verification" +``` + +--- + +### Task 7: Add `verifyJwtM2MToken()` and Update `verifyM2MToken()` + +**Files:** + +- Modify: `packages/backend/src/tokens/verify.ts` +- Modify: `packages/backend/src/tokens/__tests__/verify.test.ts` + +**Step 1: Write the failing tests** + +In `packages/backend/src/tokens/__tests__/verify.test.ts`, add: + +```typescript +// Add imports +import { mockM2MJwtPayload, mockSignedM2MJwt } from '../../fixtures/machine'; + +// Add helper function (similar to createOAuthJwt) +function createM2MJwt(payload = mockM2MJwtPayload) { + return createJwt({ + header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' }, + payload, + }); +} + +// Add new describe block +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 = createM2MJwt(); + + 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 = createM2MJwt(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'); + }); + + it('handles invalid JWT format', async () => { + const invalidJwt = 'invalid.m2m.jwt'; + + const result = await verifyMachineAuthToken(invalidJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + }); + + expect(result.errors).toBeDefined(); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd packages/backend && pnpm test -- --run verify.test.ts +``` + +Expected: FAIL - M2M JWT not properly verified + +**Step 3: Implement `verifyJwtM2MToken()` and update `verifyM2MToken()`** + +In `packages/backend/src/tokens/verify.ts`, add: + +```typescript +// Add import +import { isJwtFormat, isM2MJwt } from './machine'; +// Update M2MToken import to include fromJwtPayload usage + +// Add new function (after verifyJwtOAuthToken) +async function verifyJwtM2MToken( + token: string, + options: VerifyTokenOptions, +): Promise> { + let decoded: JwtReturnType; + try { + decoded = decodeJwt(token); + } catch (e) { + return { + data: undefined, + tokenType: TokenType.M2MToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenInvalid, + message: (e as Error).message, + }), + ], + }; + } + + const { data: decodedResult, errors } = decoded; + if (errors) { + return { + data: undefined, + tokenType: TokenType.M2MToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenInvalid, + message: errors[0].message, + }), + ], + }; + } + + const { header } = decodedResult; + const { kid } = header; + let key: JsonWebKey; + + try { + if (options.jwtKey) { + key = loadClerkJwkFromPem({ kid, pem: options.jwtKey }); + } else if (options.secretKey) { + key = await loadClerkJWKFromRemote({ ...options, kid }); + } else { + return { + data: undefined, + tokenType: TokenType.M2MToken, + errors: [ + new MachineTokenVerificationError({ + action: TokenVerificationErrorAction.SetClerkJWTKey, + message: 'Failed to resolve JWK during verification.', + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + }), + ], + }; + } + + const { data: payload, errors: verifyErrors } = await verifyJwt(token, { + ...options, + key, + }); + + if (verifyErrors) { + return { + data: undefined, + tokenType: TokenType.M2MToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: verifyErrors[0].message, + }), + ], + }; + } + + const m2mToken = M2MToken.fromJwtPayload(payload, options.clockSkewInMs); + + return { data: m2mToken, tokenType: TokenType.M2MToken, errors: undefined }; + } catch (error) { + return { + tokenType: TokenType.M2MToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: (error as Error).message, + }), + ], + }; + } +} + +// Update verifyM2MToken function +async function verifyM2MToken( + token: string, + options: VerifyTokenOptions & { machineSecretKey?: string }, +): Promise> { + // JWT format: verify locally + if (isJwtFormat(token)) { + return verifyJwtM2MToken(token, options); + } + + // Opaque format: verify via BAPI + 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'); + } +} +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd packages/backend && pnpm test -- --run verify.test.ts +``` + +Expected: All tests PASS + +**Step 5: Commit** + +```bash +git add packages/backend/src/tokens/verify.ts packages/backend/src/tokens/__tests__/verify.test.ts +git commit -m "feat(backend): Add JWT verification support for M2M tokens" +``` + +--- + +### Task 8: Update `request.ts` to Use `isMachineJwt()` + +**Files:** + +- Modify: `packages/backend/src/tokens/request.ts` +- Modify: `packages/backend/src/tokens/__tests__/request.test.ts` (if exists) + +**Step 1: Update import** + +In `packages/backend/src/tokens/request.ts`, update the import: + +```typescript +// Change from: +import { getMachineTokenType, isMachineToken, isOAuthJwt, isTokenTypeAccepted } from './machine'; + +// To: +import { getMachineTokenType, isMachineToken, isMachineJwt, isTokenTypeAccepted } from './machine'; +``` + +**Step 2: Update `authenticateRequestWithTokenInHeader()`** + +Find the function and update the OAuth JWT check: + +```typescript +async function authenticateRequestWithTokenInHeader() { + const { tokenInHeader } = authenticateContext; + + // Reject machine JWTs (OAuth/M2M) when expecting session tokens. + // These are valid Clerk-signed JWTs but should not be accepted as session tokens. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (isMachineJwt(tokenInHeader!)) { + return signedOut({ + tokenType: TokenType.SessionToken, + authenticateContext, + reason: AuthErrorReason.TokenTypeMismatch, + message: '', + }); + } + + // ... rest of function unchanged +``` + +**Step 3: Run all tests to ensure no regressions** + +```bash +cd packages/backend && pnpm test +``` + +Expected: All tests PASS + +**Step 4: Commit** + +```bash +git add packages/backend/src/tokens/request.ts +git commit -m "refactor(backend): Use isMachineJwt() in request authentication" +``` + +--- + +### Task 9: Export New Functions and Final Verification + +**Files:** + +- Modify: `packages/backend/src/tokens/machine.ts` (verify exports) +- Modify: `packages/backend/src/index.ts` (if public exports needed) + +**Step 1: Verify all functions are exported from machine.ts** + +Ensure `machine.ts` exports: + +- `isM2MJwt` +- `isMachineJwt` +- All existing exports + +**Step 2: Run full test suite** + +```bash +cd packages/backend && pnpm test +``` + +Expected: All tests PASS + +**Step 3: Run linter** + +```bash +cd packages/backend && pnpm lint +``` + +Expected: No errors + +**Step 4: Run type check** + +```bash +cd packages/backend && pnpm typecheck +``` + +Expected: No errors + +**Step 5: Final commit** + +```bash +git add -A +git commit -m "chore(backend): Ensure all M2M JWT functions are properly exported" +``` + +--- + +### Task 10: Integration Test (Optional) + +**Files:** + +- Check: `integration/tests/machine-auth/m2m.test.ts` + +**Step 1: Review existing integration tests** + +Check if there are existing M2M integration tests that need updating. + +**Step 2: If tests exist, add M2M JWT test case** + +Follow the existing pattern in the file to add a test for M2M JWT verification. + +**Step 3: Run integration tests** + +```bash +pnpm test:integration:nextjs +``` + +**Step 4: Commit if changes made** + +```bash +git add integration/ +git commit -m "test(integration): Add M2M JWT integration tests" +``` + +--- + +## Summary + +After completing all tasks, you will have: + +1. Test fixtures for M2M JWT tokens +2. `isM2MJwt()` - detects M2M JWTs by `sub` claim prefix +3. `isMachineJwt()` - helper to detect any machine JWT +4. Updated `isMachineToken()` to recognize M2M JWTs +5. Updated `getMachineTokenType()` to return correct type for M2M JWTs +6. `M2MToken.fromJwtPayload()` for creating M2MToken from JWT +7. `verifyJwtM2MToken()` for local JWT verification +8. Updated `verifyM2MToken()` to route JWT vs opaque +9. Updated `request.ts` to reject M2M JWTs as session tokens +10. Full test coverage for all new functionality 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/resources/M2MToken.ts b/packages/backend/src/api/resources/M2MToken.ts index 48ccf78bf1d..1032df9bbdc 100644 --- a/packages/backend/src/api/resources/M2MToken.ts +++ b/packages/backend/src/api/resources/M2MToken.ts @@ -1,5 +1,20 @@ import type { M2MTokenJSON } from './JSON'; +/** + * Base JWT payload type for M2M tokens. + * M2M tokens don't include session-specific claims like sid, so we use a simpler type. + */ +type M2MJwtPayloadInput = { + iss?: string; + sub: string; + aud?: string[]; + exp: number; + iat: number; + nbf?: number; + jti?: string; + scopes?: string; +}; + /** * The Backend `M2MToken` object holds information about a machine-to-machine token. */ @@ -33,4 +48,23 @@ export class M2MToken { data.token, ); } + + /** + * Creates an M2MToken from a JWT payload. + * Maps standard JWT claims to token properties. + */ + static fromJwtPayload(payload: M2MJwtPayloadInput, clockSkewInMs = 5000): M2MToken { + return new M2MToken( + payload.jti ?? '', + payload.sub, + payload.aud ?? payload.scopes?.split(' ') ?? [], + null, + false, + null, + payload.exp * 1000 <= Date.now() - clockSkewInMs, + payload.exp, + payload.iat, + payload.iat, + ); + } } 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..93ca3241996 100644 --- a/packages/backend/src/tokens/__tests__/machine.test.ts +++ b/packages/backend/src/tokens/__tests__/machine.test.ts @@ -1,14 +1,16 @@ 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, + isMachineJwt, isMachineToken, isMachineTokenByPrefix, isMachineTokenType, + isM2MJwt, isOAuthJwt, isTokenTypeAccepted, M2M_TOKEN_PREFIX, @@ -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..ebb73801037 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; } 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..6b0afae9392 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, @@ -15,7 +15,14 @@ import type { JwtReturnType, MachineTokenReturnType } from '../jwt/types'; import { decodeJwt, verifyJwt } from '../jwt/verifyJwt'; import type { LoadClerkJWKFromRemoteOptions } from './keys'; import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from './keys'; -import { API_KEY_PREFIX, isJwtFormat, M2M_TOKEN_PREFIX, OAUTH_ACCESS_TOKEN_TYPES, OAUTH_TOKEN_PREFIX } from './machine'; +import { + API_KEY_PREFIX, + isJwtFormat, + isM2MJwt, + M2M_TOKEN_PREFIX, + OAUTH_ACCESS_TOKEN_TYPES, + OAUTH_TOKEN_PREFIX, +} from './machine'; import type { MachineTokenType } from './tokenTypes'; import { TokenType } from './tokenTypes'; @@ -192,10 +199,107 @@ function handleClerkAPIError( }; } +async function verifyJwtM2MToken( + token: string, + options: VerifyTokenOptions, +): Promise> { + let decoded: JwtReturnType; + try { + decoded = decodeJwt(token); + } catch (e) { + return { + data: undefined, + tokenType: TokenType.M2MToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenInvalid, + message: (e as Error).message, + }), + ], + }; + } + + const { data: decodedResult, errors } = decoded; + if (errors) { + return { + data: undefined, + tokenType: TokenType.M2MToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenInvalid, + message: errors[0].message, + }), + ], + }; + } + + const { header } = decodedResult; + const { kid } = header; + let key: JsonWebKey; + + try { + if (options.jwtKey) { + key = loadClerkJwkFromPem({ kid, pem: options.jwtKey }); + } else if (options.secretKey) { + key = await loadClerkJWKFromRemote({ ...options, kid }); + } else { + return { + data: undefined, + tokenType: TokenType.M2MToken, + errors: [ + new MachineTokenVerificationError({ + action: TokenVerificationErrorAction.SetClerkJWTKey, + message: 'Failed to resolve JWK during verification.', + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + }), + ], + }; + } + + const { data: payload, errors: verifyErrors } = await verifyJwt(token, { + ...options, + key, + }); + + if (verifyErrors) { + return { + data: undefined, + tokenType: TokenType.M2MToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: verifyErrors[0].message, + }), + ], + }; + } + + const m2mToken = M2MToken.fromJwtPayload(payload, options.clockSkewInMs); + + return { data: m2mToken, tokenType: TokenType.M2MToken, errors: undefined }; + } catch (error) { + return { + tokenType: TokenType.M2MToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: (error as Error).message, + }), + ], + }; + } +} + async function verifyM2MToken( token: string, options: VerifyTokenOptions & { machineSecretKey?: string }, ): Promise> { + // JWT format: verify locally + if (isJwtFormat(token)) { + return verifyJwtM2MToken(token, options); + } + + // Opaque format: verify via BAPI try { const client = createBackendApiClient(options); const verifiedToken = await client.m2m.verify({ token }); @@ -328,15 +432,17 @@ 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. * - * @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 "m2m_", "oauth_", "api_key_", or a JWT) * @param options - Options including secretKey for BAPI authorization */ export async function verifyMachineAuthToken(token: string, options: VerifyTokenOptions) { - if (token.startsWith(M2M_TOKEN_PREFIX)) { + // M2M: prefix OR JWT with mch_ subject + if (token.startsWith(M2M_TOKEN_PREFIX) || isM2MJwt(token)) { return verifyM2MToken(token, options); } + // OAuth: prefix OR JWT with at+jwt typ if (token.startsWith(OAUTH_TOKEN_PREFIX) || isJwtFormat(token)) { return verifyOAuthToken(token, options); } From bdf4613d2017e26cc2260fc485155044a672869a Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 18 Feb 2026 16:16:47 -0800 Subject: [PATCH 2/8] add changeset --- .changeset/cyan-wings-turn.md | 5 + .../2026-02-18-m2m-jwt-support-design.md | 191 ---- docs/plans/2026-02-18-m2m-jwt-support.md | 1005 ----------------- 3 files changed, 5 insertions(+), 1196 deletions(-) create mode 100644 .changeset/cyan-wings-turn.md delete mode 100644 docs/plans/2026-02-18-m2m-jwt-support-design.md delete mode 100644 docs/plans/2026-02-18-m2m-jwt-support.md diff --git a/.changeset/cyan-wings-turn.md b/.changeset/cyan-wings-turn.md new file mode 100644 index 00000000000..94514943b23 --- /dev/null +++ b/.changeset/cyan-wings-turn.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': minor +--- + +Add M2M JWT support diff --git a/docs/plans/2026-02-18-m2m-jwt-support-design.md b/docs/plans/2026-02-18-m2m-jwt-support-design.md deleted file mode 100644 index 8291f549edd..00000000000 --- a/docs/plans/2026-02-18-m2m-jwt-support-design.md +++ /dev/null @@ -1,191 +0,0 @@ -# M2M JWT Token Support Design - -## Overview - -Add JWT format support for M2M (machine-to-machine) tokens in the JavaScript SDK. This mirrors the existing OAuth JWT support pattern and aligns with backend changes in clerk_go (#16849) and cloudflare-workers (#1579). - -## Background - -- **Current state**: M2M tokens only support opaque format (`mt_` prefix), verified via BAPI -- **OAuth pattern**: Supports both opaque (`oat_` prefix) and JWT format (detected by `typ: at+jwt` header) -- **New M2M JWT format**: Identified by `sub` claim starting with `mch_` (machine ID) - -## Design Decisions - -### Detection Strategy - -M2M JWTs are identified by checking the `sub` claim prefix (`mch_`), unlike OAuth JWTs which use the header `typ` field. This follows the pattern established by the backend implementation. - -**Detection order in `getMachineTokenType()`** (optimized for performance): - -1. `mt_` prefix OR `isM2MJwt()` → M2MToken (grouped together) -2. `oat_` prefix OR `isOAuthJwt()` → OAuthToken (grouped together) -3. `ak_` prefix → ApiKey - -Prefix checks run first (fast string comparison), JWT decode only as fallback. - -### Verification Flow - -JWT format M2M tokens are verified locally using JWKS (same as session tokens and OAuth JWTs). Opaque tokens continue to verify via BAPI. - -## Implementation - -### 1. Detection Logic (`packages/backend/src/tokens/machine.ts`) - -**New functions:** - -```typescript -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; - } -} - -export function isMachineJwt(token: string): boolean { - return isOAuthJwt(token) || isM2MJwt(token); -} -``` - -**Updated functions:** - -```typescript -export function isMachineToken(token: string): boolean { - return isMachineTokenByPrefix(token) || isOAuthJwt(token) || isM2MJwt(token); -} - -export function getMachineTokenType(token: string): MachineTokenType { - if (token.startsWith(M2M_TOKEN_PREFIX) || isM2MJwt(token)) { - return TokenType.M2MToken; - } - if (token.startsWith(OAUTH_TOKEN_PREFIX) || isOAuthJwt(token)) { - return TokenType.OAuthToken; - } - if (token.startsWith(API_KEY_PREFIX)) { - return TokenType.ApiKey; - } - throw new Error('Unknown machine token type'); -} -``` - -### 2. M2MToken Resource (`packages/backend/src/api/resources/M2MToken.ts`) - -**Add `fromJwtPayload()` static method:** - -```typescript -type M2MJwtPayload = JwtPayload & { - jti?: string; - scopes?: string; - aud?: string[]; -}; - -static fromJwtPayload(payload: JwtPayload, clockSkewInMs = 5000): M2MToken { - const m2mPayload = payload as M2MJwtPayload; - - return new M2MToken( - m2mPayload.jti ?? '', - payload.sub, - m2mPayload.aud ?? m2mPayload.scopes?.split(' ') ?? [], - null, // claims - not extracted from JWT - false, // revoked (JWT tokens can't be revoked) - null, // revocationReason - payload.exp * 1000 <= Date.now() - clockSkewInMs, - payload.exp, - payload.iat, - payload.iat, - ); -} -``` - -### 3. Verification Logic (`packages/backend/src/tokens/verify.ts`) - -**New `verifyJwtM2MToken()` function** (mirrors `verifyJwtOAuthToken()`): - -- Decode JWT -- Load JWK from PEM or remote -- Verify signature -- Return `M2MToken.fromJwtPayload()` - -**Updated `verifyM2MToken()`:** - -- If `isJwtFormat(token)` → call `verifyJwtM2MToken()` -- Else → verify via BAPI (existing behavior) - -### 4. Request Handling (`packages/backend/src/tokens/request.ts`) - -**Update `authenticateRequestWithTokenInHeader()`:** - -```typescript -// Reject machine JWTs (OAuth/M2M) when expecting session tokens. -if (isMachineJwt(tokenInHeader!)) { - return signedOut({ - tokenType: TokenType.SessionToken, - authenticateContext, - reason: AuthErrorReason.TokenTypeMismatch, - message: '', - }); -} -``` - -### 5. Test Fixtures (`packages/backend/src/fixtures/machine.ts`) - -**New M2M JWT fixtures:** - -```typescript -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', -}; - -export const mockSignedM2MJwt = '...'; // Generated and signed with signingJwks -``` - -## Files to Modify - -1. `packages/backend/src/tokens/machine.ts` - Add `isM2MJwt()`, `isMachineJwt()`, update detection functions -2. `packages/backend/src/api/resources/M2MToken.ts` - Add `fromJwtPayload()` method -3. `packages/backend/src/tokens/verify.ts` - Add `verifyJwtM2MToken()`, update `verifyM2MToken()` -4. `packages/backend/src/tokens/request.ts` - Update to use `isMachineJwt()` -5. `packages/backend/src/fixtures/machine.ts` - Add M2M JWT test fixtures -6. `packages/backend/src/tokens/__tests__/machine.test.ts` - Add unit tests for new functions -7. `packages/backend/src/tokens/__tests__/verify.test.ts` - Add M2M JWT verification tests - -## Testing Plan - -### Unit Tests (`machine.test.ts`) - -- `isM2MJwt()`: true for JWT with `sub` starting with `mch_`, false for OAuth JWT/regular JWT/non-JWT -- `isMachineJwt()`: true for both OAuth and M2M JWTs -- `isMachineToken()`: true for M2M JWT -- `getMachineTokenType()`: returns `m2m_token` for M2M JWT - -### Unit Tests (`verify.test.ts`) - -- `verifyMachineAuthToken()` with valid M2M JWT -- M2M JWT with invalid signature → error -- M2M JWT expired → error -- M2M JWT with `alg: none` → rejected - -### Unit Tests (`M2MToken.test.ts`) - -- `M2MToken.fromJwtPayload()` correctly maps JWT claims - -## Future Work - -- **USER-4713**: Backend verification endpoint for M2M JWT tokens. Once implemented, may require updates to align with BAPI response format. - -## Related PRs - -- clerk_go #16849 - Internal endpoint to fetch instance's primary domain (for JWT `iss` claim) -- cloudflare-workers #1579 - M2M token creation with JWT format support diff --git a/docs/plans/2026-02-18-m2m-jwt-support.md b/docs/plans/2026-02-18-m2m-jwt-support.md deleted file mode 100644 index ea4d15d738e..00000000000 --- a/docs/plans/2026-02-18-m2m-jwt-support.md +++ /dev/null @@ -1,1005 +0,0 @@ -# M2M JWT Token Support Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add JWT format support for M2M tokens, mirroring the existing OAuth JWT pattern. - -**Architecture:** Extend the machine token detection and verification system to recognize M2M JWTs by their `sub` claim prefix (`mch_`). JWT M2M tokens are verified locally using JWKS, while opaque tokens continue to use BAPI verification. - -**Tech Stack:** TypeScript, Vitest, JWT (RS256) - ---- - -### Task 1: Add M2M JWT Test Fixtures - -**Files:** - -- Modify: `packages/backend/src/fixtures/machine.ts` -- Modify: `packages/backend/src/fixtures/index.ts` (if needed for exports) - -**Step 1: Add M2M JWT payload fixture** - -In `packages/backend/src/fixtures/machine.ts`, add: - -```typescript -// M2M JWT payload for testing -// Uses same timestamps as mockOAuthAccessTokenJwtPayload for consistency -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', -}; -``` - -**Step 2: Add signed M2M JWT fixture** - -Check `packages/backend/src/fixtures/index.ts` to understand how `createJwt` works, then add to `machine.ts`: - -```typescript -// Import createJwt from fixtures if not already available -// The mockSignedM2MJwt should be created using the same signing keys as OAuth - -// Valid M2M JWT token -// Header: {"alg":"RS256","kid":"ins_2GIoQhbUpy0hX7B2cVkuTMinXoD","typ":"JWT"} -// Payload: mockM2MJwtPayload -// Signed with signingJwks, verifiable with mockJwks -export const mockSignedM2MJwt = - 'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2NsZXJrLm0ybS5leGFtcGxlLnRlc3QiLCJzdWIiOiJtY2hfMnZZVnRlc3RURVNUVGVZDFRFU1R0ZXN0VEVTVHRlc3QiLCJhdWQiOlsibWNoXzF4eHh4eCIsIm1jaF8yeHh4eHgiXSwiZXhwIjoxNjY2NjQ4NTUwLCJpYXQiOjE2NjY2NDgyNTAsIm5iZiI6MTY2NjY0ODI0MCwianRpIjoibXRfMnhLYTlCZ3Y3TnhNUkRGeVF3OExwWjNjVG1VMXZIakUiLCJzY29wZXMiOiJtY2hfMXh4eHh4IG1jaF8yeHh4eHgifQ.placeholder'; -``` - -**Note:** The actual signed JWT needs to be generated. Check how `mockSignedOAuthAccessTokenJwt` was created and follow the same pattern. You may need to use `createJwt` from fixtures and sign with the test signing keys. - -**Step 3: Export the new fixtures** - -Ensure exports at end of `machine.ts`: - -```typescript -// Should already export mockSignedOAuthAccessTokenJwt, add M2M exports -``` - -**Step 4: Commit** - -```bash -git add packages/backend/src/fixtures/machine.ts -git commit -m "test(backend): Add M2M JWT test fixtures" -``` - ---- - -### Task 2: Add `isM2MJwt()` Function with Tests - -**Files:** - -- Modify: `packages/backend/src/tokens/machine.ts` -- Modify: `packages/backend/src/tokens/__tests__/machine.test.ts` - -**Step 1: Write the failing tests for `isM2MJwt()`** - -In `packages/backend/src/tokens/__tests__/machine.test.ts`, add: - -```typescript -import { mockM2MJwtPayload, mockSignedM2MJwt } from '../../fixtures/machine'; - -// Add to imports at top -import { - // ... existing imports - isM2MJwt, -} from '../machine'; - -// Add new describe block -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 true for signed M2M JWT', () => { - expect(isM2MJwt(mockSignedM2MJwt)).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); - }); -}); -``` - -**Step 2: Run tests to verify they fail** - -```bash -cd packages/backend && pnpm test -- --run machine.test.ts -``` - -Expected: FAIL with "isM2MJwt is not exported" - -**Step 3: Implement `isM2MJwt()`** - -In `packages/backend/src/tokens/machine.ts`, add: - -```typescript -/** - * 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; - } -} -``` - -**Step 4: Run tests to verify they pass** - -```bash -cd packages/backend && pnpm test -- --run machine.test.ts -``` - -Expected: All `isM2MJwt` tests PASS - -**Step 5: Commit** - -```bash -git add packages/backend/src/tokens/machine.ts packages/backend/src/tokens/__tests__/machine.test.ts -git commit -m "feat(backend): Add isM2MJwt() function to detect M2M JWT tokens" -``` - ---- - -### Task 3: Add `isMachineJwt()` Helper with Tests - -**Files:** - -- Modify: `packages/backend/src/tokens/machine.ts` -- Modify: `packages/backend/src/tokens/__tests__/machine.test.ts` - -**Step 1: Write the failing tests** - -In `machine.test.ts`, add: - -```typescript -// Add to imports -import { - // ... existing imports - isMachineJwt, -} from '../machine'; - -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); - }); -}); -``` - -**Step 2: Run tests to verify they fail** - -```bash -cd packages/backend && pnpm test -- --run machine.test.ts -``` - -Expected: FAIL with "isMachineJwt is not exported" - -**Step 3: Implement `isMachineJwt()`** - -In `machine.ts`, add: - -```typescript -/** - * 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); -} -``` - -**Step 4: Run tests to verify they pass** - -```bash -cd packages/backend && pnpm test -- --run machine.test.ts -``` - -Expected: All `isMachineJwt` tests PASS - -**Step 5: Commit** - -```bash -git add packages/backend/src/tokens/machine.ts packages/backend/src/tokens/__tests__/machine.test.ts -git commit -m "feat(backend): Add isMachineJwt() helper function" -``` - ---- - -### Task 4: Update `isMachineToken()` with Tests - -**Files:** - -- Modify: `packages/backend/src/tokens/machine.ts` -- Modify: `packages/backend/src/tokens/__tests__/machine.test.ts` - -**Step 1: Add test for M2M JWT in `isMachineToken`** - -In `machine.test.ts`, find the `describe('isMachineToken')` block and add: - -```typescript -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 true for signed M2M JWT', () => { - expect(isMachineToken(mockSignedM2MJwt)).toBe(true); -}); -``` - -**Step 2: Run tests to verify they fail** - -```bash -cd packages/backend && pnpm test -- --run machine.test.ts -``` - -Expected: FAIL - M2M JWT not recognized as machine token - -**Step 3: Update `isMachineToken()`** - -In `machine.ts`, update: - -```typescript -/** - * 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) || isM2MJwt(token); -} -``` - -**Step 4: Run tests to verify they pass** - -```bash -cd packages/backend && pnpm test -- --run machine.test.ts -``` - -Expected: All tests PASS - -**Step 5: Commit** - -```bash -git add packages/backend/src/tokens/machine.ts packages/backend/src/tokens/__tests__/machine.test.ts -git commit -m "feat(backend): Update isMachineToken() to recognize M2M JWTs" -``` - ---- - -### Task 5: Update `getMachineTokenType()` with Tests - -**Files:** - -- Modify: `packages/backend/src/tokens/machine.ts` -- Modify: `packages/backend/src/tokens/__tests__/machine.test.ts` - -**Step 1: Add tests for M2M JWT detection** - -In `machine.test.ts`, find `describe('getMachineTokenType')` and add: - -```typescript -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 "m2m_token" for signed M2M JWT', () => { - expect(getMachineTokenType(mockSignedM2MJwt)).toBe('m2m_token'); -}); -``` - -**Step 2: Run tests to verify they fail** - -```bash -cd packages/backend && pnpm test -- --run machine.test.ts -``` - -Expected: FAIL with "Unknown machine token type" - -**Step 3: Update `getMachineTokenType()`** - -In `machine.ts`, update: - -```typescript -/** - * Gets the specific type of machine token based on its prefix or JWT claims. - * - * @param token - The token string to check - * @returns The specific MachineTokenType - * @throws Error if the token doesn't match any known machine token type - */ -export function getMachineTokenType(token: string): MachineTokenType { - // 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; - } - - if (token.startsWith(API_KEY_PREFIX)) { - return TokenType.ApiKey; - } - - throw new Error('Unknown machine token type'); -} -``` - -**Step 4: Run tests to verify they pass** - -```bash -cd packages/backend && pnpm test -- --run machine.test.ts -``` - -Expected: All tests PASS - -**Step 5: Commit** - -```bash -git add packages/backend/src/tokens/machine.ts packages/backend/src/tokens/__tests__/machine.test.ts -git commit -m "feat(backend): Update getMachineTokenType() to return m2m_token for M2M JWTs" -``` - ---- - -### Task 6: Add `M2MToken.fromJwtPayload()` with Tests - -**Files:** - -- Modify: `packages/backend/src/api/resources/M2MToken.ts` -- Create or modify: `packages/backend/src/api/resources/__tests__/M2MToken.test.ts` - -**Step 1: Write the failing test** - -Create or modify `packages/backend/src/api/resources/__tests__/M2MToken.test.ts`: - -```typescript -import { describe, expect, it, vi, beforeEach, afterEach } 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(''); - }); - }); -}); -``` - -**Step 2: Run test to verify it fails** - -```bash -cd packages/backend && pnpm test -- --run M2MToken.test.ts -``` - -Expected: FAIL with "fromJwtPayload is not a function" - -**Step 3: Implement `fromJwtPayload()`** - -In `packages/backend/src/api/resources/M2MToken.ts`, add: - -```typescript -import type { JwtPayload } from '@clerk/types'; - -// Add at top of file after imports -type M2MJwtPayload = JwtPayload & { - jti?: string; - scopes?: string; - aud?: string[]; -}; - -// Add inside the M2MToken class -/** - * Creates an M2MToken from a JWT payload. - * Maps standard JWT claims to token properties. - */ -static fromJwtPayload(payload: JwtPayload, clockSkewInMs = 5000): M2MToken { - const m2mPayload = payload as M2MJwtPayload; - - return new M2MToken( - m2mPayload.jti ?? '', - payload.sub, - m2mPayload.aud ?? m2mPayload.scopes?.split(' ') ?? [], - null, - false, - null, - payload.exp * 1000 <= Date.now() - clockSkewInMs, - payload.exp, - payload.iat, - payload.iat, - ); -} -``` - -**Step 4: Run tests to verify they pass** - -```bash -cd packages/backend && pnpm test -- --run M2MToken.test.ts -``` - -Expected: All tests PASS - -**Step 5: Commit** - -```bash -git add packages/backend/src/api/resources/M2MToken.ts packages/backend/src/api/resources/__tests__/M2MToken.test.ts -git commit -m "feat(backend): Add M2MToken.fromJwtPayload() for JWT verification" -``` - ---- - -### Task 7: Add `verifyJwtM2MToken()` and Update `verifyM2MToken()` - -**Files:** - -- Modify: `packages/backend/src/tokens/verify.ts` -- Modify: `packages/backend/src/tokens/__tests__/verify.test.ts` - -**Step 1: Write the failing tests** - -In `packages/backend/src/tokens/__tests__/verify.test.ts`, add: - -```typescript -// Add imports -import { mockM2MJwtPayload, mockSignedM2MJwt } from '../../fixtures/machine'; - -// Add helper function (similar to createOAuthJwt) -function createM2MJwt(payload = mockM2MJwtPayload) { - return createJwt({ - header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' }, - payload, - }); -} - -// Add new describe block -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 = createM2MJwt(); - - 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 = createM2MJwt(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'); - }); - - it('handles invalid JWT format', async () => { - const invalidJwt = 'invalid.m2m.jwt'; - - const result = await verifyMachineAuthToken(invalidJwt, { - apiUrl: 'https://api.clerk.test', - secretKey: 'a-valid-key', - }); - - expect(result.errors).toBeDefined(); - }); -}); -``` - -**Step 2: Run tests to verify they fail** - -```bash -cd packages/backend && pnpm test -- --run verify.test.ts -``` - -Expected: FAIL - M2M JWT not properly verified - -**Step 3: Implement `verifyJwtM2MToken()` and update `verifyM2MToken()`** - -In `packages/backend/src/tokens/verify.ts`, add: - -```typescript -// Add import -import { isJwtFormat, isM2MJwt } from './machine'; -// Update M2MToken import to include fromJwtPayload usage - -// Add new function (after verifyJwtOAuthToken) -async function verifyJwtM2MToken( - token: string, - options: VerifyTokenOptions, -): Promise> { - let decoded: JwtReturnType; - try { - decoded = decodeJwt(token); - } catch (e) { - return { - data: undefined, - tokenType: TokenType.M2MToken, - errors: [ - new MachineTokenVerificationError({ - code: MachineTokenVerificationErrorCode.TokenInvalid, - message: (e as Error).message, - }), - ], - }; - } - - const { data: decodedResult, errors } = decoded; - if (errors) { - return { - data: undefined, - tokenType: TokenType.M2MToken, - errors: [ - new MachineTokenVerificationError({ - code: MachineTokenVerificationErrorCode.TokenInvalid, - message: errors[0].message, - }), - ], - }; - } - - const { header } = decodedResult; - const { kid } = header; - let key: JsonWebKey; - - try { - if (options.jwtKey) { - key = loadClerkJwkFromPem({ kid, pem: options.jwtKey }); - } else if (options.secretKey) { - key = await loadClerkJWKFromRemote({ ...options, kid }); - } else { - return { - data: undefined, - tokenType: TokenType.M2MToken, - errors: [ - new MachineTokenVerificationError({ - action: TokenVerificationErrorAction.SetClerkJWTKey, - message: 'Failed to resolve JWK during verification.', - code: MachineTokenVerificationErrorCode.TokenVerificationFailed, - }), - ], - }; - } - - const { data: payload, errors: verifyErrors } = await verifyJwt(token, { - ...options, - key, - }); - - if (verifyErrors) { - return { - data: undefined, - tokenType: TokenType.M2MToken, - errors: [ - new MachineTokenVerificationError({ - code: MachineTokenVerificationErrorCode.TokenVerificationFailed, - message: verifyErrors[0].message, - }), - ], - }; - } - - const m2mToken = M2MToken.fromJwtPayload(payload, options.clockSkewInMs); - - return { data: m2mToken, tokenType: TokenType.M2MToken, errors: undefined }; - } catch (error) { - return { - tokenType: TokenType.M2MToken, - errors: [ - new MachineTokenVerificationError({ - code: MachineTokenVerificationErrorCode.TokenVerificationFailed, - message: (error as Error).message, - }), - ], - }; - } -} - -// Update verifyM2MToken function -async function verifyM2MToken( - token: string, - options: VerifyTokenOptions & { machineSecretKey?: string }, -): Promise> { - // JWT format: verify locally - if (isJwtFormat(token)) { - return verifyJwtM2MToken(token, options); - } - - // Opaque format: verify via BAPI - 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'); - } -} -``` - -**Step 4: Run tests to verify they pass** - -```bash -cd packages/backend && pnpm test -- --run verify.test.ts -``` - -Expected: All tests PASS - -**Step 5: Commit** - -```bash -git add packages/backend/src/tokens/verify.ts packages/backend/src/tokens/__tests__/verify.test.ts -git commit -m "feat(backend): Add JWT verification support for M2M tokens" -``` - ---- - -### Task 8: Update `request.ts` to Use `isMachineJwt()` - -**Files:** - -- Modify: `packages/backend/src/tokens/request.ts` -- Modify: `packages/backend/src/tokens/__tests__/request.test.ts` (if exists) - -**Step 1: Update import** - -In `packages/backend/src/tokens/request.ts`, update the import: - -```typescript -// Change from: -import { getMachineTokenType, isMachineToken, isOAuthJwt, isTokenTypeAccepted } from './machine'; - -// To: -import { getMachineTokenType, isMachineToken, isMachineJwt, isTokenTypeAccepted } from './machine'; -``` - -**Step 2: Update `authenticateRequestWithTokenInHeader()`** - -Find the function and update the OAuth JWT check: - -```typescript -async function authenticateRequestWithTokenInHeader() { - const { tokenInHeader } = authenticateContext; - - // Reject machine JWTs (OAuth/M2M) when expecting session tokens. - // These are valid Clerk-signed JWTs but should not be accepted as session tokens. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (isMachineJwt(tokenInHeader!)) { - return signedOut({ - tokenType: TokenType.SessionToken, - authenticateContext, - reason: AuthErrorReason.TokenTypeMismatch, - message: '', - }); - } - - // ... rest of function unchanged -``` - -**Step 3: Run all tests to ensure no regressions** - -```bash -cd packages/backend && pnpm test -``` - -Expected: All tests PASS - -**Step 4: Commit** - -```bash -git add packages/backend/src/tokens/request.ts -git commit -m "refactor(backend): Use isMachineJwt() in request authentication" -``` - ---- - -### Task 9: Export New Functions and Final Verification - -**Files:** - -- Modify: `packages/backend/src/tokens/machine.ts` (verify exports) -- Modify: `packages/backend/src/index.ts` (if public exports needed) - -**Step 1: Verify all functions are exported from machine.ts** - -Ensure `machine.ts` exports: - -- `isM2MJwt` -- `isMachineJwt` -- All existing exports - -**Step 2: Run full test suite** - -```bash -cd packages/backend && pnpm test -``` - -Expected: All tests PASS - -**Step 3: Run linter** - -```bash -cd packages/backend && pnpm lint -``` - -Expected: No errors - -**Step 4: Run type check** - -```bash -cd packages/backend && pnpm typecheck -``` - -Expected: No errors - -**Step 5: Final commit** - -```bash -git add -A -git commit -m "chore(backend): Ensure all M2M JWT functions are properly exported" -``` - ---- - -### Task 10: Integration Test (Optional) - -**Files:** - -- Check: `integration/tests/machine-auth/m2m.test.ts` - -**Step 1: Review existing integration tests** - -Check if there are existing M2M integration tests that need updating. - -**Step 2: If tests exist, add M2M JWT test case** - -Follow the existing pattern in the file to add a test for M2M JWT verification. - -**Step 3: Run integration tests** - -```bash -pnpm test:integration:nextjs -``` - -**Step 4: Commit if changes made** - -```bash -git add integration/ -git commit -m "test(integration): Add M2M JWT integration tests" -``` - ---- - -## Summary - -After completing all tasks, you will have: - -1. Test fixtures for M2M JWT tokens -2. `isM2MJwt()` - detects M2M JWTs by `sub` claim prefix -3. `isMachineJwt()` - helper to detect any machine JWT -4. Updated `isMachineToken()` to recognize M2M JWTs -5. Updated `getMachineTokenType()` to return correct type for M2M JWTs -6. `M2MToken.fromJwtPayload()` for creating M2MToken from JWT -7. `verifyJwtM2MToken()` for local JWT verification -8. Updated `verifyM2MToken()` to route JWT vs opaque -9. Updated `request.ts` to reject M2M JWTs as session tokens -10. Full test coverage for all new functionality From 450ffdc5e0721284c820448b530070ed74dfea3e Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 19 Feb 2026 09:38:47 -0800 Subject: [PATCH 3/8] feat(backend): Add tokenFormat parameter to M2M token creation API Add support for creating M2M tokens in JWT format in addition to the default opaque format. The tokenFormat parameter accepts 'opaque' (default) or 'jwt'. Changes: - Add tokenFormat parameter to CreateM2MTokenParams type with JSDoc - Pass tokenFormat parameter to BAPI in createToken method - Add comprehensive tests for JWT format, opaque format, default behavior, and JWT with custom claims - Apply linting fixes to machine.test.ts --- .../src/api/__tests__/M2MTokenApi.test.ts | 124 ++++++++++++++++++ .../backend/src/api/endpoints/M2MTokenApi.ts | 11 +- .../src/tokens/__tests__/machine.test.ts | 2 +- 3 files changed, 135 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/api/__tests__/M2MTokenApi.test.ts b/packages/backend/src/api/__tests__/M2MTokenApi.test.ts index b3ba1aed1c5..82f6fe9c4e2 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: + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtY2hfeHh4eHgiLCJhdWQiOlsibWNoXzF4eHh4eCIsIm1jaF8yeHh4eHgiXSwic2NvcGVzIjoibWNoXzF4eHh4eCBtY2hfMnh4eHh4IiwiaWF0IjoxNzUzNzQzMzE2LCJleHAiOjE3NTM3NDY5MTZ9.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: + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtY2hfeHh4eHgiLCJhdWQiOlsibWNoXzF4eHh4eCIsIm1jaF8yeHh4eHgiXSwic2NvcGVzIjoibWNoXzF4eHh4eCBtY2hfMnh4eHh4Iiwicm9sZSI6InNlcnZpY2UiLCJ0aWVyIjoiZ29sZCIsImlhdCI6MTc1Mzc0MzMxNiwiZXhwIjoxNzUzNzQ2OTE2fQ.c2lnbmF0dXJl', + }); + }), + ), + ); + + 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..8286e803ced 100644 --- a/packages/backend/src/api/endpoints/M2MTokenApi.ts +++ b/packages/backend/src/api/endpoints/M2MTokenApi.ts @@ -18,6 +18,14 @@ type CreateM2MTokenParams = { */ secondsUntilExpiration?: number | null; claims?: Record | null; + /** + * Format of the token to create. + * - 'opaque': Traditional opaque token with mt_ prefix + * - 'jwt': JSON Web Token signed with instance keys + * + * @default 'opaque' + */ + tokenFormat?: 'opaque' | 'jwt'; }; type RevokeM2MTokenParams = { @@ -59,7 +67,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 +76,7 @@ export class M2MTokenApi extends AbstractAPI { bodyParams: { secondsUntilExpiration, claims, + tokenFormat, }, }, machineSecretKey, diff --git a/packages/backend/src/tokens/__tests__/machine.test.ts b/packages/backend/src/tokens/__tests__/machine.test.ts index 93ca3241996..1c797098a20 100644 --- a/packages/backend/src/tokens/__tests__/machine.test.ts +++ b/packages/backend/src/tokens/__tests__/machine.test.ts @@ -6,11 +6,11 @@ import { API_KEY_PREFIX, getMachineTokenType, isJwtFormat, + isM2MJwt, isMachineJwt, isMachineToken, isMachineTokenByPrefix, isMachineTokenType, - isM2MJwt, isOAuthJwt, isTokenTypeAccepted, M2M_TOKEN_PREFIX, From a7ff348d6fd20ffea5ed4bed381a8cf6182dfa67 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Thu, 19 Feb 2026 09:40:54 -0800 Subject: [PATCH 4/8] chore: update changeset --- .changeset/clever-ways-raise.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/clever-ways-raise.md 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 From a85cd5e81e14f95e9232f9665b7ba16a636dc3be Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 19 Feb 2026 11:09:35 -0800 Subject: [PATCH 5/8] test(backend): Update M2M JWT test tokens to match production format Updated test JWT tokens to include: - Header: Added 'kid' field with instance ID - Payload: Added 'jti' field for JWT ID These fields are required by the M2M JWT verification schema at edge (cloudflare-workers#1593) and ensure our tests use realistic JWT tokens that match production behavior. Co-Authored-By: Claude Sonnet 4.5 --- packages/backend/src/api/__tests__/M2MTokenApi.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/api/__tests__/M2MTokenApi.test.ts b/packages/backend/src/api/__tests__/M2MTokenApi.test.ts index 82f6fe9c4e2..68286b3cdbd 100644 --- a/packages/backend/src/api/__tests__/M2MTokenApi.test.ts +++ b/packages/backend/src/api/__tests__/M2MTokenApi.test.ts @@ -127,7 +127,7 @@ describe('M2MToken', () => { return HttpResponse.json({ ...mockM2MToken, token: - 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtY2hfeHh4eHgiLCJhdWQiOlsibWNoXzF4eHh4eCIsIm1jaF8yeHh4eHgiXSwic2NvcGVzIjoibWNoXzF4eHh4eCBtY2hfMnh4eHh4IiwiaWF0IjoxNzUzNzQzMzE2LCJleHAiOjE3NTM3NDY5MTZ9.signature', + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Imluc194eHh4eCJ9.eyJqdGkiOiJtdF94eHh4eCIsInN1YiI6Im1jaF94eHh4eCIsImF1ZCI6WyJtY2hfMXh4eHh4IiwibWNoXzJ4eHh4eCJdLCJzY29wZXMiOiJtY2hfMXh4eHh4IG1jaF8yeHh4eHgiLCJpYXQiOjE3NTM3NDMzMTYsImV4cCI6MTc1Mzc0NjkxNn0.signature', }); }), ), @@ -164,7 +164,7 @@ describe('M2MToken', () => { ...mockM2MToken, claims: customClaims, token: - 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtY2hfeHh4eHgiLCJhdWQiOlsibWNoXzF4eHh4eCIsIm1jaF8yeHh4eHgiXSwic2NvcGVzIjoibWNoXzF4eHh4eCBtY2hfMnh4eHh4Iiwicm9sZSI6InNlcnZpY2UiLCJ0aWVyIjoiZ29sZCIsImlhdCI6MTc1Mzc0MzMxNiwiZXhwIjoxNzUzNzQ2OTE2fQ.c2lnbmF0dXJl', + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Imluc194eHh4eCJ9.eyJqdGkiOiJtdF94eHh4eCIsInN1YiI6Im1jaF94eHh4eCIsImF1ZCI6WyJtY2hfMXh4eHh4IiwibWNoXzJ4eHh4eCJdLCJzY29wZXMiOiJtY2hfMXh4eHh4IG1jaF8yeHh4eHgiLCJyb2xlIjoic2VydmljZSIsInRpZXIiOiJnb2xkIiwiaWF0IjoxNzUzNzQzMzE2LCJleHAiOjE3NTM3NDY5MTZ9.signature', }); }), ), From 98b4733ac0cc59d0a26b455eb69d52008bea0627 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Thu, 19 Feb 2026 11:42:07 -0800 Subject: [PATCH 6/8] Delete .changeset/cyan-wings-turn.md --- .changeset/cyan-wings-turn.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/cyan-wings-turn.md diff --git a/.changeset/cyan-wings-turn.md b/.changeset/cyan-wings-turn.md deleted file mode 100644 index 94514943b23..00000000000 --- a/.changeset/cyan-wings-turn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@clerk/backend': minor ---- - -Add M2M JWT support From b833f2b4757e4469e2aa76cdeb88f6d6102a70f7 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 19 Feb 2026 13:00:28 -0800 Subject: [PATCH 7/8] chore: add default value to tokenFormat --- .../backend/src/api/endpoints/M2MTokenApi.ts | 6 +++--- packages/backend/src/api/resources/M2MToken.ts | 17 +++++------------ 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/backend/src/api/endpoints/M2MTokenApi.ts b/packages/backend/src/api/endpoints/M2MTokenApi.ts index 8286e803ced..d6d0a268d75 100644 --- a/packages/backend/src/api/endpoints/M2MTokenApi.ts +++ b/packages/backend/src/api/endpoints/M2MTokenApi.ts @@ -20,8 +20,8 @@ type CreateM2MTokenParams = { claims?: Record | null; /** * Format of the token to create. - * - 'opaque': Traditional opaque token with mt_ prefix - * - 'jwt': JSON Web Token signed with instance keys + * - 'opaque': Opaque token with mt_ prefix + * - 'jwt': JWT signed with instance keys * * @default 'opaque' */ @@ -67,7 +67,7 @@ export class M2MTokenApi extends AbstractAPI { } async createToken(params?: CreateM2MTokenParams) { - const { claims = null, machineSecretKey, secondsUntilExpiration = null, tokenFormat } = params || {}; + const { claims = null, machineSecretKey, secondsUntilExpiration = null, tokenFormat = 'opaque' } = params || {}; const requestOptions = this.#createRequestOptions( { diff --git a/packages/backend/src/api/resources/M2MToken.ts b/packages/backend/src/api/resources/M2MToken.ts index 1032df9bbdc..30c855151fc 100644 --- a/packages/backend/src/api/resources/M2MToken.ts +++ b/packages/backend/src/api/resources/M2MToken.ts @@ -1,17 +1,10 @@ +import type { JwtPayload } from '@clerk/shared/types'; + import type { M2MTokenJSON } from './JSON'; -/** - * Base JWT payload type for M2M tokens. - * M2M tokens don't include session-specific claims like sid, so we use a simpler type. - */ -type M2MJwtPayloadInput = { - iss?: string; - sub: string; - aud?: string[]; - exp: number; - iat: number; - nbf?: number; +type M2MJwtPayload = JwtPayload & { jti?: string; + aud?: string[]; scopes?: string; }; @@ -53,7 +46,7 @@ export class M2MToken { * Creates an M2MToken from a JWT payload. * Maps standard JWT claims to token properties. */ - static fromJwtPayload(payload: M2MJwtPayloadInput, clockSkewInMs = 5000): M2MToken { + static fromJwtPayload(payload: M2MJwtPayload, clockSkewInMs = 5000): M2MToken { return new M2MToken( payload.jti ?? '', payload.sub, From 9740d2c3a794d901c1f5ea533b7adccad6cdbad7 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 19 Feb 2026 22:54:12 -0800 Subject: [PATCH 8/8] chore: improve jwt routing, prevent double decoding --- .../backend/src/api/endpoints/M2MTokenApi.ts | 18 +- .../backend/src/api/resources/M2MToken.ts | 18 +- packages/backend/src/tokens/machine.ts | 4 +- packages/backend/src/tokens/verify.ts | 241 ++++++------------ 4 files changed, 110 insertions(+), 171 deletions(-) diff --git a/packages/backend/src/api/endpoints/M2MTokenApi.ts b/packages/backend/src/api/endpoints/M2MTokenApi.ts index d6d0a268d75..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. @@ -19,13 +26,9 @@ type CreateM2MTokenParams = { secondsUntilExpiration?: number | null; claims?: Record | null; /** - * Format of the token to create. - * - 'opaque': Opaque token with mt_ prefix - * - 'jwt': JWT signed with instance keys - * * @default 'opaque' */ - tokenFormat?: 'opaque' | 'jwt'; + tokenFormat?: M2MTokenFormat; }; type RevokeM2MTokenParams = { @@ -67,7 +70,7 @@ export class M2MTokenApi extends AbstractAPI { } async createToken(params?: CreateM2MTokenParams) { - const { claims = null, machineSecretKey, secondsUntilExpiration = null, tokenFormat = 'opaque' } = params || {}; + const { claims = null, machineSecretKey, secondsUntilExpiration = null, tokenFormat } = params || {}; const requestOptions = this.#createRequestOptions( { @@ -76,7 +79,8 @@ export class M2MTokenApi extends AbstractAPI { bodyParams: { secondsUntilExpiration, claims, - tokenFormat, + // 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 30c855151fc..1bf213b0776 100644 --- a/packages/backend/src/api/resources/M2MToken.ts +++ b/packages/backend/src/api/resources/M2MToken.ts @@ -1,11 +1,15 @@ -import type { JwtPayload } from '@clerk/shared/types'; - import type { M2MTokenJSON } from './JSON'; -type M2MJwtPayload = JwtPayload & { +// 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; }; /** @@ -50,14 +54,14 @@ export class M2MToken { return new M2MToken( payload.jti ?? '', payload.sub, - payload.aud ?? payload.scopes?.split(' ') ?? [], + payload.scopes?.split(' ') ?? payload.aud ?? [], null, false, null, payload.exp * 1000 <= Date.now() - clockSkewInMs, - payload.exp, - payload.iat, - payload.iat, + 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/tokens/machine.ts b/packages/backend/src/tokens/machine.ts index ebb73801037..b9e8f6b33ba 100644 --- a/packages/backend/src/tokens/machine.ts +++ b/packages/backend/src/tokens/machine.ts @@ -151,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). * @@ -158,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/verify.ts b/packages/backend/src/tokens/verify.ts index 6b0afae9392..f5206477182 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -15,14 +15,7 @@ import type { JwtReturnType, MachineTokenReturnType } from '../jwt/types'; import { decodeJwt, verifyJwt } from '../jwt/verifyJwt'; import type { LoadClerkJWKFromRemoteOptions } from './keys'; import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from './keys'; -import { - API_KEY_PREFIX, - isJwtFormat, - isM2MJwt, - M2M_TOKEN_PREFIX, - OAUTH_ACCESS_TOKEN_TYPES, - OAUTH_TOKEN_PREFIX, -} from './machine'; +import { API_KEY_PREFIX, isJwtFormat, M2M_TOKEN_PREFIX, OAUTH_ACCESS_TOKEN_TYPES, OAUTH_TOKEN_PREFIX } from './machine'; import type { MachineTokenType } from './tokenTypes'; import { TokenType } from './tokenTypes'; @@ -199,42 +192,19 @@ function handleClerkAPIError( }; } -async function verifyJwtM2MToken( +/** + * 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, + decodedResult: Jwt, options: VerifyTokenOptions, -): Promise> { - let decoded: JwtReturnType; - try { - decoded = decodeJwt(token); - } catch (e) { - return { - data: undefined, - tokenType: TokenType.M2MToken, - errors: [ - new MachineTokenVerificationError({ - code: MachineTokenVerificationErrorCode.TokenInvalid, - message: (e as Error).message, - }), - ], - }; - } - - const { data: decodedResult, errors } = decoded; - if (errors) { - return { - data: undefined, - tokenType: TokenType.M2MToken, - 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 { @@ -245,7 +215,7 @@ async function verifyJwtM2MToken( } else { return { data: undefined, - tokenType: TokenType.M2MToken, + tokenType, errors: [ new MachineTokenVerificationError({ action: TokenVerificationErrorAction.SetClerkJWTKey, @@ -259,12 +229,13 @@ async function verifyJwtM2MToken( const { data: payload, errors: verifyErrors } = await verifyJwt(token, { ...options, key, + ...(headerType ? { headerType } : {}), }); if (verifyErrors) { return { data: undefined, - tokenType: TokenType.M2MToken, + tokenType, errors: [ new MachineTokenVerificationError({ code: MachineTokenVerificationErrorCode.TokenVerificationFailed, @@ -274,12 +245,11 @@ async function verifyJwtM2MToken( }; } - const m2mToken = M2MToken.fromJwtPayload(payload, options.clockSkewInMs); - - return { data: m2mToken, tokenType: TokenType.M2MToken, errors: undefined }; + return { data: fromPayload(payload, options.clockSkewInMs), tokenType, errors: undefined }; } catch (error) { return { - tokenType: TokenType.M2MToken, + data: undefined, + tokenType, errors: [ new MachineTokenVerificationError({ code: MachineTokenVerificationErrorCode.TokenVerificationFailed, @@ -292,14 +262,8 @@ async function verifyJwtM2MToken( async function verifyM2MToken( token: string, - options: VerifyTokenOptions & { machineSecretKey?: string }, + options: VerifyTokenOptions, ): Promise> { - // JWT format: verify locally - if (isJwtFormat(token)) { - return verifyJwtM2MToken(token, options); - } - - // Opaque format: verify via BAPI try { const client = createBackendApiClient(options); const verifiedToken = await client.m2m.verify({ token }); @@ -309,106 +273,10 @@ async function verifyM2MToken( } } -async function verifyJwtOAuthToken( - accessToken: string, - 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; - let key: JsonWebKey; - - try { - if (options.jwtKey) { - key = loadClerkJwkFromPem({ kid, pem: options.jwtKey }); - } else if (options.secretKey) { - key = await loadClerkJWKFromRemote({ ...options, kid }); - } else { - return { - data: undefined, - tokenType: TokenType.OAuthToken, - errors: [ - new MachineTokenVerificationError({ - action: TokenVerificationErrorAction.SetClerkJWTKey, - message: 'Failed to resolve JWK during verification.', - code: MachineTokenVerificationErrorCode.TokenVerificationFailed, - }), - ], - }; - } - - const { data: payload, errors: verifyErrors } = await verifyJwt(accessToken, { - ...options, - key, - headerType: OAUTH_ACCESS_TOKEN_TYPES, - }); - - if (verifyErrors) { - return { - data: undefined, - tokenType: TokenType.OAuthToken, - errors: [ - new MachineTokenVerificationError({ - code: MachineTokenVerificationErrorCode.TokenVerificationFailed, - message: verifyErrors[0].message, - }), - ], - }; - } - - const token = IdPOAuthAccessToken.fromJwtPayload(payload, options.clockSkewInMs); - - return { data: token, tokenType: TokenType.OAuthToken, errors: undefined }; - } catch (error) { - return { - tokenType: TokenType.OAuthToken, - errors: [ - new MachineTokenVerificationError({ - code: MachineTokenVerificationErrorCode.TokenVerificationFailed, - message: (error as Error).message, - }), - ], - }; - } -} - 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); @@ -433,17 +301,78 @@ async function verifyAPIKey( /** * 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_", or a JWT) + * @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) { - // M2M: prefix OR JWT with mch_ subject - if (token.startsWith(M2M_TOKEN_PREFIX) || isM2MJwt(token)) { + // 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); } - // OAuth: prefix OR JWT with at+jwt typ - if (token.startsWith(OAUTH_TOKEN_PREFIX) || isJwtFormat(token)) { + if (token.startsWith(OAUTH_TOKEN_PREFIX)) { return verifyOAuthToken(token, options); } if (token.startsWith(API_KEY_PREFIX)) {