mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-02 20:40:52 +08:00
Introduce a centralized OAuth session store with TTL-based expiration to replace the previous simple map-based status tracking. Add a new /api/oauth/callback endpoint that allows remote clients to relay OAuth callback data back to the CLI proxy, enabling OAuth flows when the callback cannot reach the local machine directly. - Add oauth_sessions.go with thread-safe session store and validation - Add oauth_callback.go with POST handler for remote callback relay - Refactor auth_files.go to use new session management APIs - Register new callback route in server.go
2204 lines
68 KiB
Go
2204 lines
68 KiB
Go
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"})
|
|
}
|