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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ OpenUsage lives in your menu bar and shows you how much of your AI coding subscr
- [**Claude**](docs/providers/claude.md) / session, weekly, extra usage, local token usage (ccusage)
- [**Codex**](docs/providers/codex.md) / session, weekly, reviews, credits
- [**Copilot**](docs/providers/copilot.md) / premium, chat, completions
- [**Cursor**](docs/providers/cursor.md) / credits, plan usage, on-demand
- [**Cursor**](docs/providers/cursor.md) / credits, plan usage, on-demand, CLI auth
- [**Factory / Droid**](docs/providers/factory.md) / standard, premium tokens
- [**Gemini**](docs/providers/gemini.md) / pro, flash, workspace/free/paid tier
- [**Kimi Code**](docs/providers/kimi.md) / session, weekly
Expand Down
26 changes: 23 additions & 3 deletions docs/providers/cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,16 @@ Returns limit policy status plus any active credit grants. Response undocumented

## Authentication

### Token Location
### Token Sources

SQLite database at `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb`
OpenUsage reads Cursor auth in this order:

1. **Cursor Desktop SQLite** (preferred)
2. **Cursor CLI keychain** (fallback)

#### 1) Cursor Desktop SQLite (preferred)

Path: `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb`

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

#### 2) Cursor CLI keychain (fallback)

OpenUsage reads Cursor CLI tokens from keychain:

- `cursor-access-token`
- `cursor-refresh-token`

To initialize CLI auth:

```bash
agent login
```

### Token Refresh

Access tokens are short-lived JWTs. The app refreshes before each request if expired.
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).

```
POST https://api2.cursor.sh/oauth/token
Expand Down
103 changes: 86 additions & 17 deletions plugins/cursor/plugin.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
(function () {
const STATE_DB =
"~/Library/Application Support/Cursor/User/globalStorage/state.vscdb"
const KEYCHAIN_ACCESS_TOKEN_SERVICE = "cursor-access-token"
const KEYCHAIN_REFRESH_TOKEN_SERVICE = "cursor-refresh-token"
const BASE_URL = "https://api2.cursor.sh"
const USAGE_URL = BASE_URL + "/aiserver.v1.DashboardService/GetCurrentPeriodUsage"
const PLAN_URL = BASE_URL + "/aiserver.v1.DashboardService/GetPlanInfo"
Expand All @@ -9,6 +11,7 @@
const REST_USAGE_URL = "https://cursor.com/api/usage"
const CLIENT_ID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB"
const REFRESH_BUFFER_MS = 5 * 60 * 1000 // refresh 5 minutes before expiration
const LOGIN_HINT = "Sign in via Cursor app or run `agent login`."

function readStateValue(ctx, key) {
try {
Expand Down Expand Up @@ -46,6 +49,70 @@
}
}

function readKeychainValue(ctx, service) {
if (!ctx.host.keychain || typeof ctx.host.keychain.readGenericPassword !== "function") {
return null
}
try {
const value = ctx.host.keychain.readGenericPassword(service)
if (typeof value !== "string") return null
const trimmed = value.trim()
return trimmed || null
} catch (e) {
ctx.host.log.info("keychain read failed for " + service + ": " + String(e))
return null
}
}

function writeKeychainValue(ctx, service, value) {
if (!ctx.host.keychain || typeof ctx.host.keychain.writeGenericPassword !== "function") {
ctx.host.log.warn("keychain write unsupported")
return false
}
try {
ctx.host.keychain.writeGenericPassword(service, String(value))
return true
} catch (e) {
ctx.host.log.warn("keychain write failed for " + service + ": " + String(e))
return false
}
}

function loadAuthState(ctx) {
const sqliteAccessToken = readStateValue(ctx, "cursorAuth/accessToken")
const sqliteRefreshToken = readStateValue(ctx, "cursorAuth/refreshToken")
if (sqliteAccessToken || sqliteRefreshToken) {
return {
accessToken: sqliteAccessToken,
refreshToken: sqliteRefreshToken,
source: "sqlite",
}
}

const keychainAccessToken = readKeychainValue(ctx, KEYCHAIN_ACCESS_TOKEN_SERVICE)
const keychainRefreshToken = readKeychainValue(ctx, KEYCHAIN_REFRESH_TOKEN_SERVICE)
if (keychainAccessToken || keychainRefreshToken) {
return {
accessToken: keychainAccessToken,
refreshToken: keychainRefreshToken,
source: "keychain",
}
}

return {
accessToken: null,
refreshToken: null,
source: null,
}
}

function persistAccessToken(ctx, source, accessToken) {
if (source === "keychain") {
return writeKeychainValue(ctx, KEYCHAIN_ACCESS_TOKEN_SERVICE, accessToken)
}
return writeStateValue(ctx, "cursorAuth/accessToken", accessToken)
}

function getTokenExpiration(ctx, token) {
const payload = ctx.jwt.decodePayload(token)
if (!payload || typeof payload.exp !== "number") return null
Expand All @@ -62,7 +129,7 @@
})
}

function refreshToken(ctx, refreshTokenValue) {
function refreshToken(ctx, refreshTokenValue, source) {
if (!refreshTokenValue) {
ctx.host.log.warn("refresh skipped: no refresh token")
return null
Expand All @@ -88,9 +155,9 @@
const shouldLogout = errorInfo && errorInfo.shouldLogout === true
ctx.host.log.error("refresh failed: status=" + resp.status + " shouldLogout=" + shouldLogout)
if (shouldLogout) {
throw "Session expired. Sign in via Cursor app."
throw "Session expired. " + LOGIN_HINT
}
throw "Token expired. Sign in via Cursor app."
throw "Token expired. " + LOGIN_HINT
}

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

const newAccessToken = body.access_token
Expand All @@ -116,8 +183,8 @@
return null
}

// Persist updated access token to SQLite
writeStateValue(ctx, "cursorAuth/accessToken", newAccessToken)
// Persist updated access token to source where auth was loaded from.
persistAccessToken(ctx, source, newAccessToken)
ctx.host.log.info("refresh succeeded, token persisted")

// Note: Cursor refresh returns access_token which is used as both
Expand Down Expand Up @@ -221,15 +288,17 @@
}

function probe(ctx) {
let accessToken = readStateValue(ctx, "cursorAuth/accessToken")
const refreshTokenValue = readStateValue(ctx, "cursorAuth/refreshToken")
const authState = loadAuthState(ctx)
let accessToken = authState.accessToken
const refreshTokenValue = authState.refreshToken
const authSource = authState.source

if (!accessToken && !refreshTokenValue) {
ctx.host.log.error("probe failed: no access or refresh token in sqlite")
throw "Not logged in. Sign in via Cursor app."
ctx.host.log.error("probe failed: no access or refresh token in sqlite/keychain")
throw "Not logged in. " + LOGIN_HINT
}
ctx.host.log.info("tokens loaded: accessToken=" + (accessToken ? "yes" : "no") + " refreshToken=" + (refreshTokenValue ? "yes" : "no"))

ctx.host.log.info("tokens loaded from " + authSource + ": accessToken=" + (accessToken ? "yes" : "no") + " refreshToken=" + (refreshTokenValue ? "yes" : "no"))

const nowMs = Date.now()

Expand All @@ -238,7 +307,7 @@
ctx.host.log.info("token needs refresh (expired or expiring soon)")
let refreshed = null
try {
refreshed = refreshToken(ctx, refreshTokenValue)
refreshed = refreshToken(ctx, refreshTokenValue, authSource)
} catch (e) {
// If refresh fails but we have an access token, try it anyway
ctx.host.log.warn("refresh failed but have access token, will try: " + String(e))
Expand All @@ -248,7 +317,7 @@
accessToken = refreshed
} else if (!accessToken) {
ctx.host.log.error("refresh failed and no access token available")
throw "Not logged in. Sign in via Cursor app."
throw "Not logged in. " + LOGIN_HINT
}
}

Expand All @@ -270,7 +339,7 @@
refresh: () => {
ctx.host.log.info("usage returned 401, attempting refresh")
didRefresh = true
const refreshed = refreshToken(ctx, refreshTokenValue)
const refreshed = refreshToken(ctx, refreshTokenValue, authSource)
if (refreshed) accessToken = refreshed
return refreshed
},
Expand All @@ -283,14 +352,14 @@

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

if (usageResp.status < 200 || usageResp.status >= 300) {
ctx.host.log.error("usage returned error: status=" + usageResp.status)
throw "Usage request failed (HTTP " + String(usageResp.status) + "). Try again later."
}

ctx.host.log.info("usage fetch succeeded")

const usage = ctx.util.tryParseJson(usageResp.bodyText)
Expand Down
113 changes: 113 additions & 0 deletions plugins/cursor/plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,119 @@ describe("cursor plugin", () => {
expect(() => plugin.probe(ctx)).toThrow("Not logged in")
})

it("loads tokens from keychain when sqlite has none", async () => {
const ctx = makeCtx()
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([]))
ctx.host.keychain.readGenericPassword.mockImplementation((service) => {
if (service === "cursor-access-token") return "keychain-access-token"
if (service === "cursor-refresh-token") return "keychain-refresh-token"
Comment thread
validatedev marked this conversation as resolved.
return null
})
ctx.host.http.request.mockReturnValue({
status: 200,
bodyText: JSON.stringify({
enabled: true,
planUsage: { totalSpend: 1200, limit: 2400 },
}),
})

const plugin = await loadPlugin()
const result = plugin.probe(ctx)

expect(result.lines.find((line) => line.label === "Plan usage")).toBeTruthy()
expect(ctx.host.keychain.readGenericPassword).toHaveBeenCalledWith("cursor-access-token")
expect(ctx.host.keychain.readGenericPassword).toHaveBeenCalledWith("cursor-refresh-token")
})

it("refreshes keychain access token and persists to keychain source", async () => {
const ctx = makeCtx()
const expiredPayload = Buffer.from(JSON.stringify({ exp: 1 }), "utf8")
.toString("base64")
.replace(/=+$/g, "")
const expiredAccessToken = `a.${expiredPayload}.c`
const freshPayload = Buffer.from(JSON.stringify({ exp: 9999999999 }), "utf8")
.toString("base64")
.replace(/=+$/g, "")
const refreshedAccessToken = `a.${freshPayload}.c`

ctx.host.sqlite.query.mockReturnValue(JSON.stringify([]))
ctx.host.keychain.readGenericPassword.mockImplementation((service) => {
if (service === "cursor-access-token") return expiredAccessToken
if (service === "cursor-refresh-token") return "keychain-refresh-token"
return null
})
ctx.host.http.request.mockImplementation((opts) => {
if (String(opts.url).includes("/oauth/token")) {
return {
status: 200,
bodyText: JSON.stringify({ access_token: refreshedAccessToken }),
}
}
return {
status: 200,
bodyText: JSON.stringify({
enabled: true,
planUsage: { totalSpend: 1200, limit: 2400 },
}),
}
})

const plugin = await loadPlugin()
const result = plugin.probe(ctx)

expect(result.lines.find((line) => line.label === "Plan usage")).toBeTruthy()
expect(ctx.host.keychain.writeGenericPassword).toHaveBeenCalledWith(
"cursor-access-token",
refreshedAccessToken
)
expect(ctx.host.sqlite.exec).not.toHaveBeenCalled()
})

it("prefers sqlite tokens when sqlite and keychain both have tokens", async () => {
const ctx = makeCtx()
const sqlitePayload = Buffer.from(JSON.stringify({ exp: 9999999999 }), "utf8")
.toString("base64")
.replace(/=+$/g, "")
const sqliteToken = `a.${sqlitePayload}.c`
const keychainPayload = Buffer.from(JSON.stringify({ exp: 9999999999, sub: "keychain" }), "utf8")
.toString("base64")
.replace(/=+$/g, "")
const keychainToken = `a.${keychainPayload}.c`

ctx.host.sqlite.query.mockImplementation((db, sql) => {
if (String(sql).includes("cursorAuth/accessToken")) {
return JSON.stringify([{ value: sqliteToken }])
}
if (String(sql).includes("cursorAuth/refreshToken")) {
return JSON.stringify([{ value: "sqlite-refresh-token" }])
}
return JSON.stringify([])
})
ctx.host.keychain.readGenericPassword.mockImplementation((service) => {
if (service === "cursor-access-token") return keychainToken
if (service === "cursor-refresh-token") return "keychain-refresh-token"
return null
})
ctx.host.http.request.mockImplementation((opts) => {
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
expect(opts.headers.Authorization).toBe("Bearer " + sqliteToken)
}
return {
status: 200,
bodyText: JSON.stringify({
enabled: true,
planUsage: { totalSpend: 1200, limit: 2400 },
}),
}
})

const plugin = await loadPlugin()
const result = plugin.probe(ctx)

expect(result.lines.find((line) => line.label === "Plan usage")).toBeTruthy()
expect(ctx.host.keychain.readGenericPassword).not.toHaveBeenCalled()
})

it("throws on sqlite errors when reading token", async () => {
const ctx = makeCtx()
ctx.host.sqlite.query.mockImplementation(() => {
Expand Down