UNPKG

lanonasis-memory

Version:

Memory as a Service integration - AI-powered memory management with semantic search (Compatible with CLI v3.0.6+)

511 lines (444 loc) 18.9 kB
import * as vscode from 'vscode'; import * as http from 'http'; import * as crypto from 'crypto'; import { URL, URLSearchParams } from 'url'; /** * Secure API Key Service * Manages API keys using VS Code SecretStorage API * Supports both OAuth and direct API key authentication */ export type CredentialType = 'oauth' | 'apiKey'; export interface StoredCredential { type: CredentialType; token: string; } export class SecureApiKeyService { private static readonly API_KEY_KEY = 'lanonasis.apiKey'; private static readonly AUTH_TOKEN_KEY = 'lanonasis.authToken'; private static readonly REFRESH_TOKEN_KEY = 'lanonasis.refreshToken'; private static readonly CREDENTIAL_TYPE_KEY = 'lanonasis.credentialType'; private static readonly CALLBACK_PORT = 8080; private context: vscode.ExtensionContext; private outputChannel: vscode.OutputChannel; private migrationCompleted: boolean = false; constructor(context: vscode.ExtensionContext, outputChannel: vscode.OutputChannel) { this.context = context; this.outputChannel = outputChannel; } /** * Initialize and migrate from legacy configuration if needed */ async initialize(): Promise<void> { await this.migrateFromConfigIfNeeded(); } /** * Get API key from secure storage, or prompt if not available */ async getApiKeyOrPrompt(): Promise<string | null> { // Try to get from secure storage first const apiKey = await this.getApiKey(); if (apiKey) { return apiKey; } // Check OAuth token const credential = await this.getStoredCredentials(); if (credential?.type === 'oauth') { return credential.token; } // Prompt user if not available return await this.promptForAuthentication(); } /** * Get API key from secure storage */ async getApiKey(): Promise<string | null> { try { const apiKey = await this.context.secrets.get(SecureApiKeyService.API_KEY_KEY); return apiKey || null; } catch (error) { this.logError('Failed to retrieve API key from secure storage', error); return null; } } /** * Check if API key is configured */ async hasApiKey(): Promise<boolean> { const apiKey = await this.getApiKey(); if (apiKey) return true; // Also check for OAuth token const authHeader = await this.getAuthenticationHeader(); return authHeader !== null; } /** * Prompt user for authentication (OAuth or API key) */ async promptForAuthentication(): Promise<string | null> { const choice = await vscode.window.showQuickPick( [ { label: '$(key) OAuth (Browser)', description: 'Authenticate using OAuth2 with browser (Recommended)', value: 'oauth' }, { label: '$(key) API Key', description: 'Enter API key directly', value: 'apikey' }, { label: '$(circle-slash) Cancel', description: 'Cancel authentication', value: 'cancel' } ], { placeHolder: 'Choose authentication method' } ); if (!choice || choice.value === 'cancel') { return null; } if (choice.value === 'oauth') { return await this.authenticateWithOAuthFlow(); } else if (choice.value === 'apikey') { return await this.promptForApiKeyEntry(); } return null; } /** * Run the OAuth authentication flow and return the stored API key/token */ async authenticateWithOAuthFlow(): Promise<string | null> { const success = await this.authenticateOAuth(); if (!success) { return null; } const apiKey = await this.getApiKey(); if (apiKey) { return apiKey; } const authHeader = await this.getAuthenticationHeader(); if (authHeader?.startsWith('Bearer ')) { return authHeader.replace('Bearer ', ''); } return null; } /** * Prompt for raw API key entry and persist it securely */ async promptForApiKeyEntry(): Promise<string | null> { const apiKey = await vscode.window.showInputBox({ prompt: 'Enter your Lanonasis API Key', placeHolder: 'Get your API key from api.lanonasis.com', password: true, ignoreFocusOut: true, validateInput: (value) => { if (!value || value.trim().length === 0) { return 'API key is required'; } if (value.length < 20) { return 'API key seems too short'; } return null; } }); if (apiKey) { await this.storeApiKey(apiKey, 'apiKey'); await this.context.secrets.delete(SecureApiKeyService.AUTH_TOKEN_KEY); await this.context.secrets.delete(SecureApiKeyService.REFRESH_TOKEN_KEY); this.log('API key stored securely'); return apiKey; } return null; } /** * Authenticate with OAuth flow using PKCE */ async authenticateOAuth(): Promise<boolean> { return new Promise((resolve, reject) => { // Store timeout reference to clear it on success/error let timeoutId: NodeJS.Timeout | undefined; try { const config = vscode.workspace.getConfiguration('lanonasis'); const authUrl = config.get<string>('authUrl') || 'https://auth.lanonasis.com'; const clientId = 'vscode-extension'; const redirectUri = `http://localhost:${SecureApiKeyService.CALLBACK_PORT}/callback`; // Generate PKCE parameters const codeVerifier = this.generateCodeVerifier(); const codeChallenge = this.generateCodeChallenge(codeVerifier); const state = this.generateState(); // Store PKCE data temporarily this.context.secrets.store('oauth_code_verifier', codeVerifier); this.context.secrets.store('oauth_state', state); // Build authorization URL const authUrlObj = new URL('/oauth/authorize', authUrl); authUrlObj.searchParams.set('client_id', clientId); authUrlObj.searchParams.set('response_type', 'code'); authUrlObj.searchParams.set('redirect_uri', redirectUri); authUrlObj.searchParams.set('scope', 'memories:read memories:write memories:delete'); authUrlObj.searchParams.set('code_challenge', codeChallenge); authUrlObj.searchParams.set('code_challenge_method', 'S256'); authUrlObj.searchParams.set('state', state); // Start callback server const server = http.createServer(async (req: http.IncomingMessage, res: http.ServerResponse) => { try { if (!req.url) { res.writeHead(400, { 'Content-Type': 'text/plain' }); res.end('Missing URL'); return; } const url = new URL(req.url, `http://localhost:${SecureApiKeyService.CALLBACK_PORT}`); if (url.pathname === '/callback') { const code = url.searchParams.get('code'); const returnedState = url.searchParams.get('state'); const error = url.searchParams.get('error'); // Validate state const storedState = await this.context.secrets.get('oauth_state'); if (returnedState !== storedState) { res.writeHead(400, { 'Content-Type': 'text/html' }); res.end('<h1>Invalid state parameter</h1>'); server.close(); if (timeoutId) clearTimeout(timeoutId); reject(new Error('Invalid state parameter')); return; } if (error) { res.writeHead(400, { 'Content-Type': 'text/html' }); res.end(`<h1>OAuth Error: ${error}</h1>`); server.close(); if (timeoutId) clearTimeout(timeoutId); reject(new Error(`OAuth error: ${error}`)); return; } if (code) { // Exchange code for token const token = await this.exchangeCodeForToken(code, codeVerifier, redirectUri, authUrl); // Store token securely await this.storeApiKey(token.access_token, 'oauth'); if (token.refresh_token) { await this.context.secrets.store(SecureApiKeyService.REFRESH_TOKEN_KEY, token.refresh_token); } // Send success response res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(` <html> <head><title>Authentication Success</title></head> <body> <h1 style="color: green;">✓ Authentication Successful!</h1> <p>You can close this window and return to VS Code.</p> <script>setTimeout(() => window.close(), 2000);</script> </body> </html> `); // Cleanup await this.context.secrets.delete('oauth_code_verifier'); await this.context.secrets.delete('oauth_state'); server.close(); if (timeoutId) clearTimeout(timeoutId); resolve(true); } } else { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not found'); } } catch (err) { res.writeHead(500, { 'Content-Type': 'text/html' }); res.end(`<h1>Error: ${err instanceof Error ? err.message : 'Unknown error'}</h1>`); server.close(); if (timeoutId) clearTimeout(timeoutId); reject(err); } }); server.listen(SecureApiKeyService.CALLBACK_PORT, 'localhost', () => { // Open browser vscode.env.openExternal(vscode.Uri.parse(authUrlObj.toString())); }); // Timeout after 5 minutes timeoutId = setTimeout(() => { server.close(); reject(new Error('OAuth authentication timeout')); }, 5 * 60 * 1000); } catch (error) { if (timeoutId) clearTimeout(timeoutId); reject(error); } }); } /** * Get authentication header (OAuth token or API key) */ async getAuthenticationHeader(): Promise<string | null> { const credential = await this.getStoredCredentials(); if (credential?.type === 'oauth') { return `Bearer ${credential.token}`; } return null; } /** * Get the active credentials (OAuth token vs API key) for downstream services */ async getStoredCredentials(): Promise<StoredCredential | null> { // Prefer OAuth tokens when available const authToken = await this.context.secrets.get(SecureApiKeyService.AUTH_TOKEN_KEY); if (authToken) { try { const token = JSON.parse(authToken); if (token?.access_token && this.isTokenValid(token)) { return { type: 'oauth', token: token.access_token }; } } catch (error) { this.logError('Failed to parse stored OAuth token', error); } } const apiKey = await this.getApiKey(); if (apiKey) { const storedType = await this.context.secrets.get(SecureApiKeyService.CREDENTIAL_TYPE_KEY) as CredentialType | null; const inferredType: CredentialType = storedType === 'oauth' || storedType === 'apiKey' ? storedType : (this.looksLikeJwt(apiKey) ? 'oauth' : 'apiKey'); return { type: inferredType, token: apiKey }; } return null; } /** * Delete API key from secure storage */ async deleteApiKey(): Promise<void> { await this.context.secrets.delete(SecureApiKeyService.API_KEY_KEY); await this.context.secrets.delete(SecureApiKeyService.AUTH_TOKEN_KEY); await this.context.secrets.delete(SecureApiKeyService.REFRESH_TOKEN_KEY); await this.context.secrets.delete(SecureApiKeyService.CREDENTIAL_TYPE_KEY); this.log('API key removed from secure storage'); } /** * Store API key securely */ private async storeApiKey(apiKey: string, type: CredentialType): Promise<void> { await this.context.secrets.store(SecureApiKeyService.API_KEY_KEY, apiKey); await this.context.secrets.store(SecureApiKeyService.CREDENTIAL_TYPE_KEY, type); } /** * Migrate API key from configuration to secure storage */ private async migrateFromConfigIfNeeded(): Promise<void> { if (this.migrationCompleted) { return; } // Check if already in secure storage const hasSecureKey = await this.hasApiKey(); if (hasSecureKey) { this.migrationCompleted = true; return; } // Check configuration for legacy API key const config = vscode.workspace.getConfiguration('lanonasis'); const legacyKey = config.get<string>('apiKey'); if (legacyKey) { // Migrate to secure storage await this.storeApiKey(legacyKey, 'apiKey'); this.log('Migrated API key from configuration to secure storage'); // Optionally clear from config (but keep it for now for backward compatibility) // await config.update('apiKey', undefined, vscode.ConfigurationTarget.Global); // Notify user vscode.window.showInformationMessage( 'API key migrated to secure storage. Your credentials are now stored securely.', 'OK' ); } this.migrationCompleted = true; } /** * Exchange OAuth authorization code for token */ private async exchangeCodeForToken( code: string, codeVerifier: string, redirectUri: string, authUrl: string ): Promise<{ access_token: string; refresh_token?: string }> { const tokenUrl = new URL('/oauth/token', authUrl); const body = new URLSearchParams({ grant_type: 'authorization_code', client_id: 'vscode-extension', code, redirect_uri: redirectUri, code_verifier: codeVerifier }); const response = await fetch(tokenUrl.toString(), { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }, body: body.toString() }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Token exchange failed: ${response.status} ${errorText}`); } const tokenData = await response.json() as { access_token: string; refresh_token?: string; expires_in?: number; }; // Store token with expiration const token = { access_token: tokenData.access_token, expires_at: Date.now() + (tokenData.expires_in ? tokenData.expires_in * 1000 : 3600000) }; await this.context.secrets.store(SecureApiKeyService.AUTH_TOKEN_KEY, JSON.stringify(token)); return { access_token: tokenData.access_token, refresh_token: tokenData.refresh_token }; } /** * Check if OAuth token is valid */ private isTokenValid(token: { expires_at?: number }): boolean { if (!token.expires_at) return true; return Date.now() < token.expires_at - 60000; // 1 minute buffer } private looksLikeJwt(value: string): boolean { const parts = value.split('.'); if (parts.length !== 3) { return false; } const jwtSegment = /^[A-Za-z0-9-_]+$/; return parts.every(segment => jwtSegment.test(segment)); } /** * Generate PKCE code verifier */ private generateCodeVerifier(): string { return crypto.randomBytes(32).toString('base64url'); } /** * Generate PKCE code challenge */ private generateCodeChallenge(verifier: string): string { return crypto.createHash('sha256').update(verifier).digest('base64url'); } /** * Generate OAuth state parameter */ private generateState(): string { return crypto.randomBytes(16).toString('hex'); } /** * Log message to output channel */ private log(message: string): void { const timestamp = new Date().toISOString(); this.outputChannel.appendLine(`[${timestamp}] [SecureApiKeyService] ${message}`); } /** * Log error to output channel */ private logError(message: string, error: unknown): void { const errorMessage = error instanceof Error ? error.message : String(error); this.log(`${message}: ${errorMessage}`); console.error(message, error); } }