refactor: make cli-auth part of the providers class

This commit is contained in:
Haileyesus
2026-04-08 18:45:39 +03:00
parent 89b0067478
commit a9d778e3fb
22 changed files with 572 additions and 608 deletions

View File

@@ -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) => {

View File

@@ -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>();

View File

@@ -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;
}
}

View File

@@ -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();

View File

@@ -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';
}
}
}

View File

@@ -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();

View File

@@ -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',
});
});
});
}
}

View File

@@ -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();

View File

@@ -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;
}
}
}

View File

@@ -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();

View 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();
},
};

View 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>;
}

View File

@@ -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';

View File

@@ -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[]>;

View File

@@ -1 +0,0 @@

View File

@@ -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;

View File

@@ -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);