From b434918b5922158bce2fab0919fa7ba8b1ea6449 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20P=C3=A1scoa?= Date: Mon, 12 Oct 2026 00:10:22 +0100 Subject: [PATCH] feat: configure secure Cloudflare Worker Previews --- README.md | 18 +++++++++++++++ package.json | 1 + pnpm-lock.yaml | 3 +++ src/env.ts | 2 ++ src/server/auth.ts | 24 +++++++++++++++++++- src/server/preview-access.ts | 2 ++ worker/index.ts | 44 ++++++++++++++++++++++++++++++++++++ wrangler.example.jsonc | 31 +++++++++++++++++++++++++ 8 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 src/server/preview-access.ts diff --git a/README.md b/README.md index 43487ecb2..53df55798 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,24 @@ a domain on Cloudflare, and a Google OAuth client. Local development: put the same values in `.dev.vars` (git-ignored, with `NEXTAUTH_URL=http://localhost:8787`), run `npx wrangler d1 migrations apply splitpro --local`, then `npx wrangler dev --port 8787`. +## Cloudflare Worker Previews + +`wrangler.example.jsonc` includes a `previews` block with non-production settings and D1/R2 bindings. +Run `npx wrangler preview --name ` from the branch you want to preview; Wrangler +defaults the name to the current Git branch. Set `NEXTAUTH_URL` in that branch's `previews.vars` to +its actual Preview URL. Preview URLs are public unless protected by Cloudflare Access. + +The example D1 ID and Access audience are placeholders. Create a non-production D1 database and R2 +bucket, then set their IDs/names in the `previews` block. D1/R2 resources are shared whenever +previews use the same ID/name. For per-branch isolation, create separate resources for each branch, +update that branch's `previews` bindings and D1 migration target, and apply migrations to that D1 +before deploying its Preview. Do not point Preview bindings at production resources. + +When protecting a Preview with Cloudflare Access, set `PREVIEW_ACCESS_AUTH=true`, set +`PREVIEW_ACCESS_AUD` to the Access application's audience tag, and ensure the Access policy covers +the Preview hostname. The Worker verifies the Access identity and maps it to an app session; this +setting belongs only in `previews.vars`, not production `vars`. + ## Known gaps - Test coverage is small and focused on the riskiest paths (see below). There are no unit or component tests. diff --git a/package.json b/package.json index d20807c68..f53deda62 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "i18next-browser-languagedetector": "^8.2.1", "i18next-http-backend": "^3.0.6", "input-otp": "^1.2.3", + "jose": "6.2.12", "lucide-react": "^0.544.0", "nanoid": "^5.0.6", "next-i18next": "^15.4.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56d4a93e3..e42f12bcb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -94,6 +94,9 @@ importers: input-otp: specifier: ^1.2.3 version: 1.4.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + jose: + specifier: 6.2.12 + version: 6.2.12 lucide-react: specifier: ^0.544.0 version: 0.544.0(react@19.2.1) diff --git a/src/env.ts b/src/env.ts index 565d4397a..017f2b93b 100644 --- a/src/env.ts +++ b/src/env.ts @@ -30,6 +30,7 @@ export const env = createEnv({ ENABLE_SENDING_INVITES: z.boolean(), DISABLE_EMAIL_SIGNUP: z.boolean(), INVITE_ONLY: z.boolean(), + PREVIEW_ACCESS_AUTH: z.boolean().default(false), FROM_EMAIL: z.string().optional(), EMAIL_SERVER_HOST: z.string().optional(), EMAIL_SERVER_PORT: z.string().optional(), @@ -99,6 +100,7 @@ export const env = createEnv({ ENABLE_SENDING_INVITES: parseEnvBoolean(process.env.ENABLE_SENDING_INVITES), DISABLE_EMAIL_SIGNUP: parseEnvBoolean(process.env.DISABLE_EMAIL_SIGNUP), INVITE_ONLY: parseEnvBoolean(process.env.INVITE_ONLY), + PREVIEW_ACCESS_AUTH: parseEnvBoolean(process.env.PREVIEW_ACCESS_AUTH, false), FROM_EMAIL: process.env.FROM_EMAIL, EMAIL_SERVER_HOST: process.env.EMAIL_SERVER_HOST, EMAIL_SERVER_PORT: process.env.EMAIL_SERVER_PORT, diff --git a/src/server/auth.ts b/src/server/auth.ts index 10c380596..cc6a9fc1e 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -6,6 +6,7 @@ import { type GetServerSidePropsContext } from 'next'; import { env } from '~/env'; import { db } from '~/server/db'; import { accounts, sessions, users, verifications } from '~/server/db/schema'; +import { PREVIEW_ACCESS_EMAIL_HEADER, PREVIEW_ACCESS_NAME_HEADER } from '~/server/preview-access'; /** Session user shape the app expects (kept from the former next-auth session). */ export interface SessionUser { @@ -121,7 +122,28 @@ const loadSessionUser = async (userId: number): Promise => { export const getSessionFromHeaders = async ( raw: Headers | Record, ): Promise => { - const result = await auth.api.getSession({ headers: toHeaders(raw) }); + const headers = toHeaders(raw); + if (env.PREVIEW_ACCESS_AUTH) { + const email = headers.get(PREVIEW_ACCESS_EMAIL_HEADER)?.trim().toLowerCase(); + if (!email) return null; + + await db + .insert(users) + .values({ + email, + name: headers.get(PREVIEW_ACCESS_NAME_HEADER)?.trim() || email.split('@')[0], + emailVerified: true, + }) + .onConflictDoNothing(); + + const accessUser = await db.query.users.findFirst({ where: eq(users.email, email) }); + if (!accessUser) return null; + const user = await loadSessionUser(Number(accessUser.id)); + if (!user) return null; + return { user, expires: new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString() }; + } + + const result = await auth.api.getSession({ headers }); if (!result) { return null; } diff --git a/src/server/preview-access.ts b/src/server/preview-access.ts new file mode 100644 index 000000000..36168205c --- /dev/null +++ b/src/server/preview-access.ts @@ -0,0 +1,2 @@ +export const PREVIEW_ACCESS_EMAIL_HEADER = 'x-splitpro-preview-access-email'; +export const PREVIEW_ACCESS_NAME_HEADER = 'x-splitpro-preview-access-name'; diff --git a/worker/index.ts b/worker/index.ts index 8de38c8d8..90e3fca08 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -6,8 +6,10 @@ * Uncaught exceptions are re-thrown so they also appear in Workers Issues. */ import handler from 'vinext/server/pages-router-entry'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; import { logError } from '../src/server/observability/errorLog'; +import { PREVIEW_ACCESS_EMAIL_HEADER, PREVIEW_ACCESS_NAME_HEADER } from '../src/server/preview-access'; export { SchedulerWorkflow } from '../src/server/workflows/scheduler'; @@ -17,6 +19,8 @@ addEventListener('unhandledrejection', (event: Event) => { type FetchHandler = (request: Request, env: unknown, ctx: ExecutionContext) => Promise; const vinextFetch = (handler as unknown as { fetch: FetchHandler }).fetch; +const accessIssuer = 'https://andrepascoa.cloudflareaccess.com'; +const accessJwks = createRemoteJWKSet(new URL(`${accessIssuer}/cdn-cgi/access/certs`)); const requestContext = (request: Request) => { const url = new URL(request.url); @@ -30,6 +34,46 @@ const requestContext = (request: Request) => { export default { async fetch(request: Request, env: unknown, ctx: ExecutionContext): Promise { + const previewAccessEnabled = + 'object' === typeof env && null !== env && 'true' === Reflect.get(env, 'PREVIEW_ACCESS_AUTH'); + + if (previewAccessEnabled) { + let identity = await ctx.access?.getIdentity(); + if (!identity?.email) { + const assertion = request.headers.get('cf-access-jwt-assertion'); + const audience = 'object' === typeof env && null !== env && Reflect.get(env, 'PREVIEW_ACCESS_AUD'); + if (assertion && 'string' === typeof audience) { + try { + const verified = await jwtVerify(assertion, accessJwks, { + issuer: accessIssuer, + audience, + }); + if ('string' === typeof verified.payload.email) { + identity = { + email: verified.payload.email, + name: 'string' === typeof verified.payload.name ? verified.payload.name : undefined, + }; + } + } catch (error) { + logError('worker', error, { ...requestContext(request), phase: 'access-identity-fallback' }); + } + } + } + + const email = identity?.email?.trim().toLowerCase(); + if (!email) { + return new Response('Cloudflare Access authentication was not verified.', { status: 403 }); + } + + // Replace, never trust, identity headers supplied by the incoming client. + const headers = new Headers(request.headers); + headers.delete(PREVIEW_ACCESS_EMAIL_HEADER); + headers.delete(PREVIEW_ACCESS_NAME_HEADER); + headers.set(PREVIEW_ACCESS_EMAIL_HEADER, email); + if (identity.name) headers.set(PREVIEW_ACCESS_NAME_HEADER, identity.name); + request = new Request(request, { headers }); + } + let response: Response; try { response = await vinextFetch(request, env, ctx); diff --git a/wrangler.example.jsonc b/wrangler.example.jsonc index 1a33073b4..cb2dd0e89 100644 --- a/wrangler.example.jsonc +++ b/wrangler.example.jsonc @@ -45,10 +45,41 @@ "ENABLE_SENDING_INVITES": "true", "DISABLE_EMAIL_SIGNUP": "true", "INVITE_ONLY": "false", + "PREVIEW_ACCESS_AUTH": "false", "FROM_EMAIL": "splitpro@example.com", "CLEAR_CACHE_CRON_RULE": "0 2 * * 0", "CACHE_RETENTION_INTERVAL": "2 days", "CURRENCY_RATE_PROVIDER": "frankfurter", "UPLOAD_MAX_FILE_SIZE_MB": "10", }, + "previews": { + "vars": { + "NEXTAUTH_URL": "https://replace-with-this-preview-url", + "DEFAULT_HOMEPAGE": "/balances", + "ENABLE_SENDING_INVITES": "false", + "DISABLE_EMAIL_SIGNUP": "true", + "INVITE_ONLY": "true", + "PREVIEW_ACCESS_AUTH": "true", + "PREVIEW_ACCESS_AUD": "replace-with-your-access-audience-tag", + "CLEAR_CACHE_CRON_RULE": "0 2 * * 0", + "CACHE_RETENTION_INTERVAL": "2 days", + "CURRENCY_RATE_PROVIDER": "frankfurter", + "UPLOAD_MAX_FILE_SIZE_MB": "10", + }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "splitpro-preview", + "database_id": "00000000-0000-0000-0000-000000000000", + "migrations_dir": "migrations", + }, + ], + "r2_buckets": [{ "binding": "UPLOADS", "bucket_name": "splitpro-preview-uploads" }], + "observability": { + "enabled": true, + "head_sampling_rate": 1, + "logs": { "enabled": true, "invocation_logs": true, "persist": true, "head_sampling_rate": 1 }, + "traces": { "enabled": true, "persist": true, "head_sampling_rate": 1 }, + }, + }, }