use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use sylphx_sdk_core::{normalize_management_base_url, DEFAULT_MANAGEMENT_BASE_URL};
const FILE_NAME: &str = "credentials.json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CredentialsFile {
pub token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oauth: Option<OAuthSession>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OAuthSession {
pub issuer: String,
pub token_endpoint: String,
pub revocation_endpoint: String,
pub client_id: String,
pub refresh_token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub access_expires_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh_expires_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
}
pub fn config_dir() -> Result<PathBuf> {
if let Ok(dir) = std::env::var("SYLPHX_CONFIG_DIR") {
let p = PathBuf::from(dir);
if !p.as_os_str().is_empty() {
return Ok(p);
}
}
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
if !xdg.is_empty() {
return Ok(PathBuf::from(xdg).join("sylphx"));
}
}
let home = dirs::home_dir().ok_or_else(|| anyhow!("cannot resolve home directory"))?;
Ok(home.join(".config").join("sylphx"))
}
pub fn credentials_path() -> Result<PathBuf> {
Ok(config_dir()?.join(FILE_NAME))
}
pub fn token_kind(token: &str) -> &'static str {
let t = token.trim();
if t.starts_with("svc_") {
"service_token"
} else if t.starts_with("eyJ") {
"oauth_jwt"
} else if t.starts_with("slx_") {
"legacy_user_token"
} else if t.is_empty() {
"empty"
} else {
"unknown"
}
}
pub fn load() -> Result<Option<CredentialsFile>> {
let path = credentials_path()?;
if !path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(&path)
.with_context(|| format!("read credentials {}", path.display()))?;
let mut parsed: CredentialsFile = serde_json::from_str(&raw)
.with_context(|| format!("parse credentials {}", path.display()))?;
if parsed.token.trim().is_empty() {
return Ok(None);
}
if let Some(url) = parsed.api_url.take() {
let normalized = normalize_management_base_url(&url);
if normalized != url {
parsed.api_url = Some(normalized);
let _ = save(&parsed);
} else {
parsed.api_url = Some(normalized);
}
}
Ok(Some(parsed))
}
pub fn save(creds: &CredentialsFile) -> Result<PathBuf> {
let dir = config_dir()?;
fs::create_dir_all(&dir).with_context(|| format!("create config dir {}", dir.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))
.with_context(|| format!("secure config dir {}", dir.display()))?;
}
let path = dir.join(FILE_NAME);
let mut normalized = creds.clone();
if let Some(url) = normalized.api_url.as_deref() {
normalized.api_url = Some(normalize_management_base_url(url));
} else {
normalized.api_url = Some(DEFAULT_MANAGEMENT_BASE_URL.to_string());
}
let body = serde_json::to_string_pretty(&normalized)?;
write_private_atomic(&path, body.as_bytes())?;
Ok(path)
}
pub fn clear() -> Result<bool> {
let path = credentials_path()?;
if path.exists() {
fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
return Ok(true);
}
Ok(false)
}
pub fn resolve_token(cli_token: Option<&str>) -> Result<String> {
if let Some(t) = cli_token.map(str::trim).filter(|t| !t.is_empty()) {
return Ok(t.to_string());
}
if let Ok(t) = std::env::var("SYLPHX_TOKEN") {
let t = t.trim().to_string();
if !t.is_empty() {
return Ok(t);
}
}
if let Some(c) = load()? {
if token_kind(&c.token) == "oauth_jwt" && c.oauth.is_none() && jwt_is_expired(&c.token) {
return Err(anyhow!(
"stored OAuth access token is expired and no refresh session is saved.\n\
\n\
Fix (pick one):\n\
• sylphx login --token svc_… # service token (CI/agent)\n\
• export SYLPHX_TOKEN=svc_…\n\
• sylphx login # device flow (needs browser approval)\n\
\n\
Note: bare JWT credentials from older CLI builds cannot refresh.\n\
Then: sylphx doctor"
));
}
return Ok(c.token);
}
Err(anyhow!(
"no Management token.\n\
\n\
Fix (pick one):\n\
• sylphx login # browser device approval\n\
• sylphx login --token svc_… # CI / agent service token\n\
• export SYLPHX_TOKEN=svc_…\n\
\n\
Then: sylphx doctor"
))
}
fn jwt_is_expired(token: &str) -> bool {
let Some(payload_b64) = token.split('.').nth(1) else {
return false;
};
let mut s = payload_b64.replace('-', "+").replace('_', "/");
while s.len() % 4 != 0 {
s.push('=');
}
let Ok(bytes) = base64_decode_approx(&s) else {
return false;
};
let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
return false;
};
let Some(exp) = v.get("exp").and_then(|x| x.as_u64()) else {
return false;
};
match now_epoch_seconds() {
Ok(now) => now >= exp,
Err(_) => false,
}
}
fn base64_decode_approx(s: &str) -> Result<Vec<u8>> {
use base64::Engine;
base64::engine::general_purpose::STANDARD
.decode(s.as_bytes())
.or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(s.as_bytes()))
.context("decode jwt payload")
}
pub fn resolve_api_url(cli_url: Option<&str>, default_url: &str) -> String {
let raw = if let Some(u) = cli_url.map(str::trim).filter(|u| !u.is_empty()) {
u.to_string()
} else if let Ok(u) = std::env::var("SYLPHX_API_URL") {
let u = u.trim().to_string();
if u.is_empty() {
String::new()
} else {
u
}
} else if let Ok(Some(c)) = load() {
c.api_url.unwrap_or_default()
} else {
String::new()
};
if raw.is_empty() {
normalize_management_base_url(default_url)
} else {
normalize_management_base_url(&raw)
}
}
pub fn now_epoch_seconds() -> Result<u64> {
Ok(SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("system clock is before Unix epoch")?
.as_secs())
}
fn write_private_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| anyhow!("credentials path has no parent"))?;
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let tmp = parent.join(format!(".{FILE_NAME}.{}.{}.tmp", std::process::id(), nonce));
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let result = (|| -> Result<()> {
let mut file = options
.open(&tmp)
.with_context(|| format!("create {}", tmp.display()))?;
file.write_all(bytes)?;
file.sync_all()?;
fs::rename(&tmp, path)
.with_context(|| format!("replace credentials {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
.with_context(|| format!("secure credentials {}", path.display()))?;
}
if let Ok(dir) = fs::File::open(parent) {
let _ = dir.sync_all();
}
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(&tmp);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static LOCK: Mutex<()> = Mutex::new(());
#[test]
fn save_load_clear_roundtrip_normalizes_host_only_url() {
let _g = LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
std::env::set_var("SYLPHX_CONFIG_DIR", dir.path());
let creds = CredentialsFile {
token: "svc_test_abc".into(),
api_url: Some("https://api.sylphx.com".into()),
source: Some("token".into()),
oauth: None,
};
let path = save(&creds).unwrap();
assert!(path.exists());
let loaded = load().unwrap().unwrap();
assert_eq!(loaded.api_url.as_deref(), Some("https://api.sylphx.com/v1"));
assert!(clear().unwrap());
std::env::remove_var("SYLPHX_CONFIG_DIR");
}
#[test]
fn resolve_token_prefers_cli_over_file() {
let _g = LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
std::env::set_var("SYLPHX_CONFIG_DIR", dir.path());
std::env::remove_var("SYLPHX_TOKEN");
save(&CredentialsFile {
token: "svc_file".into(),
api_url: None,
source: Some("token".into()),
oauth: None,
})
.unwrap();
assert_eq!(resolve_token(Some("svc_cli")).unwrap(), "svc_cli");
assert_eq!(resolve_token(None).unwrap(), "svc_file");
let _ = clear();
std::env::remove_var("SYLPHX_CONFIG_DIR");
}
#[test]
fn token_kind_detects_shapes() {
assert_eq!(token_kind("svc_abc"), "service_token");
assert_eq!(token_kind("eyJhbGciOi"), "oauth_jwt");
}
}