use std::fs;
use std::path::PathBuf;
use anyhow::{Context, Result, anyhow};
use boxlite::BoxliteRestOptions;
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum AuthMethod {
#[default]
ApiKey,
Oidc,
}
mod secret_string_opt {
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(
secret: &Option<SecretString>,
ser: S,
) -> Result<S::Ok, S::Error> {
match secret {
Some(s) => ser.serialize_some(s.expose_secret()),
None => ser.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Option<SecretString>, D::Error> {
let opt = Option::<String>::deserialize(de)?;
Ok(opt.map(SecretString::from))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Profile {
pub url: String,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "secret_string_opt"
)]
pub api_key: Option<SecretString>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "secret_string_opt"
)]
pub access_token: Option<SecretString>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "secret_string_opt"
)]
pub refresh_token: Option<SecretString>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "secret_string_opt"
)]
pub id_token: Option<SecretString>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub oidc_issuer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub client_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub audience: Option<String>,
#[serde(default)]
pub auth_method: AuthMethod,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub path_prefix: Option<String>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct CredentialsFile {
#[serde(default)]
profiles: std::collections::BTreeMap<String, Profile>,
}
pub const DEFAULT_PROFILE: &str = "default";
pub fn path() -> Result<PathBuf> {
if let Some(env_home) = std::env::var_os("BOXLITE_HOME") {
let env_home = PathBuf::from(env_home);
if env_home.is_absolute() {
return Ok(env_home.join("credentials.toml"));
}
}
let home =
dirs::home_dir().ok_or_else(|| anyhow!("could not determine home directory for $HOME"))?;
Ok(home.join(".boxlite").join("credentials.toml"))
}
pub fn load_named(name: &str) -> Result<Option<Profile>> {
let file_path = path()?;
if !file_path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(&file_path)
.with_context(|| format!("reading credentials file {}", file_path.display()))?;
let parsed: CredentialsFile = toml::from_str(&raw)
.with_context(|| format!("parsing credentials file {}", file_path.display()))?;
Ok(parsed.profiles.get(name).cloned())
}
pub fn save_named(name: &str, profile: &Profile) -> Result<()> {
let file_path = path()?;
let parent = file_path
.parent()
.ok_or_else(|| anyhow!("credentials path has no parent: {}", file_path.display()))?;
create_secure_dir(parent)?;
let mut existing: CredentialsFile = if file_path.exists() {
let raw = fs::read_to_string(&file_path)
.with_context(|| format!("reading credentials file {}", file_path.display()))?;
toml::from_str(&raw)
.with_context(|| format!("parsing credentials file {}", file_path.display()))?
} else {
CredentialsFile::default()
};
existing.profiles.insert(name.to_string(), profile.clone());
let serialized =
toml::to_string_pretty(&existing).context("serializing credentials to TOML")?;
write_secure(&file_path, serialized.as_bytes())?;
Ok(())
}
pub fn delete_named(name: &str) -> Result<bool> {
let file_path = path()?;
if !file_path.exists() {
return Ok(false);
}
let raw = fs::read_to_string(&file_path)
.with_context(|| format!("reading credentials file {}", file_path.display()))?;
let mut parsed: CredentialsFile = toml::from_str(&raw)
.with_context(|| format!("parsing credentials file {}", file_path.display()))?;
if parsed.profiles.remove(name).is_none() {
return Ok(false);
}
if parsed.profiles.is_empty() {
fs::remove_file(&file_path)
.with_context(|| format!("removing empty credentials file {}", file_path.display()))?;
return Ok(true);
}
let serialized = toml::to_string_pretty(&parsed).context("serializing credentials to TOML")?;
write_secure(&file_path, serialized.as_bytes())?;
Ok(true)
}
pub async fn load_active(name: &str, http: &reqwest::Client) -> Result<Option<BoxliteRestOptions>> {
let Some(mut profile) = load_named(name)? else {
return Ok(None);
};
if crate::commands::auth::oidc::refresh::refresh_needed(&profile) {
crate::commands::auth::oidc::refresh::refresh(&mut profile, http).await?;
save_named(name, &profile)?;
}
Ok(Some(into_rest_options(profile)))
}
pub fn into_rest_options(profile: Profile) -> BoxliteRestOptions {
let mut options = BoxliteRestOptions::new(profile.url);
let bearer = match profile.auth_method {
AuthMethod::ApiKey => profile.api_key.as_ref(),
AuthMethod::Oidc => profile.access_token.as_ref(),
};
if let Some(secret) = bearer {
options = options.with_api_key(secret.expose_secret().to_string());
}
if let Some(path_prefix) = profile.path_prefix {
options = options.with_path_prefix(path_prefix);
}
options
}
#[cfg(unix)]
fn create_secure_dir(dir: &std::path::Path) -> Result<()> {
use std::os::unix::fs::DirBuilderExt;
if dir.exists() {
return Ok(());
}
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)
.with_context(|| format!("creating directory {}", dir.display()))
}
#[cfg(not(unix))]
fn create_secure_dir(dir: &std::path::Path) -> Result<()> {
fs::create_dir_all(dir).with_context(|| format!("creating directory {}", dir.display()))
}
#[cfg(unix)]
fn write_secure(path: &std::path::Path, data: &[u8]) -> Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.with_context(|| format!("opening credentials file {}", path.display()))?;
file.write_all(data)
.with_context(|| format!("writing credentials file {}", path.display()))?;
Ok(())
}
#[cfg(not(unix))]
fn write_secure(path: &std::path::Path, data: &[u8]) -> Result<()> {
fs::write(path, data).with_context(|| format!("writing credentials file {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::defaults::LOCAL_SERVE_URL;
use std::sync::Mutex;
use tempfile::TempDir;
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct BoxliteHomeGuard {
previous: Option<std::ffi::OsString>,
_lock: std::sync::MutexGuard<'static, ()>,
}
impl BoxliteHomeGuard {
fn set(dir: &std::path::Path) -> Self {
let lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let previous = std::env::var_os("BOXLITE_HOME");
unsafe { std::env::set_var("BOXLITE_HOME", dir) };
Self {
previous,
_lock: lock,
}
}
}
impl Drop for BoxliteHomeGuard {
fn drop(&mut self) {
unsafe {
match self.previous.take() {
Some(value) => std::env::set_var("BOXLITE_HOME", value),
None => std::env::remove_var("BOXLITE_HOME"),
}
}
}
}
fn sample_api_key_profile() -> Profile {
Profile {
url: LOCAL_SERVE_URL.to_string(),
api_key: Some(SecretString::from("opaque-key-123".to_string())),
..Profile::default()
}
}
fn sample_oidc_profile() -> Profile {
Profile {
url: "https://api.boxlite.ai/api".to_string(),
access_token: Some(SecretString::from("at-abc".to_string())),
refresh_token: Some(SecretString::from("rt-xyz".to_string())),
id_token: Some(SecretString::from("eyJhbGciOi...".to_string())),
expires_at: Some(
chrono::DateTime::<chrono::Utc>::from_timestamp(1_800_000_000, 0).unwrap(),
),
oidc_issuer: Some("https://dex.boxlite.ai/dex".to_string()),
client_id: Some("boxlite".to_string()),
audience: Some("boxlite-api".to_string()),
auth_method: AuthMethod::Oidc,
api_key: None,
path_prefix: Some("acme".to_string()),
}
}
fn assert_profiles_equal(actual: &Profile, expected: &Profile) {
assert_eq!(actual.url, expected.url, "url");
assert_eq!(
actual.api_key.as_ref().map(|s| s.expose_secret()),
expected.api_key.as_ref().map(|s| s.expose_secret()),
"api_key"
);
assert_eq!(
actual.access_token.as_ref().map(|s| s.expose_secret()),
expected.access_token.as_ref().map(|s| s.expose_secret()),
"access_token"
);
assert_eq!(
actual.refresh_token.as_ref().map(|s| s.expose_secret()),
expected.refresh_token.as_ref().map(|s| s.expose_secret()),
"refresh_token"
);
assert_eq!(
actual.id_token.as_ref().map(|s| s.expose_secret()),
expected.id_token.as_ref().map(|s| s.expose_secret()),
"id_token"
);
assert_eq!(actual.expires_at, expected.expires_at, "expires_at");
assert_eq!(actual.oidc_issuer, expected.oidc_issuer, "oidc_issuer");
assert_eq!(actual.client_id, expected.client_id, "client_id");
assert_eq!(actual.audience, expected.audience, "audience");
assert_eq!(actual.auth_method, expected.auth_method, "auth_method");
assert_eq!(actual.path_prefix, expected.path_prefix, "path_prefix");
}
#[test]
fn round_trip_default_profile() {
let tmp = TempDir::new().unwrap();
let _guard = BoxliteHomeGuard::set(tmp.path());
let profile = sample_api_key_profile();
save_named(DEFAULT_PROFILE, &profile).expect("save");
let loaded = load_named(DEFAULT_PROFILE)
.expect("load")
.expect("profile present");
assert_profiles_equal(&loaded, &profile);
}
#[test]
fn round_trip_oidc_profile() {
let tmp = TempDir::new().unwrap();
let _guard = BoxliteHomeGuard::set(tmp.path());
let profile = sample_oidc_profile();
save_named(DEFAULT_PROFILE, &profile).expect("save");
let loaded = load_named(DEFAULT_PROFILE)
.expect("load")
.expect("profile present");
assert_profiles_equal(&loaded, &profile);
}
#[test]
fn loads_legacy_api_key_only_file() {
let tmp = TempDir::new().unwrap();
let _guard = BoxliteHomeGuard::set(tmp.path());
let legacy = format!(
"[profiles.default]\nurl = \"{}\"\napi_key = \"legacy-key\"\n",
LOCAL_SERVE_URL
);
let file_path = path().expect("path");
std::fs::create_dir_all(file_path.parent().unwrap()).expect("mkdir");
std::fs::write(&file_path, legacy).expect("write legacy file");
let loaded = load_named(DEFAULT_PROFILE)
.expect("load")
.expect("profile present");
assert_eq!(loaded.url, LOCAL_SERVE_URL);
assert_eq!(
loaded.api_key.as_ref().map(|s| s.expose_secret()),
Some("legacy-key")
);
assert_eq!(loaded.auth_method, AuthMethod::ApiKey);
assert!(loaded.access_token.is_none());
assert!(loaded.refresh_token.is_none());
assert!(loaded.expires_at.is_none());
assert!(
loaded.path_prefix.is_none(),
"legacy file has no path_prefix → load as None; URL builder skips the segment"
);
}
#[tokio::test]
async fn into_rest_options_propagates_path_prefix() {
let profile = Profile {
url: "https://api.boxlite.ai/api".to_string(),
api_key: Some(SecretString::from("blk_live_x".to_string())),
path_prefix: Some("acme".to_string()),
..Profile::default()
};
let opts = into_rest_options(profile);
assert_eq!(opts.path_prefix.as_deref(), Some("acme"));
}
#[test]
fn debug_does_not_leak_secrets() {
let profile = sample_oidc_profile();
let rendered = format!("{:?}", profile);
assert!(
!rendered.contains("at-abc"),
"access_token leaked through Debug: {}",
rendered
);
assert!(
!rendered.contains("rt-xyz"),
"refresh_token leaked through Debug: {}",
rendered
);
assert!(
!rendered.contains("eyJhbGciOi"),
"id_token leaked through Debug: {}",
rendered
);
}
#[test]
fn serialized_form_keeps_legacy_keys_only_for_api_key_profile() {
let tmp = TempDir::new().unwrap();
let _guard = BoxliteHomeGuard::set(tmp.path());
save_named(DEFAULT_PROFILE, &sample_api_key_profile()).expect("save");
let raw = std::fs::read_to_string(path().expect("path")).expect("read");
assert!(raw.contains("api_key"), "api_key key missing: {}", raw);
assert!(
!raw.contains("access_token"),
"None OIDC fields should not serialize: {}",
raw
);
assert!(
!raw.contains("refresh_token"),
"None OIDC fields should not serialize: {}",
raw
);
}
#[test]
fn load_missing_returns_none() {
let tmp = TempDir::new().unwrap();
let _guard = BoxliteHomeGuard::set(tmp.path());
assert!(load_named(DEFAULT_PROFILE).expect("load ok").is_none());
}
#[test]
fn delete_idempotent() {
let tmp = TempDir::new().unwrap();
let _guard = BoxliteHomeGuard::set(tmp.path());
save_named(DEFAULT_PROFILE, &sample_api_key_profile()).expect("save");
assert!(
delete_named(DEFAULT_PROFILE).expect("first delete"),
"first call removed the profile"
);
assert!(
!delete_named(DEFAULT_PROFILE).expect("second delete"),
"second call reports nothing to delete"
);
}
#[cfg(unix)]
#[test]
fn save_sets_0600_perms() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let _guard = BoxliteHomeGuard::set(tmp.path());
save_named(DEFAULT_PROFILE, &sample_api_key_profile()).expect("save");
let file_path = path().expect("path");
let meta = std::fs::metadata(&file_path).expect("stat");
let mode = meta.permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "file mode should be 0600, got {:o}", mode);
}
#[tokio::test]
async fn into_rest_options_uses_api_key() {
let profile = sample_api_key_profile();
let options = into_rest_options(profile);
let cred = options.credential.expect("credential set");
let tok = cred.get_token().await.expect("get_token");
assert_eq!(tok.token, "opaque-key-123");
assert!(tok.expires_at.is_none(), "API keys must not carry expiry");
}
#[test]
fn into_rest_options_no_creds_is_none() {
let profile = Profile {
url: LOCAL_SERVE_URL.to_string(),
..Profile::default()
};
let options = into_rest_options(profile);
assert!(options.credential.is_none());
}
#[test]
fn save_named_preserves_other_profiles() {
let tmp = TempDir::new().unwrap();
let _guard = BoxliteHomeGuard::set(tmp.path());
let local = sample_api_key_profile();
let cloud = sample_oidc_profile();
save_named("local", &local).expect("save local");
save_named("cloud", &cloud).expect("save cloud");
let loaded_local = load_named("local")
.expect("load local")
.expect("local present");
let loaded_cloud = load_named("cloud")
.expect("load cloud")
.expect("cloud present");
assert_profiles_equal(&loaded_local, &local);
assert_profiles_equal(&loaded_cloud, &cloud);
}
#[test]
fn delete_named_only_removes_target_profile() {
let tmp = TempDir::new().unwrap();
let _guard = BoxliteHomeGuard::set(tmp.path());
save_named("local", &sample_api_key_profile()).expect("save local");
save_named("cloud", &sample_oidc_profile()).expect("save cloud");
assert!(delete_named("local").expect("delete local"));
assert!(load_named("local").expect("load local").is_none());
assert!(load_named("cloud").expect("load cloud").is_some());
assert!(delete_named("cloud").expect("delete cloud"));
assert!(!path().expect("path").exists(), "file should be removed");
assert!(!delete_named("cloud").expect("delete missing"));
}
#[test]
fn load_named_unknown_profile_returns_none() {
let tmp = TempDir::new().unwrap();
let _guard = BoxliteHomeGuard::set(tmp.path());
save_named("default", &sample_api_key_profile()).expect("save");
assert!(load_named("does-not-exist").expect("load ok").is_none());
}
#[tokio::test]
async fn into_rest_options_oidc_uses_access_token() {
let profile = Profile {
url: "https://api.boxlite.ai/api".to_string(),
api_key: Some(SecretString::from("should-not-be-used".to_string())),
access_token: Some(SecretString::from("oidc-at".to_string())),
auth_method: AuthMethod::Oidc,
..Profile::default()
};
let options = into_rest_options(profile);
let cred = options.credential.expect("credential set");
let tok = cred.get_token().await.expect("get_token");
assert_eq!(tok.token, "oidc-at");
}
}