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 = agent_keychain_command().output().ok()?;
162 if !out.status.success() {
163 return None;
164 }
165 let token = String::from_utf8(out.stdout).ok()?;
166 let token = token.trim();
167 (!token.is_empty()).then(|| token.to_string())
168}
169
170#[cfg(target_os = "macos")]
173fn agent_keychain_command() -> std::process::Command {
174 let mut command = std::process::Command::new("/usr/bin/security");
175 command
176 .args([
177 "find-generic-password",
178 "-s",
179 AGENT_KEYCHAIN_SERVICE,
180 "-a",
181 AGENT_KEYCHAIN_ACCOUNT,
182 "-w",
183 ])
184 .stdin(std::process::Stdio::null())
185 .stderr(std::process::Stdio::null());
186 for var in crate::vendor::vendor_secret_env_vars_to_remove(&[]) {
188 command.env_remove(var);
189 }
190 command
191}
192
193#[cfg(not(target_os = "macos"))]
194pub fn read_agent_keychain_token() -> Option<String> {
195 None
196}
197
198pub fn resolve_access_token(db_path: &Path, agent_auth_path: &Path) -> Result<String> {
213 let default_agent = default_agent_auth_path().ok();
214 let ambient = default_agent.as_deref() == Some(agent_auth_path);
215 resolve_access_token_with(db_path, agent_auth_path, || {
216 ambient.then(read_agent_keychain_token).flatten()
217 })
218}
219
220pub fn resolve_access_token_with(
222 db_path: &Path,
223 agent_auth_path: &Path,
224 keychain: impl FnOnce() -> Option<String>,
225) -> Result<String> {
226 match read_access_token(db_path) {
227 Ok(token) => Ok(token),
228 Err(ide_err) if !db_path.exists() => {
229 if agent_auth_path.exists() {
230 match read_agent_access_token(agent_auth_path) {
231 Ok(token) => return Ok(token),
232 Err(agent_err) => return keychain().ok_or(agent_err),
233 }
234 }
235 keychain().ok_or(ide_err)
236 }
237 Err(ide_err) => Err(ide_err),
238 }
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct SessionAuth {
247 pub user_id: String,
248 pub account_key: String,
252 pub cookie_value: String,
253}
254
255pub fn session_auth(token: &str) -> Result<SessionAuth> {
261 let claims = crate::jwt::claims(token).ok_or_else(|| {
262 AppError::Credentials(
263 "Cursor session token could not be decoded. Sign in to the Cursor IDE again.".into(),
264 )
265 })?;
266 let sub = claims
267 .get("sub")
268 .and_then(serde_json::Value::as_str)
269 .ok_or_else(|| AppError::Credentials("Cursor session token has no `sub` claim.".into()))?;
270 let user_id = sub
271 .split('|')
272 .nth(1)
273 .filter(|s| !s.is_empty())
274 .ok_or_else(|| {
275 AppError::Credentials(format!(
276 "Cursor session token `sub` claim has an unexpected shape: {sub:?}"
277 ))
278 })?
279 .to_string();
280 let mut hasher = std::collections::hash_map::DefaultHasher::new();
281 user_id.hash(&mut hasher);
282 let account_key = format!("{:016x}", hasher.finish());
283 let cookie_value = format!("{user_id}%3A%3A{token}");
284 Ok(SessionAuth {
285 user_id,
286 account_key,
287 cookie_value,
288 })
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use base64::Engine as _;
295 use tempfile::TempDir;
296
297 fn fake_jwt(claims: serde_json::Value) -> String {
300 let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#);
301 let payload =
302 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string().as_bytes());
303 format!("{header}.{payload}.sig")
304 }
305
306 fn seed_db(path: &Path, token: Option<&str>) {
307 let conn = Connection::open(path).unwrap();
308 conn.execute("CREATE TABLE ItemTable (key TEXT, value TEXT)", [])
309 .unwrap();
310 if let Some(t) = token {
311 conn.execute(
312 "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)",
313 rusqlite::params![TOKEN_KEY, t],
314 )
315 .unwrap();
316 }
317 }
318
319 #[test]
320 fn default_db_path_ends_with_the_cursor_state_file() {
321 let p = default_db_path().unwrap();
322 assert!(
323 p.ends_with(
324 std::path::Path::new("Cursor")
325 .join("User")
326 .join("globalStorage")
327 .join("state.vscdb")
328 )
329 );
330 }
331
332 #[test]
333 fn missing_file_is_a_credentials_error_naming_the_path() {
334 let dir = TempDir::new().unwrap();
335 let path = dir.path().join("state.vscdb");
336 let err = read_access_token(&path).unwrap_err();
337 match err {
338 AppError::Credentials(m) => assert!(m.contains(&path.display().to_string())),
339 other => panic!("expected Credentials error, got {other:?}"),
340 }
341 }
342
343 #[test]
344 fn reads_the_token_back_out_of_the_item_table() {
345 let dir = TempDir::new().unwrap();
346 let path = dir.path().join("state.vscdb");
347 seed_db(&path, Some("fake-token-value"));
348 assert_eq!(read_access_token(&path).unwrap(), "fake-token-value");
349 }
350
351 #[test]
352 fn missing_row_is_a_credentials_error() {
353 let dir = TempDir::new().unwrap();
354 let path = dir.path().join("state.vscdb");
355 seed_db(&path, None);
356 let err = read_access_token(&path).unwrap_err();
357 assert!(matches!(err, AppError::Credentials(_)));
358 }
359
360 #[test]
361 fn empty_token_is_a_credentials_error() {
362 let dir = TempDir::new().unwrap();
363 let path = dir.path().join("state.vscdb");
364 seed_db(&path, Some(""));
365 let err = read_access_token(&path).unwrap_err();
366 assert!(matches!(err, AppError::Credentials(_)));
367 }
368
369 #[test]
370 fn session_auth_extracts_user_id_and_builds_the_cookie_value() {
371 let token = fake_jwt(serde_json::json!({"sub": "auth0|user_abc123"}));
372 let auth = session_auth(&token).unwrap();
373 assert_eq!(auth.user_id, "user_abc123");
374 assert_eq!(auth.account_key.len(), 16);
375 assert!(!auth.account_key.contains("user_abc123"));
376 assert_eq!(auth.cookie_value, format!("user_abc123%3A%3A{token}"));
377 }
378
379 #[test]
380 fn session_auth_account_key_is_stable_and_account_specific() {
381 let one = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|one"}))).unwrap();
382 let one_again = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|one"}))).unwrap();
383 let two = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|two"}))).unwrap();
384 assert_eq!(one.account_key, one_again.account_key);
385 assert_ne!(one.account_key, two.account_key);
386 }
387
388 #[test]
389 fn session_auth_rejects_a_non_jwt_token() {
390 let err = session_auth("not-a-jwt").unwrap_err();
391 assert!(matches!(err, AppError::Credentials(_)));
392 }
393
394 #[test]
395 fn session_auth_rejects_missing_sub_claim() {
396 let token = fake_jwt(serde_json::json!({"other": "value"}));
397 let err = session_auth(&token).unwrap_err();
398 match err {
399 AppError::Credentials(m) => assert!(m.contains("sub")),
400 other => panic!("expected Credentials error, got {other:?}"),
401 }
402 }
403
404 #[test]
405 fn session_auth_rejects_sub_without_a_pipe_separated_user_id() {
406 let token = fake_jwt(serde_json::json!({"sub": "no-pipe-here"}));
407 let err = session_auth(&token).unwrap_err();
408 assert!(matches!(err, AppError::Credentials(_)));
409 }
410
411 #[test]
412 fn default_agent_auth_path_follows_the_cli_own_convention_per_os() {
413 let p = default_agent_auth_path().unwrap();
414 let expected_tail = if cfg!(target_os = "macos") {
415 std::path::Path::new(".cursor").join("auth.json")
416 } else if cfg!(windows) {
417 std::path::Path::new("Cursor").join("auth.json")
418 } else {
419 std::path::Path::new("cursor").join("auth.json")
420 };
421 assert!(p.ends_with(expected_tail), "{}", p.display());
422 assert!(!cfg!(target_os = "macos") || !p.to_string_lossy().contains("Application Support"));
425 }
426
427 #[test]
428 fn agent_auth_missing_file_is_a_credentials_error_naming_the_path() {
429 let dir = TempDir::new().unwrap();
430 let path = dir.path().join("auth.json");
431 let err = read_agent_access_token(&path).unwrap_err();
432 match err {
433 AppError::Credentials(m) => assert!(m.contains(&path.display().to_string())),
434 other => panic!("expected Credentials error, got {other:?}"),
435 }
436 }
437
438 #[test]
439 fn agent_auth_reads_access_token_out_of_the_json_file() {
440 let dir = TempDir::new().unwrap();
441 let path = dir.path().join("auth.json");
442 std::fs::write(
443 &path,
444 serde_json::json!({"accessToken": "agent-token-value", "refreshToken": "r"})
445 .to_string(),
446 )
447 .unwrap();
448 assert_eq!(read_agent_access_token(&path).unwrap(), "agent-token-value");
449 }
450
451 #[test]
452 fn agent_auth_missing_field_is_a_credentials_error() {
453 let dir = TempDir::new().unwrap();
454 let path = dir.path().join("auth.json");
455 std::fs::write(&path, serde_json::json!({"refreshToken": "r"}).to_string()).unwrap();
456 let err = read_agent_access_token(&path).unwrap_err();
457 assert!(matches!(err, AppError::Credentials(_)));
458 }
459
460 #[test]
461 fn agent_auth_empty_token_is_a_credentials_error() {
462 let dir = TempDir::new().unwrap();
463 let path = dir.path().join("auth.json");
464 std::fs::write(&path, serde_json::json!({"accessToken": ""}).to_string()).unwrap();
465 let err = read_agent_access_token(&path).unwrap_err();
466 assert!(matches!(err, AppError::Credentials(_)));
467 }
468
469 #[test]
470 fn agent_auth_malformed_json_is_a_credentials_error() {
471 let dir = TempDir::new().unwrap();
472 let path = dir.path().join("auth.json");
473 std::fs::write(&path, "not json").unwrap();
474 let err = read_agent_access_token(&path).unwrap_err();
475 assert!(matches!(err, AppError::Credentials(_)));
476 }
477
478 #[test]
479 fn resolve_prefers_the_ide_db_when_both_are_present() {
480 let dir = TempDir::new().unwrap();
481 let db_path = dir.path().join("state.vscdb");
482 seed_db(&db_path, Some("ide-token"));
483 let agent_path = dir.path().join("auth.json");
484 std::fs::write(
485 &agent_path,
486 serde_json::json!({"accessToken": "agent-token"}).to_string(),
487 )
488 .unwrap();
489 assert_eq!(
490 resolve_access_token_with(&db_path, &agent_path, || None).unwrap(),
491 "ide-token"
492 );
493 }
494
495 #[test]
496 fn resolve_falls_back_to_the_agent_file_when_the_ide_db_is_missing() {
497 let dir = TempDir::new().unwrap();
498 let db_path = dir.path().join("state.vscdb");
499 let agent_path = dir.path().join("auth.json");
500 std::fs::write(
501 &agent_path,
502 serde_json::json!({"accessToken": "agent-token"}).to_string(),
503 )
504 .unwrap();
505 assert_eq!(
506 resolve_access_token_with(&db_path, &agent_path, || None).unwrap(),
507 "agent-token"
508 );
509 }
510
511 #[test]
512 fn resolve_does_not_hide_an_existing_broken_ide_db_with_the_agent_file() {
513 let dir = TempDir::new().unwrap();
514 let db_path = dir.path().join("state.vscdb");
515 seed_db(&db_path, None);
516 let agent_path = dir.path().join("auth.json");
517 std::fs::write(
518 &agent_path,
519 serde_json::json!({"accessToken": "agent-token"}).to_string(),
520 )
521 .unwrap();
522
523 let err = resolve_access_token_with(&db_path, &agent_path, || None).unwrap_err();
524 match err {
525 AppError::Credentials(m) => {
526 assert!(m.contains(&db_path.display().to_string()));
527 assert!(!m.contains(&agent_path.display().to_string()));
528 }
529 other => panic!("expected Credentials error, got {other:?}"),
530 }
531 }
532
533 #[test]
534 fn resolve_surfaces_the_ide_error_when_both_sources_are_missing() {
535 let dir = TempDir::new().unwrap();
536 let db_path = dir.path().join("state.vscdb");
537 let agent_path = dir.path().join("auth.json");
538 let err = resolve_access_token_with(&db_path, &agent_path, || None).unwrap_err();
539 match err {
540 AppError::Credentials(m) => assert!(m.contains(&db_path.display().to_string())),
541 other => panic!("expected Credentials error, got {other:?}"),
542 }
543 }
544
545 #[test]
546 fn resolve_surfaces_the_agent_error_when_its_file_exists_but_is_malformed() {
547 let dir = TempDir::new().unwrap();
548 let db_path = dir.path().join("state.vscdb");
549 let agent_path = dir.path().join("auth.json");
550 std::fs::write(&agent_path, "not json").unwrap();
551
552 let err = resolve_access_token_with(&db_path, &agent_path, || None).unwrap_err();
553 match err {
554 AppError::Credentials(m) => {
555 assert!(m.contains(&agent_path.display().to_string()));
556 assert!(m.contains("could not parse"));
557 }
558 other => panic!("expected Credentials error, got {other:?}"),
559 }
560 }
561
562 #[test]
563 fn resolve_falls_back_to_the_keychain_when_the_ide_db_and_agent_file_are_missing() {
564 let dir = TempDir::new().unwrap();
565 let db_path = dir.path().join("state.vscdb");
566 let agent_path = dir.path().join("auth.json");
567 assert_eq!(
568 resolve_access_token_with(&db_path, &agent_path, || Some("keychain-token".into()))
569 .unwrap(),
570 "keychain-token"
571 );
572 }
573
574 #[test]
575 fn resolve_tries_the_keychain_when_the_agent_file_is_unusable() {
576 let dir = TempDir::new().unwrap();
577 let db_path = dir.path().join("state.vscdb");
578 let agent_path = dir.path().join("auth.json");
579 std::fs::write(
580 &agent_path,
581 serde_json::json!({"refreshToken": "r"}).to_string(),
582 )
583 .unwrap();
584 assert_eq!(
585 resolve_access_token_with(&db_path, &agent_path, || Some("keychain-token".into()))
586 .unwrap(),
587 "keychain-token"
588 );
589 }
590
591 #[test]
592 fn resolve_never_consults_the_keychain_over_a_present_ide_db() {
593 let dir = TempDir::new().unwrap();
594 let db_path = dir.path().join("state.vscdb");
595 seed_db(&db_path, None);
596 let agent_path = dir.path().join("auth.json");
597 let err = resolve_access_token_with(&db_path, &agent_path, || {
598 panic!("an existing IDE db must not be widened to the keychain")
599 })
600 .unwrap_err();
601 assert!(matches!(err, AppError::Credentials(_)));
602 }
603
604 #[test]
605 fn resolve_leaves_the_keychain_alone_for_a_non_default_agent_path() {
606 let dir = TempDir::new().unwrap();
609 let err = resolve_access_token(
610 &dir.path().join("state.vscdb"),
611 &dir.path().join("auth.json"),
612 )
613 .unwrap_err();
614 assert!(matches!(err, AppError::Credentials(_)));
615 }
616
617 #[cfg(target_os = "macos")]
620 #[test]
621 fn agent_keychain_lookup_scrubs_vendor_secret_env_vars() {
622 let command = agent_keychain_command();
623 assert_eq!(command.get_program(), "/usr/bin/security");
624 let removed: Vec<String> = command
625 .get_envs()
626 .filter(|(_, value)| value.is_none())
627 .map(|(key, _)| key.to_string_lossy().into_owned())
628 .collect();
629 for var in crate::vendor::vendor_secret_env_vars_to_remove(&[]) {
630 assert!(
631 removed.iter().any(|key| key == var),
632 "expected {var} to be scrubbed from the cursor-agent keychain lookup"
633 );
634 }
635 }
636}