diff --git a/.changeset/hot-tables-worry.md b/.changeset/hot-tables-worry.md new file mode 100644 index 00000000000..90e7fec86b6 --- /dev/null +++ b/.changeset/hot-tables-worry.md @@ -0,0 +1,83 @@ +--- +"@clerk/backend": minor +--- + +Adds machine-to-machine endpoints to the Backend SDK: + +**Note:** Renamed from "machine_tokens" to "m2m_tokens" for clarity and consistency with canonical terminology. This avoids confusion with other machine-related concepts like machine secrets. + +### Create M2M Tokens + +A machine secret is required when creating M2M tokens. + +```ts +const clerkClient = createClerkClient({ + machineSecretKey: process.env.CLERK_MACHINE_SECRET_KEY +}) + +clerkClient.m2mTokens.create({ + // or pass as an option here + // machineSecretKey: process.env.CLERK_MACHINE_SECRET_KEY + 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.m2mTokens.revoke({ + // or pass as an option here + // machineSecretKey: process.env.CLERK_MACHINE_SECRET_KEY + m2mTokenId: 'mt_xxxxx', + revocationReason: 'Revoked by user', +}) + +// Using instance secret (default) +const clerkClient = createClerkClient({ secretKey: 'sk_xxxx' }) +clerkClient.m2mTokens.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.m2mTokens.verifySecret({ + // or pass as an option here + // machineSecretKey: process.env.CLERK_MACHINE_SECRET_KEY + secret: 'mt_secret_xxxxx', +}) + +// Using instance secret (default) +const clerkClient = createClerkClient({ secretKey: 'sk_xxxx' }) +clerkClient.m2mTokens.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, { + // or pass as an option here + // machineSecretKey: process.env.CLERK_MACHINE_SECRET_KEY + acceptsToken: 'm2m_token', // previously machine_token +}) + +if (authReq.isAuthenticated) { + // ... do something +} +``` diff --git a/.changeset/pink-countries-hunt.md b/.changeset/pink-countries-hunt.md new file mode 100644 index 00000000000..2f1e0154a3d --- /dev/null +++ b/.changeset/pink-countries-hunt.md @@ -0,0 +1,18 @@ +--- +"@clerk/astro": patch +"@clerk/express": patch +"@clerk/fastify": patch +"@clerk/nextjs": patch +"@clerk/nuxt": patch +"@clerk/react-router": patch +"@clerk/remix": patch +"@clerk/tanstack-react-start": patch +--- + +Add ability to define a machine secret key to Clerk BAPI client function + +```ts +const clerkClient = createClerkClient({ machineSecretKey: 'ak_xxxxx' }) + +clerkClient.m2mTokens.create({...}) +``` diff --git a/packages/astro/src/env.d.ts b/packages/astro/src/env.d.ts index 410177d713e..601eb6b7891 100644 --- a/packages/astro/src/env.d.ts +++ b/packages/astro/src/env.d.ts @@ -11,6 +11,7 @@ interface InternalEnv { readonly CLERK_API_VERSION?: string; readonly CLERK_JWT_KEY?: string; readonly CLERK_SECRET_KEY?: string; + readonly CLERK_MACHINE_SECRET_KEY?: string; readonly PUBLIC_CLERK_DOMAIN?: string; readonly PUBLIC_CLERK_IS_SATELLITE?: string; readonly PUBLIC_CLERK_PROXY_URL?: string; diff --git a/packages/astro/src/integration/create-integration.ts b/packages/astro/src/integration/create-integration.ts index 5dd12008f2d..4c23c723154 100644 --- a/packages/astro/src/integration/create-integration.ts +++ b/packages/astro/src/integration/create-integration.ts @@ -190,6 +190,7 @@ function createClerkEnvSchema() { PUBLIC_CLERK_TELEMETRY_DISABLED: envField.boolean({ context: 'client', access: 'public', optional: true }), PUBLIC_CLERK_TELEMETRY_DEBUG: envField.boolean({ context: 'client', access: 'public', optional: true }), CLERK_SECRET_KEY: envField.string({ context: 'server', access: 'secret' }), + CLERK_MACHINE_SECRET_KEY: envField.string({ context: 'server', access: 'secret', optional: true }), CLERK_JWT_KEY: envField.string({ context: 'server', access: 'secret', optional: true }), }; } diff --git a/packages/astro/src/server/clerk-client.ts b/packages/astro/src/server/clerk-client.ts index 03b011e8f3e..10b53c03a27 100644 --- a/packages/astro/src/server/clerk-client.ts +++ b/packages/astro/src/server/clerk-client.ts @@ -8,6 +8,7 @@ type CreateClerkClientWithOptions = (context: APIContext, options?: ClerkOptions const createClerkClientWithOptions: CreateClerkClientWithOptions = (context, options) => createClerkClient({ secretKey: getSafeEnv(context).sk, + machineSecretKey: getSafeEnv(context).machineSecretKey, publishableKey: getSafeEnv(context).pk, apiUrl: getSafeEnv(context).apiUrl, apiVersion: getSafeEnv(context).apiVersion, diff --git a/packages/astro/src/server/get-safe-env.ts b/packages/astro/src/server/get-safe-env.ts index ee890f95095..c1b21574f8d 100644 --- a/packages/astro/src/server/get-safe-env.ts +++ b/packages/astro/src/server/get-safe-env.ts @@ -27,6 +27,7 @@ function getSafeEnv(context: ContextOrLocals) { proxyUrl: getContextEnvVar('PUBLIC_CLERK_PROXY_URL', context), pk: getContextEnvVar('PUBLIC_CLERK_PUBLISHABLE_KEY', context), sk: getContextEnvVar('CLERK_SECRET_KEY', context), + machineSecretKey: getContextEnvVar('CLERK_MACHINE_SECRET_KEY', context), signInUrl: getContextEnvVar('PUBLIC_CLERK_SIGN_IN_URL', context), signUpUrl: getContextEnvVar('PUBLIC_CLERK_SIGN_UP_URL', context), clerkJsUrl: getContextEnvVar('PUBLIC_CLERK_JS_URL', context), diff --git a/packages/backend/src/api/__tests__/M2MTokenApi.test.ts b/packages/backend/src/api/__tests__/M2MTokenApi.test.ts new file mode 100644 index 00000000000..c73a49fa1f4 --- /dev/null +++ b/packages/backend/src/api/__tests__/M2MTokenApi.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('M2MToken', () => { + 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.m2mTokens.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.m2mTokens.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.m2mTokens.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.m2mTokens.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.m2mTokens.revoke({ + m2mTokenId: m2mId, + revocationReason: 'revoked by test', + }); + + 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.m2mTokens + .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.m2mTokens.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.m2mTokens.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.m2mTokens + .verifySecret({ + secret: m2mSecret, + }) + .catch(err => err); + + expect(errResponse.status).toBe(401); + }); + }); +}); 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__/factory.test.ts b/packages/backend/src/api/__tests__/factory.test.ts index 7ffb78b43cc..fdeaae4eea2 100644 --- a/packages/backend/src/api/__tests__/factory.test.ts +++ b/packages/backend/src/api/__tests__/factory.test.ts @@ -302,4 +302,133 @@ describe('api.client', () => { ); }); }); + + describe('Authorization header', () => { + it('preserves existing Authorization header when provided in headerParams', async () => { + const apiClient = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + secretKey: 'sk_test_xxx', + machineSecretKey: 'ak_test_xxx', + useMachineSecretKey: true, + }); + + server.use( + http.post( + 'https://api.clerk.test/m2m_tokens/verify', + validateHeaders(({ request }) => { + expect(request.headers.get('Authorization')).toBe('Bearer ak_test_in_header_params'); + return HttpResponse.json({ + object: 'machine_to_machine_token', + id: 'mt_test', + }); + }), + ), + ); + + const response = await apiClient.m2mTokens.verifySecret({ + machineSecretKey: 'ak_test_in_header_params', // this will be added to headerParams.Authorization + secret: 'mt_secret_test', + }); + expect(response.id).toBe('mt_test'); + }); + + it('uses machine secret key when useMachineSecretKey is true and no existing Authorization header', async () => { + const apiClient = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + secretKey: 'sk_test_123', + machineSecretKey: 'ak_test_xxx', + useMachineSecretKey: true, + }); + + server.use( + http.post( + 'https://api.clerk.test/m2m_tokens/verify', + validateHeaders(({ request }) => { + expect(request.headers.get('Authorization')).toBe('Bearer ak_test_xxx'); + return HttpResponse.json({ + object: 'machine_to_machine_token', + id: 'mt_test', + }); + }), + ), + ); + + const response = await apiClient.m2mTokens.verifySecret({ + secret: 'mt_secret_test', + }); + expect(response.id).toBe('mt_test'); + }); + + it('falls back to secret key when useMachineSecretKey is false (default)', async () => { + const apiClient = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + secretKey: 'sk_test_xxx', + machineSecretKey: 'ak_test_xxx', + // useMachineSecretKey: false, this is default + }); + + server.use( + http.get( + `https://api.clerk.test/v1/users/user_deadbeef`, + validateHeaders(({ request }) => { + expect(request.headers.get('Authorization')).toBe('Bearer sk_test_xxx'); + return HttpResponse.json(userJson); + }), + ), + ); + + const response = await apiClient.users.getUser('user_deadbeef'); + + expect(response.id).toBe('user_cafebabe'); + }); + + it('falls back to secret key when machineSecretKey is not provided', async () => { + const apiClient = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + secretKey: 'sk_test_xxx', + useMachineSecretKey: true, + }); + + server.use( + http.get( + `https://api.clerk.test/v1/users/user_deadbeef`, + validateHeaders(({ request }) => { + expect(request.headers.get('Authorization')).toBe('Bearer sk_test_xxx'); + return HttpResponse.json(userJson); + }), + ), + ); + + const response = await apiClient.users.getUser('user_deadbeef'); + + expect(response.id).toBe('user_cafebabe'); + }); + + it('prioritizes machine secret key over secret key when both are provided and useMachineSecretKey is true', async () => { + const apiClient = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + secretKey: 'sk_test_xxx', + machineSecretKey: 'ak_test_xxx', + useMachineSecretKey: true, + }); + + server.use( + http.post( + 'https://api.clerk.test/m2m_tokens/verify', + validateHeaders(({ request }) => { + expect(request.headers.get('Authorization')).toBe('Bearer ak_test_xxx'); + return HttpResponse.json({ + object: 'machine_to_machine_token', + id: 'mt_test', + }); + }), + ), + ); + + const response = await apiClient.m2mTokens.verifySecret({ + secret: 'mt_secret_test', + }); + expect(response.id).toBe('mt_test'); + }); + }); }); diff --git a/packages/backend/src/api/endpoints/M2MTokenApi.ts b/packages/backend/src/api/endpoints/M2MTokenApi.ts new file mode 100644 index 00000000000..6bd1f8f9151 --- /dev/null +++ b/packages/backend/src/api/endpoints/M2MTokenApi.ts @@ -0,0 +1,106 @@ +import { joinPaths } from '../../util/path'; +import type { ClerkBackendApiRequestOptions } from '../request'; +import type { M2MToken } from '../resources/M2MToken'; +import { AbstractAPI } from './AbstractApi'; + +const basePath = '/m2m_tokens'; + +type CreateM2MTokenParams = { + /** + * Custom machine secret key for authentication. + */ + machineSecretKey?: string; + secondsUntilExpiration?: number | null; + claims?: Record | null; +}; + +type RevokeM2MTokenParams = { + /** + * Custom machine secret key for authentication. + */ + machineSecretKey?: string; + /** + * Machine-to-machine token ID to revoke. + */ + m2mTokenId: string; + revocationReason?: string | null; +}; + +type VerifyM2MTokenParams = { + /** + * Custom machine secret key for authentication. + */ + machineSecretKey?: string; + /** + * Machine-to-machine token secret to verify. + */ + secret: string; +}; + +export class M2MTokenApi extends AbstractAPI { + #createRequestOptions(options: ClerkBackendApiRequestOptions, machineSecretKey?: string) { + if (machineSecretKey) { + return { + ...options, + headerParams: { + ...options.headerParams, + Authorization: `Bearer ${machineSecretKey}`, + }, + }; + } + + return options; + } + + async create(params?: CreateM2MTokenParams) { + 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: RevokeM2MTokenParams) { + 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: VerifyM2MTokenParams) { + 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..c03875a427d 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 './M2MTokenApi'; 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..db0b1cfeac1 100644 --- a/packages/backend/src/api/factory.ts +++ b/packages/backend/src/api/factory.ts @@ -13,8 +13,8 @@ import { InvitationAPI, JwksAPI, JwtTemplatesApi, + M2MTokenApi, MachineApi, - MachineTokensApi, 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( + m2mTokens: new M2MTokenApi( 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..2e42e10f10c 100644 --- a/packages/backend/src/api/request.ts +++ b/packages/backend/src/api/request.ts @@ -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/Deserializer.ts b/packages/backend/src/api/resources/Deserializer.ts index d18bf76c039..72bfbc59ce6 100644 --- a/packages/backend/src/api/resources/Deserializer.ts +++ b/packages/backend/src/api/resources/Deserializer.ts @@ -15,10 +15,10 @@ import { InstanceSettings, Invitation, JwtTemplate, + M2MToken, Machine, MachineScope, MachineSecretKey, - MachineToken, OauthAccessToken, OAuthApplication, Organization, @@ -141,8 +141,8 @@ function jsonToObject(item: any): any { return MachineScope.fromJSON(item); case ObjectType.MachineSecretKey: return MachineSecretKey.fromJSON(item); - case ObjectType.MachineToken: - return MachineToken.fromJSON(item); + case ObjectType.M2MToken: + return M2MToken.fromJSON(item); case ObjectType.OauthAccessToken: return OauthAccessToken.fromJSON(item); case ObjectType.OAuthApplication: diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index a1e216f328d..2bfde1520e9 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -37,7 +37,7 @@ export const ObjectType = { Machine: 'machine', MachineScope: 'machine_scope', MachineSecretKey: 'machine_secret_key', - MachineToken: 'machine_to_machine_token', + M2MToken: 'machine_to_machine_token', JwtTemplate: 'jwt_template', OauthAccessToken: 'oauth_access_token', IdpOAuthAccessToken: 'clerk_idp_oauth_access_token', @@ -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 { @@ -729,9 +730,9 @@ export interface MachineSecretKeyJSON { secret: string; } -export interface MachineTokenJSON extends ClerkResourceJSON { - object: typeof ObjectType.MachineToken; - name: string; +export interface M2MTokenJSON extends ClerkResourceJSON { + object: typeof ObjectType.M2MToken; + 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/MachineToken.ts b/packages/backend/src/api/resources/M2MToken.ts similarity index 64% rename from packages/backend/src/api/resources/MachineToken.ts rename to packages/backend/src/api/resources/M2MToken.ts index 1d19837bcdf..97358e79745 100644 --- a/packages/backend/src/api/resources/MachineToken.ts +++ b/packages/backend/src/api/resources/M2MToken.ts @@ -1,9 +1,8 @@ -import type { MachineTokenJSON } from './JSON'; +import type { M2MTokenJSON } from './JSON'; -export class MachineToken { +export class M2MToken { 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) { - return new MachineToken( + static fromJSON(data: M2MTokenJSON): M2MToken { + return new M2MToken( 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/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/index.ts b/packages/backend/src/api/resources/index.ts index 0e7cb401791..566efbdebbf 100644 --- a/packages/backend/src/api/resources/index.ts +++ b/packages/backend/src/api/resources/index.ts @@ -33,7 +33,7 @@ export * from './JSON'; export * from './Machine'; export * from './MachineScope'; export * from './MachineSecretKey'; -export * from './MachineToken'; +export * from './M2MToken'; export * from './JwtTemplate'; export * from './OauthAccessToken'; export * from './OAuthApplication'; diff --git a/packages/backend/src/fixtures/machine.ts b/packages/backend/src/fixtures/machine.ts index b16dd04e5a3..170ba7eec34 100644 --- a/packages/backend/src/fixtures/machine.ts +++ b/packages/backend/src/fixtures/machine.ts @@ -1,7 +1,7 @@ export const mockTokens = { api_key: 'ak_LCWGdaM8mv8K4PC/57IICZQXAeWfCgF30DZaFXHoGn9=', oauth_token: 'oat_8XOIucKvqHVr5tYP123456789abcdefghij', - machine_token: 'mt_8XOIucKvqHVr5tYP123456789abcdefghij', + m2m_token: 'mt_8XOIucKvqHVr5tYP123456789abcdefghij', } as const; export const mockVerificationResults = { @@ -36,17 +36,15 @@ export const mockVerificationResults = { createdAt: 1744928754551, updatedAt: 1744928754551, }, - machine_token: { + m2m_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, @@ -62,7 +60,7 @@ export const mockMachineAuthResponses = { endpoint: 'https://api.clerk.test/oauth_applications/access_tokens/verify', errorMessage: 'OAuth token not found', }, - machine_token: { + m2m_token: { endpoint: 'https://api.clerk.test/m2m_tokens/verify', errorMessage: 'Machine token not found', }, 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..2df02870852 100644 --- a/packages/backend/src/tokens/__tests__/authObjects.test.ts +++ b/packages/backend/src/tokens/__tests__/authObjects.test.ts @@ -346,27 +346,26 @@ describe('authenticatedMachineObject', () => { describe('Machine Token authentication', () => { const debugData = { foo: 'bar' }; - const token = mockTokens.machine_token; - const verificationResult = mockVerificationResults.machine_token; + const token = mockTokens.m2m_token; + const verificationResult = mockVerificationResults.m2m_token; it('getToken returns the token passed in', async () => { - const authObject = authenticatedMachineObject('machine_token', token, verificationResult, debugData); + const authObject = authenticatedMachineObject('m2m_token', token, verificationResult, debugData); const retrievedToken = await authObject.getToken(); expect(retrievedToken).toBe(token); }); it('has() always returns false', () => { - const authObject = authenticatedMachineObject('machine_token', token, verificationResult, debugData); + const authObject = authenticatedMachineObject('m2m_token', token, verificationResult, debugData); expect(authObject.has({})).toBe(false); }); it('properly initializes properties', () => { - const authObject = authenticatedMachineObject('machine_token', token, verificationResult, debugData); - expect(authObject.tokenType).toBe('machine_token'); + const authObject = authenticatedMachineObject('m2m_token', token, verificationResult, debugData); + expect(authObject.tokenType).toBe('m2m_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'); }); }); @@ -374,22 +373,20 @@ describe('authenticatedMachineObject', () => { describe('unauthenticatedMachineObject', () => { it('properly initializes properties', () => { - const authObject = unauthenticatedMachineObject('machine_token'); - expect(authObject.tokenType).toBe('machine_token'); + const authObject = unauthenticatedMachineObject('m2m_token'); + expect(authObject.tokenType).toBe('m2m_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', () => { - const authObject = unauthenticatedMachineObject('machine_token'); + const authObject = unauthenticatedMachineObject('m2m_token'); expect(authObject.has({})).toBe(false); }); it('getToken always returns null ', async () => { - const authObject = unauthenticatedMachineObject('machine_token'); + const authObject = unauthenticatedMachineObject('m2m_token'); const retrievedToken = await authObject.getToken(); expect(retrievedToken).toBeNull(); }); @@ -413,7 +410,7 @@ describe('getAuthObjectForAcceptedToken', () => { it('returns InvalidTokenAuthObject if acceptsToken is array and token type does not match', () => { const result = getAuthObjectForAcceptedToken({ authObject: machineAuth, - acceptsToken: ['machine_token', 'oauth_token'], + acceptsToken: ['m2m_token', 'oauth_token'], }); expect((result as InvalidTokenAuthObject).tokenType).toBeNull(); expect((result as InvalidTokenAuthObject).isAuthenticated).toBe(false); @@ -432,9 +429,9 @@ describe('getAuthObjectForAcceptedToken', () => { }); it('returns unauthenticated object for requested type if acceptsToken is a single value and does not match', () => { - const result = getAuthObjectForAcceptedToken({ authObject: machineAuth, acceptsToken: 'machine_token' }); - expect((result as UnauthenticatedMachineObject<'machine_token'>).tokenType).toBe('machine_token'); - expect((result as UnauthenticatedMachineObject<'machine_token'>).id).toBeNull(); + const result = getAuthObjectForAcceptedToken({ authObject: machineAuth, acceptsToken: 'm2m_token' }); + expect((result as UnauthenticatedMachineObject<'m2m_token'>).tokenType).toBe('m2m_token'); + expect((result as UnauthenticatedMachineObject<'m2m_token'>).id).toBeNull(); }); }); diff --git a/packages/backend/src/tokens/__tests__/authStatus.test.ts b/packages/backend/src/tokens/__tests__/authStatus.test.ts index 8f6dc1f9f2f..ba301bdfb05 100644 --- a/packages/backend/src/tokens/__tests__/authStatus.test.ts +++ b/packages/backend/src/tokens/__tests__/authStatus.test.ts @@ -115,7 +115,7 @@ describe('signed-out', () => { it('returns an unauthenticated machine object with toAuth()', async () => { const signedOutAuthObject = signedOut({ - tokenType: 'machine_token', + tokenType: 'm2m_token', authenticateContext: {} as AuthenticateContext, reason: 'auth-reason', message: 'auth-message', @@ -123,11 +123,11 @@ describe('signed-out', () => { const token = await signedOutAuthObject.getToken(); expect(token).toBeNull(); - expect(signedOutAuthObject.tokenType).toBe('machine_token'); + expect(signedOutAuthObject.tokenType).toBe('m2m_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__/getAuth.test-d.ts b/packages/backend/src/tokens/__tests__/getAuth.test-d.ts index 13e1a8873e7..5398ed8adf8 100644 --- a/packages/backend/src/tokens/__tests__/getAuth.test-d.ts +++ b/packages/backend/src/tokens/__tests__/getAuth.test-d.ts @@ -17,15 +17,15 @@ test('infers the correct AuthObject type for each accepted token type', () => { // Individual token types expectTypeOf(getAuth(request, { acceptsToken: 'session_token' })).toMatchTypeOf(); expectTypeOf(getAuth(request, { acceptsToken: 'api_key' })).toMatchTypeOf>(); - expectTypeOf(getAuth(request, { acceptsToken: 'machine_token' })).toMatchTypeOf>(); + expectTypeOf(getAuth(request, { acceptsToken: 'm2m_token' })).toMatchTypeOf>(); expectTypeOf(getAuth(request, { acceptsToken: 'oauth_token' })).toMatchTypeOf>(); // Array of token types - expectTypeOf(getAuth(request, { acceptsToken: ['session_token', 'machine_token'] })).toMatchTypeOf< - SessionAuthObject | MachineAuthObject<'machine_token'> | InvalidTokenAuthObject + expectTypeOf(getAuth(request, { acceptsToken: ['session_token', 'm2m_token'] })).toMatchTypeOf< + SessionAuthObject | MachineAuthObject<'m2m_token'> | InvalidTokenAuthObject >(); - expectTypeOf(getAuth(request, { acceptsToken: ['machine_token', 'oauth_token'] })).toMatchTypeOf< - MachineAuthObject<'machine_token' | 'oauth_token'> | InvalidTokenAuthObject + expectTypeOf(getAuth(request, { acceptsToken: ['m2m_token', 'oauth_token'] })).toMatchTypeOf< + MachineAuthObject<'m2m_token' | 'oauth_token'> | InvalidTokenAuthObject >(); // Any token type @@ -41,8 +41,8 @@ test('verifies discriminated union works correctly with acceptsToken: any', () = expectTypeOf(auth).toMatchTypeOf(); } else if (auth.tokenType === 'api_key') { expectTypeOf(auth).toMatchTypeOf>(); - } else if (auth.tokenType === 'machine_token') { - expectTypeOf(auth).toMatchTypeOf>(); + } else if (auth.tokenType === 'm2m_token') { + expectTypeOf(auth).toMatchTypeOf>(); } else if (auth.tokenType === 'oauth_token') { expectTypeOf(auth).toMatchTypeOf>(); } diff --git a/packages/backend/src/tokens/__tests__/machine.test.ts b/packages/backend/src/tokens/__tests__/machine.test.ts index fcff9e566b2..57b0a3e7893 100644 --- a/packages/backend/src/tokens/__tests__/machine.test.ts +++ b/packages/backend/src/tokens/__tests__/machine.test.ts @@ -35,8 +35,8 @@ describe('isMachineToken', () => { }); describe('getMachineTokenType', () => { - it('returns "machine_token" for tokens with M2M prefix', () => { - expect(getMachineTokenType(`${M2M_TOKEN_PREFIX}some-token-value`)).toBe('machine_token'); + it('returns "m2m_token" for tokens with M2M prefix', () => { + expect(getMachineTokenType(`${M2M_TOKEN_PREFIX}some-token-value`)).toBe('m2m_token'); }); it('returns "oauth_token" for tokens with OAuth prefix', () => { @@ -65,25 +65,25 @@ describe('getMachineTokenType', () => { describe('isTokenTypeAccepted', () => { it('accepts any token type', () => { expect(isTokenTypeAccepted('api_key', 'any')).toBe(true); - expect(isTokenTypeAccepted('machine_token', 'any')).toBe(true); + expect(isTokenTypeAccepted('m2m_token', 'any')).toBe(true); expect(isTokenTypeAccepted('oauth_token', 'any')).toBe(true); expect(isTokenTypeAccepted('session_token', 'any')).toBe(true); }); it('accepts a list of token types', () => { - expect(isTokenTypeAccepted('api_key', ['api_key', 'machine_token'])).toBe(true); - expect(isTokenTypeAccepted('session_token', ['api_key', 'machine_token'])).toBe(false); + expect(isTokenTypeAccepted('api_key', ['api_key', 'm2m_token'])).toBe(true); + expect(isTokenTypeAccepted('session_token', ['api_key', 'm2m_token'])).toBe(false); }); it('rejects a mismatching token type', () => { - expect(isTokenTypeAccepted('api_key', 'machine_token')).toBe(false); + expect(isTokenTypeAccepted('api_key', 'm2m_token')).toBe(false); }); }); describe('isMachineTokenType', () => { it('returns true for machine token types', () => { expect(isMachineTokenType('api_key')).toBe(true); - expect(isMachineTokenType('machine_token')).toBe(true); + expect(isMachineTokenType('m2m_token')).toBe(true); expect(isMachineTokenType('oauth_token')).toBe(true); }); diff --git a/packages/backend/src/tokens/__tests__/request.test-d.ts b/packages/backend/src/tokens/__tests__/request.test-d.ts index 8825ed00c49..ec80f32b421 100644 --- a/packages/backend/src/tokens/__tests__/request.test-d.ts +++ b/packages/backend/src/tokens/__tests__/request.test-d.ts @@ -16,17 +16,17 @@ test('returns the correct `authenticateRequest()` return type for each accepted expectTypeOf(authenticateRequest(request, { acceptsToken: 'api_key' })).toMatchTypeOf< Promise> >(); - expectTypeOf(authenticateRequest(request, { acceptsToken: 'machine_token' })).toMatchTypeOf< - Promise> + expectTypeOf(authenticateRequest(request, { acceptsToken: 'm2m_token' })).toMatchTypeOf< + Promise> >(); expectTypeOf(authenticateRequest(request, { acceptsToken: 'oauth_token' })).toMatchTypeOf< Promise> >(); // Array of token types - expectTypeOf( - authenticateRequest(request, { acceptsToken: ['session_token', 'api_key', 'machine_token'] }), - ).toMatchTypeOf>>(); + expectTypeOf(authenticateRequest(request, { acceptsToken: ['session_token', 'api_key', 'm2m_token'] })).toMatchTypeOf< + Promise> + >(); // Any token type expectTypeOf(authenticateRequest(request, { acceptsToken: 'any' })).toMatchTypeOf>>(); diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index a2467f845f7..426f1e90aaf 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -1200,7 +1200,7 @@ describe('tokens.authenticateRequest(options)', () => { }); // Test each token type with parameterized tests - const tokenTypes = [TokenType.ApiKey, TokenType.OAuthToken, TokenType.MachineToken]; + const tokenTypes = [TokenType.ApiKey, TokenType.OAuthToken, TokenType.M2MToken]; describe.each(tokenTypes)('%s Authentication', tokenType => { const mockToken = mockTokens[tokenType]; @@ -1242,6 +1242,34 @@ describe('tokens.authenticateRequest(options)', () => { }); }); + test('accepts machine secret when verifying machine-to-machine token', async () => { + server.use( + http.post(mockMachineAuthResponses.m2m_token.endpoint, ({ request }) => { + expect(request.headers.get('Authorization')).toBe('Bearer ak_xxxxx'); + return HttpResponse.json(mockVerificationResults.m2m_token); + }), + ); + + const request = mockRequest({ authorization: `Bearer ${mockTokens.m2m_token}` }); + const requestState = await authenticateRequest( + request, + mockOptions({ acceptsToken: 'm2m_token', machineSecretKey: 'ak_xxxxx' }), + ); + + expect(requestState).toBeMachineAuthenticated(); + }); + + test('throws an error if acceptsToken is m2m_token but machineSecretKey or secretKey is not provided', async () => { + const request = mockRequest({ authorization: `Bearer ${mockTokens.m2m_token}` }); + + await expect( + authenticateRequest(request, mockOptions({ acceptsToken: 'm2m_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,58 +1309,58 @@ 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, }); }); test('returns unauthenticated state when token type mismatches (OAuth token provided, M2M token expected)', async () => { const request = mockRequest({ authorization: `Bearer ${mockTokens.oauth_token}` }); - const result = await authenticateRequest(request, mockOptions({ acceptsToken: 'machine_token' })); + const result = await authenticateRequest(request, mockOptions({ acceptsToken: 'm2m_token' })); expect(result).toBeMachineUnauthenticated({ - tokenType: 'oauth_token', + tokenType: 'm2m_token', reason: AuthErrorReason.TokenTypeMismatch, message: '', }); expect(result.toAuth()).toBeMachineUnauthenticatedToAuth({ - tokenType: 'oauth_token', + tokenType: 'm2m_token', isAuthenticated: false, }); }); test('returns unauthenticated state when token type mismatches (M2M token provided, API key expected)', async () => { - const request = mockRequest({ authorization: `Bearer ${mockTokens.machine_token}` }); + const request = mockRequest({ authorization: `Bearer ${mockTokens.m2m_token}` }); 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, }); }); test('returns unauthenticated state when session token is provided but machine token is expected', async () => { const request = mockRequestWithHeaderAuth(); - const result = await authenticateRequest(request, mockOptions({ acceptsToken: 'machine_token' })); + const result = await authenticateRequest(request, mockOptions({ acceptsToken: 'm2m_token' })); expect(result).toBeMachineUnauthenticated({ - tokenType: 'machine_token', + tokenType: 'm2m_token', reason: AuthErrorReason.TokenTypeMismatch, message: '', isAuthenticated: false, }); expect(result.toAuth()).toBeMachineUnauthenticatedToAuth({ - tokenType: 'machine_token', + tokenType: 'm2m_token', isAuthenticated: false, }); }); @@ -1356,7 +1384,7 @@ describe('tokens.authenticateRequest(options)', () => { }); test('returns unauthenticated state when token type is not in the acceptsToken array', async () => { - const request = mockRequest({ authorization: `Bearer ${mockTokens.machine_token}` }); + const request = mockRequest({ authorization: `Bearer ${mockTokens.m2m_token}` }); const requestState = await authenticateRequest( request, mockOptions({ acceptsToken: ['api_key', 'oauth_token'] }), diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index 3de15ff314c..f48cfae8e57 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -1,7 +1,7 @@ import { http, HttpResponse } from 'msw'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { APIKey, IdPOAuthAccessToken, MachineToken } from '../../api'; +import type { APIKey, IdPOAuthAccessToken, M2MToken } from '../../api'; import { mockJwks, mockJwt, mockJwtPayload } from '../../fixtures'; import { mockVerificationResults } from '../../fixtures/machine'; import { server, validateHeaders } from '../../mock-server'; @@ -97,14 +97,14 @@ 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( http.post( 'https://api.clerk.test/m2m_tokens/verify', validateHeaders(() => { - return HttpResponse.json(mockVerificationResults.machine_token); + return HttpResponse.json(mockVerificationResults.m2m_token); }), ), ); @@ -114,16 +114,45 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { secretKey: 'a-valid-key', }); - expect(result.tokenType).toBe('machine_token'); + expect(result.tokenType).toBe('m2m_token'); expect(result.data).toBeDefined(); expect(result.errors).toBeUndefined(); - const data = result.data as MachineToken; + const data = result.data as M2MToken; + 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 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.m2m_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('m2m_token'); + expect(result.data).toBeDefined(); + expect(result.errors).toBeUndefined(); + + const data = result.data as M2MToken; 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 OAuth token', async () => { @@ -212,7 +241,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { secretKey: 'a-valid-key', }); - expect(result.tokenType).toBe('machine_token'); + expect(result.tokenType).toBe('m2m_token'); expect(result.data).toBeUndefined(); expect(result.errors).toBeDefined(); expect(result.errors?.[0].message).toBe('Machine token not found'); @@ -233,7 +262,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { secretKey: 'a-valid-key', }); - expect(result.tokenType).toBe('machine_token'); + expect(result.tokenType).toBe('m2m_token'); expect(result.data).toBeUndefined(); expect(result.errors).toBeDefined(); expect(result.errors?.[0].message).toBe('Unexpected error'); diff --git a/packages/backend/src/tokens/authObjects.ts b/packages/backend/src/tokens/authObjects.ts index 9e80332b61a..7b7d5eaa7a1 100644 --- a/packages/backend/src/tokens/authObjects.ts +++ b/packages/backend/src/tokens/authObjects.ts @@ -11,7 +11,7 @@ import type { SharedSignedInAuthObjectProperties, } from '@clerk/types'; -import type { APIKey, CreateBackendApiOptions, IdPOAuthAccessToken, MachineToken } from '../api'; +import type { APIKey, CreateBackendApiOptions, IdPOAuthAccessToken, M2MToken } from '../api'; import { createBackendApiClient } from '../api'; import { isTokenTypeAccepted } from '../internal'; import type { AuthenticateContext } from './authenticateContext'; @@ -95,8 +95,7 @@ type MachineObjectExtendedProperties = { | { name: string; claims: Claims | null; userId: string; orgId: null } | { 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; + m2m_token: { claims: TAuthenticated extends true ? Claims | null : null; machineId: TAuthenticated extends true ? string : null; }; @@ -280,12 +279,11 @@ export function authenticatedMachineObject( orgId: result.subject.startsWith('org_') ? result.subject : null, } as unknown as AuthenticatedMachineObject; } - case TokenType.MachineToken: { - const result = verificationResult as MachineToken; + case TokenType.M2MToken: { + const result = verificationResult as M2MToken; return { ...baseObject, tokenType, - name: result.name, claims: result.claims, scopes: result.scopes, machineId: result.subject, @@ -335,11 +333,10 @@ export function unauthenticatedMachineObject( orgId: null, } as unknown as UnauthenticatedMachineObject; } - case TokenType.MachineToken: { + case TokenType.M2MToken: { 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..8097306851c 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.M2MToken || 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/machine.ts b/packages/backend/src/tokens/machine.ts index 17df764a429..26ecd57209d 100644 --- a/packages/backend/src/tokens/machine.ts +++ b/packages/backend/src/tokens/machine.ts @@ -35,7 +35,7 @@ export function isMachineTokenByPrefix(token: string): boolean { */ export function getMachineTokenType(token: string): MachineTokenType { if (token.startsWith(M2M_TOKEN_PREFIX)) { - return TokenType.MachineToken; + return TokenType.M2MToken; } if (token.startsWith(OAUTH_TOKEN_PREFIX)) { @@ -73,11 +73,11 @@ export const isTokenTypeAccepted = ( }; /** - * Checks if a token type string is a machine token type (api_key, machine_token, or oauth_token). + * Checks if a token type string is a machine token type (api_key, m2m_token, or oauth_token). * * @param type - The token type string to check * @returns true if the type is a machine token type */ export function isMachineTokenType(type: string): type is MachineTokenType { - return type === TokenType.ApiKey || type === TokenType.MachineToken || type === TokenType.OAuthToken; + return type === TokenType.ApiKey || type === TokenType.M2MToken || type === TokenType.OAuthToken; } diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index a8064934a8e..595e6ea5742 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.M2MToken) { + 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 m2m_token + if (acceptsToken === TokenType.M2MToken) { + assertMachineSecretOrSecretKey(authenticateContext); } const organizationMatcher = new OrganizationMatcher(options.organizationSyncOptions); @@ -772,7 +791,7 @@ export const authenticateRequest: AuthenticateRequest = (async ( if ( acceptsToken === TokenType.OAuthToken || acceptsToken === TokenType.ApiKey || - acceptsToken === TokenType.MachineToken + acceptsToken === TokenType.M2MToken ) { return signedOut({ tokenType: acceptsToken, diff --git a/packages/backend/src/tokens/tokenTypes.ts b/packages/backend/src/tokens/tokenTypes.ts index 8207d724212..6d01df194cc 100644 --- a/packages/backend/src/tokens/tokenTypes.ts +++ b/packages/backend/src/tokens/tokenTypes.ts @@ -1,7 +1,7 @@ export const TokenType = { SessionToken: 'session_token', ApiKey: 'api_key', - MachineToken: 'machine_token', + M2MToken: 'm2m_token', OAuthToken: 'oauth_token', } as const; diff --git a/packages/backend/src/tokens/types.ts b/packages/backend/src/tokens/types.ts index 2b95dfb6c23..bd37033514c 100644 --- a/packages/backend/src/tokens/types.ts +++ b/packages/backend/src/tokens/types.ts @@ -1,7 +1,7 @@ import type { MatchFunction } from '@clerk/shared/pathToRegexp'; import type { PendingSessionOptions } from '@clerk/types'; -import type { ApiClient, APIKey, IdPOAuthAccessToken, MachineToken } from '../api'; +import type { ApiClient, APIKey, IdPOAuthAccessToken, M2MToken } from '../api'; import type { AuthenticatedMachineObject, AuthObject, @@ -67,6 +67,11 @@ export type AuthenticateRequestOptions = { * @default 'session_token' */ acceptsToken?: TokenType | TokenType[] | 'any'; + /** + * The machine secret key to use when verifying machine-to-machine tokens. + * This will override the Clerk secret key. + */ + machineSecretKey?: string; } & VerifyTokenOptions; /** @@ -136,7 +141,7 @@ export type OrganizationSyncOptions = { */ type Pattern = string; -export type MachineAuthType = MachineToken | APIKey | IdPOAuthAccessToken; +export type MachineAuthType = M2MToken | APIKey | IdPOAuthAccessToken; export type OrganizationSyncTargetMatchers = { OrganizationMatcher: MatchFunction>> | null; diff --git a/packages/backend/src/tokens/verify.ts b/packages/backend/src/tokens/verify.ts index ad76138290b..ad8597f19da 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -1,7 +1,7 @@ import { isClerkAPIResponseError } from '@clerk/shared/error'; import type { JwtPayload } from '@clerk/types'; -import type { APIKey, IdPOAuthAccessToken, MachineToken } from '../api'; +import type { APIKey, IdPOAuthAccessToken, M2MToken } from '../api'; import { createBackendApiClient } from '../api/factory'; import { MachineTokenVerificationError, @@ -202,14 +202,14 @@ function handleClerkAPIError( async function verifyMachineToken( secret: string, - options: VerifyTokenOptions, -): Promise> { + options: VerifyTokenOptions & { machineSecretKey?: string }, +): Promise> { try { const client = createBackendApiClient(options); - const verifiedToken = await client.machineTokens.verifySecret(secret); - return { data: verifiedToken, tokenType: TokenType.MachineToken, errors: undefined }; + const verifiedToken = await client.m2mTokens.verifySecret({ secret }); + return { data: verifiedToken, tokenType: TokenType.M2MToken, errors: undefined }; } catch (err: any) { - return handleClerkAPIError(TokenType.MachineToken, err, 'Machine token not found'); + return handleClerkAPIError(TokenType.M2MToken, err, 'Machine token not found'); } } diff --git a/packages/express/src/__tests__/getAuth.test.ts b/packages/express/src/__tests__/getAuth.test.ts index 750994777a4..605508d9290 100644 --- a/packages/express/src/__tests__/getAuth.test.ts +++ b/packages/express/src/__tests__/getAuth.test.ts @@ -32,12 +32,12 @@ describe('getAuth', () => { }); it('returns the actual auth object if its tokenType is included in the acceptsToken array', () => { - const req = mockRequestWithAuth({ tokenType: 'machine_token', id: 'm2m_1234' }); - const result = getAuth(req, { acceptsToken: ['machine_token', 'api_key'] }); - expect(result.tokenType).toBe('machine_token'); + const req = mockRequestWithAuth({ tokenType: 'm2m_token', id: 'm2m_1234' }); + const result = getAuth(req, { acceptsToken: ['m2m_token', 'api_key'] }); + expect(result.tokenType).toBe('m2m_token'); - expect((result as AuthenticatedMachineObject<'machine_token'>).id).toBe('m2m_1234'); - expect((result as AuthenticatedMachineObject<'machine_token'>).subject).toBeUndefined(); + expect((result as AuthenticatedMachineObject<'m2m_token'>).id).toBe('m2m_1234'); + expect((result as AuthenticatedMachineObject<'m2m_token'>).subject).toBeUndefined(); }); it('returns an unauthenticated auth object when the tokenType does not match acceptsToken', () => { diff --git a/packages/express/src/utils.ts b/packages/express/src/utils.ts index 8f1de38dc3c..6b65a04e237 100644 --- a/packages/express/src/utils.ts +++ b/packages/express/src/utils.ts @@ -18,6 +18,7 @@ export const loadClientEnv = () => { export const loadApiEnv = () => { return { secretKey: process.env.CLERK_SECRET_KEY || '', + machineSecretKey: process.env.CLERK_MACHINE_SECRET_KEY || '', apiUrl: process.env.CLERK_API_URL || 'https://api.clerk.com', apiVersion: process.env.CLERK_API_VERSION || 'v1', domain: process.env.CLERK_DOMAIN || '', diff --git a/packages/fastify/src/__tests__/__snapshots__/constants.test.ts.snap b/packages/fastify/src/__tests__/__snapshots__/constants.test.ts.snap index 619746ea65b..5d76837170b 100644 --- a/packages/fastify/src/__tests__/__snapshots__/constants.test.ts.snap +++ b/packages/fastify/src/__tests__/__snapshots__/constants.test.ts.snap @@ -5,6 +5,7 @@ exports[`constants > from environment variables 1`] = ` "API_URL": "https://api.clerk.com", "API_VERSION": "v1", "JWT_KEY": "", + "MACHINE_SECRET_KEY": "", "PUBLISHABLE_KEY": "", "SDK_METADATA": { "environment": "test", diff --git a/packages/fastify/src/__tests__/getAuth.test.ts b/packages/fastify/src/__tests__/getAuth.test.ts index 9d5dfe8f660..535c1f022f3 100644 --- a/packages/fastify/src/__tests__/getAuth.test.ts +++ b/packages/fastify/src/__tests__/getAuth.test.ts @@ -28,11 +28,11 @@ describe('getAuth(req)', () => { }); it('returns the actual auth object if its tokenType is included in the acceptsToken array', () => { - const req = { auth: { tokenType: 'machine_token', id: 'm2m_1234' } } as unknown as FastifyRequest; - const result = getAuth(req, { acceptsToken: ['machine_token', 'api_key'] }); - expect(result.tokenType).toBe('machine_token'); - expect((result as AuthenticatedMachineObject<'machine_token'>).id).toBe('m2m_1234'); - expect((result as AuthenticatedMachineObject<'machine_token'>).subject).toBeUndefined(); + const req = { auth: { tokenType: 'm2m_token', id: 'm2m_1234' } } as unknown as FastifyRequest; + const result = getAuth(req, { acceptsToken: ['m2m_token', 'api_key'] }); + expect(result.tokenType).toBe('m2m_token'); + expect((result as AuthenticatedMachineObject<'m2m_token'>).id).toBe('m2m_1234'); + expect((result as AuthenticatedMachineObject<'m2m_token'>).subject).toBeUndefined(); }); it('returns an unauthenticated auth object when the tokenType does not match acceptsToken', () => { diff --git a/packages/fastify/src/clerkClient.ts b/packages/fastify/src/clerkClient.ts index df67ece1443..3ec3dda03e5 100644 --- a/packages/fastify/src/clerkClient.ts +++ b/packages/fastify/src/clerkClient.ts @@ -1,9 +1,10 @@ import { createClerkClient } from '@clerk/backend'; -import { API_URL, API_VERSION, JWT_KEY, SDK_METADATA, SECRET_KEY } from './constants'; +import { API_URL, API_VERSION, JWT_KEY, MACHINE_SECRET_KEY, SDK_METADATA, SECRET_KEY } from './constants'; export const clerkClient = createClerkClient({ secretKey: SECRET_KEY, + machineSecretKey: MACHINE_SECRET_KEY, apiUrl: API_URL, apiVersion: API_VERSION, jwtKey: JWT_KEY, diff --git a/packages/fastify/src/constants.ts b/packages/fastify/src/constants.ts index fe1c9b95981..ed5917de251 100644 --- a/packages/fastify/src/constants.ts +++ b/packages/fastify/src/constants.ts @@ -3,6 +3,7 @@ import { apiUrlFromPublishableKey } from '@clerk/shared/apiUrlFromPublishableKey export const API_VERSION = process.env.CLERK_API_VERSION || 'v1'; export const SECRET_KEY = process.env.CLERK_SECRET_KEY || ''; +export const MACHINE_SECRET_KEY = process.env.CLERK_MACHINE_SECRET_KEY || ''; export const PUBLISHABLE_KEY = process.env.CLERK_PUBLISHABLE_KEY || ''; export const API_URL = process.env.CLERK_API_URL || apiUrlFromPublishableKey(PUBLISHABLE_KEY); export const JWT_KEY = process.env.CLERK_JWT_KEY || ''; diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts index 9912dbed6f1..64a3913f80f 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -666,7 +666,7 @@ describe('clerkMiddleware(params)', () => { publishableKey, status: AuthStatus.SignedOut, headers: new Headers(), - toAuth: () => ({ tokenType: TokenType.MachineToken, id: null }), + toAuth: () => ({ tokenType: TokenType.M2MToken, id: null }), }); const resp = await clerkMiddleware(async auth => { @@ -717,7 +717,7 @@ describe('clerkMiddleware(params)', () => { }); const resp = await clerkMiddleware(async auth => { - await auth.protect({ token: [TokenType.SessionToken, TokenType.MachineToken] }); + await auth.protect({ token: [TokenType.SessionToken, TokenType.M2MToken] }); })(req, {} as NextFetchEvent); expect(resp?.status).toEqual(401); diff --git a/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts b/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts index 91c5144086e..57cc934a3ea 100644 --- a/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts +++ b/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts @@ -72,7 +72,7 @@ describe('getAuthDataFromRequest', () => { }); const auth = getAuthDataFromRequest(req, { - acceptsToken: ['machine_token', 'oauth_token', 'session_token'], + acceptsToken: ['m2m_token', 'oauth_token', 'session_token'], }); expect(auth.tokenType).toBeNull(); @@ -183,7 +183,7 @@ describe('getAuthDataFromRequest', () => { }); const auth = getAuthDataFromRequest(req, { - acceptsToken: ['api_key', 'machine_token'], + acceptsToken: ['api_key', 'm2m_token'], }); expect(auth.tokenType).toBe('api_key'); @@ -203,7 +203,7 @@ describe('getAuthDataFromRequest', () => { data: { id: 'oat_id123', subject: 'user_12345' }, }, { - tokenType: 'machine_token' as const, + tokenType: 'm2m_token' as const, token: 'mt_123', data: { id: 'm2m_123', subject: 'mch_123' }, }, @@ -243,7 +243,7 @@ describe('getAuthDataFromRequest', () => { data: undefined, }, { - tokenType: 'machine_token' as const, + tokenType: 'm2m_token' as const, token: 'mt_123', data: undefined, }, diff --git a/packages/nextjs/src/server/constants.ts b/packages/nextjs/src/server/constants.ts index cbddb6ea346..73c4371486f 100644 --- a/packages/nextjs/src/server/constants.ts +++ b/packages/nextjs/src/server/constants.ts @@ -5,6 +5,7 @@ export const CLERK_JS_VERSION = process.env.NEXT_PUBLIC_CLERK_JS_VERSION || ''; export const CLERK_JS_URL = process.env.NEXT_PUBLIC_CLERK_JS_URL || ''; export const API_VERSION = process.env.CLERK_API_VERSION || 'v1'; export const SECRET_KEY = process.env.CLERK_SECRET_KEY || ''; +export const MACHINE_SECRET_KEY = process.env.CLERK_MACHINE_SECRET_KEY || ''; export const PUBLISHABLE_KEY = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY || ''; export const ENCRYPTION_KEY = process.env.CLERK_ENCRYPTION_KEY || ''; export const API_URL = process.env.CLERK_API_URL || apiUrlFromPublishableKey(PUBLISHABLE_KEY); diff --git a/packages/nextjs/src/server/createClerkClient.ts b/packages/nextjs/src/server/createClerkClient.ts index 09eded3264b..d10cbc0930b 100644 --- a/packages/nextjs/src/server/createClerkClient.ts +++ b/packages/nextjs/src/server/createClerkClient.ts @@ -5,6 +5,7 @@ import { API_VERSION, DOMAIN, IS_SATELLITE, + MACHINE_SECRET_KEY, PROXY_URL, PUBLISHABLE_KEY, SDK_METADATA, @@ -22,6 +23,7 @@ const clerkClientDefaultOptions = { proxyUrl: PROXY_URL, domain: DOMAIN, isSatellite: IS_SATELLITE, + machineSecretKey: MACHINE_SECRET_KEY, sdkMetadata: SDK_METADATA, telemetry: { disabled: TELEMETRY_DISABLED, diff --git a/packages/nextjs/src/server/protect.ts b/packages/nextjs/src/server/protect.ts index b5372c766a5..7527263858b 100644 --- a/packages/nextjs/src/server/protect.ts +++ b/packages/nextjs/src/server/protect.ts @@ -67,7 +67,7 @@ export interface AuthProtect { /** * @example - * auth.protect({ token: ['session_token', 'machine_token'] }); + * auth.protect({ token: ['session_token', 'm2m_token'] }); */ ( options?: AuthProtectOptions & { token: T }, diff --git a/packages/nuxt/src/global.d.ts b/packages/nuxt/src/global.d.ts index c6f45c051a2..7cde099d9c9 100644 --- a/packages/nuxt/src/global.d.ts +++ b/packages/nuxt/src/global.d.ts @@ -10,6 +10,7 @@ declare module 'nuxt/schema' { interface RuntimeConfig { clerk: { secretKey?: string; + machineSecretKey?: string; jwtKey?: string; webhookSigningSecret?: string; }; diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 84fa45cb53f..38cb9d2c266 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -78,6 +78,7 @@ export default defineNuxtModule({ // Private keys available only on within server-side clerk: { secretKey: undefined, + machineSecretKey: undefined, jwtKey: undefined, webhookSigningSecret: undefined, }, diff --git a/packages/nuxt/src/runtime/server/__tests__/auth.test-d.ts b/packages/nuxt/src/runtime/server/__tests__/auth.test-d.ts index 4f9e79c7f29..d2a97f285a0 100644 --- a/packages/nuxt/src/runtime/server/__tests__/auth.test-d.ts +++ b/packages/nuxt/src/runtime/server/__tests__/auth.test-d.ts @@ -17,17 +17,15 @@ test('infers the correct AuthObject type for each accepted token type', () => { // Individual token types expectTypeOf(event.locals.auth({ acceptsToken: 'session_token' })).toMatchTypeOf(); expectTypeOf(event.locals.auth({ acceptsToken: 'api_key' })).toMatchTypeOf>(); - expectTypeOf(event.locals.auth({ acceptsToken: 'machine_token' })).toMatchTypeOf< - MachineAuthObject<'machine_token'> - >(); + expectTypeOf(event.locals.auth({ acceptsToken: 'm2m_token' })).toMatchTypeOf>(); expectTypeOf(event.locals.auth({ acceptsToken: 'oauth_token' })).toMatchTypeOf>(); // Array of token types - expectTypeOf(event.locals.auth({ acceptsToken: ['session_token', 'machine_token'] })).toMatchTypeOf< - SessionAuthObject | MachineAuthObject<'machine_token'> | InvalidTokenAuthObject + expectTypeOf(event.locals.auth({ acceptsToken: ['session_token', 'm2m_token'] })).toMatchTypeOf< + SessionAuthObject | MachineAuthObject<'m2m_token'> | InvalidTokenAuthObject >(); - expectTypeOf(event.locals.auth({ acceptsToken: ['machine_token', 'oauth_token'] })).toMatchTypeOf< - MachineAuthObject<'machine_token' | 'oauth_token'> | InvalidTokenAuthObject + expectTypeOf(event.locals.auth({ acceptsToken: ['m2m_token', 'oauth_token'] })).toMatchTypeOf< + MachineAuthObject<'m2m_token' | 'oauth_token'> | InvalidTokenAuthObject >(); // Any token type @@ -48,8 +46,8 @@ test('verifies discriminated union works correctly with acceptsToken: any', () = expectTypeOf(auth).toMatchTypeOf(); } else if (auth.tokenType === 'api_key') { expectTypeOf(auth).toMatchTypeOf>(); - } else if (auth.tokenType === 'machine_token') { - expectTypeOf(auth).toMatchTypeOf>(); + } else if (auth.tokenType === 'm2m_token') { + expectTypeOf(auth).toMatchTypeOf>(); } else if (auth.tokenType === 'oauth_token') { expectTypeOf(auth).toMatchTypeOf>(); } diff --git a/packages/nuxt/src/runtime/server/__tests__/clerkMiddleware.test.ts b/packages/nuxt/src/runtime/server/__tests__/clerkMiddleware.test.ts index 9dd99f6d34f..7b8076f7cc8 100644 --- a/packages/nuxt/src/runtime/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nuxt/src/runtime/server/__tests__/clerkMiddleware.test.ts @@ -195,13 +195,13 @@ describe('clerkMiddleware(params)', () => { app.use(clerkMiddleware()); app.use( '/', - eventHandler(event => event.context.auth({ acceptsToken: 'machine_token' })), + eventHandler(event => event.context.auth({ acceptsToken: 'm2m_token' })), ); const response = await handler(new Request(new URL('/', 'http://localhost'))); expect(response.status).toBe(200); const result = await response.json(); - expect(result.tokenType).toBe('machine_token'); + expect(result.tokenType).toBe('m2m_token'); expect(result.isAuthenticated).toBe(false); expect(result.id).toBe(null); }); @@ -217,7 +217,7 @@ describe('clerkMiddleware(params)', () => { app.use(clerkMiddleware()); app.use( '/', - eventHandler(event => event.context.auth({ acceptsToken: ['session_token', 'machine_token'] })), + eventHandler(event => event.context.auth({ acceptsToken: ['session_token', 'm2m_token'] })), ); const response = await handler(new Request(new URL('/', 'http://localhost'))); diff --git a/packages/nuxt/src/runtime/server/clerkClient.ts b/packages/nuxt/src/runtime/server/clerkClient.ts index f623a273a33..06e7b6f81ae 100644 --- a/packages/nuxt/src/runtime/server/clerkClient.ts +++ b/packages/nuxt/src/runtime/server/clerkClient.ts @@ -16,6 +16,7 @@ export function clerkClient(event: H3Event) { domain: runtimeConfig.public.clerk.domain, isSatellite: runtimeConfig.public.clerk.isSatellite, secretKey: runtimeConfig.clerk.secretKey, + machineSecretKey: runtimeConfig.clerk.machineSecretKey, jwtKey: runtimeConfig.clerk.jwtKey, telemetry: { disabled: isTruthy(runtimeConfig.public.clerk.telemetry?.disabled), diff --git a/packages/react-router/src/ssr/authenticateRequest.ts b/packages/react-router/src/ssr/authenticateRequest.ts index e82f86ee247..a0c434ca881 100644 --- a/packages/react-router/src/ssr/authenticateRequest.ts +++ b/packages/react-router/src/ssr/authenticateRequest.ts @@ -13,7 +13,7 @@ export async function authenticateRequest( const { request } = args; const { audience, authorizedParties } = opts; - const { apiUrl, secretKey, jwtKey, proxyUrl, isSatellite, domain, publishableKey } = opts; + const { apiUrl, secretKey, jwtKey, proxyUrl, isSatellite, domain, publishableKey, machineSecretKey } = opts; const { signInUrl, signUpUrl, afterSignInUrl, afterSignUpUrl } = opts; const requestState = await createClerkClient({ @@ -24,6 +24,7 @@ export async function authenticateRequest( isSatellite, domain, publishableKey, + machineSecretKey, userAgent: `${PACKAGE_NAME}@${PACKAGE_VERSION}`, }).authenticateRequest(patchRequest(request), { audience, diff --git a/packages/react-router/src/ssr/loadOptions.ts b/packages/react-router/src/ssr/loadOptions.ts index 13b7c361ab0..969cce2dd12 100644 --- a/packages/react-router/src/ssr/loadOptions.ts +++ b/packages/react-router/src/ssr/loadOptions.ts @@ -22,6 +22,7 @@ export const loadOptions = (args: LoaderFunctionArgs, overrides: RootAuthLoaderO // 4. Then try from globalThis (Cloudflare Workers). // 5. Then from loader context (Cloudflare Pages). const secretKey = overrides.secretKey || getEnvVariable('CLERK_SECRET_KEY', context); + const machineSecretKey = overrides.machineSecretKey || getEnvVariable('CLERK_MACHINE_SECRET_KEY', context); const publishableKey = overrides.publishableKey || getPublicEnvVariables(context).publishableKey; const jwtKey = overrides.jwtKey || getEnvVariable('CLERK_JWT_KEY', context); const apiUrl = getEnvVariable('CLERK_API_URL', context) || apiUrlFromPublishableKey(publishableKey); @@ -67,6 +68,7 @@ export const loadOptions = (args: LoaderFunctionArgs, overrides: RootAuthLoaderO // used to append options that are not initialized from env ...overrides, secretKey, + machineSecretKey, publishableKey, jwtKey, apiUrl, diff --git a/packages/react-router/src/ssr/types.ts b/packages/react-router/src/ssr/types.ts index f2d7fb20cfe..87d54d5994a 100644 --- a/packages/react-router/src/ssr/types.ts +++ b/packages/react-router/src/ssr/types.ts @@ -25,6 +25,10 @@ export type RootAuthLoaderOptions = { * Used to override the CLERK_SECRET_KEY env variable if needed. */ secretKey?: string; + /** + * Used to override the CLERK_MACHINE_SECRET_KEY env variable if needed. + */ + machineSecretKey?: string; /** * @deprecated Use [session token claims](https://clerk.com/docs/backend-requests/making/custom-session-token) instead. */ diff --git a/packages/remix/src/ssr/authenticateRequest.ts b/packages/remix/src/ssr/authenticateRequest.ts index b0cce6c17df..3c102f5efda 100644 --- a/packages/remix/src/ssr/authenticateRequest.ts +++ b/packages/remix/src/ssr/authenticateRequest.ts @@ -13,7 +13,7 @@ export async function authenticateRequest( const { request } = args; const { audience, authorizedParties } = opts; - const { apiUrl, secretKey, jwtKey, proxyUrl, isSatellite, domain, publishableKey } = opts; + const { apiUrl, secretKey, jwtKey, proxyUrl, isSatellite, domain, publishableKey, machineSecretKey } = opts; const { signInUrl, signUpUrl, afterSignInUrl, afterSignUpUrl } = opts; const requestState = await createClerkClient({ @@ -32,6 +32,7 @@ export async function authenticateRequest( signUpUrl, afterSignInUrl, afterSignUpUrl, + machineSecretKey, }); const locationHeader = requestState.headers.get(constants.Headers.Location); diff --git a/packages/remix/src/ssr/loadOptions.ts b/packages/remix/src/ssr/loadOptions.ts index ef0dc1738d4..82e2b532e1c 100644 --- a/packages/remix/src/ssr/loadOptions.ts +++ b/packages/remix/src/ssr/loadOptions.ts @@ -20,6 +20,7 @@ export const loadOptions = (args: LoaderFunctionArgs, overrides: RootAuthLoaderO // 3. Then try from globalThis (Cloudflare Workers). // 4. Then from loader context (Cloudflare Pages). const secretKey = overrides.secretKey || getEnvVariable('CLERK_SECRET_KEY', context) || ''; + const machineSecretKey = overrides.machineSecretKey || getEnvVariable('CLERK_MACHINE_SECRET_KEY', context); const publishableKey = overrides.publishableKey || getEnvVariable('CLERK_PUBLISHABLE_KEY', context) || ''; const jwtKey = overrides.jwtKey || getEnvVariable('CLERK_JWT_KEY', context); const apiUrl = getEnvVariable('CLERK_API_URL', context) || apiUrlFromPublishableKey(publishableKey); @@ -69,6 +70,7 @@ export const loadOptions = (args: LoaderFunctionArgs, overrides: RootAuthLoaderO // used to append options that are not initialized from env ...overrides, secretKey, + machineSecretKey, publishableKey, jwtKey, apiUrl, diff --git a/packages/remix/src/ssr/types.ts b/packages/remix/src/ssr/types.ts index 52b75f1301f..5a560a0e684 100644 --- a/packages/remix/src/ssr/types.ts +++ b/packages/remix/src/ssr/types.ts @@ -17,6 +17,10 @@ export type RootAuthLoaderOptions = { publishableKey?: string; jwtKey?: string; secretKey?: string; + /** + * Used to override the CLERK_MACHINE_SECRET_KEY env variable if needed. + */ + machineSecretKey?: string; /** * @deprecated Use [session token claims](https://clerk.com/docs/backend-requests/making/custom-session-token) instead. */ diff --git a/packages/tanstack-react-start/src/server/authenticateRequest.ts b/packages/tanstack-react-start/src/server/authenticateRequest.ts index c393a9cfae8..c364dee74eb 100644 --- a/packages/tanstack-react-start/src/server/authenticateRequest.ts +++ b/packages/tanstack-react-start/src/server/authenticateRequest.ts @@ -13,12 +13,14 @@ export async function authenticateRequest( ): Promise { const { audience, authorizedParties } = opts; - const { apiUrl, secretKey, jwtKey, proxyUrl, isSatellite, domain, publishableKey, acceptsToken } = opts; + const { apiUrl, secretKey, jwtKey, proxyUrl, isSatellite, domain, publishableKey, acceptsToken, machineSecretKey } = + opts; const { signInUrl, signUpUrl, afterSignInUrl, afterSignUpUrl } = opts; const requestState = await createClerkClient({ apiUrl, secretKey, + machineSecretKey, jwtKey, proxyUrl, isSatellite, diff --git a/packages/tanstack-react-start/src/server/clerkClient.ts b/packages/tanstack-react-start/src/server/clerkClient.ts index e8398f5af26..5dedf992072 100644 --- a/packages/tanstack-react-start/src/server/clerkClient.ts +++ b/packages/tanstack-react-start/src/server/clerkClient.ts @@ -7,6 +7,7 @@ const clerkClient = (options?: ClerkOptions): ClerkClient => { const commonEnv = commonEnvs(); return createClerkClient({ secretKey: commonEnv.SECRET_KEY, + machineSecretKey: commonEnv.MACHINE_SECRET_KEY, publishableKey: commonEnv.PUBLISHABLE_KEY, apiUrl: commonEnv.API_URL, apiVersion: commonEnv.API_VERSION, diff --git a/packages/tanstack-react-start/src/server/constants.ts b/packages/tanstack-react-start/src/server/constants.ts index bca9f121c14..06cb83905b4 100644 --- a/packages/tanstack-react-start/src/server/constants.ts +++ b/packages/tanstack-react-start/src/server/constants.ts @@ -22,6 +22,7 @@ export const commonEnvs = () => { // Server-only environment variables API_VERSION: getEnvVariable('CLERK_API_VERSION') || 'v1', SECRET_KEY: getEnvVariable('CLERK_SECRET_KEY'), + MACHINE_SECRET_KEY: getEnvVariable('CLERK_MACHINE_SECRET_KEY'), ENCRYPTION_KEY: getEnvVariable('CLERK_ENCRYPTION_KEY'), CLERK_JWT_KEY: getEnvVariable('CLERK_JWT_KEY'), API_URL: getEnvVariable('CLERK_API_URL') || apiUrlFromPublishableKey(publicEnvs.publishableKey), diff --git a/packages/tanstack-react-start/src/server/loadOptions.ts b/packages/tanstack-react-start/src/server/loadOptions.ts index d3ef62f4825..cbf1e4e984b 100644 --- a/packages/tanstack-react-start/src/server/loadOptions.ts +++ b/packages/tanstack-react-start/src/server/loadOptions.ts @@ -15,6 +15,7 @@ export const loadOptions = (request: Request, overrides: LoaderOptions = {}) => const clerkRequest = createClerkRequest(patchRequest(request)); const commonEnv = commonEnvs(); const secretKey = overrides.secretKey || commonEnv.SECRET_KEY; + const machineSecretKey = overrides.machineSecretKey || commonEnv.MACHINE_SECRET_KEY; const publishableKey = overrides.publishableKey || commonEnv.PUBLISHABLE_KEY; const jwtKey = overrides.jwtKey || commonEnv.CLERK_JWT_KEY; const apiUrl = getEnvVariable('CLERK_API_URL') || apiUrlFromPublishableKey(publishableKey); @@ -52,6 +53,7 @@ export const loadOptions = (request: Request, overrides: LoaderOptions = {}) => // used to append options that are not initialized from env ...overrides, secretKey, + machineSecretKey, publishableKey, jwtKey, apiUrl, diff --git a/packages/tanstack-react-start/src/server/types.ts b/packages/tanstack-react-start/src/server/types.ts index bb76829f20a..b289b865e9a 100644 --- a/packages/tanstack-react-start/src/server/types.ts +++ b/packages/tanstack-react-start/src/server/types.ts @@ -12,6 +12,7 @@ export type LoaderOptions = { publishableKey?: string; jwtKey?: string; secretKey?: string; + machineSecretKey?: string; signInUrl?: string; signUpUrl?: string; } & Pick &