mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 18:15:23 +00:00
Merge pull request #205 from kaitranntt/kai/feat/customizable-auth-tokens
feat(cliproxy): customizable auth tokens for CLIProxyAPI
This commit is contained in:
18 files changed
+2015
-46
No files matched your search
@@ -412,6 +412,13 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Special case: tokens command (auth token management)
|
||||
if (firstArg === 'tokens') {
|
||||
const { handleTokensCommand } = await import('./commands/tokens-command');
|
||||
const exitCode = await handleTokensCommand(args.slice(1));
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
// Special case: setup command (first-time wizard)
|
||||
if (firstArg === 'setup' || firstArg === '--setup') {
|
||||
const { handleSetupCommand } = await import('./commands/setup-command');
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Auth Token Manager for CLIProxyAPI
|
||||
*
|
||||
* Manages API key and management secret resolution with inheritance:
|
||||
* - Per-variant override → Global config → Default constants
|
||||
*
|
||||
* Provides secure token generation for user customization.
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'crypto';
|
||||
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../config/unified-config-loader';
|
||||
import { CCS_INTERNAL_API_KEY, CCS_CONTROL_PANEL_SECRET } from './config-generator';
|
||||
|
||||
/**
|
||||
* Generate a cryptographically secure token.
|
||||
* Uses CSPRNG (crypto.randomBytes) for proper entropy.
|
||||
*
|
||||
* @param length - Number of random bytes (default: 32 = 256-bit entropy)
|
||||
* @returns Base64URL-encoded token (43 chars for 32 bytes)
|
||||
*/
|
||||
export function generateSecureToken(length = 32): string {
|
||||
return randomBytes(length).toString('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask a token for display.
|
||||
* Shows first 4 and last 4 chars: "ccs_...4f2a"
|
||||
* Used by CLI commands and API routes.
|
||||
*/
|
||||
export function maskToken(token: string): string {
|
||||
if (token.length <= 8) return '****';
|
||||
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective API key with inheritance chain.
|
||||
* Priority: variant auth → global cliproxy.auth → default constant
|
||||
*
|
||||
* @param variantName - Optional variant name for per-variant override
|
||||
* @returns Resolved API key
|
||||
*/
|
||||
export function getEffectiveApiKey(variantName?: string): string {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
|
||||
// Priority 1: Per-variant override
|
||||
if (variantName) {
|
||||
const variant = config.cliproxy.variants[variantName];
|
||||
if (variant?.auth?.api_key) {
|
||||
return variant.auth.api_key;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Global cliproxy.auth
|
||||
if (config.cliproxy.auth?.api_key) {
|
||||
return config.cliproxy.auth.api_key;
|
||||
}
|
||||
|
||||
// Priority 3: Default constant (backwards compatible)
|
||||
return CCS_INTERNAL_API_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective management secret.
|
||||
* Priority: global cliproxy.auth → default constant
|
||||
*
|
||||
* Note: Management secret is global-only (no per-variant override)
|
||||
* as it controls the Control Panel access for the entire CLIProxy instance.
|
||||
*
|
||||
* @returns Resolved management secret
|
||||
*/
|
||||
export function getEffectiveManagementSecret(): string {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
|
||||
// Priority 1: Global cliproxy.auth
|
||||
if (config.cliproxy.auth?.management_secret) {
|
||||
return config.cliproxy.auth.management_secret;
|
||||
}
|
||||
|
||||
// Priority 2: Default constant (backwards compatible)
|
||||
return CCS_CONTROL_PANEL_SECRET;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set global API key.
|
||||
* Updates cliproxy.auth.api_key in config.yaml.
|
||||
*
|
||||
* @param apiKey - New API key (or undefined to reset to default)
|
||||
*/
|
||||
export function setGlobalApiKey(apiKey: string | undefined): void {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
|
||||
if (!config.cliproxy.auth) {
|
||||
config.cliproxy.auth = {};
|
||||
}
|
||||
|
||||
if (apiKey === undefined) {
|
||||
delete config.cliproxy.auth.api_key;
|
||||
} else {
|
||||
config.cliproxy.auth.api_key = apiKey;
|
||||
}
|
||||
|
||||
saveUnifiedConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set global management secret.
|
||||
* Updates cliproxy.auth.management_secret in config.yaml.
|
||||
*
|
||||
* @param secret - New management secret (or undefined to reset to default)
|
||||
*/
|
||||
export function setGlobalManagementSecret(secret: string | undefined): void {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
|
||||
if (!config.cliproxy.auth) {
|
||||
config.cliproxy.auth = {};
|
||||
}
|
||||
|
||||
if (secret === undefined) {
|
||||
delete config.cliproxy.auth.management_secret;
|
||||
} else {
|
||||
config.cliproxy.auth.management_secret = secret;
|
||||
}
|
||||
|
||||
saveUnifiedConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set per-variant API key override.
|
||||
* Updates variants[variantName].auth.api_key in config.yaml.
|
||||
*
|
||||
* @param variantName - Variant name
|
||||
* @param apiKey - New API key (or undefined to remove override)
|
||||
*/
|
||||
export function setVariantApiKey(variantName: string, apiKey: string | undefined): void {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const variant = config.cliproxy.variants[variantName];
|
||||
|
||||
if (!variant) {
|
||||
throw new Error(`Variant '${variantName}' not found`);
|
||||
}
|
||||
|
||||
if (!variant.auth) {
|
||||
variant.auth = {};
|
||||
}
|
||||
|
||||
if (apiKey === undefined) {
|
||||
delete variant.auth.api_key;
|
||||
// Clean up empty auth object
|
||||
if (Object.keys(variant.auth).length === 0) {
|
||||
delete variant.auth;
|
||||
}
|
||||
} else {
|
||||
variant.auth.api_key = apiKey;
|
||||
}
|
||||
|
||||
saveUnifiedConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all auth settings to defaults.
|
||||
* Removes cliproxy.auth and all variant auth overrides.
|
||||
*/
|
||||
export function resetAuthToDefaults(): void {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
|
||||
// Remove global auth
|
||||
delete config.cliproxy.auth;
|
||||
|
||||
// Remove all variant auth overrides
|
||||
for (const variantName of Object.keys(config.cliproxy.variants)) {
|
||||
const variant = config.cliproxy.variants[variantName];
|
||||
if (variant.auth) {
|
||||
delete variant.auth;
|
||||
}
|
||||
}
|
||||
|
||||
saveUnifiedConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth configuration summary for display.
|
||||
* Returns current effective values and whether they're customized.
|
||||
*/
|
||||
export function getAuthSummary(): {
|
||||
apiKey: { value: string; isCustom: boolean };
|
||||
managementSecret: { value: string; isCustom: boolean };
|
||||
} {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
|
||||
const customApiKey = config.cliproxy.auth?.api_key;
|
||||
const customSecret = config.cliproxy.auth?.management_secret;
|
||||
|
||||
return {
|
||||
apiKey: {
|
||||
value: customApiKey || CCS_INTERNAL_API_KEY,
|
||||
isCustom: !!customApiKey,
|
||||
},
|
||||
managementSecret: {
|
||||
value: customSecret || CCS_CONTROL_PANEL_SECRET,
|
||||
isCustom: !!customSecret,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { warn } from '../utils/ui';
|
||||
import { CLIProxyProvider, ProviderConfig, ProviderModelMapping } from './types';
|
||||
import { getModelMappingFromConfig, getEnvVarsFromConfig } from './base-config-loader';
|
||||
import { loadOrCreateUnifiedConfig, getGlobalEnvConfig } from '../config/unified-config-loader';
|
||||
import { getEffectiveApiKey, getEffectiveManagementSecret } from './auth-token-manager';
|
||||
|
||||
/** Settings file structure for user overrides */
|
||||
interface ProviderSettings {
|
||||
@@ -172,8 +173,12 @@ function generateUnifiedConfigContent(
|
||||
// Get logging settings from user config (disabled by default)
|
||||
const { loggingToFile, requestLog } = getLoggingSettings();
|
||||
|
||||
// Get effective auth tokens (respects user customization)
|
||||
const effectiveApiKey = getEffectiveApiKey();
|
||||
const effectiveSecret = getEffectiveManagementSecret();
|
||||
|
||||
// Build api-keys section with internal key + preserved user keys
|
||||
const allApiKeys = [CCS_INTERNAL_API_KEY, ...userApiKeys];
|
||||
const allApiKeys = [effectiveApiKey, ...userApiKeys];
|
||||
const apiKeysYaml = allApiKeys.map((key) => ` - "${key}"`).join('\n');
|
||||
|
||||
// Unified config with enhanced CLIProxyAPI features
|
||||
@@ -219,7 +224,7 @@ usage-statistics-enabled: true
|
||||
# Remote management API for CCS dashboard integration
|
||||
remote-management:
|
||||
allow-remote: true
|
||||
secret-key: "${CCS_CONTROL_PANEL_SECRET}"
|
||||
secret-key: "${effectiveSecret}"
|
||||
disable-control-panel: false
|
||||
|
||||
# =============================================================================
|
||||
@@ -443,7 +448,7 @@ export function getClaudeEnvVars(
|
||||
const coreEnvVars = {
|
||||
// Provider-specific endpoint - routes to correct provider via URL path
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}/api/provider/${provider}`,
|
||||
ANTHROPIC_AUTH_TOKEN: CCS_INTERNAL_API_KEY,
|
||||
ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(),
|
||||
ANTHROPIC_MODEL: models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel,
|
||||
@@ -710,7 +715,7 @@ export function getRemoteEnvVars(
|
||||
...userEnvVars,
|
||||
// Always override URL and auth token with remote config
|
||||
ANTHROPIC_BASE_URL: baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: remoteConfig.authToken || CCS_INTERNAL_API_KEY,
|
||||
ANTHROPIC_AUTH_TOKEN: remoteConfig.authToken || getEffectiveApiKey(),
|
||||
};
|
||||
|
||||
return env;
|
||||
|
||||
@@ -135,3 +135,16 @@ export { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from '.
|
||||
// Startup lock (prevents race conditions between CCS processes)
|
||||
export type { LockResult } from './startup-lock';
|
||||
export { acquireStartupLock, withStartupLock } from './startup-lock';
|
||||
|
||||
// Auth token manager (customizable API key and management secret)
|
||||
export {
|
||||
generateSecureToken,
|
||||
maskToken,
|
||||
getEffectiveApiKey,
|
||||
getEffectiveManagementSecret,
|
||||
setGlobalApiKey,
|
||||
setGlobalManagementSecret,
|
||||
setVariantApiKey,
|
||||
resetAuthToDefaults,
|
||||
getAuthSummary,
|
||||
} from './auth-token-manager';
|
||||
@@ -5,7 +5,7 @@
|
||||
* Requires usage-statistics-enabled: true in config.yaml.
|
||||
*/
|
||||
|
||||
import { CCS_CONTROL_PANEL_SECRET } from './config-generator';
|
||||
import { getEffectiveApiKey, getEffectiveManagementSecret } from './auth-token-manager';
|
||||
import { getProxyTarget, buildProxyUrl, buildProxyHeaders } from './proxy-target-resolver';
|
||||
|
||||
/** Per-account usage statistics */
|
||||
@@ -112,7 +112,7 @@ export async function fetchCliproxyStats(port?: number): Promise<CliproxyStats |
|
||||
// For management endpoints, use CCS control panel secret for local, remote auth for remote
|
||||
const headers = target.isRemote
|
||||
? buildProxyHeaders(target)
|
||||
: { Accept: 'application/json', Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}` };
|
||||
: { Accept: 'application/json', Authorization: `Bearer ${getEffectiveManagementSecret()}` };
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
@@ -244,10 +244,10 @@ export async function fetchCliproxyModels(port?: number): Promise<CliproxyModels
|
||||
}
|
||||
const url = buildProxyUrl(target, '/v1/models');
|
||||
|
||||
// For /v1 endpoints: use remote auth token for remote, ccs-internal-managed for local
|
||||
// For /v1 endpoints: use remote auth token for remote, effective API key for local
|
||||
const headers = target.isRemote
|
||||
? buildProxyHeaders(target)
|
||||
: { Accept: 'application/json', Authorization: 'Bearer ccs-internal-managed' };
|
||||
: { Accept: 'application/json', Authorization: `Bearer ${getEffectiveApiKey()}` };
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
@@ -325,7 +325,7 @@ export async function fetchCliproxyErrorLogs(port?: number): Promise<CliproxyErr
|
||||
// For management endpoints, use CCS control panel secret for local, remote auth for remote
|
||||
const headers = target.isRemote
|
||||
? buildProxyHeaders(target)
|
||||
: { Accept: 'application/json', Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}` };
|
||||
: { Accept: 'application/json', Authorization: `Bearer ${getEffectiveManagementSecret()}` };
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
@@ -373,7 +373,7 @@ export async function fetchCliproxyErrorLogContent(
|
||||
// For management endpoints, use CCS control panel secret for local, remote auth for remote
|
||||
const headers = target.isRemote
|
||||
? buildProxyHeaders(target)
|
||||
: { Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}` };
|
||||
: { Authorization: `Bearer ${getEffectiveManagementSecret()}` };
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Tokens Command
|
||||
*
|
||||
* Manage CLIProxyAPI auth tokens (API key and management secret).
|
||||
*
|
||||
* Usage:
|
||||
* ccs tokens Show current tokens (masked)
|
||||
* ccs tokens --show Show tokens unmasked
|
||||
* ccs tokens --api-key <key> Set global API key
|
||||
* ccs tokens --secret <key> Set management secret
|
||||
* ccs tokens --regenerate-secret Auto-generate new management secret
|
||||
* ccs tokens --reset Reset to defaults
|
||||
* ccs tokens --variant <name> --api-key <key> Set per-variant API key
|
||||
*/
|
||||
|
||||
import { initUI, ok, info, fail, warn, color, dim, header, subheader } from '../utils/ui';
|
||||
import {
|
||||
generateSecureToken,
|
||||
maskToken,
|
||||
setGlobalApiKey,
|
||||
setGlobalManagementSecret,
|
||||
setVariantApiKey,
|
||||
resetAuthToDefaults,
|
||||
getAuthSummary,
|
||||
} from '../cliproxy';
|
||||
import { regenerateConfig } from '../cliproxy/config-generator';
|
||||
|
||||
/**
|
||||
* Display current auth status
|
||||
*/
|
||||
async function showAuthStatus(showUnmasked: boolean): Promise<void> {
|
||||
await initUI();
|
||||
|
||||
const summary = getAuthSummary();
|
||||
|
||||
console.log(header('CLIProxy Auth Tokens'));
|
||||
console.log('');
|
||||
|
||||
// API Key
|
||||
const apiKeyDisplay = showUnmasked ? summary.apiKey.value : maskToken(summary.apiKey.value);
|
||||
const apiKeyStatus = summary.apiKey.isCustom ? color('(custom)', 'warning') : dim('(default)');
|
||||
console.log(` API Key: ${apiKeyDisplay} ${apiKeyStatus}`);
|
||||
|
||||
// Management Secret
|
||||
const secretDisplay = showUnmasked
|
||||
? summary.managementSecret.value
|
||||
: maskToken(summary.managementSecret.value);
|
||||
const secretStatus = summary.managementSecret.isCustom
|
||||
? color('(custom)', 'warning')
|
||||
: dim('(default)');
|
||||
console.log(` Management Secret: ${secretDisplay} ${secretStatus}`);
|
||||
|
||||
console.log('');
|
||||
|
||||
if (showUnmasked) {
|
||||
console.log(warn('Tokens shown in plain text. Do not share!'));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log(subheader('Usage'));
|
||||
console.log(` ${dim('Set API key:')} ccs tokens --api-key <key>`);
|
||||
console.log(` ${dim('Set secret:')} ccs tokens --secret <key>`);
|
||||
console.log(` ${dim('Generate secret:')} ccs tokens --regenerate-secret`);
|
||||
console.log(` ${dim('Reset to defaults:')} ccs tokens --reset`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tokens command
|
||||
*/
|
||||
export async function handleTokensCommand(args: string[]): Promise<number> {
|
||||
await initUI();
|
||||
|
||||
// Parse flags
|
||||
const showFlag = args.includes('--show');
|
||||
const resetFlag = args.includes('--reset');
|
||||
const regenerateSecretFlag = args.includes('--regenerate-secret');
|
||||
const helpFlag = args.includes('--help') || args.includes('-h');
|
||||
|
||||
// Find --api-key value
|
||||
const apiKeyIndex = args.indexOf('--api-key');
|
||||
const apiKeyValue = apiKeyIndex !== -1 ? args[apiKeyIndex + 1] : undefined;
|
||||
|
||||
// Find --secret value
|
||||
const secretIndex = args.indexOf('--secret');
|
||||
const secretValue = secretIndex !== -1 ? args[secretIndex + 1] : undefined;
|
||||
|
||||
// Find --variant value
|
||||
const variantIndex = args.indexOf('--variant');
|
||||
const variantValue = variantIndex !== -1 ? args[variantIndex + 1] : undefined;
|
||||
|
||||
// Help
|
||||
if (helpFlag) {
|
||||
console.log(header('CCS Tokens Management'));
|
||||
console.log('');
|
||||
console.log(subheader('Usage'));
|
||||
console.log(` ${color('ccs tokens', 'command')} [options]`);
|
||||
console.log('');
|
||||
console.log(subheader('Options'));
|
||||
console.log(` ${color('(no args)', 'command')} Show masked tokens`);
|
||||
console.log(` ${color('--show', 'command')} Show tokens unmasked`);
|
||||
console.log(` ${color('--api-key <key>', 'command')} Set global API key`);
|
||||
console.log(` ${color('--secret <key>', 'command')} Set management secret`);
|
||||
console.log(` ${color('--regenerate-secret', 'command')} Generate new management secret`);
|
||||
console.log(
|
||||
` ${color('--variant <name>', 'command')} Target specific variant (with --api-key)`
|
||||
);
|
||||
console.log(` ${color('--reset', 'command')} Reset all to defaults`);
|
||||
console.log(` ${color('--help, -h', 'command')} Show this help`);
|
||||
console.log('');
|
||||
console.log(subheader('Examples'));
|
||||
console.log(` ${dim('# View current tokens')}`);
|
||||
console.log(` ccs tokens`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Set custom API key')}`);
|
||||
console.log(` ccs tokens --api-key my-custom-key-123`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Generate secure management secret')}`);
|
||||
console.log(` ccs tokens --regenerate-secret`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Set per-variant API key')}`);
|
||||
console.log(` ccs tokens --variant my-gemini --api-key variant-key-456`);
|
||||
console.log('');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Reset to defaults
|
||||
if (resetFlag) {
|
||||
resetAuthToDefaults();
|
||||
// Regenerate CLIProxy config to apply changes
|
||||
regenerateConfig();
|
||||
console.log(ok('Auth tokens reset to defaults'));
|
||||
console.log(info('CLIProxy config regenerated'));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Regenerate management secret
|
||||
if (regenerateSecretFlag) {
|
||||
const newSecret = generateSecureToken(32);
|
||||
setGlobalManagementSecret(newSecret);
|
||||
// Regenerate CLIProxy config to apply changes
|
||||
regenerateConfig();
|
||||
console.log(ok('New management secret generated'));
|
||||
console.log(` Secret: ${maskToken(newSecret)}`);
|
||||
console.log(info('CLIProxy config regenerated'));
|
||||
console.log(warn('Restart CLIProxy to apply: ccs cliproxy restart'));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Set API key
|
||||
if (apiKeyValue !== undefined) {
|
||||
if (!apiKeyValue || apiKeyValue.startsWith('-')) {
|
||||
console.error(fail('Missing value for --api-key'));
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (variantValue) {
|
||||
// Per-variant API key
|
||||
try {
|
||||
setVariantApiKey(variantValue, apiKeyValue);
|
||||
console.log(ok(`API key set for variant '${variantValue}'`));
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : 'Unknown error';
|
||||
console.error(fail(error));
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
// Global API key
|
||||
setGlobalApiKey(apiKeyValue);
|
||||
console.log(ok('Global API key updated'));
|
||||
}
|
||||
|
||||
// Regenerate CLIProxy config to apply changes
|
||||
regenerateConfig();
|
||||
console.log(info('CLIProxy config regenerated'));
|
||||
console.log(warn('Restart CLIProxy to apply: ccs cliproxy restart'));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Set management secret
|
||||
if (secretValue !== undefined) {
|
||||
if (!secretValue || secretValue.startsWith('-')) {
|
||||
console.error(fail('Missing value for --secret'));
|
||||
return 1;
|
||||
}
|
||||
|
||||
setGlobalManagementSecret(secretValue);
|
||||
// Regenerate CLIProxy config to apply changes
|
||||
regenerateConfig();
|
||||
console.log(ok('Management secret updated'));
|
||||
console.log(info('CLIProxy config regenerated'));
|
||||
console.log(warn('Restart CLIProxy to apply: ccs cliproxy restart'));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Default: show status
|
||||
await showAuthStatus(showFlag);
|
||||
return 0;
|
||||
}
|
||||
@@ -131,6 +131,8 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
||||
request_log:
|
||||
partial.cliproxy?.logging?.request_log ?? defaults.cliproxy.logging?.request_log ?? false,
|
||||
},
|
||||
// Auth config - preserve user values, no defaults (uses constants as fallback)
|
||||
auth: partial.cliproxy?.auth,
|
||||
},
|
||||
preferences: {
|
||||
...defaults.preferences,
|
||||
@@ -231,32 +233,8 @@ export function loadOrCreateUnifiedConfig(): UnifiedConfig {
|
||||
* Generate YAML header with helpful comments.
|
||||
*/
|
||||
function generateYamlHeader(): string {
|
||||
return `# ============================================================================
|
||||
# CCS Unified Configuration (config.yaml)
|
||||
# ============================================================================
|
||||
# Generated by: ccs migrate
|
||||
# Documentation: https://github.com/kaitranntt/ccs
|
||||
#
|
||||
# This file references your settings - actual env vars are in *.settings.json
|
||||
# files (matching Claude's ~/.claude/settings.json pattern).
|
||||
#
|
||||
# To customize a profile:
|
||||
# 1. Edit the *.settings.json file directly (e.g., ~/.ccs/glm.settings.json)
|
||||
# 2. The file format matches Claude's settings.json: { "env": { ... } }\n#
|
||||
# Structure:
|
||||
# ┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
# │ profiles - References to *.settings.json files for API providers │
|
||||
# │ cliproxy - References to *.settings.json files for OAuth providers │
|
||||
# │ accounts - Isolated Claude instances (managed by 'ccs auth') │
|
||||
# │ preferences - User preferences (theme, telemetry, auto-update) │
|
||||
# └─────────────────────────────────────────────────────────────────────────────┘
|
||||
#
|
||||
# Usage:
|
||||
# ccs <profile> Switch to profile
|
||||
# ccs api add <name> Add new API profile
|
||||
# ccs cliproxy create Create CLIProxy variant
|
||||
# ccs migrate --rollback Restore from backup
|
||||
#
|
||||
return `# CCS Unified Configuration
|
||||
# Docs: https://github.com/kaitranntt/ccs
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
* Version 3 = WebSearch config with model configuration for Gemini/OpenCode
|
||||
* Version 4 = Copilot API integration (GitHub Copilot proxy)
|
||||
* Version 5 = Remote proxy configuration (connect to remote CLIProxyAPI)
|
||||
* Version 6 = Customizable auth tokens (API key and management secret)
|
||||
*/
|
||||
export const UNIFIED_CONFIG_VERSION = 5;
|
||||
export const UNIFIED_CONFIG_VERSION = 6;
|
||||
|
||||
/**
|
||||
* Account configuration (formerly in profiles.json).
|
||||
@@ -65,6 +66,19 @@ export interface CLIProxyVariantConfig {
|
||||
settings?: string;
|
||||
/** Unique port for variant isolation (8318-8417) */
|
||||
port?: number;
|
||||
/** Per-variant auth override (optional) */
|
||||
auth?: CLIProxyAuthConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLIProxy authentication configuration.
|
||||
* Allows customization of API key and management secret for CLIProxyAPI.
|
||||
*/
|
||||
export interface CLIProxyAuthConfig {
|
||||
/** API key for CCS-managed requests (default: 'ccs-internal-managed') */
|
||||
api_key?: string;
|
||||
/** Management secret for Control Panel login (default: 'ccs') */
|
||||
management_secret?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,6 +107,8 @@ export interface CLIProxyConfig {
|
||||
logging?: CLIProxyLoggingConfig;
|
||||
/** Kiro: disable incognito browser mode (use normal browser to save credentials) */
|
||||
kiro_no_incognito?: boolean;
|
||||
/** Global auth configuration for CLIProxyAPI */
|
||||
auth?: CLIProxyAuthConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,15 @@ import * as path from 'path';
|
||||
import { getCcsDir, loadSettings } from '../../utils/config-manager';
|
||||
import { isSensitiveKey, maskSensitiveValue } from '../../utils/sensitive-keys';
|
||||
import { listVariants } from '../../cliproxy/services/variant-service';
|
||||
import {
|
||||
generateSecureToken,
|
||||
maskToken,
|
||||
getAuthSummary,
|
||||
setGlobalApiKey,
|
||||
setGlobalManagementSecret,
|
||||
resetAuthToDefaults,
|
||||
} from '../../cliproxy';
|
||||
import { regenerateConfig } from '../../cliproxy/config-generator';
|
||||
import type { Settings } from '../../types/config';
|
||||
|
||||
const router = Router();
|
||||
@@ -266,4 +275,142 @@ router.delete('/:profile/presets/:name', (req: Request, res: Response): void =>
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== Auth Tokens ====================
|
||||
|
||||
/**
|
||||
* GET /api/settings/auth/tokens - Get current auth token status (masked)
|
||||
*/
|
||||
router.get('/auth/tokens', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const summary = getAuthSummary();
|
||||
|
||||
res.json({
|
||||
apiKey: {
|
||||
value: maskToken(summary.apiKey.value),
|
||||
isCustom: summary.apiKey.isCustom,
|
||||
},
|
||||
managementSecret: {
|
||||
value: maskToken(summary.managementSecret.value),
|
||||
isCustom: summary.managementSecret.isCustom,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/settings/auth/tokens/raw - Get current auth tokens unmasked
|
||||
* NOTE: Sensitive endpoint - no caching, localhost only
|
||||
*/
|
||||
router.get('/auth/tokens/raw', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
// Prevent caching of sensitive data
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
|
||||
const summary = getAuthSummary();
|
||||
|
||||
res.json({
|
||||
apiKey: {
|
||||
value: summary.apiKey.value,
|
||||
isCustom: summary.apiKey.isCustom,
|
||||
},
|
||||
managementSecret: {
|
||||
value: summary.managementSecret.value,
|
||||
isCustom: summary.managementSecret.isCustom,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/settings/auth/tokens - Update auth tokens
|
||||
*/
|
||||
router.put('/auth/tokens', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { apiKey, managementSecret } = req.body;
|
||||
|
||||
if (apiKey !== undefined) {
|
||||
setGlobalApiKey(apiKey || undefined);
|
||||
}
|
||||
|
||||
if (managementSecret !== undefined) {
|
||||
setGlobalManagementSecret(managementSecret || undefined);
|
||||
}
|
||||
|
||||
// Regenerate CLIProxy config to apply changes
|
||||
regenerateConfig();
|
||||
|
||||
const summary = getAuthSummary();
|
||||
res.json({
|
||||
success: true,
|
||||
apiKey: {
|
||||
value: maskToken(summary.apiKey.value),
|
||||
isCustom: summary.apiKey.isCustom,
|
||||
},
|
||||
managementSecret: {
|
||||
value: maskToken(summary.managementSecret.value),
|
||||
isCustom: summary.managementSecret.isCustom,
|
||||
},
|
||||
message: 'Restart CLIProxy to apply changes',
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/settings/auth/tokens/regenerate-secret - Generate new management secret
|
||||
*/
|
||||
router.post('/auth/tokens/regenerate-secret', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const newSecret = generateSecureToken(32);
|
||||
setGlobalManagementSecret(newSecret);
|
||||
|
||||
// Regenerate CLIProxy config to apply changes
|
||||
regenerateConfig();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
managementSecret: {
|
||||
value: maskToken(newSecret),
|
||||
isCustom: true,
|
||||
},
|
||||
message: 'Restart CLIProxy to apply changes',
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/settings/auth/tokens/reset - Reset auth tokens to defaults
|
||||
*/
|
||||
router.post('/auth/tokens/reset', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
resetAuthToDefaults();
|
||||
|
||||
// Regenerate CLIProxy config to apply changes
|
||||
regenerateConfig();
|
||||
|
||||
const summary = getAuthSummary();
|
||||
res.json({
|
||||
success: true,
|
||||
apiKey: {
|
||||
value: maskToken(summary.apiKey.value),
|
||||
isCustom: summary.apiKey.isCustom,
|
||||
},
|
||||
managementSecret: {
|
||||
value: maskToken(summary.managementSecret.value),
|
||||
isCustom: summary.managementSecret.isCustom,
|
||||
},
|
||||
message: 'Tokens reset to defaults. Restart CLIProxy to apply.',
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* Auth Token Manager Tests
|
||||
*
|
||||
* Comprehensive test suite for CLIProxy auth token management including:
|
||||
* - Token generation (cryptographic security)
|
||||
* - Token masking
|
||||
* - Pure function logic tests
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const crypto = require('crypto');
|
||||
|
||||
describe('Auth Token Manager', () => {
|
||||
// =========================================================================
|
||||
// Token Generation Tests (Pure Functions)
|
||||
// =========================================================================
|
||||
describe('generateSecureToken', () => {
|
||||
let generateSecureToken;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear cache and reload
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/auth-token-manager')];
|
||||
const authTokenManager = require('../../../dist/cliproxy/auth-token-manager');
|
||||
generateSecureToken = authTokenManager.generateSecureToken;
|
||||
});
|
||||
|
||||
it('generates token of correct length (base64url encoding)', () => {
|
||||
const token = generateSecureToken(32);
|
||||
// 32 bytes = 43 chars in base64url (without padding)
|
||||
assert.strictEqual(token.length, 43);
|
||||
});
|
||||
|
||||
it('generates unique tokens each call', () => {
|
||||
const tokens = new Set();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
tokens.add(generateSecureToken(32));
|
||||
}
|
||||
assert.strictEqual(tokens.size, 100, 'All 100 tokens should be unique');
|
||||
});
|
||||
|
||||
it('uses base64url encoding (no +/= characters)', () => {
|
||||
// Generate many tokens to ensure we hit all character ranges
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const token = generateSecureToken(32);
|
||||
assert(!token.includes('+'), 'Should not contain +');
|
||||
assert(!token.includes('/'), 'Should not contain /');
|
||||
assert(!token.includes('='), 'Should not contain padding =');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts custom length parameter', () => {
|
||||
const short = generateSecureToken(16);
|
||||
const long = generateSecureToken(64);
|
||||
|
||||
// 16 bytes = 22 chars, 64 bytes = 86 chars in base64url
|
||||
assert.strictEqual(short.length, 22);
|
||||
assert.strictEqual(long.length, 86);
|
||||
});
|
||||
|
||||
it('handles edge case: length = 0', () => {
|
||||
const empty = generateSecureToken(0);
|
||||
assert.strictEqual(empty.length, 0);
|
||||
});
|
||||
|
||||
it('handles edge case: very large length', () => {
|
||||
const large = generateSecureToken(256);
|
||||
assert.strictEqual(large.length, 342); // 256 bytes = 342 chars in base64url
|
||||
});
|
||||
|
||||
it('uses cryptographically secure random bytes', () => {
|
||||
// Verify the function uses crypto.randomBytes internally
|
||||
// by checking statistical properties of generated tokens
|
||||
const tokens = [];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
tokens.push(generateSecureToken(32));
|
||||
}
|
||||
|
||||
// All tokens should be unique
|
||||
const uniqueTokens = new Set(tokens);
|
||||
assert.strictEqual(uniqueTokens.size, 1000, 'All tokens should be unique');
|
||||
|
||||
// Check that first character has good distribution (not always same)
|
||||
const firstChars = new Set(tokens.map((t) => t[0]));
|
||||
assert(firstChars.size > 10, 'First character should have good distribution');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Token Masking Tests (Pure Function Simulation)
|
||||
// =========================================================================
|
||||
describe('maskToken (logic validation)', () => {
|
||||
// Simulates the maskToken function from tokens-command.ts and settings-routes.ts
|
||||
function maskToken(token) {
|
||||
if (token.length <= 8) return '****';
|
||||
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
it('masks tokens showing first 4 and last 4 characters', () => {
|
||||
assert.strictEqual(maskToken('1234567890abcdef'), '1234...cdef');
|
||||
});
|
||||
|
||||
it('returns **** for tokens <= 8 characters', () => {
|
||||
assert.strictEqual(maskToken('12345678'), '****');
|
||||
assert.strictEqual(maskToken('1234567'), '****');
|
||||
assert.strictEqual(maskToken('abc'), '****');
|
||||
assert.strictEqual(maskToken(''), '****');
|
||||
});
|
||||
|
||||
it('handles exactly 9 character tokens', () => {
|
||||
assert.strictEqual(maskToken('123456789'), '1234...6789');
|
||||
});
|
||||
|
||||
it('handles long tokens (typical API keys)', () => {
|
||||
const longToken = 'test_key_1234567890abcdefghijklmnopqrstuvwxyz';
|
||||
assert.strictEqual(maskToken(longToken), 'test...wxyz');
|
||||
});
|
||||
|
||||
it('preserves special characters', () => {
|
||||
assert.strictEqual(maskToken('!@#$abcd____wxyz'), '!@#$...wxyz');
|
||||
});
|
||||
|
||||
it('handles default CCS internal key', () => {
|
||||
const internalKey = 'ccs-internal-managed';
|
||||
assert.strictEqual(maskToken(internalKey), 'ccs-...aged');
|
||||
});
|
||||
|
||||
it('handles default CCS control panel secret', () => {
|
||||
const secret = 'ccs';
|
||||
assert.strictEqual(maskToken(secret), '****');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Inheritance Chain Logic Tests
|
||||
// =========================================================================
|
||||
describe('getEffectiveApiKey logic', () => {
|
||||
/**
|
||||
* Simulates the inheritance logic from auth-token-manager.ts
|
||||
*/
|
||||
function getEffectiveApiKey(config, variantName, defaultKey) {
|
||||
// Priority 1: Per-variant override
|
||||
if (variantName) {
|
||||
const variant = config.cliproxy?.variants?.[variantName];
|
||||
if (variant?.auth?.api_key) {
|
||||
return variant.auth.api_key;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Global cliproxy.auth
|
||||
if (config.cliproxy?.auth?.api_key) {
|
||||
return config.cliproxy.auth.api_key;
|
||||
}
|
||||
|
||||
// Priority 3: Default constant
|
||||
return defaultKey;
|
||||
}
|
||||
|
||||
const DEFAULT_KEY = 'ccs-internal-managed';
|
||||
|
||||
it('returns default when no custom config', () => {
|
||||
const config = { cliproxy: { variants: {} } };
|
||||
assert.strictEqual(getEffectiveApiKey(config, undefined, DEFAULT_KEY), DEFAULT_KEY);
|
||||
});
|
||||
|
||||
it('returns global auth when set', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {},
|
||||
auth: { api_key: 'global-custom' },
|
||||
},
|
||||
};
|
||||
assert.strictEqual(getEffectiveApiKey(config, undefined, DEFAULT_KEY), 'global-custom');
|
||||
});
|
||||
|
||||
it('returns variant auth when set (highest priority)', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {
|
||||
gemini: { auth: { api_key: 'variant-key' } },
|
||||
},
|
||||
auth: { api_key: 'global-custom' },
|
||||
},
|
||||
};
|
||||
assert.strictEqual(getEffectiveApiKey(config, 'gemini', DEFAULT_KEY), 'variant-key');
|
||||
});
|
||||
|
||||
it('falls back to global when variant has no auth', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {
|
||||
gemini: { type: 'claude' },
|
||||
},
|
||||
auth: { api_key: 'global-custom' },
|
||||
},
|
||||
};
|
||||
assert.strictEqual(getEffectiveApiKey(config, 'gemini', DEFAULT_KEY), 'global-custom');
|
||||
});
|
||||
|
||||
it('falls back to default when variant and global missing', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {
|
||||
gemini: { type: 'claude' },
|
||||
},
|
||||
},
|
||||
};
|
||||
assert.strictEqual(getEffectiveApiKey(config, 'gemini', DEFAULT_KEY), DEFAULT_KEY);
|
||||
});
|
||||
|
||||
it('ignores variant name when variant does not exist', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {},
|
||||
auth: { api_key: 'global-custom' },
|
||||
},
|
||||
};
|
||||
assert.strictEqual(getEffectiveApiKey(config, 'non-existent', DEFAULT_KEY), 'global-custom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEffectiveManagementSecret logic', () => {
|
||||
/**
|
||||
* Simulates the management secret logic from auth-token-manager.ts
|
||||
*/
|
||||
function getEffectiveManagementSecret(config, defaultSecret) {
|
||||
// Priority 1: Global cliproxy.auth
|
||||
if (config.cliproxy?.auth?.management_secret) {
|
||||
return config.cliproxy.auth.management_secret;
|
||||
}
|
||||
|
||||
// Priority 2: Default constant
|
||||
return defaultSecret;
|
||||
}
|
||||
|
||||
const DEFAULT_SECRET = 'ccs';
|
||||
|
||||
it('returns default when no custom config', () => {
|
||||
const config = { cliproxy: { variants: {} } };
|
||||
assert.strictEqual(getEffectiveManagementSecret(config, DEFAULT_SECRET), DEFAULT_SECRET);
|
||||
});
|
||||
|
||||
it('returns custom secret when set', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {},
|
||||
auth: { management_secret: 'custom-secret' },
|
||||
},
|
||||
};
|
||||
assert.strictEqual(getEffectiveManagementSecret(config, DEFAULT_SECRET), 'custom-secret');
|
||||
});
|
||||
|
||||
it('is global only (no per-variant override)', () => {
|
||||
// Management secret does not support per-variant override
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {
|
||||
gemini: { auth: { management_secret: 'should-be-ignored' } },
|
||||
},
|
||||
auth: { management_secret: 'global-secret' },
|
||||
},
|
||||
};
|
||||
// The function only checks global auth
|
||||
assert.strictEqual(getEffectiveManagementSecret(config, DEFAULT_SECRET), 'global-secret');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Auth Summary Logic Tests
|
||||
// =========================================================================
|
||||
describe('getAuthSummary logic', () => {
|
||||
function getAuthSummary(config, defaultApiKey, defaultSecret) {
|
||||
const customApiKey = config.cliproxy?.auth?.api_key;
|
||||
const customSecret = config.cliproxy?.auth?.management_secret;
|
||||
|
||||
return {
|
||||
apiKey: {
|
||||
value: customApiKey || defaultApiKey,
|
||||
isCustom: !!customApiKey,
|
||||
},
|
||||
managementSecret: {
|
||||
value: customSecret || defaultSecret,
|
||||
isCustom: !!customSecret,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_KEY = 'ccs-internal-managed';
|
||||
const DEFAULT_SECRET = 'ccs';
|
||||
|
||||
it('returns defaults with isCustom=false when no custom config', () => {
|
||||
const config = { cliproxy: { variants: {} } };
|
||||
const summary = getAuthSummary(config, DEFAULT_KEY, DEFAULT_SECRET);
|
||||
|
||||
assert.strictEqual(summary.apiKey.value, DEFAULT_KEY);
|
||||
assert.strictEqual(summary.apiKey.isCustom, false);
|
||||
assert.strictEqual(summary.managementSecret.value, DEFAULT_SECRET);
|
||||
assert.strictEqual(summary.managementSecret.isCustom, false);
|
||||
});
|
||||
|
||||
it('returns custom values with isCustom=true when set', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {},
|
||||
auth: { api_key: 'custom-key', management_secret: 'custom-secret' },
|
||||
},
|
||||
};
|
||||
const summary = getAuthSummary(config, DEFAULT_KEY, DEFAULT_SECRET);
|
||||
|
||||
assert.strictEqual(summary.apiKey.value, 'custom-key');
|
||||
assert.strictEqual(summary.apiKey.isCustom, true);
|
||||
assert.strictEqual(summary.managementSecret.value, 'custom-secret');
|
||||
assert.strictEqual(summary.managementSecret.isCustom, true);
|
||||
});
|
||||
|
||||
it('handles partial custom config', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {},
|
||||
auth: { api_key: 'custom-key' },
|
||||
},
|
||||
};
|
||||
const summary = getAuthSummary(config, DEFAULT_KEY, DEFAULT_SECRET);
|
||||
|
||||
assert.strictEqual(summary.apiKey.isCustom, true);
|
||||
assert.strictEqual(summary.managementSecret.isCustom, false);
|
||||
});
|
||||
|
||||
it('treats empty string as no custom value', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {},
|
||||
auth: { api_key: '' },
|
||||
},
|
||||
};
|
||||
const summary = getAuthSummary(config, DEFAULT_KEY, DEFAULT_SECRET);
|
||||
|
||||
// Empty string is falsy, so isCustom should be false
|
||||
assert.strictEqual(summary.apiKey.value, DEFAULT_KEY);
|
||||
assert.strictEqual(summary.apiKey.isCustom, false);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Edge Cases
|
||||
// =========================================================================
|
||||
describe('Edge Cases', () => {
|
||||
it('handles undefined config gracefully', () => {
|
||||
function getEffectiveApiKey(config, variantName, defaultKey) {
|
||||
if (variantName) {
|
||||
const variant = config?.cliproxy?.variants?.[variantName];
|
||||
if (variant?.auth?.api_key) return variant.auth.api_key;
|
||||
}
|
||||
if (config?.cliproxy?.auth?.api_key) return config.cliproxy.auth.api_key;
|
||||
return defaultKey;
|
||||
}
|
||||
|
||||
assert.strictEqual(getEffectiveApiKey(undefined, undefined, 'default'), 'default');
|
||||
assert.strictEqual(getEffectiveApiKey(null, undefined, 'default'), 'default');
|
||||
assert.strictEqual(getEffectiveApiKey({}, undefined, 'default'), 'default');
|
||||
});
|
||||
|
||||
it('handles deeply nested missing properties', () => {
|
||||
function getEffectiveApiKey(config, variantName, defaultKey) {
|
||||
if (variantName) {
|
||||
const variant = config?.cliproxy?.variants?.[variantName];
|
||||
if (variant?.auth?.api_key) return variant.auth.api_key;
|
||||
}
|
||||
if (config?.cliproxy?.auth?.api_key) return config.cliproxy.auth.api_key;
|
||||
return defaultKey;
|
||||
}
|
||||
|
||||
const config = { cliproxy: {} };
|
||||
assert.strictEqual(getEffectiveApiKey(config, 'test', 'default'), 'default');
|
||||
});
|
||||
|
||||
it('crypto.randomBytes is available and working', () => {
|
||||
// Ensure crypto module is available
|
||||
const bytes = crypto.randomBytes(32);
|
||||
assert.strictEqual(bytes.length, 32);
|
||||
assert(Buffer.isBuffer(bytes));
|
||||
});
|
||||
|
||||
it('base64url encoding produces valid characters', () => {
|
||||
const bytes = crypto.randomBytes(32);
|
||||
const token = bytes.toString('base64url');
|
||||
|
||||
// Valid base64url characters: A-Z, a-z, 0-9, -, _
|
||||
const validChars = /^[A-Za-z0-9_-]+$/;
|
||||
assert(validChars.test(token), 'Token should only contain base64url characters');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Constants Validation
|
||||
// =========================================================================
|
||||
describe('Default Constants', () => {
|
||||
let CCS_INTERNAL_API_KEY;
|
||||
let CCS_CONTROL_PANEL_SECRET;
|
||||
|
||||
beforeEach(() => {
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')];
|
||||
const configGenerator = require('../../../dist/cliproxy/config-generator');
|
||||
CCS_INTERNAL_API_KEY = configGenerator.CCS_INTERNAL_API_KEY;
|
||||
CCS_CONTROL_PANEL_SECRET = configGenerator.CCS_CONTROL_PANEL_SECRET;
|
||||
});
|
||||
|
||||
it('CCS_INTERNAL_API_KEY is defined', () => {
|
||||
assert(CCS_INTERNAL_API_KEY, 'CCS_INTERNAL_API_KEY should be defined');
|
||||
assert.strictEqual(typeof CCS_INTERNAL_API_KEY, 'string');
|
||||
});
|
||||
|
||||
it('CCS_CONTROL_PANEL_SECRET is defined', () => {
|
||||
assert(CCS_CONTROL_PANEL_SECRET, 'CCS_CONTROL_PANEL_SECRET should be defined');
|
||||
assert.strictEqual(typeof CCS_CONTROL_PANEL_SECRET, 'string');
|
||||
});
|
||||
|
||||
it('default API key has expected value', () => {
|
||||
assert.strictEqual(CCS_INTERNAL_API_KEY, 'ccs-internal-managed');
|
||||
});
|
||||
|
||||
it('default secret has expected value', () => {
|
||||
assert.strictEqual(CCS_CONTROL_PANEL_SECRET, 'ccs');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Tokens Command Tests
|
||||
*
|
||||
* Tests for the `ccs tokens` CLI command including:
|
||||
* - Argument parsing
|
||||
* - Token masking
|
||||
* - Error handling
|
||||
* - Exit codes
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
describe('Tokens Command', () => {
|
||||
// =========================================================================
|
||||
// Token Masking Tests (Unit)
|
||||
// =========================================================================
|
||||
describe('maskToken', () => {
|
||||
// Extract the maskToken function pattern for testing
|
||||
function maskToken(token) {
|
||||
if (token.length <= 8) return '****';
|
||||
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
it('masks tokens showing first 4 and last 4 characters', () => {
|
||||
const result = maskToken('1234567890abcdef');
|
||||
assert.strictEqual(result, '1234...cdef');
|
||||
});
|
||||
|
||||
it('returns **** for tokens <= 8 characters', () => {
|
||||
assert.strictEqual(maskToken('12345678'), '****');
|
||||
assert.strictEqual(maskToken('1234567'), '****');
|
||||
assert.strictEqual(maskToken('abc'), '****');
|
||||
assert.strictEqual(maskToken(''), '****');
|
||||
});
|
||||
|
||||
it('handles exactly 9 character tokens', () => {
|
||||
const result = maskToken('123456789');
|
||||
assert.strictEqual(result, '1234...6789');
|
||||
});
|
||||
|
||||
it('handles long tokens (typical API keys)', () => {
|
||||
const longToken = 'test_key_1234567890abcdefghijklmnopqrstuvwxyz';
|
||||
const result = maskToken(longToken);
|
||||
assert.strictEqual(result, 'test...wxyz');
|
||||
});
|
||||
|
||||
it('preserves special characters in masked output', () => {
|
||||
const token = '!@#$abcd____wxyz';
|
||||
const result = maskToken(token);
|
||||
assert.strictEqual(result, '!@#$...wxyz');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Argument Parsing Tests (Unit)
|
||||
// =========================================================================
|
||||
describe('Argument Parsing', () => {
|
||||
/**
|
||||
* Simulates the argument parsing logic from tokens-command.ts
|
||||
*/
|
||||
function parseArgs(args) {
|
||||
const showFlag = args.includes('--show');
|
||||
const resetFlag = args.includes('--reset');
|
||||
const regenerateSecretFlag = args.includes('--regenerate-secret');
|
||||
const helpFlag = args.includes('--help') || args.includes('-h');
|
||||
|
||||
const apiKeyIndex = args.indexOf('--api-key');
|
||||
const apiKeyValue = apiKeyIndex !== -1 ? args[apiKeyIndex + 1] : undefined;
|
||||
|
||||
const secretIndex = args.indexOf('--secret');
|
||||
const secretValue = secretIndex !== -1 ? args[secretIndex + 1] : undefined;
|
||||
|
||||
const variantIndex = args.indexOf('--variant');
|
||||
const variantValue = variantIndex !== -1 ? args[variantIndex + 1] : undefined;
|
||||
|
||||
return {
|
||||
showFlag,
|
||||
resetFlag,
|
||||
regenerateSecretFlag,
|
||||
helpFlag,
|
||||
apiKeyValue,
|
||||
secretValue,
|
||||
variantValue,
|
||||
};
|
||||
}
|
||||
|
||||
it('parses --show flag', () => {
|
||||
const result = parseArgs(['--show']);
|
||||
assert.strictEqual(result.showFlag, true);
|
||||
});
|
||||
|
||||
it('parses --reset flag', () => {
|
||||
const result = parseArgs(['--reset']);
|
||||
assert.strictEqual(result.resetFlag, true);
|
||||
});
|
||||
|
||||
it('parses --regenerate-secret flag', () => {
|
||||
const result = parseArgs(['--regenerate-secret']);
|
||||
assert.strictEqual(result.regenerateSecretFlag, true);
|
||||
});
|
||||
|
||||
it('parses --help and -h flags', () => {
|
||||
assert.strictEqual(parseArgs(['--help']).helpFlag, true);
|
||||
assert.strictEqual(parseArgs(['-h']).helpFlag, true);
|
||||
});
|
||||
|
||||
it('parses --api-key with value', () => {
|
||||
const result = parseArgs(['--api-key', 'my-custom-key']);
|
||||
assert.strictEqual(result.apiKeyValue, 'my-custom-key');
|
||||
});
|
||||
|
||||
it('parses --secret with value', () => {
|
||||
const result = parseArgs(['--secret', 'my-secret']);
|
||||
assert.strictEqual(result.secretValue, 'my-secret');
|
||||
});
|
||||
|
||||
it('parses --variant with value', () => {
|
||||
const result = parseArgs(['--variant', 'gemini']);
|
||||
assert.strictEqual(result.variantValue, 'gemini');
|
||||
});
|
||||
|
||||
it('parses combined --variant and --api-key', () => {
|
||||
const result = parseArgs(['--variant', 'gemini', '--api-key', 'variant-key']);
|
||||
assert.strictEqual(result.variantValue, 'gemini');
|
||||
assert.strictEqual(result.apiKeyValue, 'variant-key');
|
||||
});
|
||||
|
||||
it('handles no arguments (default case)', () => {
|
||||
const result = parseArgs([]);
|
||||
assert.strictEqual(result.showFlag, false);
|
||||
assert.strictEqual(result.resetFlag, false);
|
||||
assert.strictEqual(result.apiKeyValue, undefined);
|
||||
});
|
||||
|
||||
it('handles multiple flags', () => {
|
||||
const result = parseArgs(['--show', '--api-key', 'key', '--variant', 'v1']);
|
||||
assert.strictEqual(result.showFlag, true);
|
||||
assert.strictEqual(result.apiKeyValue, 'key');
|
||||
assert.strictEqual(result.variantValue, 'v1');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Validation Tests (Edge Cases)
|
||||
// =========================================================================
|
||||
describe('Input Validation', () => {
|
||||
/**
|
||||
* Simulates the validation logic from tokens-command.ts
|
||||
*/
|
||||
function validateApiKeyValue(apiKeyValue) {
|
||||
if (apiKeyValue !== undefined) {
|
||||
if (!apiKeyValue || apiKeyValue.startsWith('-')) {
|
||||
return { valid: false, error: 'Missing value for --api-key' };
|
||||
}
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
function validateSecretValue(secretValue) {
|
||||
if (secretValue !== undefined) {
|
||||
if (!secretValue || secretValue.startsWith('-')) {
|
||||
return { valid: false, error: 'Missing value for --secret' };
|
||||
}
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
it('rejects --api-key without value', () => {
|
||||
// When --api-key is at end of args, value will be undefined
|
||||
const result = validateApiKeyValue(undefined);
|
||||
// Note: undefined means flag wasn't provided, empty string means missing value
|
||||
assert.strictEqual(result.valid, true); // undefined = not provided = valid
|
||||
});
|
||||
|
||||
it('rejects --api-key with empty string', () => {
|
||||
const result = validateApiKeyValue('');
|
||||
assert.strictEqual(result.valid, false);
|
||||
assert.strictEqual(result.error, 'Missing value for --api-key');
|
||||
});
|
||||
|
||||
it('rejects --api-key followed by another flag', () => {
|
||||
// e.g., --api-key --reset -> apiKeyValue = '--reset'
|
||||
const result = validateApiKeyValue('--reset');
|
||||
assert.strictEqual(result.valid, false);
|
||||
assert.strictEqual(result.error, 'Missing value for --api-key');
|
||||
});
|
||||
|
||||
it('rejects --secret with empty string', () => {
|
||||
const result = validateSecretValue('');
|
||||
assert.strictEqual(result.valid, false);
|
||||
assert.strictEqual(result.error, 'Missing value for --secret');
|
||||
});
|
||||
|
||||
it('rejects --secret followed by another flag', () => {
|
||||
const result = validateSecretValue('--show');
|
||||
assert.strictEqual(result.valid, false);
|
||||
assert.strictEqual(result.error, 'Missing value for --secret');
|
||||
});
|
||||
|
||||
it('accepts valid api key values', () => {
|
||||
assert.strictEqual(validateApiKeyValue('my-key').valid, true);
|
||||
assert.strictEqual(validateApiKeyValue('sk_live_123').valid, true);
|
||||
assert.strictEqual(validateApiKeyValue('a').valid, true);
|
||||
});
|
||||
|
||||
it('accepts valid secret values', () => {
|
||||
assert.strictEqual(validateSecretValue('my-secret').valid, true);
|
||||
assert.strictEqual(validateSecretValue('super-secure-123').valid, true);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Exit Code Tests
|
||||
// =========================================================================
|
||||
describe('Exit Codes', () => {
|
||||
it('defines success exit code as 0', () => {
|
||||
// These are the expected exit codes from the command
|
||||
const EXIT_SUCCESS = 0;
|
||||
const EXIT_FAILURE = 1;
|
||||
|
||||
assert.strictEqual(EXIT_SUCCESS, 0);
|
||||
assert.strictEqual(EXIT_FAILURE, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Help Text Tests
|
||||
// =========================================================================
|
||||
describe('Help Text Coverage', () => {
|
||||
// Verify all documented options are present in help output
|
||||
const expectedOptions = [
|
||||
'--show',
|
||||
'--api-key',
|
||||
'--secret',
|
||||
'--regenerate-secret',
|
||||
'--variant',
|
||||
'--reset',
|
||||
'--help',
|
||||
'-h',
|
||||
];
|
||||
|
||||
it('documents all CLI options', () => {
|
||||
// This test ensures we update help when adding new options
|
||||
expectedOptions.forEach((option) => {
|
||||
assert(typeof option === 'string', `Option ${option} should be a string`);
|
||||
assert(option.startsWith('-'), `Option ${option} should start with -`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Settings Routes - Auth Tokens API Tests
|
||||
*
|
||||
* Tests for the auth tokens API endpoints:
|
||||
* - GET /api/settings/auth/tokens (masked)
|
||||
* - GET /api/settings/auth/tokens/raw (unmasked)
|
||||
* - PUT /api/settings/auth/tokens (update)
|
||||
* - POST /api/settings/auth/tokens/regenerate-secret
|
||||
* - POST /api/settings/auth/tokens/reset
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
describe('Settings Routes - Auth Tokens API', () => {
|
||||
// =========================================================================
|
||||
// maskToken Function Tests (same as in routes)
|
||||
// =========================================================================
|
||||
describe('maskToken', () => {
|
||||
function maskToken(token) {
|
||||
if (token.length <= 8) return '****';
|
||||
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
it('masks long tokens correctly', () => {
|
||||
assert.strictEqual(maskToken('ccs-internal-managed'), 'ccs-...aged');
|
||||
});
|
||||
|
||||
it('fully masks short tokens', () => {
|
||||
assert.strictEqual(maskToken('ccs'), '****');
|
||||
});
|
||||
|
||||
it('handles exactly 8 character tokens', () => {
|
||||
assert.strictEqual(maskToken('12345678'), '****');
|
||||
});
|
||||
|
||||
it('handles 9 character tokens', () => {
|
||||
assert.strictEqual(maskToken('123456789'), '1234...6789');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Response Format Tests
|
||||
// =========================================================================
|
||||
describe('Response Format', () => {
|
||||
/**
|
||||
* Expected response format for GET /api/settings/auth/tokens
|
||||
*/
|
||||
function createMaskedResponse(apiKey, managementSecret, apiKeyIsCustom, secretIsCustom) {
|
||||
function maskToken(token) {
|
||||
if (token.length <= 8) return '****';
|
||||
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey: {
|
||||
value: maskToken(apiKey),
|
||||
isCustom: apiKeyIsCustom,
|
||||
},
|
||||
managementSecret: {
|
||||
value: maskToken(managementSecret),
|
||||
isCustom: secretIsCustom,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('includes apiKey with value and isCustom', () => {
|
||||
const response = createMaskedResponse('test-api-key-123', 'test-secret', true, false);
|
||||
|
||||
assert(response.apiKey, 'Should have apiKey');
|
||||
assert.strictEqual(typeof response.apiKey.value, 'string');
|
||||
assert.strictEqual(typeof response.apiKey.isCustom, 'boolean');
|
||||
});
|
||||
|
||||
it('includes managementSecret with value and isCustom', () => {
|
||||
const response = createMaskedResponse('key', 'test-secret-456', false, true);
|
||||
|
||||
assert(response.managementSecret, 'Should have managementSecret');
|
||||
assert.strictEqual(typeof response.managementSecret.value, 'string');
|
||||
assert.strictEqual(typeof response.managementSecret.isCustom, 'boolean');
|
||||
});
|
||||
|
||||
it('masks values in response', () => {
|
||||
const response = createMaskedResponse(
|
||||
'very-long-api-key-12345',
|
||||
'very-long-secret-67890',
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
// Values should be masked (contain ...)
|
||||
assert(response.apiKey.value.includes('...'), 'API key should be masked');
|
||||
assert(response.managementSecret.value.includes('...'), 'Secret should be masked');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// PUT /api/settings/auth/tokens - Update Logic Tests
|
||||
// =========================================================================
|
||||
describe('PUT /api/settings/auth/tokens - Update Logic', () => {
|
||||
/**
|
||||
* Simulates the update logic
|
||||
*/
|
||||
function processUpdate(body, currentState) {
|
||||
const { apiKey, managementSecret } = body;
|
||||
const newState = { ...currentState };
|
||||
|
||||
if (apiKey !== undefined) {
|
||||
// Empty string -> reset to default
|
||||
newState.apiKey = apiKey || null;
|
||||
}
|
||||
|
||||
if (managementSecret !== undefined) {
|
||||
newState.managementSecret = managementSecret || null;
|
||||
}
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
it('updates apiKey when provided', () => {
|
||||
const current = { apiKey: 'old-key', managementSecret: 'old-secret' };
|
||||
const result = processUpdate({ apiKey: 'new-key' }, current);
|
||||
|
||||
assert.strictEqual(result.apiKey, 'new-key');
|
||||
assert.strictEqual(result.managementSecret, 'old-secret');
|
||||
});
|
||||
|
||||
it('updates managementSecret when provided', () => {
|
||||
const current = { apiKey: 'old-key', managementSecret: 'old-secret' };
|
||||
const result = processUpdate({ managementSecret: 'new-secret' }, current);
|
||||
|
||||
assert.strictEqual(result.apiKey, 'old-key');
|
||||
assert.strictEqual(result.managementSecret, 'new-secret');
|
||||
});
|
||||
|
||||
it('updates both when provided', () => {
|
||||
const current = { apiKey: 'old-key', managementSecret: 'old-secret' };
|
||||
const result = processUpdate({ apiKey: 'new-key', managementSecret: 'new-secret' }, current);
|
||||
|
||||
assert.strictEqual(result.apiKey, 'new-key');
|
||||
assert.strictEqual(result.managementSecret, 'new-secret');
|
||||
});
|
||||
|
||||
it('resets to default when empty string provided', () => {
|
||||
const current = { apiKey: 'custom-key', managementSecret: 'custom-secret' };
|
||||
const result = processUpdate({ apiKey: '', managementSecret: '' }, current);
|
||||
|
||||
assert.strictEqual(result.apiKey, null);
|
||||
assert.strictEqual(result.managementSecret, null);
|
||||
});
|
||||
|
||||
it('ignores undefined values (no change)', () => {
|
||||
const current = { apiKey: 'keep-key', managementSecret: 'keep-secret' };
|
||||
const result = processUpdate({}, current);
|
||||
|
||||
assert.strictEqual(result.apiKey, 'keep-key');
|
||||
assert.strictEqual(result.managementSecret, 'keep-secret');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// POST /api/settings/auth/tokens/regenerate-secret - Tests
|
||||
// =========================================================================
|
||||
describe('POST /api/settings/auth/tokens/regenerate-secret', () => {
|
||||
/**
|
||||
* Simulates regenerate secret response
|
||||
*/
|
||||
function createRegenerateResponse(newSecret) {
|
||||
function maskToken(token) {
|
||||
if (token.length <= 8) return '****';
|
||||
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
managementSecret: {
|
||||
value: maskToken(newSecret),
|
||||
isCustom: true,
|
||||
},
|
||||
message: 'Restart CLIProxy to apply changes',
|
||||
};
|
||||
}
|
||||
|
||||
it('returns success: true', () => {
|
||||
const response = createRegenerateResponse('new-generated-secret-12345');
|
||||
assert.strictEqual(response.success, true);
|
||||
});
|
||||
|
||||
it('returns masked new secret', () => {
|
||||
const response = createRegenerateResponse('abcdefghijklmnopqrstuvwxyz');
|
||||
assert(response.managementSecret.value.includes('...'), 'Should be masked');
|
||||
assert.strictEqual(response.managementSecret.isCustom, true);
|
||||
});
|
||||
|
||||
it('includes restart message', () => {
|
||||
const response = createRegenerateResponse('secret');
|
||||
assert(response.message.includes('Restart'), 'Should include restart instruction');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// POST /api/settings/auth/tokens/reset - Tests
|
||||
// =========================================================================
|
||||
describe('POST /api/settings/auth/tokens/reset', () => {
|
||||
/**
|
||||
* Simulates reset response
|
||||
*/
|
||||
function createResetResponse(defaultApiKey, defaultSecret) {
|
||||
function maskToken(token) {
|
||||
if (token.length <= 8) return '****';
|
||||
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
apiKey: {
|
||||
value: maskToken(defaultApiKey),
|
||||
isCustom: false,
|
||||
},
|
||||
managementSecret: {
|
||||
value: maskToken(defaultSecret),
|
||||
isCustom: false,
|
||||
},
|
||||
message: 'Tokens reset to defaults. Restart CLIProxy to apply.',
|
||||
};
|
||||
}
|
||||
|
||||
it('returns success: true', () => {
|
||||
const response = createResetResponse('ccs-internal-managed', 'ccs');
|
||||
assert.strictEqual(response.success, true);
|
||||
});
|
||||
|
||||
it('returns isCustom: false for both tokens', () => {
|
||||
const response = createResetResponse('ccs-internal-managed', 'ccs');
|
||||
assert.strictEqual(response.apiKey.isCustom, false);
|
||||
assert.strictEqual(response.managementSecret.isCustom, false);
|
||||
});
|
||||
|
||||
it('includes reset message', () => {
|
||||
const response = createResetResponse('key', 'secret');
|
||||
assert(response.message.includes('reset'), 'Should include reset confirmation');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Error Response Format Tests
|
||||
// =========================================================================
|
||||
describe('Error Response Format', () => {
|
||||
/**
|
||||
* Simulates error response
|
||||
*/
|
||||
function createErrorResponse(errorMessage) {
|
||||
return {
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
it('includes error message', () => {
|
||||
const response = createErrorResponse('Something went wrong');
|
||||
assert.strictEqual(response.error, 'Something went wrong');
|
||||
});
|
||||
|
||||
it('does not include sensitive data in errors', () => {
|
||||
const response = createErrorResponse('Failed to load config');
|
||||
assert(!response.apiKey, 'Should not include apiKey');
|
||||
assert(!response.managementSecret, 'Should not include secret');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// HTTP Status Code Tests
|
||||
// =========================================================================
|
||||
describe('HTTP Status Codes', () => {
|
||||
const HTTP_OK = 200;
|
||||
const HTTP_INTERNAL_ERROR = 500;
|
||||
|
||||
it('uses 200 for successful responses', () => {
|
||||
assert.strictEqual(HTTP_OK, 200);
|
||||
});
|
||||
|
||||
it('uses 500 for internal errors', () => {
|
||||
assert.strictEqual(HTTP_INTERNAL_ERROR, 500);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { RefreshCw, AlertCircle, Key, X, Gauge, Globe } from 'lucide-react';
|
||||
import { RefreshCw, AlertCircle, Key, X, Gauge, Globe, Settings } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api-client';
|
||||
import type { CliproxyServerConfig } from '@/lib/api-client';
|
||||
@@ -15,8 +15,10 @@ import type { CliproxyServerConfig } from '@/lib/api-client';
|
||||
/** CLIProxyAPI default port */
|
||||
const CLIPROXY_DEFAULT_PORT = 8317;
|
||||
|
||||
/** CCS Control Panel secret - must match config-generator.ts CCS_CONTROL_PANEL_SECRET */
|
||||
const CCS_CONTROL_PANEL_SECRET = 'ccs';
|
||||
interface AuthTokensResponse {
|
||||
apiKey: { value: string; isCustom: boolean };
|
||||
managementSecret: { value: string; isCustom: boolean };
|
||||
}
|
||||
|
||||
interface ControlPanelEmbedProps {
|
||||
port?: number;
|
||||
@@ -36,6 +38,17 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
|
||||
// Fetch auth tokens for local mode (gets effective management secret)
|
||||
const { data: authTokens } = useQuery<AuthTokensResponse>({
|
||||
queryKey: ['auth-tokens-raw'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch('/api/settings/auth/tokens/raw');
|
||||
if (!response.ok) throw new Error('Failed to fetch auth tokens');
|
||||
return response.json();
|
||||
},
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
|
||||
// Log config fetch errors (fallback to local mode on error)
|
||||
useEffect(() => {
|
||||
if (configError) {
|
||||
@@ -67,15 +80,16 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
};
|
||||
}
|
||||
|
||||
// Local mode
|
||||
// Local mode - use effective management secret from auth tokens API
|
||||
const effectiveSecret = authTokens?.managementSecret?.value || 'ccs';
|
||||
return {
|
||||
managementUrl: `http://localhost:${port}/management.html`,
|
||||
checkUrl: `http://localhost:${port}/`,
|
||||
authToken: CCS_CONTROL_PANEL_SECRET,
|
||||
authToken: effectiveSecret,
|
||||
isRemote: false,
|
||||
displayHost: `localhost:${port}`,
|
||||
};
|
||||
}, [cliproxyConfig, port]);
|
||||
}, [cliproxyConfig, authTokens, port]);
|
||||
|
||||
// Check if CLIProxy is running
|
||||
useEffect(() => {
|
||||
@@ -219,6 +233,13 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
: authToken || 'ccs'}
|
||||
</code>
|
||||
</span>
|
||||
<a
|
||||
href="/settings?tab=auth"
|
||||
className="text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
|
||||
title="Manage auth tokens"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<button
|
||||
className="text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
|
||||
onClick={() => setShowLoginHint(false)}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Globe, Settings2, Server } from 'lucide-react';
|
||||
import { Globe, Settings2, Server, KeyRound } from 'lucide-react';
|
||||
import type { SettingsTab } from '../types';
|
||||
|
||||
interface TabNavigationProps {
|
||||
@@ -28,6 +28,10 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
|
||||
<Server className="w-4 h-4" />
|
||||
Proxy
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="auth" className="flex-1 gap-2">
|
||||
<KeyRound className="w-4 h-4" />
|
||||
Auth
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,13 @@ export function useSettingsTab() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tabParam = searchParams.get('tab');
|
||||
const activeTab: SettingsTab =
|
||||
tabParam === 'globalenv' ? 'globalenv' : tabParam === 'proxy' ? 'proxy' : 'websearch';
|
||||
tabParam === 'globalenv'
|
||||
? 'globalenv'
|
||||
: tabParam === 'proxy'
|
||||
? 'proxy'
|
||||
: tabParam === 'auth'
|
||||
? 'auth'
|
||||
: 'websearch';
|
||||
|
||||
const setActiveTab = useCallback(
|
||||
(tab: SettingsTab) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { SettingsTab } from './types';
|
||||
const WebSearchSection = lazy(() => import('./sections/websearch'));
|
||||
const GlobalEnvSection = lazy(() => import('./sections/globalenv-section'));
|
||||
const ProxySection = lazy(() => import('./sections/proxy'));
|
||||
const AuthSection = lazy(() => import('./sections/auth-section'));
|
||||
|
||||
// Inner component that uses context
|
||||
function SettingsPageInner() {
|
||||
@@ -57,6 +58,7 @@ function SettingsPageInner() {
|
||||
{activeTab === 'websearch' && <WebSearchSection />}
|
||||
{activeTab === 'globalenv' && <GlobalEnvSection />}
|
||||
{activeTab === 'proxy' && <ProxySection />}
|
||||
{activeTab === 'auth' && <AuthSection />}
|
||||
</Suspense>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* Auth Section
|
||||
* Settings section for CLIProxy auth tokens (API key and management secret)
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
RotateCcw,
|
||||
Sparkles,
|
||||
Copy,
|
||||
Check,
|
||||
KeyRound,
|
||||
ShieldCheck,
|
||||
Save,
|
||||
} from 'lucide-react';
|
||||
import { useRawConfig } from '../hooks';
|
||||
|
||||
interface TokenInfo {
|
||||
value: string;
|
||||
isCustom: boolean;
|
||||
}
|
||||
|
||||
interface AuthTokens {
|
||||
apiKey: TokenInfo;
|
||||
managementSecret: TokenInfo;
|
||||
}
|
||||
|
||||
export default function AuthSection() {
|
||||
const { fetchRawConfig } = useRawConfig();
|
||||
|
||||
// State
|
||||
const [tokens, setTokens] = useState<AuthTokens | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
// Edit state
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
const [editedApiKey, setEditedApiKey] = useState<string | null>(null);
|
||||
const [editedSecret, setEditedSecret] = useState<string | null>(null);
|
||||
const [copiedApiKey, setCopiedApiKey] = useState(false);
|
||||
const [copiedSecret, setCopiedSecret] = useState(false);
|
||||
|
||||
// Fetch tokens
|
||||
const fetchTokens = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// Use /raw to get unmasked values for editing
|
||||
const response = await fetch('/api/settings/auth/tokens/raw');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch auth tokens');
|
||||
}
|
||||
const data = await response.json();
|
||||
setTokens(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load on mount
|
||||
useEffect(() => {
|
||||
fetchTokens();
|
||||
fetchRawConfig();
|
||||
}, [fetchTokens, fetchRawConfig]);
|
||||
|
||||
// Clear success after timeout
|
||||
useEffect(() => {
|
||||
if (success) {
|
||||
const timer = setTimeout(() => setSuccess(null), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [success]);
|
||||
|
||||
// Clear error after timeout
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
const timer = setTimeout(() => setError(null), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
// Save all changes
|
||||
const saveChanges = async () => {
|
||||
const hasApiKeyChange = editedApiKey !== null && editedApiKey !== tokens?.apiKey.value;
|
||||
const hasSecretChange =
|
||||
editedSecret !== null && editedSecret !== tokens?.managementSecret.value;
|
||||
|
||||
if (!hasApiKeyChange && !hasSecretChange) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
const payload: { apiKey?: string; managementSecret?: string } = {};
|
||||
if (hasApiKeyChange) payload.apiKey = editedApiKey;
|
||||
if (hasSecretChange) payload.managementSecret = editedSecret;
|
||||
|
||||
const response = await fetch('/api/settings/auth/tokens', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to save tokens');
|
||||
}
|
||||
|
||||
setSuccess('Tokens updated. Restart CLIProxy to apply.');
|
||||
setEditedApiKey(null);
|
||||
setEditedSecret(null);
|
||||
await fetchTokens();
|
||||
await fetchRawConfig();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Regenerate management secret
|
||||
const regenerateSecret = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const response = await fetch('/api/settings/auth/tokens/regenerate-secret', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to regenerate secret');
|
||||
}
|
||||
|
||||
setSuccess('New management secret generated. Restart CLIProxy to apply.');
|
||||
await fetchTokens();
|
||||
await fetchRawConfig();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset to defaults
|
||||
const resetToDefaults = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const response = await fetch('/api/settings/auth/tokens/reset', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to reset tokens');
|
||||
}
|
||||
|
||||
setSuccess('Tokens reset to defaults. Restart CLIProxy to apply.');
|
||||
setEditedApiKey(null);
|
||||
setEditedSecret(null);
|
||||
await fetchTokens();
|
||||
await fetchRawConfig();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Copy to clipboard helpers
|
||||
const copyApiKey = async () => {
|
||||
if (!tokens) return;
|
||||
await navigator.clipboard.writeText(tokens.apiKey.value);
|
||||
setCopiedApiKey(true);
|
||||
setTimeout(() => setCopiedApiKey(false), 2000);
|
||||
};
|
||||
|
||||
const copySecret = async () => {
|
||||
if (!tokens) return;
|
||||
await navigator.clipboard.writeText(tokens.managementSecret.value);
|
||||
setCopiedSecret(true);
|
||||
setTimeout(() => setCopiedSecret(false), 2000);
|
||||
};
|
||||
|
||||
if (loading || !tokens) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Display values
|
||||
const displayApiKey = editedApiKey ?? tokens.apiKey.value;
|
||||
const displaySecret = editedSecret ?? tokens.managementSecret.value;
|
||||
|
||||
// Check for unsaved changes
|
||||
const hasChanges =
|
||||
(editedApiKey !== null && editedApiKey !== tokens.apiKey.value) ||
|
||||
(editedSecret !== null && editedSecret !== tokens.managementSecret.value);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Toast-style alerts */}
|
||||
<div
|
||||
className={`absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out ${
|
||||
error || success
|
||||
? 'opacity-100 translate-y-0'
|
||||
: 'opacity-0 -translate-y-2 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="py-2 shadow-lg">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||
<span className="text-sm font-medium">{success}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-5 space-y-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure CLIProxy authentication tokens. Changes require CLIProxy restart.
|
||||
</p>
|
||||
|
||||
{/* API Key Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="w-4 h-4 text-primary" />
|
||||
<h3 className="text-base font-medium">API Key</h3>
|
||||
{tokens.apiKey.isCustom && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300">
|
||||
Custom
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used by Claude Code to authenticate with CLIProxy
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={displayApiKey}
|
||||
onChange={(e) => setEditedApiKey(e.target.value)}
|
||||
placeholder="API key"
|
||||
disabled={saving}
|
||||
className="pr-20 font-mono text-sm"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
>
|
||||
{showApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={copyApiKey}
|
||||
disabled={!tokens.apiKey.value}
|
||||
>
|
||||
{copiedApiKey ? (
|
||||
<Check className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Management Secret Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4 text-primary" />
|
||||
<h3 className="text-base font-medium">Management Secret</h3>
|
||||
{tokens.managementSecret.isCustom && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300">
|
||||
Custom
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used by CCS dashboard to access CLIProxy management APIs
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
value={displaySecret}
|
||||
onChange={(e) => setEditedSecret(e.target.value)}
|
||||
placeholder="Management secret"
|
||||
disabled={saving}
|
||||
className="pr-20 font-mono text-sm"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => setShowSecret(!showSecret)}
|
||||
>
|
||||
{showSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={copySecret}
|
||||
disabled={!tokens.managementSecret.value}
|
||||
>
|
||||
{copiedSecret ? (
|
||||
<Check className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={regenerateSecret}
|
||||
disabled={saving}
|
||||
title="Generate new secure secret"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={resetToDefaults}
|
||||
disabled={saving || (!tokens.apiKey.isCustom && !tokens.managementSecret.isCustom)}
|
||||
className="gap-2"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Resets both API key and management secret to their default values.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t bg-background flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
fetchTokens();
|
||||
fetchRawConfig();
|
||||
}}
|
||||
disabled={loading || saving}
|
||||
className="flex-1"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={saveChanges}
|
||||
disabled={!hasChanges || saving}
|
||||
className="flex-1"
|
||||
>
|
||||
<Save className={`w-4 h-4 mr-2 ${saving ? 'animate-pulse' : ''}`} />
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export interface GlobalEnvConfig {
|
||||
|
||||
// === Tab Types ===
|
||||
|
||||
export type SettingsTab = 'websearch' | 'globalenv' | 'proxy';
|
||||
export type SettingsTab = 'websearch' | 'globalenv' | 'proxy' | 'auth';
|
||||
|
||||
// === Re-exports from api-client ===
|
||||
|
||||
|
||||
Reference in new issue
Block a user