Skip to main content

claude_codex/providers/cursor/
auth.rs

1use 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
140/// Load only the bearer token for call sites that do not need auth metadata.
141pub 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}