fix(openai): harden codex device auth flow

This commit is contained in:
Vincent Koc
2026-04-21 10:44:09 -07:00
committed by Val Alexander
parent eea8b02296
commit fd6e12f051
4 changed files with 167 additions and 89 deletions

View File

@@ -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)",
);
});
});

View File

@@ -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 } : {}),
};
}

View File

@@ -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", () => {

View File

@@ -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");