1use std::path::{Path, PathBuf};
17use std::{hash::Hash, hash::Hasher};
18
19use rusqlite::{Connection, OpenFlags};
20
21use crate::display::sanitize_untrusted_path;
22use crate::error::{AppError, Result};
23
24const TOKEN_KEY: &str = "cursorAuth/accessToken";
25const SCOPED_PROFILE_KEY: &str = "cursorAuth/cachedScopedProfile";
28
29pub fn default_db_path() -> Result<PathBuf> {
37 let base = directories::BaseDirs::new().ok_or_else(|| {
38 AppError::Other("could not resolve the platform config directory (no HOME?)".into())
39 })?;
40 Ok(base
41 .config_dir()
42 .join("Cursor")
43 .join("User")
44 .join("globalStorage")
45 .join("state.vscdb"))
46}
47
48pub fn read_access_token(path: &Path) -> Result<String> {
54 if !path.exists() {
55 return Err(AppError::Credentials(format!(
56 "Cursor database not found at {}. Open the Cursor IDE (or run `cursor-agent`) and \
57 sign in at least once, then try again.",
58 sanitize_untrusted_path(path)
59 )));
60 }
61 let conn =
65 Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY).map_err(|e| {
66 AppError::Credentials(format!(
67 "could not open Cursor database at {}: {e}",
68 sanitize_untrusted_path(path)
69 ))
70 })?;
71 let token: String = conn
72 .query_row(
73 "SELECT value FROM ItemTable WHERE key = ?1",
74 [TOKEN_KEY],
75 |row| row.get(0),
76 )
77 .map_err(|_| {
78 AppError::Credentials(format!(
79 "no Cursor session found in {}. Sign in to the Cursor IDE, then try again.",
80 sanitize_untrusted_path(path)
81 ))
82 })?;
83 if token.trim().is_empty() {
84 return Err(AppError::Credentials(
85 "Cursor session token is empty. Sign in to the Cursor IDE again.".into(),
86 ));
87 }
88 Ok(token)
89}
90
91pub fn default_agent_auth_path() -> Result<PathBuf> {
105 let base = directories::BaseDirs::new().ok_or_else(|| {
106 AppError::Other("could not resolve the platform config directory (no HOME?)".into())
107 })?;
108 Ok(if cfg!(target_os = "macos") {
109 base.home_dir().join(".cursor").join("auth.json")
110 } else if cfg!(windows) {
111 base.config_dir().join("Cursor").join("auth.json")
112 } else {
113 base.config_dir().join("cursor").join("auth.json")
114 })
115}
116
117pub fn read_agent_access_token(path: &Path) -> Result<String> {
122 if !path.exists() {
123 return Err(AppError::Credentials(format!(
124 "cursor-agent auth file not found at {}. Run `cursor-agent` and sign in at least \
125 once, then try again.",
126 sanitize_untrusted_path(path)
127 )));
128 }
129 let bytes = std::fs::read(path).map_err(|e| AppError::io_at(path, e))?;
130 let value: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| {
131 AppError::Credentials(format!(
132 "could not parse {}: {e}",
133 sanitize_untrusted_path(path)
134 ))
135 })?;
136 let token = value
137 .get("accessToken")
138 .and_then(serde_json::Value::as_str)
139 .filter(|s| !s.trim().is_empty())
140 .ok_or_else(|| {
141 AppError::Credentials(format!(
142 "no accessToken in {}. Sign in with `cursor-agent` again.",
143 sanitize_untrusted_path(path)
144 ))
145 })?;
146 Ok(token.to_string())
147}
148
149#[cfg(target_os = "macos")]
155const AGENT_KEYCHAIN_SERVICE: &str = "cursor-access-token";
156#[cfg(target_os = "macos")]
157const AGENT_KEYCHAIN_ACCOUNT: &str = "cursor-user";
158
159#[cfg(target_os = "macos")]
166pub fn read_agent_keychain_token() -> Option<String> {
167 let out = agent_keychain_command().output().ok()?;
168 if !out.status.success() {
169 return None;
170 }
171 let token = String::from_utf8(out.stdout).ok()?;
172 let token = token.trim();
173 (!token.is_empty()).then(|| token.to_string())
174}
175
176#[cfg(target_os = "macos")]
179fn agent_keychain_command() -> std::process::Command {
180 let mut command = std::process::Command::new("/usr/bin/security");
181 command
182 .args([
183 "find-generic-password",
184 "-s",
185 AGENT_KEYCHAIN_SERVICE,
186 "-a",
187 AGENT_KEYCHAIN_ACCOUNT,
188 "-w",
189 ])
190 .stdin(std::process::Stdio::null())
191 .stderr(std::process::Stdio::null());
192 for var in crate::vendor::vendor_secret_env_vars_to_remove(&[]) {
194 command.env_remove(var);
195 }
196 command
197}
198
199#[cfg(not(target_os = "macos"))]
200pub fn read_agent_keychain_token() -> Option<String> {
201 None
202}
203
204pub fn resolve_access_token(db_path: &Path, agent_auth_path: &Path) -> Result<String> {
219 let default_agent = default_agent_auth_path().ok();
220 let ambient = default_agent.as_deref() == Some(agent_auth_path);
221 resolve_access_token_with(db_path, agent_auth_path, || {
222 ambient.then(read_agent_keychain_token).flatten()
223 })
224}
225
226pub fn resolve_access_token_with(
228 db_path: &Path,
229 agent_auth_path: &Path,
230 keychain: impl FnOnce() -> Option<String>,
231) -> Result<String> {
232 match read_access_token(db_path) {
233 Ok(token) => Ok(token),
234 Err(ide_err) if !db_path.exists() => {
235 if agent_auth_path.exists() {
236 match read_agent_access_token(agent_auth_path) {
237 Ok(token) => return Ok(token),
238 Err(agent_err) => return keychain().ok_or(agent_err),
239 }
240 }
241 keychain().ok_or(ide_err)
242 }
243 Err(ide_err) => Err(ide_err),
244 }
245}
246
247#[derive(Debug, Clone, PartialEq, Eq)]
252pub struct SessionAuth {
253 pub user_id: String,
254 pub account_key: String,
258 pub cookie_value: String,
259}
260
261pub fn session_auth(token: &str) -> Result<SessionAuth> {
266 let claims = crate::jwt::claims(token).ok_or_else(opaque_token_error)?;
267 let sub = claims
268 .get("sub")
269 .and_then(serde_json::Value::as_str)
270 .ok_or_else(|| AppError::Credentials("Cursor session token has no `sub` claim.".into()))?;
271 auth_from_sub(token, sub)
272}
273
274pub fn session_auth_for(db_path: &Path, token: &str) -> Result<SessionAuth> {
284 match session_auth(token) {
285 Ok(auth) => Ok(auth),
286 Err(jwt_err) => match read_cached_auth_id(db_path) {
287 Some(auth_id) => auth_from_sub(token, &auth_id),
288 None => Err(jwt_err),
289 },
290 }
291}
292
293fn read_cached_auth_id(path: &Path) -> Option<String> {
297 let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY).ok()?;
298 let profile: String = conn
299 .query_row(
300 "SELECT value FROM ItemTable WHERE key = ?1",
301 [SCOPED_PROFILE_KEY],
302 |row| row.get(0),
303 )
304 .ok()?;
305 let value: serde_json::Value = serde_json::from_str(&profile).ok()?;
306 value
307 .get("authId")
308 .and_then(serde_json::Value::as_str)
309 .map(str::to_owned)
310}
311
312fn opaque_token_error() -> AppError {
313 AppError::Credentials(
314 "Cursor session token could not be decoded. Sign in to the Cursor IDE again.".into(),
315 )
316}
317
318fn auth_from_sub(token: &str, sub: &str) -> Result<SessionAuth> {
321 let user_id = sub
322 .split('|')
323 .nth(1)
324 .filter(|s| !s.is_empty())
325 .ok_or_else(|| {
326 AppError::Credentials(format!(
327 "Cursor session token `sub` claim has an unexpected shape: {sub:?}"
328 ))
329 })?
330 .to_string();
331 let mut hasher = std::collections::hash_map::DefaultHasher::new();
332 user_id.hash(&mut hasher);
333 let account_key = format!("{:016x}", hasher.finish());
334 let cookie_value = format!("{user_id}%3A%3A{token}");
335 Ok(SessionAuth {
336 user_id,
337 account_key,
338 cookie_value,
339 })
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345 use base64::Engine as _;
346 use tempfile::TempDir;
347
348 fn fake_jwt(claims: serde_json::Value) -> String {
351 let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#);
352 let payload =
353 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string().as_bytes());
354 format!("{header}.{payload}.sig")
355 }
356
357 fn seed_db(path: &Path, token: Option<&str>) {
358 let conn = Connection::open(path).unwrap();
359 conn.execute("CREATE TABLE ItemTable (key TEXT, value TEXT)", [])
360 .unwrap();
361 if let Some(t) = token {
362 conn.execute(
363 "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)",
364 rusqlite::params![TOKEN_KEY, t],
365 )
366 .unwrap();
367 }
368 }
369
370 fn seed_scoped_profile(path: &Path, profile: &str) {
372 let conn = Connection::open(path).unwrap();
373 conn.execute(
374 "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)",
375 rusqlite::params![SCOPED_PROFILE_KEY, profile],
376 )
377 .unwrap();
378 }
379
380 #[test]
384 fn an_opaque_token_uses_the_cached_profile_identity() {
385 let td = TempDir::new().unwrap();
386 let path = td.path().join("state.vscdb");
387 let opaque = format!("curs_{}", "a".repeat(400));
388 seed_db(&path, None);
389 seed_scoped_profile(
390 &path,
391 r#"{"authId":"grok|user_01M4473VZ67Q3X6HF8WZFE3SMW","displayName":"Someone"}"#,
392 );
393 let auth = session_auth_for(&path, &opaque).unwrap();
394 assert_eq!(auth.user_id, "user_01M4473VZ67Q3X6HF8WZFE3SMW");
395 assert_eq!(
396 auth.cookie_value,
397 format!("user_01M4473VZ67Q3X6HF8WZFE3SMW%3A%3A{opaque}")
398 );
399 }
400
401 #[test]
402 fn an_opaque_token_without_a_profile_keeps_the_decode_error() {
403 let td = TempDir::new().unwrap();
404 let path = td.path().join("state.vscdb");
405 seed_db(&path, None);
406 let err = session_auth_for(&path, "curs_opaque_without_profile").unwrap_err();
407 assert!(err.to_string().contains("could not be decoded"), "{err}");
408 }
409
410 #[test]
411 fn a_jwt_still_wins_over_the_cached_profile() {
412 let td = TempDir::new().unwrap();
413 let path = td.path().join("state.vscdb");
414 seed_db(&path, None);
415 seed_scoped_profile(&path, r#"{"authId":"grok|user_from_profile"}"#);
416 let jwt = fake_jwt(serde_json::json!({"sub": "auth0|user_from_jwt"}));
417 let auth = session_auth_for(&path, &jwt).unwrap();
418 assert_eq!(auth.user_id, "user_from_jwt");
419 }
420
421 #[test]
422 fn a_profile_without_a_usable_auth_id_is_not_an_identity() {
423 let td = TempDir::new().unwrap();
424 let path = td.path().join("state.vscdb");
425 seed_db(&path, None);
426 seed_scoped_profile(&path, r#"{"authId":"no-pipe-here"}"#);
427 assert!(session_auth_for(&path, "curs_opaque").is_err());
428 seed_scoped_profile(&path, r#"{"displayName":"no auth id"}"#);
429 assert!(session_auth_for(&path, "curs_opaque").is_err());
430 }
431
432 #[test]
433 fn a_missing_profile_row_falls_back_to_the_jwt_path() {
434 let td = TempDir::new().unwrap();
435 let path = td.path().join("state.vscdb");
436 seed_db(&path, None);
437 let jwt = fake_jwt(serde_json::json!({"sub": "auth0|only_jwt"}));
438 assert_eq!(session_auth_for(&path, &jwt).unwrap().user_id, "only_jwt");
439 }
440
441 #[test]
442 fn default_db_path_ends_with_the_cursor_state_file() {
443 let p = default_db_path().unwrap();
444 assert!(
445 p.ends_with(
446 std::path::Path::new("Cursor")
447 .join("User")
448 .join("globalStorage")
449 .join("state.vscdb")
450 )
451 );
452 }
453
454 #[test]
455 fn missing_file_is_a_credentials_error_naming_the_path() {
456 let dir = TempDir::new().unwrap();
457 let path = dir.path().join("state.vscdb");
458 let err = read_access_token(&path).unwrap_err();
459 match err {
460 AppError::Credentials(m) => assert!(m.contains(&path.display().to_string())),
461 other => panic!("expected Credentials error, got {other:?}"),
462 }
463 }
464
465 #[test]
466 fn reads_the_token_back_out_of_the_item_table() {
467 let dir = TempDir::new().unwrap();
468 let path = dir.path().join("state.vscdb");
469 seed_db(&path, Some("fake-token-value"));
470 assert_eq!(read_access_token(&path).unwrap(), "fake-token-value");
471 }
472
473 #[test]
474 fn missing_row_is_a_credentials_error() {
475 let dir = TempDir::new().unwrap();
476 let path = dir.path().join("state.vscdb");
477 seed_db(&path, None);
478 let err = read_access_token(&path).unwrap_err();
479 assert!(matches!(err, AppError::Credentials(_)));
480 }
481
482 #[test]
483 fn empty_token_is_a_credentials_error() {
484 let dir = TempDir::new().unwrap();
485 let path = dir.path().join("state.vscdb");
486 seed_db(&path, Some(""));
487 let err = read_access_token(&path).unwrap_err();
488 assert!(matches!(err, AppError::Credentials(_)));
489 }
490
491 #[test]
492 fn session_auth_extracts_user_id_and_builds_the_cookie_value() {
493 let token = fake_jwt(serde_json::json!({"sub": "auth0|user_abc123"}));
494 let auth = session_auth(&token).unwrap();
495 assert_eq!(auth.user_id, "user_abc123");
496 assert_eq!(auth.account_key.len(), 16);
497 assert!(!auth.account_key.contains("user_abc123"));
498 assert_eq!(auth.cookie_value, format!("user_abc123%3A%3A{token}"));
499 }
500
501 #[test]
502 fn session_auth_account_key_is_stable_and_account_specific() {
503 let one = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|one"}))).unwrap();
504 let one_again = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|one"}))).unwrap();
505 let two = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|two"}))).unwrap();
506 assert_eq!(one.account_key, one_again.account_key);
507 assert_ne!(one.account_key, two.account_key);
508 }
509
510 #[test]
511 fn session_auth_rejects_a_non_jwt_token() {
512 let err = session_auth("not-a-jwt").unwrap_err();
513 assert!(matches!(err, AppError::Credentials(_)));
514 }
515
516 #[test]
517 fn session_auth_rejects_missing_sub_claim() {
518 let token = fake_jwt(serde_json::json!({"other": "value"}));
519 let err = session_auth(&token).unwrap_err();
520 match err {
521 AppError::Credentials(m) => assert!(m.contains("sub")),
522 other => panic!("expected Credentials error, got {other:?}"),
523 }
524 }
525
526 #[test]
527 fn session_auth_rejects_sub_without_a_pipe_separated_user_id() {
528 let token = fake_jwt(serde_json::json!({"sub": "no-pipe-here"}));
529 let err = session_auth(&token).unwrap_err();
530 assert!(matches!(err, AppError::Credentials(_)));
531 }
532
533 #[test]
534 fn default_agent_auth_path_follows_the_cli_own_convention_per_os() {
535 let p = default_agent_auth_path().unwrap();
536 let expected_tail = if cfg!(target_os = "macos") {
537 std::path::Path::new(".cursor").join("auth.json")
538 } else if cfg!(windows) {
539 std::path::Path::new("Cursor").join("auth.json")
540 } else {
541 std::path::Path::new("cursor").join("auth.json")
542 };
543 assert!(p.ends_with(expected_tail), "{}", p.display());
544 assert!(!cfg!(target_os = "macos") || !p.to_string_lossy().contains("Application Support"));
547 }
548
549 #[test]
550 fn agent_auth_missing_file_is_a_credentials_error_naming_the_path() {
551 let dir = TempDir::new().unwrap();
552 let path = dir.path().join("auth.json");
553 let err = read_agent_access_token(&path).unwrap_err();
554 match err {
555 AppError::Credentials(m) => assert!(m.contains(&path.display().to_string())),
556 other => panic!("expected Credentials error, got {other:?}"),
557 }
558 }
559
560 #[test]
561 fn agent_auth_reads_access_token_out_of_the_json_file() {
562 let dir = TempDir::new().unwrap();
563 let path = dir.path().join("auth.json");
564 std::fs::write(
565 &path,
566 serde_json::json!({"accessToken": "agent-token-value", "refreshToken": "r"})
567 .to_string(),
568 )
569 .unwrap();
570 assert_eq!(read_agent_access_token(&path).unwrap(), "agent-token-value");
571 }
572
573 #[test]
574 fn agent_auth_missing_field_is_a_credentials_error() {
575 let dir = TempDir::new().unwrap();
576 let path = dir.path().join("auth.json");
577 std::fs::write(&path, serde_json::json!({"refreshToken": "r"}).to_string()).unwrap();
578 let err = read_agent_access_token(&path).unwrap_err();
579 assert!(matches!(err, AppError::Credentials(_)));
580 }
581
582 #[test]
583 fn agent_auth_empty_token_is_a_credentials_error() {
584 let dir = TempDir::new().unwrap();
585 let path = dir.path().join("auth.json");
586 std::fs::write(&path, serde_json::json!({"accessToken": ""}).to_string()).unwrap();
587 let err = read_agent_access_token(&path).unwrap_err();
588 assert!(matches!(err, AppError::Credentials(_)));
589 }
590
591 #[test]
592 fn agent_auth_malformed_json_is_a_credentials_error() {
593 let dir = TempDir::new().unwrap();
594 let path = dir.path().join("auth.json");
595 std::fs::write(&path, "not json").unwrap();
596 let err = read_agent_access_token(&path).unwrap_err();
597 assert!(matches!(err, AppError::Credentials(_)));
598 }
599
600 #[test]
601 fn resolve_prefers_the_ide_db_when_both_are_present() {
602 let dir = TempDir::new().unwrap();
603 let db_path = dir.path().join("state.vscdb");
604 seed_db(&db_path, Some("ide-token"));
605 let agent_path = dir.path().join("auth.json");
606 std::fs::write(
607 &agent_path,
608 serde_json::json!({"accessToken": "agent-token"}).to_string(),
609 )
610 .unwrap();
611 assert_eq!(
612 resolve_access_token_with(&db_path, &agent_path, || None).unwrap(),
613 "ide-token"
614 );
615 }
616
617 #[test]
618 fn resolve_falls_back_to_the_agent_file_when_the_ide_db_is_missing() {
619 let dir = TempDir::new().unwrap();
620 let db_path = dir.path().join("state.vscdb");
621 let agent_path = dir.path().join("auth.json");
622 std::fs::write(
623 &agent_path,
624 serde_json::json!({"accessToken": "agent-token"}).to_string(),
625 )
626 .unwrap();
627 assert_eq!(
628 resolve_access_token_with(&db_path, &agent_path, || None).unwrap(),
629 "agent-token"
630 );
631 }
632
633 #[test]
634 fn resolve_does_not_hide_an_existing_broken_ide_db_with_the_agent_file() {
635 let dir = TempDir::new().unwrap();
636 let db_path = dir.path().join("state.vscdb");
637 seed_db(&db_path, None);
638 let agent_path = dir.path().join("auth.json");
639 std::fs::write(
640 &agent_path,
641 serde_json::json!({"accessToken": "agent-token"}).to_string(),
642 )
643 .unwrap();
644
645 let err = resolve_access_token_with(&db_path, &agent_path, || None).unwrap_err();
646 match err {
647 AppError::Credentials(m) => {
648 assert!(m.contains(&db_path.display().to_string()));
649 assert!(!m.contains(&agent_path.display().to_string()));
650 }
651 other => panic!("expected Credentials error, got {other:?}"),
652 }
653 }
654
655 #[test]
656 fn resolve_surfaces_the_ide_error_when_both_sources_are_missing() {
657 let dir = TempDir::new().unwrap();
658 let db_path = dir.path().join("state.vscdb");
659 let agent_path = dir.path().join("auth.json");
660 let err = resolve_access_token_with(&db_path, &agent_path, || None).unwrap_err();
661 match err {
662 AppError::Credentials(m) => assert!(m.contains(&db_path.display().to_string())),
663 other => panic!("expected Credentials error, got {other:?}"),
664 }
665 }
666
667 #[test]
668 fn resolve_surfaces_the_agent_error_when_its_file_exists_but_is_malformed() {
669 let dir = TempDir::new().unwrap();
670 let db_path = dir.path().join("state.vscdb");
671 let agent_path = dir.path().join("auth.json");
672 std::fs::write(&agent_path, "not json").unwrap();
673
674 let err = resolve_access_token_with(&db_path, &agent_path, || None).unwrap_err();
675 match err {
676 AppError::Credentials(m) => {
677 assert!(m.contains(&agent_path.display().to_string()));
678 assert!(m.contains("could not parse"));
679 }
680 other => panic!("expected Credentials error, got {other:?}"),
681 }
682 }
683
684 #[test]
685 fn resolve_falls_back_to_the_keychain_when_the_ide_db_and_agent_file_are_missing() {
686 let dir = TempDir::new().unwrap();
687 let db_path = dir.path().join("state.vscdb");
688 let agent_path = dir.path().join("auth.json");
689 assert_eq!(
690 resolve_access_token_with(&db_path, &agent_path, || Some("keychain-token".into()))
691 .unwrap(),
692 "keychain-token"
693 );
694 }
695
696 #[test]
697 fn resolve_tries_the_keychain_when_the_agent_file_is_unusable() {
698 let dir = TempDir::new().unwrap();
699 let db_path = dir.path().join("state.vscdb");
700 let agent_path = dir.path().join("auth.json");
701 std::fs::write(
702 &agent_path,
703 serde_json::json!({"refreshToken": "r"}).to_string(),
704 )
705 .unwrap();
706 assert_eq!(
707 resolve_access_token_with(&db_path, &agent_path, || Some("keychain-token".into()))
708 .unwrap(),
709 "keychain-token"
710 );
711 }
712
713 #[test]
714 fn resolve_never_consults_the_keychain_over_a_present_ide_db() {
715 let dir = TempDir::new().unwrap();
716 let db_path = dir.path().join("state.vscdb");
717 seed_db(&db_path, None);
718 let agent_path = dir.path().join("auth.json");
719 let err = resolve_access_token_with(&db_path, &agent_path, || {
720 panic!("an existing IDE db must not be widened to the keychain")
721 })
722 .unwrap_err();
723 assert!(matches!(err, AppError::Credentials(_)));
724 }
725
726 #[test]
727 fn resolve_leaves_the_keychain_alone_for_a_non_default_agent_path() {
728 let dir = TempDir::new().unwrap();
731 let err = resolve_access_token(
732 &dir.path().join("state.vscdb"),
733 &dir.path().join("auth.json"),
734 )
735 .unwrap_err();
736 assert!(matches!(err, AppError::Credentials(_)));
737 }
738
739 #[cfg(target_os = "macos")]
742 #[test]
743 fn agent_keychain_lookup_scrubs_vendor_secret_env_vars() {
744 let command = agent_keychain_command();
745 assert_eq!(command.get_program(), "/usr/bin/security");
746 let removed: Vec<String> = command
747 .get_envs()
748 .filter(|(_, value)| value.is_none())
749 .map(|(key, _)| key.to_string_lossy().into_owned())
750 .collect();
751 for var in crate::vendor::vendor_secret_env_vars_to_remove(&[]) {
752 assert!(
753 removed.iter().any(|key| key == var),
754 "expected {var} to be scrubbed from the cursor-agent keychain lookup"
755 );
756 }
757 }
758}