Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions internal/api/handlers/management/auth_files.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot"
cursorauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/cursor"
geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
gitlabauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gitlab"
iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow"
Expand Down Expand Up @@ -3473,3 +3474,78 @@ func (h *Handler) RequestKiloToken(c *gin.Context) {
"verification_uri": resp.VerificationURL,
})
}

// RequestCursorToken initiates the Cursor authentication flow via cursor-agent login.
func (h *Handler) RequestCursorToken(c *gin.Context) {
state := fmt.Sprintf("cursor-%d", time.Now().UnixNano())
RegisterOAuthSession(state, "cursor")

oauth := cursorauth.NewCursorOAuth(h.cfg)

go func() {
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
defer cancel()

tokenData, err := oauth.Login(ctx)
if err != nil {
log.Errorf("Cursor authentication failed: %v", err)
SetOAuthSessionError(state, fmt.Sprintf("Authentication failed: %v", err))
return
}

tokenStorage := &cursorauth.CursorTokenStorage{
TokenData: *tokenData,
}

metadata, errMeta := cursorTokenMetadata(tokenStorage)
if errMeta != nil {
log.Errorf("Failed to build cursor token metadata: %v", errMeta)
SetOAuthSessionError(state, "Failed to build token metadata")
return
}

fileName := "cursor.json"
record := &coreauth.Auth{
ID: fileName,
Provider: "cursor",
Label: "cursor",
FileName: fileName,
Storage: tokenStorage,
Metadata: metadata,
}

savedPath, errSave := h.saveTokenRecord(ctx, record)
if errSave != nil {
log.Errorf("Failed to save cursor authentication tokens: %v", errSave)
SetOAuthSessionError(state, "Failed to save authentication tokens")
return
}

fmt.Printf("Cursor authentication successful! Token saved to %s\n", savedPath)
CompleteOAuthSession(state)
CompleteOAuthSessionsByProvider("cursor")
}()

c.JSON(200, gin.H{
"status": "ok",
"state": state,
"url": "cursor-agent-login",
})
}

func cursorTokenMetadata(storage *cursorauth.CursorTokenStorage) (map[string]any, error) {
if storage == nil {
return nil, fmt.Errorf("token storage is nil")
}
payload, errMarshal := json.Marshal(storage.TokenData)
if errMarshal != nil {
return nil, errMarshal
}
var metadata map[string]any
if errUnmarshal := json.Unmarshal(payload, &metadata); errUnmarshal != nil {
return nil, errUnmarshal
}
metadata["type"] = "cursor"
metadata["timestamp"] = time.Now().UnixMilli()
return metadata, nil
}
1 change: 1 addition & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,7 @@ func (s *Server) registerManagementRoutes() {
mgmt.POST("/iflow-auth-url", s.mgmt.RequestIFlowCookieToken)
mgmt.GET("/kiro-auth-url", s.mgmt.RequestKiroToken)
mgmt.GET("/github-auth-url", s.mgmt.RequestGitHubToken)
mgmt.GET("/cursor-auth-url", s.mgmt.RequestCursorToken)
mgmt.POST("/oauth-callback", s.mgmt.PostOAuthCallback)
mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus)
}
Expand Down
251 changes: 251 additions & 0 deletions internal/auth/cursor/oauth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
// Package cursor provides OAuth authentication for Cursor IDE using cursor-agent login.
package cursor

import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"

"github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
log "github.com/sirupsen/logrus"
)

const (
authPollInterval = 2 * time.Second
authPollTimeout = 5 * time.Minute
urlExtractTimeout = 10 * time.Second
)

// ansiPattern matches ANSI escape codes.
var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)

// loginURLPattern matches the Cursor login URL.
var loginURLPattern = regexp.MustCompile(`https://cursor\.com/loginDeepControl[^\s]*`)

// CursorOAuth handles the OAuth flow for Cursor authentication.
type CursorOAuth struct {
cfg *config.Config
}

// NewCursorOAuth creates a new Cursor OAuth handler.
func NewCursorOAuth(cfg *config.Config) *CursorOAuth {
return &CursorOAuth{cfg: cfg}
}

// cursorAgentBinary returns the cursor-agent binary path.
func (c *CursorOAuth) cursorAgentBinary() string {
if c.cfg != nil && c.cfg.CursorAgentPath != "" {
return c.cfg.CursorAgentPath
}
return "cursor-agent"
}

// Login initiates the cursor-agent login flow and waits for authentication.
func (c *CursorOAuth) Login(ctx context.Context) (*CursorTokenData, error) {
binary := c.cursorAgentBinary()

// Spawn cursor-agent login
cmd := exec.CommandContext(ctx, binary, "login")
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("cursor: stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return nil, fmt.Errorf("cursor: stderr pipe: %w", err)
}

if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("cursor: start login: %w", err)
}

// Read stdout in background to extract login URL
stdoutCh := make(chan string, 1)
go func() {
buf := make([]byte, 8192)
var output strings.Builder
for {
n, readErr := stdout.Read(buf)
if n > 0 {
output.Write(buf[:n])
}
if readErr != nil {
break
}
}
stdoutCh <- output.String()
}()

// Read stderr in background
go func() {
buf := make([]byte, 4096)
for {
_, readErr := stderr.Read(buf)
if readErr != nil {
break
}
}
}()

// Try to extract URL with polling
var loginURL string
extractStart := time.Now()
for loginURL == "" && time.Since(extractStart) < urlExtractTimeout {
select {
case rawOutput := <-stdoutCh:
loginURL = extractLoginURL(rawOutput)
if loginURL == "" {
// Put it back for later
go func() { stdoutCh <- rawOutput }()
}
case <-time.After(100 * time.Millisecond):
// Keep waiting
case <-ctx.Done():
_ = cmd.Process.Kill()
return nil, ctx.Err()
}
}
Comment on lines +70 to +115

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The current implementation for reading stdout from the cursor-agent process and extracting the login URL has a critical bug that will lead to a deadlock. The goroutine on lines 72-85 reads from the stdout pipe in a loop until it encounters an error (like io.EOF), which typically happens only when the process exits. However, the cursor-agent login process prints the URL and then waits for the user to authenticate in the browser before exiting. This creates a deadlock: the Go code waits for the process to exit to get the URL, and the process waits for the user to use the URL to exit.

The URL extraction loop on lines 98-115 is also problematic, as it uses go func() { stdoutCh <- rawOutput }() to put data back onto the channel, which is an inefficient busy-wait pattern.

I suggest refactoring this section to process the stdout stream as it arrives and extract the URL without waiting for the process to terminate.

    // Read stdout in background to extract the login URL.
    urlCh := make(chan string, 1)
    go func() {
        defer close(urlCh)
        buf := make([]byte, 4096)
        var output strings.Builder
        for {
            n, err := stdout.Read(buf)
            if n > 0 {
                output.Write(buf[:n])
                if url := extractLoginURL(output.String()); url != "" {
                    urlCh <- url
                    // Once the URL is found, drain the rest of stdout to prevent the process from blocking.
                    _, _ = io.Copy(io.Discard, stdout)
                    return
                }
            }
            if err != nil {
                return // Exit goroutine on read error or EOF.
            }
        }
    }()

    // Read and discard stderr in the background to prevent the process from blocking.
    go func() {
        _, _ = io.Copy(io.Discard, stderr)
    }()

    // Wait for the login URL with a timeout.
    var loginURL string
    select {
    case url, ok := <-urlCh:
        if ok {
            loginURL = url
        }
    case <-time.After(urlExtractTimeout):
        log.Warn("cursor: timed out waiting for login URL from cursor-agent")
    case <-ctx.Done():
        _ = cmd.Process.Kill()
        return nil, ctx.Err()
    }


if loginURL != "" {
log.Infof("cursor: login URL extracted, opening browser")
fmt.Printf("Please visit: %s\n", loginURL)
if browser.IsAvailable() {
if errOpen := browser.OpenURL(loginURL); errOpen != nil {
log.Warnf("cursor: failed to open browser: %v", errOpen)
}
}
} else {
log.Warn("cursor: could not extract login URL from cursor-agent output")
}

// Wait for cursor-agent to exit
go func() {
_ = cmd.Wait()
}()

// Poll for auth file
log.Info("cursor: waiting for authentication...")
tokenData, err := c.pollForAuthFile(ctx)
if err != nil {
return nil, fmt.Errorf("cursor: auth polling: %w", err)
}

return tokenData, nil
}

// extractLoginURL strips ANSI codes, removes whitespace, and extracts the login URL.
func extractLoginURL(output string) string {
// Strip ANSI codes
clean := ansiPattern.ReplaceAllString(output, "")
// Remove all whitespace (URL may be split across lines)
clean = strings.Join(strings.Fields(clean), "")
// Extract URL
match := loginURLPattern.FindString(clean)
return match
}

// pollForAuthFile polls for the cursor auth file and reads token data.
func (c *CursorOAuth) pollForAuthFile(ctx context.Context) (*CursorTokenData, error) {
deadline := time.Now().Add(authPollTimeout)

for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}

authPath, err := c.FindAuthFile()
if err == nil && authPath != "" {
tokenData, readErr := c.ReadAuthFile(authPath)
if readErr == nil {
log.Infof("cursor: auth file found at %s", authPath)
return tokenData, nil
}
log.Debugf("cursor: auth file found but unreadable: %v", readErr)
}

time.Sleep(authPollInterval)
}

return nil, fmt.Errorf("authentication timed out after %v", authPollTimeout)
}

// GetPossibleAuthPaths returns all possible cursor auth file paths in priority order.
func (c *CursorOAuth) GetPossibleAuthPaths() []string {
home := os.Getenv("CURSOR_ACP_HOME_DIR")
if home == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil
}
home = homeDir
}

authFiles := []string{"cli-config.json", "auth.json"}
var paths []string

if runtime.GOOS == "darwin" {
for _, f := range authFiles {
paths = append(paths, filepath.Join(home, ".cursor", f))
}
for _, f := range authFiles {
paths = append(paths, filepath.Join(home, ".config", "cursor", f))
}
} else {
// Linux
for _, f := range authFiles {
paths = append(paths, filepath.Join(home, ".config", "cursor", f))
}
xdgConfig := os.Getenv("XDG_CONFIG_HOME")
if xdgConfig != "" && xdgConfig != filepath.Join(home, ".config") {
for _, f := range authFiles {
paths = append(paths, filepath.Join(xdgConfig, "cursor", f))
}
}
for _, f := range authFiles {
paths = append(paths, filepath.Join(home, ".cursor", f))
}
}

return paths
}

// FindAuthFile returns the first existing auth file path.
func (c *CursorOAuth) FindAuthFile() (string, error) {
paths := c.GetPossibleAuthPaths()
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
return "", fmt.Errorf("no cursor auth file found")
}

// ReadAuthFile reads and parses a cursor auth file.
func (c *CursorOAuth) ReadAuthFile(path string) (*CursorTokenData, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read auth file: %w", err)
}

var tokenData CursorTokenData
if err := json.Unmarshal(data, &tokenData); err != nil {
return nil, fmt.Errorf("parse auth file: %w", err)
}

if tokenData.AccessToken == "" {
return nil, fmt.Errorf("no access token in auth file")
}

tokenData.AuthFile = path
return &tokenData, nil
}
23 changes: 23 additions & 0 deletions internal/auth/cursor/oauth_web_templates.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Package cursor provides HTML templates for Cursor OAuth status pages.
package cursor

// SuccessHTML is displayed after successful Cursor authentication.
const SuccessHTML = `<!DOCTYPE html>
<html>
<head><title>Cursor Auth</title></head>
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
<h1>Cursor Authentication Successful</h1>
<p>You can close this window and return to the terminal.</p>
</body>
</html>`

// FailureHTML is displayed when Cursor authentication fails.
const FailureHTML = `<!DOCTYPE html>
<html>
<head><title>Cursor Auth</title></head>
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
<h1>Cursor Authentication Failed</h1>
<p>%s</p>
<p>Please try again.</p>
</body>
</html>`
Comment on lines +1 to +23

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This file defines HTML templates for a web-based OAuth flow. However, the Cursor authentication implemented in this pull request uses the command-line cursor-agent and does not involve a web callback to this application. As such, these templates appear to be unused. This file can likely be removed to avoid dead code in the repository.

Loading
Loading