From a90684944d5a338ea057bbf3a4e6f2158b1c0a8d Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Sun, 15 Feb 2026 14:22:24 +0100 Subject: [PATCH 1/4] fix(agents): enhance AgentCLI with defensive option handling and debug logging --- src/agents/core/AgentCLI.ts | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/agents/core/AgentCLI.ts b/src/agents/core/AgentCLI.ts index 8d69534..7befaae 100644 --- a/src/agents/core/AgentCLI.ts +++ b/src/agents/core/AgentCLI.ts @@ -68,7 +68,16 @@ export class AgentCLI { .allowUnknownOption() .argument('[args...]', `Arguments to pass to ${this.adapter.displayName}`) .action(async (args, options) => { - await this.handleRun(args, options); + // Commander.js v11 behavior: options is Command instance when args array is empty, + // but plain object when args are provided. Handle both cases defensively. + const opts = typeof options?.opts === 'function' ? options.opts() : options; + + // Debug logging + logger.debug(`[AgentCLI] action called with args: ${JSON.stringify(args)}`); + logger.debug(`[AgentCLI] options type: ${typeof options}, has opts(): ${typeof options?.opts === 'function'}`); + logger.debug(`[AgentCLI] extracted opts: ${JSON.stringify(opts)}`); + + await this.handleRun(args, opts); }); // Add health check command @@ -89,7 +98,9 @@ export class AgentCLI { .option('--force', 'Force re-initialization') .option('--project-name ', 'Project name for framework initialization') .action(async (framework, options) => { - await this.handleInit(framework, options); + // Commander.js v11 behavior: options might be Command instance or plain object + const opts = typeof options?.opts === 'function' ? options.opts() : options; + await this.handleInit(framework, opts); }); } } @@ -123,8 +134,13 @@ export class AgentCLI { process.exit(1); } - // Apply silent mode from CLI flag (if provided) - if (options.silent) { + // Auto-enable silent mode in non-interactive mode (--task flag present) + // This suppresses welcome/goodbye messages and interactive prompts + const isNonInteractiveMode = !!options.task; + const shouldBeSilent = options.silent || isNonInteractiveMode; + + // Apply silent mode from CLI flag or auto-detected non-interactive mode + if (shouldBeSilent) { // Type-safe check: ensure adapter has setSilentMode method if ('setSilentMode' in this.adapter && typeof this.adapter.setSilentMode === 'function') { this.adapter.setSilentMode(true); @@ -206,6 +222,9 @@ export class AgentCLI { // Collect all arguments to pass to the agent const agentArgs = this.collectPassThroughArgs(args, options); + // Debug logging + logger.debug(`[AgentCLI] collected agentArgs: ${JSON.stringify(agentArgs)}`); + // Run the agent (welcome message will be shown inside) await this.adapter.run(agentArgs, providerEnv); } catch (error) { From efeeeb49b9a2f55ee929e25aff81a9d75861d10a Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Mon, 16 Feb 2026 11:29:37 +0100 Subject: [PATCH 2/4] feat(cli): add JWT authentication support with CLI integration and health checks --- src/agents/core/AgentCLI.ts | 9 +- src/agents/core/BaseAgentAdapter.ts | 8 +- .../commands/doctor/checks/JWTAuthCheck.ts | 122 ++++++++++ src/cli/commands/doctor/checks/index.ts | 1 + src/cli/commands/doctor/index.ts | 208 +++++++++--------- src/env/types.ts | 12 +- src/providers/core/types.ts | 30 ++- .../plugins/sso/proxy/plugins/index.ts | 4 +- .../sso/proxy/plugins/jwt-auth.plugin.ts | 59 +++++ .../sso/proxy/plugins/sso-auth.plugin.ts | 15 +- .../proxy/plugins/sso.session-sync.plugin.ts | 11 +- .../plugins/sso/proxy/plugins/types.ts | 4 +- .../plugins/sso/proxy/proxy-types.ts | 2 + src/providers/plugins/sso/proxy/sso.proxy.ts | 54 ++++- src/providers/plugins/sso/sso.http-client.ts | 172 +++++++++------ src/providers/plugins/sso/sso.template.ts | 7 + src/utils/security.ts | 114 +++++++++- 17 files changed, 639 insertions(+), 193 deletions(-) create mode 100644 src/cli/commands/doctor/checks/JWTAuthCheck.ts create mode 100644 src/providers/plugins/sso/proxy/plugins/jwt-auth.plugin.ts diff --git a/src/agents/core/AgentCLI.ts b/src/agents/core/AgentCLI.ts index 7befaae..e26664e 100644 --- a/src/agents/core/AgentCLI.ts +++ b/src/agents/core/AgentCLI.ts @@ -64,6 +64,7 @@ export class AgentCLI { .option('--api-key ', 'Override API key') .option('--base-url ', 'Override base URL') .option('--timeout ', 'Override timeout (in seconds)', parseInt) + .option('--jwt-token ', 'JWT token for authentication (overrides config)') .option('--task ', 'Execute a single task (agent-specific flag mapping)') .allowUnknownOption() .argument('[args...]', `Arguments to pass to ${this.adapter.displayName}`) @@ -157,6 +158,12 @@ export class AgentCLI { timeout: options.timeout as number | undefined }); + // JWT token from CLI overrides everything + if (options.jwtToken) { + process.env.CODEMIE_JWT_TOKEN = options.jwtToken as string; + process.env.CODEMIE_AUTH_METHOD = 'jwt'; + } + // Validate essential configuration const missingFields: string[] = []; if (!config.baseUrl) missingFields.push('baseUrl'); @@ -358,7 +365,7 @@ export class AgentCLI { ): string[] { const agentArgs = [...args]; // Config-only options (not passed to agent, handled by CodeMie CLI) - const configOnlyOptions = ['profile', 'provider', 'apiKey', 'baseUrl', 'timeout', 'model', 'silent']; + const configOnlyOptions = ['profile', 'provider', 'apiKey', 'baseUrl', 'timeout', 'model', 'silent', 'jwtToken']; for (const [key, value] of Object.entries(options)) { // Skip config-only options (handled by CodeMie CLI layer) diff --git a/src/agents/core/BaseAgentAdapter.ts b/src/agents/core/BaseAgentAdapter.ts index 3d00742..a121088 100644 --- a/src/agents/core/BaseAgentAdapter.ts +++ b/src/agents/core/BaseAgentAdapter.ts @@ -528,9 +528,11 @@ export abstract class BaseAgentAdapter implements AgentAdapter { const provider = ProviderRegistry.getProvider(providerName); const isSSOProvider = provider?.authType === 'sso'; + const isJWTAuth = env.CODEMIE_AUTH_METHOD === 'jwt'; const isProxyEnabled = this.metadata.ssoConfig?.enabled ?? false; - return isSSOProvider && isProxyEnabled; + // Proxy needed for SSO cookie injection OR JWT bearer token injection + return (isSSOProvider || isJWTAuth) && isProxyEnabled; } /** @@ -567,7 +569,9 @@ export abstract class BaseAgentAdapter implements AgentAdapter { integrationId: env.CODEMIE_INTEGRATION_ID, sessionId: env.CODEMIE_SESSION_ID, version: env.CODEMIE_CLI_VERSION, - profileConfig + profileConfig, + authMethod: (env.CODEMIE_AUTH_METHOD as 'sso' | 'jwt') || undefined, + jwtToken: env.CODEMIE_JWT_TOKEN || undefined }; } diff --git a/src/cli/commands/doctor/checks/JWTAuthCheck.ts b/src/cli/commands/doctor/checks/JWTAuthCheck.ts new file mode 100644 index 0000000..d0c4393 --- /dev/null +++ b/src/cli/commands/doctor/checks/JWTAuthCheck.ts @@ -0,0 +1,122 @@ +/** + * JWT Authentication health check + */ + +import { CredentialStore } from '../../../../utils/security.js'; +import { ConfigLoader } from '../../../../utils/config.js'; +import { HealthCheck, HealthCheckResult, HealthCheckDetail, ProgressCallback } from '../types.js'; + +export class JWTAuthCheck implements HealthCheck { + name = 'JWT Authentication'; + + async run(onProgress?: ProgressCallback): Promise { + const details: HealthCheckDetail[] = []; + let success = true; + + try { + onProgress?.('Checking JWT authentication'); + + const config = await ConfigLoader.load(); + + // Only check if profile uses JWT auth + if (config.authMethod !== 'jwt') { + details.push({ + status: 'info', + message: 'Not using JWT authentication (skipped)' + }); + return { name: this.name, success: true, details }; + } + + // Check 1: JWT token available (env var or credential store) + onProgress?.('Checking JWT token presence'); + const tokenEnvVar = config.jwtConfig?.tokenEnvVar || 'CODEMIE_JWT_TOKEN'; + const envToken = process.env[tokenEnvVar]; + + if (!envToken) { + const store = CredentialStore.getInstance(); + const storedCreds = await store.retrieveJWTCredentials(config.baseUrl); + + if (!storedCreds) { + details.push({ + status: 'error', + message: `JWT token not found in ${tokenEnvVar} or credential store`, + hint: `Set ${tokenEnvVar} or run: codemie setup` + }); + success = false; + return { name: this.name, success, details }; + } + + // Token found in credential store + details.push({ + status: 'ok', + message: 'JWT token found in credential store' + }); + } else { + // Token found in environment variable + details.push({ + status: 'ok', + message: `JWT token found in ${tokenEnvVar}` + }); + } + + // Check 2: Token expiration (warn 7 days before expiry) + onProgress?.('Checking JWT token expiration'); + const token = envToken || (await CredentialStore.getInstance() + .retrieveJWTCredentials(config.baseUrl))?.token; + + if (token) { + try { + // Parse JWT payload to get expiration + const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()); + if (payload.exp) { + const expiresAt = payload.exp * 1000; // Convert to milliseconds + const daysUntilExpiry = (expiresAt - Date.now()) / (1000 * 60 * 60 * 24); + const expiresDate = new Date(expiresAt); + + if (daysUntilExpiry < 0) { + details.push({ + status: 'error', + message: `JWT token expired on ${expiresDate.toISOString()}`, + hint: 'Please provide a fresh token via codemie setup or update CODEMIE_JWT_TOKEN' + }); + success = false; + } else if (daysUntilExpiry < 7) { + details.push({ + status: 'warn', + message: `JWT token expires in ${Math.ceil(daysUntilExpiry)} days (${expiresDate.toISOString()})`, + hint: 'Consider refreshing your token soon' + }); + } else { + details.push({ + status: 'ok', + message: `JWT token expires on ${expiresDate.toISOString()}` + }); + } + } else { + // Token has no expiration field + details.push({ + status: 'info', + message: 'JWT token has no expiration date' + }); + } + } catch { + // Non-standard JWT - skip expiration check + details.push({ + status: 'info', + message: 'Could not parse JWT token expiration (non-standard format)' + }); + } + } + + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + details.push({ + status: 'error', + message: `JWT authentication check failed: ${errorMessage}` + }); + success = false; + } + + return { name: this.name, success, details }; + } +} diff --git a/src/cli/commands/doctor/checks/index.ts b/src/cli/commands/doctor/checks/index.ts index bb070eb..aa16418 100644 --- a/src/cli/commands/doctor/checks/index.ts +++ b/src/cli/commands/doctor/checks/index.ts @@ -8,6 +8,7 @@ export { PythonCheck } from './PythonCheck.js'; export { UvCheck } from './UvCheck.js'; export { AwsCliCheck } from './AwsCliCheck.js'; export { AIConfigCheck } from './AIConfigCheck.js'; +export { JWTAuthCheck } from './JWTAuthCheck.js'; export { AgentsCheck } from './AgentsCheck.js'; export { WorkflowsCheck } from './WorkflowsCheck.js'; export { FrameworksCheck } from './FrameworksCheck.js'; diff --git a/src/cli/commands/doctor/index.ts b/src/cli/commands/doctor/index.ts index 153bccf..c371e5a 100644 --- a/src/cli/commands/doctor/index.ts +++ b/src/cli/commands/doctor/index.ts @@ -5,7 +5,7 @@ import { Command } from 'commander'; import chalk from 'chalk'; import os from 'os'; -import { HealthCheckResult } from './types.js'; +import { HealthCheck, ItemWiseHealthCheck, HealthCheckResult } from './types.js'; import { HealthCheckFormatter } from './formatter.js'; import { NodeVersionCheck, @@ -14,6 +14,7 @@ import { UvCheck, AwsCliCheck, AIConfigCheck, + JWTAuthCheck, AgentsCheck, WorkflowsCheck, FrameworksCheck @@ -79,110 +80,119 @@ export function createDoctorCommand(): Command { // Display header formatter.displayHeader(); - // Helper to display a pre-computed check result - const displayResult = (result: HealthCheckResult): void => { - formatter.displayCheck(result); - if (result.details && result.details.length > 0) { - result.details.forEach(detail => { - logger.debug(` - ${detail.status}: ${detail.message}`); - }); - } - logger.debug(''); - }; - - // --- Group 1: Independent tool checks (run in parallel) --- - const nodeCheck = new NodeVersionCheck(); - const npmCheck = new NpmCheck(); - const pythonCheck = new PythonCheck(); - const uvCheck = new UvCheck(); - const awsCheck = new AwsCliCheck(); - - logger.debug('=== Running Tool Checks (parallel) ==='); - const toolStartTime = Date.now(); - const [nodeResult, npmResult, pythonResult, uvResult, awsResult] = await Promise.all([ - nodeCheck.run(), - npmCheck.run(), - pythonCheck.run(), - uvCheck.run(), - awsCheck.run() - ]); - logger.debug(`Tool checks completed in ${Date.now() - toolStartTime}ms`); - - // Display tool check results sequentially - for (const result of [nodeResult, npmResult, pythonResult, uvResult, awsResult]) { - results.push(result); - displayResult(result); - } - - // --- Group 2: AI Config + Provider check (sequential, provider depends on config) --- - const aiConfigCheck = new AIConfigCheck(); - logger.debug('=== Running Check: Active Profile ==='); - const configStartTime = Date.now(); - const configResult = await aiConfigCheck.run(); - logger.debug(`Check completed in ${Date.now() - configStartTime}ms`); - results.push(configResult); - displayResult(configResult); - - // Run provider-specific checks if config is available - const config = aiConfigCheck.getConfig(); - if (config && config.provider) { - logger.debug(`=== Running Provider Check: ${config.provider} ===`); - logger.debug(`Base URL: ${config.baseUrl}`); - logger.debug(`Model: ${config.model}`); - - const healthCheck = ProviderRegistry.getHealthCheck(config.provider); - - if (healthCheck) { - formatter.startCheck('Provider'); - - try { - const providerStartTime = Date.now(); - const providerResult = await healthCheck.check(config); - logger.debug(`Provider check completed in ${Date.now() - providerStartTime}ms`); - logger.debug(`Status: ${providerResult.status}`); - - const doctorResult = adaptProviderResult(providerResult); - results.push(doctorResult); - formatter.displayCheckWithHeader(doctorResult); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logger.error(`Provider check failed: ${errorMessage}`); - if (error instanceof Error && error.stack) { - logger.debug(`Stack trace: ${error.stack}`); + // Define standard health checks + const checks: HealthCheck[] = [ + new NodeVersionCheck(), + new NpmCheck(), + new PythonCheck(), + new UvCheck(), + new AwsCliCheck(), + new AIConfigCheck(), + new JWTAuthCheck(), + new AgentsCheck(), + new WorkflowsCheck(), + new FrameworksCheck() + ]; + + // Run and display standard checks immediately + for (const check of checks) { + logger.debug(`=== Running Check: ${check.name} ===`); + const startTime = Date.now(); + + // Check if this is an ItemWiseHealthCheck + const isItemWise = 'runWithItemDisplay' in check; + + if (isItemWise) { + // Display section header + console.log(formatter['getCheckHeader'](check.name)); + + // Run with item-by-item display + const result = await (check as ItemWiseHealthCheck).runWithItemDisplay( + (itemName) => { + logger.debug(` Checking item: ${itemName}`); + formatter.startItem(itemName); + }, + (detail) => { + logger.debug(` Result: ${detail.status} - ${detail.message}`); + formatter.displayItem(detail); } + ); + results.push(result); + + const elapsed = Date.now() - startTime; + logger.debug(`Check completed in ${elapsed}ms: ${result.success ? 'SUCCESS' : 'FAILED'}`); + logger.debug(''); - results.push({ - name: 'Provider Check Error', - success: false, - details: [{ - status: 'error', - message: `Check failed: ${errorMessage}` - }] + // Add blank line after section + console.log(); + } else { + // Regular check with section-level progress + formatter.startCheck(check.name); + const result = await check.run((message) => { + logger.debug(` Progress: ${message}`); + formatter.updateProgress(message); + }); + results.push(result); + formatter.displayCheck(result); + + const elapsed = Date.now() - startTime; + logger.debug(`Check completed in ${elapsed}ms: ${result.success ? 'SUCCESS' : 'FAILED'}`); + if (result.details && result.details.length > 0) { + result.details.forEach(detail => { + logger.debug(` - ${detail.status}: ${detail.message}`); }); } - } else { - logger.debug(`No health check available for provider: ${config.provider}`); + logger.debug(''); } - } - // --- Group 3: Discovery checks (run in parallel) --- - const agentsCheck = new AgentsCheck(); - const workflowsCheck = new WorkflowsCheck(); - const frameworksCheck = new FrameworksCheck(); - - logger.debug('=== Running Discovery Checks (parallel) ==='); - const discoveryStartTime = Date.now(); - const [agentsResult, workflowsResult, frameworksResult] = await Promise.all([ - agentsCheck.run(), - workflowsCheck.run(), - frameworksCheck.run() - ]); - logger.debug(`Discovery checks completed in ${Date.now() - discoveryStartTime}ms`); - - // Display discovery check results sequentially - for (const result of [agentsResult, workflowsResult, frameworksResult]) { - results.push(result); - displayResult(result); + // After AIConfigCheck, immediately run provider-specific checks + if (check instanceof AIConfigCheck) { + const config = check.getConfig(); + + if (config && config.provider) { + logger.debug(`=== Running Provider Check: ${config.provider} ===`); + logger.debug(`Base URL: ${config.baseUrl}`); + logger.debug(`Model: ${config.model}`); + + // Get health check from ProviderRegistry + const healthCheck = ProviderRegistry.getHealthCheck(config.provider); + + if (healthCheck) { + formatter.startCheck('Provider'); + + try { + const providerStartTime = Date.now(); + const providerResult = await healthCheck.check(config); + const elapsed = Date.now() - providerStartTime; + + logger.debug(`Provider check completed in ${elapsed}ms`); + logger.debug(`Status: ${providerResult.status}`); + + const doctorResult = adaptProviderResult(providerResult); + results.push(doctorResult); + formatter.displayCheckWithHeader(doctorResult); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Provider check failed: ${errorMessage}`); + if (error instanceof Error && error.stack) { + logger.debug(`Stack trace: ${error.stack}`); + } + + // If check throws, capture error + results.push({ + name: 'Provider Check Error', + success: false, + details: [{ + status: 'error', + message: `Check failed: ${errorMessage}` + }] + }); + } + } else { + logger.debug(`No health check available for provider: ${config.provider}`); + } + } + } } logger.debug('=== All Checks Completed ==='); diff --git a/src/env/types.ts b/src/env/types.ts index 9df5ce3..b4d4997 100644 --- a/src/env/types.ts +++ b/src/env/types.ts @@ -46,7 +46,7 @@ export interface ProviderProfile { ignorePatterns?: string[]; // SSO-specific fields - authMethod?: 'manual' | 'sso'; + authMethod?: 'manual' | 'sso' | 'jwt' | 'api-key'; codeMieUrl?: string; codeMieProject?: string; // Selected project/application name codemieAssistants?: CodemieAssistant[]; @@ -56,6 +56,14 @@ export interface ProviderProfile { cookiesEncrypted?: string; }; + // JWT-specific fields + jwtConfig?: { + token?: string; + tokenEnvVar?: string; + apiUrl?: string; + expiresAt?: number; + }; + // AWS Bedrock-specific fields awsProfile?: string; awsRegion?: string; @@ -92,7 +100,7 @@ export interface LegacyConfig { debug?: boolean; allowedDirs?: string[]; ignorePatterns?: string[]; - authMethod?: 'manual' | 'sso'; + authMethod?: 'manual' | 'sso' | 'jwt' | 'api-key'; codeMieUrl?: string; codeMieProject?: string; // Selected project/application name codemieAssistants?: CodemieAssistant[]; diff --git a/src/providers/core/types.ts b/src/providers/core/types.ts index ee61f51..7d9a582 100644 --- a/src/providers/core/types.ts +++ b/src/providers/core/types.ts @@ -38,7 +38,7 @@ export interface ModelMetadata { /** * Authentication type for providers */ -export type AuthenticationType = 'api-key' | 'sso' | 'oauth' | 'none'; +export type AuthenticationType = 'api-key' | 'sso' | 'oauth' | 'jwt' | 'none'; /** * Provider template - declarative metadata @@ -402,6 +402,34 @@ export interface SSOCredentials { expiresAt?: number; } +/** + * JWT credentials for storage + */ +export interface JWTCredentials { + token: string; + apiUrl: string; + expiresAt?: number; +} + +/** + * Unified authentication credentials + */ +export type AuthCredentials = SSOCredentials | JWTCredentials; + +/** + * Type guard for JWT credentials + */ +export function isJWTCredentials(creds: AuthCredentials): creds is JWTCredentials { + return 'token' in creds && !('cookies' in creds); +} + +/** + * Type guard for SSO credentials + */ +export function isSSOCredentials(creds: AuthCredentials): creds is SSOCredentials { + return 'cookies' in creds && !('token' in creds); +} + /** * CodeMie integration metadata */ diff --git a/src/providers/plugins/sso/proxy/plugins/index.ts b/src/providers/plugins/sso/proxy/plugins/index.ts index 6d7a06b..8a0f338 100644 --- a/src/providers/plugins/sso/proxy/plugins/index.ts +++ b/src/providers/plugins/sso/proxy/plugins/index.ts @@ -8,6 +8,7 @@ import { getPluginRegistry } from './registry.js'; import { EndpointBlockerPlugin } from './endpoint-blocker.plugin.js'; import { SSOAuthPlugin } from './sso-auth.plugin.js'; +import { JWTAuthPlugin } from './jwt-auth.plugin.js'; import { HeaderInjectionPlugin } from './header-injection.plugin.js'; import { LoggingPlugin } from './logging.plugin.js'; import { SSOSessionSyncPlugin } from './sso.session-sync.plugin.js'; @@ -22,6 +23,7 @@ export function registerCorePlugins(): void { // Register in any order (priority determines execution order) registry.register(new EndpointBlockerPlugin()); // Priority 5 - blocks unwanted endpoints early registry.register(new SSOAuthPlugin()); + registry.register(new JWTAuthPlugin()); registry.register(new HeaderInjectionPlugin()); registry.register(new LoggingPlugin()); // Always enabled - logs to log files at INFO level registry.register(new SSOSessionSyncPlugin()); // Priority 100 - syncs sessions via multiple processors @@ -31,7 +33,7 @@ export function registerCorePlugins(): void { registerCorePlugins(); // Re-export for convenience -export { EndpointBlockerPlugin, SSOAuthPlugin, HeaderInjectionPlugin, LoggingPlugin }; +export { EndpointBlockerPlugin, SSOAuthPlugin, JWTAuthPlugin, HeaderInjectionPlugin, LoggingPlugin }; export { SSOSessionSyncPlugin } from './sso.session-sync.plugin.js'; export { getPluginRegistry, resetPluginRegistry } from './registry.js'; export * from './types.js'; diff --git a/src/providers/plugins/sso/proxy/plugins/jwt-auth.plugin.ts b/src/providers/plugins/sso/proxy/plugins/jwt-auth.plugin.ts new file mode 100644 index 0000000..1b41ff2 --- /dev/null +++ b/src/providers/plugins/sso/proxy/plugins/jwt-auth.plugin.ts @@ -0,0 +1,59 @@ +/** + * JWT Authentication Plugin + * Priority: 10 (same as SSO - only one activates based on credential type) + * + * SOLID: Single responsibility = inject JWT bearer token + * KISS: Simple interceptor, one clear purpose + */ + +import { ProxyPlugin, PluginContext, ProxyInterceptor } from './types.js'; +import { ProxyContext } from '../proxy-types.js'; +import { JWTCredentials } from '../../../../core/types.js'; +import { logger } from '../../../../../utils/logger.js'; + +export class JWTAuthPlugin implements ProxyPlugin { + id = '@codemie/proxy-jwt-auth'; + name = 'JWT Authentication'; + version = '1.0.0'; + priority = 10; + + async createInterceptor(context: PluginContext): Promise { + // Guard: skip if credentials are not JWT (mutual exclusion with SSOAuthPlugin) + if (!context.credentials || !('token' in context.credentials)) { + return new NoOpInterceptor('jwt-auth'); + } + + return new JWTAuthInterceptor(context.credentials as JWTCredentials); + } +} + +/** + * No-op interceptor returned when this plugin is not the active auth method. + * Zero runtime cost - no hooks implemented. + */ +class NoOpInterceptor implements ProxyInterceptor { + constructor(public name: string) {} +} + +class JWTAuthInterceptor implements ProxyInterceptor { + name = 'jwt-auth'; + + constructor(private credentials: JWTCredentials) {} + + async onRequest(context: ProxyContext): Promise { + // Check token expiration + if (this.credentials.expiresAt && Date.now() > this.credentials.expiresAt) { + throw new Error('JWT token expired. Please re-authenticate.'); + } + + // Inject Bearer token into Authorization header + context.headers['authorization'] = `Bearer ${this.credentials.token}`; + + logger.debug(`[${this.name}] Injected JWT bearer token`, { + tokenPrefix: this.credentials.token.substring(0, 20) + '...', + expiresAt: this.credentials.expiresAt + ? new Date(this.credentials.expiresAt).toISOString() + : 'no expiration' + }); + } +} diff --git a/src/providers/plugins/sso/proxy/plugins/sso-auth.plugin.ts b/src/providers/plugins/sso/proxy/plugins/sso-auth.plugin.ts index 2d54cde..d4a3668 100644 --- a/src/providers/plugins/sso/proxy/plugins/sso-auth.plugin.ts +++ b/src/providers/plugins/sso/proxy/plugins/sso-auth.plugin.ts @@ -18,14 +18,23 @@ export class SSOAuthPlugin implements ProxyPlugin { priority = 10; async createInterceptor(context: PluginContext): Promise { - if (!context.credentials) { - throw new Error('SSO credentials required for SSOAuthPlugin'); + // Guard: skip if credentials are JWT (not SSO) + if (!context.credentials || !('cookies' in context.credentials)) { + return new NoOpInterceptor('sso-auth'); } - return new SSOAuthInterceptor(context.credentials); + return new SSOAuthInterceptor(context.credentials as SSOCredentials); } } +/** + * No-op interceptor returned when this plugin is not the active auth method. + * Zero runtime cost - no hooks implemented. + */ +class NoOpInterceptor implements ProxyInterceptor { + constructor(public name: string) {} +} + class SSOAuthInterceptor implements ProxyInterceptor { name = 'sso-auth'; diff --git a/src/providers/plugins/sso/proxy/plugins/sso.session-sync.plugin.ts b/src/providers/plugins/sso/proxy/plugins/sso.session-sync.plugin.ts index 131df1f..c9327ae 100644 --- a/src/providers/plugins/sso/proxy/plugins/sso.session-sync.plugin.ts +++ b/src/providers/plugins/sso/proxy/plugins/sso.session-sync.plugin.ts @@ -24,6 +24,7 @@ import { ProxyPlugin, PluginContext, ProxyInterceptor } from './types.js'; import { logger } from '../../../../../utils/logger.js'; import type { ProcessingContext } from '../../session/BaseProcessor.js'; import { SessionSyncer } from '../../session/SessionSyncer.js'; +import type { SSOCredentials } from '../../../../core/types.js'; export class SSOSessionSyncPlugin implements ProxyPlugin { id = '@codemie/sso-session-sync'; @@ -38,8 +39,9 @@ export class SSOSessionSyncPlugin implements ProxyPlugin { throw new Error('Session ID not available (session sync disabled)'); } - if (!context.credentials) { - logger.debug('[SSOSessionSyncPlugin] Skipping: SSO credentials not available'); + // Guard: skip if credentials are JWT (not SSO) + if (!context.credentials || !('cookies' in context.credentials)) { + logger.debug('[SSOSessionSyncPlugin] Skipping: Not SSO credentials'); throw new Error('SSO credentials not available (session sync disabled)'); } @@ -60,10 +62,13 @@ export class SSOSessionSyncPlugin implements ProxyPlugin { // Check if dry-run mode is enabled const dryRun = this.isDryRunEnabled(context); + // Cast credentials to SSOCredentials (already validated above) + const ssoCredentials = context.credentials as SSOCredentials; + return new SSOSessionSyncInterceptor( context.config.sessionId, context.config.targetApiUrl, - context.credentials.cookies, + ssoCredentials.cookies, context.config.clientType, context.config.version, dryRun diff --git a/src/providers/plugins/sso/proxy/plugins/types.ts b/src/providers/plugins/sso/proxy/plugins/types.ts index cb91d65..6f917d1 100644 --- a/src/providers/plugins/sso/proxy/plugins/types.ts +++ b/src/providers/plugins/sso/proxy/plugins/types.ts @@ -8,7 +8,7 @@ import { IncomingHttpHeaders } from 'http'; import { ProxyConfig, ProxyContext } from '../proxy-types.js'; import { logger } from '../../../../../utils/logger.js'; -import { SSOCredentials } from '../../../../core/types.js'; +import { SSOCredentials, JWTCredentials } from '../../../../core/types.js'; import type { CodeMieConfigOptions } from '../../../../../env/types.js'; /** @@ -46,7 +46,7 @@ export interface ProxyPlugin { export interface PluginContext { config: ProxyConfig; logger: typeof logger; - credentials?: SSOCredentials; + credentials?: SSOCredentials | JWTCredentials; profileConfig?: CodeMieConfigOptions; // Full profile config (read once at CLI level) [key: string]: unknown; // Extensible } diff --git a/src/providers/plugins/sso/proxy/proxy-types.ts b/src/providers/plugins/sso/proxy/proxy-types.ts index 5b9b7f3..b9a0f49 100644 --- a/src/providers/plugins/sso/proxy/proxy-types.ts +++ b/src/providers/plugins/sso/proxy/proxy-types.ts @@ -23,6 +23,8 @@ export interface ProxyConfig { sessionId?: string; version?: string; // CLI version for metrics and headers profileConfig?: CodeMieConfigOptions; // Full profile config (read once at CLI level) + authMethod?: 'sso' | 'jwt'; // Authentication method + jwtToken?: string; // JWT token (from CLI arg or env var) } /** diff --git a/src/providers/plugins/sso/proxy/sso.proxy.ts b/src/providers/plugins/sso/proxy/sso.proxy.ts index a504596..6bf8de0 100644 --- a/src/providers/plugins/sso/proxy/sso.proxy.ts +++ b/src/providers/plugins/sso/proxy/sso.proxy.ts @@ -56,22 +56,42 @@ export class CodeMieProxy { * Start the proxy server */ async start(): Promise<{ port: number; url: string }> { - // 1. Check if provider uses SSO authentication - const provider = ProviderRegistry.getProvider(this.config.provider || ''); - const isSSOProvider = provider?.authType === 'sso'; + // 1. Detect auth method from config + const authMethod = this.config.authMethod || 'sso'; // Default: SSO for backward compat - // 2. Load credentials (if needed for SSO) + // 2. Load credentials based on auth method let credentials: any = null; - if (isSSOProvider) { - const { CodeMieSSO } = await import('../sso.auth.js'); - const sso = new CodeMieSSO(); - credentials = await sso.getStoredCredentials(this.config.targetApiUrl); - if (!credentials) { + if (authMethod === 'jwt') { + // JWT path: token from CLI arg, env var, or credential store + const token = this.config.jwtToken + || process.env.CODEMIE_JWT_TOKEN + || await this.loadJWTFromStore(); + + if (!token) { throw new AuthenticationError( - `SSO credentials not found for ${this.config.targetApiUrl}. Please run: codemie profile login --url ${this.config.targetApiUrl}` + 'JWT token not found. Provide via --jwt-token, CODEMIE_JWT_TOKEN env var, or run: codemie setup' ); } + + credentials = { token, apiUrl: this.config.targetApiUrl }; + } else { + // SSO path: existing behavior (unchanged) + const provider = ProviderRegistry.getProvider(this.config.provider || ''); + const isSSOProvider = provider?.authType === 'sso'; + + if (isSSOProvider) { + const { CodeMieSSO } = await import('../sso.auth.js'); + const sso = new CodeMieSSO(); + credentials = await sso.getStoredCredentials(this.config.targetApiUrl); + + if (!credentials) { + throw new AuthenticationError( + `SSO credentials not found for ${this.config.targetApiUrl}. ` + + `Please run: codemie profile login --url ${this.config.targetApiUrl}` + ); + } + } } // 3. Build plugin context (includes profile config read once at CLI level) @@ -539,4 +559,18 @@ export class CodeMieProxy { }); }); } + + /** + * Load JWT token from credential store + */ + private async loadJWTFromStore(): Promise { + try { + const { CredentialStore } = await import('../../../../utils/security.js'); + const store = CredentialStore.getInstance(); + const jwtCreds = await store.retrieveJWTCredentials(this.config.targetApiUrl); + return jwtCreds?.token || null; + } catch { + return null; + } + } } diff --git a/src/providers/plugins/sso/sso.http-client.ts b/src/providers/plugins/sso/sso.http-client.ts index 1d252b2..8b73c38 100644 --- a/src/providers/plugins/sso/sso.http-client.ts +++ b/src/providers/plugins/sso/sso.http-client.ts @@ -35,16 +35,51 @@ export const CODEMIE_ENDPOINTS = { } as const; /** - * Fetch models from CodeMie SSO API + * Internal helper: build auth headers from cookies or JWT token */ -export async function fetchCodeMieModels( +function buildAuthHeaders(auth: Record | string): Record { + const cliVersion = process.env.CODEMIE_CLI_VERSION || 'unknown'; + const headers: Record = { + 'Content-Type': 'application/json', + 'User-Agent': `codemie-cli/${cliVersion}`, + 'X-CodeMie-CLI': `codemie-cli/${cliVersion}`, + 'X-CodeMie-Client': 'codemie-cli' + }; + + if (typeof auth === 'string') { + // JWT token (string) + headers['authorization'] = `Bearer ${auth}`; + } else { + // SSO cookies (object) - existing behavior + headers['cookie'] = Object.entries(auth) + .map(([key, value]) => `${key}=${value}`) + .join(';'); + } + + return headers; +} + +/** + * Fetch models from CodeMie API (supports both cookies and JWT) + * + * Overload 1: SSO cookies (backward compatible - existing callers unchanged) + * Overload 2: JWT token string (new) + */ +/* eslint-disable no-redeclare */ +export function fetchCodeMieModels( apiUrl: string, cookies: Record +): Promise; +export function fetchCodeMieModels( + apiUrl: string, + jwtToken: string +): Promise; +export async function fetchCodeMieModels( + apiUrl: string, + auth: Record | string ): Promise { - const cookieString = Object.entries(cookies) - .map(([key, value]) => `${key}=${value}`) - .join(';'); - +/* eslint-enable no-redeclare */ + const headers = buildAuthHeaders(auth); const url = `${apiUrl}${CODEMIE_ENDPOINTS.MODELS}`; const client = new HTTPClient({ @@ -53,19 +88,11 @@ export async function fetchCodeMieModels( rejectUnauthorized: false }); - const cliVersion = process.env.CODEMIE_CLI_VERSION || 'unknown'; - - const response = await client.getRaw(url, { - 'cookie': cookieString, - 'Content-Type': 'application/json', - 'User-Agent': `codemie-cli/${cliVersion}`, - 'X-CodeMie-CLI': `codemie-cli/${cliVersion}`, - 'X-CodeMie-Client': 'codemie-cli' - }); + const response = await client.getRaw(url, headers); if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) { if (response.statusCode === 401 || response.statusCode === 403) { - throw new Error('SSO session expired - please run setup again'); + throw new Error('Authentication failed - invalid or expired credentials'); } throw new Error(`Failed to fetch models: ${response.statusCode} ${response.statusMessage}`); } @@ -99,21 +126,30 @@ export async function fetchCodeMieModels( } /** - * Fetch user information including accessible applications + * Fetch user information including accessible applications (supports both cookies and JWT) * * @param apiUrl - CodeMie API base URL - * @param cookies - SSO session cookies + * @param auth - SSO session cookies or JWT token * @returns User info with applications array * @throws Error if request fails or response invalid + * + * Overload 1: SSO cookies (backward compatible - existing callers unchanged) + * Overload 2: JWT token string (new) */ -export async function fetchCodeMieUserInfo( +/* eslint-disable no-redeclare */ +export function fetchCodeMieUserInfo( apiUrl: string, cookies: Record +): Promise; +export function fetchCodeMieUserInfo( + apiUrl: string, + jwtToken: string +): Promise; +export async function fetchCodeMieUserInfo( + apiUrl: string, + auth: Record | string ): Promise { - const cookieString = Object.entries(cookies) - .map(([key, value]) => `${key}=${value}`) - .join(';'); - + const headers = buildAuthHeaders(auth); const url = `${apiUrl}${CODEMIE_ENDPOINTS.USER}`; const client = new HTTPClient({ @@ -122,20 +158,12 @@ export async function fetchCodeMieUserInfo( rejectUnauthorized: false }); - const cliVersion = process.env.CODEMIE_CLI_VERSION || 'unknown'; - - const response = await client.getRaw(url, { - 'cookie': cookieString, - 'Content-Type': 'application/json', - 'User-Agent': `codemie-cli/${cliVersion}`, - 'X-CodeMie-CLI': `codemie-cli/${cliVersion}`, - 'X-CodeMie-Client': 'codemie-cli' - }); + const response = await client.getRaw(url, headers); // Handle HTTP errors if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) { if (response.statusCode === 401 || response.statusCode === 403) { - throw new Error('SSO session expired - please run setup again'); + throw new Error('Authentication failed - invalid or expired credentials'); } throw new Error(`Failed to fetch user info: ${response.statusCode} ${response.statusMessage}`); } @@ -150,23 +178,33 @@ export async function fetchCodeMieUserInfo( return userInfo; } +/* eslint-enable no-redeclare */ /** - * Fetch application details (non-blocking, best-effort) + * Fetch application details (non-blocking, best-effort) - supports both cookies and JWT * * @param apiUrl - CodeMie API base URL - * @param cookies - SSO session cookies + * @param auth - SSO session cookies or JWT token * @returns Application names array (same as /v1/user for now) + * + * Overload 1: SSO cookies (backward compatible - existing callers unchanged) + * Overload 2: JWT token string (new) */ -export async function fetchApplicationDetails( +/* eslint-disable no-redeclare */ +export function fetchApplicationDetails( apiUrl: string, cookies: Record +): Promise; +export function fetchApplicationDetails( + apiUrl: string, + jwtToken: string +): Promise; +export async function fetchApplicationDetails( + apiUrl: string, + auth: Record | string ): Promise { try { - const cookieString = Object.entries(cookies) - .map(([key, value]) => `${key}=${value}`) - .join(';'); - + const headers = buildAuthHeaders(auth); const url = `${apiUrl}${CODEMIE_ENDPOINTS.ADMIN_APPLICATIONS}?limit=1000`; const client = new HTTPClient({ @@ -175,15 +213,7 @@ export async function fetchApplicationDetails( rejectUnauthorized: false }); - const cliVersion = process.env.CODEMIE_CLI_VERSION || 'unknown'; - - const response = await client.getRaw(url, { - 'cookie': cookieString, - 'Content-Type': 'application/json', - 'User-Agent': `codemie-cli/${cliVersion}`, - 'X-CodeMie-CLI': `codemie-cli/${cliVersion}`, - 'X-CodeMie-Client': 'codemie-cli' - }); + const response = await client.getRaw(url, headers); if (response.statusCode !== 200) { return []; @@ -196,19 +226,30 @@ export async function fetchApplicationDetails( return []; } } +/* eslint-enable no-redeclare */ /** - * Fetch integrations from CodeMie SSO API (paginated) + * Fetch integrations from CodeMie API (paginated) - supports both cookies and JWT + * + * Overload 1: SSO cookies (backward compatible - existing callers unchanged) + * Overload 2: JWT token string (new) */ -export async function fetchCodeMieIntegrations( +/* eslint-disable no-redeclare */ +export function fetchCodeMieIntegrations( apiUrl: string, cookies: Record, + endpointPath?: string +): Promise; +export function fetchCodeMieIntegrations( + apiUrl: string, + jwtToken: string, + endpointPath?: string +): Promise; +export async function fetchCodeMieIntegrations( + apiUrl: string, + auth: Record | string, endpointPath: string = CODEMIE_ENDPOINTS.USER_SETTINGS ): Promise { - const cookieString = Object.entries(cookies) - .map(([key, value]) => `${key}=${value}`) - .join(';'); - const allIntegrations: CodeMieIntegration[] = []; let currentPage = 0; const perPage = 50; @@ -231,7 +272,7 @@ export async function fetchCodeMieIntegrations( console.log(`[DEBUG] Fetching integrations from: ${fullUrl}`); } - const pageIntegrations = await fetchIntegrationsPage(fullUrl, cookieString); + const pageIntegrations = await fetchIntegrationsPage(fullUrl, auth); if (pageIntegrations.length === 0) { hasMorePages = false; @@ -258,30 +299,25 @@ export async function fetchCodeMieIntegrations( return allIntegrations; } +/* eslint-enable no-redeclare */ /** - * Fetch single page of integrations + * Fetch single page of integrations - supports both cookies and JWT */ -async function fetchIntegrationsPage(fullUrl: string, cookieString: string): Promise { +async function fetchIntegrationsPage(fullUrl: string, auth: Record | string): Promise { + const headers = buildAuthHeaders(auth); + const client = new HTTPClient({ timeout: 10000, maxRetries: 3, rejectUnauthorized: false }); - const cliVersion = process.env.CODEMIE_CLI_VERSION || 'unknown'; - - const response = await client.getRaw(fullUrl, { - 'cookie': cookieString, - 'Content-Type': 'application/json', - 'User-Agent': `codemie-cli/${cliVersion}`, - 'X-CodeMie-CLI': `codemie-cli/${cliVersion}`, - 'X-CodeMie-Client': 'codemie-cli' - }); + const response = await client.getRaw(fullUrl, headers); if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) { if (response.statusCode === 401 || response.statusCode === 403) { - throw new Error('SSO session expired - please run setup again'); + throw new Error('Authentication failed - invalid or expired credentials'); } if (response.statusCode === 404) { throw new Error(`Integrations endpoint not found. Response: ${response.data}`); diff --git a/src/providers/plugins/sso/sso.template.ts b/src/providers/plugins/sso/sso.template.ts index 7e65ef8..99cc8bf 100644 --- a/src/providers/plugins/sso/sso.template.ts +++ b/src/providers/plugins/sso/sso.template.ts @@ -40,6 +40,13 @@ export const SSOTemplate = registerProvider({ if (config.codeMieProject) env.CODEMIE_PROJECT = config.codeMieProject; if (config.authMethod) env.CODEMIE_AUTH_METHOD = config.authMethod; + // Export JWT token when auth method is JWT + if (config.authMethod === 'jwt') { + const tokenEnvVar = config.jwtConfig?.tokenEnvVar || 'CODEMIE_JWT_TOKEN'; + const token = process.env[tokenEnvVar] || config.jwtConfig?.token; + if (token) env.CODEMIE_JWT_TOKEN = token; + } + // Only export integration ID if integration is configured if (config.codeMieIntegration?.id) { env.CODEMIE_INTEGRATION_ID = config.codeMieIntegration.id; diff --git a/src/utils/security.ts b/src/utils/security.ts index 1efecd1..f531717 100644 --- a/src/utils/security.ts +++ b/src/utils/security.ts @@ -11,7 +11,7 @@ import * as crypto from 'crypto'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; -import { SSOCredentials } from '../providers/core/types.js'; +import { SSOCredentials, JWTCredentials } from '../providers/core/types.js'; import { getCodemiePath } from './paths.js'; // ============================================================================ @@ -394,6 +394,118 @@ export class CredentialStore { } } + /** + * Store JWT credentials securely + * @param credentials - JWT credentials to store + * @param baseUrl - Optional base URL for per-URL storage + */ + async storeJWTCredentials(credentials: JWTCredentials, baseUrl?: string): Promise { + const encrypted = this.encrypt(JSON.stringify(credentials)); + + // Determine storage key based on whether baseUrl is provided + // Use jwt- prefix to avoid collision with SSO credentials + const accountName = baseUrl ? `jwt-${this.getUrlStorageKey(baseUrl)}` : 'jwt-credentials'; + const filePath = baseUrl + ? path.join(CREDENTIALS_DIR, `jwt-${this.getUrlStorageKey(baseUrl)}.enc`) + : path.join(CREDENTIALS_DIR, 'jwt-credentials.enc'); + + // Store to keychain if available (best effort, don't fail if it errors) + const keytarModule = await getKeytar(); + if (keytarModule) { + try { + await keytarModule.setPassword(SERVICE_NAME, accountName, encrypted); + } catch { + // Continue to file storage even if keychain fails + } + } + + // Always store to file as well for consistency + await this.storeToFile(encrypted, filePath); + } + + /** + * Retrieve JWT credentials from secure storage + * @param baseUrl - Optional base URL for per-URL retrieval + * @returns JWT credentials or null if not found or expired + */ + async retrieveJWTCredentials(baseUrl?: string): Promise { + // Determine storage key based on whether baseUrl is provided + const accountName = baseUrl ? `jwt-${this.getUrlStorageKey(baseUrl)}` : 'jwt-credentials'; + const filePath = baseUrl + ? path.join(CREDENTIALS_DIR, `jwt-${this.getUrlStorageKey(baseUrl)}.enc`) + : path.join(CREDENTIALS_DIR, 'jwt-credentials.enc'); + + // Try keychain first if available + const keytarModule = await getKeytar(); + if (keytarModule) { + try { + const encrypted = await keytarModule.getPassword(SERVICE_NAME, accountName); + if (encrypted) { + const decrypted = this.decrypt(encrypted); + const credentials = JSON.parse(decrypted) as JWTCredentials; + + // Check token expiration + if (credentials.expiresAt && Date.now() > credentials.expiresAt) { + return null; // Token expired + } + + return credentials; + } + } catch { + // Fall through to file storage + } + } + + // Always try file storage as fallback + try { + const encrypted = await this.retrieveFromFile(filePath); + if (encrypted) { + const decrypted = this.decrypt(encrypted); + const credentials = JSON.parse(decrypted) as JWTCredentials; + + // Check token expiration + if (credentials.expiresAt && Date.now() > credentials.expiresAt) { + return null; // Token expired + } + + return credentials; + } + } catch { + // Unable to decrypt file storage + } + + return null; + } + + /** + * Clear JWT credentials from secure storage + * @param baseUrl - Optional base URL for per-URL deletion + */ + async clearJWTCredentials(baseUrl?: string): Promise { + // Determine storage key based on whether baseUrl is provided + const accountName = baseUrl ? `jwt-${this.getUrlStorageKey(baseUrl)}` : 'jwt-credentials'; + const filePath = baseUrl + ? path.join(CREDENTIALS_DIR, `jwt-${this.getUrlStorageKey(baseUrl)}.enc`) + : path.join(CREDENTIALS_DIR, 'jwt-credentials.enc'); + + // Clear keychain if available + const keytarModule = await getKeytar(); + if (keytarModule) { + try { + await keytarModule.deletePassword(SERVICE_NAME, accountName); + } catch { + // Ignore errors, will try file storage next + } + } + + // Also clear file storage + try { + await fs.unlink(filePath); + } catch { + // Ignore file not found errors + } + } + private encrypt(text: string): string { const iv = crypto.randomBytes(16); // Use a proper 32-byte key by hashing the encryptionKey From 374e40f003c37db70087167c08a7885f98cf801c Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Mon, 16 Feb 2026 13:30:23 +0100 Subject: [PATCH 3/4] feat(providers): add Bearer Authorization provider for JWT authentication - Add new JWT Bearer Authorization provider (bearer-auth) - Implement setup flow asking only for API URL (token provided at runtime) - Add URL normalization utility (ensureApiBase) to handle /code-assistant-api suffix - Update agent plugins to support bearer-auth provider (Claude, Gemini, OpenCode, CodeMie Code) - Skip apiKey validation for JWT and SSO authentication methods in AgentCLI - Add codeMieUrl field to JWT config following SSO pattern - Remove redundant apiUrl from jwtConfig (uses baseUrl instead) - Add jwtConfig support to LegacyConfig type Users can now configure JWT authentication with: codemie setup -> Bearer Authorization codemie-claude --jwt-token --base-url Generated with AI Co-Authored-By: codemie-ai --- src/agents/core/AgentCLI.ts | 6 +- src/agents/plugins/claude/claude.plugin.ts | 2 +- src/agents/plugins/codemie-code.plugin.ts | 2 +- src/agents/plugins/gemini/gemini.plugin.ts | 2 +- .../plugins/opencode/opencode.plugin.ts | 2 +- src/env/types.ts | 6 +- src/providers/index.ts | 2 + src/providers/plugins/jwt/index.ts | 15 ++ src/providers/plugins/jwt/jwt.setup-steps.ts | 181 ++++++++++++++++++ src/providers/plugins/jwt/jwt.template.ts | 62 ++++++ src/providers/plugins/sso/sso.http-client.ts | 22 +++ 11 files changed, 296 insertions(+), 6 deletions(-) create mode 100644 src/providers/plugins/jwt/index.ts create mode 100644 src/providers/plugins/jwt/jwt.setup-steps.ts create mode 100644 src/providers/plugins/jwt/jwt.template.ts diff --git a/src/agents/core/AgentCLI.ts b/src/agents/core/AgentCLI.ts index e26664e..d99e668 100644 --- a/src/agents/core/AgentCLI.ts +++ b/src/agents/core/AgentCLI.ts @@ -174,7 +174,11 @@ export class AgentCLI { const provider = config.provider ? ProviderRegistry.getProvider(config.provider) : null; const requiresAuth = provider?.requiresAuth ?? true; // Default to true for safety - if (requiresAuth && !config.apiKey) { + // Skip apiKey validation for SSO and JWT authentication methods + const authMethod = config.authMethod; + const usesAlternativeAuth = authMethod === 'sso' || authMethod === 'jwt'; + + if (requiresAuth && !config.apiKey && !usesAlternativeAuth) { missingFields.push('apiKey'); } diff --git a/src/agents/plugins/claude/claude.plugin.ts b/src/agents/plugins/claude/claude.plugin.ts index 2121b3e..a048b82 100644 --- a/src/agents/plugins/claude/claude.plugin.ts +++ b/src/agents/plugins/claude/claude.plugin.ts @@ -71,7 +71,7 @@ export const ClaudePluginMetadata: AgentMetadata = { opusModel: ['ANTHROPIC_DEFAULT_OPUS_MODEL'], }, - supportedProviders: ['litellm', 'ai-run-sso', 'bedrock'], + supportedProviders: ['litellm', 'ai-run-sso', 'bedrock', 'bearer-auth'], blockedModelPatterns: [], recommendedModels: ['claude-4-5-sonnet', 'claude-4-opus', 'gpt-4.1'], diff --git a/src/agents/plugins/codemie-code.plugin.ts b/src/agents/plugins/codemie-code.plugin.ts index a8a7903..aa321f3 100644 --- a/src/agents/plugins/codemie-code.plugin.ts +++ b/src/agents/plugins/codemie-code.plugin.ts @@ -27,7 +27,7 @@ export const CodeMieCodePluginMetadata: AgentMetadata = { envMapping: {}, - supportedProviders: ['ollama', 'litellm', 'ai-run-sso'], + supportedProviders: ['ollama', 'litellm', 'ai-run-sso', 'bearer-auth'], blockedModelPatterns: [], // Built-in agent doesn't use proxy (handles auth internally) diff --git a/src/agents/plugins/gemini/gemini.plugin.ts b/src/agents/plugins/gemini/gemini.plugin.ts index 9c4af31..3094625 100644 --- a/src/agents/plugins/gemini/gemini.plugin.ts +++ b/src/agents/plugins/gemini/gemini.plugin.ts @@ -21,7 +21,7 @@ const metadata = { model: ['GEMINI_MODEL'] }, - supportedProviders: ['ai-run-sso', 'litellm'], + supportedProviders: ['ai-run-sso', 'litellm', 'bearer-auth'], blockedModelPatterns: [/^claude/i, /^gpt/i], // Gemini models only recommendedModels: ['gemini-3-pro'], diff --git a/src/agents/plugins/opencode/opencode.plugin.ts b/src/agents/plugins/opencode/opencode.plugin.ts index 9e991dc..f791e08 100644 --- a/src/agents/plugins/opencode/opencode.plugin.ts +++ b/src/agents/plugins/opencode/opencode.plugin.ts @@ -136,7 +136,7 @@ export const OpenCodePluginMetadata: AgentMetadata = { apiKey: [], model: [] }, - supportedProviders: ['litellm', 'ai-run-sso'], + supportedProviders: ['litellm', 'ai-run-sso', 'bearer-auth'], ssoConfig: { enabled: true, clientType: 'codemie-opencode' }, lifecycle: { diff --git a/src/env/types.ts b/src/env/types.ts index b4d4997..cf1ae42 100644 --- a/src/env/types.ts +++ b/src/env/types.ts @@ -60,7 +60,6 @@ export interface ProviderProfile { jwtConfig?: { token?: string; tokenEnvVar?: string; - apiUrl?: string; expiresAt?: number; }; @@ -109,6 +108,11 @@ export interface LegacyConfig { apiUrl?: string; cookiesEncrypted?: string; }; + jwtConfig?: { + token?: string; + tokenEnvVar?: string; + expiresAt?: number; + }; } /** diff --git a/src/providers/index.ts b/src/providers/index.ts index 13dd3b8..0957783 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -33,11 +33,13 @@ export type { HTTPClientConfig, HTTPResponse } from './core/base/http-client.js' // Plugin imports execute their auto-registration code on import import './plugins/ollama/index.js'; import './plugins/sso/index.js'; +import './plugins/jwt/index.js'; import './plugins/litellm/index.js'; import './plugins/bedrock/index.js'; // Re-export plugin modules for direct access if needed export * as Ollama from './plugins/ollama/index.js'; export * as SSO from './plugins/sso/index.js'; +export * as JWT from './plugins/jwt/index.js'; export * as LiteLLM from './plugins/litellm/index.js'; export * as Bedrock from './plugins/bedrock/index.js'; diff --git a/src/providers/plugins/jwt/index.ts b/src/providers/plugins/jwt/index.ts new file mode 100644 index 0000000..ee497cd --- /dev/null +++ b/src/providers/plugins/jwt/index.ts @@ -0,0 +1,15 @@ +/** + * JWT Bearer Authorization Provider + * + * Export provider template and setup steps. + * Auto-registers when imported. + */ + +import { ProviderRegistry } from '../../core/registry.js'; +import { JWTBearerSetupSteps } from './jwt.setup-steps.js'; + +export { JWTTemplate } from './jwt.template.js'; +export { JWTBearerSetupSteps } from './jwt.setup-steps.js'; + +// Register setup steps +ProviderRegistry.registerSetupSteps('bearer-auth', JWTBearerSetupSteps); diff --git a/src/providers/plugins/jwt/jwt.setup-steps.ts b/src/providers/plugins/jwt/jwt.setup-steps.ts new file mode 100644 index 0000000..1e3fd35 --- /dev/null +++ b/src/providers/plugins/jwt/jwt.setup-steps.ts @@ -0,0 +1,181 @@ +/** + * JWT Bearer Authorization Setup Steps + * + * Simplified setup flow for JWT authentication. + * Only asks for API URL during setup - token is provided later at runtime. + */ + +import inquirer from 'inquirer'; +import chalk from 'chalk'; +import type { + ProviderSetupSteps, + ProviderCredentials, + AuthValidationResult +} from '../../core/types.js'; +import type { CodeMieConfigOptions } from '../../../env/types.js'; +import { ensureApiBase } from '../sso/sso.http-client.js'; + +export const JWTBearerSetupSteps: ProviderSetupSteps = { + name: 'bearer-auth', + + async getCredentials(_isUpdate?: boolean): Promise { + console.log(chalk.cyan('\nšŸ” JWT Bearer Authorization Setup\n')); + console.log(chalk.white('This provider uses JWT tokens for authentication.')); + console.log(chalk.white('You only need to provide the API URL during setup.\n')); + console.log(chalk.yellow('ā„¹ļø JWT token will be provided later via:\n')); + console.log(chalk.white(' • CLI option: --jwt-token ')); + console.log(chalk.white(' • Environment variable: CODEMIE_JWT_TOKEN=\n')); + + // Step 1: Get API URL + const urlAnswers = await inquirer.prompt([ + { + type: 'input', + name: 'baseUrl', + message: 'CodeMie base URL:', + default: 'https://codemie.lab.epam.com', + validate: (input: string) => { + if (!input.trim()) return 'API URL is required'; + if (!input.startsWith('http://') && !input.startsWith('https://')) { + return 'Please enter a valid URL starting with http:// or https://'; + } + return true; + } + } + ]); + + // Store user's input (base URL without suffix) + const codeMieUrl = urlAnswers.baseUrl.trim(); + + // Normalize URL - add /code-assistant-api suffix if not present + const baseUrl = ensureApiBase(codeMieUrl); + + // Step 2: Optional - environment variable name + console.log(chalk.cyan('\nšŸ“ Token Configuration (Optional)\n')); + console.log(chalk.white('You can specify a custom environment variable name for the token.')); + console.log(chalk.white('Default: CODEMIE_JWT_TOKEN\n')); + + const tokenConfigAnswers = await inquirer.prompt([ + { + type: 'confirm', + name: 'customEnvVar', + message: 'Use a custom environment variable name?', + default: false + } + ]); + + let tokenEnvVar = 'CODEMIE_JWT_TOKEN'; + + if (tokenConfigAnswers.customEnvVar) { + const envVarAnswers = await inquirer.prompt([ + { + type: 'input', + name: 'envVar', + message: 'Environment variable name:', + default: 'CODEMIE_JWT_TOKEN', + validate: (input: string) => { + if (!input.trim()) return 'Variable name is required'; + if (!/^[A-Z_][A-Z0-9_]*$/.test(input)) { + return 'Variable name must be uppercase with underscores only'; + } + return true; + } + } + ]); + + tokenEnvVar = envVarAnswers.envVar; + } + + console.log(chalk.green('\nāœ“ Configuration saved\n')); + console.log(chalk.cyan('šŸ“Œ Next Steps:\n')); + console.log(chalk.white('1. Set your JWT token:')); + console.log(chalk.cyan(` export ${tokenEnvVar}="your-jwt-token-here"`)); + console.log(chalk.white('\n2. Run your agent with the token:')); + console.log(chalk.cyan(` codemie-claude "your prompt here"`)); + console.log(chalk.white('\n3. Or provide token via CLI:')); + console.log(chalk.cyan(` codemie-claude --jwt-token "your-token" "your prompt"\n`)); + + // Return configuration (follows SSO pattern) + return { + baseUrl, // Full API URL with suffix + additionalConfig: { + codeMieUrl, // User's input (base URL) + authMethod: 'jwt', + jwtConfig: { + tokenEnvVar + // Note: No apiUrl needed - baseUrl is used for credential storage + } + } + }; + }, + + async fetchModels(_credentials: ProviderCredentials): Promise { + // Return default models - actual model list will be fetched at runtime with JWT token + // User can override model selection via CLI or config + return [ + 'claude-4-5-sonnet', + 'claude-opus-4-6', + 'claude-4-5-haiku', + 'gpt-4-turbo', + 'gpt-4o', + 'gpt-4o-mini' + ]; + }, + + buildConfig( + credentials: ProviderCredentials, + selectedModel: string + ): Partial { + const jwtConfig = credentials.additionalConfig?.jwtConfig as + | { tokenEnvVar?: string } + | undefined; + + return { + provider: 'bearer-auth', + codeMieUrl: credentials.additionalConfig?.codeMieUrl as string | undefined, // Base URL (user input) + baseUrl: credentials.baseUrl, // Full API URL with suffix + model: selectedModel, + authMethod: 'jwt', + jwtConfig + }; + }, + + async validateAuth(config: CodeMieConfigOptions): Promise { + // Check if JWT token is available at runtime + const tokenEnvVar = config.jwtConfig?.tokenEnvVar || 'CODEMIE_JWT_TOKEN'; + const token = process.env[tokenEnvVar] || config.jwtConfig?.token; + + if (!token) { + return { + valid: false, + error: `JWT token not found in ${tokenEnvVar} environment variable` + }; + } + + // Basic JWT format validation + const parts = token.split('.'); + if (parts.length !== 3) { + return { + valid: false, + error: 'Invalid JWT token format (expected header.payload.signature)' + }; + } + + // Check token expiration (if parseable) + try { + const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); + if (payload.exp && Date.now() > payload.exp * 1000) { + const expiresAt = payload.exp * 1000; + return { + valid: false, + error: `JWT token expired on ${new Date(expiresAt).toISOString()}`, + expiresAt + }; + } + } catch { + // Non-standard JWT payload - skip expiration check + } + + // Token is present and valid format + return { valid: true }; + } +}; diff --git a/src/providers/plugins/jwt/jwt.template.ts b/src/providers/plugins/jwt/jwt.template.ts new file mode 100644 index 0000000..c504243 --- /dev/null +++ b/src/providers/plugins/jwt/jwt.template.ts @@ -0,0 +1,62 @@ +/** + * JWT Bearer Authorization Provider Template + * + * Template definition for JWT token authentication. + * Users provide only the API URL during setup - JWT token is provided later + * via --jwt-token CLI option or CODEMIE_JWT_TOKEN environment variable. + * + * Auto-registers on import via registerProvider(). + */ + +import type { ProviderTemplate } from '../../core/types.js'; +import { registerProvider } from '../../core/index.js'; + +export const JWTTemplate = registerProvider({ + name: 'bearer-auth', + displayName: 'Bearer Authorization', + description: 'JWT token authentication - Provide token via CLI or environment variable', + defaultBaseUrl: 'https://codemie.lab.epam.com', + requiresAuth: true, + authType: 'jwt', + priority: 1, // Show after CodeMie SSO + defaultProfileName: 'jwt-bearer', + recommendedModels: [ + 'claude-4-5-sonnet', + 'claude-opus-4-6', + 'gpt-4-turbo', + ], + capabilities: ['streaming', 'tools', 'function-calling'], + supportsModelInstallation: false, + supportsStreaming: true, + customProperties: { + requiresToken: true, + tokenSource: 'runtime' // Token provided at runtime, not during setup + }, + + // Environment Variable Export + exportEnvVars: (config) => { + const env: Record = {}; + + // Export base URL (user's input) - matches SSO pattern + if (config.codeMieUrl) { + env.CODEMIE_URL = config.codeMieUrl; + } + + // Set auth method to JWT + env.CODEMIE_AUTH_METHOD = 'jwt'; + + // Export JWT token if available (from env var or config) + const tokenEnvVar = config.jwtConfig?.tokenEnvVar || 'CODEMIE_JWT_TOKEN'; + const token = process.env[tokenEnvVar] || config.jwtConfig?.token; + if (token) { + env.CODEMIE_JWT_TOKEN = token; + } + + // Export project info if available + if (config.codeMieProject) { + env.CODEMIE_PROJECT = config.codeMieProject; + } + + return env; + } +}); diff --git a/src/providers/plugins/sso/sso.http-client.ts b/src/providers/plugins/sso/sso.http-client.ts index 8b73c38..f9afdc6 100644 --- a/src/providers/plugins/sso/sso.http-client.ts +++ b/src/providers/plugins/sso/sso.http-client.ts @@ -59,6 +59,28 @@ function buildAuthHeaders(auth: Record | string): Record 'https://codemie.lab.epam.com/code-assistant-api' + * + * ensureApiBase('https://codemie.lab.epam.com/code-assistant-api') + * // => 'https://codemie.lab.epam.com/code-assistant-api' + */ +export function ensureApiBase(rawUrl: string): string { + let base = rawUrl.replace(/\/$/, ''); // Remove trailing slash + // If URL doesn't have /code-assistant-api suffix, append it + if (!/\/code-assistant-api(\/|$)/i.test(base)) { + base = `${base}/code-assistant-api`; + } + return base; +} + /** * Fetch models from CodeMie API (supports both cookies and JWT) * From 695e21ddaf9938fbbf5534c88c793ac4f37d7f28 Mon Sep 17 00:00:00 2001 From: Taras Spashchenko Date: Mon, 16 Feb 2026 13:45:13 +0100 Subject: [PATCH 4/4] docs(providers): add JWT Bearer Authorization documentation - Add comprehensive JWT authentication section to AUTHENTICATION.md - Document JWT setup flow, token provision methods, and validation - Include CI/CD pipeline examples and troubleshooting guide - Add JWT vs SSO comparison table - Update README.md to mention JWT Bearer Auth in provider list Generated with AI Co-Authored-By: codemie-ai --- README.md | 6 +- docs/AUTHENTICATION.md | 147 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2711d32..290f4f2 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![TypeScript](https://img.shields.io/badge/TypeScript-5.3%2B-blue.svg)](https://www.typescriptlang.org/) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) -> **Unified AI Coding Assistant CLI** - Manage Claude Code, Google Gemini, OpenCode, and custom AI agents from one powerful command-line interface. Multi-provider support (OpenAI, Azure OpenAI, AWS Bedrock, LiteLLM, Ollama, Enterprise SSO). Built-in LangGraph agent with file operations, command execution, and planning tools. Cross-platform support for Windows, Linux, and macOS. +> **Unified AI Coding Assistant CLI** - Manage Claude Code, Google Gemini, OpenCode, and custom AI agents from one powerful command-line interface. Multi-provider support (OpenAI, Azure OpenAI, AWS Bedrock, LiteLLM, Ollama, Enterprise SSO, JWT Bearer Auth). Built-in LangGraph agent with file operations, command execution, and planning tools. Cross-platform support for Windows, Linux, and macOS. --- @@ -23,10 +23,10 @@ CodeMie CLI is the all-in-one AI coding assistant for developers. - ✨ **One CLI, Multiple AI Agents** - Switch between Claude Code, Gemini, OpenCode, and built-in agent. -- šŸ”„ **Multi-Provider Support** - OpenAI, Azure OpenAI, AWS Bedrock, LiteLLM, Ollama, and Enterprise SSO. +- šŸ”„ **Multi-Provider Support** - OpenAI, Azure OpenAI, AWS Bedrock, LiteLLM, Ollama, Enterprise SSO, and JWT Bearer Auth. - šŸš€ **Built-in Agent** - A powerful LangGraph-based assistant with file operations, command execution, and planning tools. - šŸ–„ļø **Cross-Platform** - Full support for Windows, Linux, and macOS with platform-specific optimizations. -- šŸ” **Enterprise Ready** - SSO authentication, audit logging, and role-based access. +- šŸ” **Enterprise Ready** - SSO and JWT authentication, audit logging, and role-based access. - ⚔ **Productivity Boost** - Code review, refactoring, test generation, and bug fixing. - šŸŽÆ **Profile Management** - Manage work, personal, and team configurations separately. - šŸ“Š **Usage Analytics** - Track and analyze AI usage across all agents with detailed insights. diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 226f5f3..d5ce746 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -1,5 +1,13 @@ # Authentication & SSO Management +## Authentication Methods + +CodeMie CLI supports multiple authentication methods: + +- **CodeMie SSO** - Browser-based Single Sign-On (recommended for enterprise) +- **JWT Bearer Authorization** - Token-based authentication for CI/CD and external auth systems +- **API Key** - Direct API key authentication for other providers (OpenAI, Anthropic, etc.) + ## AI/Run CodeMie SSO Setup For enterprise environments with AI/Run CodeMie SSO (Single Sign-On): @@ -103,3 +111,142 @@ AI/Run CodeMie SSO provides enterprise-grade features: - **Automatic Plugin Installation**: Claude Code plugin auto-installs for session tracking - **Audit Logging**: Enterprise audit trails for security compliance - **Role-Based Access**: Model access based on organizational permissions + +## JWT Bearer Authorization + +For environments with external token management systems, CI/CD pipelines, or testing scenarios, CodeMie CLI supports JWT Bearer Authorization. This method provides tokens at runtime rather than during setup. + +### Initial Setup + +JWT setup only requires the API URL - tokens are provided later: + +```bash +codemie setup +# Select: Bearer Authorization +``` + +**The wizard will:** +1. Prompt for the CodeMie base URL (e.g., `https://codemie.lab.epam.com`) +2. Optionally ask for a custom environment variable name (default: `CODEMIE_JWT_TOKEN`) +3. Save the configuration without requiring a token +4. Display instructions for providing tokens at runtime + +### Providing JWT Tokens + +After setup, provide tokens via environment variable or CLI option: + +**Environment Variable (Recommended):** +```bash +# Set token in your environment +export CODEMIE_JWT_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + +# Run commands normally +codemie-claude "analyze this code" +``` + +**CLI Option:** +```bash +# Provide token per command +codemie-claude --jwt-token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." "analyze this code" +``` + +**Custom Environment Variable:** +```bash +# If you configured a custom env var during setup +export MY_CUSTOM_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +codemie-claude "analyze this code" +``` + +### JWT Token Management + +JWT tokens are validated automatically: + +```bash +# Check JWT authentication status +codemie doctor + +# View token status and expiration +codemie profile status +``` + +**Token Validation:** +- Format validation (header.payload.signature) +- Expiration checking (warns if expiring within 7 days) +- Automatic error messages for expired tokens + +### Use Cases + +JWT Bearer Authorization is ideal for: + +**CI/CD Pipelines:** +```bash +# GitLab CI example +script: + - export CODEMIE_JWT_TOKEN="${CI_JOB_JWT}" + - codemie-claude --task "review changes in this commit" +``` + +**External Auth Systems:** +```bash +# Obtain token from your auth provider +TOKEN=$(curl -s https://auth.example.com/token | jq -r .access_token) + +# Use with CodeMie +codemie-claude --jwt-token "$TOKEN" "your prompt" +``` + +**Testing & Development:** +```bash +# Use short-lived test tokens +export CODEMIE_JWT_TOKEN="test-token-expires-in-1h" +codemie-claude "run tests" +``` + +### JWT vs SSO + +| Feature | JWT Bearer Auth | CodeMie SSO | +|---------|----------------|-------------| +| **Setup** | URL only | Browser-based flow | +| **Token Source** | Runtime (CLI/env) | Stored in keychain | +| **Best For** | CI/CD, external auth | Interactive development | +| **Token Refresh** | Manual (obtain new token) | Automatic | +| **Security** | Token management external | Managed by CLI | + +### Troubleshooting JWT + +**Token not found:** +```bash +# Check environment variable +echo $CODEMIE_JWT_TOKEN + +# Verify variable name matches config +codemie profile status + +# Provide via CLI instead +codemie-claude --jwt-token "your-token" "your prompt" +``` + +**Token expired:** +```bash +# Obtain new token from your auth provider +export CODEMIE_JWT_TOKEN="new-token-here" + +# Verify expiration +codemie doctor +``` + +**Invalid token format:** +```bash +# JWT must have 3 parts (header.payload.signature) +# Check token structure +echo $CODEMIE_JWT_TOKEN | awk -F. '{print NF}' # Should output: 3 +``` + +**Configuration issues:** +```bash +# Reset and reconfigure +codemie setup # Choose Bearer Authorization again + +# Or manually edit config +cat ~/.codemie/codemie-cli.config.json +```