Skip to content

Fix Claude Keychain credential selection - #31

Merged
dotCipher merged 1 commit into
mainfrom
fix/keychain-account-scope-integration
Sep 14, 2026
Merged

dotCipher merged 1 commit into
mainfrom
fix/keychain-account-scope-integration

Conversation

@dotCipher

Copy link
Copy Markdown
Owner

Summary

  • prefer the current macOS account when reading the Claude Code Keychain service
  • reject MCP-only Keychain entries that do not carry claudeAiOauth
  • retain the credentials-file fallback when no valid Keychain credential exists
  • use execFileSync to avoid shell command interpolation

Supersedes #30 after rebasing its reviewed change onto #19. Fixes #29.

Verification

  • npm run typecheck
  • npm test: 122 passing
  • git diff --check

readClaudeCredentials() runs, with no account specified:

    security find-generic-password -s "Claude Code-credentials" -w

More than one Keychain item can share that service name. Attaching an MCP
server in an OpenCode session writes a second item under it, with
acct="unknown", holding only mcpOAuth. `security` returns the first match, so
the bridge can read that item, find no claudeAiOauth, and have getClaudeTokens
return null.

It then falls through to opencode's own OAuth entry, and when that entry is
stale the user sees "Token refresh failed (400): invalid_grant" on every new
session while their actual Claude credential is valid the entire time. Neither
re-authenticating nor `claude login` fixes it, because neither touches the item
being read.

Observed on a machine carrying both items:

    unscoped lookup          -> acct="unknown", keys ["mcpOAuth"]
    scoped to the account    -> keys ["claudeAiOauth", "mcpOAuth"]

Three changes:

- Look the item up scoped to userInfo().username first, then fall back to the
  historical unscoped lookup, so anyone whose item lives under a different
  account name keeps working exactly as before.
- Select on content rather than on match order. A returned item that carries no
  claudeAiOauth is not the credential, whichever account it belongs to. The
  selection is a pure exported function so it can be tested without a Keychain.
- When the Keychain produced matches but none carried claudeAiOauth, fall
  through to ~/.claude/.credentials.json instead of returning that item. The
  old code returned it, which made the file fallback below it unreachable.

Also switches to execFileSync with an argument array. The account name is
user-controlled and should not be interpolated into a shell command line, and
the array form removes the need for the embedded 2>/dev/null redirection.

Adds four unit tests for the selection logic; there was no keychain coverage.

Verified: full suite 114 pass / 0 fail. On a machine with both items,
readClaudeCredentials() returns the mcpOAuth-only item before this change and
the real credential after it.

Signed-off-by: Mike Hiltz <mike@ildan.ai>
@dotCipher
dotCipher merged commit 946dcae into main Sep 14, 2026
1 check passed
@dotCipher
dotCipher deleted the fix/keychain-account-scope-integration branch September 14, 2026 16:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Keychain lookup is unscoped, so an MCP OAuth item can shadow the real credential

2 participants