UNPKG

@tehreet/conduit

Version:

LLM API gateway with intelligent routing, robust process management, and health monitoring

512 lines (403 loc) 14.2 kB
# Secure Authentication for Electron SaaS Apps ## Synapse Implementation Guide ## Overview This guide explains how to implement secure authentication in your Synapse Electron app, removing the need to embed API credentials in your distributed application. ## The Problem Currently, your Synapse app embeds these credentials in the built application: - `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY` - Claude API key - Digital Ocean Spaces credentials - Sentry DSN **Security Risk**: Anyone who downloads your app can extract these credentials from the bundled JavaScript files. ## Recommended Architecture Modern Electron SaaS applications use a **proxy/backend service** pattern with user-specific authentication: ``` ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │ Electron App│────►│ Your Backend │────►│ Services │ │ (Client) │◄────│ (Proxy API) │◄────│ (Supabase, │ └─────────────┘ └──────────────┘ │ Claude, │ ▲ │ DO Spaces) │ │ └─────────────┘ │ ┌──────┴───────┐ │ User Auth │ │ (JWT/Session)│ └──────────────┘ ``` ## Implementation Options ### Option A: Supabase Edge Functions (Recommended) Since you're already using Supabase, Edge Functions provide the perfect proxy layer. #### 1. Create the Edge Function ```typescript // supabase/functions/api-proxy/index.ts import { serve } from "https://deno.land/std@0.168.0/http/server.ts" import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' const CLAUDE_API_KEY = Deno.env.get('CLAUDE_API_KEY') const DO_SPACES_ACCESS_KEY = Deno.env.get('DO_SPACES_ACCESS_KEY') const DO_SPACES_SECRET_KEY = Deno.env.get('DO_SPACES_SECRET_KEY') serve(async (req) => { // Verify JWT from Electron app const authHeader = req.headers.get('Authorization') if (!authHeader) { return new Response('Unauthorized', { status: 401 }) } // Create Supabase client with user's JWT const supabase = createClient( Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_ANON_KEY') ?? '', { global: { headers: { Authorization: authHeader } } } ) // Verify user is authenticated const { data: { user }, error } = await supabase.auth.getUser() if (error || !user) { return new Response('Unauthorized', { status: 401 }) } // Route requests based on endpoint const url = new URL(req.url) const path = url.pathname if (path.startsWith('/claude')) { return proxyClaudeRequest(req, user) } else if (path.startsWith('/storage')) { return proxyStorageRequest(req, user) } return new Response('Not found', { status: 404 }) }) async function proxyClaudeRequest(req: Request, user: any) { const body = await req.json() // Add user context to track usage const enhancedBody = { ...body, metadata: { user_id: user.id, timestamp: new Date().toISOString() } } // Forward to Claude API const response = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'x-api-key': CLAUDE_API_KEY!, 'anthropic-version': '2023-06-01', 'content-type': 'application/json', }, body: JSON.stringify(body) }) // Log usage for billing await logUsage(user.id, 'claude', response.headers) return response } async function proxyStorageRequest(req: Request, user: any) { // Generate presigned URL for DO Spaces const { filename, contentType } = await req.json() // Create S3 client and generate presigned URL // (Implementation depends on your S3 client library) const presignedUrl = await generatePresignedUrl(filename, contentType) return new Response(JSON.stringify({ uploadUrl: presignedUrl }), { headers: { 'Content-Type': 'application/json' } }) } ``` #### 2. Deploy the Function ```bash # Create the function supabase functions new api-proxy # Deploy it supabase functions deploy api-proxy --no-verify-jwt # Set environment variables supabase secrets set CLAUDE_API_KEY=sk-ant-xxx supabase secrets set DO_SPACES_ACCESS_KEY=xxx supabase secrets set DO_SPACES_SECRET_KEY=xxx ``` ### Option B: Standalone Backend Service Create a dedicated backend service using Express.js or Fastify: ```typescript // backend/src/server.ts import express from 'express' import { createClient } from '@supabase/supabase-js' import cors from 'cors' import rateLimit from 'express-rate-limit' const app = express() // Configure CORS for Electron app.use(cors({ origin: ['file://', 'http://localhost:3000'], credentials: true })) // Rate limiting per user const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // limit each user to 100 requests per windowMs keyGenerator: (req) => req.user?.id || req.ip }) app.use('/api/', limiter) app.use(express.json()) // Authentication middleware const authMiddleware = async (req, res, next) => { const token = req.headers.authorization?.replace('Bearer ', '') if (!token) { return res.status(401).json({ error: 'No token provided' }) } try { const supabase = createClient( process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY! ) const { data: { user }, error } = await supabase.auth.getUser(token) if (error || !user) { return res.status(401).json({ error: 'Invalid token' }) } req.user = user next() } catch (error) { res.status(401).json({ error: 'Invalid token' }) } } // Claude API proxy app.post('/api/claude/messages', authMiddleware, async (req, res) => { try { const response = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'x-api-key': process.env.CLAUDE_API_KEY!, 'anthropic-version': '2023-06-01', 'content-type': 'application/json', }, body: JSON.stringify(req.body) }) const data = await response.json() res.json(data) } catch (error) { res.status(500).json({ error: 'Failed to proxy request' }) } }) // Storage presigned URL generation app.post('/api/storage/presigned-url', authMiddleware, async (req, res) => { const { filename, contentType } = req.body // Generate presigned URL (implementation varies by provider) const uploadUrl = await generatePresignedUrl( `users/${req.user.id}/${filename}`, contentType ) res.json({ uploadUrl }) }) const PORT = process.env.PORT || 3001 app.listen(PORT, () => { console.log(`API proxy running on port ${PORT}`) }) ``` ## Electron App Updates ### 1. Create a Secure API Client ```typescript // src/renderer/services/api-client.ts import { supabase } from './supabase' export class SecureAPIClient { private baseURL: string constructor() { // Use Edge Functions in production, local backend in development this.baseURL = import.meta.env.PROD ? 'https://your-project.supabase.co/functions/v1' : 'http://localhost:3001/api' } private async getAuthHeaders(): Promise<HeadersInit> { const { data: { session } } = await supabase.auth.getSession() if (!session) throw new Error('Not authenticated') return { 'Authorization': `Bearer ${session.access_token}`, 'Content-Type': 'application/json' } } async callClaude(messages: any[]): Promise<any> { const headers = await this.getAuthHeaders() const response = await fetch(`${this.baseURL}/claude/messages`, { method: 'POST', headers, body: JSON.stringify({ messages, model: 'claude-3-opus-20240229', max_tokens: 4096 }) }) if (!response.ok) { const error = await response.json() throw new Error(error.message || 'API call failed') } return response.json() } async getPresignedUploadUrl(filename: string, contentType: string): Promise<string> { const headers = await this.getAuthHeaders() const response = await fetch(`${this.baseURL}/storage/presigned-url`, { method: 'POST', headers, body: JSON.stringify({ filename, contentType }) }) if (!response.ok) { throw new Error('Failed to get upload URL') } const { uploadUrl } = await response.json() return uploadUrl } async uploadFile(file: File): Promise<void> { // Get presigned URL from backend const uploadUrl = await this.getPresignedUploadUrl(file.name, file.type) // Upload directly to storage const response = await fetch(uploadUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type } }) if (!response.ok) { throw new Error('Upload failed') } } } export const apiClient = new SecureAPIClient() ``` ### 2. Update Your Agent Service ```typescript // src/main/services/agent-orchestrator-service.ts import { apiClient } from '../renderer/services/api-client' export class AgentOrchestratorService { async sendMessageToClaude(agentId: string, messages: any[]) { try { // Use the secure API client instead of direct API calls const response = await apiClient.callClaude(messages) // Process response as before return this.processClaudeResponse(response) } catch (error) { console.error('Failed to call Claude:', error) throw error } } } ``` ## Environment Variables ### Client-Side `.env` (Safe to expose) ```env # Public Supabase configuration VITE_SUPABASE_URL=https://your-project.supabase.co VITE_SUPABASE_ANON_KEY=your_anon_key # This is safe - it's designed to be public # API endpoints VITE_API_URL=https://your-backend.com # Your proxy API URL # Public Sentry DSN (safe to expose) VITE_SENTRY_DSN=https://xxx@sentry.io/xxx ``` ### Server-Side Environment Variables (Never exposed) ```env # Private API Keys (Edge Functions or Backend) CLAUDE_API_KEY=sk-ant-xxx DO_SPACES_ACCESS_KEY_ID=xxx DO_SPACES_SECRET_ACCESS_KEY=xxx DO_SPACES_BUCKET=your-bucket DO_SPACES_REGION=nyc3 DO_SPACES_ENDPOINT=https://nyc3.digitaloceanspaces.com # Supabase Service Role (if needed) SUPABASE_SERVICE_ROLE_KEY=xxx ``` ## Security Best Practices ### 1. Rate Limiting Implement per-user rate limits to prevent abuse: ```typescript // Supabase Edge Function example const rateLimits = new Map() function checkRateLimit(userId: string): boolean { const key = `${userId}-${Math.floor(Date.now() / 60000)}` // Per minute const count = rateLimits.get(key) || 0 if (count >= 10) { // 10 requests per minute return false } rateLimits.set(key, count + 1) return true } ``` ### 2. Usage Tracking Track API usage for billing and analytics: ```typescript async function logUsage(userId: string, service: string, metadata: any) { await supabase.from('api_usage').insert({ user_id: userId, service, timestamp: new Date().toISOString(), metadata }) } ``` ### 3. Token Management Handle token refresh automatically in your Electron app: ```typescript // src/renderer/hooks/useAuth.ts import { useEffect } from 'react' import { supabase } from '../lib/supabase' export function useAuth() { useEffect(() => { // Listen for auth changes const { data: { subscription } } = supabase.auth.onAuthStateChange( async (event, session) => { if (event === 'TOKEN_REFRESHED') { console.log('Token refreshed successfully') } else if (event === 'SIGNED_OUT') { // Clear any cached data await window.synapseAPI.clearCache() } } ) return () => subscription.unsubscribe() }, []) } ``` ### 4. Secure Local Storage Use Electron's `safeStorage` API for any sensitive data that must be stored locally: ```typescript // src/main/services/secure-storage.ts import { safeStorage } from 'electron' export class SecureStorage { static encrypt(text: string): Buffer { if (!safeStorage.isEncryptionAvailable()) { throw new Error('Encryption not available') } return safeStorage.encryptString(text) } static decrypt(encrypted: Buffer): string { if (!safeStorage.isEncryptionAvailable()) { throw new Error('Encryption not available') } return safeStorage.decryptString(encrypted) } } ``` ## Implementation Steps 1. **Set up Supabase Edge Functions** - Create the proxy function - Deploy with proper environment variables - Test authentication flow 2. **Update Electron App** - Create the secure API client - Replace direct API calls with proxy calls - Update environment variables 3. **Test Security** - Verify no credentials in built app - Test with expired tokens - Check rate limiting works 4. **Add Monitoring** - Track API usage per user - Monitor for anomalies - Set up alerts for failures 5. **Deploy** - Update your build process - Remove sensitive env vars from CI/CD - Test production builds ## Examples from Popular Apps This architecture is used by successful Electron SaaS applications: - **Notion**: Proxies all API calls through their backend - **Linear**: Uses GraphQL proxy with authentication - **Discord**: Custom backend handles all operations - **Figma**: WebSocket proxy for real-time features - **Slack**: Token-based API gateway ## Conclusion By implementing this proxy architecture, you'll: - ✅ Remove all sensitive credentials from your Electron app - ✅ Enable per-user authentication and authorization - ✅ Add usage tracking for billing - ✅ Implement rate limiting and abuse prevention - ✅ Maintain a secure, scalable architecture The key principle: **Only public keys and user-specific tokens should ever be in your Electron app**. All sensitive operations should go through your authenticated proxy layer.