mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-16 17:16:19 +00:00
refactor: make cli-auth part of the providers class
This commit is contained in:
@@ -4,6 +4,7 @@ import { asyncHandler } from '@/shared/http/async-handler.js';
|
||||
import { AppError } from '@/shared/utils/app-error.js';
|
||||
import { createApiErrorResponse, createApiSuccessResponse } from '@/shared/http/api-response.js';
|
||||
import { llmService } from '@/modules/ai-runtime/services/ai-runtime.service.js';
|
||||
import { llmAuthService } from '@/modules/ai-runtime/services/auth.service.js';
|
||||
import { llmSessionsService } from '@/modules/ai-runtime/services/sessions.service.js';
|
||||
import { llmMcpService } from '@/modules/ai-runtime/services/mcp.service.js';
|
||||
import { llmSkillsService } from '@/modules/ai-runtime/services/skills.service.js';
|
||||
@@ -233,6 +234,15 @@ router.get(
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/providers/:provider/auth/status',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const auth = await llmAuthService.getProviderAuthStatus(provider);
|
||||
res.json(createApiSuccessResponse({ provider, auth }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/providers/:provider/sessions',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
IProvider,
|
||||
IProviderAuthRuntime,
|
||||
IProviderMcpRuntime,
|
||||
IProviderSessionSynchronizerRuntime,
|
||||
IProviderSkillsRuntime,
|
||||
@@ -25,6 +26,7 @@ export abstract class AbstractProvider implements IProvider {
|
||||
abstract readonly mcp: IProviderMcpRuntime;
|
||||
abstract readonly skills: IProviderSkillsRuntime;
|
||||
abstract readonly sessionSynchronizer: IProviderSessionSynchronizerRuntime;
|
||||
abstract readonly auth: IProviderAuthRuntime;
|
||||
|
||||
protected readonly sessions = new Map<string, MutableProviderSession>();
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import type { IProviderAuthRuntime, ProviderAuthStatus } from '@/modules/ai-runtime/types/index.js';
|
||||
|
||||
type ClaudeCredentialsFile = {
|
||||
email?: string;
|
||||
user?: string;
|
||||
claudeAiOauth?: {
|
||||
accessToken?: string;
|
||||
expiresAt?: number | string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads auth status for Claude from env/settings and OAuth credentials.
|
||||
*/
|
||||
export class ClaudeAuthRuntime implements IProviderAuthRuntime {
|
||||
async getStatus(): Promise<ProviderAuthStatus> {
|
||||
try {
|
||||
if (process.env.ANTHROPIC_API_KEY?.trim()) {
|
||||
return {
|
||||
provider: 'claude',
|
||||
authenticated: true,
|
||||
email: 'API Key Auth',
|
||||
method: 'api_key',
|
||||
};
|
||||
}
|
||||
|
||||
const settingsEnv = await this.loadClaudeSettingsEnv();
|
||||
if (settingsEnv.ANTHROPIC_API_KEY?.trim()) {
|
||||
return {
|
||||
provider: 'claude',
|
||||
authenticated: true,
|
||||
email: 'API Key Auth',
|
||||
method: 'api_key',
|
||||
};
|
||||
}
|
||||
|
||||
if (settingsEnv.ANTHROPIC_AUTH_TOKEN?.trim()) {
|
||||
return {
|
||||
provider: 'claude',
|
||||
authenticated: true,
|
||||
email: 'Configured via settings.json',
|
||||
method: 'api_key',
|
||||
};
|
||||
}
|
||||
|
||||
const credentialsPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
||||
const content = await readFile(credentialsPath, 'utf8');
|
||||
const credentials = JSON.parse(content) as ClaudeCredentialsFile;
|
||||
const oauth = credentials.claudeAiOauth;
|
||||
const accessToken = oauth?.accessToken;
|
||||
|
||||
if (accessToken && !this.isExpired(oauth?.expiresAt)) {
|
||||
return {
|
||||
provider: 'claude',
|
||||
authenticated: true,
|
||||
email: credentials.email ?? credentials.user ?? null,
|
||||
method: 'credentials_file',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
provider: 'claude',
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Not authenticated',
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
provider: 'claude',
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Not authenticated',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads optional env values from ~/.claude/settings.json.
|
||||
*/
|
||||
private async loadClaudeSettingsEnv(): Promise<Record<string, string>> {
|
||||
try {
|
||||
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
||||
const content = await readFile(settingsPath, 'utf8');
|
||||
const settings = JSON.parse(content) as { env?: unknown };
|
||||
if (!settings.env || typeof settings.env !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(settings.env as Record<string, unknown>).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when an OAuth expiration timestamp is in the past.
|
||||
*/
|
||||
private isExpired(expiresAt: number | string | undefined): boolean {
|
||||
if (expiresAt === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof expiresAt === 'number') {
|
||||
return Date.now() >= expiresAt;
|
||||
}
|
||||
|
||||
const numeric = Number.parseInt(expiresAt, 10);
|
||||
return Number.isFinite(numeric) ? Date.now() >= numeric : false;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { BaseSdkProvider } from '@/modules/ai-runtime/providers/base/base-sdk.provider.js';
|
||||
import type {
|
||||
IProviderAuthRuntime,
|
||||
IProviderMcpRuntime,
|
||||
IProviderSessionSynchronizerRuntime,
|
||||
IProviderSkillsRuntime,
|
||||
@@ -18,6 +19,7 @@ import type {
|
||||
StartSessionInput,
|
||||
} from '@/modules/ai-runtime/types/index.js';
|
||||
import { ClaudeMcpRuntime } from '@/modules/ai-runtime/providers/claude/claude-mcp.runtime.js';
|
||||
import { ClaudeAuthRuntime } from '@/modules/ai-runtime/providers/claude/claude-auth.runtime.js';
|
||||
import { ClaudeSkillsRuntime } from '@/modules/ai-runtime/providers/claude/claude-skills.runtime.js';
|
||||
import { ClaudeSessionSynchronizerRuntime } from '@/modules/ai-runtime/providers/claude/claude-session-synchronizer.runtime.js';
|
||||
|
||||
@@ -75,6 +77,7 @@ const readString = (value: unknown): string | undefined => {
|
||||
* Claude SDK provider implementation.
|
||||
*/
|
||||
export class ClaudeProvider extends BaseSdkProvider {
|
||||
readonly auth: IProviderAuthRuntime = new ClaudeAuthRuntime();
|
||||
readonly mcp: IProviderMcpRuntime = new ClaudeMcpRuntime();
|
||||
readonly skills: IProviderSkillsRuntime = new ClaudeSkillsRuntime();
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizerRuntime = new ClaudeSessionSynchronizerRuntime();
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import type { IProviderAuthRuntime, ProviderAuthStatus } from '@/modules/ai-runtime/types/index.js';
|
||||
|
||||
type CodexAuthFile = {
|
||||
OPENAI_API_KEY?: string;
|
||||
tokens?: {
|
||||
id_token?: string;
|
||||
access_token?: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads auth status from ~/.codex/auth.json.
|
||||
*/
|
||||
export class CodexAuthRuntime implements IProviderAuthRuntime {
|
||||
async getStatus(): Promise<ProviderAuthStatus> {
|
||||
try {
|
||||
const authPath = path.join(os.homedir(), '.codex', 'auth.json');
|
||||
const content = await readFile(authPath, 'utf8');
|
||||
const auth = JSON.parse(content) as CodexAuthFile;
|
||||
const tokens = auth.tokens ?? {};
|
||||
|
||||
if (tokens.id_token || tokens.access_token) {
|
||||
return {
|
||||
provider: 'codex',
|
||||
authenticated: true,
|
||||
email: this.extractEmail(tokens.id_token),
|
||||
method: 'token_file',
|
||||
};
|
||||
}
|
||||
|
||||
if (auth.OPENAI_API_KEY?.trim()) {
|
||||
return {
|
||||
provider: 'codex',
|
||||
authenticated: true,
|
||||
email: 'API Key Auth',
|
||||
method: 'api_key',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
provider: 'codex',
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'No valid tokens found',
|
||||
};
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException)?.code;
|
||||
return {
|
||||
provider: 'codex',
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: code === 'ENOENT'
|
||||
? 'Codex not configured'
|
||||
: (error instanceof Error ? error.message : 'Failed to read Codex auth state'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort id_token email extraction from JWT payload.
|
||||
*/
|
||||
private extractEmail(idToken: string | undefined): string {
|
||||
if (!idToken) {
|
||||
return 'Authenticated';
|
||||
}
|
||||
|
||||
try {
|
||||
const parts = idToken.split('.');
|
||||
if (parts.length < 2) {
|
||||
return 'Authenticated';
|
||||
}
|
||||
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) as {
|
||||
email?: string;
|
||||
user?: string;
|
||||
};
|
||||
return payload.email ?? payload.user ?? 'Authenticated';
|
||||
} catch {
|
||||
return 'Authenticated';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { BaseSdkProvider } from '@/modules/ai-runtime/providers/base/base-sdk.provider.js';
|
||||
import type {
|
||||
IProviderAuthRuntime,
|
||||
IProviderMcpRuntime,
|
||||
IProviderSessionSynchronizerRuntime,
|
||||
IProviderSkillsRuntime,
|
||||
@@ -12,6 +13,7 @@ import type {
|
||||
StartSessionInput,
|
||||
} from '@/modules/ai-runtime/types/index.js';
|
||||
import { CodexMcpRuntime } from '@/modules/ai-runtime/providers/codex/codex-mcp.runtime.js';
|
||||
import { CodexAuthRuntime } from '@/modules/ai-runtime/providers/codex/codex-auth.runtime.js';
|
||||
import { CodexSkillsRuntime } from '@/modules/ai-runtime/providers/codex/codex-skills.runtime.js';
|
||||
import { CodexSessionSynchronizerRuntime } from '@/modules/ai-runtime/providers/codex/codex-session-synchronizer.runtime.js';
|
||||
import { AppError } from '@/shared/utils/app-error.js';
|
||||
@@ -67,6 +69,7 @@ type CodexSdkModule = {
|
||||
* Codex SDK provider implementation.
|
||||
*/
|
||||
export class CodexProvider extends BaseSdkProvider {
|
||||
readonly auth: IProviderAuthRuntime = new CodexAuthRuntime();
|
||||
readonly mcp: IProviderMcpRuntime = new CodexMcpRuntime();
|
||||
readonly skills: IProviderSkillsRuntime = new CodexSkillsRuntime();
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizerRuntime = new CodexSessionSynchronizerRuntime();
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
import type { IProviderAuthRuntime, ProviderAuthStatus } from '@/modules/ai-runtime/types/index.js';
|
||||
|
||||
const CURSOR_STATUS_TIMEOUT_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Reads auth status from `cursor-agent status`.
|
||||
*/
|
||||
export class CursorAuthRuntime implements IProviderAuthRuntime {
|
||||
async getStatus(): Promise<ProviderAuthStatus> {
|
||||
return new Promise((resolve) => {
|
||||
let completed = false;
|
||||
let childProcess: ReturnType<typeof spawn> | null = null;
|
||||
const timeout = setTimeout(() => {
|
||||
if (completed) {
|
||||
return;
|
||||
}
|
||||
|
||||
completed = true;
|
||||
if (childProcess) {
|
||||
childProcess.kill();
|
||||
}
|
||||
|
||||
resolve({
|
||||
provider: 'cursor',
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Command timeout',
|
||||
});
|
||||
}, CURSOR_STATUS_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
childProcess = spawn('cursor-agent', ['status']);
|
||||
} catch {
|
||||
clearTimeout(timeout);
|
||||
completed = true;
|
||||
resolve({
|
||||
provider: 'cursor',
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Cursor CLI not found or not installed',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
childProcess.stdout?.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
childProcess.stderr?.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
childProcess.on('close', (code) => {
|
||||
if (completed) {
|
||||
return;
|
||||
}
|
||||
|
||||
completed = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (code === 0) {
|
||||
const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
|
||||
if (emailMatch) {
|
||||
resolve({
|
||||
provider: 'cursor',
|
||||
authenticated: true,
|
||||
email: emailMatch[1],
|
||||
method: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (stdout.includes('Logged in')) {
|
||||
resolve({
|
||||
provider: 'cursor',
|
||||
authenticated: true,
|
||||
email: 'Logged in',
|
||||
method: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
provider: 'cursor',
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Not logged in',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
provider: 'cursor',
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: stderr.trim() || 'Not logged in',
|
||||
});
|
||||
});
|
||||
|
||||
childProcess.on('error', () => {
|
||||
if (completed) {
|
||||
return;
|
||||
}
|
||||
|
||||
completed = true;
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
provider: 'cursor',
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Cursor CLI not found or not installed',
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BaseCliProvider } from '@/modules/ai-runtime/providers/base/base-cli.provider.js';
|
||||
import type {
|
||||
IProviderAuthRuntime,
|
||||
IProviderMcpRuntime,
|
||||
IProviderSessionSynchronizerRuntime,
|
||||
IProviderSkillsRuntime,
|
||||
@@ -7,6 +8,7 @@ import type {
|
||||
StartSessionInput,
|
||||
} from '@/modules/ai-runtime/types/index.js';
|
||||
import { CursorMcpRuntime } from '@/modules/ai-runtime/providers/cursor/cursor-mcp.runtime.js';
|
||||
import { CursorAuthRuntime } from '@/modules/ai-runtime/providers/cursor/cursor-auth.runtime.js';
|
||||
import { CursorSkillsRuntime } from '@/modules/ai-runtime/providers/cursor/cursor-skills.runtime.js';
|
||||
import { CursorSessionSynchronizerRuntime } from '@/modules/ai-runtime/providers/cursor/cursor-session-synchronizer.runtime.js';
|
||||
|
||||
@@ -23,6 +25,7 @@ const ANSI_REGEX =
|
||||
* Cursor CLI provider implementation.
|
||||
*/
|
||||
export class CursorProvider extends BaseCliProvider {
|
||||
readonly auth: IProviderAuthRuntime = new CursorAuthRuntime();
|
||||
readonly mcp: IProviderMcpRuntime = new CursorMcpRuntime();
|
||||
readonly skills: IProviderSkillsRuntime = new CursorSkillsRuntime();
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizerRuntime = new CursorSessionSynchronizerRuntime();
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import type { IProviderAuthRuntime, ProviderAuthStatus } from '@/modules/ai-runtime/types/index.js';
|
||||
|
||||
type GeminiOauthCreds = {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads auth status from env and Gemini OAuth files.
|
||||
*/
|
||||
export class GeminiAuthRuntime implements IProviderAuthRuntime {
|
||||
async getStatus(): Promise<ProviderAuthStatus> {
|
||||
if (process.env.GEMINI_API_KEY?.trim()) {
|
||||
return {
|
||||
provider: 'gemini',
|
||||
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 = JSON.parse(content) as GeminiOauthCreds;
|
||||
if (!creds.access_token) {
|
||||
return {
|
||||
provider: 'gemini',
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'No valid tokens found in oauth_creds',
|
||||
};
|
||||
}
|
||||
|
||||
const validated = await this.resolveEmailFromAccessToken(creds.access_token);
|
||||
if (validated.email) {
|
||||
return {
|
||||
provider: 'gemini',
|
||||
authenticated: true,
|
||||
email: validated.email,
|
||||
method: 'oauth',
|
||||
};
|
||||
}
|
||||
|
||||
if (!validated.tokenValid && !creds.refresh_token) {
|
||||
return {
|
||||
provider: 'gemini',
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Access token invalid and no refresh token found',
|
||||
};
|
||||
}
|
||||
|
||||
const fallbackEmail = await this.readActiveGoogleAccountEmail();
|
||||
return {
|
||||
provider: 'gemini',
|
||||
authenticated: true,
|
||||
email: fallbackEmail ?? 'OAuth Session',
|
||||
method: 'oauth',
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
provider: 'gemini',
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Gemini CLI not configured',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates token and extracts email via Google's tokeninfo endpoint.
|
||||
*/
|
||||
private async resolveEmailFromAccessToken(
|
||||
accessToken: string,
|
||||
): Promise<{ tokenValid: boolean; email: string | null }> {
|
||||
try {
|
||||
const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${accessToken}`);
|
||||
if (!response.ok) {
|
||||
return { tokenValid: false, email: null };
|
||||
}
|
||||
|
||||
const tokenInfo = await response.json() as { email?: string };
|
||||
return {
|
||||
tokenValid: true,
|
||||
email: tokenInfo.email ?? null,
|
||||
};
|
||||
} catch {
|
||||
return { tokenValid: false, email: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads active Google account email from ~/.gemini/google_accounts.json.
|
||||
*/
|
||||
private async readActiveGoogleAccountEmail(): Promise<string | null> {
|
||||
try {
|
||||
const accountsPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
|
||||
const content = await readFile(accountsPath, 'utf8');
|
||||
const accounts = JSON.parse(content) as { active?: string };
|
||||
return typeof accounts.active === 'string' && accounts.active.trim()
|
||||
? accounts.active
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BaseCliProvider } from '@/modules/ai-runtime/providers/base/base-cli.provider.js';
|
||||
import type {
|
||||
IProviderAuthRuntime,
|
||||
IProviderMcpRuntime,
|
||||
IProviderSessionSynchronizerRuntime,
|
||||
IProviderSkillsRuntime,
|
||||
@@ -7,6 +8,7 @@ import type {
|
||||
StartSessionInput,
|
||||
} from '@/modules/ai-runtime/types/index.js';
|
||||
import { GeminiMcpRuntime } from '@/modules/ai-runtime/providers/gemini/gemini-mcp.runtime.js';
|
||||
import { GeminiAuthRuntime } from '@/modules/ai-runtime/providers/gemini/gemini-auth.runtime.js';
|
||||
import { GeminiSkillsRuntime } from '@/modules/ai-runtime/providers/gemini/gemini-skills.runtime.js';
|
||||
import { GeminiSessionSynchronizerRuntime } from '@/modules/ai-runtime/providers/gemini/gemini-session-synchronizer.runtime.js';
|
||||
|
||||
@@ -31,6 +33,7 @@ const GEMINI_MODELS: ProviderModel[] = [
|
||||
* Gemini CLI provider implementation.
|
||||
*/
|
||||
export class GeminiProvider extends BaseCliProvider {
|
||||
readonly auth: IProviderAuthRuntime = new GeminiAuthRuntime();
|
||||
readonly mcp: IProviderMcpRuntime = new GeminiMcpRuntime();
|
||||
readonly skills: IProviderSkillsRuntime = new GeminiSkillsRuntime();
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizerRuntime = new GeminiSessionSynchronizerRuntime();
|
||||
|
||||
12
server/src/modules/ai-runtime/services/auth.service.ts
Normal file
12
server/src/modules/ai-runtime/services/auth.service.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { llmProviderRegistry } from '@/modules/ai-runtime/ai-runtime.registry.js';
|
||||
import type { ProviderAuthStatus } from '@/modules/ai-runtime/types/index.js';
|
||||
|
||||
export const llmAuthService = {
|
||||
/**
|
||||
* Returns auth status for one provider.
|
||||
*/
|
||||
async getProviderAuthStatus(providerName: string): Promise<ProviderAuthStatus> {
|
||||
const provider = llmProviderRegistry.resolveProvider(providerName);
|
||||
return provider.auth.getStatus();
|
||||
},
|
||||
};
|
||||
19
server/src/modules/ai-runtime/types/auth.types.ts
Normal file
19
server/src/modules/ai-runtime/types/auth.types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { LLMProvider } from '@/shared/types/app.js';
|
||||
|
||||
/**
|
||||
* Provider authentication status normalized for frontend consumption.
|
||||
*/
|
||||
export type ProviderAuthStatus = {
|
||||
provider: LLMProvider;
|
||||
authenticated: boolean;
|
||||
email: string | null;
|
||||
method: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Auth runtime contract for one provider.
|
||||
*/
|
||||
export interface IProviderAuthRuntime {
|
||||
getStatus(): Promise<ProviderAuthStatus>;
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export * from '@/modules/ai-runtime/types/provider.types.js';
|
||||
export * from '@/modules/ai-runtime/types/mcp.types.js';
|
||||
export * from '@/modules/ai-runtime/types/skills.types.js';
|
||||
export * from '@/modules/ai-runtime/types/session-synchronizer.types.js';
|
||||
export * from '@/modules/ai-runtime/types/auth.types.js';
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { LLMProvider } from '@/shared/types/app.js';
|
||||
import type { IProviderMcpRuntime } from '@/modules/ai-runtime/types/mcp.types.js';
|
||||
import type { IProviderSkillsRuntime } from '@/modules/ai-runtime/types/skills.types.js';
|
||||
import type { IProviderSessionSynchronizerRuntime } from '@/modules/ai-runtime/types/session-synchronizer.types.js';
|
||||
import type { IProviderAuthRuntime } from '@/modules/ai-runtime/types/auth.types.js';
|
||||
|
||||
export type ProviderExecutionFamily = 'sdk' | 'cli';
|
||||
|
||||
@@ -80,6 +81,7 @@ export interface IProvider {
|
||||
readonly mcp: IProviderMcpRuntime;
|
||||
readonly skills: IProviderSkillsRuntime;
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizerRuntime;
|
||||
readonly auth: IProviderAuthRuntime;
|
||||
|
||||
listModels(): Promise<ProviderModel[]>;
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,434 +0,0 @@
|
||||
import express from 'express';
|
||||
import spawn from 'cross-spawn';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/claude/status', async (req, res) => {
|
||||
try {
|
||||
const credentialsResult = await checkClaudeCredentials();
|
||||
|
||||
if (credentialsResult.authenticated) {
|
||||
return res.json({
|
||||
authenticated: true,
|
||||
email: credentialsResult.email || 'Authenticated',
|
||||
method: credentialsResult.method // 'api_key' or 'credentials_file'
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: credentialsResult.error || 'Not authenticated'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking Claude auth status:', error);
|
||||
res.status(500).json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/cursor/status', async (req, res) => {
|
||||
try {
|
||||
const result = await checkCursorStatus();
|
||||
|
||||
res.json({
|
||||
authenticated: result.authenticated,
|
||||
email: result.email,
|
||||
error: result.error
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking Cursor auth status:', error);
|
||||
res.status(500).json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/codex/status', async (req, res) => {
|
||||
try {
|
||||
const result = await checkCodexCredentials();
|
||||
|
||||
res.json({
|
||||
authenticated: result.authenticated,
|
||||
email: result.email,
|
||||
error: result.error
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking Codex auth status:', error);
|
||||
res.status(500).json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/gemini/status', async (req, res) => {
|
||||
try {
|
||||
const result = await checkGeminiCredentials();
|
||||
|
||||
res.json({
|
||||
authenticated: result.authenticated,
|
||||
email: result.email,
|
||||
error: result.error
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking Gemini auth status:', error);
|
||||
res.status(500).json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function loadClaudeSettingsEnv() {
|
||||
try {
|
||||
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
||||
const content = await fs.readFile(settingsPath, 'utf8');
|
||||
const settings = JSON.parse(content);
|
||||
|
||||
if (settings?.env && typeof settings.env === 'object') {
|
||||
return settings.env;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore missing or malformed settings and fall back to other auth sources.
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks Claude authentication credentials using two methods with priority order:
|
||||
*
|
||||
* Priority 1: ANTHROPIC_API_KEY environment variable
|
||||
* Priority 1b: ~/.claude/settings.json env values
|
||||
* Priority 2: ~/.claude/.credentials.json OAuth tokens
|
||||
*
|
||||
* The Claude Agent SDK prioritizes environment variables over authenticated subscriptions.
|
||||
* This matching behavior ensures consistency with how the SDK authenticates.
|
||||
*
|
||||
* References:
|
||||
* - https://support.claude.com/en/articles/12304248-managing-api-key-environment-variables-in-claude-code
|
||||
* "Claude Code prioritizes environment variable API keys over authenticated subscriptions"
|
||||
* - https://platform.claude.com/docs/en/agent-sdk/overview
|
||||
* SDK authentication documentation
|
||||
*
|
||||
* @returns {Promise<Object>} Authentication status with { authenticated, email, method }
|
||||
* - authenticated: boolean indicating if valid credentials exist
|
||||
* - email: user email or auth method identifier
|
||||
* - method: 'api_key' for env var, 'credentials_file' for OAuth tokens
|
||||
*/
|
||||
async function checkClaudeCredentials() {
|
||||
// Priority 1: Check for ANTHROPIC_API_KEY environment variable
|
||||
// The SDK checks this first and uses it if present, even if OAuth tokens exist.
|
||||
// When set, API calls are charged via pay-as-you-go rates instead of subscription.
|
||||
if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.trim()) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: 'API Key Auth',
|
||||
method: 'api_key'
|
||||
};
|
||||
}
|
||||
|
||||
// Priority 1b: Check ~/.claude/settings.json env values.
|
||||
// Claude Code can read proxy/auth values from settings.json even when the
|
||||
// CloudCLI server process itself was not started with those env vars exported.
|
||||
const settingsEnv = await loadClaudeSettingsEnv();
|
||||
|
||||
if (typeof settingsEnv.ANTHROPIC_API_KEY === 'string' && settingsEnv.ANTHROPIC_API_KEY.trim()) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: 'API Key Auth',
|
||||
method: 'api_key'
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof settingsEnv.ANTHROPIC_AUTH_TOKEN === 'string' && settingsEnv.ANTHROPIC_AUTH_TOKEN.trim()) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: 'Configured via settings.json',
|
||||
method: 'api_key'
|
||||
};
|
||||
}
|
||||
|
||||
// Priority 2: Check ~/.claude/.credentials.json for OAuth tokens
|
||||
// This is the standard authentication method used by Claude CLI after running
|
||||
// 'claude /login' or 'claude setup-token' commands.
|
||||
try {
|
||||
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
||||
const content = await fs.readFile(credPath, 'utf8');
|
||||
const creds = JSON.parse(content);
|
||||
|
||||
const oauth = creds.claudeAiOauth;
|
||||
if (oauth && oauth.accessToken) {
|
||||
const isExpired = oauth.expiresAt && Date.now() >= oauth.expiresAt;
|
||||
|
||||
if (!isExpired) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: creds.email || creds.user || null,
|
||||
method: 'credentials_file'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function checkCursorStatus() {
|
||||
return new Promise((resolve) => {
|
||||
let processCompleted = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!processCompleted) {
|
||||
processCompleted = true;
|
||||
if (childProcess) {
|
||||
childProcess.kill();
|
||||
}
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Command timeout'
|
||||
});
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
let childProcess;
|
||||
try {
|
||||
childProcess = spawn('cursor-agent', ['status']);
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
processCompleted = true;
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Cursor CLI not found or not installed'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
childProcess.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
childProcess.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
childProcess.on('close', (code) => {
|
||||
if (processCompleted) return;
|
||||
processCompleted = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (code === 0) {
|
||||
const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
|
||||
|
||||
if (emailMatch) {
|
||||
resolve({
|
||||
authenticated: true,
|
||||
email: emailMatch[1],
|
||||
output: stdout
|
||||
});
|
||||
} else if (stdout.includes('Logged in')) {
|
||||
resolve({
|
||||
authenticated: true,
|
||||
email: 'Logged in',
|
||||
output: stdout
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Not logged in'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: stderr || 'Not logged in'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
childProcess.on('error', (err) => {
|
||||
if (processCompleted) return;
|
||||
processCompleted = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Cursor CLI not found or not installed'
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function checkCodexCredentials() {
|
||||
try {
|
||||
const authPath = path.join(os.homedir(), '.codex', 'auth.json');
|
||||
const content = await fs.readFile(authPath, 'utf8');
|
||||
const auth = JSON.parse(content);
|
||||
|
||||
// Tokens are nested under 'tokens' key
|
||||
const tokens = auth.tokens || {};
|
||||
|
||||
// Check for valid tokens (id_token or access_token)
|
||||
if (tokens.id_token || tokens.access_token) {
|
||||
// Try to extract email from id_token JWT payload
|
||||
let email = 'Authenticated';
|
||||
if (tokens.id_token) {
|
||||
try {
|
||||
// JWT is base64url encoded: header.payload.signature
|
||||
const parts = tokens.id_token.split('.');
|
||||
if (parts.length >= 2) {
|
||||
// Decode the payload (second part)
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
||||
email = payload.email || payload.user || 'Authenticated';
|
||||
}
|
||||
} catch {
|
||||
// If JWT decoding fails, use fallback
|
||||
email = 'Authenticated';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
email
|
||||
};
|
||||
}
|
||||
|
||||
// Also check for OPENAI_API_KEY as fallback auth method
|
||||
if (auth.OPENAI_API_KEY) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: 'API Key Auth'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'No valid tokens found'
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Codex not configured'
|
||||
};
|
||||
}
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function checkGeminiCredentials() {
|
||||
if (process.env.GEMINI_API_KEY && process.env.GEMINI_API_KEY.trim()) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: 'API Key Auth'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
|
||||
const content = await fs.readFile(credsPath, 'utf8');
|
||||
const creds = JSON.parse(content);
|
||||
|
||||
if (creds.access_token) {
|
||||
let email = 'OAuth Session';
|
||||
|
||||
try {
|
||||
// Validate token against Google API
|
||||
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${creds.access_token}`);
|
||||
if (tokenRes.ok) {
|
||||
const tokenInfo = await tokenRes.json();
|
||||
if (tokenInfo.email) {
|
||||
email = tokenInfo.email;
|
||||
}
|
||||
} else if (!creds.refresh_token) {
|
||||
// Token invalid and no refresh token available
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Access token invalid and no refresh token found'
|
||||
};
|
||||
} else {
|
||||
// Token might be expired but we have a refresh token, so CLI will refresh it
|
||||
try {
|
||||
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
|
||||
const accContent = await fs.readFile(accPath, 'utf8');
|
||||
const accounts = JSON.parse(accContent);
|
||||
if (accounts.active) {
|
||||
email = accounts.active;
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
} catch (e) {
|
||||
// Network error, fallback to checking local accounts file
|
||||
try {
|
||||
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
|
||||
const accContent = await fs.readFile(accPath, 'utf8');
|
||||
const accounts = JSON.parse(accContent);
|
||||
if (accounts.active) {
|
||||
email = accounts.active;
|
||||
}
|
||||
} catch (err) { }
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
email: email
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'No valid tokens found in oauth_creds'
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Gemini CLI not configured'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -56,7 +56,6 @@ const [
|
||||
pushSubRoutes,
|
||||
agentRoutes,
|
||||
projectsRoutes,
|
||||
cliAuthRoutes,
|
||||
codexRoutes,
|
||||
geminiRoutes,
|
||||
pluginsRoutes,
|
||||
@@ -83,7 +82,6 @@ const [
|
||||
importRoute('./modules/push-sub/push-sub.routes.js'),
|
||||
importRoute('./modules/agent/agent.routes.js'),
|
||||
importRoute('./modules/projects/projects.routes.js'),
|
||||
importRoute('./modules/cli-auth/cli-auth.routes.js'),
|
||||
importRoute('./modules/codex/codex.routes.js'),
|
||||
importRoute('./modules/gemini/gemini.routes.js'),
|
||||
importRoute('./modules/plugins/plugins.routes.js'),
|
||||
@@ -162,9 +160,6 @@ app.use('/api/credentials', authenticateToken, credentialsRoutes);
|
||||
app.use('/api/notification-preferences', authenticateToken, notificationPreferencesRoutes);
|
||||
app.use('/api/push-sub', authenticateToken, pushSubRoutes);
|
||||
|
||||
// CLI Authentication API Routes (protected)
|
||||
app.use('/api/cli', authenticateToken, cliAuthRoutes);
|
||||
|
||||
// User API Routes (protected)
|
||||
app.use('/api/user', authenticateToken, userRoutes);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user