diff --git a/.changeset/three-cameras-decide.md b/.changeset/three-cameras-decide.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/three-cameras-decide.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cab3138702e..da6b80dcfed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -292,6 +292,7 @@ jobs: [ "generic", "express", + "fastify", "ap-flows", "localhost", "sessions", diff --git a/integration/presets/fastify.ts b/integration/presets/fastify.ts new file mode 100644 index 00000000000..6de2cc43448 --- /dev/null +++ b/integration/presets/fastify.ts @@ -0,0 +1,19 @@ +import { applicationConfig } from '../models/applicationConfig'; +import { templates } from '../templates'; +import { PKGLAB } from './utils'; + +const vite = applicationConfig() + .setName('fastify-vite') + .useTemplate(templates['fastify-vite']) + .setEnvFormatter('public', key => `VITE_${key}`) + .addScript('setup', 'pnpm install') + .addScript('dev', 'pnpm dev') + .addScript('build', 'pnpm build') + .addScript('serve', 'pnpm start') + .addDependency('@clerk/fastify', PKGLAB) + .addDependency('@clerk/clerk-js', PKGLAB) + .addDependency('@clerk/ui', PKGLAB); + +export const fastify = { + vite, +} as const; diff --git a/integration/presets/index.ts b/integration/presets/index.ts index 246dfbac48e..83c27057a82 100644 --- a/integration/presets/index.ts +++ b/integration/presets/index.ts @@ -3,6 +3,7 @@ import { customFlows } from './custom-flows'; import { envs, instanceKeys } from './envs'; import { expo } from './expo'; import { express } from './express'; +import { fastify } from './fastify'; import { hono } from './hono'; import { createLongRunningApps } from './longRunningApps'; import { next } from './next'; @@ -16,6 +17,7 @@ export const appConfigs = { customFlows, envs, express, + fastify, hono, longRunningApps: createLongRunningApps(), next, diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index 9f4caa4b16d..3da5e75c82f 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -4,6 +4,7 @@ import { astro } from './astro'; import { envs } from './envs'; import { expo } from './expo'; import { express } from './express'; +import { fastify } from './fastify'; import { hono } from './hono'; import { next } from './next'; import { nuxt } from './nuxt'; @@ -82,6 +83,12 @@ export const createLongRunningApps = () => { { id: 'react-router.node', config: reactRouter.reactRouterNode, env: envs.withEmailCodes }, { id: 'express.vite.withEmailCodes', config: express.vite, env: envs.withEmailCodes }, + /** + * Fastify apps + */ + { id: 'fastify.vite.withEmailCodes', config: fastify.vite, env: envs.withEmailCodes }, + { id: 'fastify.vite.withEmailCodesProxy', config: fastify.vite, env: envs.withEmailCodesProxy }, + /** * Hono apps */ diff --git a/integration/templates/fastify-vite/index.html b/integration/templates/fastify-vite/index.html new file mode 100644 index 00000000000..a38207521b9 --- /dev/null +++ b/integration/templates/fastify-vite/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + TS + Fastify + + +
+ + + diff --git a/integration/templates/fastify-vite/package.json b/integration/templates/fastify-vite/package.json new file mode 100644 index 00000000000..b4a73f5276d --- /dev/null +++ b/integration/templates/fastify-vite/package.json @@ -0,0 +1,25 @@ +{ + "name": "fastify-vite", + "version": "0.0.0", + "private": true, + "scripts": { + "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" + }, + "dependencies": { + "dotenv": "^17.2.1", + "express": "^5.1.0", + "fastify": "^5.7.2", + "fastify-plugin": "^5.0.1", + "tsx": "^4.20.3", + "vite-express": "^0.21.1" + }, + "devDependencies": { + "@types/express": "^5.0.3", + "@types/node": "^24.2.1", + "typescript": "^5.8.3", + "vite": "^6.3.3" + } +} diff --git a/integration/templates/fastify-vite/src/client/main.ts b/integration/templates/fastify-vite/src/client/main.ts new file mode 100644 index 00000000000..7dcc4eb0a36 --- /dev/null +++ b/integration/templates/fastify-vite/src/client/main.ts @@ -0,0 +1,30 @@ +import { Clerk } from '@clerk/clerk-js'; +import { ClerkUI } from '@clerk/ui/entry'; + +const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY; + +document.addEventListener('DOMContentLoaded', async function () { + const clerk = new Clerk(publishableKey); + + await clerk.load({ + ui: { ClerkUI }, + }); + + 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/fastify-vite/src/client/tsconfig.json b/integration/templates/fastify-vite/src/client/tsconfig.json new file mode 100644 index 00000000000..e659ea0c37b --- /dev/null +++ b/integration/templates/fastify-vite/src/client/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler" + } +} diff --git a/integration/templates/fastify-vite/src/client/vite-env.d.ts b/integration/templates/fastify-vite/src/client/vite-env.d.ts new file mode 100644 index 00000000000..11f02fe2a00 --- /dev/null +++ b/integration/templates/fastify-vite/src/client/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/integration/templates/fastify-vite/src/server/main.ts b/integration/templates/fastify-vite/src/server/main.ts new file mode 100644 index 00000000000..800bb0bb3c3 --- /dev/null +++ b/integration/templates/fastify-vite/src/server/main.ts @@ -0,0 +1,67 @@ +import 'dotenv/config'; + +import { clerkPlugin, getAuth } from '@clerk/fastify'; +import express from 'express'; +import Fastify from 'fastify'; +import ViteExpress from 'vite-express'; + +async function start() { + const fastify = Fastify(); + + const proxyEnabled = process.env.CLERK_PROXY_ENABLED === 'true'; + + fastify.register(clerkPlugin, { + publishableKey: process.env.VITE_CLERK_PUBLISHABLE_KEY, + ...(proxyEnabled ? { frontendApiProxy: { enabled: true } } : {}), + }); + + fastify.get('/protected', async (request, reply) => { + const { userId } = getAuth(request); + if (!userId) { + return reply.code(401).send('Unauthorized'); + } + + return reply.send('Protected API response'); + }); + + // Start Fastify on an internal port, then bridge /api requests from Express + await fastify.listen({ port: 0, host: '127.0.0.1' }); + const fastifyAddress = fastify.server.address(); + const fastifyPort = typeof fastifyAddress === 'object' ? fastifyAddress?.port : 0; + + const expressApp = express(); + + // Proxy /api requests to Fastify + expressApp.use('/api', async (req: any, res: any) => { + const url = `http://127.0.0.1:${fastifyPort}${req.url}`; + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === 'string') { + headers[key] = value; + } else if (Array.isArray(value)) { + headers[key] = value.join(', '); + } + } + + const response = await fetch(url, { + method: req.method, + headers, + body: ['GET', 'HEAD'].includes(req.method) ? undefined : req, + // @ts-expect-error duplex needed for streaming request bodies + duplex: ['GET', 'HEAD'].includes(req.method) ? undefined : 'half', + redirect: 'manual', + }); + + res.status(response.status); + response.headers.forEach((value: string, key: string) => { + res.setHeader(key, value); + }); + const body = await response.arrayBuffer(); + res.send(Buffer.from(body)); + }); + + const port = parseInt(process.env.PORT as string) || 3002; + ViteExpress.listen(expressApp, port, () => console.log(`Server is listening on port ${port}...`)); +} + +start(); diff --git a/integration/templates/fastify-vite/tsconfig.json b/integration/templates/fastify-vite/tsconfig.json new file mode 100644 index 00000000000..3df50bf37ec --- /dev/null +++ b/integration/templates/fastify-vite/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "NodeNext", + "lib": ["ESNext", "DOM"], + "moduleResolution": "NodeNext", + "strict": true, + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/integration/templates/index.ts b/integration/templates/index.ts index 81329df1aa7..d073d7fa58b 100644 --- a/integration/templates/index.ts +++ b/integration/templates/index.ts @@ -12,6 +12,7 @@ export const templates = { 'react-cra': resolve(__dirname, './react-cra'), 'react-vite': resolve(__dirname, './react-vite'), 'express-vite': resolve(__dirname, './express-vite'), + 'fastify-vite': resolve(__dirname, './fastify-vite'), 'hono-vite': resolve(__dirname, './hono-vite'), 'elements-next': resolve(__dirname, './elements-next'), 'astro-node': resolve(__dirname, './astro-node'), diff --git a/integration/tests/fastify/basic.test.ts b/integration/tests/fastify/basic.test.ts new file mode 100644 index 00000000000..c33e6061071 --- /dev/null +++ b/integration/tests/fastify/basic.test.ts @@ -0,0 +1,53 @@ +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 @fastify', ({ 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'); + }); +}); diff --git a/integration/tests/fastify/proxy.test.ts b/integration/tests/fastify/proxy.test.ts new file mode 100644 index 00000000000..84f6de0818a --- /dev/null +++ b/integration/tests/fastify/proxy.test.ts @@ -0,0 +1,84 @@ +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../../presets'; +import type { FakeUser } from '../../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodesProxy] })( + 'frontend API proxy tests for @fastify', + ({ 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('protected routes still require auth when proxy is enabled', 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('authenticated requests work with proxy enabled', 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('handshake redirect uses forwarded headers for proxyUrl, not localhost', async () => { + // This test proves that the SDK must derive proxyUrl from x-forwarded-* headers. + // When a reverse proxy sits in front of the app, the raw request URL is localhost, + // but the handshake redirect must point to the public origin. + // + // We simulate a behind-proxy scenario by sending x-forwarded-proto and x-forwarded-host + // headers, with a __client_uat cookie (non-zero) but no session cookie, which forces + // a handshake. The handshake redirect Location should use the forwarded origin. + const url = new URL('/api/protected', app.serverUrl); + const res = await fetch(url.toString(), { + headers: { + 'x-forwarded-proto': 'https', + 'x-forwarded-host': 'myapp.example.com', + 'sec-fetch-dest': 'document', + Accept: 'text/html', + Cookie: '__clerk_db_jwt=needstobeset; __client_uat=1', + }, + redirect: 'manual', + }); + + // The server should respond with a 307 handshake redirect + expect(res.status).toBe(307); + const location = res.headers.get('location') ?? ''; + // The redirect must point to the public origin (from forwarded headers), + // NOT to http://localhost:PORT. If the SDK uses requestUrl.origin instead + // of forwarded headers, this assertion will fail. + expect(location).toContain('https://myapp.example.com'); + expect(location).not.toContain('localhost'); + }); + }, +); diff --git a/package.json b/package.json index 900b864776b..920a4b4eae3 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "test:integration:deployment:nextjs": "pnpm playwright test --config integration/playwright.deployments.config.ts", "test:integration:expo-web:disabled": "E2E_APP_ID=expo.expo-web pnpm test:integration:base --grep @expo-web", "test:integration:express": "E2E_APP_ID=express.* pnpm test:integration:base --grep @express", + "test:integration:fastify": "E2E_APP_ID=fastify.* pnpm test:integration:base --grep @fastify", "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_1_ENV_KEY=sessions-prod-1 E2E_SESSIONS_APP_1_HOST=multiple-apps-e2e.clerk.app pnpm test:integration:base --grep @handshake", "test:integration:handshake:staging": "DISABLE_WEB_SECURITY=true E2E_APP_1_ENV_KEY=clerkstage-sessions-prod-1 E2E_SESSIONS_APP_1_HOST=clerkstage-sessions-prod-1-e2e.clerk.app pnpm test:integration:base --grep @handshake",