SetOAuthSessionError previously sent generic messages to the management panel (e.g. "Failed to complete Gemini CLI onboarding"), hiding the actual error returned by Google APIs. The specific error was only written to the server log via log.Errorf, which is often inaccessible in headless/Docker deployments. Include the upstream error in all 8 OAuth error paths so the management panel shows actionable messages like "no Google Cloud projects available for this account" instead of a generic failure.
2474 lines
74 KiB
Go
2474 lines
74 KiB
Go
package management
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/antigravity"
|
|
"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/kimi"
|
|
"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"
|
|
)
|
|
|
|
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 stopCallbackForwarderInstance(port int, forwarder *callbackForwarder) {
|
|
if forwarder == nil {
|
|
return
|
|
}
|
|
callbackForwardersMu.Lock()
|
|
if current := callbackForwarders[port]; current == forwarder {
|
|
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 (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 !auth.NextRetryAfter.IsZero() {
|
|
entry["next_retry_after"] = auth.NextRetryAfter
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
if claims := extractCodexIDTokenClaims(auth); claims != nil {
|
|
entry["id_token"] = claims
|
|
}
|
|
return entry
|
|
}
|
|
|
|
func extractCodexIDTokenClaims(auth *coreauth.Auth) gin.H {
|
|
if auth == nil || auth.Metadata == nil {
|
|
return nil
|
|
}
|
|
if !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") {
|
|
return nil
|
|
}
|
|
idTokenRaw, ok := auth.Metadata["id_token"].(string)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
idToken := strings.TrimSpace(idTokenRaw)
|
|
if idToken == "" {
|
|
return nil
|
|
}
|
|
claims, err := codex.ParseJWTToken(idToken)
|
|
if err != nil || claims == nil {
|
|
return nil
|
|
}
|
|
|
|
result := gin.H{}
|
|
if v := strings.TrimSpace(claims.CodexAuthInfo.ChatgptAccountID); v != "" {
|
|
result["chatgpt_account_id"] = v
|
|
}
|
|
if v := strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType); v != "" {
|
|
result["plan_type"] = v
|
|
}
|
|
if v := claims.CodexAuthInfo.ChatgptSubscriptionActiveStart; v != nil {
|
|
result["chatgpt_subscription_active_start"] = v
|
|
}
|
|
if v := claims.CodexAuthInfo.ChatgptSubscriptionActiveUntil; v != nil {
|
|
result["chatgpt_subscription_active_until"] = v
|
|
}
|
|
|
|
if len(result) == 0 {
|
|
return nil
|
|
}
|
|
return result
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
targetPath := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
|
|
targetID := ""
|
|
if targetAuth := h.findAuthForDelete(name); targetAuth != nil {
|
|
targetID = strings.TrimSpace(targetAuth.ID)
|
|
if path := strings.TrimSpace(authAttribute(targetAuth, "path")); path != "" {
|
|
targetPath = path
|
|
}
|
|
}
|
|
if !filepath.IsAbs(targetPath) {
|
|
if abs, errAbs := filepath.Abs(targetPath); errAbs == nil {
|
|
targetPath = abs
|
|
}
|
|
}
|
|
if errRemove := os.Remove(targetPath); errRemove != nil {
|
|
if os.IsNotExist(errRemove) {
|
|
c.JSON(404, gin.H{"error": "file not found"})
|
|
} else {
|
|
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to remove file: %v", errRemove)})
|
|
}
|
|
return
|
|
}
|
|
if errDeleteRecord := h.deleteTokenRecord(ctx, targetPath); errDeleteRecord != nil {
|
|
c.JSON(500, gin.H{"error": errDeleteRecord.Error()})
|
|
return
|
|
}
|
|
if targetID != "" {
|
|
h.disableAuth(ctx, targetID)
|
|
} else {
|
|
h.disableAuth(ctx, targetPath)
|
|
}
|
|
c.JSON(200, gin.H{"status": "ok"})
|
|
}
|
|
|
|
func (h *Handler) findAuthForDelete(name string) *coreauth.Auth {
|
|
if h == nil || h.authManager == nil {
|
|
return nil
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return nil
|
|
}
|
|
if auth, ok := h.authManager.GetByID(name); ok {
|
|
return auth
|
|
}
|
|
auths := h.authManager.List()
|
|
for _, auth := range auths {
|
|
if auth == nil {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(auth.FileName) == name {
|
|
return auth
|
|
}
|
|
if filepath.Base(strings.TrimSpace(authAttribute(auth, "path"))) == name {
|
|
return auth
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *Handler) authIDForPath(path string) string {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return ""
|
|
}
|
|
id := path
|
|
if h != nil && h.cfg != nil {
|
|
authDir := strings.TrimSpace(h.cfg.AuthDir)
|
|
if authDir != "" {
|
|
if rel, errRel := filepath.Rel(authDir, path); errRel == nil && rel != "" {
|
|
id = rel
|
|
}
|
|
}
|
|
}
|
|
// On Windows, normalize ID casing to avoid duplicate auth entries caused by case-insensitive paths.
|
|
if runtime.GOOS == "windows" {
|
|
id = strings.ToLower(id)
|
|
}
|
|
return id
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// PatchAuthFileStatus toggles the disabled state of an auth file
|
|
func (h *Handler) PatchAuthFileStatus(c *gin.Context) {
|
|
if h.authManager == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Disabled *bool `json:"disabled"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
|
return
|
|
}
|
|
|
|
name := strings.TrimSpace(req.Name)
|
|
if name == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
|
return
|
|
}
|
|
if req.Disabled == nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "disabled is required"})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
// Find auth by name or ID
|
|
var targetAuth *coreauth.Auth
|
|
if auth, ok := h.authManager.GetByID(name); ok {
|
|
targetAuth = auth
|
|
} else {
|
|
auths := h.authManager.List()
|
|
for _, auth := range auths {
|
|
if auth.FileName == name {
|
|
targetAuth = auth
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if targetAuth == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"})
|
|
return
|
|
}
|
|
|
|
// Update disabled state
|
|
targetAuth.Disabled = *req.Disabled
|
|
if *req.Disabled {
|
|
targetAuth.Status = coreauth.StatusDisabled
|
|
targetAuth.StatusMessage = "disabled via management API"
|
|
} else {
|
|
targetAuth.Status = coreauth.StatusActive
|
|
targetAuth.StatusMessage = ""
|
|
}
|
|
targetAuth.UpdatedAt = time.Now()
|
|
|
|
if _, err := h.authManager.Update(ctx, targetAuth); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", err)})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok", "disabled": *req.Disabled})
|
|
}
|
|
|
|
// PatchAuthFileFields updates editable fields (prefix, proxy_url, priority) of an auth file.
|
|
func (h *Handler) PatchAuthFileFields(c *gin.Context) {
|
|
if h.authManager == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Prefix *string `json:"prefix"`
|
|
ProxyURL *string `json:"proxy_url"`
|
|
Priority *int `json:"priority"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
|
return
|
|
}
|
|
|
|
name := strings.TrimSpace(req.Name)
|
|
if name == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
// Find auth by name or ID
|
|
var targetAuth *coreauth.Auth
|
|
if auth, ok := h.authManager.GetByID(name); ok {
|
|
targetAuth = auth
|
|
} else {
|
|
auths := h.authManager.List()
|
|
for _, auth := range auths {
|
|
if auth.FileName == name {
|
|
targetAuth = auth
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if targetAuth == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"})
|
|
return
|
|
}
|
|
|
|
changed := false
|
|
if req.Prefix != nil {
|
|
targetAuth.Prefix = *req.Prefix
|
|
changed = true
|
|
}
|
|
if req.ProxyURL != nil {
|
|
targetAuth.ProxyURL = *req.ProxyURL
|
|
changed = true
|
|
}
|
|
if req.Priority != nil {
|
|
if targetAuth.Metadata == nil {
|
|
targetAuth.Metadata = make(map[string]any)
|
|
}
|
|
if *req.Priority == 0 {
|
|
delete(targetAuth.Metadata, "priority")
|
|
} else {
|
|
targetAuth.Metadata["priority"] = *req.Priority
|
|
}
|
|
changed = true
|
|
}
|
|
|
|
if !changed {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
|
return
|
|
}
|
|
|
|
targetAuth.UpdatedAt = time.Now()
|
|
|
|
if _, err := h.authManager.Update(ctx, targetAuth); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", err)})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
}
|
|
|
|
func (h *Handler) disableAuth(ctx context.Context, id string) {
|
|
if h == nil || h.authManager == nil {
|
|
return
|
|
}
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return
|
|
}
|
|
if auth, ok := h.authManager.GetByID(id); ok {
|
|
auth.Disabled = true
|
|
auth.Status = coreauth.StatusDisabled
|
|
auth.StatusMessage = "removed via management API"
|
|
auth.UpdatedAt = time.Now()
|
|
_, _ = h.authManager.Update(ctx, auth)
|
|
return
|
|
}
|
|
authID := h.authIDForPath(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")
|
|
}
|
|
if h.postAuthHook != nil {
|
|
if err := h.postAuthHook(ctx, record); err != nil {
|
|
return "", fmt.Errorf("post-auth hook failed: %w", err)
|
|
}
|
|
}
|
|
return store.Save(ctx, record)
|
|
}
|
|
|
|
func (h *Handler) RequestAnthropicToken(c *gin.Context) {
|
|
ctx := context.Background()
|
|
ctx = PopulateAuthContext(ctx, c)
|
|
|
|
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)
|
|
var forwarder *callbackForwarder
|
|
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
|
|
}
|
|
var errStart error
|
|
if forwarder, 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 stopCallbackForwarderInstance(anthropicCallbackPort, forwarder)
|
|
}
|
|
|
|
// 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 !IsOAuthSessionPending(state, "anthropic") {
|
|
return nil, errOAuthSessionNotPending
|
|
}
|
|
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 {
|
|
if errors.Is(errWait, errOAuthSessionNotPending) {
|
|
return
|
|
}
|
|
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 using internal auth service
|
|
bundle, errExchange := anthropicAuth.ExchangeCodeForTokens(ctx, code, state, pkceCodes)
|
|
if errExchange != nil {
|
|
authErr := claude.NewAuthenticationError(claude.ErrCodeExchangeFailed, errExchange)
|
|
log.Errorf("Failed to exchange authorization code for tokens: %v", authErr)
|
|
SetOAuthSessionError(state, "Failed to exchange authorization code for tokens")
|
|
return
|
|
}
|
|
|
|
// 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)
|
|
CompleteOAuthSessionsByProvider("anthropic")
|
|
}()
|
|
|
|
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
|
}
|
|
|
|
func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
|
|
ctx := context.Background()
|
|
ctx = PopulateAuthContext(ctx, c)
|
|
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 using exported constants from internal/auth/gemini
|
|
conf := &oauth2.Config{
|
|
ClientID: geminiAuth.ClientID,
|
|
ClientSecret: geminiAuth.ClientSecret,
|
|
RedirectURL: fmt.Sprintf("http://localhost:%d/oauth2callback", geminiAuth.DefaultCallbackPort),
|
|
Scopes: geminiAuth.Scopes,
|
|
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)
|
|
var forwarder *callbackForwarder
|
|
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
|
|
}
|
|
var errStart error
|
|
if forwarder, 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 stopCallbackForwarderInstance(geminiCallbackPort, forwarder)
|
|
}
|
|
|
|
// 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 !IsOAuthSessionPending(state, "gemini") {
|
|
return
|
|
}
|
|
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"] = geminiAuth.ClientID
|
|
ifToken["client_secret"] = geminiAuth.ClientSecret
|
|
ifToken["scopes"] = geminiAuth.Scopes
|
|
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, &geminiAuth.WebLoginOptions{
|
|
NoBrowser: 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, fmt.Sprintf("Failed to complete Gemini CLI onboarding: %v", errAll))
|
|
return
|
|
}
|
|
if errVerify := ensureGeminiProjectsEnabled(ctx, gemClient, projects); errVerify != nil {
|
|
log.Errorf("Failed to verify Cloud AI API status: %v", errVerify)
|
|
SetOAuthSessionError(state, fmt.Sprintf("Failed to verify Cloud AI API status: %v", errVerify))
|
|
return
|
|
}
|
|
ts.ProjectID = strings.Join(projects, ",")
|
|
ts.Checked = true
|
|
} else if strings.EqualFold(requestedProjectID, "GOOGLE_ONE") {
|
|
ts.Auto = false
|
|
if errSetup := performGeminiCLISetup(ctx, gemClient, &ts, ""); errSetup != nil {
|
|
log.Errorf("Google One auto-discovery failed: %v", errSetup)
|
|
SetOAuthSessionError(state, fmt.Sprintf("Google One auto-discovery failed: %v", errSetup))
|
|
return
|
|
}
|
|
if strings.TrimSpace(ts.ProjectID) == "" {
|
|
log.Error("Google One auto-discovery returned empty project ID")
|
|
SetOAuthSessionError(state, "Google One auto-discovery returned empty 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, fmt.Sprintf("Failed to verify Cloud AI API status: %v", errCheck))
|
|
return
|
|
}
|
|
ts.Checked = isChecked
|
|
if !isChecked {
|
|
log.Error("Cloud AI API is not enabled for the auto-discovered project")
|
|
SetOAuthSessionError(state, fmt.Sprintf("Cloud AI API not enabled for project %s", ts.ProjectID))
|
|
return
|
|
}
|
|
} else {
|
|
if errEnsure := ensureGeminiProjectAndOnboard(ctx, gemClient, &ts, requestedProjectID); errEnsure != nil {
|
|
log.Errorf("Failed to complete Gemini CLI onboarding: %v", errEnsure)
|
|
SetOAuthSessionError(state, fmt.Sprintf("Failed to complete Gemini CLI onboarding: %v", errEnsure))
|
|
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, fmt.Sprintf("Failed to verify Cloud AI API status: %v", errCheck))
|
|
return
|
|
}
|
|
ts.Checked = isChecked
|
|
if !isChecked {
|
|
log.Error("Cloud AI API is not enabled for the selected project")
|
|
SetOAuthSessionError(state, fmt.Sprintf("Cloud AI API not enabled for project %s", ts.ProjectID))
|
|
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)
|
|
CompleteOAuthSessionsByProvider("gemini")
|
|
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()
|
|
ctx = PopulateAuthContext(ctx, c)
|
|
|
|
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)
|
|
var forwarder *callbackForwarder
|
|
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
|
|
}
|
|
var errStart error
|
|
if forwarder, 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 stopCallbackForwarderInstance(codexCallbackPort, forwarder)
|
|
}
|
|
|
|
// 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 !IsOAuthSessionPending(state, "codex") {
|
|
return
|
|
}
|
|
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...")
|
|
// Exchange code for tokens using internal auth service
|
|
bundle, errExchange := openaiAuth.ExchangeCodeForTokens(ctx, code, pkceCodes)
|
|
if errExchange != nil {
|
|
authErr := codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, errExchange)
|
|
SetOAuthSessionError(state, "Failed to exchange authorization code for tokens")
|
|
log.Errorf("Failed to exchange authorization code for tokens: %v", authErr)
|
|
return
|
|
}
|
|
|
|
// Extract additional info for filename generation
|
|
claims, _ := codex.ParseJWTToken(bundle.TokenData.IDToken)
|
|
planType := ""
|
|
hashAccountID := ""
|
|
if claims != nil {
|
|
planType = strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType)
|
|
if accountID := claims.GetAccountID(); accountID != "" {
|
|
digest := sha256.Sum256([]byte(accountID))
|
|
hashAccountID = hex.EncodeToString(digest[:])[:8]
|
|
}
|
|
}
|
|
|
|
// Create token storage and persist
|
|
tokenStorage := openaiAuth.CreateTokenStorage(bundle)
|
|
fileName := codex.CredentialFileName(tokenStorage.Email, planType, hashAccountID, true)
|
|
record := &coreauth.Auth{
|
|
ID: fileName,
|
|
Provider: "codex",
|
|
FileName: fileName,
|
|
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)
|
|
CompleteOAuthSessionsByProvider("codex")
|
|
}()
|
|
|
|
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
|
}
|
|
|
|
func (h *Handler) RequestAntigravityToken(c *gin.Context) {
|
|
ctx := context.Background()
|
|
ctx = PopulateAuthContext(ctx, c)
|
|
|
|
fmt.Println("Initializing Antigravity authentication...")
|
|
|
|
authSvc := antigravity.NewAntigravityAuth(h.cfg, nil)
|
|
|
|
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", antigravity.CallbackPort)
|
|
authURL := authSvc.BuildAuthURL(state, redirectURI)
|
|
|
|
RegisterOAuthSession(state, "antigravity")
|
|
|
|
isWebUI := isWebUIRequest(c)
|
|
var forwarder *callbackForwarder
|
|
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
|
|
}
|
|
var errStart error
|
|
if forwarder, errStart = startCallbackForwarder(antigravity.CallbackPort, "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 stopCallbackForwarderInstance(antigravity.CallbackPort, forwarder)
|
|
}
|
|
|
|
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 !IsOAuthSessionPending(state, "antigravity") {
|
|
return
|
|
}
|
|
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)
|
|
}
|
|
|
|
tokenResp, errToken := authSvc.ExchangeCodeForTokens(ctx, authCode, redirectURI)
|
|
if errToken != nil {
|
|
log.Errorf("Failed to exchange token: %v", errToken)
|
|
SetOAuthSessionError(state, "Failed to exchange token")
|
|
return
|
|
}
|
|
|
|
accessToken := strings.TrimSpace(tokenResp.AccessToken)
|
|
if accessToken == "" {
|
|
log.Error("antigravity: token exchange returned empty access token")
|
|
SetOAuthSessionError(state, "Failed to exchange token")
|
|
return
|
|
}
|
|
|
|
email, errInfo := authSvc.FetchUserInfo(ctx, accessToken)
|
|
if errInfo != nil {
|
|
log.Errorf("Failed to fetch user info: %v", errInfo)
|
|
SetOAuthSessionError(state, "Failed to fetch user info")
|
|
return
|
|
}
|
|
email = strings.TrimSpace(email)
|
|
if email == "" {
|
|
log.Error("antigravity: user info returned empty email")
|
|
SetOAuthSessionError(state, "Failed to fetch user info")
|
|
return
|
|
}
|
|
|
|
projectID := ""
|
|
if accessToken != "" {
|
|
fetchedProjectID, errProject := authSvc.FetchProjectID(ctx, accessToken)
|
|
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 := antigravity.CredentialFileName(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)
|
|
CompleteOAuthSessionsByProvider("antigravity")
|
|
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()
|
|
ctx = PopulateAuthContext(ctx, c)
|
|
|
|
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("%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) RequestKimiToken(c *gin.Context) {
|
|
ctx := context.Background()
|
|
ctx = PopulateAuthContext(ctx, c)
|
|
|
|
fmt.Println("Initializing Kimi authentication...")
|
|
|
|
state := fmt.Sprintf("kmi-%d", time.Now().UnixNano())
|
|
// Initialize Kimi auth service
|
|
kimiAuth := kimi.NewKimiAuth(h.cfg)
|
|
|
|
// Generate authorization URL
|
|
deviceFlow, errStartDeviceFlow := kimiAuth.StartDeviceFlow(ctx)
|
|
if errStartDeviceFlow != nil {
|
|
log.Errorf("Failed to generate authorization URL: %v", errStartDeviceFlow)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"})
|
|
return
|
|
}
|
|
authURL := deviceFlow.VerificationURIComplete
|
|
if authURL == "" {
|
|
authURL = deviceFlow.VerificationURI
|
|
}
|
|
|
|
RegisterOAuthSession(state, "kimi")
|
|
|
|
go func() {
|
|
fmt.Println("Waiting for authentication...")
|
|
authBundle, errWaitForAuthorization := kimiAuth.WaitForAuthorization(ctx, deviceFlow)
|
|
if errWaitForAuthorization != nil {
|
|
SetOAuthSessionError(state, "Authentication failed")
|
|
fmt.Printf("Authentication failed: %v\n", errWaitForAuthorization)
|
|
return
|
|
}
|
|
|
|
// Create token storage
|
|
tokenStorage := kimiAuth.CreateTokenStorage(authBundle)
|
|
|
|
metadata := map[string]any{
|
|
"type": "kimi",
|
|
"access_token": authBundle.TokenData.AccessToken,
|
|
"refresh_token": authBundle.TokenData.RefreshToken,
|
|
"token_type": authBundle.TokenData.TokenType,
|
|
"scope": authBundle.TokenData.Scope,
|
|
"timestamp": time.Now().UnixMilli(),
|
|
}
|
|
if authBundle.TokenData.ExpiresAt > 0 {
|
|
expired := time.Unix(authBundle.TokenData.ExpiresAt, 0).UTC().Format(time.RFC3339)
|
|
metadata["expired"] = expired
|
|
}
|
|
if strings.TrimSpace(authBundle.DeviceID) != "" {
|
|
metadata["device_id"] = strings.TrimSpace(authBundle.DeviceID)
|
|
}
|
|
|
|
fileName := fmt.Sprintf("kimi-%d.json", time.Now().UnixMilli())
|
|
record := &coreauth.Auth{
|
|
ID: fileName,
|
|
Provider: "kimi",
|
|
FileName: fileName,
|
|
Label: "Kimi User",
|
|
Storage: tokenStorage,
|
|
Metadata: metadata,
|
|
}
|
|
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 Kimi services through this CLI")
|
|
CompleteOAuthSession(state)
|
|
CompleteOAuthSessionsByProvider("kimi")
|
|
}()
|
|
|
|
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
|
}
|
|
|
|
func (h *Handler) RequestIFlowToken(c *gin.Context) {
|
|
ctx := context.Background()
|
|
ctx = PopulateAuthContext(ctx, c)
|
|
|
|
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)
|
|
var forwarder *callbackForwarder
|
|
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
|
|
}
|
|
var errStart error
|
|
if forwarder, 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 stopCallbackForwarderInstance(iflowauth.CallbackPort, forwarder)
|
|
}
|
|
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 !IsOAuthSessionPending(state, "iflow") {
|
|
return
|
|
}
|
|
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("%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)
|
|
CompleteOAuthSessionsByProvider("iflow")
|
|
}()
|
|
|
|
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())
|
|
} else {
|
|
fileName = fmt.Sprintf("iflow-%s", fileName)
|
|
}
|
|
|
|
tokenStorage.Email = email
|
|
timestamp := time.Now().Unix()
|
|
|
|
record := &coreauth.Auth{
|
|
ID: fmt.Sprintf("%s-%d.json", fileName, timestamp),
|
|
Provider: "iflow",
|
|
FileName: fmt.Sprintf("%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 == "" {
|
|
// Auto-discovery: try onboardUser without specifying a project
|
|
// to let Google auto-provision one (matches Gemini CLI headless behavior
|
|
// and Antigravity's FetchProjectID pattern).
|
|
autoOnboardReq := map[string]any{
|
|
"tierId": tierID,
|
|
"metadata": metadata,
|
|
}
|
|
|
|
autoCtx, autoCancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer autoCancel()
|
|
for attempt := 1; ; attempt++ {
|
|
var onboardResp map[string]any
|
|
if errOnboard := callGeminiCLI(autoCtx, httpClient, "onboardUser", autoOnboardReq, &onboardResp); errOnboard != nil {
|
|
return fmt.Errorf("auto-discovery onboardUser: %w", errOnboard)
|
|
}
|
|
|
|
if done, okDone := onboardResp["done"].(bool); okDone && done {
|
|
if resp, okResp := onboardResp["response"].(map[string]any); okResp {
|
|
switch v := resp["cloudaicompanionProject"].(type) {
|
|
case string:
|
|
projectID = strings.TrimSpace(v)
|
|
case map[string]any:
|
|
if id, okID := v["id"].(string); okID {
|
|
projectID = strings.TrimSpace(id)
|
|
}
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
log.Debugf("Auto-discovery: onboarding in progress, attempt %d...", attempt)
|
|
select {
|
|
case <-autoCtx.Done():
|
|
return &projectSelectionRequiredError{}
|
|
case <-time.After(2 * time.Second):
|
|
}
|
|
}
|
|
|
|
if projectID == "" {
|
|
return &projectSelectionRequiredError{}
|
|
}
|
|
log.Infof("Auto-discovered project ID via onboarding: %s", projectID)
|
|
}
|
|
|
|
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) {
|
|
// Check if this is a free user (gen-lang-client projects or free/legacy tier)
|
|
isFreeUser := strings.HasPrefix(projectID, "gen-lang-client-") ||
|
|
strings.EqualFold(tierID, "FREE") ||
|
|
strings.EqualFold(tierID, "LEGACY")
|
|
|
|
if isFreeUser {
|
|
// For free users, use backend project ID for preview model access
|
|
log.Infof("Gemini onboarding: frontend project %s maps to backend project %s", projectID, responseProjectID)
|
|
log.Infof("Using backend project ID: %s (recommended for preview model access)", responseProjectID)
|
|
finalProjectID = responseProjectID
|
|
} else {
|
|
// Pro users: keep requested project ID (original behavior)
|
|
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", misc.GeminiCLIUserAgent(""))
|
|
|
|
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", misc.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", misc.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.StatusOK, gin.H{"status": "ok"})
|
|
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"})
|
|
}
|
|
|
|
// PopulateAuthContext extracts request info and adds it to the context
|
|
func PopulateAuthContext(ctx context.Context, c *gin.Context) context.Context {
|
|
info := &coreauth.RequestInfo{
|
|
Query: c.Request.URL.Query(),
|
|
Headers: c.Request.Header,
|
|
}
|
|
return coreauth.WithRequestInfo(ctx, info)
|
|
}
|