use crate::{Error, Result};
use async_trait::async_trait;
use base64::Engine;
use serde::Deserialize;
use std::path::{Path, PathBuf};
use super::{AuthCredential, AuthResolver, ResolvedAuth};
pub struct CursorAuthResolver {
agent_auth_json: PathBuf,
ide_vscdb: PathBuf,
}
impl CursorAuthResolver {
pub fn new() -> Self {
let home = dirs::home_dir().unwrap_or_default();
Self {
agent_auth_json: home.join(".config/cursor/auth.json"),
ide_vscdb: home.join(".config/Cursor/User/globalStorage/state.vscdb"),
}
}
#[cfg(test)]
fn with_paths(agent_auth_json: PathBuf, ide_vscdb: PathBuf) -> Self {
Self {
agent_auth_json,
ide_vscdb,
}
}
fn access_token(&self) -> Result<(String, String)> {
if let Ok(content) = std::fs::read_to_string(&self.agent_auth_json) {
if let Ok(auth) = serde_json::from_str::<AgentAuthJson>(&content) {
if !auth.access_token.is_empty() {
return Ok((
auth.access_token,
format!("file:{}", self.agent_auth_json.display()),
));
}
}
}
if self.ide_vscdb.exists() {
if let Some(token) = read_ide_access_token(&self.ide_vscdb)? {
return Ok((token, format!("cursor-ide:{}", self.ide_vscdb.display())));
}
}
Err(Error::Auth(format!(
"no cursor credentials found (looked in {} and {})",
self.agent_auth_json.display(),
self.ide_vscdb.display()
)))
}
}
impl Default for CursorAuthResolver {
fn default() -> Self {
Self::new()
}
}
#[derive(Deserialize)]
struct AgentAuthJson {
#[serde(default, rename = "accessToken")]
access_token: String,
}
fn read_ide_access_token(db: &Path) -> Result<Option<String>> {
use rusqlite::{Connection, OpenFlags};
let conn = Connection::open_with_flags(db, OpenFlags::SQLITE_OPEN_READ_ONLY)
.or_else(|_| {
let uri = format!("file:{}?immutable=1", db.display());
Connection::open_with_flags(
&uri,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
)
})
.map_err(|e| Error::Auth(format!("open cursor ide db: {}", e)))?;
let value: rusqlite::Result<String> = conn.query_row(
"SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken'",
[],
|row| row.get(0),
);
match value {
Ok(raw) => {
let raw = raw.trim();
let token = serde_json::from_str::<String>(raw).unwrap_or_else(|_| raw.to_string());
if token.is_empty() {
Ok(None)
} else {
Ok(Some(token))
}
}
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(Error::Auth(format!("query cursor ide db: {}", e))),
}
}
fn user_id_from_jwt(token: &str) -> Result<String> {
let payload_b64 = token
.split('.')
.nth(1)
.ok_or_else(|| Error::Auth("cursor token is not a JWT".into()))?;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload_b64.trim_end_matches('='))
.map_err(|e| Error::Auth(format!("decode cursor JWT payload: {}", e)))?;
let claims: serde_json::Value =
serde_json::from_slice(&bytes).map_err(|e| Error::Auth(format!("parse cursor JWT: {}", e)))?;
let sub = claims
.get("sub")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::Auth("cursor JWT missing sub claim".into()))?;
Ok(sub.rsplit('|').next().unwrap_or(sub).to_string())
}
#[async_trait]
impl AuthResolver for CursorAuthResolver {
async fn resolve(&self) -> Result<ResolvedAuth> {
let (token, source) = self.access_token()?;
let user_id = user_id_from_jwt(&token)?;
let session_token = format!("{}%3A%3A{}", user_id, token);
Ok(ResolvedAuth {
credential: AuthCredential::Cookie(session_token),
source,
})
}
fn have_credentials(&self) -> bool {
self.agent_auth_json.exists() || self.ide_vscdb.exists()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_jwt(sub: &str) -> String {
let payload = serde_json::json!({ "sub": sub }).to_string();
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload.as_bytes());
format!("aGVhZGVy.{}.c2ln", b64)
}
fn tmp(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("quotas-cursor-test-{}", name))
}
#[test]
fn user_id_strips_auth_provider_prefix() {
let tok = fake_jwt("google-oauth2|user_01KBG9A9SXVHENSG6Z95CMHPYZ");
assert_eq!(
user_id_from_jwt(&tok).unwrap(),
"user_01KBG9A9SXVHENSG6Z95CMHPYZ"
);
}
#[test]
fn user_id_without_prefix_uses_whole_sub() {
let tok = fake_jwt("user_bare");
assert_eq!(user_id_from_jwt(&tok).unwrap(), "user_bare");
}
#[tokio::test]
async fn resolves_from_agent_cli_auth_json() {
let jwt = fake_jwt("google-oauth2|user_AGENT");
let auth_path = tmp("agent-auth.json");
std::fs::write(
&auth_path,
serde_json::json!({ "accessToken": jwt }).to_string(),
)
.unwrap();
let resolver = CursorAuthResolver::with_paths(auth_path.clone(), tmp("nonexistent.vscdb"));
assert!(resolver.have_credentials());
let resolved = resolver.resolve().await.unwrap();
let cookie = resolved.credential.unwrap_cookie().unwrap();
assert_eq!(cookie, format!("user_AGENT%3A%3A{}", jwt));
std::fs::remove_file(&auth_path).ok();
}
#[tokio::test]
async fn resolves_from_ide_vscdb_when_agent_missing() {
let jwt = fake_jwt("google-oauth2|user_IDE");
let db_path = tmp("ide-state.vscdb");
std::fs::remove_file(&db_path).ok();
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute("CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)", [])
.unwrap();
conn.execute(
"INSERT INTO ItemTable (key, value) VALUES ('cursorAuth/accessToken', ?1)",
[serde_json::Value::String(jwt.clone()).to_string()],
)
.unwrap();
}
let resolver =
CursorAuthResolver::with_paths(tmp("no-agent-auth.json"), db_path.clone());
assert!(resolver.have_credentials());
let resolved = resolver.resolve().await.unwrap();
let cookie = resolved.credential.unwrap_cookie().unwrap();
assert_eq!(cookie, format!("user_IDE%3A%3A{}", jwt));
std::fs::remove_file(&db_path).ok();
}
#[tokio::test]
async fn missing_everything_errors_and_reports_no_credentials() {
let resolver =
CursorAuthResolver::with_paths(tmp("absent-auth.json"), tmp("absent.vscdb"));
assert!(!resolver.have_credentials());
assert!(resolver.resolve().await.is_err());
}
}