refactor(providers): move auth status checks into provider runtimes

Move provider authentication status logic out of the CLI auth route so auth checks
live with the provider implementations that understand each provider's install
and credential model.

Add provider-specific auth runtime classes for Claude, Codex, Cursor, and Gemini,
and expose them through the shared provider contract as `provider.auth`. Add a
provider auth service that resolves providers through the registry and delegates
status checks via `auth.getStatus()`.

Keep the existing `/api/cli/<provider>/status` endpoints, but make them thin route
adapters over the new provider auth service. This removes duplicated route-local
credential parsing and makes auth status a first-class provider capability beside
MCP and message handling.
This commit is contained in:
Haileyesus
2026-04-17 15:15:26 +03:00
parent 7832429011
commit 32dfd27156
13 changed files with 617 additions and 436 deletions

View File

@@ -0,0 +1,151 @@
import { readFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import spawn from 'cross-spawn';
import type { IProviderAuthRuntime } from '@/shared/interfaces.js';
import type { ProviderAuthStatus } from '@/shared/types.js';
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
type GeminiCredentialsStatus = {
authenticated: boolean;
email: string | null;
method: string | null;
error?: string;
};
export class GeminiAuthProvider implements IProviderAuthRuntime {
/**
* Checks whether the Gemini CLI is available on this host.
*/
private checkInstalled(): boolean {
const cliPath = process.env.GEMINI_PATH || 'gemini';
try {
spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Returns Gemini CLI installation and credential status.
*/
async getStatus(): Promise<ProviderAuthStatus> {
const installed = this.checkInstalled();
if (!installed) {
return {
installed,
provider: 'gemini',
authenticated: false,
email: null,
method: null,
error: 'Gemini CLI is not installed',
};
}
const credentials = await this.checkCredentials();
return {
installed,
provider: 'gemini',
authenticated: credentials.authenticated,
email: credentials.email,
method: credentials.method,
error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
};
}
/**
* Checks Gemini credentials from API key env vars or local OAuth credential files.
*/
private async checkCredentials(): Promise<GeminiCredentialsStatus> {
if (process.env.GEMINI_API_KEY?.trim()) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
try {
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
const content = await readFile(credsPath, 'utf8');
const creds = readObjectRecord(JSON.parse(content)) ?? {};
const accessToken = readOptionalString(creds.access_token);
if (!accessToken) {
return {
authenticated: false,
email: null,
method: null,
error: 'No valid tokens found in oauth_creds',
};
}
const refreshToken = readOptionalString(creds.refresh_token);
const tokenInfo = await this.getTokenInfoEmail(accessToken);
if (tokenInfo.valid) {
return {
authenticated: true,
email: tokenInfo.email || 'OAuth Session',
method: 'credentials_file',
};
}
if (!refreshToken) {
return {
authenticated: false,
email: null,
method: 'credentials_file',
error: 'Access token invalid and no refresh token found',
};
}
return {
authenticated: true,
email: await this.getActiveAccountEmail() || 'OAuth Session',
method: 'credentials_file',
};
} catch {
return {
authenticated: false,
email: null,
method: null,
error: 'Gemini CLI not configured',
};
}
}
/**
* Validates a Gemini OAuth access token and returns an email when Google reports one.
*/
private async getTokenInfoEmail(accessToken: string): Promise<{ valid: boolean; email: string | null }> {
try {
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${accessToken}`);
if (!tokenRes.ok) {
return { valid: false, email: null };
}
const tokenInfo = readObjectRecord(await tokenRes.json());
return {
valid: true,
email: readOptionalString(tokenInfo?.email) ?? null,
};
} catch {
return { valid: false, email: null };
}
}
/**
* Reads Gemini's active local Google account as an offline fallback for display.
*/
private async getActiveAccountEmail(): Promise<string | null> {
try {
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
const accContent = await readFile(accPath, 'utf8');
const accounts = readObjectRecord(JSON.parse(accContent));
return readOptionalString(accounts?.active) ?? null;
} catch {
return null;
}
}
}

View File

@@ -1,7 +1,9 @@
import sessionManager from '@/sessionManager.js';
import { getGeminiCliSessionMessages } from '@/projects.js';
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import { GeminiAuthProvider } from '@/modules/providers/list/gemini/gemini-auth.provider.js';
import { GeminiMcpProvider } from '@/modules/providers/list/gemini/gemini-mcp.provider.js';
import type { IProviderAuthRuntime } from '@/shared/interfaces.js';
import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
@@ -15,6 +17,7 @@ function readRawProviderMessage(raw: unknown): RawProviderMessage | null {
export class GeminiProvider extends AbstractProvider {
readonly mcp = new GeminiMcpProvider();
readonly auth: IProviderAuthRuntime = new GeminiAuthProvider();
constructor() {
super('gemini');