use std::path::{Path, PathBuf};
use serde_json::Value;
use crate::cache::home_dir;
use crate::error::{AppError, Result};
pub const AUTH_FILE: &str = ".cline/data/settings/providers.json";
pub const API_KEY_ENV: &str = "CLINE_API_KEY";
pub const SIGNED_OUT: &str =
"Cline is not signed in. Run `cline auth` to sign in, or set CLINE_API_KEY.";
pub const EXPIRED: &str = "Cline sign-in expired. Run `cline auth` to sign in again.";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Credential {
pub token: String,
pub account_id: Option<String>,
pub source: String,
pub expired: bool,
}
struct Candidate {
token: String,
account_id: Option<String>,
expires_ms: Option<i64>,
}
pub fn default_paths() -> Result<Vec<PathBuf>> {
Ok(vec![home_dir()?.join(AUTH_FILE)])
}
pub fn read_from(path: &Path) -> Option<Credential> {
let text = std::fs::read_to_string(path).ok()?;
let now = now_ms();
best_candidate(&text, now).map(|found| Credential {
token: found.token,
account_id: found.account_id,
source: path.display().to_string(),
expired: found.expires_ms.is_some_and(|expires| expires <= now),
})
}
fn best_candidate(text: &str, now_ms: i64) -> Option<Candidate> {
let value: Value = serde_json::from_str(text).ok()?;
let providers = value.get("providers")?.as_object()?;
providers
.values()
.filter_map(candidate)
.max_by_key(|found| {
let expires = found.expires_ms.unwrap_or(i64::MAX);
(expires > now_ms, expires)
})
}
fn candidate(provider: &Value) -> Option<Candidate> {
let auth = provider.get("settings")?.get("auth")?;
let token = auth
.get("accessToken")
.and_then(Value::as_str)
.map(str::trim)
.filter(|token| !token.is_empty())?
.to_string();
Some(Candidate {
token,
account_id: auth
.get("accountId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|id| !id.is_empty())
.map(str::to_string),
expires_ms: auth.get("expiresAt").and_then(Value::as_i64),
})
}
pub fn resolve(
configured: Option<&[PathBuf]>,
env_var_name: &str,
inline_key: Option<&str>,
) -> Result<Credential> {
let paths = match configured {
Some(paths) if !paths.is_empty() => paths.to_vec(),
_ => default_paths()?,
};
let env_key = crate::config::optional_api_key(env_var_name, inline_key);
resolve_from(env_key.as_deref(), &paths)
}
pub fn resolve_from(env_key: Option<&str>, paths: &[PathBuf]) -> Result<Credential> {
if let Some(key) = env_key.map(str::trim).filter(|key| !key.is_empty()) {
return Ok(Credential {
token: key.to_string(),
account_id: None,
source: API_KEY_ENV.to_string(),
expired: false,
});
}
for path in paths {
if let Some(credential) = read_from(path) {
return Ok(credential);
}
}
Err(AppError::Credentials(message_for(paths)))
}
fn message_for(_paths: &[PathBuf]) -> String {
SIGNED_OUT.to_string()
}
fn now_ms() -> i64 {
chrono::Utc::now().timestamp_millis()
}
#[cfg(test)]
mod tests {
use super::*;
const NOW_MS: i64 = 1_788_000_000_000;
fn store(entries: &str) -> String {
format!(r#"{{"version":1,"providers":{{{entries}}}}}"#)
}
fn entry(name: &str, token: &str, expires: i64) -> String {
format!(
r#""{name}":{{"settings":{{"auth":{{"accessToken":"{token}","accountId":"usr-1","expiresAt":{expires}}}}}}}"#
)
}
#[test]
fn picks_the_token_with_the_most_life_left() {
let text = store(&format!(
"{},{}",
entry("cline-pass", "stale", NOW_MS + 60_000),
entry("cline", "current", NOW_MS + 3_600_000)
));
let found = best_candidate(&text, NOW_MS).expect("a live credential");
assert_eq!(found.token, "current");
assert_eq!(found.account_id.as_deref(), Some("usr-1"));
}
#[test]
fn a_live_token_outranks_a_lapsed_one_even_when_the_lapsed_one_sorts_later() {
let text = store(&format!(
"{},{}",
entry("cline-pass", "lapsed", NOW_MS - 1),
entry("cline", "live", NOW_MS + 1_000)
));
let found = best_candidate(&text, NOW_MS).expect("a live credential");
assert_eq!(found.token, "live");
}
#[test]
fn a_lapsed_token_is_returned_flagged_not_withheld() {
let dir = tempfile::TempDir::new().expect("temp dir");
let path = dir.path().join("providers.json");
std::fs::write(&path, store(&entry("cline", "lapsed", 1))).expect("write store");
let credential = read_from(&path).expect("a lapsed credential is still a credential");
assert!(credential.expired);
assert_eq!(credential.account_id.as_deref(), Some("usr-1"));
}
#[test]
fn keeps_the_workos_prefix_the_api_requires() {
let text = store(&entry("cline", "workos:eyJhbGciOi", NOW_MS + 1000));
assert_eq!(
best_candidate(&text, NOW_MS).expect("credential").token,
"workos:eyJhbGciOi"
);
}
#[test]
fn an_entry_without_an_expiry_is_still_usable() {
let text = store(r#""cline":{"settings":{"auth":{"accessToken":"forever"}}}"#);
let found = best_candidate(&text, NOW_MS).expect("credential");
assert_eq!(found.token, "forever");
assert!(found.account_id.is_none());
}
#[test]
fn malformed_stores_are_not_credentials() {
for text in [
"not json".to_string(),
"{}".to_string(),
store(""),
store(r#""cline":{"settings":{}}"#),
store(r#""cline":{"settings":{"auth":{"accessToken":" "}}}"#),
] {
assert!(
best_candidate(&text, NOW_MS).is_none(),
"unexpectedly accepted {text}"
);
}
}
#[test]
fn env_key_wins_over_every_path() {
let credential = resolve_from(Some("sk-cline"), &[PathBuf::from("/nonexistent")])
.expect("env credential");
assert_eq!(credential.token, "sk-cline");
assert_eq!(credential.source, API_KEY_ENV);
}
#[test]
fn a_missing_store_reports_signed_out_not_expired() {
let error = resolve_from(None, &[PathBuf::from("/nonexistent/providers.json")])
.expect_err("no credential");
assert!(error.to_string().contains("not signed in"));
}
#[test]
fn an_expired_store_resolves_to_a_flagged_credential() {
let dir = tempfile::TempDir::new().expect("temp dir");
let path = dir.path().join("providers.json");
std::fs::write(&path, store(&entry("cline", "lapsed", 1))).expect("write store");
let credential = resolve_from(None, &[path]).expect("a lapsed credential resolves");
assert!(credential.expired);
}
}