Commit b5766115 authored by Your Name's avatar Your Name

Add proxy support for OAuth2 Chrome extension

parent fbb49301
......@@ -140,6 +140,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- OAuth2 endpoints: `/dashboard/claude/auth/start`, `/dashboard/claude/auth/complete`, `/dashboard/claude/auth/status`
- Extension endpoints: `/dashboard/extension/download`, `/dashboard/oauth2/callback`
- Comprehensive documentation in CLAUDE_OAUTH2_SETUP.md and CLAUDE_OAUTH2_DEEP_DIVE.md
- Proxy-aware OAuth2 extension serving: detects X-Forwarded-For, X-Forwarded-Host, X-Real-IP headers
- Force interception mechanism: extension activates for localhost when OAuth flow initiated from dashboard
- Supports reverse proxy deployments (nginx, caddy, etc.) with automatic proxy detection
- **Kiro Provider Integration**: Native support for Kiro (Amazon Q Developer / AWS CodeWhisperer)
- KiroAuth class (`aisbf/kiro_auth.py`) for AWS credential management
- Support for multiple authentication methods:
......
......@@ -676,6 +676,9 @@ Model metadata is automatically extracted from provider responses and stored in
- Full OAuth2 PKCE authentication flow
- Automatic token refresh with refresh token rotation
- Chrome extension for remote server OAuth2 callback interception
- Proxy-aware extension serving: automatically detects reverse proxy deployments via X-Forwarded-* headers
- Force interception mechanism: extension activates for localhost when OAuth flow initiated from dashboard
- Supports nginx, caddy, and other reverse proxies with automatic proxy detection
- Dashboard integration with authentication UI
- Credentials stored in `~/.aisbf/claude_credentials.json`
- Optional curl_cffi TLS fingerprinting for Cloudflare bypass
......@@ -694,6 +697,14 @@ Model metadata is automatically extracted from provider responses and stored in
- Supports IDE credentials and CLI authentication
- Access to Claude models through Kiro
- No separate API key required (uses Kiro credentials)
### Kilocode
- OAuth2 Device Authorization Grant flow
- Supports both API key and OAuth2 authentication
- Seamless integration with Kilocode services
- Dashboard OAuth2 authentication UI
- Credentials stored in ~/.kilo_credentials.json
- Access to Kilocode AI models and services
- Supports streaming, tool calling, and extended thinking
## Rotation Models
......
......@@ -137,6 +137,34 @@ pip install aisbf
sudo pip install aisbf
```
## Post-Installation OAuth2 Setup
AISBF supports OAuth2 authentication for several providers:
### Claude (Anthropic)
- Full OAuth2 PKCE flow for Claude Code (claude.ai)
- Chrome extension for remote server deployments
- Proxy-aware: automatically detects reverse proxy deployments
- Dashboard integration for easy authentication
### Kiro (Amazon Q Developer)
- Native OAuth2 support for AWS CodeWhisperer
- Multiple authentication methods (IDE credentials, kiro-cli, direct refresh token)
- Automatic credential management
### Kilocode
- Device Authorization Grant OAuth2 flow
- Seamless integration with Kilocode services
**Setup Instructions:**
1. Start AISBF: `aisbf`
2. Access dashboard: `http://localhost:17765/dashboard`
3. Navigate to Providers section
4. Configure OAuth2 providers and follow authentication prompts
5. For remote deployments: Install Chrome extension from dashboard
For detailed OAuth2 setup, see README.md and DOCUMENTATION.md in the installed package.
## Troubleshooting
### Build Errors
......
......@@ -131,6 +131,7 @@ See [`PYPI.md`](PYPI.md) for detailed instructions on publishing to PyPI.
- Claude Code (OAuth2 authentication via claude.ai)
- Ollama (direct HTTP)
- Kiro (Amazon Q Developer / AWS CodeWhisperer)
- Kilocode (OAuth2 Device Authorization Grant)
## Configuration
### SSL/TLS Configuration
......@@ -287,6 +288,9 @@ AISBF supports Claude Code (claude.ai) as a provider using OAuth2 authentication
- Automatic token refresh with refresh token rotation
- Chrome extension for remote server OAuth2 callback interception
- Dashboard integration with authentication UI
- Proxy-aware extension serving: automatically detects reverse proxy deployments
- Force interception mechanism: extension activates for localhost when OAuth flow initiated from dashboard
- Supports nginx, caddy, and other reverse proxies with X-Forwarded-* header detection
- Credentials stored in `~/.aisbf/claude_credentials.json`
- Optional curl_cffi TLS fingerprinting for Cloudflare bypass
- Compatible with official claude-cli credentials
......
......@@ -46,6 +46,7 @@ import argparse
import secrets
import hashlib
import asyncio
import httpx
from logging.handlers import RotatingFileHandler
from datetime import datetime, timedelta
from collections import defaultdict
......@@ -918,10 +919,62 @@ async def startup_event():
logger.warning("TOR hidden service initialization failed")
# Pre-fetch models at startup for providers without local model config
# For Kilo providers, check API key, OAuth2 file credentials, and database-stored credentials
logger.info("Pre-fetching models from providers with dynamic model lists...")
prefetch_count = 0
for provider_id, provider_config in config.providers.items():
if not (hasattr(provider_config, 'models') and provider_config.models):
# For Kilo providers, check if any authentication method is available
provider_type = getattr(provider_config, 'type', '')
if provider_type in ('kilo', 'kilocode'):
has_valid_auth = False
# Check 1: API key
api_key = getattr(provider_config, 'api_key', None)
if api_key and not api_key.startswith('YOUR_'):
has_valid_auth = True
logger.info(f"Kilo provider '{provider_id}' has API key configured, fetching models...")
# Check 2: OAuth2 credentials file
if not has_valid_auth:
try:
from aisbf.kilo_oauth2 import KiloOAuth2
kilo_config = getattr(provider_config, 'kilo_config', None)
credentials_file = None
api_base = getattr(provider_config, 'endpoint', 'https://api.kilo.ai')
if kilo_config and isinstance(kilo_config, dict):
credentials_file = kilo_config.get('credentials_file')
oauth2 = KiloOAuth2(credentials_file=credentials_file, api_base=api_base)
if oauth2.is_authenticated():
has_valid_auth = True
logger.info(f"Kilo provider '{provider_id}' has valid OAuth2 credentials file, fetching models...")
except Exception as e:
logger.debug(f"Kilo provider '{provider_id}' OAuth2 file check failed: {e}")
# Check 3: Database-stored credentials (uploaded via dashboard)
if not has_valid_auth:
try:
from aisbf.database import get_database
db = get_database()
if db:
# Check for uploaded credentials files for this provider
auth_files = db.get_user_auth_files(0, provider_id) # 0 for admin/global
if auth_files:
for auth_file in auth_files:
file_type = auth_file.get('file_type', '')
file_path = auth_file.get('file_path', '')
if file_type in ('credentials', 'kilo_credentials', 'config') and file_path:
if os.path.exists(file_path):
has_valid_auth = True
logger.info(f"Kilo provider '{provider_id}' has uploaded credentials in database, fetching models...")
break
except Exception as e:
logger.debug(f"Kilo provider '{provider_id}' database credentials check failed: {e}")
if not has_valid_auth:
logger.info(f"Skipping model prefetch for Kilo provider '{provider_id}' (no API key, OAuth2 file, or uploaded credentials)")
continue
try:
models = await fetch_provider_models(provider_id)
if models:
......@@ -1404,6 +1457,137 @@ async def dashboard_providers(request: Request):
"success": "Configuration saved successfully! Restart server for changes to take effect." if success else None
})
async def _auto_detect_provider_models(provider_key: str, provider: dict) -> list:
"""
Auto-detect models from a provider's API endpoint.
Tries to fetch models from the provider's /v1/models or /models endpoint.
For Kilo providers, uses OAuth2 authentication if available.
Args:
provider_key: Provider identifier (e.g., 'kilo', 'my-openai-provider')
provider: Provider configuration dict
Returns:
List of model dicts, or empty list if detection fails
"""
import logging
logger = logging.getLogger(__name__)
try:
endpoint = provider.get('endpoint', '')
if not endpoint:
logger.debug(f"No endpoint for provider '{provider_key}', skipping auto-detection")
return []
provider_type = provider.get('type', 'openai')
api_key = provider.get('api_key', '')
# Skip if API key is a placeholder
if api_key and api_key.startswith('YOUR_'):
api_key = ''
# Check if this is a Kilo provider (by type or by endpoint URL)
is_kilo_provider = provider_type in ('kilo', 'kilocode')
if not is_kilo_provider:
# Also check endpoint URL for Kilo domains
kilo_domains = ['kilocode.ai', 'api.kilo.ai', 'kilo.ai']
for domain in kilo_domains:
if domain in endpoint:
is_kilo_provider = True
break
# For Kilo providers, try to get OAuth2 token
if is_kilo_provider:
from aisbf.kilo_oauth2 import KiloOAuth2
kilo_config = provider.get('kilo_config', {})
credentials_file = kilo_config.get('credentials_file', '~/.kilo_credentials.json')
oauth2 = KiloOAuth2(credentials_file=credentials_file, api_base=endpoint)
token = oauth2.get_valid_token()
if token:
api_key = token
logger.info(f"Using OAuth2 token for Kilo provider '{provider_key}'")
else:
logger.warning(f"No OAuth2 token available for Kilo provider '{provider_key}', please authenticate first")
return []
# Skip if no authentication available
if not api_key:
logger.debug(f"No API key or token for provider '{provider_key}', skipping auto-detection")
return []
# Build models URL - try multiple paths
models_url = None
response_data = None
for path in ['/v1/models', '/models']:
test_url = endpoint.rstrip('/') + path
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=5.0)) as client:
headers = {'Authorization': f'Bearer {api_key}'}
response = await client.get(test_url, headers=headers)
if response.status_code == 200:
models_url = test_url
response_data = response.json()
break
elif response.status_code == 401:
logger.debug(f"Authentication failed for {test_url}")
else:
logger.debug(f"Got status {response.status_code} from {test_url}")
except Exception as e:
logger.debug(f"Error fetching {test_url}: {e}")
continue
if not models_url or not response_data:
logger.debug(f"Could not reach models endpoint for provider '{provider_key}'")
return []
# Parse response - handle both OpenAI format {data: [...]} and array format
models_list = response_data.get('data', response_data) if isinstance(response_data, dict) else response_data
if not isinstance(models_list, list):
logger.debug(f"Unexpected models response format for provider '{provider_key}'")
return []
# Convert to our model format
detected_models = []
for model_data in models_list:
if isinstance(model_data, str):
# Simple string model ID
detected_models.append({
'name': model_data,
'rate_limit': 0,
'max_request_tokens': 100000,
'context_size': 100000
})
elif isinstance(model_data, dict):
# Dict with id/name
model_id = model_data.get('id', model_data.get('model', ''))
if not model_id:
continue
# Extract context size
context_size = (
model_data.get('context_window') or
model_data.get('context_length') or
model_data.get('max_input_tokens')
)
detected_models.append({
'name': model_id,
'rate_limit': 0,
'max_request_tokens': int(context_size) if context_size else 100000,
'context_size': int(context_size) if context_size else 100000
})
logger.info(f"Auto-detected {len(detected_models)} models for provider '{provider_key}' from {models_url}")
return detected_models
except Exception as e:
logger.warning(f"Failed to auto-detect models for provider '{provider_key}': {e}")
return []
@app.post("/dashboard/providers")
async def dashboard_providers_save(request: Request, config: str = Form(...)):
"""Save providers configuration"""
......@@ -1423,6 +1607,23 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
if 'condense_context' not in model or model.get('condense_context') is None:
model['condense_context'] = 80
# Auto-detect models for providers with no models configured
auto_detected_count = 0
for provider_key, provider in providers_data.items():
# Check if provider has no models or empty models list
has_models = 'models' in provider and isinstance(provider.get('models'), list) and len(provider.get('models', [])) > 0
if not has_models:
# Try to auto-detect models from API
detected_models = await _auto_detect_provider_models(provider_key, provider)
if detected_models:
provider['models'] = detected_models
auto_detected_count += 1
logger.info(f"Auto-detected {len(detected_models)} models for provider '{provider_key}'")
if auto_detected_count > 0:
logger.info(f"Auto-detected models for {auto_detected_count} provider(s)")
# Load existing config to preserve structure
config_path = Path.home() / '.aisbf' / 'providers.json'
if not config_path.exists():
......@@ -1441,11 +1642,15 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
success_msg = "Configuration saved successfully! Restart server for changes to take effect."
if auto_detected_count > 0:
success_msg = f"Configuration saved successfully! Auto-detected models for {auto_detected_count} provider(s). Restart server for changes to take effect."
return templates.TemplateResponse("dashboard/providers.html", {
"request": request,
"session": request.session,
"providers_json": json.dumps(providers_data),
"success": "Configuration saved successfully! Restart server for changes to take effect."
"success": success_msg
})
except json.JSONDecodeError as e:
# Reload current config on error
......@@ -2152,7 +2357,6 @@ async def dashboard_restart(request: Request):
if auth_check:
return auth_check
import os
import signal
logger.info("Server restart requested from dashboard")
......@@ -5689,8 +5893,16 @@ async def dashboard_claude_auth_start(request: Request):
request_host = request.headers.get('host', '').split(':')[0]
is_localhost_request = request_host in ['127.0.0.1', 'localhost', '::1']
# Use local callback if accessing from localhost
use_extension = not (is_local_access or is_localhost_request)
# Check if request is coming through a proxy
has_proxy_headers = (
'X-Forwarded-For' in request.headers or
'X-Forwarded-Host' in request.headers or
'X-Real-IP' in request.headers
)
# Use local callback only if truly accessing from localhost (not behind proxy)
# If behind proxy, always serve extension even if request appears local
use_extension = not (is_local_access or is_localhost_request) or has_proxy_headers
# If using localhost, start the callback server
if not use_extension:
......@@ -5893,5 +6105,272 @@ async def dashboard_claude_auth_status(request: Request):
)
# Kilo OAuth2 authentication endpoints
@app.post("/dashboard/kilo/auth/start")
async def dashboard_kilo_auth_start(request: Request):
"""Start Kilo OAuth2 Device Authorization Grant flow"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
data = await request.json()
provider_key = data.get('provider_key')
credentials_file = data.get('credentials_file', '~/.kilo_credentials.json')
if not provider_key:
return JSONResponse(
status_code=400,
content={"success": False, "error": "Provider key is required"}
)
# Import KiloOAuth2
from aisbf.kilo_oauth2 import KiloOAuth2
# Create auth instance
auth = KiloOAuth2(credentials_file=credentials_file)
# Initiate device authorization (async method)
device_auth = await auth.initiate_device_auth()
if not device_auth:
return JSONResponse(
status_code=500,
content={"success": False, "error": "Failed to initiate device authorization"}
)
# Store device code in session for polling
request.session['kilo_device_code'] = device_auth['code']
request.session['kilo_provider'] = provider_key
request.session['kilo_credentials_file'] = credentials_file
request.session['kilo_expires_at'] = time.time() + device_auth['expiresIn']
return JSONResponse({
"success": True,
"user_code": device_auth['code'],
"verification_uri": device_auth['verificationUrl'],
"expires_in": device_auth['expiresIn'],
"interval": 3,
"message": f"Please visit {device_auth['verificationUrl']} and enter code: {device_auth['code']}"
})
except Exception as e:
logger.error(f"Error starting Kilo auth: {e}")
return JSONResponse(
status_code=500,
content={"success": False, "error": str(e)}
)
@app.post("/dashboard/kilo/auth/poll")
async def dashboard_kilo_auth_poll(request: Request):
"""Poll Kilo OAuth2 device authorization status"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
# Get device code from session
device_code = request.session.get('kilo_device_code')
credentials_file = request.session.get('kilo_credentials_file', '~/.kilo_credentials.json')
expires_at = request.session.get('kilo_expires_at', 0)
if not device_code:
return JSONResponse(
status_code=400,
content={"success": False, "status": "error", "error": "No device authorization in progress"}
)
# Check if expired
if time.time() > expires_at:
# Clear session
request.session.pop('kilo_device_code', None)
request.session.pop('kilo_provider', None)
request.session.pop('kilo_credentials_file', None)
request.session.pop('kilo_expires_at', None)
return JSONResponse({
"success": False,
"status": "expired",
"error": "Device authorization expired"
})
# Import KiloOAuth2
from aisbf.kilo_oauth2 import KiloOAuth2
# Create auth instance
auth = KiloOAuth2(credentials_file=credentials_file)
# Poll device authorization status (async method)
result = await auth.poll_device_auth(device_code)
if result['status'] == 'approved':
# Save credentials
token = result.get('token')
user_email = result.get('userEmail')
if token:
credentials = {
"type": "oauth",
"access": token,
"refresh": token, # Same token for both
"expires": int(time.time()) + (365 * 24 * 60 * 60), # 1 year
"userEmail": user_email
}
auth._save_credentials(credentials)
logger.info(f"KiloOAuth2: Saved credentials for {user_email}")
# Clear session data
request.session.pop('kilo_device_code', None)
request.session.pop('kilo_provider', None)
request.session.pop('kilo_credentials_file', None)
request.session.pop('kilo_expires_at', None)
return JSONResponse({
"success": True,
"status": "approved",
"message": "Authentication completed successfully"
})
elif result['status'] == 'pending':
return JSONResponse({
"success": True,
"status": "pending",
"message": "Waiting for user authorization"
})
elif result['status'] == 'denied':
# Clear session
request.session.pop('kilo_device_code', None)
request.session.pop('kilo_provider', None)
request.session.pop('kilo_credentials_file', None)
request.session.pop('kilo_expires_at', None)
return JSONResponse({
"success": False,
"status": "denied",
"error": "User denied authorization"
})
elif result['status'] == 'expired':
# Clear session
request.session.pop('kilo_device_code', None)
request.session.pop('kilo_provider', None)
request.session.pop('kilo_credentials_file', None)
request.session.pop('kilo_expires_at', None)
return JSONResponse({
"success": False,
"status": "expired",
"error": "Device authorization expired"
})
elif result['status'] == 'slow_down':
return JSONResponse({
"success": True,
"status": "slow_down",
"message": "Polling too frequently, slowing down"
})
else:
return JSONResponse({
"success": False,
"status": "error",
"error": result.get('error', 'Unknown error')
})
except Exception as e:
logger.error(f"Error polling Kilo auth: {e}")
return JSONResponse(
status_code=500,
content={"success": False, "status": "error", "error": str(e)}
)
@app.post("/dashboard/kilo/auth/status")
async def dashboard_kilo_auth_status(request: Request):
"""Check Kilo authentication status"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
data = await request.json()
provider_key = data.get('provider_key')
credentials_file = data.get('credentials_file', '~/.kilo_credentials.json')
if not provider_key:
return JSONResponse(
status_code=400,
content={"authenticated": False, "error": "Provider key is required"}
)
# Import KiloOAuth2
from aisbf.kilo_oauth2 import KiloOAuth2
# Create auth instance
auth = KiloOAuth2(credentials_file=credentials_file)
# Check if authenticated
if auth.is_authenticated():
# Try to get a valid token (will refresh if needed)
token = auth.get_valid_token()
if token:
# Get token expiration info
expires_at = auth.credentials.get('expires', 0)
return JSONResponse({
"authenticated": True,
"expires_in": max(0, expires_at - time.time()),
"email": auth.credentials.get('userEmail')
})
return JSONResponse({
"authenticated": False
})
except Exception as e:
logger.error(f"Error checking Kilo auth status: {e}")
return JSONResponse(
status_code=500,
content={"authenticated": False, "error": str(e)}
)
@app.post("/dashboard/kilo/auth/logout")
async def dashboard_kilo_auth_logout(request: Request):
"""Logout from Kilo OAuth2 (clear stored credentials)"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
data = await request.json()
provider_key = data.get('provider_key')
credentials_file = data.get('credentials_file', '~/.kilo_credentials.json')
if not provider_key:
return JSONResponse(
status_code=400,
content={"success": False, "error": "Provider key is required"}
)
# Import KiloOAuth2
from aisbf.kilo_oauth2 import KiloOAuth2
# Create auth instance
auth = KiloOAuth2(credentials_file=credentials_file)
# Logout (clear credentials)
auth.logout()
return JSONResponse({
"success": True,
"message": "Logged out successfully"
})
except Exception as e:
logger.error(f"Error logging out from Kilo: {e}")
return JSONResponse(
status_code=500,
content={"success": False, "error": str(e)}
)
if __name__ == "__main__":
main()
......@@ -14,7 +14,8 @@ const DEFAULT_CONFIG = {
enabled: true,
remoteServer: '', // Will be set from AISBF dashboard
ports: [54545], // Default OAuth callback ports to intercept
paths: ['/callback', '/oauth/callback', '/auth/callback']
paths: ['/callback', '/oauth/callback', '/auth/callback'],
forceInterception: false // Override for OAuth flows initiated from AISBF
};
// Current configuration
......@@ -76,11 +77,16 @@ function generateRules() {
// If the remote server is on localhost, we don't need to intercept
// The OAuth2 callback can go directly to localhost without redirection
if (isRemoteLocal) {
// EXCEPTION: If we have an ongoing OAuth flow initiated from AISBF (forceInterception flag)
if (isRemoteLocal && !config.forceInterception) {
console.log('[AISBF] Remote server is localhost - no interception needed');
return rules;
}
if (isRemoteLocal && config.forceInterception) {
console.log('[AISBF] Remote server is localhost but force interception is enabled for active OAuth flow');
}
for (const port of config.ports) {
for (const path of config.paths) {
// Rule for 127.0.0.1
......@@ -217,7 +223,8 @@ chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) =>
enabled: true,
remoteServer: message.remoteServer || sender.url.replace(/\/dashboard.*$/, ''),
ports: message.ports || config.ports,
paths: message.paths || config.paths
paths: message.paths || config.paths,
forceInterception: message.forceInterception || false
};
saveConfig(newConfig).then(success => {
sendResponse({ success, config: newConfig });
......
......@@ -48,6 +48,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<option value="ollama">Ollama</option>
<option value="kiro">Kiro (Amazon Q Developer)</option>
<option value="claude">Claude (OAuth2)</option>
<option value="kilocode">Kilocode (OAuth2)</option>
</select>
<small style="color: #a0a0a0; display: block; margin-top: 5px;">Select the type of provider to configure appropriate settings</small>
</div>
......@@ -125,6 +126,7 @@ function renderProviderDetails(key) {
const provider = providersData[key];
const isKiroProvider = provider.type === 'kiro';
const isClaudeProvider = provider.type === 'claude';
const isKiloProvider = provider.type === 'kilocode';
// Initialize kiro_config if this is a kiro provider and doesn't have it
if (isKiroProvider && !provider.kiro_config) {
......@@ -146,8 +148,17 @@ function renderProviderDetails(key) {
};
}
// Initialize kilo_config if this is a kilocode provider and doesn't have it
if (isKiloProvider && !provider.kilo_config) {
provider.kilo_config = {
credentials_file: '~/.kilo_credentials.json',
api_base: 'https://kilocode.ai/api/openrouter'
};
}
const kiroConfig = provider.kiro_config || {};
const claudeConfig = provider.claude_config || {};
const kiloConfig = provider.kilo_config || {};
// Build authentication fields based on provider type
let authFieldsHtml = '';
......@@ -219,6 +230,61 @@ function renderProviderDetails(key) {
<div id="kiro-upload-status-${key}" style="margin-top: 10px;"></div>
</div>
`;
} else if (isKiloProvider) {
// Kilocode authentication fields - supports both API key and OAuth2
authFieldsHtml = `
<div style="background: #0f2840; padding: 15px; border-radius: 5px; margin-bottom: 15px; border-left: 3px solid #4a9eff;">
<h4 style="margin: 0 0 15px 0; color: #4a9eff;">Kilocode Authentication</h4>
<small style="color: #a0a0a0; display: block; margin-bottom: 15px;">
Choose your authentication method: API Key (recommended for simplicity) or OAuth2 Device Authorization Grant.
</small>
<h5 style="margin: 20px 0 10px 0; color: #8ec8ff;">Option 1: API Key / Auth Token</h5>
<div class="form-group">
<label>API Key</label>
<input type="password" value="${provider.api_key || ''}" onchange="updateProvider('${key}', 'api_key', this.value)" placeholder="Enter your Kilocode API key">
<small style="color: #a0a0a0; display: block; margin-top: 5px;">If provided, API key authentication will be used instead of OAuth2</small>
</div>
<h5 style="margin: 20px 0 10px 0; color: #8ec8ff;">Option 2: OAuth2 Authentication</h5>
<small style="color: #a0a0a0; display: block; margin-bottom: 15px;">
If no API key is provided, OAuth2 will be used automatically.
</small>
<div class="form-group">
<label>API Base URL</label>
<input type="text" value="${kiloConfig.api_base || 'https://kilocode.ai/api/openrouter'}" readonly style="background: #0f2840; cursor: not-allowed;" placeholder="https://kilocode.ai/api/openrouter">
<small style="color: #a0a0a0; display: block; margin-top: 5px;">Kilocode API base URL (fixed)</small>
</div>
<div class="form-group">
<label>Credentials File Path</label>
<input type="text" value="${kiloConfig.credentials_file || '~/.kilo_credentials.json'}" onchange="updateKiloConfig('${key}', 'credentials_file', this.value)" placeholder="~/.kilo_credentials.json">
<small style="color: #a0a0a0; display: block; margin-top: 5px;">Path where OAuth2 credentials will be stored</small>
</div>
<div style="margin-top: 15px;">
<button type="button" class="btn" onclick="authenticateKilo('${key}')" style="background: #4a9eff;">
🔐 Authenticate with Kilocode OAuth2
</button>
<button type="button" class="btn btn-secondary" onclick="checkKiloAuth('${key}')" style="margin-left: 10px;">
Check Status
</button>
</div>
<div id="kilo-auth-status-${key}" style="margin-top: 10px; padding: 10px; border-radius: 3px; display: none;">
<!-- Auth status will be displayed here -->
</div>
<h5 style="margin: 20px 0 10px 0; color: #8ec8ff;">Or Upload OAuth2 Credentials File</h5>
<div class="form-group">
<label>Upload Credentials File</label>
<input type="file" id="kilo-creds-file-${key}" accept=".json" onchange="uploadKiloFile('${key}', this.files[0])">
<small style="color: #a0a0a0; display: block; margin-top: 5px;">Upload Kilocode OAuth2 credentials JSON file</small>
</div>
<div id="kilo-upload-status-${key}" style="margin-top: 10px;"></div>
</div>
`;
} else if (isClaudeProvider) {
// Claude OAuth2 authentication fields
authFieldsHtml = `
......@@ -286,8 +352,9 @@ function renderProviderDetails(key) {
<div class="form-group">
<label>Endpoint</label>
<input type="text" value="${provider.endpoint}" onchange="updateProvider('${key}', 'endpoint', this.value)" required>
<input type="text" value="${provider.endpoint}" onchange="updateProvider('${key}', 'endpoint', this.value)" ${provider.type === 'kilocode' ? 'readonly style="background: #0f2840; cursor: not-allowed;"' : ''} required>
${isKiroProvider ? '<small style="color: #a0a0a0; display: block; margin-top: 5px;">Typically: https://q.us-east-1.amazonaws.com</small>' : ''}
${provider.type === 'kilocode' ? '<small style="color: #a0a0a0; display: block; margin-top: 5px;">Fixed endpoint for Kilocode provider</small>' : ''}
</div>
<div class="form-group">
......@@ -299,6 +366,7 @@ function renderProviderDetails(key) {
<option value="ollama" ${provider.type === 'ollama' ? 'selected' : ''}>Ollama</option>
<option value="kiro" ${provider.type === 'kiro' ? 'selected' : ''}>Kiro (Amazon Q Developer)</option>
<option value="claude" ${provider.type === 'claude' ? 'selected' : ''}>Claude (OAuth2)</option>
<option value="kilocode" ${provider.type === 'kilocode' ? 'selected' : ''}>Kilocode (OAuth2)</option>
</select>
</div>
......@@ -522,7 +590,8 @@ function updateNewProviderDefaults() {
'anthropic': 'Anthropic provider (Claude). Uses API key authentication. Endpoint: https://api.anthropic.com/v1',
'ollama': 'Ollama local provider. No API key required by default. Endpoint: http://localhost:11434/api',
'kiro': 'Kiro (Amazon Q Developer) provider. Uses Kiro credentials (IDE, CLI, or direct tokens). Endpoint: https://q.us-east-1.amazonaws.com',
'claude': 'Claude Code provider. Uses OAuth2 authentication (browser-based login). Endpoint: https://api.anthropic.com/v1'
'claude': 'Claude Code provider. Uses OAuth2 authentication (browser-based login). Endpoint: https://api.anthropic.com/v1',
'kilocode': 'Kilocode provider. Uses OAuth2 Device Authorization Grant. Endpoint: https://kilocode.ai/api/openrouter'
};
descriptionEl.textContent = descriptions[providerType] || 'Standard provider configuration.';
......@@ -558,7 +627,7 @@ function confirmAddProvider() {
name: key,
endpoint: '',
type: providerType,
api_key_required: providerType !== 'kiro' && providerType !== 'ollama' && providerType !== 'claude',
api_key_required: providerType !== 'kiro' && providerType !== 'ollama' && providerType !== 'claude' && providerType !== 'kilocode',
rate_limit: 0,
default_rate_limit: 0,
models: []
......@@ -583,6 +652,13 @@ function confirmAddProvider() {
newProvider.claude_config = {
credentials_file: '~/.claude_credentials.json'
};
} else if (providerType === 'kilocode') {
newProvider.endpoint = 'https://kilocode.ai/api/openrouter';
newProvider.name = key + ' (Kilocode OAuth2)';
newProvider.kilo_config = {
credentials_file: '~/.kilo_credentials.json',
api_base: 'https://kilocode.ai/api/openrouter'
};
} else if (providerType === 'openai') {
newProvider.endpoint = 'https://api.openai.com/v1';
newProvider.api_key = '';
......@@ -674,6 +750,7 @@ function updateProviderType(key, newType) {
client_secret: ''
};
delete providersData[key].claude_config;
delete providersData[key].kilo_config;
// Set default endpoint for kiro
if (!providersData[key].endpoint || providersData[key].endpoint === '') {
providersData[key].endpoint = 'https://q.us-east-1.amazonaws.com';
......@@ -685,15 +762,28 @@ function updateProviderType(key, newType) {
credentials_file: '~/.claude_credentials.json'
};
delete providersData[key].kiro_config;
delete providersData[key].kilo_config;
// Set default endpoint for claude
if (!providersData[key].endpoint || providersData[key].endpoint === '') {
providersData[key].endpoint = 'https://api.anthropic.com/v1';
}
} else if (newType !== 'kiro' && newType !== 'claude' && (oldType === 'kiro' || oldType === 'claude')) {
// Transitioning FROM kiro/claude: remove special configs, set api_key_required to true
} else if (newType === 'kilocode' && oldType !== 'kilocode') {
// Transitioning TO kilocode: initialize kilo_config, set api_key_required to false
providersData[key].api_key_required = false;
providersData[key].kilo_config = {
credentials_file: '~/.kilo_credentials.json',
api_base: 'https://kilocode.ai/api/openrouter'
};
delete providersData[key].kiro_config;
delete providersData[key].claude_config;
// Set endpoint for kilocode (fixed, not modifiable)
providersData[key].endpoint = 'https://kilocode.ai/api/openrouter';
} else if (newType !== 'kiro' && newType !== 'claude' && newType !== 'kilocode' && (oldType === 'kiro' || oldType === 'claude' || oldType === 'kilocode')) {
// Transitioning FROM kiro/claude/kilocode: remove special configs, set api_key_required to true
providersData[key].api_key_required = true;
delete providersData[key].kiro_config;
delete providersData[key].claude_config;
delete providersData[key].kilo_config;
}
// Re-render to show appropriate fields
......@@ -714,6 +804,193 @@ function updateClaudeConfig(key, field, value) {
providersData[key].claude_config[field] = value;
}
function updateKiloConfig(key, field, value) {
if (!providersData[key].kilo_config) {
providersData[key].kilo_config = {};
}
providersData[key].kilo_config[field] = value;
}
async function authenticateKilo(key) {
const statusEl = document.getElementById(`kilo-auth-status-${key}`);
statusEl.style.display = 'block';
statusEl.style.background = '#0f2840';
statusEl.style.border = '1px solid #4a9eff';
statusEl.innerHTML = '<p style="margin: 0; color: #4a9eff;">🔄 Starting OAuth2 Device Authorization flow...</p>';
try {
const response = await fetch('/dashboard/kilo/auth/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider_key: key,
credentials_file: providersData[key].kilo_config?.credentials_file || '~/.kilo_credentials.json',
api_base: providersData[key].kilo_config?.api_base || 'https://api.kilo.ai'
})
});
const data = await response.json();
if (!data.success) {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Failed to start authentication: ${data.error || 'Unknown error'}</p>`;
return;
}
// Display the verification URL and code to the user
statusEl.style.background = '#0f2840';
statusEl.style.border = '1px solid #4a9eff';
statusEl.innerHTML = `
<div style="margin: 0;">
<p style="margin: 0 0 10px 0; color: #4a9eff; font-weight: bold;">🔐 Authorization Required</p>
<p style="margin: 0 0 10px 0; color: #e0e0e0;">
Please visit: <a href="${data.verification_uri}" target="_blank" style="color: #4eff9e; text-decoration: underline;">${data.verification_uri}</a>
</p>
<p style="margin: 0 0 10px 0; color: #e0e0e0;">
Enter code: <strong style="color: #4eff9e; font-size: 18px; letter-spacing: 2px;">${data.user_code}</strong>
</p>
<p style="margin: 0; color: #a0a0a0; font-size: 13px;">
Waiting for authorization... (expires in ${Math.floor(data.expires_in / 60)} minutes)
</p>
</div>
`;
// Open the verification URL in a new window
try {
window.open(data.verification_uri, 'kilo-auth', 'width=600,height=700');
} catch (e) {
console.error('Could not open auth window:', e);
}
// Poll for authorization completion
let pollCount = 0;
const maxPolls = Math.floor(data.expires_in / data.interval);
const pollInterval = setInterval(async () => {
pollCount++;
try {
const pollResponse = await fetch('/dashboard/kilo/auth/poll', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
const pollData = await pollResponse.json();
if (pollData.status === 'approved') {
clearInterval(pollInterval);
statusEl.style.background = '#0f4020';
statusEl.style.border = '1px solid #4eff9e';
statusEl.innerHTML = '<p style="margin: 0; color: #4eff9e;">✓ Authentication successful! Credentials saved.</p>';
} else if (pollData.status === 'denied') {
clearInterval(pollInterval);
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = '<p style="margin: 0; color: #ff4a4a;">✗ Authorization denied by user.</p>';
} else if (pollData.status === 'expired') {
clearInterval(pollInterval);
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = '<p style="margin: 0; color: #ff4a4a;">✗ Authorization code expired. Please try again.</p>';
} else if (pollData.status === 'slow_down') {
// Server asked us to slow down, but we'll continue at the same rate
console.log('Server requested slow down');
}
// status === 'pending', continue polling
} catch (error) {
console.error('Error polling Kilo auth:', error);
}
// Timeout after max polls
if (pollCount >= maxPolls) {
clearInterval(pollInterval);
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = '<p style="margin: 0; color: #ff4a4a;">✗ Authentication timeout. Please try again.</p>';
}
}, data.interval * 1000); // Convert seconds to milliseconds
} catch (error) {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Error: ${error.message}</p>`;
}
}
async function checkKiloAuth(key) {
const statusEl = document.getElementById(`kilo-auth-status-${key}`);
statusEl.style.display = 'block';
statusEl.style.background = '#0f2840';
statusEl.style.border = '1px solid #4a9eff';
statusEl.innerHTML = '<p style="margin: 0; color: #4a9eff;">🔄 Checking authentication status...</p>';
try {
const response = await fetch('/dashboard/kilo/auth/status', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider_key: key,
credentials_file: providersData[key].kilo_config?.credentials_file || '~/.kilo_credentials.json'
})
});
const data = await response.json();
if (data.authenticated) {
statusEl.style.background = '#0f4020';
statusEl.style.border = '1px solid #4eff9e';
const expiresIn = data.expires_in ? ` (expires in ${Math.floor(data.expires_in / (24 * 60 * 60))} days)` : '';
statusEl.innerHTML = `<p style="margin: 0; color: #4eff9e;">✓ Authenticated${expiresIn}</p>`;
} else {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = '<p style="margin: 0; color: #ff4a4a;">✗ Not authenticated. Click "Authenticate with Kilo" to log in.</p>';
}
} catch (error) {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Error: ${error.message}</p>`;
}
}
async function uploadKiloFile(providerKey, file) {
if (!file) return;
const statusEl = document.getElementById(`kilo-upload-status-${providerKey}`);
statusEl.innerHTML = '<p style="margin: 0; color: #4a9eff;">🔄 Uploading file...</p>';
const formData = new FormData();
formData.append('file', file);
formData.append('provider_key', providerKey);
formData.append('file_type', 'credentials');
try {
const response = await fetch('/dashboard/providers/upload-auth-file', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
statusEl.innerHTML = `<p style="margin: 0; color: #4eff9e;">✓ File uploaded successfully! Path: ${data.file_path}</p>`;
// Update the config with the new file path
updateKiloConfig(providerKey, 'credentials_file', data.file_path);
} else {
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Upload failed: ${data.error}</p>`;
}
} catch (error) {
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Error: ${error.message}</p>`;
}
}
// Extension detection and configuration
let extensionInstalled = false;
let extensionCheckComplete = false;
......@@ -1015,6 +1292,9 @@ function showExtensionInstructions() {
<p style="margin: 0; color: #4eff9e; font-weight: bold;">ℹ️ About This Extension</p>
<p style="margin: 5px 0 0 0; color: #a0a0a0; font-size: 14px;">
This extension intercepts OAuth2 callbacks from localhost:54545 and redirects them to your AISBF server, enabling remote authentication.
<p style="margin: 5px 0 0 0; color: #a0a0a0; font-size: 14px;">
<strong>Proxy Support:</strong> The extension automatically detects reverse proxy deployments (nginx, caddy, etc.) via X-Forwarded-* headers and activates when OAuth flows are initiated from the dashboard.
</p>
</p>
</div>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment