Files
CLIProxyAPIPlus/sdk/auth/cursor.go
MrHuangJser 7386a70724 feat(cursor): auto-identify accounts from JWT sub for multi-account support
Previously Cursor required a manual ?label=xxx parameter to distinguish
accounts (unlike Codex which auto-generates filenames from JWT claims).

Cursor JWTs contain a "sub" claim (e.g. "auth0|user_XXXX") that uniquely
identifies each account. Now we:

- Add ParseJWTSub() + SubToShortHash() to extract and hash the sub claim
- Refactor GetTokenExpiry() to share the new decodeJWTPayload() helper
- Update CredentialFileName(label, subHash) to auto-generate filenames
  from the sub hash when no explicit label is provided
  (e.g. "cursor.8f202e67.json" instead of always "cursor.json")
- Add DisplayLabel() for human-readable account identification
- Store "sub" in metadata for observability
- Update both management API handler and SDK authenticator

Same account always produces the same filename (deterministic), different
accounts get different files. Explicit ?label= still takes priority.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:40:02 +08:00

99 lines
2.7 KiB
Go

package auth
import (
"context"
"fmt"
"time"
cursorauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/cursor"
"github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
// CursorAuthenticator implements OAuth PKCE login for Cursor.
type CursorAuthenticator struct{}
// NewCursorAuthenticator constructs a new Cursor authenticator.
func NewCursorAuthenticator() Authenticator {
return &CursorAuthenticator{}
}
// Provider returns the provider key for cursor.
func (CursorAuthenticator) Provider() string {
return "cursor"
}
// RefreshLead returns the time before expiry when a refresh should be attempted.
func (CursorAuthenticator) RefreshLead() *time.Duration {
d := 10 * time.Minute
return &d
}
// Login initiates the Cursor PKCE authentication flow.
func (a CursorAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
if cfg == nil {
return nil, fmt.Errorf("cursor auth: configuration is required")
}
if opts == nil {
opts = &LoginOptions{}
}
// Generate PKCE auth parameters
authParams, err := cursorauth.GenerateAuthParams()
if err != nil {
return nil, fmt.Errorf("cursor: failed to generate auth params: %w", err)
}
// Display the login URL
log.Info("Starting Cursor authentication...")
log.Infof("Please visit this URL to log in: %s", authParams.LoginURL)
// Try to open the browser automatically
if !opts.NoBrowser {
if browser.IsAvailable() {
if errOpen := browser.OpenURL(authParams.LoginURL); errOpen != nil {
log.Warnf("Failed to open browser automatically: %v", errOpen)
}
}
}
log.Info("Waiting for Cursor authorization...")
// Poll for the auth result
tokens, err := cursorauth.PollForAuth(ctx, authParams.UUID, authParams.Verifier)
if err != nil {
return nil, fmt.Errorf("cursor: authentication failed: %w", err)
}
expiresAt := cursorauth.GetTokenExpiry(tokens.AccessToken)
// Auto-identify account from JWT sub claim
sub := cursorauth.ParseJWTSub(tokens.AccessToken)
subHash := cursorauth.SubToShortHash(sub)
log.Info("Cursor authentication successful!")
metadata := map[string]any{
"type": "cursor",
"access_token": tokens.AccessToken,
"refresh_token": tokens.RefreshToken,
"expires_at": expiresAt.Format(time.RFC3339),
"timestamp": time.Now().UnixMilli(),
}
if sub != "" {
metadata["sub"] = sub
}
fileName := cursorauth.CredentialFileName("", subHash)
return &coreauth.Auth{
ID: fileName,
Provider: a.Provider(),
FileName: fileName,
Label: cursorauth.DisplayLabel("", subHash),
Metadata: metadata,
}, nil
}