From 52e426a48854d9b629855f6891e037df50490e82 Mon Sep 17 00:00:00 2001 From: Andy <119136210+AndyMik90@users.noreply.github.com> Date: Tue, 27 Jan 2026 19:10:33 +0100 Subject: [PATCH] fix(claude-profile): preserve subscriptionType and rateLimitTier during token refresh (#1556) When OAuth tokens were refreshed, the credential update functions were dropping the subscriptionType and rateLimitTier fields. This caused Claude Code to display "Cloud API" instead of "Claude Max Account" in terminals spawned inside Auto Claude. Changes: - Add subscriptionType and rateLimitTier to FullOAuthCredentials interface - Update extractFullCredentials to extract these fields from OAuth data - Update all platform update functions (macOS, Linux, Windows) to preserve these fields when writing refreshed tokens - Update all platform read functions to return these fields The subscription info is set during initial OAuth authentication and is NOT returned by the token refresh endpoint, so it must be preserved from the existing credentials when writing new tokens. Co-authored-by: Claude Opus 4.5 --- .../main/claude-profile/credential-utils.ts | 108 ++++++++++++------ 1 file changed, 70 insertions(+), 38 deletions(-) diff --git a/apps/frontend/src/main/claude-profile/credential-utils.ts b/apps/frontend/src/main/claude-profile/credential-utils.ts index 070cc28a..215b353a 100644 --- a/apps/frontend/src/main/claude-profile/credential-utils.ts +++ b/apps/frontend/src/main/claude-profile/credential-utils.ts @@ -81,6 +81,8 @@ export interface FullOAuthCredentials extends PlatformCredentials { refreshToken: string | null; expiresAt: number | null; // Unix timestamp in ms when access token expires scopes: string[] | null; + subscriptionType: string | null; // e.g., "max" for Claude Max subscription + rateLimitTier: string | null; // e.g., "default_claude_max_20x" } /** @@ -258,6 +260,8 @@ function extractFullCredentials(data: { refreshToken?: string; expiresAt?: number; scopes?: string[]; + subscriptionType?: string; + rateLimitTier?: string; }; email?: string }): { @@ -266,6 +270,8 @@ function extractFullCredentials(data: { refreshToken: string | null; expiresAt: number | null; scopes: string[] | null; + subscriptionType: string | null; + rateLimitTier: string | null; } { // Extract OAuth token from nested structure const token = data?.claudeAiOauth?.accessToken || null; @@ -282,7 +288,11 @@ function extractFullCredentials(data: { // Extract scopes (array of strings) const scopes = data?.claudeAiOauth?.scopes || null; - return { token, email, refreshToken, expiresAt, scopes }; + // Extract subscription info (determines "Max" vs "API" display in Claude Code) + const subscriptionType = data?.claudeAiOauth?.subscriptionType || null; + const rateLimitTier = data?.claudeAiOauth?.rateLimitTier || null; + + return { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier }; } /** @@ -995,7 +1005,7 @@ function getFullCredentialsFromMacOSKeychain(configDir?: string): FullOAuthCrede } if (!securityPath) { - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, error: 'macOS security command not found' }; + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: 'macOS security command not found' }; } try { @@ -1008,7 +1018,7 @@ function getFullCredentialsFromMacOSKeychain(configDir?: string): FullOAuthCrede ); // Parse and validate using shared helper - const { token, email, refreshToken, expiresAt, scopes } = parseCredentialJson( + const { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier } = parseCredentialJson( credentialsJson, `macOS:Full:${serviceName}`, extractFullCredentials @@ -1017,7 +1027,7 @@ function getFullCredentialsFromMacOSKeychain(configDir?: string): FullOAuthCrede // Validate token format if present if (token && !isValidTokenFormat(token)) { console.warn('[CredentialUtils:macOS:Full] Invalid token format for service:', serviceName); - return { token: null, email, refreshToken, expiresAt, scopes }; + return { token: null, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier }; } if (isDebug) { @@ -1026,15 +1036,17 @@ function getFullCredentialsFromMacOSKeychain(configDir?: string): FullOAuthCrede hasEmail: !!email, hasRefreshToken: !!refreshToken, expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, - tokenFingerprint: getTokenFingerprint(token) + tokenFingerprint: getTokenFingerprint(token), + subscriptionType, + rateLimitTier }); } - return { token, email, refreshToken, expiresAt, scopes }; + return { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier }; } catch (error) { // Unexpected error (executeCredentialRead already handles "not found" cases) const errorMessage = error instanceof Error ? error.message : String(error); console.warn('[CredentialUtils:macOS:Full] Keychain access failed for service:', serviceName, errorMessage); - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, error: `Keychain access failed: ${errorMessage}` }; + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: `Keychain access failed: ${errorMessage}` }; } } @@ -1051,7 +1063,7 @@ function getFullCredentialsFromLinuxSecretService(configDir?: string): FullOAuth if (isDebug) { console.warn('[CredentialUtils:Linux:SecretService:Full] secret-tool not found'); } - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, error: 'secret-tool not found' }; + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: 'secret-tool not found' }; } try { @@ -1064,7 +1076,7 @@ function getFullCredentialsFromLinuxSecretService(configDir?: string): FullOAuth ); // Parse and validate using shared helper - const { token, email, refreshToken, expiresAt, scopes } = parseCredentialJson( + const { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier } = parseCredentialJson( credentialsJson, `Linux:SecretService:Full:${attribute}`, extractFullCredentials @@ -1072,7 +1084,7 @@ function getFullCredentialsFromLinuxSecretService(configDir?: string): FullOAuth if (token && !isValidTokenFormat(token)) { console.warn('[CredentialUtils:Linux:SecretService:Full] Invalid token format for attribute:', attribute); - return { token: null, email, refreshToken, expiresAt, scopes }; + return { token: null, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier }; } if (isDebug) { @@ -1082,15 +1094,17 @@ function getFullCredentialsFromLinuxSecretService(configDir?: string): FullOAuth hasEmail: !!email, hasRefreshToken: !!refreshToken, expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, - tokenFingerprint: getTokenFingerprint(token) + tokenFingerprint: getTokenFingerprint(token), + subscriptionType, + rateLimitTier }); } - return { token, email, refreshToken, expiresAt, scopes }; + return { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier }; } catch (error) { // Unexpected error (executeCredentialRead already handles "not found" cases) const errorMessage = error instanceof Error ? error.message : String(error); console.warn('[CredentialUtils:Linux:SecretService:Full] Secret Service access failed:', errorMessage); - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, error: `Secret Service access failed: ${errorMessage}` }; + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: `Secret Service access failed: ${errorMessage}` }; } } @@ -1129,7 +1143,7 @@ function getFullCredentialsFromLinuxFile(configDir?: string): FullOAuthCredentia if (isDebug) { console.warn('[CredentialUtils:Linux:Full] Invalid credentials path rejected:', { credentialsPath }); } - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, error: 'Invalid credentials path' }; + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: 'Invalid credentials path' }; } // Check if credentials file exists @@ -1137,7 +1151,7 @@ function getFullCredentialsFromLinuxFile(configDir?: string): FullOAuthCredentia if (isDebug) { console.warn('[CredentialUtils:Linux:Full] Credentials file not found:', credentialsPath); } - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null }; + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null }; } try { @@ -1149,21 +1163,21 @@ function getFullCredentialsFromLinuxFile(configDir?: string): FullOAuthCredentia data = JSON.parse(content); } catch { console.warn('[CredentialUtils:Linux:Full] Failed to parse credentials JSON:', credentialsPath); - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null }; + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null }; } // Validate JSON structure if (!validateCredentialData(data)) { console.warn('[CredentialUtils:Linux:Full] Invalid credentials data structure:', credentialsPath); - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null }; + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null }; } - const { token, email, refreshToken, expiresAt, scopes } = extractFullCredentials(data); + const { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier } = extractFullCredentials(data); // Validate token format if present if (token && !isValidTokenFormat(token)) { console.warn('[CredentialUtils:Linux:Full] Invalid token format in:', credentialsPath); - return { token: null, email, refreshToken, expiresAt, scopes }; + return { token: null, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier }; } if (isDebug) { @@ -1172,14 +1186,16 @@ function getFullCredentialsFromLinuxFile(configDir?: string): FullOAuthCredentia hasEmail: !!email, hasRefreshToken: !!refreshToken, expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, - tokenFingerprint: getTokenFingerprint(token) + tokenFingerprint: getTokenFingerprint(token), + subscriptionType, + rateLimitTier }); } - return { token, email, refreshToken, expiresAt, scopes }; + return { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.warn('[CredentialUtils:Linux:Full] Failed to read credentials file:', credentialsPath, errorMessage); - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, error: `Failed to read credentials: ${errorMessage}` }; + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: `Failed to read credentials: ${errorMessage}` }; } } @@ -1192,7 +1208,7 @@ function getFullCredentialsFromWindowsCredentialManager(configDir?: string): Ful // Defense-in-depth: Validate target name format before using in PowerShell if (!isValidTargetName(targetName)) { - const invalidResult = { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, error: 'Invalid credential target name format' }; + const invalidResult = { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: 'Invalid credential target name format' }; if (isDebug) { console.warn('[CredentialUtils:Windows:Full] Invalid target name rejected:', { targetName }); } @@ -1202,7 +1218,7 @@ function getFullCredentialsFromWindowsCredentialManager(configDir?: string): Ful // Find PowerShell executable const psPath = findPowerShellPath(); if (!psPath) { - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, error: 'PowerShell not found' }; + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: 'PowerShell not found' }; } try { @@ -1259,7 +1275,7 @@ function getFullCredentialsFromWindowsCredentialManager(configDir?: string): Ful const credentialsJson = result.trim() || null; // Parse and validate using shared helper - const { token, email, refreshToken, expiresAt, scopes } = parseCredentialJson( + const { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier } = parseCredentialJson( credentialsJson, `Windows:Full:${targetName}`, extractFullCredentials @@ -1268,7 +1284,7 @@ function getFullCredentialsFromWindowsCredentialManager(configDir?: string): Ful // Validate token format if present if (token && !isValidTokenFormat(token)) { console.warn('[CredentialUtils:Windows:Full] Invalid token format for target:', targetName); - return { token: null, email, refreshToken, expiresAt, scopes }; + return { token: null, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier }; } if (isDebug) { @@ -1277,14 +1293,16 @@ function getFullCredentialsFromWindowsCredentialManager(configDir?: string): Ful hasEmail: !!email, hasRefreshToken: !!refreshToken, expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, - tokenFingerprint: getTokenFingerprint(token) + tokenFingerprint: getTokenFingerprint(token), + subscriptionType, + rateLimitTier }); } - return { token, email, refreshToken, expiresAt, scopes }; + return { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.warn('[CredentialUtils:Windows:Full] Credential Manager access failed for target:', targetName, errorMessage); - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, error: `Credential Manager access failed: ${errorMessage}` }; + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: `Credential Manager access failed: ${errorMessage}` }; } } @@ -1310,7 +1328,7 @@ export function getFullCredentialsFromKeychain(configDir?: string): FullOAuthCre } // Unknown platform - return empty - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, error: `Unsupported platform: ${process.platform}` }; + return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: `Unsupported platform: ${process.platform}` }; } /** @@ -1344,10 +1362,13 @@ function updateMacOSKeychainCredentials( } try { - // Read existing credentials to preserve email + // Read existing credentials to preserve email, subscriptionType, and rateLimitTier const existing = getFullCredentialsFromMacOSKeychain(configDir); // Build new credential JSON with all fields + // IMPORTANT: Preserve subscriptionType and rateLimitTier from existing credentials + // These fields determine "Max" vs "API" display in Claude Code and are NOT returned + // by the OAuth token refresh endpoint - they must be preserved from the original auth. const newCredentialData = { claudeAiOauth: { accessToken: credentials.accessToken, @@ -1355,7 +1376,9 @@ function updateMacOSKeychainCredentials( expiresAt: credentials.expiresAt, scopes: credentials.scopes || existing.scopes || [], email: existing.email || undefined, - emailAddress: existing.email || undefined + emailAddress: existing.email || undefined, + subscriptionType: existing.subscriptionType || undefined, + rateLimitTier: existing.rateLimitTier || undefined }, email: existing.email || undefined }; @@ -1444,10 +1467,11 @@ function updateLinuxSecretServiceCredentials( } try { - // Read existing credentials to preserve email + // Read existing credentials to preserve email, subscriptionType, and rateLimitTier const existing = getFullCredentialsFromLinuxSecretService(configDir); // Build new credential JSON with all fields + // IMPORTANT: Preserve subscriptionType and rateLimitTier from existing credentials const newCredentialData = { claudeAiOauth: { accessToken: credentials.accessToken, @@ -1455,7 +1479,9 @@ function updateLinuxSecretServiceCredentials( expiresAt: credentials.expiresAt, scopes: credentials.scopes || existing.scopes || [], email: existing.email || undefined, - emailAddress: existing.email || undefined + emailAddress: existing.email || undefined, + subscriptionType: existing.subscriptionType || undefined, + rateLimitTier: existing.rateLimitTier || undefined }, email: existing.email || undefined }; @@ -1542,10 +1568,11 @@ function updateLinuxFileCredentials( } try { - // Read existing credentials to preserve email and other fields + // Read existing credentials to preserve email, subscriptionType, and rateLimitTier const existing = getFullCredentialsFromLinuxFile(configDir); // Build new credential JSON with all fields + // IMPORTANT: Preserve subscriptionType and rateLimitTier from existing credentials const newCredentialData = { claudeAiOauth: { accessToken: credentials.accessToken, @@ -1553,7 +1580,9 @@ function updateLinuxFileCredentials( expiresAt: credentials.expiresAt, scopes: credentials.scopes || existing.scopes || [], email: existing.email || undefined, - emailAddress: existing.email || undefined + emailAddress: existing.email || undefined, + subscriptionType: existing.subscriptionType || undefined, + rateLimitTier: existing.rateLimitTier || undefined }, email: existing.email || undefined }; @@ -1605,10 +1634,11 @@ function updateWindowsCredentialManagerCredentials( } try { - // Read existing credentials to preserve email + // Read existing credentials to preserve email, subscriptionType, and rateLimitTier const existing = getFullCredentialsFromWindowsCredentialManager(configDir); // Build new credential JSON with all fields + // IMPORTANT: Preserve subscriptionType and rateLimitTier from existing credentials const newCredentialData = { claudeAiOauth: { accessToken: credentials.accessToken, @@ -1616,7 +1646,9 @@ function updateWindowsCredentialManagerCredentials( expiresAt: credentials.expiresAt, scopes: credentials.scopes || existing.scopes || [], email: existing.email || undefined, - emailAddress: existing.email || undefined + emailAddress: existing.email || undefined, + subscriptionType: existing.subscriptionType || undefined, + rateLimitTier: existing.rateLimitTier || undefined }, email: existing.email || undefined };