diff --git a/runtimes/opencode/plugins/claude-code-auth.ts b/runtimes/opencode/plugins/claude-code-auth.ts index 3ec7474..298c486 100644 --- a/runtimes/opencode/plugins/claude-code-auth.ts +++ b/runtimes/opencode/plugins/claude-code-auth.ts @@ -15,6 +15,7 @@ type OAuthSuccess = { type: "success"; refresh: string; access: string; expires: type CallbackResult = { code: string; state: string }; type AccountRecord = OAuthStored & { addedAt: number; lastUsed: number }; type AccountStore = { version: number; activeIndex: number; accounts: AccountRecord[] }; +type RefreshFailure = { auth: OAuthStored; error: unknown }; const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; const TOKEN_URL = "https://platform.claude.com/v1/oauth/token"; @@ -23,6 +24,8 @@ const CALLBACK_PATH = "/callback"; const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`; const SCOPES = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"; const OAUTH_TIMEOUT_MS = 5 * 60 * 1000; +const REFRESH_LOCK_TIMEOUT_MS = 15_000; +const REFRESH_LOCK_STALE_MS = 2 * 60 * 1000; const CLAUDE_CODE_VERSION = "2.1.75"; const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude."; const OPENCODE_IDENTITY = "You are OpenCode, the best coding agent on the planet."; @@ -59,6 +62,10 @@ function accountsFilePath() { return path.join(homedir(), ".local", "share", "opencode", "anthropic-oauth-accounts.json"); } +function refreshLockPath() { + return `${authFilePath()}.anthropic-refresh.lock`; +} + async function readJson(filePath: string, fallback: T): Promise { try { return JSON.parse(await fs.readFile(filePath, "utf8")) as T; } catch { return fallback; } } @@ -76,6 +83,15 @@ async function writeAnthropicAuth(auth: OAuthStored) { await writeJson(file, data); } +async function readAnthropicAuth() { + const data = await readJson>(authFilePath(), {}); + const auth = data.anthropic; + if (!auth || typeof auth !== "object") return undefined; + const candidate = auth as Partial; + if (candidate.type !== "oauth" || typeof candidate.refresh !== "string" || typeof candidate.access !== "string" || typeof candidate.expires !== "number") return undefined; + return candidate as OAuthStored; +} + function normalizeAccountStore(input: Partial | null | undefined): AccountStore { const accounts = Array.isArray(input?.accounts) ? input.accounts.filter((account): account is AccountRecord => { @@ -95,6 +111,38 @@ async function saveAccountStore(store: AccountStore) { await writeJson(accountsFilePath(), normalizeAccountStore(store)); } +async function sleep(ms: number) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function withRefreshLock(action: () => Promise): Promise { + const lockPath = refreshLockPath(); + const started = Date.now(); + while (true) { + try { + await fs.mkdir(lockPath, { recursive: false }); + await fs.writeFile(path.join(lockPath, "owner"), `${process.pid}\n${new Date().toISOString()}\n`, "utf8"); + break; + } catch (error) { + const code = (error as { code?: string }).code; + if (code !== "EEXIST") throw error; + const stat = await fs.stat(lockPath).catch(() => undefined); + if (stat && Date.now() - stat.mtimeMs > REFRESH_LOCK_STALE_MS) { + await fs.rm(lockPath, { force: true, recursive: true }).catch(() => undefined); + continue; + } + if (Date.now() - started > REFRESH_LOCK_TIMEOUT_MS) throw new Error("Timed out waiting for Anthropic OAuth refresh lock"); + await sleep(100); + } + } + + try { + return await action(); + } finally { + await fs.rm(lockPath, { force: true, recursive: true }).catch(() => undefined); + } +} + function findCurrentAccountIndex(store: AccountStore, auth: OAuthStored) { if (!store.accounts.length) return 0; const byRefresh = store.accounts.findIndex((account) => account.refresh === auth.refresh); @@ -104,6 +152,25 @@ function findCurrentAccountIndex(store: AccountStore, auth: OAuthStored) { return store.activeIndex; } +function sameOAuth(a: OAuthStored | undefined, b: OAuthStored | undefined) { + return !!a && !!b && a.refresh === b.refresh && a.access === b.access; +} + +function usableAccessToken(auth: OAuthStored | undefined) { + return !!auth?.access && auth.expires > Date.now(); +} + +function dedupeOAuthCandidates(candidates: Array) { + const seen = new Set(); + return candidates.filter((candidate): candidate is OAuthStored => { + if (!candidate) return false; + const key = `${candidate.refresh}\n${candidate.access}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + function upsertAccount(store: AccountStore, auth: OAuthStored, now = Date.now()) { const index = store.accounts.findIndex((account) => account.refresh === auth.refresh || account.access === auth.access); const nextAccount: AccountRecord = { ...auth, addedAt: now, lastUsed: now }; @@ -151,6 +218,22 @@ function shouldRotateAuth(status: number, bodyText: string) { return haystack.includes("rate_limit") || haystack.includes("rate limit") || haystack.includes("usage limit") || haystack.includes("usage_limit") || haystack.includes("usage_limit_reached") || haystack.includes("usage_not_included") || haystack.includes("invalid api key") || haystack.includes("authentication_error") || haystack.includes("permission_error"); } +function refreshFailureText(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +function isInvalidGrantFailure(error: unknown) { + const haystack = refreshFailureText(error).toLowerCase(); + return haystack.includes("invalid_grant") || haystack.includes("invalid refresh") || haystack.includes("refresh token") || (haystack.includes("http 400") && haystack.includes(TOKEN_URL)); +} + +function summarizeRefreshFailures(failures: RefreshFailure[]) { + const last = failures[failures.length - 1]; + const detail = last ? refreshFailureText(last.error).replace(/sk-ant-[A-Za-z0-9_.\-]+/g, "sk-ant-[redacted]").replace(/rt\.1\.[A-Za-z0-9_.\-]+/g, "rt.1.[redacted]") : "unknown error"; + const invalid = failures.some((failure) => isInvalidGrantFailure(failure.error)); + return `Anthropic OAuth refresh failed for ${failures.length} stored credential${failures.length === 1 ? "" : "s"}${invalid ? " (invalid_grant detected)" : ""}. Re-run OpenCode auth if no other account can refresh. Last error: ${detail}`; +} + function base64urlEncode(bytes: Uint8Array): string { let binary = ""; for (const byte of bytes) binary += String.fromCharCode(byte); @@ -513,15 +596,49 @@ async function getFreshOAuth(getAuth: () => Promise Date.now()) return oauth; - const refreshed = await refreshAnthropicToken(oauth.refresh); - await writeAnthropicAuth(refreshed); - const store = await loadAccountStore(); - if (store.accounts.length > 0) { - upsertAccount(store, refreshed); - await saveAccountStore(store); + if (usableAccessToken(oauth)) return oauth; + + return withRefreshLock(async () => { + const latest = await readAnthropicAuth(); + if (usableAccessToken(latest)) return latest; + + const store = await loadAccountStore(); + const active = store.accounts[store.activeIndex]; + const candidates = dedupeOAuthCandidates([latest, oauth, active, ...store.accounts]); + const failures: RefreshFailure[] = []; + + for (const candidate of candidates) { + try { + const refreshed = await refreshAnthropicToken(candidate.refresh); + await writeAnthropicAuth(refreshed); + upsertAccount(store, refreshed); + await saveAccountStore(store); + return refreshed; + } catch (error) { + failures.push({ auth: candidate, error }); + if (!isInvalidGrantFailure(error)) break; + } + } + + if (failures.length > 0) { + throw new Error(summarizeRefreshFailures(failures)); + } + + throw new Error("Anthropic OAuth refresh failed: no stored OAuth credentials were available"); + }); +} + +async function getFreshOAuthOrRotate(getAuth: () => Promise) { + try { + return await getFreshOAuth(getAuth); + } catch (error) { + const auth = await readAnthropicAuth(); + if (auth) { + const rotated = await rotateAnthropicAccount(auth); + if (rotated && !sameOAuth(rotated, auth)) return rotated; + } + throw error; } - return refreshed; } const claudeCodeAuthPlugin: Plugin = async () => ({ @@ -538,7 +655,7 @@ const claudeCodeAuthPlugin: Plugin = async () => ({ if (!url || !ANTHROPIC_HOSTS.has(url.hostname)) return fetch(input, init); const originalBody = typeof init?.body === "string" ? init.body : input instanceof Request ? await input.clone().text().catch(() => undefined) : undefined; const rewritten = rewriteRequestPayload(originalBody); - const freshAuth = await getFreshOAuth(getAuth); + const freshAuth = await getFreshOAuthOrRotate(getAuth); if (!freshAuth) return fetch(input, init); const headers = new Headers(init?.headers); if (input instanceof Request) input.headers.forEach((value, key) => { if (!headers.has(key)) headers.set(key, value); }); diff --git a/tests/opencode-claude-auth-refresh-hardening.sh b/tests/opencode-claude-auth-refresh-hardening.sh new file mode 100755 index 0000000..57cfc00 --- /dev/null +++ b/tests/opencode-claude-auth-refresh-hardening.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# tests/opencode-claude-auth-refresh-hardening.sh - guard Claude OAuth refresh hardening. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +PLUGIN="$SCRIPT_DIR/runtimes/opencode/plugins/claude-code-auth.ts" + +require_source() { + local pattern="$1" + local description="$2" + if ! grep -Fq "$pattern" "$PLUGIN"; then + echo "FAIL: missing $description" >&2 + echo "pattern: $pattern" >&2 + exit 1 + fi +} + +require_source "function refreshLockPath()" "shared refresh lock path" +require_source "async function withRefreshLock" "cross-process refresh lock" +require_source "await readAnthropicAuth()" "auth file re-read inside refresh path" +require_source "if (usableAccessToken(latest)) return latest" "winner-token reuse after lock acquisition" +require_source "function isInvalidGrantFailure" "invalid_grant refresh failure classifier" +require_source "const candidates = dedupeOAuthCandidates([latest, oauth, active, ...store.accounts])" "remembered account retry candidates" +require_source "summarizeRefreshFailures" "redacted refresh failure diagnostics" +require_source "getFreshOAuthOrRotate" "request path refresh fallback wrapper" + +echo "PASS: tests/opencode-claude-auth-refresh-hardening.sh"