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');
});
}, [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

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();
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> {
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.
const cfIdentity = await verifyCFAccessRequest(request, CF_ACCESS_TEAM_DOMAIN, CF_ACCESS_AUD);
return cfIdentity !== null;
Comment on lines +77 to +80
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.

New CF Access auth behavior (strict mode when CF_ACCESS_TEAM_DOMAIN/CF_ACCESS_AUD are configured) isn’t covered by tests. Since packages/api/test/admin.test.ts already asserts admin auth behavior, it would be good to add cases for: (1) CF Access configured β†’ Basic/Bearer rejected; (2) valid CF assertion header accepted (can be done by stubbing the JWKS fetch used by createRemoteJWKSet).

Copilot uses AI. Check for mistakes.
}

// 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;
Comment on lines +74 to +86
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.

adminAuthGuard treats CF Access as enabled only when both env vars are set; if one is missing (or empty), it falls back to Bearer/Basic auth. That fallback is a security footgun in production because a partial/mistyped CF Access config can unintentionally re-enable local auth methods. Prefer failing closed (deny all admin requests or throw on startup) when either CF_ACCESS_TEAM_DOMAIN or CF_ACCESS_AUD is set without the other.

Copilot uses AI. Check for mistakes.
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 +23 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/CF_ACCESS_AUD are independently optional, which makes it easy to misconfigure production: if only one is set, the API will silently fall back to local Basic/Bearer auth. Consider validating these as an all-or-nothing pair (e.g., schema refinement requiring both or neither), and validate CF_ACCESS_TEAM_DOMAIN as a full URL (including https://) to match verifyCFAccessRequest expectations; also update the example value in the comment accordingly.

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 configuration comment suggests CF_ACCESS_TEAM_DOMAIN=<team>.cloudflareaccess.com, but verifyCFAccessRequest expects teamDomain to be a full URL (e.g. https://<team>.cloudflareaccess.com). Updating this example (and keeping it consistent with apiEnvSchema) will prevent misconfiguration that would otherwise cause all admin requests to be denied.

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