import { signupsFull } from "./waitlist"; import { env } from "cloudflare:workers"; import { z } from "zod"; import { first, run } from "./db"; import { randomToken, timingSafeEqualStrings, toBase64 } from "./crypto"; import { pendingLoginCookie, requiresSecondFactor } from "./two-factor"; import { cookieAttributes, cookieName, cookieValue, createSessionCookie } from "./auth"; import { accountNameTaken, createUser, promoteAdminEmailOnVerify, reclaimUnverifiedEmail } from "./accounts"; import { publicUrl } from "./events/emit"; import { requestIp, withinRateLimit } from "./limits"; import { isReservedName } from "~/lib/reserved-names"; import { recordTermsAcceptance } from "./legal"; import { accountDisableState, disabledNotice } from "./account-disable"; import { safeReturnPath } from "~/lib/return-to"; import { userChanged } from "./user-row"; const STATE_TTL_SECONDS = 600; const GITHUB_USER_AGENT = "gitfor-forge"; const MAX_NAME_SUFFIX = 1000; const STATE_COOKIE_BASE = "gitfor_oauth"; function clearStateCookie(): string { return `${cookieName(STATE_COOKIE_BASE)}=; ${cookieAttributes(0)}`; } const tokenResponse = z.object({ access_token: z.string() }); const githubUser = z.object({ id: z.number(), login: z.string(), name: z.string().nullable(), }); const githubEmails = z.array(z.object({ email: z.string(), primary: z.boolean(), verified: z.boolean() })); /** Always the canonical origin (AUTH-A11): the callback and its `__Host-` cookies must live on PUBLIC_URL. */ function callbackUrl(): string { return publicUrl("/auth/github/callback"); } /** Auth routes reached on an alias host (e.g. `*.workers.dev`) bounce to PUBLIC_URL so cookies and redirect_uri agree. */ function canonicalRedirect(request: Request): Response | null { const requested = new URL(request.url); const canonical = new URL(env.PUBLIC_URL); if (requested.host === canonical.host) return null; return Response.redirect(publicUrl(`${requested.pathname}${requested.search}`), 302); } async function pkceChallenge(verifier: string): Promise { const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); return toBase64(new Uint8Array(digest)).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""); } /** The PKCE verifier and the page to return to, re-validated on the way out. */ function parseStoredState(raw: string | null): { verifier: string; next: string | null } | null { if (!raw) return null; try { const value: unknown = JSON.parse(raw); if (typeof value === "object" && value !== null && "verifier" in value && typeof value.verifier === "string") { return { verifier: value.verifier, next: "next" in value ? safeReturnPath(value.next) : null }; } } catch { return null; } return null; } export async function startGithubLogin(request: Request): Promise { if (!env.GITHUB_CLIENT_ID) return new Response("GitHub login is not configured", { status: 404 }); const bounce = canonicalRedirect(request); if (bounce) return bounce; const state = randomToken("oauth"); const verifier = randomToken("pkce"); const next = safeReturnPath(new URL(request.url).searchParams.get("next")); await env.KV.put(`oauth:${state}`, JSON.stringify({ verifier, next }), { expirationTtl: STATE_TTL_SECONDS, }); const url = new URL("https://github.com/login/oauth/authorize"); url.searchParams.set("client_id", env.GITHUB_CLIENT_ID); url.searchParams.set("redirect_uri", callbackUrl()); url.searchParams.set("scope", "read:user user:email"); url.searchParams.set("state", state); url.searchParams.set("code_challenge", await pkceChallenge(verifier)); url.searchParams.set("code_challenge_method", "S256"); return new Response(null, { status: 302, headers: { location: url.toString(), "set-cookie": `${cookieName(STATE_COOKIE_BASE)}=${state}; ${cookieAttributes(STATE_TTL_SECONDS)}`, }, }); } function failure(message: string, status = 400): Response { return new Response(`${message}\n\nStart again: /auth/github\n`, { status, headers: { "content-type": "text/plain; charset=utf-8", "set-cookie": clearStateCookie(), }, }); } function redirectWithCookie(location: string, cookie: string): Response { const headers = new Headers({ location }); headers.append("set-cookie", cookie); headers.append("set-cookie", clearStateCookie()); return new Response(null, { status: 302, headers }); } async function uniqueName(login: string): Promise { const base = login.replace(/[^A-Za-z0-9-]/g, "-").slice(0, 32) || "user"; if (!isReservedName(base) && !(await accountNameTaken(base))) return base; for (let suffix = 2; suffix < MAX_NAME_SUFFIX; suffix++) { const candidate = `${base}-${suffix}`; if (!(await accountNameTaken(candidate))) return candidate; } throw new Error("Could not allocate a username"); } export async function finishGithubLogin(request: Request): Promise { const bounce = canonicalRedirect(request); if (bounce) return bounce; const url = new URL(request.url); const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); if (!code || !state || !env.GITHUB_CLIENT_ID || !env.GITHUB_CLIENT_SECRET) return failure("Invalid OAuth callback"); const browserState = cookieValue(request, cookieName(STATE_COOKIE_BASE)); if (!browserState || !timingSafeEqualStrings(browserState, state)) { return failure("This sign-in was not started from this browser"); } const stored = parseStoredState(await env.KV.get(`oauth:${state}`)); if (!stored) return failure("OAuth state expired"); await env.KV.delete(`oauth:${state}`); const { verifier, next } = stored; const exchange = await fetch("https://github.com/login/oauth/access_token", { method: "POST", headers: { accept: "application/json", "content-type": "application/json", "user-agent": GITHUB_USER_AGENT, }, body: JSON.stringify({ client_id: env.GITHUB_CLIENT_ID, client_secret: env.GITHUB_CLIENT_SECRET, code, code_verifier: verifier, redirect_uri: callbackUrl(), }), }); const exchanged = tokenResponse.safeParse(await exchange.json().catch(() => null)); if (!exchanged.success) return failure("GitHub rejected the sign-in code; it may have expired"); const token = exchanged.data.access_token; const headers = { authorization: `Bearer ${token}`, "user-agent": GITHUB_USER_AGENT, accept: "application/vnd.github+json", }; const [profileResponse, emailsResponse] = await Promise.all([ fetch("https://api.github.com/user", { headers }), fetch("https://api.github.com/user/emails", { headers }), ]); const profile = githubUser.safeParse(await profileResponse.json().catch(() => null)); const emailList = githubEmails.safeParse(await emailsResponse.json().catch(() => null)); if (!profile.success || !emailList.success) return failure("Could not read your GitHub profile", 502); const emails = emailList.data; const primary = emails.find((entry) => entry.primary && entry.verified) ?? emails.find((entry) => entry.verified); if (!primary) return failure("Your GitHub account has no verified email"); const githubId = String(profile.data.id); let userId = (await first<{ account_id: string }>("SELECT account_id FROM users WHERE github_id = ?", githubId))?.account_id ?? null; if (!userId) { if (!(await withinRateLimit("AUTH_LIMITER", `signup:${requestIp(request)}`))) { return failure("Too many sign-ups from your network; try again shortly.", 429); } let byEmail = await first<{ account_id: string; email_verified_at: number | null; github_id: string | null; }>("SELECT account_id, email_verified_at, github_id FROM users WHERE email = ?", primary.email); // AUTH-A2: a stale, never-verified local row must not block the verified GitHub owner of the address. if (byEmail && byEmail.email_verified_at === null && (await reclaimUnverifiedEmail(primary.email))) { byEmail = null; } if (byEmail && (byEmail.email_verified_at === null || byEmail.github_id !== null)) { return failure( "An account with this email already exists. Sign in with your password and verify your email address, then try GitHub again.", 409, ); } if (byEmail) { await run("UPDATE users SET github_id = ? WHERE account_id = ?", githubId, byEmail.account_id); await userChanged(byEmail.account_id); userId = byEmail.account_id; } else { // At the cap, a new GitHub sign-in goes to the signup page, which offers the waitlist. if (await signupsFull()) return Response.redirect(publicUrl("/signup"), 302); userId = await createUser({ name: await uniqueName(profile.data.login), email: primary.email, password: null, githubId, displayName: profile.data.name, }); await recordTermsAcceptance(userId); } } await run( "UPDATE users SET email_verified_at = COALESCE(email_verified_at, ?) WHERE account_id = ? AND email = ?", Date.now(), userId, primary.email, ); await userChanged(userId); await promoteAdminEmailOnVerify(userId); const disabled = await accountDisableState(userId); if (disabled) return failure(disabledNotice(disabled.reason).message, 403); if (await requiresSecondFactor(userId)) { const twoFactor = next ? `/login/two-factor?next=${encodeURIComponent(next)}` : "/login/two-factor"; return redirectWithCookie(twoFactor, await pendingLoginCookie(userId)); } return redirectWithCookie(next ?? "/", await createSessionCookie(userId, request)); }