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

@@ -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)
}