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:
Andy
2026-01-21 09:30:42 +01:00
committed by GitHub
parent 7dcb7bbe89
commit 55857d6dc3
2 changed files with 163 additions and 38 deletions
+122 -28
View File
@@ -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"
+41 -10
View File
@@ -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) {