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>
This commit is contained in:
MrHuangJser
2026-03-27 17:40:02 +08:00
parent 1b7447b682
commit 7386a70724
4 changed files with 80 additions and 21 deletions

View File

@@ -3736,17 +3736,21 @@ func (h *Handler) RequestCursorToken(c *gin.Context) {
"timestamp": time.Now().UnixMilli(),
}
// Extract expiry from JWT
// Extract expiry and account identity from JWT
expiry := cursorauth.GetTokenExpiry(tokens.AccessToken)
if !expiry.IsZero() {
metadata["expires_at"] = expiry.Format(time.RFC3339)
}
fileName := cursorauth.CredentialFileName(label)
displayLabel := "Cursor User"
if label != "" {
displayLabel = "Cursor " + label
// Auto-identify account from JWT sub claim for multi-account support
sub := cursorauth.ParseJWTSub(tokens.AccessToken)
subHash := cursorauth.SubToShortHash(sub)
if sub != "" {
metadata["sub"] = sub
}
fileName := cursorauth.CredentialFileName(label, subHash)
displayLabel := cursorauth.DisplayLabel(label, subHash)
record := &coreauth.Auth{
ID: fileName,
Provider: "cursor",

View File

@@ -6,11 +6,28 @@ import (
)
// CredentialFileName returns the filename used to persist Cursor credentials.
// It uses the label as a suffix to disambiguate multiple accounts.
func CredentialFileName(label string) string {
// Priority: explicit label > auto-generated from JWT sub hash.
// If both label and subHash are empty, falls back to "cursor.json".
func CredentialFileName(label, subHash string) string {
label = strings.TrimSpace(label)
if label == "" {
return "cursor.json"
subHash = strings.TrimSpace(subHash)
if label != "" {
return fmt.Sprintf("cursor.%s.json", label)
}
return fmt.Sprintf("cursor-%s.json", label)
if subHash != "" {
return fmt.Sprintf("cursor.%s.json", subHash)
}
return "cursor.json"
}
// DisplayLabel returns a human-readable label for the Cursor account.
func DisplayLabel(label, subHash string) string {
label = strings.TrimSpace(label)
if label != "" {
return "Cursor " + label
}
if subHash != "" {
return "Cursor " + subHash
}
return "Cursor User"
}

View File

@@ -171,29 +171,60 @@ func RefreshToken(ctx context.Context, refreshToken string) (*TokenPair, error)
return &tokens, nil
}
// 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 {
// 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 time.Now().Add(1 * time.Hour)
return nil
}
// Decode the payload (middle part)
payload := parts[1]
// Add padding if needed
switch len(payload) % 4 {
case 2:
payload += "=="
case 3:
payload += "="
}
// Replace URL-safe characters
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)
}

View File

@@ -69,6 +69,10 @@ func (a CursorAuthenticator) Login(ctx context.Context, cfg *config.Config, opts
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{
@@ -78,14 +82,17 @@ func (a CursorAuthenticator) Login(ctx context.Context, cfg *config.Config, opts
"expires_at": expiresAt.Format(time.RFC3339),
"timestamp": time.Now().UnixMilli(),
}
if sub != "" {
metadata["sub"] = sub
}
fileName := "cursor.json"
fileName := cursorauth.CredentialFileName("", subHash)
return &coreauth.Auth{
ID: fileName,
Provider: a.Provider(),
FileName: fileName,
Label: "cursor-user",
Label: cursorauth.DisplayLabel("", subHash),
Metadata: metadata,
}, nil
}