Files
openclaw/extensions/openai/openai-chatgpt-oauth-flow.runtime.ts
Peter Steinberger 1b6c42d1f0 refactor(openai): unify ChatGPT auth identity derivation in plugin SDK (#126544)
The OpenAI ChatGPT auth profile identity was derived in two drifted copies:
the plugin-sdk helper (used by Codex/Hermes import migrations) fell back to
bare JWT sub and leaked the workspace accountId into the user subject, while
the openai extension copy (used by login/refresh) composed the OIDC-stable
iss|sub pair and honored the credential email fallback.

Fold the extension's chain into the canonical SDK helper (credential-email
fallback, iss|sub before bare sub, no workspace-id subject), delete the
extension copy, and move all extension call sites onto
openclaw/plugin-sdk/provider-auth. Cross-checked against Codex OSS
(codex-rs/login/src/token_data.rs): chatgpt_account_id is workspace identity,
never user identity. No doctor migration: the divergent fallback branch only
fires for tokens lacking every user-id claim and email, which real ChatGPT
tokens do not produce, so no shipped install holds divergent keys from it.
2026-08-19 23:48:43 -07:00

307 lines
9.2 KiB
TypeScript

/**
* 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.
*/
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { resolveOpenAICodexAuthIdentity } from "openclaw/plugin-sdk/provider-auth";
import {
createOAuthLoginCancelledError,
oauthErrorHtml,
oauthSuccessHtml,
parseOAuthAuthorizationInput,
throwIfOAuthLoginAborted,
withOAuthLoginAbort,
type OAuthCredentials,
type OAuthPrompt,
} from "openclaw/plugin-sdk/provider-oauth-runtime";
import {
createOpenAIAuthorizationFlow,
resolveOpenAICallbackHost,
resolveOpenAIRedirectUri,
} from "./openai-chatgpt-oauth-authorization.runtime.js";
import {
exchangeOpenAIAuthorizationCode,
refreshOpenAIAccessToken,
} from "./openai-chatgpt-oauth-token.runtime.js";
const CALLBACK_PORT = 1455;
const CALLBACK_HOST = resolveOpenAICallbackHost();
const REDIRECT_URI = resolveOpenAIRedirectUri(CALLBACK_HOST);
const MANUAL_PROMPT_FALLBACK_MS = 15_000;
const loadNodeOAuthHttp = createLazyRuntimeModule(() => import("node:http"));
function waitForManualPromptFallback(signal?: AbortSignal): Promise<null> {
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?.();
});
}
function parseAuthorizationCode(input: string, state: string): string | undefined {
const parsed = parseOAuthAuthorizationInput(input);
if (parsed.state && parsed.state !== state) {
throw new Error("State mismatch");
}
return parsed.code;
}
async function promptForAuthorizationCode(
onPrompt: (prompt: OAuthPrompt) => Promise<string>,
state: string,
): Promise<string | undefined> {
return parseAuthorizationCode(
await onPrompt({ message: "Paste the authorization code (or full redirect URL):" }),
state,
);
}
type OAuthServerInfo = {
close: () => void;
cancelWait: () => void;
waitForCode: () => Promise<{ code: string } | null>;
};
function sendOAuthHtmlResponse(
res: import("node:http").ServerResponse,
statusCode: number,
html: string,
): void {
res.statusCode = statusCode;
// Callback browsers may reuse HTTP/1.1 connections. Force disconnect after
// the response so an accepted socket cannot keep the auth process alive.
res.setHeader("Connection", "close");
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(html);
}
async function startLocalOAuthServer(state: string): Promise<OAuthServerInfo> {
const http = await loadNodeOAuthHttp();
let settleWait: ((value: { code: string } | null) => void) | undefined;
const waitForCodePromise = new Promise<{ code: string } | null>((resolve) => {
settleWait = resolve;
});
const server = http.createServer((req, res) => {
try {
const url = new URL(req.url || "", "http://localhost");
if (url.pathname !== "/auth/callback") {
sendOAuthHtmlResponse(res, 404, oauthErrorHtml("Callback route not found."));
return;
}
if (url.searchParams.get("state") !== state) {
sendOAuthHtmlResponse(res, 400, oauthErrorHtml("State mismatch."));
return;
}
const code = url.searchParams.get("code");
if (!code) {
sendOAuthHtmlResponse(res, 400, oauthErrorHtml("Missing authorization code."));
return;
}
sendOAuthHtmlResponse(
res,
200,
oauthSuccessHtml("OpenAI authentication completed. You can close this window."),
);
settleWait?.({ code });
} catch {
sendOAuthHtmlResponse(
res,
500,
oauthErrorHtml("Internal error while processing OAuth callback."),
);
}
});
return new Promise((resolve) => {
server
.listen(CALLBACK_PORT, CALLBACK_HOST, () => {
resolve({
close: () => {
server.close();
// Force-close preconnected sockets so they cannot pin the CLI process.
server.closeAllConnections();
},
cancelWait: () => {
settleWait?.(null);
},
waitForCode: () => waitForCodePromise,
});
})
.on("error", () => {
settleWait?.(null);
resolve({
close: () => {
try {
server.close();
} catch {
// ignore
}
},
cancelWait: () => {},
waitForCode: async () => null,
});
});
});
}
function resolveOpenAICredentials(
result: Awaited<ReturnType<typeof refreshOpenAIAccessToken>>,
): OAuthCredentials {
if (result.type !== "success") {
throw new Error(result.message);
}
const accountId = resolveOpenAICodexAuthIdentity({ access: result.access }).accountId;
if (!accountId) {
throw new Error("Failed to extract accountId from token");
}
return {
access: result.access,
refresh: result.refresh,
expires: result.expires,
accountId,
};
}
/**
* 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")
*/
export async function loginOpenAICodex(options: {
onAuth: (info: { url: string; instructions?: string }) => Promise<void> | void;
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
onProgress?: (message: string) => void;
onManualCodeInput?: () => Promise<string>;
originator?: string;
signal?: AbortSignal;
}): Promise<OAuthCredentials> {
throwIfOAuthLoginAborted(options.signal);
const { verifier, redirectUri, state, url } = await createOpenAIAuthorizationFlow(
options.originator ?? "openclaw",
REDIRECT_URI,
);
const server = await startLocalOAuthServer(state);
let code: string | undefined;
try {
throwIfOAuthLoginAborted(options.signal);
await options.onAuth({
url,
instructions: "A browser window should open. Complete login to finish.",
});
throwIfOAuthLoginAborted(options.signal);
if (options.onManualCodeInput) {
// Race between browser callback and manual input
let manualCode: string | undefined;
let manualError: Error | undefined;
const manualPromise = options
.onManualCodeInput()
.then((input) => {
manualCode = input;
server.cancelWait();
})
.catch((err: unknown) => {
manualError = err instanceof Error ? err : new Error(String(err));
server.cancelWait();
});
const result = await withOAuthLoginAbort(
server.waitForCode(),
options.signal,
server.cancelWait,
);
if (!result?.code && !manualCode && !manualError) {
await withOAuthLoginAbort(manualPromise, options.signal, server.cancelWait);
}
if (manualError) {
throw manualError;
}
if (result?.code) {
code = result.code;
} else if (manualCode) {
code = parseAuthorizationCode(manualCode, state);
}
} 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,
);
}
}
// Fallback to onPrompt if still no code
if (!code) {
code = await withOAuthLoginAbort(
promptForAuthorizationCode(options.onPrompt, state),
options.signal,
server.cancelWait,
);
}
if (!code) {
throw new Error("Missing authorization code");
}
return resolveOpenAICredentials(
await exchangeOpenAIAuthorizationCode(code, verifier, redirectUri, {
signal: options.signal,
}),
);
} finally {
server.close();
}
}
/**
* Refresh OpenAI Codex OAuth token
*/
export async function refreshOpenAICodexToken(refreshToken: string): Promise<OAuthCredentials> {
return resolveOpenAICredentials(await refreshOpenAIAccessToken(refreshToken));
}