Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/m2m-machine-id-verification.md
Original file line number Diff line number Diff line change
@@ -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.
87 changes: 86 additions & 1 deletion packages/backend/src/api/__tests__/M2MTokenApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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', () => {
Expand Down
20 changes: 15 additions & 5 deletions packages/backend/src/api/endpoints/M2MTokenApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -172,7 +174,7 @@ export class M2MTokenApi extends AbstractAPI {
return this.request<M2MToken>(requestOptions);
}

async #verifyJwtFormat(token: string): Promise<M2MToken> {
async #verifyJwtFormat(token: string, machineId?: string): Promise<M2MToken> {
let decoded;
try {
const { data, errors } = decodeJwt(token);
Expand All @@ -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];
}
Expand All @@ -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(
Expand All @@ -214,6 +216,14 @@ export class M2MTokenApi extends AbstractAPI {
machineSecretKey,
);

return this.request<M2MToken>(requestOptions);
const m2mToken = await this.request<M2MToken>(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;
}
}
6 changes: 3 additions & 3 deletions packages/backend/src/api/resources/M2MToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);

/**
Expand Down
9 changes: 4 additions & 5 deletions packages/backend/src/api/resources/__tests__/M2MToken.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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',
Expand Down
20 changes: 19 additions & 1 deletion packages/backend/src/jwt/verifyMachineJwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { TokenType } from '../tokens/tokenTypes';
export type JwtMachineVerifyOptions = Pick<LoadClerkJWKFromRemoteOptions, 'secretKey' | 'apiUrl' | 'skipJwksCache'> & {
jwtKey?: string;
clockSkewInMs?: number;
/** When provided, verification additionally requires the token to be scoped for this machine (`mch_...`). */
machineId?: string;
};

/**
Expand Down Expand Up @@ -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,
};
Expand Down
70 changes: 70 additions & 0 deletions packages/backend/src/tokens/__tests__/verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/tokens/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
16 changes: 13 additions & 3 deletions packages/backend/src/tokens/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,11 +210,11 @@ function handleClerkAPIError(

async function verifyM2MToken(
token: string,
options: VerifyTokenOptions,
options: VerifyMachineAuthTokenOptions,
): Promise<MachineTokenReturnType<M2MToken, MachineTokenVerificationError>> {
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');
Expand Down Expand Up @@ -247,14 +247,24 @@ 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.
*
* @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 {
Expand Down
Loading