feat(ai): add OpenAI Codex OAuth + responses provider

This commit is contained in:
Ahmed Kamal 2026-01-04 21:11:19 +02:00
parent 6ddfd1be13
commit 1650041a63
22 changed files with 2705 additions and 5 deletions

View file

@ -28,6 +28,11 @@ export {
loginGeminiCli,
refreshGoogleCloudToken,
} from "./google-gemini-cli.js";
// OpenAI Codex (ChatGPT OAuth)
export {
loginOpenAICodex,
refreshOpenAICodexToken,
} from "./openai-codex.js";
export * from "./types.js";
@ -39,6 +44,7 @@ import { refreshAnthropicToken } from "./anthropic.js";
import { refreshGitHubCopilotToken } from "./github-copilot.js";
import { refreshAntigravityToken } from "./google-antigravity.js";
import { refreshGoogleCloudToken } from "./google-gemini-cli.js";
import { refreshOpenAICodexToken } from "./openai-codex.js";
import type { OAuthCredentials, OAuthProvider, OAuthProviderInfo } from "./types.js";
/**
@ -74,6 +80,9 @@ export async function refreshOAuthToken(
}
newCredentials = await refreshAntigravityToken(credentials.refresh, credentials.projectId);
break;
case "openai-codex":
newCredentials = await refreshOpenAICodexToken(credentials.refresh);
break;
default:
throw new Error(`Unknown OAuth provider: ${provider}`);
}
@ -139,5 +148,10 @@ export function getOAuthProviders(): OAuthProviderInfo[] {
name: "Antigravity (Gemini 3, Claude, GPT-OSS)",
available: true,
},
{
id: "openai-codex",
name: "ChatGPT Plus/Pro (Codex Subscription)",
available: true,
},
];
}

View file

@ -0,0 +1,342 @@
/**
* OpenAI Codex (ChatGPT OAuth) flow
*/
import { randomBytes } from "node:crypto";
import http from "node:http";
import { generatePKCE } from "./pkce.js";
import type { OAuthCredentials, OAuthPrompt } from "./types.js";
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
const TOKEN_URL = "https://auth.openai.com/oauth/token";
const REDIRECT_URI = "http://localhost:1455/auth/callback";
const SCOPE = "openid profile email offline_access";
const JWT_CLAIM_PATH = "https://api.openai.com/auth";
const SUCCESS_HTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Authentication successful</title>
</head>
<body>
<p>Authentication successful. Return to your terminal to continue.</p>
</body>
</html>`;
type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number };
type TokenFailure = { type: "failed" };
type TokenResult = TokenSuccess | TokenFailure;
type JwtPayload = {
[JWT_CLAIM_PATH]?: {
chatgpt_account_id?: string;
};
[key: string]: unknown;
};
function createState(): string {
return randomBytes(16).toString("hex");
}
function parseAuthorizationInput(input: string): { code?: string; state?: string } {
const value = input.trim();
if (!value) return {};
try {
const url = new URL(value);
return {
code: url.searchParams.get("code") ?? undefined,
state: url.searchParams.get("state") ?? undefined,
};
} catch {
// not a URL
}
if (value.includes("#")) {
const [code, state] = value.split("#", 2);
return { code, state };
}
if (value.includes("code=")) {
const params = new URLSearchParams(value);
return {
code: params.get("code") ?? undefined,
state: params.get("state") ?? undefined,
};
}
return { code: value };
}
function decodeJwt(token: string): JwtPayload | null {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
const payload = parts[1] ?? "";
const decoded = Buffer.from(payload, "base64").toString("utf-8");
return JSON.parse(decoded) as JwtPayload;
} catch {
return null;
}
}
async function exchangeAuthorizationCode(
code: string,
verifier: string,
redirectUri: string = REDIRECT_URI,
): Promise<TokenResult> {
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: CLIENT_ID,
code,
code_verifier: verifier,
redirect_uri: redirectUri,
}),
});
if (!response.ok) {
const text = await response.text().catch(() => "");
console.error("[openai-codex] code->token failed:", response.status, text);
return { type: "failed" };
}
const json = (await response.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") {
console.error("[openai-codex] token response missing fields:", json);
return { type: "failed" };
}
return {
type: "success",
access: json.access_token,
refresh: json.refresh_token,
expires: Date.now() + json.expires_in * 1000,
};
}
async function refreshAccessToken(refreshToken: string): Promise<TokenResult> {
try {
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLIENT_ID,
}),
});
if (!response.ok) {
const text = await response.text().catch(() => "");
console.error("[openai-codex] Token refresh failed:", response.status, text);
return { type: "failed" };
}
const json = (await response.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") {
console.error("[openai-codex] Token refresh response missing fields:", json);
return { type: "failed" };
}
return {
type: "success",
access: json.access_token,
refresh: json.refresh_token,
expires: Date.now() + json.expires_in * 1000,
};
} catch (error) {
console.error("[openai-codex] Token refresh error:", error);
return { type: "failed" };
}
}
async function createAuthorizationFlow(): Promise<{ verifier: string; state: string; url: string }> {
const { verifier, challenge } = await generatePKCE();
const state = createState();
const url = new URL(AUTHORIZE_URL);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", CLIENT_ID);
url.searchParams.set("redirect_uri", REDIRECT_URI);
url.searchParams.set("scope", SCOPE);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("state", state);
url.searchParams.set("id_token_add_organizations", "true");
url.searchParams.set("codex_cli_simplified_flow", "true");
url.searchParams.set("originator", "codex_cli_rs");
return { verifier, state, url: url.toString() };
}
type OAuthServerInfo = {
close: () => void;
waitForCode: () => Promise<{ code: string } | null>;
};
function startLocalOAuthServer(state: string): Promise<OAuthServerInfo> {
let lastCode: string | null = null;
const server = http.createServer((req, res) => {
try {
const url = new URL(req.url || "", "http://localhost");
if (url.pathname !== "/auth/callback") {
res.statusCode = 404;
res.end("Not found");
return;
}
if (url.searchParams.get("state") !== state) {
res.statusCode = 400;
res.end("State mismatch");
return;
}
const code = url.searchParams.get("code");
if (!code) {
res.statusCode = 400;
res.end("Missing authorization code");
return;
}
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(SUCCESS_HTML);
lastCode = code;
} catch {
res.statusCode = 500;
res.end("Internal error");
}
});
return new Promise((resolve) => {
server
.listen(1455, "127.0.0.1", () => {
resolve({
close: () => server.close(),
waitForCode: async () => {
const sleep = () => new Promise((r) => setTimeout(r, 100));
for (let i = 0; i < 600; i += 1) {
if (lastCode) return { code: lastCode };
await sleep();
}
return null;
},
});
})
.on("error", (err: NodeJS.ErrnoException) => {
console.error(
"[openai-codex] Failed to bind http://127.0.0.1:1455 (",
err.code,
") Falling back to manual paste.",
);
resolve({
close: () => {
try {
server.close();
} catch {
// ignore
}
},
waitForCode: async () => null,
});
});
});
}
function getAccountId(accessToken: string): string | null {
const payload = decodeJwt(accessToken);
const auth = payload?.[JWT_CLAIM_PATH];
const accountId = auth?.chatgpt_account_id;
return typeof accountId === "string" && accountId.length > 0 ? accountId : null;
}
/**
* Login with OpenAI Codex OAuth
*/
export async function loginOpenAICodex(options: {
onAuth: (info: { url: string; instructions?: string }) => void;
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
onProgress?: (message: string) => void;
}): Promise<OAuthCredentials> {
const { verifier, state, url } = await createAuthorizationFlow();
const server = await startLocalOAuthServer(state);
options.onAuth({ url, instructions: "A browser window should open. Complete login to finish." });
let code: string | undefined;
try {
const result = await server.waitForCode();
if (result?.code) {
code = result.code;
}
if (!code) {
const input = await options.onPrompt({
message: "Paste the authorization code (or full redirect URL):",
});
const parsed = parseAuthorizationInput(input);
if (parsed.state && parsed.state !== state) {
throw new Error("State mismatch");
}
code = parsed.code;
}
if (!code) {
throw new Error("Missing authorization code");
}
const tokenResult = await exchangeAuthorizationCode(code, verifier);
if (tokenResult.type !== "success") {
throw new Error("Token exchange failed");
}
const accountId = getAccountId(tokenResult.access);
if (!accountId) {
throw new Error("Failed to extract accountId from token");
}
return {
access: tokenResult.access,
refresh: tokenResult.refresh,
expires: tokenResult.expires,
accountId,
};
} finally {
server.close();
}
}
/**
* Refresh OpenAI Codex OAuth token
*/
export async function refreshOpenAICodexToken(refreshToken: string): Promise<OAuthCredentials> {
const result = await refreshAccessToken(refreshToken);
if (result.type !== "success") {
throw new Error("Failed to refresh OpenAI Codex token");
}
const accountId = getAccountId(result.access);
if (!accountId) {
throw new Error("Failed to extract accountId from token");
}
return {
access: result.access,
refresh: result.refresh,
expires: result.expires,
accountId,
};
}

View file

@ -5,9 +5,15 @@ export type OAuthCredentials = {
enterpriseUrl?: string;
projectId?: string;
email?: string;
accountId?: string;
};
export type OAuthProvider = "anthropic" | "github-copilot" | "google-gemini-cli" | "google-antigravity";
export type OAuthProvider =
| "anthropic"
| "github-copilot"
| "google-gemini-cli"
| "google-antigravity"
| "openai-codex";
export type OAuthPrompt = {
message: string;