Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ curl "http://127.0.0.1:8317/v8/management/oauth/status?state=$STATE" \
## 使用

从 CPA 的 `/v1/models` 获取模型 ID,在客户端选择对应的 `cursor/…` 模型。
浏览器登录令牌过期后需重新登录;暂不支持单账号代理覆盖。
浏览器登录的访问令牌会在过期前约一天通过 CPA 的凭据刷新自动续期;仅当 Cursor 结束会话时需重新登录。暂不支持单账号代理覆盖。

## 开发

Expand Down
70 changes: 67 additions & 3 deletions auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand All @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion host_http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
159 changes: 159 additions & 0 deletions refresh_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}