feat: add Cloudflare Zero Trust authentication layer for admin routes#2298
Conversation
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 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Coverage Report for API Unit Tests Coverage (./packages/api)
File CoverageNo changed files found. |
Coverage Report for Expo Unit Tests Coverage (./apps/expo)
File CoverageNo changed files found. |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
packrat-admin | 9d67f5a | Apr 26 2026, 12:02 AM |
There was a problem hiding this comment.
Pull request overview
Adds an optional Cloudflare Zero Trust (Access) authentication layer for /api/admin/* by cryptographically verifying the CF-Access-JWT-Assertion, and updates the admin SPA to detect/forward CF Access credentials. Also tightens local-dev admin JWT verification by binding issuer/audience.
Changes:
- Add
verifyCFAccessRequestmiddleware backed by remote JWKS + issuer/audience validation. - Update admin route guard to require CF Access verification when configured; otherwise keep local-dev Bearer/Basic fallbacks.
- Update admin SPA to detect CF Access sessions and send
CF-Access-JWT-Assertionon API calls; improve route/login guard behavior.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/api/wrangler.jsonc | Documents new optional CF Access env vars for admin-route auth. |
| packages/api/src/utils/env-validation.ts | Adds CF Access env vars to the validated env schema. |
| packages/api/src/routes/admin/index.ts | Enforces CF Access verification (when configured) and strengthens admin JWT issuer/audience validation. |
| packages/api/src/middleware/index.ts | Re-exports CF Access verifier/types from middleware barrel. |
| packages/api/src/middleware/cfAccess.ts | Implements CF Access JWT assertion verification using remote JWKS. |
| apps/admin/lib/cfAccess.ts | Adds client helper to fetch CF Access identity/JWT with concurrency memoization. |
| apps/admin/lib/api.ts | Sends CF Access JWT assertion header when present; otherwise uses local session JWT. |
| apps/admin/components/auth-guard.tsx | Adds CF Access detection path and improves unmount safety. |
| apps/admin/app/login/page.tsx | Auto-redirects when CF Access session is detected; shows local-dev hint when not. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export async function verifyCFAccessRequest( | ||
| request: Request, | ||
| teamDomain: string, | ||
| aud: string, | ||
| ): Promise<CFAccessIdentity | null> { |
There was a problem hiding this comment.
This new verifier is security-critical and currently has no unit tests. Since packages/api/test/middleware/* already covers other auth middleware, consider adding tests for missing header → null, invalid JWT/issuer/audience → null, and a valid JWT signed by a JWKS key → identity returned.
| // - 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 |
There was a problem hiding this comment.
The inline config comment suggests CF_ACCESS_TEAM_DOMAIN=<team>.cloudflareaccess.com (no scheme), but verifyCFAccessRequest expects a full URL (e.g. https://<team>.cloudflareaccess.com) and will throw on an invalid URL when building the JWKS endpoint. Update the comment to include the required https:// prefix to avoid production misconfiguration.
| // CF_ACCESS_TEAM_DOMAIN=<team>.cloudflareaccess.com | |
| // CF_ACCESS_TEAM_DOMAIN=https://<team>.cloudflareaccess.com |
| CF_ACCESS_TEAM_DOMAIN: z.string().optional(), // e.g. "packrat.cloudflareaccess.com" | ||
| CF_ACCESS_AUD: z.string().optional(), // CF Access policy Application Audience tag |
There was a problem hiding this comment.
CF_ACCESS_TEAM_DOMAIN is currently z.string().optional() with an example lacking https://, but the CF Access verifier requires a valid URL string (and uses it for both JWKS URL construction and issuer matching). Consider validating it as z.string().url().optional() and updating the example to https://<team>.cloudflareaccess.com to prevent a misconfig that would 401 all admin requests.
| 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 |
| 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. |
There was a problem hiding this comment.
If only one of CF_ACCESS_TEAM_DOMAIN / CF_ACCESS_AUD is set, this guard silently falls back to Bearer/Basic auth. That’s a risky failure mode for production because a partial CF Access rollout/misconfig would unintentionally keep the weaker local-dev auth paths enabled. Consider enforcing an “all-or-nothing” config and fail closed (401) when exactly one is set.
| return () => { | ||
| canceled = true; | ||
| }; | ||
| }, []); // router is stable in App Router |
There was a problem hiding this comment.
The effect uses router.replace(...) but omits router from the dependency array. Elsewhere in the admin app (e.g. app/page.tsx, app/login/page.tsx) [router] is included; keeping that here avoids stale-closure risks and maintains consistent hooks usage.
| }, []); // router is stable in App Router | |
| }, [router]); |
| * Promise is memoized for the page lifetime — safe to call concurrently. | ||
| */ | ||
| export function getCFAccessIdentity(): Promise<CFAccessIdentityResponse | null> { | ||
| identityPromise ??= fetchIdentity(); |
There was a problem hiding this comment.
identityPromise memoizes the first identity fetch result even when it resolves to null (transient network error / temporary 5xx). That makes the app treat CF Access as “not present” for the rest of the page lifetime and can cause incorrect redirects/auth behavior. Consider only memoizing successful identities, or resetting identityPromise back to undefined when the fetch resolves to null so subsequent calls can retry.
| identityPromise ??= fetchIdentity(); | |
| identityPromise ??= fetchIdentity().then((identity) => { | |
| if (identity === null) { | |
| identityPromise = undefined; | |
| } | |
| return identity; | |
| }); |
| useEffect(() => { | ||
| isBehindCFAccess().then((behind) => { | ||
| setCFAccess(behind); | ||
| if (behind) router.replace('/dashboard'); | ||
| }); |
There was a problem hiding this comment.
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 (like components/auth-guard.tsx) to avoid setting state on an unmounted component.
| useEffect(() => { | |
| isBehindCFAccess().then((behind) => { | |
| setCFAccess(behind); | |
| if (behind) router.replace('/dashboard'); | |
| }); | |
| useEffect(() => { | |
| let cancelled = false; | |
| isBehindCFAccess().then((behind) => { | |
| if (cancelled) return; | |
| setCFAccess(behind); | |
| if (behind) router.replace('/dashboard'); | |
| }); | |
| return () => { | |
| cancelled = true; | |
| }; |
feat: add Cloudflare Zero Trust authentication layer for admin routes
Summary
/api/admin/*routes using Cloudflare Zero TrustChanges
packages/api/src/middleware/cfAccess.ts— new cryptographic request verifier backed by the team's public JWKSpackages/api/src/routes/admin/index.ts— updated auth guard; when CF Access env vars are present, only cryptographically verified requests are accepted with no password fallthroughapps/admin/lib/cfAccess.ts— new client-side CF Access identity helper with safe concurrency handlingapps/admin/lib/api.ts— admin API client now includes the CF Access JWT assertion in requestsapps/admin/components/auth-guard.tsx— improved unmount safety and CF Access session detectionapps/admin/app/login/page.tsx— auto-redirect when a valid CF Access session is presentConfiguration
Set these two optional env vars in Cloudflare Workers dashboard to activate the Zero Trust layer:
When unset, existing local dev auth (Basic / Bearer JWT) continues to work unchanged.
Post-Deploy Monitoring & Validation
verifyCFAccessRequest— a null return on a legitimate request indicates the CF Access app AUD tag is misconfiguredCF_ACCESS_TEAM_DOMAINincludeshttps://andCF_ACCESS_AUDmatches the Access Application's audience tag; rollback by unsetting the env vars