History

Add OAuth support for Gemini CLI, Claude, Codex, and Qwen authentication

- Implemented new handlers (`RequestGeminiCLIToken`, `RequestAnthropicToken`, `RequestCodexToken`, `RequestQwenToken`) for initiating OAuth flows. - Added endpoints for authorization URLs in management API. - Extracted `GenerateRandomState` to a reusable utility in `misc` package. - Refactored related logic in `openai_login.go` and `anthropic_login.go` for consistency. Add endpoints for initiating OAuth flows in management API documentation - Documented new endpoints for Anthropic, Codex, Gemini CLI, and Qwen login URLs. - Provided request and response examples for each endpoint in both English and Chinese versions.

Luis Pater committed 1 year ago

Commit 545dff81 parent 7353bc07 files · +621 −20Browse files

Unified · Split

7 files
  1. MANAGEMENT_API.md +50 −1
  2. MANAGEMENT_API_CN.md +50 −1
  3. internal/api/handlers/management/auth_files.go +490 −0
  4. internal/api/server.go +6 −0
  5. internal/cmd/anthropic_login.go +2 −1
  6. internal/cmd/openai_login.go +2 −17
  7. internal/misc/oauth.go +21 −0
MANAGEMENT_API.md+50−1
@@ -514,6 +514,56 @@ Manage JSON token files under `auth-dir`: list, download, upload, delete. · 513 unmodified lines
514 { "status": "ok", "deleted": 3 }514 { "status": "ok", "deleted": 3 }
515 ```515 ```
516 516
517### Login/OAuth URLs
518
519These endpoints initiate provider login flows and return a URL to open in a browser. Tokens are saved under `auths/` once the flow completes.
520
521- GET `/anthropic-auth-url` — Start Anthropic (Claude) login
522 - Request:
523 ```bash
524 curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
525 http://localhost:8317/v0/management/anthropic-auth-url
526 ```
527 - Response:
528 ```json
529 { "status": "ok", "url": "https://..." }
530 ```
531
532- GET `/codex-auth-url` — Start Codex login
533 - Request:
534 ```bash
535 curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
536 http://localhost:8317/v0/management/codex-auth-url
537 ```
538 - Response:
539 ```json
540 { "status": "ok", "url": "https://..." }
541 ```
542
543- GET `/gemini-cli-auth-url` — Start Google (Gemini CLI) login
544 - Query params:
545 - `project_id` (optional): Google Cloud project ID.
546 - Request:
547 ```bash
548 curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
549 'http://localhost:8317/v0/management/gemini-cli-auth-url?project_id=<PROJECT_ID>'
550 ```
551 - Response:
552 ```json
553 { "status": "ok", "url": "https://..." }
554 ```
555
556- GET `/qwen-auth-url` — Start Qwen login (device flow)
557 - Request:
558 ```bash
559 curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
560 http://localhost:8317/v0/management/qwen-auth-url
561 ```
562 - Response:
563 ```json
564 { "status": "ok", "url": "https://..." }
565 ```
566
517## Error Responses567## Error Responses
518 568
519Generic error format:569Generic error format:
@@ -527,4 +577,3 @@ Generic error format: · 7 unmodified lines
527 577
528- Changes are written back to the YAML config file and hot‑reloaded by the file watcher and clients.578- Changes are written back to the YAML config file and hot‑reloaded by the file watcher and clients.
529- `allow-remote-management` and `remote-management-key` cannot be changed via the API; configure them in the config file.579- `allow-remote-management` and `remote-management-key` cannot be changed via the API; configure them in the config file.
530
MANAGEMENT_API_CN.md+50−1
@@ -514,6 +514,56 @@ · 513 unmodified lines
514 { "status": "ok", "deleted": 3 }514 { "status": "ok", "deleted": 3 }
515 ```515 ```
516 516
517### 登录/授权 URL
518
519以下端点用于发起各提供商的登录流程,并返回需要在浏览器中打开的 URL。流程完成后,令牌会保存到 `auths/` 目录。
520
521- GET `/anthropic-auth-url` — 开始 Anthropic(Claude)登录
522 - 请求:
523 ```bash
524 curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
525 http://localhost:8317/v0/management/anthropic-auth-url
526 ```
527 - 响应:
528 ```json
529 { "status": "ok", "url": "https://..." }
530 ```
531
532- GET `/codex-auth-url` — 开始 Codex 登录
533 - 请求:
534 ```bash
535 curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
536 http://localhost:8317/v0/management/codex-auth-url
537 ```
538 - 响应:
539 ```json
540 { "status": "ok", "url": "https://..." }
541 ```
542
543- GET `/gemini-cli-auth-url` — 开始 Google(Gemini CLI)登录
544 - 查询参数:
545 - `project_id`(可选):Google Cloud 项目 ID。
546 - 请求:
547 ```bash
548 curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
549 'http://localhost:8317/v0/management/gemini-cli-auth-url?project_id=<PROJECT_ID>'
550 ```
551 - 响应:
552 ```json
553 { "status": "ok", "url": "https://..." }
554 ```
555
556- GET `/qwen-auth-url` — 开始 Qwen 登录(设备授权流程)
557 - 请求:
558 ```bash
559 curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
560 http://localhost:8317/v0/management/qwen-auth-url
561 ```
562 - 响应:
563 ```json
564 { "status": "ok", "url": "https://..." }
565 ```
566
517## 错误响应567## 错误响应
518 568
519通用错误格式:569通用错误格式:
@@ -527,4 +577,3 @@ · 7 unmodified lines
527 577
528- 变更会写回 YAML 配置文件,并由文件监控器热重载配置与客户端。578- 变更会写回 YAML 配置文件,并由文件监控器热重载配置与客户端。
529- `allow-remote-management` 与 `remote-management-key` 不能通过 API 修改,需在配置文件中设置。579- `allow-remote-management` 与 `remote-management-key` 不能通过 API 修改,需在配置文件中设置。
530
internal/api/handlers/management/auth_files.go+490−0
@@ -1,14 +1,27 @@
1package management1package management
2 2
3import (3import (
4 "context"
5 "encoding/json"
4 "fmt"6 "fmt"
5 "io"7 "io"
8 "net/http"
6 "os"9 "os"
7 "path/filepath"10 "path/filepath"
8 "strings"11 "strings"
12 "time"
9 13
10 "github.com/gin-gonic/gin"14 "github.com/gin-gonic/gin"
15 "github.com/luispater/CLIProxyAPI/internal/auth/claude"
16 "github.com/luispater/CLIProxyAPI/internal/auth/codex"
17 geminiAuth "github.com/luispater/CLIProxyAPI/internal/auth/gemini"
18 "github.com/luispater/CLIProxyAPI/internal/auth/qwen"
19 "github.com/luispater/CLIProxyAPI/internal/client"
20 "github.com/luispater/CLIProxyAPI/internal/misc"
21 log "github.com/sirupsen/logrus"
11 "github.com/tidwall/gjson"22 "github.com/tidwall/gjson"
23 "golang.org/x/oauth2"
24 "golang.org/x/oauth2/google"
12)25)
13 26
14// List auth files27// List auth files
@@ -147,3 +160,480 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) { · 132 unmodified lines
147 }160 }
148 c.JSON(200, gin.H{"status": "ok"})161 c.JSON(200, gin.H{"status": "ok"})
149}162}
163
164func (h *Handler) RequestAnthropicToken(c *gin.Context) {
165 ctx := context.Background()
166
167 log.Info("Initializing Claude authentication...")
168
169 // Generate PKCE codes
170 pkceCodes, err := claude.GeneratePKCECodes()
171 if err != nil {
172 log.Fatalf("Failed to generate PKCE codes: %v", err)
173 return
174 }
175
176 // Generate random state parameter
177 state, err := misc.GenerateRandomState()
178 if err != nil {
179 log.Fatalf("Failed to generate state parameter: %v", err)
180 return
181 }
182
183 // Initialize Claude auth service
184 anthropicAuth := claude.NewClaudeAuth(h.cfg)
185
186 // Generate authorization URL
187 authURL, state, err := anthropicAuth.GenerateAuthURL(state, pkceCodes)
188 if err != nil {
189 log.Fatalf("Failed to generate authorization URL: %v", err)
190 return
191 }
192
193 go func() {
194 // Initialize OAuth server
195 oauthServer := claude.NewOAuthServer(54545)
196
197 // Start OAuth callback server
198 if err = oauthServer.Start(); err != nil {
199 if strings.Contains(err.Error(), "already in use") {
200 authErr := claude.NewAuthenticationError(claude.ErrPortInUse, err)
201 log.Error(claude.GetUserFriendlyMessage(authErr))
202 return
203 }
204 authErr := claude.NewAuthenticationError(claude.ErrServerStartFailed, err)
205 log.Fatalf("Failed to start OAuth callback server: %v", authErr)
206 return
207 }
208 defer func() {
209 if err = oauthServer.Stop(ctx); err != nil {
210 log.Warnf("Failed to stop OAuth server: %v", err)
211 }
212 }()
213
214 log.Info("Waiting for authentication callback...")
215
216 // Wait for OAuth callback
217 result, errWaitForCallback := oauthServer.WaitForCallback(5 * time.Minute)
218 if errWaitForCallback != nil {
219 if strings.Contains(errWaitForCallback.Error(), "timeout") {
220 authErr := claude.NewAuthenticationError(claude.ErrCallbackTimeout, errWaitForCallback)
221 log.Error(claude.GetUserFriendlyMessage(authErr))
222 } else {
223 log.Errorf("Authentication failed: %v", errWaitForCallback)
224 }
225 return
226 }
227
228 if result.Error != "" {
229 oauthErr := claude.NewOAuthError(result.Error, "", http.StatusBadRequest)
230 log.Error(claude.GetUserFriendlyMessage(oauthErr))
231 return
232 }
233
234 // Validate state parameter
235 if result.State != state {
236 authErr := claude.NewAuthenticationError(claude.ErrInvalidState, fmt.Errorf("expected %s, got %s", state, result.State))
237 log.Error(claude.GetUserFriendlyMessage(authErr))
238 return
239 }
240
241 log.Debug("Authorization code received, exchanging for tokens...")
242
243 // Exchange authorization code for tokens
244 authBundle, errExchangeCodeForTokens := anthropicAuth.ExchangeCodeForTokens(ctx, result.Code, state, pkceCodes)
245 if errExchangeCodeForTokens != nil {
246 authErr := claude.NewAuthenticationError(claude.ErrCodeExchangeFailed, errExchangeCodeForTokens)
247 log.Errorf("Failed to exchange authorization code for tokens: %v", authErr)
248 log.Debug("This may be due to network issues or invalid authorization code")
249 return
250 }
251
252 // Create token storage
253 tokenStorage := anthropicAuth.CreateTokenStorage(authBundle)
254
255 // Initialize Claude client
256 anthropicClient := client.NewClaudeClient(h.cfg, tokenStorage)
257
258 // Save token storage
259 if errWaitForCallback = anthropicClient.SaveTokenToFile(); errWaitForCallback != nil {
260 log.Fatalf("Failed to save authentication tokens: %v", errWaitForCallback)
261 return
262 }
263
264 log.Info("Authentication successful!")
265 if authBundle.APIKey != "" {
266 log.Info("API key obtained and saved")
267 }
268
269 log.Info("You can now use Claude services through this CLI")
270 }()
271
272 c.JSON(200, gin.H{"status": "ok", "url": authURL})
273}
274
275func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
276 ctx := context.Background()
277
278 // Optional project ID from query
279 projectID := c.Query("project_id")
280
281 log.Info("Initializing Google authentication...")
282
283 // OAuth2 configuration (mirrors internal/auth/gemini)
284 conf := &oauth2.Config{
285 ClientID: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
286 ClientSecret: "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",
287 RedirectURL: "http://localhost:8085/oauth2callback",
288 Scopes: []string{
289 "https://www.googleapis.com/auth/cloud-platform",
290 "https://www.googleapis.com/auth/userinfo.email",
291 "https://www.googleapis.com/auth/userinfo.profile",
292 },
293 Endpoint: google.Endpoint,
294 }
295
296 // Build authorization URL and return it immediately
297 authURL := conf.AuthCodeURL("state-token", oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent"))
298
299 go func() {
300 codeChan := make(chan string)
301 errChan := make(chan error)
302
303 mux := http.NewServeMux()
304 server := &http.Server{Addr: ":8085", Handler: mux}
305
306 mux.HandleFunc("/oauth2callback", func(w http.ResponseWriter, r *http.Request) {
307 if err := r.URL.Query().Get("error"); err != "" {
308 _, _ = fmt.Fprintf(w, "Authentication failed: %s", err)
309 errChan <- fmt.Errorf("authentication failed via callback: %s", err)
310 return
311 }
312 code := r.URL.Query().Get("code")
313 if code == "" {
314 _, _ = fmt.Fprint(w, "Authentication failed: code not found.")
315 errChan <- fmt.Errorf("code not found in callback")
316 return
317 }
318 _, _ = fmt.Fprint(w, "<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>")
319 codeChan <- code
320 })
321
322 go func() {
323 if errListen := server.ListenAndServe(); errListen != nil && errListen != http.ErrServerClosed {
324 log.Fatalf("ListenAndServe(): %v", errListen)
325 }
326 }()
327
328 log.Info("Waiting for authentication callback...")
329
330 var authCode string
331 select {
332 case code := <-codeChan:
333 authCode = code
334 case errCallback := <-errChan:
335 log.Errorf("Authentication failed: %v", errCallback)
336 // Attempt graceful shutdown
337 if errShutdown := server.Shutdown(ctx); errShutdown != nil {
338 log.Warnf("Failed to shut down server: %v", errShutdown)
339 }
340 return
341 case <-time.After(5 * time.Minute):
342 log.Error("oauth flow timed out")
343 if errShutdown := server.Shutdown(ctx); errShutdown != nil {
344 log.Warnf("Failed to shut down server after timeout: %v", errShutdown)
345 }
346 return
347 }
348
349 // Shutdown the callback server after receiving the code
350 if errShutdown := server.Shutdown(ctx); errShutdown != nil {
351 log.Warnf("Failed to shut down server: %v", errShutdown)
352 }
353
354 // Exchange authorization code for token
355 token, err := conf.Exchange(ctx, authCode)
356 if err != nil {
357 log.Errorf("Failed to exchange token: %v", err)
358 return
359 }
360
361 // Create token storage (mirrors internal/auth/gemini createTokenStorage)
362 httpClient := conf.Client(ctx, token)
363 req, errNewRequest := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", nil)
364 if errNewRequest != nil {
365 log.Errorf("Could not get user info: %v", errNewRequest)
366 return
367 }
368 req.Header.Set("Content-Type", "application/json")
369 req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
370
371 resp, errDo := httpClient.Do(req)
372 if errDo != nil {
373 log.Errorf("Failed to execute request: %v", errDo)
374 return
375 }
376 defer func() {
377 if errClose := resp.Body.Close(); errClose != nil {
378 log.Printf("warn: failed to close response body: %v", errClose)
379 }
380 }()
381
382 bodyBytes, _ := io.ReadAll(resp.Body)
383 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
384 log.Errorf("Get user info request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
385 return
386 }
387
388 email := gjson.GetBytes(bodyBytes, "email").String()
389 if email != "" {
390 log.Infof("Authenticated user email: %s", email)
391 } else {
392 log.Info("Failed to get user email from token")
393 }
394
395 // Marshal/unmarshal oauth2.Token to generic map and enrich fields
396 var ifToken map[string]any
397 jsonData, _ := json.Marshal(token)
398 if errUnmarshal := json.Unmarshal(jsonData, &ifToken); errUnmarshal != nil {
399 log.Errorf("Failed to unmarshal token: %v", errUnmarshal)
400 return
401 }
402
403 ifToken["token_uri"] = "https://oauth2.googleapis.com/token"
404 ifToken["client_id"] = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
405 ifToken["client_secret"] = "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"
406 ifToken["scopes"] = []string{
407 "https://www.googleapis.com/auth/cloud-platform",
408 "https://www.googleapis.com/auth/userinfo.email",
409 "https://www.googleapis.com/auth/userinfo.profile",
410 }
411 ifToken["universe_domain"] = "googleapis.com"
412
413 ts := geminiAuth.GeminiTokenStorage{
414 Token: ifToken,
415 ProjectID: projectID,
416 Email: email,
417 }
418
419 // Initialize authenticated HTTP client via GeminiAuth to honor proxy settings
420 gemAuth := geminiAuth.NewGeminiAuth()
421 httpClient2, errGetClient := gemAuth.GetAuthenticatedClient(ctx, &ts, h.cfg, true)
422 if errGetClient != nil {
423 log.Fatalf("failed to get authenticated client: %v", errGetClient)
424 return
425 }
426 log.Info("Authentication successful.")
427
428 // Initialize the API client
429 cliClient := client.NewGeminiCLIClient(httpClient2, &ts, h.cfg)
430
431 // Perform the user setup process (migrated from DoLogin)
432 if err = cliClient.SetupUser(ctx, ts.Email, projectID); err != nil {
433 if err.Error() == "failed to start user onboarding, need define a project id" {
434 log.Error("Failed to start user onboarding: A project ID is required.")
435 project, errGetProjectList := cliClient.GetProjectList(ctx)
436 if errGetProjectList != nil {
437 log.Fatalf("Failed to get project list: %v", err)
438 } else {
439 log.Infof("Your account %s needs to specify a project ID.", ts.Email)
440 log.Info("========================================================================")
441 for _, p := range project.Projects {
442 log.Infof("Project ID: %s", p.ProjectID)
443 log.Infof("Project Name: %s", p.Name)
444 log.Info("------------------------------------------------------------------------")
445 }
446 log.Infof("Please run this command to login again with a specific project:\n\n%s --login --project_id <project_id>\n", os.Args[0])
447 }
448 } else {
449 log.Fatalf("Failed to complete user setup: %v", err)
450 }
451 return
452 }
453
454 // Post-setup checks and token persistence
455 auto := projectID == ""
456 cliClient.SetIsAuto(auto)
457 if !cliClient.IsChecked() && !cliClient.IsAuto() {
458 isChecked, checkErr := cliClient.CheckCloudAPIIsEnabled()
459 if checkErr != nil {
460 log.Fatalf("Failed to check if Cloud AI API is enabled: %v", checkErr)
461 return
462 }
463 cliClient.SetIsChecked(isChecked)
464 if !isChecked {
465 log.Fatal("Failed to check if Cloud AI API is enabled. If you encounter an error message, please create an issue.")
466 return
467 }
468 }
469
470 if err = cliClient.SaveTokenToFile(); err != nil {
471 log.Fatalf("Failed to save token to file: %v", err)
472 return
473 }
474
475 log.Info("You can now use Gemini CLI services through this CLI")
476 }()
477
478 c.JSON(200, gin.H{"status": "ok", "url": authURL})
479}
480
481func (h *Handler) RequestCodexToken(c *gin.Context) {
482 ctx := context.Background()
483
484 log.Info("Initializing Codex authentication...")
485
486 // Generate PKCE codes
487 pkceCodes, err := codex.GeneratePKCECodes()
488 if err != nil {
489 log.Fatalf("Failed to generate PKCE codes: %v", err)
490 return
491 }
492
493 // Generate random state parameter
494 state, err := misc.GenerateRandomState()
495 if err != nil {
496 log.Fatalf("Failed to generate state parameter: %v", err)
497 return
498 }
499
500 // Initialize Codex auth service
501 openaiAuth := codex.NewCodexAuth(h.cfg)
502
503 // Generate authorization URL
504 authURL, err := openaiAuth.GenerateAuthURL(state, pkceCodes)
505 if err != nil {
506 log.Fatalf("Failed to generate authorization URL: %v", err)
507 return
508 }
509
510 go func() {
511 // Initialize OAuth server
512 oauthServer := codex.NewOAuthServer(1455)
513
514 // Start OAuth callback server
515 if err = oauthServer.Start(); err != nil {
516 if strings.Contains(err.Error(), "already in use") {
517 authErr := codex.NewAuthenticationError(codex.ErrPortInUse, err)
518 log.Error(codex.GetUserFriendlyMessage(authErr))
519 return
520 }
521 authErr := codex.NewAuthenticationError(codex.ErrServerStartFailed, err)
522 log.Fatalf("Failed to start OAuth callback server: %v", authErr)
523 return
524 }
525 defer func() {
526 if err = oauthServer.Stop(ctx); err != nil {
527 log.Warnf("Failed to stop OAuth server: %v", err)
528 }
529 }()
530
531 log.Info("Waiting for authentication callback...")
532
533 // Wait for OAuth callback
534 result, errWaitForCallback := oauthServer.WaitForCallback(5 * time.Minute)
535 if errWaitForCallback != nil {
536 if strings.Contains(errWaitForCallback.Error(), "timeout") {
537 authErr := codex.NewAuthenticationError(codex.ErrCallbackTimeout, errWaitForCallback)
538 log.Error(codex.GetUserFriendlyMessage(authErr))
539 } else {
540 log.Errorf("Authentication failed: %v", errWaitForCallback)
541 }
542 return
543 }
544
545 if result.Error != "" {
546 oauthErr := codex.NewOAuthError(result.Error, "", http.StatusBadRequest)
547 log.Error(codex.GetUserFriendlyMessage(oauthErr))
548 return
549 }
550
551 // Validate state parameter
552 if result.State != state {
553 authErr := codex.NewAuthenticationError(codex.ErrInvalidState, fmt.Errorf("expected %s, got %s", state, result.State))
554 log.Error(codex.GetUserFriendlyMessage(authErr))
555 return
556 }
557
558 log.Debug("Authorization code received, exchanging for tokens...")
559
560 // Exchange authorization code for tokens
561 authBundle, errExchangeCodeForTokens := openaiAuth.ExchangeCodeForTokens(ctx, result.Code, pkceCodes)
562 if errExchangeCodeForTokens != nil {
563 authErr := codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, errExchangeCodeForTokens)
564 log.Errorf("Failed to exchange authorization code for tokens: %v", authErr)
565 log.Debug("This may be due to network issues or invalid authorization code")
566 return
567 }
568
569 // Create token storage
570 tokenStorage := openaiAuth.CreateTokenStorage(authBundle)
571
572 // Initialize Codex client
573 openaiClient, errWaitForCallback := client.NewCodexClient(h.cfg, tokenStorage)
574 if errWaitForCallback != nil {
575 log.Fatalf("Failed to initialize Codex client: %v", errWaitForCallback)
576 return
577 }
578
579 // Save token storage
580 if errWaitForCallback = openaiClient.SaveTokenToFile(); errWaitForCallback != nil {
581 log.Fatalf("Failed to save authentication tokens: %v", errWaitForCallback)
582 return
583 }
584
585 log.Info("Authentication successful!")
586 if authBundle.APIKey != "" {
587 log.Info("API key obtained and saved")
588 }
589
590 log.Info("You can now use Codex services through this CLI")
591 }()
592
593 c.JSON(200, gin.H{"status": "ok", "url": authURL})
594}
595
596func (h *Handler) RequestQwenToken(c *gin.Context) {
597 ctx := context.Background()
598
599 log.Info("Initializing Qwen authentication...")
600
601 // Initialize Qwen auth service
602 qwenAuth := qwen.NewQwenAuth(h.cfg)
603
604 // Generate authorization URL
605 deviceFlow, err := qwenAuth.InitiateDeviceFlow(ctx)
606 if err != nil {
607 log.Fatalf("Failed to generate authorization URL: %v", err)
608 return
609 }
610 authURL := deviceFlow.VerificationURIComplete
611
612 go func() {
613 log.Info("Waiting for authentication...")
614 tokenData, errPollForToken := qwenAuth.PollForToken(deviceFlow.DeviceCode, deviceFlow.CodeVerifier)
615 if errPollForToken != nil {
616 fmt.Printf("Authentication failed: %v\n", errPollForToken)
617 os.Exit(1)
618 }
619
620 // Create token storage
621 tokenStorage := qwenAuth.CreateTokenStorage(tokenData)
622
623 // Initialize Qwen client
624 qwenClient := client.NewQwenClient(h.cfg, tokenStorage)
625
626 tokenStorage.Email = fmt.Sprintf("qwen-%d", time.Now().UnixMilli())
627
628 // Save token storage
629 if err = qwenClient.SaveTokenToFile(); err != nil {
630 log.Fatalf("Failed to save authentication tokens: %v", err)
631 return
632 }
633
634 log.Info("Authentication successful!")
635 log.Info("You can now use Qwen services through this CLI")
636 }()
637
638 c.JSON(200, gin.H{"status": "ok", "url": authURL})
639}
internal/api/server.go+6−0
@@ -211,6 +211,12 @@ func (s *Server) setupRoutes() { · 210 unmodified lines
211 mgmt.GET("/auth-files/download", s.mgmt.DownloadAuthFile)211 mgmt.GET("/auth-files/download", s.mgmt.DownloadAuthFile)
212 mgmt.POST("/auth-files", s.mgmt.UploadAuthFile)212 mgmt.POST("/auth-files", s.mgmt.UploadAuthFile)
213 mgmt.DELETE("/auth-files", s.mgmt.DeleteAuthFile)213 mgmt.DELETE("/auth-files", s.mgmt.DeleteAuthFile)
214
215 mgmt.GET("/anthropic-auth-url", s.mgmt.RequestAnthropicToken)
216 mgmt.GET("/codex-auth-url", s.mgmt.RequestCodexToken)
217 mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken)
218 mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken)
219
214 }220 }
215 }221 }
216}222}
internal/cmd/anthropic_login.go+2−1
@@ -15,6 +15,7 @@ import ( · 14 unmodified lines
15 "github.com/luispater/CLIProxyAPI/internal/browser"15 "github.com/luispater/CLIProxyAPI/internal/browser"
16 "github.com/luispater/CLIProxyAPI/internal/client"16 "github.com/luispater/CLIProxyAPI/internal/client"
17 "github.com/luispater/CLIProxyAPI/internal/config"17 "github.com/luispater/CLIProxyAPI/internal/config"
18 "github.com/luispater/CLIProxyAPI/internal/misc"
18 "github.com/luispater/CLIProxyAPI/internal/util"19 "github.com/luispater/CLIProxyAPI/internal/util"
19 log "github.com/sirupsen/logrus"20 log "github.com/sirupsen/logrus"
20)21)
@@ -44,7 +45,7 @@ func DoClaudeLogin(cfg *config.Config, options *LoginOptions) { · 23 unmodified lines
44 }45 }
45 46
46 // Generate random state parameter47 // Generate random state parameter
47 state, err := generateRandomState()48 state, err := misc.GenerateRandomState()
48 if err != nil {49 if err != nil {
49 log.Fatalf("Failed to generate state parameter: %v", err)50 log.Fatalf("Failed to generate state parameter: %v", err)
50 return51 return
internal/cmd/openai_login.go+2−17
@@ -5,8 +5,6 @@ package cmd · 4 unmodified lines
5 5
6import (6import (
7 "context"7 "context"
8 "crypto/rand"
9 "encoding/hex"
10 "fmt"8 "fmt"
11 "net/http"9 "net/http"
12 "os"10 "os"
@@ -17,6 +15,7 @@ import ( · 4 unmodified lines
17 "github.com/luispater/CLIProxyAPI/internal/browser"15 "github.com/luispater/CLIProxyAPI/internal/browser"
18 "github.com/luispater/CLIProxyAPI/internal/client"16 "github.com/luispater/CLIProxyAPI/internal/client"
19 "github.com/luispater/CLIProxyAPI/internal/config"17 "github.com/luispater/CLIProxyAPI/internal/config"
18 "github.com/luispater/CLIProxyAPI/internal/misc"
20 "github.com/luispater/CLIProxyAPI/internal/util"19 "github.com/luispater/CLIProxyAPI/internal/util"
21 log "github.com/sirupsen/logrus"20 log "github.com/sirupsen/logrus"
22)21)
@@ -52,7 +51,7 @@ func DoCodexLogin(cfg *config.Config, options *LoginOptions) { · 29 unmodified lines
52 }51 }
53 52
54 // Generate random state parameter53 // Generate random state parameter
55 state, err := generateRandomState()54 state, err := misc.GenerateRandomState()
56 if err != nil {55 if err != nil {
57 log.Fatalf("Failed to generate state parameter: %v", err)56 log.Fatalf("Failed to generate state parameter: %v", err)
58 return57 return
@@ -177,17 +176,3 @@ func DoCodexLogin(cfg *config.Config, options *LoginOptions) { · 118 unmodified lines
177 176
178 log.Info("You can now use Codex services through this CLI")177 log.Info("You can now use Codex services through this CLI")
179}178}
180
181// generateRandomState generates a cryptographically secure random state parameter
182// for OAuth2 flows to prevent CSRF attacks.
183//
184// Returns:
185// - string: A hexadecimal encoded random state string
186// - error: An error if the random generation fails, nil otherwise
187func generateRandomState() (string, error) {
188 bytes := make([]byte, 16)
189 if _, err := rand.Read(bytes); err != nil {
190 return "", fmt.Errorf("failed to generate random bytes: %w", err)
191 }
192 return hex.EncodeToString(bytes), nil
193}
internal/misc/oauth.go+21−0
@@ -0,0 +1,21 @@
1package misc
2
3import (
4 "crypto/rand"
5 "encoding/hex"
6 "fmt"
7)
8
9// GenerateRandomState generates a cryptographically secure random state parameter
10// for OAuth2 flows to prevent CSRF attacks.
11//
12// Returns:
13// - string: A hexadecimal encoded random state string
14// - error: An error if the random generation fails, nil otherwise
15func GenerateRandomState() (string, error) {
16 bytes := make([]byte, 16)
17 if _, err := rand.Read(bytes); err != nil {
18 return "", fmt.Errorf("failed to generate random bytes: %w", err)
19 }
20 return hex.EncodeToString(bytes), nil
21}