From b5bd9b0d6897db78e39e3b89e2f1f83373a9f202 Mon Sep 17 00:00:00 2001 From: Finley Holt Date: Sun, 18 Jan 2026 15:38:38 -0800 Subject: [PATCH] fix(auth): add OAuth token format validation to prevent 401 errors OAuth tokens configured in profiles were not being validated, causing 401 "Invalid bearer token" errors when malformed or invalid tokens were stored. This was particularly problematic when profile tokens would overwrite valid tokens from .env files. Changes: - Add isValidTokenFormat() helper with token pattern validation - Update hasValidToken() to validate token format before use - Validate token format in setProfileToken() before storage - Validate decrypted tokens in getProfileEnv() and getActiveProfileEnv() - Return undefined instead of empty string on decryption failure - Add token source debug logging for troubleshooting Co-Authored-By: Claude Opus 4.5 --- apps/frontend/src/main/agent/agent-process.ts | 21 +++++++++++++++- .../src/main/claude-profile-manager.ts | 21 +++++++++++++--- .../src/main/claude-profile/profile-utils.ts | 24 ++++++++++++++++++- .../main/claude-profile/token-encryption.ts | 5 ++-- apps/frontend/src/main/rate-limit-detector.ts | 16 +++++++++---- apps/frontend/src/main/title-generator.ts | 13 ++++++---- 6 files changed, 84 insertions(+), 16 deletions(-) diff --git a/apps/frontend/src/main/agent/agent-process.ts b/apps/frontend/src/main/agent/agent-process.ts index 26e7337d88..7a6af0a7dd 100644 --- a/apps/frontend/src/main/agent/agent-process.ts +++ b/apps/frontend/src/main/agent/agent-process.ts @@ -162,6 +162,17 @@ export class AgentProcessManager { // are available even when app is launched from Finder/Dock const augmentedEnv = getAugmentedEnv(); + // Debug: Log OAuth token sources (when DEBUG=true or in development mode) + const DEBUG = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development'; + if (DEBUG) { + const maskToken = (t: string | undefined) => t ? `${t.substring(0, 15)}...` : 'none'; + console.log('[AgentProcess] Token sources:', { + processEnv: maskToken(process.env.CLAUDE_CODE_OAUTH_TOKEN), + extraEnv: maskToken(extraEnv.CLAUDE_CODE_OAUTH_TOKEN), + profileEnv: maskToken(profileEnv.CLAUDE_CODE_OAUTH_TOKEN), + }); + } + // On Windows, detect and pass git-bash path for Claude Code CLI // Electron can detect git via where.exe, but Python subprocess may not have the same PATH const gitBashEnv: Record = {}; @@ -184,7 +195,7 @@ export class AgentProcessManager { const claudeCliEnv = this.detectAndSetCliPath('claude'); const ghCliEnv = this.detectAndSetCliPath('gh'); - return { + const finalEnv = { ...augmentedEnv, ...gitBashEnv, ...claudeCliEnv, @@ -195,6 +206,14 @@ export class AgentProcessManager { PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' } as NodeJS.ProcessEnv; + + // Debug: Log final token being passed (only when DEBUG=true) + if (DEBUG) { + const maskToken = (t: string | undefined) => t ? `${t.substring(0, 15)}...` : 'none'; + console.log('[AgentProcess] Final CLAUDE_CODE_OAUTH_TOKEN:', maskToken(finalEnv.CLAUDE_CODE_OAUTH_TOKEN)); + } + + return finalEnv; } private handleProcessFailure( diff --git a/apps/frontend/src/main/claude-profile-manager.ts b/apps/frontend/src/main/claude-profile-manager.ts index 452c0a2c99..df490d27c6 100644 --- a/apps/frontend/src/main/claude-profile-manager.ts +++ b/apps/frontend/src/main/claude-profile-manager.ts @@ -48,6 +48,7 @@ import { createProfileDirectory as createProfileDirectoryImpl, isProfileAuthenticated as isProfileAuthenticatedImpl, hasValidToken, + isValidTokenFormat, expandHomePath } from './claude-profile/profile-utils'; @@ -320,6 +321,7 @@ export class ClaudeProfileManager { /** * Set the OAuth token for a profile (encrypted storage). * Used when capturing token from `claude setup-token` output. + * Returns false if token format is invalid or profile not found. */ setProfileToken(profileId: string, token: string, email?: string): boolean { const profile = this.getProfile(profileId); @@ -327,6 +329,12 @@ export class ClaudeProfileManager { return false; } + // Validate token format before storing + if (!isValidTokenFormat(token)) { + console.error('[ProfileManager] Invalid token format. Token must start with sk-ant-oat01-'); + return false; + } + // Encrypt the token before storing profile.oauthToken = encryptToken(token); profile.tokenCreatedAt = new Date(); @@ -365,10 +373,17 @@ export class ClaudeProfileManager { // Decrypt the token before putting in environment const decryptedToken = decryptToken(profile.oauthToken); if (decryptedToken) { - env.CLAUDE_CODE_OAUTH_TOKEN = decryptedToken; + // Validate token format after decryption + if (isValidTokenFormat(decryptedToken)) { + env.CLAUDE_CODE_OAUTH_TOKEN = decryptedToken; + } else { + console.warn('[ProfileManager] Decrypted token has invalid format, falling back to configDir'); + } } - } else if (profile?.configDir && !profile.isDefault) { - // Fallback to configDir for backward compatibility + } + + // Fallback to configDir for backward compatibility (if no valid token and not default profile) + if (!env.CLAUDE_CODE_OAUTH_TOKEN && profile?.configDir && !profile.isDefault) { env.CLAUDE_CONFIG_DIR = profile.configDir; } diff --git a/apps/frontend/src/main/claude-profile/profile-utils.ts b/apps/frontend/src/main/claude-profile/profile-utils.ts index e6d8ceea83..a374a45f12 100644 --- a/apps/frontend/src/main/claude-profile/profile-utils.ts +++ b/apps/frontend/src/main/claude-profile/profile-utils.ts @@ -8,6 +8,20 @@ import { join } from 'path'; import { existsSync, readFileSync, readdirSync, mkdirSync } from 'fs'; import type { ClaudeProfile } from '../../shared/types'; +/** + * OAuth token format pattern (sk-ant-oat01-...) + * This is the expected format for Claude Code OAuth tokens. + */ +const OAUTH_TOKEN_PATTERN = /^sk-ant-oat01-[A-Za-z0-9_-]+$/; + +/** + * Validate OAuth token format. + * Returns true if token matches expected format (sk-ant-oat01-...). + */ +export function isValidTokenFormat(token: string | undefined): boolean { + return !!token && OAUTH_TOKEN_PATTERN.test(token); +} + /** * Default Claude config directory */ @@ -129,13 +143,21 @@ export function isProfileAuthenticated(profile: ClaudeProfile): boolean { /** * Check if a profile has a valid OAuth token. - * Token is valid for 1 year from creation. + * Token is valid for 1 year from creation and must match expected format. */ export function hasValidToken(profile: ClaudeProfile): boolean { if (!profile?.oauthToken) { return false; } + // Validate token format (must start with sk-ant-oat01-) + // Note: For encrypted tokens (enc:...), we can't validate format before decryption, + // but the token-encryption module will return undefined if decryption fails + if (!profile.oauthToken.startsWith('enc:') && !isValidTokenFormat(profile.oauthToken)) { + console.warn('[profile-utils] Token has invalid format (expected sk-ant-oat01-...)'); + return false; + } + // Check if token is expired (1 year validity) if (profile.tokenCreatedAt) { const oneYearAgo = new Date(); diff --git a/apps/frontend/src/main/claude-profile/token-encryption.ts b/apps/frontend/src/main/claude-profile/token-encryption.ts index ad8ab2f142..cfc7516d67 100644 --- a/apps/frontend/src/main/claude-profile/token-encryption.ts +++ b/apps/frontend/src/main/claude-profile/token-encryption.ts @@ -24,8 +24,9 @@ export function encryptToken(token: string): string { /** * Decrypt a token. Handles both encrypted (enc:...) and legacy plain tokens. + * Returns undefined if decryption fails, allowing callers to handle the failure appropriately. */ -export function decryptToken(storedToken: string): string { +export function decryptToken(storedToken: string): string | undefined { try { if (storedToken.startsWith('enc:') && safeStorage.isEncryptionAvailable()) { const encryptedData = Buffer.from(storedToken.slice(4), 'base64'); @@ -33,7 +34,7 @@ export function decryptToken(storedToken: string): string { } } catch (error) { console.error('[TokenEncryption] Failed to decrypt token:', error); - return ''; // Return empty string on decryption failure + return undefined; // Return undefined on decryption failure } // Return as-is for legacy unencrypted tokens return storedToken; diff --git a/apps/frontend/src/main/rate-limit-detector.ts b/apps/frontend/src/main/rate-limit-detector.ts index e93278c909..7293838132 100644 --- a/apps/frontend/src/main/rate-limit-detector.ts +++ b/apps/frontend/src/main/rate-limit-detector.ts @@ -4,6 +4,7 @@ */ import { getClaudeProfileManager } from './claude-profile-manager'; +import { isValidTokenFormat } from './claude-profile/profile-utils'; /** * Regex pattern to detect Claude Code rate limit messages @@ -276,10 +277,17 @@ export function getProfileEnv(profileId?: string): Record { : profileManager.getActiveProfileToken(); if (decryptedToken) { - console.warn('[getProfileEnv] Using OAuth token for profile:', profile.name); - return { - CLAUDE_CODE_OAUTH_TOKEN: decryptedToken - }; + // Validate token format after decryption + if (!isValidTokenFormat(decryptedToken)) { + console.warn('[getProfileEnv] Token has invalid format for profile:', profile.name); + console.warn('[getProfileEnv] Token should start with sk-ant-oat01-. Please re-authenticate.'); + // Don't use invalid token - fall through to other auth methods + } else { + console.warn('[getProfileEnv] Using OAuth token for profile:', profile.name); + return { + CLAUDE_CODE_OAUTH_TOKEN: decryptedToken + }; + } } else { console.warn('[getProfileEnv] Failed to decrypt token for profile:', profile.name); } diff --git a/apps/frontend/src/main/title-generator.ts b/apps/frontend/src/main/title-generator.ts index ae809ba351..dbba4bdb2f 100644 --- a/apps/frontend/src/main/title-generator.ts +++ b/apps/frontend/src/main/title-generator.ts @@ -138,13 +138,16 @@ export class TitleGenerator extends EventEmitter { debug('Generating title for description:', description.substring(0, 100) + '...'); const autoBuildEnv = this.loadAutoBuildEnv(); - debug('Environment loaded', { - hasOAuthToken: !!autoBuildEnv.CLAUDE_CODE_OAUTH_TOKEN - }); - - // Get active Claude profile environment (CLAUDE_CONFIG_DIR if not default) const profileEnv = getProfileEnv(); + // Log token sources for debugging (masked for security) + const maskToken = (t: string | undefined) => t ? `${t.substring(0, 15)}...` : 'none'; + debug('Token sources:', { + autoBuildEnv: maskToken(autoBuildEnv.CLAUDE_CODE_OAUTH_TOKEN), + profileEnv: maskToken(profileEnv.CLAUDE_CODE_OAUTH_TOKEN), + effectiveSource: profileEnv.CLAUDE_CODE_OAUTH_TOKEN ? 'profile' : (autoBuildEnv.CLAUDE_CODE_OAUTH_TOKEN ? 'autoBuildEnv' : 'none') + }); + return new Promise((resolve) => { // Parse Python command to handle space-separated commands like "py -3" const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.pythonPath);