Files
1jehuang--jcode/crates/jcode-base/src/auth/cursor.rs
T
wehub-resource-sync a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

735 lines
24 KiB
Rust

use anyhow::{Context, Result};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use std::process::{Command, Output, Stdio};
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
const CURSOR_API_BASE: &str = "https://api2.cursor.sh";
// Cursor's server rejects stale client versions for chat ("Update Required").
// This must track a real, currently-served Cursor IDE release (e.g. 3.8.x),
// not the Composer model number. Override at runtime with
// `JCODE_CURSOR_CLIENT_VERSION` if Cursor moves the floor again.
const CURSOR_DIRECT_CLIENT_VERSION_DEFAULT: &str = "3.8.24";
const CURSOR_OAUTH_CLIENT_ID: &str = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB";
const CURSOR_EXTERNAL_COMMAND_TIMEOUT: Duration = Duration::from_secs(3);
pub const CURSOR_AUTH_FILE_SOURCE_ID: &str = "cursor_auth_json";
pub const CURSOR_VSCDB_SOURCE_ID: &str = "cursor_vscdb";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExternalCursorAuthSource {
CursorAuthFile,
CursorVscdb,
}
impl ExternalCursorAuthSource {
pub fn source_id(self) -> &'static str {
match self {
Self::CursorAuthFile => CURSOR_AUTH_FILE_SOURCE_ID,
Self::CursorVscdb => CURSOR_VSCDB_SOURCE_ID,
}
}
pub fn display_name(self) -> &'static str {
match self {
Self::CursorAuthFile => "Cursor auth.json",
Self::CursorVscdb => "Cursor IDE state.vscdb",
}
}
pub fn path(self) -> Result<PathBuf> {
match self {
Self::CursorAuthFile => cursor_auth_file_path(),
Self::CursorVscdb => find_cursor_vscdb(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CursorDirectTokens {
pub access_token: String,
pub refresh_token: Option<String>,
pub source: &'static str,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct CursorAuthFileData {
access_token: Option<String>,
refresh_token: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CursorRefreshResponse {
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
#[serde(default)]
should_logout: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CursorApiKeyExchangeResponse {
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
}
#[derive(Debug, Deserialize)]
struct JwtClaims {
exp: Option<u64>,
}
#[derive(Debug, Serialize)]
struct CursorRefreshRequest<'a> {
grant_type: &'static str,
client_id: &'static str,
refresh_token: &'a str,
}
/// Check if Cursor API key is available (env var or saved file).
pub fn has_cursor_api_key() -> bool {
load_api_key().is_ok()
}
/// Whether direct Cursor native auth is available without relying on cursor-agent runtime.
pub fn has_cursor_native_auth() -> bool {
load_access_token_from_env_or_file().is_ok() || has_cursor_vscdb_token() || has_cursor_api_key()
}
/// Check whether the local Cursor Agent CLI reports an authenticated session.
///
/// Full auth status may spend a little time probing external commands, while
/// `AuthStatus::check_fast()` intentionally skips this path for UI responsiveness.
pub fn has_authenticated_cli_session() -> bool {
let command = std::env::var_os("JCODE_CURSOR_CLI_PATH")
.unwrap_or_else(|| std::ffi::OsString::from("cursor-agent"));
let command_label = command.to_string_lossy();
if !super::command_exists(&command_label) {
return false;
}
let mut status_command = Command::new(&command);
status_command.arg("status");
let Ok(Some(output)) =
command_output_with_timeout(&mut status_command, CURSOR_EXTERNAL_COMMAND_TIMEOUT)
else {
return false;
};
status_output_indicates_authenticated(output.status.success(), &output.stdout, &output.stderr)
}
/// Check whether a trusted Cursor auth.json contains a usable direct access token.
pub fn has_cursor_auth_file_token() -> bool {
let Ok(file_path) = cursor_auth_file_path() else {
return false;
};
if !file_path.exists()
|| !crate::config::Config::external_auth_source_allowed_for_path(
CURSOR_AUTH_FILE_SOURCE_ID,
&file_path,
)
{
return false;
}
load_access_token_from_env_or_file()
.map(|tokens| tokens.source == "cursor_auth_file")
.unwrap_or(false)
}
pub fn preferred_external_auth_source() -> Option<ExternalCursorAuthSource> {
if cursor_auth_file_path()
.map(|path| path.exists())
.unwrap_or(false)
{
return Some(ExternalCursorAuthSource::CursorAuthFile);
}
if cursor_vscdb_paths().into_iter().any(|path| path.exists()) {
return Some(ExternalCursorAuthSource::CursorVscdb);
}
None
}
pub fn has_unconsented_external_auth() -> Option<ExternalCursorAuthSource> {
let source = preferred_external_auth_source()?;
let allowed = source
.path()
.ok()
.map(|path| {
crate::config::Config::external_auth_source_allowed_for_path(source.source_id(), &path)
})
.unwrap_or(false);
if allowed { None } else { Some(source) }
}
pub fn trust_external_auth_source(source: ExternalCursorAuthSource) -> Result<()> {
crate::config::Config::allow_external_auth_source_for_path(
source.source_id(),
&source.path()?,
)?;
super::AuthStatus::invalidate_cache();
Ok(())
}
/// Resolve the advertised client version for native Cursor API requests.
pub fn cursor_direct_client_version() -> String {
std::env::var("JCODE_CURSOR_CLIENT_VERSION")
.ok()
.map(|raw| raw.trim().to_string())
.filter(|raw| !raw.is_empty())
.unwrap_or_else(|| CURSOR_DIRECT_CLIENT_VERSION_DEFAULT.to_string())
}
/// Check if Cursor IDE's local vscdb has an access token.
pub fn has_cursor_vscdb_token() -> bool {
find_cursor_vscdb()
.ok()
.map(|path| {
crate::config::Config::external_auth_source_allowed_for_path(
CURSOR_VSCDB_SOURCE_ID,
&path,
)
})
.unwrap_or(false)
&& read_vscdb_token().is_ok()
}
/// Read access token from Cursor IDE's SQLite storage (state.vscdb).
/// Uses the `sqlite3` CLI to avoid adding a native dependency.
pub fn read_vscdb_token() -> Result<String> {
let db_path = find_cursor_vscdb()?;
read_vscdb_key(&db_path, "cursorAuth/accessToken")
}
/// Read refresh token from Cursor IDE's SQLite storage (state.vscdb).
pub fn read_vscdb_refresh_token() -> Result<String> {
let db_path = find_cursor_vscdb()?;
read_vscdb_key(&db_path, "cursorAuth/refreshToken")
}
/// Read the machine ID from Cursor's vscdb (needed for API checksum header).
pub fn read_vscdb_machine_id() -> Result<String> {
let db_path = find_cursor_vscdb()?;
read_vscdb_key(&db_path, "storage.serviceMachineId")
}
/// Find the Cursor vscdb file on this platform.
fn find_cursor_vscdb() -> Result<PathBuf> {
let candidates = cursor_vscdb_paths();
for path in &candidates {
if path.exists() {
return crate::storage::validate_external_auth_file(path);
}
}
anyhow::bail!("Cursor state.vscdb not found (is Cursor IDE installed?)")
}
/// Platform-specific candidate paths for Cursor's state.vscdb.
fn cursor_vscdb_paths() -> Vec<PathBuf> {
#[cfg(target_os = "linux")]
let relatives = [
".config/Cursor/User/globalStorage/state.vscdb",
".config/cursor/User/globalStorage/state.vscdb",
];
#[cfg(target_os = "macos")]
let relatives = [
"Library/Application Support/Cursor/User/globalStorage/state.vscdb",
"Library/Application Support/cursor/User/globalStorage/state.vscdb",
];
#[cfg(target_os = "windows")]
let relatives = [
"AppData/Roaming/Cursor/User/globalStorage/state.vscdb",
"AppData/Roaming/cursor/User/globalStorage/state.vscdb",
];
// Other Unix platforms (e.g. FreeBSD) follow the XDG-style layout like Linux.
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
let relatives = [
".config/Cursor/User/globalStorage/state.vscdb",
".config/cursor/User/globalStorage/state.vscdb",
];
relatives
.into_iter()
.filter_map(|relative| crate::storage::user_home_path(relative).ok())
.collect()
}
/// Read a key from a vscdb file using the sqlite3 CLI.
fn read_vscdb_key(db_path: &PathBuf, key: &str) -> Result<String> {
let mut command = Command::new("sqlite3");
command.arg(db_path).arg(format!(
"SELECT value FROM ItemTable WHERE key = '{}';",
key
));
let output = command_output_with_timeout(&mut command, CURSOR_EXTERNAL_COMMAND_TIMEOUT)
.context("Failed to run sqlite3 (is it installed?)")?
.ok_or_else(|| anyhow::anyhow!("sqlite3 timed out reading {}", db_path.display()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("sqlite3 failed: {}", stderr.trim());
}
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
if value.is_empty() {
anyhow::bail!("Key '{}' not found or empty in {}", key, db_path.display());
}
Ok(value)
}
fn command_output_with_timeout(command: &mut Command, timeout: Duration) -> Result<Option<Output>> {
let mut child = command
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let start = std::time::Instant::now();
loop {
if child.try_wait()?.is_some() {
return child.wait_with_output().map(Some).map_err(Into::into);
}
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
return Ok(None);
}
std::thread::sleep(Duration::from_millis(25));
}
}
/// Load Cursor API key. Checks in order:
/// 1. `CURSOR_API_KEY` env var
/// 2. Saved key in `~/.config/jcode/cursor.env`
pub fn load_api_key() -> Result<String> {
if let Ok(key) = std::env::var("CURSOR_API_KEY") {
let trimmed = jcode_provider_env::sanitize_secret_value(&key);
if !trimmed.is_empty() {
return Ok(trimmed.to_string());
}
}
let file_path = config_file_path()?;
if file_path.exists() {
crate::storage::harden_secret_file_permissions(&file_path);
let content = std::fs::read_to_string(&file_path)
.with_context(|| format!("Failed to read {}", file_path.display()))?;
for line in content.lines() {
let line = line.trim();
if let Some(key) = line.strip_prefix("CURSOR_API_KEY=") {
let key = jcode_provider_env::sanitize_secret_value(key);
if !key.is_empty() {
return Ok(key.to_string());
}
}
}
}
anyhow::bail!(
"Cursor API key not found. Set CURSOR_API_KEY env var, \
or run `/login cursor` to configure."
)
}
/// Save a Cursor API key to `~/.config/jcode/cursor.env`.
pub fn save_api_key(key: &str) -> Result<()> {
let file_path = config_file_path()?;
crate::storage::upsert_env_file_value(&file_path, "CURSOR_API_KEY", Some(key))?;
crate::env::set_var("CURSOR_API_KEY", key);
Ok(())
}
/// Remove the saved Cursor API key from `~/.config/jcode/cursor.env` and the
/// current process environment.
pub fn clear_api_key() -> Result<()> {
let file_path = config_file_path()?;
crate::storage::upsert_env_file_value(&file_path, "CURSOR_API_KEY", None)?;
crate::env::remove_var("CURSOR_API_KEY");
Ok(())
}
fn config_file_path() -> Result<PathBuf> {
let config_dir = crate::storage::app_config_dir()?;
Ok(config_dir.join("cursor.env"))
}
/// Resolve Cursor CLI/device-login auth file path.
pub fn cursor_auth_file_path() -> Result<PathBuf> {
#[cfg(target_os = "windows")]
{
let appdata = std::env::var_os("APPDATA")
.map(PathBuf::from)
.or_else(|| crate::storage::user_home_path("AppData/Roaming").ok())
.ok_or_else(|| anyhow::anyhow!("No APPDATA directory found"))?;
return Ok(appdata.join("Cursor").join("auth.json"));
}
#[cfg(target_os = "macos")]
{
return crate::storage::user_home_path(".cursor/auth.json")
.context("No home directory found for Cursor auth.json");
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
// Honor JCODE_HOME isolation (used by the onboarding sandbox and tests)
// the same way every other external-CLI auth detector does. Without this,
// Cursor would leak the real `~/.config/cursor/auth.json` into a sandbox
// while Codex/Claude/Gemini/Copilot correctly look under
// `$JCODE_HOME/external/...`, so a fresh-install sandbox would show only
// Cursor as importable.
if std::env::var_os("JCODE_HOME").is_some() {
return crate::storage::user_home_path(".config/cursor/auth.json")
.context("No home directory found for Cursor auth.json");
}
let config_dir =
dirs::config_dir().ok_or_else(|| anyhow::anyhow!("No config directory found"))?;
Ok(config_dir.join("cursor").join("auth.json"))
}
}
/// Load direct Cursor tokens from env or Cursor's auth.json.
pub fn load_access_token_from_env_or_file() -> Result<CursorDirectTokens> {
if let Ok(access_token) = std::env::var("CURSOR_ACCESS_TOKEN") {
let access_token = access_token.trim().to_string();
if !access_token.is_empty() {
let refresh_token = std::env::var("CURSOR_REFRESH_TOKEN")
.ok()
.map(|raw| raw.trim().to_string())
.filter(|raw| !raw.is_empty());
return Ok(CursorDirectTokens {
access_token,
refresh_token,
source: "env",
});
}
}
let file_path = cursor_auth_file_path()?;
if file_path.exists()
&& crate::config::Config::external_auth_source_allowed_for_path(
CURSOR_AUTH_FILE_SOURCE_ID,
&file_path,
)
{
let safe_path = crate::storage::validate_external_auth_file(&file_path)?;
let raw = std::fs::read_to_string(&safe_path)
.with_context(|| format!("Failed to read {}", file_path.display()))?;
let parsed: CursorAuthFileData = serde_json::from_str(&raw)
.with_context(|| format!("Failed to parse {}", file_path.display()))?;
if let Some(access_token) = parsed
.access_token
.map(|token| token.trim().to_string())
.filter(|token| !token.is_empty())
{
return Ok(CursorDirectTokens {
access_token,
refresh_token: parsed
.refresh_token
.map(|token| token.trim().to_string())
.filter(|token| !token.is_empty()),
source: "cursor_auth_file",
});
}
}
anyhow::bail!(
"Cursor direct access token not found. Set CURSOR_ACCESS_TOKEN, log in with Cursor, or configure CURSOR_API_KEY."
)
}
/// Resolve the best available direct-auth credentials for Cursor's native API.
pub async fn resolve_direct_tokens(client: &Client) -> Result<CursorDirectTokens> {
if let Ok(tokens) = load_access_token_from_env_or_file() {
if !token_is_expiring_soon(&tokens.access_token) {
return Ok(tokens);
}
if let Some(refresh_token) = tokens.refresh_token.as_deref()
&& let Ok(refreshed) = refresh_direct_access_token(client, refresh_token).await
{
return Ok(CursorDirectTokens {
source: tokens.source,
..refreshed
});
}
}
if find_cursor_vscdb()
.ok()
.map(|path| {
crate::config::Config::external_auth_source_allowed_for_path(
CURSOR_VSCDB_SOURCE_ID,
&path,
)
})
.unwrap_or(false)
&& let Ok(access_token) = read_vscdb_token()
{
let refresh_token = read_vscdb_refresh_token().ok();
if !token_is_expiring_soon(&access_token) {
return Ok(CursorDirectTokens {
access_token,
refresh_token,
source: "cursor_vscdb",
});
}
if let Some(refresh_token) = refresh_token.as_deref()
&& let Ok(refreshed) = refresh_direct_access_token(client, refresh_token).await
{
return Ok(CursorDirectTokens {
source: "cursor_vscdb",
..refreshed
});
}
}
let api_key = load_api_key()?;
let exchanged = exchange_api_key_for_tokens(client, &api_key).await?;
Ok(CursorDirectTokens {
source: "cursor_api_key",
..exchanged
})
}
/// Force-refresh a resolved Cursor token set, preserving the original source label.
pub async fn refresh_resolved_tokens(
client: &Client,
tokens: &CursorDirectTokens,
) -> Result<CursorDirectTokens> {
let refresh_token = tokens
.refresh_token
.as_deref()
.context("Cursor token was rejected and no refresh token is available")?;
let mut refreshed = refresh_direct_access_token(client, refresh_token).await?;
refreshed.source = tokens.source;
if tokens.source == "cursor_auth_file" {
let _ = save_auth_file_tokens(&refreshed);
}
Ok(refreshed)
}
fn save_auth_file_tokens(tokens: &CursorDirectTokens) -> Result<()> {
let file_path = cursor_auth_file_path()?;
if !file_path.exists()
|| !crate::config::Config::external_auth_source_allowed_for_path(
CURSOR_AUTH_FILE_SOURCE_ID,
&file_path,
)
{
return Ok(());
}
let data = CursorAuthFileData {
access_token: Some(tokens.access_token.clone()),
refresh_token: tokens.refresh_token.clone(),
};
let serialized = serde_json::to_string_pretty(&data)?;
std::fs::write(&file_path, format!("{}\n", serialized))
.with_context(|| format!("Failed to update {}", file_path.display()))?;
Ok(())
}
pub fn error_indicates_not_logged_in(err: &anyhow::Error) -> bool {
let text = format!("{err:#}").to_ascii_lowercase();
text.contains("error_not_logged_in")
|| text.contains("unauthenticated")
|| text.contains("actionrequired\":\"login")
|| text.contains("action required: login")
}
/// Build the `x-client-key` header expected by Cursor's native API.
pub fn client_key_for_access_token(access_token: &str) -> String {
sha256_hex(access_token)
}
/// Build the `x-session-id` header expected by Cursor's native API.
pub fn session_id_for_access_token(access_token: &str) -> String {
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_DNS, access_token.as_bytes()).to_string()
}
/// Build the `x-cursor-checksum` header expected by Cursor's native API.
pub fn checksum_for_access_token(access_token: &str) -> String {
let machine_id =
read_vscdb_machine_id().unwrap_or_else(|_| sha256_hex(&format!("{access_token}machineId")));
format!("{}{}", timestamp_header_now(), machine_id)
}
async fn refresh_direct_access_token(
client: &Client,
refresh_token: &str,
) -> Result<CursorDirectTokens> {
let result: Result<CursorDirectTokens> = async {
let request = CursorRefreshRequest {
grant_type: "refresh_token",
client_id: CURSOR_OAUTH_CLIENT_ID,
refresh_token,
};
let response = client
.post(format!("{CURSOR_API_BASE}/oauth/token"))
.header(reqwest::header::CONTENT_TYPE, "application/json")
.json(&request)
.send()
.await
.context("Failed to refresh Cursor access token")?;
if !response.status().is_success() {
let status = response.status();
let body = crate::util::http_error_body(response, "HTTP error").await;
anyhow::bail!(
"Cursor access token refresh failed ({}): {}",
status,
body.trim()
);
}
let parsed: CursorRefreshResponse = response
.json()
.await
.context("Failed to decode Cursor token refresh response")?;
if parsed.should_logout || parsed.access_token.trim().is_empty() {
anyhow::bail!(
"Cursor refresh token was rejected; Cursor requested logout/login. Re-run Cursor login, then retry auth-test."
);
}
Ok(CursorDirectTokens {
access_token: parsed.access_token,
refresh_token: parsed
.refresh_token
.or_else(|| Some(refresh_token.to_string())),
source: "cursor_refresh",
})
}
.await;
match &result {
Ok(_) => {
let _ = crate::auth::refresh_state::record_success("cursor");
}
Err(err) => {
let _ = crate::auth::refresh_state::record_failure("cursor", err.to_string());
}
}
result
}
async fn exchange_api_key_for_tokens(client: &Client, api_key: &str) -> Result<CursorDirectTokens> {
let response = client
.post(format!("{CURSOR_API_BASE}/auth/exchange_user_api_key"))
.header(reqwest::header::CONTENT_TYPE, "application/json")
.bearer_auth(api_key)
.body("{}")
.send()
.await
.context("Failed to exchange Cursor API key for access token")?;
if !response.status().is_success() {
let status = response.status();
let body = crate::util::http_error_body(response, "HTTP error").await;
anyhow::bail!(
"Cursor API key exchange failed ({}): {}",
status,
body.trim()
);
}
let parsed: CursorApiKeyExchangeResponse = response
.json()
.await
.context("Failed to decode Cursor API key exchange response")?;
Ok(CursorDirectTokens {
access_token: parsed.access_token,
refresh_token: parsed.refresh_token,
source: "cursor_api_key",
})
}
fn token_is_expiring_soon(token: &str) -> bool {
let Some(exp) = token_expiry_epoch_secs(token) else {
return false;
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0);
exp <= now.saturating_add(60)
}
fn token_expiry_epoch_secs(token: &str) -> Option<u64> {
let payload = token.split('.').nth(1)?;
let decoded = URL_SAFE_NO_PAD.decode(payload).ok()?;
serde_json::from_slice::<JwtClaims>(&decoded).ok()?.exp
}
fn sha256_hex(input: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
hex::encode(hasher.finalize())
}
fn timestamp_header_now() -> String {
let epoch_kiloseconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis() / 1_000_000)
.unwrap_or(0);
let mut bytes = [
((epoch_kiloseconds >> 40) & 0xFF) as u8,
((epoch_kiloseconds >> 32) & 0xFF) as u8,
((epoch_kiloseconds >> 24) & 0xFF) as u8,
((epoch_kiloseconds >> 16) & 0xFF) as u8,
((epoch_kiloseconds >> 8) & 0xFF) as u8,
(epoch_kiloseconds & 0xFF) as u8,
];
let mut prev = 165u8;
for (index, byte) in bytes.iter_mut().enumerate() {
*byte = (*byte ^ prev).wrapping_add(index as u8);
prev = *byte;
}
URL_SAFE_NO_PAD.encode(bytes)
}
fn status_output_indicates_authenticated(success: bool, stdout: &[u8], stderr: &[u8]) -> bool {
let combined = format!(
"{}\n{}",
String::from_utf8_lossy(stdout),
String::from_utf8_lossy(stderr)
)
.to_ascii_lowercase();
if combined.contains("not authenticated")
|| combined.contains("login required")
|| combined.contains("not logged in")
|| combined.contains("unauthenticated")
{
return false;
}
if !success {
return false;
}
if combined.contains("authenticated")
|| combined.contains("account")
|| combined.contains("email")
|| combined.contains("endpoint")
{
return true;
}
success
}
#[cfg(test)]
#[path = "cursor_tests.rs"]
mod tests;