fix(ai): repaired chatgpt token exchange
Added Codex connector OAuth scopes to browser login and preserved structured token endpoint error details instead of object coercion.\n\nFixes #2825
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed ChatGPT/Codex browser login missing connector OAuth scopes and rendering object-shaped token endpoint errors as `[object Object]`. ([#2825](https://github.com/can1357/oh-my-pi/issues/2825))
|
||||
|
||||
## [16.0.3] - 2026-06-16
|
||||
|
||||
### Added
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
*/
|
||||
|
||||
import { OPENAI_HEADER_VALUES } from "@oh-my-pi/pi-catalog/wire/codex";
|
||||
import type { FetchImpl } from "../../types";
|
||||
import { isRecord } from "../../utils";
|
||||
import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server";
|
||||
import { generatePKCE } from "./pkce";
|
||||
import type { OAuthController, OAuthCredentials } from "./types";
|
||||
@@ -12,7 +14,7 @@ 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 SCOPE = "openid profile email offline_access";
|
||||
const SCOPE = "openid profile email offline_access api.connectors.read api.connectors.invoke";
|
||||
const JWT_CLAIM_PATH = "https://api.openai.com/auth";
|
||||
const JWT_PROFILE_CLAIM = "https://api.openai.com/profile";
|
||||
const TOKEN_REQUEST_TIMEOUT_MS = 15_000;
|
||||
@@ -62,6 +64,37 @@ interface PKCE {
|
||||
verifier: string;
|
||||
challenge: string;
|
||||
}
|
||||
function describeTokenEndpointValue(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
if (!isRecord(value)) return undefined;
|
||||
|
||||
const code = describeTokenEndpointValue(value.code ?? value.error);
|
||||
const message = describeTokenEndpointValue(value.message ?? value.error_description ?? value.description);
|
||||
if (code && message && code !== message) return `${code}: ${message}`;
|
||||
return code ?? message ?? JSON.stringify(value);
|
||||
}
|
||||
|
||||
/** Formats OpenAI Codex OAuth token endpoint errors for login and refresh failures. */
|
||||
export function formatOpenAICodexTokenEndpointError(status: number, bodyText: string): string {
|
||||
const trimmed = bodyText.trim();
|
||||
if (trimmed.length === 0) return `${status}`;
|
||||
|
||||
try {
|
||||
const body: unknown = JSON.parse(trimmed);
|
||||
if (!isRecord(body)) return `${status} ${trimmed}`;
|
||||
|
||||
const error = describeTokenEndpointValue(body.error);
|
||||
const description = describeTokenEndpointValue(body.error_description);
|
||||
if (error && description && error !== description) return `${status} ${error}: ${description}`;
|
||||
return `${status} ${error ?? description ?? describeTokenEndpointValue(body.message) ?? trimmed}`;
|
||||
} catch {
|
||||
return `${status} ${trimmed}`;
|
||||
}
|
||||
}
|
||||
/** Builds the Codex browser OAuth URL used by browser login; exported for auth regression tests. */
|
||||
export function createOpenAICodexAuthorizationUrl(args: {
|
||||
state: string;
|
||||
@@ -87,11 +120,11 @@ export function createOpenAICodexAuthorizationUrl(args: {
|
||||
}
|
||||
|
||||
class OpenAICodexOAuthFlow extends OAuthCallbackFlow {
|
||||
constructor(
|
||||
ctrl: OAuthController,
|
||||
private readonly pkce: PKCE,
|
||||
private readonly originator: string,
|
||||
) {
|
||||
#pkce: PKCE;
|
||||
#originator: string;
|
||||
#fetch: FetchImpl;
|
||||
|
||||
constructor(ctrl: OAuthController, pkce: PKCE, originator: string, fetchImpl: FetchImpl) {
|
||||
super(ctrl, {
|
||||
preferredPort: CALLBACK_PORT,
|
||||
callbackPath: CALLBACK_PATH,
|
||||
@@ -101,25 +134,33 @@ class OpenAICodexOAuthFlow extends OAuthCallbackFlow {
|
||||
// registered allowlist entry.
|
||||
redirectUri: `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`,
|
||||
} satisfies OAuthCallbackFlowOptions);
|
||||
this.#pkce = pkce;
|
||||
this.#originator = originator;
|
||||
this.#fetch = fetchImpl;
|
||||
}
|
||||
|
||||
async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
|
||||
const url = createOpenAICodexAuthorizationUrl({
|
||||
state,
|
||||
redirectUri,
|
||||
challenge: this.pkce.challenge,
|
||||
originator: this.originator,
|
||||
challenge: this.#pkce.challenge,
|
||||
originator: this.#originator,
|
||||
});
|
||||
return { url, instructions: "A browser window should open. Complete login to finish." };
|
||||
}
|
||||
|
||||
async exchangeToken(code: string, _state: string, redirectUri: string): Promise<OAuthCredentials> {
|
||||
return exchangeCodeForToken(code, this.pkce.verifier, redirectUri);
|
||||
return exchangeCodeForToken(code, this.#pkce.verifier, redirectUri, this.#fetch);
|
||||
}
|
||||
}
|
||||
|
||||
async function exchangeCodeForToken(code: string, verifier: string, redirectUri: string): Promise<OAuthCredentials> {
|
||||
const tokenResponse = await fetch(TOKEN_URL, {
|
||||
async function exchangeCodeForToken(
|
||||
code: string,
|
||||
verifier: string,
|
||||
redirectUri: string,
|
||||
fetchImpl: FetchImpl = fetch,
|
||||
): Promise<OAuthCredentials> {
|
||||
const tokenResponse = await fetchImpl(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
@@ -133,13 +174,8 @@ async function exchangeCodeForToken(code: string, verifier: string, redirectUri:
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
let detail = `${tokenResponse.status}`;
|
||||
try {
|
||||
const body = (await tokenResponse.json()) as { error?: string; error_description?: string };
|
||||
if (body.error)
|
||||
detail = `${tokenResponse.status} ${body.error}${body.error_description ? `: ${body.error_description}` : ""}`;
|
||||
} catch {}
|
||||
throw new Error(`Token exchange failed: ${detail}`);
|
||||
const bodyText = await tokenResponse.text();
|
||||
throw new Error(`Token exchange failed: ${formatOpenAICodexTokenEndpointError(tokenResponse.status, bodyText)}`);
|
||||
}
|
||||
|
||||
const tokenData = (await tokenResponse.json()) as {
|
||||
@@ -177,7 +213,7 @@ export type OpenAICodexLoginOptions = OAuthController & {
|
||||
export async function loginOpenAICodex(options: OpenAICodexLoginOptions): Promise<OAuthCredentials> {
|
||||
const pkce = await generatePKCE();
|
||||
const originator = options.originator?.trim() || OPENAI_HEADER_VALUES.ORIGINATOR_CODEX;
|
||||
const flow = new OpenAICodexOAuthFlow(options, pkce, originator);
|
||||
const flow = new OpenAICodexOAuthFlow(options, pkce, originator, options.fetch ?? fetch);
|
||||
|
||||
return flow.login();
|
||||
}
|
||||
@@ -285,13 +321,10 @@ export async function refreshOpenAICodexToken(refreshToken: string): Promise<OAu
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = `${response.status}`;
|
||||
try {
|
||||
const body = (await response.json()) as { error?: string; error_description?: string };
|
||||
if (body.error)
|
||||
detail = `${response.status} ${body.error}${body.error_description ? `: ${body.error_description}` : ""}`;
|
||||
} catch {}
|
||||
throw new Error(`OpenAI Codex token refresh failed: ${detail}`);
|
||||
const bodyText = await response.text();
|
||||
throw new Error(
|
||||
`OpenAI Codex token refresh failed: ${formatOpenAICodexTokenEndpointError(response.status, bodyText)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const tokenData = (await response.json()) as {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { createOpenAICodexAuthorizationUrl } from "@oh-my-pi/pi-ai/oauth/openai-codex";
|
||||
import {
|
||||
createOpenAICodexAuthorizationUrl,
|
||||
formatOpenAICodexTokenEndpointError,
|
||||
} from "@oh-my-pi/pi-ai/oauth/openai-codex";
|
||||
import { type RequestBody, transformRequestBody } from "@oh-my-pi/pi-ai/providers/openai-codex/request-transformer";
|
||||
import { CodexApiError, parseCodexError } from "@oh-my-pi/pi-ai/providers/openai-codex/response-handler";
|
||||
import { convertOpenAICodexResponsesTools } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
|
||||
@@ -20,6 +23,27 @@ describe("openai-codex oauth", () => {
|
||||
|
||||
expect(new URL(authUrl).searchParams.get("originator")).toBe(OPENAI_HEADER_VALUES.ORIGINATOR_CODEX);
|
||||
});
|
||||
|
||||
it("requests Codex connector scopes during browser login", () => {
|
||||
const authUrl = createOpenAICodexAuthorizationUrl({
|
||||
state: "state",
|
||||
redirectUri: "http://localhost:1455/auth/callback",
|
||||
challenge: "challenge",
|
||||
});
|
||||
|
||||
const scopes = new Set((new URL(authUrl).searchParams.get("scope") ?? "").split(" "));
|
||||
expect(scopes.has("api.connectors.read")).toBe(true);
|
||||
expect(scopes.has("api.connectors.invoke")).toBe(true);
|
||||
});
|
||||
|
||||
it("formats object token endpoint errors without object coercion", () => {
|
||||
const detail = formatOpenAICodexTokenEndpointError(
|
||||
403,
|
||||
JSON.stringify({ error: { code: "access_denied", message: "Connector scope missing" } }),
|
||||
);
|
||||
|
||||
expect(detail).toBe("403 access_denied: Connector scope missing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("openai-codex tool schemas", () => {
|
||||
|
||||
Reference in New Issue
Block a user