codex-proxy / src /routes /auth.ts
icebear
fix: Electron OAuth login + i18n layout shift + update modal (#53)
1b6fb15 unverified
raw
history blame
9.7 kB
import { Hono } from "hono";
import type { AccountPool } from "../auth/account-pool.js";
import type { RefreshScheduler } from "../auth/refresh-scheduler.js";
import { validateManualToken } from "../auth/chatgpt-oauth.js";
import { getConfig } from "../config.js";
import {
startOAuthFlow,
consumeSession,
exchangeCode,
requestDeviceCode,
pollDeviceToken,
importCliAuth,
markSessionCompleted,
isSessionCompleted,
} from "../auth/oauth-pkce.js";
export function createAuthRoutes(
pool: AccountPool,
scheduler: RefreshScheduler,
): Hono {
const app = new Hono();
// Auth status (JSON) β€” pool-level summary
app.get("/auth/status", (c) => {
const authenticated = pool.isAuthenticated();
const userInfo = pool.getUserInfo();
const config = getConfig();
const proxyApiKey = config.server.proxy_api_key ?? pool.getProxyApiKey();
const summary = pool.getPoolSummary();
return c.json({
authenticated,
user: authenticated ? userInfo : null,
proxy_api_key: authenticated ? proxyApiKey : null,
pool: summary,
});
});
// Start OAuth login β€” 302 redirect to Auth0 (same-machine shortcut)
app.get("/auth/login", (c) => {
const config = getConfig();
const originalHost = c.req.header("host") || `localhost:${config.server.port}`;
const { authUrl } = startOAuthFlow(originalHost, "login", pool, scheduler);
return c.redirect(authUrl);
});
// POST /auth/login-start β€” returns { authUrl, state } for popup flow
app.post("/auth/login-start", (c) => {
const config = getConfig();
const originalHost = c.req.header("host") || `localhost:${config.server.port}`;
const { authUrl, state } = startOAuthFlow(originalHost, "login", pool, scheduler);
return c.json({ authUrl, state });
});
// POST /auth/code-relay β€” accepts { callbackUrl }, parses code+state, exchanges tokens
app.post("/auth/code-relay", async (c) => {
const body = await c.req.json<{ callbackUrl: string }>();
const callbackUrl = body.callbackUrl?.trim();
if (!callbackUrl) {
return c.json({ error: "callbackUrl is required" }, 400);
}
let url: URL;
try {
url = new URL(callbackUrl);
} catch {
return c.json({ error: "Invalid URL" }, 400);
}
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
if (error) {
const desc = url.searchParams.get("error_description") || error;
return c.json({ error: `OAuth error: ${desc}` }, 400);
}
if (!code || !state) {
return c.json({ error: "URL must contain code and state parameters" }, 400);
}
const session = consumeSession(state);
if (!session) {
// Session already consumed by callback server β€” treat as success
if (isSessionCompleted(state)) {
return c.json({ success: true });
}
return c.json({ error: "Invalid or expired session. Please try again." }, 400);
}
try {
const tokens = await exchangeCode(code, session.codeVerifier, session.redirectUri);
const entryId = pool.addAccount(tokens.access_token, tokens.refresh_token);
scheduler.scheduleOne(entryId, tokens.access_token);
markSessionCompleted(state);
console.log(`[Auth] OAuth via code-relay β€” account ${entryId} added`);
return c.json({ success: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error("[Auth] Code relay token exchange failed:", msg);
return c.json({ error: `Token exchange failed: ${msg}` }, 500);
}
});
// OAuth callback β€” Auth0 redirects here after user login (legacy/fallback)
app.get("/auth/callback", async (c) => {
const code = c.req.query("code");
const state = c.req.query("state");
const error = c.req.query("error");
const errorDescription = c.req.query("error_description");
if (error) {
console.error(`[Auth] OAuth error: ${error} β€” ${errorDescription}`);
return c.html(errorPage(`OAuth error: ${errorDescription || error}`));
}
if (!code || !state) {
return c.html(errorPage("Missing code or state parameter"), 400);
}
const session = consumeSession(state);
if (!session) {
// Session already consumed by callback server β€” redirect home
if (isSessionCompleted(state)) {
const config = getConfig();
const host = c.req.header("host") || `localhost:${config.server.port}`;
return c.redirect(`http://${host}/`);
}
return c.html(errorPage("Invalid or expired OAuth session. Please try again."), 400);
}
try {
const tokens = await exchangeCode(code, session.codeVerifier, session.redirectUri);
const entryId = pool.addAccount(tokens.access_token, tokens.refresh_token);
scheduler.scheduleOne(entryId, tokens.access_token);
markSessionCompleted(state);
console.log(`[Auth] OAuth login completed β€” account ${entryId} added`);
// Redirect back to the original host the user was browsing from
const returnUrl = `http://${session.returnHost}/`;
return c.redirect(returnUrl);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error("[Auth] Token exchange failed:", msg);
return c.html(errorPage(`Token exchange failed: ${msg}`), 500);
}
});
// ── Device Code Flow ────────────────────────────────────────────
// POST /auth/device-login β€” start device code flow
app.post("/auth/device-login", async (c) => {
try {
const deviceResp = await requestDeviceCode();
console.log(`[Auth] Device code flow started β€” user_code: ${deviceResp.user_code}`);
return c.json({
userCode: deviceResp.user_code,
verificationUri: deviceResp.verification_uri,
verificationUriComplete: deviceResp.verification_uri_complete,
deviceCode: deviceResp.device_code,
expiresIn: deviceResp.expires_in,
interval: deviceResp.interval,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error("[Auth] Device code request failed:", msg);
return c.json({ error: msg }, 500);
}
});
// GET /auth/device-poll/:deviceCode β€” poll for device code authorization
app.get("/auth/device-poll/:deviceCode", async (c) => {
const deviceCode = c.req.param("deviceCode");
try {
const tokens = await pollDeviceToken(deviceCode);
const entryId = pool.addAccount(tokens.access_token, tokens.refresh_token);
scheduler.scheduleOne(entryId, tokens.access_token);
console.log(`[Auth] Device code flow completed β€” account ${entryId} added`);
return c.json({ success: true });
} catch (err: unknown) {
const code = (err as { code?: string }).code || "unknown";
if (code === "authorization_pending" || code === "slow_down") {
return c.json({ pending: true, code });
}
const msg = err instanceof Error ? err.message : String(err);
console.error("[Auth] Device code poll failed:", msg);
return c.json({ error: msg }, 400);
}
});
// ── CLI Token Import ───────────────────────────────────────────
// POST /auth/import-cli β€” import token from Codex CLI auth.json
app.post("/auth/import-cli", async (c) => {
try {
const cliAuth = importCliAuth();
const entryId = pool.addAccount(cliAuth.access_token!, cliAuth.refresh_token);
scheduler.scheduleOne(entryId, cliAuth.access_token!);
console.log(`[Auth] CLI token imported β€” account ${entryId} added`);
return c.json({ success: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error("[Auth] CLI import failed:", msg);
return c.json({ error: msg }, 500);
}
});
// Manual token submission β€” adds to pool
app.post("/auth/token", async (c) => {
const body = await c.req.json<{ token: string }>();
const token = body.token?.trim();
if (!token) {
c.status(400);
return c.json({ error: "Token is required" });
}
const validation = validateManualToken(token);
if (!validation.valid) {
c.status(400);
return c.json({ error: validation.error });
}
const entryId = pool.addAccount(token);
scheduler.scheduleOne(entryId, token);
return c.json({ success: true });
});
// Logout β€” clears all accounts
app.post("/auth/logout", (c) => {
pool.clearToken();
return c.json({ success: true });
});
return app;
}
function errorPage(message: string): string {
return `<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>Login Error</title>
<style>
body { font-family: -apple-system, sans-serif; background: #0d1117; color: #c9d1d9;
display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 12px;
padding: 2rem; max-width: 420px; text-align: center; }
h2 { color: #f85149; margin-bottom: 1rem; }
a { color: #58a6ff; }
</style></head>
<body><div class="card">
<h2>Login Failed</h2>
<p>${escapeHtml(message)}</p>
<p style="margin-top:1rem"><a href="/">Back to Home</a></p>
</div></body></html>`;
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}