1use 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
23pub 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
42pub 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 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#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct SessionAuth {
91 pub user_id: String,
92 pub account_key: String,
96 pub cookie_value: String,
97}
98
99pub 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
135fn 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 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}