diff --git a/.changeset/hot-tables-worry.md b/.changeset/hot-tables-worry.md new file mode 100644 index 00000000000..bbeefa22f31 --- /dev/null +++ b/.changeset/hot-tables-worry.md @@ -0,0 +1,73 @@ +--- +"@clerk/backend": minor +--- + +Adds machine-to-machine endpoints to the Backend SDK: + +### Create M2M Tokens + +A machine secret is required when creating M2M tokens. + +```ts +const clerkClient = createClerkClient({ + machineSecretKey: process.env.CLERK_MACHINE_SECRET_KEY +}) + +clerkClient.machineTokens.create({ + secondsUntilExpiration: 3600, +}) +``` + +### Revoke M2M Tokens + +You can revoke tokens using either a machine secret or instance secret: + +```ts +// Using machine secret +const clerkClient = createClerkClient({ machineSecretKey: process.env.CLERK_MACHINE_SECRET_KEY }) +clerkClient.machineTokens.revoke({ + m2mTokenId: 'mt_xxxxx', + revocationReason: 'Revoked by user', +}) + +// Using instance secret (default) +const clerkClient = createClerkClient({ secretKey: 'sk_xxxx' }) +clerkClient.machineTokens.revoke({ + m2mTokenId: 'mt_xxxxx', + revocationReason: 'Revoked by user', +}) +``` + +### Verify M2M Tokens + +You can verify tokens using either a machine secret or instance secret: + +```ts +// Using machine secret +const clerkClient = createClerkClient({ machineSecretKey: process.env.CLERK_MACHINE_SECRET_KEY }) +clerkClient.machineTokens.verifySecret({ + secret: 'mt_secret_xxxxx', +}) + +// Using instance secret (default) +const clerkClient = createClerkClient({ secretKey: 'sk_xxxx' }) +clerkClient.machineTokens.verifySecret({ + secret: 'mt_secret_xxxxx', +}) +``` + +To verify machine-to-machine tokens using when using `authenticateRequest()` with a machine secret, use the `machineSecret` option: + +```ts +const clerkClient = createClerkClient({ + machineSecretKey: process.env.CLERK_MACHINE_SECRET_KEY +}) + +const authReq = await clerkClient.authenticateRequest(c.req.raw, { + acceptsToken: 'machine_token', +}) + +if (authReq.isAuthenticated) { + // ... do something +} +``` \ No newline at end of file diff --git a/packages/backend/src/api/__tests__/MachineApi.test.ts b/packages/backend/src/api/__tests__/MachineApi.test.ts index 9b721206211..25e8594fb62 100644 --- a/packages/backend/src/api/__tests__/MachineApi.test.ts +++ b/packages/backend/src/api/__tests__/MachineApi.test.ts @@ -127,7 +127,10 @@ describe('MachineAPI', () => { validateHeaders(async ({ request }) => { const body = await request.json(); expect(body).toEqual({ name: 'Updated Machine' }); - return HttpResponse.json(mockMachine); + return HttpResponse.json({ + ...mockMachine, + name: 'Updated Machine', + }); }), ), ); @@ -135,7 +138,7 @@ describe('MachineAPI', () => { const response = await apiClient.machines.update(updateParams); expect(response.id).toBe(machineId); - expect(response.name).toBe('Test Machine'); + expect(response.name).toBe('Updated Machine'); }); it('deletes a machine', async () => { diff --git a/packages/backend/src/api/__tests__/MachineTokenApi.test.ts b/packages/backend/src/api/__tests__/MachineTokenApi.test.ts new file mode 100644 index 00000000000..7207f64c6cb --- /dev/null +++ b/packages/backend/src/api/__tests__/MachineTokenApi.test.ts @@ -0,0 +1,285 @@ +import { http, HttpResponse } from 'msw'; +import { describe, expect, it } from 'vitest'; + +import { server, validateHeaders } from '../../mock-server'; +import { createBackendApiClient } from '../factory'; + +describe('MachineTokenAPI', () => { + const m2mId = 'mt_xxxxx'; + const m2mSecret = 'mt_secret_xxxxx'; + + const mockM2MToken = { + object: 'machine_to_machine_token', + id: m2mId, + subject: 'mch_xxxxx', + scopes: ['mch_1xxxxx', 'mch_2xxxxx'], + claims: { foo: 'bar' }, + secret: m2mSecret, + revoked: false, + revocation_reason: null, + expired: false, + expiration: 1753746916590, + created_at: 1753743316590, + updated_at: 1753743316590, + }; + + describe('create', () => { + it('creates a m2m token using machine secret key in backend client', async () => { + const apiClient = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + machineSecretKey: 'ak_xxxxx', + }); + + server.use( + http.post( + 'https://api.clerk.test/m2m_tokens', + validateHeaders(({ request }) => { + expect(request.headers.get('Authorization')).toBe('Bearer ak_xxxxx'); + return HttpResponse.json(mockM2MToken); + }), + ), + ); + + const response = await apiClient.machineTokens.create({ + secondsUntilExpiration: 3600, + }); + + expect(response.id).toBe(m2mId); + expect(response.secret).toBe(m2mSecret); + expect(response.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']); + expect(response.claims).toEqual({ foo: 'bar' }); + }); + + it('creates a m2m token using machine secret key option', async () => { + const apiClient = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + }); + + server.use( + http.post( + 'https://api.clerk.test/m2m_tokens', + validateHeaders(({ request }) => { + expect(request.headers.get('Authorization')).toBe('Bearer ak_xxxxx'); + return HttpResponse.json(mockM2MToken); + }), + ), + ); + + const response = await apiClient.machineTokens.create({ + machineSecretKey: 'ak_xxxxx', + secondsUntilExpiration: 3600, + }); + + expect(response.id).toBe(m2mId); + expect(response.secret).toBe(m2mSecret); + expect(response.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']); + expect(response.claims).toEqual({ foo: 'bar' }); + }); + + it('does not accept an instance secret as authorization header', async () => { + const apiClient = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + secretKey: 'sk_xxxxx', + }); + + server.use( + http.post( + 'https://api.clerk.test/m2m_tokens', + validateHeaders(() => { + return HttpResponse.json( + { + errors: [ + { + message: + 'The provided Machine Secret Key is invalid. Make sure that your Machine Secret Key is correct.', + code: 'machine_secret_key_invalid', + }, + ], + }, + { status: 401 }, + ); + }), + ), + ); + + const errResponse = await apiClient.machineTokens.create().catch(err => err); + + expect(errResponse.status).toBe(401); + expect(errResponse.errors[0].code).toBe('machine_secret_key_invalid'); + expect(errResponse.errors[0].message).toBe( + 'The provided Machine Secret Key is invalid. Make sure that your Machine Secret Key is correct.', + ); + }); + }); + + describe('revoke', () => { + const mockRevokedM2MToken = { + object: 'machine_to_machine_token', + id: m2mId, + subject: 'mch_xxxxx', + scopes: ['mch_1xxxxx', 'mch_2xxxxx'], + claims: { foo: 'bar' }, + revoked: true, + revocation_reason: 'revoked by test', + expired: false, + expiration: 1753746916590, + created_at: 1753743316590, + updated_at: 1753743316590, + }; + + it('revokes a m2m token using machine secret', async () => { + const apiClient = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + machineSecretKey: 'ak_xxxxx', + }); + + server.use( + http.post( + `https://api.clerk.test/m2m_tokens/${m2mId}/revoke`, + validateHeaders(({ request }) => { + expect(request.headers.get('Authorization')).toBe('Bearer ak_xxxxx'); + return HttpResponse.json(mockRevokedM2MToken); + }), + ), + ); + + const response = await apiClient.machineTokens.revoke({ + m2mTokenId: m2mId, + revocationReason: 'revoked by test', + }); + + expect(response.revoked).toBe(true); + expect(response.secret).toBeUndefined(); + expect(response.revocationReason).toBe('revoked by test'); + expect(response.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']); + expect(response.claims).toEqual({ foo: 'bar' }); + }); + + it('revokes a m2m token using instance secret', async () => { + const apiClient = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + secretKey: 'sk_xxxxx', + }); + + server.use( + http.post( + `https://api.clerk.test/m2m_tokens/${m2mId}/revoke`, + validateHeaders(({ request }) => { + expect(request.headers.get('Authorization')).toBe('Bearer sk_xxxxx'); + return HttpResponse.json(mockRevokedM2MToken); + }), + ), + ); + + const response = await apiClient.machineTokens.revoke({ + m2mTokenId: m2mId, + revocationReason: 'revoked using instance secret', + }); + + expect(response.revoked).toBe(true); + expect(response.secret).toBeUndefined(); + expect(response.revocationReason).toBe('revoked by test'); + }); + + it('requires a machine secret or instance secret to revoke a m2m token', async () => { + const apiClient = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + }); + + server.use( + http.post( + `https://api.clerk.test/m2m_tokens/${m2mId}/revoke`, + validateHeaders(() => { + return HttpResponse.json(mockRevokedM2MToken); + }), + ), + ); + + const errResponse = await apiClient.machineTokens + .revoke({ + m2mTokenId: m2mId, + revocationReason: 'revoked by test', + }) + .catch(err => err); + + expect(errResponse.status).toBe(401); + }); + }); + + describe('verifySecret', () => { + it('verifies a m2m token using machine secret', async () => { + const apiClient = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + machineSecretKey: 'ak_xxxxx', + }); + + server.use( + http.post( + 'https://api.clerk.test/m2m_tokens/verify', + validateHeaders(({ request }) => { + expect(request.headers.get('Authorization')).toBe('Bearer ak_xxxxx'); + return HttpResponse.json(mockM2MToken); + }), + ), + ); + + const response = await apiClient.machineTokens.verifySecret({ + secret: m2mSecret, + }); + + expect(response.id).toBe(m2mId); + expect(response.secret).toBe(m2mSecret); + expect(response.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']); + expect(response.claims).toEqual({ foo: 'bar' }); + }); + + it('verifies a m2m token using instance secret', async () => { + const apiClient = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + secretKey: 'sk_xxxxx', + }); + + server.use( + http.post( + 'https://api.clerk.test/m2m_tokens/verify', + validateHeaders(({ request }) => { + expect(request.headers.get('Authorization')).toBe('Bearer sk_xxxxx'); + return HttpResponse.json(mockM2MToken); + }), + ), + ); + + const response = await apiClient.machineTokens.verifySecret({ + secret: m2mSecret, + }); + + expect(response.id).toBe(m2mId); + expect(response.secret).toBe(m2mSecret); + expect(response.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']); + expect(response.claims).toEqual({ foo: 'bar' }); + }); + + it('requires a machine secret or instance secret to verify a m2m token', async () => { + const apiClient = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + }); + + server.use( + http.post( + 'https://api.clerk.test/m2m_tokens/verify', + validateHeaders(() => { + return HttpResponse.json(mockM2MToken); + }), + ), + ); + + const errResponse = await apiClient.machineTokens + .verifySecret({ + secret: m2mSecret, + }) + .catch(err => err); + + expect(errResponse.status).toBe(401); + }); + }); +}); diff --git a/packages/backend/src/api/endpoints/MachineTokenApi.ts b/packages/backend/src/api/endpoints/MachineTokenApi.ts new file mode 100644 index 00000000000..91bbe16d9aa --- /dev/null +++ b/packages/backend/src/api/endpoints/MachineTokenApi.ts @@ -0,0 +1,99 @@ +import type { ClerkBackendApiRequestOptions } from '../../api/request'; +import { joinPaths } from '../../util/path'; +import type { MachineToken } from '../resources/MachineToken'; +import { AbstractAPI } from './AbstractApi'; + +const basePath = '/m2m_tokens'; + +type CreateMachineTokenParams = { + /** + * Custom machine secret key for authentication. + */ + machineSecretKey?: string; + secondsUntilExpiration?: number | null; + claims?: Record | null; +}; + +type RevokeMachineTokenParams = { + /** + * Custom machine secret key for authentication. + */ + machineSecretKey?: string; + m2mTokenId: string; + revocationReason?: string | null; +}; + +type VerifyMachineTokenParams = { + /** + * Custom machine secret key for authentication. + */ + machineSecretKey?: string; + secret: string; +}; + +export class MachineTokenApi extends AbstractAPI { + #createRequestOptions(options: ClerkBackendApiRequestOptions, machineSecretKey?: string) { + if (machineSecretKey) { + return { + ...options, + headerParams: { + Authorization: `Bearer ${machineSecretKey}`, + }, + }; + } + + return options; + } + + async create(params?: CreateMachineTokenParams) { + const { claims = null, machineSecretKey, secondsUntilExpiration = null } = params || {}; + + const requestOptions = this.#createRequestOptions( + { + method: 'POST', + path: basePath, + bodyParams: { + secondsUntilExpiration, + claims, + }, + }, + machineSecretKey, + ); + + return this.request(requestOptions); + } + + async revoke(params: RevokeMachineTokenParams) { + const { m2mTokenId, revocationReason = null, machineSecretKey } = params; + + this.requireId(m2mTokenId); + + const requestOptions = this.#createRequestOptions( + { + method: 'POST', + path: joinPaths(basePath, m2mTokenId, 'revoke'), + bodyParams: { + revocationReason, + }, + }, + machineSecretKey, + ); + + return this.request(requestOptions); + } + + async verifySecret(params: VerifyMachineTokenParams) { + const { secret, machineSecretKey } = params; + + const requestOptions = this.#createRequestOptions( + { + method: 'POST', + path: joinPaths(basePath, 'verify'), + bodyParams: { secret }, + }, + machineSecretKey, + ); + + return this.request(requestOptions); + } +} diff --git a/packages/backend/src/api/endpoints/MachineTokensApi.ts b/packages/backend/src/api/endpoints/MachineTokensApi.ts deleted file mode 100644 index 4c61f35d235..00000000000 --- a/packages/backend/src/api/endpoints/MachineTokensApi.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { joinPaths } from '../../util/path'; -import type { MachineToken } from '../resources/MachineToken'; -import { AbstractAPI } from './AbstractApi'; - -const basePath = '/m2m_tokens'; - -export class MachineTokensApi extends AbstractAPI { - async verifySecret(secret: string) { - return this.request({ - method: 'POST', - path: joinPaths(basePath, 'verify'), - bodyParams: { secret }, - }); - } -} diff --git a/packages/backend/src/api/endpoints/index.ts b/packages/backend/src/api/endpoints/index.ts index e7eeb312c68..df9984cf2b3 100644 --- a/packages/backend/src/api/endpoints/index.ts +++ b/packages/backend/src/api/endpoints/index.ts @@ -12,7 +12,7 @@ export * from './IdPOAuthAccessTokenApi'; export * from './InstanceApi'; export * from './InvitationApi'; export * from './MachineApi'; -export * from './MachineTokensApi'; +export * from './MachineTokenApi'; export * from './JwksApi'; export * from './JwtTemplatesApi'; export * from './OrganizationApi'; diff --git a/packages/backend/src/api/factory.ts b/packages/backend/src/api/factory.ts index 8c101a8d4cb..b3336e952dd 100644 --- a/packages/backend/src/api/factory.ts +++ b/packages/backend/src/api/factory.ts @@ -14,7 +14,7 @@ import { JwksAPI, JwtTemplatesApi, MachineApi, - MachineTokensApi, + MachineTokenApi, OAuthApplicationsApi, OrganizationAPI, PhoneNumberAPI, @@ -66,10 +66,12 @@ export function createBackendApiClient(options: CreateBackendApiOptions) { jwks: new JwksAPI(request), jwtTemplates: new JwtTemplatesApi(request), machines: new MachineApi(request), - machineTokens: new MachineTokensApi( + machineTokens: new MachineTokenApi( buildRequest({ ...options, skipApiVersionInUrl: true, + requireSecretKey: false, + useMachineSecretKey: true, }), ), oauthApplications: new OAuthApplicationsApi(request), diff --git a/packages/backend/src/api/request.ts b/packages/backend/src/api/request.ts index 929d216680e..09c0991c349 100644 --- a/packages/backend/src/api/request.ts +++ b/packages/backend/src/api/request.ts @@ -32,7 +32,7 @@ type ClerkBackendApiRequestOptionsBodyParams = | { bodyParams?: never; options?: { - deepSnakecaseBodyParamKeys?: never; + deepSnakecaseBodyParamKeys?: boolean; }; }; @@ -85,12 +85,24 @@ type BuildRequestOptions = { * @default false */ skipApiVersionInUrl?: boolean; + /* Machine secret key */ + machineSecretKey?: string; + /** + * If true, uses machineSecretKey for authorization instead of secretKey. + * + * Note: This is only used for machine-to-machine tokens. + * + * @default false + */ + useMachineSecretKey?: boolean; }; export function buildRequest(options: BuildRequestOptions) { const requestFn = async (requestOptions: ClerkBackendApiRequestOptions): Promise> => { const { secretKey, + machineSecretKey, + useMachineSecretKey = false, requireSecretKey = true, apiUrl = API_URL, apiVersion = API_VERSION, @@ -124,12 +136,19 @@ export function buildRequest(options: BuildRequestOptions) { // Build headers const headers = new Headers({ 'Clerk-API-Version': SUPPORTED_BAPI_VERSION, - 'User-Agent': userAgent, + [constants.Headers.UserAgent]: userAgent, ...headerParams, }); - if (secretKey) { - headers.set('Authorization', `Bearer ${secretKey}`); + // If Authorization header already exists, preserve it. + // Otherwise, use machine secret key if enabled, or fall back to regular secret key + const authorizationHeader = constants.Headers.Authorization; + if (!headers.has(authorizationHeader)) { + if (useMachineSecretKey && machineSecretKey) { + headers.set(authorizationHeader, `Bearer ${machineSecretKey}`); + } else if (secretKey) { + headers.set(authorizationHeader, `Bearer ${secretKey}`); + } } let res: Response | undefined; diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index a1e216f328d..21407ad79f6 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -714,6 +714,7 @@ export interface MachineJSON extends ClerkResourceJSON { updated_at: number; default_token_ttl: number; scoped_machines: MachineJSON[]; + secret_key?: string; } export interface MachineScopeJSON { @@ -731,7 +732,7 @@ export interface MachineSecretKeyJSON { export interface MachineTokenJSON extends ClerkResourceJSON { object: typeof ObjectType.MachineToken; - name: string; + secret?: string; subject: string; scopes: string[]; claims: Record | null; @@ -739,8 +740,6 @@ export interface MachineTokenJSON extends ClerkResourceJSON { revocation_reason: string | null; expired: boolean; expiration: number | null; - created_by: string | null; - creation_reason: string | null; created_at: number; updated_at: number; } diff --git a/packages/backend/src/api/resources/Machine.ts b/packages/backend/src/api/resources/Machine.ts index 8a096e35276..079ca8a2e7f 100644 --- a/packages/backend/src/api/resources/Machine.ts +++ b/packages/backend/src/api/resources/Machine.ts @@ -9,6 +9,7 @@ export class Machine { readonly updatedAt: number, readonly scopedMachines: Machine[], readonly defaultTokenTtl: number, + readonly secretKey?: string, ) {} static fromJSON(data: MachineJSON): Machine { @@ -31,6 +32,7 @@ export class Machine { ), ), data.default_token_ttl, + data.secret_key, ); } } diff --git a/packages/backend/src/api/resources/MachineToken.ts b/packages/backend/src/api/resources/MachineToken.ts index 1d19837bcdf..2ee4b69a69d 100644 --- a/packages/backend/src/api/resources/MachineToken.ts +++ b/packages/backend/src/api/resources/MachineToken.ts @@ -3,7 +3,6 @@ import type { MachineTokenJSON } from './JSON'; export class MachineToken { constructor( readonly id: string, - readonly name: string, readonly subject: string, readonly scopes: string[], readonly claims: Record | null, @@ -11,16 +10,14 @@ export class MachineToken { readonly revocationReason: string | null, readonly expired: boolean, readonly expiration: number | null, - readonly createdBy: string | null, - readonly creationReason: string | null, readonly createdAt: number, readonly updatedAt: number, + readonly secret?: string, ) {} - static fromJSON(data: MachineTokenJSON) { + static fromJSON(data: MachineTokenJSON): MachineToken { return new MachineToken( data.id, - data.name, data.subject, data.scopes, data.claims, @@ -28,10 +25,9 @@ export class MachineToken { data.revocation_reason, data.expired, data.expiration, - data.created_by, - data.creation_reason, data.created_at, data.updated_at, + data.secret, ); } } diff --git a/packages/backend/src/fixtures/machine.ts b/packages/backend/src/fixtures/machine.ts index b16dd04e5a3..0372853f2e3 100644 --- a/packages/backend/src/fixtures/machine.ts +++ b/packages/backend/src/fixtures/machine.ts @@ -38,15 +38,13 @@ export const mockVerificationResults = { }, machine_token: { id: 'm2m_ey966f1b1xf93586b2debdcadb0b3bd1', - name: 'my-machine-token', subject: 'mch_2vYVtestTESTtestTESTtestTESTtest', - scopes: ['read:foo', 'write:bar'], + scopes: ['mch_1xxxxx', 'mch_2xxxxx'], claims: { foo: 'bar' }, revoked: false, revocationReason: null, expired: false, expiration: null, - createdBy: null, creationReason: null, createdAt: 1745185445567, updatedAt: 1745185445567, diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 6f4e3aee673..7d072d4e841 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -11,7 +11,7 @@ import { verifyToken as _verifyToken } from './tokens/verify'; export const verifyToken = withLegacyReturn(_verifyToken); -export type ClerkOptions = CreateBackendApiOptions & +export type ClerkOptions = Omit & Partial< Pick< CreateAuthenticateRequestOptions['options'], diff --git a/packages/backend/src/tokens/__tests__/authObjects.test.ts b/packages/backend/src/tokens/__tests__/authObjects.test.ts index 64bfce39818..1f50379a2cb 100644 --- a/packages/backend/src/tokens/__tests__/authObjects.test.ts +++ b/packages/backend/src/tokens/__tests__/authObjects.test.ts @@ -365,8 +365,7 @@ describe('authenticatedMachineObject', () => { expect(authObject.tokenType).toBe('machine_token'); expect(authObject.id).toBe('m2m_ey966f1b1xf93586b2debdcadb0b3bd1'); expect(authObject.subject).toBe('mch_2vYVtestTESTtestTESTtestTESTtest'); - expect(authObject.scopes).toEqual(['read:foo', 'write:bar']); - expect(authObject.claims).toEqual({ foo: 'bar' }); + expect(authObject.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']); expect(authObject.machineId).toBe('mch_2vYVtestTESTtestTESTtestTESTtest'); }); }); @@ -377,10 +376,8 @@ describe('unauthenticatedMachineObject', () => { const authObject = unauthenticatedMachineObject('machine_token'); expect(authObject.tokenType).toBe('machine_token'); expect(authObject.id).toBeNull(); - expect(authObject.name).toBeNull(); expect(authObject.subject).toBeNull(); expect(authObject.scopes).toBeNull(); - expect(authObject.claims).toBeNull(); }); it('has() always returns false', () => { diff --git a/packages/backend/src/tokens/__tests__/authStatus.test.ts b/packages/backend/src/tokens/__tests__/authStatus.test.ts index 8f6dc1f9f2f..527d7885d44 100644 --- a/packages/backend/src/tokens/__tests__/authStatus.test.ts +++ b/packages/backend/src/tokens/__tests__/authStatus.test.ts @@ -125,9 +125,8 @@ describe('signed-out', () => { expect(token).toBeNull(); expect(signedOutAuthObject.tokenType).toBe('machine_token'); expect(signedOutAuthObject.id).toBeNull(); - expect(signedOutAuthObject.name).toBeNull(); expect(signedOutAuthObject.subject).toBeNull(); - expect(signedOutAuthObject.claims).toBeNull(); + expect(signedOutAuthObject.scopes).toBeNull(); }); }); }); diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index a2467f845f7..a0eb8f1b40e 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -1242,6 +1242,34 @@ describe('tokens.authenticateRequest(options)', () => { }); }); + test('accepts machine secret when verifying machine-to-machine token', async () => { + server.use( + http.post(mockMachineAuthResponses.machine_token.endpoint, ({ request }) => { + expect(request.headers.get('Authorization')).toBe('Bearer ak_xxxxx'); + return HttpResponse.json(mockVerificationResults.machine_token); + }), + ); + + const request = mockRequest({ authorization: `Bearer ${mockTokens.machine_token}` }); + const requestState = await authenticateRequest( + request, + mockOptions({ acceptsToken: 'machine_token', machineSecretKey: 'ak_xxxxx' }), + ); + + expect(requestState).toBeMachineAuthenticated(); + }); + + test('throws an error if acceptsToken is machine_token but machineSecretKey or secretKey is not provided', async () => { + const request = mockRequest({ authorization: `Bearer ${mockTokens.machine_token}` }); + + await expect( + authenticateRequest(request, mockOptions({ acceptsToken: 'machine_token', secretKey: undefined })), + ).rejects.toThrow( + 'Machine token authentication requires either a Machine secret key or a Clerk secret key. ' + + 'Ensure a Clerk secret key or Machine secret key is set.', + ); + }); + describe('Any Token Type Authentication', () => { test.each(tokenTypes)('accepts %s when acceptsToken is "any"', async tokenType => { const mockToken = mockTokens[tokenType]; @@ -1281,12 +1309,12 @@ describe('tokens.authenticateRequest(options)', () => { const result = await authenticateRequest(request, mockOptions({ acceptsToken: 'oauth_token' })); expect(result).toBeMachineUnauthenticated({ - tokenType: 'api_key', + tokenType: 'oauth_token', reason: AuthErrorReason.TokenTypeMismatch, message: '', }); expect(result.toAuth()).toBeMachineUnauthenticatedToAuth({ - tokenType: 'api_key', + tokenType: 'oauth_token', isAuthenticated: false, }); }); @@ -1296,12 +1324,12 @@ describe('tokens.authenticateRequest(options)', () => { const result = await authenticateRequest(request, mockOptions({ acceptsToken: 'machine_token' })); expect(result).toBeMachineUnauthenticated({ - tokenType: 'oauth_token', + tokenType: 'machine_token', reason: AuthErrorReason.TokenTypeMismatch, message: '', }); expect(result.toAuth()).toBeMachineUnauthenticatedToAuth({ - tokenType: 'oauth_token', + tokenType: 'machine_token', isAuthenticated: false, }); }); @@ -1311,12 +1339,12 @@ describe('tokens.authenticateRequest(options)', () => { const result = await authenticateRequest(request, mockOptions({ acceptsToken: 'api_key' })); expect(result).toBeMachineUnauthenticated({ - tokenType: 'machine_token', + tokenType: 'api_key', reason: AuthErrorReason.TokenTypeMismatch, message: '', }); expect(result.toAuth()).toBeMachineUnauthenticatedToAuth({ - tokenType: 'machine_token', + tokenType: 'api_key', isAuthenticated: false, }); }); diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index 3de15ff314c..a943486e25e 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -97,7 +97,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(data.claims).toEqual({ foo: 'bar' }); }); - it('verifies provided Machine token', async () => { + it('verifies provided Machine token with instance secret key', async () => { const token = 'mt_8XOIucKvqHVr5tYP123456789abcdefghij'; server.use( @@ -120,10 +120,39 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { const data = result.data as MachineToken; expect(data.id).toBe('m2m_ey966f1b1xf93586b2debdcadb0b3bd1'); - expect(data.name).toBe('my-machine-token'); expect(data.subject).toBe('mch_2vYVtestTESTtestTESTtestTESTtest'); - expect(data.scopes).toEqual(['read:foo', 'write:bar']); expect(data.claims).toEqual({ foo: 'bar' }); + expect(data.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']); + }); + + it('verifies provided Machine token with machine secret', async () => { + const token = 'mt_8XOIucKvqHVr5tYP123456789abcdefghij'; + + server.use( + http.post( + 'https://api.clerk.test/m2m_tokens/verify', + validateHeaders(({ request }) => { + expect(request.headers.get('Authorization')).toBe('Bearer ak_xxxxx'); + return HttpResponse.json(mockVerificationResults.machine_token); + }), + ), + ); + + const result = await verifyMachineAuthToken(token, { + apiUrl: 'https://api.clerk.test', + // @ts-expect-error: Machine secret key is only visible in createClerkClient() + machineSecretKey: 'ak_xxxxx', + }); + + expect(result.tokenType).toBe('machine_token'); + expect(result.data).toBeDefined(); + expect(result.errors).toBeUndefined(); + + const data = result.data as MachineToken; + expect(data.id).toBe('m2m_ey966f1b1xf93586b2debdcadb0b3bd1'); + expect(data.subject).toBe('mch_2vYVtestTESTtestTESTtestTESTtest'); + expect(data.claims).toEqual({ foo: 'bar' }); + expect(data.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']); }); it('verifies provided OAuth token', async () => { diff --git a/packages/backend/src/tokens/authObjects.ts b/packages/backend/src/tokens/authObjects.ts index 9e80332b61a..79cc800a00b 100644 --- a/packages/backend/src/tokens/authObjects.ts +++ b/packages/backend/src/tokens/authObjects.ts @@ -96,7 +96,6 @@ type MachineObjectExtendedProperties = { | { name: string; claims: Claims | null; userId: null; orgId: string } : { name: null; claims: null; userId: null; orgId: null }; machine_token: { - name: TAuthenticated extends true ? string : null; claims: TAuthenticated extends true ? Claims | null : null; machineId: TAuthenticated extends true ? string : null; }; @@ -285,7 +284,6 @@ export function authenticatedMachineObject( return { ...baseObject, tokenType, - name: result.name, claims: result.claims, scopes: result.scopes, machineId: result.subject, @@ -339,7 +337,6 @@ export function unauthenticatedMachineObject( return { ...baseObject, tokenType, - name: null, claims: null, scopes: null, machineId: null, diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index 3c0193b6fe9..e8385574866 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -6,6 +6,7 @@ import { runtime } from '../runtime'; import { assertValidPublishableKey } from '../util/optionsAssertions'; import { getCookieSuffix, getSuffixedCookieName, parsePublishableKey } from '../util/shared'; import type { ClerkRequest } from './clerkRequest'; +import { TokenType } from './tokenTypes'; import type { AuthenticateRequestOptions } from './types'; interface AuthenticateContext extends AuthenticateRequestOptions { @@ -66,14 +67,20 @@ class AuthenticateContext implements AuthenticateContext { private clerkRequest: ClerkRequest, options: AuthenticateRequestOptions, ) { - // Even though the options are assigned to this later in this function - // we set the publishableKey here because it is being used in cookies/headers/handshake-values - // as part of getMultipleAppsCookie - this.initPublishableKeyValues(options); - this.initHeaderValues(); - // initCookieValues should be used before initHandshakeValues because it depends on suffixedCookies - this.initCookieValues(); - this.initHandshakeValues(); + if (options.acceptsToken === TokenType.MachineToken || options.acceptsToken === TokenType.ApiKey) { + // For non-session tokens, we only want to set the header values. + this.initHeaderValues(); + } else { + // Even though the options are assigned to this later in this function + // we set the publishableKey here because it is being used in cookies/headers/handshake-values + // as part of getMultipleAppsCookie. + this.initPublishableKeyValues(options); + this.initHeaderValues(); + // initCookieValues should be used before initHandshakeValues because it depends on suffixedCookies + this.initCookieValues(); + this.initHandshakeValues(); + } + Object.assign(this, options); this.clerkUrl = this.clerkRequest.clerkUrl; } diff --git a/packages/backend/src/tokens/factory.ts b/packages/backend/src/tokens/factory.ts index 0bc9cf3c4ae..7f0c3916608 100644 --- a/packages/backend/src/tokens/factory.ts +++ b/packages/backend/src/tokens/factory.ts @@ -17,11 +17,13 @@ type BuildTimeOptions = Partial< | 'proxyUrl' | 'publishableKey' | 'secretKey' + | 'machineSecretKey' > >; const defaultOptions = { secretKey: '', + machineSecretKey: '', jwtKey: '', apiUrl: undefined, apiVersion: undefined, diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index a8064934a8e..e3f87ee5925 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -60,6 +60,15 @@ function assertSignInUrlFormatAndOrigin(_signInUrl: string, origin: string) { } } +function assertMachineSecretOrSecretKey(authenticateContext: AuthenticateContext) { + if (!authenticateContext.machineSecretKey && !authenticateContext.secretKey) { + throw new Error( + 'Machine token authentication requires either a Machine secret key or a Clerk secret key. ' + + 'Ensure a Clerk secret key or Machine secret key is set.', + ); + } +} + function isRequestEligibleForRefresh( err: TokenVerificationError, authenticateContext: { refreshTokenInCookie?: string }, @@ -79,8 +88,9 @@ function checkTokenTypeMismatch( ): UnauthenticatedState | null { const mismatch = !isTokenTypeAccepted(parsedTokenType, acceptsToken); if (mismatch) { + const tokenTypeToReturn = (typeof acceptsToken === 'string' ? acceptsToken : parsedTokenType) as MachineTokenType; return signedOut({ - tokenType: parsedTokenType, + tokenType: tokenTypeToReturn, authenticateContext, reason: AuthErrorReason.TokenTypeMismatch, }); @@ -139,17 +149,26 @@ export const authenticateRequest: AuthenticateRequest = (async ( options: AuthenticateRequestOptions, ): Promise | UnauthenticatedState> => { const authenticateContext = await createAuthenticateContext(createClerkRequest(request), options); - assertValidSecretKey(authenticateContext.secretKey); // Default tokenType is session_token for backwards compatibility. const acceptsToken = options.acceptsToken ?? TokenType.SessionToken; - if (authenticateContext.isSatellite) { - assertSignInUrlExists(authenticateContext.signInUrl, authenticateContext.secretKey); - if (authenticateContext.signInUrl && authenticateContext.origin) { - assertSignInUrlFormatAndOrigin(authenticateContext.signInUrl, authenticateContext.origin); + // machine-to-machine tokens can accept a machine secret or a secret key + if (acceptsToken !== TokenType.MachineToken) { + assertValidSecretKey(authenticateContext.secretKey); + + if (authenticateContext.isSatellite) { + assertSignInUrlExists(authenticateContext.signInUrl, authenticateContext.secretKey); + if (authenticateContext.signInUrl && authenticateContext.origin) { + assertSignInUrlFormatAndOrigin(authenticateContext.signInUrl, authenticateContext.origin); + } + assertProxyUrlOrDomain(authenticateContext.proxyUrl || authenticateContext.domain); } - assertProxyUrlOrDomain(authenticateContext.proxyUrl || authenticateContext.domain); + } + + // Make sure a machine secret or instance secret key is provided if acceptsToken is machine_token + if (acceptsToken === TokenType.MachineToken) { + assertMachineSecretOrSecretKey(authenticateContext); } const organizationMatcher = new OrganizationMatcher(options.organizationSyncOptions); diff --git a/packages/backend/src/tokens/types.ts b/packages/backend/src/tokens/types.ts index 2b95dfb6c23..d7a62eb2271 100644 --- a/packages/backend/src/tokens/types.ts +++ b/packages/backend/src/tokens/types.ts @@ -67,6 +67,12 @@ export type AuthenticateRequestOptions = { * @default 'session_token' */ acceptsToken?: TokenType | TokenType[] | 'any'; + /** + * The machine secret to use when verifying machine-to-machine tokens. + * This will override the Clerk secret key. + * @internal + */ + machineSecretKey?: string; } & VerifyTokenOptions; /** diff --git a/packages/backend/src/tokens/verify.ts b/packages/backend/src/tokens/verify.ts index ad76138290b..6b3fac666fc 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -202,11 +202,11 @@ function handleClerkAPIError( async function verifyMachineToken( secret: string, - options: VerifyTokenOptions, + options: VerifyTokenOptions & { machineSecretKey?: string }, ): Promise> { try { const client = createBackendApiClient(options); - const verifiedToken = await client.machineTokens.verifySecret(secret); + const verifiedToken = await client.machineTokens.verifySecret({ secret }); return { data: verifiedToken, tokenType: TokenType.MachineToken, errors: undefined }; } catch (err: any) { return handleClerkAPIError(TokenType.MachineToken, err, 'Machine token not found'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7c098b38b6..7f24b8faf43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3554,114 +3554,57 @@ packages: engines: {node: '>=18.14.0'} hasBin: true - '@next/env@14.2.30': - resolution: {integrity: sha512-KBiBKrDY6kxTQWGzKjQB7QirL3PiiOkV7KW98leHFjtVRKtft76Ra5qSA/SL75xT44dp6hOcqiiJ6iievLOYug==} - '@next/env@14.2.31': resolution: {integrity: sha512-X8VxxYL6VuezrG82h0pUA1V+DuTSJp7Nv15bxq3ivrFqZLjx81rfeHMWOE9T0jm1n3DtHGv8gdn6B0T0kr0D3Q==} - '@next/swc-darwin-arm64@14.2.30': - resolution: {integrity: sha512-EAqfOTb3bTGh9+ewpO/jC59uACadRHM6TSA9DdxJB/6gxOpyV+zrbqeXiFTDy9uV6bmipFDkfpAskeaDcO+7/g==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - '@next/swc-darwin-arm64@14.2.31': resolution: {integrity: sha512-dTHKfaFO/xMJ3kzhXYgf64VtV6MMwDs2viedDOdP+ezd0zWMOQZkxcwOfdcQeQCpouTr9b+xOqMCUXxgLizl8Q==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@14.2.30': - resolution: {integrity: sha512-TyO7Wz1IKE2kGv8dwQ0bmPL3s44EKVencOqwIY69myoS3rdpO1NPg5xPM5ymKu7nfX4oYJrpMxv8G9iqLsnL4A==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - '@next/swc-darwin-x64@14.2.31': resolution: {integrity: sha512-iSavebQgeMukUAfjfW8Fi2Iz01t95yxRl2w2wCzjD91h5In9la99QIDKcKSYPfqLjCgwz3JpIWxLG6LM/sxL4g==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@14.2.30': - resolution: {integrity: sha512-I5lg1fgPJ7I5dk6mr3qCH1hJYKJu1FsfKSiTKoYwcuUf53HWTrEkwmMI0t5ojFKeA6Vu+SfT2zVy5NS0QLXV4Q==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - '@next/swc-linux-arm64-gnu@14.2.31': resolution: {integrity: sha512-XJb3/LURg1u1SdQoopG6jDL2otxGKChH2UYnUTcby4izjM0il7ylBY5TIA7myhvHj9lG5pn9F2nR2s3i8X9awQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@14.2.30': - resolution: {integrity: sha512-8GkNA+sLclQyxgzCDs2/2GSwBc92QLMrmYAmoP2xehe5MUKBLB2cgo34Yu242L1siSkwQkiV4YLdCnjwc/Micw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - '@next/swc-linux-arm64-musl@14.2.31': resolution: {integrity: sha512-IInDAcchNCu3BzocdqdCv1bKCmUVO/bKJHnBFTeq3svfaWpOPewaLJ2Lu3GL4yV76c/86ZvpBbG/JJ1lVIs5MA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@14.2.30': - resolution: {integrity: sha512-8Ly7okjssLuBoe8qaRCcjGtcMsv79hwzn/63wNeIkzJVFVX06h5S737XNr7DZwlsbTBDOyI6qbL2BJB5n6TV/w==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - '@next/swc-linux-x64-gnu@14.2.31': resolution: {integrity: sha512-YTChJL5/9e4NXPKW+OJzsQa42RiWUNbE+k+ReHvA+lwXk+bvzTsVQboNcezWOuCD+p/J+ntxKOB/81o0MenBhw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@14.2.30': - resolution: {integrity: sha512-dBmV1lLNeX4mR7uI7KNVHsGQU+OgTG5RGFPi3tBJpsKPvOPtg9poyav/BYWrB3GPQL4dW5YGGgalwZ79WukbKQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - '@next/swc-linux-x64-musl@14.2.31': resolution: {integrity: sha512-A0JmD1y4q/9ufOGEAhoa60Sof++X10PEoiWOH0gZ2isufWZeV03NnyRlRmJpRQWGIbRkJUmBo9I3Qz5C10vx4w==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@14.2.30': - resolution: {integrity: sha512-6MMHi2Qc1Gkq+4YLXAgbYslE1f9zMGBikKMdmQRHXjkGPot1JY3n5/Qrbg40Uvbi8//wYnydPnyvNhI1DMUW1g==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - '@next/swc-win32-arm64-msvc@14.2.31': resolution: {integrity: sha512-nowJ5GbMeDOMzbTm29YqrdrD6lTM8qn2wnZfGpYMY7SZODYYpaJHH1FJXE1l1zWICHR+WfIMytlTDBHu10jb8A==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-ia32-msvc@14.2.30': - resolution: {integrity: sha512-pVZMnFok5qEX4RT59mK2hEVtJX+XFfak+/rjHpyFh7juiT52r177bfFKhnlafm0UOSldhXjj32b+LZIOdswGTg==} - engines: {node: '>= 10'} - cpu: [ia32] - os: [win32] - '@next/swc-win32-ia32-msvc@14.2.31': resolution: {integrity: sha512-pk9Bu4K0015anTS1OS9d/SpS0UtRObC+xe93fwnm7Gvqbv/W1ZbzhK4nvc96RURIQOux3P/bBH316xz8wjGSsA==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] - '@next/swc-win32-x64-msvc@14.2.30': - resolution: {integrity: sha512-4KCo8hMZXMjpTzs3HOqOGYYwAXymXIy7PEPAXNEcEOyKqkjiDlECumrWziy+JEF0Oi4ILHGxzgQ3YiMGG2t/Lg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - '@next/swc-win32-x64-msvc@14.2.31': resolution: {integrity: sha512-LwFZd4JFnMHGceItR9+jtlMm8lGLU/IPkgjBBgYmdYSfalbHCiDpjMYtgDQ2wtwiAOSJOCyFI4m8PikrsDyA6Q==} engines: {node: '>= 10'} @@ -11016,24 +10959,6 @@ packages: resolution: {integrity: sha512-Nc3loyVASW59W+8fLDZT1lncpG7llffyZ2o0UQLx/Fr20i7P8oP+lE7+TEcFvXj9IUWU6LjB9P3BH+iFGyp+mg==} engines: {node: ^14.16.0 || >=16.0.0} - next@14.2.30: - resolution: {integrity: sha512-+COdu6HQrHHFQ1S/8BBsCag61jZacmvbuL2avHvQFbWa2Ox7bE+d8FyNgxRLjXQ5wtPyQwEmk85js/AuaG2Sbg==} - engines: {node: '>=18.17.0'} - hasBin: true - peerDependencies: - '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.41.2 - react: ^18.2.0 - react-dom: ^18.2.0 - sass: ^1.3.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@playwright/test': - optional: true - sass: - optional: true - next@14.2.31: resolution: {integrity: sha512-Wyw1m4t8PhqG+or5a1U/Deb888YApC4rAez9bGhHkTsfwAy4SWKVro0GhEx4sox1856IbLhvhce2hAA6o8vkog==} engines: {node: '>=18.17.0'} @@ -18121,61 +18046,32 @@ snapshots: - rollup - supports-color - '@next/env@14.2.30': {} - '@next/env@14.2.31': {} - '@next/swc-darwin-arm64@14.2.30': - optional: true - '@next/swc-darwin-arm64@14.2.31': optional: true - '@next/swc-darwin-x64@14.2.30': - optional: true - '@next/swc-darwin-x64@14.2.31': optional: true - '@next/swc-linux-arm64-gnu@14.2.30': - optional: true - '@next/swc-linux-arm64-gnu@14.2.31': optional: true - '@next/swc-linux-arm64-musl@14.2.30': - optional: true - '@next/swc-linux-arm64-musl@14.2.31': optional: true - '@next/swc-linux-x64-gnu@14.2.30': - optional: true - '@next/swc-linux-x64-gnu@14.2.31': optional: true - '@next/swc-linux-x64-musl@14.2.30': - optional: true - '@next/swc-linux-x64-musl@14.2.31': optional: true - '@next/swc-win32-arm64-msvc@14.2.30': - optional: true - '@next/swc-win32-arm64-msvc@14.2.31': optional: true - '@next/swc-win32-ia32-msvc@14.2.30': - optional: true - '@next/swc-win32-ia32-msvc@14.2.31': optional: true - '@next/swc-win32-x64-msvc@14.2.30': - optional: true - '@next/swc-win32-x64-msvc@14.2.31': optional: true @@ -27682,33 +27578,6 @@ snapshots: p-wait-for: 5.0.2 qs: 6.14.0 - next@14.2.30(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.54.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@next/env': 14.2.30 - '@swc/helpers': 0.5.5 - busboy: 1.6.0 - caniuse-lite: 1.0.30001723 - graceful-fs: 4.2.11 - postcss: 8.4.31 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - styled-jsx: 5.1.1(@babel/core@7.28.0)(babel-plugin-macros@3.1.0)(react@18.3.1) - optionalDependencies: - '@next/swc-darwin-arm64': 14.2.30 - '@next/swc-darwin-x64': 14.2.30 - '@next/swc-linux-arm64-gnu': 14.2.30 - '@next/swc-linux-arm64-musl': 14.2.30 - '@next/swc-linux-x64-gnu': 14.2.30 - '@next/swc-linux-x64-musl': 14.2.30 - '@next/swc-win32-arm64-msvc': 14.2.30 - '@next/swc-win32-ia32-msvc': 14.2.30 - '@next/swc-win32-x64-msvc': 14.2.30 - '@opentelemetry/api': 1.9.0 - '@playwright/test': 1.54.1 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - next@14.2.31(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.54.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 14.2.31