Skip to content
File

Blob: src/worker/auth.ts

typescript198 lines
1/**
2 * Authentication module — Cloudflare Access JWT validation with dev-mode
3 * fallback.
4 *
5 * Two modes (checked in order):
6 * 1. CF_ACCESS_AUD + CF_ACCESS_TEAM set → validate Cf-Access-Jwt-Assertion JWT
7 * 2. Neither set → open dev mode (synthetic identity)
8 */
9 
10// ---------------------------------------------------------------------------
11// Types
12// ---------------------------------------------------------------------------
13 
14export interface AuthResult {
15 authenticated: boolean;
16 userId: string;
17 email: string;
18 error?: string;
19}
20 
21interface AccessJWTPayload {
22 iss: string;
23 sub: string;
24 aud: string[];
25 email: string;
26 iat: number;
27 exp: number;
28 type: string;
29}
30 
31// ---------------------------------------------------------------------------
32// Public API
33// ---------------------------------------------------------------------------
34 
35export async function authenticateRequest(request: Request, env: Env): Promise<AuthResult> {
36 // Production: Cloudflare Access JWT
37 if (isAccessConfigured(env)) {
38 return authenticateAccess(request, env);
39 }
40 
41 // Local dev: no Access configured — allow all with synthetic identity
42 return { authenticated: true, userId: "dev-user", email: "dev@localhost" };
43}
44 
45/**
46 * Derive a deterministic sandbox ID scoped to a user.
47 * Prevents cross-user access regardless of query params.
48 */
49export function deriveSandboxId(userId: string, workspace: string): string {
50 return `${userId}-${workspace || "default"}`;
51}
52 
53export function validateHostAccessPolicy(
54 request: Request,
55 env: Pick<Env, "CF_ACCESS_AUD" | "CF_ACCESS_TEAM">,
56): string | null {
57 if (isAccessConfigured(env)) return null;
58 
59 const hostname = new URL(request.url).hostname.toLowerCase();
60 if (isLocalDevHost(hostname)) return null;
61 
62 return "Cloudflare Access must be configured for non-local hosts";
63}
64 
65function isAccessConfigured(env: Pick<Env, "CF_ACCESS_AUD" | "CF_ACCESS_TEAM">): boolean {
66 return Boolean(env.CF_ACCESS_AUD && env.CF_ACCESS_TEAM);
67}
68 
69function isLocalDevHost(hostname: string): boolean {
70 return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
71}
72 
73// ---------------------------------------------------------------------------
74// Cloudflare Access JWT
75// ---------------------------------------------------------------------------
76 
77/** In-memory JWKS cache — lost on isolate eviction, which is fine. */
78let jwksCache: { keys: CryptoKey[]; fetchedAt: number } | null = null;
79const JWKS_TTL_MS = 5 * 60 * 1000; // 5 minutes
80 
81async function authenticateAccess(request: Request, env: Env): Promise<AuthResult> {
82 const fail = (msg: string): AuthResult => ({
83 authenticated: false,
84 userId: "",
85 email: "",
86 error: msg,
87 });
88 
89 const jwt = request.headers.get("Cf-Access-Jwt-Assertion");
90 if (!jwt) return fail("Missing Cf-Access-Jwt-Assertion header");
91 
92 // Decode header + payload (without verification first, to get kid)
93 const parts = jwt.split(".");
94 if (parts.length !== 3) return fail("Malformed JWT");
95 
96 let header: { alg: string; kid?: string };
97 let payload: AccessJWTPayload;
98 try {
99 header = JSON.parse(b64urlDecode(parts[0]));
100 payload = JSON.parse(b64urlDecode(parts[1]));
101 } catch {
102 return fail("Invalid JWT encoding");
103 }
104 
105 // Check expiration
106 if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
107 return fail("JWT expired");
108 }
109 
110 // Check audience
111 const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
112 if (!aud.includes(env.CF_ACCESS_AUD)) {
113 return fail("JWT audience mismatch");
114 }
115 
116 // Check issuer
117 const expectedIssuer = `https://${env.CF_ACCESS_TEAM}.cloudflareaccess.com`;
118 if (payload.iss !== expectedIssuer) {
119 return fail("JWT issuer mismatch");
120 }
121 
122 // Verify signature
123 const keys = await fetchJWKS(env.CF_ACCESS_TEAM);
124 const verified = await verifySignature(parts, header, keys);
125 if (!verified) return fail("JWT signature verification failed");
126 
127 return {
128 authenticated: true,
129 userId: payload.sub,
130 email: payload.email || "",
131 };
132}
133 
134async function fetchJWKS(team: string): Promise<CryptoKey[]> {
135 if (jwksCache && Date.now() - jwksCache.fetchedAt < JWKS_TTL_MS) {
136 return jwksCache.keys;
137 }
138 
139 const url = `https://${team}.cloudflareaccess.com/cdn-cgi/access/certs`;
140 const res = await fetch(url);
141 if (!res.ok) throw new Error(`Failed to fetch JWKS: ${res.status}`);
142 
143 const body: {
144 keys: JsonWebKey[];
145 public_certs: unknown[];
146 } = await res.json();
147 
148 const keys = await Promise.all(
149 body.keys
150 .filter((k) => k.kty === "RSA" && k.use === "sig")
151 .map((k) => crypto.subtle.importKey("jwk", k, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"])),
152 );
153 
154 jwksCache = { keys, fetchedAt: Date.now() };
155 return keys;
156}
157 
158async function verifySignature(
159 parts: string[],
160 header: { alg: string; kid?: string },
161 keys: CryptoKey[],
162): Promise<boolean> {
163 if (header.alg !== "RS256") return false;
164 
165 const signingInput = new TextEncoder().encode(`${parts[0]}.${parts[1]}`);
166 const signature = b64urlToUint8Array(parts[2]);
167 
168 // Try each key — Access rotates keys and the JWT may match any
169 for (const key of keys) {
170 try {
171 const ok = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signature, signingInput);
172 if (ok) return true;
173 } catch {
174 // wrong key, try next
175 }
176 }
177 
178 return false;
179}
180 
181// ---------------------------------------------------------------------------
182// Base64url helpers
183// ---------------------------------------------------------------------------
184 
185function b64urlDecode(input: string): string {
186 const padded = input.replace(/-/g, "+").replace(/_/g, "/");
187 return atob(padded);
188}
189 
190function b64urlToUint8Array(input: string): Uint8Array {
191 const raw = b64urlDecode(input);
192 const arr = new Uint8Array(raw.length);
193 for (let i = 0; i < raw.length; i++) {
194 arr[i] = raw.charCodeAt(i);
195 }
196 return arr;
197}