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, reclaimUnverifiedEmail } from "./accounts"; import { publicUrl } from "./events/emit"; import { isReservedName } from "~/lib/reserved-names"; 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(/=+$/, ""); } 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"); await env.KV.put(`oauth:${state}`, verifier, { 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 verifier = await env.KV.get(`oauth:${state}`); if (!verifier) return failure("OAuth state expired"); await env.KV.delete(`oauth:${state}`); 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) { 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); userId = byEmail.account_id; } else { userId = await createUser({ name: await uniqueName(profile.data.login), email: primary.email, password: null, githubId, displayName: profile.data.name, }); } } await run( "UPDATE users SET email_verified_at = COALESCE(email_verified_at, ?) WHERE account_id = ? AND email = ?", Date.now(), userId, primary.email, ); if (await requiresSecondFactor(userId)) { return redirectWithCookie("/login/two-factor", await pendingLoginCookie(userId)); } return redirectWithCookie("/", await createSessionCookie(userId, request)); }