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
24 changes: 21 additions & 3 deletions apps/admin/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -26,6 +27,14 @@ export default function LoginPage() {
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const [cfAccess, setCFAccess] = useState<boolean | null>(null);

useEffect(() => {
isBehindCFAccess().then((behind) => {
setCFAccess(behind);
if (behind) router.replace('/dashboard');
});
Comment on lines +32 to +36
Copy link

Copilot AI Apr 26, 2026

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 (like components/auth-guard.tsx) to avoid setting state on an unmounted component.

Suggested change
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;
};

Copilot uses AI. Check for mistakes.
}, [router]);

async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
Expand Down Expand Up @@ -60,10 +69,12 @@ export default function LoginPage() {
}
}

// Redirect in progress (behind CF Access) — show nothing while navigating
if (cfAccess === true) return null;

return (
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<div className="w-full max-w-sm space-y-6">
{/* Logo */}
<div className="flex flex-col items-center gap-2 text-center">
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-primary/15 border border-primary/20">
<Package className="w-5 h-5 text-primary" />
Expand Down Expand Up @@ -110,6 +121,13 @@ export default function LoginPage() {
</form>
</CardContent>
</Card>

{cfAccess === false && (
<p className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
<Shield className="w-3 h-3" />
Local dev mode — Cloudflare Access not detected
</p>
)}
</div>
</div>
);
Expand Down
27 changes: 22 additions & 5 deletions apps/admin/components/auth-guard.tsx
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';
Expand All @@ -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
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
}, []); // router is stable in App Router
}, [router]);

Copilot uses AI. Check for mistakes.

if (!ready) return null;
return <>{children}</>;
Expand Down
18 changes: 15 additions & 3 deletions apps/admin/lib/api.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>> {
const cfJwt = await getCFAccessJWT();
if (cfJwt) return { 'CF-Access-JWT-Assertion': cfJwt };
return getAuthHeader();
}

async function adminFetch<T>(path: string, init?: RequestInit): Promise<T> {
const authHeaders = await buildAuthHeaders();
const res = await fetch(`${API_BASE}/api/admin${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
...getAuthHeader(),
...authHeaders,
...init?.headers,
},
});
Expand Down
58 changes: 58 additions & 0 deletions apps/admin/lib/cfAccess.ts
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();
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
identityPromise ??= fetchIdentity();
identityPromise ??= fetchIdentity().then((identity) => {
if (identity === null) {
identityPromise = undefined;
}
return identity;
});

Copilot uses AI. Check for mistakes.
return identityPromise;
}

/** Returns the CF Access JWT assertion, or null when not behind CF Access. */
export async function getCFAccessJWT(): Promise<string | null> {
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<boolean> {
return (await getCFAccessIdentity()) !== null;
}
51 changes: 51 additions & 0 deletions packages/api/src/middleware/cfAccess.ts
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
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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;
}
}
2 changes: 2 additions & 0 deletions packages/api/src/middleware/index.ts
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';
33 changes: 22 additions & 11 deletions packages/api/src/routes/admin/index.ts
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';
Expand All @@ -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') ?? '';
Expand Down Expand Up @@ -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);
Expand All @@ -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
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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;
}

Expand Down
4 changes: 4 additions & 0 deletions packages/api/src/utils/env-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.

// Email Configuration
EMAIL_PROVIDER: z.enum(['resend', 'sendgrid', 'ses']),
RESEND_API_KEY: z.string(),
Expand Down
4 changes: 4 additions & 0 deletions packages/api/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// CF_ACCESS_TEAM_DOMAIN=<team>.cloudflareaccess.com
// CF_ACCESS_TEAM_DOMAIN=https://<team>.cloudflareaccess.com

Copilot uses AI. Check for mistakes.
// CF_ACCESS_AUD=<Application Audience tag from CF Access dashboard>
"r2_buckets": [
{
"binding": "PACKRAT_BUCKET",
Expand Down
Loading