Fall back to legacy keychain when stale scoped Claude credentials 401

Claude Code maintains the legacy 'Claude Code-credentials' Keychain item
for the default config dir, but a scoped item (service suffixed with
sha256(configDir)) can be left behind by sessions that ran with
CLAUDE_CONFIG_DIR set. Once its access token expires, nothing refreshes
it: readFromKeychain returned the scoped token unconditionally, every
usage fetch 401'd as 'stale-token', and system-default auth has no CLI
recovery lane — so the status bar showed 'Refreshing sign-in' forever
while the legacy item held a perfectly valid token.

Two changes:
- readFromKeychain: an actual access token from the legacy item now
  beats scoped refresh-only credentials (Orca cannot refresh tokens
  itself, so refresh-only must not shadow a working token).
- On a stale-token OAuth failure with scoped-keychain credentials, retry
  once with the legacy item's token before classifying the failure.
  Host system-default auth only — managed/WSL credentials never fall
  back to the host user's legacy keychain item.

The legacy retry runs before CLI repair, so a readable working token is
preferred over launching a hidden 25s claude PTY.
This commit is contained in:
Brennan Benson
2026-07-14 14:22:59 -07:00
parent 1b3f281ff5
commit d508cdd773
2 changed files with 203 additions and 25 deletions
+101
View File
@@ -178,6 +178,104 @@ describe('fetchClaudeRateLimits', () => {
)
})
it('falls back to the legacy keychain token when the scoped token is rejected as stale', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
envPatch: { CLAUDE_CONFIG_DIR: configDir },
stripAuthEnv: false,
provenance: 'system'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) =>
dir
? JSON.stringify({
claudeAiOauth: { accessToken: 'stale-scoped-token', refreshToken: 'refresh-1' }
})
: JSON.stringify({ claudeAiOauth: { accessToken: 'fresh-legacy-token' } })
)
netFetchMock.mockImplementation(async (_url: string, init?: RequestInit) => {
const auth = (init?.headers as Record<string, string> | undefined)?.Authorization
return auth === 'Bearer fresh-legacy-token'
? new Response(
JSON.stringify({ five_hour: { utilization: 12 }, seven_day: { utilization: 34 } }),
{ status: 200 }
)
: new Response(
JSON.stringify({ error: { message: 'Invalid authentication credentials' } }),
{ status: 401 }
)
})
await expect(
fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false })
).resolves.toMatchObject({
provider: 'claude',
status: 'ok',
session: { usedPercent: 12 },
weekly: { usedPercent: 34 }
})
expect(netFetchMock).toHaveBeenCalledTimes(2)
expect(fetchViaPty).not.toHaveBeenCalled()
})
it('prefers a legacy access token over scoped refresh-only credentials', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
envPatch: { CLAUDE_CONFIG_DIR: configDir },
stripAuthEnv: false,
provenance: 'system'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) =>
dir
? JSON.stringify({ claudeAiOauth: { refreshToken: 'refresh-only' } })
: JSON.stringify({ claudeAiOauth: { accessToken: 'fresh-legacy-token' } })
)
await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({
provider: 'claude',
status: 'ok',
session: { usedPercent: 12 }
})
expect(netFetchMock).toHaveBeenCalledTimes(1)
expect(netFetchMock).toHaveBeenCalledWith(
'https://api.anthropic.com/api/oauth/usage',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer fresh-legacy-token' })
})
)
})
it('does not retry with the legacy keychain for managed account credentials', async () => {
const configDir = '/Users/test/managed-account'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
envPatch: { CLAUDE_CONFIG_DIR: configDir },
stripAuthEnv: true,
provenance: 'managed:account-1'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) =>
dir ? JSON.stringify({ claudeAiOauth: { accessToken: 'stale-managed-token' } }) : null
)
netFetchMock.mockResolvedValue(
new Response(JSON.stringify({ error: { message: 'Invalid authentication credentials' } }), {
status: 401
})
)
await expect(
fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false })
).resolves.toMatchObject({
provider: 'claude',
status: 'error'
})
expect(netFetchMock).toHaveBeenCalledTimes(1)
expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalledWith(undefined)
})
it('accepts Claude Code statusline-style rate limit window fields', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
@@ -734,6 +832,9 @@ describe('fetchClaudeRateLimits', () => {
}
})
)
// Legacy item absent — the stale-scoped legacy fallback must not preempt
// CLI repair in this scenario.
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(
JSON.stringify({
claudeAiOauth: {
+102 -25
View File
@@ -158,13 +158,16 @@ async function readFromKeychain(configDir?: string): Promise<OAuthCredentialRead
if (scopedCredentials.token) {
return scopedCredentials
}
if (scopedCredentials.hasRefreshableCredentials) {
return scopedCredentials
}
const legacyCredentials = await readCredentialsFromStrictKeychain(undefined, 'legacy-keychain')
// Why: Orca cannot refresh tokens itself, so an actual access token from
// either item beats refresh-only credentials. A scoped item the CLI stopped
// maintaining must not shadow a still-working legacy token.
if (legacyCredentials.token) {
return legacyCredentials
}
if (scopedCredentials.hasRefreshableCredentials) {
return scopedCredentials
}
if (legacyCredentials.hasRefreshableCredentials) {
return legacyCredentials
}
@@ -637,6 +640,84 @@ async function supplementOAuthUsageFromCli(input: {
}
}
async function completeOAuthUsageSuccess(input: {
oauthLimits: ProviderRateLimits
oauthCredentials: OAuthCredentialReadResult
attempts: ClaudeUsageAttemptState
options?: FetchClaudeRateLimitsOptions
}): Promise<ProviderRateLimits> {
const limits = await supplementOAuthUsageFromCli({
oauthLimits: input.oauthLimits,
authPreparation: input.options?.authPreparation,
oauthCredentials: input.oauthCredentials,
attempts: input.attempts,
networkProxySettings: input.options?.networkProxySettings,
allowUsagePanelSupplement:
input.options?.allowUsagePanelSupplement ??
isManagedClaudeAuth(input.options?.authPreparation),
signal: input.options?.signal
})
if (input.options?.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
return withClaudeUsageMetadata(
limits,
metadataForAttempt({
attemptedSources: input.attempts.attemptedSources,
oauthCredentials: input.oauthCredentials,
authPreparation: input.options?.authPreparation,
source: 'oauth'
})
)
}
function canRetryWithLegacyKeychainToken(input: {
classification: ClaudeUsageErrorClassification
oauthCredentials: OAuthCredentialReadResult
authPreparation?: ClaudeRuntimeAuthPreparation
}): boolean {
// Why: the CLI only maintains the legacy keychain item for the default config
// dir, so a scoped item can hold a token that expired long ago and will 401
// on every fetch with no recovery path. Host system-default auth may retry
// with the legacy item; managed/WSL credentials must never be answered with
// the host user's legacy keychain account.
return (
input.classification.failureKind === 'stale-token' &&
input.oauthCredentials.source === 'scoped-keychain' &&
(input.authPreparation?.runtime ?? 'host') === 'host' &&
!isManagedClaudeAuth(input.authPreparation)
)
}
async function retryOAuthWithLegacyKeychainToken(input: {
failedToken: string | null
attempts: ClaudeUsageAttemptState
options?: FetchClaudeRateLimitsOptions
}): Promise<ProviderRateLimits | null> {
const legacyCredentials = await readCredentialsFromStrictKeychain(undefined, 'legacy-keychain')
if (!legacyCredentials.token || legacyCredentials.token === input.failedToken) {
return null
}
if (input.options?.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
try {
const oauthLimits = await fetchViaOAuth(legacyCredentials.token, input.options?.signal)
if (input.options?.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
return await completeOAuthUsageSuccess({
oauthLimits,
oauthCredentials: legacyCredentials,
attempts: input.attempts,
options: input.options
})
} catch (err) {
warnClaudeUsageFetchFailure(input.options?.authPreparation, legacyCredentials, err)
return null
}
}
function shouldDeferForLiveClaude(
authPreparation: ClaudeRuntimeAuthPreparation | undefined,
classification: ClaudeUsageErrorClassification
@@ -798,32 +879,28 @@ export async function fetchClaudeRateLimits(
if (options?.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
const limits = await supplementOAuthUsageFromCli({
oauthLimits,
authPreparation: options?.authPreparation,
oauthCredentials,
attempts,
networkProxySettings: options?.networkProxySettings,
allowUsagePanelSupplement:
options?.allowUsagePanelSupplement ?? isManagedClaudeAuth(options?.authPreparation),
signal: options?.signal
})
if (options?.signal?.aborted) {
return abortedClaudeRateLimitResult()
}
return withClaudeUsageMetadata(
limits,
metadataForAttempt({
attemptedSources: attempts.attemptedSources,
oauthCredentials,
authPreparation: options?.authPreparation,
source: 'oauth'
})
)
return await completeOAuthUsageSuccess({ oauthLimits, oauthCredentials, attempts, options })
} catch (err) {
warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, err)
const classification = classifyClaudeOAuthUsageError(err)
if (
canRetryWithLegacyKeychainToken({
classification,
oauthCredentials,
authPreparation: options?.authPreparation
})
) {
const legacyResult = await retryOAuthWithLegacyKeychainToken({
failedToken: oauthCredentials.token,
attempts,
options
})
if (legacyResult) {
return legacyResult
}
}
if (shouldDeferForLiveClaude(options?.authPreparation, classification)) {
return liveClaudeDeferredResult({
attempts,