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