From 7386a70724a8d968eb4e5511e1de6b78bd6a91b1 Mon Sep 17 00:00:00 2001 From: MrHuangJser Date: Fri, 27 Mar 2026 17:40:02 +0800 Subject: [PATCH] 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) --- .../api/handlers/management/auth_files.go | 14 ++++-- internal/auth/cursor/filename.go | 27 ++++++++-- internal/auth/cursor/oauth.go | 49 +++++++++++++++---- sdk/auth/cursor.go | 11 ++++- 4 files changed, 80 insertions(+), 21 deletions(-) diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 91e1c425..1c2abd34 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -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", diff --git a/internal/auth/cursor/filename.go b/internal/auth/cursor/filename.go index 47cce08b..e8fb8415 100644 --- a/internal/auth/cursor/filename.go +++ b/internal/auth/cursor/filename.go @@ -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" } diff --git a/internal/auth/cursor/oauth.go b/internal/auth/cursor/oauth.go index 065eff7e..009dda01 100644 --- a/internal/auth/cursor/oauth.go +++ b/internal/auth/cursor/oauth.go @@ -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) } diff --git a/sdk/auth/cursor.go b/sdk/auth/cursor.go index d6077be6..5e26221c 100644 --- a/sdk/auth/cursor.go +++ b/sdk/auth/cursor.go @@ -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 }