diff --git a/src/credentials.test.ts b/src/credentials.test.ts index 14e4a59..274c5b1 100644 --- a/src/credentials.test.ts +++ b/src/credentials.test.ts @@ -1,7 +1,13 @@ import { describe, it } from "node:test" import assert from "node:assert/strict" import { refreshViaOAuth, parseOAuthResponse } from "./credentials.ts" -import { chmodSync, mkdirSync, statSync, writeFileSync } from "node:fs" +import { + chmodSync, + mkdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs" import { mkdtemp, readFile, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" @@ -120,6 +126,7 @@ describe("credential caching", () => { label: "Account 1", source: "keychain", credentials: { + authType: "oauth", accessToken: "token", refreshToken: "refresh", expiresAt: now + 10 * 60_000, @@ -153,6 +160,7 @@ describe("credential caching", () => { label: "Account 1", source: "keychain", credentials: { + authType: "oauth", accessToken: "token", refreshToken: "refresh", expiresAt: now + 10 * 60_000, @@ -188,6 +196,7 @@ describe("credential caching", () => { label: "Account 1", source: "keychain", credentials: { + authType: "oauth", accessToken: "old-token", refreshToken: "old-refresh", expiresAt: now + 30_000, // expires in 30s, below 60s threshold @@ -231,6 +240,7 @@ describe("credential caching", () => { label: "Account 1", source: "keychain", credentials: { + authType: "oauth", accessToken: "token", refreshToken: "refresh", expiresAt: now + 10 * 60_000, @@ -312,6 +322,7 @@ export function buildAccountLabels(creds) { return creds.map((_, i) => \`Account const mod = await import(pathToFileURL(tempCredentials).href) mod.syncAuthJson({ + authType: "oauth", accessToken: "tok", refreshToken: "ref", expiresAt: Date.now() + 600_000, @@ -396,6 +407,7 @@ export function buildAccountLabels(creds) { return creds.map((_, i) => \`Account const mod = await import(pathToFileURL(tempCredentials).href) mod.syncAuthJson({ + authType: "oauth", accessToken: "tok", refreshToken: "ref", expiresAt: Date.now() + 600_000, @@ -416,6 +428,79 @@ export function buildAccountLabels(creds) { return creds.map((_, i) => \`Account } } }) + + it("writes API key credentials using auth.json api shape", async () => { + const originalHome = process.env.HOME + const tempHome = await mkdtemp( + join(tmpdir(), "opencode-claude-auth-api-sync-"), + ) + process.env.HOME = tempHome + + try { + const tempDir = await mkdtemp( + join(tmpdir(), "opencode-claude-auth-sync-api-"), + ) + const tempCredentials = join(tempDir, "credentials.ts") + const tempKeychain = join(tempDir, "keychain.ts") + const tempBetas = join(tempDir, "betas.ts") + const tempLogger = join(tempDir, "logger.ts") + const sourceCredentials = await readFile( + new URL("./credentials.ts", import.meta.url), + "utf8", + ) + const rewritten = sourceCredentials.replace( + /from\s+["']\.\/(\w+)\.js["']/g, + 'from "./$1.ts"', + ) + + await writeFile( + tempKeychain, + `export function readAllClaudeAccounts() { return [] } +export function refreshAccount() { return null } +export function writeBackCredentials() { return true } +export function buildAccountLabels(creds) { return creds.map((_, i) => \`Account \${i + 1}\`) }`, + "utf8", + ) + await writeFile( + tempBetas, + `export function resetExcludedBetas() {}\n`, + "utf8", + ) + await writeFile( + tempLogger, + `export function log() {}\nexport function initLogger() {}\nexport function closeLogger() {}\n`, + "utf8", + ) + await writeFile(tempCredentials, rewritten, "utf8") + + const mod = await import(pathToFileURL(tempCredentials).href) + mod.syncAuthJson({ + authType: "api", + accessToken: "sk-ant-api03-test", + refreshToken: "", + expiresAt: Number.MAX_SAFE_INTEGER, + }) + + const authPath = join( + tempHome, + ".local", + "share", + "opencode", + "auth.json", + ) + const written = JSON.parse(readFileSync(authPath, "utf-8")) + assert.deepEqual(written.anthropic, { + type: "api", + key: "sk-ant-api03-test", + }) + } finally { + if (typeof originalHome === "string") { + process.env.HOME = originalHome + } else { + delete process.env.HOME + } + } + }) }) describe("refreshViaOAuth", () => { diff --git a/src/credentials.ts b/src/credentials.ts index 31f8305..e5780ff 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -118,12 +118,19 @@ function syncToPath(authPath: string, creds: ClaudeCredentials): void { } } } - auth.anthropic = { - type: "oauth", - access: creds.accessToken, - refresh: creds.refreshToken, - expires: creds.expiresAt, - } + // ClaudeCredentials.authType maps to auth.json's anthropic.type field. + auth.anthropic = + creds.authType === "api" + ? { + type: "api", + key: creds.accessToken, + } + : { + type: "oauth", + access: creds.accessToken, + refresh: creds.refreshToken, + expires: creds.expiresAt, + } const dir = dirname(authPath) if (!existsSync(dir)) { mkdirSync(dir, { recursive: true, mode: 0o700 }) @@ -181,6 +188,7 @@ export function parseOAuthResponse( if (!data.access_token) return null return { + authType: "oauth", accessToken: data.access_token, refreshToken: data.refresh_token ?? currentRefreshToken, expiresAt: now + (data.expires_in ?? 36_000) * 1000, @@ -274,6 +282,7 @@ export function refreshIfNeeded( if (!target) return null const creds = target.credentials + if (creds.authType === "api") return creds if (creds.expiresAt > Date.now() + 60_000) return creds log("refresh_needed", { diff --git a/src/keychain.test.ts b/src/keychain.test.ts index 5a05cf6..5f7b89f 100644 --- a/src/keychain.test.ts +++ b/src/keychain.test.ts @@ -6,87 +6,20 @@ import { join } from "node:path" import { tmpdir } from "node:os" import { buildAccountLabels, + extractServicesFromDump, + 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" - const services: string[] = [] - const seen = new Set() - - const re = /"Claude Code-credentials(?:-[0-9a-f]+)?"/g - let m = re.exec(output) - while (m !== null) { - const svc = m[0].slice(1, -1) - if (!seen.has(svc)) { - seen.add(svc) - services.push(svc) - } - m = re.exec(output) - } - - const ordered: string[] = [] - if (seen.has(PRIMARY)) ordered.push(PRIMARY) - for (const svc of services) { - if (svc !== PRIMARY) ordered.push(svc) - } - return ordered -} - function readCredentialsFile(credPath: string): { + authType: "api" | "oauth" accessToken: string refreshToken: string expiresAt: number + subscriptionType?: string } | null { try { const raw = readFileSync(credPath, "utf-8") @@ -110,6 +43,7 @@ describe("parseCredentials", () => { }) const result = parseCredentials(raw) assert.ok(result) + assert.equal(result.authType, "oauth") assert.equal(result.accessToken, "at-123") assert.equal(result.refreshToken, "rt-456") assert.equal(result.expiresAt, 1700000000000) @@ -124,6 +58,7 @@ describe("parseCredentials", () => { }) const result = parseCredentials(raw) assert.ok(result) + assert.equal(result.authType, "oauth") assert.equal(result.accessToken, "at-789") assert.equal(result.refreshToken, "rt-012") assert.equal(result.expiresAt, 1700000000000) @@ -137,9 +72,20 @@ describe("parseCredentials", () => { }) const result = parseCredentials(raw) assert.ok(result) + assert.equal(result.authType, "oauth") assert.equal(result.subscriptionType, undefined) }) + it("parses managed api keys from the current Claude Code entry", () => { + const result = parseCredentials("sk-ant-api03-example") + assert.deepEqual(result, { + authType: "api", + accessToken: "sk-ant-api03-example", + refreshToken: "", + expiresAt: Number.MAX_SAFE_INTEGER, + }) + }) + it("returns null for MCP-only entries", () => { const raw = JSON.stringify({ mcpOAuth: { @@ -215,28 +161,35 @@ attributes: keychain: "/Users/test/Library/Keychains/login.keychain-db" version: 512 class: "genp" +attributes: + 0x00000007 ="Claude Code" + "svce"="Claude Code" +keychain: "/Users/test/Library/Keychains/login.keychain-db" +version: 512 +class: "genp" attributes: 0x00000007 ="Claude Code-credentials" "svce"="Claude Code-credentials" ` - it("discovers all Claude Code-credentials* services", () => { + it("discovers both current and legacy Claude Code services", () => { const services = extractServicesFromDump(SAMPLE_DUMP) + assert.ok(services.includes("Claude Code")) assert.ok(services.includes("Claude Code-credentials")) assert.ok(services.includes("Claude Code-credentials-e8dc196c")) assert.ok(services.includes("Claude Code-credentials-b28bbb7c")) - assert.equal(services.length, 3) + assert.equal(services.length, 4) }) - it("puts the primary service first", () => { - assert.equal( - extractServicesFromDump(SAMPLE_DUMP)[0], - "Claude Code-credentials", - ) + it("puts the current and legacy primary services first", () => { + const services = extractServicesFromDump(SAMPLE_DUMP) + assert.equal(services[0], "Claude Code") + assert.equal(services[1], "Claude Code-credentials") }) it("deduplicates entries that appear twice (svce and blob line)", () => { const services = extractServicesFromDump(SAMPLE_DUMP) + assert.equal(services.filter((s) => s === "Claude Code").length, 1) assert.equal( services.filter((s) => s === "Claude Code-credentials").length, 1, @@ -251,9 +204,13 @@ attributes: const dump = ` 0x00000007 ="Some Other Service" "svce"="Some Other Service" + 0x00000007 ="Claude Code" 0x00000007 ="Claude Code-credentials" ` - assert.deepEqual(extractServicesFromDump(dump), ["Claude Code-credentials"]) + assert.deepEqual(extractServicesFromDump(dump), [ + "Claude Code", + "Claude Code-credentials", + ]) }) it("returns empty array for a dump with no Claude Code entries", () => { @@ -281,11 +238,13 @@ attributes: it("handles a dump where primary service appears after suffixed ones", () => { const dump = ` "svce"="Claude Code-credentials-b28bbb7c" + "svce"="Claude Code" "svce"="Claude Code-credentials" ` const services = extractServicesFromDump(dump) - assert.equal(services[0], "Claude Code-credentials") - assert.equal(services[1], "Claude Code-credentials-b28bbb7c") + assert.equal(services[0], "Claude Code") + assert.equal(services[1], "Claude Code-credentials") + assert.equal(services[2], "Claude Code-credentials-b28bbb7c") }) it("handles all five real-world suffixes from a populated keychain", () => { @@ -305,11 +264,13 @@ attributes: const makeAccountCreds = ( sub?: string, ): { + authType: "oauth" accessToken: string refreshToken: string expiresAt: number subscriptionType?: string } => ({ + authType: "oauth", accessToken: "at", refreshToken: "rt", expiresAt: 9999999999999, @@ -391,6 +352,7 @@ describe("credentials file fallback", () => { ) const result = readCredentialsFile(credPath) assert.deepEqual(result, { + authType: "oauth", accessToken: "file-at", refreshToken: "file-rt", expiresAt: 1700000000000, @@ -522,6 +484,7 @@ describe("writeBackCredentials (file source)", () => { ) const result = writeBackCredentials("file", { + authType: "oauth", accessToken: "new-at", refreshToken: "new-rt", expiresAt: 2000, @@ -568,6 +531,7 @@ describe("writeBackCredentials (file source)", () => { chmodSync(credPath, 0o644) writeBackCredentials("file", { + authType: "oauth", accessToken: "new-at", refreshToken: "new-rt", expiresAt: 2000, @@ -594,6 +558,7 @@ describe("writeBackCredentials (file source)", () => { try { const result = writeBackCredentials("file", { + authType: "oauth", accessToken: "at", refreshToken: "rt", expiresAt: 1000, @@ -622,6 +587,7 @@ describe("writeBackCredentials (file source)", () => { writeFileSync(join(claudeDir, ".credentials.json"), "not json {") const result = writeBackCredentials("file", { + authType: "oauth", accessToken: "at", refreshToken: "rt", expiresAt: 1000, @@ -636,4 +602,36 @@ describe("writeBackCredentials (file source)", () => { rmSync(tempHome, { recursive: true, force: true }) } }) + + it("returns false without rewriting API key entries", async () => { + const originalHome = process.env.HOME + const tempHome = await mkdtemp( + join(tmpdir(), "opencode-claude-auth-wb-api-"), + ) + process.env.HOME = tempHome + + try { + const claudeDir = join(tempHome, ".claude") + mkdirSync(claudeDir, { recursive: true }) + const credPath = join(claudeDir, ".credentials.json") + writeFileSync(credPath, "sk-ant-api03-existing", "utf-8") + + const result = writeBackCredentials("file", { + authType: "api", + accessToken: "sk-ant-api03-existing", + refreshToken: "", + expiresAt: Number.MAX_SAFE_INTEGER, + }) + + assert.equal(result, false) + assert.equal(readFileSync(credPath, "utf-8"), "sk-ant-api03-existing") + } finally { + if (typeof originalHome === "string") { + process.env.HOME = originalHome + } else { + delete process.env.HOME + } + rmSync(tempHome, { recursive: true, force: true }) + } + }) }) diff --git a/src/keychain.ts b/src/keychain.ts index 9ca4bd9..2a7c9b7 100644 --- a/src/keychain.ts +++ b/src/keychain.ts @@ -5,6 +5,7 @@ import { join } from "node:path" import { log } from "./logger.ts" export interface ClaudeCredentials { + authType: "api" | "oauth" accessToken: string refreshToken: string expiresAt: number @@ -17,9 +18,27 @@ export interface ClaudeAccount { credentials: ClaudeCredentials } -const PRIMARY_SERVICE = "Claude Code-credentials" +const PRIMARY_SERVICES = ["Claude Code", "Claude Code-credentials"] as const +const PRIMARY_SERVICE_SET: ReadonlySet = new Set(PRIMARY_SERVICES) +const SERVICE_PATTERNS = [ + /"Claude Code"/g, + /"Claude Code-credentials(?:-[0-9a-f]+)?"/g, +] + +export function parseCredentials(raw: string): ClaudeCredentials | null { + // Claude Code currently stores managed API keys as raw sk-ant-* entries rather + // than JSON blobs, so treat that prefix as the API-key discriminator. + if (raw.startsWith("sk-ant-")) { + return { + authType: "api", + accessToken: raw, + refreshToken: "", + // API keys do not expire on the OAuth schedule, so keep them eligible for + // sync/usage without forcing refresh logic down the token path. + expiresAt: Number.MAX_SAFE_INTEGER, + } + } -function parseCredentials(raw: string): ClaudeCredentials | null { let parsed: unknown try { parsed = JSON.parse(raw) @@ -63,6 +82,7 @@ function parseCredentials(raw: string): ClaudeCredentials | null { }) return { + authType: "oauth", accessToken: creds.accessToken, refreshToken: creds.refreshToken, expiresAt: creds.expiresAt, @@ -135,36 +155,44 @@ function readKeychainService(serviceName: string): string | null { } } -function listClaudeKeychainServices(): string[] { - try { - const dump = execSync("security dump-keychain", { - timeout: 5000, - encoding: "utf-8", - }) - - const services: string[] = [] - const seen = new Set() +export function extractServicesFromDump(dump: string): string[] { + const services: string[] = [] + const seen = new Set() - const re = /"Claude Code-credentials(?:-[0-9a-f]+)?"/g - let m = re.exec(dump) + for (const pattern of SERVICE_PATTERNS) { + let m = pattern.exec(dump) while (m !== null) { const svc = m[0].slice(1, -1) if (!seen.has(svc)) { seen.add(svc) services.push(svc) } - m = re.exec(dump) + m = pattern.exec(dump) } + } - const ordered: string[] = [] - if (seen.has(PRIMARY_SERVICE)) ordered.push(PRIMARY_SERVICE) - for (const svc of services) { - if (svc !== PRIMARY_SERVICE) ordered.push(svc) - } + const ordered: string[] = [] + for (const primaryService of PRIMARY_SERVICES) { + if (seen.has(primaryService)) ordered.push(primaryService) + } + for (const svc of services) { + if (!PRIMARY_SERVICE_SET.has(svc)) ordered.push(svc) + } + return ordered +} + +function listClaudeKeychainServices(): string[] { + try { + const dump = execSync("security dump-keychain", { + timeout: 5000, + encoding: "utf-8", + }) + + const ordered = extractServicesFromDump(dump) log("keychain_list", { servicesFound: ordered }) return ordered } catch { - return [PRIMARY_SERVICE] + return [...PRIMARY_SERVICES] } } @@ -282,6 +310,8 @@ export function writeBackCredentials( source: string, creds: ClaudeCredentials, ): boolean { + if (creds.authType === "api") return false + const newCreds = { accessToken: creds.accessToken, refreshToken: creds.refreshToken,