openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
419 lines (418 loc) • 15.2 kB
JavaScript
import { r as fetchWithSsrFGuard } from "./fetch-guard-BttkNCLm.js";
import { a as oauthErrorHtml, c as resolveOAuthTokenExpiresAt, d as withOAuthLoginAbort, i as generatePKCE, l as resolveOAuthTokenLifetimeMs, n as createOAuthLoginCancelledError, o as oauthSuccessHtml, s as parseOAuthAuthorizationInput, u as throwIfOAuthLoginAborted } from "./provider-oauth-runtime-CHge3JIZ.js";
import "./ssrf-runtime-BOGN5pUi.js";
import { n as resolveCodexAuthIdentity } from "./openai-chatgpt-auth-identity-Mk0NOf7H.js";
import "./openai-chatgpt-oauth-abort.runtime.js";
import "./openai-chatgpt-oauth-page.runtime.js";
import "./openai-chatgpt-pkce.runtime.js";
//#region extensions/openai/openai-chatgpt-oauth-flow.runtime.ts
/**
* OpenAI Codex (ChatGPT OAuth) flow
*
* NOTE: This module uses Node.js crypto and http for the OAuth callback.
* It is only intended for CLI use, not browser environments.
*/
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
const TOKEN_URL = "https://auth.openai.com/oauth/token";
const CALLBACK_PORT = 1455;
const CALLBACK_PATH = "/auth/callback";
const DEFAULT_CALLBACK_HOST = "localhost";
const LOOPBACK_CALLBACK_HOSTS = new Set([
"localhost",
"127.0.0.1",
"::1"
]);
const CALLBACK_HOST = resolveCallbackHost();
const REDIRECT_URI = resolveRedirectUri(CALLBACK_HOST);
const MANUAL_PROMPT_FALLBACK_MS = 15e3;
const TOKEN_REQUEST_TIMEOUT_MS = 3e4;
const SCOPE = "openid profile email offline_access";
let nodeOAuthRuntimePromise = null;
function loadNodeOAuthRuntime() {
if (typeof process === "undefined" || !process.versions?.node && !process.versions?.bun) return Promise.reject(/* @__PURE__ */ new Error("OpenAI Codex OAuth is only available in Node.js environments"));
nodeOAuthRuntimePromise ??= Promise.all([import("node:crypto"), import("node:http")]).then(([cryptoModule, httpModule]) => ({
randomBytes: cryptoModule.randomBytes,
http: httpModule
}));
return nodeOAuthRuntimePromise;
}
function resolveCallbackHost(env = process.env) {
const host = env.OPENCLAW_OAUTH_CALLBACK_HOST?.trim() || DEFAULT_CALLBACK_HOST;
if (!LOOPBACK_CALLBACK_HOSTS.has(host)) throw new Error("OpenAI Codex OAuth callback host must be localhost, 127.0.0.1, or ::1");
return host;
}
function resolveRedirectUri(host = CALLBACK_HOST) {
const url = new URL(`http://${host === "::1" ? "[::1]" : host}:${CALLBACK_PORT}`);
url.pathname = CALLBACK_PATH;
return url.toString();
}
function createState(randomBytes) {
return randomBytes(16).toString("hex");
}
function waitForManualPromptFallback(signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(createOAuthLoginCancelledError());
return;
}
const cleanup = () => {
signal?.removeEventListener("abort", abort);
};
const abort = () => {
clearTimeout(timeout);
cleanup();
reject(createOAuthLoginCancelledError());
};
const timeout = setTimeout(() => {
cleanup();
resolve(null);
}, MANUAL_PROMPT_FALLBACK_MS);
signal?.addEventListener("abort", abort, { once: true });
timeout.unref?.();
});
}
async function promptForAuthorizationCode(onPrompt, state) {
const parsed = parseOAuthAuthorizationInput(await onPrompt({ message: "Paste the authorization code (or full redirect URL):" }));
if (parsed.state && parsed.state !== state) throw new Error("State mismatch");
return parsed.code;
}
function formatMissingTokenResponseFields(json) {
const missing = [];
if (!json.access_token) missing.push("access_token");
if (!json.refresh_token) missing.push("refresh_token");
if (resolveOAuthTokenLifetimeMs(json.expires_in) === void 0) missing.push("expires_in");
return missing.join(", ");
}
function formatTokenRequestError(operation, error, timeoutMs, signal) {
if (signal?.aborted) return "Login cancelled";
if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) return `OpenAI Codex token ${operation} timed out after ${timeoutMs}ms`;
return `OpenAI Codex token ${operation} error: ${error instanceof Error ? error.message : String(error)}`;
}
async function postTokenForm(body, options = {}) {
const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS;
throwIfOAuthLoginAborted(options.signal);
const { response, release } = await fetchWithSsrFGuard({
url: TOKEN_URL,
init: {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body
},
timeoutMs,
signal: options.signal,
auditContext: "openai-chatgpt-oauth-token"
});
try {
const responseBody = await response.arrayBuffer();
return new Response(responseBody, {
status: response.status,
statusText: response.statusText,
headers: response.headers
});
} finally {
await release();
}
}
async function exchangeAuthorizationCode(code, verifier, redirectUri = REDIRECT_URI, options = {}) {
const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS;
let response;
try {
response = await postTokenForm(new URLSearchParams({
grant_type: "authorization_code",
client_id: CLIENT_ID,
code,
code_verifier: verifier,
redirect_uri: redirectUri
}), {
signal: options.signal,
timeoutMs
});
} catch (error) {
return {
type: "failed",
message: formatTokenRequestError("exchange", error, timeoutMs, options.signal)
};
}
if (!response.ok) {
const text = await response.text().catch(() => "");
return {
type: "failed",
status: response.status,
message: `OpenAI Codex token exchange failed (${response.status}): ${text || response.statusText}`
};
}
const json = await response.json();
const expires = resolveOAuthTokenExpiresAt(json.expires_in);
if (!json.access_token || !json.refresh_token || expires === void 0) return {
type: "failed",
message: `OpenAI Codex token exchange response missing fields: ${formatMissingTokenResponseFields(json)}`
};
return {
type: "success",
access: json.access_token,
refresh: json.refresh_token,
expires
};
}
async function refreshAccessToken(refreshToken, options = {}) {
try {
const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS;
const response = await postTokenForm(new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLIENT_ID
}), {
signal: options.signal,
timeoutMs
});
if (!response.ok) {
const text = await response.text().catch(() => "");
return {
type: "failed",
status: response.status,
message: `OpenAI Codex token refresh failed (${response.status}): ${text || response.statusText}`
};
}
const json = await response.json();
const expires = resolveOAuthTokenExpiresAt(json.expires_in);
if (!json.access_token || !json.refresh_token || expires === void 0) return {
type: "failed",
message: `OpenAI Codex token refresh response missing fields: ${formatMissingTokenResponseFields(json)}`
};
return {
type: "success",
access: json.access_token,
refresh: json.refresh_token,
expires
};
} catch (error) {
return {
type: "failed",
message: formatTokenRequestError("refresh", error, options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS, options.signal)
};
}
}
async function createAuthorizationFlow(originator = "openclaw") {
const [{ verifier, challenge }, runtime] = await Promise.all([generatePKCE(), loadNodeOAuthRuntime()]);
const state = createState(runtime.randomBytes);
const url = new URL(AUTHORIZE_URL);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", CLIENT_ID);
const redirectUri = REDIRECT_URI;
url.searchParams.set("redirect_uri", redirectUri);
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", originator);
return {
verifier,
redirectUri,
state,
url: url.toString()
};
}
async function startLocalOAuthServer(state) {
const { http } = await loadNodeOAuthRuntime();
let settleWait;
const waitForCodePromise = new Promise((resolve) => {
let settled = false;
settleWait = (value) => {
if (settled) return;
settled = true;
resolve(value);
};
});
const server = http.createServer((req, res) => {
try {
const url = new URL(req.url || "", "http://localhost");
if (url.pathname !== "/auth/callback") {
res.statusCode = 404;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(oauthErrorHtml("Callback route not found."));
return;
}
if (url.searchParams.get("state") !== state) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(oauthErrorHtml("State mismatch."));
return;
}
const code = url.searchParams.get("code");
if (!code) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(oauthErrorHtml("Missing authorization code."));
return;
}
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(oauthSuccessHtml("OpenAI authentication completed. You can close this window."));
settleWait?.({ code });
} catch {
res.statusCode = 500;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(oauthErrorHtml("Internal error while processing OAuth callback."));
}
});
return new Promise((resolve) => {
server.listen(CALLBACK_PORT, CALLBACK_HOST, () => {
resolve({
close: () => server.close(),
cancelWait: () => {
settleWait?.(null);
},
waitForCode: () => waitForCodePromise
});
}).on("error", () => {
settleWait?.(null);
resolve({
close: () => {
try {
server.close();
} catch {}
},
cancelWait: () => {},
waitForCode: async () => null
});
});
});
}
function getAccountId(accessToken) {
const accountId = resolveCodexAuthIdentity({ accessToken }).accountId;
return typeof accountId === "string" && accountId.length > 0 ? accountId : null;
}
/**
* Login with OpenAI Codex OAuth
*
* @param options.onAuth - Called with URL and instructions when auth starts
* @param options.onPrompt - Called to prompt user for manual code paste (fallback if no onManualCodeInput)
* @param options.onProgress - Optional progress messages
* @param options.onManualCodeInput - Optional promise that resolves with user-pasted code.
* Races with browser callback - whichever completes first wins.
* Useful for showing paste input immediately alongside browser flow.
* @param options.originator - OAuth originator parameter (defaults to "openclaw")
*/
async function loginOpenAICodex(options) {
throwIfOAuthLoginAborted(options.signal);
const { verifier, redirectUri, state, url } = await createAuthorizationFlow(options.originator);
const server = await startLocalOAuthServer(state);
let code;
try {
throwIfOAuthLoginAborted(options.signal);
options.onAuth({
url,
instructions: "A browser window should open. Complete login to finish."
});
throwIfOAuthLoginAborted(options.signal);
if (options.onManualCodeInput) {
let manualCode;
let manualError;
const manualPromise = options.onManualCodeInput().then((input) => {
manualCode = input;
server.cancelWait();
}).catch((err) => {
manualError = err instanceof Error ? err : new Error(String(err));
server.cancelWait();
});
const result = await withOAuthLoginAbort(server.waitForCode(), options.signal, server.cancelWait);
if (manualError) throw manualError;
if (result?.code) code = result.code;
else if (manualCode) {
const parsed = parseOAuthAuthorizationInput(manualCode);
if (parsed.state && parsed.state !== state) throw new Error("State mismatch");
code = parsed.code;
}
if (!code) {
await withOAuthLoginAbort(manualPromise, options.signal, server.cancelWait);
if (manualError) throw toLintErrorObject(manualError, "Non-Error thrown");
if (manualCode) {
const parsed = parseOAuthAuthorizationInput(manualCode);
if (parsed.state && parsed.state !== state) throw new Error("State mismatch");
code = parsed.code;
}
}
} else {
const callbackPromise = server.waitForCode();
const result = await withOAuthLoginAbort(Promise.race([callbackPromise, waitForManualPromptFallback(options.signal)]), options.signal, server.cancelWait);
if (result?.code) code = result.code;
else {
const promptCodePromise = promptForAuthorizationCode(options.onPrompt, state).then((promptCode) => {
server.cancelWait();
return promptCode;
});
code = await withOAuthLoginAbort(Promise.race([callbackPromise.then((callback) => callback?.code), promptCodePromise]), options.signal, server.cancelWait);
}
}
if (!code) code = await withOAuthLoginAbort(promptForAuthorizationCode(options.onPrompt, state), options.signal, server.cancelWait);
if (!code) throw new Error("Missing authorization code");
const tokenResult = await exchangeAuthorizationCode(code, verifier, redirectUri, { signal: options.signal });
if (tokenResult.type !== "success") throw new Error(tokenResult.message);
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
*/
async function refreshOpenAICodexToken(refreshToken) {
const result = await refreshAccessToken(refreshToken);
if (result.type !== "success") throw new Error(result.message);
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
};
}
const openaiCodexOAuthProvider = {
id: "openai",
name: "ChatGPT Plus/Pro (Codex Subscription)",
usesCallbackServer: true,
async login(callbacks) {
return loginOpenAICodex({
onAuth: callbacks.onAuth,
onPrompt: callbacks.onPrompt,
onProgress: callbacks.onProgress,
onManualCodeInput: callbacks.onManualCodeInput,
signal: callbacks.signal
});
},
async refreshToken(credentials) {
return refreshOpenAICodexToken(credentials.refresh);
},
getApiKey(credentials) {
return credentials.access;
}
};
const testing = {
callbackHost: CALLBACK_HOST,
createAuthorizationFlow,
exchangeAuthorizationCode,
loginOpenAICodex,
refreshAccessToken,
resolveCallbackHost,
resolveRedirectUri
};
function toLintErrorObject(value, fallbackMessage) {
if (value instanceof Error) return value;
if (typeof value === "string") return new Error(value);
const error = new Error(fallbackMessage, { cause: value });
if (typeof value === "object" && value !== null || typeof value === "function") Object.assign(error, value);
return error;
}
//#endregion
export { testing as i, openaiCodexOAuthProvider as n, refreshOpenAICodexToken as r, loginOpenAICodex as t };