syntaxai/tdd.md · main · src / d21_handlers_auth.ts
// 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],
]),
});
};