auth-flow
Auth Flow
Sources: Claude Code Setup, API Key Management
MVP Status
AuthManager not implemented. MVP uses SetupWizard to collect credentials at runtime (no persistence). Credentials passed directly to IPC handlers.
Current flow: User enters keys in SetupWizard → passed to window.api.startRun() → injected into sandbox env vars.
The Problem
Claude Code requires browser-based OAuth authentication, but Daytona sandboxes are headless (no browser access).
Environment Variables
| Variable | Description | Auth Method |
|---|---|---|
ANTHROPIC_API_KEY |
API key from console.anthropic.com | Pay-as-you-go API |
CLAUDE_CODE_OAUTH_TOKEN |
OAuth token from local auth | Subscription-based |
Priority: ANTHROPIC_API_KEY overrides OAuth token if both are set.
Warning: Setting ANTHROPIC_API_KEY bypasses any subscription and charges to API pay-as-you-go rates.
Authentication Options for Sandboxes
Option 1: API Key (Recommended for Sandboxes)
Most reliable for headless environments.
- Get API key from console.anthropic.com
- Store securely (Electron safeStorage)
- Inject as environment variable in sandbox:
const sandbox = await daytona.create({
language: 'typescript',
envVars: {
ANTHROPIC_API_KEY: credentials.apiKey
}
})
Pros: Simple, reliable, no OAuth complexity Cons: Pay-as-you-go pricing (no subscription usage)
Option 2: OAuth Token Transfer
Transfer OAuth credentials from authenticated local machine.
- User authenticates locally:
claude(opens browser) - Token stored at:
~/.config/claude-code/auth.json - User provides token to app
- App injects as environment variable:
const sandbox = await daytona.create({
language: 'typescript',
envVars: {
CLAUDE_CODE_OAUTH_TOKEN: credentials.oauthToken
}
})
Pros: Uses subscription plan Cons: Token may expire, manual extraction needed
auth.json Location & Format
~/.config/claude-code/auth.json
The token is portable across machines (not IP/machine-bound).
Security: Treat like a password. Revoke immediately if compromised via Claude.ai account settings.
AuthManager Implementation
import { safeStorage } from 'electron'
interface Credentials {
daytonaApiKey: string
authMethod: 'api_key' | 'oauth_token'
anthropicApiKey?: string
claudeOAuthToken?: string
}
class AuthManager {
private readonly STORAGE_KEY = 'multishot-credentials'
async loadCredentials(): Promise<Credentials | null> {
try {
const encrypted = await this.readFromStorage()
if (!encrypted) return null
const decrypted = safeStorage.decryptString(encrypted)
return JSON.parse(decrypted)
} catch {
return null
}
}
async saveCredentials(credentials: Credentials): Promise<void> {
const encrypted = safeStorage.encryptString(JSON.stringify(credentials))
await this.writeToStorage(encrypted)
}
async validateApiKey(apiKey: string): Promise<boolean> {
try {
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'content-type': 'application/json'
},
body: JSON.stringify({
model: 'claude-3-haiku-20240307',
max_tokens: 1,
messages: [{ role: 'user', content: 'hi' }]
})
})
return response.ok || response.status === 400 // 400 = valid key, bad request
} catch {
return false
}
}
async validateDaytonaKey(apiKey: string): Promise<boolean> {
try {
const { Daytona } = await import('@daytonaio/sdk')
const daytona = new Daytona({ apiKey })
await daytona.list({}, 1, 1) // Simple list call to verify
return true
} catch {
return false
}
}
getEnvVarsForSandbox(credentials: Credentials): Record<string, string> {
if (credentials.authMethod === 'api_key' && credentials.anthropicApiKey) {
return { ANTHROPIC_API_KEY: credentials.anthropicApiKey }
}
if (credentials.claudeOAuthToken) {
return { CLAUDE_CODE_OAUTH_TOKEN: credentials.claudeOAuthToken }
}
return {}
}
async clearCredentials(): Promise<void> {
await this.deleteFromStorage()
}
// Platform-specific storage methods
private async readFromStorage(): Promise<Buffer | null> {
// Implementation using electron-store or similar
}
private async writeToStorage(data: Buffer): Promise<void> {
// Implementation
}
private async deleteFromStorage(): Promise<void> {
// Implementation
}
}
export const authManager = new AuthManager()
Setup Wizard Flow
┌─────────────────────────────────────────┐
│ Welcome to Multishot │
│ │
│ Enter your Daytona API Key: │
│ [____________________________________] │
│ │
│ Get key: app.daytona.io │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Claude Authentication │
│ │
│ ○ API Key (recommended for sandboxes) │
│ - Pay-as-you-go pricing │
│ - Most reliable │
│ │
│ ○ OAuth Token │
│ - Uses subscription plan │
│ - May require refresh │
│ │
│ [Continue] │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ (If API Key selected) │
│ │
│ Anthropic API Key: │
│ [____________________________________] │
│ │
│ Get key: console.anthropic.com │
│ │
│ [Validate & Save] │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ (If OAuth Token selected) │
│ │
│ 1. Run 'claude' locally to auth │
│ 2. Find token in: │
│ ~/.config/claude-code/auth.json │
│ 3. Paste token below: │
│ │
│ [____________________________________] │
│ │
│ [Validate & Save] │
└─────────────────────────────────────────┘
Credential Storage
Requirements:
- Never store plaintext credentials in database or files
- Use Electron's
safeStorageAPI (OS keychain integration) - Environment variables exist only during sandbox lifetime
- Redact credentials in logs
import { safeStorage } from 'electron'
// Check if encryption is available
if (safeStorage.isEncryptionAvailable()) {
// Encrypt before storing
const encrypted = safeStorage.encryptString(JSON.stringify(credentials))
// Decrypt when reading
const decrypted = safeStorage.decryptString(encrypted)
const credentials = JSON.parse(decrypted)
}
Error Detection & Recovery
Watch for these patterns in sandbox output:
| Error Pattern | Cause | Action |
|---|---|---|
"Authentication failed" |
Invalid credentials | Re-prompt setup wizard |
"OAuth token has expired" |
Token expired | Request new token |
"Invalid API key" |
Wrong or revoked key | Verify key in console |
"Rate limit exceeded" |
Too many requests | Back off, retry |
"Auth conflict" |
Both token and key set | Clear one method |
function detectAuthError(output: string): 'expired' | 'invalid' | 'conflict' | null {
if (output.includes('OAuth token has expired')) return 'expired'
if (output.includes('Authentication failed')) return 'invalid'
if (output.includes('Invalid API key')) return 'invalid'
if (output.includes('Auth conflict')) return 'conflict'
return null
}
// In IPC handler
sandbox.process.getSessionCommandLogs(session, cmdId,
(stdout) => {
const error = detectAuthError(stdout)
if (error) {
sendToRenderer('auth-error', { type: error })
}
},
(stderr) => {
const error = detectAuthError(stderr)
if (error) {
sendToRenderer('auth-error', { type: error })
}
}
)
Checking Auth Status
In interactive Claude Code:
/status
Shows current authentication method and account info.
Token Revocation
If credentials are compromised:
- API Key: Revoke at console.anthropic.com → API Keys
- OAuth Token: Revoke at claude.ai → Account Settings → Security → Active Sessions
Best Practices
- Prefer API Key for sandbox/headless use - most reliable
- Validate on entry - test credentials before saving
- Handle expiry - OAuth tokens can expire, detect and prompt
- Secure storage - always use safeStorage, never plaintext
- Minimal scope - only request permissions needed
- Clear on logout - remove credentials when user logs out