OAuth refresh persists credentials without expiresAt/scopes, forcing /login after every affected refresh
Environment: Claude Code 2.1.224–2.1.226 (native installer), Windows 11 Pro, Claude Max subscription. Behavior predates these versions (identical persist code verified in all three bundles on disk).
Summary
The silent OAuth refresh sometimes rewrites ~/.claude/.credentials.json dropping expiresAt, refreshTokenExpiresAt, and scopes while keeping the (successfully rotated, live) tokens. On the next launch, the client rejects the credential with oauth_no_inference_scope — because scopes is missing, not because anything is actually expired — and forces a full /login. Users hit this as "I have to log in every ~8 hours."
Root cause (from the bundled source)
The credential-merge function used on refresh has fallbacks for some fields but not others:
function zhf(e,t){return{
accessToken:t.accessToken,
refreshToken:t.refreshToken,
expiresAt:t.expiresAt, // no fallback
refreshTokenExpiresAt:t.refreshTokenExpiresAt??e?.refreshTokenExpiresAt,
scopes:t.scopes, // no fallback
subscriptionType:t.subscriptionType??e?.subscriptionType??null,
rateLimitTier:t.rateLimitTier??e?.rateLimitTier??null,
clientId:t.clientId}}
When the refresh result lacks expiresAt/scopes, they're written as undefined and vanish from the JSON. The login gate then fails before the tokens are ever tried:
if(!t.scopes?.includes("user:inference")) return "oauth_no_inference_scope";
Evidence (30-min file snapshots over 33 h)
- A refresh at 15:32 wrote a healthy 508-byte file; a refresh at 23:33 the same day rotated both tokens (access-token SHA256 prefix changed, so the refresh succeeded server-side) but wrote a 367-byte file containing only
accessToken, refreshToken, subscriptionType, rateLimitTier. - Single Claude process the whole time, no crash; the file was never touched again (mtime frozen 6.5 h); the next launch demanded
/login. - Reproduced again the next day at 14:33.
- Externally re-adding the missing fields to the degraded file (derived expiry + the standard 5 scopes) produces a credential the client accepts — confirming the tokens were live and only the metadata was lost.
Since the drop only happens on some refreshes, the trigger appears to be a token-refresh response variant that omits expires_in/scope; the merge function faithfully propagates the gap instead of falling back.
Suggested fix
Fall back to the previous credential's expiresAt/scopes in the merge (as already done for subscriptionType/rateLimitTier), or derive expiresAt from expires_in/now and treat a missing scope in a refresh response as "unchanged".
🤖 Diagnosed with Claude Code
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗