mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:50:43 +00:00
fix(openai): harden codex device auth flow
This commit is contained in:
committed by
Val Alexander
parent
eea8b02296
commit
fd6e12f051
@@ -19,74 +19,84 @@ function createJsonResponse(body: unknown, init?: { status?: number }) {
|
||||
|
||||
describe("loginOpenAICodexDeviceCode", () => {
|
||||
it("requests a device code, polls for authorization, and exchanges OAuth tokens", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
createJsonResponse({
|
||||
device_auth_id: "device-auth-123",
|
||||
user_code: "CODE-12345",
|
||||
interval: "0",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(null, { status: 404 }))
|
||||
.mockResolvedValueOnce(
|
||||
createJsonResponse({
|
||||
authorization_code: "authorization-code-123",
|
||||
code_challenge: "ignored",
|
||||
code_verifier: "code-verifier-123",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createJsonResponse({
|
||||
access_token: createJwt({
|
||||
exp: Math.floor(Date.now() / 1000) + 600,
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_account_id: "acct_123",
|
||||
},
|
||||
"https://api.openai.com/profile": {
|
||||
email: "codex@example.com",
|
||||
},
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
createJsonResponse({
|
||||
device_auth_id: "device-auth-123",
|
||||
user_code: "CODE-12345",
|
||||
interval: "0",
|
||||
}),
|
||||
refresh_token: "refresh-token-123",
|
||||
id_token: createJwt({
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_account_id: "acct_123",
|
||||
},
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(null, { status: 404 }))
|
||||
.mockResolvedValueOnce(
|
||||
createJsonResponse({
|
||||
authorization_code: "authorization-code-123",
|
||||
code_challenge: "ignored",
|
||||
code_verifier: "code-verifier-123",
|
||||
}),
|
||||
expires_in: 600,
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createJsonResponse({
|
||||
access_token: createJwt({
|
||||
exp: Math.floor(Date.now() / 1000) + 600,
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_account_id: "acct_123",
|
||||
},
|
||||
"https://api.openai.com/profile": {
|
||||
email: "codex@example.com",
|
||||
},
|
||||
}),
|
||||
refresh_token: "refresh-token-123",
|
||||
id_token: createJwt({
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_account_id: "acct_123",
|
||||
},
|
||||
}),
|
||||
expires_in: 600,
|
||||
}),
|
||||
);
|
||||
const onVerification = vi.fn(async () => {});
|
||||
const onProgress = vi.fn();
|
||||
|
||||
const credentialsPromise = loginOpenAICodexDeviceCode({
|
||||
fetchFn: fetchMock as typeof fetch,
|
||||
onVerification,
|
||||
onProgress,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
await vi.advanceTimersByTimeAsync(4_999);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
const credentials = await credentialsPromise;
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"https://auth.openai.com/api/accounts/deviceauth/usercode",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
const onVerification = vi.fn(async () => {});
|
||||
const onProgress = vi.fn();
|
||||
|
||||
const credentials = await loginOpenAICodexDeviceCode({
|
||||
fetchFn: fetchMock as typeof fetch,
|
||||
onVerification,
|
||||
onProgress,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"https://auth.openai.com/api/accounts/deviceauth/usercode",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
expect(onVerification).toHaveBeenCalledWith({
|
||||
verificationUrl: "https://auth.openai.com/codex/device",
|
||||
userCode: "CODE-12345",
|
||||
expiresInMs: 900_000,
|
||||
});
|
||||
expect(onProgress).toHaveBeenNthCalledWith(1, "Requesting device code…");
|
||||
expect(onProgress).toHaveBeenNthCalledWith(2, "Waiting for device authorization…");
|
||||
expect(onProgress).toHaveBeenNthCalledWith(3, "Exchanging device code…");
|
||||
expect(credentials).toMatchObject({
|
||||
access: expect.any(String),
|
||||
refresh: "refresh-token-123",
|
||||
accountId: "acct_123",
|
||||
idToken: expect.any(String),
|
||||
});
|
||||
expect(credentials.expires).toBeGreaterThan(Date.now());
|
||||
expect(onVerification).toHaveBeenCalledWith({
|
||||
verificationUrl: "https://auth.openai.com/codex/device",
|
||||
userCode: "CODE-12345",
|
||||
expiresInMs: 900_000,
|
||||
});
|
||||
expect(onProgress).toHaveBeenNthCalledWith(1, "Requesting device code…");
|
||||
expect(onProgress).toHaveBeenNthCalledWith(2, "Waiting for device authorization…");
|
||||
expect(onProgress).toHaveBeenNthCalledWith(3, "Exchanging device code…");
|
||||
expect(credentials).toMatchObject({
|
||||
access: expect.any(String),
|
||||
refresh: "refresh-token-123",
|
||||
accountId: "acct_123",
|
||||
});
|
||||
expect(credentials.expires).toBeGreaterThan(Date.now());
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("treats JWT-derived expiry fallback as an absolute timestamp", async () => {
|
||||
@@ -129,17 +139,21 @@ describe("loginOpenAICodexDeviceCode", () => {
|
||||
});
|
||||
|
||||
it("surfaces user-code request failures", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(null, { status: 503 }));
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||
new Response(`down\r\n\u001B[31mnow\u001B[0m`, {
|
||||
status: 503,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
loginOpenAICodexDeviceCode({
|
||||
fetchFn: fetchMock as typeof fetch,
|
||||
onVerification: async () => {},
|
||||
}),
|
||||
).rejects.toThrow("OpenAI device code request failed: HTTP 503");
|
||||
).rejects.toThrow("OpenAI device code request failed: HTTP 503 down now");
|
||||
});
|
||||
|
||||
it("surfaces device authorization failures with payload details", async () => {
|
||||
it("surfaces device authorization failures with sanitized payload details", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
@@ -152,8 +166,8 @@ describe("loginOpenAICodexDeviceCode", () => {
|
||||
.mockResolvedValueOnce(
|
||||
createJsonResponse(
|
||||
{
|
||||
error: "authorization_declined",
|
||||
error_description: "Denied",
|
||||
error: "authorization_declined\r\n\u001B[31mspoofed\u001B[0m",
|
||||
error_description: "Denied\r\nnext line",
|
||||
},
|
||||
{ status: 401 },
|
||||
),
|
||||
@@ -164,6 +178,8 @@ describe("loginOpenAICodexDeviceCode", () => {
|
||||
fetchFn: fetchMock as typeof fetch,
|
||||
onVerification: async () => {},
|
||||
}),
|
||||
).rejects.toThrow("OpenAI device authorization failed: authorization_declined (Denied)");
|
||||
).rejects.toThrow(
|
||||
"OpenAI device authorization failed: authorization_declined spoofed (Denied next line)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ const OPENAI_AUTH_BASE_URL = "https://auth.openai.com";
|
||||
const OPENAI_CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
const OPENAI_CODEX_DEVICE_CODE_TIMEOUT_MS = 15 * 60_000;
|
||||
const OPENAI_CODEX_DEVICE_CODE_DEFAULT_INTERVAL_MS = 5_000;
|
||||
const OPENAI_CODEX_DEVICE_CODE_MIN_INTERVAL_MS = 1_000;
|
||||
const OPENAI_CODEX_DEVICE_CALLBACK_URL = `${OPENAI_AUTH_BASE_URL}/deviceauth/callback`;
|
||||
|
||||
type OpenAICodexDeviceCodePrompt = {
|
||||
@@ -22,7 +23,6 @@ type OpenAICodexDeviceCodeCredentials = {
|
||||
refresh: string;
|
||||
expires: number;
|
||||
accountId?: string;
|
||||
idToken?: string;
|
||||
};
|
||||
|
||||
type DeviceCodeUserCodePayload = {
|
||||
@@ -58,11 +58,12 @@ type DeviceCodeAuthorizationCode = {
|
||||
};
|
||||
|
||||
function normalizePositiveMilliseconds(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return Math.trunc(value * 1000);
|
||||
}
|
||||
if (typeof value === "string" && /^\d+$/.test(value.trim())) {
|
||||
return Number.parseInt(value.trim(), 10) * 1000;
|
||||
const seconds = Number.parseInt(value.trim(), 10);
|
||||
return seconds > 0 ? seconds * 1000 : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -86,6 +87,27 @@ function parseJsonObject(text: string): Record<string, unknown> | null {
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeDeviceCodeErrorText(value: string): string {
|
||||
const esc = String.fromCharCode(0x1b);
|
||||
const ansiCsiRegex = new RegExp(`${esc}\\[[\\u0020-\\u003f]*[\\u0040-\\u007e]`, "g");
|
||||
const osc8Regex = new RegExp(`${esc}\\]8;;.*?${esc}\\\\|${esc}\\]8;;${esc}\\\\`, "g");
|
||||
const c0Start = String.fromCharCode(0x00);
|
||||
const c0End = String.fromCharCode(0x1f);
|
||||
const del = String.fromCharCode(0x7f);
|
||||
const controlCharsRegex = new RegExp(`[${c0Start}-${c0End}${del}]`, "g");
|
||||
return value
|
||||
.replace(osc8Regex, "")
|
||||
.replace(ansiCsiRegex, "")
|
||||
.replace(controlCharsRegex, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function resolveNextDeviceCodePollDelayMs(intervalMs: number, deadlineMs: number): number {
|
||||
const remainingMs = Math.max(0, deadlineMs - Date.now());
|
||||
return Math.min(Math.max(intervalMs, OPENAI_CODEX_DEVICE_CODE_MIN_INTERVAL_MS), remainingMs);
|
||||
}
|
||||
|
||||
function formatDeviceCodeError(params: {
|
||||
prefix: string;
|
||||
status: number;
|
||||
@@ -94,13 +116,15 @@ function formatDeviceCodeError(params: {
|
||||
const body = parseJsonObject(params.bodyText);
|
||||
const error = trimNonEmptyString(body?.error);
|
||||
const description = trimNonEmptyString(body?.error_description);
|
||||
if (error && description) {
|
||||
return `${params.prefix}: ${error} (${description})`;
|
||||
const safeError = error ? sanitizeDeviceCodeErrorText(error) : undefined;
|
||||
const safeDescription = description ? sanitizeDeviceCodeErrorText(description) : undefined;
|
||||
if (safeError && safeDescription) {
|
||||
return `${params.prefix}: ${safeError} (${safeDescription})`;
|
||||
}
|
||||
if (error) {
|
||||
return `${params.prefix}: ${error}`;
|
||||
if (safeError) {
|
||||
return `${params.prefix}: ${safeError}`;
|
||||
}
|
||||
const bodyText = params.bodyText.trim();
|
||||
const bodyText = sanitizeDeviceCodeErrorText(params.bodyText);
|
||||
return bodyText
|
||||
? `${params.prefix}: HTTP ${params.status} ${bodyText}`
|
||||
: `${params.prefix}: HTTP ${params.status}`;
|
||||
@@ -185,7 +209,7 @@ async function pollOpenAICodexDeviceCode(params: {
|
||||
|
||||
if (response.status === 403 || response.status === 404) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, Math.max(0, Math.min(params.intervalMs, deadline - Date.now()))),
|
||||
setTimeout(resolve, resolveNextDeviceCodePollDelayMs(params.intervalMs, deadline)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -253,7 +277,6 @@ async function exchangeOpenAICodexDeviceCode(params: {
|
||||
refresh,
|
||||
expires,
|
||||
...(accountId ? { accountId } : {}),
|
||||
...(idToken ? { idToken } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -253,7 +253,6 @@ describe("openai codex provider", () => {
|
||||
refresh: "device-refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
accountId: "acct-device-123",
|
||||
idToken: "device-id-token",
|
||||
});
|
||||
|
||||
const result = await deviceCodeMethod?.run({
|
||||
@@ -285,12 +284,57 @@ describe("openai codex provider", () => {
|
||||
"eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjdC1kZXZpY2UtMTIzIn19.signature",
|
||||
refresh: "device-refresh-token",
|
||||
accountId: "acct-device-123",
|
||||
idToken: "device-id-token",
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultModel: "openai-codex/gpt-5.4",
|
||||
});
|
||||
expect(result?.profiles[0]?.credential).not.toHaveProperty("idToken");
|
||||
});
|
||||
|
||||
it("does not log the device pairing code in remote mode", async () => {
|
||||
const provider = buildOpenAICodexProviderPlugin();
|
||||
const deviceCodeMethod = provider.auth?.find((method) => method.id === "device-code");
|
||||
const note = vi.fn(async () => {});
|
||||
const progress = { update: vi.fn(), stop: vi.fn() };
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
loginOpenAICodexDeviceCodeMock.mockImplementationOnce(async ({ onVerification }) => {
|
||||
await onVerification({
|
||||
verificationUrl: "https://auth.openai.com/codex/device",
|
||||
userCode: "CODE-12345",
|
||||
expiresInMs: 900_000,
|
||||
});
|
||||
return {
|
||||
access:
|
||||
"eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjdC1kZXZpY2UtMTIzIn19.signature",
|
||||
refresh: "device-refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
accountId: "acct-device-123",
|
||||
};
|
||||
});
|
||||
|
||||
await expect(
|
||||
deviceCodeMethod?.run({
|
||||
config: {},
|
||||
env: process.env,
|
||||
prompter: {
|
||||
note,
|
||||
progress: vi.fn(() => progress),
|
||||
} as never,
|
||||
runtime: runtime as never,
|
||||
isRemote: true,
|
||||
openUrl: async () => {},
|
||||
oauth: { createVpsAwareHandlers: (() => ({})) as never },
|
||||
}),
|
||||
).resolves.toBeDefined();
|
||||
|
||||
const logOutput = runtime.log.mock.calls.flat().join("\n");
|
||||
expect(logOutput).toContain("https://auth.openai.com/codex/device");
|
||||
expect(logOutput).not.toContain("CODE-12345");
|
||||
});
|
||||
|
||||
it("exposes Codex CLI auth as a runtime-only external profile", () => {
|
||||
|
||||
@@ -332,9 +332,7 @@ async function runOpenAICodexDeviceCode(ctx: ProviderAuthContext) {
|
||||
"OpenAI Codex device code",
|
||||
);
|
||||
if (ctx.isRemote) {
|
||||
ctx.runtime.log(
|
||||
`\nOpen this URL in your LOCAL browser:\n\n${verificationUrl}\n\nEnter this code:\n\n${userCode}\n`,
|
||||
);
|
||||
ctx.runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${verificationUrl}\n`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -359,10 +357,7 @@ async function runOpenAICodexDeviceCode(ctx: ProviderAuthContext) {
|
||||
expires: creds.expires,
|
||||
email: identity.email,
|
||||
profileName: identity.profileName,
|
||||
credentialExtra: {
|
||||
...(trimNonEmptyString(creds.accountId) ? { accountId: creds.accountId } : {}),
|
||||
...(trimNonEmptyString(creds.idToken) ? { idToken: creds.idToken } : {}),
|
||||
},
|
||||
credentialExtra: trimNonEmptyString(creds.accountId) ? { accountId: creds.accountId } : {},
|
||||
});
|
||||
} catch (error) {
|
||||
spin.stop("OpenAI device code failed");
|
||||
|
||||
Reference in New Issue
Block a user