Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .changeset/three-cameras-decide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ jobs:
[
"generic",
"express",
"fastify",
"ap-flows",
"localhost",
"sessions",
Expand Down
19 changes: 19 additions & 0 deletions integration/presets/fastify.ts
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions integration/presets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -16,6 +17,7 @@ export const appConfigs = {
customFlows,
envs,
express,
fastify,
hono,
longRunningApps: createLongRunningApps(),
next,
Expand Down
7 changes: 7 additions & 0 deletions integration/presets/longRunningApps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
*/
Expand Down
13 changes: 13 additions & 0 deletions integration/templates/fastify-vite/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + TS + Fastify</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/client/main.ts"></script>
</body>
</html>
25 changes: 25 additions & 0 deletions integration/templates/fastify-vite/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
30 changes: 30 additions & 0 deletions integration/templates/fastify-vite/src/client/main.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<div id="user-button"></div>
`;

const userButtonDiv = document.getElementById('user-button');

clerk.mountUserButton(userButtonDiv);
} else {
document.getElementById('app')!.innerHTML = `
<div id="sign-in"></div>
`;

const signInDiv = document.getElementById('sign-in');

clerk.mountSignIn(signInDiv);
}
});
7 changes: 7 additions & 0 deletions integration/templates/fastify-vite/src/client/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/// <reference types="vite/client" />
67 changes: 67 additions & 0 deletions integration/templates/fastify-vite/src/server/main.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {};
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();
19 changes: 19 additions & 0 deletions integration/templates/fastify-vite/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
1 change: 1 addition & 0 deletions integration/templates/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
53 changes: 53 additions & 0 deletions integration/tests/fastify/basic.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
84 changes: 84 additions & 0 deletions integration/tests/fastify/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
},
);
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading