Fix Claude managed token refresh read-back (#2372)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-19 21:05:10 -04:00 committed by GitHub
parent db3765fc17
commit fc82a914ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 125 additions and 5 deletions

View File

@ -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')

View File

@ -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<string, unknown>
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 {