const SERVICE: &str = "ytcli";
const TOKEN_ENV: &str = "YTCLI_TOKEN";
#[derive(Debug, thiserror::Error)]
pub enum SecretError {
#[error("no token stored for account `{0}`; run `ytcli auth login --account {0}`")]
Missing(String),
#[error(
"the OS keychain is unavailable; ytcli never falls back to plaintext storage.\n\
Where there is no keychain — a container, CI, a session sandbox — put the token in the \
environment as YTCLI_TOKEN instead. Set it as an environment variable, never as a \
command-line argument: arguments are visible to every process on the machine"
)]
Unavailable(#[source] keyring::Error),
#[error("keychain error")]
Backend(#[source] keyring::Error),
}
static READ: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, String>>> =
std::sync::OnceLock::new();
fn cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, String>> {
READ.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
}
fn entry(account: &str) -> Result<keyring::Entry, SecretError> {
keyring::Entry::new(SERVICE, account).map_err(SecretError::Unavailable)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Origin {
Keychain,
Environment,
}
pub fn token(account: &str) -> Result<String, SecretError> {
token_from(account).map(|(token, _)| token)
}
pub fn token_from(account: &str) -> Result<(String, Origin), SecretError> {
if let Ok(token) = std::env::var(TOKEN_ENV)
&& !token.is_empty()
{
tracing::debug!("using the token from {TOKEN_ENV}");
return Ok((token, Origin::Environment));
}
if let Ok(cached) = cache().lock()
&& let Some(token) = cached.get(account)
{
return Ok((token.clone(), Origin::Keychain));
}
match entry(account)?.get_password() {
Ok(token) => {
if let Ok(mut cached) = cache().lock() {
cached.insert(account.to_owned(), token.clone());
}
Ok((token, Origin::Keychain))
}
Err(keyring::Error::NoEntry) => Err(SecretError::Missing(account.to_owned())),
Err(err) => Err(SecretError::Backend(err)),
}
}
#[must_use]
pub fn overridden() -> bool {
std::env::var(TOKEN_ENV).is_ok_and(|token| !token.is_empty())
}
pub fn store(account: &str, token: &str) -> Result<(), SecretError> {
entry(account)?
.set_password(token)
.map_err(SecretError::Backend)?;
if let Ok(mut cached) = cache().lock() {
cached.insert(account.to_owned(), token.to_owned());
}
Ok(())
}
pub fn forget(account: &str) -> Result<(), SecretError> {
if let Ok(mut cached) = cache().lock() {
cached.remove(account);
}
match entry(account)?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(err) => Err(SecretError::Backend(err)),
}
}
#[must_use]
pub fn is_stored(account: &str) -> bool {
token(account).is_ok()
}