import { createHash } from "crypto"; import { readFileSync } from "fs"; import { join } from "path"; import type { IncomingMessage } from "http"; import type { Application, Request, Response, NextFunction } from "express"; import type { AuthAdapter, AuthUser, AuthenticatedRequest } from "./adapter.js"; import { SessionStore } from "./adapter.js"; /** * API-key authentication adapter. * * Each user provides their own Anthropic API key on the login page. * The key is stored encrypted in the server-side session and is injected * as `ANTHROPIC_API_KEY` into every PTY spawned for that user. * The plaintext key is never sent to the browser after the login form POST. * * User identity is derived from the key itself (SHA-256 prefix), so two * sessions using the same key share the same userId and home directory. * * Optional env vars: * ADMIN_USERS — comma-separated user IDs (SHA-256 prefixes) or API-key * prefixes that receive the admin role */ export class ApiKeyAdapter implements AuthAdapter { private readonly store: SessionStore; private readonly adminUsers: ReadonlySet; constructor(store: SessionStore) { this.store = store; this.adminUsers = new Set( (process.env.ADMIN_USERS ?? "") .split(",") .map((s) => s.trim()) .filter(Boolean), ); } authenticate(req: IncomingMessage): AuthUser | null { const session = this.store.getFromRequest(req); if (!session || !session.encryptedApiKey) return null; const apiKey = this.store.decrypt(session.encryptedApiKey); if (!apiKey) return null; return { id: session.userId, email: session.email, name: session.name, isAdmin: session.isAdmin || this.adminUsers.has(session.userId), apiKey, }; } setupRoutes(app: Application): void { const loginHtml = this.loadLoginPage(); // GET /auth/login — serve the API key login form app.get("/auth/login", (_req, res) => { res.setHeader("Content-Type", "text/html"); res.send(loginHtml); }); // POST /auth/login — validate key, create encrypted session app.post( "/auth/login", // express.urlencoded is registered in pty-server.ts before setupRoutes (req: Request, res: Response) => { const apiKey = (req.body as Record)?.api_key?.trim() ?? ""; if (!apiKey.startsWith("sk-ant-")) { res.setHeader("Content-Type", "text/html"); res.status(400).send( loginHtml.replace( "", `

Invalid API key format. Keys must start with sk-ant-.

`, ), ); return; } const userId = deriveUserId(apiKey); const isAdmin = this.adminUsers.has(userId); const encryptedApiKey = this.store.encrypt(apiKey); const sessionId = this.store.create({ userId, isAdmin, encryptedApiKey, }); this.store.setCookie(res as unknown as import("http").ServerResponse, sessionId); const next = (req.query as Record)?.next; res.redirect(next && next.startsWith("/") ? next : "/"); }, ); // POST /auth/logout — destroy session app.post("/auth/logout", (req, res) => { const id = this.store.getIdFromRequest(req as unknown as IncomingMessage); if (id) this.store.delete(id); this.store.clearCookie(res as unknown as import("http").ServerResponse); res.redirect("/auth/login"); }); } requireAuth(req: Request, res: Response, next: NextFunction): void { const user = this.authenticate(req as unknown as IncomingMessage); if (!user) { const accept = req.headers["accept"] ?? ""; if (accept.includes("application/json")) { res.status(401).json({ error: "Unauthorized" }); } else { res.redirect(`/auth/login?next=${encodeURIComponent(req.originalUrl)}`); } return; } (req as AuthenticatedRequest).user = user; next(); } // ── Internals ───────────────────────────────────────────────────────────── private loadLoginPage(): string { // Serve from the public directory at build time; fall back to inline HTML. try { const p = join(import.meta.dirname, "../public/login.html"); return readFileSync(p, "utf8"); } catch { return INLINE_LOGIN_HTML; } } } /** * Derives a stable, opaque user ID from an API key. * Uses the first 16 hex chars of SHA-256(key) — short enough to be readable, * long enough to be unique. */ function deriveUserId(apiKey: string): string { return createHash("sha256").update(apiKey).digest("hex").slice(0, 16); } // Fallback inline login page used when public/login.html is not present. const INLINE_LOGIN_HTML = ` Claude Code — Sign In

Claude Code

Enter your Anthropic API key to start a session.

Your key is stored encrypted on the server and never sent to the browser. Get a key at console.anthropic.com.

`;