import type { Hooks, PluginInput } from "@opencode-ai/plugin" import { Log } from "../util/log" import { Installation } from "../installation" import { Auth, OAUTH_DUMMY_KEY } from "../auth" import os from "os" const log = Log.create({ service: "plugin.codex" }) const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" const ISSUER = "https://auth.openai.com" const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses" const OAUTH_PORT = 1455 interface PkceCodes { verifier: string challenge: string } async function generatePKCE(): Promise { const verifier = generateRandomString(43) const encoder = new TextEncoder() const data = encoder.encode(verifier) const hash = await crypto.subtle.digest("SHA-256", data) const challenge = base64UrlEncode(hash) return { verifier, challenge } } function generateRandomString(length: number): string { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" const bytes = crypto.getRandomValues(new Uint8Array(length)) return Array.from(bytes) .map((b) => chars[b % chars.length]) .join("") } function base64UrlEncode(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer) const binary = String.fromCharCode(...bytes) return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") } function generateState(): string { return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) } export interface IdTokenClaims { chatgpt_account_id?: string organizations?: Array<{ id: string }> email?: string "https://api.openai.com/auth"?: { chatgpt_account_id?: string } } export function parseJwtClaims(token: string): IdTokenClaims | undefined { const parts = token.split(".") if (parts.length !== 3) return undefined try { return JSON.parse(Buffer.from(parts[1], "base64url").toString()) } catch { return undefined } } export function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined { return ( claims.chatgpt_account_id || claims["https://api.openai.com/auth"]?.chatgpt_account_id || claims.organizations?.[0]?.id ) } export function extractAccountId(tokens: TokenResponse): string | undefined { if (tokens.id_token) { const claims = parseJwtClaims(tokens.id_token) const accountId = claims && extractAccountIdFromClaims(claims) if (accountId) return accountId } if (tokens.access_token) { const claims = parseJwtClaims(tokens.access_token) return claims ? extractAccountIdFromClaims(claims) : undefined } return undefined } function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string { const params = new URLSearchParams({ response_type: "code", client_id: CLIENT_ID, redirect_uri: redirectUri, scope: "openid profile email offline_access", code_challenge: pkce.challenge, code_challenge_method: "S256", id_token_add_organizations: "true", codex_cli_simplified_flow: "true", state, originator: "opencode", }) return `${ISSUER}/oauth/authorize?${params.toString()}` } interface TokenResponse { id_token: string access_token: string refresh_token: string expires_in?: number } async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise { const response = await fetch(`${ISSUER}/oauth/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: redirectUri, client_id: CLIENT_ID, code_verifier: pkce.verifier, }).toString(), }) if (!response.ok) { throw new Error(`Token exchange failed: ${response.status}`) } return response.json() } async function refreshAccessToken(refreshToken: string): Promise { const response = await fetch(`${ISSUER}/oauth/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: CLIENT_ID, }).toString(), }) if (!response.ok) { throw new Error(`Token refresh failed: ${response.status}`) } return response.json() } const HTML_SUCCESS = ` OpenCode - Codex Authorization Successful

Authorization Successful

You can close this window and return to OpenCode.

` const HTML_ERROR = (error: string) => ` OpenCode - Codex Authorization Failed

Authorization Failed

An error occurred during authorization.

${error}
` interface PendingOAuth { pkce: PkceCodes state: string resolve: (tokens: TokenResponse) => void reject: (error: Error) => void } let oauthServer: ReturnType | undefined let pendingOAuth: PendingOAuth | undefined async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> { if (oauthServer) { return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` } } oauthServer = Bun.serve({ port: OAUTH_PORT, fetch(req) { const url = new URL(req.url) if (url.pathname === "/auth/callback") { const code = url.searchParams.get("code") const state = url.searchParams.get("state") const error = url.searchParams.get("error") const errorDescription = url.searchParams.get("error_description") if (error) { const errorMsg = errorDescription || error pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth = undefined return new Response(HTML_ERROR(errorMsg), { headers: { "Content-Type": "text/html" }, }) } if (!code) { const errorMsg = "Missing authorization code" pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth = undefined return new Response(HTML_ERROR(errorMsg), { status: 400, headers: { "Content-Type": "text/html" }, }) } if (!pendingOAuth || state !== pendingOAuth.state) { const errorMsg = "Invalid state - potential CSRF attack" pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth = undefined return new Response(HTML_ERROR(errorMsg), { status: 400, headers: { "Content-Type": "text/html" }, }) } const current = pendingOAuth pendingOAuth = undefined exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce) .then((tokens) => current.resolve(tokens)) .catch((err) => current.reject(err)) return new Response(HTML_SUCCESS, { headers: { "Content-Type": "text/html" }, }) } if (url.pathname === "/cancel") { pendingOAuth?.reject(new Error("Login cancelled")) pendingOAuth = undefined return new Response("Login cancelled", { status: 200 }) } return new Response("Not found", { status: 404 }) }, }) log.info("codex oauth server started", { port: OAUTH_PORT }) return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` } } function stopOAuthServer() { if (oauthServer) { oauthServer.stop() oauthServer = undefined log.info("codex oauth server stopped") } } function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise { return new Promise((resolve, reject) => { const timeout = setTimeout( () => { if (pendingOAuth) { pendingOAuth = undefined reject(new Error("OAuth callback timeout - authorization took too long")) } }, 5 * 60 * 1000, ) // 5 minute timeout pendingOAuth = { pkce, state, resolve: (tokens) => { clearTimeout(timeout) resolve(tokens) }, reject: (error) => { clearTimeout(timeout) reject(error) }, } }) } export async function CodexAuthPlugin(input: PluginInput): Promise { return { auth: { provider: "openai", async loader(getAuth, provider) { const auth = await getAuth() if (auth.type !== "oauth") return {} // Filter models to only allowed Codex models for OAuth const allowedModels = new Set([ "gpt-5.1-codex-max", "gpt-5.1-codex-mini", "gpt-5.2", "gpt-5.2-codex", "gpt-5.1-codex", ]) for (const modelId of Object.keys(provider.models)) { if (!allowedModels.has(modelId)) { delete provider.models[modelId] } } // Zero out costs for Codex (included with ChatGPT subscription) for (const model of Object.values(provider.models)) { model.cost = { input: 0, output: 0, cache: { read: 0, write: 0 }, } } return { apiKey: OAUTH_DUMMY_KEY, async fetch(requestInput: RequestInfo | URL, init?: RequestInit) { // Remove dummy API key authorization header if (init?.headers) { if (init.headers instanceof Headers) { init.headers.delete("authorization") init.headers.delete("Authorization") } else if (Array.isArray(init.headers)) { init.headers = init.headers.filter(([key]) => key.toLowerCase() !== "authorization") } else { delete init.headers["authorization"] delete init.headers["Authorization"] } } const currentAuth = await getAuth() if (currentAuth.type !== "oauth") return fetch(requestInput, init) // Cast to include accountId field const authWithAccount = currentAuth as typeof currentAuth & { accountId?: string } // Check if token needs refresh if (!currentAuth.access || currentAuth.expires < Date.now()) { log.info("refreshing codex access token") const tokens = await refreshAccessToken(currentAuth.refresh) const newAccountId = extractAccountId(tokens) || authWithAccount.accountId await input.client.auth.set({ path: { id: "openai" }, body: { type: "oauth", refresh: tokens.refresh_token, access: tokens.access_token, expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, ...(newAccountId && { accountId: newAccountId }), }, }) currentAuth.access = tokens.access_token authWithAccount.accountId = newAccountId } // Build headers const headers = new Headers() if (init?.headers) { if (init.headers instanceof Headers) { init.headers.forEach((value, key) => headers.set(key, value)) } else if (Array.isArray(init.headers)) { for (const [key, value] of init.headers) { if (value !== undefined) headers.set(key, String(value)) } } else { for (const [key, value] of Object.entries(init.headers)) { if (value !== undefined) headers.set(key, String(value)) } } } // Set authorization header with access token headers.set("authorization", `Bearer ${currentAuth.access}`) // Set ChatGPT-Account-Id header for organization subscriptions if (authWithAccount.accountId) { headers.set("ChatGPT-Account-Id", authWithAccount.accountId) } // Rewrite URL to Codex endpoint const parsed = requestInput instanceof URL ? requestInput : new URL(typeof requestInput === "string" ? requestInput : requestInput.url) const url = parsed.pathname.includes("/v1/responses") || parsed.pathname.includes("/chat/completions") ? new URL(CODEX_API_ENDPOINT) : parsed return fetch(url, { ...init, headers, }) }, } }, methods: [ { label: "ChatGPT Pro/Plus", type: "oauth", authorize: async () => { const { redirectUri } = await startOAuthServer() const pkce = await generatePKCE() const state = generateState() const authUrl = buildAuthorizeUrl(redirectUri, pkce, state) const callbackPromise = waitForOAuthCallback(pkce, state) return { url: authUrl, instructions: "Complete authorization in your browser. This window will close automatically.", method: "auto" as const, callback: async () => { const tokens = await callbackPromise stopOAuthServer() const accountId = extractAccountId(tokens) return { type: "success" as const, refresh: tokens.refresh_token, access: tokens.access_token, expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, accountId, } }, } }, }, { label: "Manually enter API Key", type: "api", }, ], }, "chat.headers": async (input, output) => { if (input.model.providerID !== "openai") return output.headers.originator = "opencode" output.headers["User-Agent"] = `opencode/${Installation.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})` output.headers.session_id = input.sessionID }, } }