diff --git a/src/utils/oauth.ts b/src/utils/oauth.ts index 9cf7e1e..385c58d 100644 --- a/src/utils/oauth.ts +++ b/src/utils/oauth.ts @@ -110,36 +110,69 @@ async function getOAuthTokenWindows(): Promise { return null; } -async function getOAuthTokenMacOS(): Promise { +// Discover the correct Keychain service name. Claude Code may use a hash-suffixed +// name (e.g. "Claude Code-credentials-697375ae") instead of the legacy "Claude Code-credentials". +async function findKeychainServiceName(): Promise { try { const { stdout } = await execAsync( - `security find-generic-password -s "Claude Code-credentials" -w`, + `security dump-keychain 2>/dev/null | grep -o '"Claude Code-credentials[^"]*"'`, { timeout: 5000 } ); - const content = stdout.trim(); - - // The keychain stores JSON with structure: {"claudeAiOauth":{"accessToken":"..."}} - if (content.startsWith("{")) { - try { - const parsed = JSON.parse(content); - if (parsed.claudeAiOauth && typeof parsed.claudeAiOauth === "object") { - const token = parsed.claudeAiOauth.accessToken; - if (token && typeof token === "string" && token.startsWith("sk-ant-oat")) { - debug("Found OAuth token in macOS Keychain under claudeAiOauth.accessToken"); - return token; - } + // Pick the longest match — the hash-suffixed variant is more specific than the legacy name + const matches = stdout + .trim() + .split("\n") + .map((s) => s.replace(/^"|"$/g, "")) + .filter(Boolean) + .sort((a, b) => b.length - a.length); + if (matches.length > 0) { + debug(`Found keychain service: ${matches[0]}`); + return matches[0]; + } + } catch (error) { + debug("Keychain service name lookup failed:", error); + } + return "Claude Code-credentials"; +} + +function extractTokenFromKeychainContent(content: string): string | null { + if (content.startsWith("{")) { + try { + const parsed = JSON.parse(content); + if (parsed.claudeAiOauth && typeof parsed.claudeAiOauth === "object") { + const token = parsed.claudeAiOauth.accessToken; + if (token && typeof token === "string" && token.startsWith("sk-ant-oat")) { + return token; } - } catch (parseError) { - debug("Failed to parse keychain JSON:", parseError); } + } catch (parseError) { + debug("Failed to parse keychain JSON:", parseError); } + } + if (content.startsWith("sk-ant-oat")) { + return content; + } + return null; +} - // Fallback: check if it's a raw token - if (content.startsWith("sk-ant-oat")) { - return content; +async function getOAuthTokenMacOS(): Promise { + // Discover the correct service name (handles hash-suffixed entries) + const serviceName = await findKeychainServiceName(); + + for (const name of [serviceName, "Claude Code-credentials"]) { + try { + const { stdout } = await execAsync( + `security find-generic-password -s "${name}" -w`, + { timeout: 5000 } + ); + const token = extractTokenFromKeychainContent(stdout.trim()); + if (token) { + debug(`Found OAuth token in macOS Keychain (${name})`); + return token; + } + } catch (error) { + debug(`macOS Keychain retrieval failed for "${name}":`, error); } - } catch (error) { - debug("macOS Keychain retrieval failed:", error); } // Fallback to config file locations (same as Linux)