@tehreet/conduit
Version:
LLM API gateway with intelligent routing, robust process management, and health monitoring
512 lines (403 loc) • 14.2 kB
Markdown
# 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.