Skip to content

Commit 6727708

Browse files
committed
fix(oauth): bound Codex token requests
1 parent 5f68291 commit 6727708

5 files changed

Lines changed: 236 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
2727
- Agents/providers: add OpenAI-compatible cache retention, forward cached token usage in chat completions, preserve runtime context before active user turns, strip stale Anthropic thinking, load Claude CLI OAuth for Pi auth profiles, avoid false Codex runtime live switches, and quarantine unsupported tool schemas. (#82062, #87167, #86855)
2828
- Gateway/performance: cache plugin metadata fingerprints and stable plugin index fingerprints, borrow read-only session metadata safely, keep the active session working store hot, keep status on a bounded fast path, and preserve model auth profile suffixes. (#86439)
2929
- Package/install/release: align npm package exclusions and inventory, omit unpacked test helpers, skip Homebrew until macOS packages need it, cap tsdown heap in containers, bound install/release smoke waits, and harden post-publish verification.
30+
- Codex: bound ChatGPT OAuth token exchange and refresh requests so stalled auth endpoints fail instead of hanging login or refresh.
3031
- QA/E2E/CI: bound Telegram, kitchen-sink, Open WebUI, ClawHub, MCP, Discord, realtime, labeler, and GitHub API waits; fail empty explicit test, live-media, gateway CPU, plugin gauntlet, and beta-smoke runs instead of false-greening.
3132

3233
## 2026.5.26

extensions/openai/openai-codex-oauth-flow.runtime.test.ts

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,23 @@
1-
import { describe, expect, it } from "vitest";
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
3+
const ssrfMocks = vi.hoisted(() => ({
4+
fetchWithSsrFGuard: vi.fn(),
5+
}));
6+
7+
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
8+
fetchWithSsrFGuard: ssrfMocks.fetchWithSsrFGuard,
9+
}));
10+
211
import { testing } from "./openai-codex-oauth-flow.runtime.js";
312

13+
function timeoutError(): Error {
14+
return new DOMException("timed out", "TimeoutError");
15+
}
16+
17+
afterEach(() => {
18+
ssrfMocks.fetchWithSsrFGuard.mockReset();
19+
});
20+
421
describe("OpenAI Codex OAuth flow", () => {
522
it("waits for Node OAuth runtime before creating an authorization flow", async () => {
623
const flow = await testing.createAuthorizationFlow("openclaw-test");
@@ -16,14 +33,51 @@ describe("OpenAI Codex OAuth flow", () => {
1633
});
1734

1835
it("builds callback redirect URIs from the configured loopback host", () => {
19-
expect(testing.resolveRedirectUri("127.0.0.1")).toBe(
20-
"http://127.0.0.1:1455/auth/callback",
21-
);
36+
expect(testing.resolveRedirectUri("127.0.0.1")).toBe("http://127.0.0.1:1455/auth/callback");
2237
});
2338

2439
it("rejects non-loopback callback bind hosts", () => {
25-
expect(() =>
26-
testing.resolveCallbackHost({ OPENCLAW_OAUTH_CALLBACK_HOST: "0.0.0.0" }),
27-
).toThrow("callback host must be localhost, 127.0.0.1, or ::1");
40+
expect(() => testing.resolveCallbackHost({ OPENCLAW_OAUTH_CALLBACK_HOST: "0.0.0.0" })).toThrow(
41+
"callback host must be localhost, 127.0.0.1, or ::1",
42+
);
43+
});
44+
45+
it("times out token exchange requests", async () => {
46+
ssrfMocks.fetchWithSsrFGuard.mockRejectedValueOnce(timeoutError());
47+
48+
const result = await testing.exchangeAuthorizationCode(
49+
"code",
50+
"verifier",
51+
testing.resolveRedirectUri("localhost"),
52+
{ timeoutMs: 5 },
53+
);
54+
55+
expect(ssrfMocks.fetchWithSsrFGuard).toHaveBeenCalledWith(
56+
expect.objectContaining({
57+
auditContext: "openai-codex-oauth-token",
58+
timeoutMs: 5,
59+
}),
60+
);
61+
expect(result).toMatchObject({
62+
type: "failed",
63+
message: "OpenAI Codex token exchange timed out after 5ms",
64+
});
65+
});
66+
67+
it("times out token refresh requests", async () => {
68+
ssrfMocks.fetchWithSsrFGuard.mockRejectedValueOnce(timeoutError());
69+
70+
const result = await testing.refreshAccessToken("old-refresh-token", { timeoutMs: 5 });
71+
72+
expect(ssrfMocks.fetchWithSsrFGuard).toHaveBeenCalledWith(
73+
expect.objectContaining({
74+
auditContext: "openai-codex-oauth-token",
75+
timeoutMs: 5,
76+
}),
77+
);
78+
expect(result).toMatchObject({
79+
type: "failed",
80+
message: "OpenAI Codex token refresh timed out after 5ms",
81+
});
2882
});
2983
});

extensions/openai/openai-codex-oauth-flow.runtime.ts

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const LOOPBACK_CALLBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
2626
const CALLBACK_HOST = resolveCallbackHost();
2727
const REDIRECT_URI = resolveRedirectUri(CALLBACK_HOST);
2828
const MANUAL_PROMPT_FALLBACK_MS = 15_000;
29+
const TOKEN_REQUEST_TIMEOUT_MS = 30_000;
2930
const SCOPE = "openid profile email offline_access";
3031

3132
type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number };
@@ -40,6 +41,9 @@ type NodeOAuthRuntime = {
4041
randomBytes: typeof import("node:crypto").randomBytes;
4142
http: typeof import("node:http");
4243
};
44+
type TokenRequestOptions = {
45+
timeoutMs?: number;
46+
};
4347

4448
let nodeOAuthRuntimePromise: Promise<NodeOAuthRuntime> | null = null;
4549

@@ -144,14 +148,30 @@ function formatMissingTokenResponseFields(json: TokenResponseJson): string {
144148
return missing.join(", ");
145149
}
146150

147-
async function postTokenForm(body: URLSearchParams): Promise<Response> {
151+
function formatTokenRequestError(
152+
operation: "exchange" | "refresh",
153+
error: unknown,
154+
timeoutMs: number,
155+
): string {
156+
if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) {
157+
return `OpenAI Codex token ${operation} timed out after ${timeoutMs}ms`;
158+
}
159+
return `OpenAI Codex token ${operation} error: ${error instanceof Error ? error.message : String(error)}`;
160+
}
161+
162+
async function postTokenForm(
163+
body: URLSearchParams,
164+
options: TokenRequestOptions = {},
165+
): Promise<Response> {
166+
const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS;
148167
const { response, release } = await fetchWithSsrFGuard({
149168
url: TOKEN_URL,
150169
init: {
151170
method: "POST",
152171
headers: { "Content-Type": "application/x-www-form-urlencoded" },
153172
body,
154173
},
174+
timeoutMs,
155175
auditContext: "openai-codex-oauth-token",
156176
});
157177
try {
@@ -170,16 +190,27 @@ async function exchangeAuthorizationCode(
170190
code: string,
171191
verifier: string,
172192
redirectUri: string = REDIRECT_URI,
193+
options: TokenRequestOptions = {},
173194
): Promise<TokenResult> {
174-
const response = await postTokenForm(
175-
new URLSearchParams({
176-
grant_type: "authorization_code",
177-
client_id: CLIENT_ID,
178-
code,
179-
code_verifier: verifier,
180-
redirect_uri: redirectUri,
181-
}),
182-
);
195+
const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS;
196+
let response: Response;
197+
try {
198+
response = await postTokenForm(
199+
new URLSearchParams({
200+
grant_type: "authorization_code",
201+
client_id: CLIENT_ID,
202+
code,
203+
code_verifier: verifier,
204+
redirect_uri: redirectUri,
205+
}),
206+
{ timeoutMs },
207+
);
208+
} catch (error) {
209+
return {
210+
type: "failed",
211+
message: formatTokenRequestError("exchange", error, timeoutMs),
212+
};
213+
}
183214

184215
if (!response.ok) {
185216
const text = await response.text().catch(() => "");
@@ -207,14 +238,19 @@ async function exchangeAuthorizationCode(
207238
};
208239
}
209240

210-
async function refreshAccessToken(refreshToken: string): Promise<TokenResult> {
241+
async function refreshAccessToken(
242+
refreshToken: string,
243+
options: TokenRequestOptions = {},
244+
): Promise<TokenResult> {
211245
try {
246+
const timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS;
212247
const response = await postTokenForm(
213248
new URLSearchParams({
214249
grant_type: "refresh_token",
215250
refresh_token: refreshToken,
216251
client_id: CLIENT_ID,
217252
}),
253+
{ timeoutMs },
218254
);
219255

220256
if (!response.ok) {
@@ -244,7 +280,11 @@ async function refreshAccessToken(refreshToken: string): Promise<TokenResult> {
244280
} catch (error) {
245281
return {
246282
type: "failed",
247-
message: `OpenAI Codex token refresh error: ${error instanceof Error ? error.message : String(error)}`,
283+
message: formatTokenRequestError(
284+
"refresh",
285+
error,
286+
options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS,
287+
),
248288
};
249289
}
250290
}

src/llm/utils/oauth/openai-codex.test.ts

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,46 @@ function stubTokenResponse(body: Record<string, unknown>): void {
1414
);
1515
}
1616

17+
function stubHangingTokenRequest(timeoutMs: number): void {
18+
vi.spyOn(AbortSignal, "timeout").mockImplementation((actualTimeoutMs) => {
19+
expect(actualTimeoutMs).toBe(timeoutMs);
20+
const controller = new AbortController();
21+
queueMicrotask(() => {
22+
controller.abort(new DOMException("timed out", "TimeoutError"));
23+
});
24+
return controller.signal;
25+
});
26+
27+
vi.stubGlobal(
28+
"fetch",
29+
vi.fn(
30+
(_input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) =>
31+
new Promise<Response>((_resolve, reject) => {
32+
const signal = init?.signal;
33+
if (!signal) {
34+
reject(new Error("missing abort signal"));
35+
return;
36+
}
37+
38+
const abort = () => {
39+
reject(
40+
signal.reason instanceof Error
41+
? signal.reason
42+
: new DOMException("aborted", "AbortError"),
43+
);
44+
};
45+
if (signal.aborted) {
46+
abort();
47+
return;
48+
}
49+
signal.addEventListener("abort", abort, { once: true });
50+
}),
51+
),
52+
);
53+
}
54+
1755
afterEach(() => {
56+
vi.restoreAllMocks();
1857
vi.unstubAllGlobals();
1958
});
2059

@@ -33,15 +72,13 @@ describe("OpenAI Codex OAuth token responses", () => {
3372
});
3473

3574
it("builds callback redirect URIs from the configured loopback host", () => {
36-
expect(testing.resolveRedirectUri("127.0.0.1")).toBe(
37-
"http://127.0.0.1:1455/auth/callback",
38-
);
75+
expect(testing.resolveRedirectUri("127.0.0.1")).toBe("http://127.0.0.1:1455/auth/callback");
3976
});
4077

4178
it("rejects non-loopback callback bind hosts", () => {
42-
expect(() =>
43-
testing.resolveCallbackHost({ OPENCLAW_OAUTH_CALLBACK_HOST: "0.0.0.0" }),
44-
).toThrow("callback host must be localhost, 127.0.0.1, or ::1");
79+
expect(() => testing.resolveCallbackHost({ OPENCLAW_OAUTH_CALLBACK_HOST: "0.0.0.0" })).toThrow(
80+
"callback host must be localhost, 127.0.0.1, or ::1",
81+
);
4582
});
4683

4784
it("does not echo token payload values when the exchange response is malformed", async () => {
@@ -62,6 +99,22 @@ describe("OpenAI Codex OAuth token responses", () => {
6299
}
63100
});
64101

102+
it("times out token exchange requests", async () => {
103+
stubHangingTokenRequest(5);
104+
105+
const result = await testing.exchangeAuthorizationCode(
106+
"code",
107+
"verifier",
108+
testing.resolveRedirectUri("localhost"),
109+
{ timeoutMs: 5 },
110+
);
111+
112+
expect(result).toMatchObject({
113+
type: "failed",
114+
message: "OpenAI Codex token exchange timed out after 5ms",
115+
});
116+
});
117+
65118
it("does not echo token payload values when the refresh response is malformed", async () => {
66119
stubTokenResponse({
67120
access_token: "new-secret-access-token",
@@ -82,6 +135,17 @@ describe("OpenAI Codex OAuth token responses", () => {
82135
}
83136
});
84137

138+
it("times out token refresh requests", async () => {
139+
stubHangingTokenRequest(5);
140+
141+
const result = await testing.refreshAccessToken("old-refresh-token", { timeoutMs: 5 });
142+
143+
expect(result).toMatchObject({
144+
type: "failed",
145+
message: "OpenAI Codex token refresh timed out after 5ms",
146+
});
147+
});
148+
85149
it("extracts the account id from URL-safe base64 JWT payloads", async () => {
86150
const accessToken = createJwt({
87151
"https://api.openai.com/auth": {

0 commit comments

Comments
 (0)