Skip to content

Commit d282239

Browse files
committed
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
1 parent 8e4fa16 commit d282239

13 files changed

Lines changed: 1690 additions & 18 deletions

File tree

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
# M2M JWT Token Support Design
2+
3+
## Overview
4+
5+
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).
6+
7+
## Background
8+
9+
- **Current state**: M2M tokens only support opaque format (`mt_` prefix), verified via BAPI
10+
- **OAuth pattern**: Supports both opaque (`oat_` prefix) and JWT format (detected by `typ: at+jwt` header)
11+
- **New M2M JWT format**: Identified by `sub` claim starting with `mch_` (machine ID)
12+
13+
## Design Decisions
14+
15+
### Detection Strategy
16+
17+
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.
18+
19+
**Detection order in `getMachineTokenType()`** (optimized for performance):
20+
21+
1. `mt_` prefix OR `isM2MJwt()` → M2MToken (grouped together)
22+
2. `oat_` prefix OR `isOAuthJwt()` → OAuthToken (grouped together)
23+
3. `ak_` prefix → ApiKey
24+
25+
Prefix checks run first (fast string comparison), JWT decode only as fallback.
26+
27+
### Verification Flow
28+
29+
JWT format M2M tokens are verified locally using JWKS (same as session tokens and OAuth JWTs). Opaque tokens continue to verify via BAPI.
30+
31+
## Implementation
32+
33+
### 1. Detection Logic (`packages/backend/src/tokens/machine.ts`)
34+
35+
**New functions:**
36+
37+
```typescript
38+
export function isM2MJwt(token: string): boolean {
39+
if (!isJwtFormat(token)) {
40+
return false;
41+
}
42+
try {
43+
const { data, errors } = decodeJwt(token);
44+
return !errors && !!data && typeof data.payload.sub === 'string' && data.payload.sub.startsWith('mch_');
45+
} catch {
46+
return false;
47+
}
48+
}
49+
50+
export function isMachineJwt(token: string): boolean {
51+
return isOAuthJwt(token) || isM2MJwt(token);
52+
}
53+
```
54+
55+
**Updated functions:**
56+
57+
```typescript
58+
export function isMachineToken(token: string): boolean {
59+
return isMachineTokenByPrefix(token) || isOAuthJwt(token) || isM2MJwt(token);
60+
}
61+
62+
export function getMachineTokenType(token: string): MachineTokenType {
63+
if (token.startsWith(M2M_TOKEN_PREFIX) || isM2MJwt(token)) {
64+
return TokenType.M2MToken;
65+
}
66+
if (token.startsWith(OAUTH_TOKEN_PREFIX) || isOAuthJwt(token)) {
67+
return TokenType.OAuthToken;
68+
}
69+
if (token.startsWith(API_KEY_PREFIX)) {
70+
return TokenType.ApiKey;
71+
}
72+
throw new Error('Unknown machine token type');
73+
}
74+
```
75+
76+
### 2. M2MToken Resource (`packages/backend/src/api/resources/M2MToken.ts`)
77+
78+
**Add `fromJwtPayload()` static method:**
79+
80+
```typescript
81+
type M2MJwtPayload = JwtPayload & {
82+
jti?: string;
83+
scopes?: string;
84+
aud?: string[];
85+
};
86+
87+
static fromJwtPayload(payload: JwtPayload, clockSkewInMs = 5000): M2MToken {
88+
const m2mPayload = payload as M2MJwtPayload;
89+
90+
return new M2MToken(
91+
m2mPayload.jti ?? '',
92+
payload.sub,
93+
m2mPayload.aud ?? m2mPayload.scopes?.split(' ') ?? [],
94+
null, // claims - not extracted from JWT
95+
false, // revoked (JWT tokens can't be revoked)
96+
null, // revocationReason
97+
payload.exp * 1000 <= Date.now() - clockSkewInMs,
98+
payload.exp,
99+
payload.iat,
100+
payload.iat,
101+
);
102+
}
103+
```
104+
105+
### 3. Verification Logic (`packages/backend/src/tokens/verify.ts`)
106+
107+
**New `verifyJwtM2MToken()` function** (mirrors `verifyJwtOAuthToken()`):
108+
109+
- Decode JWT
110+
- Load JWK from PEM or remote
111+
- Verify signature
112+
- Return `M2MToken.fromJwtPayload()`
113+
114+
**Updated `verifyM2MToken()`:**
115+
116+
- If `isJwtFormat(token)` → call `verifyJwtM2MToken()`
117+
- Else → verify via BAPI (existing behavior)
118+
119+
### 4. Request Handling (`packages/backend/src/tokens/request.ts`)
120+
121+
**Update `authenticateRequestWithTokenInHeader()`:**
122+
123+
```typescript
124+
// Reject machine JWTs (OAuth/M2M) when expecting session tokens.
125+
if (isMachineJwt(tokenInHeader!)) {
126+
return signedOut({
127+
tokenType: TokenType.SessionToken,
128+
authenticateContext,
129+
reason: AuthErrorReason.TokenTypeMismatch,
130+
message: '',
131+
});
132+
}
133+
```
134+
135+
### 5. Test Fixtures (`packages/backend/src/fixtures/machine.ts`)
136+
137+
**New M2M JWT fixtures:**
138+
139+
```typescript
140+
export const mockM2MJwtPayload = {
141+
iss: 'https://clerk.m2m.example.test',
142+
sub: 'mch_2vYVtestTESTtestTESTtestTESTtest',
143+
aud: ['mch_1xxxxx', 'mch_2xxxxx'],
144+
exp: 1666648550,
145+
iat: 1666648250,
146+
nbf: 1666648240,
147+
jti: 'mt_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE',
148+
scopes: 'mch_1xxxxx mch_2xxxxx',
149+
};
150+
151+
export const mockSignedM2MJwt = '...'; // Generated and signed with signingJwks
152+
```
153+
154+
## Files to Modify
155+
156+
1. `packages/backend/src/tokens/machine.ts` - Add `isM2MJwt()`, `isMachineJwt()`, update detection functions
157+
2. `packages/backend/src/api/resources/M2MToken.ts` - Add `fromJwtPayload()` method
158+
3. `packages/backend/src/tokens/verify.ts` - Add `verifyJwtM2MToken()`, update `verifyM2MToken()`
159+
4. `packages/backend/src/tokens/request.ts` - Update to use `isMachineJwt()`
160+
5. `packages/backend/src/fixtures/machine.ts` - Add M2M JWT test fixtures
161+
6. `packages/backend/src/tokens/__tests__/machine.test.ts` - Add unit tests for new functions
162+
7. `packages/backend/src/tokens/__tests__/verify.test.ts` - Add M2M JWT verification tests
163+
164+
## Testing Plan
165+
166+
### Unit Tests (`machine.test.ts`)
167+
168+
- `isM2MJwt()`: true for JWT with `sub` starting with `mch_`, false for OAuth JWT/regular JWT/non-JWT
169+
- `isMachineJwt()`: true for both OAuth and M2M JWTs
170+
- `isMachineToken()`: true for M2M JWT
171+
- `getMachineTokenType()`: returns `m2m_token` for M2M JWT
172+
173+
### Unit Tests (`verify.test.ts`)
174+
175+
- `verifyMachineAuthToken()` with valid M2M JWT
176+
- M2M JWT with invalid signature → error
177+
- M2M JWT expired → error
178+
- M2M JWT with `alg: none` → rejected
179+
180+
### Unit Tests (`M2MToken.test.ts`)
181+
182+
- `M2MToken.fromJwtPayload()` correctly maps JWT claims
183+
184+
## Future Work
185+
186+
- **USER-4713**: Backend verification endpoint for M2M JWT tokens. Once implemented, may require updates to align with BAPI response format.
187+
188+
## Related PRs
189+
190+
- clerk_go #16849 - Internal endpoint to fetch instance's primary domain (for JWT `iss` claim)
191+
- cloudflare-workers #1579 - M2M token creation with JWT format support

0 commit comments

Comments
 (0)