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) => {}
Err(err) => return Err(SecretError::Backend(err)),
}
store_refresh(account, None)
}
const REFRESH_SERVICE: &str = "ytcli-refresh";
fn refresh_entry(account: &str) -> Result<keyring::Entry, SecretError> {
keyring::Entry::new(REFRESH_SERVICE, account).map_err(SecretError::Unavailable)
}
pub fn refresh_token(account: &str) -> Result<Option<String>, SecretError> {
match refresh_entry(account)?.get_password() {
Ok(token) => Ok(Some(token)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(err) => Err(SecretError::Backend(err)),
}
}
pub fn store_refresh(account: &str, token: Option<&str>) -> Result<(), SecretError> {
let entry = refresh_entry(account)?;
match token {
Some(token) => entry.set_password(token).map_err(SecretError::Backend),
None => match entry.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()
}