mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-17 03:27:02 +08:00
590 lines
16 KiB
Go
590 lines
16 KiB
Go
package cursoraccount
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"cursor/gen/aiserverv1"
|
|
"cursor/internal/backend/server/upstream"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/pkg/browser"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
const (
|
|
StateSignedOut = "signed_out"
|
|
StateWaiting = "waiting"
|
|
StateSignedIn = "signed_in"
|
|
StateError = "error"
|
|
|
|
websiteURL = "https://cursor.com"
|
|
backendURL = "https://api2.cursor.sh"
|
|
authClientID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB"
|
|
loginTimeout = 10 * time.Minute
|
|
pollInterval = time.Second
|
|
refreshMargin = 2 * time.Minute
|
|
)
|
|
|
|
var ErrNotSignedIn = errors.New("尚未在 cursor-byok 中登录 Cursor 账号")
|
|
|
|
// Status 是可安全返回给前端的脱敏账号状态。
|
|
type Status struct {
|
|
State string `json:"state"`
|
|
AuthID string `json:"authId"`
|
|
Email string `json:"email"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
type credentials struct {
|
|
AccessToken string `json:"accessToken"`
|
|
RefreshToken string `json:"refreshToken"`
|
|
AuthID string `json:"authId"`
|
|
Email string `json:"email,omitempty"`
|
|
}
|
|
|
|
type pollResponse struct {
|
|
AccessToken string `json:"accessToken"`
|
|
RefreshToken string `json:"refreshToken"`
|
|
AuthID string `json:"authId"`
|
|
}
|
|
|
|
type refreshResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
ShouldLogout bool `json:"shouldLogout"`
|
|
}
|
|
|
|
// Manager 持有 cursor-byok 自己的 Cursor 登录态,不读写 Cursor 客户端状态库。
|
|
type Manager struct {
|
|
path string
|
|
client *http.Client
|
|
|
|
mu sync.RWMutex
|
|
credentials credentials
|
|
state string
|
|
lastError string
|
|
loginCancel context.CancelFunc
|
|
loginGeneration uint64
|
|
|
|
refreshMu sync.Mutex
|
|
}
|
|
|
|
func NewManager(path string, client *http.Client) *Manager {
|
|
if client == nil {
|
|
client = &http.Client{Timeout: 15 * time.Second}
|
|
}
|
|
manager := &Manager{
|
|
path: strings.TrimSpace(path),
|
|
client: client,
|
|
state: StateSignedOut,
|
|
}
|
|
if err := manager.load(); err != nil {
|
|
manager.state = StateError
|
|
manager.lastError = fmt.Sprintf("读取 Cursor 账号凭据失败: %v", err)
|
|
}
|
|
return manager
|
|
}
|
|
|
|
func (manager *Manager) Status() Status {
|
|
if manager == nil {
|
|
return Status{State: StateSignedOut}
|
|
}
|
|
manager.mu.RLock()
|
|
defer manager.mu.RUnlock()
|
|
return Status{
|
|
State: manager.state,
|
|
AuthID: manager.credentials.AuthID,
|
|
Email: manager.credentials.Email,
|
|
Error: manager.lastError,
|
|
}
|
|
}
|
|
|
|
// EnsureEmail backfills a human-readable identity for credentials saved by
|
|
// builds that only persisted authId. Profile lookup failure does not invalidate
|
|
// an otherwise usable control-plane login.
|
|
func (manager *Manager) EnsureEmail(ctx context.Context) {
|
|
if manager == nil || !manager.SignedIn() {
|
|
return
|
|
}
|
|
current, generation := manager.snapshotCredentials()
|
|
if strings.TrimSpace(current.Email) != "" {
|
|
return
|
|
}
|
|
authorization, err := manager.Authorization(ctx)
|
|
if err != nil {
|
|
return
|
|
}
|
|
profile, err := manager.fetchProfile(ctx, authorization)
|
|
if err != nil || strings.TrimSpace(profile.GetEmail()) == "" {
|
|
return
|
|
}
|
|
current, currentGeneration := manager.snapshotCredentials()
|
|
if currentGeneration != generation {
|
|
return
|
|
}
|
|
current.Email = strings.TrimSpace(profile.GetEmail())
|
|
_ = manager.commitCredentials(generation, current)
|
|
}
|
|
|
|
func (manager *Manager) SignedIn() bool {
|
|
if manager == nil {
|
|
return false
|
|
}
|
|
manager.mu.RLock()
|
|
defer manager.mu.RUnlock()
|
|
return manager.state == StateSignedIn && strings.TrimSpace(manager.credentials.AccessToken) != ""
|
|
}
|
|
|
|
// StartLogin 启动官方浏览器 PKCE 登录,并在后台等待登录结果。
|
|
func (manager *Manager) StartLogin() (Status, error) {
|
|
if manager == nil {
|
|
return Status{State: StateError}, fmt.Errorf("Cursor 账号服务未初始化")
|
|
}
|
|
verifierBytes := make([]byte, 32)
|
|
if _, err := rand.Read(verifierBytes); err != nil {
|
|
return manager.Status(), fmt.Errorf("生成 Cursor 登录校验码失败: %w", err)
|
|
}
|
|
verifier := base64.RawURLEncoding.EncodeToString(verifierBytes)
|
|
challengeBytes := sha256.Sum256([]byte(verifier))
|
|
challenge := base64.RawURLEncoding.EncodeToString(challengeBytes[:])
|
|
loginID := uuid.NewString()
|
|
|
|
loginURL, err := buildLoginURL(loginID, challenge)
|
|
if err != nil {
|
|
return manager.Status(), err
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), loginTimeout)
|
|
|
|
manager.mu.Lock()
|
|
if manager.loginCancel != nil {
|
|
manager.loginCancel()
|
|
}
|
|
manager.loginGeneration++
|
|
generation := manager.loginGeneration
|
|
manager.loginCancel = cancel
|
|
manager.state = StateWaiting
|
|
manager.lastError = ""
|
|
manager.mu.Unlock()
|
|
|
|
if err := browser.OpenURL(loginURL); err != nil {
|
|
cancel()
|
|
manager.finishWithError(generation, fmt.Sprintf("打开 Cursor 登录页面失败: %v", err))
|
|
return manager.Status(), err
|
|
}
|
|
|
|
go manager.pollLogin(ctx, generation, loginID, verifier)
|
|
return manager.Status(), nil
|
|
}
|
|
|
|
// Disconnect 只清除 cursor-byok 自己保存的账号,不调用 Cursor 客户端 logout。
|
|
func (manager *Manager) Disconnect() (Status, error) {
|
|
if manager == nil {
|
|
return Status{State: StateSignedOut}, nil
|
|
}
|
|
manager.mu.Lock()
|
|
manager.loginGeneration++
|
|
if manager.loginCancel != nil {
|
|
manager.loginCancel()
|
|
manager.loginCancel = nil
|
|
}
|
|
manager.credentials = credentials{}
|
|
manager.state = StateSignedOut
|
|
manager.lastError = ""
|
|
manager.mu.Unlock()
|
|
|
|
err := os.Remove(manager.path)
|
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
manager.mu.Lock()
|
|
manager.state = StateError
|
|
manager.lastError = fmt.Sprintf("清除 Cursor 账号凭据失败: %v", err)
|
|
manager.mu.Unlock()
|
|
return manager.Status(), err
|
|
}
|
|
return manager.Status(), nil
|
|
}
|
|
|
|
func (manager *Manager) Shutdown() {
|
|
if manager == nil {
|
|
return
|
|
}
|
|
manager.mu.Lock()
|
|
manager.loginGeneration++
|
|
if manager.loginCancel != nil {
|
|
manager.loginCancel()
|
|
manager.loginCancel = nil
|
|
}
|
|
manager.mu.Unlock()
|
|
}
|
|
|
|
// Authorization 返回官方控制面请求使用的真实 Cursor Bearer 身份。
|
|
func (manager *Manager) Authorization(ctx context.Context) (string, error) {
|
|
if manager == nil {
|
|
return "", ErrNotSignedIn
|
|
}
|
|
manager.refreshMu.Lock()
|
|
defer manager.refreshMu.Unlock()
|
|
|
|
creds, generation := manager.snapshotCredentials()
|
|
if strings.TrimSpace(creds.AccessToken) == "" {
|
|
return "", ErrNotSignedIn
|
|
}
|
|
if !tokenNeedsRefresh(creds.AccessToken, time.Now()) {
|
|
return bearer(creds.AccessToken), nil
|
|
}
|
|
if strings.TrimSpace(creds.RefreshToken) == "" {
|
|
manager.setAuthorizationError(generation, "Cursor 登录已过期,请重新登录")
|
|
return "", fmt.Errorf("Cursor 登录已过期且没有刷新令牌")
|
|
}
|
|
|
|
updated, shouldLogout, err := manager.refresh(ctx, creds)
|
|
if err != nil {
|
|
manager.setAuthorizationError(generation, fmt.Sprintf("刷新 Cursor 登录失败: %v", err))
|
|
return "", err
|
|
}
|
|
if shouldLogout {
|
|
manager.invalidateAuthorization(generation, "Cursor 登录已失效,请重新登录")
|
|
return "", ErrNotSignedIn
|
|
}
|
|
if err := manager.commitCredentials(generation, updated); err != nil {
|
|
return "", err
|
|
}
|
|
return bearer(updated.AccessToken), nil
|
|
}
|
|
|
|
func (manager *Manager) pollLogin(ctx context.Context, generation uint64, loginID string, verifier string) {
|
|
defer func() {
|
|
manager.mu.Lock()
|
|
if manager.loginGeneration == generation {
|
|
manager.loginCancel = nil
|
|
}
|
|
manager.mu.Unlock()
|
|
}()
|
|
|
|
for {
|
|
result, pending, err := manager.pollOnce(ctx, loginID, verifier)
|
|
if err == nil && !pending {
|
|
creds := credentials{
|
|
AccessToken: strings.TrimSpace(result.AccessToken),
|
|
RefreshToken: strings.TrimSpace(result.RefreshToken),
|
|
AuthID: strings.TrimSpace(result.AuthID),
|
|
}
|
|
if creds.AccessToken == "" {
|
|
manager.finishWithError(generation, "Cursor 登录响应缺少 access token")
|
|
return
|
|
}
|
|
if profile, profileErr := manager.fetchProfile(ctx, bearer(creds.AccessToken)); profileErr == nil {
|
|
creds.Email = strings.TrimSpace(profile.GetEmail())
|
|
}
|
|
_ = manager.commitCredentials(generation, creds)
|
|
return
|
|
}
|
|
if err != nil && !isRetryablePollError(err) {
|
|
manager.finishWithError(generation, fmt.Sprintf("Cursor 登录失败: %v", err))
|
|
return
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
|
manager.finishWithError(generation, "Cursor 登录等待超时,请重试")
|
|
}
|
|
return
|
|
case <-time.After(pollInterval):
|
|
}
|
|
}
|
|
}
|
|
|
|
func (manager *Manager) fetchProfile(ctx context.Context, authorization string) (*aiserverv1.GetMeResponse, error) {
|
|
body, err := proto.Marshal(&aiserverv1.GetMeRequest{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, backendURL+"/aiserver.v1.DashboardService/GetMe", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("authorization", authorization)
|
|
req.Header.Set("x-cursor-checksum", upstream.BuildCursorChecksum(authorization))
|
|
req.Header.Set("content-type", "application/proto")
|
|
req.Header.Set("accept", "application/proto")
|
|
req.Header.Set("connect-protocol-version", "1")
|
|
resp, err := manager.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("GetMe 返回 HTTP %d", resp.StatusCode)
|
|
}
|
|
profile := &aiserverv1.GetMeResponse{}
|
|
if err := proto.Unmarshal(responseBody, profile); err != nil {
|
|
return nil, err
|
|
}
|
|
return profile, nil
|
|
}
|
|
|
|
func (manager *Manager) pollOnce(ctx context.Context, loginID string, verifier string) (pollResponse, bool, error) {
|
|
endpoint, err := url.Parse(backendURL + "/auth/poll")
|
|
if err != nil {
|
|
return pollResponse{}, false, err
|
|
}
|
|
query := endpoint.Query()
|
|
query.Set("uuid", loginID)
|
|
query.Set("verifier", verifier)
|
|
endpoint.RawQuery = query.Encode()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
|
if err != nil {
|
|
return pollResponse{}, false, err
|
|
}
|
|
resp, err := manager.client.Do(req)
|
|
if err != nil {
|
|
return pollResponse{}, false, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64*1024))
|
|
return pollResponse{}, true, nil
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
|
|
if err != nil {
|
|
return pollResponse{}, false, err
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return pollResponse{}, false, fmt.Errorf("登录服务返回 HTTP %d", resp.StatusCode)
|
|
}
|
|
result := pollResponse{}
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
return pollResponse{}, false, fmt.Errorf("解析登录响应失败: %w", err)
|
|
}
|
|
return result, false, nil
|
|
}
|
|
|
|
func (manager *Manager) refresh(ctx context.Context, current credentials) (credentials, bool, error) {
|
|
payload, err := json.Marshal(map[string]string{
|
|
"grant_type": "refresh_token",
|
|
"client_id": authClientID,
|
|
"refresh_token": current.RefreshToken,
|
|
})
|
|
if err != nil {
|
|
return credentials{}, false, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, backendURL+"/oauth/token", bytes.NewReader(payload))
|
|
if err != nil {
|
|
return credentials{}, false, err
|
|
}
|
|
req.Header.Set("content-type", "application/json")
|
|
resp, err := manager.client.Do(req)
|
|
if err != nil {
|
|
return credentials{}, false, err
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
|
|
if err != nil {
|
|
return credentials{}, false, err
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return credentials{}, false, fmt.Errorf("刷新服务返回 HTTP %d", resp.StatusCode)
|
|
}
|
|
result := refreshResponse{}
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
return credentials{}, false, fmt.Errorf("解析刷新响应失败: %w", err)
|
|
}
|
|
if result.ShouldLogout {
|
|
return credentials{}, true, nil
|
|
}
|
|
if strings.TrimSpace(result.AccessToken) == "" {
|
|
return credentials{}, false, fmt.Errorf("刷新响应缺少 access token")
|
|
}
|
|
current.AccessToken = strings.TrimSpace(result.AccessToken)
|
|
if strings.TrimSpace(result.RefreshToken) != "" {
|
|
current.RefreshToken = strings.TrimSpace(result.RefreshToken)
|
|
}
|
|
return current, false, nil
|
|
}
|
|
|
|
func (manager *Manager) load() error {
|
|
if manager.path == "" {
|
|
return fmt.Errorf("Cursor 账号凭据路径为空")
|
|
}
|
|
data, err := os.ReadFile(manager.path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
loaded := credentials{}
|
|
if err := json.Unmarshal(data, &loaded); err != nil {
|
|
return err
|
|
}
|
|
loaded.AccessToken = strings.TrimSpace(loaded.AccessToken)
|
|
loaded.RefreshToken = strings.TrimSpace(loaded.RefreshToken)
|
|
loaded.AuthID = strings.TrimSpace(loaded.AuthID)
|
|
loaded.Email = strings.TrimSpace(loaded.Email)
|
|
if loaded.AccessToken == "" {
|
|
return nil
|
|
}
|
|
manager.credentials = loaded
|
|
manager.state = StateSignedIn
|
|
return nil
|
|
}
|
|
|
|
func (manager *Manager) save(value credentials) error {
|
|
if manager.path == "" {
|
|
return fmt.Errorf("Cursor 账号凭据路径为空")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(manager.path), 0o700); err != nil {
|
|
return err
|
|
}
|
|
data, err := json.MarshalIndent(value, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tempPath := manager.path + ".tmp"
|
|
if err := os.WriteFile(tempPath, append(data, '\n'), 0o600); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Chmod(tempPath, 0o600); err != nil {
|
|
_ = os.Remove(tempPath)
|
|
return err
|
|
}
|
|
if err := os.Rename(tempPath, manager.path); err != nil {
|
|
_ = os.Remove(tempPath)
|
|
return err
|
|
}
|
|
return os.Chmod(manager.path, 0o600)
|
|
}
|
|
|
|
func (manager *Manager) snapshotCredentials() (credentials, uint64) {
|
|
manager.mu.RLock()
|
|
defer manager.mu.RUnlock()
|
|
return manager.credentials, manager.loginGeneration
|
|
}
|
|
|
|
func (manager *Manager) finishWithError(generation uint64, message string) {
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
if manager.loginGeneration != generation {
|
|
return
|
|
}
|
|
manager.state = StateError
|
|
manager.lastError = strings.TrimSpace(message)
|
|
}
|
|
|
|
func (manager *Manager) commitCredentials(generation uint64, value credentials) error {
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
if manager.loginGeneration != generation {
|
|
return ErrNotSignedIn
|
|
}
|
|
if err := manager.save(value); err != nil {
|
|
manager.state = StateError
|
|
manager.lastError = fmt.Sprintf("保存 Cursor 登录凭据失败: %v", err)
|
|
return err
|
|
}
|
|
manager.credentials = value
|
|
manager.state = StateSignedIn
|
|
manager.lastError = ""
|
|
return nil
|
|
}
|
|
|
|
func (manager *Manager) setAuthorizationError(generation uint64, message string) {
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
if manager.loginGeneration != generation {
|
|
return
|
|
}
|
|
manager.state = StateError
|
|
manager.lastError = strings.TrimSpace(message)
|
|
}
|
|
|
|
func (manager *Manager) invalidateAuthorization(generation uint64, message string) {
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
if manager.loginGeneration != generation {
|
|
return
|
|
}
|
|
manager.loginGeneration++
|
|
manager.credentials = credentials{}
|
|
manager.state = StateError
|
|
manager.lastError = strings.TrimSpace(message)
|
|
_ = os.Remove(manager.path)
|
|
}
|
|
|
|
func buildLoginURL(loginID string, challenge string) (string, error) {
|
|
parsed, err := url.Parse(websiteURL + "/loginDeepControl")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
query := parsed.Query()
|
|
query.Set("challenge", challenge)
|
|
query.Set("uuid", loginID)
|
|
query.Set("mode", "login")
|
|
query.Set("supportsSelectedTeamLogin", "true")
|
|
parsed.RawQuery = query.Encode()
|
|
return parsed.String(), nil
|
|
}
|
|
|
|
func bearer(token string) string {
|
|
value := strings.TrimSpace(token)
|
|
if strings.HasPrefix(strings.ToLower(value), "bearer ") {
|
|
return value
|
|
}
|
|
return "Bearer " + value
|
|
}
|
|
|
|
func tokenNeedsRefresh(token string, now time.Time) bool {
|
|
parts := strings.Split(strings.TrimSpace(token), ".")
|
|
if len(parts) < 2 {
|
|
return false
|
|
}
|
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
return false
|
|
}
|
|
claims := struct {
|
|
ExpiresAt json.Number `json:"exp"`
|
|
}{}
|
|
decoder := json.NewDecoder(bytes.NewReader(payload))
|
|
decoder.UseNumber()
|
|
if err := decoder.Decode(&claims); err != nil || claims.ExpiresAt == "" {
|
|
return false
|
|
}
|
|
expiresAt, err := claims.ExpiresAt.Int64()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return !now.Add(refreshMargin).Before(time.Unix(expiresAt, 0))
|
|
}
|
|
|
|
func isRetryablePollError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
var urlErr *url.Error
|
|
if errors.As(err, &urlErr) {
|
|
return true
|
|
}
|
|
message := strings.ToLower(err.Error())
|
|
return strings.Contains(message, "http 429") || strings.Contains(message, "http 5")
|
|
}
|