Skip to content

chore(repo,backend): Add machine-to-machine integration tests #6500

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 24 commits into from
Aug 12, 2025
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ jobs:
'nuxt',
'react-router',
'billing',
'machine'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙃

Suggested change
'machine'
'm2m'

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So there's a follow up PR for this that will group all "machine" related tests (api keys, m2m)

]
test-project: ['chrome']
include:
Expand Down
6 changes: 6 additions & 0 deletions integration/presets/longRunningApps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
177 changes: 177 additions & 0 deletions integration/tests/machine-auth/m2m.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
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 { clerkMiddleware, getAuth } from '@clerk/express';
import express from 'express';
import ViteExpress from 'vite-express';

const app = express();

app.use(
clerkMiddleware({
publishableKey: process.env.VITE_CLERK_PUBLISHABLE_KEY,
machineSecretKey: process.env.CLERK_MACHINE_SECRET_KEY,
}),
);

app.get('/api/protected', (req, res) => {
const { machineId } = getAuth(req, { acceptsToken: 'm2m_token' });
if (!machineId) {
res.status(401).send('Unauthorized');
return;
}

res.send('Protected response');
});

ViteExpress.listen(app, process.env.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();

const u = createTestUtils({ app });

// Email server can access primary API server
emailServer = await u.services.clerk.machines.create({
name: `${fakeCompanyName} Email Server`,
scopedMachines: [primaryApiServer.id],
});
emailServerM2MToken = await u.services.clerk.m2mTokens.create({
machineSecretKey: emailServer.secretKey,
secondsUntilExpiration: 60 * 30,
});

// Analytics server cannot access primary API server
analyticsServer = await u.services.clerk.machines.create({
name: `${fakeCompanyName} Analytics Server`,
// No scoped machines
});
analyticsServerM2MToken = await u.services.clerk.m2mTokens.create({
machineSecretKey: analyticsServer.secretKey,
secondsUntilExpiration: 60 * 30,
});
});

test.afterAll(async () => {
const u = createTestUtils({ app });

await u.services.clerk.m2mTokens.revoke({
m2mTokenId: emailServerM2MToken.id,
});
await u.services.clerk.m2mTokens.revoke({
m2mTokenId: analyticsServerM2MToken.id,
});
await u.services.clerk.machines.delete(emailServer.id);
await u.services.clerk.machines.delete(primaryApiServer.id);
await u.services.clerk.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');

// 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');
await u.services.clerk.m2mTokens.revoke({
m2mTokenId: m2mToken.id,
});
});
});
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ export type {
InstanceSettings,
Invitation,
JwtTemplate,
Machine,
M2MToken,
OauthAccessToken,
OAuthApplication,
Organization,
Expand Down
12 changes: 12 additions & 0 deletions turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are testing against nextjs, right ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to my comment above, the api keys test needs nextjs

"@clerk/express#build"
],
"env": ["CLEANUP", "DEBUG", "E2E_*", "INTEGRATION_INSTANCE_KEYS"],
"inputs": ["integration/**"],
"outputLogs": "new-only"
}
}
}
Loading