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