import { env } from "cloudflare:workers"; import { cookieValue } from "../auth"; import { decryptSecret, hmacSha256Hex, randomToken, sha256Hex, timingSafeEqualStrings } from "../crypto"; import { enforceRateLimit, requestIp } from "../limits"; import { codeKey, cookieSecret, SITE_LIMITS } from "./config"; import { safeNext, signAccess, verifyAccess } from "./naming"; import { verifySitePassword } from "./password"; import { finishOidc, startOidc } from "./oidc"; import { html, plain } from "./static"; import type { SiteMeta } from "./config"; export const LOGIN_PATH = "/__gitfor/login"; export const CALLBACK_PATH = "/__gitfor/callback"; const LOGOUT_PATH = "/__gitfor/logout"; const STATE_SECONDS = 600; type CodeRecord = { siteId: string; host: string; subject: string }; function secure(url: URL): boolean { return url.protocol === "https:"; } function name(url: URL, base: string): string { return secure(url) ? `__Host-${base}` : base; } /** Host-only (no Domain), so the cookie is never shared with the forge or sibling sites. */ function setCookie(url: URL, base: string, value: string, maxAge: number): string { return `${name(url, base)}=${encodeURIComponent(value)}; Path=/; HttpOnly;${secure(url) ? " Secure;" : ""} SameSite=Lax; Max-Age=${maxAge}`; } function redirect(location: string, cookies: string[] = []): Response { const headers = new Headers({ location, "cache-control": "no-store" }); for (const cookie of cookies) headers.append("set-cookie", cookie); return new Response(null, { status: 303, headers }); } /** The verified visitor for this site and host, or null. Public sites never need a cookie. */ export async function accessSubject(request: Request, meta: SiteMeta): Promise { const url = new URL(request.url); const token = cookieValue(request, name(url, "gitfor_site")); if (!token) return null; return verifyAccess(cookieSecret(), token, { siteId: meta.id, host: url.host, version: meta.accessVersion, now: Date.now(), }); } async function grant(url: URL, meta: SiteMeta, subject: string, next: string): Promise { const token = await signAccess(cookieSecret(), { siteId: meta.id, host: url.host, version: meta.accessVersion, subject, expiresAt: Date.now() + SITE_LIMITS.sessionSeconds * 1000, }); return redirect(next, [ setCookie(url, "gitfor_site", token, SITE_LIMITS.sessionSeconds), setCookie(url, "gitfor_state", "", 0), ]); } function escapeHtml(value: string): string { return value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`); } function passwordForm(next: string, error: string | null): Response { const message = error ? `

${escapeHtml(error)}

` : ""; return html( error ? 401 : 200, `Password required

This site is password protected

${message}
`, ); } /** Sends a visitor without a valid cookie to the right sign-in flow. */ export function challenge(request: Request): Response { const url = new URL(request.url); const next = `${url.pathname}${url.search}`; if (request.method !== "GET" && request.method !== "HEAD") return plain(401, "Sign in required"); return redirect(`${LOGIN_PATH}?next=${encodeURIComponent(next)}`); } function packState(parts: string[]): string { return parts.map(encodeURIComponent).join("|"); } async function signedState(url: URL, payload: string): Promise { const mac = await hmacSha256Hex(cookieSecret(), `state|${url.host}|${payload}`); return `${payload}~${mac}`; } /** Reads the state cookie; it is MAC'd to the host so a cookie tossed from a sibling site doesn't verify. */ async function readState(request: Request, url: URL): Promise { const raw = cookieValue(request, name(url, "gitfor_state")); if (!raw) return null; const index = raw.lastIndexOf("~"); if (index < 0) return null; const payload = raw.slice(0, index); if (!timingSafeEqualStrings(await signedState(url, payload), raw)) return null; try { return payload.split("|").map(decodeURIComponent); } catch { return null; } } async function startForgeLogin(url: URL, meta: SiteMeta, next: string): Promise { const state = randomToken("st").replace(/[^A-Za-z0-9_]/g, ""); const forge = new URL("/sites/authorize", env.PUBLIC_URL); forge.searchParams.set("site", meta.id); forge.searchParams.set("host", url.host); forge.searchParams.set("state", state); const cookie = await signedState(url, packState(["gitfor", state, next])); return redirect(forge.toString(), [setCookie(url, "gitfor_state", cookie, STATE_SECONDS)]); } async function finishForgeLogin(url: URL, meta: SiteMeta, state: string[]): Promise { const [, expected, next] = state; const code = url.searchParams.get("code"); if (!code || url.searchParams.get("state") !== expected) return plain(400, "Sign-in state mismatch; try again"); const key = codeKey(await sha256Hex(code)); const record = await env.KV.get(key, "json"); await env.KV.delete(key); if (!record || record.siteId !== meta.id || record.host !== url.host) return plain(400, "Sign-in code expired"); return grant(url, meta, record.subject, safeNext(next)); } async function passwordLogin(request: Request, url: URL, meta: SiteMeta): Promise { const origin = request.headers.get("origin"); if (origin && origin !== url.origin) return plain(403, "Cross-origin sign-in blocked"); await enforceRateLimit("AUTH_LIMITER", `site-password:${meta.id}:${requestIp(request)}`); const form = await request.formData(); const next = safeNext(String(form.get("next") ?? "/")); const password = String(form.get("password") ?? "").slice(0, 1024); if (!meta.passwordHash || !(await verifySitePassword(password, meta.passwordHash))) { return passwordForm(next, "Incorrect password"); } return grant(url, meta, "password", next); } async function oidcSecret(meta: SiteMeta): Promise { if (!meta.oidc) throw new Error("OIDC is not configured"); return decryptSecret(env.SECRETS_KEY, meta.oidc.secretCt, meta.oidc.secretIv); } /** Handles `/__gitfor/*` sign-in routes on a site host; returns null for any other path. */ export async function handleAccessRoute(request: Request, meta: SiteMeta): Promise { const url = new URL(request.url); if (url.pathname === LOGOUT_PATH) { return redirect("/", [setCookie(url, "gitfor_site", "", 0), setCookie(url, "gitfor_state", "", 0)]); } if (url.pathname !== LOGIN_PATH && url.pathname !== CALLBACK_PATH) return null; if (meta.access === "public") return redirect("/"); if (url.pathname === LOGIN_PATH) { const next = safeNext(url.searchParams.get("next")); if (meta.access === "password") { return request.method === "POST" ? passwordLogin(request, url, meta) : passwordForm(next, null); } if (meta.access === "gitfor") return startForgeLogin(url, meta, next); if (!meta.oidc) return plain(503, "Sign-in is not configured for this site"); await enforceRateLimit("AUTH_LIMITER", `site-oidc:${meta.id}:${requestIp(request)}`); const started = await startOidc(meta.oidc, `${url.origin}${CALLBACK_PATH}`); const cookie = await signedState(url, packState(["oidc", started.state, started.nonce, started.verifier, next])); return redirect(started.location, [setCookie(url, "gitfor_state", cookie, STATE_SECONDS)]); } const state = await readState(request, url); if (!state) return plain(400, "Sign-in session expired; try again"); if (state[0] === "gitfor" && meta.access === "gitfor") return finishForgeLogin(url, meta, state); if (state[0] !== "oidc" || meta.access !== "oidc" || !meta.oidc) return plain(400, "Sign-in mode changed; try again"); const [, expected, nonce, verifier, next] = state; if (url.searchParams.get("state") !== expected) return plain(400, "Sign-in state mismatch; try again"); const code = url.searchParams.get("code"); if (!code) return plain(400, url.searchParams.get("error_description") ?? "Sign-in was cancelled"); const email = await finishOidc(meta.oidc, await oidcSecret(meta), { code, nonce, verifier, redirectUri: `${url.origin}${CALLBACK_PATH}`, }); if (!email) return plain(403, "Your account is not allowed to view this site"); return grant(url, meta, email, safeNext(next)); }