// c21 (auth) — GitHub OAuth start + callback handlers. Composes // c14_github (token exchange + user fetch), c14_forgejo (existence check // + agent registration), c32_session (sign + cookie), c51 layout for // the welcome page rendered after first-time registration. import * as github from "./c14_github.ts"; import * as forgejo from "./c14_forgejo.ts"; import { parseUrl } from "./c14_request_parse.ts"; import { SESSION_TTL_SEC, parseCookies, signSession, sessionCookieHeader, timingSafeEqual, randomHex, } from "./b32_session.ts"; import { renderPage, errorPage } from "./b51_render_layout.ts"; const BASE_URL = process.env.BASE_URL ?? "https://tdd.md"; const CALLBACK_URL = `${BASE_URL}/auth/github/callback`; const CLEAR_OAUTH_STATE = "tdd_oauth_state=; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=0"; const CLEAR_OAUTH_RETURN = "tdd_oauth_return=; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=0"; // Same-origin internal path. Anything that doesn't start with a single // "/" or that contains "//" / ":" is rejected to prevent open-redirect. const isSafeReturnTo = (s: string): boolean => s.startsWith("/") && !s.startsWith("//") && !s.includes("\n") && !s.includes("\r") && s.length < 1024; export const startGithubOauth = (req?: Request): Response => { if (!github.isConfigured() || !forgejo.isConfigured()) { return new Response("registration is not configured on this server", { status: 503 }); } const nonce = randomHex(16); const headers = new Headers(); headers.append("Set-Cookie", `tdd_oauth_state=${nonce}; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=600`); // Optional ?to= query — set a return cookie the callback // honours after a successful sign-in. Used by /edit and /admin // links so the user lands back where they came from. if (req) { const urlR = parseUrl(req.url); const to = urlR.ok ? urlR.value.searchParams.get("to") : null; if (to && isSafeReturnTo(to)) { headers.append( "Set-Cookie", `tdd_oauth_return=${encodeURIComponent(to)}; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=600`, ); } } headers.set("Location", github.authorizeUrl(nonce, CALLBACK_URL)); return new Response(null, { status: 302, headers }); }; const welcomeBody = (reg: forgejo.AgentRegistration): string => { const verb = reg.isNew ? "created" : "rotated"; return `# welcome, ${reg.username} > Your tdd.md agent has been ${verb}. **Save the token below — this page is the only time you'll see it.** If you lose it, [register again](/agents/register) to issue a fresh one (the old one will stop working). ## push token \`\`\` ${reg.pushToken} \`\`\` ## kata: string-calc Your repo is at [\`git.tdd.md/${reg.username}/string-calc\`](https://git.tdd.md/${reg.username}/string-calc), already initialized with a default branch \`main\`. \`\`\` git clone ${reg.repoCloneUrl} cd string-calc # play the kata, commit per phase # red: commit a failing test # green: commit the impl that makes it pass # refactor: commit a structural change with tests staying green git push # username: ${reg.username} # password: \`\`\` When you push, the judge replays your commits and posts the verdict at [/agents/${reg.username}/string-calc](/agents/${reg.username}/string-calc). [← spec](/games/string-calc) · [all agents](/agents) `; }; export const handleGithubCallback = async (req: Request): Promise => { const urlR = parseUrl(req.url); if (!urlR.ok) return errorPage("invalid callback URL"); const url = urlR.value; const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); if (!code || !state) return errorPage("missing code or state"); const cookies = parseCookies(req.headers.get("cookie")); const cookieState = cookies.tdd_oauth_state; if (!cookieState || !timingSafeEqual(cookieState, state)) { return errorPage("state mismatch — open the registration page again and retry"); } let username: string; let email: string; let fullName: string | null; try { const accessToken = await github.exchangeCode(code, CALLBACK_URL); const user = await github.fetchUser(accessToken); username = user.login; fullName = user.name; // GitHub's noreply email format: unique per account, never collides // with another Forgejo user. We don't need a deliverable address — // agents authenticate by token, not by email reset flow. email = `${user.id}+${user.login}@users.noreply.github.com`; } catch (err) { return errorPage(`github oauth failed: ${(err as Error).message}`, 400); } // Login vs register: if the user already exists in Forgejo, this // is a returning visitor — set the session cookie, redirect to // their dashboard (or to the cookie-stored returnTo path, when one // was set by /auth/github/start?to=...), don't rotate their token. const isExisting = await forgejo.userExists(username); const sessionToken = await signSession(username); const sessionCookie = sessionCookieHeader(sessionToken, SESSION_TTL_SEC); const returnToRaw = cookies.tdd_oauth_return ? decodeURIComponent(cookies.tdd_oauth_return) : null; const returnTo = returnToRaw && isSafeReturnTo(returnToRaw) ? returnToRaw : null; if (isExisting) { return new Response(null, { status: 302, headers: new Headers([ ["Location", returnTo ?? `/agents/${username}`], ["Set-Cookie", sessionCookie], ["Set-Cookie", CLEAR_OAUTH_STATE], ["Set-Cookie", CLEAR_OAUTH_RETURN], ]), }); } let reg: forgejo.AgentRegistration; try { reg = await forgejo.registerAgent({ username, email, fullName: fullName ?? undefined, }); } catch (err) { return errorPage(`failed to create your agent: ${(err as Error).message}`, 422); } const html = await renderPage({ title: `welcome ${reg.username} — tdd.md`, bodyMarkdown: welcomeBody(reg), active: "agents", noindex: true, }); return new Response(html, { headers: new Headers([ ["Content-Type", "text/html; charset=utf-8"], ["Set-Cookie", sessionCookie], ["Set-Cookie", CLEAR_OAUTH_STATE], ["Set-Cookie", CLEAR_OAUTH_RETURN], ]), }); };