Fall back to legacy keychain when stale scoped Claude credentials 401 (#8767)

* 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.

* Pin WSL-gate and same-token short-circuit for legacy keychain retry

The legacy-keychain retry must never answer a WSL target with the host
user's keychain account, and must not double the usage request when the
legacy item mirrors the failed scoped token. Add tests locking both.
This commit is contained in:
Brennan Benson 2026-07-14 15:28:22 -07:00 committed by GitHub
parent ba5fa7d909
commit 792e113729
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 268 additions and 25 deletions

View File

@ -178,6 +178,169 @@ 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('does not retry with the legacy keychain for WSL targets', async () => {
// Why: a WSL target's stale credentials must never be answered with the
// host user's legacy macOS Keychain account.
const configDir = '\\\\wsl$\\Ubuntu\\home\\test\\.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
runtime: 'wsl',
wslDistro: 'Ubuntu',
wslLinuxConfigDir: '/home/test/.claude',
envPatch: { CLAUDE_CONFIG_DIR: '/home/test/.claude' },
stripAuthEnv: false,
provenance: 'system'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (dir) =>
dir
? JSON.stringify({ claudeAiOauth: { accessToken: 'stale-wsl-token' } })
: JSON.stringify({ claudeAiOauth: { accessToken: 'fresh-legacy-token' } })
)
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('skips the legacy retry when the legacy item mirrors the failed scoped token', async () => {
// Why: Claude's usage endpoint has a tight request budget; retrying the
// identical token would double the request for a guaranteed second 401.
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
envPatch: { CLAUDE_CONFIG_DIR: configDir },
stripAuthEnv: false,
provenance: 'system'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue(
JSON.stringify({ claudeAiOauth: { accessToken: 'mirrored-token' } })
)
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).toHaveBeenCalledWith(undefined)
})
it('accepts Claude Code statusline-style rate limit window fields', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
@ -734,6 +897,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: {

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,