1use std::path::{Path, PathBuf};
14use std::{hash::Hash, hash::Hasher};
15
16use rusqlite::{Connection, OpenFlags};
17
18use crate::display::sanitize_untrusted_path;
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 (or run `cursor-agent`) and \
51 sign in at least once, then try again.",
52 sanitize_untrusted_path(path)
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 sanitize_untrusted_path(path)
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 sanitize_untrusted_path(path)
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> {
99 let base = directories::BaseDirs::new().ok_or_else(|| {
100 AppError::Other("could not resolve the platform config directory (no HOME?)".into())
101 })?;
102 Ok(if cfg!(target_os = "macos") {
103 base.home_dir().join(".cursor").join("auth.json")
104 } else if cfg!(windows) {
105 base.config_dir().join("Cursor").join("auth.json")
106 } else {
107 base.config_dir().join("cursor").join("auth.json")
108 })
109}
110
111pub fn read_agent_access_token(path: &Path) -> Result<String> {
116 if !path.exists() {
117 return Err(AppError::Credentials(format!(
118 "cursor-agent auth file not found at {}. Run `cursor-agent` and sign in at least \
119 once, then try again.",
120 sanitize_untrusted_path(path)
121 )));
122 }
123 let bytes = std::fs::read(path).map_err(|e| AppError::io_at(path, e))?;
124 let value: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| {
125 AppError::Credentials(format!(
126 "could not parse {}: {e}",
127 sanitize_untrusted_path(path)
128 ))
129 })?;
130 let token = value
131 .get("accessToken")
132 .and_then(serde_json::Value::as_str)
133 .filter(|s| !s.trim().is_empty())
134 .ok_or_else(|| {
135 AppError::Credentials(format!(
136 "no accessToken in {}. Sign in with `cursor-agent` again.",
137 sanitize_untrusted_path(path)
138 ))
139 })?;
140 Ok(token.to_string())
141}
142
143#[cfg(target_os = "macos")]
149const AGENT_KEYCHAIN_SERVICE: &str = "cursor-access-token";
150#[cfg(target_os = "macos")]
151const AGENT_KEYCHAIN_ACCOUNT: &str = "cursor-user";
152
153#[cfg(target_os = "macos")]
160pub fn read_agent_keychain_token() -> Option<String> {
161 let out = std::process::Command::new("/usr/bin/security")
162 .args([
163 "find-generic-password",
164 "-s",
165 AGENT_KEYCHAIN_SERVICE,
166 "-a",
167 AGENT_KEYCHAIN_ACCOUNT,
168 "-w",
169 ])
170 .stdin(std::process::Stdio::null())
171 .stderr(std::process::Stdio::null())
172 .output()
173 .ok()?;
174 if !out.status.success() {
175 return None;
176 }
177 let token = String::from_utf8(out.stdout).ok()?;
178 let token = token.trim();
179 (!token.is_empty()).then(|| token.to_string())
180}
181
182#[cfg(not(target_os = "macos"))]
183pub fn read_agent_keychain_token() -> Option<String> {
184 None
185}
186
187pub fn resolve_access_token(db_path: &Path, agent_auth_path: &Path) -> Result<String> {
202 let default_agent = default_agent_auth_path().ok();
203 let ambient = default_agent.as_deref() == Some(agent_auth_path);
204 resolve_access_token_with(db_path, agent_auth_path, || {
205 ambient.then(read_agent_keychain_token).flatten()
206 })
207}
208
209pub fn resolve_access_token_with(
211 db_path: &Path,
212 agent_auth_path: &Path,
213 keychain: impl FnOnce() -> Option<String>,
214) -> Result<String> {
215 match read_access_token(db_path) {
216 Ok(token) => Ok(token),
217 Err(ide_err) if !db_path.exists() => {
218 if agent_auth_path.exists() {
219 match read_agent_access_token(agent_auth_path) {
220 Ok(token) => return Ok(token),
221 Err(agent_err) => return keychain().ok_or(agent_err),
222 }
223 }
224 keychain().ok_or(ide_err)
225 }
226 Err(ide_err) => Err(ide_err),
227 }
228}
229
230#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct SessionAuth {
236 pub user_id: String,
237 pub account_key: String,
241 pub cookie_value: String,
242}
243
244pub fn session_auth(token: &str) -> Result<SessionAuth> {
250 let claims = crate::jwt::claims(token).ok_or_else(|| {
251 AppError::Credentials(
252 "Cursor session token could not be decoded. Sign in to the Cursor IDE again.".into(),
253 )
254 })?;
255 let sub = claims
256 .get("sub")
257 .and_then(serde_json::Value::as_str)
258 .ok_or_else(|| AppError::Credentials("Cursor session token has no `sub` claim.".into()))?;
259 let user_id = sub
260 .split('|')
261 .nth(1)
262 .filter(|s| !s.is_empty())
263 .ok_or_else(|| {
264 AppError::Credentials(format!(
265 "Cursor session token `sub` claim has an unexpected shape: {sub:?}"
266 ))
267 })?
268 .to_string();
269 let mut hasher = std::collections::hash_map::DefaultHasher::new();
270 user_id.hash(&mut hasher);
271 let account_key = format!("{:016x}", hasher.finish());
272 let cookie_value = format!("{user_id}%3A%3A{token}");
273 Ok(SessionAuth {
274 user_id,
275 account_key,
276 cookie_value,
277 })
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283 use base64::Engine as _;
284 use tempfile::TempDir;
285
286 fn fake_jwt(claims: serde_json::Value) -> String {
289 let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#);
290 let payload =
291 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string().as_bytes());
292 format!("{header}.{payload}.sig")
293 }
294
295 fn seed_db(path: &Path, token: Option<&str>) {
296 let conn = Connection::open(path).unwrap();
297 conn.execute("CREATE TABLE ItemTable (key TEXT, value TEXT)", [])
298 .unwrap();
299 if let Some(t) = token {
300 conn.execute(
301 "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)",
302 rusqlite::params![TOKEN_KEY, t],
303 )
304 .unwrap();
305 }
306 }
307
308 #[test]
309 fn default_db_path_ends_with_the_cursor_state_file() {
310 let p = default_db_path().unwrap();
311 assert!(
312 p.ends_with(
313 std::path::Path::new("Cursor")
314 .join("User")
315 .join("globalStorage")
316 .join("state.vscdb")
317 )
318 );
319 }
320
321 #[test]
322 fn missing_file_is_a_credentials_error_naming_the_path() {
323 let dir = TempDir::new().unwrap();
324 let path = dir.path().join("state.vscdb");
325 let err = read_access_token(&path).unwrap_err();
326 match err {
327 AppError::Credentials(m) => assert!(m.contains(&path.display().to_string())),
328 other => panic!("expected Credentials error, got {other:?}"),
329 }
330 }
331
332 #[test]
333 fn reads_the_token_back_out_of_the_item_table() {
334 let dir = TempDir::new().unwrap();
335 let path = dir.path().join("state.vscdb");
336 seed_db(&path, Some("fake-token-value"));
337 assert_eq!(read_access_token(&path).unwrap(), "fake-token-value");
338 }
339
340 #[test]
341 fn missing_row_is_a_credentials_error() {
342 let dir = TempDir::new().unwrap();
343 let path = dir.path().join("state.vscdb");
344 seed_db(&path, None);
345 let err = read_access_token(&path).unwrap_err();
346 assert!(matches!(err, AppError::Credentials(_)));
347 }
348
349 #[test]
350 fn empty_token_is_a_credentials_error() {
351 let dir = TempDir::new().unwrap();
352 let path = dir.path().join("state.vscdb");
353 seed_db(&path, Some(""));
354 let err = read_access_token(&path).unwrap_err();
355 assert!(matches!(err, AppError::Credentials(_)));
356 }
357
358 #[test]
359 fn session_auth_extracts_user_id_and_builds_the_cookie_value() {
360 let token = fake_jwt(serde_json::json!({"sub": "auth0|user_abc123"}));
361 let auth = session_auth(&token).unwrap();
362 assert_eq!(auth.user_id, "user_abc123");
363 assert_eq!(auth.account_key.len(), 16);
364 assert!(!auth.account_key.contains("user_abc123"));
365 assert_eq!(auth.cookie_value, format!("user_abc123%3A%3A{token}"));
366 }
367
368 #[test]
369 fn session_auth_account_key_is_stable_and_account_specific() {
370 let one = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|one"}))).unwrap();
371 let one_again = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|one"}))).unwrap();
372 let two = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|two"}))).unwrap();
373 assert_eq!(one.account_key, one_again.account_key);
374 assert_ne!(one.account_key, two.account_key);
375 }
376
377 #[test]
378 fn session_auth_rejects_a_non_jwt_token() {
379 let err = session_auth("not-a-jwt").unwrap_err();
380 assert!(matches!(err, AppError::Credentials(_)));
381 }
382
383 #[test]
384 fn session_auth_rejects_missing_sub_claim() {
385 let token = fake_jwt(serde_json::json!({"other": "value"}));
386 let err = session_auth(&token).unwrap_err();
387 match err {
388 AppError::Credentials(m) => assert!(m.contains("sub")),
389 other => panic!("expected Credentials error, got {other:?}"),
390 }
391 }
392
393 #[test]
394 fn session_auth_rejects_sub_without_a_pipe_separated_user_id() {
395 let token = fake_jwt(serde_json::json!({"sub": "no-pipe-here"}));
396 let err = session_auth(&token).unwrap_err();
397 assert!(matches!(err, AppError::Credentials(_)));
398 }
399
400 #[test]
401 fn default_agent_auth_path_follows_the_cli_own_convention_per_os() {
402 let p = default_agent_auth_path().unwrap();
403 let expected_tail = if cfg!(target_os = "macos") {
404 std::path::Path::new(".cursor").join("auth.json")
405 } else if cfg!(windows) {
406 std::path::Path::new("Cursor").join("auth.json")
407 } else {
408 std::path::Path::new("cursor").join("auth.json")
409 };
410 assert!(p.ends_with(expected_tail), "{}", p.display());
411 assert!(!cfg!(target_os = "macos") || !p.to_string_lossy().contains("Application Support"));
414 }
415
416 #[test]
417 fn agent_auth_missing_file_is_a_credentials_error_naming_the_path() {
418 let dir = TempDir::new().unwrap();
419 let path = dir.path().join("auth.json");
420 let err = read_agent_access_token(&path).unwrap_err();
421 match err {
422 AppError::Credentials(m) => assert!(m.contains(&path.display().to_string())),
423 other => panic!("expected Credentials error, got {other:?}"),
424 }
425 }
426
427 #[test]
428 fn agent_auth_reads_access_token_out_of_the_json_file() {
429 let dir = TempDir::new().unwrap();
430 let path = dir.path().join("auth.json");
431 std::fs::write(
432 &path,
433 serde_json::json!({"accessToken": "agent-token-value", "refreshToken": "r"})
434 .to_string(),
435 )
436 .unwrap();
437 assert_eq!(read_agent_access_token(&path).unwrap(), "agent-token-value");
438 }
439
440 #[test]
441 fn agent_auth_missing_field_is_a_credentials_error() {
442 let dir = TempDir::new().unwrap();
443 let path = dir.path().join("auth.json");
444 std::fs::write(&path, serde_json::json!({"refreshToken": "r"}).to_string()).unwrap();
445 let err = read_agent_access_token(&path).unwrap_err();
446 assert!(matches!(err, AppError::Credentials(_)));
447 }
448
449 #[test]
450 fn agent_auth_empty_token_is_a_credentials_error() {
451 let dir = TempDir::new().unwrap();
452 let path = dir.path().join("auth.json");
453 std::fs::write(&path, serde_json::json!({"accessToken": ""}).to_string()).unwrap();
454 let err = read_agent_access_token(&path).unwrap_err();
455 assert!(matches!(err, AppError::Credentials(_)));
456 }
457
458 #[test]
459 fn agent_auth_malformed_json_is_a_credentials_error() {
460 let dir = TempDir::new().unwrap();
461 let path = dir.path().join("auth.json");
462 std::fs::write(&path, "not json").unwrap();
463 let err = read_agent_access_token(&path).unwrap_err();
464 assert!(matches!(err, AppError::Credentials(_)));
465 }
466
467 #[test]
468 fn resolve_prefers_the_ide_db_when_both_are_present() {
469 let dir = TempDir::new().unwrap();
470 let db_path = dir.path().join("state.vscdb");
471 seed_db(&db_path, Some("ide-token"));
472 let agent_path = dir.path().join("auth.json");
473 std::fs::write(
474 &agent_path,
475 serde_json::json!({"accessToken": "agent-token"}).to_string(),
476 )
477 .unwrap();
478 assert_eq!(
479 resolve_access_token_with(&db_path, &agent_path, || None).unwrap(),
480 "ide-token"
481 );
482 }
483
484 #[test]
485 fn resolve_falls_back_to_the_agent_file_when_the_ide_db_is_missing() {
486 let dir = TempDir::new().unwrap();
487 let db_path = dir.path().join("state.vscdb");
488 let agent_path = dir.path().join("auth.json");
489 std::fs::write(
490 &agent_path,
491 serde_json::json!({"accessToken": "agent-token"}).to_string(),
492 )
493 .unwrap();
494 assert_eq!(
495 resolve_access_token_with(&db_path, &agent_path, || None).unwrap(),
496 "agent-token"
497 );
498 }
499
500 #[test]
501 fn resolve_does_not_hide_an_existing_broken_ide_db_with_the_agent_file() {
502 let dir = TempDir::new().unwrap();
503 let db_path = dir.path().join("state.vscdb");
504 seed_db(&db_path, None);
505 let agent_path = dir.path().join("auth.json");
506 std::fs::write(
507 &agent_path,
508 serde_json::json!({"accessToken": "agent-token"}).to_string(),
509 )
510 .unwrap();
511
512 let err = resolve_access_token_with(&db_path, &agent_path, || None).unwrap_err();
513 match err {
514 AppError::Credentials(m) => {
515 assert!(m.contains(&db_path.display().to_string()));
516 assert!(!m.contains(&agent_path.display().to_string()));
517 }
518 other => panic!("expected Credentials error, got {other:?}"),
519 }
520 }
521
522 #[test]
523 fn resolve_surfaces_the_ide_error_when_both_sources_are_missing() {
524 let dir = TempDir::new().unwrap();
525 let db_path = dir.path().join("state.vscdb");
526 let agent_path = dir.path().join("auth.json");
527 let err = resolve_access_token_with(&db_path, &agent_path, || None).unwrap_err();
528 match err {
529 AppError::Credentials(m) => assert!(m.contains(&db_path.display().to_string())),
530 other => panic!("expected Credentials error, got {other:?}"),
531 }
532 }
533
534 #[test]
535 fn resolve_surfaces_the_agent_error_when_its_file_exists_but_is_malformed() {
536 let dir = TempDir::new().unwrap();
537 let db_path = dir.path().join("state.vscdb");
538 let agent_path = dir.path().join("auth.json");
539 std::fs::write(&agent_path, "not json").unwrap();
540
541 let err = resolve_access_token_with(&db_path, &agent_path, || None).unwrap_err();
542 match err {
543 AppError::Credentials(m) => {
544 assert!(m.contains(&agent_path.display().to_string()));
545 assert!(m.contains("could not parse"));
546 }
547 other => panic!("expected Credentials error, got {other:?}"),
548 }
549 }
550
551 #[test]
552 fn resolve_falls_back_to_the_keychain_when_the_ide_db_and_agent_file_are_missing() {
553 let dir = TempDir::new().unwrap();
554 let db_path = dir.path().join("state.vscdb");
555 let agent_path = dir.path().join("auth.json");
556 assert_eq!(
557 resolve_access_token_with(&db_path, &agent_path, || Some("keychain-token".into()))
558 .unwrap(),
559 "keychain-token"
560 );
561 }
562
563 #[test]
564 fn resolve_tries_the_keychain_when_the_agent_file_is_unusable() {
565 let dir = TempDir::new().unwrap();
566 let db_path = dir.path().join("state.vscdb");
567 let agent_path = dir.path().join("auth.json");
568 std::fs::write(
569 &agent_path,
570 serde_json::json!({"refreshToken": "r"}).to_string(),
571 )
572 .unwrap();
573 assert_eq!(
574 resolve_access_token_with(&db_path, &agent_path, || Some("keychain-token".into()))
575 .unwrap(),
576 "keychain-token"
577 );
578 }
579
580 #[test]
581 fn resolve_never_consults_the_keychain_over_a_present_ide_db() {
582 let dir = TempDir::new().unwrap();
583 let db_path = dir.path().join("state.vscdb");
584 seed_db(&db_path, None);
585 let agent_path = dir.path().join("auth.json");
586 let err = resolve_access_token_with(&db_path, &agent_path, || {
587 panic!("an existing IDE db must not be widened to the keychain")
588 })
589 .unwrap_err();
590 assert!(matches!(err, AppError::Credentials(_)));
591 }
592
593 #[test]
594 fn resolve_leaves_the_keychain_alone_for_a_non_default_agent_path() {
595 let dir = TempDir::new().unwrap();
598 let err = resolve_access_token(
599 &dir.path().join("state.vscdb"),
600 &dir.path().join("auth.json"),
601 )
602 .unwrap_err();
603 assert!(matches!(err, AppError::Credentials(_)));
604 }
605}