fix(auth): renew the vaulted login instead of asking again - #43
Merged
Merged
Conversation
Empty commit to open a fresh PR into main and drive release.yml once GitHub Actions recovers from the outage. No code change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The subscription login did not survive a restart, on Linux and on Windows alike, and the cause was not persistence. The credential is in the IDE PasswordSafe and therefore in the OS store (KWallet or GNOME Keyring through the Secret Service, Keychain, Credential Manager), and it was still there after the reboot. What expired was the access token inside it - `auth login` issues one good for about ten hours, so any restart the next day found a perfect credential that authenticated nothing, `hasUsableToken()` answered false, and false meant signed out. The blob always carried a refresh token good for weeks, and nothing was allowed to spend it, since spending it means the binary rewriting `~/.claude/.credentials.json` - the file the vault exists to remove. The way out is in the binary and is a first-class path. With the environment carrying CLAUDE_CODE_OAUTH_REFRESH_TOKEN and CLAUDE_CODE_OAUTH_SCOPES, `claude auth login` takes a dedicated non-interactive branch, mints a credential into its own store and exits. Verified against 2.1.223 that the branch is genuinely non-interactive - an invalid refresh token fails on the HTTP round-trip and exits 1, with no browser and no TTY wait. So renewal is the binary's job, exactly as it always was, and the plugin's job stays what it was - capture the account while the config is freshest, harvest the credential off the disk, delete it. The plugin still holds no OAuth client, calls no token endpoint and never writes that file back. This is one bug rather than two. The binary's default credential store is its `plaintext` provider on every platform, so the vault path and the expiry are identical everywhere. No platform-specific code was needed; the only Windows-specific care is that the renewal environment strips CLAUDE_CODE_OAUTH_TOKEN case-insensitively, since environment names are case-insensitive there. An expired-but-renewable blob now counts as an identity (`CredentialsVault.canRenew`), the renewal runs off the EDT in `launch()` before the env is built and never while a sign-in is in flight, the refresh token rotates at every renewal so ordinary use extends it indefinitely, and a failure arms a five-minute cooldown because the boot watcher polls every three seconds. Also drops a leftover CC-TRACE prefix from a rate-limit debug log. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The usage windows and the extra-credit balance were rounded to a whole number on the Kotlin side before they ever reached the web app, so the dashboard bars and the composer dots could only ever read as integers. The percentage now travels as a Double and the front end formats it with `toFixed(1)`, which also makes the locale question visible - whether the decimal separator renders as a comma or a dot is now something the UI can be observed doing rather than guessed at. Removes `JcefSessionData.pctOf` and `JcefState.normalizePercent` along with it, since neither has a caller once the rounding moves to the display layer. The clamping they performed goes with them; the event-sourced windows are still multiplied by 100 exactly as `RateLimitInfo.utilizationPercent` did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 5.0.1 notes described the fix without naming the credential it applies to, which leaves an API-key user unable to tell whether it concerns them. It does not. An Anthropic API key is a different identity in a different slot (`providerApiKey:anthropic` in the same PasswordSafe, not `CLAUDE_CREDENTIALS_JSON`), it has no expiry and no refresh token, so nothing was lost across a restart and nothing is renewed now. `CredentialsVault.renew` reads the `claudeAiOauth` blob and nothing else, and `envOverlay` withdraws entirely when an API key is present. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
The subscription login (Claude Pro/Max — the Sign in button and the account row) did not survive a restart — reported on Linux and Windows — and the cause was not persistence. The credential is stored through the IDE's
PasswordSafeand therefore lands in the OS store (KWallet / GNOME Keyring via the Secret Service, Keychain, Credential Manager), and it was still there after the reboot; confirmed by reading the entry straight out of the OS store.What expired was the access token inside it.
auth loginissues one good for ~10 h, so any restart the next day found a perfectly persisted credential that authenticated nothing:hasUsableToken()answered false, and false meant signed out.Scope: OAuth only, not API keys
An Anthropic API key is unaffected and was never affected. It is a different identity in a different slot —
providerApiKey:anthropicin the same PasswordSafe, notCLAUDE_CREDENTIALS_JSON— with no expiry and no refresh token, so there was nothing to lose across a restart and there is nothing to renew now.CredentialsVault.renew()reads theclaudeAiOauthblob and nothing else, andenvOverlaywithdraws entirely when an API key is present.The fix
The blob always carried a refresh token good for weeks, and nothing was allowed to spend it — spending it means the binary rewriting
~/.claude/.credentials.json, the file the vault exists to remove.The way out is in the binary and is a first-class path: with
CLAUDE_CODE_OAUTH_REFRESH_TOKEN+CLAUDE_CODE_OAUTH_SCOPESin the environment,claude auth logintakes a dedicated non-interactive branch (tengu_login_from_refresh_token→POST platform.claude.com/v1/oauth/token), mints a credential into its own store and exits. Verified against 2.1.223 that the branch is genuinely non-interactive: an invalid refresh token fails on the HTTP round-trip and exits 1, with no browser and no TTY wait.So renewal is the binary's job, exactly as it always was, and the plugin's job stays what it was — capture the account while the config is freshest, harvest the credential off the disk, delete it. The plugin still holds no OAuth client, calls no token endpoint and never writes that file back; the vault's invariant is untouched, only its cost is gone.
One bug, not two
The binary's default credential store is its
plaintextprovider on every platform (the keychain prefetch is stubbed and the Windows-Credential-Manager flag does not replace it), so the vault path and the expiry are identical everywhere. No platform-specific code — the only Windows-specific care is that the renewal environment stripsCLAUDE_CODE_OAUTH_TOKENcase-insensitively, since environment names are case-insensitive there.Wiring
CredentialsVault.canRenew()/needsRenewal()/renew()+AuthCli.loginFromRefreshToken()(60 s timeout; the binary's own HTTP timeout is 30 s).hasCredentialno longer reports signed out (it runs on the EDT, so it must not renew).ClaudeSession.renewVaultedCredentialruns the renewal off the EDT insidelaunch(), before the launch env is built, and never while a sign-in is in flight (both write the same file).ownLoginCheckedAtttl cache is dropped on failure, since a renewal can sign the binary in even when we fail to take custody.Commits
fix(auth): renew the vaulted login instead of asking againfeat(ui): show plan-limit percentages with one decimalDoubleand are formatted withtoFixed(1)in the web app instead of being rounded to anIntin Kotlin. RemovesJcefSessionData.pctOfandJcefState.normalizePercent— the clamping they performed goes with them, which is worth a reviewer's eye.docs(release): say which sign-in the renewal fix coversVerification
Every gate in
ci.yml, run locally (Static analysis,Dependency auditandPlugin verifierare skipped on a PR intodevelopby design, so they were run by hand):test koverVerifydetekt/spotlessChecknpm test(vitest)npm run lint/format:checknpm audit --omit=dev --audit-level=lowverifyPluginnode_modulesentries;META-INF/{LICENSE,THIRD-PARTY-NOTICES.md}presentSix new tests pin the renewal semantics in
CredentialsVaultHeadlessTest, including the reboot case itself (expired access token + live refresh token = still an identity).Version bumped to 5.0.1, with
CHANGELOG.md,RELEASE_NOTES.mdandCLAUDE.mdupdated.🤖 Generated with Claude Code