import type { IncomingMessage, ServerResponse } from "node:http"; import { addAccountToConfig } from "./account-store.js"; import { PayloadTooLargeError, readLimitedBody } from "./body-limit.js"; import { buildAuthUrl, discoverProject, exchangeAuthorizationCode, generatePkce, generateState, getOAuthClientConfig, getUserEmail, isHostedOAuthConfigured, } from "./oauth.js"; import type { AccountRotator } from "./rotator.js"; interface PendingSession { verifier: string; createdAt: number; } const pendingSessions = new Map(); const SESSION_TTL_MS = 15 * 60 * 1000; const SESSION_PRUNE_INTERVAL_MS = 5 * 60 * 1000; const MAX_CLI_LOGIN_BODY_BYTES = 64 * 1024; function escapeHtml(value: unknown): string { return String(value) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function prunePendingSessions(): void { const cutoff = Date.now() - SESSION_TTL_MS; for (const [state, session] of pendingSessions.entries()) { if (session.createdAt < cutoff) { pendingSessions.delete(state); } } } // Background reaper. Without this, a long-lived proxy that never sees // /auth/antigravity/start or /callback would accumulate stale sessions // (each is a 96-byte PKCE verifier + timestamp). The interval is unref'd // so it does not block process exit. let pruneTimer: ReturnType | null = null; function startPendingSessionReaper(): void { if (pruneTimer) return; pruneTimer = setInterval( () => prunePendingSessions(), SESSION_PRUNE_INTERVAL_MS, ); if (pruneTimer.unref) pruneTimer.unref(); } export function stopPendingSessionReaper(): void { if (pruneTimer) { clearInterval(pruneTimer); pruneTimer = null; } } startPendingSessionReaper(); function renderPage(title: string, body: string): string { return ` ${escapeHtml(title)}
${body}
`; } export function serveLoginLanding(res: ServerResponse): void { const hostedReady = isHostedOAuthConfigured(); const oauth = hostedReady ? getOAuthClientConfig() : null; const message = hostedReady ? `

This page starts the Antigravity sign-in flow and returns here automatically so the account can be added to this rotator.

Configured callback: ${escapeHtml(oauth?.redirectUri)}

Signing in here grants this server a refresh token for the selected Google account. That allows the rotator to keep using that account until access is revoked.
Continue With Google` : `

This server is not yet configured for hosted OAuth.

Set ANTIGRAVITY_REDIRECT_URI, and usually ANTIGRAVITY_CLIENT_ID plus ANTIGRAVITY_CLIENT_SECRET, to a public callback URL registered with the OAuth client.

The current redirect is still loopback-only, so the transparent public callback cannot complete yet.
`; res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end( renderPage("Antigravity Login", `

Connect Your Account

${message}`), ); } export function startHostedLogin( req: IncomingMessage, res: ServerResponse, ): void { if (!isHostedOAuthConfigured()) { res.writeHead(409, { "Content-Type": "text/html; charset=utf-8" }); res.end( renderPage( "Hosted OAuth Not Configured", "

Hosted Login Isn’t Ready

This server still uses a loopback redirect URI. Configure a public redirect before sharing this page.

", ), ); return; } prunePendingSessions(); const { verifier, challenge } = generatePkce(); const state = generateState(); pendingSessions.set(state, { verifier, createdAt: Date.now() }); const authUrl = buildAuthUrl(state, challenge); res.writeHead(302, { Location: authUrl }); res.end(); } // ── Web-based CLI login (/login-cli) ────────────────────────────────────────── // Replicates the CLI login flow in the browser: shows the Google OAuth URL, // user signs in and pastes the redirect URL back, server exchanges the code. interface CliLoginSession { verifier: string; challenge: string; oauthState: string; authUrl: string; createdAt: number; } const cliLoginSessions = new Map(); function pruneCliSessions(): void { const cutoff = Date.now() - SESSION_TTL_MS; for (const [id, session] of cliLoginSessions.entries()) { if (session.createdAt < cutoff) { cliLoginSessions.delete(id); } } } export function serveCliLogin(res: ServerResponse): void { pruneCliSessions(); const { verifier, challenge } = generatePkce(); const oauthState = generateState(); let authUrl: string; try { authUrl = buildAuthUrl(oauthState, challenge); } catch (err) { res.writeHead(503, { "Content-Type": "text/html; charset=utf-8" }); res.end( renderPage( "OAuth Not Configured", `

OAuth Login Isn’t Ready

${escapeHtml(err instanceof Error ? err.message : String(err))}

`, ), ); return; } const sessionId = generateState(); cliLoginSessions.set(sessionId, { verifier, challenge, oauthState, authUrl, createdAt: Date.now(), }); res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end( renderPage( "Add Account", `

Add Account

This page works like the CLI login. Follow the steps below to add a Google account to the rotator.

Step 1 — Sign in with Google

Click the button below to open the Google sign-in page in a new tab:

Sign in with Google ↗

Step 2 — Paste the redirect URL

After signing in, Google will redirect to localhost (which will fail — that's expected). Copy the full URL from your browser's address bar and paste it here:

`, ), ); } export async function handleCliLoginApi( req: IncomingMessage, res: ServerResponse, rotator: AccountRotator, ): Promise { let body: { session?: string; redirectUrl?: string }; try { const raw = await readLimitedBody(req, MAX_CLI_LOGIN_BODY_BYTES); const parsed: unknown = JSON.parse(raw.toString("utf-8")); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error("Request body must be an object"); } body = parsed as { session?: string; redirectUrl?: string }; } catch (err) { res.writeHead(err instanceof PayloadTooLargeError ? 413 : 400, { "Content-Type": "application/json", }); res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" })); return; } const { session: sessionId, redirectUrl } = body; if ( typeof sessionId !== "string" || typeof redirectUrl !== "string" || sessionId.length === 0 || redirectUrl.length === 0 || sessionId.length > 256 || redirectUrl.length > 8 * 1024 ) { res.writeHead(400, { "Content-Type": "application/json" }); res.end( JSON.stringify({ ok: false, error: "Missing session or redirectUrl" }), ); return; } pruneCliSessions(); const session = cliLoginSessions.get(sessionId); if (!session) { res.writeHead(400, { "Content-Type": "application/json" }); res.end( JSON.stringify({ ok: false, error: "Session expired or invalid. Reload the page and try again.", }), ); return; } // Parse the redirect URL to extract code let code: string | undefined; let state: string | null; try { const url = new URL(redirectUrl.trim()); code = url.searchParams.get("code") ?? undefined; state = url.searchParams.get("state"); } catch { res.writeHead(400, { "Content-Type": "application/json" }); res.end( JSON.stringify({ ok: false, error: "Could not parse the URL. Make sure you pasted the full redirect URL.", }), ); return; } if (!code) { res.writeHead(400, { "Content-Type": "application/json" }); res.end( JSON.stringify({ ok: false, error: "No authorization code found in the URL.", }), ); return; } if (state !== session.oauthState) { res.writeHead(400, { "Content-Type": "application/json" }); res.end( JSON.stringify({ ok: false, error: "State mismatch - reload the login page and try again.", }), ); return; } cliLoginSessions.delete(sessionId); try { const tokenData = await exchangeAuthorizationCode(code, session.verifier); const email = await getUserEmail(tokenData.accessToken); const project = await discoverProject(tokenData.accessToken); const label = email ? email.split("@")[0] : "Account"; const entry = { email: email || "unknown@gmail.com", refreshToken: tokenData.refreshToken, projectId: project.projectId, projectSource: project.source, label, }; const { isNew } = addAccountToConfig(entry); rotator.addOrUpdateAccount(entry); res.writeHead(200, { "Content-Type": "application/json" }); res.end( JSON.stringify({ ok: true, email: entry.email, isNew, projectId: project.projectId, }), ); } catch { res.writeHead(500, { "Content-Type": "application/json" }); res.end( JSON.stringify({ ok: false, error: "Unable to complete login. Please try again.", }), ); } } export async function handleHostedCallback( req: IncomingMessage, res: ServerResponse, rotator: AccountRotator, ): Promise { const requestUrl = new URL(req.url || "/", "http://localhost"); const code = requestUrl.searchParams.get("code"); const state = requestUrl.searchParams.get("state"); const error = requestUrl.searchParams.get("error"); if (error) { res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); res.end( renderPage( "Sign-In Cancelled", `

Sign-In Cancelled

Google returned: ${escapeHtml(error)}

`, ), ); return; } if (!code || !state) { res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); res.end( renderPage( "Missing Parameters", "

Missing Parameters

The callback did not include a valid code and state.

", ), ); return; } prunePendingSessions(); const session = pendingSessions.get(state); if (!session) { res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); res.end( renderPage( "Session Expired", "

Session Expired

This sign-in session is no longer valid. Start again from the login page.

", ), ); return; } pendingSessions.delete(state); try { const tokenData = await exchangeAuthorizationCode(code, session.verifier); const email = await getUserEmail(tokenData.accessToken); const project = await discoverProject(tokenData.accessToken); const label = email ? email.split("@")[0] : "Account"; const entry = { email: email || "unknown@gmail.com", refreshToken: tokenData.refreshToken, projectId: project.projectId, projectSource: project.source, label, }; const { isNew } = addAccountToConfig(entry); rotator.addOrUpdateAccount(entry); res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end( renderPage( "Account Connected", `

Account Connected

${escapeHtml(entry.email)} was ${isNew ? "added" : "updated"} successfully.

Project: ${escapeHtml(project.projectId)} via ${escapeHtml(project.source)}.

The rotator can start using this account immediately.

If you ever want to stop sharing access, revoke this app's access from the Google account security settings.
`, ), ); } catch (err) { res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" }); res.end( renderPage( "Sign-In Failed", `

Sign-In Failed

${escapeHtml(err instanceof Error ? err.message : String(err))}

`, ), ); } }