From c37d163b0ccea1956bb4c48712f2814106688ea8 Mon Sep 17 00:00:00 2001 From: Cody Moore Date: Sat, 30 May 2026 19:29:27 -0400 Subject: [PATCH] fix: avoid stale keychain refresh during oauth login --- src/index.ts | 27 ++++++++++++++++++--------- src/keychain.ts | 18 +++++++++++++++--- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/index.ts b/src/index.ts index 088e3d6..5daee61 100644 --- a/src/index.ts +++ b/src/index.ts @@ -278,7 +278,7 @@ async function storeAuth( }); } -/** Layered token refresh: keychain → stored refresh → CLI refresh token. */ +/** Layered token refresh: valid keychain access → stored refresh → CLI refresh token. */ async function refreshAuth( auth: AuthType, client: PluginClient, @@ -286,10 +286,19 @@ async function refreshAuth( type Tokens = { access: string; refresh: string; expires: number }; let fresh: Tokens | null = null; const errors: string[] = []; + const attemptedRefreshTokens = new Set(); - // Layer 1: Claude CLI keychain + const tryRefresh = (refreshToken: string | undefined): Tokens | null => { + if (!refreshToken || attemptedRefreshTokens.has(refreshToken)) return null; + attemptedRefreshTokens.add(refreshToken); + return refreshTokens(refreshToken); + }; + + // Layer 1: Claude CLI keychain, but only if the access token is already + // valid. Expired keychain refresh tokens are often stale after re-login, so + // do not try them before OpenCode's own stored OAuth refresh token. try { - const kt = getClaudeTokens(); + const kt = getClaudeTokens({ refreshExpired: false }); if (kt && kt.expires > Date.now() + 60_000) fresh = kt; } catch (err) { errors.push(String(err)); @@ -297,16 +306,16 @@ async function refreshAuth( // Layer 2: Stored refresh token if (!fresh && auth.refresh) { - try { fresh = refreshTokens(auth.refresh); } + try { fresh = tryRefresh(auth.refresh); } catch (err) { errors.push(String(err)); } } - // Layer 3: CLI refresh token + // Layer 3: CLI refresh token, only if it differs from tokens already tried. if (!fresh) { try { const creds = readClaudeCredentials(); if (creds?.claudeAiOauth?.refreshToken) - fresh = refreshTokens(creds.claudeAiOauth.refreshToken); + fresh = tryRefresh(creds.claudeAiOauth.refreshToken); } catch (err) { errors.push(String(err)); } @@ -538,7 +547,7 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { // OpenCode builds its provider state. This ensures the loader runs on // startup so models appear immediately without requiring a restart. try { - const tokens = getClaudeTokens(); + const tokens = getClaudeTokens({ refreshExpired: false }); if (tokens) await storeAuth(client, tokens); } catch {} @@ -576,7 +585,7 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { // Auto-bootstrap from Claude CLI keychain if no OAuth tokens stored if (auth.type !== "oauth") { try { - const tokens = getClaudeTokens(); + const tokens = getClaudeTokens({ refreshExpired: false }); if (tokens) { await storeAuth(client, tokens); auth = { type: "oauth", ...tokens }; @@ -912,7 +921,7 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { type: "oauth" as const, authorize: async () => { // First try: auto-bootstrap from Claude CLI keychain - const tokens = getClaudeTokens(); + const tokens = getClaudeTokens({ refreshExpired: false }); if (tokens) { await storeAuth(client, tokens); return { diff --git a/src/keychain.ts b/src/keychain.ts index 4943827..54009c9 100644 --- a/src/keychain.ts +++ b/src/keychain.ts @@ -16,6 +16,11 @@ interface KeychainCredentials { }; } +interface GetClaudeTokensOptions { + refreshExpired?: boolean; + logRefreshFailures?: boolean; +} + /** * Read Claude CLI credentials from macOS Keychain. * Falls back to ~/.claude/.credentials.json on other platforms. @@ -49,7 +54,9 @@ export function readClaudeCredentials(): KeychainCredentials | null { * Get valid OAuth tokens from Claude CLI. * If expired, attempts to refresh via curl. */ -export function getClaudeTokens(): OAuthTokens | null { +export function getClaudeTokens(options: GetClaudeTokensOptions = {}): OAuthTokens | null { + const refreshExpired = options.refreshExpired ?? true; + const logRefreshFailures = options.logRefreshFailures ?? true; const creds = readClaudeCredentials(); if (!creds?.claudeAiOauth) return null; @@ -64,13 +71,18 @@ export function getClaudeTokens(): OAuthTokens | null { }; } - // Expired — try refresh + // Expired — try refresh only when the caller explicitly wants side effects. + // Login/bootstrap paths use this in read-only mode so stale Claude CLI + // keychain refresh tokens do not produce invalid_grant noise before a fresh + // OAuth login starts. + if (!refreshExpired) return null; + if (refreshToken) { try { console.error("[opencode-oauth] Claude CLI token expired, refreshing..."); return refreshTokens(refreshToken); } catch (err) { - console.error(`[opencode-oauth] Keychain refresh failed: ${err}`); + if (logRefreshFailures) console.error(`[opencode-oauth] Keychain refresh failed: ${err}`); } }