use std::path::PathBuf;
use std::sync::Arc;
use crate::transport::HttpConfig;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use wyrd_spec::auth::{SecretBearer, TokenResponse};
use wyrd_spec::ids::TenantSlug;
use crate::auth::{AuthError, TokenExchange};
use crate::credentials_file::CredentialsFile;
use crate::error::WyrdClientError;
use crate::transport::credential::{AccessTokenSource, MintedAccessToken};
const RENEW_MARGIN: chrono::Duration = chrono::Duration::seconds(60);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SavedLogin {
pub origin: String,
pub tenant_key: TenantSlug,
pub access_token: SecretBearer,
pub access_expires_at: DateTime<Utc>,
pub refresh_token: SecretBearer,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SavedLoginSummary {
pub origin: String,
pub tenant_key: TenantSlug,
pub access_expires_at: DateTime<Utc>,
}
impl SavedLogin {
pub fn from_token(
origin: String,
tenant_key: TenantSlug,
token: TokenResponse,
) -> Result<Self, WyrdClientError> {
let refresh_token = token.refresh_token.ok_or_else(|| {
saved_login(
"refresh_refused",
"the server issued the login without a refresh token",
)
})?;
Ok(Self {
origin,
tenant_key,
access_token: token.access_token,
access_expires_at: crate::auth::expires_at(token.expires_in),
refresh_token,
})
}
#[must_use]
pub fn summary(&self) -> SavedLoginSummary {
SavedLoginSummary {
origin: self.origin.clone(),
tenant_key: self.tenant_key.clone(),
access_expires_at: self.access_expires_at,
}
}
fn is_for(&self, origin: &str, tenant_key: &TenantSlug) -> bool {
self.origin == origin && self.tenant_key == *tenant_key
}
}
pub fn canonical_origin(server_url: &str) -> Result<String, WyrdClientError> {
HttpConfig {
base_url: server_url.to_owned(),
..HttpConfig::default()
}
.validate()
.map(|origin| origin.as_str().to_owned())
}
const LOGINS_KEY: &str = "logins";
#[derive(Deserialize)]
struct StoredLogins {
#[serde(default)]
logins: Vec<SavedLogin>,
}
#[derive(Serialize)]
struct StoredLoginsRef<'a> {
logins: &'a [SavedLogin],
}
#[derive(Debug, Clone)]
pub struct SavedLogins {
file: CredentialsFile,
}
impl SavedLogins {
#[must_use]
pub fn locate() -> Option<Self> {
CredentialsFile::locate().map(|file| Self { file })
}
#[must_use]
pub fn at(dir: PathBuf) -> Self {
Self {
file: CredentialsFile::at(dir),
}
}
pub fn save(&self, login: SavedLogin) -> Result<(), WyrdClientError> {
let _lock = self.file.lock()?;
let mut logins = self.read()?;
logins.retain(|saved| !saved.is_for(&login.origin, &login.tenant_key));
logins.push(login);
self.write(&logins)
}
pub fn list(&self) -> Result<Vec<SavedLogin>, WyrdClientError> {
self.read()
}
pub fn select(
&self,
origin: &str,
tenant: Option<&str>,
) -> Result<Option<SavedLogin>, WyrdClientError> {
let mut candidates: Vec<SavedLogin> = self
.list()?
.into_iter()
.filter(|login| login.origin == origin)
.collect();
if candidates.is_empty() {
return Ok(None);
}
if let Some(selector) = tenant {
candidates.retain(|login| login.tenant_key.as_str() == selector);
if candidates.is_empty() {
return Err(saved_login(
"tenant_mismatch",
format!("no saved login for {origin} matches tenant {selector}"),
));
}
}
Ok(candidates.pop())
}
#[must_use]
#[cfg(feature = "internal")]
pub fn source(&self, login: &SavedLogin, exchange: TokenExchange) -> Arc<SavedLoginSource> {
self.renewing_source(login, exchange)
}
#[must_use]
pub(crate) fn renewing_source(
&self,
login: &SavedLogin,
exchange: TokenExchange,
) -> Arc<SavedLoginSource> {
Arc::new(SavedLoginSource {
store: self.clone(),
origin: login.origin.clone(),
tenant_key: login.tenant_key.clone(),
identity: format!("saved-login:{}:{}", login.origin, login.tenant_key),
exchange,
})
}
pub fn remove(
&self,
origin: &str,
tenant_key: &TenantSlug,
) -> Result<Option<SavedLogin>, WyrdClientError> {
let _lock = self.file.lock()?;
let mut logins = self.read()?;
let Some(index) = logins
.iter()
.position(|login| login.is_for(origin, tenant_key))
else {
return Ok(None);
};
let removed = logins.remove(index);
self.write(&logins)?;
Ok(Some(removed))
}
fn renew(
&self,
origin: &str,
tenant_key: &TenantSlug,
exchange: &TokenExchange,
) -> Result<MintedAccessToken, WyrdClientError> {
let _lock = self.file.lock()?;
let mut logins = self.read()?;
let login = logins
.iter_mut()
.find(|login| login.is_for(origin, tenant_key))
.ok_or_else(|| {
saved_login(
"logged_out",
"the saved login was removed; run `wyrd auth login`",
)
})?;
if login.access_expires_at - RENEW_MARGIN > Utc::now() {
return Ok(MintedAccessToken {
access_token: login.access_token.clone(),
expires_at: login.access_expires_at,
});
}
let handle = tokio::runtime::Handle::try_current().map_err(|_| {
saved_login(
"refresh_refused",
"saved login renewal needs the client's async runtime",
)
})?;
let rotated = handle
.block_on(exchange.refresh(&login.refresh_token))
.map_err(|error| match error {
AuthError::Server(wyrd) => saved_login(
"refresh_refused",
format!(
"the server refused to renew the saved login ({}); run `wyrd auth login`",
wyrd.code()
),
),
AuthError::Client(client) => client,
})?;
login.refresh_token = rotated.refresh_token.ok_or_else(|| {
saved_login(
"refresh_refused",
"the server renewed the saved login without a refresh token",
)
})?;
login.access_token = rotated.access_token.clone();
let expires_at = crate::auth::expires_at(rotated.expires_in);
login.access_expires_at = expires_at;
self.write(&logins)?;
Ok(MintedAccessToken {
access_token: rotated.access_token,
expires_at,
})
}
fn read(&self) -> Result<Vec<SavedLogin>, WyrdClientError> {
let Some(text) = self.file.read_text()? else {
return Ok(Vec::new());
};
let stored: StoredLogins = toml::from_str(&text).map_err(|_| {
saved_login(
"corrupt",
format!(
"{} has saved logins that do not decode",
self.file.path().display()
),
)
})?;
Ok(stored.logins)
}
fn write(&self, logins: &[SavedLogin]) -> Result<(), WyrdClientError> {
let mut document = self.file.document()?;
if logins.is_empty() {
document.remove(LOGINS_KEY);
} else {
let mut rendered = toml::to_string(&StoredLoginsRef { logins })
.map_err(|_| saved_login("corrupt", "the saved logins do not encode"))?
.parse::<toml_edit::DocumentMut>()
.map_err(|_| saved_login("corrupt", "the saved logins do not encode"))?;
if let Some(item) = rendered.remove(LOGINS_KEY) {
document.insert(LOGINS_KEY, item);
}
}
self.file.replace(&document)
}
}
pub struct SavedLoginSource {
store: SavedLogins,
origin: String,
tenant_key: TenantSlug,
identity: String,
exchange: TokenExchange,
}
impl std::fmt::Debug for SavedLoginSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SavedLoginSource")
.field("identity", &self.identity)
.finish_non_exhaustive()
}
}
impl AccessTokenSource for SavedLoginSource {
fn identity(&self) -> &str {
&self.identity
}
fn mint(&self) -> Result<MintedAccessToken, WyrdClientError> {
self.store
.renew(&self.origin, &self.tenant_key, &self.exchange)
}
}
pub(crate) fn saved_login(reason: &'static str, message: impl Into<String>) -> WyrdClientError {
WyrdClientError::SavedLogin {
reason,
message: message.into(),
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use chrono::Utc;
use wyrd_spec::auth::SecretBearer;
use wyrd_spec::ids::TenantSlug;
use super::{SavedLogin, SavedLogins, canonical_origin};
fn login(origin: &str, key: &str) -> SavedLogin {
SavedLogin {
origin: origin.to_owned(),
tenant_key: TenantSlug::from_str(key).expect("slug"),
access_token: SecretBearer::new("access-sentinel".to_owned()),
access_expires_at: Utc::now(),
refresh_token: SecretBearer::new("refresh-sentinel".to_owned()),
}
}
#[test]
fn canonical_origin_is_the_url_origin() {
for spelling in [
"https://wyrd.example.com",
"HTTPS://Wyrd.Example.com:443/?x=1#f",
"https://wyrd.example.com/api/v1/",
"https://wyrd.example.com:443/",
] {
assert_eq!(
canonical_origin(spelling).expect("parses"),
"https://wyrd.example.com",
"{spelling}"
);
}
assert_eq!(
canonical_origin("http://LOCALHOST:8080/x").expect("parses"),
"http://localhost:8080"
);
for refused in [
"not a url",
"https://alice:hunter2@wyrd.example.com",
"https://alice@wyrd.example.com",
] {
let error = canonical_origin(refused).expect_err("refused");
assert!(!error.to_string().contains("hunter2"), "{error}");
}
}
#[test]
fn selection_picks_the_named_or_newest_login() {
let dir = crate::credentials_file::private_tempdir();
let store = SavedLogins::at(dir.path().to_path_buf());
let origin = "https://wyrd.example.com";
let acme = login(origin, "acme");
let globex = login(origin, "globex");
store.save(acme.clone()).expect("saves");
store.save(globex.clone()).expect("saves");
assert!(
store
.select("https://other.example.com", None)
.expect("selects")
.is_none()
);
let newest = store.select(origin, None).expect("selects").expect("one");
assert_eq!(newest.tenant_key, globex.tenant_key);
let named = store
.select(origin, Some("acme"))
.expect("selects")
.expect("one");
assert_eq!(named.tenant_key, acme.tenant_key);
let respelled = canonical_origin("HTTPS://WYRD.example.com:443/api?x#f").expect("parses");
let named = store
.select(&respelled, Some("acme"))
.expect("selects")
.expect("another spelling selects the same login");
assert_eq!(named.tenant_key, acme.tenant_key);
let mismatch = store.select(origin, Some("initech")).expect_err("mismatch");
assert_eq!(mismatch.code(), "WYRD_CLIENT_401_SAVED_LOGIN_UNUSABLE");
assert!(mismatch.to_string().contains("tenant_mismatch"));
store.save(acme.clone()).expect("logs in again");
assert_eq!(store.list().expect("lists").len(), 2);
let newest = store.select(origin, None).expect("selects").expect("one");
assert_eq!(newest.tenant_key, acme.tenant_key);
let printed = format!("{:?} {:?}", acme.summary(), acme);
assert!(!printed.contains("sentinel"), "{printed}");
}
#[test]
fn logins_live_in_credentials_toml_beside_user_content() {
let dir = crate::credentials_file::private_tempdir();
let file = dir.path().join("credentials.toml");
let user = "# my machine key\n[default]\napi_key = \"wyrd_sk_user\" # keep\n";
std::fs::write(&file, user).expect("writes");
#[cfg(unix)]
std::fs::set_permissions(
&file,
<std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
)
.expect("chmod");
let store = SavedLogins::at(dir.path().to_path_buf());
let origin = "https://wyrd.example.com";
let acme = login(origin, "acme");
let globex = login(origin, "globex");
store.save(acme.clone()).expect("saves");
store.save(globex.clone()).expect("saves");
let text = std::fs::read_to_string(&file).expect("reads");
assert!(text.starts_with(user), "{text}");
assert_eq!(
store.list().expect("lists"),
vec![acme.clone(), globex.clone()]
);
let names: Vec<_> = std::fs::read_dir(dir.path())
.expect("lists dir")
.map(|entry| entry.expect("entry").file_name())
.collect();
assert_eq!(names, ["credentials.toml"]);
for record in [&acme, &globex] {
let removed = store
.remove(&record.origin, &record.tenant_key)
.expect("removes");
assert_eq!(removed.as_ref(), Some(record));
}
assert!(
store
.remove(origin, &acme.tenant_key)
.expect("removes nothing")
.is_none()
);
assert_eq!(std::fs::read_to_string(&file).expect("reads"), user);
}
#[cfg(unix)]
#[test]
fn unsafe_and_corrupt_stores_fail_closed() {
let dir = crate::credentials_file::private_tempdir();
let store = SavedLogins::at(dir.path().to_path_buf());
let record = login("https://wyrd.example.com", "acme");
store.save(record.clone()).expect("saves");
let file = dir.path().join("credentials.toml");
let chmod = |path: &std::path::Path, mode: u32| {
std::fs::set_permissions(
path,
<std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(mode),
)
.expect("chmod");
};
chmod(&file, 0o644);
let error = store.select(&record.origin, None).expect_err("unsafe");
assert!(error.to_string().contains("unsafe_store"), "{error}");
let error = store.save(record.clone()).expect_err("unsafe write");
assert!(error.to_string().contains("unsafe_store"), "{error}");
chmod(&file, 0o600);
std::fs::write(&file, b"[[logins]]\norigin = 1\n").expect("corrupts");
let error = store.select(&record.origin, None).expect_err("corrupt");
assert!(error.to_string().contains("corrupt"), "{error}");
std::fs::remove_file(&file).expect("removes");
let target = dir.path().join("elsewhere.toml");
std::fs::write(&target, b"").expect("writes");
chmod(&target, 0o600);
std::os::unix::fs::symlink(&target, &file).expect("links");
let error = store.select(&record.origin, None).expect_err("symlink");
assert!(error.to_string().contains("unsafe_store"), "{error}");
std::fs::remove_file(&file).expect("unlinks");
for writable in [0o777, 0o775, 0o757] {
chmod(dir.path(), writable);
let error = store
.select(&record.origin, None)
.expect_err("a directory others can write");
assert!(error.to_string().contains("unsafe_store"), "{error}");
let error = store.save(record.clone()).expect_err("no write either");
assert!(error.to_string().contains("unsafe_store"), "{error}");
}
for private in [0o755, 0o700] {
chmod(dir.path(), private);
store.save(record.clone()).expect("saves");
assert_eq!(
store.select(&record.origin, None).expect("selects"),
Some(record.clone())
);
assert_eq!(
store
.remove(&record.origin, &record.tenant_key)
.expect("removes"),
Some(record.clone())
);
}
}
}