diff --git a/.changeset/nasty-pianos-shop.md b/.changeset/nasty-pianos-shop.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/nasty-pianos-shop.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/backend/src/api/__tests__/M2MTokenApi.test.ts b/packages/backend/src/api/__tests__/M2MTokenApi.test.ts index b3ba1aed1c5..af17622b939 100644 --- a/packages/backend/src/api/__tests__/M2MTokenApi.test.ts +++ b/packages/backend/src/api/__tests__/M2MTokenApi.test.ts @@ -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, @@ -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' }); }); @@ -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' }); }); @@ -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', @@ -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' }); }); @@ -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' }); }); @@ -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' }); }); @@ -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); + }); + }); }); diff --git a/packages/backend/src/api/endpoints/M2MTokenApi.ts b/packages/backend/src/api/endpoints/M2MTokenApi.ts index c68db0fb355..ca9ce176ebf 100644 --- a/packages/backend/src/api/endpoints/M2MTokenApi.ts +++ b/packages/backend/src/api/endpoints/M2MTokenApi.ts @@ -1,10 +1,32 @@ +import type { ClerkPaginationRequest } from '@clerk/shared/types'; + import { joinPaths } from '../../util/path'; 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. @@ -57,6 +79,14 @@ export class M2MTokenApi extends AbstractAPI { return options; } + async list(queryParams: GetM2MTokenListParams) { + return this.request>({ + method: 'GET', + path: basePath, + queryParams, + }); + } + async createToken(params?: CreateM2MTokenParams) { const { claims = null, machineSecretKey, secondsUntilExpiration = null } = params || {}; diff --git a/packages/backend/src/api/resources/Deserializer.ts b/packages/backend/src/api/resources/Deserializer.ts index 626d5e20019..4488ff0667a 100644 --- a/packages/backend/src/api/resources/Deserializer.ts +++ b/packages/backend/src/api/resources/Deserializer.ts @@ -77,6 +77,12 @@ export function deserialize(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; @@ -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; }