Skip to content
29 changes: 29 additions & 0 deletions .changeset/shiny-apples-travel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@clerk/backend": minor
---

Add `list()` method to M2M tokens API to retrieve a list of machine-to-machine tokens for a given machine.

```ts
// Retrieve M2M tokens for a specific machine
const response = await clerkClient.m2m.list({
subject: 'mch_1xxxxxxxxxxxxx',
});

console.log(response.data); // M2MToken[]
console.log(response.totalCount); // number
```

Filter by revoked or expired tokens:

```ts
const revokedTokens = await clerkClient.m2m.list({
subject: 'mch_1xxxxxxxxxxxxx',
revoked: true,
});

const expiredTokens = await clerkClient.m2m.list({
subject: 'mch_1xxxxxxxxxxxxx',
expired: true,
});
```
205 changes: 194 additions & 11 deletions packages/backend/src/api/__tests__/M2MTokenApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import { server, validateHeaders } from '../../mock-server';
import { createBackendApiClient } from '../factory';

describe('M2MToken', () => {
const m2mId = 'mt_xxxxx';
const m2mSecret = 'mt_secret_xxxxx';
const m2mId = 'mt_1xxxxxxxxxxxxx';
const m2mSecret = 'mt_secret_1xxxxxxxxxxxxx';

const mockM2MToken = {
object: 'machine_to_machine_token',
id: m2mId,
subject: 'mch_xxxxx',
scopes: ['mch_1xxxxx', 'mch_2xxxxx'],
subject: 'mch_1xxxxxxxxxxxxx',
scopes: ['mch_1xxxxxxxxxxxxx', 'mch_2xxxxxxxxxxxxx'],
claims: { foo: 'bar' },
token: m2mSecret,
revoked: false,
Expand Down Expand Up @@ -46,7 +46,7 @@ describe('M2MToken', () => {

expect(response.id).toBe(m2mId);
expect(response.token).toBe(m2mSecret);
expect(response.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']);
expect(response.scopes).toEqual(['mch_1xxxxxxxxxxxxx', 'mch_2xxxxxxxxxxxxx']);
expect(response.claims).toEqual({ foo: 'bar' });
});

Expand All @@ -72,7 +72,7 @@ describe('M2MToken', () => {

expect(response.id).toBe(m2mId);
expect(response.token).toBe(m2mSecret);
expect(response.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']);
expect(response.scopes).toEqual(['mch_1xxxxxxxxxxxxx', 'mch_2xxxxxxxxxxxxx']);
expect(response.claims).toEqual({ foo: 'bar' });
});

Expand Down Expand Up @@ -116,8 +116,8 @@ describe('M2MToken', () => {
const mockRevokedM2MToken = {
object: 'machine_to_machine_token',
id: m2mId,
subject: 'mch_xxxxx',
scopes: ['mch_1xxxxx', 'mch_2xxxxx'],
subject: 'mch_1xxxxxxxxxxxxx',
scopes: ['mch_1xxxxxxxxxxxxx', 'mch_2xxxxxxxxxxxxx'],
claims: { foo: 'bar' },
revoked: true,
revocation_reason: 'revoked by test',
Expand Down Expand Up @@ -151,7 +151,7 @@ describe('M2MToken', () => {
expect(response.revoked).toBe(true);
expect(response.token).toBeUndefined();
expect(response.revocationReason).toBe('revoked by test');
expect(response.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']);
expect(response.scopes).toEqual(['mch_1xxxxxxxxxxxxx', 'mch_2xxxxxxxxxxxxx']);
expect(response.claims).toEqual({ foo: 'bar' });
});

Expand Down Expand Up @@ -229,7 +229,7 @@ describe('M2MToken', () => {

expect(response.id).toBe(m2mId);
expect(response.token).toBe(m2mSecret);
expect(response.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']);
expect(response.scopes).toEqual(['mch_1xxxxxxxxxxxxx', 'mch_2xxxxxxxxxxxxx']);
expect(response.claims).toEqual({ foo: 'bar' });
});

Expand All @@ -255,7 +255,7 @@ describe('M2MToken', () => {

expect(response.id).toBe(m2mId);
expect(response.token).toBe(m2mSecret);
expect(response.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']);
expect(response.scopes).toEqual(['mch_1xxxxxxxxxxxxx', 'mch_2xxxxxxxxxxxxx']);
expect(response.claims).toEqual({ foo: 'bar' });
});

Expand All @@ -282,4 +282,187 @@ describe('M2MToken', () => {
expect(errResponse.status).toBe(401);
});
});

describe('list', () => {
const machineId = 'mch_1xxxxxxxxxxxxx';
const mockM2MTokenList = {
m2m_tokens: [
{
...mockM2MToken,
id: 'mt_1xxxxxxxxxxxxx',
},
{
...mockM2MToken,
id: 'mt_2xxxxxxxxxxxxx',
revoked: true,
},
],
total_count: 2,
};

it('lists m2m tokens with machine secret key', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
machineSecretKey: 'ak_xxxxx',
});

server.use(
http.get(
'https://api.clerk.test/m2m_tokens',
validateHeaders(({ request }) => {
expect(request.headers.get('Authorization')).toBe('Bearer ak_xxxxx');
const url = new URL(request.url);
expect(url.searchParams.get('subject')).toBe(machineId);
expect(url.searchParams.get('limit')).toBe('10');
return HttpResponse.json(mockM2MTokenList);
}),
),
);

const response = await apiClient.m2m.list({
subject: machineId,
limit: 10,
});

expect(response.data).toHaveLength(2);
expect(response.data[0].id).toBe('mt_1xxxxxxxxxxxxx');
expect(response.data[1].id).toBe('mt_2xxxxxxxxxxxxx');
expect(response.totalCount).toBe(2);
});

it('lists m2m tokens with instance secret key', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
secretKey: 'sk_xxxxx',
});

server.use(
http.get(
'https://api.clerk.test/m2m_tokens',
validateHeaders(({ request }) => {
expect(request.headers.get('Authorization')).toBe('Bearer sk_xxxxx');
return HttpResponse.json(mockM2MTokenList);
}),
),
);

const response = await apiClient.m2m.list({
subject: machineId,
});

expect(response.data).toHaveLength(2);
expect(response.totalCount).toBe(2);
});

it('lists m2m tokens with revoked filter', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
secretKey: 'sk_xxxxx',
});

server.use(
http.get(
'https://api.clerk.test/m2m_tokens',
validateHeaders(({ request }) => {
const url = new URL(request.url);
expect(url.searchParams.get('revoked')).toBe('true');
return HttpResponse.json(mockM2MTokenList);
}),
),
);

const response = await apiClient.m2m.list({
subject: machineId,
revoked: true,
});

expect(response.data).toHaveLength(2);
});

it('lists m2m tokens with expired filter', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
secretKey: 'sk_xxxxx',
});

server.use(
http.get(
'https://api.clerk.test/m2m_tokens',
validateHeaders(({ request }) => {
const url = new URL(request.url);
expect(url.searchParams.get('expired')).toBe('true');
return HttpResponse.json(mockM2MTokenList);
}),
),
);

const response = await apiClient.m2m.list({
subject: machineId,
expired: true,
});

expect(response.data).toHaveLength(2);
});

it('lists m2m tokens with pagination', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
secretKey: 'sk_xxxxx',
});

server.use(
http.get(
'https://api.clerk.test/m2m_tokens',
validateHeaders(({ request }) => {
const url = new URL(request.url);
expect(url.searchParams.get('limit')).toBe('5');
expect(url.searchParams.get('offset')).toBe('10');
return HttpResponse.json(mockM2MTokenList);
}),
),
);

const response = await apiClient.m2m.list({
subject: machineId,
limit: 5,
offset: 10,
});

expect(response.data).toHaveLength(2);
expect(response.totalCount).toBe(2);
});

it('requires a machine secret or instance secret to list m2m tokens', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
});

server.use(
http.get(
'https://api.clerk.test/m2m_tokens',
validateHeaders(() => {
return HttpResponse.json(
{
errors: [
{
message: 'Unauthorized',
code: 'unauthorized',
},
],
},
{ status: 401 },
);
}),
),
);

const errResponse = await apiClient.m2m
.list({
subject: machineId,
})
.catch(err => err);

expect(errResponse.status).toBe(401);
});
});
});
30 changes: 30 additions & 0 deletions packages/backend/src/api/endpoints/M2MTokenApi.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,33 @@
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';

const basePath = '/m2m_tokens';

type GetM2MTokenListParams = ClerkPaginationRequest<{
/**
* The machine ID to query machine-to-machine tokens by
*/
subject: string;
/**
* Whether to include revoked machine-to-machine tokens.
*
* @default false
*/
revoked?: boolean;
/**
* Whether to include expired machine-to-machine tokens.
*
* @default false
*/
expired?: boolean;
}>;

type CreateM2MTokenParams = {
/**
* Custom machine secret key for authentication.
Expand Down Expand Up @@ -58,6 +80,14 @@ export class M2MTokenApi extends AbstractAPI {
return options;
}

async list(queryParams: GetM2MTokenListParams) {
return this.request<PaginatedResourceResponse<M2MToken[]>>({
method: 'GET',
path: basePath,
queryParams,
});
}

async createToken(params?: CreateM2MTokenParams) {
const { claims = null, machineSecretKey, secondsUntilExpiration = null } = params || {};

Expand Down
27 changes: 27 additions & 0 deletions packages/backend/src/api/resources/Deserializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ export function deserialize<U = any>(payload: unknown): PaginatedResourceRespons
if (Array.isArray(payload)) {
const data = payload.map(item => jsonToObject(item)) as U;
return { data };
} else if (isM2MTokenResponse(payload)) {
// Handle M2M token list responses with m2m_tokens property
data = payload.m2m_tokens.map(item => jsonToObject(item)) as U;
totalCount = payload.total_count;

return { data, totalCount };
} else if (isPaginated(payload)) {
data = payload.data.map(item => jsonToObject(item)) as U;
totalCount = payload.total_count;
Expand All @@ -95,6 +101,27 @@ function isPaginated(payload: unknown): payload is PaginatedResponseJSON {
return Array.isArray(payload.data) && payload.data !== undefined;
}

/**
* Detects M2M token list responses from the Backend API.
*
* The Clerk Backend API returns M2M token lists with `m2m_tokens` and `total_count` properties.
* This function identifies those responses and normalizes them to the standard `data` and
* `totalCount` format for consistency with other paginated API methods across the SDK.
*
* This approach avoids making breaking changes to BAPI Proxy or supporting both response
* formats. Once BAPI Proxy is updated to return the standard `data` property format,
* this function can be safely removed.
*
* @see https://clerk.com/docs/reference/backend-api/tag/m2m-tokens/get/m2m_tokens
*/
function isM2MTokenResponse(payload: unknown): payload is { m2m_tokens: unknown[]; total_count: number } {
if (!payload || typeof payload !== 'object' || !('m2m_tokens' in payload)) {
return false;
}

return Array.isArray(payload.m2m_tokens);
}

function getCount(item: PaginatedResponseJSON) {
return item.total_count;
}
Expand Down
Loading