From 1bb40c23d57b4a627e06915bff8997ae5ba8a60a Mon Sep 17 00:00:00 2001 From: Yuri Trukhin Date: Sun, 1 Nov 2026 21:35:48 +0100 Subject: [PATCH] feat(auth): renew browser logins with the stored refresh token Browser logins kept Cursor's refresh token but never used it, so every account had to sign in again when its access token expired (about two months after login). auth.refresh now exchanges the refresh token at api2.cursor.sh/oauth/token (grant_type refresh_token, the client id Cursor's IDE and CLI use) once the access token is within a day of expiry, and reports shouldLogout or a rejected grant as 401. Fresh tokens are still checked locally without a request. Cursor's login poll returns one session token as both access and refresh token, and renewal answers with a new session token and no refresh token. When the stored refresh token is a JWT that expires before the new token, the new token becomes the refresh token as well, so renewal does not depend on the original login's expiry. A rotated refresh token is stored as returned; opaque or longer-lived ones are kept. CPA's refresh loop only calls auth.refresh for plugin credentials that name an interval, so renewable logins now carry the host attribute refresh_interval_seconds=3600. API keys and cursor-agent imports are unchanged. --- README.md | 3 +- README_CN.md | 2 +- auth.go | 70 ++++++++++++++++++++- host_http.go | 2 +- refresh_test.go | 159 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 230 insertions(+), 6 deletions(-) create mode 100644 refresh_test.go diff --git a/README.md b/README.md index ab00fd3..04431c8 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,8 @@ Alternatively, import a Cursor API key credential into CPA: ## Use Get model IDs from CPA's `/v1/models` and select a `cursor/…` model in your client. -Browser tokens require signing in again after expiry. Per-account proxy overrides are currently unsupported. +Browser logins renew their access token through CPA's credential refresh about a day before it expires; +sign in again only if Cursor ends the session. Per-account proxy overrides are currently unsupported. ## Development diff --git a/README_CN.md b/README_CN.md index 61a858b..cb9dc65 100644 --- a/README_CN.md +++ b/README_CN.md @@ -89,7 +89,7 @@ curl "http://127.0.0.1:8317/v8/management/oauth/status?state=$STATE" \ ## 使用 从 CPA 的 `/v1/models` 获取模型 ID,在客户端选择对应的 `cursor/…` 模型。 -浏览器登录令牌过期后需重新登录;暂不支持单账号代理覆盖。 +浏览器登录的访问令牌会在过期前约一天通过 CPA 的凭据刷新自动续期;仅当 Cursor 结束会话时需重新登录。暂不支持单账号代理覆盖。 ## 开发 diff --git a/auth.go b/auth.go index 2adfd11..d0747d8 100644 --- a/auth.go +++ b/auth.go @@ -99,7 +99,13 @@ func authRecord(auth credential, name, label string, extra map[string]any) map[s if label == "" { label = "Cursor" } - return map[string]any{"Provider": "cursor", "ID": "cursor-" + identity, "FileName": name, "Label": label, "StorageJSON": encodeJSON(stored), "Metadata": stored} + record := map[string]any{"Provider": "cursor", "ID": "cursor-" + identity, "FileName": name, "Label": label, "StorageJSON": encodeJSON(stored), "Metadata": stored} + if renewable(auth) { + // CPA's refresh loop calls auth.refresh only for credentials that name an interval; + // refreshAuth itself contacts Cursor only within refreshLead of the token's expiry. + record["Attributes"] = map[string]string{"refresh_interval_seconds": "3600"} + } + return record } func (p *pluginRuntime) parseAuth(req rpcRequest) (any, error) { stored, err := jsonObject(req.RawJSON) @@ -171,12 +177,36 @@ func (p *pluginRuntime) token(s *requestScope, req rpcRequest) (string, error) { } return auth.Access, nil } + +// cursorClientID is the OAuth client Cursor's IDE and CLI renew their login sessions with. +const cursorClientID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB" + +// refreshLead renews a browser session this long before its access token expires. +const refreshLead = 24 * time.Hour + +// renewable reports whether a credential holds a refresh token issued by browser login. +func renewable(auth credential) bool { + return auth.Type == "oauth" && auth.Refresh != "" && auth.Refresh != "none" && auth.Refresh != "cursor-agent" +} func (p *pluginRuntime) refreshAuth(s *requestScope, req rpcRequest) (any, error) { auth, stored, err := storedCredential(req) if err != nil { return nil, err } - if auth.Type == "oauth" { + now := p.now() + expires := auth.Expires + if expires == 0 { + expires = jwtExpiry(auth.Access) + } + if renewable(auth) && (auth.Access == "" || expires > 0 && expires-now.UnixMilli() <= refreshLead.Milliseconds()) { + if err = checkProxy(req, stored); err != nil { + return nil, err + } + if auth, err = p.renew(s, auth); err != nil { + return nil, err + } + expires = auth.Expires + } else if auth.Type == "oauth" { if _, err = p.token(s, req); err != nil { return nil, err } @@ -185,7 +215,41 @@ func (p *pluginRuntime) refreshAuth(s *requestScope, req rpcRequest) (any, error if req.AuthID != "" { record["ID"] = req.AuthID } - return map[string]any{"Auth": record, "NextRefreshAfter": p.now().Add(time.Hour).UTC().Format(time.RFC3339)}, nil + next := now.Add(time.Hour) + if renewable(auth) && expires > 0 { + if due := time.UnixMilli(expires).Add(-refreshLead); due.After(now) && due.Before(next) { + next = due + } + } + return map[string]any{"Auth": record, "NextRefreshAfter": next.UTC().Format(time.RFC3339)}, nil +} + +// renew exchanges the stored refresh token for a new access token. Cursor answers a +// session it will not renew with HTTP 200, an empty token and shouldLogout. +// +// Browser login stores one session token as both access and refresh token, and renewal +// returns a new session token without a refresh token. When the stored refresh token is +// a JWT that expires before the new token, the new token becomes the refresh token too, +// so renewal does not depend on the original login's expiry. +func (p *pluginRuntime) renew(s *requestScope, auth credential) (credential, error) { + ctx, cancel := context.WithTimeout(s.ctx, 15*time.Second) + defer cancel() + result, err := s.json(ctx, http.MethodPost, "/oauth/token", "", map[string]any{"grant_type": "refresh_token", "client_id": cursorClientID, "refresh_token": auth.Refresh}) + if err != nil { + return auth, err + } + token := str(result["access_token"]) + if result["shouldLogout"] == true || token == "" { + return auth, failure(401, "Cursor ended this session; sign in to Cursor again") + } + auth.Access = token + auth.Expires = jwtExpiry(token) + if refresh := str(result["refresh_token"]); refresh != "" { + auth.Refresh = refresh + } else if expires := jwtExpiry(auth.Refresh); expires > 0 && auth.Expires > expires { + auth.Refresh = token + } + return auth, nil } func (p *pluginRuntime) startLogin(s *requestScope, req rpcRequest) (any, error) { now := p.now() diff --git a/host_http.go b/host_http.go index 4ede52d..dfb707c 100644 --- a/host_http.go +++ b/host_http.go @@ -217,7 +217,7 @@ func (s *requestScope) json(ctx context.Context, method, path, token string, bod payload = encodeJSON(body) } h := headers(token) - if strings.HasPrefix(path, "/auth/") { + if strings.HasPrefix(path, "/auth/") || strings.HasPrefix(path, "/oauth/") { h.Del("Connect-Protocol-Version") } resp, err := s.do(ctx, method, url, h, payload) diff --git a/refresh_test.go b/refresh_test.go new file mode 100644 index 0000000..8b6f561 --- /dev/null +++ b/refresh_test.go @@ -0,0 +1,159 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "strings" + "testing" + "time" +) + +func fixtureJWT(exp time.Time) string { + payload := base64.RawURLEncoding.EncodeToString(encodeJSON(map[string]any{"exp": exp.Unix(), "sub": "github|1"})) + return "eyJhbGciOiJIUzI1NiJ9." + payload + ".sig" +} +func refreshedCredential(t *testing.T, result any) credential { + t.Helper() + record := object(result.(map[string]any)["Auth"]) + stored, err := jsonObject(record["StorageJSON"].([]byte)) + if err != nil { + t.Fatal(err) + } + var auth credential + if err = json.Unmarshal(encodeJSON(stored["cursor_auth"]), &auth); err != nil { + t.Fatal(err) + } + return auth +} +func TestNativeRefreshRenewsBrowserSessionBeforeExpiry(t *testing.T) { + now := time.Unix(1800000000, 0) + renewed := fixtureJWT(now.Add(60 * 24 * time.Hour)) + session := fixtureJWT(now.Add(time.Hour)) + longLived := fixtureJWT(now.Add(90 * 24 * time.Hour)) + for _, tc := range []struct { + name, refresh, rotated, want string + }{ + {name: "opaque refresh token kept", refresh: "fixture-refresh", want: "fixture-refresh"}, + {name: "rotated refresh token stored", refresh: "fixture-refresh", rotated: "rotated-refresh", want: "rotated-refresh"}, + {name: "session token chained", refresh: session, want: renewed}, + {name: "longer-lived refresh JWT kept", refresh: longLived, want: longLived}, + } { + t.Run(tc.name, func(t *testing.T) { + p := newTestRuntime(t) + p.now = func() time.Time { return now } + h := newMockHost(t) + s := p.newScope(h.call, "refresh") + calls := 0 + h.respond = func(_ context.Context, req mockRequest) (mockResponse, error) { + calls++ + if req.Method != "POST" || !strings.HasSuffix(req.URL, "/oauth/token") { + t.Errorf("unexpected request %s %s", req.Method, req.URL) + } + if req.Headers.Get("Connect-Protocol-Version") != "" || req.Headers.Get("Authorization") != "" { + t.Errorf("refresh sent RPC headers: %v", req.Headers) + } + body, _ := jsonObject(req.Body) + if body["grant_type"] != "refresh_token" || body["client_id"] != cursorClientID || body["refresh_token"] != tc.refresh { + t.Errorf("unexpected refresh body %v", body) + } + reply := map[string]any{"access_token": renewed} + if tc.rotated != "" { + reply["refresh_token"] = tc.rotated + } + return mockResponse{Status: 200, Body: encodeJSON(reply)}, nil + } + stored := map[string]any{"type": "cursor", "label": "Personal", "cursor_auth": map[string]any{"type": "oauth", "access": session, "refresh": tc.refresh, "expires": now.Add(time.Hour).UnixMilli(), "accountId": "fixture@example.invalid"}} + result, err := p.refreshAuth(s, rpcRequest{StorageJSON: encodeJSON(stored), AuthID: "cursor-fixture", FileName: "cursor-fixture.json"}) + s.close() + if err != nil { + t.Fatal(err) + } + auth := refreshedCredential(t, result) + if calls != 1 || auth.Access != renewed || auth.Refresh != tc.want || auth.Expires != now.Add(60*24*time.Hour).UnixMilli() || auth.AccountID != "fixture@example.invalid" { + t.Fatalf("renewal not persisted: calls=%d auth=%+v", calls, auth) + } + out := result.(map[string]any) + record := object(out["Auth"]) + if record["ID"] != "cursor-fixture" || out["NextRefreshAfter"] != now.Add(time.Hour).UTC().Format(time.RFC3339) { + t.Fatalf("unexpected refresh record %v", out) + } + if attrs, _ := record["Attributes"].(map[string]string); attrs["refresh_interval_seconds"] != "3600" { + t.Fatalf("renewable login not scheduled for host refresh: %v", record["Attributes"]) + } + }) + } +} +func TestNativeAuthRecordSchedulesOnlyRenewableLogins(t *testing.T) { + for _, tc := range []struct { + auth credential + want bool + }{ + {credential{Type: "oauth", Access: "a", Refresh: "fixture-refresh"}, true}, + {credential{Type: "oauth", Access: "a", Refresh: "none"}, false}, + {credential{Type: "oauth", Refresh: "cursor-agent"}, false}, + {credential{Type: "api", Key: "k"}, false}, + } { + _, scheduled := authRecord(tc.auth, "", "", nil)["Attributes"] + if scheduled != tc.want { + t.Fatalf("%+v: scheduled=%t, want %t", tc.auth, scheduled, tc.want) + } + } +} +func TestNativeRefreshSchedulesRenewalAndSkipsFreshTokens(t *testing.T) { + now := time.Unix(1800000000, 0) + for _, tc := range []struct { + name string + expires time.Time + refresh string + next time.Time + }{ + {"fresh", now.Add(10 * 24 * time.Hour), "fixture-refresh", now.Add(time.Hour)}, + {"due within the hour", now.Add(refreshLead + 30*time.Minute), "fixture-refresh", now.Add(30 * time.Minute)}, + {"no refresh token", now.Add(time.Hour), "none", now.Add(time.Hour)}, + } { + t.Run(tc.name, func(t *testing.T) { + p := newTestRuntime(t) + p.now = func() time.Time { return now } + h := newMockHost(t) + s := p.newScope(h.call, "refresh") + defer s.close() + h.respond = func(_ context.Context, req mockRequest) (mockResponse, error) { + t.Errorf("unexpected request %s", req.URL) + return mockResponse{Status: 500}, nil + } + access := fixtureJWT(tc.expires) + stored := map[string]any{"type": "cursor", "cursor_auth": map[string]any{"type": "oauth", "access": access, "refresh": tc.refresh, "expires": tc.expires.UnixMilli()}} + result, err := p.refreshAuth(s, rpcRequest{StorageJSON: encodeJSON(stored)}) + if err != nil { + t.Fatal(err) + } + if auth := refreshedCredential(t, result); auth.Access != access { + t.Fatal("fresh token was replaced") + } + if next := result.(map[string]any)["NextRefreshAfter"]; next != tc.next.UTC().Format(time.RFC3339) { + t.Fatalf("NextRefreshAfter = %v, want %v", next, tc.next.UTC().Format(time.RFC3339)) + } + }) + } +} +func TestNativeRefreshReportsEndedSession(t *testing.T) { + now := time.Unix(1800000000, 0) + for name, reply := range map[string]mockResponse{ + "shouldLogout": {Status: 200, Body: []byte(`{"access_token":"","shouldLogout":true}`)}, + "invalid grant": {Status: 401, Body: []byte(`{"error":"invalid_grant"}`)}, + } { + t.Run(name, func(t *testing.T) { + p := newTestRuntime(t) + p.now = func() time.Time { return now } + h := newMockHost(t) + s := p.newScope(h.call, "refresh") + defer s.close() + h.respond = func(context.Context, mockRequest) (mockResponse, error) { return reply, nil } + stored := map[string]any{"type": "cursor", "cursor_auth": map[string]any{"type": "oauth", "access": fixtureJWT(now.Add(-time.Minute)), "refresh": "fixture-refresh"}} + if _, err := p.refreshAuth(s, rpcRequest{StorageJSON: encodeJSON(stored)}); errorStatus(err) != 401 { + t.Fatalf("ended session not reported as 401: %v", err) + } + }) + } +}