diff --git a/README.md b/README.md index e57f723..a3ab6d3 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,16 @@ Self-contained Anthropic auth provider for OpenCode using your Claude Code crede The plugin registers its own auth provider with a custom fetch handler that intercepts all Anthropic API requests. It reads OAuth tokens from the macOS Keychain (or `~/.claude/.credentials.json` on other platforms), caches them in memory with a 30-second TTL, and handles the full request lifecycle — no builtin Anthropic auth plugin required. On macOS, multiple Claude Code accounts are detected automatically and can be switched via `opencode auth login`. -It also syncs credentials to OpenCode's `auth.json` as a fallback (on Windows, it writes to both `%USERPROFILE%\.local\share\opencode\auth.json` and `%LOCALAPPDATA%\opencode\auth.json` to cover all installation methods). If a token is near expiry, it refreshes directly via Anthropic's OAuth endpoint (zero LLM tokens consumed), falling back to the Claude CLI if the direct refresh fails. Background re-sync runs every 5 minutes. +It also syncs credentials to OpenCode's `auth.json` as a fallback (on Windows, it writes to `%USERPROFILE%\.local\share\opencode\auth.json`, `%LOCALAPPDATA%\opencode\auth.json`, and `%APPDATA%\opencode\auth.json` to cover all installation methods — OpenCode itself reads from the Roaming `%APPDATA%` location). If a token is near expiry, it refreshes directly via Anthropic's OAuth endpoint (zero LLM tokens consumed), falling back to the Claude CLI if the direct refresh fails. Background re-sync runs every 5 minutes. + +On Windows, when Claude Code is launched from Claude Desktop, `claude auth login` never writes credentials to disk because Claude Desktop sets `CLAUDE_CODE_OAUTH_TOKEN` in the child environment and the CLI considers the session already authenticated. The plugin detects this case and reads the token directly from the environment as a last-resort credentials source. ## Prerequisites - Claude Code installed and authenticated (run `claude` at least once) - OpenCode installed -macOS is preferred (uses Keychain). Linux and Windows work via the credentials file fallback. +macOS is preferred (uses Keychain). Linux and Windows work via the credentials file fallback. On Windows, if Claude Code is launched from Claude Desktop, the plugin can also fall back to the `CLAUDE_CODE_OAUTH_TOKEN` env var that Claude Desktop sets for child processes. ## Installation @@ -180,14 +182,15 @@ This reads your stored credentials, calls Anthropic's OAuth token endpoint, and All configurable parameters can be overridden via environment variables. If Anthropic changes something before we publish an update, set an env var and keep working: -| Variable | Description | Default | -| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `ANTHROPIC_CLI_VERSION` | Claude CLI version for user-agent and billing headers | `2.1.80` | -| `ANTHROPIC_USER_AGENT` | Full User-Agent string (overrides CLI version) | `claude-cli/{version} (external, cli)` | -| `ANTHROPIC_BETA_FLAGS` | Comma-separated beta feature flags | `claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,prompt-caching-scope-2026-01-05` | -| `ANTHROPIC_ENABLE_1M_CONTEXT` | Enable 1M token context window for 4.6+ models (requires Max subscription) | `false` | -| `CLAUDE_AUTH_DEBUG` | Enable diagnostic logging (`1` for default path, or a custom file path) | disabled | -| `OPENCODE_CLAUDE_AUTH_MAX_RETRY_MS` | Max ms the plugin waits when honouring a 429/529 `retry-after` header. Beyond this cap the response surfaces immediately so OpenCode doesn't appear to hang on hour-long quota resets. | `30000` | +| Variable | Description | Default | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `ANTHROPIC_CLI_VERSION` | Claude CLI version for user-agent and billing headers | `2.1.80` | +| `ANTHROPIC_USER_AGENT` | Full User-Agent string (overrides CLI version) | `claude-cli/{version} (external, cli)` | +| `ANTHROPIC_BETA_FLAGS` | Comma-separated beta feature flags | `claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,prompt-caching-scope-2026-01-05` | +| `ANTHROPIC_ENABLE_1M_CONTEXT` | Enable 1M token context window for 4.6+ models (requires Max subscription) | `false` | +| `CLAUDE_AUTH_DEBUG` | Enable diagnostic logging (`1` for default path, or a custom file path) | disabled | +| `OPENCODE_CLAUDE_AUTH_MAX_RETRY_MS` | Max ms the plugin waits when honouring a 429/529 `retry-after` header. Beyond this cap the response surfaces immediately so OpenCode doesn't appear to hang on hour-long quota resets. | `30000` | +| `CLAUDE_CODE_OAUTH_TOKEN` | Read-only fallback credential source on Windows when Claude Code is launched from Claude Desktop. The plugin reads this if `~/.claude/.credentials.json` is absent. Set automatically by Claude Desktop; not intended for manual use. | unset | Example: @@ -207,7 +210,8 @@ export ANTHROPIC_ENABLE_1M_CONTEXT=true # requires Claude Max - On macOS, enumerates all `Claude Code-credentials*` Keychain entries and labels them by subscription tier - Provides an account switcher via `opencode auth login` when multiple accounts are found; persists selection to `~/.local/share/opencode/claude-account-source.txt` - Syncs credentials to `auth.json` on startup and every 5 minutes as a fallback (sync never triggers refresh; refresh is lazy, only on API requests) -- On Windows, writes to both `%USERPROFILE%\.local\share\opencode\auth.json` and `%LOCALAPPDATA%\opencode\auth.json` +- On Windows, writes to `%USERPROFILE%\.local\share\opencode\auth.json`, `%LOCALAPPDATA%\opencode\auth.json`, and `%APPDATA%\opencode\auth.json` (OpenCode reads from the Roaming `%APPDATA%` path) +- On Windows, if Claude Code was launched from Claude Desktop and no on-disk credentials file exists, falls back to reading the OAuth token from `CLAUDE_CODE_OAUTH_TOKEN`. This source has no refresh token; expiry is decoded from the JWT `exp` claim (defaulting to ~10 hours if decode fails). When this token expires, the plugin returns null immediately rather than invoking the CLI fallback (which cannot help) - Retries API requests on 429 (rate limit) and 529 (overloaded) with exponential backoff, respecting `retry-after` headers - When a token is within 60 seconds of expiry, refreshes directly via `POST https://claude.ai/v1/oauth/token` (no LLM tokens consumed). Falls back to `claude` CLI if the direct refresh fails. New tokens are written back to Keychain (macOS) or credentials file (Linux/Windows) to keep stored credentials in sync with rotated refresh tokens - If credentials aren't OAuth-based, the auth loader returns `{}` and falls through to API key auth diff --git a/src/credentials.test.ts b/src/credentials.test.ts index 14e4a59..de02d10 100644 --- a/src/credentials.test.ts +++ b/src/credentials.test.ts @@ -1,10 +1,22 @@ 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 { + getAuthJsonPaths, + parseOAuthResponse, + refreshViaOAuth, + syncAuthJson, +} from "./credentials.ts" +import { + chmodSync, + existsSync, + mkdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs" import { mkdtemp, readFile, writeFile } from "node:fs/promises" -import { tmpdir } from "node:os" -import { join } from "node:path" +import { homedir, tmpdir } from "node:os" +import { dirname, join } from "node:path" import { pathToFileURL } from "node:url" async function loadCredentialsWithCountingKeychain( @@ -477,3 +489,344 @@ describe("parseOAuthResponse", () => { assert.equal(parseOAuthResponse("", currentRefresh, now), null) }) }) + +function withPlatform(value: NodeJS.Platform, fn: () => void): void { + const original = process.platform + Object.defineProperty(process, "platform", { + value, + configurable: true, + }) + try { + fn() + } finally { + Object.defineProperty(process, "platform", { + value: original, + configurable: true, + }) + } +} + +function withEnvVars( + vars: Record, + fn: () => void, +): void { + const originals: Record = {} + for (const key of Object.keys(vars)) { + originals[key] = process.env[key] + const v = vars[key] + if (v === undefined) delete process.env[key] + else process.env[key] = v + } + try { + fn() + } finally { + for (const key of Object.keys(originals)) { + const orig = originals[key] + if (typeof orig === "string") process.env[key] = orig + else delete process.env[key] + } + } +} + +describe("getAuthJsonPaths", () => { + const xdgPath = join(homedir(), ".local", "share", "opencode", "auth.json") + + it("returns only the XDG path on non-Windows platforms", () => { + withPlatform("darwin", () => { + assert.deepEqual(getAuthJsonPaths(), [xdgPath]) + }) + withPlatform("linux", () => { + assert.deepEqual(getAuthJsonPaths(), [xdgPath]) + }) + }) + + it("returns XDG + Local + Roaming AppData paths on Windows when both env vars are set", () => { + withPlatform("win32", () => { + withEnvVars( + { + LOCALAPPDATA: "C:\\Users\\test\\AppData\\Local", + APPDATA: "C:\\Users\\test\\AppData\\Roaming", + }, + () => { + const paths = getAuthJsonPaths() + assert.equal(paths.length, 3) + assert.equal(paths[0], xdgPath) + assert.equal( + paths[1], + join("C:\\Users\\test\\AppData\\Local", "opencode", "auth.json"), + ) + assert.equal( + paths[2], + join("C:\\Users\\test\\AppData\\Roaming", "opencode", "auth.json"), + ) + }, + ) + }) + }) + + it("includes both Local and Roaming Windows paths (regression: Roaming was previously missing)", () => { + // Bug 2 from PR #200: OpenCode reads from %APPDATA% (Roaming), but the + // plugin previously only wrote to %LOCALAPPDATA%. Both must be present. + withPlatform("win32", () => { + withEnvVars( + { + LOCALAPPDATA: "C:\\local", + APPDATA: "C:\\roaming", + }, + () => { + const paths = getAuthJsonPaths() + const hasLocal = paths.some((p) => p.startsWith("C:\\local")) + const hasRoaming = paths.some((p) => p.startsWith("C:\\roaming")) + assert.ok(hasLocal, "should include %LOCALAPPDATA% path") + assert.ok(hasRoaming, "should include %APPDATA% (Roaming) path") + }, + ) + }) + }) + + it("falls back to homedir-derived AppData paths when env vars are unset on Windows", () => { + withPlatform("win32", () => { + withEnvVars({ LOCALAPPDATA: undefined, APPDATA: undefined }, () => { + const paths = getAuthJsonPaths() + assert.equal(paths.length, 3) + assert.equal(paths[0], xdgPath) + assert.equal( + paths[1], + join(homedir(), "AppData", "Local", "opencode", "auth.json"), + ) + assert.equal( + paths[2], + join(homedir(), "AppData", "Roaming", "opencode", "auth.json"), + ) + }) + }) + }) +}) + +describe("syncAuthJson error handling", () => { + it("does not throw when a target path is unwritable (continues to next path)", async () => { + // Force the XDG path to be unwritable by pre-creating a *directory* + // at the target file path. writeFileSync then fails with EISDIR. + // The contract is: syncAuthJson logs the failure and continues so a + // single bad sync target cannot tear down plugin init or the 5-min + // sync timer (relevant on Windows where there are now three target + // paths and any one of them might be locked by AV / on a network + // share / etc.). + const originalHome = process.env.HOME + const tempHome = await mkdtemp( + join(tmpdir(), "opencode-claude-auth-syncerr-"), + ) + process.env.HOME = tempHome + + try { + const xdgPath = join(tempHome, ".local", "share", "opencode", "auth.json") + mkdirSync(dirname(xdgPath), { recursive: true }) + // Pre-create a directory at the target file path. Any subsequent + // writeFileSync(authPath, ...) will fail with EISDIR. + mkdirSync(xdgPath, { recursive: true }) + + assert.doesNotThrow(() => { + syncAuthJson({ + accessToken: "tok", + refreshToken: "ref", + expiresAt: Date.now() + 600_000, + }) + }) + + // Sanity-check the directory is still a directory (i.e. write + // really did fail and was not silently allowed through). + assert.ok( + existsSync(xdgPath) && statSync(xdgPath).isDirectory(), + "target path should still be a directory; write should have failed", + ) + } finally { + if (typeof originalHome === "string") { + process.env.HOME = originalHome + } else { + delete process.env.HOME + } + rmSync(tempHome, { recursive: true, force: true }) + } + }) +}) + +async function loadCredentialsWithCountingEnvKeychain( + envCreds: { + accessToken: string + refreshToken: string + expiresAt: number + } | null, +): Promise<{ + credentialsModule: { + refreshIfNeeded: (account: { + label: string + source: string + credentials: { + accessToken: string + refreshToken: string + expiresAt: number + } + }) => { + accessToken: string + refreshToken: string + expiresAt: number + } | null + } + keychainModule: { __getRefreshCount: () => number } +}> { + const tempDir = await mkdtemp(join(tmpdir(), "opencode-claude-auth-envref-")) + const tempKeychain = join(tempDir, "keychain.ts") + const tempBetas = join(tempDir, "betas.ts") + const tempLogger = join(tempDir, "logger.ts") + const tempCredentials = join(tempDir, "credentials.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( + tempLogger, + `export function log() {}\nexport function initLogger() {}\nexport function closeLogger() {}\n`, + "utf8", + ) + + // Stub keychain: refreshAccount returns the configured envCreds for "env", + // null otherwise. Counts every call so the test can assert the env + // short-circuit prevented a second call from the CLI fallback. + await writeFile( + tempKeychain, + `let refreshCount = 0 +const envCreds = ${JSON.stringify(envCreds)} +export function readAllClaudeAccounts() { return [] } +export function refreshAccount(source) { + refreshCount += 1 + if (source === "env") return envCreds + return null +} +export function writeBackCredentials() { return false } +export function buildAccountLabels(creds) { return creds.map((_, i) => \`A\${i+1}\`) } +export function __getRefreshCount() { return refreshCount } +`, + "utf8", + ) + + await writeFile( + tempBetas, + `export function resetExcludedBetas() {}\n`, + "utf8", + ) + await writeFile(tempCredentials, rewritten, "utf8") + + const [credentialsModule, keychainModule] = await Promise.all([ + import(pathToFileURL(tempCredentials).href), + import(pathToFileURL(tempKeychain).href), + ]) + + return { + credentialsModule: credentialsModule as { + refreshIfNeeded: (account: { + label: string + source: string + credentials: { + accessToken: string + refreshToken: string + expiresAt: number + } + }) => { + accessToken: string + refreshToken: string + expiresAt: number + } | null + }, + keychainModule: keychainModule as { __getRefreshCount: () => number }, + } +} + +describe("refreshIfNeeded (env source short-circuit)", () => { + it("returns null immediately when env token is also expired (skips CLI fallback)", async () => { + const now = Date.now() + // Stub refreshAccount("env") returns an already-expired credential. + const { credentialsModule, keychainModule } = + await loadCredentialsWithCountingEnvKeychain({ + accessToken: "expired-env-token", + refreshToken: "", + expiresAt: now - 60_000, // expired + }) + + const account = { + label: "Claude (env)", + source: "env", + credentials: { + accessToken: "old-env-token", + refreshToken: "", // empty: skips OAuth refresh path + expiresAt: now - 1_000, // expired -> triggers refreshIfNeeded body + }, + } + + const result = credentialsModule.refreshIfNeeded(account) + assert.equal(result, null) + + // Critical assertion: the env block calls refreshAccount exactly once. + // If the env short-circuit is removed, control falls through to the CLI + // fallback which calls refreshAccount AGAIN (line 323 in credentials.ts), + // making this count 2. So count===1 proves the short-circuit engaged + // and refreshViaCli() was skipped. + assert.equal( + keychainModule.__getRefreshCount(), + 1, + "env-source path should call refreshAccount exactly once and skip CLI fallback", + ) + }) + + it("returns refreshed credentials when env var has been rotated to a fresh token", async () => { + const now = Date.now() + const { credentialsModule, keychainModule } = + await loadCredentialsWithCountingEnvKeychain({ + accessToken: "fresh-env-token", + refreshToken: "", + expiresAt: now + 3_600_000, // 1h in the future + }) + + const account = { + label: "Claude (env)", + source: "env", + credentials: { + accessToken: "old-env-token", + refreshToken: "", + expiresAt: now - 1_000, // expired -> triggers refreshIfNeeded body + }, + } + + const result = credentialsModule.refreshIfNeeded(account) + assert.ok(result, "should return refreshed credentials") + assert.equal(result.accessToken, "fresh-env-token") + // Account credentials should have been updated in-place. + assert.equal(account.credentials.accessToken, "fresh-env-token") + assert.equal(keychainModule.__getRefreshCount(), 1) + }) + + it("returns null when CLAUDE_CODE_OAUTH_TOKEN has been unset entirely", async () => { + const now = Date.now() + const { credentialsModule, keychainModule } = + await loadCredentialsWithCountingEnvKeychain(null) + + const account = { + label: "Claude (env)", + source: "env", + credentials: { + accessToken: "old-env-token", + refreshToken: "", + expiresAt: now - 1_000, + }, + } + + assert.equal(credentialsModule.refreshIfNeeded(account), null) + // Still exactly one call — env block hits, gets null, returns null. + // Does NOT fall through to the CLI fallback that would call refreshAccount again. + assert.equal(keychainModule.__getRefreshCount(), 1) + }) +}) diff --git a/src/credentials.ts b/src/credentials.ts index 31f8305..db4cc22 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -95,13 +95,22 @@ export function saveAccountSource(source: string): void { } } -function getAuthJsonPaths(): string[] { +export function getAuthJsonPaths(): string[] { const xdgPath = join(homedir(), ".local", "share", "opencode", "auth.json") if (process.platform === "win32") { - const appData = + // Write to both Local and Roaming AppData on Windows. + // OpenCode stores its data directory in %APPDATA% (Roaming), but earlier + // versions of this plugin only wrote to %LOCALAPPDATA%. Writing to both + // ensures OpenCode's own auth picker also sees fresh credentials. + const localAppData = process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local") - const localAppDataPath = join(appData, "opencode", "auth.json") - return [xdgPath, localAppDataPath] + const roamingAppData = + process.env.APPDATA ?? join(homedir(), "AppData", "Roaming") + return [ + xdgPath, + join(localAppData, "opencode", "auth.json"), + join(roamingAppData, "opencode", "auth.json"), + ] } return [xdgPath] } @@ -138,6 +147,11 @@ function syncToPath(authPath: string, creds: ClaudeCredentials): void { } export function syncAuthJson(creds: ClaudeCredentials): void { + // Each target path is independent: a failure on one path (e.g. a locked + // %APPDATA% file on Windows) must not abort the remaining writes. Failures + // are logged per-path and otherwise swallowed so the caller (plugin init, + // 5-min timer, account-switch authorize) is not torn down by a single bad + // sync target. for (const authPath of getAuthJsonPaths()) { try { syncToPath(authPath, creds) @@ -148,7 +162,6 @@ export function syncAuthJson(creds: ClaudeCredentials): void { success: false, error: err instanceof Error ? err.message : String(err), }) - throw err } } } @@ -292,6 +305,25 @@ export function refreshIfNeeded( } } + // Env-var credentials have no refresh token and no CLI path. + // Re-read the env var in case it was rotated by the parent process (e.g. + // Claude Desktop refreshed and updated its child environment). If it has + // genuinely expired, return null immediately rather than blocking for up to + // 60 s on a CLI timeout that cannot help. + if (target.source === "env") { + log("refresh_fallback_env", { source: target.source }) + const refreshed = refreshAccount(target.source) + if (refreshed && refreshed.expiresAt > Date.now() + 60_000) { + target.credentials = refreshed + return refreshed + } + log("refresh_exhausted", { + source: target.source, + reason: "env_token_expired", + }) + return null + } + // Fall back to CLI-based refresh (consumes Haiku tokens) log("refresh_fallback_cli", { source: target.source }) refreshViaCli() diff --git a/src/index.ts b/src/index.ts index d60cfb1..79478b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -527,7 +527,9 @@ const plugin: Plugin = async () => { const sourceDescription = chosen.source === "file" ? "credentials file (~/.claude/.credentials.json)" - : "macOS Keychain" + : chosen.source === "env" + ? "CLAUDE_CODE_OAUTH_TOKEN environment variable" + : "macOS Keychain" return { url: "", diff --git a/src/keychain.test.ts b/src/keychain.test.ts index 5a05cf6..5f71bd7 100644 --- a/src/keychain.test.ts +++ b/src/keychain.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path" import { tmpdir } from "node:os" import { buildAccountLabels, + refreshAccount, updateCredentialBlob, writeBackCredentials, } from "./keychain.ts" @@ -498,6 +499,103 @@ describe("updateCredentialBlob", () => { }) }) +// Helper: build a JWT with the given payload claims. Signature is irrelevant +// — readEnvToken only decodes the payload, it does not verify. +function makeJwt(payload: Record): string { + const header = Buffer.from( + JSON.stringify({ alg: "none", typ: "JWT" }), + ).toString("base64url") + const body = Buffer.from(JSON.stringify(payload)).toString("base64url") + return `${header}.${body}.sig` +} + +function withEnvToken(value: string | undefined, fn: () => void): void { + const original = process.env.CLAUDE_CODE_OAUTH_TOKEN + if (value === undefined) { + delete process.env.CLAUDE_CODE_OAUTH_TOKEN + } else { + process.env.CLAUDE_CODE_OAUTH_TOKEN = value + } + try { + fn() + } finally { + if (typeof original === "string") { + process.env.CLAUDE_CODE_OAUTH_TOKEN = original + } else { + delete process.env.CLAUDE_CODE_OAUTH_TOKEN + } + } +} + +describe("readEnvToken (via refreshAccount('env'))", () => { + it("decodes the exp claim from a valid JWT into expiresAt (ms)", () => { + const expSeconds = Math.floor(Date.now() / 1000) + 3_600 + withEnvToken(makeJwt({ exp: expSeconds, sub: "user-1" }), () => { + const result = refreshAccount("env") + assert.ok(result, "should return credentials when env var is set") + assert.equal(result.expiresAt, expSeconds * 1000) + }) + }) + + it("returns the env var verbatim as accessToken with empty refreshToken", () => { + const token = makeJwt({ exp: Math.floor(Date.now() / 1000) + 3_600 }) + withEnvToken(token, () => { + const result = refreshAccount("env") + assert.ok(result) + assert.equal(result.accessToken, token) + assert.equal(result.refreshToken, "") + }) + }) + + it("falls back to ~10h default when JWT payload is malformed (not base64)", () => { + const before = Date.now() + withEnvToken("not.a.jwt", () => { + const result = refreshAccount("env") + assert.ok(result) + // 10h default = 36_000_000 ms; allow generous tolerance for test scheduling + const after = Date.now() + assert.ok( + result.expiresAt >= before + 36_000_000 - 5_000 && + result.expiresAt <= after + 36_000_000 + 5_000, + `expected ~10h default, got expiresAt=${result.expiresAt} relative to now`, + ) + }) + }) + + it("falls back to ~10h default when token is a single segment with no payload", () => { + const before = Date.now() + withEnvToken("opaque-token-no-dots", () => { + const result = refreshAccount("env") + assert.ok(result) + const after = Date.now() + assert.ok( + result.expiresAt >= before + 36_000_000 - 5_000 && + result.expiresAt <= after + 36_000_000 + 5_000, + ) + }) + }) + + it("falls back to ~10h default when payload JSON is missing the exp claim", () => { + const before = Date.now() + const tokenNoExp = makeJwt({ sub: "user-1" }) // no exp + withEnvToken(tokenNoExp, () => { + const result = refreshAccount("env") + assert.ok(result) + const after = Date.now() + assert.ok( + result.expiresAt >= before + 36_000_000 - 5_000 && + result.expiresAt <= after + 36_000_000 + 5_000, + ) + }) + }) + + it("returns null when CLAUDE_CODE_OAUTH_TOKEN is not set", () => { + withEnvToken(undefined, () => { + assert.equal(refreshAccount("env"), null) + }) + }) +}) + describe("writeBackCredentials (file source)", () => { it("reads, updates, and writes back credentials to file", async () => { const originalHome = process.env.HOME @@ -637,3 +735,24 @@ describe("writeBackCredentials (file source)", () => { } }) }) + +describe("writeBackCredentials (env source)", () => { + it("returns false because env-var credentials are read-only", () => { + const originalToken = process.env.CLAUDE_CODE_OAUTH_TOKEN + process.env.CLAUDE_CODE_OAUTH_TOKEN = "header.payload.sig" + try { + const result = writeBackCredentials("env", { + accessToken: "at", + refreshToken: "rt", + expiresAt: Date.now() + 600_000, + }) + assert.equal(result, false) + } finally { + if (typeof originalToken === "string") { + process.env.CLAUDE_CODE_OAUTH_TOKEN = originalToken + } else { + delete process.env.CLAUDE_CODE_OAUTH_TOKEN + } + } + }) +}) diff --git a/src/keychain.ts b/src/keychain.ts index fb3fa18..5f3446d 100644 --- a/src/keychain.ts +++ b/src/keychain.ts @@ -186,6 +186,47 @@ function readCredentialsFile(): ClaudeCredentials | null { } } +/** + * Read credentials from the CLAUDE_CODE_OAUTH_TOKEN environment variable. + * + * Claude Desktop sets this variable when it spawns Claude Code processes. When + * it is present, `claude auth login` considers the session already authenticated + * and never writes credentials to disk, so the env var is the only source + * available on those systems. + * + * The token is a JWT; we decode the `exp` claim to derive `expiresAt`. If + * decoding fails we default to 10 hours (observed Claude token lifetime). + * There is no refresh token in this path — when the token expires the caller + * must obtain a new one (e.g. by restarting Claude Desktop). + */ +function readEnvToken(): ClaudeCredentials | null { + const token = process.env.CLAUDE_CODE_OAUTH_TOKEN + if (!token) return null + + // Attempt to decode expiry from the JWT payload (second base64url segment). + let expiresAt = Date.now() + 36_000 * 1000 // default: 10 hours + try { + const payloadB64 = token.split(".")[1] + if (payloadB64) { + const payload = JSON.parse( + Buffer.from(payloadB64, "base64url").toString("utf-8"), + ) as { exp?: unknown } + if (typeof payload.exp === "number") { + expiresAt = payload.exp * 1000 + } + } + } catch (err) { + // JWT decode failed — use default expiry, but record why so failures + // aren't silent. + log("env_token_decode_failed", { + message: err instanceof Error ? err.message : String(err), + }) + } + + log("env_token_read", { success: true, expiresAt }) + return { accessToken: token, refreshToken: "", expiresAt } +} + export function buildAccountLabels(credsList: ClaudeCredentials[]): string[] { const baseLabels = credsList.map((c) => { if (c.subscriptionType) { @@ -211,9 +252,22 @@ export function buildAccountLabels(credsList: ClaudeCredentials[]): string[] { export function readAllClaudeAccounts(): ClaudeAccount[] { if (process.platform !== "darwin") { const creds = readCredentialsFile() - if (!creds) return [] - const [label] = buildAccountLabels([creds]) - return [{ label, source: "file", credentials: creds }] + if (creds) { + const [label] = buildAccountLabels([creds]) + return [{ label, source: "file", credentials: creds }] + } + + // Fallback: use CLAUDE_CODE_OAUTH_TOKEN when set (e.g. launched from Claude + // Desktop). In that environment the env var is already a valid access token + // and `claude auth login` never writes credentials to disk because it + // considers the session already authenticated. + const envCreds = readEnvToken() + if (envCreds) { + log("env_token_fallback", { reason: "credentials_file_not_found" }) + return [{ label: "Claude (env)", source: "env", credentials: envCreds }] + } + + return [] } const services = listClaudeKeychainServices() @@ -287,6 +341,9 @@ export function writeBackCredentials( source: string, creds: ClaudeCredentials, ): boolean { + // Env-var credentials are read-only; there is nowhere to write them back. + if (source === "env") return false + const newCreds = { accessToken: creds.accessToken, refreshToken: creds.refreshToken, @@ -347,9 +404,8 @@ export function writeBackCredentials( } export function refreshAccount(source: string): ClaudeCredentials | null { - if (source === "file") { - return readCredentialsFile() - } + if (source === "file") return readCredentialsFile() + if (source === "env") return readEnvToken() const raw = readKeychainService(source) if (!raw) return null return parseCredentials(raw)