Skip to content
Open
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
87 changes: 86 additions & 1 deletion src/credentials.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -120,6 +126,7 @@ describe("credential caching", () => {
label: "Account 1",
source: "keychain",
credentials: {
authType: "oauth",
accessToken: "token",
refreshToken: "refresh",
expiresAt: now + 10 * 60_000,
Expand Down Expand Up @@ -153,6 +160,7 @@ describe("credential caching", () => {
label: "Account 1",
source: "keychain",
credentials: {
authType: "oauth",
accessToken: "token",
refreshToken: "refresh",
expiresAt: now + 10 * 60_000,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -231,6 +240,7 @@ describe("credential caching", () => {
label: "Account 1",
source: "keychain",
credentials: {
authType: "oauth",
accessToken: "token",
refreshToken: "refresh",
expiresAt: now + 10 * 60_000,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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", () => {
Expand Down
21 changes: 15 additions & 6 deletions src/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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", {
Expand Down
Loading