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
85pub fn default_agent_auth_path() -> Result<PathBuf> {
90 let base = directories::BaseDirs::new().ok_or_else(|| {
91 AppError::Other("could not resolve the platform config directory (no HOME?)".into())
92 })?;
93 Ok(base.config_dir().join("cursor").join("auth.json"))
94}
95
96pub fn read_agent_access_token(path: &Path) -> Result<String> {
101 if !path.exists() {
102 return Err(AppError::Credentials(format!(
103 "cursor-agent auth file not found at {}. Run `cursor-agent` and sign in at least \
104 once, then try again.",
105 path.display()
106 )));
107 }
108 let bytes = std::fs::read(path).map_err(|e| AppError::io_at(path, e))?;
109 let value: serde_json::Value = serde_json::from_slice(&bytes)
110 .map_err(|e| AppError::Credentials(format!("could not parse {}: {e}", path.display())))?;
111 let token = value
112 .get("accessToken")
113 .and_then(serde_json::Value::as_str)
114 .filter(|s| !s.trim().is_empty())
115 .ok_or_else(|| {
116 AppError::Credentials(format!(
117 "no accessToken in {}. Sign in with `cursor-agent` again.",
118 path.display()
119 ))
120 })?;
121 Ok(token.to_string())
122}
123
124pub fn resolve_access_token(db_path: &Path, agent_auth_path: &Path) -> Result<String> {
133 match read_access_token(db_path) {
134 Ok(token) => Ok(token),
135 Err(_) if !db_path.exists() && agent_auth_path.exists() => {
136 read_agent_access_token(agent_auth_path)
137 }
138 Err(ide_err) => Err(ide_err),
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct SessionAuth {
148 pub user_id: String,
149 pub account_key: String,
153 pub cookie_value: String,
154}
155
156pub fn session_auth(token: &str) -> Result<SessionAuth> {
162 let claims = parse_jwt_claims(token).ok_or_else(|| {
163 AppError::Credentials(
164 "Cursor session token could not be decoded. Sign in to the Cursor IDE again.".into(),
165 )
166 })?;
167 let sub = claims
168 .get("sub")
169 .and_then(serde_json::Value::as_str)
170 .ok_or_else(|| AppError::Credentials("Cursor session token has no `sub` claim.".into()))?;
171 let user_id = sub
172 .split('|')
173 .nth(1)
174 .filter(|s| !s.is_empty())
175 .ok_or_else(|| {
176 AppError::Credentials(format!(
177 "Cursor session token `sub` claim has an unexpected shape: {sub:?}"
178 ))
179 })?
180 .to_string();
181 let mut hasher = std::collections::hash_map::DefaultHasher::new();
182 user_id.hash(&mut hasher);
183 let account_key = format!("{:016x}", hasher.finish());
184 let cookie_value = format!("{user_id}%3A%3A{token}");
185 Ok(SessionAuth {
186 user_id,
187 account_key,
188 cookie_value,
189 })
190}
191
192fn parse_jwt_claims(token: &str) -> Option<serde_json::Value> {
196 let mut parts = token.split('.');
197 let _header = parts.next()?;
198 let payload = parts.next()?;
199 let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
200 .decode(payload)
201 .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload))
202 .ok()?;
203 serde_json::from_slice(&decoded).ok()
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use tempfile::TempDir;
210
211 fn fake_jwt(claims: serde_json::Value) -> String {
214 let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#);
215 let payload =
216 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string().as_bytes());
217 format!("{header}.{payload}.sig")
218 }
219
220 fn seed_db(path: &Path, token: Option<&str>) {
221 let conn = Connection::open(path).unwrap();
222 conn.execute("CREATE TABLE ItemTable (key TEXT, value TEXT)", [])
223 .unwrap();
224 if let Some(t) = token {
225 conn.execute(
226 "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)",
227 rusqlite::params![TOKEN_KEY, t],
228 )
229 .unwrap();
230 }
231 }
232
233 #[test]
234 fn default_db_path_ends_with_the_cursor_state_file() {
235 let p = default_db_path().unwrap();
236 assert!(
237 p.ends_with(
238 std::path::Path::new("Cursor")
239 .join("User")
240 .join("globalStorage")
241 .join("state.vscdb")
242 )
243 );
244 }
245
246 #[test]
247 fn missing_file_is_a_credentials_error_naming_the_path() {
248 let dir = TempDir::new().unwrap();
249 let path = dir.path().join("state.vscdb");
250 let err = read_access_token(&path).unwrap_err();
251 match err {
252 AppError::Credentials(m) => assert!(m.contains(&path.display().to_string())),
253 other => panic!("expected Credentials error, got {other:?}"),
254 }
255 }
256
257 #[test]
258 fn reads_the_token_back_out_of_the_item_table() {
259 let dir = TempDir::new().unwrap();
260 let path = dir.path().join("state.vscdb");
261 seed_db(&path, Some("fake-token-value"));
262 assert_eq!(read_access_token(&path).unwrap(), "fake-token-value");
263 }
264
265 #[test]
266 fn missing_row_is_a_credentials_error() {
267 let dir = TempDir::new().unwrap();
268 let path = dir.path().join("state.vscdb");
269 seed_db(&path, None);
270 let err = read_access_token(&path).unwrap_err();
271 assert!(matches!(err, AppError::Credentials(_)));
272 }
273
274 #[test]
275 fn empty_token_is_a_credentials_error() {
276 let dir = TempDir::new().unwrap();
277 let path = dir.path().join("state.vscdb");
278 seed_db(&path, Some(""));
279 let err = read_access_token(&path).unwrap_err();
280 assert!(matches!(err, AppError::Credentials(_)));
281 }
282
283 #[test]
284 fn session_auth_extracts_user_id_and_builds_the_cookie_value() {
285 let token = fake_jwt(serde_json::json!({"sub": "auth0|user_abc123"}));
286 let auth = session_auth(&token).unwrap();
287 assert_eq!(auth.user_id, "user_abc123");
288 assert_eq!(auth.account_key.len(), 16);
289 assert!(!auth.account_key.contains("user_abc123"));
290 assert_eq!(auth.cookie_value, format!("user_abc123%3A%3A{token}"));
291 }
292
293 #[test]
294 fn session_auth_account_key_is_stable_and_account_specific() {
295 let one = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|one"}))).unwrap();
296 let one_again = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|one"}))).unwrap();
297 let two = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|two"}))).unwrap();
298 assert_eq!(one.account_key, one_again.account_key);
299 assert_ne!(one.account_key, two.account_key);
300 }
301
302 #[test]
303 fn session_auth_rejects_a_non_jwt_token() {
304 let err = session_auth("not-a-jwt").unwrap_err();
305 assert!(matches!(err, AppError::Credentials(_)));
306 }
307
308 #[test]
309 fn session_auth_rejects_missing_sub_claim() {
310 let token = fake_jwt(serde_json::json!({"other": "value"}));
311 let err = session_auth(&token).unwrap_err();
312 match err {
313 AppError::Credentials(m) => assert!(m.contains("sub")),
314 other => panic!("expected Credentials error, got {other:?}"),
315 }
316 }
317
318 #[test]
319 fn session_auth_rejects_sub_without_a_pipe_separated_user_id() {
320 let token = fake_jwt(serde_json::json!({"sub": "no-pipe-here"}));
321 let err = session_auth(&token).unwrap_err();
322 assert!(matches!(err, AppError::Credentials(_)));
323 }
324
325 #[test]
326 fn default_agent_auth_path_ends_with_cursor_auth_json() {
327 let p = default_agent_auth_path().unwrap();
328 assert!(p.ends_with(std::path::Path::new("cursor").join("auth.json")));
329 }
330
331 #[test]
332 fn agent_auth_missing_file_is_a_credentials_error_naming_the_path() {
333 let dir = TempDir::new().unwrap();
334 let path = dir.path().join("auth.json");
335 let err = read_agent_access_token(&path).unwrap_err();
336 match err {
337 AppError::Credentials(m) => assert!(m.contains(&path.display().to_string())),
338 other => panic!("expected Credentials error, got {other:?}"),
339 }
340 }
341
342 #[test]
343 fn agent_auth_reads_access_token_out_of_the_json_file() {
344 let dir = TempDir::new().unwrap();
345 let path = dir.path().join("auth.json");
346 std::fs::write(
347 &path,
348 serde_json::json!({"accessToken": "agent-token-value", "refreshToken": "r"})
349 .to_string(),
350 )
351 .unwrap();
352 assert_eq!(read_agent_access_token(&path).unwrap(), "agent-token-value");
353 }
354
355 #[test]
356 fn agent_auth_missing_field_is_a_credentials_error() {
357 let dir = TempDir::new().unwrap();
358 let path = dir.path().join("auth.json");
359 std::fs::write(&path, serde_json::json!({"refreshToken": "r"}).to_string()).unwrap();
360 let err = read_agent_access_token(&path).unwrap_err();
361 assert!(matches!(err, AppError::Credentials(_)));
362 }
363
364 #[test]
365 fn agent_auth_empty_token_is_a_credentials_error() {
366 let dir = TempDir::new().unwrap();
367 let path = dir.path().join("auth.json");
368 std::fs::write(&path, serde_json::json!({"accessToken": ""}).to_string()).unwrap();
369 let err = read_agent_access_token(&path).unwrap_err();
370 assert!(matches!(err, AppError::Credentials(_)));
371 }
372
373 #[test]
374 fn agent_auth_malformed_json_is_a_credentials_error() {
375 let dir = TempDir::new().unwrap();
376 let path = dir.path().join("auth.json");
377 std::fs::write(&path, "not json").unwrap();
378 let err = read_agent_access_token(&path).unwrap_err();
379 assert!(matches!(err, AppError::Credentials(_)));
380 }
381
382 #[test]
383 fn resolve_prefers_the_ide_db_when_both_are_present() {
384 let dir = TempDir::new().unwrap();
385 let db_path = dir.path().join("state.vscdb");
386 seed_db(&db_path, Some("ide-token"));
387 let agent_path = dir.path().join("auth.json");
388 std::fs::write(
389 &agent_path,
390 serde_json::json!({"accessToken": "agent-token"}).to_string(),
391 )
392 .unwrap();
393 assert_eq!(
394 resolve_access_token(&db_path, &agent_path).unwrap(),
395 "ide-token"
396 );
397 }
398
399 #[test]
400 fn resolve_falls_back_to_the_agent_file_when_the_ide_db_is_missing() {
401 let dir = TempDir::new().unwrap();
402 let db_path = dir.path().join("state.vscdb");
403 let agent_path = dir.path().join("auth.json");
404 std::fs::write(
405 &agent_path,
406 serde_json::json!({"accessToken": "agent-token"}).to_string(),
407 )
408 .unwrap();
409 assert_eq!(
410 resolve_access_token(&db_path, &agent_path).unwrap(),
411 "agent-token"
412 );
413 }
414
415 #[test]
416 fn resolve_does_not_hide_an_existing_broken_ide_db_with_the_agent_file() {
417 let dir = TempDir::new().unwrap();
418 let db_path = dir.path().join("state.vscdb");
419 seed_db(&db_path, None);
420 let agent_path = dir.path().join("auth.json");
421 std::fs::write(
422 &agent_path,
423 serde_json::json!({"accessToken": "agent-token"}).to_string(),
424 )
425 .unwrap();
426
427 let err = resolve_access_token(&db_path, &agent_path).unwrap_err();
428 match err {
429 AppError::Credentials(m) => {
430 assert!(m.contains(&db_path.display().to_string()));
431 assert!(!m.contains(&agent_path.display().to_string()));
432 }
433 other => panic!("expected Credentials error, got {other:?}"),
434 }
435 }
436
437 #[test]
438 fn resolve_surfaces_the_ide_error_when_both_sources_are_missing() {
439 let dir = TempDir::new().unwrap();
440 let db_path = dir.path().join("state.vscdb");
441 let agent_path = dir.path().join("auth.json");
442 let err = resolve_access_token(&db_path, &agent_path).unwrap_err();
443 match err {
444 AppError::Credentials(m) => assert!(m.contains(&db_path.display().to_string())),
445 other => panic!("expected Credentials error, got {other:?}"),
446 }
447 }
448
449 #[test]
450 fn resolve_surfaces_the_agent_error_when_its_file_exists_but_is_malformed() {
451 let dir = TempDir::new().unwrap();
452 let db_path = dir.path().join("state.vscdb");
453 let agent_path = dir.path().join("auth.json");
454 std::fs::write(&agent_path, "not json").unwrap();
455
456 let err = resolve_access_token(&db_path, &agent_path).unwrap_err();
457 match err {
458 AppError::Credentials(m) => {
459 assert!(m.contains(&agent_path.display().to_string()));
460 assert!(m.contains("could not parse"));
461 }
462 other => panic!("expected Credentials error, got {other:?}"),
463 }
464 }
465}