From 9d67f5ab212815a4dc4abce8cafc4376d58c30b1 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 25 Apr 2026 18:01:06 -0600 Subject: [PATCH] feat: add Cloudflare Zero Trust authentication layer for admin routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces cryptographic request verification on all /api/admin/* routes using Cloudflare Access JWT assertions validated against the team JWKS endpoint. When CF_ACCESS_TEAM_DOMAIN and CF_ACCESS_AUD are set, access is gated exclusively on a valid signed JWT — no password fallthrough. Also hardens the admin SPA authentication flow with improved concurrency handling, cancellation safety, and CF Access identity forwarding. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- apps/admin/app/login/page.tsx | 24 ++++++++-- apps/admin/components/auth-guard.tsx | 27 +++++++++-- apps/admin/lib/api.ts | 18 ++++++-- apps/admin/lib/cfAccess.ts | 58 ++++++++++++++++++++++++ packages/api/src/middleware/cfAccess.ts | 51 +++++++++++++++++++++ packages/api/src/middleware/index.ts | 2 + packages/api/src/routes/admin/index.ts | 33 +++++++++----- packages/api/src/utils/env-validation.ts | 4 ++ packages/api/wrangler.jsonc | 4 ++ 9 files changed, 199 insertions(+), 22 deletions(-) create mode 100644 apps/admin/lib/cfAccess.ts create mode 100644 packages/api/src/middleware/cfAccess.ts diff --git a/apps/admin/app/login/page.tsx b/apps/admin/app/login/page.tsx index 660dd59180..12f1b6c846 100644 --- a/apps/admin/app/login/page.tsx +++ b/apps/admin/app/login/page.tsx @@ -11,9 +11,10 @@ import { import { Input } from '@packrat/web-ui/components/input'; import { Label } from '@packrat/web-ui/components/label'; import { storeToken } from 'admin-app/lib/auth'; -import { Package } from 'lucide-react'; +import { isBehindCFAccess } from 'admin-app/lib/cfAccess'; +import { Package, Shield } from 'lucide-react'; import { useRouter } from 'next/navigation'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; const API_BASE = process.env.NEXT_PUBLIC_API_URL; if (!API_BASE) { @@ -26,6 +27,14 @@ export default function LoginPage() { const [password, setPassword] = useState(''); const [error, setError] = useState(null); const [pending, setPending] = useState(false); + const [cfAccess, setCFAccess] = useState(null); + + useEffect(() => { + isBehindCFAccess().then((behind) => { + setCFAccess(behind); + if (behind) router.replace('/dashboard'); + }); + }, [router]); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -60,10 +69,12 @@ export default function LoginPage() { } } + // Redirect in progress (behind CF Access) — show nothing while navigating + if (cfAccess === true) return null; + return (
- {/* Logo */}
@@ -110,6 +121,13 @@ export default function LoginPage() { + + {cfAccess === false && ( +

+ + Local dev mode — Cloudflare Access not detected +

+ )}
); diff --git a/apps/admin/components/auth-guard.tsx b/apps/admin/components/auth-guard.tsx index b543d6849c..238f10a292 100644 --- a/apps/admin/components/auth-guard.tsx +++ b/apps/admin/components/auth-guard.tsx @@ -1,6 +1,7 @@ 'use client'; import { getStoredToken } from 'admin-app/lib/auth'; +import { isBehindCFAccess } from 'admin-app/lib/cfAccess'; import { useRouter } from 'next/navigation'; import type React from 'react'; import { useEffect, useState } from 'react'; @@ -10,12 +11,28 @@ export function AuthGuard({ children }: { children: React.ReactNode }) { const [ready, setReady] = useState(false); useEffect(() => { - if (!getStoredToken()) { - router.replace('/login'); - } else { - setReady(true); + let canceled = false; + + async function check() { + if (await isBehindCFAccess()) { + if (!canceled) setReady(true); + return; + } + if (!getStoredToken()) { + if (!canceled) router.replace('/login'); + return; + } + if (!canceled) setReady(true); } - }, [router]); + + void check().catch(() => { + if (!canceled) router.replace('/login'); + }); + + return () => { + canceled = true; + }; + }, []); // router is stable in App Router if (!ready) return null; return <>{children}; diff --git a/apps/admin/lib/api.ts b/apps/admin/lib/api.ts index b0d26abde2..744e895249 100644 --- a/apps/admin/lib/api.ts +++ b/apps/admin/lib/api.ts @@ -1,20 +1,32 @@ // Browser-callable API client for the admin app. -// In production, CF Access protects the domain; a short-lived JWT is sent -// as a Bearer token. In local dev, authenticate once at /login to obtain a token. +// +// Auth priority (first match wins): +// 1. CF Zero Trust — CF-Access-JWT-Assertion fetched from /cdn-cgi/access/get-identity +// (available when the admin app is deployed behind Cloudflare Access) +// 2. Session JWT — short-lived Bearer token stored in sessionStorage after Basic auth login +// (local dev fallback) import { clearToken, getAuthHeader } from './auth'; +import { getCFAccessJWT } from './cfAccess'; const API_BASE = process.env.NEXT_PUBLIC_API_URL; if (!API_BASE) { throw new Error('NEXT_PUBLIC_API_URL must be set (root .env.local → PUBLIC_API_URL)'); } +async function buildAuthHeaders(): Promise> { + const cfJwt = await getCFAccessJWT(); + if (cfJwt) return { 'CF-Access-JWT-Assertion': cfJwt }; + return getAuthHeader(); +} + async function adminFetch(path: string, init?: RequestInit): Promise { + const authHeaders = await buildAuthHeaders(); const res = await fetch(`${API_BASE}/api/admin${path}`, { ...init, headers: { 'Content-Type': 'application/json', - ...getAuthHeader(), + ...authHeaders, ...init?.headers, }, }); diff --git a/apps/admin/lib/cfAccess.ts b/apps/admin/lib/cfAccess.ts new file mode 100644 index 0000000000..8c62233e4a --- /dev/null +++ b/apps/admin/lib/cfAccess.ts @@ -0,0 +1,58 @@ +// CF Access client-side utilities for the admin SPA. +// +// CF Access protects the admin app in production. After authentication, CF sets +// an HttpOnly cookie (not readable by JS). The identity endpoint at +// /cdn-cgi/access/get-identity returns the signed JWT assertion we can forward +// to the API for cryptographic verification. + +export type CFAccessIdentityResponse = { + email: string; + name: string; + jwt: string; +}; + +// Cache the in-flight Promise — all concurrent callers share one network request. +// Avoids thundering-herd on first render when multiple components call simultaneously. +let identityPromise: Promise | undefined; + +function fetchIdentity(): Promise { + return fetch('/cdn-cgi/access/get-identity', { credentials: 'include' }) + .then((res) => { + if (!res.ok) return null; + return res.json() as Promise; + }) + .then((data) => { + if ( + typeof data !== 'object' || + data === null || + typeof (data as Record)['email'] !== 'string' || + typeof (data as Record)['jwt'] !== 'string' + ) { + return null; + } + const d = data as { email: string; name?: string; jwt: string }; + return { email: d.email, name: d.name ?? '', jwt: d.jwt }; + }) + .catch(() => null); +} + +/** + * Returns the CF Access identity for the current session. + * Returns null when not behind CF Access (local dev). + * Promise is memoized for the page lifetime — safe to call concurrently. + */ +export function getCFAccessIdentity(): Promise { + identityPromise ??= fetchIdentity(); + return identityPromise; +} + +/** Returns the CF Access JWT assertion, or null when not behind CF Access. */ +export async function getCFAccessJWT(): Promise { + const identity = await getCFAccessIdentity(); + return identity?.jwt ?? null; +} + +/** True when the app is running behind CF Access (identity endpoint responds). */ +export async function isBehindCFAccess(): Promise { + return (await getCFAccessIdentity()) !== null; +} diff --git a/packages/api/src/middleware/cfAccess.ts b/packages/api/src/middleware/cfAccess.ts new file mode 100644 index 0000000000..2610203c51 --- /dev/null +++ b/packages/api/src/middleware/cfAccess.ts @@ -0,0 +1,51 @@ +import { createRemoteJWKSet, jwtVerify } from 'jose'; + +export type CFAccessIdentity = { + email: string; +}; + +type CFAccessJWTPayload = { email?: string }; + +// Module-level singleton — survives across requests on warm isolates. +// jose caches keys internally by kid; re-fetches only on unknown key rotation. +let moduleJwks: ReturnType | null = null; +let moduleTeamDomain: string | null = null; + +function getJwks(teamDomain: string): ReturnType { + if (!moduleJwks || moduleTeamDomain !== teamDomain) { + // teamDomain is the full URL: "https://.cloudflareaccess.com" + moduleJwks = createRemoteJWKSet(new URL(`${teamDomain}/cdn-cgi/access/certs`)); + moduleTeamDomain = teamDomain; + } + return moduleJwks; +} + +/** + * Extracts and verifies the CF-Access-JWT-Assertion header from the request + * against the team's public JWKS. Validates both issuer and audience. + * + * Only call when both teamDomain and aud are configured. + * Returns null when the header is absent or the token fails verification. + * + * teamDomain must be the full URL: "https://.cloudflareaccess.com" + * aud is the CF Access Application Audience tag. + */ +export async function verifyCFAccessRequest( + request: Request, + teamDomain: string, + aud: string, +): Promise { + const token = request.headers.get('cf-access-jwt-assertion'); + if (!token) return null; + try { + const { payload } = await jwtVerify(token, getJwks(teamDomain), { + audience: aud, + issuer: teamDomain, // CF Access JWT iss == team domain URL + }); + const { email } = payload; + if (typeof email !== 'string' || email.length === 0) return null; + return { email }; + } catch { + return null; + } +} diff --git a/packages/api/src/middleware/index.ts b/packages/api/src/middleware/index.ts index 241f56de09..cd3a7fbb13 100644 --- a/packages/api/src/middleware/index.ts +++ b/packages/api/src/middleware/index.ts @@ -1,2 +1,4 @@ export type { AuthUser } from './auth'; export { adminAuthPlugin, apiKeyAuthPlugin, authPlugin } from './auth'; +export type { CFAccessIdentity } from './cfAccess'; +export { verifyCFAccessRequest } from './cfAccess'; diff --git a/packages/api/src/routes/admin/index.ts b/packages/api/src/routes/admin/index.ts index c63afc7904..85d1910660 100644 --- a/packages/api/src/routes/admin/index.ts +++ b/packages/api/src/routes/admin/index.ts @@ -1,5 +1,6 @@ import { createDb } from '@packrat/api/db'; import { catalogItems, packs, users } from '@packrat/api/db/schema'; +import { verifyCFAccessRequest } from '@packrat/api/middleware/cfAccess'; import { timingSafeEqual } from '@packrat/api/utils/auth'; import { getEnv } from '@packrat/api/utils/env-validation'; import { assertAllDefined } from '@packrat/guards'; @@ -10,6 +11,8 @@ import { z } from 'zod'; import { analyticsRoutes } from './analytics'; const ADMIN_TOKEN_TTL_SECONDS = 3600; // 1 hour +const ADMIN_JWT_ISSUER = 'packrat-api'; +const ADMIN_JWT_AUDIENCE = 'packrat-admin'; function basicAuthGuard(request: Request): { authorized: true } | { authorized: false } { const header = request.headers.get('authorization') ?? ''; @@ -44,6 +47,8 @@ async function issueAdminJwt(username: string): Promise { return new SignJWT({ role: 'admin' }) .setProtectedHeader({ alg: 'HS256' }) .setSubject(username) + .setIssuer(ADMIN_JWT_ISSUER) + .setAudience(ADMIN_JWT_AUDIENCE) .setIssuedAt() .setExpirationTime(`${ADMIN_TOKEN_TTL_SECONDS}s`) .sign(secret); @@ -53,26 +58,32 @@ async function verifyAdminJwt(token: string): Promise { try { const env = getEnv(); const secret = new TextEncoder().encode(env.JWT_SECRET); - const { payload } = await jwtVerify(token, secret); + const { payload } = await jwtVerify(token, secret, { + issuer: ADMIN_JWT_ISSUER, + audience: ADMIN_JWT_AUDIENCE, + }); return payload.role === 'admin'; } catch { return false; } } -// Accept: Cloudflare Access header (prod), Bearer JWT (admin SPA), or Basic -// auth (local dev / htmx UI). Returns true to let the request through. +// Accept: Cloudflare Access JWT (prod, cryptographically verified), Bearer JWT +// (admin SPA session token), or Basic auth (local dev only). async function adminAuthGuard(request: Request): Promise { - // Production: Cloudflare Access injects this header after verifying the user - if (request.headers.get('CF-Access-Authenticated-User-Email')) return true; + const env = getEnv(); + const { CF_ACCESS_TEAM_DOMAIN, CF_ACCESS_AUD } = env; - const header = request.headers.get('authorization') ?? ''; - if (header.startsWith('Bearer ')) { - return verifyAdminJwt(header.slice(7)); - } - if (header.startsWith('Basic ')) { - return basicAuthGuard(request).authorized; + if (CF_ACCESS_TEAM_DOMAIN && CF_ACCESS_AUD) { + // CF Access configured: cryptographic JWT verification only, no fallthrough. + const cfIdentity = await verifyCFAccessRequest(request, CF_ACCESS_TEAM_DOMAIN, CF_ACCESS_AUD); + return cfIdentity !== null; } + + // CF Access not configured — local dev fallbacks only. + const header = request.headers.get('authorization') ?? ''; + if (header.startsWith('Bearer ')) return verifyAdminJwt(header.slice(7)); + if (header.startsWith('Basic ')) return basicAuthGuard(request).authorized; return false; } diff --git a/packages/api/src/utils/env-validation.ts b/packages/api/src/utils/env-validation.ts index 8333e07d6b..ee84374021 100644 --- a/packages/api/src/utils/env-validation.ts +++ b/packages/api/src/utils/env-validation.ts @@ -20,6 +20,10 @@ export const apiEnvSchema = z.object({ PACKRAT_API_KEY: z.string(), REFRESH_TOKEN_PEPPER: z.string().min(32).optional(), + // Cloudflare Zero Trust / Access (optional — enables CF Access JWT verification for admin routes) + CF_ACCESS_TEAM_DOMAIN: z.string().optional(), // e.g. "packrat.cloudflareaccess.com" + CF_ACCESS_AUD: z.string().optional(), // CF Access policy Application Audience tag + // Email Configuration EMAIL_PROVIDER: z.enum(['resend', 'sendgrid', 'ses']), RESEND_API_KEY: z.string(), diff --git a/packages/api/wrangler.jsonc b/packages/api/wrangler.jsonc index fdf2f2a503..fe898c180f 100644 --- a/packages/api/wrangler.jsonc +++ b/packages/api/wrangler.jsonc @@ -21,6 +21,10 @@ // Environment variables are managed via: // - Production: Cloudflare dashboard // - Local development: .dev.vars file (not committed to git) + // + // Cloudflare Zero Trust / Access (optional — set to enable JWT verification on /api/admin/*): + // CF_ACCESS_TEAM_DOMAIN=.cloudflareaccess.com + // CF_ACCESS_AUD= "r2_buckets": [ { "binding": "PACKRAT_BUCKET",