import { Router, type Request, type Response, urlencoded, json } from "express"; import { randomBytes } from "node:crypto"; import { AuthStore, SUPPORTED_SCOPES, base64UrlSha256, filterScopes, safeEqual } from "./store.js"; import { PairingManager } from "../pairing/manager.js"; import type { Logger } from "../logger/index.js"; import { PRODUCT_NAME } from "../version.js"; import { escapeHtml, setAuthSecurityHeaders } from "./html.js"; export interface OAuthDeps { store: AuthStore; pairing: PairingManager; workspaceName: string; getBaseUrl: (req: Request) => string; logger: Logger; } interface PendingAuthRequest { id: string; clientId: string; redirectUri: string; scopes: string[]; state?: string; codeChallenge: string; resource?: string; expiresAt: number; } function isAllowedRedirectUri(uri: string): boolean { let parsed: URL; try { parsed = new URL(uri); } catch { return false; } if (parsed.protocol === "https:") return true; if (parsed.protocol === "http:" && (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1")) { return true; } return false; } function authorizationServerMetadata(base: string): Record { return { issuer: base, authorization_endpoint: `${base}/oauth/authorize`, token_endpoint: `${base}/oauth/token`, registration_endpoint: `${base}/oauth/register`, revocation_endpoint: `${base}/oauth/revoke`, response_types_supported: ["code"], response_modes_supported: ["query"], grant_types_supported: ["authorization_code", "refresh_token"], code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"], scopes_supported: [...SUPPORTED_SCOPES], }; } function protectedResourceMetadata(base: string): Record { return { resource: `${base}/mcp`, authorization_servers: [base], scopes_supported: [...SUPPORTED_SCOPES], bearer_methods_supported: ["header"], resource_name: PRODUCT_NAME, }; } function pairingPage(opts: { requestId: string; workspaceName: string; scopes: string[]; error?: string; }): string { const scopeLabels: Record = { "workspace.read": "Read files in this workspace", "workspace.search": "Search this workspace", "git.read": "Read git status and diffs", "execution.read": "Read Codex execution summaries", offline_access: "Stay connected between sessions", }; const scopeList = opts.scopes .map((scope) => `
  • ${escapeHtml(scopeLabels[scope] ?? scope)}
  • `) .join(""); const errorHtml = opts.error ? `` : ""; const escapedProductName = escapeHtml(PRODUCT_NAME); const escapedWorkspaceName = escapeHtml(opts.workspaceName); const escapedRequestId = escapeHtml(opts.requestId); return ` ${escapedProductName}

    ${escapedProductName}

    ChatGPT is requesting access to workspace ${escapedWorkspaceName} (read-only):

      ${scopeList}
    ${errorHtml}

    The pairing code was generated by Codex on this computer.
    It expires in a few minutes.

    `; } export function createOAuthRouter(deps: OAuthDeps): Router { const router = Router(); const pendingRequests = new Map(); const prunePending = (): void => { const now = Date.now(); for (const [id, request] of pendingRequests) { if (now > request.expiresAt) pendingRequests.delete(id); } }; // ---- Discovery metadata ------------------------------------------------- const asMetadataHandler = (req: Request, res: Response): void => { res.json(authorizationServerMetadata(deps.getBaseUrl(req))); }; const prMetadataHandler = (req: Request, res: Response): void => { res.json(protectedResourceMetadata(deps.getBaseUrl(req))); }; router.get("/.well-known/oauth-authorization-server", asMetadataHandler); router.get("/.well-known/oauth-authorization-server/mcp", asMetadataHandler); router.get("/.well-known/openid-configuration", asMetadataHandler); router.get("/.well-known/oauth-protected-resource", prMetadataHandler); router.get("/.well-known/oauth-protected-resource/mcp", prMetadataHandler); // ---- Dynamic Client Registration (RFC 7591) ------------------------------ router.post("/oauth/register", json(), (req, res) => { const body = req.body as { client_name?: string; redirect_uris?: unknown }; const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris : []; if ( redirectUris.length === 0 || !redirectUris.every((uri) => typeof uri === "string" && isAllowedRedirectUri(uri)) ) { res.status(400).json({ error: "invalid_redirect_uri", error_description: "redirect_uris must be https URLs (or http://localhost for development)", }); return; } const client = deps.store.registerClient({ clientName: typeof body.client_name === "string" ? body.client_name.slice(0, 200) : undefined, redirectUris: redirectUris as string[], }); deps.logger.info(`Registered OAuth client ${client.clientId} (${client.clientName ?? "unnamed"})`); res.status(201).json({ client_id: client.clientId, client_name: client.clientName, redirect_uris: client.redirectUris, token_endpoint_auth_method: "none", grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], }); }); // ---- Authorization endpoint ---------------------------------------------- router.get("/oauth/authorize", (req, res) => { prunePending(); const query = req.query as Record; const client = query.client_id ? deps.store.getClient(query.client_id) : undefined; if (!client) { setAuthSecurityHeaders(res); res.status(400).send("Unknown client. Please reconnect from ChatGPT."); return; } const redirectUri = query.redirect_uri; if (!redirectUri || !client.redirectUris.includes(redirectUri)) { setAuthSecurityHeaders(res); res.status(400).send("Invalid redirect_uri."); return; } const fail = (error: string, description: string): void => { const url = new URL(redirectUri); url.searchParams.set("error", error); url.searchParams.set("error_description", description); if (query.state) url.searchParams.set("state", query.state); res.redirect(url.toString()); }; if (query.response_type !== "code") { fail("unsupported_response_type", "Only response_type=code is supported"); return; } if (!query.code_challenge || query.code_challenge_method !== "S256") { fail("invalid_request", "PKCE with S256 is required"); return; } const scopes = filterScopes(query.scope); const request: PendingAuthRequest = { id: randomBytes(16).toString("hex"), clientId: client.clientId, redirectUri, scopes, state: query.state, codeChallenge: query.code_challenge, resource: query.resource, expiresAt: Date.now() + 10 * 60_000, }; pendingRequests.set(request.id, request); setAuthSecurityHeaders(res); res .status(200) .type("html") .send(pairingPage({ requestId: request.id, workspaceName: deps.workspaceName, scopes })); }); router.post("/oauth/authorize", urlencoded({ extended: false }), (req, res) => { prunePending(); const body = req.body as { request_id?: string; pairing_code?: string }; const request = body.request_id ? pendingRequests.get(body.request_id) : undefined; if (!request) { setAuthSecurityHeaders(res); res.status(400).send("This authorization request has expired. Please reconnect from ChatGPT."); return; } const verdict = deps.pairing.verify(body.pairing_code ?? "", req.ip); if (!verdict.ok) { const messages: Record = { invalid: `Incorrect pairing code.${verdict.attemptsLeft !== undefined ? ` ${verdict.attemptsLeft} attempts left.` : ""}`, expired: "This pairing code has expired. Ask Codex to generate a new one.", too_many_attempts: "Too many incorrect attempts. Ask Codex to generate a new pairing code.", rate_limited: "Too many attempts. Please wait a minute and try again.", no_active_session: "No active pairing session. Ask Codex to generate a pairing code.", }; deps.logger.warn(`Pairing verification failed: ${verdict.reason}`); setAuthSecurityHeaders(res); res .status(verdict.reason === "invalid" ? 401 : 410) .type("html") .send( pairingPage({ requestId: request.id, workspaceName: deps.workspaceName, scopes: request.scopes, error: messages[verdict.reason] ?? "Verification failed.", }) ); return; } pendingRequests.delete(request.id); const code = deps.store.createAuthorizationCode({ clientId: request.clientId, redirectUri: request.redirectUri, codeChallenge: request.codeChallenge, scopes: request.scopes, pairingSessionId: verdict.sessionId, resource: request.resource, }); deps.logger.info(`Pairing verified; issued authorization code for client ${request.clientId}`); const url = new URL(request.redirectUri); url.searchParams.set("code", code); if (request.state) url.searchParams.set("state", request.state); res.redirect(url.toString()); }); // ---- Token endpoint -------------------------------------------------------- router.post("/oauth/token", urlencoded({ extended: false }), json(), (req, res) => { const body = req.body as Record; const grantType = body.grant_type; if (grantType === "authorization_code") { const { code, code_verifier: codeVerifier, client_id: clientId, redirect_uri: redirectUri } = body; if (!code || !codeVerifier || !clientId) { res.status(400).json({ error: "invalid_request" }); return; } const record = deps.store.consumeAuthorizationCode(code); if (!record || record.clientId !== clientId) { res.status(400).json({ error: "invalid_grant" }); return; } if (redirectUri && redirectUri !== record.redirectUri) { res.status(400).json({ error: "invalid_grant", error_description: "redirect_uri mismatch" }); return; } if (!safeEqual(base64UrlSha256(codeVerifier), record.codeChallenge)) { deps.logger.warn("PKCE verification failed at token endpoint"); res.status(400).json({ error: "invalid_grant", error_description: "PKCE verification failed" }); return; } const tokens = deps.store.issueTokens({ clientId, scopes: record.scopes }); deps.logger.info(`Issued access token for client ${clientId}`); res.json({ access_token: tokens.accessToken, token_type: "Bearer", expires_in: tokens.expiresIn, refresh_token: tokens.refreshToken ?? undefined, scope: tokens.scopes.join(" "), }); return; } if (grantType === "refresh_token") { const { refresh_token: refreshToken, client_id: clientId } = body; if (!refreshToken || !clientId) { res.status(400).json({ error: "invalid_request" }); return; } const result = deps.store.refresh(refreshToken, clientId); if (!result.ok) { res.status(400).json({ error: result.reason }); return; } res.json({ access_token: result.tokens.accessToken, token_type: "Bearer", expires_in: result.tokens.expiresIn, refresh_token: result.tokens.refreshToken ?? undefined, scope: result.tokens.scopes.join(" "), }); return; } res.status(400).json({ error: "unsupported_grant_type" }); }); // ---- Revocation (RFC 7009) --------------------------------------------------- router.post("/oauth/revoke", urlencoded({ extended: false }), (req, res) => { const body = req.body as { token?: string }; if (body.token) deps.store.revokeToken(body.token); res.status(200).json({}); }); return router; }