From 55857d6dc3377e76500bebad9a6ccacd501dd3f6 Mon Sep 17 00:00:00 2001 From: Andy <119136210+AndyMik90@users.noreply.github.com> Date: Wed, 21 Jan 2026 09:30:42 +0100 Subject: [PATCH] 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 * 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 Co-authored-by: Alex <63423455+AlexMadera@users.noreply.github.com> --- apps/backend/core/auth.py | 150 +++++++++++++++++++++----- apps/frontend/src/main/app-updater.ts | 51 +++++++-- 2 files changed, 163 insertions(+), 38 deletions(-) diff --git a/apps/backend/core/auth.py b/apps/backend/core/auth.py index fc85a289..42e9d642 100644 --- a/apps/backend/core/auth.py +++ b/apps/backend/core/auth.py @@ -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" diff --git a/apps/frontend/src/main/app-updater.ts b/apps/frontend/src/main/app-updater.ts index ffab241e..d38b7115 100644 --- a/apps/frontend/src/main/app-updater.ts +++ b/apps/frontend/src/main/app-updater.ts @@ -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 | 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 { 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) {