From fc82a914ffe712f829dfe06622f6b9f12ad8ec3a Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 19 May 2026 21:05:10 -0400 Subject: [PATCH] Fix Claude managed token refresh read-back (#2372) Co-authored-by: Orca --- .../runtime-auth-service.test.ts | 93 ++++++++++++++++++- .../claude-accounts/runtime-auth-service.ts | 37 +++++++- 2 files changed, 125 insertions(+), 5 deletions(-) diff --git a/src/main/claude-accounts/runtime-auth-service.test.ts b/src/main/claude-accounts/runtime-auth-service.test.ts index 1f77f2a77..233db78d2 100644 --- a/src/main/claude-accounts/runtime-auth-service.test.ts +++ b/src/main/claude-accounts/runtime-auth-service.test.ts @@ -217,14 +217,15 @@ function createClaudeCredentialsJson( function createClaudeCredentialsWithoutEmail( accessToken: string, - organizationUuid: string | null = null + organizationUuid: string | null = null, + options: { expiresAt?: number; refreshToken?: string } = {} ): string { return `${JSON.stringify({ claudeAiOauth: { ...(organizationUuid ? { organizationUuid } : {}), accessToken, - refreshToken: `${accessToken}-refresh`, - expiresAt: Date.now() + 60_000 + refreshToken: options.refreshToken ?? `${accessToken}-refresh`, + expiresAt: options.expiresAt ?? Date.now() + 60_000 } })}\n` } @@ -1096,6 +1097,92 @@ describe('ClaudeRuntimeAuthService', () => { expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(originalCredentials) }) + it('reads back identity-less refreshed credentials when the refresh token matches', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const refreshToken = 'same-managed-refresh-token' + const originalCredentials = createClaudeCredentialsWithoutEmail('original', null, { + expiresAt: 1_000, + refreshToken + }) + const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed', null, { + expiresAt: 2_000, + refreshToken + }) + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-from-account' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) + }) + + it('rules out other identity-less accounts with different refresh tokens', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const account1RefreshToken = 'account-1-refresh-token' + const account1OriginalCredentials = createClaudeCredentialsWithoutEmail('account-1', null, { + expiresAt: 1_000, + refreshToken: account1RefreshToken + }) + const account1RefreshedCredentials = createClaudeCredentialsWithoutEmail( + 'account-1-refreshed', + null, + { + expiresAt: 2_000, + refreshToken: account1RefreshToken + } + ) + const account2Credentials = createClaudeCredentialsWithoutEmail('account-2', null, { + refreshToken: 'account-2-refresh-token' + }) + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1OriginalCredentials + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + account2Credentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1), + createClaudeAccount('account-2', managedAuthPath2, { email: 'other@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, account1RefreshedCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe( + account1RefreshedCredentials + ) + expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1RefreshedCredentials) + }) + it('restores the system default after rejecting unverifiable managed credentials', async () => { const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 349bff071..f41a0fbc6 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -60,6 +60,7 @@ type ClaudeKeychainReadResult = type ClaudeKeychainSnapshotValue = | { status: 'captured'; credentialsJson: string | null } | { status: 'unknown' } +type ClaudeRefreshTokenComparison = 'same' | 'different' | 'missing' const RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR = Symbol('runtime-oauth-account-parse-error') @@ -517,14 +518,24 @@ export class ClaudeRuntimeAuthService { managedIdentity?.organizationUuid ?? managedOauthIdentity.organizationUuid ) + const refreshTokenComparison = this.compareRefreshTokens( + runtimeCredentialsJson, + managedCredentialsJson + ) if (!identity.email) { + if (refreshTokenComparison === 'same') { + return 'match' + } + if (!identity.organizationUuid && refreshTokenComparison === 'different') { + return 'mismatch' + } return 'unverifiable' } if (account.email && this.normalizeField(account.email) !== identity.email) { return 'mismatch' } if (selectedOrganizationUuid && !identity.organizationUuid) { - return 'unverifiable' + return refreshTokenComparison === 'same' ? 'match' : 'unverifiable' } if ( selectedOrganizationUuid && @@ -534,7 +545,7 @@ export class ClaudeRuntimeAuthService { return 'mismatch' } if (!selectedOrganizationUuid && identity.organizationUuid) { - return 'unverifiable' + return refreshTokenComparison === 'same' ? 'match' : 'unverifiable' } return 'match' @@ -656,6 +667,28 @@ export class ClaudeRuntimeAuthService { ) } + private compareRefreshTokens( + runtimeCredentialsJson: string, + managedCredentialsJson: string + ): ClaudeRefreshTokenComparison { + const runtimeRefreshToken = this.readRefreshTokenFromCredentials(runtimeCredentialsJson) + const managedRefreshToken = this.readRefreshTokenFromCredentials(managedCredentialsJson) + if (!runtimeRefreshToken || !managedRefreshToken) { + return 'missing' + } + return runtimeRefreshToken === managedRefreshToken ? 'same' : 'different' + } + + private readRefreshTokenFromCredentials(credentialsJson: string): string | null { + try { + const parsed = JSON.parse(credentialsJson) as Record + const oauth = this.asRecord(parsed.claudeAiOauth) + return this.normalizeField(this.readString(oauth, 'refreshToken')) + } catch { + return null + } + } + private readIdentityFromOauthAccount(oauthAccount: unknown): ClaudeAuthIdentity { const oauth = this.asRecord(oauthAccount) return {