use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ServerAuth {
pub resource: String,
pub issuer: String,
pub authorization_endpoint: String,
pub token_endpoint: String,
pub client_id: String,
pub access_token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>,
#[serde(default)]
pub expires_at: u64,
#[serde(default)]
pub scope: String,
}
impl std::fmt::Debug for ServerAuth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ServerAuth")
.field("resource", &self.resource)
.field("issuer", &self.issuer)
.field("authorization_endpoint", &self.authorization_endpoint)
.field("token_endpoint", &self.token_endpoint)
.field("client_id", &self.client_id)
.field("access_token", &"<redacted>")
.field(
"refresh_token",
match self.refresh_token {
Some(_) => &"<redacted>",
None => &"<none>",
},
)
.field("expires_at", &self.expires_at)
.field("scope", &self.scope)
.finish()
}
}
impl ServerAuth {
pub fn is_expired_at(&self, now: u64) -> bool {
self.expires_at <= now.saturating_add(60)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AuthStore {
#[serde(default)]
servers: HashMap<String, ServerAuth>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
keychain_servers: Vec<String>,
}
impl AuthStore {
pub fn load(path: &Path) -> anyhow::Result<Self> {
Self::load_with(path, None)
}
pub fn load_with(
path: &Path,
store: Option<&dyn leviath_core::CredentialStore>,
) -> anyhow::Result<Self> {
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("Failed to read MCP auth store: {}", e))?;
let mut this: Self = serde_json::from_str(&content)
.map_err(|e| anyhow::anyhow!("MCP auth store is corrupt: {}", e))?;
if let Some(store) = store {
for name in this.keychain_servers.clone() {
let Ok(Some(json)) = store.get(&leviath_core::mcp_account(&name)) else {
tracing::warn!(
"no stored credential for MCP server '{name}'; \
run `lev mcp login {name}` to re-authenticate"
);
continue;
};
match serde_json::from_str::<ServerAuth>(&json) {
Ok(auth) => {
this.servers.insert(name.clone(), auth);
}
Err(e) => {
tracing::warn!(
"stored credential for MCP server '{name}' is unreadable ({e}); \
run `lev mcp login {name}` to re-authenticate"
);
}
}
}
}
Ok(this)
}
pub fn save(&self, path: &Path) -> anyhow::Result<()> {
self.save_with(path, None)
}
pub fn save_with(
&self,
path: &Path,
store: Option<&dyn leviath_core::CredentialStore>,
) -> anyhow::Result<()> {
create_parent_dir(path)?;
let to_write = match store {
None => self.clone(),
Some(store) => {
let mut names: Vec<String> = Vec::new();
for (name, auth) in &self.servers {
let json =
serde_json::to_string(auth).expect("ServerAuth is always serializable");
store
.set(&leviath_core::mcp_account(name), &json)
.map_err(|e| {
anyhow::anyhow!("failed to store credentials for '{name}': {e}")
})?;
names.push(name.clone());
}
names.sort();
Self {
servers: HashMap::new(),
keychain_servers: names,
}
}
};
let content =
serde_json::to_string_pretty(&to_write).expect("AuthStore is always serializable");
leviath_sys::write_private(path, content.as_bytes())
.map_err(|e| anyhow::anyhow!("Failed to write MCP auth store: {}", e))?;
Ok(())
}
pub fn default_path() -> Option<PathBuf> {
leviath_home().map(|home| home.join("mcp-auth.json"))
}
pub fn get(&self, server: &str) -> Option<&ServerAuth> {
self.servers.get(server)
}
pub fn set(&mut self, server: &str, auth: ServerAuth) {
self.servers.insert(server.to_string(), auth);
}
pub fn remove(&mut self, server: &str) -> bool {
let indexed = self.keychain_servers.iter().any(|s| s == server);
self.keychain_servers.retain(|s| s != server);
self.servers.remove(server).is_some() || indexed
}
pub fn keychain_server_names(&self) -> &[String] {
&self.keychain_servers
}
pub fn server_names(&self) -> Vec<&str> {
self.servers.keys().map(String::as_str).collect()
}
}
fn create_parent_dir(path: &Path) -> anyhow::Result<()> {
match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => std::fs::create_dir_all(parent)
.map_err(|e| anyhow::anyhow!("Failed to create MCP auth store directory: {}", e)),
_ => Ok(()),
}
}
fn leviath_home() -> Option<PathBuf> {
leviath_core::data_dir()
}
#[cfg(test)]
mod tests {
#[test]
fn keychain_mode_keeps_the_tokens_out_of_the_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp-auth.json");
let credentials = leviath_core::MemoryStore::new();
let mut store = AuthStore::default();
store.set(
"github",
ServerAuth {
resource: "https://example.test/mcp".to_string(),
issuer: "https://example.test".to_string(),
authorization_endpoint: "https://example.test/authorize".to_string(),
token_endpoint: "https://example.test/token".to_string(),
client_id: "cid".to_string(),
access_token: "at-SECRET".to_string(),
refresh_token: Some("rt-SECRET".to_string()),
expires_at: 9_999_999_999,
scope: String::new(),
},
);
store.save_with(&path, Some(&credentials)).unwrap();
let on_disk = std::fs::read_to_string(&path).unwrap();
assert!(!on_disk.contains("at-SECRET"), "{on_disk}");
assert!(!on_disk.contains("rt-SECRET"), "{on_disk}");
assert!(
on_disk.contains("github"),
"the name is the index: {on_disk}"
);
let loaded = AuthStore::load_with(&path, Some(&credentials)).unwrap();
let auth = loaded.get("github").expect("the grant is restored");
assert_eq!(auth.access_token, "at-SECRET");
assert_eq!(auth.refresh_token.as_deref(), Some("rt-SECRET"));
assert_eq!(loaded.keychain_server_names(), ["github"]);
let blind = AuthStore::load_with(&path, None).unwrap();
assert!(blind.get("github").is_none());
}
#[test]
fn an_unreadable_stored_grant_is_skipped() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp-auth.json");
std::fs::write(&path, r#"{"servers":{},"keychain_servers":["github"]}"#).unwrap();
let credentials = leviath_core::MemoryStore::new();
leviath_core::CredentialStore::set(
&credentials,
&leviath_core::mcp_account("github"),
"not json at all",
)
.unwrap();
let loaded = AuthStore::load_with(&path, Some(&credentials)).unwrap();
assert!(loaded.get("github").is_none(), "skipped, not fabricated");
}
#[test]
fn an_indexed_server_with_no_stored_credential_is_skipped() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp-auth.json");
std::fs::write(
&path,
r#"{"servers":{},"keychain_servers":["github","linear"]}"#,
)
.unwrap();
let credentials = leviath_core::MemoryStore::new();
let auth = ServerAuth {
resource: "https://example.test/mcp".to_string(),
issuer: "https://example.test".to_string(),
authorization_endpoint: "https://example.test/authorize".to_string(),
token_endpoint: "https://example.test/token".to_string(),
client_id: "cid".to_string(),
access_token: "at".to_string(),
refresh_token: None,
expires_at: 0,
scope: String::new(),
};
leviath_core::CredentialStore::set(
&credentials,
&leviath_core::mcp_account("linear"),
&serde_json::to_string(&auth).unwrap(),
)
.unwrap();
let loaded = AuthStore::load_with(&path, Some(&credentials)).unwrap();
assert!(loaded.get("github").is_none(), "absent, not fabricated");
assert!(loaded.get("linear").is_some(), "and the other still loads");
}
#[test]
fn a_store_that_refuses_the_write_fails_the_save() {
struct Refuses;
impl leviath_core::CredentialStore for Refuses {
fn get(&self, _: &str) -> Result<Option<String>, String> {
Ok(None)
}
fn set(&self, _: &str, _: &str) -> Result<(), String> {
Err("read-only".to_string())
}
fn delete(&self, _: &str) -> Result<bool, String> {
Err("read-only".to_string())
}
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp-auth.json");
let mut store = AuthStore::default();
store.set(
"github",
ServerAuth {
resource: "https://example.test/mcp".to_string(),
issuer: "https://example.test".to_string(),
authorization_endpoint: "https://example.test/authorize".to_string(),
token_endpoint: "https://example.test/token".to_string(),
client_id: "c".to_string(),
access_token: "at".to_string(),
refresh_token: None,
expires_at: 0,
scope: String::new(),
},
);
let err = store
.save_with(&path, Some(&Refuses))
.expect_err("a refused write is not a save");
assert!(err.to_string().contains("failed to store"), "{err}");
use leviath_core::CredentialStore;
assert_eq!(CredentialStore::get(&Refuses, "x").unwrap(), None);
assert!(CredentialStore::delete(&Refuses, "x").is_err());
}
#[test]
fn remove_clears_the_keychain_index() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp-auth.json");
std::fs::write(&path, r#"{"servers":{},"keychain_servers":["github"]}"#).unwrap();
let mut store = AuthStore::load_with(&path, None).unwrap();
assert_eq!(store.keychain_server_names(), ["github"]);
assert!(
store.remove("github"),
"an indexed server counts as present"
);
assert!(store.keychain_server_names().is_empty());
assert!(!store.remove("github"), "and is gone the second time");
}
use super::*;
fn sample() -> ServerAuth {
ServerAuth {
resource: "https://mcp.example.com/mcp".to_string(),
issuer: "https://auth.example.com".to_string(),
authorization_endpoint: "https://auth.example.com/authorize".to_string(),
token_endpoint: "https://auth.example.com/token".to_string(),
client_id: "client-123".to_string(),
access_token: "at".to_string(),
refresh_token: Some("rt".to_string()),
expires_at: 10_000,
scope: "openid".to_string(),
}
}
#[test]
fn debug_output_never_contains_the_tokens() {
let mut auth = sample();
auth.access_token = "ACCESS-TOKEN-SECRET".to_string();
auth.refresh_token = Some("REFRESH-TOKEN-SECRET".to_string());
let rendered = format!("{auth:?}");
assert!(
!rendered.contains("ACCESS-TOKEN-SECRET"),
"access token leaked: {rendered}"
);
assert!(
!rendered.contains("REFRESH-TOKEN-SECRET"),
"refresh token leaked: {rendered}"
);
assert!(rendered.contains("<redacted>"), "{rendered}");
assert!(rendered.contains("auth.example.com"), "{rendered}");
assert!(rendered.contains("client-123"), "{rendered}");
}
#[test]
fn debug_distinguishes_an_absent_refresh_token() {
let mut auth = sample();
auth.refresh_token = None;
assert!(format!("{auth:?}").contains("<none>"));
}
#[test]
fn a_token_well_in_the_future_is_not_expired() {
assert!(!sample().is_expired_at(5_000));
}
#[test]
fn a_token_inside_the_refresh_margin_is_expired() {
assert!(sample().is_expired_at(9_950));
}
#[test]
fn a_lapsed_token_is_expired() {
assert!(sample().is_expired_at(20_000));
}
#[test]
fn an_unknown_expiry_reads_as_expired() {
let mut auth = sample();
auth.expires_at = 0;
assert!(auth.is_expired_at(0));
}
#[test]
fn set_get_and_remove() {
let mut store = AuthStore::default();
assert!(store.get("s").is_none());
store.set("s", sample());
assert_eq!(store.get("s"), Some(&sample()));
assert_eq!(store.server_names(), vec!["s"]);
assert!(store.remove("s"));
assert!(!store.remove("s"), "second remove finds nothing");
assert!(store.get("s").is_none());
}
#[test]
fn loading_a_missing_file_is_an_empty_store() {
let dir = tempfile::tempdir().unwrap();
let store = AuthStore::load(&dir.path().join("nope.json")).unwrap();
assert!(store.server_names().is_empty());
}
#[test]
fn save_then_load_round_trips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sub").join("mcp-auth.json");
let mut store = AuthStore::default();
store.set("s", sample());
store.save(&path).unwrap();
let loaded = AuthStore::load(&path).unwrap();
assert_eq!(loaded.get("s"), Some(&sample()));
}
#[test]
fn a_corrupt_file_is_an_error_not_a_silent_reset() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp-auth.json");
std::fs::write(&path, "{ not json").unwrap();
let err = AuthStore::load(&path).expect_err("corrupt store must error");
assert!(err.to_string().contains("corrupt"), "got: {err}");
}
#[test]
fn a_saved_refresh_token_survives_but_none_is_omitted() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp-auth.json");
let mut auth = sample();
auth.refresh_token = None;
let mut store = AuthStore::default();
store.set("s", auth);
store.save(&path).unwrap();
assert!(
!std::fs::read_to_string(&path)
.unwrap()
.contains("refresh_token")
);
assert_eq!(
AuthStore::load(&path)
.unwrap()
.get("s")
.unwrap()
.refresh_token,
None
);
}
#[test]
fn create_parent_dir_makes_a_missing_directory() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("a").join("b").join("store.json");
create_parent_dir(&path).unwrap();
assert!(path.parent().unwrap().is_dir());
}
#[test]
fn create_parent_dir_is_a_no_op_without_a_real_parent() {
create_parent_dir(Path::new("store.json")).unwrap();
create_parent_dir(Path::new("/")).unwrap();
}
#[test]
fn loading_an_unreadable_path_errors() {
let dir = tempfile::tempdir().unwrap();
let as_dir = dir.path().join("store-is-a-dir");
std::fs::create_dir(&as_dir).unwrap();
let err = AuthStore::load(&as_dir).expect_err("reading a dir must fail");
assert!(
err.to_string().contains("read MCP auth store"),
"got: {err}"
);
}
#[test]
fn saving_onto_a_directory_errors() {
let dir = tempfile::tempdir().unwrap();
let as_dir = dir.path().join("target-is-a-dir");
std::fs::create_dir(&as_dir).unwrap();
let err = AuthStore::default()
.save(&as_dir)
.expect_err("writing onto a directory must fail");
assert!(
err.to_string().contains("write MCP auth store"),
"got: {err}"
);
}
#[test]
fn saving_under_a_non_directory_parent_errors() {
let dir = tempfile::tempdir().unwrap();
let blocker = dir.path().join("blocker");
std::fs::write(&blocker, b"i am a file").unwrap();
let path = blocker.join("mcp-auth.json");
let err = AuthStore::default()
.save(&path)
.expect_err("cannot create a dir under a file");
assert!(err.to_string().contains("directory"), "got: {err}");
}
#[test]
fn saved_file_is_owner_only() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp-auth.json");
AuthStore::default().save(&path).unwrap();
let mode = leviath_sys::ensure_file_private(&path).unwrap();
assert!(
mode.is_none(),
"already private after save, got remediation {mode:?}"
);
}
#[cfg(unix)]
#[test]
fn saving_never_leaves_the_token_store_group_or_world_readable() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp-auth.json");
let mut store = AuthStore::default();
store.set("s", sample());
store.save(&path).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
store.save(&path).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
#[test]
fn default_path_honors_leviath_home() {
temp_env::with_var("LEVIATH_HOME", Some("/tmp/lev-home-test"), || {
assert_eq!(
AuthStore::default_path(),
Some(PathBuf::from("/tmp/lev-home-test/.leviath/mcp-auth.json"))
);
});
}
#[test]
fn default_path_falls_back_to_dot_leviath() {
temp_env::with_var_unset("LEVIATH_HOME", || {
let path = AuthStore::default_path().expect("home should resolve");
assert!(path.ends_with(".leviath/mcp-auth.json"), "got: {path:?}");
});
}
}