From d2822398942d6b178230f74a9a79e96ebd1b7a52 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 18 Feb 2026 16:15:08 -0800 Subject: [PATCH 01/32] 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 02/32] 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 03/32] 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 04/32] 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 05/32] 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 06/32] 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 07/32] 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 08/32] 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)) { From 858a501fba52038ccddbb5ff4c385afb51944f90 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 23 Feb 2026 10:16:04 -0800 Subject: [PATCH 09/32] chore: clean up --- packages/backend/src/api/endpoints/M2MTokenApi.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/api/endpoints/M2MTokenApi.ts b/packages/backend/src/api/endpoints/M2MTokenApi.ts index 75d692834b3..0195310aa04 100644 --- a/packages/backend/src/api/endpoints/M2MTokenApi.ts +++ b/packages/backend/src/api/endpoints/M2MTokenApi.ts @@ -70,7 +70,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( { @@ -79,8 +79,7 @@ export class M2MTokenApi extends AbstractAPI { bodyParams: { secondsUntilExpiration, claims, - // Only send tokenFormat if explicitly specified; BAPI defaults to 'opaque' - ...(tokenFormat !== undefined ? { tokenFormat } : {}), + tokenFormat, }, }, machineSecretKey, From 2f27712c192ee22144dad2f140ad35268aef5d87 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 23 Feb 2026 11:50:11 -0800 Subject: [PATCH 10/32] refactor(backend): extract verifyDecodedJwtMachineToken to shared jwt module --- packages/backend/src/jwt/verifyMachineJwt.ts | 81 +++++++++++++++++ packages/backend/src/tokens/verify.ts | 95 ++------------------ 2 files changed, 87 insertions(+), 89 deletions(-) create mode 100644 packages/backend/src/jwt/verifyMachineJwt.ts diff --git a/packages/backend/src/jwt/verifyMachineJwt.ts b/packages/backend/src/jwt/verifyMachineJwt.ts new file mode 100644 index 00000000000..c44b84f7404 --- /dev/null +++ b/packages/backend/src/jwt/verifyMachineJwt.ts @@ -0,0 +1,81 @@ +import type { Jwt, JwtPayload } from '@clerk/shared/types'; + +import { + MachineTokenVerificationError, + MachineTokenVerificationErrorCode, + TokenVerificationErrorAction, +} from '../errors'; +import type { MachineTokenReturnType } from '../jwt/types'; +import { verifyJwt } from '../jwt/verifyJwt'; +import type { LoadClerkJWKFromRemoteOptions } from '../tokens/keys'; +import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from '../tokens/keys'; +import type { MachineTokenType } from '../tokens/tokenTypes'; + +export type JwtMachineVerifyOptions = Pick & { + jwtKey?: string; + clockSkewInMs?: number; +}; + +export async function verifyDecodedJwtMachineToken( + token: string, + decodedResult: Jwt, + options: JwtMachineVerifyOptions, + tokenType: MachineTokenType, + fromPayload: (payload: JwtPayload, clockSkewInMs?: number) => T, + headerType?: string[], +): Promise> { + const { kid } = decodedResult.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, + 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, + ...(headerType ? { headerType } : {}), + }); + + if (verifyErrors) { + return { + data: undefined, + tokenType, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: verifyErrors[0].message, + }), + ], + }; + } + + return { data: fromPayload(payload, options.clockSkewInMs), tokenType, errors: undefined }; + } catch (error) { + return { + data: undefined, + tokenType, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: (error as Error).message, + }), + ], + }; + } +} diff --git a/packages/backend/src/tokens/verify.ts b/packages/backend/src/tokens/verify.ts index f5206477182..f05099ee5dd 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -13,6 +13,7 @@ import { import type { VerifyJwtOptions } from '../jwt'; import type { JwtReturnType, MachineTokenReturnType } from '../jwt/types'; import { decodeJwt, verifyJwt } from '../jwt/verifyJwt'; +import { verifyDecodedJwtMachineToken } from '../jwt/verifyMachineJwt'; 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'; @@ -117,7 +118,6 @@ export async function verifyToken( if (options.jwtKey) { key = loadClerkJwkFromPem({ kid, pem: options.jwtKey }); } else if (options.secretKey) { - // Fetch JWKS from Backend API using the key key = await loadClerkJWKFromRemote({ ...options, kid }); } else { return { @@ -137,12 +137,6 @@ export async function verifyToken( } } -/** - * Handles errors from Clerk API responses for machine tokens - * @param tokenType - The type of machine token - * @param err - The error from the Clerk API - * @param notFoundMessage - Custom message for 404 errors - */ function handleClerkAPIError( tokenType: MachineTokenType, err: any, @@ -192,74 +186,6 @@ function handleClerkAPIError( }; } -/** - * 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, - tokenType: MachineTokenType, - fromPayload: (payload: JwtPayload, clockSkewInMs?: number) => T, - headerType?: string[], -): Promise> { - const { kid } = decodedResult.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, - 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, - ...(headerType ? { headerType } : {}), - }); - - if (verifyErrors) { - return { - data: undefined, - tokenType, - errors: [ - new MachineTokenVerificationError({ - code: MachineTokenVerificationErrorCode.TokenVerificationFailed, - message: verifyErrors[0].message, - }), - ], - }; - } - - return { data: fromPayload(payload, options.clockSkewInMs), tokenType, errors: undefined }; - } catch (error) { - return { - data: undefined, - tokenType, - errors: [ - new MachineTokenVerificationError({ - code: MachineTokenVerificationErrorCode.TokenVerificationFailed, - message: (error as Error).message, - }), - ], - }; - } -} - async function verifyM2MToken( token: string, options: VerifyTokenOptions, @@ -307,22 +233,12 @@ async function verifyAPIKey( * @param options - Options including secretKey for BAPI authorization */ export async function verifyMachineAuthToken(token: string, options: VerifyTokenOptions) { - // JWT format: decode once and route based on claims if (isJwtFormat(token)) { let decodedResult: Jwt; try { const { data, errors: decodeErrors } = decodeJwt(token); if (decodeErrors) { - return { - data: undefined, - tokenType: TokenType.M2MToken, - errors: [ - new MachineTokenVerificationError({ - code: MachineTokenVerificationErrorCode.TokenInvalid, - message: decodeErrors[0].message, - }), - ], - } as MachineTokenReturnType; + throw decodeErrors[0]; } decodedResult = data; } catch (e) { @@ -340,7 +256,9 @@ export async function verifyMachineAuthToken(token: string, options: VerifyToken // M2M JWT: sub starts with mch_ if (decodedResult.payload.sub.startsWith('mch_')) { - return verifyDecodedJwtMachineToken(token, decodedResult, options, TokenType.M2MToken, M2MToken.fromJwtPayload); + return verifyDecodedJwtMachineToken(token, decodedResult, options, TokenType.M2MToken, (payload, skew) => + M2MToken.fromJwtPayload(payload, skew), + ); } // OAuth JWT: typ is at+jwt or application/at+jwt @@ -350,12 +268,11 @@ export async function verifyMachineAuthToken(token: string, options: VerifyToken decodedResult, options, TokenType.OAuthToken, - IdPOAuthAccessToken.fromJwtPayload, + (payload, skew) => IdPOAuthAccessToken.fromJwtPayload(payload, skew), OAUTH_ACCESS_TOKEN_TYPES, ); } - // JWT format but unrecognized machine token type return { data: undefined, tokenType: TokenType.OAuthToken, From 86383d4a2fd0ae3915d027544ccc60d9e30198ab Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 23 Feb 2026 12:04:12 -0800 Subject: [PATCH 11/32] feat(backend): support JWT format in m2m.verify() --- .../src/api/__tests__/M2MTokenApi.test.ts | 72 ++++++++++++++++++- .../backend/src/api/endpoints/M2MTokenApi.ts | 45 +++++++++++- packages/backend/src/api/factory.ts | 4 ++ packages/backend/src/jwt/verifyMachineJwt.ts | 2 +- 4 files changed, 119 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/api/__tests__/M2MTokenApi.test.ts b/packages/backend/src/api/__tests__/M2MTokenApi.test.ts index 68286b3cdbd..24f0afa9a69 100644 --- a/packages/backend/src/api/__tests__/M2MTokenApi.test.ts +++ b/packages/backend/src/api/__tests__/M2MTokenApi.test.ts @@ -1,8 +1,12 @@ import { http, HttpResponse } from 'msw'; -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockJwtPayload, mockJwks, mockM2MJwtPayload, signingJwks } from '../../fixtures'; +import { signJwt } from '../../jwt/signJwt'; import { server, validateHeaders } from '../../mock-server'; +import { buildRequest } from '../request'; import { createBackendApiClient } from '../factory'; +import { M2MTokenApi } from '../endpoints/M2MTokenApi'; describe('M2MToken', () => { const m2mId = 'mt_xxxxx'; @@ -406,4 +410,70 @@ describe('M2MToken', () => { expect(errResponse.status).toBe(401); }); }); + + async function createSignedM2MJwt(payload = mockM2MJwtPayload) { + const { data } = await signJwt(payload, signingJwks, { + algorithm: 'RS256', + header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' }, + }); + return data!; + } + + describe('verify — JWT format', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(mockJwtPayload.iat * 1000)); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('verifies a JWT M2M token using secretKey (JWKS lookup)', async () => { + const m2mApi = new M2MTokenApi( + buildRequest({ apiUrl: 'https://api.clerk.test', skipApiVersionInUrl: true, requireSecretKey: false }), + { secretKey: 'sk_test_xxxxx', apiUrl: 'https://api.clerk.test', skipJwksCache: true }, + ); + + server.use( + http.get( + 'https://api.clerk.test/v1/jwks', + validateHeaders(() => HttpResponse.json(mockJwks)), + ), + ); + + const jwtToken = await createSignedM2MJwt(); + const result = await m2mApi.verify({ token: jwtToken }); + + expect(result.id).toBe('mt_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE'); + expect(result.subject).toBe('mch_2vYVtestTESTtestTESTtestTESTtest'); + expect(result.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']); + }); + + it('throws when JWT signature cannot be verified', async () => { + const m2mApi = new M2MTokenApi( + buildRequest({ apiUrl: 'https://api.clerk.test', skipApiVersionInUrl: true, requireSecretKey: false }), + { secretKey: 'sk_test_xxxxx', apiUrl: 'https://api.clerk.test', skipJwksCache: true }, + ); + + server.use( + http.get( + 'https://api.clerk.test/v1/jwks', + validateHeaders(() => HttpResponse.json({ keys: [] })), + ), + ); + + const jwtToken = await createSignedM2MJwt(); + await expect(m2mApi.verify({ token: jwtToken })).rejects.toThrow(); + }); + + it('throws when no secretKey or jwtKey is provided', async () => { + const m2mApi = new M2MTokenApi( + buildRequest({ apiUrl: 'https://api.clerk.test', skipApiVersionInUrl: true, requireSecretKey: false }), + { apiUrl: 'https://api.clerk.test' }, + ); + + const jwtToken = await createSignedM2MJwt(); + await expect(m2mApi.verify({ token: jwtToken })).rejects.toThrow('Failed to resolve JWK during verification'); + }); + }); }); diff --git a/packages/backend/src/api/endpoints/M2MTokenApi.ts b/packages/backend/src/api/endpoints/M2MTokenApi.ts index 0195310aa04..199a73218ac 100644 --- a/packages/backend/src/api/endpoints/M2MTokenApi.ts +++ b/packages/backend/src/api/endpoints/M2MTokenApi.ts @@ -1,7 +1,13 @@ import { joinPaths } from '../../util/path'; import { deprecated } from '../../util/shared'; -import type { ClerkBackendApiRequestOptions } from '../request'; -import type { M2MToken } from '../resources/M2MToken'; +import { MachineTokenVerificationError, MachineTokenVerificationErrorCode } from '../../errors'; +import { decodeJwt } from '../../jwt/verifyJwt'; +import type { JwtMachineVerifyOptions } from '../../jwt/verifyMachineJwt'; +import { verifyDecodedJwtMachineToken } from '../../jwt/verifyMachineJwt'; +import { isJwtFormat } from '../../tokens/machine'; +import { TokenType } from '../../tokens/tokenTypes'; +import type { ClerkBackendApiRequestOptions, RequestFunction } from '../request'; +import { M2MToken } from '../resources/M2MToken'; import { AbstractAPI } from './AbstractApi'; const basePath = '/m2m_tokens'; @@ -55,6 +61,13 @@ type VerifyM2MTokenParams = { }; export class M2MTokenApi extends AbstractAPI { + #verifyOptions: JwtMachineVerifyOptions; + + constructor(request: RequestFunction, verifyOptions: JwtMachineVerifyOptions = {}) { + super(request); + this.#verifyOptions = verifyOptions; + } + #createRequestOptions(options: ClerkBackendApiRequestOptions, machineSecretKey?: string) { if (machineSecretKey) { return { @@ -110,6 +123,34 @@ export class M2MTokenApi extends AbstractAPI { async verify(params: VerifyM2MTokenParams) { const { token, machineSecretKey } = params; + if (isJwtFormat(token)) { + let decodedResult; + try { + const { data, errors } = decodeJwt(token); + if (errors) throw errors[0]; + decodedResult = data!; + } catch (e) { + throw new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenInvalid, + message: (e as Error).message, + }); + } + + const result = await verifyDecodedJwtMachineToken( + token, + decodedResult, + this.#verifyOptions, + TokenType.M2MToken, + (payload, skew) => M2MToken.fromJwtPayload(payload, skew), + ); + + if (result.errors) { + throw result.errors[0]; + } + + return result.data!; + } + const requestOptions = this.#createRequestOptions( { method: 'POST', diff --git a/packages/backend/src/api/factory.ts b/packages/backend/src/api/factory.ts index 22f1aa89c21..bfdc8b31237 100644 --- a/packages/backend/src/api/factory.ts +++ b/packages/backend/src/api/factory.ts @@ -83,6 +83,10 @@ export function createBackendApiClient(options: CreateBackendApiOptions) { requireSecretKey: false, useMachineSecretKey: true, }), + { + secretKey: options.secretKey, + apiUrl: options.apiUrl, + }, ), oauthApplications: new OAuthApplicationsApi(request), organizations: new OrganizationAPI(request), diff --git a/packages/backend/src/jwt/verifyMachineJwt.ts b/packages/backend/src/jwt/verifyMachineJwt.ts index c44b84f7404..f6d36beee13 100644 --- a/packages/backend/src/jwt/verifyMachineJwt.ts +++ b/packages/backend/src/jwt/verifyMachineJwt.ts @@ -11,7 +11,7 @@ import type { LoadClerkJWKFromRemoteOptions } from '../tokens/keys'; import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from '../tokens/keys'; import type { MachineTokenType } from '../tokens/tokenTypes'; -export type JwtMachineVerifyOptions = Pick & { +export type JwtMachineVerifyOptions = Pick & { jwtKey?: string; clockSkewInMs?: number; }; From ce5bcbeda2194e0251c9d4b1b7e4834543ee83b1 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 23 Feb 2026 12:11:38 -0800 Subject: [PATCH 12/32] test(integration): use m2m.verify() for JWT format M2M test --- integration/tests/machine-auth/m2m.test.ts | 28 ++++++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/integration/tests/machine-auth/m2m.test.ts b/integration/tests/machine-auth/m2m.test.ts index 32d4309ff1b..1da6afd1733 100644 --- a/integration/tests/machine-auth/m2m.test.ts +++ b/integration/tests/machine-auth/m2m.test.ts @@ -42,10 +42,9 @@ test.describe('machine-to-machine auth @machine', () => { app.get('/api/protected', async (req, res) => { const token = req.get('Authorization')?.split(' ')[1]; - try { - const m2mToken = await clerkClient.m2m.verifyToken({ token }); - res.send('Protected response ' + m2mToken.id); + const m2mToken = await clerkClient.m2m.verify({ token }); + res.send('Protected response ' + m2mToken.subject); } catch { res.status(401).send('Unauthorized'); } @@ -150,7 +149,7 @@ test.describe('machine-to-machine auth @machine', () => { }, }); expect(res.status()).toBe(200); - expect(await res.text()).toBe('Protected response ' + emailServerM2MToken.id); + expect(await res.text()).toBe('Protected response ' + emailServer.id); // Analytics server can access primary API server after adding scope await u.services.clerk.machines.createScope(analyticsServer.id, primaryApiServer.id); @@ -165,9 +164,28 @@ test.describe('machine-to-machine auth @machine', () => { }, }); expect(res2.status()).toBe(200); - expect(await res2.text()).toBe('Protected response ' + m2mToken.id); + expect(await res2.text()).toBe('Protected response ' + analyticsServer.id); await u.services.clerk.m2m.revokeToken({ m2mTokenId: m2mToken.id, }); }); + + test('verifies JWT format M2M token via local verification', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + const client = createClerkClient({ + secretKey: instanceKeys.get('with-api-keys').sk, + }); + const jwtToken = await client.m2m.createToken({ + machineSecretKey: emailServer.secretKey, + secondsUntilExpiration: 60 * 30, + tokenFormat: 'jwt', + }); + + const res = await u.page.request.get(app.serverUrl + '/api/protected', { + headers: { Authorization: `Bearer ${jwtToken.token}` }, + }); + expect(res.status()).toBe(200); + expect(await res.text()).toBe('Protected response ' + emailServer.id); + }); }); From dbec863c927775445342fdfa4453276245e3a6af Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 23 Feb 2026 12:14:41 -0800 Subject: [PATCH 13/32] fix(backend): restore createToken behavior and update internal exports snapshot --- packages/backend/src/__tests__/exports.test.ts | 4 ---- packages/backend/src/api/endpoints/M2MTokenApi.ts | 15 +++++++++------ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/backend/src/__tests__/exports.test.ts b/packages/backend/src/__tests__/exports.test.ts index 5eae512cebc..a9230368d80 100644 --- a/packages/backend/src/__tests__/exports.test.ts +++ b/packages/backend/src/__tests__/exports.test.ts @@ -49,13 +49,9 @@ describe('subpath /internal exports', () => { "decorateObjectWithResources", "getAuthObjectForAcceptedToken", "getAuthObjectFromJwt", - "getMachineTokenType", "invalidTokenAuthObject", - "isM2MJwt", - "isMachineJwt", "isMachineToken", "isMachineTokenByPrefix", - "isMachineTokenType", "isTokenTypeAccepted", "makeAuthObjectSerializable", "reverificationError", diff --git a/packages/backend/src/api/endpoints/M2MTokenApi.ts b/packages/backend/src/api/endpoints/M2MTokenApi.ts index 199a73218ac..87876482d58 100644 --- a/packages/backend/src/api/endpoints/M2MTokenApi.ts +++ b/packages/backend/src/api/endpoints/M2MTokenApi.ts @@ -1,11 +1,11 @@ -import { joinPaths } from '../../util/path'; -import { deprecated } from '../../util/shared'; import { MachineTokenVerificationError, MachineTokenVerificationErrorCode } from '../../errors'; import { decodeJwt } from '../../jwt/verifyJwt'; import type { JwtMachineVerifyOptions } from '../../jwt/verifyMachineJwt'; import { verifyDecodedJwtMachineToken } from '../../jwt/verifyMachineJwt'; import { isJwtFormat } from '../../tokens/machine'; import { TokenType } from '../../tokens/tokenTypes'; +import { joinPaths } from '../../util/path'; +import { deprecated } from '../../util/shared'; import type { ClerkBackendApiRequestOptions, RequestFunction } from '../request'; import { M2MToken } from '../resources/M2MToken'; import { AbstractAPI } from './AbstractApi'; @@ -83,7 +83,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( { @@ -92,7 +92,8 @@ export class M2MTokenApi extends AbstractAPI { bodyParams: { secondsUntilExpiration, claims, - tokenFormat, + // Only send tokenFormat if explicitly specified; BAPI defaults to 'opaque' + ...(tokenFormat !== undefined ? { tokenFormat } : {}), }, }, machineSecretKey, @@ -127,7 +128,9 @@ export class M2MTokenApi extends AbstractAPI { let decodedResult; try { const { data, errors } = decodeJwt(token); - if (errors) throw errors[0]; + if (errors) { + throw errors[0]; + } decodedResult = data!; } catch (e) { throw new MachineTokenVerificationError({ @@ -148,7 +151,7 @@ export class M2MTokenApi extends AbstractAPI { throw result.errors[0]; } - return result.data!; + return result.data; } const requestOptions = this.#createRequestOptions( From 6e96d465fb10110c7e8fa068c7991ff417fc38ea Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 23 Feb 2026 12:44:16 -0800 Subject: [PATCH 14/32] chore: clean up verification functions --- ${LOGBOOK_VAULT_PATH}/m2m-jwt-verify.md | 125 ++++++++++++++++++ .../backend/src/api/endpoints/M2MTokenApi.ts | 60 ++++----- .../backend/src/api/resources/M2MToken.ts | 4 - packages/backend/src/internal.ts | 10 +- packages/backend/src/jwt/verifyMachineJwt.ts | 114 +++++++++++----- packages/backend/src/tokens/request.ts | 2 +- packages/backend/src/tokens/verify.ts | 15 +-- 7 files changed, 236 insertions(+), 94 deletions(-) create mode 100644 ${LOGBOOK_VAULT_PATH}/m2m-jwt-verify.md diff --git a/${LOGBOOK_VAULT_PATH}/m2m-jwt-verify.md b/${LOGBOOK_VAULT_PATH}/m2m-jwt-verify.md new file mode 100644 index 00000000000..6b4c5a64479 --- /dev/null +++ b/${LOGBOOK_VAULT_PATH}/m2m-jwt-verify.md @@ -0,0 +1,125 @@ +--- +session_id: ebff6fe8-aa63-4528-b452-88654bd2bee1 +branch: rob/USER-4704-m2m-jwts +project: javascript +date: 2026-02-23 +--- + +# m2m-jwt-verify + +## Summary + +Implemented JWT format support for M2M tokens in the Clerk JavaScript backend SDK. The work involved cleaning up comments, fixing an ESLint unbound-method issue, refactoring the JWT machine token verification to eliminate a generic callback pattern, and making `clerkClient.m2m.verify()` handle both opaque and JWT-format M2M tokens transparently. All 1008 backend tests pass. + +## Key Decisions + +- **Extracted `verifyDecodedJwtMachineToken` to `jwt/verifyMachineJwt.ts`** to break a circular dependency (`M2MTokenApi → verify.ts → factory.ts → M2MTokenApi`) — avoids both duplication and cycles. +- **Replaced generic callback pattern** (`fromPayload` callback) with two typed functions `verifyM2MJwt` / `verifyOAuthJwt` sharing a private `resolveKeyAndVerifyJwt` helper — cleaner return types, no generics. +- **`M2MTokenApi` constructor extended** with `JwtMachineVerifyOptions` (secretKey, apiUrl) threaded from `factory.ts` — API layer can now do local JWT verification without touching `authenticateRequest`. +- **No changes to `authenticateRequest`** — JWT M2M tokens already bypass `m2m.verify()` in that path (routed to `verifyDecodedJwtMachineToken` directly), so no double verification risk. +- **Removed 4 unused internal exports** (`isMachineTokenType`, `getMachineTokenType`, `isM2MJwt`, `isMachineJwt`) from `internal.ts` — only keeping what other packages actually use. +- **Integration test reverts middleware route** — no longer needs `clerkMiddleware` for JWT format since `m2m.verify()` handles both formats directly. + +## What Was Done + +### Comment cleanup + +- Removed redundant JSDoc/inline comments from `verify.ts`, `M2MToken.ts` +- Kept routing comments (`// M2M JWT: sub starts with mch_`, `// OAuth JWT: typ is at+jwt`, `// Opaque token routing by prefix`) + +### ESLint fix + +- `M2MToken.fromJwtPayload` and `IdPOAuthAccessToken.fromJwtPayload` wrapped in arrow functions `(payload, skew) => X.fromJwtPayload(payload, skew)` to fix `@typescript-eslint/unbound-method` +- Consolidated duplicate try/catch in `verifyMachineAuthToken` decode error path using `if (decodeErrors) throw decodeErrors[0]` + +### `internal.ts` trim + +- Removed exports not consumed by any other package: `isMachineTokenType`, `getMachineTokenType`, `isM2MJwt`, `isMachineJwt` +- Kept: `isMachineTokenByPrefix`, `isTokenTypeAccepted`, `isMachineToken`, `verifyMachineAuthToken` + +### New file: `packages/backend/src/jwt/verifyMachineJwt.ts` + +- `resolveKeyAndVerifyJwt(token, kid, options, headerType?)` — private, returns `{ payload } | { error }` +- `verifyM2MJwt(token, decoded, options)` — exported, returns `MachineTokenReturnType` +- `verifyOAuthJwt(token, decoded, options)` — exported, returns `MachineTokenReturnType` +- Imports M2MToken/IdPOAuthAccessToken from `api/resources/` (no circular dep since resources don't import factory) + +### `packages/backend/src/tokens/verify.ts` + +- Removed `verifyDecodedJwtMachineToken` function body (moved to `verifyMachineJwt.ts`) +- Swapped import: `verifyDecodedJwtMachineToken` → `{ verifyM2MJwt, verifyOAuthJwt }` +- Two call sites now: `verifyM2MJwt(token, decodedResult, options)` and `verifyOAuthJwt(token, decodedResult, options)` + +### `packages/backend/src/api/endpoints/M2MTokenApi.ts` + +- Constructor extended: `constructor(request, verifyOptions: JwtMachineVerifyOptions = {})` +- New `#verifyJwtFormat(token)` private method (JS `#` syntax — truly private, not just TS `private`) + - Decodes JWT, throws `MachineTokenVerificationError` on failure + - Calls `verifyM2MJwt`, throws on errors, returns `M2MToken` +- `verify()` now: JWT → `#verifyJwtFormat(token)`, opaque → BAPI +- Removed `TokenType` import (no longer needed), kept `MachineTokenVerificationError`, `decodeJwt`, `isJwtFormat` + +### `packages/backend/src/api/factory.ts` + +- `M2MTokenApi` now gets second arg: `{ secretKey: options.secretKey, apiUrl: options.apiUrl }` + +### `integration/tests/machine-auth/m2m.test.ts` + +- Server template uses `clerkClient.m2m.verify({ token })` (was `verifyToken` — deprecated) on single `/api/protected` route +- Removed `/api/jwt-protected` middleware route (no longer needed) +- Response uses `m2mToken.subject` instead of `m2mToken.id` (consistent for both formats) +- Added test: `'verifies JWT format M2M token via local verification'` — creates `tokenFormat: 'jwt'` token, calls single route, asserts 200 + correct subject + +### Test verification + +- `packages/backend`: 56 test files, 1008 tests, 0 type errors ✓ + +## Open Questions / Follow-ups + +- [ ] Integration test assertions use `emailServer.id` as the expected subject — need to confirm the machine's `id` field matches the JWT `sub` claim when running against real API +- [ ] `jwtKey` (PEM public key) is not threaded through `factory.ts` to `M2MTokenApi` — only `secretKey` + `apiUrl`. Add `jwtKey` support if networkless M2M JWT verification is needed +- [ ] `verifyToken` deprecation in `M2MTokenApi` — confirm timeline for removal +- [ ] Consider whether `verifyMachineAuthToken` should also be removed from `internal.ts` exports since it's not consumed externally yet (currently kept as it's the core machine auth function) + +## Context for Next Session + +**Key files:** + +- `packages/backend/src/jwt/verifyMachineJwt.ts` — new shared module, source of truth for JWT machine token verification +- `packages/backend/src/api/endpoints/M2MTokenApi.ts` — `verify()` now handles both opaque + JWT; `#verifyJwtFormat` is the private JWT path +- `packages/backend/src/tokens/verify.ts` — `verifyMachineAuthToken` routes JWT M2M/OAuth to the new typed helpers +- `packages/backend/src/api/factory.ts` — threads `secretKey` + `apiUrl` into `M2MTokenApi` +- `integration/tests/machine-auth/m2m.test.ts` — single route integration test covering opaque + JWT format +- `docs/plans/2026-02-23-m2m-jwt-verify-in-api-client.md` — the implementation plan (now fully executed) + +**Run tests:** + +```bash +cd packages/backend && pnpm run test:node +``` + +Note: if vitest fails with `Cannot find module`, delete `packages/backend/node_modules` and run `pnpm install` from root. + +**Branch:** `rob/USER-4704-m2m-jwts` — merges from `release/core-2` + +## Diagram + +```mermaid +graph TD + A["clerkClient.m2m.verify(token)"] -->|isJwtFormat| B["#verifyJwtFormat (private)"] + A -->|opaque prefix| C["POST /m2m_tokens/verify (BAPI)"] + B --> D["verifyM2MJwt()"] + D --> E["resolveKeyAndVerifyJwt()"] + E -->|jwtKey| F["loadClerkJwkFromPem"] + E -->|secretKey| G["loadClerkJWKFromRemote (JWKS)"] + E --> H["verifyJwt()"] + H --> I["M2MToken.fromJwtPayload()"] + + J["authenticateRequest()"] --> K["verifyMachineAuthToken()"] + K -->|JWT + sub=mch_| D + K -->|JWT + OAuth typ| L["verifyOAuthJwt()"] + L --> E + K -->|opaque mt_| C + K -->|opaque oat_| M["POST /idp_oauth_access_tokens/verify"] + K -->|opaque ak_| N["POST /api_keys/verify"] +``` diff --git a/packages/backend/src/api/endpoints/M2MTokenApi.ts b/packages/backend/src/api/endpoints/M2MTokenApi.ts index 87876482d58..4213dafb0ce 100644 --- a/packages/backend/src/api/endpoints/M2MTokenApi.ts +++ b/packages/backend/src/api/endpoints/M2MTokenApi.ts @@ -1,13 +1,12 @@ import { MachineTokenVerificationError, MachineTokenVerificationErrorCode } from '../../errors'; import { decodeJwt } from '../../jwt/verifyJwt'; import type { JwtMachineVerifyOptions } from '../../jwt/verifyMachineJwt'; -import { verifyDecodedJwtMachineToken } from '../../jwt/verifyMachineJwt'; +import { verifyM2MJwt } from '../../jwt/verifyMachineJwt'; import { isJwtFormat } from '../../tokens/machine'; -import { TokenType } from '../../tokens/tokenTypes'; import { joinPaths } from '../../util/path'; import { deprecated } from '../../util/shared'; import type { ClerkBackendApiRequestOptions, RequestFunction } from '../request'; -import { M2MToken } from '../resources/M2MToken'; +import type { M2MToken } from '../resources/M2MToken'; import { AbstractAPI } from './AbstractApi'; const basePath = '/m2m_tokens'; @@ -83,7 +82,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( { @@ -92,8 +91,7 @@ export class M2MTokenApi extends AbstractAPI { bodyParams: { secondsUntilExpiration, claims, - // Only send tokenFormat if explicitly specified; BAPI defaults to 'opaque' - ...(tokenFormat !== undefined ? { tokenFormat } : {}), + tokenFormat, }, }, machineSecretKey, @@ -121,37 +119,33 @@ export class M2MTokenApi extends AbstractAPI { return this.request(requestOptions); } - async verify(params: VerifyM2MTokenParams) { - const { token, machineSecretKey } = params; - - if (isJwtFormat(token)) { - let decodedResult; - try { - const { data, errors } = decodeJwt(token); - if (errors) { - throw errors[0]; - } - decodedResult = data!; - } catch (e) { - throw new MachineTokenVerificationError({ - code: MachineTokenVerificationErrorCode.TokenInvalid, - message: (e as Error).message, - }); + async #verifyJwtFormat(token: string): Promise { + let decoded; + try { + const { data, errors } = decodeJwt(token); + if (errors) { + throw errors[0]; } + decoded = data; + } catch (e) { + throw new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenInvalid, + message: (e as Error).message, + }); + } - const result = await verifyDecodedJwtMachineToken( - token, - decodedResult, - this.#verifyOptions, - TokenType.M2MToken, - (payload, skew) => M2MToken.fromJwtPayload(payload, skew), - ); + const result = await verifyM2MJwt(token, decoded, this.#verifyOptions); + if (result.errors) { + throw result.errors[0]; + } + return result.data; + } - if (result.errors) { - throw result.errors[0]; - } + async verify(params: VerifyM2MTokenParams) { + const { token, machineSecretKey } = params; - return result.data; + if (isJwtFormat(token)) { + return this.#verifyJwtFormat(token); } const requestOptions = this.#createRequestOptions( diff --git a/packages/backend/src/api/resources/M2MToken.ts b/packages/backend/src/api/resources/M2MToken.ts index 1bf213b0776..a68853f694f 100644 --- a/packages/backend/src/api/resources/M2MToken.ts +++ b/packages/backend/src/api/resources/M2MToken.ts @@ -46,10 +46,6 @@ export class M2MToken { ); } - /** - * Creates an M2MToken from a JWT payload. - * Maps standard JWT claims to token properties. - */ static fromJwtPayload(payload: M2MJwtPayload, clockSkewInMs = 5000): M2MToken { return new M2MToken( payload.jti ?? '', diff --git a/packages/backend/src/internal.ts b/packages/backend/src/internal.ts index 8b1d80d2c53..020dcab4217 100644 --- a/packages/backend/src/internal.ts +++ b/packages/backend/src/internal.ts @@ -56,12 +56,4 @@ export { reverificationError, reverificationErrorResponse } from '@clerk/shared/ export { verifyMachineAuthToken } from './tokens/verify'; -export { - isMachineTokenByPrefix, - isMachineTokenType, - getMachineTokenType, - isTokenTypeAccepted, - isMachineToken, - isM2MJwt, - isMachineJwt, -} from './tokens/machine'; +export { isMachineTokenByPrefix, isTokenTypeAccepted, isMachineToken } from './tokens/machine'; diff --git a/packages/backend/src/jwt/verifyMachineJwt.ts b/packages/backend/src/jwt/verifyMachineJwt.ts index f6d36beee13..ad263b67a55 100644 --- a/packages/backend/src/jwt/verifyMachineJwt.ts +++ b/packages/backend/src/jwt/verifyMachineJwt.ts @@ -1,5 +1,7 @@ import type { Jwt, JwtPayload } from '@clerk/shared/types'; +import { IdPOAuthAccessToken } from '../api/resources/IdPOAuthAccessToken'; +import { M2MToken } from '../api/resources/M2MToken'; import { MachineTokenVerificationError, MachineTokenVerificationErrorCode, @@ -9,40 +11,44 @@ import type { MachineTokenReturnType } from '../jwt/types'; import { verifyJwt } from '../jwt/verifyJwt'; import type { LoadClerkJWKFromRemoteOptions } from '../tokens/keys'; import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from '../tokens/keys'; -import type { MachineTokenType } from '../tokens/tokenTypes'; +import { OAUTH_ACCESS_TOKEN_TYPES } from '../tokens/machine'; +import { TokenType } from '../tokens/tokenTypes'; export type JwtMachineVerifyOptions = Pick & { jwtKey?: string; clockSkewInMs?: number; }; -export async function verifyDecodedJwtMachineToken( +/** + * Resolves the signing key and verifies a machine JWT's signature and claims. + * + * Networkless when `jwtKey` (PEM) is provided; performs a JWKS fetch when only `secretKey` is set. + * Returns a discriminated union so callers can branch on `'error' in result` without try/catch. + * + * Note: uses `MachineTokenVerificationError`, not `TokenVerificationError` — the two error types + * are intentionally separate because session-token errors carry handshake metadata that machine + * tokens don't need. + */ +async function resolveKeyAndVerifyJwt( token: string, - decodedResult: Jwt, + kid: string, options: JwtMachineVerifyOptions, - tokenType: MachineTokenType, - fromPayload: (payload: JwtPayload, clockSkewInMs?: number) => T, headerType?: string[], -): Promise> { - const { kid } = decodedResult.header; - let key: JsonWebKey; - +): Promise<{ payload: JwtPayload } | { error: MachineTokenVerificationError }> { try { + let key: JsonWebKey; + if (options.jwtKey) { key = loadClerkJwkFromPem({ kid, pem: options.jwtKey }); } else if (options.secretKey) { key = await loadClerkJWKFromRemote({ ...options, kid }); } else { return { - data: undefined, - tokenType, - errors: [ - new MachineTokenVerificationError({ - action: TokenVerificationErrorAction.SetClerkJWTKey, - message: 'Failed to resolve JWK during verification.', - code: MachineTokenVerificationErrorCode.TokenVerificationFailed, - }), - ], + error: new MachineTokenVerificationError({ + action: TokenVerificationErrorAction.SetClerkJWTKey, + message: 'Failed to resolve JWK during verification.', + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + }), }; } @@ -54,28 +60,66 @@ export async function verifyDecodedJwtMachineToken( if (verifyErrors) { return { - data: undefined, - tokenType, - errors: [ - new MachineTokenVerificationError({ - code: MachineTokenVerificationErrorCode.TokenVerificationFailed, - message: verifyErrors[0].message, - }), - ], + error: new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: verifyErrors[0].message, + }), }; } - return { data: fromPayload(payload, options.clockSkewInMs), tokenType, errors: undefined }; + return { payload }; } catch (error) { return { - data: undefined, - tokenType, - errors: [ - new MachineTokenVerificationError({ - code: MachineTokenVerificationErrorCode.TokenVerificationFailed, - message: (error as Error).message, - }), - ], + error: new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: (error as Error).message, + }), }; } } + +/** + * Verifies a pre-decoded M2M JWT (identified by `sub` starting with `mch_`). + * Delegates key resolution and signature verification to `resolveKeyAndVerifyJwt`, + * then maps the verified payload to an `M2MToken` via `M2MToken.fromJwtPayload`. + */ +export async function verifyM2MJwt( + token: string, + decoded: Jwt, + options: JwtMachineVerifyOptions, +): Promise> { + const result = await resolveKeyAndVerifyJwt(token, decoded.header.kid, options); + + if ('error' in result) { + return { data: undefined, tokenType: TokenType.M2MToken, errors: [result.error] }; + } + + return { + data: M2MToken.fromJwtPayload(result.payload, options.clockSkewInMs), + tokenType: TokenType.M2MToken, + errors: undefined, + }; +} + +/** + * Verifies a pre-decoded OAuth access token JWT (identified by `typ: at+jwt` or `application/at+jwt`). + * Delegates key resolution and signature verification to `resolveKeyAndVerifyJwt` with the + * allowed OAuth header types, then maps the verified payload to an `IdPOAuthAccessToken`. + */ +export async function verifyOAuthJwt( + token: string, + decoded: Jwt, + options: JwtMachineVerifyOptions, +): Promise> { + const result = await resolveKeyAndVerifyJwt(token, decoded.header.kid, options, OAUTH_ACCESS_TOKEN_TYPES); + + if ('error' in result) { + return { data: undefined, tokenType: TokenType.OAuthToken, errors: [result.error] }; + } + + return { + data: IdPOAuthAccessToken.fromJwtPayload(result.payload, options.clockSkewInMs), + tokenType: TokenType.OAuthToken, + errors: undefined, + }; +} diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index f37ad50609b..d88713b954b 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -412,7 +412,7 @@ export const authenticateRequest: AuthenticateRequest = (async ( const { tokenInHeader } = authenticateContext; // 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, + // These are valid Clerk-signed JWTs and will pass verify() verification, // but should not be accepted as session tokens. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion if (isMachineJwt(tokenInHeader!)) { diff --git a/packages/backend/src/tokens/verify.ts b/packages/backend/src/tokens/verify.ts index f05099ee5dd..59a7cf5d6e2 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -13,7 +13,7 @@ import { import type { VerifyJwtOptions } from '../jwt'; import type { JwtReturnType, MachineTokenReturnType } from '../jwt/types'; import { decodeJwt, verifyJwt } from '../jwt/verifyJwt'; -import { verifyDecodedJwtMachineToken } from '../jwt/verifyMachineJwt'; +import { verifyM2MJwt, verifyOAuthJwt } from '../jwt/verifyMachineJwt'; 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'; @@ -256,21 +256,12 @@ export async function verifyMachineAuthToken(token: string, options: VerifyToken // M2M JWT: sub starts with mch_ if (decodedResult.payload.sub.startsWith('mch_')) { - return verifyDecodedJwtMachineToken(token, decodedResult, options, TokenType.M2MToken, (payload, skew) => - M2MToken.fromJwtPayload(payload, skew), - ); + return verifyM2MJwt(token, decodedResult, options); } // 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, - (payload, skew) => IdPOAuthAccessToken.fromJwtPayload(payload, skew), - OAUTH_ACCESS_TOKEN_TYPES, - ); + return verifyOAuthJwt(token, decodedResult, options); } return { From 5dff71b488d878bf0938c0904a66ac802411cc02 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 23 Feb 2026 12:44:35 -0800 Subject: [PATCH 15/32] delete unused file --- ${LOGBOOK_VAULT_PATH}/m2m-jwt-verify.md | 125 ------------------------ 1 file changed, 125 deletions(-) delete mode 100644 ${LOGBOOK_VAULT_PATH}/m2m-jwt-verify.md diff --git a/${LOGBOOK_VAULT_PATH}/m2m-jwt-verify.md b/${LOGBOOK_VAULT_PATH}/m2m-jwt-verify.md deleted file mode 100644 index 6b4c5a64479..00000000000 --- a/${LOGBOOK_VAULT_PATH}/m2m-jwt-verify.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -session_id: ebff6fe8-aa63-4528-b452-88654bd2bee1 -branch: rob/USER-4704-m2m-jwts -project: javascript -date: 2026-02-23 ---- - -# m2m-jwt-verify - -## Summary - -Implemented JWT format support for M2M tokens in the Clerk JavaScript backend SDK. The work involved cleaning up comments, fixing an ESLint unbound-method issue, refactoring the JWT machine token verification to eliminate a generic callback pattern, and making `clerkClient.m2m.verify()` handle both opaque and JWT-format M2M tokens transparently. All 1008 backend tests pass. - -## Key Decisions - -- **Extracted `verifyDecodedJwtMachineToken` to `jwt/verifyMachineJwt.ts`** to break a circular dependency (`M2MTokenApi → verify.ts → factory.ts → M2MTokenApi`) — avoids both duplication and cycles. -- **Replaced generic callback pattern** (`fromPayload` callback) with two typed functions `verifyM2MJwt` / `verifyOAuthJwt` sharing a private `resolveKeyAndVerifyJwt` helper — cleaner return types, no generics. -- **`M2MTokenApi` constructor extended** with `JwtMachineVerifyOptions` (secretKey, apiUrl) threaded from `factory.ts` — API layer can now do local JWT verification without touching `authenticateRequest`. -- **No changes to `authenticateRequest`** — JWT M2M tokens already bypass `m2m.verify()` in that path (routed to `verifyDecodedJwtMachineToken` directly), so no double verification risk. -- **Removed 4 unused internal exports** (`isMachineTokenType`, `getMachineTokenType`, `isM2MJwt`, `isMachineJwt`) from `internal.ts` — only keeping what other packages actually use. -- **Integration test reverts middleware route** — no longer needs `clerkMiddleware` for JWT format since `m2m.verify()` handles both formats directly. - -## What Was Done - -### Comment cleanup - -- Removed redundant JSDoc/inline comments from `verify.ts`, `M2MToken.ts` -- Kept routing comments (`// M2M JWT: sub starts with mch_`, `// OAuth JWT: typ is at+jwt`, `// Opaque token routing by prefix`) - -### ESLint fix - -- `M2MToken.fromJwtPayload` and `IdPOAuthAccessToken.fromJwtPayload` wrapped in arrow functions `(payload, skew) => X.fromJwtPayload(payload, skew)` to fix `@typescript-eslint/unbound-method` -- Consolidated duplicate try/catch in `verifyMachineAuthToken` decode error path using `if (decodeErrors) throw decodeErrors[0]` - -### `internal.ts` trim - -- Removed exports not consumed by any other package: `isMachineTokenType`, `getMachineTokenType`, `isM2MJwt`, `isMachineJwt` -- Kept: `isMachineTokenByPrefix`, `isTokenTypeAccepted`, `isMachineToken`, `verifyMachineAuthToken` - -### New file: `packages/backend/src/jwt/verifyMachineJwt.ts` - -- `resolveKeyAndVerifyJwt(token, kid, options, headerType?)` — private, returns `{ payload } | { error }` -- `verifyM2MJwt(token, decoded, options)` — exported, returns `MachineTokenReturnType` -- `verifyOAuthJwt(token, decoded, options)` — exported, returns `MachineTokenReturnType` -- Imports M2MToken/IdPOAuthAccessToken from `api/resources/` (no circular dep since resources don't import factory) - -### `packages/backend/src/tokens/verify.ts` - -- Removed `verifyDecodedJwtMachineToken` function body (moved to `verifyMachineJwt.ts`) -- Swapped import: `verifyDecodedJwtMachineToken` → `{ verifyM2MJwt, verifyOAuthJwt }` -- Two call sites now: `verifyM2MJwt(token, decodedResult, options)` and `verifyOAuthJwt(token, decodedResult, options)` - -### `packages/backend/src/api/endpoints/M2MTokenApi.ts` - -- Constructor extended: `constructor(request, verifyOptions: JwtMachineVerifyOptions = {})` -- New `#verifyJwtFormat(token)` private method (JS `#` syntax — truly private, not just TS `private`) - - Decodes JWT, throws `MachineTokenVerificationError` on failure - - Calls `verifyM2MJwt`, throws on errors, returns `M2MToken` -- `verify()` now: JWT → `#verifyJwtFormat(token)`, opaque → BAPI -- Removed `TokenType` import (no longer needed), kept `MachineTokenVerificationError`, `decodeJwt`, `isJwtFormat` - -### `packages/backend/src/api/factory.ts` - -- `M2MTokenApi` now gets second arg: `{ secretKey: options.secretKey, apiUrl: options.apiUrl }` - -### `integration/tests/machine-auth/m2m.test.ts` - -- Server template uses `clerkClient.m2m.verify({ token })` (was `verifyToken` — deprecated) on single `/api/protected` route -- Removed `/api/jwt-protected` middleware route (no longer needed) -- Response uses `m2mToken.subject` instead of `m2mToken.id` (consistent for both formats) -- Added test: `'verifies JWT format M2M token via local verification'` — creates `tokenFormat: 'jwt'` token, calls single route, asserts 200 + correct subject - -### Test verification - -- `packages/backend`: 56 test files, 1008 tests, 0 type errors ✓ - -## Open Questions / Follow-ups - -- [ ] Integration test assertions use `emailServer.id` as the expected subject — need to confirm the machine's `id` field matches the JWT `sub` claim when running against real API -- [ ] `jwtKey` (PEM public key) is not threaded through `factory.ts` to `M2MTokenApi` — only `secretKey` + `apiUrl`. Add `jwtKey` support if networkless M2M JWT verification is needed -- [ ] `verifyToken` deprecation in `M2MTokenApi` — confirm timeline for removal -- [ ] Consider whether `verifyMachineAuthToken` should also be removed from `internal.ts` exports since it's not consumed externally yet (currently kept as it's the core machine auth function) - -## Context for Next Session - -**Key files:** - -- `packages/backend/src/jwt/verifyMachineJwt.ts` — new shared module, source of truth for JWT machine token verification -- `packages/backend/src/api/endpoints/M2MTokenApi.ts` — `verify()` now handles both opaque + JWT; `#verifyJwtFormat` is the private JWT path -- `packages/backend/src/tokens/verify.ts` — `verifyMachineAuthToken` routes JWT M2M/OAuth to the new typed helpers -- `packages/backend/src/api/factory.ts` — threads `secretKey` + `apiUrl` into `M2MTokenApi` -- `integration/tests/machine-auth/m2m.test.ts` — single route integration test covering opaque + JWT format -- `docs/plans/2026-02-23-m2m-jwt-verify-in-api-client.md` — the implementation plan (now fully executed) - -**Run tests:** - -```bash -cd packages/backend && pnpm run test:node -``` - -Note: if vitest fails with `Cannot find module`, delete `packages/backend/node_modules` and run `pnpm install` from root. - -**Branch:** `rob/USER-4704-m2m-jwts` — merges from `release/core-2` - -## Diagram - -```mermaid -graph TD - A["clerkClient.m2m.verify(token)"] -->|isJwtFormat| B["#verifyJwtFormat (private)"] - A -->|opaque prefix| C["POST /m2m_tokens/verify (BAPI)"] - B --> D["verifyM2MJwt()"] - D --> E["resolveKeyAndVerifyJwt()"] - E -->|jwtKey| F["loadClerkJwkFromPem"] - E -->|secretKey| G["loadClerkJWKFromRemote (JWKS)"] - E --> H["verifyJwt()"] - H --> I["M2MToken.fromJwtPayload()"] - - J["authenticateRequest()"] --> K["verifyMachineAuthToken()"] - K -->|JWT + sub=mch_| D - K -->|JWT + OAuth typ| L["verifyOAuthJwt()"] - L --> E - K -->|opaque mt_| C - K -->|opaque oat_| M["POST /idp_oauth_access_tokens/verify"] - K -->|opaque ak_| N["POST /api_keys/verify"] -``` From 5fe771fe90850134b7fbe56eb6169525a2095b17 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 23 Feb 2026 13:09:18 -0800 Subject: [PATCH 16/32] fix unit test --- packages/backend/src/api/__tests__/M2MTokenApi.test.ts | 4 ++-- packages/backend/src/jwt/verifyMachineJwt.ts | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/api/__tests__/M2MTokenApi.test.ts b/packages/backend/src/api/__tests__/M2MTokenApi.test.ts index 24f0afa9a69..ff34c7aa39b 100644 --- a/packages/backend/src/api/__tests__/M2MTokenApi.test.ts +++ b/packages/backend/src/api/__tests__/M2MTokenApi.test.ts @@ -224,8 +224,8 @@ describe('M2MToken', () => { 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(); + // tokenFormat defaults to 'opaque' when omitted + expect(body.token_format).toBe('opaque'); return HttpResponse.json(mockM2MToken); }), ), diff --git a/packages/backend/src/jwt/verifyMachineJwt.ts b/packages/backend/src/jwt/verifyMachineJwt.ts index ad263b67a55..7af2d8af91f 100644 --- a/packages/backend/src/jwt/verifyMachineJwt.ts +++ b/packages/backend/src/jwt/verifyMachineJwt.ts @@ -80,8 +80,6 @@ async function resolveKeyAndVerifyJwt( /** * Verifies a pre-decoded M2M JWT (identified by `sub` starting with `mch_`). - * Delegates key resolution and signature verification to `resolveKeyAndVerifyJwt`, - * then maps the verified payload to an `M2MToken` via `M2MToken.fromJwtPayload`. */ export async function verifyM2MJwt( token: string, @@ -103,8 +101,6 @@ export async function verifyM2MJwt( /** * Verifies a pre-decoded OAuth access token JWT (identified by `typ: at+jwt` or `application/at+jwt`). - * Delegates key resolution and signature verification to `resolveKeyAndVerifyJwt` with the - * allowed OAuth header types, then maps the verified payload to an `IdPOAuthAccessToken`. */ export async function verifyOAuthJwt( token: string, From c00bec404d5338d7775a0824991e6d8a2d0e2931 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 23 Feb 2026 13:51:52 -0800 Subject: [PATCH 17/32] chore: clean up lint issues --- integration/tests/machine-auth/m2m.test.ts | 2 +- packages/backend/src/api/__tests__/M2MTokenApi.test.ts | 6 +++--- packages/backend/src/api/endpoints/M2MTokenApi.ts | 5 +++++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/integration/tests/machine-auth/m2m.test.ts b/integration/tests/machine-auth/m2m.test.ts index 1da6afd1733..376757e7e51 100644 --- a/integration/tests/machine-auth/m2m.test.ts +++ b/integration/tests/machine-auth/m2m.test.ts @@ -44,7 +44,7 @@ test.describe('machine-to-machine auth @machine', () => { const token = req.get('Authorization')?.split(' ')[1]; try { const m2mToken = await clerkClient.m2m.verify({ token }); - res.send('Protected response ' + m2mToken.subject); + res.send('Protected response ' + m2mToken.id); } catch { res.status(401).send('Unauthorized'); } diff --git a/packages/backend/src/api/__tests__/M2MTokenApi.test.ts b/packages/backend/src/api/__tests__/M2MTokenApi.test.ts index ff34c7aa39b..d7badfd506e 100644 --- a/packages/backend/src/api/__tests__/M2MTokenApi.test.ts +++ b/packages/backend/src/api/__tests__/M2MTokenApi.test.ts @@ -1,12 +1,12 @@ import { http, HttpResponse } from 'msw'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mockJwtPayload, mockJwks, mockM2MJwtPayload, signingJwks } from '../../fixtures'; +import { mockJwks, mockJwtPayload, mockM2MJwtPayload, signingJwks } from '../../fixtures'; import { signJwt } from '../../jwt/signJwt'; import { server, validateHeaders } from '../../mock-server'; -import { buildRequest } from '../request'; -import { createBackendApiClient } from '../factory'; import { M2MTokenApi } from '../endpoints/M2MTokenApi'; +import { createBackendApiClient } from '../factory'; +import { buildRequest } from '../request'; describe('M2MToken', () => { const m2mId = 'mt_xxxxx'; diff --git a/packages/backend/src/api/endpoints/M2MTokenApi.ts b/packages/backend/src/api/endpoints/M2MTokenApi.ts index 4213dafb0ce..0ef4612a756 100644 --- a/packages/backend/src/api/endpoints/M2MTokenApi.ts +++ b/packages/backend/src/api/endpoints/M2MTokenApi.ts @@ -62,6 +62,11 @@ type VerifyM2MTokenParams = { export class M2MTokenApi extends AbstractAPI { #verifyOptions: JwtMachineVerifyOptions; + /** + * @param verifyOptions - JWT verification options (secretKey, apiUrl, etc.). + * Passed explicitly because BuildRequestOptions are captured inside the buildRequest closure + * and are not accessible from the RequestFunction itself. + */ constructor(request: RequestFunction, verifyOptions: JwtMachineVerifyOptions = {}) { super(request); this.#verifyOptions = verifyOptions; From 741662475d714ffb6c60a41de95cd1f7c4f3e512 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 23 Feb 2026 14:51:10 -0800 Subject: [PATCH 18/32] fix: add missing jwtKey --- packages/backend/src/api/factory.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/api/factory.ts b/packages/backend/src/api/factory.ts index bfdc8b31237..c24deb9c142 100644 --- a/packages/backend/src/api/factory.ts +++ b/packages/backend/src/api/factory.ts @@ -33,7 +33,9 @@ import { import { BillingAPI } from './endpoints/BillingApi'; import { buildRequest } from './request'; -export type CreateBackendApiOptions = Parameters[0]; +export type CreateBackendApiOptions = Parameters[0] & { + jwtKey?: string; +}; export type ApiClient = ReturnType; @@ -86,6 +88,7 @@ export function createBackendApiClient(options: CreateBackendApiOptions) { { secretKey: options.secretKey, apiUrl: options.apiUrl, + jwtKey: options.jwtKey, }, ), oauthApplications: new OAuthApplicationsApi(request), From 63635641edb8fc7cff4db3e0ecc3e21138a2e742 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 23 Feb 2026 14:53:49 -0800 Subject: [PATCH 19/32] fix: use correct subject in integration --- integration/tests/machine-auth/m2m.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/tests/machine-auth/m2m.test.ts b/integration/tests/machine-auth/m2m.test.ts index 376757e7e51..1da6afd1733 100644 --- a/integration/tests/machine-auth/m2m.test.ts +++ b/integration/tests/machine-auth/m2m.test.ts @@ -44,7 +44,7 @@ test.describe('machine-to-machine auth @machine', () => { const token = req.get('Authorization')?.split(' ')[1]; try { const m2mToken = await clerkClient.m2m.verify({ token }); - res.send('Protected response ' + m2mToken.id); + res.send('Protected response ' + m2mToken.subject); } catch { res.status(401).send('Unauthorized'); } From 3c8e1e377128deae56eb18e809b2783be78c8435 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 23 Feb 2026 16:13:11 -0800 Subject: [PATCH 20/32] chore: extend ClerkError --- packages/backend/src/errors.ts | 21 +++++++++++++-------- packages/backend/src/fixtures/machine.ts | 14 -------------- packages/backend/src/tokens/verify.ts | 2 +- 3 files changed, 14 insertions(+), 23 deletions(-) diff --git a/packages/backend/src/errors.ts b/packages/backend/src/errors.ts index d9434e711a7..eb9272e5831 100644 --- a/packages/backend/src/errors.ts +++ b/packages/backend/src/errors.ts @@ -1,3 +1,5 @@ +import { ClerkError } from '@clerk/shared/error'; + export type TokenCarrier = 'header' | 'cookie'; export const TokenVerificationErrorCode = { @@ -80,11 +82,11 @@ export const MachineTokenVerificationErrorCode = { export type MachineTokenVerificationErrorCode = (typeof MachineTokenVerificationErrorCode)[keyof typeof MachineTokenVerificationErrorCode]; -export class MachineTokenVerificationError extends Error { - code: MachineTokenVerificationErrorCode; - long_message?: string; - status?: number; - action?: TokenVerificationErrorAction; +export class MachineTokenVerificationError extends ClerkError { + static kind = 'MachineTokenVerificationError'; + declare readonly code: MachineTokenVerificationErrorCode; + readonly status?: number; + readonly action?: TokenVerificationErrorAction; constructor({ message, @@ -97,14 +99,17 @@ export class MachineTokenVerificationError extends Error { status?: number; action?: TokenVerificationErrorAction; }) { - super(message); + super({ message, code }); Object.setPrototypeOf(this, MachineTokenVerificationError.prototype); - - this.code = code; this.status = status; this.action = action; } + // Keep message unformatted, matching ClerkAPIResponseError's approach + protected static override formatMessage(_name: string, msg: string, _code: string, _docsUrl: string | undefined) { + return msg; + } + public getFullMessage() { return `${this.message} (code=${this.code}, status=${this.status || 'n/a'})`; } diff --git a/packages/backend/src/fixtures/machine.ts b/packages/backend/src/fixtures/machine.ts index e6cf6fe9158..fb5c50cd5c5 100644 --- a/packages/backend/src/fixtures/machine.ts +++ b/packages/backend/src/fixtures/machine.ts @@ -79,17 +79,3 @@ 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/tokens/verify.ts b/packages/backend/src/tokens/verify.ts index 59a7cf5d6e2..f2c80d7127f 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, M2MToken } from '../api'; +import type { APIKey, IdPOAuthAccessToken, M2MToken } from '../api'; import { createBackendApiClient } from '../api/factory'; import { MachineTokenVerificationError, From b5dacacaa7220041f621e0e591211773e8fd8c77 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 23 Feb 2026 18:34:49 -0800 Subject: [PATCH 21/32] chore: use correct M2M ids --- packages/backend/src/api/resources/M2MToken.ts | 4 ++-- packages/backend/src/fixtures/machine.ts | 2 +- packages/backend/src/tokens/__tests__/authObjects.test.ts | 2 +- packages/backend/src/tokens/__tests__/verify.test.ts | 4 ++-- packages/express/src/__tests__/getAuth.test.ts | 4 ++-- packages/fastify/src/__tests__/getAuth.test.ts | 4 ++-- packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts | 2 +- .../src/server/__tests__/getAuthDataFromRequest.test.ts | 2 +- 8 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/backend/src/api/resources/M2MToken.ts b/packages/backend/src/api/resources/M2MToken.ts index a68853f694f..a8cd4d65db6 100644 --- a/packages/backend/src/api/resources/M2MToken.ts +++ b/packages/backend/src/api/resources/M2MToken.ts @@ -6,7 +6,7 @@ type M2MJwtPayload = { sub: string; exp: number; iat: number; - jti?: string; + jti: string; aud?: string[]; scopes?: string; [key: string]: unknown; @@ -48,7 +48,7 @@ export class M2MToken { static fromJwtPayload(payload: M2MJwtPayload, clockSkewInMs = 5000): M2MToken { return new M2MToken( - payload.jti ?? '', + payload.jti, payload.sub, payload.scopes?.split(' ') ?? payload.aud ?? [], null, diff --git a/packages/backend/src/fixtures/machine.ts b/packages/backend/src/fixtures/machine.ts index fb5c50cd5c5..5b2f3cb5134 100644 --- a/packages/backend/src/fixtures/machine.ts +++ b/packages/backend/src/fixtures/machine.ts @@ -37,7 +37,7 @@ export const mockVerificationResults = { updatedAt: 1744928754551, }, m2m_token: { - id: 'm2m_ey966f1b1xf93586b2debdcadb0b3bd1', + id: 'mt_ey966f1b1xf93586b2debdcadb0b3bd1', subject: 'mch_2vYVtestTESTtestTESTtestTESTtest', scopes: ['mch_1xxxxx', 'mch_2xxxxx'], claims: { foo: 'bar' }, diff --git a/packages/backend/src/tokens/__tests__/authObjects.test.ts b/packages/backend/src/tokens/__tests__/authObjects.test.ts index 2df02870852..f562b184add 100644 --- a/packages/backend/src/tokens/__tests__/authObjects.test.ts +++ b/packages/backend/src/tokens/__tests__/authObjects.test.ts @@ -363,7 +363,7 @@ describe('authenticatedMachineObject', () => { it('properly initializes properties', () => { const authObject = authenticatedMachineObject('m2m_token', token, verificationResult, debugData); expect(authObject.tokenType).toBe('m2m_token'); - expect(authObject.id).toBe('m2m_ey966f1b1xf93586b2debdcadb0b3bd1'); + expect(authObject.id).toBe('mt_ey966f1b1xf93586b2debdcadb0b3bd1'); expect(authObject.subject).toBe('mch_2vYVtestTESTtestTESTtestTESTtest'); expect(authObject.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']); expect(authObject.machineId).toBe('mch_2vYVtestTESTtestTESTtestTESTtest'); diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index 0f7b0171d62..b682db6ef37 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -150,7 +150,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(result.errors).toBeUndefined(); const data = result.data as M2MToken; - expect(data.id).toBe('m2m_ey966f1b1xf93586b2debdcadb0b3bd1'); + expect(data.id).toBe('mt_ey966f1b1xf93586b2debdcadb0b3bd1'); expect(data.subject).toBe('mch_2vYVtestTESTtestTESTtestTESTtest'); expect(data.claims).toEqual({ foo: 'bar' }); expect(data.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']); @@ -180,7 +180,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(result.errors).toBeUndefined(); const data = result.data as M2MToken; - expect(data.id).toBe('m2m_ey966f1b1xf93586b2debdcadb0b3bd1'); + expect(data.id).toBe('mt_ey966f1b1xf93586b2debdcadb0b3bd1'); expect(data.subject).toBe('mch_2vYVtestTESTtestTESTtestTESTtest'); expect(data.claims).toEqual({ foo: 'bar' }); expect(data.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']); diff --git a/packages/express/src/__tests__/getAuth.test.ts b/packages/express/src/__tests__/getAuth.test.ts index 605508d9290..8bf827cd393 100644 --- a/packages/express/src/__tests__/getAuth.test.ts +++ b/packages/express/src/__tests__/getAuth.test.ts @@ -32,11 +32,11 @@ describe('getAuth', () => { }); it('returns the actual auth object if its tokenType is included in the acceptsToken array', () => { - const req = mockRequestWithAuth({ tokenType: 'm2m_token', id: 'm2m_1234' }); + const req = mockRequestWithAuth({ tokenType: 'm2m_token', id: 'mt_1234' }); const result = getAuth(req, { acceptsToken: ['m2m_token', 'api_key'] }); expect(result.tokenType).toBe('m2m_token'); - expect((result as AuthenticatedMachineObject<'m2m_token'>).id).toBe('m2m_1234'); + expect((result as AuthenticatedMachineObject<'m2m_token'>).id).toBe('mt_1234'); expect((result as AuthenticatedMachineObject<'m2m_token'>).subject).toBeUndefined(); }); diff --git a/packages/fastify/src/__tests__/getAuth.test.ts b/packages/fastify/src/__tests__/getAuth.test.ts index 535c1f022f3..4a6772c6f08 100644 --- a/packages/fastify/src/__tests__/getAuth.test.ts +++ b/packages/fastify/src/__tests__/getAuth.test.ts @@ -28,10 +28,10 @@ describe('getAuth(req)', () => { }); it('returns the actual auth object if its tokenType is included in the acceptsToken array', () => { - const req = { auth: { tokenType: 'm2m_token', id: 'm2m_1234' } } as unknown as FastifyRequest; + const req = { auth: { tokenType: 'm2m_token', id: 'mt_1234' } } as unknown as FastifyRequest; const result = getAuth(req, { acceptsToken: ['m2m_token', 'api_key'] }); expect(result.tokenType).toBe('m2m_token'); - expect((result as AuthenticatedMachineObject<'m2m_token'>).id).toBe('m2m_1234'); + expect((result as AuthenticatedMachineObject<'m2m_token'>).id).toBe('mt_1234'); expect((result as AuthenticatedMachineObject<'m2m_token'>).subject).toBeUndefined(); }); diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts index 69419e2d504..7b4ae556993 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -655,7 +655,7 @@ describe('clerkMiddleware(params)', () => { const req = mockRequest({ url: '/api/protected', headers: new Headers({ - [constants.Headers.Authorization]: 'Bearer m2m_123', + [constants.Headers.Authorization]: 'Bearer mt_123', }), }); diff --git a/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts b/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts index 57cc934a3ea..51ffbb247e5 100644 --- a/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts +++ b/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts @@ -205,7 +205,7 @@ describe('getAuthDataFromRequest', () => { { tokenType: 'm2m_token' as const, token: 'mt_123', - data: { id: 'm2m_123', subject: 'mch_123' }, + data: { id: 'mt_123', subject: 'mch_123' }, }, ])( 'returns authenticated $tokenType object when token is valid and acceptsToken is $tokenType', From 1615d5bafac48455b514a26e220a5cbec07dee16 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 24 Feb 2026 07:38:39 -0800 Subject: [PATCH 22/32] fix: build error --- packages/backend/src/api/resources/M2MToken.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/api/resources/M2MToken.ts b/packages/backend/src/api/resources/M2MToken.ts index a8cd4d65db6..a68853f694f 100644 --- a/packages/backend/src/api/resources/M2MToken.ts +++ b/packages/backend/src/api/resources/M2MToken.ts @@ -6,7 +6,7 @@ type M2MJwtPayload = { sub: string; exp: number; iat: number; - jti: string; + jti?: string; aud?: string[]; scopes?: string; [key: string]: unknown; @@ -48,7 +48,7 @@ export class M2MToken { static fromJwtPayload(payload: M2MJwtPayload, clockSkewInMs = 5000): M2MToken { return new M2MToken( - payload.jti, + payload.jti ?? '', payload.sub, payload.scopes?.split(' ') ?? payload.aud ?? [], null, From 81c1a287461f702a10c94ba7a5b8bf6212dbad07 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Tue, 24 Feb 2026 07:47:57 -0800 Subject: [PATCH 23/32] chore: update changeset --- .changeset/clever-ways-raise.md | 46 ++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/.changeset/clever-ways-raise.md b/.changeset/clever-ways-raise.md index 4d34aaa5e98..733d8287c82 100644 --- a/.changeset/clever-ways-raise.md +++ b/.changeset/clever-ways-raise.md @@ -2,4 +2,48 @@ "@clerk/backend": minor --- -Add M2M JWT token verification support +Added support for JWT token format when creating and verifying machine-to-machine (M2M) tokens. This enables fully **networkless verification** when using the public JWT key. + +**Creating a JWT-format M2M token** + +```ts +const clerkClient = createClerkClient({ + machineSecretKey: process.env.CLERK_MACHINE_SECRET_KEY +}) + +const m2mToken = await clerkClient.m2m.createToken({ + tokenFormat: 'jwt', +}) + +console.log('M2M token created:', m2mToken.token) +``` + +**Verifying a token** + +```ts +const clerkClient = createClerkClient({ + machineSecretKey: process.env.CLERK_MACHINE_SECRET_KEY +}) + +const authHeader = req.headers.get('Authorization') +const token = authHeader.slice(7) + +const verified = await clerkClient.m2m.verify(token) + +console.log('Verified M2M token:', verified) +``` + +**Networkless verification** + +```ts +const clerkClient = createClerkClient({ + jwtKey: process.env.CLERK_JWT_KEY +}) + +const authHeader = req.headers.get('Authorization') +const token = authHeader.slice(7) + +const verified = await clerkClient.m2m.verify(token) + +console.log('Verified M2M token:', verified) +``` From 3e6bef2d3108a8e8e12cf889aabafed1df68f201 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 24 Feb 2026 07:48:36 -0800 Subject: [PATCH 24/32] chore: unit test scopes --- packages/backend/src/api/resources/M2MToken.ts | 6 +++--- .../src/api/resources/__tests__/M2MToken.test.ts | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/api/resources/M2MToken.ts b/packages/backend/src/api/resources/M2MToken.ts index a68853f694f..230781dcbf5 100644 --- a/packages/backend/src/api/resources/M2MToken.ts +++ b/packages/backend/src/api/resources/M2MToken.ts @@ -48,7 +48,7 @@ export class M2MToken { static fromJwtPayload(payload: M2MJwtPayload, clockSkewInMs = 5000): M2MToken { return new M2MToken( - payload.jti ?? '', + payload.jti ?? '', // jti should always be present in Clerk-issued M2M JWTs payload.sub, payload.scopes?.split(' ') ?? payload.aud ?? [], null, @@ -56,8 +56,8 @@ export class M2MToken { null, payload.exp * 1000 <= Date.now() - clockSkewInMs, payload.exp, // seconds (raw JWT exp claim) - payload.iat, // seconds (raw JWT iat claim) - payload.iat, // seconds (raw JWT iat claim) + payload.iat, // seconds — createdAt, mapped from JWT iat claim + payload.iat, // seconds — updatedAt, no JWT equivalent; defaults to iat ); } } diff --git a/packages/backend/src/api/resources/__tests__/M2MToken.test.ts b/packages/backend/src/api/resources/__tests__/M2MToken.test.ts index 931ced62f55..e2e8bd14a71 100644 --- a/packages/backend/src/api/resources/__tests__/M2MToken.test.ts +++ b/packages/backend/src/api/resources/__tests__/M2MToken.test.ts @@ -38,6 +38,21 @@ describe('M2MToken', () => { expect(token.updatedAt).toBe(1666648250); }); + it('prefers scopes claim over aud when both are present', () => { + const payload = { + sub: 'mch_test', + exp: 1666648550, + iat: 1666648250, + jti: 'mt_test', + scopes: 'scope1 scope2', + aud: ['aud1', 'aud2'], + }; + + const token = M2MToken.fromJwtPayload(payload); + + expect(token.scopes).toEqual(['scope1', 'scope2']); + }); + it('parses scopes from space-separated string when aud is missing', () => { const payload = { sub: 'mch_test', From ca124f6aec559d3d3b71dd43d9ffdb645c7e41b2 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 24 Feb 2026 09:31:57 -0800 Subject: [PATCH 25/32] chore: fix nextjs jwt locking for machine auth --- packages/nextjs/src/server/data/getAuthDataFromRequest.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/nextjs/src/server/data/getAuthDataFromRequest.ts b/packages/nextjs/src/server/data/getAuthDataFromRequest.ts index d242129b954..2e5d1752ebb 100644 --- a/packages/nextjs/src/server/data/getAuthDataFromRequest.ts +++ b/packages/nextjs/src/server/data/getAuthDataFromRequest.ts @@ -131,6 +131,13 @@ export const getAuthDataFromRequest = (req: RequestLike, opts: GetAuthDataFromRe return machineAuthObject; } + // If a machine token was sent but this endpoint only accepts session tokens, + // reject it — don't let it fall through to session auth where its sub claim + // would be incorrectly mapped to userId. + if (bearerToken && isMachineToken(bearerToken)) { + return signedOutAuthObject(options); + } + // If a random token is present and acceptsToken is an array that does NOT include session_token, // return invalid token auth object. if (bearerToken && Array.isArray(acceptsToken) && !acceptsToken.includes(TokenType.SessionToken)) { From 3d8b68e3a7b808a0f777a7718c146873846f49ac Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 24 Feb 2026 09:34:27 -0800 Subject: [PATCH 26/32] chore: add more tests --- .../express/src/__tests__/getAuth.test.ts | 8 +++ .../fastify/src/__tests__/getAuth.test.ts | 8 +++ .../__tests__/getAuthDataFromRequest.test.ts | 55 +++++++++++++++++++ 3 files changed, 71 insertions(+) diff --git a/packages/express/src/__tests__/getAuth.test.ts b/packages/express/src/__tests__/getAuth.test.ts index 8bf827cd393..8f45c6b3e3c 100644 --- a/packages/express/src/__tests__/getAuth.test.ts +++ b/packages/express/src/__tests__/getAuth.test.ts @@ -48,4 +48,12 @@ describe('getAuth', () => { expect(result.userId).toBeNull(); expect(result.orgId).toBeNull(); }); + + it('returns an unauthenticated session auth object when m2m_token is present but session_token is expected', () => { + const req = mockRequestWithAuth({ tokenType: 'm2m_token', id: 'mt_1234', subject: 'mch_1234' }); + const result = getAuth(req, { acceptsToken: 'session_token' }); + expect(result.tokenType).toBe('session_token'); + expect(result.userId).toBeNull(); + expect(result.isAuthenticated).toBe(false); + }); }); diff --git a/packages/fastify/src/__tests__/getAuth.test.ts b/packages/fastify/src/__tests__/getAuth.test.ts index 4a6772c6f08..7488909f993 100644 --- a/packages/fastify/src/__tests__/getAuth.test.ts +++ b/packages/fastify/src/__tests__/getAuth.test.ts @@ -42,4 +42,12 @@ describe('getAuth(req)', () => { expect(result.userId).toBeNull(); expect(result.orgId).toBeNull(); }); + + it('returns an unauthenticated session auth object when m2m_token is present but session_token is expected', () => { + const req = { auth: { tokenType: 'm2m_token', id: 'mt_1234', subject: 'mch_1234' } } as unknown as FastifyRequest; + const result = getAuth(req, { acceptsToken: 'session_token' }); + expect(result.tokenType).toBe('session_token'); + expect(result.userId).toBeNull(); + expect(result.isAuthenticated).toBe(false); + }); }); diff --git a/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts b/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts index 51ffbb247e5..1a49c6b6c13 100644 --- a/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts +++ b/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts @@ -298,4 +298,59 @@ describe('getAuthDataFromRequest', () => { expect((auth as SignedOutAuthObject).userId).toBeNull(); expect(auth.isAuthenticated).toBe(false); }); + + describe('JWT-format machine token rejection', () => { + // Constructs a JWT-format M2M token (sub: 'mch_...') without an encrypted auth object. + // This simulates a request that bypasses middleware and sends a raw JWT-format M2M token. + const toBase64Url = (str: string) => btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + const mockM2MJwt = [ + toBase64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' })), + toBase64Url(JSON.stringify({ sub: 'mch_testmachine123', exp: 9999999999, iat: 1666648250 })), + 'fakesignature', + ].join('.'); + + it('returns signed-out when JWT-format M2M token is sent to a session-only endpoint', () => { + const req = mockRequest({ + url: '/api/protected', + headers: new Headers({ + [constants.Headers.Authorization]: `Bearer ${mockM2MJwt}`, + }), + }); + + const auth = getAuthDataFromRequest(req, { acceptsToken: 'session_token' }); + + expect(auth.tokenType).toBe('session_token'); + expect((auth as SignedOutAuthObject).userId).toBeNull(); + expect(auth.isAuthenticated).toBe(false); + }); + + it('returns signed-out when JWT-format M2M token is sent with default (session) acceptsToken', () => { + const req = mockRequest({ + url: '/api/protected', + headers: new Headers({ + [constants.Headers.Authorization]: `Bearer ${mockM2MJwt}`, + }), + }); + + const auth = getAuthDataFromRequest(req); + + expect(auth.tokenType).toBe('session_token'); + expect((auth as SignedOutAuthObject).userId).toBeNull(); + expect(auth.isAuthenticated).toBe(false); + }); + + it('returns signed-out when JWT-format M2M token is sent but no encrypted auth object is present', () => { + const req = mockRequest({ + url: '/api/protected', + headers: new Headers({ + [constants.Headers.Authorization]: `Bearer ${mockM2MJwt}`, + }), + }); + + // m2m_token is accepted but no encrypted machineAuthObject was set by middleware + const auth = getAuthDataFromRequest(req, { acceptsToken: ['api_key', 'm2m_token'] }); + + expect(auth.isAuthenticated).toBe(false); + }); + }); }); From 2f01baf1c0b74966242c4804ea8ab4858240c9a5 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 24 Feb 2026 10:03:36 -0800 Subject: [PATCH 27/32] chore: apply coderabbit suggestions --- integration/tests/machine-auth/m2m.test.ts | 1 + packages/backend/src/api/endpoints/M2MTokenApi.ts | 4 ++-- packages/backend/src/api/resources/M2MToken.ts | 6 +++--- .../backend/src/api/resources/__tests__/M2MToken.test.ts | 6 +++--- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/integration/tests/machine-auth/m2m.test.ts b/integration/tests/machine-auth/m2m.test.ts index 1da6afd1733..2f44ee49fea 100644 --- a/integration/tests/machine-auth/m2m.test.ts +++ b/integration/tests/machine-auth/m2m.test.ts @@ -187,5 +187,6 @@ test.describe('machine-to-machine auth @machine', () => { }); expect(res.status()).toBe(200); expect(await res.text()).toBe('Protected response ' + emailServer.id); + await client.m2m.revokeToken({ m2mTokenId: jwtToken.id }); }); }); diff --git a/packages/backend/src/api/endpoints/M2MTokenApi.ts b/packages/backend/src/api/endpoints/M2MTokenApi.ts index 0ef4612a756..1d94dfa9b1f 100644 --- a/packages/backend/src/api/endpoints/M2MTokenApi.ts +++ b/packages/backend/src/api/endpoints/M2MTokenApi.ts @@ -2,7 +2,7 @@ import { MachineTokenVerificationError, MachineTokenVerificationErrorCode } from import { decodeJwt } from '../../jwt/verifyJwt'; import type { JwtMachineVerifyOptions } from '../../jwt/verifyMachineJwt'; import { verifyM2MJwt } from '../../jwt/verifyMachineJwt'; -import { isJwtFormat } from '../../tokens/machine'; +import { isM2MJwt } from '../../tokens/machine'; import { joinPaths } from '../../util/path'; import { deprecated } from '../../util/shared'; import type { ClerkBackendApiRequestOptions, RequestFunction } from '../request'; @@ -149,7 +149,7 @@ export class M2MTokenApi extends AbstractAPI { async verify(params: VerifyM2MTokenParams) { const { token, machineSecretKey } = params; - if (isJwtFormat(token)) { + if (isM2MJwt(token)) { return this.#verifyJwtFormat(token); } diff --git a/packages/backend/src/api/resources/M2MToken.ts b/packages/backend/src/api/resources/M2MToken.ts index 230781dcbf5..e3253329056 100644 --- a/packages/backend/src/api/resources/M2MToken.ts +++ b/packages/backend/src/api/resources/M2MToken.ts @@ -55,9 +55,9 @@ export class M2MToken { false, null, payload.exp * 1000 <= Date.now() - clockSkewInMs, - payload.exp, // seconds (raw JWT exp claim) - payload.iat, // seconds — createdAt, mapped from JWT iat claim - payload.iat, // seconds — updatedAt, no JWT equivalent; defaults to iat + payload.exp * 1000, // milliseconds — expiration, converted from JWT exp claim + payload.iat * 1000, // milliseconds — createdAt, converted from JWT iat claim + payload.iat * 1000, // milliseconds — updatedAt, no JWT equivalent; defaults to iat ); } } diff --git a/packages/backend/src/api/resources/__tests__/M2MToken.test.ts b/packages/backend/src/api/resources/__tests__/M2MToken.test.ts index e2e8bd14a71..ca158ae2e37 100644 --- a/packages/backend/src/api/resources/__tests__/M2MToken.test.ts +++ b/packages/backend/src/api/resources/__tests__/M2MToken.test.ts @@ -33,9 +33,9 @@ describe('M2MToken', () => { 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); + expect(token.expiration).toBe(1666648550 * 1000); + expect(token.createdAt).toBe(1666648250 * 1000); + expect(token.updatedAt).toBe(1666648250 * 1000); }); it('prefers scopes claim over aud when both are present', () => { From f884e9577df5e63dd00952ef0e45f04ea3d22e58 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 24 Feb 2026 10:19:52 -0800 Subject: [PATCH 28/32] chore: fix protect machine auth handling --- packages/backend/src/tokens/machine.ts | 3 ++- packages/backend/src/tokens/verify.ts | 12 +++++++++--- packages/nextjs/src/server/protect.ts | 9 +++++++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/tokens/machine.ts b/packages/backend/src/tokens/machine.ts index b9e8f6b33ba..cfc055e96d3 100644 --- a/packages/backend/src/tokens/machine.ts +++ b/packages/backend/src/tokens/machine.ts @@ -4,6 +4,7 @@ import type { MachineTokenType } from './tokenTypes'; import { TokenType } from './tokenTypes'; export const M2M_TOKEN_PREFIX = 'mt_'; +export const M2M_SUBJECT_PREFIX = 'mch_'; export const OAUTH_TOKEN_PREFIX = 'oat_'; export const API_KEY_PREFIX = 'ak_'; @@ -58,7 +59,7 @@ export function isM2MJwt(token: string): boolean { } try { const { data, errors } = decodeJwt(token); - return !errors && !!data && typeof data.payload.sub === 'string' && data.payload.sub.startsWith('mch_'); + return !errors && !!data && typeof data.payload.sub === 'string' && data.payload.sub.startsWith(M2M_SUBJECT_PREFIX); } catch { return false; } diff --git a/packages/backend/src/tokens/verify.ts b/packages/backend/src/tokens/verify.ts index f2c80d7127f..e19acc1f44b 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -16,7 +16,14 @@ import { decodeJwt, verifyJwt } from '../jwt/verifyJwt'; import { verifyM2MJwt, verifyOAuthJwt } from '../jwt/verifyMachineJwt'; 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, + M2M_SUBJECT_PREFIX, + M2M_TOKEN_PREFIX, + OAUTH_ACCESS_TOKEN_TYPES, + OAUTH_TOKEN_PREFIX, +} from './machine'; import type { MachineTokenType } from './tokenTypes'; import { TokenType } from './tokenTypes'; @@ -254,8 +261,7 @@ export async function verifyMachineAuthToken(token: string, options: VerifyToken } as MachineTokenReturnType; } - // M2M JWT: sub starts with mch_ - if (decodedResult.payload.sub.startsWith('mch_')) { + if (decodedResult.payload.sub.startsWith(M2M_SUBJECT_PREFIX)) { return verifyM2MJwt(token, decodedResult, options); } diff --git a/packages/nextjs/src/server/protect.ts b/packages/nextjs/src/server/protect.ts index 7527263858b..113359e6a5a 100644 --- a/packages/nextjs/src/server/protect.ts +++ b/packages/nextjs/src/server/protect.ts @@ -130,8 +130,13 @@ export function createProtect(opts: { }; const handleUnauthorized = () => { - // For machine tokens, return a 401 response - if (authObject.tokenType !== TokenType.SessionToken) { + // Return 401 for machine token contexts: either the auth object itself is a machine token, + // or the endpoint was configured to accept machine tokens but auth failed (e.g. middleware + // bypassed, so authObject fell back to a signed-out session object). + if ( + authObject.tokenType !== TokenType.SessionToken || + !isTokenTypeAccepted(TokenType.SessionToken, requestedToken) + ) { return unauthorized(); } From 866d47b945016c9ef7e04fae33d5386f3a9b46ef Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 24 Feb 2026 10:51:38 -0800 Subject: [PATCH 29/32] fix e2e test --- integration/tests/machine-auth/m2m.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/integration/tests/machine-auth/m2m.test.ts b/integration/tests/machine-auth/m2m.test.ts index 2f44ee49fea..73721cebc53 100644 --- a/integration/tests/machine-auth/m2m.test.ts +++ b/integration/tests/machine-auth/m2m.test.ts @@ -187,6 +187,7 @@ test.describe('machine-to-machine auth @machine', () => { }); expect(res.status()).toBe(200); expect(await res.text()).toBe('Protected response ' + emailServer.id); - await client.m2m.revokeToken({ m2mTokenId: jwtToken.id }); + // JWT-format tokens are self-contained and not stored in BAPI, so revocation + // is not applicable — they expire naturally via the exp claim. }); }); From da9e999acffb15c6acfc8715d64e105056fdd7dd Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Tue, 24 Feb 2026 11:54:36 -0800 Subject: [PATCH 30/32] chore: update changeset Added support for JWT token format for M2M tokens, enabling networkless verification with the public JWT key. --- .changeset/clever-ways-raise.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/clever-ways-raise.md b/.changeset/clever-ways-raise.md index 733d8287c82..76f0ea44291 100644 --- a/.changeset/clever-ways-raise.md +++ b/.changeset/clever-ways-raise.md @@ -1,5 +1,6 @@ --- "@clerk/backend": minor +"@clerk/nextjs": minor --- Added support for JWT token format when creating and verifying machine-to-machine (M2M) tokens. This enables fully **networkless verification** when using the public JWT key. From 38d60fed7bca4cbbf0de3ed8fce0a31ce3b5f6d9 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 26 Feb 2026 19:36:51 -0800 Subject: [PATCH 31/32] chore: fix merge conflicts --- packages/backend/src/api/__tests__/M2MTokenApi.test.ts | 3 +++ packages/backend/src/api/endpoints/M2MTokenApi.ts | 7 ++----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/api/__tests__/M2MTokenApi.test.ts b/packages/backend/src/api/__tests__/M2MTokenApi.test.ts index 158fa602f25..447b919287f 100644 --- a/packages/backend/src/api/__tests__/M2MTokenApi.test.ts +++ b/packages/backend/src/api/__tests__/M2MTokenApi.test.ts @@ -474,6 +474,9 @@ describe('M2MToken', () => { const jwtToken = await createSignedM2MJwt(); await expect(m2mApi.verify({ token: jwtToken })).rejects.toThrow('Failed to resolve JWK during verification'); + }); + }); + describe('list', () => { const machineId = 'mch_1xxxxxxxxxxxxx'; const mockM2MTokenList = { diff --git a/packages/backend/src/api/endpoints/M2MTokenApi.ts b/packages/backend/src/api/endpoints/M2MTokenApi.ts index a6448a2f330..fefe0ea69cb 100644 --- a/packages/backend/src/api/endpoints/M2MTokenApi.ts +++ b/packages/backend/src/api/endpoints/M2MTokenApi.ts @@ -1,3 +1,5 @@ +import type { ClerkPaginationRequest } from '@clerk/shared/types'; + import { MachineTokenVerificationError, MachineTokenVerificationErrorCode } from '../../errors'; import { decodeJwt } from '../../jwt/verifyJwt'; import type { JwtMachineVerifyOptions } from '../../jwt/verifyMachineJwt'; @@ -6,11 +8,6 @@ import { isM2MJwt } from '../../tokens/machine'; import { joinPaths } from '../../util/path'; import { deprecated } from '../../util/shared'; import type { ClerkBackendApiRequestOptions, RequestFunction } from '../request'; -import type { ClerkPaginationRequest } from '@clerk/shared/types'; - -import { joinPaths } from '../../util/path'; -import { deprecated } from '../../util/shared'; -import type { ClerkBackendApiRequestOptions } from '../request'; import type { PaginatedResourceResponse } from '../resources/Deserializer'; import type { M2MToken } from '../resources/M2MToken'; import { AbstractAPI } from './AbstractApi'; From 7888d873c41e93e1784e248b4cb176a60be96d50 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 26 Feb 2026 19:45:07 -0800 Subject: [PATCH 32/32] fix tests --- 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 447b919287f..04af4fc8b33 100644 --- a/packages/backend/src/api/__tests__/M2MTokenApi.test.ts +++ b/packages/backend/src/api/__tests__/M2MTokenApi.test.ts @@ -142,7 +142,7 @@ describe('M2MToken', () => { }); 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']); + expect(response.scopes).toEqual(['mch_1xxxxxxxxxxxxx', 'mch_2xxxxxxxxxxxxx']); }); it('creates a jwt m2m token with custom claims and scopes', async () => { @@ -182,7 +182,7 @@ describe('M2MToken', () => { 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']); + expect(response.scopes).toEqual(['mch_1xxxxxxxxxxxxx', 'mch_2xxxxxxxxxxxxx']); }); it('creates an opaque format m2m token when explicitly specified', async () => {