Guard both credential stores, and tell a foreign login from an unreadable one

The refusal added for the scoped store did not cover the home's credentials
file, and went vacuously false when the scoped item was empty — the exact state
a Keychain outage leaves behind, with the CLI's rotation in the file. Recovery
then seeded the empty item with the pre-outage copy, which the CLI reads first,
so it shadowed the live file and the next refresh replayed a spent token.

The same refusal also swallowed the foreign-login revert, because a fresh login
by another identity is always the fresher credential. Refusing to overwrite it
left every pane on the account running someone else's identity. The two causes
are now separated by whether the home's identity record actually proves a
different account: provable means revert, unreadable means hold off.

Two assertions in this file pinned the flat harness field the bridge stopped
reading, so a real regression stayed green; both now assert the keyed channel.

Claude-Session: https://claude.ai/code/session_012E63B2jhajtUbArQHhZjVQ
This commit is contained in:
Merge Sim
2026-09-02 02:53:31 -07:00
parent b09a262237
commit c1bf80fb4f
3 changed files with 172 additions and 19 deletions
@@ -88,13 +88,15 @@ describe('ClaudeRuntimeAuthService', () => {
await service.prepareForClaudeLaunch()
testState.managedKeychainCredentials.set('account-1', freshCredentials)
testState.scopedKeychainCredentials = staleCredentials
setScopedKeychainCredentialsForManagedPath(managedAuthPath, staleCredentials)
service.clearLastWrittenCredentialsJson('account-1')
await service.prepareForClaudeLaunch()
expect(testState.managedKeychainCredentials.get('account-1')).toBe(freshCredentials)
expect(testState.scopedKeychainCredentials).toBe(freshCredentials)
expect(testState.scopedKeychainCredentialsByConfigDir.get(realpathSync(managedAuthPath))).toBe(
freshCredentials
)
})
it('does not import a different identity from the scoped Keychain item', async () => {
@@ -134,7 +136,9 @@ describe('ClaudeRuntimeAuthService', () => {
await service.prepareForClaudeLaunch()
expect(testState.managedKeychainCredentials.get('account-1')).toBe(managedCredentials)
expect(testState.scopedKeychainCredentials).toBe(managedCredentials)
expect(testState.scopedKeychainCredentialsByConfigDir.get(realpathSync(managedAuthPath))).toBe(
managedCredentials
)
})
it('does not import an older same-identity scoped Keychain credential', async () => {
@@ -346,6 +350,102 @@ describe('ClaudeRuntimeAuthService', () => {
expect(existsSync(join(managedAuthPath, '.credentials.json'))).toBe(false)
})
// Why: an outage leaves the primary empty and the rotation in the file; seeding the primary with
// the pre-outage copy would shadow the live file, and the CLI reads the primary first.
it('does not seed an empty scoped item while a fresher file rotation is live', async () => {
if (process.platform !== 'darwin') {
return
}
const staleCredentials = createClaudeCredentialsWithoutEmail('stale', null, {
expiresAt: 2_000,
refreshToken: 'stale-refresh'
})
const rotatedCredentials = createClaudeCredentialsWithoutEmail('rotated', null, {
expiresAt: 3_000,
refreshToken: 'rotated-refresh'
})
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
staleCredentials
)
const store = createStore(
createSettings({
claudeManagedAccounts: [
createClaudeAccount('account-1', managedAuthPath, { managedAuthRuntime: 'host' })
],
activeClaudeManagedAccountId: 'account-1'
})
)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
await service.prepareForClaudeLaunch()
// Outage: the CLI dropped the Keychain item and rotated into the file, and its identity
// record is mid-rewrite so the rotation cannot yet be proven to belong to this account.
testState.scopedKeychainCredentialsByConfigDir.delete(realpathSync(managedAuthPath))
writeFileSync(join(managedAuthPath, '.credentials.json'), rotatedCredentials, 'utf-8')
writeFileSync(join(managedAuthPath, '.claude.json'), '{"oauthAccount":', 'utf-8')
await service.prepareForClaudeLaunch()
expect(
testState.scopedKeychainCredentialsByConfigDir.get(realpathSync(managedAuthPath))
).toBeUndefined()
expect(readFileSync(join(managedAuthPath, '.credentials.json'), 'utf-8')).toBe(
rotatedCredentials
)
})
// Why: a login the home's identity record proves is someone else's is not a rotation to keep —
// the selected account must win, and discarding a foreign token replays nothing.
it('re-imposes the account credential over a provably foreign in-pane login', async () => {
if (process.platform !== 'darwin') {
return
}
const managedCredentials = createClaudeCredentialsWithoutEmail('managed', null, {
expiresAt: 2_000,
refreshToken: 'managed-refresh'
})
const foreignCredentials = createClaudeCredentialsWithoutEmail('foreign', null, {
expiresAt: 5_000,
refreshToken: 'foreign-refresh'
})
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'account-1',
managedCredentials
)
const store = createStore(
createSettings({
claudeManagedAccounts: [
createClaudeAccount('account-1', managedAuthPath, { managedAuthRuntime: 'host' })
],
activeClaudeManagedAccountId: 'account-1'
})
)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
await service.prepareForClaudeLaunch()
// A different identity logged in inside the managed pane, and said so in the home's record.
setScopedKeychainCredentialsForManagedPath(managedAuthPath, foreignCredentials)
writeFileSync(
join(managedAuthPath, '.claude.json'),
JSON.stringify({
oauthAccount: { accountUuid: 'account-2', emailAddress: 'other@example.com' }
}),
'utf-8'
)
await service.prepareForClaudeLaunch()
expect(testState.managedKeychainCredentials.get('account-1')).toBe(managedCredentials)
expect(testState.scopedKeychainCredentialsByConfigDir.get(realpathSync(managedAuthPath))).toBe(
managedCredentials
)
})
// Why: a torn .claude.json makes identity unprovable; the CLI's newer rotation must still not be
// overwritten with an already-consumed refresh token.
it('does not overwrite a fresher scoped credential when identity cannot be proven', async () => {
@@ -88,12 +88,13 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync {
// below repair any stale Keychain item left by the previous login.
this.skipNextReadBackForAccountId = null
}
// Both stores in the managed home are real credential sources: the CLI reads the
// Keychain first and falls back to the file, and persists rotations to whichever it
// can write.
const runtimeOauthAccount = this.readRuntimeOauthAccount(preparation.configDir)
const fileCandidate = await this.readManagedCredentialsFileCandidate(selected)
if (!skipScopedReadBack) {
// Both stores in the managed home are real credential sources: the CLI reads the
// Keychain first and falls back to the file, and persists rotations to whichever it
// can write. Adopt the freshest that proves it belongs to this account.
const runtimeOauthAccount = this.readRuntimeOauthAccount(preparation.configDir)
const fileCandidate = await this.readManagedCredentialsFileCandidate(selected)
// Adopt the freshest candidate that proves it belongs to this account.
for (const candidate of [scoped, fileCandidate]) {
if (!candidate || !this.isValidCredentialsJsonObject(candidate)) {
continue
@@ -121,19 +122,38 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync {
}
}
const managed = await this.readManagedCredentials(selected)
// Why: a scoped credential we could not positively match may still be the CLI's newer
// rotation; overwriting it would hand the next reader an already-consumed refresh token.
const wouldOverwriteFresherScoped =
scoped !== null &&
scoped !== managed &&
this.isValidCredentialsJsonObject(scoped) &&
// A login the home's own identity record proves belongs to someone else is not a rotation
// to preserve: discarding it replays nothing, and the selected account must win.
const foreignLogin = [scoped, fileCandidate].some(
(candidate) =>
candidate !== null &&
this.isValidCredentialsJsonObject(candidate) &&
candidate !== managed &&
this.runtimeIdentityIsProvablyForeign(runtimeOauthAccount, selected, candidate)
)
// Otherwise an unadopted candidate may be the CLI's newer rotation, in either store.
// Overwriting it would hand the next reader an already-consumed refresh token, and the
// CLI reads the Keychain first, so seeding an empty item shadows a live file too.
const unadoptedFresher =
managed !== null &&
this.runtimeCredentialsAreFresher(scoped, managed)
if (managed && !wouldOverwriteFresherScoped && this.isValidCredentialsJsonObject(managed)) {
[scoped, fileCandidate].some(
(candidate) =>
candidate !== null &&
candidate !== managed &&
this.isValidCredentialsJsonObject(candidate) &&
this.runtimeCredentialsAreFresher(candidate, managed)
)
const wouldShadowLiveFile =
scoped === null &&
fileCandidate !== null &&
fileCandidate !== managed &&
this.isValidCredentialsJsonObject(fileCandidate)
const refuseManagedWrite = !foreignLogin && (unadoptedFresher || wouldShadowLiveFile)
if (managed && !refuseManagedWrite && this.isValidCredentialsJsonObject(managed)) {
await writeActiveClaudeKeychainCredentials(managed, preparation.configDir)
} else if (wouldOverwriteFresherScoped) {
} else if (refuseManagedWrite) {
console.warn(
'[claude-runtime-auth] Refusing to overwrite a fresher scoped Claude credential'
'[claude-runtime-auth] Refusing to overwrite an unadopted Claude credential rotation'
)
}
if (this.managedKeychainUnavailable?.accountId === selected.id) {
@@ -1,6 +1,6 @@
import type { ClaudeManagedAccount } from '../../../shared/managed-account-types'
import { ClaudeRuntimeAuthRuntimeState } from './runtime-auth-runtime-state'
import type { ClaudeReadBackMatch } from './runtime-auth-types'
import { RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR, type ClaudeReadBackMatch } from './runtime-auth-types'
export class ClaudeRuntimeAuthCredentialMatching extends ClaudeRuntimeAuthRuntimeState {
protected async findManagedAccountForRuntimeCredentials(
@@ -117,6 +117,39 @@ export class ClaudeRuntimeAuthCredentialMatching extends ClaudeRuntimeAuthRuntim
return 'match'
}
// Why: an unreadable identity record and one naming another account demand opposite responses —
// hold off when we cannot tell, revert when we can.
// Why: an unreadable identity record and one naming another account demand opposite responses —
// hold off when we cannot tell, revert when we can. Identity may be provable from the credential
// blob or from the home's own record, so consult both.
protected runtimeIdentityIsProvablyForeign(
runtimeOauthAccount: unknown,
account: ClaudeManagedAccount,
credentialsJson: string | null = null
): boolean {
const accountEmail = this.normalizeField(account.email)
const accountOrganizationUuid = this.normalizeField(account.organizationUuid)
const identities = [
credentialsJson === null ? null : this.readIdentityFromCredentials(credentialsJson),
runtimeOauthAccount === RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR || !runtimeOauthAccount
? null
: this.readIdentityFromOauthAccount(runtimeOauthAccount)
]
return identities.some((identity) => {
if (identity === null) {
return false
}
if (identity.email !== null && accountEmail !== null && identity.email !== accountEmail) {
return true
}
return (
identity.organizationUuid !== null &&
accountOrganizationUuid !== null &&
identity.organizationUuid !== accountOrganizationUuid
)
})
}
protected liveRuntimeCredentialsCanUpdateActiveAccount(
runtimeCredentialsJson: string,
account: ClaudeManagedAccount,