app/worker/src/auth.ts
zzmq.dev@gmail.com/zz / app / worker / src / auth.ts
@ 527d5696
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);
}