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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 23 additions & 1 deletion src/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -121,7 +122,28 @@ const loadSessionUser = async (userId: number): Promise<SessionUser | null> => {
export const getSessionFromHeaders = async (
raw: Headers | Record<string, string | string[] | undefined>,
): Promise<Session | null> => {
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;
}
Expand Down
2 changes: 2 additions & 0 deletions src/server/preview-access.ts
Original file line number Diff line number Diff line change
@@ -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';
44 changes: 44 additions & 0 deletions worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -17,6 +19,8 @@

type FetchHandler = (request: Request, env: unknown, ctx: ExecutionContext) => Promise<Response>;
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);
Expand All @@ -30,6 +34,46 @@

export default {
async fetch(request: Request, env: unknown, ctx: ExecutionContext): Promise<Response> {
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);

Check failure on line 73 in worker/index.ts

View workflow job for this annotation

GitHub Actions / typecheck

'identity' is possibly 'undefined'.

Check failure on line 73 in worker/index.ts

View workflow job for this annotation

GitHub Actions / typecheck

'identity' is possibly 'undefined'.
request = new Request(request, { headers });
}

let response: Response;
try {
response = await vinextFetch(request, env, ctx);
Expand Down
31 changes: 31 additions & 0 deletions wrangler.example.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
},
},
}
Loading