zz_

app/worker/src/auth.ts

zzmq.dev@gmail.com/zz / app / worker / src / auth.ts @ 41775bc0 Download
Ladder Summary
  • file This module implements the OAuth 2.0 login flow for Cloudflare and Google inside a Cloudflare Worker: it defines the `OAuthEnv`, `Provider` and `ProviderIdentity` types, builds a provider‑specific authorization URL (`buildAuthorizeUrl`), and exchanges an authorization code for a populated `ProviderIdentity` object (`exchangeCodeForIdentity`). Internally it stores per‑provider endpoint configurations, validates required secrets, and parses the user‑info responses into a unified shape. The only exported symbols are the three types (`OAuthEnv`, `Provider`, `ProviderIdentity`) and the two functions `buildAuthorizeUrl` and `exchangeCodeForIdentity`.
 // Login: Cloudflare OAuth + Google OAuth (no GitHub -- zz aims to
 // replace GitHub, not depend on it for identity). Both are standard
 // OAuth 2.0 authorization-code flow. Cloudflare's token is kept
 // (encrypted) specifically so a later feature can call the Cloudflare
 // API as the signed-in user; Google's is kept the same way for
 // symmetry even though nothing uses it yet.
 //
 // Cloudflare endpoints confirmed against developers.cloudflare.com's
 // own OAuth docs (fundamentals/oauth/integrate-with-cloudflare);
 // "User Details Read" is scope id "user-details.read", confirmed via
 // GET https://api.cloudflare.com/client/v4/oauth/scopes. The full
 // login flow (authorize -> consent -> token exchange -> profile fetch
 // -> account/session creation) is verified live end to end as of
 // 2026-10-12, including real email/name via GET /client/v4/user (the
 // OIDC-shaped /oauth2/userinfo returns no profile data at all -- see
 // the cloudflare provider's own comment for why that endpoint isn't
 // used here).
 import { createHash, randomBytes } from "node:crypto";
 
 export interface OAuthEnv {
  CF_OAUTH_CLIENT_ID?: string;
  CF_OAUTH_CLIENT_SECRET?: string;
  CF_OAUTH_REDIRECT_URI?: string;
  GOOGLE_OAUTH_CLIENT_ID?: string;
  GOOGLE_OAUTH_CLIENT_SECRET?: string;
  GOOGLE_OAUTH_REDIRECT_URI?: string;
  TOKEN_ENCRYPTION_KEY?: string; // base64, 32 bytes, for AES-GCM
 }
 
 export type Provider = "cloudflare" | "google";
 
 export interface ProviderIdentity {
  subject: string;
  email: string | null;
  displayName: string;
  accessToken: string;
  refreshToken: string | null;
  expiresInSeconds: number | null;
 }
 
 interface ProviderConfig {
  authorizeUrl: string;
  tokenUrl: string;
  userinfoUrl: string;
  scope: string;
  clientId(env: OAuthEnv): string;
  clientSecret(env: OAuthEnv): string;
  redirectUri(env: OAuthEnv): string;
  extraAuthorizeParams?: Record<string, string>;
  parseUserInfo(json: unknown): { subject: string; email: string | null; displayName: string };
 }
 
 const PROVIDERS: Record<Provider, ProviderConfig> = {
  cloudflare: {
  authorizeUrl: "https://dash.cloudflare.com/oauth2/auth",
  tokenUrl: "https://dash.cloudflare.com/oauth2/token",
  // Not the OIDC-shaped /oauth2/userinfo (confirmed live to return
  // only {sub, iss, aud, ...}, no email/name, despite the
  // "user-details.read" scope's name) -- the OAuth access token also
  // works as a Bearer token against the real REST API, confirmed
  // live via /dev-cf-userinfo-raw: GET /client/v4/user returns the
  // full account profile. Only email/name/username are used below;
  // the rest of that response (org membership, full permissions
  // list, billing/subscription flags) is deliberately not requested
  // further or stored.
  userinfoUrl: "https://api.cloudflare.com/client/v4/user",
  scope: "user-details.read",
  clientId: (env) => required(env.CF_OAUTH_CLIENT_ID, "CF_OAUTH_CLIENT_ID"),
  clientSecret: (env) => required(env.CF_OAUTH_CLIENT_SECRET, "CF_OAUTH_CLIENT_SECRET"),
  redirectUri: (env) => required(env.CF_OAUTH_REDIRECT_URI, "CF_OAUTH_REDIRECT_URI"),
  parseUserInfo: (json) => {
  const j = json as { result?: { id?: string; email?: string; first_name?: string | null; last_name?: string | null; username?: string } };
  const r = j.result;
  if (!r?.id) throw new Error(`Cloudflare /client/v4/user response had no "result.id": ${JSON.stringify(json)}`);
  const fullName = [r.first_name, r.last_name].filter(Boolean).join(" ").trim();
  // email before username: Cloudflare's `username` is often itself
  // an opaque, auto-generated hash (confirmed live) when no
  // first/last name is set, no more readable than the raw id.
  return { subject: r.id, email: r.email ?? null, displayName: fullName || r.email || r.username || `cf-${r.id.slice(0, 8)}` };
  },
  },
  google: {
  authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
  tokenUrl: "https://oauth2.googleapis.com/token",
  userinfoUrl: "https://openidconnect.googleapis.com/v1/userinfo",
  scope: "openid email profile",
  extraAuthorizeParams: { access_type: "offline", prompt: "consent" },
  clientId: (env) => required(env.GOOGLE_OAUTH_CLIENT_ID, "GOOGLE_OAUTH_CLIENT_ID"),
  clientSecret: (env) => required(env.GOOGLE_OAUTH_CLIENT_SECRET, "GOOGLE_OAUTH_CLIENT_SECRET"),
  redirectUri: (env) => required(env.GOOGLE_OAUTH_REDIRECT_URI, "GOOGLE_OAUTH_REDIRECT_URI"),
  parseUserInfo: (json) => {
  const j = json as { sub: string; email?: string; name?: string };
  return { subject: j.sub, email: j.email ?? null, displayName: j.name ?? j.email ?? j.sub };
  },
  },
 };
 
 function required(value: string | undefined, name: string): string {
  if (!value) throw new Error(`missing Worker secret/var: ${name}`);
  return value;
 }
 
 export function buildAuthorizeUrl(provider: Provider, env: OAuthEnv, state: string): string {
  const config = PROVIDERS[provider];
  const url = new URL(config.authorizeUrl);
  url.searchParams.set("client_id", config.clientId(env));
  url.searchParams.set("redirect_uri", config.redirectUri(env));
  url.searchParams.set("response_type", "code");
  url.searchParams.set("scope", config.scope);
  url.searchParams.set("state", state);
  for (const [k, v] of Object.entries(config.extraAuthorizeParams ?? {})) url.searchParams.set(k, v);
  return url.toString();
 }
 
 export async function exchangeCodeForIdentity(provider: Provider, env: OAuthEnv, code: string): Promise<ProviderIdentity> {
  const config = PROVIDERS[provider];
  const basic = btoa(`${config.clientId(env)}:${config.clientSecret(env)}`);
  const tokenResp = await fetch(config.tokenUrl, {
  method: "POST",
  headers: { "content-type": "application/x-www-form-urlencoded", authorization: `Basic ${basic}` },
  body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: config.redirectUri(env) }),
  });
  if (!tokenResp.ok) throw new Error(`${provider} token exchange failed: ${tokenResp.status} ${await tokenResp.text()}`);
  const tokenJson = (await tokenResp.json()) as { access_token: string; refresh_token?: string; expires_in?: number };
 
  const userResp = await fetch(config.userinfoUrl, { headers: { authorization: `Bearer ${tokenJson.access_token}` } });
  if (!userResp.ok) throw new Error(`${provider} userinfo fetch failed: ${userResp.status} ${await userResp.text()}`);
  const userJson = await userResp.json();
  const { subject, email, displayName } = config.parseUserInfo(userJson);
 
  return { subject, email, displayName, accessToken: tokenJson.access_token, refreshToken: tokenJson.refresh_token ?? null, expiresInSeconds: tokenJson.expires_in ?? null };
 }
 
 // --- Encryption for stored OAuth tokens (AES-GCM, Web Crypto -- available natively in Workers) ---
 
 async function importKey(env: OAuthEnv): Promise<CryptoKey> {
  const raw = Uint8Array.from(atob(required(env.TOKEN_ENCRYPTION_KEY, "TOKEN_ENCRYPTION_KEY")), (c) => c.charCodeAt(0));
  return crypto.subtle.importKey("raw", raw, "AES-GCM", false, ["encrypt", "decrypt"]);
 }
 
 export async function encryptSecret(env: OAuthEnv, plaintext: string): Promise<string> {
  const key = await importKey(env);
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(plaintext));
  const combined = new Uint8Array(iv.length + ciphertext.byteLength);
  combined.set(iv, 0);
  combined.set(new Uint8Array(ciphertext), iv.length);
  return btoa(String.fromCharCode(...combined));
 }
 
 export async function decryptSecret(env: OAuthEnv, encoded: string): Promise<string> {
  const key = await importKey(env);
  const combined = Uint8Array.from(atob(encoded), (c) => c.charCodeAt(0));
  const iv = combined.slice(0, 12);
  const ciphertext = combined.slice(12);
  const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
  return new TextDecoder().decode(plaintext);
 }
 
 // --- Session cookie helpers ---
 
 const SESSION_COOKIE = "zz_session";
 
 export function sessionCookieHeader(sessionId: string, maxAgeSeconds: number): string {
  return `${SESSION_COOKIE}=${sessionId}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${maxAgeSeconds}`;
 }
 
 export function clearSessionCookieHeader(): string {
  return `${SESSION_COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`;
 }
 
 export function readSessionCookie(request: Request): string | null {
  const header = request.headers.get("cookie");
  if (!header) return null;
  for (const part of header.split(";")) {
  const [k, v] = part.trim().split("=");
  if (k === SESSION_COOKIE) return v ?? null;
  }
  return null;
 }
 
 // --- Agent bearer tokens (CLI/tool auth, separate from human sessions) ---
 
 export function generateAgentToken(): string {
  return `zzat-${randomBytes(24).toString("hex")}`;
 }
 
 export function hashAgentToken(token: string): string {
  return createHash("sha256").update(token).digest("hex");
 }
 
 export function readBearerToken(request: Request): string | null {
  const header = request.headers.get("authorization");
  if (!header?.startsWith("Bearer ")) return null;
  return header.slice("Bearer ".length);
 }