diff --git a/.changeset/nasty-colts-travel.md b/.changeset/nasty-colts-travel.md new file mode 100644 index 00000000000..f17a9c22575 --- /dev/null +++ b/.changeset/nasty-colts-travel.md @@ -0,0 +1,5 @@ +--- +"@clerk/backend": minor +--- + +Exports `Machine` and `M2MToken` resource classes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cba7aba6c99..b3ddf289db2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -293,6 +293,7 @@ jobs: 'nuxt', 'react-router', 'billing', + 'machine' ] test-project: ['chrome'] include: diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index ec173fd86ce..b46ba8ba506 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -47,6 +47,12 @@ export const createLongRunningApps = () => { { id: 'withBillingJwtV2.vue.vite', config: vue.vite, env: envs.withBillingJwtV2 }, { id: 'withBilling.vue.vite', config: vue.vite, env: envs.withBilling }, + /** + * Machine auth apps + * TODO(rob): Group other machine auth apps together (api keys, m2m tokens, etc) + */ + { id: 'withMachine.express.vite', config: express.vite, env: envs.withAPIKeys }, + /** * Vite apps - basic flows */ diff --git a/integration/tests/machine-auth/m2m.test.ts b/integration/tests/machine-auth/m2m.test.ts new file mode 100644 index 00000000000..c495a1b26f9 --- /dev/null +++ b/integration/tests/machine-auth/m2m.test.ts @@ -0,0 +1,172 @@ +import { createClerkClient, type M2MToken, type Machine } from '@clerk/backend'; +import { faker } from '@faker-js/faker'; +import { expect, test } from '@playwright/test'; + +import type { Application } from '../../models/application'; +import { appConfigs } from '../../presets'; +import { instanceKeys } from '../../presets/envs'; +import { createTestUtils } from '../../testUtils'; + +test.describe('machine-to-machine auth @machine', () => { + test.describe.configure({ mode: 'parallel' }); + let app: Application; + let primaryApiServer: Machine; + let emailServer: Machine; + let analyticsServer: Machine; + let emailServerM2MToken: M2MToken; + let analyticsServerM2MToken: M2MToken; + + test.beforeAll(async () => { + const fakeCompanyName = faker.company.name(); + + // Create primary machine using instance secret key + const client = createClerkClient({ + secretKey: instanceKeys.get('with-api-keys').sk, + }); + primaryApiServer = await client.machines.create({ + name: `${fakeCompanyName} Primary API Server`, + }); + + app = await appConfigs.express.vite + .clone() + .addFile( + 'src/server/main.ts', + () => ` + import 'dotenv/config'; + import { clerkClient } from '@clerk/express'; + import express from 'express'; + import ViteExpress from 'vite-express'; + + const app = express(); + + app.get('/api/protected', async (req, res) => { + const secret = req.get('Authorization')?.split(' ')[1]; + + try { + const m2mToken = await clerkClient.m2mTokens.verifySecret({ secret }); + res.send('Protected response ' + m2mToken.id); + } catch { + res.status(401).send('Unauthorized'); + } + }); + + const port = parseInt(process.env.PORT as string) || 3002; + ViteExpress.listen(app, port, () => console.log('Server started')); + `, + ) + .commit(); + + await app.setup(); + + // Using the created machine, set a machine secret key using the primary machine's secret key + const env = appConfigs.envs.withAPIKeys + .clone() + .setEnvVariable('private', 'CLERK_MACHINE_SECRET_KEY', primaryApiServer.secretKey); + await app.withEnv(env); + await app.dev(); + + // Email server can access primary API server + emailServer = await client.machines.create({ + name: `${fakeCompanyName} Email Server`, + scopedMachines: [primaryApiServer.id], + }); + emailServerM2MToken = await client.m2mTokens.create({ + machineSecretKey: emailServer.secretKey, + secondsUntilExpiration: 60 * 30, + }); + + // Analytics server cannot access primary API server + analyticsServer = await client.machines.create({ + name: `${fakeCompanyName} Analytics Server`, + // No scoped machines + }); + analyticsServerM2MToken = await client.m2mTokens.create({ + machineSecretKey: analyticsServer.secretKey, + secondsUntilExpiration: 60 * 30, + }); + }); + + test.afterAll(async () => { + const client = createClerkClient({ + secretKey: instanceKeys.get('with-api-keys').sk, + }); + + await client.m2mTokens.revoke({ + m2mTokenId: emailServerM2MToken.id, + }); + await client.m2mTokens.revoke({ + m2mTokenId: analyticsServerM2MToken.id, + }); + await client.machines.delete(emailServer.id); + await client.machines.delete(primaryApiServer.id); + await client.machines.delete(analyticsServer.id); + + await app.teardown(); + }); + + test('rejects requests with invalid M2M tokens', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + const res = await u.page.request.get(app.serverUrl + '/api/protected', { + headers: { + Authorization: `Bearer invalid`, + }, + }); + expect(res.status()).toBe(401); + expect(await res.text()).toBe('Unauthorized'); + + const res2 = await u.page.request.get(app.serverUrl + '/api/protected', { + headers: { + Authorization: `Bearer mt_xxx`, + }, + }); + expect(res2.status()).toBe(401); + expect(await res2.text()).toBe('Unauthorized'); + }); + + test('rejects M2M requests when sender machine lacks access to receiver machine', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + const res = await u.page.request.get(app.serverUrl + '/api/protected', { + headers: { + Authorization: `Bearer ${analyticsServerM2MToken.secret}`, + }, + }); + expect(res.status()).toBe(401); + expect(await res.text()).toBe('Unauthorized'); + }); + + test('authorizes M2M requests when sender machine has proper access to receiver machine', async ({ + page, + context, + }) => { + const u = createTestUtils({ app, page, context }); + + // Email server can access primary API server + const res = await u.page.request.get(app.serverUrl + '/api/protected', { + headers: { + Authorization: `Bearer ${emailServerM2MToken.secret}`, + }, + }); + expect(res.status()).toBe(200); + expect(await res.text()).toBe('Protected response ' + emailServerM2MToken.id); + + // Analytics server can access primary API server after adding scope + await u.services.clerk.machines.createScope(analyticsServer.id, primaryApiServer.id); + const m2mToken = await u.services.clerk.m2mTokens.create({ + machineSecretKey: analyticsServer.secretKey, + secondsUntilExpiration: 60 * 30, + }); + + const res2 = await u.page.request.get(app.serverUrl + '/api/protected', { + headers: { + Authorization: `Bearer ${m2mToken.secret}`, + }, + }); + expect(res2.status()).toBe(200); + expect(await res2.text()).toBe('Protected response ' + m2mToken.id); + await u.services.clerk.m2mTokens.revoke({ + m2mTokenId: m2mToken.id, + }); + }); +}); diff --git a/package.json b/package.json index 2e0873c4a13..926374fe642 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "test:integration:generic": "E2E_APP_ID=react.vite.*,next.appRouter.withEmailCodes* pnpm test:integration:base --grep @generic", "test:integration:handshake": "DISABLE_WEB_SECURITY=true E2E_APP_ID=next.appRouter.sessionsProd1 pnpm test:integration:base --grep @handshake", "test:integration:localhost": "pnpm test:integration:base --grep @localhost", + "test:integration:machine": "E2E_APP_ID=withMachine.* pnpm test:integration:base --grep @machine", "test:integration:nextjs": "E2E_APP_ID=next.appRouter.* pnpm test:integration:base --grep @nextjs", "test:integration:nuxt": "E2E_APP_ID=nuxt.node npm run test:integration:base -- --grep @nuxt", "test:integration:quickstart": "E2E_APP_ID=quickstart.* pnpm test:integration:base --grep @quickstart", diff --git a/packages/backend/src/api/endpoints/M2MTokenApi.ts b/packages/backend/src/api/endpoints/M2MTokenApi.ts index 6bd1f8f9151..379f4f13ffd 100644 --- a/packages/backend/src/api/endpoints/M2MTokenApi.ts +++ b/packages/backend/src/api/endpoints/M2MTokenApi.ts @@ -10,6 +10,11 @@ type CreateM2MTokenParams = { * Custom machine secret key for authentication. */ machineSecretKey?: string; + /** + * Number of seconds until the token expires. + * + * @default null - Token does not expire + */ secondsUntilExpiration?: number | null; claims?: Record | null; }; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 21d29130e78..5aa8a0f8da9 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -126,6 +126,8 @@ export type { InstanceSettings, Invitation, JwtTemplate, + Machine, + M2MToken, OauthAccessToken, OAuthApplication, Organization, diff --git a/turbo.json b/turbo.json index 5a651d02495..46fba8aadaf 100644 --- a/turbo.json +++ b/turbo.json @@ -344,6 +344,18 @@ "env": ["CLEANUP", "DEBUG", "E2E_*", "INTEGRATION_INSTANCE_KEYS"], "inputs": ["integration/**"], "outputLogs": "new-only" + }, + "//#test:integration:machine": { + "dependsOn": [ + "@clerk/testing#build", + "@clerk/clerk-js#build", + "@clerk/backend#build", + "@clerk/nextjs#build", + "@clerk/express#build" + ], + "env": ["CLEANUP", "DEBUG", "E2E_*", "INTEGRATION_INSTANCE_KEYS"], + "inputs": ["integration/**"], + "outputLogs": "new-only" } } }