package management import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/url" "os" "path/filepath" "sort" "strconv" "strings" "sync" "time" "github.com/gin-gonic/gin" "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude" "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex" geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini" iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow" "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen" "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "golang.org/x/oauth2" "golang.org/x/oauth2/google" ) var lastRefreshKeys = []string{"last_refresh", "lastRefresh", "last_refreshed_at", "lastRefreshedAt"} const ( anthropicCallbackPort = 54545 geminiCallbackPort = 8085 codexCallbackPort = 1455 geminiCLIEndpoint = "https://cloudcode-pa.googleapis.com" geminiCLIVersion = "v1internal" geminiCLIUserAgent = "google-api-nodejs-client/9.15.1" geminiCLIApiClient = "gl-node/22.17.0" geminiCLIClientMetadata = "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI" ) type callbackForwarder struct { provider string server *http.Server done chan struct{} } var ( callbackForwardersMu sync.Mutex callbackForwarders = make(map[int]*callbackForwarder) ) func extractLastRefreshTimestamp(meta map[string]any) (time.Time, bool) { if len(meta) == 0 { return time.Time{}, false } for _, key := range lastRefreshKeys { if val, ok := meta[key]; ok { if ts, ok1 := parseLastRefreshValue(val); ok1 { return ts, true } } } return time.Time{}, false } func parseLastRefreshValue(v any) (time.Time, bool) { switch val := v.(type) { case string: s := strings.TrimSpace(val) if s == "" { return time.Time{}, false } layouts := []string{time.RFC3339, time.RFC3339Nano, "2006-01-02 15:04:05", "2006-01-02T15:04:05Z07:00"} for _, layout := range layouts { if ts, err := time.Parse(layout, s); err == nil { return ts.UTC(), true } } if unix, err := strconv.ParseInt(s, 10, 64); err == nil && unix > 0 { return time.Unix(unix, 0).UTC(), true } case float64: if val <= 0 { return time.Time{}, false } return time.Unix(int64(val), 0).UTC(), true case int64: if val <= 0 { return time.Time{}, false } return time.Unix(val, 0).UTC(), true case int: if val <= 0 { return time.Time{}, false } return time.Unix(int64(val), 0).UTC(), true case json.Number: if i, err := val.Int64(); err == nil && i > 0 { return time.Unix(i, 0).UTC(), true } } return time.Time{}, false } func isWebUIRequest(c *gin.Context) bool { raw := strings.TrimSpace(c.Query("is_webui")) if raw == "" { return false } switch strings.ToLower(raw) { case "1", "true", "yes", "on": return true default: return false } } func startCallbackForwarder(port int, provider, targetBase string) (*callbackForwarder, error) { callbackForwardersMu.Lock() prev := callbackForwarders[port] if prev != nil { delete(callbackForwarders, port) } callbackForwardersMu.Unlock() if prev != nil { stopForwarderInstance(port, prev) } addr := fmt.Sprintf("127.0.0.1:%d", port) ln, err := net.Listen("tcp", addr) if err != nil { return nil, fmt.Errorf("failed to listen on %s: %w", addr, err) } handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { target := targetBase if raw := r.URL.RawQuery; raw != "" { if strings.Contains(target, "?") { target = target + "&" + raw } else { target = target + "?" + raw } } w.Header().Set("Cache-Control", "no-store") http.Redirect(w, r, target, http.StatusFound) }) srv := &http.Server{ Handler: handler, ReadHeaderTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, } done := make(chan struct{}) go func() { if errServe := srv.Serve(ln); errServe != nil && !errors.Is(errServe, http.ErrServerClosed) { log.WithError(errServe).Warnf("callback forwarder for %s stopped unexpectedly", provider) } close(done) }() forwarder := &callbackForwarder{ provider: provider, server: srv, done: done, } callbackForwardersMu.Lock() callbackForwarders[port] = forwarder callbackForwardersMu.Unlock() log.Infof("callback forwarder for %s listening on %s", provider, addr) return forwarder, nil } func stopCallbackForwarder(port int) { callbackForwardersMu.Lock() forwarder := callbackForwarders[port] if forwarder != nil { delete(callbackForwarders, port) } callbackForwardersMu.Unlock() stopForwarderInstance(port, forwarder) } func stopForwarderInstance(port int, forwarder *callbackForwarder) { if forwarder == nil || forwarder.server == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := forwarder.server.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) { log.WithError(err).Warnf("failed to shut down callback forwarder on port %d", port) } select { case <-forwarder.done: case <-time.After(2 * time.Second): } log.Infof("callback forwarder on port %d stopped", port) } func sanitizeAntigravityFileName(email string) string { if strings.TrimSpace(email) == "" { return "antigravity.json" } replacer := strings.NewReplacer("@", "_", ".", "_") return fmt.Sprintf("antigravity-%s.json", replacer.Replace(email)) } func (h *Handler) managementCallbackURL(path string) (string, error) { if h == nil || h.cfg == nil || h.cfg.Port <= 0 { return "", fmt.Errorf("server port is not configured") } if !strings.HasPrefix(path, "/") { path = "/" + path } scheme := "http" if h.cfg.TLS.Enable { scheme = "https" } return fmt.Sprintf("%s://127.0.0.1:%d%s", scheme, h.cfg.Port, path), nil } func (h *Handler) ListAuthFiles(c *gin.Context) { if h == nil { c.JSON(500, gin.H{"error": "handler not initialized"}) return } if h.authManager == nil { h.listAuthFilesFromDisk(c) return } auths := h.authManager.List() files := make([]gin.H, 0, len(auths)) for _, auth := range auths { if entry := h.buildAuthFileEntry(auth); entry != nil { files = append(files, entry) } } sort.Slice(files, func(i, j int) bool { nameI, _ := files[i]["name"].(string) nameJ, _ := files[j]["name"].(string) return strings.ToLower(nameI) < strings.ToLower(nameJ) }) c.JSON(200, gin.H{"files": files}) } // GetAuthFileModels returns the models supported by a specific auth file func (h *Handler) GetAuthFileModels(c *gin.Context) { name := c.Query("name") if name == "" { c.JSON(400, gin.H{"error": "name is required"}) return } // Try to find auth ID via authManager var authID string if h.authManager != nil { auths := h.authManager.List() for _, auth := range auths { if auth.FileName == name || auth.ID == name { authID = auth.ID break } } } if authID == "" { authID = name // fallback to filename as ID } // Get models from registry reg := registry.GetGlobalRegistry() models := reg.GetModelsForClient(authID) result := make([]gin.H, 0, len(models)) for _, m := range models { entry := gin.H{ "id": m.ID, } if m.DisplayName != "" { entry["display_name"] = m.DisplayName } if m.Type != "" { entry["type"] = m.Type } if m.OwnedBy != "" { entry["owned_by"] = m.OwnedBy } result = append(result, entry) } c.JSON(200, gin.H{"models": result}) } // List auth files from disk when the auth manager is unavailable. func (h *Handler) listAuthFilesFromDisk(c *gin.Context) { entries, err := os.ReadDir(h.cfg.AuthDir) if err != nil { c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read auth dir: %v", err)}) return } files := make([]gin.H, 0) for _, e := range entries { if e.IsDir() { continue } name := e.Name() if !strings.HasSuffix(strings.ToLower(name), ".json") { continue } if info, errInfo := e.Info(); errInfo == nil { fileData := gin.H{"name": name, "size": info.Size(), "modtime": info.ModTime()} // Read file to get type field full := filepath.Join(h.cfg.AuthDir, name) if data, errRead := os.ReadFile(full); errRead == nil { typeValue := gjson.GetBytes(data, "type").String() emailValue := gjson.GetBytes(data, "email").String() fileData["type"] = typeValue fileData["email"] = emailValue } files = append(files, fileData) } } c.JSON(200, gin.H{"files": files}) } func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H { if auth == nil { return nil } auth.EnsureIndex() runtimeOnly := isRuntimeOnlyAuth(auth) if runtimeOnly && (auth.Disabled || auth.Status == coreauth.StatusDisabled) { return nil } path := strings.TrimSpace(authAttribute(auth, "path")) if path == "" && !runtimeOnly { return nil } name := strings.TrimSpace(auth.FileName) if name == "" { name = auth.ID } entry := gin.H{ "id": auth.ID, "auth_index": auth.Index, "name": name, "type": strings.TrimSpace(auth.Provider), "provider": strings.TrimSpace(auth.Provider), "label": auth.Label, "status": auth.Status, "status_message": auth.StatusMessage, "disabled": auth.Disabled, "unavailable": auth.Unavailable, "runtime_only": runtimeOnly, "source": "memory", "size": int64(0), } if email := authEmail(auth); email != "" { entry["email"] = email } if accountType, account := auth.AccountInfo(); accountType != "" || account != "" { if accountType != "" { entry["account_type"] = accountType } if account != "" { entry["account"] = account } } if !auth.CreatedAt.IsZero() { entry["created_at"] = auth.CreatedAt } if !auth.UpdatedAt.IsZero() { entry["modtime"] = auth.UpdatedAt entry["updated_at"] = auth.UpdatedAt } if !auth.LastRefreshedAt.IsZero() { entry["last_refresh"] = auth.LastRefreshedAt } if path != "" { entry["path"] = path entry["source"] = "file" if info, err := os.Stat(path); err == nil { entry["size"] = info.Size() entry["modtime"] = info.ModTime() } else if os.IsNotExist(err) { // Hide credentials removed from disk but still lingering in memory. if !runtimeOnly && (auth.Disabled || auth.Status == coreauth.StatusDisabled || strings.EqualFold(strings.TrimSpace(auth.StatusMessage), "removed via management api")) { return nil } entry["source"] = "memory" } else { log.WithError(err).Warnf("failed to stat auth file %s", path) } } return entry } func authEmail(auth *coreauth.Auth) string { if auth == nil { return "" } if auth.Metadata != nil { if v, ok := auth.Metadata["email"].(string); ok { return strings.TrimSpace(v) } } if auth.Attributes != nil { if v := strings.TrimSpace(auth.Attributes["email"]); v != "" { return v } if v := strings.TrimSpace(auth.Attributes["account_email"]); v != "" { return v } } return "" } func authAttribute(auth *coreauth.Auth, key string) string { if auth == nil || len(auth.Attributes) == 0 { return "" } return auth.Attributes[key] } func isRuntimeOnlyAuth(auth *coreauth.Auth) bool { if auth == nil || len(auth.Attributes) == 0 { return false } return strings.EqualFold(strings.TrimSpace(auth.Attributes["runtime_only"]), "true") } // Download single auth file by name func (h *Handler) DownloadAuthFile(c *gin.Context) { name := c.Query("name") if name == "" || strings.Contains(name, string(os.PathSeparator)) { c.JSON(400, gin.H{"error": "invalid name"}) return } if !strings.HasSuffix(strings.ToLower(name), ".json") { c.JSON(400, gin.H{"error": "name must end with .json"}) return } full := filepath.Join(h.cfg.AuthDir, name) data, err := os.ReadFile(full) if err != nil { if os.IsNotExist(err) { c.JSON(404, gin.H{"error": "file not found"}) } else { c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read file: %v", err)}) } return } c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", name)) c.Data(200, "application/json", data) } // Upload auth file: multipart or raw JSON with ?name= func (h *Handler) UploadAuthFile(c *gin.Context) { if h.authManager == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"}) return } ctx := c.Request.Context() if file, err := c.FormFile("file"); err == nil && file != nil { name := filepath.Base(file.Filename) if !strings.HasSuffix(strings.ToLower(name), ".json") { c.JSON(400, gin.H{"error": "file must be .json"}) return } dst := filepath.Join(h.cfg.AuthDir, name) if !filepath.IsAbs(dst) { if abs, errAbs := filepath.Abs(dst); errAbs == nil { dst = abs } } if errSave := c.SaveUploadedFile(file, dst); errSave != nil { c.JSON(500, gin.H{"error": fmt.Sprintf("failed to save file: %v", errSave)}) return } data, errRead := os.ReadFile(dst) if errRead != nil { c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read saved file: %v", errRead)}) return } if errReg := h.registerAuthFromFile(ctx, dst, data); errReg != nil { c.JSON(500, gin.H{"error": errReg.Error()}) return } c.JSON(200, gin.H{"status": "ok"}) return } name := c.Query("name") if name == "" || strings.Contains(name, string(os.PathSeparator)) { c.JSON(400, gin.H{"error": "invalid name"}) return } if !strings.HasSuffix(strings.ToLower(name), ".json") { c.JSON(400, gin.H{"error": "name must end with .json"}) return } data, err := io.ReadAll(c.Request.Body) if err != nil { c.JSON(400, gin.H{"error": "failed to read body"}) return } dst := filepath.Join(h.cfg.AuthDir, filepath.Base(name)) if !filepath.IsAbs(dst) { if abs, errAbs := filepath.Abs(dst); errAbs == nil { dst = abs } } if errWrite := os.WriteFile(dst, data, 0o600); errWrite != nil { c.JSON(500, gin.H{"error": fmt.Sprintf("failed to write file: %v", errWrite)}) return } if err = h.registerAuthFromFile(ctx, dst, data); err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } c.JSON(200, gin.H{"status": "ok"}) } // Delete auth files: single by name or all func (h *Handler) DeleteAuthFile(c *gin.Context) { if h.authManager == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"}) return } ctx := c.Request.Context() if all := c.Query("all"); all == "true" || all == "1" || all == "*" { entries, err := os.ReadDir(h.cfg.AuthDir) if err != nil { c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read auth dir: %v", err)}) return } deleted := 0 for _, e := range entries { if e.IsDir() { continue } name := e.Name() if !strings.HasSuffix(strings.ToLower(name), ".json") { continue } full := filepath.Join(h.cfg.AuthDir, name) if !filepath.IsAbs(full) { if abs, errAbs := filepath.Abs(full); errAbs == nil { full = abs } } if err = os.Remove(full); err == nil { if errDel := h.deleteTokenRecord(ctx, full); errDel != nil { c.JSON(500, gin.H{"error": errDel.Error()}) return } deleted++ h.disableAuth(ctx, full) } } c.JSON(200, gin.H{"status": "ok", "deleted": deleted}) return } name := c.Query("name") if name == "" || strings.Contains(name, string(os.PathSeparator)) { c.JSON(400, gin.H{"error": "invalid name"}) return } full := filepath.Join(h.cfg.AuthDir, filepath.Base(name)) if !filepath.IsAbs(full) { if abs, errAbs := filepath.Abs(full); errAbs == nil { full = abs } } if err := os.Remove(full); err != nil { if os.IsNotExist(err) { c.JSON(404, gin.H{"error": "file not found"}) } else { c.JSON(500, gin.H{"error": fmt.Sprintf("failed to remove file: %v", err)}) } return } if err := h.deleteTokenRecord(ctx, full); err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } h.disableAuth(ctx, full) c.JSON(200, gin.H{"status": "ok"}) } func (h *Handler) authIDForPath(path string) string { path = strings.TrimSpace(path) if path == "" { return "" } if h == nil || h.cfg == nil { return path } authDir := strings.TrimSpace(h.cfg.AuthDir) if authDir == "" { return path } if rel, err := filepath.Rel(authDir, path); err == nil && rel != "" { return rel } return path } func (h *Handler) registerAuthFromFile(ctx context.Context, path string, data []byte) error { if h.authManager == nil { return nil } if path == "" { return fmt.Errorf("auth path is empty") } if data == nil { var err error data, err = os.ReadFile(path) if err != nil { return fmt.Errorf("failed to read auth file: %w", err) } } metadata := make(map[string]any) if err := json.Unmarshal(data, &metadata); err != nil { return fmt.Errorf("invalid auth file: %w", err) } provider, _ := metadata["type"].(string) if provider == "" { provider = "unknown" } label := provider if email, ok := metadata["email"].(string); ok && email != "" { label = email } lastRefresh, hasLastRefresh := extractLastRefreshTimestamp(metadata) authID := h.authIDForPath(path) if authID == "" { authID = path } attr := map[string]string{ "path": path, "source": path, } auth := &coreauth.Auth{ ID: authID, Provider: provider, FileName: filepath.Base(path), Label: label, Status: coreauth.StatusActive, Attributes: attr, Metadata: metadata, CreatedAt: time.Now(), UpdatedAt: time.Now(), } if hasLastRefresh { auth.LastRefreshedAt = lastRefresh } if existing, ok := h.authManager.GetByID(authID); ok { auth.CreatedAt = existing.CreatedAt if !hasLastRefresh { auth.LastRefreshedAt = existing.LastRefreshedAt } auth.NextRefreshAfter = existing.NextRefreshAfter auth.Runtime = existing.Runtime _, err := h.authManager.Update(ctx, auth) return err } _, err := h.authManager.Register(ctx, auth) return err } func (h *Handler) disableAuth(ctx context.Context, id string) { if h == nil || h.authManager == nil { return } authID := h.authIDForPath(id) if authID == "" { authID = strings.TrimSpace(id) } if authID == "" { return } if auth, ok := h.authManager.GetByID(authID); ok { auth.Disabled = true auth.Status = coreauth.StatusDisabled auth.StatusMessage = "removed via management API" auth.UpdatedAt = time.Now() _, _ = h.authManager.Update(ctx, auth) } } func (h *Handler) deleteTokenRecord(ctx context.Context, path string) error { if strings.TrimSpace(path) == "" { return fmt.Errorf("auth path is empty") } store := h.tokenStoreWithBaseDir() if store == nil { return fmt.Errorf("token store unavailable") } return store.Delete(ctx, path) } func (h *Handler) tokenStoreWithBaseDir() coreauth.Store { if h == nil { return nil } store := h.tokenStore if store == nil { store = sdkAuth.GetTokenStore() h.tokenStore = store } if h.cfg != nil { if dirSetter, ok := store.(interface{ SetBaseDir(string) }); ok { dirSetter.SetBaseDir(h.cfg.AuthDir) } } return store } func (h *Handler) saveTokenRecord(ctx context.Context, record *coreauth.Auth) (string, error) { if record == nil { return "", fmt.Errorf("token record is nil") } store := h.tokenStoreWithBaseDir() if store == nil { return "", fmt.Errorf("token store unavailable") } return store.Save(ctx, record) } func (h *Handler) RequestAnthropicToken(c *gin.Context) { ctx := context.Background() fmt.Println("Initializing Claude authentication...") // Generate PKCE codes pkceCodes, err := claude.GeneratePKCECodes() if err != nil { log.Errorf("Failed to generate PKCE codes: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate PKCE codes"}) return } // Generate random state parameter state, err := misc.GenerateRandomState() if err != nil { log.Errorf("Failed to generate state parameter: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate state parameter"}) return } // Initialize Claude auth service anthropicAuth := claude.NewClaudeAuth(h.cfg) // Generate authorization URL (then override redirect_uri to reuse server port) authURL, state, err := anthropicAuth.GenerateAuthURL(state, pkceCodes) if err != nil { log.Errorf("Failed to generate authorization URL: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"}) return } RegisterOAuthSession(state, "anthropic") isWebUI := isWebUIRequest(c) if isWebUI { targetURL, errTarget := h.managementCallbackURL("/anthropic/callback") if errTarget != nil { log.WithError(errTarget).Error("failed to compute anthropic callback target") c.JSON(http.StatusInternalServerError, gin.H{"error": "callback server unavailable"}) return } if _, errStart := startCallbackForwarder(anthropicCallbackPort, "anthropic", targetURL); errStart != nil { log.WithError(errStart).Error("failed to start anthropic callback forwarder") c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start callback server"}) return } } go func() { if isWebUI { defer stopCallbackForwarder(anthropicCallbackPort) } // Helper: wait for callback file waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-anthropic-%s.oauth", state)) waitForFile := func(path string, timeout time.Duration) (map[string]string, error) { deadline := time.Now().Add(timeout) for { if time.Now().After(deadline) { SetOAuthSessionError(state, "Timeout waiting for OAuth callback") return nil, fmt.Errorf("timeout waiting for OAuth callback") } data, errRead := os.ReadFile(path) if errRead == nil { var m map[string]string _ = json.Unmarshal(data, &m) _ = os.Remove(path) return m, nil } time.Sleep(500 * time.Millisecond) } } fmt.Println("Waiting for authentication callback...") // Wait up to 5 minutes resultMap, errWait := waitForFile(waitFile, 5*time.Minute) if errWait != nil { authErr := claude.NewAuthenticationError(claude.ErrCallbackTimeout, errWait) log.Error(claude.GetUserFriendlyMessage(authErr)) return } if errStr := resultMap["error"]; errStr != "" { oauthErr := claude.NewOAuthError(errStr, "", http.StatusBadRequest) log.Error(claude.GetUserFriendlyMessage(oauthErr)) SetOAuthSessionError(state, "Bad request") return } if resultMap["state"] != state { authErr := claude.NewAuthenticationError(claude.ErrInvalidState, fmt.Errorf("expected %s, got %s", state, resultMap["state"])) log.Error(claude.GetUserFriendlyMessage(authErr)) SetOAuthSessionError(state, "State code error") return } // Parse code (Claude may append state after '#') rawCode := resultMap["code"] code := strings.Split(rawCode, "#")[0] // Exchange code for tokens (replicate logic using updated redirect_uri) // Extract client_id from the modified auth URL clientID := "" if u2, errP := url.Parse(authURL); errP == nil { clientID = u2.Query().Get("client_id") } // Build request bodyMap := map[string]any{ "code": code, "state": state, "grant_type": "authorization_code", "client_id": clientID, "redirect_uri": "http://localhost:54545/callback", "code_verifier": pkceCodes.CodeVerifier, } bodyJSON, _ := json.Marshal(bodyMap) httpClient := util.SetProxy(&h.cfg.SDKConfig, &http.Client{}) req, _ := http.NewRequestWithContext(ctx, "POST", "https://console.anthropic.com/v1/oauth/token", strings.NewReader(string(bodyJSON))) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") resp, errDo := httpClient.Do(req) if errDo != nil { authErr := claude.NewAuthenticationError(claude.ErrCodeExchangeFailed, errDo) log.Errorf("Failed to exchange authorization code for tokens: %v", authErr) SetOAuthSessionError(state, "Failed to exchange authorization code for tokens") return } defer func() { if errClose := resp.Body.Close(); errClose != nil { log.Errorf("failed to close response body: %v", errClose) } }() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { log.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(respBody)) SetOAuthSessionError(state, fmt.Sprintf("token exchange failed with status %d", resp.StatusCode)) return } var tResp struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int `json:"expires_in"` Account struct { EmailAddress string `json:"email_address"` } `json:"account"` } if errU := json.Unmarshal(respBody, &tResp); errU != nil { log.Errorf("failed to parse token response: %v", errU) SetOAuthSessionError(state, "Failed to parse token response") return } bundle := &claude.ClaudeAuthBundle{ TokenData: claude.ClaudeTokenData{ AccessToken: tResp.AccessToken, RefreshToken: tResp.RefreshToken, Email: tResp.Account.EmailAddress, Expire: time.Now().Add(time.Duration(tResp.ExpiresIn) * time.Second).Format(time.RFC3339), }, LastRefresh: time.Now().Format(time.RFC3339), } // Create token storage tokenStorage := anthropicAuth.CreateTokenStorage(bundle) record := &coreauth.Auth{ ID: fmt.Sprintf("claude-%s.json", tokenStorage.Email), Provider: "claude", FileName: fmt.Sprintf("claude-%s.json", tokenStorage.Email), Storage: tokenStorage, Metadata: map[string]any{"email": tokenStorage.Email}, } savedPath, errSave := h.saveTokenRecord(ctx, record) if errSave != nil { log.Errorf("Failed to save authentication tokens: %v", errSave) SetOAuthSessionError(state, "Failed to save authentication tokens") return } fmt.Printf("Authentication successful! Token saved to %s\n", savedPath) if bundle.APIKey != "" { fmt.Println("API key obtained and saved") } fmt.Println("You can now use Claude services through this CLI") CompleteOAuthSession(state) }() c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state}) } func (h *Handler) RequestGeminiCLIToken(c *gin.Context) { ctx := context.Background() proxyHTTPClient := util.SetProxy(&h.cfg.SDKConfig, &http.Client{}) ctx = context.WithValue(ctx, oauth2.HTTPClient, proxyHTTPClient) // Optional project ID from query projectID := c.Query("project_id") fmt.Println("Initializing Google authentication...") // OAuth2 configuration (mirrors internal/auth/gemini) conf := &oauth2.Config{ ClientID: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com", ClientSecret: "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", RedirectURL: "http://localhost:8085/oauth2callback", Scopes: []string{ "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile", }, Endpoint: google.Endpoint, } // Build authorization URL and return it immediately state := fmt.Sprintf("gem-%d", time.Now().UnixNano()) authURL := conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent")) RegisterOAuthSession(state, "gemini") isWebUI := isWebUIRequest(c) if isWebUI { targetURL, errTarget := h.managementCallbackURL("/google/callback") if errTarget != nil { log.WithError(errTarget).Error("failed to compute gemini callback target") c.JSON(http.StatusInternalServerError, gin.H{"error": "callback server unavailable"}) return } if _, errStart := startCallbackForwarder(geminiCallbackPort, "gemini", targetURL); errStart != nil { log.WithError(errStart).Error("failed to start gemini callback forwarder") c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start callback server"}) return } } go func() { if isWebUI { defer stopCallbackForwarder(geminiCallbackPort) } // Wait for callback file written by server route waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-gemini-%s.oauth", state)) fmt.Println("Waiting for authentication callback...") deadline := time.Now().Add(5 * time.Minute) var authCode string for { if time.Now().After(deadline) { log.Error("oauth flow timed out") SetOAuthSessionError(state, "OAuth flow timed out") return } if data, errR := os.ReadFile(waitFile); errR == nil { var m map[string]string _ = json.Unmarshal(data, &m) _ = os.Remove(waitFile) if errStr := m["error"]; errStr != "" { log.Errorf("Authentication failed: %s", errStr) SetOAuthSessionError(state, "Authentication failed") return } authCode = m["code"] if authCode == "" { log.Errorf("Authentication failed: code not found") SetOAuthSessionError(state, "Authentication failed: code not found") return } break } time.Sleep(500 * time.Millisecond) } // Exchange authorization code for token token, err := conf.Exchange(ctx, authCode) if err != nil { log.Errorf("Failed to exchange token: %v", err) SetOAuthSessionError(state, "Failed to exchange token") return } requestedProjectID := strings.TrimSpace(projectID) // Create token storage (mirrors internal/auth/gemini createTokenStorage) authHTTPClient := conf.Client(ctx, token) req, errNewRequest := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", nil) if errNewRequest != nil { log.Errorf("Could not get user info: %v", errNewRequest) SetOAuthSessionError(state, "Could not get user info") return } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken)) resp, errDo := authHTTPClient.Do(req) if errDo != nil { log.Errorf("Failed to execute request: %v", errDo) SetOAuthSessionError(state, "Failed to execute request") return } defer func() { if errClose := resp.Body.Close(); errClose != nil { log.Printf("warn: failed to close response body: %v", errClose) } }() bodyBytes, _ := io.ReadAll(resp.Body) if resp.StatusCode < 200 || resp.StatusCode >= 300 { log.Errorf("Get user info request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) SetOAuthSessionError(state, fmt.Sprintf("Get user info request failed with status %d", resp.StatusCode)) return } email := gjson.GetBytes(bodyBytes, "email").String() if email != "" { fmt.Printf("Authenticated user email: %s\n", email) } else { fmt.Println("Failed to get user email from token") } // Marshal/unmarshal oauth2.Token to generic map and enrich fields var ifToken map[string]any jsonData, _ := json.Marshal(token) if errUnmarshal := json.Unmarshal(jsonData, &ifToken); errUnmarshal != nil { log.Errorf("Failed to unmarshal token: %v", errUnmarshal) SetOAuthSessionError(state, "Failed to unmarshal token") return } ifToken["token_uri"] = "https://oauth2.googleapis.com/token" ifToken["client_id"] = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com" ifToken["client_secret"] = "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" ifToken["scopes"] = []string{ "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile", } ifToken["universe_domain"] = "googleapis.com" ts := geminiAuth.GeminiTokenStorage{ Token: ifToken, ProjectID: requestedProjectID, Email: email, Auto: requestedProjectID == "", } // Initialize authenticated HTTP client via GeminiAuth to honor proxy settings gemAuth := geminiAuth.NewGeminiAuth() gemClient, errGetClient := gemAuth.GetAuthenticatedClient(ctx, &ts, h.cfg, true) if errGetClient != nil { log.Errorf("failed to get authenticated client: %v", errGetClient) SetOAuthSessionError(state, "Failed to get authenticated client") return } fmt.Println("Authentication successful.") if strings.EqualFold(requestedProjectID, "ALL") { ts.Auto = false projects, errAll := onboardAllGeminiProjects(ctx, gemClient, &ts) if errAll != nil { log.Errorf("Failed to complete Gemini CLI onboarding: %v", errAll) SetOAuthSessionError(state, "Failed to complete Gemini CLI onboarding") return } if errVerify := ensureGeminiProjectsEnabled(ctx, gemClient, projects); errVerify != nil { log.Errorf("Failed to verify Cloud AI API status: %v", errVerify) SetOAuthSessionError(state, "Failed to verify Cloud AI API status") return } ts.ProjectID = strings.Join(projects, ",") ts.Checked = true } else { if errEnsure := ensureGeminiProjectAndOnboard(ctx, gemClient, &ts, requestedProjectID); errEnsure != nil { log.Errorf("Failed to complete Gemini CLI onboarding: %v", errEnsure) SetOAuthSessionError(state, "Failed to complete Gemini CLI onboarding") return } if strings.TrimSpace(ts.ProjectID) == "" { log.Error("Onboarding did not return a project ID") SetOAuthSessionError(state, "Failed to resolve project ID") return } isChecked, errCheck := checkCloudAPIIsEnabled(ctx, gemClient, ts.ProjectID) if errCheck != nil { log.Errorf("Failed to verify Cloud AI API status: %v", errCheck) SetOAuthSessionError(state, "Failed to verify Cloud AI API status") return } ts.Checked = isChecked if !isChecked { log.Error("Cloud AI API is not enabled for the selected project") SetOAuthSessionError(state, "Cloud AI API not enabled") return } } recordMetadata := map[string]any{ "email": ts.Email, "project_id": ts.ProjectID, "auto": ts.Auto, "checked": ts.Checked, } fileName := geminiAuth.CredentialFileName(ts.Email, ts.ProjectID, true) record := &coreauth.Auth{ ID: fileName, Provider: "gemini", FileName: fileName, Storage: &ts, Metadata: recordMetadata, } savedPath, errSave := h.saveTokenRecord(ctx, record) if errSave != nil { log.Errorf("Failed to save token to file: %v", errSave) SetOAuthSessionError(state, "Failed to save token to file") return } CompleteOAuthSession(state) fmt.Printf("You can now use Gemini CLI services through this CLI; token saved to %s\n", savedPath) }() c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state}) } func (h *Handler) RequestCodexToken(c *gin.Context) { ctx := context.Background() fmt.Println("Initializing Codex authentication...") // Generate PKCE codes pkceCodes, err := codex.GeneratePKCECodes() if err != nil { log.Errorf("Failed to generate PKCE codes: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate PKCE codes"}) return } // Generate random state parameter state, err := misc.GenerateRandomState() if err != nil { log.Errorf("Failed to generate state parameter: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate state parameter"}) return } // Initialize Codex auth service openaiAuth := codex.NewCodexAuth(h.cfg) // Generate authorization URL authURL, err := openaiAuth.GenerateAuthURL(state, pkceCodes) if err != nil { log.Errorf("Failed to generate authorization URL: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"}) return } RegisterOAuthSession(state, "codex") isWebUI := isWebUIRequest(c) if isWebUI { targetURL, errTarget := h.managementCallbackURL("/codex/callback") if errTarget != nil { log.WithError(errTarget).Error("failed to compute codex callback target") c.JSON(http.StatusInternalServerError, gin.H{"error": "callback server unavailable"}) return } if _, errStart := startCallbackForwarder(codexCallbackPort, "codex", targetURL); errStart != nil { log.WithError(errStart).Error("failed to start codex callback forwarder") c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start callback server"}) return } } go func() { if isWebUI { defer stopCallbackForwarder(codexCallbackPort) } // Wait for callback file waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-codex-%s.oauth", state)) deadline := time.Now().Add(5 * time.Minute) var code string for { if time.Now().After(deadline) { authErr := codex.NewAuthenticationError(codex.ErrCallbackTimeout, fmt.Errorf("timeout waiting for OAuth callback")) log.Error(codex.GetUserFriendlyMessage(authErr)) SetOAuthSessionError(state, "Timeout waiting for OAuth callback") return } if data, errR := os.ReadFile(waitFile); errR == nil { var m map[string]string _ = json.Unmarshal(data, &m) _ = os.Remove(waitFile) if errStr := m["error"]; errStr != "" { oauthErr := codex.NewOAuthError(errStr, "", http.StatusBadRequest) log.Error(codex.GetUserFriendlyMessage(oauthErr)) SetOAuthSessionError(state, "Bad Request") return } if m["state"] != state { authErr := codex.NewAuthenticationError(codex.ErrInvalidState, fmt.Errorf("expected %s, got %s", state, m["state"])) SetOAuthSessionError(state, "State code error") log.Error(codex.GetUserFriendlyMessage(authErr)) return } code = m["code"] break } time.Sleep(500 * time.Millisecond) } log.Debug("Authorization code received, exchanging for tokens...") // Extract client_id from authURL clientID := "" if u2, errP := url.Parse(authURL); errP == nil { clientID = u2.Query().Get("client_id") } // Exchange code for tokens with redirect equal to mgmtRedirect form := url.Values{ "grant_type": {"authorization_code"}, "client_id": {clientID}, "code": {code}, "redirect_uri": {"http://localhost:1455/auth/callback"}, "code_verifier": {pkceCodes.CodeVerifier}, } httpClient := util.SetProxy(&h.cfg.SDKConfig, &http.Client{}) req, _ := http.NewRequestWithContext(ctx, "POST", "https://auth.openai.com/oauth/token", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") resp, errDo := httpClient.Do(req) if errDo != nil { authErr := codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, errDo) SetOAuthSessionError(state, "Failed to exchange authorization code for tokens") log.Errorf("Failed to exchange authorization code for tokens: %v", authErr) return } defer func() { _ = resp.Body.Close() }() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { SetOAuthSessionError(state, fmt.Sprintf("Token exchange failed with status %d", resp.StatusCode)) log.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(respBody)) return } var tokenResp struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` IDToken string `json:"id_token"` ExpiresIn int `json:"expires_in"` } if errU := json.Unmarshal(respBody, &tokenResp); errU != nil { SetOAuthSessionError(state, "Failed to parse token response") log.Errorf("failed to parse token response: %v", errU) return } claims, _ := codex.ParseJWTToken(tokenResp.IDToken) email := "" accountID := "" if claims != nil { email = claims.GetUserEmail() accountID = claims.GetAccountID() } // Build bundle compatible with existing storage bundle := &codex.CodexAuthBundle{ TokenData: codex.CodexTokenData{ IDToken: tokenResp.IDToken, AccessToken: tokenResp.AccessToken, RefreshToken: tokenResp.RefreshToken, AccountID: accountID, Email: email, Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339), }, LastRefresh: time.Now().Format(time.RFC3339), } // Create token storage and persist tokenStorage := openaiAuth.CreateTokenStorage(bundle) record := &coreauth.Auth{ ID: fmt.Sprintf("codex-%s.json", tokenStorage.Email), Provider: "codex", FileName: fmt.Sprintf("codex-%s.json", tokenStorage.Email), Storage: tokenStorage, Metadata: map[string]any{ "email": tokenStorage.Email, "account_id": tokenStorage.AccountID, }, } savedPath, errSave := h.saveTokenRecord(ctx, record) if errSave != nil { SetOAuthSessionError(state, "Failed to save authentication tokens") log.Errorf("Failed to save authentication tokens: %v", errSave) return } fmt.Printf("Authentication successful! Token saved to %s\n", savedPath) if bundle.APIKey != "" { fmt.Println("API key obtained and saved") } fmt.Println("You can now use Codex services through this CLI") CompleteOAuthSession(state) }() c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state}) } func (h *Handler) RequestAntigravityToken(c *gin.Context) { const ( antigravityCallbackPort = 51121 antigravityClientID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" antigravityClientSecret = "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" ) var antigravityScopes = []string{ "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/cclog", "https://www.googleapis.com/auth/experimentsandconfigs", } ctx := context.Background() fmt.Println("Initializing Antigravity authentication...") state, errState := misc.GenerateRandomState() if errState != nil { log.Errorf("Failed to generate state parameter: %v", errState) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate state parameter"}) return } redirectURI := fmt.Sprintf("http://localhost:%d/oauth-callback", antigravityCallbackPort) params := url.Values{} params.Set("access_type", "offline") params.Set("client_id", antigravityClientID) params.Set("prompt", "consent") params.Set("redirect_uri", redirectURI) params.Set("response_type", "code") params.Set("scope", strings.Join(antigravityScopes, " ")) params.Set("state", state) authURL := "https://accounts.google.com/o/oauth2/v2/auth?" + params.Encode() RegisterOAuthSession(state, "antigravity") isWebUI := isWebUIRequest(c) if isWebUI { targetURL, errTarget := h.managementCallbackURL("/antigravity/callback") if errTarget != nil { log.WithError(errTarget).Error("failed to compute antigravity callback target") c.JSON(http.StatusInternalServerError, gin.H{"error": "callback server unavailable"}) return } if _, errStart := startCallbackForwarder(antigravityCallbackPort, "antigravity", targetURL); errStart != nil { log.WithError(errStart).Error("failed to start antigravity callback forwarder") c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start callback server"}) return } } go func() { if isWebUI { defer stopCallbackForwarder(antigravityCallbackPort) } waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-antigravity-%s.oauth", state)) deadline := time.Now().Add(5 * time.Minute) var authCode string for { if time.Now().After(deadline) { log.Error("oauth flow timed out") SetOAuthSessionError(state, "OAuth flow timed out") return } if data, errReadFile := os.ReadFile(waitFile); errReadFile == nil { var payload map[string]string _ = json.Unmarshal(data, &payload) _ = os.Remove(waitFile) if errStr := strings.TrimSpace(payload["error"]); errStr != "" { log.Errorf("Authentication failed: %s", errStr) SetOAuthSessionError(state, "Authentication failed") return } if payloadState := strings.TrimSpace(payload["state"]); payloadState != "" && payloadState != state { log.Errorf("Authentication failed: state mismatch") SetOAuthSessionError(state, "Authentication failed: state mismatch") return } authCode = strings.TrimSpace(payload["code"]) if authCode == "" { log.Error("Authentication failed: code not found") SetOAuthSessionError(state, "Authentication failed: code not found") return } break } time.Sleep(500 * time.Millisecond) } httpClient := util.SetProxy(&h.cfg.SDKConfig, &http.Client{}) form := url.Values{} form.Set("code", authCode) form.Set("client_id", antigravityClientID) form.Set("client_secret", antigravityClientSecret) form.Set("redirect_uri", redirectURI) form.Set("grant_type", "authorization_code") req, errNewRequest := http.NewRequestWithContext(ctx, http.MethodPost, "https://oauth2.googleapis.com/token", strings.NewReader(form.Encode())) if errNewRequest != nil { log.Errorf("Failed to build token request: %v", errNewRequest) SetOAuthSessionError(state, "Failed to build token request") return } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, errDo := httpClient.Do(req) if errDo != nil { log.Errorf("Failed to execute token request: %v", errDo) SetOAuthSessionError(state, "Failed to exchange token") return } defer func() { if errClose := resp.Body.Close(); errClose != nil { log.Errorf("antigravity token exchange close error: %v", errClose) } }() if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { bodyBytes, _ := io.ReadAll(resp.Body) log.Errorf("Antigravity token exchange failed with status %d: %s", resp.StatusCode, string(bodyBytes)) SetOAuthSessionError(state, fmt.Sprintf("Token exchange failed: %d", resp.StatusCode)) return } var tokenResp struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int64 `json:"expires_in"` TokenType string `json:"token_type"` } if errDecode := json.NewDecoder(resp.Body).Decode(&tokenResp); errDecode != nil { log.Errorf("Failed to parse token response: %v", errDecode) SetOAuthSessionError(state, "Failed to parse token response") return } email := "" if strings.TrimSpace(tokenResp.AccessToken) != "" { infoReq, errInfoReq := http.NewRequestWithContext(ctx, http.MethodGet, "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", nil) if errInfoReq != nil { log.Errorf("Failed to build user info request: %v", errInfoReq) SetOAuthSessionError(state, "Failed to build user info request") return } infoReq.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken) infoResp, errInfo := httpClient.Do(infoReq) if errInfo != nil { log.Errorf("Failed to execute user info request: %v", errInfo) SetOAuthSessionError(state, "Failed to execute user info request") return } defer func() { if errClose := infoResp.Body.Close(); errClose != nil { log.Errorf("antigravity user info close error: %v", errClose) } }() if infoResp.StatusCode >= http.StatusOK && infoResp.StatusCode < http.StatusMultipleChoices { var infoPayload struct { Email string `json:"email"` } if errDecodeInfo := json.NewDecoder(infoResp.Body).Decode(&infoPayload); errDecodeInfo == nil { email = strings.TrimSpace(infoPayload.Email) } } else { bodyBytes, _ := io.ReadAll(infoResp.Body) log.Errorf("User info request failed with status %d: %s", infoResp.StatusCode, string(bodyBytes)) SetOAuthSessionError(state, fmt.Sprintf("User info request failed: %d", infoResp.StatusCode)) return } } projectID := "" if strings.TrimSpace(tokenResp.AccessToken) != "" { fetchedProjectID, errProject := sdkAuth.FetchAntigravityProjectID(ctx, tokenResp.AccessToken, httpClient) if errProject != nil { log.Warnf("antigravity: failed to fetch project ID: %v", errProject) } else { projectID = fetchedProjectID log.Infof("antigravity: obtained project ID %s", projectID) } } now := time.Now() metadata := map[string]any{ "type": "antigravity", "access_token": tokenResp.AccessToken, "refresh_token": tokenResp.RefreshToken, "expires_in": tokenResp.ExpiresIn, "timestamp": now.UnixMilli(), "expired": now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339), } if email != "" { metadata["email"] = email } if projectID != "" { metadata["project_id"] = projectID } fileName := sanitizeAntigravityFileName(email) label := strings.TrimSpace(email) if label == "" { label = "antigravity" } record := &coreauth.Auth{ ID: fileName, Provider: "antigravity", FileName: fileName, Label: label, Metadata: metadata, } savedPath, errSave := h.saveTokenRecord(ctx, record) if errSave != nil { log.Errorf("Failed to save token to file: %v", errSave) SetOAuthSessionError(state, "Failed to save token to file") return } CompleteOAuthSession(state) fmt.Printf("Authentication successful! Token saved to %s\n", savedPath) if projectID != "" { fmt.Printf("Using GCP project: %s\n", projectID) } fmt.Println("You can now use Antigravity services through this CLI") }() c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state}) } func (h *Handler) RequestQwenToken(c *gin.Context) { ctx := context.Background() fmt.Println("Initializing Qwen authentication...") state := fmt.Sprintf("gem-%d", time.Now().UnixNano()) // Initialize Qwen auth service qwenAuth := qwen.NewQwenAuth(h.cfg) // Generate authorization URL deviceFlow, err := qwenAuth.InitiateDeviceFlow(ctx) if err != nil { log.Errorf("Failed to generate authorization URL: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"}) return } authURL := deviceFlow.VerificationURIComplete RegisterOAuthSession(state, "qwen") go func() { fmt.Println("Waiting for authentication...") tokenData, errPollForToken := qwenAuth.PollForToken(deviceFlow.DeviceCode, deviceFlow.CodeVerifier) if errPollForToken != nil { SetOAuthSessionError(state, "Authentication failed") fmt.Printf("Authentication failed: %v\n", errPollForToken) return } // Create token storage tokenStorage := qwenAuth.CreateTokenStorage(tokenData) tokenStorage.Email = fmt.Sprintf("qwen-%d", time.Now().UnixMilli()) record := &coreauth.Auth{ ID: fmt.Sprintf("qwen-%s.json", tokenStorage.Email), Provider: "qwen", FileName: fmt.Sprintf("qwen-%s.json", tokenStorage.Email), Storage: tokenStorage, Metadata: map[string]any{"email": tokenStorage.Email}, } savedPath, errSave := h.saveTokenRecord(ctx, record) if errSave != nil { log.Errorf("Failed to save authentication tokens: %v", errSave) SetOAuthSessionError(state, "Failed to save authentication tokens") return } fmt.Printf("Authentication successful! Token saved to %s\n", savedPath) fmt.Println("You can now use Qwen services through this CLI") CompleteOAuthSession(state) }() c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state}) } func (h *Handler) RequestIFlowToken(c *gin.Context) { ctx := context.Background() fmt.Println("Initializing iFlow authentication...") state := fmt.Sprintf("ifl-%d", time.Now().UnixNano()) authSvc := iflowauth.NewIFlowAuth(h.cfg) authURL, redirectURI := authSvc.AuthorizationURL(state, iflowauth.CallbackPort) RegisterOAuthSession(state, "iflow") isWebUI := isWebUIRequest(c) if isWebUI { targetURL, errTarget := h.managementCallbackURL("/iflow/callback") if errTarget != nil { log.WithError(errTarget).Error("failed to compute iflow callback target") c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "callback server unavailable"}) return } if _, errStart := startCallbackForwarder(iflowauth.CallbackPort, "iflow", targetURL); errStart != nil { log.WithError(errStart).Error("failed to start iflow callback forwarder") c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to start callback server"}) return } } go func() { if isWebUI { defer stopCallbackForwarder(iflowauth.CallbackPort) } fmt.Println("Waiting for authentication...") waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-iflow-%s.oauth", state)) deadline := time.Now().Add(5 * time.Minute) var resultMap map[string]string for { if time.Now().After(deadline) { SetOAuthSessionError(state, "Authentication failed") fmt.Println("Authentication failed: timeout waiting for callback") return } if data, errR := os.ReadFile(waitFile); errR == nil { _ = os.Remove(waitFile) _ = json.Unmarshal(data, &resultMap) break } time.Sleep(500 * time.Millisecond) } if errStr := strings.TrimSpace(resultMap["error"]); errStr != "" { SetOAuthSessionError(state, "Authentication failed") fmt.Printf("Authentication failed: %s\n", errStr) return } if resultState := strings.TrimSpace(resultMap["state"]); resultState != state { SetOAuthSessionError(state, "Authentication failed") fmt.Println("Authentication failed: state mismatch") return } code := strings.TrimSpace(resultMap["code"]) if code == "" { SetOAuthSessionError(state, "Authentication failed") fmt.Println("Authentication failed: code missing") return } tokenData, errExchange := authSvc.ExchangeCodeForTokens(ctx, code, redirectURI) if errExchange != nil { SetOAuthSessionError(state, "Authentication failed") fmt.Printf("Authentication failed: %v\n", errExchange) return } tokenStorage := authSvc.CreateTokenStorage(tokenData) identifier := strings.TrimSpace(tokenStorage.Email) if identifier == "" { identifier = fmt.Sprintf("iflow-%d", time.Now().UnixMilli()) tokenStorage.Email = identifier } record := &coreauth.Auth{ ID: fmt.Sprintf("iflow-%s.json", identifier), Provider: "iflow", FileName: fmt.Sprintf("iflow-%s.json", identifier), Storage: tokenStorage, Metadata: map[string]any{"email": identifier, "api_key": tokenStorage.APIKey}, Attributes: map[string]string{"api_key": tokenStorage.APIKey}, } savedPath, errSave := h.saveTokenRecord(ctx, record) if errSave != nil { SetOAuthSessionError(state, "Failed to save authentication tokens") log.Errorf("Failed to save authentication tokens: %v", errSave) return } fmt.Printf("Authentication successful! Token saved to %s\n", savedPath) if tokenStorage.APIKey != "" { fmt.Println("API key obtained and saved") } fmt.Println("You can now use iFlow services through this CLI") CompleteOAuthSession(state) }() c.JSON(http.StatusOK, gin.H{"status": "ok", "url": authURL, "state": state}) } func (h *Handler) RequestIFlowCookieToken(c *gin.Context) { ctx := context.Background() var payload struct { Cookie string `json:"cookie"` } if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "cookie is required"}) return } cookieValue := strings.TrimSpace(payload.Cookie) if cookieValue == "" { c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "cookie is required"}) return } cookieValue, errNormalize := iflowauth.NormalizeCookie(cookieValue) if errNormalize != nil { c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": errNormalize.Error()}) return } // Check for duplicate BXAuth before authentication bxAuth := iflowauth.ExtractBXAuth(cookieValue) if existingFile, err := iflowauth.CheckDuplicateBXAuth(h.cfg.AuthDir, bxAuth); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to check duplicate"}) return } else if existingFile != "" { existingFileName := filepath.Base(existingFile) c.JSON(http.StatusConflict, gin.H{"status": "error", "error": "duplicate BXAuth found", "existing_file": existingFileName}) return } authSvc := iflowauth.NewIFlowAuth(h.cfg) tokenData, errAuth := authSvc.AuthenticateWithCookie(ctx, cookieValue) if errAuth != nil { c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": errAuth.Error()}) return } tokenData.Cookie = cookieValue tokenStorage := authSvc.CreateCookieTokenStorage(tokenData) email := strings.TrimSpace(tokenStorage.Email) if email == "" { c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "failed to extract email from token"}) return } fileName := iflowauth.SanitizeIFlowFileName(email) if fileName == "" { fileName = fmt.Sprintf("iflow-%d", time.Now().UnixMilli()) } tokenStorage.Email = email timestamp := time.Now().Unix() record := &coreauth.Auth{ ID: fmt.Sprintf("iflow-%s-%d.json", fileName, timestamp), Provider: "iflow", FileName: fmt.Sprintf("iflow-%s-%d.json", fileName, timestamp), Storage: tokenStorage, Metadata: map[string]any{ "email": email, "api_key": tokenStorage.APIKey, "expired": tokenStorage.Expire, "cookie": tokenStorage.Cookie, "type": tokenStorage.Type, "last_refresh": tokenStorage.LastRefresh, }, Attributes: map[string]string{ "api_key": tokenStorage.APIKey, }, } savedPath, errSave := h.saveTokenRecord(ctx, record) if errSave != nil { c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to save authentication tokens"}) return } fmt.Printf("iFlow cookie authentication successful. Token saved to %s\n", savedPath) c.JSON(http.StatusOK, gin.H{ "status": "ok", "saved_path": savedPath, "email": email, "expired": tokenStorage.Expire, "type": tokenStorage.Type, }) } type projectSelectionRequiredError struct{} func (e *projectSelectionRequiredError) Error() string { return "gemini cli: project selection required" } func ensureGeminiProjectAndOnboard(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage, requestedProject string) error { if storage == nil { return fmt.Errorf("gemini storage is nil") } trimmedRequest := strings.TrimSpace(requestedProject) if trimmedRequest == "" { projects, errProjects := fetchGCPProjects(ctx, httpClient) if errProjects != nil { return fmt.Errorf("fetch project list: %w", errProjects) } if len(projects) == 0 { return fmt.Errorf("no Google Cloud projects available for this account") } trimmedRequest = strings.TrimSpace(projects[0].ProjectID) if trimmedRequest == "" { return fmt.Errorf("resolved project id is empty") } storage.Auto = true } else { storage.Auto = false } if err := performGeminiCLISetup(ctx, httpClient, storage, trimmedRequest); err != nil { return err } if strings.TrimSpace(storage.ProjectID) == "" { storage.ProjectID = trimmedRequest } return nil } func onboardAllGeminiProjects(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage) ([]string, error) { projects, errProjects := fetchGCPProjects(ctx, httpClient) if errProjects != nil { return nil, fmt.Errorf("fetch project list: %w", errProjects) } if len(projects) == 0 { return nil, fmt.Errorf("no Google Cloud projects available for this account") } activated := make([]string, 0, len(projects)) seen := make(map[string]struct{}, len(projects)) for _, project := range projects { candidate := strings.TrimSpace(project.ProjectID) if candidate == "" { continue } if _, dup := seen[candidate]; dup { continue } if err := performGeminiCLISetup(ctx, httpClient, storage, candidate); err != nil { return nil, fmt.Errorf("onboard project %s: %w", candidate, err) } finalID := strings.TrimSpace(storage.ProjectID) if finalID == "" { finalID = candidate } activated = append(activated, finalID) seen[candidate] = struct{}{} } if len(activated) == 0 { return nil, fmt.Errorf("no Google Cloud projects available for this account") } return activated, nil } func ensureGeminiProjectsEnabled(ctx context.Context, httpClient *http.Client, projectIDs []string) error { for _, pid := range projectIDs { trimmed := strings.TrimSpace(pid) if trimmed == "" { continue } isChecked, errCheck := checkCloudAPIIsEnabled(ctx, httpClient, trimmed) if errCheck != nil { return fmt.Errorf("project %s: %w", trimmed, errCheck) } if !isChecked { return fmt.Errorf("project %s: Cloud AI API not enabled", trimmed) } } return nil } func performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage, requestedProject string) error { metadata := map[string]string{ "ideType": "IDE_UNSPECIFIED", "platform": "PLATFORM_UNSPECIFIED", "pluginType": "GEMINI", } trimmedRequest := strings.TrimSpace(requestedProject) explicitProject := trimmedRequest != "" loadReqBody := map[string]any{ "metadata": metadata, } if explicitProject { loadReqBody["cloudaicompanionProject"] = trimmedRequest } var loadResp map[string]any if errLoad := callGeminiCLI(ctx, httpClient, "loadCodeAssist", loadReqBody, &loadResp); errLoad != nil { return fmt.Errorf("load code assist: %w", errLoad) } tierID := "legacy-tier" if tiers, okTiers := loadResp["allowedTiers"].([]any); okTiers { for _, rawTier := range tiers { tier, okTier := rawTier.(map[string]any) if !okTier { continue } if isDefault, okDefault := tier["isDefault"].(bool); okDefault && isDefault { if id, okID := tier["id"].(string); okID && strings.TrimSpace(id) != "" { tierID = strings.TrimSpace(id) break } } } } projectID := trimmedRequest if projectID == "" { if id, okProject := loadResp["cloudaicompanionProject"].(string); okProject { projectID = strings.TrimSpace(id) } if projectID == "" { if projectMap, okProject := loadResp["cloudaicompanionProject"].(map[string]any); okProject { if id, okID := projectMap["id"].(string); okID { projectID = strings.TrimSpace(id) } } } } if projectID == "" { return &projectSelectionRequiredError{} } onboardReqBody := map[string]any{ "tierId": tierID, "metadata": metadata, "cloudaicompanionProject": projectID, } storage.ProjectID = projectID for { var onboardResp map[string]any if errOnboard := callGeminiCLI(ctx, httpClient, "onboardUser", onboardReqBody, &onboardResp); errOnboard != nil { return fmt.Errorf("onboard user: %w", errOnboard) } if done, okDone := onboardResp["done"].(bool); okDone && done { responseProjectID := "" if resp, okResp := onboardResp["response"].(map[string]any); okResp { switch projectValue := resp["cloudaicompanionProject"].(type) { case map[string]any: if id, okID := projectValue["id"].(string); okID { responseProjectID = strings.TrimSpace(id) } case string: responseProjectID = strings.TrimSpace(projectValue) } } finalProjectID := projectID if responseProjectID != "" { if explicitProject && !strings.EqualFold(responseProjectID, projectID) { log.Warnf("Gemini onboarding returned project %s instead of requested %s; keeping requested project ID.", responseProjectID, projectID) } else { finalProjectID = responseProjectID } } storage.ProjectID = strings.TrimSpace(finalProjectID) if storage.ProjectID == "" { storage.ProjectID = strings.TrimSpace(projectID) } if storage.ProjectID == "" { return fmt.Errorf("onboard user completed without project id") } log.Infof("Onboarding complete. Using Project ID: %s", storage.ProjectID) return nil } log.Println("Onboarding in progress, waiting 5 seconds...") time.Sleep(5 * time.Second) } } func callGeminiCLI(ctx context.Context, httpClient *http.Client, endpoint string, body any, result any) error { endPointURL := fmt.Sprintf("%s/%s:%s", geminiCLIEndpoint, geminiCLIVersion, endpoint) if strings.HasPrefix(endpoint, "operations/") { endPointURL = fmt.Sprintf("%s/%s", geminiCLIEndpoint, endpoint) } var reader io.Reader if body != nil { rawBody, errMarshal := json.Marshal(body) if errMarshal != nil { return fmt.Errorf("marshal request body: %w", errMarshal) } reader = bytes.NewReader(rawBody) } req, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, endPointURL, reader) if errRequest != nil { return fmt.Errorf("create request: %w", errRequest) } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", geminiCLIUserAgent) req.Header.Set("X-Goog-Api-Client", geminiCLIApiClient) req.Header.Set("Client-Metadata", geminiCLIClientMetadata) resp, errDo := httpClient.Do(req) if errDo != nil { return fmt.Errorf("execute request: %w", errDo) } defer func() { if errClose := resp.Body.Close(); errClose != nil { log.Errorf("response body close error: %v", errClose) } }() if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { bodyBytes, _ := io.ReadAll(resp.Body) return fmt.Errorf("api request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes))) } if result == nil { _, _ = io.Copy(io.Discard, resp.Body) return nil } if errDecode := json.NewDecoder(resp.Body).Decode(result); errDecode != nil { return fmt.Errorf("decode response body: %w", errDecode) } return nil } func fetchGCPProjects(ctx context.Context, httpClient *http.Client) ([]interfaces.GCPProjectProjects, error) { req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, "https://cloudresourcemanager.googleapis.com/v1/projects", nil) if errRequest != nil { return nil, fmt.Errorf("could not create project list request: %w", errRequest) } resp, errDo := httpClient.Do(req) if errDo != nil { return nil, fmt.Errorf("failed to execute project list request: %w", errDo) } defer func() { if errClose := resp.Body.Close(); errClose != nil { log.Errorf("response body close error: %v", errClose) } }() if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { bodyBytes, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("project list request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes))) } var projects interfaces.GCPProject if errDecode := json.NewDecoder(resp.Body).Decode(&projects); errDecode != nil { return nil, fmt.Errorf("failed to unmarshal project list: %w", errDecode) } return projects.Projects, nil } func checkCloudAPIIsEnabled(ctx context.Context, httpClient *http.Client, projectID string) (bool, error) { serviceUsageURL := "https://serviceusage.googleapis.com" requiredServices := []string{ "cloudaicompanion.googleapis.com", } for _, service := range requiredServices { checkURL := fmt.Sprintf("%s/v1/projects/%s/services/%s", serviceUsageURL, projectID, service) req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, checkURL, nil) if errRequest != nil { return false, fmt.Errorf("failed to create request: %w", errRequest) } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", geminiCLIUserAgent) resp, errDo := httpClient.Do(req) if errDo != nil { return false, fmt.Errorf("failed to execute request: %w", errDo) } if resp.StatusCode == http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) if gjson.GetBytes(bodyBytes, "state").String() == "ENABLED" { _ = resp.Body.Close() continue } } _ = resp.Body.Close() enableURL := fmt.Sprintf("%s/v1/projects/%s/services/%s:enable", serviceUsageURL, projectID, service) req, errRequest = http.NewRequestWithContext(ctx, http.MethodPost, enableURL, strings.NewReader("{}")) if errRequest != nil { return false, fmt.Errorf("failed to create request: %w", errRequest) } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", geminiCLIUserAgent) resp, errDo = httpClient.Do(req) if errDo != nil { return false, fmt.Errorf("failed to execute request: %w", errDo) } bodyBytes, _ := io.ReadAll(resp.Body) errMessage := string(bodyBytes) errMessageResult := gjson.GetBytes(bodyBytes, "error.message") if errMessageResult.Exists() { errMessage = errMessageResult.String() } if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated { _ = resp.Body.Close() continue } else if resp.StatusCode == http.StatusBadRequest { _ = resp.Body.Close() if strings.Contains(strings.ToLower(errMessage), "already enabled") { continue } } _ = resp.Body.Close() return false, fmt.Errorf("project activation required: %s", errMessage) } return true, nil } func (h *Handler) GetAuthStatus(c *gin.Context) { state := strings.TrimSpace(c.Query("state")) if state == "" { c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "state is required"}) return } if err := ValidateOAuthState(state); err != nil { c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid state"}) return } _, status, ok := GetOAuthSession(state) if !ok { c.JSON(http.StatusOK, gin.H{"status": "ok"}) return } if status != "" { c.JSON(http.StatusOK, gin.H{"status": "error", "error": status}) return } c.JSON(http.StatusOK, gin.H{"status": "wait"}) }