From 2186e98cb35f56bdef32edafa63fc6bbf34b17d2 Mon Sep 17 00:00:00 2001 From: Bruce-Yii <298228875+Bruce-Yii@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:41:32 +0800 Subject: [PATCH 1/3] fix(oauth): guard expires_in parsing against NaN across token responses --- src/codex/account-store.ts | 6 +++++- src/oauth/anthropic.ts | 6 +++++- src/oauth/chatgpt.ts | 6 +++++- src/oauth/kimi.ts | 4 +++- tests/anthropic-hardening.test.ts | 13 +++++++++++++ tests/chatgpt-token-expiry.test.ts | 31 ++++++++++++++++++++++++++++++ tests/codex-account-store.test.ts | 25 ++++++++++++++++++++++++ tests/kimi-oauth-identity.test.ts | 11 +++++++++++ 8 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 tests/chatgpt-token-expiry.test.ts diff --git a/src/codex/account-store.ts b/src/codex/account-store.ts index 09630ca312..b4ed8af5cc 100644 --- a/src/codex/account-store.ts +++ b/src/codex/account-store.ts @@ -491,11 +491,15 @@ export async function getValidCodexToken(id: string): Promise throw new TokenRefreshError(reason, `Codex token refresh failed (${reason}); reauthenticate the account.`); } const data = (await res.json()) as { access_token: string; refresh_token?: string; expires_in: number }; + // Guard against a missing/non-finite expires_in (malformed upstream response): + // a NaN expiry would never compare as expired and would block refresh forever. + const expiresIn = + typeof data.expires_in === "number" && Number.isFinite(data.expires_in) ? data.expires_in : 3600; const updated: CodexAccountCredentials = { accessToken: data.access_token, refreshToken: data.refresh_token ?? lockedCred.refreshToken, - expiresAt: Date.now() + data.expires_in * 1000, + expiresAt: Date.now() + expiresIn * 1000, chatgptAccountId: lockedCred.chatgptAccountId, }; if (!saveCodexAccountCredentialIfGeneration(id, startGeneration, updated)) { diff --git a/src/oauth/anthropic.ts b/src/oauth/anthropic.ts index a4a94eea7b..fce715f150 100644 --- a/src/oauth/anthropic.ts +++ b/src/oauth/anthropic.ts @@ -81,10 +81,14 @@ function parseTokenResponse(responseBody: string): AnthropicTokenResponse { function credsFrom(data: AnthropicTokenResponse, refreshFallback?: string): OAuthCredentials { const accountUuid = data.account?.uuid; const email = data.account?.email_address; + // Guard against a missing/non-finite expires_in (malformed upstream response): + // a NaN expiry would never compare as expired and would block refresh forever. + const expiresIn = + typeof data.expires_in === "number" && Number.isFinite(data.expires_in) ? data.expires_in : 3600; return { refresh: data.refresh_token || refreshFallback || "", access: data.access_token, - expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, + expires: Date.now() + expiresIn * 1000 - 5 * 60 * 1000, accountId: typeof accountUuid === "string" && accountUuid.length > 0 ? accountUuid : undefined, email: typeof email === "string" && email.length > 0 ? email : undefined, }; diff --git a/src/oauth/chatgpt.ts b/src/oauth/chatgpt.ts index b8089737c0..e5e56b8e14 100644 --- a/src/oauth/chatgpt.ts +++ b/src/oauth/chatgpt.ts @@ -49,10 +49,14 @@ export function extractEmail(idToken?: string, accessToken?: string): string | u function credsFromToken(data: Record): OAuthCredentials { const idToken = typeof data.id_token === "string" ? data.id_token : undefined; const accessToken = data.access_token as string; + // ?? only guards null/undefined; NaN or a string expires_in would otherwise + // produce a NaN expiry that never compares as expired (refresh blocked forever). + const expiresIn = + typeof data.expires_in === "number" && Number.isFinite(data.expires_in) ? data.expires_in : 3600; return { access: accessToken, refresh: (data.refresh_token as string) ?? "", - expires: Date.now() + ((data.expires_in as number) ?? 3600) * 1000, + expires: Date.now() + expiresIn * 1000, accountId: extractAccountId(idToken, accessToken), email: extractEmail(idToken, accessToken), }; diff --git a/src/oauth/kimi.ts b/src/oauth/kimi.ts index be14640e9b..54beb2b5c7 100644 --- a/src/oauth/kimi.ts +++ b/src/oauth/kimi.ts @@ -151,7 +151,9 @@ async function requestDeviceAuthorization(): Promise<{ } function parseTokenPayload(payload: TokenResponse, refreshFallback?: string): OAuthCredentials { - if (!payload.access_token || typeof payload.expires_in !== "number") { + // Number.isFinite is required here: typeof NaN === "number", so the type check + // alone would let a NaN expires_in through and produce a never-refreshing expiry. + if (!payload.access_token || typeof payload.expires_in !== "number" || !Number.isFinite(payload.expires_in)) { throw new Error("Kimi token response missing required fields"); } const refresh = payload.refresh_token ?? refreshFallback; diff --git a/tests/anthropic-hardening.test.ts b/tests/anthropic-hardening.test.ts index fe78b500bd..4ff2782940 100644 --- a/tests/anthropic-hardening.test.ts +++ b/tests/anthropic-hardening.test.ts @@ -43,6 +43,19 @@ describe("anthropic provider hardening", () => { await expect(refreshAnthropicToken("secret")).rejects.toMatchObject({ httpStatus: 503, oauthError: undefined }); }); + test("refresh with a non-finite expires_in falls back to a finite default expiry", async () => { + globalThis.fetch = (async () => new Response( + // JSON.stringify would turn Infinity into null; hand-write 1e999 so JSON.parse + // yields Infinity, which would previously produce expires: NaN (never refreshing). + '{"access_token":"at","refresh_token":"rt","expires_in":1e999}', + { status: 200 }, + )) as typeof fetch; + + const cred = await refreshAnthropicToken("secret"); + expect(Number.isFinite(cred.expires)).toBe(true); + expect(cred.expires).toBeGreaterThan(Date.now()); + }); + test("key mode rejects a blank API key", async () => { const adapter = createAnthropicAdapter(provider({ apiKey: " " })); diff --git a/tests/chatgpt-token-expiry.test.ts b/tests/chatgpt-token-expiry.test.ts new file mode 100644 index 0000000000..7c2ba79e18 --- /dev/null +++ b/tests/chatgpt-token-expiry.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { refreshChatGPTToken } from "../src/oauth/chatgpt"; + +const originalFetch = globalThis.fetch; +afterEach(() => { globalThis.fetch = originalFetch; }); + +describe("ChatGPT OAuth token response parsing", () => { + test("refresh with a non-finite expires_in falls back to a finite default expiry", async () => { + globalThis.fetch = (async () => new Response( + // JSON.stringify would turn Infinity into null; hand-write 1e999 so JSON.parse + // yields Infinity, which ?? 3600 alone would let through (NaN expiry, never refreshing). + '{"access_token":"at","refresh_token":"rt","expires_in":1e999}', + { status: 200 }, + )) as typeof fetch; + + const cred = await refreshChatGPTToken("secret"); + expect(Number.isFinite(cred.expires)).toBe(true); + expect(cred.expires).toBeGreaterThan(Date.now()); + }); + + test("refresh with a string expires_in falls back to a finite default expiry", async () => { + globalThis.fetch = (async () => new Response( + JSON.stringify({ access_token: "at", refresh_token: "rt", expires_in: "garbage" }), + { status: 200 }, + )) as typeof fetch; + + const cred = await refreshChatGPTToken("secret"); + expect(Number.isFinite(cred.expires)).toBe(true); + expect(cred.expires).toBeGreaterThan(Date.now()); + }); +}); diff --git a/tests/codex-account-store.test.ts b/tests/codex-account-store.test.ts index eed7c09959..9ede7bb9d3 100644 --- a/tests/codex-account-store.test.ts +++ b/tests/codex-account-store.test.ts @@ -261,6 +261,31 @@ describe("codex-account-store CRUD", () => { } }); + test("refresh with a non-finite expires_in falls back to a finite default expiry", async () => { + const { + getCodexAccountCredential, + getValidCodexToken, + saveCodexAccountCredential, + } = await import("../src/codex/account-store"); + saveCodexAccountCredential("refresh-bad-expiry", { accessToken: "old", refreshToken: "old-r", expiresAt: 0, chatgptAccountId: "acc" }); + const originalFetch = globalThis.fetch; + // JSON.stringify turns NaN into null; hand-write 1e999 so JSON.parse yields Infinity, + // the realistic corrupt shape that would previously produce expiresAt: NaN. + globalThis.fetch = (async () => new Response( + '{"access_token":"new","refresh_token":"new-r","expires_in":1e999}', + { status: 200 }, + )) as typeof fetch; + + try { + await getValidCodexToken("refresh-bad-expiry"); + const stored = getCodexAccountCredential("refresh-bad-expiry")!; + expect(Number.isFinite(stored.expiresAt)).toBe(true); + expect(stored.expiresAt).toBeGreaterThan(Date.now()); + } finally { + globalThis.fetch = originalFetch; + } + }); + test("refresh waits behind file lock and reuses credential refreshed by another process", async () => { const { getValidCodexToken, diff --git a/tests/kimi-oauth-identity.test.ts b/tests/kimi-oauth-identity.test.ts index cc3bc78a48..89e85e1c94 100644 --- a/tests/kimi-oauth-identity.test.ts +++ b/tests/kimi-oauth-identity.test.ts @@ -75,6 +75,17 @@ describe("Kimi token-response wiring (production parseTokenPayload path)", () => expect(cred.accountId).toBe("wired-user"); expect(cred.email).toBe(["w", String.fromCharCode(64), "kimi.example"].join("")); }); + + test("refreshKimiToken rejects a non-finite expires_in (would otherwise yield NaN expiry)", async () => { + globalThis.fetch = (async () => new Response( + // JSON.stringify would turn Infinity into null; hand-write 1e999 so JSON.parse + // yields Infinity, which typeof === "number" alone would let through. + `{"access_token":"at","refresh_token":"rt","expires_in":1e999}`, + { status: 200 }, + )) as typeof fetch; + + await expect(refreshKimiToken("old-refresh")).rejects.toThrow("missing required fields"); + }); }); describe("Kimi multiauth via saveCredential", () => { From fc5889e0aa45efb281650e718151bcca682a8040 Mon Sep 17 00:00:00 2001 From: Bruce-Yii <298228875+Bruce-Yii@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:59:43 +0800 Subject: [PATCH 2/3] fix(oauth): guard computed token expiry against numeric overflow --- src/codex/account-store.ts | 6 +++++- src/oauth/anthropic.ts | 6 +++++- src/oauth/chatgpt.ts | 6 +++++- src/oauth/kimi.ts | 8 +++++++- tests/anthropic-hardening.test.ts | 13 +++++++++++++ tests/chatgpt-token-expiry.test.ts | 13 +++++++++++++ tests/codex-account-store.test.ts | 25 +++++++++++++++++++++++++ tests/kimi-oauth-identity.test.ts | 11 +++++++++++ 8 files changed, 84 insertions(+), 4 deletions(-) diff --git a/src/codex/account-store.ts b/src/codex/account-store.ts index b4ed8af5cc..3fb09ebebc 100644 --- a/src/codex/account-store.ts +++ b/src/codex/account-store.ts @@ -495,11 +495,15 @@ export async function getValidCodexToken(id: string): Promise // a NaN expiry would never compare as expired and would block refresh forever. const expiresIn = typeof data.expires_in === "number" && Number.isFinite(data.expires_in) ? data.expires_in : 3600; + // The computed timestamp itself must stay finite: Number.MAX_VALUE passes + // Number.isFinite but overflows to Infinity once multiplied by 1000. + const expiresAt = Date.now() + expiresIn * 1000; + const safeExpiresAt = Number.isFinite(expiresAt) ? expiresAt : Date.now() + 3600 * 1000; const updated: CodexAccountCredentials = { accessToken: data.access_token, refreshToken: data.refresh_token ?? lockedCred.refreshToken, - expiresAt: Date.now() + expiresIn * 1000, + expiresAt: safeExpiresAt, chatgptAccountId: lockedCred.chatgptAccountId, }; if (!saveCodexAccountCredentialIfGeneration(id, startGeneration, updated)) { diff --git a/src/oauth/anthropic.ts b/src/oauth/anthropic.ts index fce715f150..aac60df480 100644 --- a/src/oauth/anthropic.ts +++ b/src/oauth/anthropic.ts @@ -85,10 +85,14 @@ function credsFrom(data: AnthropicTokenResponse, refreshFallback?: string): OAut // a NaN expiry would never compare as expired and would block refresh forever. const expiresIn = typeof data.expires_in === "number" && Number.isFinite(data.expires_in) ? data.expires_in : 3600; + // The computed timestamp itself must stay finite: Number.MAX_VALUE passes + // Number.isFinite but overflows to Infinity once multiplied by 1000. + const computedExpires = Date.now() + expiresIn * 1000 - 5 * 60 * 1000; + const expires = Number.isFinite(computedExpires) ? computedExpires : Date.now() + 3600 * 1000 - 5 * 60 * 1000; return { refresh: data.refresh_token || refreshFallback || "", access: data.access_token, - expires: Date.now() + expiresIn * 1000 - 5 * 60 * 1000, + expires, accountId: typeof accountUuid === "string" && accountUuid.length > 0 ? accountUuid : undefined, email: typeof email === "string" && email.length > 0 ? email : undefined, }; diff --git a/src/oauth/chatgpt.ts b/src/oauth/chatgpt.ts index e5e56b8e14..871c274a10 100644 --- a/src/oauth/chatgpt.ts +++ b/src/oauth/chatgpt.ts @@ -53,10 +53,14 @@ function credsFromToken(data: Record): OAuthCredentials { // produce a NaN expiry that never compares as expired (refresh blocked forever). const expiresIn = typeof data.expires_in === "number" && Number.isFinite(data.expires_in) ? data.expires_in : 3600; + // The computed timestamp itself must stay finite: Number.MAX_VALUE passes + // Number.isFinite but overflows to Infinity once multiplied by 1000. + const computedExpires = Date.now() + expiresIn * 1000; + const expires = Number.isFinite(computedExpires) ? computedExpires : Date.now() + 3600 * 1000; return { access: accessToken, refresh: (data.refresh_token as string) ?? "", - expires: Date.now() + expiresIn * 1000, + expires, accountId: extractAccountId(idToken, accessToken), email: extractEmail(idToken, accessToken), }; diff --git a/src/oauth/kimi.ts b/src/oauth/kimi.ts index 54beb2b5c7..9e8734dcbb 100644 --- a/src/oauth/kimi.ts +++ b/src/oauth/kimi.ts @@ -153,16 +153,22 @@ async function requestDeviceAuthorization(): Promise<{ function parseTokenPayload(payload: TokenResponse, refreshFallback?: string): OAuthCredentials { // Number.isFinite is required here: typeof NaN === "number", so the type check // alone would let a NaN expires_in through and produce a never-refreshing expiry. + // The computed timestamp itself must also stay finite: Number.MAX_VALUE passes + // Number.isFinite but overflows to Infinity once multiplied by 1000. if (!payload.access_token || typeof payload.expires_in !== "number" || !Number.isFinite(payload.expires_in)) { throw new Error("Kimi token response missing required fields"); } + const expires = Date.now() + payload.expires_in * 1000 - OAUTH_EXPIRY_SKEW_MS; + if (!Number.isFinite(expires)) { + throw new Error("Kimi token response missing required fields"); + } const refresh = payload.refresh_token ?? refreshFallback; if (!refresh) throw new Error("Kimi token response missing refresh token"); const identity = identityFromKimiTokens(payload.access_token, refresh); return { access: payload.access_token, refresh, - expires: Date.now() + payload.expires_in * 1000 - OAUTH_EXPIRY_SKEW_MS, + expires, ...identity, }; } diff --git a/tests/anthropic-hardening.test.ts b/tests/anthropic-hardening.test.ts index 4ff2782940..f2b55a9e11 100644 --- a/tests/anthropic-hardening.test.ts +++ b/tests/anthropic-hardening.test.ts @@ -56,6 +56,19 @@ describe("anthropic provider hardening", () => { expect(cred.expires).toBeGreaterThan(Date.now()); }); + test("refresh with an overflowing expires_in falls back to a finite default expiry", async () => { + globalThis.fetch = (async () => new Response( + // Number.MAX_VALUE passes Number.isFinite but overflows to Infinity when + // multiplied by 1000 — the computed expiry must still be guarded. + '{"access_token":"at","refresh_token":"rt","expires_in":1.7976931348623157e308}', + { status: 200 }, + )) as typeof fetch; + + const cred = await refreshAnthropicToken("secret"); + expect(Number.isFinite(cred.expires)).toBe(true); + expect(cred.expires).toBeGreaterThan(Date.now()); + }); + test("key mode rejects a blank API key", async () => { const adapter = createAnthropicAdapter(provider({ apiKey: " " })); diff --git a/tests/chatgpt-token-expiry.test.ts b/tests/chatgpt-token-expiry.test.ts index 7c2ba79e18..4568d3acbd 100644 --- a/tests/chatgpt-token-expiry.test.ts +++ b/tests/chatgpt-token-expiry.test.ts @@ -28,4 +28,17 @@ describe("ChatGPT OAuth token response parsing", () => { expect(Number.isFinite(cred.expires)).toBe(true); expect(cred.expires).toBeGreaterThan(Date.now()); }); + + test("refresh with an overflowing expires_in falls back to a finite default expiry", async () => { + globalThis.fetch = (async () => new Response( + // Number.MAX_VALUE passes Number.isFinite but overflows to Infinity when + // multiplied by 1000 — the computed expiry must still be guarded. + '{"access_token":"at","refresh_token":"rt","expires_in":1.7976931348623157e308}', + { status: 200 }, + )) as typeof fetch; + + const cred = await refreshChatGPTToken("secret"); + expect(Number.isFinite(cred.expires)).toBe(true); + expect(cred.expires).toBeGreaterThan(Date.now()); + }); }); diff --git a/tests/codex-account-store.test.ts b/tests/codex-account-store.test.ts index 9ede7bb9d3..3abdeb63ce 100644 --- a/tests/codex-account-store.test.ts +++ b/tests/codex-account-store.test.ts @@ -286,6 +286,31 @@ describe("codex-account-store CRUD", () => { } }); + test("refresh with an overflowing expires_in falls back to a finite default expiry", async () => { + const { + getCodexAccountCredential, + getValidCodexToken, + saveCodexAccountCredential, + } = await import("../src/codex/account-store"); + saveCodexAccountCredential("refresh-overflow-expiry", { accessToken: "old", refreshToken: "old-r", expiresAt: 0, chatgptAccountId: "acc" }); + const originalFetch = globalThis.fetch; + // Number.MAX_VALUE passes Number.isFinite but overflows to Infinity when + // multiplied by 1000 — the computed expiresAt must still be guarded. + globalThis.fetch = (async () => new Response( + '{"access_token":"new","refresh_token":"new-r","expires_in":1.7976931348623157e308}', + { status: 200 }, + )) as typeof fetch; + + try { + await getValidCodexToken("refresh-overflow-expiry"); + const stored = getCodexAccountCredential("refresh-overflow-expiry")!; + expect(Number.isFinite(stored.expiresAt)).toBe(true); + expect(stored.expiresAt).toBeGreaterThan(Date.now()); + } finally { + globalThis.fetch = originalFetch; + } + }); + test("refresh waits behind file lock and reuses credential refreshed by another process", async () => { const { getValidCodexToken, diff --git a/tests/kimi-oauth-identity.test.ts b/tests/kimi-oauth-identity.test.ts index 89e85e1c94..97c8f01403 100644 --- a/tests/kimi-oauth-identity.test.ts +++ b/tests/kimi-oauth-identity.test.ts @@ -86,6 +86,17 @@ describe("Kimi token-response wiring (production parseTokenPayload path)", () => await expect(refreshKimiToken("old-refresh")).rejects.toThrow("missing required fields"); }); + + test("refreshKimiToken rejects an overflowing expires_in (finite input, Infinity expiry)", async () => { + globalThis.fetch = (async () => new Response( + // Number.MAX_VALUE passes Number.isFinite but overflows to Infinity when + // multiplied by 1000 — the computed expiry must be rejected as malformed. + `{"access_token":"at","refresh_token":"rt","expires_in":1.7976931348623157e308}`, + { status: 200 }, + )) as typeof fetch; + + await expect(refreshKimiToken("old-refresh")).rejects.toThrow("missing required fields"); + }); }); describe("Kimi multiauth via saveCredential", () => { From 355b69e5b003edebe3f543d1818a9618fa075b14 Mon Sep 17 00:00:00 2001 From: Bruce-Yii <298228875+Bruce-Yii@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:10:07 +0800 Subject: [PATCH 3/3] fix(oauth): reject negative expires_in and assert exact fallback window in tests --- src/codex/account-store.ts | 9 +++++--- src/oauth/anthropic.ts | 9 +++++--- src/oauth/chatgpt.ts | 7 ++++-- src/oauth/kimi.ts | 8 ++++++- tests/anthropic-hardening.test.ts | 26 +++++++++++++++++---- tests/chatgpt-token-expiry.test.ts | 34 ++++++++++++++++++++++----- tests/codex-account-store.test.ts | 37 ++++++++++++++++++++++++++---- tests/kimi-oauth-identity.test.ts | 9 ++++++++ 8 files changed, 116 insertions(+), 23 deletions(-) diff --git a/src/codex/account-store.ts b/src/codex/account-store.ts index 3fb09ebebc..5932a614b7 100644 --- a/src/codex/account-store.ts +++ b/src/codex/account-store.ts @@ -491,10 +491,13 @@ export async function getValidCodexToken(id: string): Promise throw new TokenRefreshError(reason, `Codex token refresh failed (${reason}); reauthenticate the account.`); } const data = (await res.json()) as { access_token: string; refresh_token?: string; expires_in: number }; - // Guard against a missing/non-finite expires_in (malformed upstream response): - // a NaN expiry would never compare as expired and would block refresh forever. + // Guard against a missing/non-finite/negative expires_in (malformed upstream + // response): a NaN expiry would never compare as expired, and a negative + // duration would stamp an already-past expiry — both block refresh semantics. const expiresIn = - typeof data.expires_in === "number" && Number.isFinite(data.expires_in) ? data.expires_in : 3600; + typeof data.expires_in === "number" && Number.isFinite(data.expires_in) && data.expires_in >= 0 + ? data.expires_in + : 3600; // The computed timestamp itself must stay finite: Number.MAX_VALUE passes // Number.isFinite but overflows to Infinity once multiplied by 1000. const expiresAt = Date.now() + expiresIn * 1000; diff --git a/src/oauth/anthropic.ts b/src/oauth/anthropic.ts index aac60df480..d4e0acdc03 100644 --- a/src/oauth/anthropic.ts +++ b/src/oauth/anthropic.ts @@ -81,10 +81,13 @@ function parseTokenResponse(responseBody: string): AnthropicTokenResponse { function credsFrom(data: AnthropicTokenResponse, refreshFallback?: string): OAuthCredentials { const accountUuid = data.account?.uuid; const email = data.account?.email_address; - // Guard against a missing/non-finite expires_in (malformed upstream response): - // a NaN expiry would never compare as expired and would block refresh forever. + // Guard against a missing/non-finite/negative expires_in (malformed upstream + // response): a NaN expiry would never compare as expired, and a negative + // duration would stamp an already-past expiry — both block refresh semantics. const expiresIn = - typeof data.expires_in === "number" && Number.isFinite(data.expires_in) ? data.expires_in : 3600; + typeof data.expires_in === "number" && Number.isFinite(data.expires_in) && data.expires_in >= 0 + ? data.expires_in + : 3600; // The computed timestamp itself must stay finite: Number.MAX_VALUE passes // Number.isFinite but overflows to Infinity once multiplied by 1000. const computedExpires = Date.now() + expiresIn * 1000 - 5 * 60 * 1000; diff --git a/src/oauth/chatgpt.ts b/src/oauth/chatgpt.ts index 871c274a10..f4ecc7f8a9 100644 --- a/src/oauth/chatgpt.ts +++ b/src/oauth/chatgpt.ts @@ -50,9 +50,12 @@ function credsFromToken(data: Record): OAuthCredentials { const idToken = typeof data.id_token === "string" ? data.id_token : undefined; const accessToken = data.access_token as string; // ?? only guards null/undefined; NaN or a string expires_in would otherwise - // produce a NaN expiry that never compares as expired (refresh blocked forever). + // produce a NaN expiry that never compares as expired, and a negative duration + // would stamp an already-past expiry — both block refresh semantics. const expiresIn = - typeof data.expires_in === "number" && Number.isFinite(data.expires_in) ? data.expires_in : 3600; + typeof data.expires_in === "number" && Number.isFinite(data.expires_in) && data.expires_in >= 0 + ? data.expires_in + : 3600; // The computed timestamp itself must stay finite: Number.MAX_VALUE passes // Number.isFinite but overflows to Infinity once multiplied by 1000. const computedExpires = Date.now() + expiresIn * 1000; diff --git a/src/oauth/kimi.ts b/src/oauth/kimi.ts index 9e8734dcbb..bfba15a587 100644 --- a/src/oauth/kimi.ts +++ b/src/oauth/kimi.ts @@ -153,9 +153,15 @@ async function requestDeviceAuthorization(): Promise<{ function parseTokenPayload(payload: TokenResponse, refreshFallback?: string): OAuthCredentials { // Number.isFinite is required here: typeof NaN === "number", so the type check // alone would let a NaN expires_in through and produce a never-refreshing expiry. + // Negative durations would stamp an already-past expiry — also malformed. // The computed timestamp itself must also stay finite: Number.MAX_VALUE passes // Number.isFinite but overflows to Infinity once multiplied by 1000. - if (!payload.access_token || typeof payload.expires_in !== "number" || !Number.isFinite(payload.expires_in)) { + if ( + !payload.access_token + || typeof payload.expires_in !== "number" + || !Number.isFinite(payload.expires_in) + || payload.expires_in < 0 + ) { throw new Error("Kimi token response missing required fields"); } const expires = Date.now() + payload.expires_in * 1000 - OAUTH_EXPIRY_SKEW_MS; diff --git a/tests/anthropic-hardening.test.ts b/tests/anthropic-hardening.test.ts index f2b55a9e11..6b01c4e972 100644 --- a/tests/anthropic-hardening.test.ts +++ b/tests/anthropic-hardening.test.ts @@ -43,7 +43,7 @@ describe("anthropic provider hardening", () => { await expect(refreshAnthropicToken("secret")).rejects.toMatchObject({ httpStatus: 503, oauthError: undefined }); }); - test("refresh with a non-finite expires_in falls back to a finite default expiry", async () => { + test("refresh with a non-finite expires_in falls back to the 3600s default (3300s after skew)", async () => { globalThis.fetch = (async () => new Response( // JSON.stringify would turn Infinity into null; hand-write 1e999 so JSON.parse // yields Infinity, which would previously produce expires: NaN (never refreshing). @@ -51,12 +51,15 @@ describe("anthropic provider hardening", () => { { status: 200 }, )) as typeof fetch; + const before = Date.now(); const cred = await refreshAnthropicToken("secret"); expect(Number.isFinite(cred.expires)).toBe(true); - expect(cred.expires).toBeGreaterThan(Date.now()); + expect(cred.expires).toBeGreaterThan(before); + // 3600s default minus the 5-minute refresh skew. + expect(Math.abs(cred.expires - (before + 3300 * 1000))).toBeLessThan(30_000); }); - test("refresh with an overflowing expires_in falls back to a finite default expiry", async () => { + test("refresh with an overflowing expires_in falls back to the 3600s default (3300s after skew)", async () => { globalThis.fetch = (async () => new Response( // Number.MAX_VALUE passes Number.isFinite but overflows to Infinity when // multiplied by 1000 — the computed expiry must still be guarded. @@ -64,9 +67,24 @@ describe("anthropic provider hardening", () => { { status: 200 }, )) as typeof fetch; + const before = Date.now(); const cred = await refreshAnthropicToken("secret"); expect(Number.isFinite(cred.expires)).toBe(true); - expect(cred.expires).toBeGreaterThan(Date.now()); + expect(cred.expires).toBeGreaterThan(before); + expect(Math.abs(cred.expires - (before + 3300 * 1000))).toBeLessThan(30_000); + }); + + test("refresh with a negative expires_in falls back to the 3600s default (3300s after skew)", async () => { + globalThis.fetch = (async () => new Response( + JSON.stringify({ access_token: "at", refresh_token: "rt", expires_in: -1 }), + { status: 200 }, + )) as typeof fetch; + + const before = Date.now(); + const cred = await refreshAnthropicToken("secret"); + expect(Number.isFinite(cred.expires)).toBe(true); + expect(cred.expires).toBeGreaterThan(before); + expect(Math.abs(cred.expires - (before + 3300 * 1000))).toBeLessThan(30_000); }); test("key mode rejects a blank API key", async () => { diff --git a/tests/chatgpt-token-expiry.test.ts b/tests/chatgpt-token-expiry.test.ts index 4568d3acbd..7b0460e1f9 100644 --- a/tests/chatgpt-token-expiry.test.ts +++ b/tests/chatgpt-token-expiry.test.ts @@ -4,8 +4,11 @@ import { refreshChatGPTToken } from "../src/oauth/chatgpt"; const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; }); +const FALLBACK_MS = 3600 * 1000; +const TOLERANCE_MS = 30_000; + describe("ChatGPT OAuth token response parsing", () => { - test("refresh with a non-finite expires_in falls back to a finite default expiry", async () => { + test("refresh with a non-finite expires_in falls back to the 3600s default", async () => { globalThis.fetch = (async () => new Response( // JSON.stringify would turn Infinity into null; hand-write 1e999 so JSON.parse // yields Infinity, which ?? 3600 alone would let through (NaN expiry, never refreshing). @@ -13,23 +16,27 @@ describe("ChatGPT OAuth token response parsing", () => { { status: 200 }, )) as typeof fetch; + const before = Date.now(); const cred = await refreshChatGPTToken("secret"); expect(Number.isFinite(cred.expires)).toBe(true); - expect(cred.expires).toBeGreaterThan(Date.now()); + expect(cred.expires).toBeGreaterThan(before); + expect(Math.abs(cred.expires - (before + FALLBACK_MS))).toBeLessThan(TOLERANCE_MS); }); - test("refresh with a string expires_in falls back to a finite default expiry", async () => { + test("refresh with a string expires_in falls back to the 3600s default", async () => { globalThis.fetch = (async () => new Response( JSON.stringify({ access_token: "at", refresh_token: "rt", expires_in: "garbage" }), { status: 200 }, )) as typeof fetch; + const before = Date.now(); const cred = await refreshChatGPTToken("secret"); expect(Number.isFinite(cred.expires)).toBe(true); - expect(cred.expires).toBeGreaterThan(Date.now()); + expect(cred.expires).toBeGreaterThan(before); + expect(Math.abs(cred.expires - (before + FALLBACK_MS))).toBeLessThan(TOLERANCE_MS); }); - test("refresh with an overflowing expires_in falls back to a finite default expiry", async () => { + test("refresh with an overflowing expires_in falls back to the 3600s default", async () => { globalThis.fetch = (async () => new Response( // Number.MAX_VALUE passes Number.isFinite but overflows to Infinity when // multiplied by 1000 — the computed expiry must still be guarded. @@ -37,8 +44,23 @@ describe("ChatGPT OAuth token response parsing", () => { { status: 200 }, )) as typeof fetch; + const before = Date.now(); + const cred = await refreshChatGPTToken("secret"); + expect(Number.isFinite(cred.expires)).toBe(true); + expect(cred.expires).toBeGreaterThan(before); + expect(Math.abs(cred.expires - (before + FALLBACK_MS))).toBeLessThan(TOLERANCE_MS); + }); + + test("refresh with a negative expires_in falls back to the 3600s default", async () => { + globalThis.fetch = (async () => new Response( + JSON.stringify({ access_token: "at", refresh_token: "rt", expires_in: -1 }), + { status: 200 }, + )) as typeof fetch; + + const before = Date.now(); const cred = await refreshChatGPTToken("secret"); expect(Number.isFinite(cred.expires)).toBe(true); - expect(cred.expires).toBeGreaterThan(Date.now()); + expect(cred.expires).toBeGreaterThan(before); + expect(Math.abs(cred.expires - (before + FALLBACK_MS))).toBeLessThan(TOLERANCE_MS); }); }); diff --git a/tests/codex-account-store.test.ts b/tests/codex-account-store.test.ts index 3abdeb63ce..63f19cf0b3 100644 --- a/tests/codex-account-store.test.ts +++ b/tests/codex-account-store.test.ts @@ -261,7 +261,7 @@ describe("codex-account-store CRUD", () => { } }); - test("refresh with a non-finite expires_in falls back to a finite default expiry", async () => { + test("refresh with a non-finite expires_in falls back to the 3600s default", async () => { const { getCodexAccountCredential, getValidCodexToken, @@ -277,16 +277,18 @@ describe("codex-account-store CRUD", () => { )) as typeof fetch; try { + const before = Date.now(); await getValidCodexToken("refresh-bad-expiry"); const stored = getCodexAccountCredential("refresh-bad-expiry")!; expect(Number.isFinite(stored.expiresAt)).toBe(true); - expect(stored.expiresAt).toBeGreaterThan(Date.now()); + expect(stored.expiresAt).toBeGreaterThan(before); + expect(Math.abs(stored.expiresAt - (before + 3600 * 1000))).toBeLessThan(30_000); } finally { globalThis.fetch = originalFetch; } }); - test("refresh with an overflowing expires_in falls back to a finite default expiry", async () => { + test("refresh with an overflowing expires_in falls back to the 3600s default", async () => { const { getCodexAccountCredential, getValidCodexToken, @@ -302,10 +304,37 @@ describe("codex-account-store CRUD", () => { )) as typeof fetch; try { + const before = Date.now(); await getValidCodexToken("refresh-overflow-expiry"); const stored = getCodexAccountCredential("refresh-overflow-expiry")!; expect(Number.isFinite(stored.expiresAt)).toBe(true); - expect(stored.expiresAt).toBeGreaterThan(Date.now()); + expect(stored.expiresAt).toBeGreaterThan(before); + expect(Math.abs(stored.expiresAt - (before + 3600 * 1000))).toBeLessThan(30_000); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("refresh with a negative expires_in falls back to the 3600s default", async () => { + const { + getCodexAccountCredential, + getValidCodexToken, + saveCodexAccountCredential, + } = await import("../src/codex/account-store"); + saveCodexAccountCredential("refresh-negative-expiry", { accessToken: "old", refreshToken: "old-r", expiresAt: 0, chatgptAccountId: "acc" }); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response( + JSON.stringify({ access_token: "new", refresh_token: "new-r", expires_in: -1 }), + { status: 200 }, + )) as typeof fetch; + + try { + const before = Date.now(); + await getValidCodexToken("refresh-negative-expiry"); + const stored = getCodexAccountCredential("refresh-negative-expiry")!; + expect(Number.isFinite(stored.expiresAt)).toBe(true); + expect(stored.expiresAt).toBeGreaterThan(before); + expect(Math.abs(stored.expiresAt - (before + 3600 * 1000))).toBeLessThan(30_000); } finally { globalThis.fetch = originalFetch; } diff --git a/tests/kimi-oauth-identity.test.ts b/tests/kimi-oauth-identity.test.ts index 97c8f01403..00070311d9 100644 --- a/tests/kimi-oauth-identity.test.ts +++ b/tests/kimi-oauth-identity.test.ts @@ -97,6 +97,15 @@ describe("Kimi token-response wiring (production parseTokenPayload path)", () => await expect(refreshKimiToken("old-refresh")).rejects.toThrow("missing required fields"); }); + + test("refreshKimiToken rejects a negative expires_in (already-past expiry)", async () => { + globalThis.fetch = (async () => new Response( + JSON.stringify({ access_token: "at", refresh_token: "rt", expires_in: -1 }), + { status: 200 }, + )) as typeof fetch; + + await expect(refreshKimiToken("old-refresh")).rejects.toThrow("missing required fields"); + }); }); describe("Kimi multiauth via saveCredential", () => {