diff --git a/src/cliproxy/auth/gemini-token-refresh.ts b/src/cliproxy/auth/gemini-token-refresh.ts new file mode 100644 index 00000000..f6679c4c --- /dev/null +++ b/src/cliproxy/auth/gemini-token-refresh.ts @@ -0,0 +1,207 @@ +/** + * Gemini Token Refresh + * + * Handles proactive token validation and refresh for Gemini OAuth tokens. + * Prevents UND_ERR_SOCKET errors by ensuring tokens are valid before use. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +/** + * Gemini OAuth credentials - PUBLIC from official Gemini CLI source code + * These are not secrets - they're public OAuth client credentials that Google + * distributes with their official applications. See: + * https://github.com/google/generative-ai-python (Gemini CLI source) + * + * GitHub secret scanning may flag these, but they are intentionally hardcoded + * as they're required for OAuth token refresh and are publicly documented. + */ + +const GEMINI_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'; + +const GEMINI_CLIENT_SECRET = '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'; + +/** Google OAuth token endpoint */ +const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; + +/** Refresh tokens 5 minutes before expiry */ +const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000; + +/** Gemini oauth_creds.json structure */ +interface GeminiOAuthCreds { + access_token: string; + refresh_token?: string; + expiry_date?: number; // Unix timestamp in milliseconds + scope?: string; + token_type?: string; + id_token?: string; +} + +/** Token refresh response from Google */ +interface TokenRefreshResponse { + access_token?: string; + expires_in?: number; + token_type?: string; + error?: string; + error_description?: string; +} + +/** + * Get path to Gemini OAuth credentials file + */ +export function getGeminiOAuthPath(): string { + return path.join(os.homedir(), '.gemini', 'oauth_creds.json'); +} + +/** + * Read Gemini OAuth credentials + */ +function readGeminiCreds(): GeminiOAuthCreds | null { + const oauthPath = getGeminiOAuthPath(); + if (!fs.existsSync(oauthPath)) { + return null; + } + try { + const content = fs.readFileSync(oauthPath, 'utf8'); + return JSON.parse(content) as GeminiOAuthCreds; + } catch { + return null; + } +} + +/** + * Write Gemini OAuth credentials + * @returns error message if write failed, undefined on success + */ +function writeGeminiCreds(creds: GeminiOAuthCreds): string | undefined { + const oauthPath = getGeminiOAuthPath(); + const dir = path.dirname(oauthPath); + try { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(oauthPath, JSON.stringify(creds, null, 2), { mode: 0o600 }); + return undefined; + } catch (err) { + return err instanceof Error ? err.message : 'Failed to write credentials'; + } +} + +/** + * Check if Gemini token is expired or expiring soon + */ +export function isGeminiTokenExpiringSoon(): boolean { + const creds = readGeminiCreds(); + if (!creds || !creds.access_token) { + return true; // No token = needs auth + } + if (!creds.expiry_date) { + return false; // No expiry info = assume valid + } + const expiresIn = creds.expiry_date - Date.now(); + return expiresIn < REFRESH_LEAD_TIME_MS; +} + +/** + * Refresh Gemini access token using refresh_token + * @returns true if refresh succeeded, false otherwise + */ +export async function refreshGeminiToken(): Promise<{ + success: boolean; + error?: string; +}> { + const creds = readGeminiCreds(); + if (!creds || !creds.refresh_token) { + return { success: false, error: 'No refresh token available' }; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + try { + const response = await fetch(GOOGLE_TOKEN_URL, { + method: 'POST', + signal: controller.signal, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: creds.refresh_token, + client_id: GEMINI_CLIENT_ID, + client_secret: GEMINI_CLIENT_SECRET, + }).toString(), + }); + + clearTimeout(timeoutId); + + const data = (await response.json()) as TokenRefreshResponse; + + if (!response.ok || data.error) { + return { + success: false, + error: data.error_description || data.error || `OAuth error: ${response.status}`, + }; + } + + if (!data.access_token) { + return { success: false, error: 'No access_token in response' }; + } + + // Update credentials file with new token + const updatedCreds: GeminiOAuthCreds = { + ...creds, + access_token: data.access_token, + expiry_date: Date.now() + (data.expires_in ?? 3600) * 1000, + }; + const writeError = writeGeminiCreds(updatedCreds); + if (writeError) { + return { success: false, error: `Token refreshed but failed to save: ${writeError}` }; + } + + return { success: true }; + } catch (err) { + clearTimeout(timeoutId); + if (err instanceof Error && err.name === 'AbortError') { + return { success: false, error: 'Token refresh timeout' }; + } + return { success: false, error: err instanceof Error ? err.message : 'Unknown error' }; + } +} + +/** + * Ensure Gemini token is valid, refreshing if needed + * @param verbose Log progress if true + * @returns true if token is valid (or was refreshed), false if refresh failed + */ +export async function ensureGeminiTokenValid(verbose = false): Promise<{ + valid: boolean; + refreshed: boolean; + error?: string; +}> { + const creds = readGeminiCreds(); + if (!creds || !creds.access_token) { + return { valid: false, refreshed: false, error: 'No Gemini credentials found' }; + } + + if (!isGeminiTokenExpiringSoon()) { + return { valid: true, refreshed: false }; + } + + // Token is expired or expiring soon - try to refresh + if (verbose) { + console.log('[i] Gemini token expired or expiring soon, refreshing...'); + } + + const result = await refreshGeminiToken(); + if (result.success) { + if (verbose) { + console.log('[OK] Gemini token refreshed successfully'); + } + return { valid: true, refreshed: true }; + } + + return { valid: false, refreshed: false, error: result.error }; +} diff --git a/src/cliproxy/auth/token-manager.ts b/src/cliproxy/auth/token-manager.ts index 4eb4fa72..436539cc 100644 --- a/src/cliproxy/auth/token-manager.ts +++ b/src/cliproxy/auth/token-manager.ts @@ -258,3 +258,27 @@ export function displayAuthStatus(): void { console.log('To authenticate: ccs --auth'); console.log('To logout: ccs --logout'); } + +/** + * Ensure OAuth token is valid for provider, refreshing if expired or expiring soon. + * This prevents UND_ERR_SOCKET errors caused by expired tokens during API calls. + * + * @param provider The CLIProxy provider + * @param verbose Log progress if true + * @returns Object with valid status and whether refresh occurred + */ +export async function ensureTokenValid( + provider: CLIProxyProvider, + verbose = false +): Promise<{ valid: boolean; refreshed: boolean; error?: string }> { + // Currently only Gemini uses oauth_creds.json with expiry_date + // Other providers (agy, codex) use CLIProxyAPI's internal auth management + if (provider === 'gemini') { + const { ensureGeminiTokenValid } = await import('./gemini-token-refresh'); + return ensureGeminiTokenValid(verbose); + } + + // For other providers, assume token is valid if authenticated + // CLIProxyAPI handles token refresh internally for antigravity/codex + return { valid: isAuthenticated(provider), refreshed: false }; +} diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 4b11748d..0de849d6 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -444,6 +444,23 @@ export async function execClaudeWithCLIProxy( } else { log(`${provider} already authenticated`); } + + // 3a. Proactive token refresh - prevent UND_ERR_SOCKET from expired tokens + // CLIProxyAPI may fail mid-request if token expires during use + const { ensureTokenValid } = await import('./auth/token-manager'); + const tokenResult = await ensureTokenValid(provider, verbose); + if (!tokenResult.valid) { + // Token expired and refresh failed - trigger re-auth + console.error(warn('OAuth token expired and refresh failed')); + if (tokenResult.error) { + console.error(` ${tokenResult.error}`); + } + console.error(` Run "ccs ${provider} --auth" to re-authenticate`); + process.exit(1); + } + if (tokenResult.refreshed) { + log('Token was refreshed proactively'); + } } // 4. First-run model configuration (interactive)