-
Notifications
You must be signed in to change notification settings - Fork 38
feat: add Cloudflare Zero Trust authentication layer for admin routes #2298
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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 | ||||||
|
||||||
| }, []); // router is stable in App Router | |
| }, [router]); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<CFAccessIdentityResponse | null> | undefined; | ||||||||||||||||
|
|
||||||||||||||||
| function fetchIdentity(): Promise<CFAccessIdentityResponse | null> { | ||||||||||||||||
| return fetch('/cdn-cgi/access/get-identity', { credentials: 'include' }) | ||||||||||||||||
| .then((res) => { | ||||||||||||||||
| if (!res.ok) return null; | ||||||||||||||||
| return res.json() as Promise<unknown>; | ||||||||||||||||
| }) | ||||||||||||||||
| .then((data) => { | ||||||||||||||||
| if ( | ||||||||||||||||
| typeof data !== 'object' || | ||||||||||||||||
| data === null || | ||||||||||||||||
| typeof (data as Record<string, unknown>)['email'] !== 'string' || | ||||||||||||||||
| typeof (data as Record<string, unknown>)['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<CFAccessIdentityResponse | null> { | ||||||||||||||||
| identityPromise ??= fetchIdentity(); | ||||||||||||||||
|
||||||||||||||||
| identityPromise ??= fetchIdentity(); | |
| identityPromise ??= fetchIdentity().then((identity) => { | |
| if (identity === null) { | |
| identityPromise = undefined; | |
| } | |
| return identity; | |
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<typeof createRemoteJWKSet> | null = null; | ||
| let moduleTeamDomain: string | null = null; | ||
|
|
||
| function getJwks(teamDomain: string): ReturnType<typeof createRemoteJWKSet> { | ||
| if (!moduleJwks || moduleTeamDomain !== teamDomain) { | ||
| // teamDomain is the full URL: "https://<team>.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://<team>.cloudflareaccess.com" | ||
| * aud is the CF Access Application Audience tag. | ||
| */ | ||
| export async function verifyCFAccessRequest( | ||
| request: Request, | ||
| teamDomain: string, | ||
| aud: string, | ||
| ): Promise<CFAccessIdentity | null> { | ||
|
Comment on lines
+33
to
+37
|
||
| const token = request.headers.get('cf-access-jwt-assertion'); | ||
| if (!token) return null; | ||
| try { | ||
| const { payload } = await jwtVerify<CFAccessJWTPayload>(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; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,4 @@ | ||
| export type { AuthUser } from './auth'; | ||
| export { adminAuthPlugin, apiKeyAuthPlugin, authPlugin } from './auth'; | ||
| export type { CFAccessIdentity } from './cfAccess'; | ||
| export { verifyCFAccessRequest } from './cfAccess'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string> { | |
| 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<boolean> { | |
| 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<boolean> { | ||
| // 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. | ||
|
Comment on lines
+74
to
+78
|
||
| 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; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||
|
Comment on lines
+24
to
+25
|
||||||||||
| CF_ACCESS_TEAM_DOMAIN: z.string().optional(), // e.g. "packrat.cloudflareaccess.com" | |
| CF_ACCESS_AUD: z.string().optional(), // CF Access policy Application Audience tag | |
| CF_ACCESS_TEAM_DOMAIN: z.string().url().optional(), // e.g. "https://packrat.cloudflareaccess.com" | |
| CF_ACCESS_AUD: z.string().optional(), // CF Access policy Application Audience tag |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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=<team>.cloudflareaccess.com | ||||||
|
||||||
| // CF_ACCESS_TEAM_DOMAIN=<team>.cloudflareaccess.com | |
| // CF_ACCESS_TEAM_DOMAIN=https://<team>.cloudflareaccess.com |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This effect kicks off an async
isBehindCFAccess()call and then sets state / navigates, but it doesn’t guard against the component unmounting during the redirect. Consider adding a cancellation flag (likecomponents/auth-guard.tsx) to avoid setting state on an unmounted component.