diff --git a/src/cliproxy/auth/gemini-token-refresh.ts b/src/cliproxy/auth/gemini-token-refresh.ts index f6679c4c..fedb48dd 100644 --- a/src/cliproxy/auth/gemini-token-refresh.ts +++ b/src/cliproxy/auth/gemini-token-refresh.ts @@ -106,11 +106,12 @@ export function isGeminiTokenExpiringSoon(): boolean { /** * Refresh Gemini access token using refresh_token - * @returns true if refresh succeeded, false otherwise + * @returns Result with success status, optional error, and expiry time */ export async function refreshGeminiToken(): Promise<{ success: boolean; error?: string; + expiresAt?: number; }> { const creds = readGeminiCreds(); if (!creds || !creds.refresh_token) { @@ -151,17 +152,18 @@ export async function refreshGeminiToken(): Promise<{ } // Update credentials file with new token + const expiresAt = Date.now() + (data.expires_in ?? 3600) * 1000; const updatedCreds: GeminiOAuthCreds = { ...creds, access_token: data.access_token, - expiry_date: Date.now() + (data.expires_in ?? 3600) * 1000, + expiry_date: expiresAt, }; const writeError = writeGeminiCreds(updatedCreds); if (writeError) { return { success: false, error: `Token refreshed but failed to save: ${writeError}` }; } - return { success: true }; + return { success: true, expiresAt }; } catch (err) { clearTimeout(timeoutId); if (err instanceof Error && err.name === 'AbortError') { diff --git a/src/cliproxy/auth/provider-refreshers/index.ts b/src/cliproxy/auth/provider-refreshers/index.ts new file mode 100644 index 00000000..232fe8c9 --- /dev/null +++ b/src/cliproxy/auth/provider-refreshers/index.ts @@ -0,0 +1,69 @@ +/** + * Provider Token Refreshers + * + * Exports refresh functions for each OAuth provider. + * Currently only Gemini is implemented; others return placeholder errors. + */ + +import { CLIProxyProvider } from '../../types'; +import { refreshGeminiToken } from '../gemini-token-refresh'; + +/** Token refresh result */ +export interface ProviderRefreshResult { + success: boolean; + error?: string; + expiresAt?: number; +} + +/** + * Refresh token for a specific provider and account + * @param provider Provider to refresh + * @param _accountId Account ID (currently unused, multi-account not yet implemented) + * @returns Refresh result with success status and optional error + */ +export async function refreshToken( + provider: CLIProxyProvider, + _accountId: string +): Promise { + switch (provider) { + case 'gemini': + return await refreshGeminiTokenWrapper(); + + case 'codex': + case 'agy': + case 'qwen': + case 'iflow': + case 'kiro': + case 'ghcp': + return { + success: false, + error: `Token refresh not yet implemented for ${provider}`, + }; + + default: + return { + success: false, + error: `Unknown provider: ${provider}`, + }; + } +} + +/** + * Wrapper for Gemini token refresh + * Converts gemini-token-refresh.ts format to provider-refreshers format + */ +async function refreshGeminiTokenWrapper(): Promise { + const result = await refreshGeminiToken(); + + if (!result.success) { + return { + success: false, + error: result.error, + }; + } + + return { + success: true, + expiresAt: result.expiresAt, + }; +} diff --git a/src/cliproxy/auth/token-expiry-checker.ts b/src/cliproxy/auth/token-expiry-checker.ts new file mode 100644 index 00000000..d3833173 --- /dev/null +++ b/src/cliproxy/auth/token-expiry-checker.ts @@ -0,0 +1,131 @@ +/** + * Token Expiry Checker + * + * Inspects token files to determine expiry times and refresh requirements. + * Supports expiry_date field with fallback to file modification time. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { CLIProxyProvider } from '../types'; +import { getProviderAccounts, getAccountTokenPath } from '../account-manager'; + +/** Preemptive refresh time: refresh tokens 45 minutes before expiry */ +export const PREEMPTIVE_REFRESH_MINUTES = 45; + +/** Fallback expiry: assume 50 minutes if no expiry_date field */ +export const FALLBACK_EXPIRY_MINUTES = 50; + +/** Maximum token file size in bytes (1MB) - prevent DoS from huge files */ +const MAX_TOKEN_FILE_SIZE = 1024 * 1024; + +/** Token expiry information for a single account */ +export interface TokenExpiryInfo { + /** Provider name */ + provider: CLIProxyProvider; + /** Account ID */ + accountId: string; + /** Path to token file */ + tokenFile: string; + /** Expiry timestamp (Unix ms) */ + expiresAt: number; + /** Whether token needs refresh (within preemptive window) */ + needsRefresh: boolean; + /** Token file last modified time */ + lastModified: Date; +} + +/** + * Token file structure + */ +interface TokenData { + access_token?: string; + refresh_token?: string; + expiry_date?: number; // Unix timestamp ms + type?: string; +} + +/** + * Get token expiry info for a specific account + * @returns null if token file doesn't exist or is invalid + */ +export function getTokenExpiryInfo( + provider: CLIProxyProvider, + accountId: string +): TokenExpiryInfo | null { + const tokenPath = getAccountTokenPath(provider, accountId); + if (!tokenPath || !fs.existsSync(tokenPath)) { + return null; + } + + try { + const stats = fs.statSync(tokenPath); + + // Prevent DoS from huge token files + if (stats.size > MAX_TOKEN_FILE_SIZE) { + return null; + } + + const content = fs.readFileSync(tokenPath, 'utf-8'); + const data: TokenData = JSON.parse(content); + + // Validate refresh_token exists (required for refresh) + if (!data.refresh_token || typeof data.refresh_token !== 'string') { + return null; + } + + // Calculate expiry time with validation + let expiresAt: number; + if ( + data.expiry_date && + typeof data.expiry_date === 'number' && + Number.isFinite(data.expiry_date) && + data.expiry_date > 0 + ) { + // Use expiry_date field if valid + expiresAt = data.expiry_date; + } else { + // Fallback: use file mtime + 50 minutes + expiresAt = stats.mtime.getTime() + FALLBACK_EXPIRY_MINUTES * 60 * 1000; + } + + // Check if needs refresh (within preemptive window) + const now = Date.now(); + const timeUntilExpiry = expiresAt - now; + const preemptiveMs = PREEMPTIVE_REFRESH_MINUTES * 60 * 1000; + const needsRefresh = timeUntilExpiry < preemptiveMs; + + return { + provider, + accountId, + tokenFile: path.basename(tokenPath), + expiresAt, + needsRefresh, + lastModified: stats.mtime, + }; + } catch { + return null; + } +} + +/** + * Get token expiry info for all accounts across all providers + * @returns Array of token expiry info, excluding invalid tokens + */ +export function getAllTokenExpiryInfo(): TokenExpiryInfo[] { + const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp']; + const results: TokenExpiryInfo[] = []; + + for (const provider of providers) { + const accounts = getProviderAccounts(provider); + + for (const account of accounts) { + const info = getTokenExpiryInfo(provider, account.id); + if (info) { + results.push(info); + } + } + } + + return results; +} diff --git a/src/cliproxy/auth/token-refresh-config.ts b/src/cliproxy/auth/token-refresh-config.ts new file mode 100644 index 00000000..39b25f42 --- /dev/null +++ b/src/cliproxy/auth/token-refresh-config.ts @@ -0,0 +1,31 @@ +/** + * Token Refresh Configuration + * + * Loads token refresh worker settings from unified config. + * Returns null if disabled or not configured. + */ + +import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; +import type { TokenRefreshSettings } from '../../config/unified-config-types'; + +/** + * Get token refresh configuration from unified config + * @returns Config if enabled, null if disabled or not configured + */ +export function getTokenRefreshConfig(): TokenRefreshSettings | null { + const config = loadOrCreateUnifiedConfig(); + + // Return null if not configured or explicitly disabled + if (!config.cliproxy?.token_refresh?.enabled) { + return null; + } + + // Return config with defaults + return { + enabled: true, + interval_minutes: config.cliproxy.token_refresh.interval_minutes ?? 30, + preemptive_minutes: config.cliproxy.token_refresh.preemptive_minutes ?? 45, + max_retries: config.cliproxy.token_refresh.max_retries ?? 3, + verbose: config.cliproxy.token_refresh.verbose ?? false, + }; +} diff --git a/src/cliproxy/auth/token-refresh-worker.ts b/src/cliproxy/auth/token-refresh-worker.ts new file mode 100644 index 00000000..071e63d9 --- /dev/null +++ b/src/cliproxy/auth/token-refresh-worker.ts @@ -0,0 +1,280 @@ +/** + * Token Refresh Worker + * + * Background worker that periodically checks and refreshes OAuth tokens + * before they expire. Runs as interval loop with retry logic. + */ + +import { CLIProxyProvider } from '../types'; +import { getAllTokenExpiryInfo, TokenExpiryInfo } from './token-expiry-checker'; +import { refreshToken } from './provider-refreshers'; + +/** Worker configuration */ +export interface TokenRefreshConfig { + /** Refresh check interval in minutes (default: 30) */ + refreshInterval: number; + /** Preemptive refresh time in minutes (default: 45) */ + preemptiveTime: number; + /** Maximum retry attempts per token (default: 3) */ + maxRetries: number; + /** Base delay for exponential backoff in ms (default: 1000) */ + retryBaseDelay: number; + /** Timeout for refresh operations in ms (default: 10000) */ + refreshTimeout: number; + /** Enable verbose logging */ + verbose: boolean; +} + +/** Result of a token refresh attempt */ +export interface RefreshResult { + provider: CLIProxyProvider; + accountId: string; + success: boolean; + error?: string; + refreshedAt?: Date; + nextExpiry?: number; +} + +/** Default worker configuration */ +const DEFAULT_CONFIG: TokenRefreshConfig = { + refreshInterval: 30, + preemptiveTime: 45, + maxRetries: 3, + retryBaseDelay: 1000, + refreshTimeout: 10000, + verbose: false, +}; + +/** Minimum config values to prevent infinite loops */ +const MIN_REFRESH_INTERVAL = 1; // 1 minute minimum +const MIN_RETRY_BASE_DELAY = 100; // 100ms minimum + +/** Unrecoverable error patterns - don't retry these */ +const UNRECOVERABLE_ERRORS = [ + 'No refresh token', + 'Invalid client', + 'Invalid grant', + 'Token has been revoked', + 'Token not found', +]; + +/** Validate and sanitize config values */ +function sanitizeConfig(config: TokenRefreshConfig): TokenRefreshConfig { + return { + refreshInterval: Math.max( + MIN_REFRESH_INTERVAL, + config.refreshInterval || DEFAULT_CONFIG.refreshInterval + ), + preemptiveTime: Math.max(0, config.preemptiveTime || DEFAULT_CONFIG.preemptiveTime), + maxRetries: Math.max(1, config.maxRetries || DEFAULT_CONFIG.maxRetries), + retryBaseDelay: Math.max( + MIN_RETRY_BASE_DELAY, + config.retryBaseDelay || DEFAULT_CONFIG.retryBaseDelay + ), + refreshTimeout: Math.max(1000, config.refreshTimeout || DEFAULT_CONFIG.refreshTimeout), + verbose: config.verbose ?? DEFAULT_CONFIG.verbose, + }; +} + +/** Promise with timeout */ +function withTimeout(promise: Promise, timeoutMs: number, errorMsg: string): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error(errorMsg)), timeoutMs)), + ]); +} + +/** + * Background token refresh worker + * Manages periodic token refresh checks with retry logic + */ +export class TokenRefreshWorker { + private config: TokenRefreshConfig; + private intervalId: NodeJS.Timeout | null = null; + private running = false; + private lastResults: RefreshResult[] = []; + private exitHandler: (() => void) | null = null; + + constructor(config: Partial = {}) { + this.config = sanitizeConfig({ ...DEFAULT_CONFIG, ...config }); + } + + /** + * Start the worker + * Runs refresh loop immediately, then on interval + */ + start(): void { + if (this.running) { + return; + } + + this.running = true; + this.log('[i] Token refresh worker started'); + + // Register process exit handlers for cleanup + this.exitHandler = () => this.stop(); + process.on('SIGINT', this.exitHandler); + process.on('SIGTERM', this.exitHandler); + process.on('beforeExit', this.exitHandler); + + // Run immediately on start + void this.refreshLoop(); + + // Then run on interval + const intervalMs = this.config.refreshInterval * 60 * 1000; + this.intervalId = setInterval(() => { + void this.refreshLoop(); + }, intervalMs); + } + + /** + * Stop the worker + */ + stop(): void { + if (!this.running) { + return; + } + + this.running = false; + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + + // Remove process exit handlers + if (this.exitHandler) { + process.off('SIGINT', this.exitHandler); + process.off('SIGTERM', this.exitHandler); + process.off('beforeExit', this.exitHandler); + this.exitHandler = null; + } + + this.log('[i] Token refresh worker stopped'); + } + + /** + * Check if worker is active + */ + isActive(): boolean { + return this.running; + } + + /** + * Manually trigger refresh check now + */ + async refreshNow(): Promise { + return await this.refreshLoop(); + } + + /** + * Get results from last refresh cycle + */ + getLastRefreshResults(): RefreshResult[] { + return [...this.lastResults]; + } + + /** + * Main refresh loop + * Checks all tokens and refreshes those needing refresh + */ + private async refreshLoop(): Promise { + const results: RefreshResult[] = []; + + try { + const tokens = getAllTokenExpiryInfo(); + const tokensNeedingRefresh = tokens.filter((t) => t.needsRefresh); + + if (tokensNeedingRefresh.length === 0) { + this.log('[OK] All tokens valid, no refresh needed'); + this.lastResults = []; + return results; + } + + this.log(`[i] Refreshing ${tokensNeedingRefresh.length} token(s)...`); + + for (const token of tokensNeedingRefresh) { + const result = await this.refreshWithRetry(token); + results.push(result); + + if (result.success) { + this.log(`[OK] ${token.provider}/${token.accountId} refreshed`); + } else { + this.log(`[X] ${token.provider}/${token.accountId} failed: ${result.error}`); + } + } + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + this.log(`[X] Refresh loop error: ${msg}`); + } + + this.lastResults = results; + return results; + } + + /** + * Refresh a single token with retry logic + * Uses exponential backoff on failures + */ + private async refreshWithRetry(token: TokenExpiryInfo): Promise { + let lastError = 'Unknown error'; + + for (let attempt = 0; attempt < this.config.maxRetries; attempt++) { + try { + // Apply timeout to refresh operation + const result = await withTimeout( + refreshToken(token.provider, token.accountId), + this.config.refreshTimeout, + `Refresh timeout after ${this.config.refreshTimeout}ms` + ); + + if (result.success) { + return { + provider: token.provider, + accountId: token.accountId, + success: true, + refreshedAt: new Date(), + nextExpiry: result.expiresAt, + }; + } + + lastError = result.error || 'Refresh failed'; + + // Don't retry if error indicates unrecoverable issue + if (this.isUnrecoverableError(lastError)) { + break; + } + } catch (error) { + lastError = error instanceof Error ? error.message : 'Unknown error'; + } + + // Exponential backoff before retry + if (attempt < this.config.maxRetries - 1) { + const delay = this.config.retryBaseDelay * Math.pow(2, attempt); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + return { + provider: token.provider, + accountId: token.accountId, + success: false, + error: lastError, + }; + } + + /** + * Check if error is unrecoverable (should not retry) + */ + private isUnrecoverableError(error: string): boolean { + return UNRECOVERABLE_ERRORS.some((pattern) => error.includes(pattern)); + } + + /** + * Log message if verbose enabled + */ + private log(msg: string): void { + if (this.config.verbose) { + console.error(`[token-refresh] ${msg}`); + } + } +} diff --git a/src/cliproxy/service-manager.ts b/src/cliproxy/service-manager.ts index 5befc836..6788c66c 100644 --- a/src/cliproxy/service-manager.ts +++ b/src/cliproxy/service-manager.ts @@ -25,10 +25,15 @@ import { registerSession } from './session-tracker'; import { detectRunningProxy, waitForProxyHealthy } from './proxy-detector'; import { withStartupLock } from './startup-lock'; import { isCliproxyRunning } from './stats-fetcher'; +import { TokenRefreshWorker, type RefreshResult } from './auth/token-refresh-worker'; +import { getTokenRefreshConfig } from './auth/token-refresh-config'; /** Background proxy process reference */ let proxyProcess: ChildProcess | null = null; +/** Token refresh worker instance */ +let tokenRefreshWorker: TokenRefreshWorker | null = null; + /** Cleanup registered flag */ let cleanupRegistered = false; @@ -77,6 +82,12 @@ function registerCleanup(): void { if (cleanupRegistered) return; const cleanup = () => { + // Stop token refresh worker first + if (tokenRefreshWorker && tokenRefreshWorker.isActive()) { + tokenRefreshWorker.stop(); + tokenRefreshWorker = null; + } + // Then stop proxy process if (proxyProcess && !proxyProcess.killed) { proxyProcess.kill('SIGTERM'); proxyProcess = null; @@ -90,6 +101,38 @@ function registerCleanup(): void { cleanupRegistered = true; } +/** + * Start token refresh worker if configured + * @param verbose Enable verbose logging + */ +function startTokenRefreshWorker(verbose: boolean): void { + // Skip if already running + if (tokenRefreshWorker && tokenRefreshWorker.isActive()) { + return; + } + + // Load config + const config = getTokenRefreshConfig(); + if (!config) { + // Not configured or disabled + return; + } + + // Create and start worker + tokenRefreshWorker = new TokenRefreshWorker({ + refreshInterval: config.interval_minutes ?? 30, + preemptiveTime: config.preemptive_minutes ?? 45, + maxRetries: config.max_retries ?? 3, + verbose: config.verbose || verbose, + }); + + tokenRefreshWorker.start(); + + if (verbose) { + console.error('[i] Token refresh worker started'); + } +} + export interface ServiceStartResult { started: boolean; alreadyRunning: boolean; @@ -254,6 +297,9 @@ export async function ensureCliproxyService( log(`Session registered for PID ${proxyProcess.pid}`); } + // 6. Start token refresh worker if configured + startTokenRefreshWorker(verbose); + return { started: true, alreadyRunning: false, port }; }); } @@ -262,6 +308,13 @@ export async function ensureCliproxyService( * Stop the managed CLIProxy service */ export function stopCliproxyService(): boolean { + // Stop token refresh worker first + if (tokenRefreshWorker && tokenRefreshWorker.isActive()) { + tokenRefreshWorker.stop(); + tokenRefreshWorker = null; + } + + // Then stop proxy process if (proxyProcess && !proxyProcess.killed) { proxyProcess.kill('SIGTERM'); proxyProcess = null; @@ -283,3 +336,27 @@ export async function getServiceStatus(port: number = CLIPROXY_DEFAULT_PORT): Pr return { running, managedByUs, port }; } + +/** + * Check if token refresh worker is running + */ +export function isTokenRefreshWorkerRunning(): boolean { + return tokenRefreshWorker !== null && tokenRefreshWorker.isActive(); +} + +/** + * Get token refresh worker status + */ +export function getTokenRefreshStatus(): { + running: boolean; + lastResults: RefreshResult[] | null; +} { + if (!tokenRefreshWorker) { + return { running: false, lastResults: null }; + } + + return { + running: tokenRefreshWorker.isActive(), + lastResults: tokenRefreshWorker.getLastRefreshResults(), + }; +} diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 166349cb..6a20a4d4 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -93,6 +93,23 @@ export interface CLIProxyLoggingConfig { request_log?: boolean; } +/** + * Token refresh configuration. + * Manages background token refresh worker settings. + */ +export interface TokenRefreshSettings { + /** Enable background token refresh (default: false) */ + enabled?: boolean; + /** Refresh check interval in minutes (default: 30) */ + interval_minutes?: number; + /** Preemptive refresh time in minutes (default: 45) */ + preemptive_minutes?: number; + /** Maximum retry attempts per token (default: 3) */ + max_retries?: number; + /** Enable verbose logging (default: false) */ + verbose?: boolean; +} + /** * CLIProxy configuration section. */ @@ -109,6 +126,8 @@ export interface CLIProxyConfig { kiro_no_incognito?: boolean; /** Global auth configuration for CLIProxyAPI */ auth?: CLIProxyAuthConfig; + /** Background token refresh worker settings */ + token_refresh?: TokenRefreshSettings; } /**