From 72477edcb99eb51426257f2f1e173c29324f243e Mon Sep 17 00:00:00 2001 From: Phan Vu Date: Fri, 24 Jul 2026 16:45:23 +0700 Subject: [PATCH 1/2] fix: normalize fractional OAuth expiry --- src/credentials.test.ts | 14 ++++++++++++++ src/credentials.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/credentials.test.ts b/src/credentials.test.ts index 514f033..2435d27 100644 --- a/src/credentials.test.ts +++ b/src/credentials.test.ts @@ -548,6 +548,20 @@ describe("parseOAuthResponse", () => { assert.equal(result.expiresAt, now + 28800 * 1000) }) + it("truncates fractional expires_in to integer milliseconds", () => { + const expiresIn = 28_800.000_901_1 + const raw = JSON.stringify({ + access_token: "sk-ant-oat01-new", + expires_in: expiresIn, + }) + + const result = parseOAuthResponse(raw, currentRefresh, now) + + assert.ok(result) + assert.equal(result.expiresAt, Math.trunc(now + expiresIn * 1000)) + assert.equal(Number.isInteger(result.expiresAt), true) + }) + it("returns null when access_token is missing", () => { const raw = JSON.stringify({ refresh_token: "rt", expires_in: 3600 }) assert.equal(parseOAuthResponse(raw, currentRefresh, now), null) diff --git a/src/credentials.ts b/src/credentials.ts index 00881d5..c1af160 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -183,7 +183,7 @@ export function parseOAuthResponse( return { accessToken: data.access_token, refreshToken: data.refresh_token ?? currentRefreshToken, - expiresAt: now + (data.expires_in ?? 36_000) * 1000, + expiresAt: Math.trunc(now + (data.expires_in ?? 36_000) * 1000), } } From 6b22edc312c7ec11a08af989d0ffd2cb1ce240ce Mon Sep 17 00:00:00 2001 From: Phan Vu Date: Fri, 24 Jul 2026 16:52:21 +0700 Subject: [PATCH 2/2] fix: normalize stored credential expiry --- src/keychain.test.ts | 63 ++++++++++++-------------------------------- src/keychain.ts | 4 +-- 2 files changed, 19 insertions(+), 48 deletions(-) diff --git a/src/keychain.test.ts b/src/keychain.test.ts index 5a05cf6..c0d9d00 100644 --- a/src/keychain.test.ts +++ b/src/keychain.test.ts @@ -6,58 +6,13 @@ import { join } from "node:path" import { tmpdir } from "node:os" import { buildAccountLabels, + parseCredentials, updateCredentialBlob, writeBackCredentials, } from "./keychain.ts" import { chmodSync, statSync } from "node:fs" import { mkdtemp } from "node:fs/promises" -// Mirrors the parseCredentials logic from keychain.ts for unit testing -function parseCredentials(raw: string): { - accessToken: string - refreshToken: string - expiresAt: number - subscriptionType?: string -} | null { - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch { - return null - } - - const data = (parsed as { claudeAiOauth?: unknown }).claudeAiOauth ?? parsed - const creds = data as { - accessToken?: unknown - refreshToken?: unknown - expiresAt?: unknown - subscriptionType?: unknown - mcpOAuth?: unknown - } - - if ((parsed as { mcpOAuth?: unknown }).mcpOAuth && !creds.accessToken) { - return null - } - - if ( - typeof creds.accessToken !== "string" || - typeof creds.refreshToken !== "string" || - typeof creds.expiresAt !== "number" - ) { - return null - } - - return { - accessToken: creds.accessToken, - refreshToken: creds.refreshToken, - expiresAt: creds.expiresAt, - subscriptionType: - typeof creds.subscriptionType === "string" - ? creds.subscriptionType - : undefined, - } -} - // Mirrors listClaudeKeychainServices regex logic for unit testing function extractServicesFromDump(output: string): string[] { const PRIMARY = "Claude Code-credentials" @@ -129,6 +84,22 @@ describe("parseCredentials", () => { assert.equal(result.expiresAt, 1700000000000) }) + it("truncates a fractional stored expiresAt", () => { + const raw = JSON.stringify({ + claudeAiOauth: { + accessToken: "at-123", + refreshToken: "rt-456", + expiresAt: 1784891051785.9011, + }, + }) + + const result = parseCredentials(raw) + + assert.ok(result) + assert.equal(result.expiresAt, 1784891051785) + assert.equal(Number.isInteger(result.expiresAt), true) + }) + it("subscriptionType is undefined when not present", () => { const raw = JSON.stringify({ accessToken: "at", diff --git a/src/keychain.ts b/src/keychain.ts index fb3fa18..f49a216 100644 --- a/src/keychain.ts +++ b/src/keychain.ts @@ -19,7 +19,7 @@ export interface ClaudeAccount { const PRIMARY_SERVICE = "Claude Code-credentials" -function parseCredentials(raw: string): ClaudeCredentials | null { +export function parseCredentials(raw: string): ClaudeCredentials | null { let parsed: unknown try { parsed = JSON.parse(raw) @@ -65,7 +65,7 @@ function parseCredentials(raw: string): ClaudeCredentials | null { return { accessToken: creds.accessToken, refreshToken: creds.refreshToken, - expiresAt: creds.expiresAt, + expiresAt: Math.trunc(creds.expiresAt), subscriptionType: typeof creds.subscriptionType === "string" ? creds.subscriptionType