Skip to content

Commit df63e76

Browse files
authored
chore(repo,backend): Add machine-to-machine integration tests (#6500)
1 parent 4abbf4d commit df63e76

File tree

8 files changed

+204
-0
lines changed

8 files changed

+204
-0
lines changed

.changeset/nasty-colts-travel.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clerk/backend": minor
3+
---
4+
5+
Exports `Machine` and `M2MToken` resource classes

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ jobs:
293293
'nuxt',
294294
'react-router',
295295
'billing',
296+
'machine'
296297
]
297298
test-project: ['chrome']
298299
include:

integration/presets/longRunningApps.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ export const createLongRunningApps = () => {
4747
{ id: 'withBillingJwtV2.vue.vite', config: vue.vite, env: envs.withBillingJwtV2 },
4848
{ id: 'withBilling.vue.vite', config: vue.vite, env: envs.withBilling },
4949

50+
/**
51+
* Machine auth apps
52+
* TODO(rob): Group other machine auth apps together (api keys, m2m tokens, etc)
53+
*/
54+
{ id: 'withMachine.express.vite', config: express.vite, env: envs.withAPIKeys },
55+
5056
/**
5157
* Vite apps - basic flows
5258
*/
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { createClerkClient, type M2MToken, type Machine } from '@clerk/backend';
2+
import { faker } from '@faker-js/faker';
3+
import { expect, test } from '@playwright/test';
4+
5+
import type { Application } from '../../models/application';
6+
import { appConfigs } from '../../presets';
7+
import { instanceKeys } from '../../presets/envs';
8+
import { createTestUtils } from '../../testUtils';
9+
10+
test.describe('machine-to-machine auth @machine', () => {
11+
test.describe.configure({ mode: 'parallel' });
12+
let app: Application;
13+
let primaryApiServer: Machine;
14+
let emailServer: Machine;
15+
let analyticsServer: Machine;
16+
let emailServerM2MToken: M2MToken;
17+
let analyticsServerM2MToken: M2MToken;
18+
19+
test.beforeAll(async () => {
20+
const fakeCompanyName = faker.company.name();
21+
22+
// Create primary machine using instance secret key
23+
const client = createClerkClient({
24+
secretKey: instanceKeys.get('with-api-keys').sk,
25+
});
26+
primaryApiServer = await client.machines.create({
27+
name: `${fakeCompanyName} Primary API Server`,
28+
});
29+
30+
app = await appConfigs.express.vite
31+
.clone()
32+
.addFile(
33+
'src/server/main.ts',
34+
() => `
35+
import 'dotenv/config';
36+
import { clerkClient } from '@clerk/express';
37+
import express from 'express';
38+
import ViteExpress from 'vite-express';
39+
40+
const app = express();
41+
42+
app.get('/api/protected', async (req, res) => {
43+
const secret = req.get('Authorization')?.split(' ')[1];
44+
45+
try {
46+
const m2mToken = await clerkClient.m2mTokens.verifySecret({ secret });
47+
res.send('Protected response ' + m2mToken.id);
48+
} catch {
49+
res.status(401).send('Unauthorized');
50+
}
51+
});
52+
53+
const port = parseInt(process.env.PORT as string) || 3002;
54+
ViteExpress.listen(app, port, () => console.log('Server started'));
55+
`,
56+
)
57+
.commit();
58+
59+
await app.setup();
60+
61+
// Using the created machine, set a machine secret key using the primary machine's secret key
62+
const env = appConfigs.envs.withAPIKeys
63+
.clone()
64+
.setEnvVariable('private', 'CLERK_MACHINE_SECRET_KEY', primaryApiServer.secretKey);
65+
await app.withEnv(env);
66+
await app.dev();
67+
68+
// Email server can access primary API server
69+
emailServer = await client.machines.create({
70+
name: `${fakeCompanyName} Email Server`,
71+
scopedMachines: [primaryApiServer.id],
72+
});
73+
emailServerM2MToken = await client.m2mTokens.create({
74+
machineSecretKey: emailServer.secretKey,
75+
secondsUntilExpiration: 60 * 30,
76+
});
77+
78+
// Analytics server cannot access primary API server
79+
analyticsServer = await client.machines.create({
80+
name: `${fakeCompanyName} Analytics Server`,
81+
// No scoped machines
82+
});
83+
analyticsServerM2MToken = await client.m2mTokens.create({
84+
machineSecretKey: analyticsServer.secretKey,
85+
secondsUntilExpiration: 60 * 30,
86+
});
87+
});
88+
89+
test.afterAll(async () => {
90+
const client = createClerkClient({
91+
secretKey: instanceKeys.get('with-api-keys').sk,
92+
});
93+
94+
await client.m2mTokens.revoke({
95+
m2mTokenId: emailServerM2MToken.id,
96+
});
97+
await client.m2mTokens.revoke({
98+
m2mTokenId: analyticsServerM2MToken.id,
99+
});
100+
await client.machines.delete(emailServer.id);
101+
await client.machines.delete(primaryApiServer.id);
102+
await client.machines.delete(analyticsServer.id);
103+
104+
await app.teardown();
105+
});
106+
107+
test('rejects requests with invalid M2M tokens', async ({ page, context }) => {
108+
const u = createTestUtils({ app, page, context });
109+
110+
const res = await u.page.request.get(app.serverUrl + '/api/protected', {
111+
headers: {
112+
Authorization: `Bearer invalid`,
113+
},
114+
});
115+
expect(res.status()).toBe(401);
116+
expect(await res.text()).toBe('Unauthorized');
117+
118+
const res2 = await u.page.request.get(app.serverUrl + '/api/protected', {
119+
headers: {
120+
Authorization: `Bearer mt_xxx`,
121+
},
122+
});
123+
expect(res2.status()).toBe(401);
124+
expect(await res2.text()).toBe('Unauthorized');
125+
});
126+
127+
test('rejects M2M requests when sender machine lacks access to receiver machine', async ({ page, context }) => {
128+
const u = createTestUtils({ app, page, context });
129+
130+
const res = await u.page.request.get(app.serverUrl + '/api/protected', {
131+
headers: {
132+
Authorization: `Bearer ${analyticsServerM2MToken.secret}`,
133+
},
134+
});
135+
expect(res.status()).toBe(401);
136+
expect(await res.text()).toBe('Unauthorized');
137+
});
138+
139+
test('authorizes M2M requests when sender machine has proper access to receiver machine', async ({
140+
page,
141+
context,
142+
}) => {
143+
const u = createTestUtils({ app, page, context });
144+
145+
// Email server can access primary API server
146+
const res = await u.page.request.get(app.serverUrl + '/api/protected', {
147+
headers: {
148+
Authorization: `Bearer ${emailServerM2MToken.secret}`,
149+
},
150+
});
151+
expect(res.status()).toBe(200);
152+
expect(await res.text()).toBe('Protected response ' + emailServerM2MToken.id);
153+
154+
// Analytics server can access primary API server after adding scope
155+
await u.services.clerk.machines.createScope(analyticsServer.id, primaryApiServer.id);
156+
const m2mToken = await u.services.clerk.m2mTokens.create({
157+
machineSecretKey: analyticsServer.secretKey,
158+
secondsUntilExpiration: 60 * 30,
159+
});
160+
161+
const res2 = await u.page.request.get(app.serverUrl + '/api/protected', {
162+
headers: {
163+
Authorization: `Bearer ${m2mToken.secret}`,
164+
},
165+
});
166+
expect(res2.status()).toBe(200);
167+
expect(await res2.text()).toBe('Protected response ' + m2mToken.id);
168+
await u.services.clerk.m2mTokens.revoke({
169+
m2mTokenId: m2mToken.id,
170+
});
171+
});
172+
});

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"test:integration:generic": "E2E_APP_ID=react.vite.*,next.appRouter.withEmailCodes* pnpm test:integration:base --grep @generic",
4343
"test:integration:handshake": "DISABLE_WEB_SECURITY=true E2E_APP_ID=next.appRouter.sessionsProd1 pnpm test:integration:base --grep @handshake",
4444
"test:integration:localhost": "pnpm test:integration:base --grep @localhost",
45+
"test:integration:machine": "E2E_APP_ID=withMachine.* pnpm test:integration:base --grep @machine",
4546
"test:integration:nextjs": "E2E_APP_ID=next.appRouter.* pnpm test:integration:base --grep @nextjs",
4647
"test:integration:nuxt": "E2E_APP_ID=nuxt.node npm run test:integration:base -- --grep @nuxt",
4748
"test:integration:quickstart": "E2E_APP_ID=quickstart.* pnpm test:integration:base --grep @quickstart",

packages/backend/src/api/endpoints/M2MTokenApi.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ type CreateM2MTokenParams = {
1010
* Custom machine secret key for authentication.
1111
*/
1212
machineSecretKey?: string;
13+
/**
14+
* Number of seconds until the token expires.
15+
*
16+
* @default null - Token does not expire
17+
*/
1318
secondsUntilExpiration?: number | null;
1419
claims?: Record<string, unknown> | null;
1520
};

packages/backend/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ export type {
126126
InstanceSettings,
127127
Invitation,
128128
JwtTemplate,
129+
Machine,
130+
M2MToken,
129131
OauthAccessToken,
130132
OAuthApplication,
131133
Organization,

turbo.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,18 @@
344344
"env": ["CLEANUP", "DEBUG", "E2E_*", "INTEGRATION_INSTANCE_KEYS"],
345345
"inputs": ["integration/**"],
346346
"outputLogs": "new-only"
347+
},
348+
"//#test:integration:machine": {
349+
"dependsOn": [
350+
"@clerk/testing#build",
351+
"@clerk/clerk-js#build",
352+
"@clerk/backend#build",
353+
"@clerk/nextjs#build",
354+
"@clerk/express#build"
355+
],
356+
"env": ["CLEANUP", "DEBUG", "E2E_*", "INTEGRATION_INSTANCE_KEYS"],
357+
"inputs": ["integration/**"],
358+
"outputLogs": "new-only"
347359
}
348360
}
349361
}

0 commit comments

Comments
 (0)