import { env, exports } from "cloudflare:workers"; import { makeAuth } from "@/worker/auth"; import { encodeBase64Url, sha256 } from "@/worker/services/crypto"; export const SELF = exports.default; export const ISSUER = "http://localhost"; export const DEFAULT_PASSWORD = "correct-horse-battery-staple"; export const DEFAULT_REDIRECT_URI = "http://127.0.0.1:0/cb"; export interface TestCredential { email: string; password: string; name: string; } export interface CreatedOAuthClient { client_id: string; client_secret: string; } export interface OAuthAuthorizeResult { status: number; location: string | null; code: string | null; verifier: string; } export interface OAuthTokenResponse { access_token?: string; id_token?: string; refresh_token?: string; token_type?: string; expires_in?: number; } export interface JsonErrorBody { error?: string; code?: string; message?: string; } export interface CountRow { c: number; } export type OAuthTokenTable = "oauth_access_tokens" | "oauth_refresh_tokens"; export interface CreateOAuthClientOptions { name: string; redirectUris?: string[]; skipConsent?: boolean; uri?: string; } export interface PkcePair { verifier: string; challenge: string; } export const testHeaders = (overrides: Record = {}): Record => ({ "content-type": "application/json", origin: ISSUER, ...overrides, }); // Workers' Headers conflates multiple Set-Cookie values into one comma-joined // string. Splitting on comma-not-followed-by-an-attribute keeps `Expires=Wed, // 01 Jan ...` from being read as a cookie boundary. export const extractSessionCookie = (res: Response): string | null => { const setCookie = res.headers.get("set-cookie"); if (!setCookie) return null; const cookie = setCookie .split(/,(?=\s*[^\s,]+=)/) .map((c) => c.split(";")[0]?.trim()) .filter((c): c is string => Boolean(c) && /better-auth\./.test(c)) .join("; "); return cookie || null; }; // `ip` controls the rate-limit bucket. Sign-ins within a suite share a bucket, // so pass per-suite IPs to keep bursts under the per-IP threshold. export const signInForCookie = async (email: string, password: string, ip: string = "10.0.0.1"): Promise => { const res = await SELF.fetch(`${ISSUER}/api/sign-in`, { method: "POST", headers: testHeaders({ "CF-Connecting-IP": ip }), body: JSON.stringify({ email, password, turnstileToken: "loopback" }), }); if (res.status !== 200) { throw new Error(`sign-in failed: ${res.status} ${await res.text()}`); } const cookie = extractSessionCookie(res); if (!cookie) throw new Error("sign-in returned no session cookie"); return cookie; }; export const makeTestAuth = () => makeAuth(env, { baseURL: ISSUER, issuer: ISSUER }); export const signUpTestUser = async (credential: TestCredential): Promise => { const auth = makeTestAuth(); await auth.api.signUpEmail({ body: credential, asResponse: false }); }; export const promoteUserToAdmin = async (email: string): Promise => { await env.DB.prepare("UPDATE users SET role = ? WHERE email = ?").bind("admin", email).run(); }; export const signUpAdmin = async (credential: TestCredential): Promise => { await signUpTestUser(credential); await promoteUserToAdmin(credential.email); }; export const createOAuthClientAsAdmin = async ( adminCookie: string, options: CreateOAuthClientOptions, ): Promise => { const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, { method: "POST", headers: testHeaders({ cookie: adminCookie }), body: JSON.stringify({ redirectUris: [DEFAULT_REDIRECT_URI], ...options, }), }); if (!res.ok) { throw new Error(`setup create-client failed: ${res.status} ${await res.text()}`); } return (await res.json()) as CreatedOAuthClient; }; export const createPkcePair = async (): Promise => { const verifier = encodeBase64Url(crypto.getRandomValues(new Uint8Array(32))); const challenge = await sha256(verifier); return { verifier, challenge }; }; export const buildAuthorizeUrl = (options: { clientId: string; redirectUri?: string; scope?: string; state?: string; challenge: string; }): URL => { const url = new URL(`${ISSUER}/api/auth/oauth2/authorize`); url.searchParams.set("response_type", "code"); url.searchParams.set("client_id", options.clientId); url.searchParams.set("redirect_uri", options.redirectUri ?? DEFAULT_REDIRECT_URI); url.searchParams.set("scope", options.scope ?? "openid profile email"); url.searchParams.set("state", options.state ?? "test-state"); url.searchParams.set("code_challenge", options.challenge); url.searchParams.set("code_challenge_method", "S256"); return url; }; export const authorizeWithPkce = async (options: { clientId: string; cookie?: string; redirectUri?: string; scope?: string; state?: string; }): Promise => { const { verifier, challenge } = await createPkcePair(); const url = buildAuthorizeUrl({ clientId: options.clientId, redirectUri: options.redirectUri, scope: options.scope, state: options.state, challenge, }); const res = await SELF.fetch(url, { headers: options.cookie ? { cookie: options.cookie } : undefined, redirect: "manual", }); const location = res.headers.get("location"); let code: string | null = null; if (location) { try { code = new URL(location, ISSUER).searchParams.get("code"); } catch { code = null; } } return { status: res.status, location, code, verifier }; }; export const exchangeAuthorizationCode = async (options: { code: string; verifier: string; clientId: string; clientSecret: string; redirectUri?: string; }): Promise => SELF.fetch(`${ISSUER}/api/auth/oauth2/token`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", authorization: `Basic ${btoa(`${options.clientId}:${options.clientSecret}`)}`, }, body: new URLSearchParams({ grant_type: "authorization_code", code: options.code, redirect_uri: options.redirectUri ?? DEFAULT_REDIRECT_URI, code_verifier: options.verifier, }), }); export const getUserIdByEmail = async (email: string): Promise => { const row = await env.DB.prepare("SELECT id FROM users WHERE email = ?").bind(email).first<{ id: string }>(); if (!row) throw new Error(`user row missing for ${email}`); return row.id; }; export const getLatestSessionIdForUser = async (userId: string): Promise => { const row = await env.DB.prepare("SELECT id FROM sessions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1") .bind(userId) .first<{ id: string }>(); if (!row) throw new Error(`session row missing for user ${userId}`); return row.id; }; export const countOAuthTokensForClient = async (table: OAuthTokenTable, clientId: string): Promise => { const row = await env.DB.prepare(`SELECT COUNT(*) AS c FROM ${table} WHERE client_id = ?`) .bind(clientId) .first(); return row?.c ?? 0; };