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
514514 { "status": "ok", "deleted": 3 }
515515 ```
516516
517+### Login/OAuth URLs
518+
519+These 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+
517567 ## Error Responses
518568
519569 Generic error format:
@@ -527,4 +577,3 @@ Generic error format: · 7 unmodified lines
527577
528578 - Changes are written back to the YAML config file and hot‑reloaded by the file watcher and clients.
529579 - `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
514514 { "status": "ok", "deleted": 3 }
515515 ```
516516
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+
517567 ## 错误响应
518568
519569 通用错误格式:
@@ -527,4 +577,3 @@ · 7 unmodified lines
527577
528578 - 变更会写回 YAML 配置文件,并由文件监控器热重载配置与客户端。
529579 - `allow-remote-management` 与 `remote-management-key` 不能通过 API 修改,需在配置文件中设置。
530−
internal/api/handlers/management/auth_files.go+490−0
@@ -1,14 +1,27 @@
11 package management
22
33 import (
4+ "context"
5+ "encoding/json"
46 "fmt"
57 "io"
8+ "net/http"
69 "os"
710 "path/filepath"
811 "strings"
12+ "time"
913
1014 "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"
1122 "github.com/tidwall/gjson"
23+ "golang.org/x/oauth2"
24+ "golang.org/x/oauth2/google"
1225 )
1326
1427 // List auth files
@@ -147,3 +160,480 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) { · 132 unmodified lines
147160 }
148161 c.JSON(200, gin.H{"status": "ok"})
149162 }
163+
164+func (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+
275+func (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+
481+func (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+
596+func (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
211211 mgmt.GET("/auth-files/download", s.mgmt.DownloadAuthFile)
212212 mgmt.POST("/auth-files", s.mgmt.UploadAuthFile)
213213 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+
214220 }
215221 }
216222 }
internal/cmd/anthropic_login.go+2−1
@@ -15,6 +15,7 @@ import ( · 14 unmodified lines
1515 "github.com/luispater/CLIProxyAPI/internal/browser"
1616 "github.com/luispater/CLIProxyAPI/internal/client"
1717 "github.com/luispater/CLIProxyAPI/internal/config"
18+ "github.com/luispater/CLIProxyAPI/internal/misc"
1819 "github.com/luispater/CLIProxyAPI/internal/util"
1920 log "github.com/sirupsen/logrus"
2021 )
@@ -44,7 +45,7 @@ func DoClaudeLogin(cfg *config.Config, options *LoginOptions) { · 23 unmodified lines
4445 }
4546
4647 // Generate random state parameter
47− state, err := generateRandomState()
48+ state, err := misc.GenerateRandomState()
4849 if err != nil {
4950 log.Fatalf("Failed to generate state parameter: %v", err)
5051 return
internal/cmd/openai_login.go+2−17
@@ -5,8 +5,6 @@ package cmd · 4 unmodified lines
55
66 import (
77 "context"
8− "crypto/rand"
9− "encoding/hex"
108 "fmt"
119 "net/http"
1210 "os"
@@ -17,6 +15,7 @@ import ( · 4 unmodified lines
1715 "github.com/luispater/CLIProxyAPI/internal/browser"
1816 "github.com/luispater/CLIProxyAPI/internal/client"
1917 "github.com/luispater/CLIProxyAPI/internal/config"
18+ "github.com/luispater/CLIProxyAPI/internal/misc"
2019 "github.com/luispater/CLIProxyAPI/internal/util"
2120 log "github.com/sirupsen/logrus"
2221 )
@@ -52,7 +51,7 @@ func DoCodexLogin(cfg *config.Config, options *LoginOptions) { · 29 unmodified lines
5251 }
5352
5453 // Generate random state parameter
55− state, err := generateRandomState()
54+ state, err := misc.GenerateRandomState()
5655 if err != nil {
5756 log.Fatalf("Failed to generate state parameter: %v", err)
5857 return
@@ -177,17 +176,3 @@ func DoCodexLogin(cfg *config.Config, options *LoginOptions) { · 118 unmodified lines
177176
178177 log.Info("You can now use Codex services through this CLI")
179178 }
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
187−func 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 @@
1+package misc
2+
3+import (
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
15+func 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+}