Skip to content
Closed
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
103 changes: 103 additions & 0 deletions src/keychain.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import assert from "node:assert/strict"
import { createHash } from "node:crypto"
import { mkdtemp, readFile, writeFile } from "node:fs/promises"
import { homedir, tmpdir } from "node:os"
import { join } from "node:path"
import { describe, it } from "node:test"
import { pathToFileURL } from "node:url"

// We test readCredentialsFile indirectly by manipulating the file it reads.
// Since readClaudeCredentials on non-darwin falls back to file reading,
Expand Down Expand Up @@ -59,6 +64,75 @@ describe("credential file parsing", () => {
})
})

describe("config dir resolution", () => {
it("uses CLAUDE_CONFIG_DIR for credentials file path", async () => {
const dir = join(tmpdir(), "claude-custom")
const prev = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = dir

try {
const mod = await loadKeychainModule()
assert.equal(mod.getCredentialsFilePath(), join(dir, ".credentials.json"))
} finally {
resetConfigEnv(prev)
}
})

it("treats empty CLAUDE_CONFIG_DIR as unset for credentials file path", async () => {
const prev = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = ""

try {
const mod = await loadKeychainModule()
assert.equal(mod.getCredentialsFilePath(), join(homedir(), ".claude", ".credentials.json"))
} finally {
resetConfigEnv(prev)
}
})

it("derives a hashed keychain service name for custom CLAUDE_CONFIG_DIR", async () => {
const dir = join(tmpdir(), "claude-custom")
const prev = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = dir

try {
const mod = await loadKeychainModule()
const hash = createHash("sha256").update(dir).digest("hex").slice(0, 8)
assert.equal(mod.getClaudeCredentialServiceName(), `Claude Code-credentials-${hash}`)
} finally {
resetConfigEnv(prev)
}
})

it("normalizes CLAUDE_CONFIG_DIR before hashing the keychain service name", async () => {
const decomposedDir = join(tmpdir(), "cafe\u0301")
const normalizedDir = decomposedDir.normalize("NFC")
const prev = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = decomposedDir

try {
const mod = await loadKeychainModule()
const hash = createHash("sha256").update(normalizedDir).digest("hex").slice(0, 8)
assert.notEqual(decomposedDir, normalizedDir)
assert.equal(mod.getClaudeCredentialServiceName(), `Claude Code-credentials-${hash}`)
} finally {
resetConfigEnv(prev)
}
})

it("treats empty CLAUDE_CONFIG_DIR as unset for keychain service name", async () => {
const prev = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = ""

try {
const mod = await loadKeychainModule()
assert.equal(mod.getClaudeCredentialServiceName(), "Claude Code-credentials")
} finally {
resetConfigEnv(prev)
}
})
})

// Mirrors the credential extraction logic from keychain.ts readCredentialsFile
function extractCredentials(
parsed: Record<string, unknown>,
Expand Down Expand Up @@ -86,3 +160,32 @@ function extractCredentials(
expiresAt: creds.expiresAt,
}
}

async function loadKeychainModule(): Promise<{
getCredentialsFilePath: () => string
getClaudeCredentialServiceName: () => string
}> {
const dir = await mkdtemp(join(tmpdir(), "opencode-claude-auth-keychain-"))
const file = join(dir, "keychain.ts")
const src = await readFile(new URL("./keychain.ts", import.meta.url), "utf8")

await writeFile(
file,
`${src}\nexport { getCredentialsFilePath, getClaudeCredentialServiceName }\n`,
"utf8",
)

return import(pathToFileURL(file).href) as Promise<{
getCredentialsFilePath: () => string
getClaudeCredentialServiceName: () => string
}>
}

function resetConfigEnv(value: string | undefined): void {
if (typeof value === "string") {
process.env.CLAUDE_CONFIG_DIR = value
return
}

delete process.env.CLAUDE_CONFIG_DIR
}
33 changes: 30 additions & 3 deletions src/keychain.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto"
import { execSync } from "node:child_process"
import { readFileSync } from "node:fs"
import { homedir } from "node:os"
Expand All @@ -9,11 +10,37 @@ export interface ClaudeCredentials {
expiresAt: number
}

const SERVICE_NAME = "Claude Code-credentials"
const DEFAULT_CLAUDE_CONFIG_DIR = join(homedir(), ".claude")
const DEFAULT_SERVICE_NAME = "Claude Code-credentials"

function getConfigDirEnv(): string | undefined {
return process.env.CLAUDE_CONFIG_DIR || undefined
}

function getClaudeConfigDir(): string {
return (getConfigDirEnv() ?? DEFAULT_CLAUDE_CONFIG_DIR).normalize("NFC")
}

function getCredentialsFilePath(): string {
return join(getClaudeConfigDir(), ".credentials.json")
}

function getClaudeCredentialServiceName(): string {
if (!getConfigDirEnv()) {
return DEFAULT_SERVICE_NAME
}

const suffix = createHash("sha256")
.update(getClaudeConfigDir())
.digest("hex")
.slice(0, 8)

return `${DEFAULT_SERVICE_NAME}-${suffix}`
}

function readCredentialsFile(): ClaudeCredentials | null {
try {
const credPath = join(homedir(), ".claude", ".credentials.json")
const credPath = getCredentialsFilePath()
const raw = readFileSync(credPath, "utf-8")
const parsed = JSON.parse(raw) as {
claudeAiOauth?: Record<string, unknown>
Expand Down Expand Up @@ -50,7 +77,7 @@ export function readClaudeCredentials(): ClaudeCredentials | null {

let raw: string
try {
raw = execSync(`security find-generic-password -s "${SERVICE_NAME}" -w`, {
raw = execSync(`security find-generic-password -s "${getClaudeCredentialServiceName()}" -w`, {
timeout: 2000,
encoding: "utf-8",
}).trim()
Expand Down