From a9787ef265b9871047a1da83a4147003a0aa7967 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 8 Aug 2025 23:28:02 -0700 Subject: [PATCH 01/22] chore(repo): Improve express e2e tests --- integration/presets/express.ts | 4 +- integration/templates/express-vite/index.html | 13 +++ .../templates/express-vite/package.json | 22 +++-- .../templates/express-vite/src/client/main.ts | 26 ++++++ .../express-vite/src/client/tsconfig.json | 7 ++ .../express-vite/src/client/vite-env.d.ts | 1 + .../templates/express-vite/src/server/main.ts | 59 +++++------- .../express-vite/src/views/index.ejs | 32 ------- .../express-vite/src/views/protected.ejs | 32 ------- .../express-vite/src/views/sign-in.ejs | 24 ----- .../express-vite/src/views/sign-up.ejs | 24 ----- .../templates/express-vite/tsconfig.json | 18 ++-- .../templates/express-vite/vite.config.ts | 4 - integration/tests/express/basic.test.ts | 89 +++++++++++++++++++ integration/tests/sign-in-flow.test.ts | 11 --- 15 files changed, 178 insertions(+), 188 deletions(-) create mode 100644 integration/templates/express-vite/index.html create mode 100644 integration/templates/express-vite/src/client/main.ts create mode 100644 integration/templates/express-vite/src/client/tsconfig.json create mode 100644 integration/templates/express-vite/src/client/vite-env.d.ts delete mode 100644 integration/templates/express-vite/src/views/index.ejs delete mode 100644 integration/templates/express-vite/src/views/protected.ejs delete mode 100644 integration/templates/express-vite/src/views/sign-in.ejs delete mode 100644 integration/templates/express-vite/src/views/sign-up.ejs delete mode 100644 integration/templates/express-vite/vite.config.ts create mode 100644 integration/tests/express/basic.test.ts diff --git a/integration/presets/express.ts b/integration/presets/express.ts index a96d43bdef7..8ca84ae40ae 100644 --- a/integration/presets/express.ts +++ b/integration/presets/express.ts @@ -6,11 +6,13 @@ import { linkPackage } from './utils'; const vite = applicationConfig() .setName('express-vite') .useTemplate(templates['express-vite']) + .setEnvFormatter('public', key => `VITE_${key}`) .addScript('setup', 'pnpm install') .addScript('dev', 'pnpm dev') .addScript('build', 'pnpm build') .addScript('serve', 'pnpm start') - .addDependency('@clerk/express', constants.E2E_CLERK_VERSION || linkPackage('express')); + .addDependency('@clerk/express', constants.E2E_CLERK_VERSION || linkPackage('express')) + .addDependency('@clerk/clerk-js', constants.E2E_CLERK_VERSION || linkPackage('clerk-js')); export const express = { vite, diff --git a/integration/templates/express-vite/index.html b/integration/templates/express-vite/index.html new file mode 100644 index 00000000000..800a32f8f04 --- /dev/null +++ b/integration/templates/express-vite/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + TS + Express + + +
+ + + diff --git a/integration/templates/express-vite/package.json b/integration/templates/express-vite/package.json index a54f5a2629c..aadf23a9bb0 100644 --- a/integration/templates/express-vite/package.json +++ b/integration/templates/express-vite/package.json @@ -4,22 +4,20 @@ "private": true, "scripts": { "build": "vite build", - "dev": "PORT=$PORT ts-node src/server/main.ts", - "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "dev": "PORT=$PORT tsx src/server/main.ts", "preview": "vite preview --port $PORT --no-open", - "start": "PORT=$PORT ts-node src/server/main.ts" + "start": "PORT=$PORT NODE_ENV=production tsx src/server/main.ts" }, "dependencies": { - "dotenv": "^16.4.7", - "ejs": "^3.1.6", - "express": "^4.18.2", - "ts-node": "^10.9.1", - "vite-express": "^0.20.0" + "dotenv": "^17.2.1", + "express": "^5.1.0", + "tsx": "^4.20.3", + "vite-express": "^0.21.1" }, "devDependencies": { - "@types/express": "^4.17.21", - "@types/node": "^20.9.3", - "typescript": "^5.7.3", - "vite": "^5.0.2" + "@types/express": "^5.0.3", + "@types/node": "^24.2.1", + "typescript": "^5.8.3", + "vite": "^6.3.3" } } diff --git a/integration/templates/express-vite/src/client/main.ts b/integration/templates/express-vite/src/client/main.ts new file mode 100644 index 00000000000..2656c4e02df --- /dev/null +++ b/integration/templates/express-vite/src/client/main.ts @@ -0,0 +1,26 @@ +import { Clerk } from '@clerk/clerk-js'; + +const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY; + +document.addEventListener('DOMContentLoaded', async function () { + const clerk = new Clerk(publishableKey); + await clerk.load(); + + if (clerk.isSignedIn) { + document.getElementById('app')!.innerHTML = ` +
+ `; + + const userButtonDiv = document.getElementById('user-button'); + + clerk.mountUserButton(userButtonDiv); + } else { + document.getElementById('app')!.innerHTML = ` +
+ `; + + const signInDiv = document.getElementById('sign-in'); + + clerk.mountSignIn(signInDiv); + } +}); diff --git a/integration/templates/express-vite/src/client/tsconfig.json b/integration/templates/express-vite/src/client/tsconfig.json new file mode 100644 index 00000000000..e659ea0c37b --- /dev/null +++ b/integration/templates/express-vite/src/client/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler" + } +} diff --git a/integration/templates/express-vite/src/client/vite-env.d.ts b/integration/templates/express-vite/src/client/vite-env.d.ts new file mode 100644 index 00000000000..11f02fe2a00 --- /dev/null +++ b/integration/templates/express-vite/src/client/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/integration/templates/express-vite/src/server/main.ts b/integration/templates/express-vite/src/server/main.ts index b0d30dd30ff..de896e9b9ab 100644 --- a/integration/templates/express-vite/src/server/main.ts +++ b/integration/templates/express-vite/src/server/main.ts @@ -1,63 +1,44 @@ -// Should be at the top of the file - used to load clerk secret key import 'dotenv/config'; -import { clerkMiddleware } from '@clerk/express'; +import { clerkMiddleware, getAuth } from '@clerk/express'; import express from 'express'; import ViteExpress from 'vite-express'; const app = express(); -app.use(clerkMiddleware()); -app.set('view engine', 'ejs'); -app.set('views', 'src/views'); +app.use( + clerkMiddleware({ + publishableKey: process.env.VITE_CLERK_PUBLISHABLE_KEY, + }), +); + +app.get('/api/protected', (req: any, res: any, _next: any) => { + const { userId } = getAuth(req); + if (!userId) { + res.status(401).send('Unauthorized'); + return; + } + + res.send('Protected API response'); +}); const legacyRequireAuth = (req: any, _res: any, next: any) => { if (!req.auth.userId) { - return next(new Error('Unauthenticated')); + return next(new Error('Unauthorized')); } next(); }; -app.get('/api/protected', legacyRequireAuth, (_req: any, res: any, _next: any) => { - return res.send('Protected API response'); -}); - -app.get('/sign-in', (_req: any, res: any) => { - return res.render('sign-in.ejs', { - publishableKey: process.env.CLERK_PUBLISHABLE_KEY, - signInUrl: process.env.CLERK_SIGN_IN_URL, - }); -}); - -app.get('/', (_req: any, res: any) => { - return res.render('index.ejs', { - publishableKey: process.env.CLERK_PUBLISHABLE_KEY, - signInUrl: process.env.CLERK_SIGN_IN_URL, - }); -}); - -app.get('/sign-up', (_req: any, res: any) => { - return res.render('sign-up.ejs', { - publishableKey: process.env.CLERK_PUBLISHABLE_KEY, - signUpUrl: process.env.CLERK_SIGN_UP_URL, - }); -}); - -app.get('/protected', (_req: any, res: any) => { - return res.render('protected.ejs', { - publishableKey: process.env.CLERK_PUBLISHABLE_KEY, - signInUrl: process.env.CLERK_SIGN_IN_URL, - signUpUrl: process.env.CLERK_SIGN_UP_URL, - }); +app.get('/api/legacy/protected', legacyRequireAuth, (_req: any, res: any, _next: any) => { + res.send('Protected API response'); }); // Handle authentication error, otherwise application will crash // @ts-ignore app.use((err, req, res, next) => { if (err) { - console.error(err); - res.status(401).end(); + res.status(401).send('Unauthorized'); return; } diff --git a/integration/templates/express-vite/src/views/index.ejs b/integration/templates/express-vite/src/views/index.ejs deleted file mode 100644 index dcc631b66d1..00000000000 --- a/integration/templates/express-vite/src/views/index.ejs +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - -
-
- - - - diff --git a/integration/templates/express-vite/src/views/protected.ejs b/integration/templates/express-vite/src/views/protected.ejs deleted file mode 100644 index b02972e078b..00000000000 --- a/integration/templates/express-vite/src/views/protected.ejs +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - -
-
- - - - diff --git a/integration/templates/express-vite/src/views/sign-in.ejs b/integration/templates/express-vite/src/views/sign-in.ejs deleted file mode 100644 index 506646ed752..00000000000 --- a/integration/templates/express-vite/src/views/sign-in.ejs +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - -
-
- - - - diff --git a/integration/templates/express-vite/src/views/sign-up.ejs b/integration/templates/express-vite/src/views/sign-up.ejs deleted file mode 100644 index 55fddcf689b..00000000000 --- a/integration/templates/express-vite/src/views/sign-up.ejs +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - -
-
- - - - diff --git a/integration/templates/express-vite/tsconfig.json b/integration/templates/express-vite/tsconfig.json index 48916c00d2f..3df50bf37ec 100644 --- a/integration/templates/express-vite/tsconfig.json +++ b/integration/templates/express-vite/tsconfig.json @@ -2,18 +2,18 @@ "compilerOptions": { "target": "ESNext", "useDefineForClassFields": true, - "lib": ["DOM", "DOM.Iterable", "ESNext"], - "allowJs": false, - "skipLibCheck": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, + "module": "NodeNext", + "lib": ["ESNext", "DOM"], + "moduleResolution": "NodeNext", "strict": true, - "forceConsistentCasingInFileNames": true, - "module": "CommonJS", - "moduleResolution": "Node", "resolveJsonModule": true, "isolatedModules": true, - "noEmit": true + "esModuleInterop": true, + "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "skipLibCheck": true }, "include": ["src"] } diff --git a/integration/templates/express-vite/vite.config.ts b/integration/templates/express-vite/vite.config.ts deleted file mode 100644 index d3102d41a30..00000000000 --- a/integration/templates/express-vite/vite.config.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { defineConfig } from 'vite'; - -// https://vitejs.dev/config/ -export default defineConfig({}); diff --git a/integration/tests/express/basic.test.ts b/integration/tests/express/basic.test.ts new file mode 100644 index 00000000000..9ae660b11c5 --- /dev/null +++ b/integration/tests/express/basic.test.ts @@ -0,0 +1,89 @@ +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../../presets'; +import type { FakeUser } from '../../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('basic tests for @express', ({ app }) => { + test.describe.configure({ mode: 'parallel' }); + + let fakeUser: FakeUser; + + test.beforeAll(async () => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + + await app.teardown(); + }); + + test('authenticates protected routes when user is signed in using getAuth()', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/'); + + await u.po.signIn.waitForMounted(); + await u.po.signIn.setIdentifier(fakeUser.email); + await u.po.signIn.continue(); + await u.po.signIn.setPassword(fakeUser.password); + await u.po.signIn.continue(); + + await u.po.userButton.waitForMounted(); + + const url = new URL('/api/protected', app.serverUrl); + const res = await u.page.request.get(url.toString()); + expect(res.status()).toBe(200); + expect(await res.text()).toBe('Protected API response'); + }); + + test('rejects protected routes when user is not authenticated using getAuth()', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/'); + + await u.po.signIn.waitForMounted(); + + const url = new URL('/api/protected', app.serverUrl); + const res = await u.page.request.get(url.toString()); + expect(res.status()).toBe(401); + expect(await res.text()).toBe('Unauthorized'); + }); + + test('authenticates protected routes when user is signed in using legacy req.auth approach', async ({ + page, + context, + }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/'); + + await u.po.signIn.waitForMounted(); + await u.po.signIn.setIdentifier(fakeUser.email); + await u.po.signIn.continue(); + await u.po.signIn.setPassword(fakeUser.password); + await u.po.signIn.continue(); + + await u.po.userButton.waitForMounted(); + + const url = new URL('/api/legacy/protected', app.serverUrl); + const res = await u.page.request.get(url.toString()); + expect(res.status()).toBe(200); + expect(await res.text()).toBe('Protected API response'); + }); + + test('rejects protected routes when user is not authenticated using legacy req.auth approach', async ({ + page, + context, + }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/'); + + await u.po.signIn.waitForMounted(); + + const url = new URL('/api/legacy/protected', app.serverUrl); + const res = await u.page.request.get(url.toString()); + expect(res.status()).toBe(401); + expect(await res.text()).toBe('Unauthorized'); + }); +}); diff --git a/integration/tests/sign-in-flow.test.ts b/integration/tests/sign-in-flow.test.ts index bd794f5e819..20326de4204 100644 --- a/integration/tests/sign-in-flow.test.ts +++ b/integration/tests/sign-in-flow.test.ts @@ -150,15 +150,4 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('sign in f await u.po.expect.toBeSignedIn(); }); - - test('access protected page @express', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - await u.po.signIn.goTo(); - await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); - await u.po.expect.toBeSignedIn(); - - expect(await u.page.locator("data-test-id='protected-api-response'").count()).toEqual(0); - await u.page.goToRelative('/protected'); - await u.page.isVisible("data-test-id='protected-api-response'"); - }); }); From dc8c7be3ef28d6f999b4e6fb2580c4308b3eb18e Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Fri, 8 Aug 2025 23:29:38 -0700 Subject: [PATCH 02/22] chore: placeholder changeset --- .changeset/eight-walls-flash.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/eight-walls-flash.md diff --git a/.changeset/eight-walls-flash.md b/.changeset/eight-walls-flash.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/eight-walls-flash.md @@ -0,0 +1,2 @@ +--- +--- From 58191b181e18ffb7b708f0ced5cbc9902396e38b Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Sat, 9 Aug 2025 11:44:36 -0700 Subject: [PATCH 03/22] chore(express): Deprecate enableHandshake option --- packages/express/src/authenticateRequest.ts | 15 ++++++--------- packages/express/src/types.ts | 9 +++------ 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/express/src/authenticateRequest.ts b/packages/express/src/authenticateRequest.ts index c74a88c40f4..89448564018 100644 --- a/packages/express/src/authenticateRequest.ts +++ b/packages/express/src/authenticateRequest.ts @@ -87,7 +87,6 @@ const absoluteProxyUrl = (relativeOrAbsoluteUrl: string, baseUrl: string): strin export const authenticateAndDecorateRequest = (options: ClerkMiddlewareOptions = {}): RequestHandler => { const clerkClient = options.clerkClient || defaultClerkClient; - const enableHandshake = options.enableHandshake ?? true; // eslint-disable-next-line @typescript-eslint/no-misused-promises const middleware: RequestHandler = async (request, response, next) => { @@ -102,14 +101,12 @@ export const authenticateAndDecorateRequest = (options: ClerkMiddlewareOptions = options, }); - if (enableHandshake) { - const err = setResponseHeaders(requestState, response); - if (err) { - return next(err); - } - if (response.writableEnded) { - return; - } + const err = setResponseHeaders(requestState, response); + if (err) { + return next(err); + } + if (response.writableEnded) { + return; } // TODO: For developers coming from the clerk-sdk-node package, we gave them examples diff --git a/packages/express/src/types.ts b/packages/express/src/types.ts index ae3273431cf..0eb19ca80fa 100644 --- a/packages/express/src/types.ts +++ b/packages/express/src/types.ts @@ -11,12 +11,9 @@ export type ClerkMiddlewareOptions = AuthenticateRequestOptions & { debug?: boolean; clerkClient?: ClerkClient; /** - * Enables Clerk's handshake flow, which helps verify the session state - * when a session JWT has expired. It issues a 307 redirect to refresh - * the session JWT if the user is still logged in. - * - * This is useful for server-rendered fullstack applications to handle - * expired JWTs securely and maintain session continuity. + * @deprecated This option is deprecated as API requests don't trigger handshake flow. + * Handshake is only relevant for server-rendered applications with page navigation, + * not for API endpoints. This option will be removed in a future version. * * @default true */ From 1707c127ac5b60e5c5a38c2c6ad36e2b2988634a Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Sat, 9 Aug 2025 11:45:36 -0700 Subject: [PATCH 04/22] chore: add changeset --- .changeset/eight-walls-flash.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.changeset/eight-walls-flash.md b/.changeset/eight-walls-flash.md index a845151cc84..6612f7ff444 100644 --- a/.changeset/eight-walls-flash.md +++ b/.changeset/eight-walls-flash.md @@ -1,2 +1,5 @@ --- +'@clerk/express': 'patch' --- + +Deprecates `enableHandshake` option in `clerkMiddleware` as it's not relevant for API requests. Handshake flow only applies to server-rendered applications with page navigation, not API endpoints. This option will be removed in a future version. From 56867b227d23050a2c5a2ec7b7bed5d29866186c Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Sat, 9 Aug 2025 11:46:47 -0700 Subject: [PATCH 05/22] add machine secret key to custom authenticate request --- packages/express/src/authenticateRequest.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/express/src/authenticateRequest.ts b/packages/express/src/authenticateRequest.ts index 89448564018..a804c2eaf50 100644 --- a/packages/express/src/authenticateRequest.ts +++ b/packages/express/src/authenticateRequest.ts @@ -19,6 +19,7 @@ export const authenticateRequest = (opts: AuthenticateRequestParams) => { const env = { ...loadApiEnv(), ...loadClientEnv() }; const secretKey = options?.secretKey || env.secretKey; + const machineSecretKey = options?.machineSecretKey || env.machineSecretKey; const publishableKey = options?.publishableKey || env.publishableKey; const isSatellite = handleValueOrFn(options?.isSatellite, clerkRequest.clerkUrl, env.isSatellite); @@ -40,6 +41,7 @@ export const authenticateRequest = (opts: AuthenticateRequestParams) => { return clerkClient.authenticateRequest(clerkRequest, { audience, secretKey, + machineSecretKey, publishableKey, jwtKey, authorizedParties, From 757ea76ddfd5ab44039a3c1df41fce054a29f2be Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Sat, 9 Aug 2025 11:49:37 -0700 Subject: [PATCH 06/22] add jsdoc --- packages/express/src/authenticateRequest.ts | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/express/src/authenticateRequest.ts b/packages/express/src/authenticateRequest.ts index a804c2eaf50..16e649ae5ae 100644 --- a/packages/express/src/authenticateRequest.ts +++ b/packages/express/src/authenticateRequest.ts @@ -11,6 +11,28 @@ import { satelliteAndMissingProxyUrlAndDomain, satelliteAndMissingSignInUrl } fr import type { AuthenticateRequestParams, ClerkMiddlewareOptions, ExpressRequestWithAuth } from './types'; import { incomingMessageToRequest, loadApiEnv, loadClientEnv } from './utils'; +/** + * @internal + * Authenticates an Express request by wrapping clerkClient.authenticateRequest. + * + * This function converts the Express request object into a standard Web Request object. + * It handles environment configuration, satellite mode validation, and proxy URL resolution. + * + * @param opts - Configuration options for request authentication + * @param opts.clerkClient - The Clerk client instance to use for authentication + * @param opts.request - The Express request object to authenticate + * @param opts.options - Optional middleware configuration options + * @returns Promise - The authentication state of the request + * + * @example + * ```typescript + * const requestState = await authenticateRequest({ + * clerkClient, + * request: req, + * options: { secretKey: 'sk_test_...' } + * }); + * ``` + */ export const authenticateRequest = (opts: AuthenticateRequestParams) => { const { clerkClient, request, options } = opts; const { jwtKey, authorizedParties, audience, acceptsToken } = options || {}; From dc77a3f9c9011ddb2f3f9a87bbcb5a717aefc3d1 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Sat, 9 Aug 2025 11:51:22 -0700 Subject: [PATCH 07/22] fix changeset --- .changeset/eight-walls-flash.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/eight-walls-flash.md b/.changeset/eight-walls-flash.md index 6612f7ff444..5367d87cc3b 100644 --- a/.changeset/eight-walls-flash.md +++ b/.changeset/eight-walls-flash.md @@ -1,5 +1,5 @@ --- -'@clerk/express': 'patch' +'@clerk/express': patch --- Deprecates `enableHandshake` option in `clerkMiddleware` as it's not relevant for API requests. Handshake flow only applies to server-rendered applications with page navigation, not API endpoints. This option will be removed in a future version. From ebff1499dbce2bba485b01b3ad66f27575e3efad Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Sat, 9 Aug 2025 11:53:30 -0700 Subject: [PATCH 08/22] update jsdoc --- packages/express/src/authenticateRequest.ts | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/packages/express/src/authenticateRequest.ts b/packages/express/src/authenticateRequest.ts index 16e649ae5ae..f3ccee9a039 100644 --- a/packages/express/src/authenticateRequest.ts +++ b/packages/express/src/authenticateRequest.ts @@ -13,25 +13,13 @@ import { incomingMessageToRequest, loadApiEnv, loadClientEnv } from './utils'; /** * @internal - * Authenticates an Express request by wrapping clerkClient.authenticateRequest. - * - * This function converts the Express request object into a standard Web Request object. - * It handles environment configuration, satellite mode validation, and proxy URL resolution. + * Authenticates an Express request by wrapping clerkClient.authenticateRequest and + * converts the express request object into a standard web request object * * @param opts - Configuration options for request authentication * @param opts.clerkClient - The Clerk client instance to use for authentication * @param opts.request - The Express request object to authenticate * @param opts.options - Optional middleware configuration options - * @returns Promise - The authentication state of the request - * - * @example - * ```typescript - * const requestState = await authenticateRequest({ - * clerkClient, - * request: req, - * options: { secretKey: 'sk_test_...' } - * }); - * ``` */ export const authenticateRequest = (opts: AuthenticateRequestParams) => { const { clerkClient, request, options } = opts; From 4f0bd61d1690a1ded49733b501321fe0a9056678 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Sat, 9 Aug 2025 12:11:32 -0700 Subject: [PATCH 09/22] add back deprecated option but keep deprecated status --- packages/express/src/authenticateRequest.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/express/src/authenticateRequest.ts b/packages/express/src/authenticateRequest.ts index f3ccee9a039..3a6532e7f34 100644 --- a/packages/express/src/authenticateRequest.ts +++ b/packages/express/src/authenticateRequest.ts @@ -99,6 +99,7 @@ const absoluteProxyUrl = (relativeOrAbsoluteUrl: string, baseUrl: string): strin export const authenticateAndDecorateRequest = (options: ClerkMiddlewareOptions = {}): RequestHandler => { const clerkClient = options.clerkClient || defaultClerkClient; + const enableHandshake = options.enableHandshake ?? true; // eslint-disable-next-line @typescript-eslint/no-misused-promises const middleware: RequestHandler = async (request, response, next) => { @@ -113,12 +114,14 @@ export const authenticateAndDecorateRequest = (options: ClerkMiddlewareOptions = options, }); - const err = setResponseHeaders(requestState, response); - if (err) { - return next(err); - } - if (response.writableEnded) { - return; + if (enableHandshake) { + const err = setResponseHeaders(requestState, response); + if (err) { + return next(err); + } + if (response.writableEnded) { + return; + } } // TODO: For developers coming from the clerk-sdk-node package, we gave them examples From ebd8b968bd4da7f163a5032bf45622174c169006 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Sat, 9 Aug 2025 12:12:55 -0700 Subject: [PATCH 10/22] update changeset --- .changeset/eight-walls-flash.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/eight-walls-flash.md b/.changeset/eight-walls-flash.md index 5367d87cc3b..f694cc99acf 100644 --- a/.changeset/eight-walls-flash.md +++ b/.changeset/eight-walls-flash.md @@ -2,4 +2,4 @@ '@clerk/express': patch --- -Deprecates `enableHandshake` option in `clerkMiddleware` as it's not relevant for API requests. Handshake flow only applies to server-rendered applications with page navigation, not API endpoints. This option will be removed in a future version. +Deprecates `enableHandshake` option in `clerkMiddleware`. This option is unnecessary for API requests since they don't trigger handshake flows. The option will be removed in a future version. \ No newline at end of file From 398c3cf0259799e364bc0609e18dbf906dcbba84 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 8 Aug 2025 22:19:03 -0700 Subject: [PATCH 11/22] init m2m e2e --- integration/presets/longRunningApps.ts | 5 + .../templates/express-vite/package.json | 15 +-- integration/tests/machine-auth/m2m.test.ts | 92 +++++++++++++++++++ package.json | 1 + packages/backend/src/index.ts | 2 + 5 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 integration/tests/machine-auth/m2m.test.ts diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index ec173fd86ce..cf994708cbc 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -47,6 +47,11 @@ 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 + */ + { id: 'withMachine.express.vite', config: express.vite, env: envs.withAPIKeys }, + /** * Vite apps - basic flows */ diff --git a/integration/templates/express-vite/package.json b/integration/templates/express-vite/package.json index aadf23a9bb0..d7d1929c53a 100644 --- a/integration/templates/express-vite/package.json +++ b/integration/templates/express-vite/package.json @@ -6,18 +6,19 @@ "build": "vite build", "dev": "PORT=$PORT tsx src/server/main.ts", "preview": "vite preview --port $PORT --no-open", - "start": "PORT=$PORT NODE_ENV=production tsx src/server/main.ts" + "start": "PORT=$PORT tsx src/server/main.ts" }, "dependencies": { - "dotenv": "^17.2.1", - "express": "^5.1.0", - "tsx": "^4.20.3", + "dotenv": "^16.4.7", + "ejs": "^3.1.6", + "express": "^4.18.2", + "tsx": "^4.19.3", "vite-express": "^0.21.1" }, "devDependencies": { - "@types/express": "^5.0.3", - "@types/node": "^24.2.1", - "typescript": "^5.8.3", + "@types/express": "^4.17.21", + "@types/node": "^20.9.3", + "typescript": "^5.7.3", "vite": "^6.3.3" } } diff --git a/integration/tests/machine-auth/m2m.test.ts b/integration/tests/machine-auth/m2m.test.ts new file mode 100644 index 00000000000..1263c810f9d --- /dev/null +++ b/integration/tests/machine-auth/m2m.test.ts @@ -0,0 +1,92 @@ +import type { M2MToken, Machine } from '@clerk/backend'; +import { expect, test } from '@playwright/test'; + +import type { Application } from '../../models/application'; +import { appConfigs } from '../../presets'; +import { createTestUtils } from '../../testUtils'; + +test.describe('M2M Token Authentication @machine', () => { + test.describe.configure({ mode: 'parallel' }); + let app: Application; + + let primaryApiServer: Machine; + let emailServer: Machine; + let m2mToken: M2MToken; + + test.beforeAll(async () => { + 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()); + + app.get('/api/protected', (req, res) => { + const { machineId } = getAuth(req, { acceptsToken: 'm2m_token' }); + + if (machineId) { + return res.send('Authorized'); + } else { + return res.status(401).send('Unauthorized'); + } + }); + + const port = parseInt(process.env.PORT as string) || 3002; + ViteExpress.listen(app, port, () => console.log(\`Server is listening on port \${port}...\`)); + `, + ) + .commit(); + + await app.setup(); + await app.withEnv(appConfigs.envs.withAPIKeys); + await app.dev(); + + const u = createTestUtils({ app }); + + try { + primaryApiServer = await u.services.clerk.machines.create({ + name: 'Primary API Server', + }); + + emailServer = await u.services.clerk.machines.create({ + name: 'Email Server', + scopedMachines: [primaryApiServer.id], + }); + m2mToken = await u.services.clerk.m2mTokens.create({ + machineSecretKey: emailServer.secretKey, + secondsUntilExpiration: 60 * 30, // 30 minutes + }); + } catch (error) { + console.error('ERROR IN BEFORE ALL', error); + } + }); + + test.afterAll(async () => { + const u = createTestUtils({ app }); + if (emailServer) { + await u.services.clerk.machines.delete(emailServer.id); + } + if (primaryApiServer) { + await u.services.clerk.machines.delete(primaryApiServer.id); + } + if (m2mToken) { + await u.services.clerk.m2mTokens.revoke({ + m2mTokenId: m2mToken.id, + }); + } + await app.teardown(); + }); + + test('should return 401 if no M2M token is provided', async ({ request }) => { + const url = new URL('/api/protected', app.serverUrl); + const res = await request.get(url.toString()); + expect(res.status()).toBe(401); + }); +}); 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/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, From 7fad6cad1419896bf576846c906b39aafd0d9ee4 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Sat, 9 Aug 2025 11:14:34 -0700 Subject: [PATCH 12/22] chore(repo): Add machine-to-machine e2e tests --- integration/tests/machine-auth/m2m.test.ts | 163 +++++++++++++++------ 1 file changed, 120 insertions(+), 43 deletions(-) diff --git a/integration/tests/machine-auth/m2m.test.ts b/integration/tests/machine-auth/m2m.test.ts index 1263c810f9d..a16d18bad00 100644 --- a/integration/tests/machine-auth/m2m.test.ts +++ b/integration/tests/machine-auth/m2m.test.ts @@ -1,19 +1,32 @@ -import type { M2MToken, Machine } from '@clerk/backend'; +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('M2M Token Authentication @machine', () => { +test.describe('machine-to-machine auth @machine', () => { test.describe.configure({ mode: 'parallel' }); let app: Application; - let primaryApiServer: Machine; let emailServer: Machine; - let m2mToken: M2MToken; + 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( @@ -26,67 +39,131 @@ test.describe('M2M Token Authentication @machine', () => { const app = express(); - app.use(clerkMiddleware()); + 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) { - return res.send('Authorized'); - } else { - return res.status(401).send('Unauthorized'); + if (!machineId) { + res.status(401).send('Unauthorized'); + return; } + + res.send('Protected response'); }); - const port = parseInt(process.env.PORT as string) || 3002; - ViteExpress.listen(app, port, () => console.log(\`Server is listening on port \${port}...\`)); + ViteExpress.listen(app, process.env.PORT, () => console.log('Server started')); `, ) .commit(); await app.setup(); - await app.withEnv(appConfigs.envs.withAPIKeys); + + // 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 }); - try { - primaryApiServer = await u.services.clerk.machines.create({ - name: 'Primary API Server', - }); - - emailServer = await u.services.clerk.machines.create({ - name: 'Email Server', - scopedMachines: [primaryApiServer.id], - }); - m2mToken = await u.services.clerk.m2mTokens.create({ - machineSecretKey: emailServer.secretKey, - secondsUntilExpiration: 60 * 30, // 30 minutes - }); - } catch (error) { - console.error('ERROR IN BEFORE ALL', error); - } + // 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 }); - if (emailServer) { - await u.services.clerk.machines.delete(emailServer.id); - } - if (primaryApiServer) { - await u.services.clerk.machines.delete(primaryApiServer.id); - } - if (m2mToken) { - await u.services.clerk.m2mTokens.revoke({ - m2mTokenId: m2mToken.id, - }); - } + + 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('should return 401 if no M2M token is provided', async ({ request }) => { - const url = new URL('/api/protected', app.serverUrl); - const res = await request.get(url.toString()); + 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'); + }); + + 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, + }); }); }); From 48bc89304fb43775b17dd5bd1afc77cb29b54194 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Sat, 9 Aug 2025 11:41:03 -0700 Subject: [PATCH 13/22] improvements --- integration/tests/machine-auth/m2m.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/integration/tests/machine-auth/m2m.test.ts b/integration/tests/machine-auth/m2m.test.ts index a16d18bad00..981aa65cc73 100644 --- a/integration/tests/machine-auth/m2m.test.ts +++ b/integration/tests/machine-auth/m2m.test.ts @@ -119,6 +119,14 @@ test.describe('machine-to-machine auth @machine', () => { }); 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 }) => { From 2fa0b44cf316f5e19c1110dc8f3183c6329ea4d8 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Sat, 9 Aug 2025 12:19:19 -0700 Subject: [PATCH 14/22] clean up long running apps --- integration/presets/longRunningApps.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index cf994708cbc..b46ba8ba506 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -49,6 +49,7 @@ export const createLongRunningApps = () => { /** * 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 }, From 98b79d7daa521a0931f1be9958564bde59823cb6 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Sat, 9 Aug 2025 12:26:23 -0700 Subject: [PATCH 15/22] add turbo entry for machine tests --- turbo.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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" } } } From 91a7e568213aadc48a453493bbba36a0440ae0ce Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Sat, 9 Aug 2025 12:28:10 -0700 Subject: [PATCH 16/22] revert package versions --- integration/templates/express-vite/package.json | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/integration/templates/express-vite/package.json b/integration/templates/express-vite/package.json index d7d1929c53a..aadf23a9bb0 100644 --- a/integration/templates/express-vite/package.json +++ b/integration/templates/express-vite/package.json @@ -6,19 +6,18 @@ "build": "vite build", "dev": "PORT=$PORT tsx src/server/main.ts", "preview": "vite preview --port $PORT --no-open", - "start": "PORT=$PORT tsx src/server/main.ts" + "start": "PORT=$PORT NODE_ENV=production tsx src/server/main.ts" }, "dependencies": { - "dotenv": "^16.4.7", - "ejs": "^3.1.6", - "express": "^4.18.2", - "tsx": "^4.19.3", + "dotenv": "^17.2.1", + "express": "^5.1.0", + "tsx": "^4.20.3", "vite-express": "^0.21.1" }, "devDependencies": { - "@types/express": "^4.17.21", - "@types/node": "^20.9.3", - "typescript": "^5.7.3", + "@types/express": "^5.0.3", + "@types/node": "^24.2.1", + "typescript": "^5.8.3", "vite": "^6.3.3" } } From 45a490d06dfb37e68c1935d5c07f6bac387784d2 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 11 Aug 2025 10:21:28 -0700 Subject: [PATCH 17/22] ci: add machine to tests --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) 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: From 268c92b9484afd648d06c24f287da51a78fbca5e Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 11 Aug 2025 13:01:14 -0700 Subject: [PATCH 18/22] simplify test --- integration/tests/machine-auth/m2m.test.ts | 46 +++++++++++----------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/integration/tests/machine-auth/m2m.test.ts b/integration/tests/machine-auth/m2m.test.ts index 981aa65cc73..0ccb57439d7 100644 --- a/integration/tests/machine-auth/m2m.test.ts +++ b/integration/tests/machine-auth/m2m.test.ts @@ -33,26 +33,26 @@ test.describe('machine-to-machine auth @machine', () => { 'src/server/main.ts', () => ` import 'dotenv/config'; - import { clerkMiddleware, getAuth } from '@clerk/express'; + import { clerkClient } 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) { + app.use(async (req, res, next) => { + const secret = req.get('Authorization')?.split(' ')[1] || ''; + + try { + await clerkClient.m2mTokens.verifySecret({ secret }); + } catch (error) { res.status(401).send('Unauthorized'); return; } + next(); + }); + + app.get('/api/protected', (req, res) => { res.send('Protected response'); }); @@ -70,41 +70,41 @@ test.describe('machine-to-machine auth @machine', () => { 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({ + emailServer = await client.machines.create({ name: `${fakeCompanyName} Email Server`, scopedMachines: [primaryApiServer.id], }); - emailServerM2MToken = await u.services.clerk.m2mTokens.create({ + emailServerM2MToken = await client.m2mTokens.create({ machineSecretKey: emailServer.secretKey, secondsUntilExpiration: 60 * 30, }); // Analytics server cannot access primary API server - analyticsServer = await u.services.clerk.machines.create({ + analyticsServer = await client.machines.create({ name: `${fakeCompanyName} Analytics Server`, // No scoped machines }); - analyticsServerM2MToken = await u.services.clerk.m2mTokens.create({ + analyticsServerM2MToken = await client.m2mTokens.create({ machineSecretKey: analyticsServer.secretKey, secondsUntilExpiration: 60 * 30, }); }); test.afterAll(async () => { - const u = createTestUtils({ app }); + const client = createClerkClient({ + secretKey: instanceKeys.get('with-api-keys').sk, + }); - await u.services.clerk.m2mTokens.revoke({ + await client.m2mTokens.revoke({ m2mTokenId: emailServerM2MToken.id, }); - await u.services.clerk.m2mTokens.revoke({ + await client.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 client.machines.delete(emailServer.id); + await client.machines.delete(primaryApiServer.id); + await client.machines.delete(analyticsServer.id); await app.teardown(); }); From 8807419a02632886dedabcfcefdb85da23aa290a Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 11 Aug 2025 16:32:34 -0700 Subject: [PATCH 19/22] fix tests --- integration/tests/machine-auth/m2m.test.ts | 23 +++++++++------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/integration/tests/machine-auth/m2m.test.ts b/integration/tests/machine-auth/m2m.test.ts index 0ccb57439d7..c495a1b26f9 100644 --- a/integration/tests/machine-auth/m2m.test.ts +++ b/integration/tests/machine-auth/m2m.test.ts @@ -39,24 +39,19 @@ test.describe('machine-to-machine auth @machine', () => { const app = express(); - app.use(async (req, res, next) => { - const secret = req.get('Authorization')?.split(' ')[1] || ''; + app.get('/api/protected', async (req, res) => { + const secret = req.get('Authorization')?.split(' ')[1]; try { - await clerkClient.m2mTokens.verifySecret({ secret }); - } catch (error) { + const m2mToken = await clerkClient.m2mTokens.verifySecret({ secret }); + res.send('Protected response ' + m2mToken.id); + } catch { res.status(401).send('Unauthorized'); - return; } - - next(); - }); - - app.get('/api/protected', (req, res) => { - res.send('Protected response'); }); - ViteExpress.listen(app, process.env.PORT, () => console.log('Server started')); + const port = parseInt(process.env.PORT as string) || 3002; + ViteExpress.listen(app, port, () => console.log('Server started')); `, ) .commit(); @@ -154,7 +149,7 @@ test.describe('machine-to-machine auth @machine', () => { }, }); expect(res.status()).toBe(200); - expect(await res.text()).toBe('Protected response'); + 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); @@ -169,7 +164,7 @@ test.describe('machine-to-machine auth @machine', () => { }, }); expect(res2.status()).toBe(200); - expect(await res2.text()).toBe('Protected response'); + expect(await res2.text()).toBe('Protected response ' + m2mToken.id); await u.services.clerk.m2mTokens.revoke({ m2mTokenId: m2mToken.id, }); From 2dff52bf9811ebe48a084641922748bc3814dd03 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 11 Aug 2025 16:35:34 -0700 Subject: [PATCH 20/22] add jsdoc description for secondsUntilExpiration --- packages/backend/src/api/endpoints/M2MTokenApi.ts | 5 +++++ 1 file changed, 5 insertions(+) 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; }; From b628254eaaf92400b29d5a947fffef2c5c503ece Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Mon, 11 Aug 2025 16:37:24 -0700 Subject: [PATCH 21/22] chore: add changeset --- .changeset/nasty-colts-travel.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/nasty-colts-travel.md diff --git a/.changeset/nasty-colts-travel.md b/.changeset/nasty-colts-travel.md new file mode 100644 index 00000000000..3673cd3f37f --- /dev/null +++ b/.changeset/nasty-colts-travel.md @@ -0,0 +1,5 @@ +--- +"@clerk/backend": patch +--- + +Exports `Machine` and `M2MToken` resource classes From 53a9eb8acb5147820522ff82444c200b10f9518b Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Tue, 12 Aug 2025 07:26:25 -0700 Subject: [PATCH 22/22] Apply suggestion from @panteliselef Co-authored-by: panteliselef --- .changeset/nasty-colts-travel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/nasty-colts-travel.md b/.changeset/nasty-colts-travel.md index 3673cd3f37f..f17a9c22575 100644 --- a/.changeset/nasty-colts-travel.md +++ b/.changeset/nasty-colts-travel.md @@ -1,5 +1,5 @@ --- -"@clerk/backend": patch +"@clerk/backend": minor --- Exports `Machine` and `M2MToken` resource classes