Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}

Expand Down
63 changes: 17 additions & 46 deletions src/keychain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/keychain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading