claude_codex/providers/cursor/
auth.rs1use base64::Engine;
2use rand::RngCore;
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use crate::auth::{AuthStorage, KeychainFileAuthStore, SystemKeychain};
8use crate::{config, paths};
9
10pub const KEYCHAIN_SERVICE: &str = "claude-code-proxy.cursor";
11pub const KEYCHAIN_ACCOUNT: &str = "auth";
12
13const REFRESH_EXPIRY_SKEW_MS: u64 = 60_000;
14const CURSOR_WEBSITE_URL: &str = "https://cursor.com";
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
17#[serde(rename_all = "camelCase")]
18pub struct StoredCursorAuth {
19 pub access_token: String,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub refresh_token: Option<String>,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub api_key: Option<String>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct CursorAuth {
28 pub access_token: String,
29 pub refresh_token: Option<String>,
30 pub api_key: Option<String>,
31 pub expires: Option<u64>,
32 pub user_id: Option<String>,
33 pub email: Option<String>,
34 pub source: String,
35}
36
37pub type DefaultCursorAuthStore = KeychainFileAuthStore<StoredCursorAuth, SystemKeychain>;
38
39pub struct CursorTokenStore<S: AuthStorage<StoredCursorAuth>> {
40 store: S,
41}
42
43impl<S: AuthStorage<StoredCursorAuth>> CursorTokenStore<S> {
44 pub fn new(store: S) -> Self {
45 Self { store }
46 }
47
48 pub fn load_auth(&self) -> anyhow::Result<Option<CursorAuth>> {
49 let Some(stored) = self.store.load()? else {
50 return Ok(None);
51 };
52 if stored.access_token.trim().is_empty() {
53 return Ok(None);
54 }
55 let auth = enrich(stored, self.auth_path());
56 self.refresh_if_needed(auth)
57 }
58
59 pub fn save_auth(&self, auth: StoredCursorAuth) -> anyhow::Result<CursorAuth> {
60 if auth.access_token.trim().is_empty() {
61 anyhow::bail!("Cursor auth accessToken is required");
62 }
63 self.store.save(auth.clone())?;
64 Ok(enrich(auth, self.auth_path()))
65 }
66
67 pub fn clear_auth(&self) -> anyhow::Result<()> {
68 self.store.clear()
69 }
70
71 pub fn auth_path(&self) -> String {
72 self.store.path()
73 }
74
75 fn refresh_if_needed(&self, auth: CursorAuth) -> anyhow::Result<Option<CursorAuth>> {
76 let Some(refresh_token) = auth.refresh_token.as_deref() else {
77 return Ok(Some(auth));
78 };
79 let Some(expires) = auth.expires else {
80 return Ok(Some(auth));
81 };
82 if expires > now_ms() + REFRESH_EXPIRY_SKEW_MS {
83 return Ok(Some(auth));
84 }
85
86 let Some(refreshed) = refresh_cursor_auth(refresh_token)? else {
87 return Ok(Some(auth));
88 };
89 self.save_auth(StoredCursorAuth {
90 access_token: refreshed.access_token,
91 refresh_token: Some(refreshed.refresh_token),
92 api_key: auth.api_key,
93 })
94 .map(Some)
95 }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct CursorLogin {
100 pub login_url: String,
101 pub uuid: String,
102 pub verifier: String,
103}
104
105#[derive(Debug, Deserialize)]
106pub struct RefreshResponse {
107 #[serde(rename = "accessToken")]
108 access_token: String,
109 #[serde(rename = "refreshToken")]
110 refresh_token: String,
111}
112
113pub fn file_store() -> CursorTokenStore<DefaultCursorAuthStore> {
114 let primary = paths::provider_auth_file("cursor");
115 let legacy = paths::provider_legacy_auth_file("cursor");
116 CursorTokenStore::new(KeychainFileAuthStore::new(
117 primary.to_string_lossy().to_string(),
118 legacy.to_string_lossy().to_string(),
119 KEYCHAIN_SERVICE,
120 KEYCHAIN_ACCOUNT,
121 use_macos_keychain(),
122 SystemKeychain,
123 ))
124}
125
126pub fn load_cursor_auth() -> anyhow::Result<Option<CursorAuth>> {
127 if let Some(token) = env_cursor_token() {
128 return Ok(Some(enrich(
129 StoredCursorAuth {
130 access_token: token,
131 refresh_token: None,
132 api_key: None,
133 },
134 "environment".to_string(),
135 )));
136 }
137 file_store().load_auth()
138}
139
140pub fn load_cursor_token() -> Option<String> {
142 load_cursor_auth()
143 .ok()
144 .flatten()
145 .map(|auth| auth.access_token)
146}
147
148pub fn save_cursor_auth(auth: StoredCursorAuth) -> anyhow::Result<CursorAuth> {
149 file_store().save_auth(auth)
150}
151
152pub fn clear_cursor_auth() -> anyhow::Result<()> {
153 file_store().clear_auth()
154}
155
156pub fn cursor_auth_location() -> String {
157 file_store().auth_path()
158}
159
160pub fn missing_auth_message() -> String {
161 [
162 "Cursor authentication was not found.",
163 "Run `claude-codex cursor auth login`, or set CCP_CURSOR_AUTH_TOKEN/CURSOR_AUTH_TOKEN.",
164 "The proxy stores Cursor credentials in its own claude-code-proxy.cursor storage, not Cursor Agent's Keychain/auth.json.",
165 ]
166 .join(" ")
167}
168
169pub fn expired_auth_message(auth: &CursorAuth) -> String {
170 let expires = auth
171 .expires
172 .map(format_unix_ms)
173 .unwrap_or_else(|| "unknown".to_string());
174 format!(
175 "Cursor access token from {} is expired or near expiry ({}). Run `claude-codex cursor auth login` again or set CCP_CURSOR_AUTH_TOKEN.",
176 auth.source, expires
177 )
178}
179
180pub fn create_cursor_login() -> CursorLogin {
181 let verifier = random_base64_url(32);
182 let challenge = base64_url(Sha256::digest(verifier.as_bytes()).as_ref());
183 let uuid = uuid::Uuid::new_v4().to_string();
184 let login_url = format!(
185 "{CURSOR_WEBSITE_URL}/loginDeepControl?challenge={challenge}&uuid={uuid}&mode=login&redirectTarget=cli"
186 );
187 CursorLogin {
188 login_url,
189 uuid,
190 verifier,
191 }
192}
193
194pub fn run_cursor_login() -> anyhow::Result<Option<CursorAuth>> {
195 let login = create_cursor_login();
196 println!("Open this URL to authenticate with Cursor:");
197 println!("{}", login.login_url);
198 println!();
199 if let Err(err) = open_cursor_login_url(&login.login_url) {
200 println!("Could not open browser automatically: {err}");
201 }
202 println!("Waiting for Cursor login...");
203 let result = wait_for_cursor_login(&login, 150, |attempt| {
204 if attempt > 0 && attempt % 10 == 0 {
205 print!(".");
206 let _ = std::io::Write::flush(&mut std::io::stdout());
207 }
208 })?;
209 let Some(result) = result else {
210 return Ok(None);
211 };
212 save_cursor_auth(StoredCursorAuth {
213 access_token: result.access_token,
214 refresh_token: Some(result.refresh_token),
215 api_key: None,
216 })
217 .map(Some)
218}
219
220pub fn wait_for_cursor_login(
221 login: &CursorLogin,
222 max_attempts: usize,
223 mut on_progress: impl FnMut(usize),
224) -> anyhow::Result<Option<RefreshResponse>> {
225 let client = reqwest::blocking::Client::new();
226 let base = config::cursor_base_url().trim_end_matches('/').to_string();
227 let mut consecutive_errors = 0usize;
228
229 for attempt in 0..max_attempts {
230 let delay =
231 Duration::from_millis((1000.0 * 1.2_f64.powi(attempt as i32)).min(10_000.0) as u64);
232 let url = format!(
233 "{base}/auth/poll?uuid={}&verifier={}",
234 login.uuid, login.verifier
235 );
236 match client
237 .get(url)
238 .header("content-type", "application/json")
239 .send()
240 {
241 Ok(resp) if resp.status() == reqwest::StatusCode::NOT_FOUND => {
242 consecutive_errors = 0;
243 on_progress(attempt);
244 std::thread::sleep(delay);
245 }
246 Ok(resp) if resp.status().is_success() => {
247 let parsed: serde_json::Value = resp.json()?;
248 return Ok(parse_cursor_auth_tokens(&parsed));
249 }
250 Ok(_) | Err(_) => {
251 consecutive_errors += 1;
252 if consecutive_errors >= 3 {
253 return Ok(None);
254 }
255 std::thread::sleep(delay);
256 }
257 }
258 }
259 Ok(None)
260}
261
262fn refresh_cursor_auth(refresh_token: &str) -> anyhow::Result<Option<RefreshResponse>> {
263 let client = reqwest::blocking::Client::new();
264 let url = format!(
265 "{}/auth/refresh",
266 config::cursor_base_url().trim_end_matches('/')
267 );
268 let resp = client
269 .post(url)
270 .header("content-type", "application/json")
271 .bearer_auth(refresh_token)
272 .body("{}")
273 .send()?;
274 if !resp.status().is_success() {
275 return Ok(None);
276 }
277 let parsed: serde_json::Value = resp.json()?;
278 Ok(parse_cursor_auth_tokens(&parsed))
279}
280
281fn parse_cursor_auth_tokens(parsed: &serde_json::Value) -> Option<RefreshResponse> {
282 Some(RefreshResponse {
283 access_token: parsed.get("accessToken")?.as_str()?.to_string(),
284 refresh_token: parsed.get("refreshToken")?.as_str()?.to_string(),
285 })
286}
287
288fn env_cursor_token() -> Option<String> {
289 env_cursor_token_from(|key| std::env::var(key).ok())
290}
291
292fn env_cursor_token_from(get: impl Fn(&str) -> Option<String>) -> Option<String> {
293 get("CCP_CURSOR_AUTH_TOKEN")
294 .filter(|token| !token.trim().is_empty())
295 .or_else(|| get("CURSOR_AUTH_TOKEN").filter(|token| !token.trim().is_empty()))
296}
297
298fn enrich(stored: StoredCursorAuth, source: String) -> CursorAuth {
299 let claims = parse_jwt_claims(&stored.access_token);
300 CursorAuth {
301 expires: token_expiry_ms(&stored.access_token),
302 user_id: claims
303 .as_ref()
304 .and_then(|claims| claims.get("sub"))
305 .and_then(|sub| sub.as_str())
306 .map(str::to_string),
307 email: claims
308 .as_ref()
309 .and_then(|claims| claims.get("email"))
310 .and_then(|email| email.as_str())
311 .map(str::to_string),
312 source,
313 access_token: stored.access_token,
314 refresh_token: stored.refresh_token,
315 api_key: stored.api_key,
316 }
317}
318
319fn token_expiry_ms(token: &str) -> Option<u64> {
320 parse_jwt_claims(token)?
321 .get("exp")?
322 .as_u64()
323 .map(|exp| exp * 1000)
324}
325
326fn parse_jwt_claims(token: &str) -> Option<serde_json::Value> {
327 let mut parts = token.split('.');
328 let _header = parts.next()?;
329 let payload = parts.next()?;
330 let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
331 .decode(payload)
332 .or_else(|_| {
333 let padded = format!("{payload}{}", "=".repeat((4 - payload.len() % 4) % 4));
334 base64::engine::general_purpose::URL_SAFE.decode(padded)
335 })
336 .ok()?;
337 serde_json::from_slice(&decoded).ok()
338}
339
340fn open_cursor_login_url(url: &str) -> anyhow::Result<()> {
341 let status = if cfg!(target_os = "macos") {
342 std::process::Command::new("open").arg(url).status()?
343 } else if cfg!(target_os = "windows") {
344 std::process::Command::new("cmd")
345 .args(["/c", "start", "", url])
346 .status()?
347 } else {
348 std::process::Command::new("xdg-open").arg(url).status()?
349 };
350 if !status.success() {
351 anyhow::bail!("open command exited with {status}");
352 }
353 Ok(())
354}
355
356fn random_base64_url(len: usize) -> String {
357 let mut bytes = vec![0u8; len];
358 rand::thread_rng().fill_bytes(&mut bytes);
359 base64_url(&bytes)
360}
361
362fn base64_url(bytes: &[u8]) -> String {
363 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
364}
365
366fn use_macos_keychain() -> bool {
367 cfg!(target_os = "macos") && std::env::var_os("CCP_CONFIG_DIR").is_none()
368}
369
370fn now_ms() -> u64 {
371 SystemTime::now()
372 .duration_since(UNIX_EPOCH)
373 .unwrap_or_default()
374 .as_millis() as u64
375}
376
377fn format_unix_ms(ms: u64) -> String {
378 let secs = (ms / 1000) as i64;
379 match time::OffsetDateTime::from_unix_timestamp(secs) {
380 Ok(ts) => ts
381 .format(&time::format_description::well_known::Rfc3339)
382 .unwrap_or_else(|_| ms.to_string()),
383 Err(_) => ms.to_string(),
384 }
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390 use crate::auth::InMemoryAuthStore;
391
392 #[test]
393 fn auth_uses_cursor_auth_token_env() {
394 let token = env_cursor_token_from(|key| match key {
395 "CURSOR_AUTH_TOKEN" => Some("tok_from_cursor".into()),
396 _ => None,
397 });
398 assert_eq!(token.as_deref(), Some("tok_from_cursor"));
399 }
400
401 #[test]
402 fn auth_prioritizes_ccp_env_over_cursor_env() {
403 let token = env_cursor_token_from(|key| match key {
404 "CCP_CURSOR_AUTH_TOKEN" => Some("tok_ccp".into()),
405 "CURSOR_AUTH_TOKEN" => Some("tok_cursor".into()),
406 _ => None,
407 });
408 assert_eq!(token.as_deref(), Some("tok_ccp"));
409 }
410
411 #[test]
412 fn auth_returns_none_when_not_set() {
413 assert!(env_cursor_token_from(|_| None).is_none());
414 }
415
416 #[test]
417 fn stored_auth_uses_camel_case_fields() {
418 let auth: StoredCursorAuth = serde_json::from_value(serde_json::json!({
419 "accessToken": "access",
420 "refreshToken": "refresh",
421 "apiKey": "api"
422 }))
423 .unwrap();
424 assert_eq!(auth.access_token, "access");
425 assert_eq!(auth.refresh_token.as_deref(), Some("refresh"));
426
427 let value = serde_json::to_value(auth).unwrap();
428 assert_eq!(value["accessToken"], "access");
429 assert_eq!(value["refreshToken"], "refresh");
430 assert!(value.get("access_token").is_none());
431 }
432
433 #[test]
434 fn cursor_token_store_enriches_jwt_claims() {
435 let store = CursorTokenStore::new(InMemoryAuthStore::new());
436 let auth = store
437 .save_auth(StoredCursorAuth {
438 access_token: test_jwt(4_102_444_800, Some("user_1"), Some("me@example.com")),
439 refresh_token: Some("refresh".into()),
440 api_key: None,
441 })
442 .unwrap();
443
444 assert_eq!(auth.user_id.as_deref(), Some("user_1"));
445 assert_eq!(auth.email.as_deref(), Some("me@example.com"));
446 assert_eq!(auth.expires, Some(4_102_444_800_000));
447 }
448
449 #[test]
450 fn create_login_matches_cursor_deep_control_shape() {
451 let login = create_cursor_login();
452 assert!(
453 login
454 .login_url
455 .starts_with("https://cursor.com/loginDeepControl?challenge=")
456 );
457 assert!(login.login_url.contains("&uuid="));
458 assert!(login.login_url.contains("&mode=login&redirectTarget=cli"));
459 assert!(!login.verifier.contains('='));
460 }
461
462 fn test_jwt(exp: u64, sub: Option<&str>, email: Option<&str>) -> String {
463 let mut payload = serde_json::json!({ "exp": exp });
464 if let Some(sub) = sub {
465 payload["sub"] = serde_json::Value::String(sub.to_string());
466 }
467 if let Some(email) = email {
468 payload["email"] = serde_json::Value::String(email.to_string());
469 }
470 let header = base64::engine::general_purpose::URL_SAFE_NO_PAD
471 .encode(br#"{"alg":"none","typ":"JWT"}"#);
472 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
473 .encode(serde_json::to_vec(&payload).unwrap());
474 format!("{header}.{payload}.sig")
475 }
476}