Skip to content

Commit 67dc21b

Browse files
feat: Implement OAuth authentication flow in configuration wizard
Added complete OAuth support for Anthropic, Gemini, and Qwen providers: New Components: - OAuth callback server with local HTTP server on port 8080 - Browser auto-open functionality for all platforms (Linux/Mac/Windows) - Interactive OAuth wizard for collecting credentials and tokens Configuration Wizard Updates: - Added "Additional Provider Configuration" step - Users can choose between API key or OAuth for supported providers - OAuth flow includes: - Client ID/Secret collection - Automatic browser launch - Callback handling with HTML success/error pages - Token exchange and storage - 5-minute timeout with visual feedback Implementation Details: - internal/oauth/callback.go: HTTP server for OAuth callbacks - internal/oauth/browser.go: Cross-platform browser launcher - internal/config/interactive/oauth_wizard.go: OAuth flow helpers - Updated wizard.go with provider configuration options Provider Support: - Anthropic: API key or OAuth - Gemini: API key or OAuth - Qwen: API key or OAuth - OpenAI: API key only The OAuth flow provides clear visual feedback and fallback to API key authentication if OAuth fails. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 0c172a8 commit 67dc21b

File tree

4 files changed

+599
-2
lines changed

4 files changed

+599
-2
lines changed
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
package interactive
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/cecil-the-coder/mcp-code-api/internal/api/auth"
9+
"github.com/cecil-the-coder/mcp-code-api/internal/api/provider"
10+
"github.com/cecil-the-coder/mcp-code-api/internal/logger"
11+
"github.com/cecil-the-coder/mcp-code-api/internal/oauth"
12+
)
13+
14+
// ProviderOAuthConfig holds OAuth configuration for a provider
15+
type ProviderOAuthConfig struct {
16+
Provider string
17+
ClientID string
18+
ClientSecret string
19+
RedirectURI string
20+
Scopes []string
21+
AuthURL string
22+
TokenURL string
23+
RefreshURL string
24+
}
25+
26+
// performOAuthFlow performs the full OAuth authentication flow
27+
func (w *Wizard) performOAuthFlow(providerName string, config ProviderOAuthConfig) (*auth.TokenInfo, error) {
28+
fmt.Printf("\n🔐 Starting OAuth flow for %s...\n", providerName)
29+
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
30+
31+
// Start callback server
32+
callbackPort := 8080
33+
server, err := oauth.NewCallbackServer(callbackPort)
34+
if err != nil {
35+
return nil, fmt.Errorf("failed to start callback server: %w", err)
36+
}
37+
defer server.Stop()
38+
39+
if err := server.Start(); err != nil {
40+
return nil, fmt.Errorf("failed to start callback server: %w", err)
41+
}
42+
43+
redirectURL := server.GetRedirectURL()
44+
fmt.Printf("📍 Callback server started at: %s\n", redirectURL)
45+
46+
// Create storage and authenticator
47+
storage := auth.NewMemoryTokenStorage()
48+
var authenticator auth.OAuthAuthenticator
49+
50+
switch providerName {
51+
case "anthropic":
52+
authenticator = auth.NewAnthropicOAuthAuthenticator(storage)
53+
case "gemini":
54+
authenticator = auth.NewOAuthAuthenticator("gemini", storage)
55+
case "qwen":
56+
authenticator = auth.NewOAuthAuthenticator("qwen", storage)
57+
default:
58+
return nil, fmt.Errorf("unsupported OAuth provider: %s", providerName)
59+
}
60+
61+
// Configure OAuth
62+
oauthConfig := &auth.OAuthConfig{
63+
ClientID: config.ClientID,
64+
ClientSecret: config.ClientSecret,
65+
RedirectURL: redirectURL,
66+
Scopes: config.Scopes,
67+
AuthURL: config.AuthURL,
68+
TokenURL: config.TokenURL,
69+
RefreshURL: config.RefreshURL,
70+
}
71+
72+
authConfig := auth.AuthConfig{
73+
Method: auth.AuthMethodOAuth,
74+
OAuthConfig: oauthConfig,
75+
}
76+
77+
ctx := context.Background()
78+
if err := authenticator.Authenticate(ctx, authConfig); err != nil {
79+
// Authentication will fail because we don't have a token yet
80+
// This is expected, we'll get the auth URL next
81+
logger.Debugf("Initial auth failed (expected): %v", err)
82+
}
83+
84+
// Start OAuth flow
85+
authURL, err := authenticator.StartOAuthFlow(ctx, config.Scopes)
86+
if err != nil {
87+
return nil, fmt.Errorf("failed to start OAuth flow: %w", err)
88+
}
89+
90+
fmt.Println("\n📱 Opening browser for authentication...")
91+
fmt.Printf("🌐 Auth URL: %s\n\n", authURL)
92+
93+
// Try to open browser
94+
if err := oauth.OpenBrowser(authURL); err != nil {
95+
logger.Debugf("Failed to open browser automatically: %v", err)
96+
fmt.Println("⚠️ Could not open browser automatically.")
97+
fmt.Println("Please manually open the URL above in your browser.")
98+
}
99+
100+
fmt.Println("⏳ Waiting for authentication callback...")
101+
fmt.Println(" (This will timeout in 5 minutes)")
102+
103+
// Wait for callback
104+
result, err := server.WaitForCallback(5 * time.Minute)
105+
if err != nil {
106+
return nil, fmt.Errorf("failed to receive callback: %w", err)
107+
}
108+
109+
if result.Error != "" {
110+
return nil, fmt.Errorf("OAuth error: %s", result.Error)
111+
}
112+
113+
fmt.Println("\n✅ Received authorization code!")
114+
fmt.Println("🔄 Exchanging code for access token...")
115+
116+
// Handle callback to exchange code for token
117+
if err := authenticator.HandleCallback(ctx, result.Code, result.State); err != nil {
118+
return nil, fmt.Errorf("failed to exchange code for token: %w", err)
119+
}
120+
121+
// Get token info
122+
tokenInfo, err := authenticator.GetTokenInfo()
123+
if err != nil {
124+
return nil, fmt.Errorf("failed to get token info: %w", err)
125+
}
126+
127+
fmt.Println("\n✅ Authentication successful!")
128+
fmt.Printf("📅 Token expires: %s\n", tokenInfo.ExpiresAt.Format(time.RFC3339))
129+
130+
return tokenInfo, nil
131+
}
132+
133+
// configureProviderOAuth configures OAuth for a specific provider
134+
func (w *Wizard) configureProviderOAuth(providerName, displayName string) (*ProviderOAuthConfig, *auth.TokenInfo, error) {
135+
fmt.Printf("\n🔐 %s OAuth Configuration\n", displayName)
136+
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
137+
138+
// Get provider-specific OAuth config
139+
var defaultAuthURL, defaultTokenURL, defaultRefreshURL string
140+
var defaultScopes []string
141+
142+
switch providerName {
143+
case "anthropic":
144+
defaultAuthURL = "https://api.anthropic.com/oauth/authorize"
145+
defaultTokenURL = "https://api.anthropic.com/oauth/token"
146+
defaultRefreshURL = "https://api.anthropic.com/oauth/refresh"
147+
defaultScopes = []string{"messages", "tools"}
148+
fmt.Println("Register your OAuth app at: https://console.anthropic.com/settings/oauth")
149+
case "gemini":
150+
defaultAuthURL = "https://accounts.google.com/o/oauth2/v2/auth"
151+
defaultTokenURL = "https://oauth2.googleapis.com/token"
152+
defaultScopes = []string{"https://www.googleapis.com/auth/generative-language"}
153+
fmt.Println("Register your OAuth app at: https://console.cloud.google.com/apis/credentials")
154+
case "qwen":
155+
defaultAuthURL = "https://dashscope.aliyuncs.com/oauth/authorize"
156+
defaultTokenURL = "https://dashscope.aliyuncs.com/oauth/token"
157+
defaultScopes = []string{"api"}
158+
fmt.Println("Register your OAuth app at: https://dashscope.console.aliyun.com/")
159+
default:
160+
return nil, nil, fmt.Errorf("OAuth not supported for %s", providerName)
161+
}
162+
163+
fmt.Println()
164+
165+
// Collect OAuth credentials
166+
clientID := w.prompt(fmt.Sprintf("Enter %s OAuth Client ID: ", displayName), false)
167+
if clientID == "" {
168+
return nil, nil, fmt.Errorf("client ID is required")
169+
}
170+
171+
clientSecret := w.prompt(fmt.Sprintf("Enter %s OAuth Client Secret: ", displayName), false)
172+
if clientSecret == "" {
173+
return nil, nil, fmt.Errorf("client secret is required")
174+
}
175+
176+
config := &ProviderOAuthConfig{
177+
Provider: providerName,
178+
ClientID: clientID,
179+
ClientSecret: clientSecret,
180+
Scopes: defaultScopes,
181+
AuthURL: defaultAuthURL,
182+
TokenURL: defaultTokenURL,
183+
RefreshURL: defaultRefreshURL,
184+
}
185+
186+
// Perform OAuth flow
187+
tokenInfo, err := w.performOAuthFlow(providerName, *config)
188+
if err != nil {
189+
return config, nil, err
190+
}
191+
192+
return config, tokenInfo, nil
193+
}
194+
195+
// getOAuthConfigForProvider returns OAuth config for a provider in the config format
196+
func getOAuthConfigForProvider(oauthConfig *ProviderOAuthConfig, tokenInfo *auth.TokenInfo) *provider.OAuthConfig {
197+
if oauthConfig == nil {
198+
return nil
199+
}
200+
201+
providerConfig := &provider.OAuthConfig{
202+
ClientID: oauthConfig.ClientID,
203+
ClientSecret: oauthConfig.ClientSecret,
204+
RedirectURL: "", // Will be set during flow
205+
Scopes: oauthConfig.Scopes,
206+
AuthURL: oauthConfig.AuthURL,
207+
TokenURL: oauthConfig.TokenURL,
208+
}
209+
210+
if tokenInfo != nil {
211+
providerConfig.AccessToken = tokenInfo.AccessToken
212+
providerConfig.RefreshToken = tokenInfo.RefreshToken
213+
providerConfig.ExpiresAt = tokenInfo.ExpiresAt
214+
}
215+
216+
return providerConfig
217+
}

0 commit comments

Comments
 (0)