Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 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
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
Copy Markdown
Contributor

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
Copy Markdown
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