Skip to content

fix: update credential handling to prioritize primary service for token refresh#99

Merged
griffinmartin merged 12 commits into
griffinmartin:mainfrom
errhythm:fix/refresh-stale-credentials
Jul 25, 2026
Merged

fix: update credential handling to prioritize primary service for token refresh#99
griffinmartin merged 12 commits into
griffinmartin:mainfrom
errhythm:fix/refresh-stale-credentials

Conversation

@errhythm

Copy link
Copy Markdown
Contributor

Two bugs were found by analyzing debug logs from users hitting the "Claude Code credentials are unavailable or expired" error.

Bug 1 — macOS / keychain (hard failure)
Users with multiple Claude Code accounts have a suffixed active account (e.g. Claude Code-credentials-b28bbb7c). When the token expires, the Claude CLI is invoked to refresh it and succeeds, but the CLI writes the new token back to the primary Keychain entry (Claude Code-credentials), not the suffixed one. refreshAccount(target.source) re-reads the suffixed entry, which still holds the old expiresAt, the post-refresh validity check fails, and refresh_exhausted → credentials_unavailable is emitted. The user sees:

Claude Code credentials are unavailable or expired. Run claude to refresh them.

Fix: if the suffixed account re-read is still stale after a CLI refresh, fall back to reading the primary entry. The fallback is gated on target.source.startsWith(PRIMARY_SERVICE + "-") so it only fires for suffixed accounts.

Bug 2 — all platforms (infinite refresh loop) (Potentially fixes #89)

After refreshIfNeeded returns fresh credentials, getCachedCredentials stores them in accountCacheMap with a 30-second TTL. When the cache expires, getActiveAccount() returns the same ClaudeAccount object from allAccounts — whose credentials.expiresAt was never updated. So refreshIfNeeded sees the original stale expiry, runs another full CLI refresh, and the cycle repeats every 30 seconds indefinitely. Visible in logs as refresh_needed firing with the same expiresAt value on every cache miss, hours after the token was successfully refreshed.

Fix: after a successful refresh, target.credentials is updated in-place on the ClaudeAccount object so subsequent cache misses see the correct expiresAt.

Also now opencode auth login will show the user account details instead of just Claude Pro/Team/Max

◆ Select which Claude Code account to use:
│ ● Claude Pro: john.doe@gmail.com (Claude Code-credentials)
│ ○ Claude Team: john@acme.com

@yvyw yvyw mentioned this pull request Mar 29, 2026
@errhythm

errhythm commented Apr 1, 2026

Copy link
Copy Markdown
Contributor Author

@griffinmartin I see a lot of great changes made and I believe we can use some of the features we have here. Do you want me to update this?

@griffinmartin

Copy link
Copy Markdown
Owner

Hey @errhythm, yeah I'd love for you to update this! There's some really useful stuff here, especially the Bug 1 fix for suffixed keychain accounts and the email display in the account selector.

A few things to keep in mind for the rebase:

  1. Bug 2 is already fixed on mainfix: eliminate idle token consumption via direct OAuth refresh #104 added a direct OAuth refresh path (refreshViaOAuth) and the in-place target.credentials update. So you can drop that part entirely. The main thing to be careful about is not overwriting the current refreshIfNeeded — your Bug 1 primary service fallback should slot in after the existing OAuth + CLI refresh flow, not replace it.

  2. The CLAUDE_CONFIG_DIR tests (keychain.test.ts) don't actually exercise the production code — they just write a file to a temp dir and read it back with readFileSync. They should call the real readCredentialsFile or readAllClaudeAccounts with the env var set so they're testing actual behavior.

  3. discoverConfigDirsForKeychain scans all of $HOME — could you scope it down to dotfiles (dirs starting with .) since Claude config dirs follow that convention? Doing existsSync on every entry in someone's home dir could be slow.

  4. The hint change — you changed the account selector hint from showing the full keychain source name to just "active"/undefined. That's fine if every account has an email to distinguish it, but if readEmailFromConfigDir returns null for some accounts, users lose the ability to tell them apart. Maybe keep the source as a fallback when there's no email?

The Bug 1 fix, configDir passthrough, keychainSuffixForDir, and the regex tightening are all good to go as-is. Looking forward to the update!

@errhythm
errhythm force-pushed the fix/refresh-stale-credentials branch 2 times, most recently from 446b7e3 to a72a12a Compare April 4, 2026 15:22
@errhythm

errhythm commented Apr 4, 2026

Copy link
Copy Markdown
Contributor Author

@griffinmartin Please review if it is correct.

ownuun added a commit to ownuun/opencode-claude-auth that referenced this pull request Apr 10, 2026
…ting

Update all config examples in README.md and installation.md to use
`opencode-claude-auth@latest` so OpenCode always fetches the newest
version on startup.

Add a troubleshooting entry and "Updating the plugin" section for the
"You're out of extra usage" / "Third-party apps" 400 error that
affected users on stale cached versions. The fix: ensure @latest in
config, clear ~/.cache/opencode/packages/opencode-claude-auth*, restart
OpenCode, and re-authenticate if needed.

Closes griffinmartin#145, closes griffinmartin#99

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@errhythm

Copy link
Copy Markdown
Contributor Author

Hi @griffinmartin, can you please review this if possible? The user account details feature at the time of switching account would be really helpful. If you can check this out it would be great.

Let me know if any of the feature we do not need anymore, I can remove.

@griffinmartin

griffinmartin commented Apr 23, 2026

Copy link
Copy Markdown
Owner

@errhythm
I checked out these changes, built, and got this error.

Then.. I re-checked out main, built, and it worked fine.

{"ts":"2026-04-23T19:00:30.299Z","event":"keychain_list","servicesFound":["Claude Code-credentials"]}
{"ts":"2026-04-23T19:00:30.324Z","event":"keychain_read","service":"Claude Code-credentials","success":true}
{"ts":"2026-04-23T19:00:30.324Z","event":"credentials_parsed","hasAccessToken":true,"hasRefreshToken":true,"hasExpiry":true,"isMcpOnly":false}
{"ts":"2026-04-23T19:00:30.325Z","event":"account_config_dir","source":"Claude Code-credentials","configDir":"/Users/gmartin/.claude"}
{"ts":"2026-04-23T19:00:30.325Z","event":"plugin_init","accountCount":1,"sources":["Claude Code-credentials"],"activeSource":"Claude Code-credentials"}
{"ts":"2026-04-23T19:00:30.325Z","event":"cache_miss","source":"Claude Code-credentials","reason":"empty"}
{"ts":"2026-04-23T19:00:30.326Z","event":"sync_auth_json","path":"/Users/gmartin/.local/share/opencode/auth.json","success":true}
{"ts":"2026-04-23T19:00:30.326Z","event":"config_no_plugin_keys","agentCount":2}
{"ts":"2026-04-23T19:00:30.573Z","event":"auth_loader_called","authType":"oauth"}
{"ts":"2026-04-23T19:00:30.573Z","event":"auth_loader_ready","modelCount":24}
{"ts":"2026-04-23T19:00:30.727Z","event":"keychain_list","servicesFound":["Claude Code-credentials"]}
{"ts":"2026-04-23T19:00:30.754Z","event":"keychain_read","service":"Claude Code-credentials","success":true}
{"ts":"2026-04-23T19:00:30.754Z","event":"credentials_parsed","hasAccessToken":true,"hasRefreshToken":true,"hasExpiry":true,"isMcpOnly":false}
{"ts":"2026-04-23T19:00:30.755Z","event":"account_config_dir","source":"Claude Code-credentials","configDir":"/Users/gmartin/.claude"}
{"ts":"2026-04-23T19:00:32.192Z","event":"cache_hit","source":"Claude Code-credentials","ttlRemaining":28133}
{"ts":"2026-04-23T19:00:32.192Z","event":"fetch_credentials","modelId":"claude-haiku-4-5","accessToken":"sk-ant-o...REDACTED","expiresAt":1776971384246}
{"ts":"2026-04-23T19:00:32.192Z","event":"fetch_headers_built","headerKeys":["anthropic-beta","anthropic-version","authorization","content-type","user-agent","x-app","x-claude-code-session-id","x-client-request-id","x-session-affinity"],"betas":["claude-code-20250219","oauth-2025-04-20","prompt-caching-scope-2026-01-05","context-management-2025-06-27","fine-grained-tool-streaming-2025-05-14","interleaved-thinking-2025-05-14"],"modelId":"claude-haiku-4-5"}
{"ts":"2026-04-23T19:00:32.278Z","event":"cache_hit","source":"Claude Code-credentials","ttlRemaining":28047}
{"ts":"2026-04-23T19:00:32.279Z","event":"fetch_credentials","modelId":"claude-opus-4-7","accessToken":"sk-ant-o...REDACTED","expiresAt":1776971384246}
{"ts":"2026-04-23T19:00:32.279Z","event":"fetch_headers_built","headerKeys":["anthropic-beta","anthropic-version","authorization","content-type","user-agent","x-app","x-claude-code-session-id","x-client-request-id","x-session-affinity"],"betas":["claude-code-20250219","oauth-2025-04-20","interleaved-thinking-2025-05-14","prompt-caching-scope-2026-01-05","context-management-2025-06-27","context-1m-2025-08-07","fine-grained-tool-streaming-2025-05-14"],"modelId":"claude-opus-4-7"}
{"ts":"2026-04-23T19:00:32.533Z","event":"fetch_response","status":400,"modelId":"claude-opus-4-7","retryAttempt":0}
{"ts":"2026-04-23T19:00:32.534Z","event":"fetch_error_response","status":400,"modelId":"claude-opus-4-7","message":"You're out of extra usage. Add more at claude.ai/settings/usage and keep going."}
{"ts":"2026-04-23T19:00:32.916Z","event":"fetch_response","status":200,"modelId":"claude-haiku-4-5","retryAttempt":0}

@errhythm
errhythm force-pushed the fix/refresh-stale-credentials branch from 8e15fc0 to 916f3cd Compare April 23, 2026 19:18
@errhythm

errhythm commented Apr 23, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for checking. I have rebased it. So it should work. @griffinmartin

@errhythm

Copy link
Copy Markdown
Contributor Author

Improved multi-account OAuth token refresh reliability for suffixed keychain accounts (Claude Code-credentials-). The core fix is in buildSuffixToDirCache: instead of scanning every ~/.* directory with a .claude.json and caching all of them, the scan now only maps directories whose SHA-256 hash matches a known keychain suffix — unrelated config dirs (API-key accounts, old sessions, etc.) are skipped entirely, and the scan exits early once all needed suffixes are resolved. I also tightened tryFallbackAccount to skip accounts whose in-memory expiresAt is already stale before attempting a live keychain read, avoiding unnecessary keychain hits when all fallback accounts are expired anyway.

Requesting a review. 😅

@errhythm

errhythm commented May 8, 2026

Copy link
Copy Markdown
Contributor Author

@griffinmartin Can you test again after build please? Thanks.

@griffinmartin

Copy link
Copy Markdown
Owner

@errhythm I can look into it this weekend. Sorry for the delay!

@griffinmartin
griffinmartin force-pushed the fix/refresh-stale-credentials branch from 667eae2 to 8f1c204 Compare May 15, 2026 04:45
@errhythm

Copy link
Copy Markdown
Contributor Author

Hi @griffinmartin, Sorry if I am bugging too much, did you get time to check?

N.B.: It is basically hard for me to switch accounts 😭
image

@griffinmartin

Copy link
Copy Markdown
Owner

@greptileai

@greptile-apps

greptile-apps Bot commented Jul 25, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes two independent credential-refresh bugs affecting macOS multi-account setups and all platforms respectively, and enriches the account-selection prompt with per-account email addresses.

  • Bug 1 (macOS keychain): after a CLI-triggered refresh of a suffixed keychain account, the code now falls back to reading the primary Claude Code-credentials entry if the suffixed entry is still stale — correctly mirroring the Claude CLI's actual write-back behaviour. configDir is now threaded through to the CLI invocation via CLAUDE_CONFIG_DIR so the correct account config is used.
  • Bug 2 (all platforms, infinite refresh loop): target.credentials is updated in-place on the ClaudeAccount object after every successful refresh, so subsequent cache misses see the fresh expiresAt and return early without re-triggering the full refresh sequence.
  • Label improvement: buildAccountLabels now accepts optional emails and sources arrays; readAllClaudeAccounts resolves each suffixed keychain entry back to its config directory via an SHA-256 hash lookup, reads the stored email address, and includes it in the displayed label.

Confidence Score: 5/5

Safe to merge; both bug fixes are logically correct and covered by unit tests and a new headless E2E harness.

The two core fixes are tightly scoped, all credential update paths have tests, and the headless harness accurately replicates the real CLI write-back behaviour. The only gap is a handful of removed parseCredentials negative-case tests that no longer have direct coverage.

Files Needing Attention: src/keychain.test.ts — removed parseCredentials negative-case tests leave the field-validation branch without regression coverage.

Important Files Changed

Filename Overview
src/credentials.ts Bug 1 fix (primary-entry fallback after CLI refresh) and Bug 2 fix (in-place credential update on ClaudeAccount) are correctly implemented. tryFallbackAccount properly updates borrowed account credentials in-place, and isSuffixedAccount gating prevents unnecessary CLI skips.
src/keychain.ts Adds configDir propagation, keychainSuffixForDir hash, suffix-to-dir cache with hasAllNeeded incremental re-scan, email label enrichment, and readCredentialsFile export.
src/credentials.test.ts Adds two focused tests for the tryFallbackAccount paths (externally-refreshed keychain and valid in-memory fallback), correctly mocking out OAuth and child-process to isolate the tested paths.
src/keychain.test.ts Adds comprehensive integration tests for readAllClaudeAccounts with mocked security/child-process, keychainSuffixForDir, CLAUDE_CONFIG_DIR support, and label enrichment. Several negative-case parseCredentials tests were removed without replacement.
scripts/test-headless.ts Well-structured headless E2E test covering happy-path, expired-primary-CLI-refresh, and Bug 1 suffixed-fallback scenarios. Safely backs up and restores account source state and guards against leaving junk keychain entries.
src/index.ts Account selection prompt now shows 'active' hint instead of '(active)' suffix, and sourceDescription includes configDir and keychain service name for better context.
src/index.test.ts Tests updated to match the new 'active' hint format. PRIMARY_SERVICE export is correctly stubbed in the fake keychain module.
package.json Adds test:headless script for running the new headless end-to-end test.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[getCachedCredentials] --> B{Cache hit?}
    B -- Yes --> Z[Return cached creds]
    B -- No --> C[refreshIfNeeded]
    C --> D{creds.expiresAt fresh?}
    D -- Yes --> Z
    D -- No --> E[refreshViaOAuth]
    E --> F{OAuth succeeded?}
    F -- Yes --> G[target.credentials = oauthCreds, writeBack, Return]
    F -- No --> H{isSuffixedAccount?}
    H -- Yes --> I[refreshViaCli requireConfigDir=true]
    H -- No --> J[refreshViaCli requireConfigDir=false]
    I --> K{configDir known?}
    K -- No --> L[CLI skipped, cliSucceeded=false]
    K -- Yes --> M[Run claude CLI with CLAUDE_CONFIG_DIR]
    J --> M
    M --> N{CLI succeeded?}
    N -- No --> L
    L --> O[tryFallbackAccount]
    O --> P{Other account fresh?}
    P -- Yes --> Q[target.credentials = fallback, Return]
    P -- No --> R[refresh_exhausted, null]
    N -- Yes --> S[refreshAccount target.source]
    S --> T{Suffixed entry still stale?}
    T -- Yes+isSuffixed --> U[refreshAccount PRIMARY_SERVICE - Bug1 fix]
    U --> V{Primary fresh?}
    V -- Yes --> W[refreshed = primaryCreds]
    V -- No --> X[refresh_exhausted, null]
    T -- No --> Y[refreshed = fresh]
    W --> Y
    Y --> AA[target.credentials = refreshed, Return - Bug2 fix]
Loading

Reviews (7): Last reviewed commit: "perf: borrow valid in-memory fallback cr..." | Re-trigger Greptile

Comment thread src/keychain.ts
Comment thread src/keychain.ts
errhythm added 7 commits July 25, 2026 14:58
…en refresh

This commit introduces a bug fix for the credential refresh logic, ensuring that if the active account is a suffixed entry and its credentials are stale, the system will fall back to the primary service ("Claude Code-credentials") to retrieve updated tokens. Additionally, the PRIMARY_SERVICE constant is now consistently exported across relevant files to maintain clarity and prevent duplication.
This commit updates the `refreshViaCli` function to accept an optional `configDir` parameter, allowing the CLI to read from and write back to the correct account directory. It also improves logging for credential refresh attempts and modifies the `buildAccountLabels` function to append email addresses when available. Additionally, new tests are added to ensure proper handling of the `CLAUDE_CONFIG_DIR` environment variable across different platforms.
… improvements

This commit introduces a mock for the child process in the credential tests, allowing for better isolation during testing. Additionally, it improves the formatting of temporary directory creation and JSON file writing in the tests for better readability and consistency. The changes ensure that the tests remain robust while simulating the necessary environment for credential handling.
This commit modifies the keychain test file to replace instances of `process.platform` with a hardcoded value of `"darwin"`. This change ensures that the tests can run consistently in a controlled environment, improving test reliability and isolation.
This commit updates the `refreshViaCli` function to include a `requireConfigDir` parameter, improving error handling for suffixed accounts. It also introduces a new `tryFallbackAccount` function to attempt refreshing credentials from alternative accounts when the primary refresh fails. Additionally, the `discoverConfigDirsForKeychain` function is refactored to utilize a caching mechanism for improved performance.
…abel suffix, restore removed regression tests

- buildSuffixToDirCache now extends the cache instead of freezing it after
  the first call, so accounts added while the process is running get their
  configDir resolved on the next readAllClaudeAccounts() call.
- Non-macOS/no-keychain-account label building no longer passes the literal
  string "file" as a label suffix, avoiding labels like "Claude Pro: file"
  when no email is available.
- Restored updateCredentialBlob root-level-format and mcpOAuth-preservation
  tests, and writeBackCredentials missing-file/invalid-JSON tests that were
  dropped during the credentials refactor.
@errhythm
errhythm force-pushed the fix/refresh-stale-credentials branch from 8f1c204 to 2b6d503 Compare July 25, 2026 09:07
Comment thread src/credentials.ts Outdated
…2 loop

tryFallbackAccount's return value was never written back to target.credentials,
so target kept its original stale expiresAt. After the 30s cache TTL,
refreshIfNeeded would re-enter the full refresh sequence (including a fresh
OAuth attempt) instead of reusing the still-valid borrowed token — the same
infinite-refresh loop Bug 2 fixed on the OAuth/CLI success paths, just on the
fallback branch.
@errhythm

Copy link
Copy Markdown
Contributor Author

@griffinmartin Done

…eadless)

Runs real opencode run invocations against the built plugin with a
sandboxed fake keychain (PATH shims for security and claude) and asserts
the refresh behaviour from an append-only shim log:

- happy path: fresh credentials, no refresh
- expired primary: OAuth fail -> CLI refresh -> re-read -> 200
- Bug 1: stale suffixed entry -> CLI writes to primary -> primary
  fallback -> 200

Assertions use the shim log rather than the plugin debug log because the
plugin initialises more than once per opencode run and each init
truncates the debug log, racily discarding early refresh events.

macOS-only manual gate alongside validate:oauth and test:models; never
runs in CI. The real keychain is read-only and the real refresh token is
never transmitted.
- tryFallbackAccount now live-reads accounts whose in-memory expiry is
  stale instead of skipping them: another process (e.g. the Claude CLI)
  may have refreshed their keychain entry since startup. In-memory-fresh
  accounts are still tried first, keychain reads on one account no
  longer abort the remaining candidates, and the donor account's
  in-memory credentials are updated after a successful borrow.
- Keychain service discovery matches any-length hex suffixes again so
  legacy entries stay visible; the suffix-to-config-dir mapping still
  only applies to the 8-char hashes the Claude CLI generates.
…read

tryFallbackAccount now returns an account's in-memory credentials
directly when they are still valid, instead of re-reading its keychain
entry first. Live keychain reads only happen as a last resort when every
candidate's in-memory expiry is stale. A 401 on a borrowed token is
already handled by the reload-and-retry fetch path.
@griffinmartin
griffinmartin merged commit 9dd33d6 into griffinmartin:main Jul 25, 2026
5 checks passed
griffinmartin pushed a commit that referenced this pull request Jul 25, 2026
🤖 I have created a release *beep* *boop*
---


##
[2.1.2](v2.1.1...v2.1.2)
(2026-07-25)


### Bug Fixes

* update credential handling to prioritize primary service for token
refresh
([#99](#99))
([9dd33d6](9dd33d6))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
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.

Auto-refresh not working

2 participants