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