Skip to main content

ytcli/
secrets.rs

1//! Credential storage.
2//!
3//! Tokens live in the OS keychain (macOS Keychain, Windows Credential Manager,
4//! Secret Service on Linux) and are keyed by **account**, not by profile, so one
5//! login serves every organisation that account can see.
6//!
7//! There is deliberately no plaintext fallback and no command that prints a
8//! token to stdout: a missing keychain is an error with instructions, not a
9//! silent downgrade to a file anyone can read.
10
11const SERVICE: &str = "ytcli";
12
13/// Environment override, for CI and containers where no keychain exists.
14///
15/// It is not a general escape hatch: it applies to whichever account is active,
16/// so it only makes sense where exactly one identity is in play.
17const TOKEN_ENV: &str = "YTCLI_TOKEN";
18
19#[derive(Debug, thiserror::Error)]
20pub enum SecretError {
21    #[error("no token stored for account `{0}`; run `ytcli auth login --account {0}`")]
22    Missing(String),
23    // Containers and sandboxes are where this lands: a Linux image with no
24    // Secret Service running, or a session sandbox that is thrown away at the
25    // end. Saying only that the keychain is missing leaves the reader with no
26    // next step, and the next step is not "store it in a file".
27    #[error(
28        "the OS keychain is unavailable; ytcli never falls back to plaintext storage.\n\
29         Where there is no keychain — a container, CI, a session sandbox — put the token in the \
30         environment as YTCLI_TOKEN instead. Set it as an environment variable, never as a \
31         command-line argument: arguments are visible to every process on the machine"
32    )]
33    Unavailable(#[source] keyring::Error),
34    #[error("keychain error")]
35    Backend(#[source] keyring::Error),
36}
37
38/// Tokens already read in this process.
39///
40/// macOS asks the user to approve every keychain read, so a command that looks
41/// at three profiles sharing one account must not raise three dialogs. The map
42/// lives for the length of one command; nothing is written to disk.
43static READ: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, String>>> =
44    std::sync::OnceLock::new();
45
46fn cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, String>> {
47    READ.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
48}
49
50fn entry(account: &str) -> Result<keyring::Entry, SecretError> {
51    keyring::Entry::new(SERVICE, account).map_err(SecretError::Unavailable)
52}
53
54/// Where a token came from.
55///
56/// Worth reporting rather than keeping to ourselves: the environment override
57/// applies to every account at once, so with more than one profile configured
58/// it silently makes them all the same identity.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum Origin {
61    Keychain,
62    Environment,
63}
64
65/// Fetch the OAuth token for an account.
66pub fn token(account: &str) -> Result<String, SecretError> {
67    token_from(account).map(|(token, _)| token)
68}
69
70/// Fetch it, and say where it came from.
71///
72/// The environment wins over the keychain so that a CI run, which has no
73/// keychain at all, does not have to pretend otherwise.
74pub fn token_from(account: &str) -> Result<(String, Origin), SecretError> {
75    if let Ok(token) = std::env::var(TOKEN_ENV)
76        && !token.is_empty()
77    {
78        tracing::debug!("using the token from {TOKEN_ENV}");
79        return Ok((token, Origin::Environment));
80    }
81
82    // A poisoned lock means another thread panicked mid-read. The cache is an
83    // optimisation, so the right answer is to ask the keychain again, not to
84    // fail the command.
85    if let Ok(cached) = cache().lock()
86        && let Some(token) = cached.get(account)
87    {
88        return Ok((token.clone(), Origin::Keychain));
89    }
90
91    match entry(account)?.get_password() {
92        Ok(token) => {
93            if let Ok(mut cached) = cache().lock() {
94                cached.insert(account.to_owned(), token.clone());
95            }
96            Ok((token, Origin::Keychain))
97        }
98        Err(keyring::Error::NoEntry) => Err(SecretError::Missing(account.to_owned())),
99        Err(err) => Err(SecretError::Backend(err)),
100    }
101}
102
103/// Whether the environment is standing in for the keychain.
104///
105/// One token for every account is the right behaviour in CI and wrong
106/// everywhere else, so the commands that show identity say when it applies.
107#[must_use]
108pub fn overridden() -> bool {
109    std::env::var(TOKEN_ENV).is_ok_and(|token| !token.is_empty())
110}
111
112/// Store (or replace) the OAuth token for an account.
113pub fn store(account: &str, token: &str) -> Result<(), SecretError> {
114    entry(account)?
115        .set_password(token)
116        .map_err(SecretError::Backend)?;
117    if let Ok(mut cached) = cache().lock() {
118        cached.insert(account.to_owned(), token.to_owned());
119    }
120    Ok(())
121}
122
123/// Remove the stored token. Removing a token that is not there is not an error.
124pub fn forget(account: &str) -> Result<(), SecretError> {
125    if let Ok(mut cached) = cache().lock() {
126        cached.remove(account);
127    }
128    match entry(account)?.delete_credential() {
129        Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
130        Err(err) => Err(SecretError::Backend(err)),
131    }
132}
133
134/// Whether a token exists, without moving the secret itself around.
135#[must_use]
136pub fn is_stored(account: &str) -> bool {
137    token(account).is_ok()
138}