Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,6 @@
### General

- DRY principles, follow existing patterns
- ./opencode-src/ is reference only, never commit
- ./temp/opencode is reference only, never commit has opencode src
- Use shared types from workspace package
- OpenCode server runs on port 5551, backend API on port 5001
51 changes: 50 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ A full-stack web application for running [OpenCode](https://github.com/sst/openc

### AI Model & Provider Configuration
- **Model Selection** - Browse and select from available AI models with filtering
- **Provider Management** - Configure multiple AI providers with API keys
- **Provider Management** - Configure multiple AI providers with API keys or OAuth
- **OAuth Authentication** - Secure OAuth login for supported providers (Anthropic, GitHub Copilot)
- **Context Usage Indicator** - Visual progress bar showing token usage
- **Agent Configuration** - Create custom agents with system prompts and tool permissions

Expand Down Expand Up @@ -138,4 +139,52 @@ cp .env.example .env
npm run dev
```

## OAuth Provider Setup

OpenCode WebUI supports OAuth authentication for select providers, offering a more secure and convenient alternative to API keys.

### Supported Providers

- **Anthropic (Claude)** - OAuth login with Claude Pro/Max accounts
- **GitHub Copilot** - OAuth device flow authentication

### OAuth vs API Keys

| Feature | OAuth | API Keys |
|---------|-------|----------|
| **Security** | High (no manual key handling) | Medium (requires secure storage) |
| **Setup** | One-time authorization flow | Manual key entry |
| **Refresh** | Automatic token refresh | Manual key rotation |
| **Expiration** | Handled automatically | Keys don't expire |

### Setting Up OAuth

1. **Navigate to Settings → Provider Credentials**
2. **Select a provider** that shows the "OAuth" badge
3. **Click "Add OAuth"** to start the authorization flow
4. **Choose authentication method:**
- **"Open Authorization Page"** - Opens browser for sign-in
- **"Use Authorization Code"** - Provides code for manual entry
5. **Complete authorization** in the browser or enter the provided code
6. **Connection status** will show as "Configured" when successful

### OAuth Flow Types

- **Auto Flow** (GitHub Copilot): Opens browser window, automatic completion
- **Code Flow** (Anthropic): Requires manual code entry from authorization page

### Troubleshooting OAuth

- **"Invalid authorization code"**: Start the OAuth flow again
- **"Access denied"**: Check provider permissions and try again
- **"Code expired"**: Authorization codes expire quickly, restart the flow
- **"Server error"**: Check internet connection and try again later

### Token Management

- OAuth tokens are stored securely in your workspace
- Tokens automatically refresh when expired
- Use "Update OAuth" to re-authorize if needed
- API keys can still be used alongside OAuth for the same provider


2 changes: 2 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { createHealthRoutes } from './routes/health'
import { createTTSRoutes, cleanupExpiredCache } from './routes/tts'
import { createFileRoutes } from './routes/files'
import { createProvidersRoutes } from './routes/providers'
import { createOAuthRoutes } from './routes/oauth'
import { ensureDirectoryExists, writeFileContent } from './services/file-operations'
import { SettingsService } from './services/settings'
import { opencodeServerManager } from './services/opencode-single-server'
Expand Down Expand Up @@ -147,6 +148,7 @@ app.route('/api/settings', createSettingsRoutes(db))
app.route('/api/health', createHealthRoutes(db))
app.route('/api/files', createFileRoutes(db))
app.route('/api/providers', createProvidersRoutes())
app.route('/api/oauth', createOAuthRoutes())
app.route('/api/tts', createTTSRoutes(db))

app.all('/api/opencode/*', async (c) => {
Expand Down
120 changes: 120 additions & 0 deletions backend/src/routes/oauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { Hono } from 'hono'
import { z } from 'zod'
import { proxyRequest } from '../services/proxy'
import { logger } from '../utils/logger'
import { ENV } from '@opencode-webui/shared'
import {
OAuthAuthorizeRequestSchema,
OAuthAuthorizeResponseSchema,
OAuthCallbackRequestSchema
} from '../../../shared/src/schemas/auth'

const OPENCODE_SERVER_URL = `http://${ENV.OPENCODE.HOST}:${ENV.OPENCODE.PORT}`

export function createOAuthRoutes() {
const app = new Hono()

app.post('/:id/oauth/authorize', async (c) => {
try {
const providerId = c.req.param('id')
const body = await c.req.json()
const validated = OAuthAuthorizeRequestSchema.parse(body)

// Proxy to OpenCode server
const response = await proxyRequest(
new Request(
`${OPENCODE_SERVER_URL}/provider/${providerId}/oauth/authorize`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(validated)
}
)
)

if (!response.ok) {
const error = await response.text()
logger.error(`OAuth authorize failed for ${providerId}:`, error)
return c.json({ error: 'OAuth authorization failed' }, 500)
}

const data = await response.json()
const validatedResponse = OAuthAuthorizeResponseSchema.parse(data)

return c.json(validatedResponse)
} catch (error) {
logger.error('OAuth authorize error:', error)
if (error instanceof z.ZodError) {
return c.json({ error: 'Invalid request data', details: error.issues }, 400)
}
return c.json({ error: 'OAuth authorization failed' }, 500)
}
})

app.post('/:id/oauth/callback', async (c) => {
try {
const providerId = c.req.param('id')
const body = await c.req.json()
const validated = OAuthCallbackRequestSchema.parse(body)

// Proxy to OpenCode server
const response = await proxyRequest(
new Request(
`${OPENCODE_SERVER_URL}/provider/${providerId}/oauth/callback`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(validated)
}
)
)

if (!response.ok) {
const error = await response.text()
logger.error(`OAuth callback failed for ${providerId}:`, error)
return c.json({ error: 'OAuth callback failed' }, 500)
}

const data = await response.json()

return c.json(data)
} catch (error) {
logger.error('OAuth callback error:', error)
if (error instanceof z.ZodError) {
return c.json({ error: 'Invalid request data', details: error.issues }, 400)
}
return c.json({ error: 'OAuth callback failed' }, 500)
}
})

app.get('/auth-methods', async (c) => {
try {
// Proxy to OpenCode server
const response = await proxyRequest(
new Request(`${OPENCODE_SERVER_URL}/provider/auth`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
})
)

if (!response.ok) {
const error = await response.text()
logger.error('Failed to get provider auth methods:', error)
return c.json({ error: 'Failed to get provider auth methods' }, 500)
}

const data = await response.json()

// The OpenCode server returns the format we need directly
return c.json({ providers: data })
} catch (error) {
logger.error('Provider auth methods error:', error)
if (error instanceof z.ZodError) {
return c.json({ error: 'Invalid response data', details: error.issues }, 500)
}
return c.json({ error: 'Failed to get provider auth methods' }, 500)
}
})

return app
}
7 changes: 1 addition & 6 deletions backend/src/services/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { AuthCredentialsSchema } from '../../../shared/src/schemas/auth'
import type { z } from 'zod'

type AuthCredentials = z.infer<typeof AuthCredentialsSchema>
type AuthEntry = AuthCredentials[string]

export class AuthService {
private authPath = getAuthPath()
Expand Down Expand Up @@ -42,7 +41,7 @@ export class AuthService {
const auth = await this.getAll()
delete auth[providerId]

await fs.writeFile(this.authPath, JSON.stringify(auth, null, 2))
await fs.writeFile(this.authPath, JSON.stringify(auth, null, 2), { mode: 0o600 })
logger.info(`Deleted credentials for provider: ${providerId}`)
}

Expand All @@ -56,8 +55,4 @@ export class AuthService {
return !!auth[providerId]
}

async get(providerId: string): Promise<AuthEntry | null> {
const auth = await this.getAll()
return auth[providerId] || null
}
}
61 changes: 61 additions & 0 deletions frontend/src/api/oauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import axios from "axios"
import { API_BASE_URL } from "@/config"

export interface OAuthAuthorizeResponse {
url: string
method: "auto" | "code"
instructions: string
}

export interface OAuthCallbackRequest {
method: number
code?: string
}

export interface ProviderAuthMethod {
type: "oauth" | "api"
label: string
}

export interface ProviderAuthMethods {
[providerId: string]: ProviderAuthMethod[]
}

function handleApiError(error: unknown, context: string): never {
if (axios.isAxiosError(error)) {
const message = error.response?.data?.error || error.message
throw new Error(`${context}: ${message}`)
}
throw error
}

export const oauthApi = {
authorize: async (providerId: string, method: number): Promise<OAuthAuthorizeResponse> => {
try {
const { data } = await axios.post(`${API_BASE_URL}/api/oauth/${providerId}/oauth/authorize`, {
method,
})
return data
} catch (error) {
handleApiError(error, "OAuth authorization failed")
}
},

callback: async (providerId: string, request: OAuthCallbackRequest): Promise<boolean> => {
try {
const { data } = await axios.post(`${API_BASE_URL}/api/oauth/${providerId}/oauth/callback`, request)
return data
} catch (error) {
handleApiError(error, "OAuth callback failed")
}
},

getAuthMethods: async (): Promise<ProviderAuthMethods> => {
try {
const { data } = await axios.get(`${API_BASE_URL}/api/oauth/auth-methods`)
return data.providers || data
} catch (error) {
handleApiError(error, "Failed to get provider auth methods")
}
},
}
23 changes: 9 additions & 14 deletions frontend/src/components/message/PromptInput.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { useState, useRef, useEffect, useMemo, type KeyboardEvent } from 'react'
import { useSendPrompt, useAbortSession, useMessages, useSendShell, useConfig, useAgents } from '@/hooks/useOpenCode'
import { useSendPrompt, useAbortSession, useMessages, useSendShell, useAgents } from '@/hooks/useOpenCode'
import { useSettings } from '@/hooks/useSettings'
import { useCommands } from '@/hooks/useCommands'
import { useCommandHandler } from '@/hooks/useCommandHandler'
import { useFileSearch } from '@/hooks/useFileSearch'
import { useModelSelection } from '@/hooks/useModelSelection'

import { useUserBash } from '@/stores/userBashStore'
import { ChevronDown } from 'lucide-react'

import { CommandSuggestions } from '@/components/command/CommandSuggestions'
import { MentionSuggestions, type MentionItem } from './MentionSuggestions'
import { detectMentionTrigger, parsePromptToParts, getFilename, filterAgentsByQuery } from '@/lib/promptParser'
import { getSessionModel } from '@/lib/model'
import { getModel, formatModelName } from '@/api/providers'
import type { components } from '@/api/opencode-types'
import type { MessageWithParts, FileInfo } from '@/api/types'
Expand Down Expand Up @@ -69,7 +69,6 @@ export function PromptInput({
const sendShell = useSendShell(opcodeUrl, directory)
const abortSession = useAbortSession(opcodeUrl, directory, sessionID)
const { data: messages } = useMessages(opcodeUrl, sessionID, directory)
const { data: config } = useConfig(opcodeUrl)
const { preferences, updateSettings } = useSettings()
const { filterCommands } = useCommands(opcodeUrl)
const { executeCommand } = useCommandHandler({
Expand Down Expand Up @@ -430,20 +429,16 @@ export function PromptInput({
const modeColor = currentMode === 'plan' ? 'text-yellow-600 dark:text-yellow-500' : 'text-green-600 dark:text-green-500'
const modeBg = currentMode === 'plan' ? 'bg-yellow-500/10 border-yellow-500/30' : 'bg-green-500/10 border-green-500/30'

const currentModel = getSessionModel(messages, config?.model) || ''
const { model: selectedModel, modelString } = useModelSelection(opcodeUrl, directory)
const currentModel = modelString || ''

useEffect(() => {
const loadModelName = async () => {
if (currentModel) {
if (selectedModel) {
try {
const [providerId, modelId] = currentModel.split('/')
if (providerId && modelId) {
const model = await getModel(providerId, modelId)
if (model) {
setModelName(formatModelName(model))
} else {
setModelName(currentModel)
}
const model = await getModel(selectedModel.providerID, selectedModel.modelID)
if (model) {
setModelName(formatModelName(model))
} else {
setModelName(currentModel)
}
Expand All @@ -456,7 +451,7 @@ export function PromptInput({
}

loadModelName()
}, [currentModel])
}, [selectedModel, currentModel])

useEffect(() => {
if (textareaRef.current && !disabled && !hasActiveStream) {
Expand Down
Loading