mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 22:16:41 +00:00
Merge pull request #260 from kaitranntt/fix/api-socket-connection
fix(cliproxy): proactive token refresh to prevent UND_ERR_SOCKET
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -258,3 +258,27 @@ export function displayAuthStatus(): void {
|
||||
console.log('To authenticate: ccs <provider> --auth');
|
||||
console.log('To logout: ccs <provider> --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 };
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user