diff --git a/src/main/claude-accounts/runtime-auth-service-managed-keychain-bridge.test.ts b/src/main/claude-accounts/runtime-auth-service-managed-keychain-bridge.test.ts index 4db9d31437b..f2e3056ce71 100644 --- a/src/main/claude-accounts/runtime-auth-service-managed-keychain-bridge.test.ts +++ b/src/main/claude-accounts/runtime-auth-service-managed-keychain-bridge.test.ts @@ -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 () => { diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index b0d8534e52d..ff00dd1dbca 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -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) { diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-credential-matching.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-credential-matching.ts index 390ec57a8b2..4acf940a731 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-credential-matching.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-credential-matching.ts @@ -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,