mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-03-30 01:06:39 +00:00
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>
250 lines
6.9 KiB
Go
250 lines
6.9 KiB
Go
// Package cursor implements Cursor OAuth PKCE authentication and token refresh.
|
|
package cursor
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
CursorLoginURL = "https://cursor.com/loginDeepControl"
|
|
CursorPollURL = "https://api2.cursor.sh/auth/poll"
|
|
CursorRefreshURL = "https://api2.cursor.sh/auth/exchange_user_api_key"
|
|
|
|
pollMaxAttempts = 150
|
|
pollBaseDelay = 1 * time.Second
|
|
pollMaxDelay = 10 * time.Second
|
|
pollBackoffMultiply = 1.2
|
|
maxConsecutiveErrors = 10
|
|
)
|
|
|
|
// AuthParams holds the PKCE parameters for Cursor login.
|
|
type AuthParams struct {
|
|
Verifier string
|
|
Challenge string
|
|
UUID string
|
|
LoginURL string
|
|
}
|
|
|
|
// TokenPair holds the access and refresh tokens from Cursor.
|
|
type TokenPair struct {
|
|
AccessToken string `json:"accessToken"`
|
|
RefreshToken string `json:"refreshToken"`
|
|
}
|
|
|
|
// GeneratePKCE creates a PKCE verifier and challenge pair.
|
|
func GeneratePKCE() (verifier, challenge string, err error) {
|
|
verifierBytes := make([]byte, 96)
|
|
if _, err = rand.Read(verifierBytes); err != nil {
|
|
return "", "", fmt.Errorf("cursor: failed to generate PKCE verifier: %w", err)
|
|
}
|
|
verifier = base64.RawURLEncoding.EncodeToString(verifierBytes)
|
|
|
|
h := sha256.Sum256([]byte(verifier))
|
|
challenge = base64.RawURLEncoding.EncodeToString(h[:])
|
|
return verifier, challenge, nil
|
|
}
|
|
|
|
// GenerateAuthParams creates the full set of auth params for Cursor login.
|
|
func GenerateAuthParams() (*AuthParams, error) {
|
|
verifier, challenge, err := GeneratePKCE()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
uuidBytes := make([]byte, 16)
|
|
if _, err = rand.Read(uuidBytes); err != nil {
|
|
return nil, fmt.Errorf("cursor: failed to generate UUID: %w", err)
|
|
}
|
|
uuid := fmt.Sprintf("%x-%x-%x-%x-%x",
|
|
uuidBytes[0:4], uuidBytes[4:6], uuidBytes[6:8], uuidBytes[8:10], uuidBytes[10:16])
|
|
|
|
loginURL := fmt.Sprintf("%s?challenge=%s&uuid=%s&mode=login&redirectTarget=cli",
|
|
CursorLoginURL, challenge, uuid)
|
|
|
|
return &AuthParams{
|
|
Verifier: verifier,
|
|
Challenge: challenge,
|
|
UUID: uuid,
|
|
LoginURL: loginURL,
|
|
}, nil
|
|
}
|
|
|
|
// PollForAuth polls the Cursor auth endpoint until the user completes login.
|
|
func PollForAuth(ctx context.Context, uuid, verifier string) (*TokenPair, error) {
|
|
delay := pollBaseDelay
|
|
consecutiveErrors := 0
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
|
|
for attempt := 0; attempt < pollMaxAttempts; attempt++ {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(delay):
|
|
}
|
|
|
|
url := fmt.Sprintf("%s?uuid=%s&verifier=%s", CursorPollURL, uuid, verifier)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cursor: failed to create poll request: %w", err)
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
consecutiveErrors++
|
|
if consecutiveErrors >= maxConsecutiveErrors {
|
|
return nil, fmt.Errorf("cursor: too many consecutive poll errors (last: %v)", err)
|
|
}
|
|
delay = minDuration(time.Duration(float64(delay)*pollBackoffMultiply), pollMaxDelay)
|
|
continue
|
|
}
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
// Still waiting for user to authorize
|
|
consecutiveErrors = 0
|
|
delay = minDuration(time.Duration(float64(delay)*pollBackoffMultiply), pollMaxDelay)
|
|
continue
|
|
}
|
|
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
var tokens TokenPair
|
|
if err := json.Unmarshal(body, &tokens); err != nil {
|
|
return nil, fmt.Errorf("cursor: failed to parse auth response: %w", err)
|
|
}
|
|
return &tokens, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("cursor: poll failed with status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
return nil, fmt.Errorf("cursor: authentication polling timeout (waited ~%.0f seconds)",
|
|
float64(pollMaxAttempts)*pollMaxDelay.Seconds()/2)
|
|
}
|
|
|
|
// RefreshToken refreshes a Cursor access token using the refresh token.
|
|
func RefreshToken(ctx context.Context, refreshToken string) (*TokenPair, error) {
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, CursorRefreshURL,
|
|
strings.NewReader("{}"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cursor: failed to create refresh request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+refreshToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cursor: token refresh request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("cursor: token refresh failed (status %d): %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var tokens TokenPair
|
|
if err := json.Unmarshal(body, &tokens); err != nil {
|
|
return nil, fmt.Errorf("cursor: failed to parse refresh response: %w", err)
|
|
}
|
|
|
|
// Keep original refresh token if not returned
|
|
if tokens.RefreshToken == "" {
|
|
tokens.RefreshToken = refreshToken
|
|
}
|
|
|
|
return &tokens, nil
|
|
}
|
|
|
|
// ParseJWTSub extracts the "sub" claim from a Cursor JWT access token.
|
|
// Cursor JWTs contain "sub" like "auth0|user_XXXX" which uniquely identifies
|
|
// the account. Returns empty string if parsing fails.
|
|
func ParseJWTSub(token string) string {
|
|
decoded := decodeJWTPayload(token)
|
|
if decoded == nil {
|
|
return ""
|
|
}
|
|
var claims struct {
|
|
Sub string `json:"sub"`
|
|
}
|
|
if err := json.Unmarshal(decoded, &claims); err != nil {
|
|
return ""
|
|
}
|
|
return claims.Sub
|
|
}
|
|
|
|
// SubToShortHash converts a JWT sub claim to a short hex hash for use in filenames.
|
|
// e.g. "auth0|user_2x..." → "a3f8b2c1"
|
|
func SubToShortHash(sub string) string {
|
|
if sub == "" {
|
|
return ""
|
|
}
|
|
h := sha256.Sum256([]byte(sub))
|
|
return fmt.Sprintf("%x", h[:4]) // 8 hex chars
|
|
}
|
|
|
|
// decodeJWTPayload decodes the payload (middle) part of a JWT.
|
|
func decodeJWTPayload(token string) []byte {
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) != 3 {
|
|
return nil
|
|
}
|
|
payload := parts[1]
|
|
switch len(payload) % 4 {
|
|
case 2:
|
|
payload += "=="
|
|
case 3:
|
|
payload += "="
|
|
}
|
|
payload = strings.ReplaceAll(payload, "-", "+")
|
|
payload = strings.ReplaceAll(payload, "_", "/")
|
|
decoded, err := base64.StdEncoding.DecodeString(payload)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return decoded
|
|
}
|
|
|
|
// GetTokenExpiry extracts the JWT expiry from an access token with a 5-minute safety margin.
|
|
// Falls back to 1 hour from now if the token can't be parsed.
|
|
func GetTokenExpiry(token string) time.Time {
|
|
decoded := decodeJWTPayload(token)
|
|
if decoded == nil {
|
|
return time.Now().Add(1 * time.Hour)
|
|
}
|
|
|
|
var claims struct {
|
|
Exp float64 `json:"exp"`
|
|
}
|
|
if err := json.Unmarshal(decoded, &claims); err != nil || claims.Exp == 0 {
|
|
return time.Now().Add(1 * time.Hour)
|
|
}
|
|
|
|
sec, frac := math.Modf(claims.Exp)
|
|
expiry := time.Unix(int64(sec), int64(frac*1e9))
|
|
// Subtract 5-minute safety margin
|
|
return expiry.Add(-5 * time.Minute)
|
|
}
|
|
|
|
func minDuration(a, b time.Duration) time.Duration {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|