Skip to content

Commit bc99c96

Browse files
Add OAuth provider authentication support (#14)
* feat: Implement OAuth support for provider authentication - Add OAuth routes and API endpoints for authorize/callback flows - Create OAuthAuthorizeDialog and OAuthCallbackDialog components - Extend ProviderSettings to support both OAuth and API key auth - Add OAuth credential support to auth service - Update shared schemas with OAuth request/response types - Support both 'auto' and 'code' OAuth flows - Maintain backward compatibility with existing API key authentication Backend: - backend/src/routes/oauth.ts (new) - backend/src/services/auth.ts (extended) - backend/src/index.ts (route registration) Frontend: - frontend/src/api/oauth.ts (new) - frontend/src/components/settings/OAuthAuthorizeDialog.tsx (new) - frontend/src/components/settings/OAuthCallbackDialog.tsx (new) - frontend/src/components/settings/ProviderSettings.tsx (enhanced) Shared: - shared/src/schemas/auth.ts (OAuth schemas) * feat: Complete OAuth implementation with comprehensive features - Add comprehensive error handling for OAuth failures with specific error messages - Implement OAuth token status checking and expiration monitoring - Add token refresh preparation with 5-minute expiration buffer - Enhance OAuth API client with detailed error reporting - Update OAuth routes to include database parameter and token status endpoint - Add complete OAuth documentation to README with setup guide - Improve user experience with clear error messages and troubleshooting steps - Support both auto and code OAuth flows for different providers - Maintain backward compatibility with existing API key authentication Error Handling: - Specific error messages for invalid codes, expiration, access denied - Graceful fallback for server errors and network issues - User-friendly error display in OAuth dialogs Token Management: - Token status endpoint for checking credential validity - Expiration monitoring with automatic refresh preparation - Secure storage of OAuth credentials alongside API keys Documentation: - Complete OAuth setup guide in README - Comparison table for OAuth vs API keys - Troubleshooting section for common OAuth issues * fix: Resolve OAuth button opening API key dialog instead - Add isOAuthMode state to track authentication mode - Update API key dialog to only open when not in OAuth mode - Modify OAuth and API key button handlers to set proper mode - Add proper cleanup handlers to reset OAuth mode on close - Ensure dialog state management prevents conflicting dialogs This fixes the issue where clicking 'Add OAuth' would incorrectly open the API key dialog instead of the OAuth authorization dialog. * refactor: Address audit findings for OAuth implementation Critical fixes: - Remove unused db parameter from createOAuthRoutes() - Add query invalidation in handleOAuthSuccess to update UI after OAuth - Remove unused setOAuth and getOAuthWithRefreshCheck methods - Fix file permission in delete method (add mode: 0o600) DRY improvements: - Extract duplicated error handling to shared oauthErrors.ts utility - Add OAuthMethod constants to replace magic numbers 0/1 - Use centralized OPENCODE_SERVER_URL constant via ENV config - Remove unused ProviderAuthMethodsResponseSchema import Code quality: - Add documentation comment noting types should match shared schemas - Consolidate error mapping logic into single mapOAuthError function - Improve code maintainability and reduce duplication Files changed: - backend/src/routes/oauth.ts - backend/src/services/auth.ts - backend/src/index.ts - frontend/src/lib/oauthErrors.ts (new) - frontend/src/api/oauth.ts - frontend/src/components/settings/OAuthAuthorizeDialog.tsx - frontend/src/components/settings/OAuthCallbackDialog.tsx - frontend/src/components/settings/ProviderSettings.tsx * Store model selection client-side with recent models display * Limit provider settings to OAuth-capable providers only * Remove unused code and consolidate model initialization into useModelSelection hook
1 parent 680fed3 commit bc99c96

20 files changed

Lines changed: 849 additions & 424 deletions

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,6 @@
3636
### General
3737

3838
- DRY principles, follow existing patterns
39-
- ./opencode-src/ is reference only, never commit
39+
- ./temp/opencode is reference only, never commit has opencode src
4040
- Use shared types from workspace package
4141
- OpenCode server runs on port 5551, backend API on port 5001

README.md

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ A full-stack web application for running [OpenCode](https://github.com/sst/openc
3535

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

@@ -138,4 +139,52 @@ cp .env.example .env
138139
npm run dev
139140
```
140141

142+
## OAuth Provider Setup
143+
144+
OpenCode WebUI supports OAuth authentication for select providers, offering a more secure and convenient alternative to API keys.
145+
146+
### Supported Providers
147+
148+
- **Anthropic (Claude)** - OAuth login with Claude Pro/Max accounts
149+
- **GitHub Copilot** - OAuth device flow authentication
150+
151+
### OAuth vs API Keys
152+
153+
| Feature | OAuth | API Keys |
154+
|---------|-------|----------|
155+
| **Security** | High (no manual key handling) | Medium (requires secure storage) |
156+
| **Setup** | One-time authorization flow | Manual key entry |
157+
| **Refresh** | Automatic token refresh | Manual key rotation |
158+
| **Expiration** | Handled automatically | Keys don't expire |
159+
160+
### Setting Up OAuth
161+
162+
1. **Navigate to Settings → Provider Credentials**
163+
2. **Select a provider** that shows the "OAuth" badge
164+
3. **Click "Add OAuth"** to start the authorization flow
165+
4. **Choose authentication method:**
166+
- **"Open Authorization Page"** - Opens browser for sign-in
167+
- **"Use Authorization Code"** - Provides code for manual entry
168+
5. **Complete authorization** in the browser or enter the provided code
169+
6. **Connection status** will show as "Configured" when successful
170+
171+
### OAuth Flow Types
172+
173+
- **Auto Flow** (GitHub Copilot): Opens browser window, automatic completion
174+
- **Code Flow** (Anthropic): Requires manual code entry from authorization page
175+
176+
### Troubleshooting OAuth
177+
178+
- **"Invalid authorization code"**: Start the OAuth flow again
179+
- **"Access denied"**: Check provider permissions and try again
180+
- **"Code expired"**: Authorization codes expire quickly, restart the flow
181+
- **"Server error"**: Check internet connection and try again later
182+
183+
### Token Management
184+
185+
- OAuth tokens are stored securely in your workspace
186+
- Tokens automatically refresh when expired
187+
- Use "Update OAuth" to re-authorize if needed
188+
- API keys can still be used alongside OAuth for the same provider
189+
141190

backend/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { createHealthRoutes } from './routes/health'
99
import { createTTSRoutes, cleanupExpiredCache } from './routes/tts'
1010
import { createFileRoutes } from './routes/files'
1111
import { createProvidersRoutes } from './routes/providers'
12+
import { createOAuthRoutes } from './routes/oauth'
1213
import { ensureDirectoryExists, writeFileContent } from './services/file-operations'
1314
import { SettingsService } from './services/settings'
1415
import { opencodeServerManager } from './services/opencode-single-server'
@@ -147,6 +148,7 @@ app.route('/api/settings', createSettingsRoutes(db))
147148
app.route('/api/health', createHealthRoutes(db))
148149
app.route('/api/files', createFileRoutes(db))
149150
app.route('/api/providers', createProvidersRoutes())
151+
app.route('/api/oauth', createOAuthRoutes())
150152
app.route('/api/tts', createTTSRoutes(db))
151153

152154
app.all('/api/opencode/*', async (c) => {

backend/src/routes/oauth.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { Hono } from 'hono'
2+
import { z } from 'zod'
3+
import { proxyRequest } from '../services/proxy'
4+
import { logger } from '../utils/logger'
5+
import { ENV } from '@opencode-webui/shared'
6+
import {
7+
OAuthAuthorizeRequestSchema,
8+
OAuthAuthorizeResponseSchema,
9+
OAuthCallbackRequestSchema
10+
} from '../../../shared/src/schemas/auth'
11+
12+
const OPENCODE_SERVER_URL = `http://${ENV.OPENCODE.HOST}:${ENV.OPENCODE.PORT}`
13+
14+
export function createOAuthRoutes() {
15+
const app = new Hono()
16+
17+
app.post('/:id/oauth/authorize', async (c) => {
18+
try {
19+
const providerId = c.req.param('id')
20+
const body = await c.req.json()
21+
const validated = OAuthAuthorizeRequestSchema.parse(body)
22+
23+
// Proxy to OpenCode server
24+
const response = await proxyRequest(
25+
new Request(
26+
`${OPENCODE_SERVER_URL}/provider/${providerId}/oauth/authorize`,
27+
{
28+
method: 'POST',
29+
headers: { 'Content-Type': 'application/json' },
30+
body: JSON.stringify(validated)
31+
}
32+
)
33+
)
34+
35+
if (!response.ok) {
36+
const error = await response.text()
37+
logger.error(`OAuth authorize failed for ${providerId}:`, error)
38+
return c.json({ error: 'OAuth authorization failed' }, 500)
39+
}
40+
41+
const data = await response.json()
42+
const validatedResponse = OAuthAuthorizeResponseSchema.parse(data)
43+
44+
return c.json(validatedResponse)
45+
} catch (error) {
46+
logger.error('OAuth authorize error:', error)
47+
if (error instanceof z.ZodError) {
48+
return c.json({ error: 'Invalid request data', details: error.issues }, 400)
49+
}
50+
return c.json({ error: 'OAuth authorization failed' }, 500)
51+
}
52+
})
53+
54+
app.post('/:id/oauth/callback', async (c) => {
55+
try {
56+
const providerId = c.req.param('id')
57+
const body = await c.req.json()
58+
const validated = OAuthCallbackRequestSchema.parse(body)
59+
60+
// Proxy to OpenCode server
61+
const response = await proxyRequest(
62+
new Request(
63+
`${OPENCODE_SERVER_URL}/provider/${providerId}/oauth/callback`,
64+
{
65+
method: 'POST',
66+
headers: { 'Content-Type': 'application/json' },
67+
body: JSON.stringify(validated)
68+
}
69+
)
70+
)
71+
72+
if (!response.ok) {
73+
const error = await response.text()
74+
logger.error(`OAuth callback failed for ${providerId}:`, error)
75+
return c.json({ error: 'OAuth callback failed' }, 500)
76+
}
77+
78+
const data = await response.json()
79+
80+
return c.json(data)
81+
} catch (error) {
82+
logger.error('OAuth callback error:', error)
83+
if (error instanceof z.ZodError) {
84+
return c.json({ error: 'Invalid request data', details: error.issues }, 400)
85+
}
86+
return c.json({ error: 'OAuth callback failed' }, 500)
87+
}
88+
})
89+
90+
app.get('/auth-methods', async (c) => {
91+
try {
92+
// Proxy to OpenCode server
93+
const response = await proxyRequest(
94+
new Request(`${OPENCODE_SERVER_URL}/provider/auth`, {
95+
method: 'GET',
96+
headers: { 'Content-Type': 'application/json' }
97+
})
98+
)
99+
100+
if (!response.ok) {
101+
const error = await response.text()
102+
logger.error('Failed to get provider auth methods:', error)
103+
return c.json({ error: 'Failed to get provider auth methods' }, 500)
104+
}
105+
106+
const data = await response.json()
107+
108+
// The OpenCode server returns the format we need directly
109+
return c.json({ providers: data })
110+
} catch (error) {
111+
logger.error('Provider auth methods error:', error)
112+
if (error instanceof z.ZodError) {
113+
return c.json({ error: 'Invalid response data', details: error.issues }, 500)
114+
}
115+
return c.json({ error: 'Failed to get provider auth methods' }, 500)
116+
}
117+
})
118+
119+
return app
120+
}

backend/src/services/auth.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { AuthCredentialsSchema } from '../../../shared/src/schemas/auth'
66
import type { z } from 'zod'
77

88
type AuthCredentials = z.infer<typeof AuthCredentialsSchema>
9-
type AuthEntry = AuthCredentials[string]
109

1110
export class AuthService {
1211
private authPath = getAuthPath()
@@ -42,7 +41,7 @@ export class AuthService {
4241
const auth = await this.getAll()
4342
delete auth[providerId]
4443

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

@@ -56,8 +55,4 @@ export class AuthService {
5655
return !!auth[providerId]
5756
}
5857

59-
async get(providerId: string): Promise<AuthEntry | null> {
60-
const auth = await this.getAll()
61-
return auth[providerId] || null
62-
}
6358
}

frontend/src/api/oauth.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import axios from "axios"
2+
import { API_BASE_URL } from "@/config"
3+
4+
export interface OAuthAuthorizeResponse {
5+
url: string
6+
method: "auto" | "code"
7+
instructions: string
8+
}
9+
10+
export interface OAuthCallbackRequest {
11+
method: number
12+
code?: string
13+
}
14+
15+
export interface ProviderAuthMethod {
16+
type: "oauth" | "api"
17+
label: string
18+
}
19+
20+
export interface ProviderAuthMethods {
21+
[providerId: string]: ProviderAuthMethod[]
22+
}
23+
24+
function handleApiError(error: unknown, context: string): never {
25+
if (axios.isAxiosError(error)) {
26+
const message = error.response?.data?.error || error.message
27+
throw new Error(`${context}: ${message}`)
28+
}
29+
throw error
30+
}
31+
32+
export const oauthApi = {
33+
authorize: async (providerId: string, method: number): Promise<OAuthAuthorizeResponse> => {
34+
try {
35+
const { data } = await axios.post(`${API_BASE_URL}/api/oauth/${providerId}/oauth/authorize`, {
36+
method,
37+
})
38+
return data
39+
} catch (error) {
40+
handleApiError(error, "OAuth authorization failed")
41+
}
42+
},
43+
44+
callback: async (providerId: string, request: OAuthCallbackRequest): Promise<boolean> => {
45+
try {
46+
const { data } = await axios.post(`${API_BASE_URL}/api/oauth/${providerId}/oauth/callback`, request)
47+
return data
48+
} catch (error) {
49+
handleApiError(error, "OAuth callback failed")
50+
}
51+
},
52+
53+
getAuthMethods: async (): Promise<ProviderAuthMethods> => {
54+
try {
55+
const { data } = await axios.get(`${API_BASE_URL}/api/oauth/auth-methods`)
56+
return data.providers || data
57+
} catch (error) {
58+
handleApiError(error, "Failed to get provider auth methods")
59+
}
60+
},
61+
}

frontend/src/components/message/PromptInput.tsx

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import { useState, useRef, useEffect, useMemo, type KeyboardEvent } from 'react'
2-
import { useSendPrompt, useAbortSession, useMessages, useSendShell, useConfig, useAgents } from '@/hooks/useOpenCode'
2+
import { useSendPrompt, useAbortSession, useMessages, useSendShell, useAgents } from '@/hooks/useOpenCode'
33
import { useSettings } from '@/hooks/useSettings'
44
import { useCommands } from '@/hooks/useCommands'
55
import { useCommandHandler } from '@/hooks/useCommandHandler'
66
import { useFileSearch } from '@/hooks/useFileSearch'
7+
import { useModelSelection } from '@/hooks/useModelSelection'
78

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

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

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

435435
useEffect(() => {
436436
const loadModelName = async () => {
437-
if (currentModel) {
437+
if (selectedModel) {
438438
try {
439-
const [providerId, modelId] = currentModel.split('/')
440-
if (providerId && modelId) {
441-
const model = await getModel(providerId, modelId)
442-
if (model) {
443-
setModelName(formatModelName(model))
444-
} else {
445-
setModelName(currentModel)
446-
}
439+
const model = await getModel(selectedModel.providerID, selectedModel.modelID)
440+
if (model) {
441+
setModelName(formatModelName(model))
447442
} else {
448443
setModelName(currentModel)
449444
}
@@ -456,7 +451,7 @@ export function PromptInput({
456451
}
457452

458453
loadModelName()
459-
}, [currentModel])
454+
}, [selectedModel, currentModel])
460455

461456
useEffect(() => {
462457
if (textareaRef.current && !disabled && !hasActiveStream) {

0 commit comments

Comments
 (0)