Skip to content

Commit deba467

Browse files
robineberscursoragentvalidatedev
authored
feat(cursor): add sqlite-first auth with keychain fallback (#210)
Read Cursor tokens from Desktop SQLite first, then fall back to CLI keychain tokens. Persist refreshed access tokens to the active source and add regression tests plus provider docs for the new auth source order. Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Mert Can Demir <validatedev@gmail.com>
1 parent 0b63ade commit deba467

4 files changed

Lines changed: 223 additions & 21 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ OpenUsage lives in your menu bar and shows you how much of your AI coding subscr
2727
- [**Claude**](docs/providers/claude.md) / session, weekly, extra usage, local token usage (ccusage)
2828
- [**Codex**](docs/providers/codex.md) / session, weekly, reviews, credits
2929
- [**Copilot**](docs/providers/copilot.md) / premium, chat, completions
30-
- [**Cursor**](docs/providers/cursor.md) / credits, plan usage, on-demand
30+
- [**Cursor**](docs/providers/cursor.md) / credits, plan usage, on-demand, CLI auth
3131
- [**Factory / Droid**](docs/providers/factory.md) / standard, premium tokens
3232
- [**Gemini**](docs/providers/gemini.md) / pro, flash, workspace/free/paid tier
3333
- [**Kimi Code**](docs/providers/kimi.md) / session, weekly

docs/providers/cursor.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,16 @@ Returns limit policy status plus any active credit grants. Response undocumented
105105

106106
## Authentication
107107

108-
### Token Location
108+
### Token Sources
109109

110-
SQLite database at `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb`
110+
OpenUsage reads Cursor auth in this order:
111+
112+
1. **Cursor Desktop SQLite** (preferred)
113+
2. **Cursor CLI keychain** (fallback)
114+
115+
#### 1) Cursor Desktop SQLite (preferred)
116+
117+
Path: `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb`
111118

112119
```bash
113120
sqlite3 ~/Library/Application\ Support/Cursor/User/globalStorage/state.vscdb \
@@ -122,9 +129,22 @@ sqlite3 ~/Library/Application\ Support/Cursor/User/globalStorage/state.vscdb \
122129
| `cursorAuth/stripeMembershipType` | Plan tier (e.g. `pro`, `ultra`) |
123130
| `cursorAuth/stripeSubscriptionStatus` | Subscription status |
124131

132+
#### 2) Cursor CLI keychain (fallback)
133+
134+
OpenUsage reads Cursor CLI tokens from keychain:
135+
136+
- `cursor-access-token`
137+
- `cursor-refresh-token`
138+
139+
To initialize CLI auth:
140+
141+
```bash
142+
agent login
143+
```
144+
125145
### Token Refresh
126146

127-
Access tokens are short-lived JWTs. The app refreshes before each request if expired.
147+
Access tokens are short-lived JWTs. The app refreshes before each request if expired, then persists the new access token back to the same source it was loaded from (SQLite or keychain).
128148

129149
```
130150
POST https://api2.cursor.sh/oauth/token

plugins/cursor/plugin.js

Lines changed: 86 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
(function () {
22
const STATE_DB =
33
"~/Library/Application Support/Cursor/User/globalStorage/state.vscdb"
4+
const KEYCHAIN_ACCESS_TOKEN_SERVICE = "cursor-access-token"
5+
const KEYCHAIN_REFRESH_TOKEN_SERVICE = "cursor-refresh-token"
46
const BASE_URL = "https://api2.cursor.sh"
57
const USAGE_URL = BASE_URL + "/aiserver.v1.DashboardService/GetCurrentPeriodUsage"
68
const PLAN_URL = BASE_URL + "/aiserver.v1.DashboardService/GetPlanInfo"
@@ -9,6 +11,7 @@
911
const REST_USAGE_URL = "https://cursor.com/api/usage"
1012
const CLIENT_ID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB"
1113
const REFRESH_BUFFER_MS = 5 * 60 * 1000 // refresh 5 minutes before expiration
14+
const LOGIN_HINT = "Sign in via Cursor app or run `agent login`."
1215

1316
function readStateValue(ctx, key) {
1417
try {
@@ -46,6 +49,70 @@
4649
}
4750
}
4851

52+
function readKeychainValue(ctx, service) {
53+
if (!ctx.host.keychain || typeof ctx.host.keychain.readGenericPassword !== "function") {
54+
return null
55+
}
56+
try {
57+
const value = ctx.host.keychain.readGenericPassword(service)
58+
if (typeof value !== "string") return null
59+
const trimmed = value.trim()
60+
return trimmed || null
61+
} catch (e) {
62+
ctx.host.log.info("keychain read failed for " + service + ": " + String(e))
63+
return null
64+
}
65+
}
66+
67+
function writeKeychainValue(ctx, service, value) {
68+
if (!ctx.host.keychain || typeof ctx.host.keychain.writeGenericPassword !== "function") {
69+
ctx.host.log.warn("keychain write unsupported")
70+
return false
71+
}
72+
try {
73+
ctx.host.keychain.writeGenericPassword(service, String(value))
74+
return true
75+
} catch (e) {
76+
ctx.host.log.warn("keychain write failed for " + service + ": " + String(e))
77+
return false
78+
}
79+
}
80+
81+
function loadAuthState(ctx) {
82+
const sqliteAccessToken = readStateValue(ctx, "cursorAuth/accessToken")
83+
const sqliteRefreshToken = readStateValue(ctx, "cursorAuth/refreshToken")
84+
if (sqliteAccessToken || sqliteRefreshToken) {
85+
return {
86+
accessToken: sqliteAccessToken,
87+
refreshToken: sqliteRefreshToken,
88+
source: "sqlite",
89+
}
90+
}
91+
92+
const keychainAccessToken = readKeychainValue(ctx, KEYCHAIN_ACCESS_TOKEN_SERVICE)
93+
const keychainRefreshToken = readKeychainValue(ctx, KEYCHAIN_REFRESH_TOKEN_SERVICE)
94+
if (keychainAccessToken || keychainRefreshToken) {
95+
return {
96+
accessToken: keychainAccessToken,
97+
refreshToken: keychainRefreshToken,
98+
source: "keychain",
99+
}
100+
}
101+
102+
return {
103+
accessToken: null,
104+
refreshToken: null,
105+
source: null,
106+
}
107+
}
108+
109+
function persistAccessToken(ctx, source, accessToken) {
110+
if (source === "keychain") {
111+
return writeKeychainValue(ctx, KEYCHAIN_ACCESS_TOKEN_SERVICE, accessToken)
112+
}
113+
return writeStateValue(ctx, "cursorAuth/accessToken", accessToken)
114+
}
115+
49116
function getTokenExpiration(ctx, token) {
50117
const payload = ctx.jwt.decodePayload(token)
51118
if (!payload || typeof payload.exp !== "number") return null
@@ -62,7 +129,7 @@
62129
})
63130
}
64131

65-
function refreshToken(ctx, refreshTokenValue) {
132+
function refreshToken(ctx, refreshTokenValue, source) {
66133
if (!refreshTokenValue) {
67134
ctx.host.log.warn("refresh skipped: no refresh token")
68135
return null
@@ -88,9 +155,9 @@
88155
const shouldLogout = errorInfo && errorInfo.shouldLogout === true
89156
ctx.host.log.error("refresh failed: status=" + resp.status + " shouldLogout=" + shouldLogout)
90157
if (shouldLogout) {
91-
throw "Session expired. Sign in via Cursor app."
158+
throw "Session expired. " + LOGIN_HINT
92159
}
93-
throw "Token expired. Sign in via Cursor app."
160+
throw "Token expired. " + LOGIN_HINT
94161
}
95162

96163
if (resp.status < 200 || resp.status >= 300) {
@@ -107,7 +174,7 @@
107174
// Check if server wants us to logout
108175
if (body.shouldLogout === true) {
109176
ctx.host.log.error("refresh response indicates shouldLogout=true")
110-
throw "Session expired. Sign in via Cursor app."
177+
throw "Session expired. " + LOGIN_HINT
111178
}
112179

113180
const newAccessToken = body.access_token
@@ -116,8 +183,8 @@
116183
return null
117184
}
118185

119-
// Persist updated access token to SQLite
120-
writeStateValue(ctx, "cursorAuth/accessToken", newAccessToken)
186+
// Persist updated access token to source where auth was loaded from.
187+
persistAccessToken(ctx, source, newAccessToken)
121188
ctx.host.log.info("refresh succeeded, token persisted")
122189

123190
// Note: Cursor refresh returns access_token which is used as both
@@ -221,15 +288,17 @@
221288
}
222289

223290
function probe(ctx) {
224-
let accessToken = readStateValue(ctx, "cursorAuth/accessToken")
225-
const refreshTokenValue = readStateValue(ctx, "cursorAuth/refreshToken")
291+
const authState = loadAuthState(ctx)
292+
let accessToken = authState.accessToken
293+
const refreshTokenValue = authState.refreshToken
294+
const authSource = authState.source
226295

227296
if (!accessToken && !refreshTokenValue) {
228-
ctx.host.log.error("probe failed: no access or refresh token in sqlite")
229-
throw "Not logged in. Sign in via Cursor app."
297+
ctx.host.log.error("probe failed: no access or refresh token in sqlite/keychain")
298+
throw "Not logged in. " + LOGIN_HINT
230299
}
231-
232-
ctx.host.log.info("tokens loaded: accessToken=" + (accessToken ? "yes" : "no") + " refreshToken=" + (refreshTokenValue ? "yes" : "no"))
300+
301+
ctx.host.log.info("tokens loaded from " + authSource + ": accessToken=" + (accessToken ? "yes" : "no") + " refreshToken=" + (refreshTokenValue ? "yes" : "no"))
233302

234303
const nowMs = Date.now()
235304

@@ -238,7 +307,7 @@
238307
ctx.host.log.info("token needs refresh (expired or expiring soon)")
239308
let refreshed = null
240309
try {
241-
refreshed = refreshToken(ctx, refreshTokenValue)
310+
refreshed = refreshToken(ctx, refreshTokenValue, authSource)
242311
} catch (e) {
243312
// If refresh fails but we have an access token, try it anyway
244313
ctx.host.log.warn("refresh failed but have access token, will try: " + String(e))
@@ -248,7 +317,7 @@
248317
accessToken = refreshed
249318
} else if (!accessToken) {
250319
ctx.host.log.error("refresh failed and no access token available")
251-
throw "Not logged in. Sign in via Cursor app."
320+
throw "Not logged in. " + LOGIN_HINT
252321
}
253322
}
254323

@@ -270,7 +339,7 @@
270339
refresh: () => {
271340
ctx.host.log.info("usage returned 401, attempting refresh")
272341
didRefresh = true
273-
const refreshed = refreshToken(ctx, refreshTokenValue)
342+
const refreshed = refreshToken(ctx, refreshTokenValue, authSource)
274343
if (refreshed) accessToken = refreshed
275344
return refreshed
276345
},
@@ -283,14 +352,14 @@
283352

284353
if (ctx.util.isAuthStatus(usageResp.status)) {
285354
ctx.host.log.error("usage returned auth error after all retries: status=" + usageResp.status)
286-
throw "Token expired. Sign in via Cursor app."
355+
throw "Token expired. " + LOGIN_HINT
287356
}
288357

289358
if (usageResp.status < 200 || usageResp.status >= 300) {
290359
ctx.host.log.error("usage returned error: status=" + usageResp.status)
291360
throw "Usage request failed (HTTP " + String(usageResp.status) + "). Try again later."
292361
}
293-
362+
294363
ctx.host.log.info("usage fetch succeeded")
295364

296365
const usage = ctx.util.tryParseJson(usageResp.bodyText)

plugins/cursor/plugin.test.js

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,119 @@ describe("cursor plugin", () => {
2626
expect(() => plugin.probe(ctx)).toThrow("Not logged in")
2727
})
2828

29+
it("loads tokens from keychain when sqlite has none", async () => {
30+
const ctx = makeCtx()
31+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([]))
32+
ctx.host.keychain.readGenericPassword.mockImplementation((service) => {
33+
if (service === "cursor-access-token") return "keychain-access-token"
34+
if (service === "cursor-refresh-token") return "keychain-refresh-token"
35+
return null
36+
})
37+
ctx.host.http.request.mockReturnValue({
38+
status: 200,
39+
bodyText: JSON.stringify({
40+
enabled: true,
41+
planUsage: { totalSpend: 1200, limit: 2400 },
42+
}),
43+
})
44+
45+
const plugin = await loadPlugin()
46+
const result = plugin.probe(ctx)
47+
48+
expect(result.lines.find((line) => line.label === "Plan usage")).toBeTruthy()
49+
expect(ctx.host.keychain.readGenericPassword).toHaveBeenCalledWith("cursor-access-token")
50+
expect(ctx.host.keychain.readGenericPassword).toHaveBeenCalledWith("cursor-refresh-token")
51+
})
52+
53+
it("refreshes keychain access token and persists to keychain source", async () => {
54+
const ctx = makeCtx()
55+
const expiredPayload = Buffer.from(JSON.stringify({ exp: 1 }), "utf8")
56+
.toString("base64")
57+
.replace(/=+$/g, "")
58+
const expiredAccessToken = `a.${expiredPayload}.c`
59+
const freshPayload = Buffer.from(JSON.stringify({ exp: 9999999999 }), "utf8")
60+
.toString("base64")
61+
.replace(/=+$/g, "")
62+
const refreshedAccessToken = `a.${freshPayload}.c`
63+
64+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([]))
65+
ctx.host.keychain.readGenericPassword.mockImplementation((service) => {
66+
if (service === "cursor-access-token") return expiredAccessToken
67+
if (service === "cursor-refresh-token") return "keychain-refresh-token"
68+
return null
69+
})
70+
ctx.host.http.request.mockImplementation((opts) => {
71+
if (String(opts.url).includes("/oauth/token")) {
72+
return {
73+
status: 200,
74+
bodyText: JSON.stringify({ access_token: refreshedAccessToken }),
75+
}
76+
}
77+
return {
78+
status: 200,
79+
bodyText: JSON.stringify({
80+
enabled: true,
81+
planUsage: { totalSpend: 1200, limit: 2400 },
82+
}),
83+
}
84+
})
85+
86+
const plugin = await loadPlugin()
87+
const result = plugin.probe(ctx)
88+
89+
expect(result.lines.find((line) => line.label === "Plan usage")).toBeTruthy()
90+
expect(ctx.host.keychain.writeGenericPassword).toHaveBeenCalledWith(
91+
"cursor-access-token",
92+
refreshedAccessToken
93+
)
94+
expect(ctx.host.sqlite.exec).not.toHaveBeenCalled()
95+
})
96+
97+
it("prefers sqlite tokens when sqlite and keychain both have tokens", async () => {
98+
const ctx = makeCtx()
99+
const sqlitePayload = Buffer.from(JSON.stringify({ exp: 9999999999 }), "utf8")
100+
.toString("base64")
101+
.replace(/=+$/g, "")
102+
const sqliteToken = `a.${sqlitePayload}.c`
103+
const keychainPayload = Buffer.from(JSON.stringify({ exp: 9999999999, sub: "keychain" }), "utf8")
104+
.toString("base64")
105+
.replace(/=+$/g, "")
106+
const keychainToken = `a.${keychainPayload}.c`
107+
108+
ctx.host.sqlite.query.mockImplementation((db, sql) => {
109+
if (String(sql).includes("cursorAuth/accessToken")) {
110+
return JSON.stringify([{ value: sqliteToken }])
111+
}
112+
if (String(sql).includes("cursorAuth/refreshToken")) {
113+
return JSON.stringify([{ value: "sqlite-refresh-token" }])
114+
}
115+
return JSON.stringify([])
116+
})
117+
ctx.host.keychain.readGenericPassword.mockImplementation((service) => {
118+
if (service === "cursor-access-token") return keychainToken
119+
if (service === "cursor-refresh-token") return "keychain-refresh-token"
120+
return null
121+
})
122+
ctx.host.http.request.mockImplementation((opts) => {
123+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
124+
expect(opts.headers.Authorization).toBe("Bearer " + sqliteToken)
125+
}
126+
return {
127+
status: 200,
128+
bodyText: JSON.stringify({
129+
enabled: true,
130+
planUsage: { totalSpend: 1200, limit: 2400 },
131+
}),
132+
}
133+
})
134+
135+
const plugin = await loadPlugin()
136+
const result = plugin.probe(ctx)
137+
138+
expect(result.lines.find((line) => line.label === "Plan usage")).toBeTruthy()
139+
expect(ctx.host.keychain.readGenericPassword).not.toHaveBeenCalled()
140+
})
141+
29142
it("throws on sqlite errors when reading token", async () => {
30143
const ctx = makeCtx()
31144
ctx.host.sqlite.query.mockImplementation(() => {

0 commit comments

Comments
 (0)