syntaxai/tdd.md · main · src / d21_handlers_auth.ts

d21_handlers_auth.ts 171 lines · 6283 bytes raw
// 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=<path> 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: <paste the token above>
\`\`\`

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<Response> => {
  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],
    ]),
  });
};