Skip to content

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

Open
errhythm wants to merge 6 commits into
griffinmartin:mainfrom
errhythm:fix/refresh-stale-credentials
Open

fix: update credential handling to prioritize primary service for token refresh#99
errhythm wants to merge 6 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
Copy link
Copy Markdown
Contributor Author

errhythm commented Apr 1, 2026

@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
Copy link
Copy Markdown
Contributor Author

errhythm commented Apr 4, 2026

@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
Copy link
Copy Markdown
Owner

griffinmartin commented Apr 23, 2026

@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
Copy link
Copy Markdown
Contributor Author

errhythm commented Apr 23, 2026

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
Copy link
Copy Markdown
Contributor Author

errhythm commented May 8, 2026

@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!

errhythm added 6 commits May 14, 2026 22:43
…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.
@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

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