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:
Andy
2026-01-27 19:10:33 +01:00
committed by GitHub
parent d8f00fe5ae
commit 52e426a488
@@ -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
};