diff --git a/.changeset/m2m-machine-id-verification.md b/.changeset/m2m-machine-id-verification.md new file mode 100644 index 00000000000..fed8b198c71 --- /dev/null +++ b/.changeset/m2m-machine-id-verification.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': minor +--- + +Add a `machineId` option to M2M token verification (`clerkClient.m2m.verify()`, `verifyMachineAuthToken()`, and `authenticateRequest()`). When provided, verification additionally requires the M2M token to be scoped for that machine, matching the check the Backend API performs when verifying opaque tokens with a machine secret key. Previously, JWT-format M2M tokens verified locally were only checked for a valid signature and token class, so a token minted for a different machine would still verify successfully. diff --git a/packages/backend/src/api/__tests__/M2MTokenApi.test.ts b/packages/backend/src/api/__tests__/M2MTokenApi.test.ts index 82ccbb6def5..47cf1cab31e 100644 --- a/packages/backend/src/api/__tests__/M2MTokenApi.test.ts +++ b/packages/backend/src/api/__tests__/M2MTokenApi.test.ts @@ -393,6 +393,52 @@ describe('M2MToken', () => { expect(response.claims).toEqual({ foo: 'bar' }); }); + it('verifies an opaque m2m token scoped for the provided machineId', async () => { + const apiClient = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + secretKey: 'sk_xxxxx', + }); + + server.use( + http.post( + 'https://api.clerk.test/m2m_tokens/verify', + validateHeaders(() => { + return HttpResponse.json(mockM2MToken); + }), + ), + ); + + const response = await apiClient.m2m.verify({ + token: m2mSecret, + machineId: 'mch_1xxxxxxxxxxxxx', + }); + + expect(response.id).toBe(m2mId); + }); + + it('throws when an opaque m2m token is not scoped for the provided machineId', async () => { + const apiClient = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + secretKey: 'sk_xxxxx', + }); + + server.use( + http.post( + 'https://api.clerk.test/m2m_tokens/verify', + validateHeaders(() => { + return HttpResponse.json(mockM2MToken); + }), + ), + ); + + await expect( + apiClient.m2m.verify({ + token: m2mSecret, + machineId: 'mch_not_in_scopes', + }), + ).rejects.toThrow('mch_not_in_scopes'); + }); + it('requires a machine secret or instance secret to verify a m2m token', async () => { const apiClient = createBackendApiClient({ apiUrl: 'https://api.clerk.test', @@ -475,7 +521,7 @@ describe('M2MToken', () => { }); const result = await m2mApi.verify({ token: jwtToken }); - // `aud` and `scopes` from the token are user-supplied custom claims and are + // `aud` and `scopes` are auto-added by the backend at mint time and are // preserved in `claims`; `scopes` additionally seeds the dedicated field. expect(result.claims).toEqual({ aud: ['mch_1xxxxx', 'mch_2xxxxx'], @@ -512,6 +558,45 @@ describe('M2MToken', () => { const jwtToken = await createSignedM2MJwt(); await expect(m2mApi.verify({ token: jwtToken })).rejects.toThrow('Failed to resolve JWK during verification'); }); + + it('verifies a JWT M2M token scoped for the provided machineId', 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, machineId: 'mch_1xxxxx' }); + + expect(result.subject).toBe('mch_2vYVtestTESTtestTESTtestTESTtest'); + expect(result.scopes).toContain('mch_1xxxxx'); + }); + + it('throws when a JWT M2M token is not scoped for the provided machineId', 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(); + await expect(m2mApi.verify({ token: jwtToken, machineId: 'mch_not_in_scopes' })).rejects.toThrow( + 'mch_not_in_scopes', + ); + }); }); describe('list', () => { diff --git a/packages/backend/src/api/endpoints/M2MTokenApi.ts b/packages/backend/src/api/endpoints/M2MTokenApi.ts index 4bdb5d8a85b..f3fec9c41cc 100644 --- a/packages/backend/src/api/endpoints/M2MTokenApi.ts +++ b/packages/backend/src/api/endpoints/M2MTokenApi.ts @@ -64,6 +64,8 @@ export type RevokeM2MTokenParams = { export type VerifyM2MTokenParams = { /** The custom machine secret key for authentication. If not provided, the SDK will use the value from the environment variables. */ machineSecretKey?: string; + /** The ID of the machine (`mch_...`) receiving the M2M token. When provided, verification additionally requires the token to be scoped for this machine. */ + machineId?: string; /** The M2M token to verify. */ token: string; }; @@ -172,7 +174,7 @@ export class M2MTokenApi extends AbstractAPI { return this.request(requestOptions); } - async #verifyJwtFormat(token: string): Promise { + async #verifyJwtFormat(token: string, machineId?: string): Promise { let decoded; try { const { data, errors } = decodeJwt(token); @@ -187,7 +189,7 @@ export class M2MTokenApi extends AbstractAPI { }); } - const result = await verifyM2MJwt(token, decoded, this.#verifyOptions); + const result = await verifyM2MJwt(token, decoded, { ...this.#verifyOptions, machineId }); if (result.errors) { throw result.errors[0]; } @@ -199,10 +201,10 @@ export class M2MTokenApi extends AbstractAPI { * @returns The verified [`M2MToken`](https://clerk.com/docs/reference/backend/types/backend-m2m-token) object. */ async verify(params: VerifyM2MTokenParams) { - const { token, machineSecretKey } = params; + const { token, machineSecretKey, machineId } = params; if (isM2MJwt(token)) { - return this.#verifyJwtFormat(token); + return this.#verifyJwtFormat(token, machineId); } const requestOptions = this.#createRequestOptions( @@ -214,6 +216,14 @@ export class M2MTokenApi extends AbstractAPI { machineSecretKey, ); - return this.request(requestOptions); + const m2mToken = await this.request(requestOptions); + // BAPI skips the receiver check unless machine-authenticated, so enforce it here. + if (machineId && !m2mToken.scopes.includes(machineId)) { + throw new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenInvalid, + message: `M2M token is not scoped for machine "${machineId}".`, + }); + } + return m2mToken; } } diff --git a/packages/backend/src/api/resources/M2MToken.ts b/packages/backend/src/api/resources/M2MToken.ts index 5b19fdd9603..b9e763d96be 100644 --- a/packages/backend/src/api/resources/M2MToken.ts +++ b/packages/backend/src/api/resources/M2MToken.ts @@ -14,9 +14,9 @@ type M2MJwtPayload = { // Structural claims that Clerk's machine-token service always adds when it mints // an M2M JWT. These are mapped onto dedicated `M2MToken` fields, so they are -// stripped from `claims`. Everything else is a user-supplied custom claim and is -// surfaced through `claims`, including `aud` and `scopes`, which the backend -// treats as custom claims (they are neither reserved nor auto-added). +// stripped from `claims`. The service also auto-adds `aud` (the scoped machine +// IDs) and `scopes` (space-joined) as reserved claims; those seed the dedicated +// `scopes` field but are additionally surfaced through `claims`. const M2M_RESERVED_JWT_CLAIMS = new Set(['iss', 'sub', 'exp', 'nbf', 'iat', 'jti']); /** diff --git a/packages/backend/src/api/resources/__tests__/M2MToken.test.ts b/packages/backend/src/api/resources/__tests__/M2MToken.test.ts index a0b440430f8..874d8cf3a00 100644 --- a/packages/backend/src/api/resources/__tests__/M2MToken.test.ts +++ b/packages/backend/src/api/resources/__tests__/M2MToken.test.ts @@ -29,8 +29,8 @@ describe('M2MToken', () => { expect(token.id).toBe('mt_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE'); expect(token.subject).toBe('mch_2vYVtestTESTtestTESTtestTESTtest'); expect(token.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']); - // `aud` is a user-supplied custom claim (the backend does not auto-add it), - // so it is surfaced through `claims` while also seeding the `scopes` field. + // `aud` (auto-added by the backend as the scoped machine IDs) seeds the + // `scopes` field and is also surfaced through `claims`. expect(token.claims).toEqual({ aud: ['mch_1xxxxx', 'mch_2xxxxx'] }); expect(token.revoked).toBe(false); expect(token.revocationReason).toBeNull(); @@ -56,9 +56,8 @@ describe('M2MToken', () => { const token = M2MToken.fromJwtPayload(payload); - // `aud` and `scopes` are user-supplied custom claims in Clerk-issued M2M - // tokens (the backend neither reserves nor auto-adds them), so they are - // preserved in `claims` alongside any other custom claims. + // `aud` and `scopes` are auto-added by the backend at mint time; they are + // preserved in `claims` alongside the user-supplied custom claims. expect(token.claims).toEqual({ aud: ['mch_1xxxxx'], scopes: 'scope1 scope2', diff --git a/packages/backend/src/jwt/verifyMachineJwt.ts b/packages/backend/src/jwt/verifyMachineJwt.ts index 91ac61c6a0c..f88473f6bbd 100644 --- a/packages/backend/src/jwt/verifyMachineJwt.ts +++ b/packages/backend/src/jwt/verifyMachineJwt.ts @@ -17,6 +17,8 @@ import { TokenType } from '../tokens/tokenTypes'; export type JwtMachineVerifyOptions = Pick & { jwtKey?: string; clockSkewInMs?: number; + /** When provided, verification additionally requires the token to be scoped for this machine (`mch_...`). */ + machineId?: string; }; /** @@ -109,8 +111,24 @@ export async function verifyM2MJwt( return { data: undefined, tokenType: TokenType.M2MToken, errors: [result.error] }; } + const m2mToken = M2MToken.fromJwtPayload(result.payload, options.clockSkewInMs); + + // Mirror the receiver check BAPI enforces on machine-authenticated verification. + if (options.machineId && !m2mToken.scopes.includes(options.machineId)) { + return { + data: undefined, + tokenType: TokenType.M2MToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenInvalid, + message: `M2M token is not scoped for machine "${options.machineId}".`, + }), + ], + }; + } + return { - data: M2MToken.fromJwtPayload(result.payload, options.clockSkewInMs), + data: m2mToken, tokenType: TokenType.M2MToken, errors: undefined, }; diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index c8c34fdca49..e8452997eca 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -679,6 +679,76 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(result.tokenType).toBe('m2m_token'); }); + describe('machineId scope enforcement', () => { + beforeEach(() => { + server.use( + http.get( + 'https://api.clerk.test/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), + ); + }); + + it('verifies an M2M JWT when machineId is in the token scopes', async () => { + const m2mJwt = await createSignedM2MJwt(); + + const result = await verifyMachineAuthToken(m2mJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + machineId: 'mch_1xxxxx', + }); + + expect(result.errors).toBeUndefined(); + expect(result.tokenType).toBe('m2m_token'); + expect((result.data as M2MToken).scopes).toContain('mch_1xxxxx'); + }); + + it('rejects an M2M JWT that is not scoped for the provided machineId', async () => { + const m2mJwt = await createSignedM2MJwt(); + + const result = await verifyMachineAuthToken(m2mJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + machineId: 'mch_not_in_scopes', + }); + + expect(result.data).toBeUndefined(); + expect(result.tokenType).toBe('m2m_token'); + expect(result.errors?.[0].code).toBe('token-invalid'); + expect(result.errors?.[0].message).toContain('mch_not_in_scopes'); + }); + + it('falls back to aud when the scopes claim is missing', async () => { + const { scopes: _scopes, ...payloadWithoutScopes } = mockM2MJwtPayload; + const m2mJwt = await createSignedM2MJwt(payloadWithoutScopes as typeof mockM2MJwtPayload); + + const result = await verifyMachineAuthToken(m2mJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + machineId: 'mch_2xxxxx', + }); + + expect(result.errors).toBeUndefined(); + expect(result.tokenType).toBe('m2m_token'); + }); + + it('rejects an M2M JWT without any scope claims when machineId is provided', async () => { + const { scopes: _scopes, aud: _aud, ...payloadWithoutScopeClaims } = mockM2MJwtPayload; + const m2mJwt = await createSignedM2MJwt(payloadWithoutScopeClaims as typeof mockM2MJwtPayload); + + const result = await verifyMachineAuthToken(m2mJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + machineId: 'mch_1xxxxx', + }); + + expect(result.data).toBeUndefined(); + expect(result.errors?.[0].code).toBe('token-invalid'); + }); + }); + describe.each([ ['session-token', 'cl_B7d4PD111AAA'], ['jwt-template', 'cl_B7d4PD222AAA'], diff --git a/packages/backend/src/tokens/types.ts b/packages/backend/src/tokens/types.ts index 823503a4aba..373445904b9 100644 --- a/packages/backend/src/tokens/types.ts +++ b/packages/backend/src/tokens/types.ts @@ -72,6 +72,12 @@ export type AuthenticateRequestOptions = { * This will override the Clerk secret key. */ machineSecretKey?: string; + /** + * The ID of the machine (`mch_...`) receiving machine-to-machine tokens. + * When provided, M2M token verification additionally requires the token to be + * scoped for this machine. + */ + machineId?: string; /** * Controls whether satellite apps automatically sync with the primary domain on initial page load. * diff --git a/packages/backend/src/tokens/verify.ts b/packages/backend/src/tokens/verify.ts index a255ef3c565..8cbfa46445e 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -210,11 +210,11 @@ function handleClerkAPIError( async function verifyM2MToken( token: string, - options: VerifyTokenOptions, + options: VerifyMachineAuthTokenOptions, ): Promise> { try { const client = createBackendApiClient(options); - const verifiedToken = await client.m2m.verify({ token }); + const verifiedToken = await client.m2m.verify({ token, machineId: options.machineId }); return { data: verifiedToken, tokenType: TokenType.M2MToken, errors: undefined }; } catch (err: any) { return handleClerkAPIError(TokenType.M2MToken, err, 'Machine token not found'); @@ -247,6 +247,16 @@ async function verifyAPIKey( } } +/** + * @interface + */ +export type VerifyMachineAuthTokenOptions = VerifyTokenOptions & { + /** + * The ID of the machine (`mch_...`) receiving machine-to-machine tokens. When provided, M2M token verification additionally requires the token to be scoped for this machine. + */ + machineId?: string; +}; + /** * 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. @@ -254,7 +264,7 @@ async function verifyAPIKey( * @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) { +export async function verifyMachineAuthToken(token: string, options: VerifyMachineAuthTokenOptions) { if (isJwtFormat(token)) { let decodedResult: Jwt; try {