fix(auth): read tokens from profile configDir to fix 401 errors (#1385)
* fix(auth): read tokens from profile configDir to fix 401 errors The backend was ignoring the profile's configDir and always reading from the default Claude credential location (~/.claude/). This caused 401 errors when using multiple profiles since each profile stores its token in a separate directory. Changes: - Add CLAUDE_CONFIG_DIR to SDK_ENV_VARS for passthrough to SDK - Add _get_token_from_config_dir() to read from custom config directory - Update get_auth_token() to check CLAUDE_CONFIG_DIR env var - Update require_auth_token() to accept optional config_dir parameter - Add formatReleaseNotes() helper for app-updater release notes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(auth): address PR review findings for profile auth - Accept encrypted tokens (enc:) in _get_token_from_config_dir() - Extract _try_decrypt_token() helper to eliminate duplicated decrypt logic - Update get_auth_token_source() to report CLAUDE_CONFIG_DIR source - Reuse formatReleaseNotes result in app-updater update-downloaded handler * fix(auth): add config_dir parameter to get_auth_token_source() for API consistency --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Alex <63423455+AlexMadera@users.noreply.github.com>
This commit is contained in:
+122
-28
@@ -60,6 +60,8 @@ SDK_ENV_VARS = [
|
||||
"CLAUDE_CODE_GIT_BASH_PATH",
|
||||
# Claude CLI path override (allows frontend to pass detected CLI path to SDK)
|
||||
"CLAUDE_CLI_PATH",
|
||||
# Profile's custom config directory (for multi-profile token storage)
|
||||
"CLAUDE_CONFIG_DIR",
|
||||
]
|
||||
|
||||
|
||||
@@ -314,6 +316,36 @@ def _decrypt_token_windows(encrypted_data: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _try_decrypt_token(token: str | None) -> str | None:
|
||||
"""
|
||||
Attempt to decrypt an encrypted token, returning original if decryption fails.
|
||||
|
||||
This helper centralizes the decrypt-or-return-as-is logic used when resolving
|
||||
tokens from various sources (env vars, config dir, keychain).
|
||||
|
||||
Args:
|
||||
token: Token string (may be encrypted with "enc:" prefix, plaintext, or None)
|
||||
|
||||
Returns:
|
||||
- Decrypted token if successfully decrypted
|
||||
- Original token if decryption fails (allows client validation to report error)
|
||||
- Original token if not encrypted
|
||||
- None if token is None
|
||||
"""
|
||||
if not token:
|
||||
return None
|
||||
|
||||
if is_encrypted_token(token):
|
||||
try:
|
||||
return decrypt_token(token)
|
||||
except ValueError:
|
||||
# Decryption failed - return encrypted token so client validation
|
||||
# (validate_token_not_encrypted) can provide specific error message.
|
||||
return token
|
||||
|
||||
return token
|
||||
|
||||
|
||||
def get_token_from_keychain() -> str | None:
|
||||
"""
|
||||
Get authentication token from system credential store.
|
||||
@@ -478,14 +510,65 @@ def _get_token_from_linux_secret_service() -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_auth_token() -> str | None:
|
||||
def _get_token_from_config_dir(config_dir: str) -> str | None:
|
||||
"""
|
||||
Get authentication token from environment variables or system credential store.
|
||||
Read token from a custom config directory's credentials file.
|
||||
|
||||
Claude Code stores credentials in .credentials.json within the config directory.
|
||||
This function reads from a profile's custom configDir instead of the default location.
|
||||
|
||||
Args:
|
||||
config_dir: Path to the config directory (e.g., ~/.auto-claude/profiles/work)
|
||||
|
||||
Returns:
|
||||
Token string if found, None otherwise
|
||||
"""
|
||||
# Expand ~ if present
|
||||
expanded_dir = os.path.expanduser(config_dir)
|
||||
|
||||
# Claude stores credentials in these files within the config dir
|
||||
cred_files = [
|
||||
os.path.join(expanded_dir, ".credentials.json"),
|
||||
os.path.join(expanded_dir, "credentials.json"),
|
||||
]
|
||||
|
||||
for cred_path in cred_files:
|
||||
if os.path.exists(cred_path):
|
||||
try:
|
||||
with open(cred_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Try both credential structures
|
||||
oauth_data = data.get("claudeAiOauth") or data.get("oauthAccount") or {}
|
||||
token = oauth_data.get("accessToken")
|
||||
|
||||
# Accept both plaintext tokens (sk-ant-oat01-) and encrypted tokens (enc:)
|
||||
if token and (
|
||||
token.startswith("sk-ant-oat01-") or token.startswith("enc:")
|
||||
):
|
||||
logger.debug(f"Found token in {cred_path}")
|
||||
return token
|
||||
except (json.JSONDecodeError, KeyError, Exception) as e:
|
||||
logger.debug(f"Failed to read {cred_path}: {e}")
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_auth_token(config_dir: str | None = None) -> str | None:
|
||||
"""
|
||||
Get authentication token from environment variables or credential store.
|
||||
|
||||
Args:
|
||||
config_dir: Optional custom config directory (profile's configDir).
|
||||
If provided, reads credentials from this directory.
|
||||
If None, checks CLAUDE_CONFIG_DIR env var, then uses default locations.
|
||||
|
||||
Checks multiple sources in priority order:
|
||||
1. CLAUDE_CODE_OAUTH_TOKEN (env var)
|
||||
2. ANTHROPIC_AUTH_TOKEN (CCR/proxy env var for enterprise setups)
|
||||
3. System credential store (macOS Keychain, Windows Credential Manager, Linux Secret Service)
|
||||
3. Custom config directory (config_dir param or CLAUDE_CONFIG_DIR env var)
|
||||
4. System credential store (macOS Keychain, Windows Credential Manager, Linux Secret Service)
|
||||
|
||||
NOTE: ANTHROPIC_API_KEY is intentionally NOT supported to prevent
|
||||
silent billing to user's API credits when OAuth is misconfigured.
|
||||
@@ -496,40 +579,46 @@ def get_auth_token() -> str | None:
|
||||
Returns:
|
||||
Token string if found, None otherwise
|
||||
"""
|
||||
# First check environment variables
|
||||
# First check environment variables (highest priority)
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
token = os.environ.get(var)
|
||||
if token:
|
||||
# Decrypt if token is encrypted
|
||||
if is_encrypted_token(token):
|
||||
try:
|
||||
token = decrypt_token(token)
|
||||
except ValueError:
|
||||
# Decryption failed - return encrypted token so client validation
|
||||
# can provide specific error message about encrypted format
|
||||
return token
|
||||
return token
|
||||
return _try_decrypt_token(token)
|
||||
|
||||
# Fallback to system credential store
|
||||
token = get_token_from_keychain()
|
||||
if token and is_encrypted_token(token):
|
||||
try:
|
||||
token = decrypt_token(token)
|
||||
except ValueError:
|
||||
# Decryption failed - return encrypted token so client validation
|
||||
# (validate_token_not_encrypted) can provide specific error message.
|
||||
# This is consistent with env var handling above.
|
||||
return token
|
||||
return token
|
||||
# Check CLAUDE_CONFIG_DIR environment variable (profile's custom config directory)
|
||||
env_config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
|
||||
effective_config_dir = config_dir or env_config_dir
|
||||
|
||||
# If a custom config directory is specified, read from there
|
||||
if effective_config_dir:
|
||||
token = _get_token_from_config_dir(effective_config_dir)
|
||||
if token:
|
||||
return _try_decrypt_token(token)
|
||||
|
||||
# Fallback to system credential store (default locations)
|
||||
return _try_decrypt_token(get_token_from_keychain())
|
||||
|
||||
|
||||
def get_auth_token_source() -> str | None:
|
||||
"""Get the name of the source that provided the auth token."""
|
||||
def get_auth_token_source(config_dir: str | None = None) -> str | None:
|
||||
"""
|
||||
Get the name of the source that provided the auth token.
|
||||
|
||||
Args:
|
||||
config_dir: Optional custom config directory (profile's configDir).
|
||||
If provided, checks this directory for credentials.
|
||||
If None, checks CLAUDE_CONFIG_DIR env var.
|
||||
"""
|
||||
# Check environment variables first
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
if os.environ.get(var):
|
||||
return var
|
||||
|
||||
# Check if token came from custom config directory (profile's configDir)
|
||||
env_config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
|
||||
effective_config_dir = config_dir or env_config_dir
|
||||
if effective_config_dir and _get_token_from_config_dir(effective_config_dir):
|
||||
return "CLAUDE_CONFIG_DIR"
|
||||
|
||||
# Check if token came from system credential store
|
||||
if get_token_from_keychain():
|
||||
if is_macos():
|
||||
@@ -542,14 +631,19 @@ def get_auth_token_source() -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def require_auth_token() -> str:
|
||||
def require_auth_token(config_dir: str | None = None) -> str:
|
||||
"""
|
||||
Get authentication token or raise ValueError.
|
||||
|
||||
Args:
|
||||
config_dir: Optional custom config directory (profile's configDir).
|
||||
If provided, reads credentials from this directory.
|
||||
If None, checks CLAUDE_CONFIG_DIR env var, then uses default locations.
|
||||
|
||||
Raises:
|
||||
ValueError: If no auth token is found in any supported source
|
||||
"""
|
||||
token = get_auth_token()
|
||||
token = get_auth_token(config_dir)
|
||||
if not token:
|
||||
error_msg = (
|
||||
"No OAuth token found.\n\n"
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import type { UpdateInfo } from 'electron-updater';
|
||||
import { app, net } from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { IPC_CHANNELS } from '../shared/constants';
|
||||
@@ -41,6 +42,41 @@ type UpdateChannel = 'latest' | 'beta';
|
||||
// Store interval ID for cleanup during shutdown
|
||||
let periodicCheckIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/**
|
||||
* Convert releaseNotes from electron-updater to a markdown string.
|
||||
* releaseNotes can be:
|
||||
* - string: Return as-is
|
||||
* - ReleaseNoteInfo[]: Convert to markdown with version headers
|
||||
* - null/undefined: Return undefined
|
||||
*/
|
||||
function formatReleaseNotes(releaseNotes: UpdateInfo['releaseNotes']): string | undefined {
|
||||
if (!releaseNotes) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If it's already a string, return as-is
|
||||
if (typeof releaseNotes === 'string') {
|
||||
return releaseNotes;
|
||||
}
|
||||
|
||||
// It's an array of ReleaseNoteInfo objects
|
||||
// Format: [{ version: "1.0.0", note: "changes..." }, ...]
|
||||
if (Array.isArray(releaseNotes)) {
|
||||
const formattedNotes = releaseNotes
|
||||
.filter(item => item.note) // Only include entries with notes
|
||||
.map(item => {
|
||||
// Each item has version and note properties
|
||||
const versionHeader = item.version ? `## ${item.version}\n` : '';
|
||||
return `${versionHeader}${item.note}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
return formattedNotes || undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the update channel for electron-updater.
|
||||
* - 'latest': Only receive stable releases (default)
|
||||
@@ -107,7 +143,7 @@ export function initializeAppUpdater(window: BrowserWindow, betaUpdates = false)
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.APP_UPDATE_AVAILABLE, {
|
||||
version: info.version,
|
||||
releaseNotes: info.releaseNotes,
|
||||
releaseNotes: formatReleaseNotes(info.releaseNotes),
|
||||
releaseDate: info.releaseDate
|
||||
});
|
||||
}
|
||||
@@ -117,18 +153,14 @@ export function initializeAppUpdater(window: BrowserWindow, betaUpdates = false)
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
console.warn('[app-updater] Update downloaded:', info.version);
|
||||
// Store downloaded update info so it persists across Settings page navigations
|
||||
// releaseNotes can be string | ReleaseNoteInfo[] | null | undefined, only use if string
|
||||
downloadedUpdateInfo = {
|
||||
version: info.version,
|
||||
releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined,
|
||||
releaseNotes: formatReleaseNotes(info.releaseNotes),
|
||||
releaseDate: info.releaseDate
|
||||
};
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.APP_UPDATE_DOWNLOADED, {
|
||||
version: info.version,
|
||||
releaseNotes: info.releaseNotes,
|
||||
releaseDate: info.releaseDate
|
||||
});
|
||||
// Reuse downloadedUpdateInfo instead of calling formatReleaseNotes again
|
||||
mainWindow.webContents.send(IPC_CHANNELS.APP_UPDATE_DOWNLOADED, downloadedUpdateInfo);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -231,10 +263,9 @@ export async function checkForUpdates(): Promise<AppUpdateInfo | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
// releaseNotes can be string | ReleaseNoteInfo[] | null | undefined, only use if string
|
||||
return {
|
||||
version: result.updateInfo.version,
|
||||
releaseNotes: typeof result.updateInfo.releaseNotes === 'string' ? result.updateInfo.releaseNotes : undefined,
|
||||
releaseNotes: formatReleaseNotes(result.updateInfo.releaseNotes),
|
||||
releaseDate: result.updateInfo.releaseDate
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user