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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user