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,126 @@
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 ClaudeCredentialsStatus = {
authenticated: boolean;
email: string | null;
method: string | null;
error?: string;
};
export class ClaudeAuthProvider implements IProviderAuthRuntime {
/**
* Checks whether the Claude Code CLI is available on this host.
*/
private checkInstalled(): boolean {
const cliPath = process.env.CLAUDE_CLI_PATH || 'claude';
try {
spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Returns Claude installation and credential status using Claude Code's auth priority.
*/
async getStatus(): Promise<ProviderAuthStatus> {
console.log("Checking Claude authentication status...")
const installed = this.checkInstalled();
if (!installed) {
return {
installed,
provider: 'claude',
authenticated: false,
email: null,
method: null,
error: 'Claude Code CLI is not installed',
};
}
const credentials = await this.checkCredentials();
console.log("Credientials status for Claude:", credentials)
return {
installed,
provider: 'claude',
authenticated: credentials.authenticated,
email: credentials.authenticated ? credentials.email || 'Authenticated' : credentials.email,
method: credentials.method,
error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
};
}
/**
* Reads Claude settings env values that the CLI can use even when the server process env is empty.
*/
private async loadSettingsEnv(): Promise<Record<string, unknown>> {
try {
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
const content = await readFile(settingsPath, 'utf8');
const settings = readObjectRecord(JSON.parse(content));
console.log("Settings env for Claude:", settings)
return readObjectRecord(settings?.env) ?? {};
} catch {
return {};
}
}
/**
* Checks Claude credentials in the same priority order used by Claude Code.
*/
private async checkCredentials(): Promise<ClaudeCredentialsStatus> {
if (process.env.ANTHROPIC_API_KEY?.trim()) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
const settingsEnv = await this.loadSettingsEnv();
if (readOptionalString(settingsEnv.ANTHROPIC_API_KEY)) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
if (readOptionalString(settingsEnv.ANTHROPIC_AUTH_TOKEN)) {
return { authenticated: true, email: 'Configured via settings.json', method: 'api_key' };
}
try {
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
const content = await readFile(credPath, 'utf8');
const creds = readObjectRecord(JSON.parse(content)) ?? {};
const oauth = readObjectRecord(creds.claudeAiOauth);
const accessToken = readOptionalString(oauth?.accessToken);
if (accessToken) {
const expiresAt = typeof oauth?.expiresAt === 'number' ? oauth.expiresAt : undefined;
const email = readOptionalString(creds.email) ?? readOptionalString(creds.user) ?? null;
if (!expiresAt || Date.now() < expiresAt) {
return {
authenticated: true,
email,
method: 'credentials_file',
};
}
return {
authenticated: false,
email,
method: 'credentials_file',
error: 'OAuth token has expired. Please re-authenticate with claude login',
};
}
return { authenticated: false, email: null, method: null };
} catch {
return { authenticated: false, email: null, method: null };
}
}
}

View File

@@ -1,6 +1,8 @@
import { getSessionMessages } from '@/projects.js'; import { getSessionMessages } from '@/projects.js';
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import { ClaudeAuthProvider } from '@/modules/providers/list/claude/claude-auth.provider.js';
import { ClaudeMcpProvider } from '@/modules/providers/list/claude/claude-mcp.provider.js'; import { ClaudeMcpProvider } from '@/modules/providers/list/claude/claude-mcp.provider.js';
import type { IProviderAuthRuntime } from '@/shared/interfaces.js';
import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js'; import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
@@ -55,6 +57,7 @@ function readRawProviderMessage(raw: unknown): RawProviderMessage | null {
export class ClaudeProvider extends AbstractProvider { export class ClaudeProvider extends AbstractProvider {
readonly mcp = new ClaudeMcpProvider(); readonly mcp = new ClaudeMcpProvider();
readonly auth: IProviderAuthRuntime = new ClaudeAuthProvider();
constructor() { constructor() {
super('claude'); super('claude');

View File

@@ -0,0 +1,100 @@
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 CodexCredentialsStatus = {
authenticated: boolean;
email: string | null;
method: string | null;
error?: string;
};
export class CodexAuthProvider implements IProviderAuthRuntime {
/**
* Checks whether Codex is available to the server runtime.
*/
private checkInstalled(): boolean {
try {
spawn.sync('codex', ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Returns Codex SDK availability and credential status.
*/
async getStatus(): Promise<ProviderAuthStatus> {
const installed = this.checkInstalled();
const credentials = await this.checkCredentials();
return {
installed,
provider: 'codex',
authenticated: credentials.authenticated,
email: credentials.email,
method: credentials.method,
error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
};
}
/**
* Reads Codex auth.json and checks OAuth tokens or an API key fallback.
*/
private async checkCredentials(): Promise<CodexCredentialsStatus> {
try {
const authPath = path.join(os.homedir(), '.codex', 'auth.json');
const content = await readFile(authPath, 'utf8');
const auth = readObjectRecord(JSON.parse(content)) ?? {};
const tokens = readObjectRecord(auth.tokens) ?? {};
const idToken = readOptionalString(tokens.id_token);
const accessToken = readOptionalString(tokens.access_token);
if (idToken || accessToken) {
return {
authenticated: true,
email: idToken ? this.readEmailFromIdToken(idToken) : 'Authenticated',
method: 'credentials_file',
};
}
if (readOptionalString(auth.OPENAI_API_KEY)) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
return { authenticated: false, email: null, method: null, error: 'No valid tokens found' };
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
return {
authenticated: false,
email: null,
method: null,
error: code === 'ENOENT' ? 'Codex not configured' : error instanceof Error ? error.message : 'Failed to read Codex auth',
};
}
}
/**
* Extracts the user email from a Codex id_token when a readable JWT payload exists.
*/
private readEmailFromIdToken(idToken: string): string {
try {
const parts = idToken.split('.');
if (parts.length >= 2) {
const payload = readObjectRecord(JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')));
return readOptionalString(payload?.email) ?? readOptionalString(payload?.user) ?? 'Authenticated';
}
} catch {
// Fall back to a generic authenticated marker if the token payload is not readable.
}
return 'Authenticated';
}
}

View File

@@ -1,6 +1,8 @@
import { getCodexSessionMessages } from '@/projects.js'; import { getCodexSessionMessages } from '@/projects.js';
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import { CodexAuthProvider } from '@/modules/providers/list/codex/codex-auth.provider.js';
import { CodexMcpProvider } from '@/modules/providers/list/codex/codex-mcp.provider.js'; import { CodexMcpProvider } from '@/modules/providers/list/codex/codex-mcp.provider.js';
import type { IProviderAuthRuntime } from '@/shared/interfaces.js';
import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js'; import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
@@ -29,6 +31,7 @@ function readRawProviderMessage(raw: unknown): RawProviderMessage | null {
export class CodexProvider extends AbstractProvider { export class CodexProvider extends AbstractProvider {
readonly mcp = new CodexMcpProvider(); readonly mcp = new CodexMcpProvider();
readonly auth: IProviderAuthRuntime = new CodexAuthProvider();
constructor() { constructor() {
super('codex'); super('codex');

View File

@@ -0,0 +1,143 @@
import spawn from 'cross-spawn';
import type { IProviderAuthRuntime } from '@/shared/interfaces.js';
import type { ProviderAuthStatus } from '@/shared/types.js';
type CursorLoginStatus = {
authenticated: boolean;
email: string | null;
method: string | null;
error?: string;
};
export class CursorAuthProvider implements IProviderAuthRuntime {
/**
* Checks whether the cursor-agent CLI is available on this host.
*/
private checkInstalled(): boolean {
try {
spawn.sync('cursor-agent', ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Returns Cursor CLI installation and login status.
*/
async getStatus(): Promise<ProviderAuthStatus> {
const installed = this.checkInstalled();
if (!installed) {
return {
installed,
provider: 'cursor',
authenticated: false,
email: null,
method: null,
error: 'Cursor CLI is not installed',
};
}
const login = await this.checkCursorLogin();
return {
installed,
provider: 'cursor',
authenticated: login.authenticated,
email: login.email,
method: login.method,
error: login.authenticated ? undefined : login.error || 'Not logged in',
};
}
/**
* Runs cursor-agent status and parses the login marker from stdout.
*/
private checkCursorLogin(): Promise<CursorLoginStatus> {
return new Promise((resolve) => {
let processCompleted = false;
let childProcess: ReturnType<typeof spawn> | undefined;
const timeout = setTimeout(() => {
if (!processCompleted) {
processCompleted = true;
childProcess?.kill();
resolve({
authenticated: false,
email: null,
method: null,
error: 'Command timeout',
});
}
}, 5000);
try {
childProcess = spawn('cursor-agent', ['status']);
} catch {
clearTimeout(timeout);
processCompleted = true;
resolve({
authenticated: false,
email: null,
method: null,
error: 'Cursor CLI not found or not installed',
});
return;
}
let stdout = '';
let stderr = '';
childProcess.stdout?.on('data', (data: Buffer) => {
stdout += data.toString();
});
childProcess.stderr?.on('data', (data: Buffer) => {
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?.[1]) {
resolve({ authenticated: true, email: emailMatch[1], method: 'cli' });
return;
}
if (stdout.includes('Logged in')) {
resolve({ authenticated: true, email: 'Logged in', method: 'cli' });
return;
}
resolve({ authenticated: false, email: null, method: null, error: 'Not logged in' });
return;
}
resolve({ authenticated: false, email: null, method: null, error: stderr || 'Not logged in' });
});
childProcess.on('error', () => {
if (processCompleted) {
return;
}
processCompleted = true;
clearTimeout(timeout);
resolve({
authenticated: false,
email: null,
method: null,
error: 'Cursor CLI not found or not installed',
});
});
});
}
}

View File

@@ -3,7 +3,9 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import { CursorAuthProvider } from '@/modules/providers/list/cursor/cursor-auth.provider.js';
import { CursorMcpProvider } from '@/modules/providers/list/cursor/cursor-mcp.provider.js'; import { CursorMcpProvider } from '@/modules/providers/list/cursor/cursor-mcp.provider.js';
import type { IProviderAuthRuntime } from '@/shared/interfaces.js';
import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js'; import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
@@ -34,6 +36,7 @@ function readRawProviderMessage(raw: unknown): RawProviderMessage | null {
export class CursorProvider extends AbstractProvider { export class CursorProvider extends AbstractProvider {
readonly mcp = new CursorMcpProvider(); readonly mcp = new CursorMcpProvider();
readonly auth: IProviderAuthRuntime = new CursorAuthProvider();
constructor() { constructor() {
super('cursor'); super('cursor');

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

View File

@@ -0,0 +1,12 @@
import { providerRegistry } from '@/modules/providers/provider.registry.js';
import type { ProviderAuthStatus } from '@/shared/types.js';
export const providerAuthService = {
/**
* Resolves a provider and returns its installation/authentication status.
*/
async getProviderAuthStatus(providerName: string): Promise<ProviderAuthStatus> {
const provider = providerRegistry.resolveProvider(providerName);
return provider.auth.getStatus();
},
};

View File

@@ -1,4 +1,4 @@
import type { IProvider, IProviderMcpRuntime } from '@/shared/interfaces.js'; import type { IProvider, IProviderAuthRuntime, IProviderMcpRuntime } from '@/shared/interfaces.js';
import type { import type {
FetchHistoryOptions, FetchHistoryOptions,
FetchHistoryResult, FetchHistoryResult,
@@ -9,12 +9,14 @@ import type {
/** /**
* Shared provider base. * Shared provider base.
* *
* Concrete providers must implement message normalization and history loading * Concrete providers must expose auth/MCP runtimes and implement message
* because both behaviors depend on each provider's native SDK/CLI event format. * normalization/history loading because those behaviors depend on native
* SDK/CLI formats.
*/ */
export abstract class AbstractProvider implements IProvider { export abstract class AbstractProvider implements IProvider {
readonly id: LLMProvider; readonly id: LLMProvider;
abstract readonly mcp: IProviderMcpRuntime; abstract readonly mcp: IProviderMcpRuntime;
abstract readonly auth: IProviderAuthRuntime;
protected constructor(id: LLMProvider) { protected constructor(id: LLMProvider) {
this.id = id; this.id = id;

View File

@@ -1,434 +1,35 @@
import express from 'express'; import express from 'express';
import { spawn } from 'child_process';
import fs from 'fs/promises'; import { providerAuthService } from '../modules/providers/services/provider-auth.service.js';
import path from 'path';
import os from 'os';
const router = express.Router(); 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: * Creates a status route handler for one provider while preserving the existing
* * /api/cli/<provider>/status endpoint shape.
* 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() { function createProviderStatusHandler(providerName) {
// Priority 1: Check for ANTHROPIC_API_KEY environment variable return async (req, res) => {
// 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 { try {
childProcess = spawn('cursor-agent', ['status']); const status = await providerAuthService.getProviderAuthStatus(providerName);
} catch (err) { return res.json(status);
clearTimeout(timeout); } catch (error) {
processCompleted = true; console.error(`Error checking ${providerName} auth status:`, error);
resolve({ return res.status(500).json({
installed: false,
provider: providerName,
authenticated: false, authenticated: false,
email: null, email: null,
error: 'Cursor CLI not found or not installed' method: null,
error: error instanceof Error ? error.message : 'Failed to check provider auth status',
}); });
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() { router.get('/claude/status', createProviderStatusHandler('claude'));
try { router.get('/cursor/status', createProviderStatusHandler('cursor'));
const authPath = path.join(os.homedir(), '.codex', 'auth.json'); router.get('/codex/status', createProviderStatusHandler('codex'));
const content = await fs.readFile(authPath, 'utf8'); router.get('/gemini/status', createProviderStatusHandler('gemini'));
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; export default router;

View File

@@ -5,10 +5,36 @@ import type {
McpScope, McpScope,
McpTransport, McpTransport,
NormalizedMessage, NormalizedMessage,
ProviderAuthStatus,
ProviderMcpServer, ProviderMcpServer,
UpsertProviderMcpServerInput, UpsertProviderMcpServerInput,
} from '@/shared/types.js'; } from '@/shared/types.js';
/**
* Main provider contract for CLI and SDK integrations.
*
* Each concrete provider owns its MCP/auth runtimes plus the provider-specific
* logic for converting native events/history into the app's normalized shape.
*/
export interface IProvider {
readonly id: LLMProvider;
readonly mcp: IProviderMcpRuntime;
readonly auth: IProviderAuthRuntime;
normalizeMessage(raw: unknown, sessionId: string | null): NormalizedMessage[];
fetchHistory(sessionId: string, options?: FetchHistoryOptions): Promise<FetchHistoryResult>;
}
/**
* Auth runtime contract for one provider.
*/
export interface IProviderAuthRuntime {
/**
* Checks whether the provider runtime is installed and has usable credentials.
*/
getStatus(): Promise<ProviderAuthStatus>;
}
/** /**
* MCP runtime contract for one provider. * MCP runtime contract for one provider.
@@ -32,17 +58,3 @@ export interface IProviderMcpRuntime {
error?: string; error?: string;
}>; }>;
} }
/**
* Main provider contract for CLI and SDK integrations.
*
* Each concrete provider owns its MCP runtime plus the provider-specific logic
* for converting native events/history into the app's normalized message shape.
*/
export interface IProvider {
readonly id: LLMProvider;
readonly mcp: IProviderMcpRuntime;
normalizeMessage(raw: unknown, sessionId: string | null): NormalizedMessage[];
fetchHistory(sessionId: string, options?: FetchHistoryOptions): Promise<FetchHistoryResult>;
}

View File

@@ -155,3 +155,25 @@ export type UpsertProviderMcpServerInput = {
bearerTokenEnvVar?: string; bearerTokenEnvVar?: string;
envHttpHeaders?: Record<string, string>; envHttpHeaders?: Record<string, string>;
}; };
// ---------------------------------------------------------------------------------------------
// -------------------- Provider auth status types --------------------
/**
* Result of a provider status check (installation + authentication).
*
* installed - Whether the provider's CLI/SDK is available
* provider - Provider id the status belongs to
* authenticated - Whether valid credentials exist
* email - User email or auth method identifier
* method - Auth method (e.g. 'api_key', 'credentials_file')
* [error] - Error message if not installed or not authenticated
*/
export type ProviderAuthStatus = {
installed: boolean;
provider: LLMProvider;
authenticated: boolean;
email: string | null;
method: string | null;
error?: string;
};