import { env } from "cloudflare:workers"; import { z } from "zod"; import { first, run } from "./db"; import { randomToken } from "./crypto"; import { createSessionCookie } from "./auth"; import { accountNameTaken, createUser } from "./accounts"; const STATE_TTL_SECONDS = 600; const GITHUB_USER_AGENT = "stig-forge"; const MAX_NAME_SUFFIX = 1000; 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() })); function callbackUrl(request: Request): string { return new URL("/auth/github/callback", request.url).toString(); } export async function startGithubLogin(request: Request): Promise { if (!env.GITHUB_CLIENT_ID) return new Response("GitHub login is not configured", { status: 404 }); const state = randomToken("oauth"); await env.KV.put(`oauth:${state}`, "1", { 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(request)); url.searchParams.set("scope", "read:user user:email"); url.searchParams.set("state", state); return Response.redirect(url.toString(), 302); } async function uniqueName(login: string): Promise { const base = login.replace(/[^A-Za-z0-9-]/g, "-").slice(0, 32) || "user"; if (!(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 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 new Response("Invalid OAuth callback", { status: 400 }); } if (!(await env.KV.get(`oauth:${state}`))) return new Response("OAuth state expired", { status: 400 }); 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, redirect_uri: callbackUrl(request), }), }); const token = tokenResponse.parse(await exchange.json()).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.parse(await profileResponse.json()); const emails = githubEmails.parse(await emailsResponse.json()); const primary = emails.find((entry) => entry.primary && entry.verified) ?? emails.find((entry) => entry.verified); if (!primary) return new Response("Your GitHub account has no verified email", { status: 400 }); const githubId = String(profile.id); let userId = (await first<{ account_id: string }>("SELECT account_id FROM users WHERE github_id = ?", githubId))?.account_id ?? null; if (!userId) { const byEmail = await first<{ account_id: string }>("SELECT account_id FROM users WHERE email = ?", primary.email); 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.login), email: primary.email, password: null, githubId, displayName: profile.name, }); } } return new Response(null, { status: 302, headers: { location: "/", "set-cookie": await createSessionCookie(userId) }, }); }