Skip to main content

ai_usagebar/cursor/
db.rs

1//! Read the Cursor IDE's own session token out of its local `state.vscdb`.
2//!
3//! Cursor has no documented usage API or API key for personal quota — every
4//! community tool that shows it (cursor-stats, cursor-usage-tracker, etc.)
5//! reads the same place: a SQLite key-value store the Cursor IDE itself
6//! maintains at `.../User/globalStorage/state.vscdb` (the same `state.vscdb`
7//! every VS Code-family app uses for `ItemTable`-shaped extension/global
8//! state), under the key `cursorAuth/accessToken`. That value is a JWT whose
9//! `sub` claim (`auth0|<userId>`) is combined with the raw token into the
10//! `WorkosCursorSessionToken` cookie the dashboard's own usage call expects —
11//! see `fetch.rs`.
12
13use std::path::{Path, PathBuf};
14use std::{hash::Hash, hash::Hasher};
15
16use base64::Engine;
17use rusqlite::{Connection, OpenFlags};
18
19use crate::error::{AppError, Result};
20
21const TOKEN_KEY: &str = "cursorAuth/accessToken";
22
23/// Default location of Cursor's local state database. This is Cursor's own
24/// per-OS convention (same one every VS Code-family app uses for its user
25/// data), not ai-usagebar's XDG cache — conveniently identical to what
26/// `directories::BaseDirs::config_dir()` already resolves on every platform:
27///   - Linux: `~/.config`
28///   - macOS: `~/Library/Application Support`
29///   - Windows: `%APPDATA%` (Roaming)
30pub fn default_db_path() -> Result<PathBuf> {
31    let base = directories::BaseDirs::new().ok_or_else(|| {
32        AppError::Other("could not resolve the platform config directory (no HOME?)".into())
33    })?;
34    Ok(base
35        .config_dir()
36        .join("Cursor")
37        .join("User")
38        .join("globalStorage")
39        .join("state.vscdb"))
40}
41
42/// Read the raw `cursorAuth/accessToken` value from `path`. A missing file or
43/// missing row means "never signed in to Cursor" — reported as a credentials
44/// error (like a missing `~/.claude/.credentials.json`) rather than a network
45/// or schema failure, so the widget's `⚠` tooltip tells the user to sign in
46/// rather than implying the API is down.
47pub fn read_access_token(path: &Path) -> Result<String> {
48    if !path.exists() {
49        return Err(AppError::Credentials(format!(
50            "Cursor database not found at {}. Open the Cursor IDE and sign in at least once, \
51             then try again.",
52            path.display()
53        )));
54    }
55    // Read-only: this file is Cursor's own live state, not ours to lock for
56    // writing. SQLite allows concurrent readers, so this is safe alongside a
57    // running Cursor IDE.
58    let conn =
59        Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY).map_err(|e| {
60            AppError::Credentials(format!(
61                "could not open Cursor database at {}: {e}",
62                path.display()
63            ))
64        })?;
65    let token: String = conn
66        .query_row(
67            "SELECT value FROM ItemTable WHERE key = ?1",
68            [TOKEN_KEY],
69            |row| row.get(0),
70        )
71        .map_err(|_| {
72            AppError::Credentials(format!(
73                "no Cursor session found in {}. Sign in to the Cursor IDE, then try again.",
74                path.display()
75            ))
76        })?;
77    if token.trim().is_empty() {
78        return Err(AppError::Credentials(
79            "Cursor session token is empty. Sign in to the Cursor IDE again.".into(),
80        ));
81    }
82    Ok(token)
83}
84
85/// The two values the `/api/usage` call needs, both derived from the same JWT:
86/// the bare user id (a query param) and the `WorkosCursorSessionToken` cookie
87/// value (`userId%3A%3Atoken` — literal, pre-encoded `::`, matching what the
88/// Cursor dashboard's own JS sends).
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct SessionAuth {
91    pub user_id: String,
92    /// Stable, non-plaintext cache identity for the signed-in Cursor account.
93    /// The hash is only a change detector: a different toolchain may produce a
94    /// different value and force one harmless refetch.
95    pub account_key: String,
96    pub cookie_value: String,
97}
98
99/// Derive [`SessionAuth`] from the raw access token. Fails when the token
100/// isn't a decodable JWT or its `sub` claim doesn't have the `issuer|userId`
101/// shape every Cursor account token carries — either way the token is
102/// unusable, so this is a credentials error, not a schema error (the *shape*
103/// of the wire endpoint isn't in play yet at this point).
104pub fn session_auth(token: &str) -> Result<SessionAuth> {
105    let claims = parse_jwt_claims(token).ok_or_else(|| {
106        AppError::Credentials(
107            "Cursor session token could not be decoded. Sign in to the Cursor IDE again.".into(),
108        )
109    })?;
110    let sub = claims
111        .get("sub")
112        .and_then(serde_json::Value::as_str)
113        .ok_or_else(|| AppError::Credentials("Cursor session token has no `sub` claim.".into()))?;
114    let user_id = sub
115        .split('|')
116        .nth(1)
117        .filter(|s| !s.is_empty())
118        .ok_or_else(|| {
119            AppError::Credentials(format!(
120                "Cursor session token `sub` claim has an unexpected shape: {sub:?}"
121            ))
122        })?
123        .to_string();
124    let mut hasher = std::collections::hash_map::DefaultHasher::new();
125    user_id.hash(&mut hasher);
126    let account_key = format!("{:016x}", hasher.finish());
127    let cookie_value = format!("{user_id}%3A%3A{token}");
128    Ok(SessionAuth {
129        user_id,
130        account_key,
131        cookie_value,
132    })
133}
134
135/// Decode a JWT's payload segment without verifying its signature — we trust
136/// it the same way the Cursor dashboard's own browser JS does (it never
137/// verifies either; the server is the one that rejects a bad token).
138fn parse_jwt_claims(token: &str) -> Option<serde_json::Value> {
139    let mut parts = token.split('.');
140    let _header = parts.next()?;
141    let payload = parts.next()?;
142    let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
143        .decode(payload)
144        .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload))
145        .ok()?;
146    serde_json::from_slice(&decoded).ok()
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use tempfile::TempDir;
153
154    /// Build a fake JWT with the given claims (no signature verification,
155    /// matching `openai::creds`'s test helper).
156    fn fake_jwt(claims: serde_json::Value) -> String {
157        let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#);
158        let payload =
159            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string().as_bytes());
160        format!("{header}.{payload}.sig")
161    }
162
163    fn seed_db(path: &Path, token: Option<&str>) {
164        let conn = Connection::open(path).unwrap();
165        conn.execute("CREATE TABLE ItemTable (key TEXT, value TEXT)", [])
166            .unwrap();
167        if let Some(t) = token {
168            conn.execute(
169                "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)",
170                rusqlite::params![TOKEN_KEY, t],
171            )
172            .unwrap();
173        }
174    }
175
176    #[test]
177    fn default_db_path_ends_with_the_cursor_state_file() {
178        let p = default_db_path().unwrap();
179        assert!(
180            p.ends_with(
181                std::path::Path::new("Cursor")
182                    .join("User")
183                    .join("globalStorage")
184                    .join("state.vscdb")
185            )
186        );
187    }
188
189    #[test]
190    fn missing_file_is_a_credentials_error_naming_the_path() {
191        let dir = TempDir::new().unwrap();
192        let path = dir.path().join("state.vscdb");
193        let err = read_access_token(&path).unwrap_err();
194        match err {
195            AppError::Credentials(m) => assert!(m.contains(&path.display().to_string())),
196            other => panic!("expected Credentials error, got {other:?}"),
197        }
198    }
199
200    #[test]
201    fn reads_the_token_back_out_of_the_item_table() {
202        let dir = TempDir::new().unwrap();
203        let path = dir.path().join("state.vscdb");
204        seed_db(&path, Some("fake-token-value"));
205        assert_eq!(read_access_token(&path).unwrap(), "fake-token-value");
206    }
207
208    #[test]
209    fn missing_row_is_a_credentials_error() {
210        let dir = TempDir::new().unwrap();
211        let path = dir.path().join("state.vscdb");
212        seed_db(&path, None);
213        let err = read_access_token(&path).unwrap_err();
214        assert!(matches!(err, AppError::Credentials(_)));
215    }
216
217    #[test]
218    fn empty_token_is_a_credentials_error() {
219        let dir = TempDir::new().unwrap();
220        let path = dir.path().join("state.vscdb");
221        seed_db(&path, Some(""));
222        let err = read_access_token(&path).unwrap_err();
223        assert!(matches!(err, AppError::Credentials(_)));
224    }
225
226    #[test]
227    fn session_auth_extracts_user_id_and_builds_the_cookie_value() {
228        let token = fake_jwt(serde_json::json!({"sub": "auth0|user_abc123"}));
229        let auth = session_auth(&token).unwrap();
230        assert_eq!(auth.user_id, "user_abc123");
231        assert_eq!(auth.account_key.len(), 16);
232        assert!(!auth.account_key.contains("user_abc123"));
233        assert_eq!(auth.cookie_value, format!("user_abc123%3A%3A{token}"));
234    }
235
236    #[test]
237    fn session_auth_account_key_is_stable_and_account_specific() {
238        let one = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|one"}))).unwrap();
239        let one_again = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|one"}))).unwrap();
240        let two = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|two"}))).unwrap();
241        assert_eq!(one.account_key, one_again.account_key);
242        assert_ne!(one.account_key, two.account_key);
243    }
244
245    #[test]
246    fn session_auth_rejects_a_non_jwt_token() {
247        let err = session_auth("not-a-jwt").unwrap_err();
248        assert!(matches!(err, AppError::Credentials(_)));
249    }
250
251    #[test]
252    fn session_auth_rejects_missing_sub_claim() {
253        let token = fake_jwt(serde_json::json!({"other": "value"}));
254        let err = session_auth(&token).unwrap_err();
255        match err {
256            AppError::Credentials(m) => assert!(m.contains("sub")),
257            other => panic!("expected Credentials error, got {other:?}"),
258        }
259    }
260
261    #[test]
262    fn session_auth_rejects_sub_without_a_pipe_separated_user_id() {
263        let token = fake_jwt(serde_json::json!({"sub": "no-pipe-here"}));
264        let err = session_auth(&token).unwrap_err();
265        assert!(matches!(err, AppError::Credentials(_)));
266    }
267}