Skip to main content

yt/
credentials.rs

1use std::fmt;
2use std::sync::OnceLock;
3
4use chrono::{DateTime, Utc};
5use miette::IntoDiagnostic as _;
6use serde::{Deserialize, Serialize};
7
8use crate::error::{Result, YtError};
9
10const SERVICE: &str = "yt";
11
12#[derive(Clone, PartialEq, Serialize, Deserialize)]
13#[serde(tag = "kind", rename_all = "lowercase")]
14pub enum StoredCredential {
15    Token { token: String },
16    Oauth(OauthCredential),
17}
18
19#[derive(Clone, PartialEq, Serialize, Deserialize)]
20pub struct OauthCredential {
21    pub access_token: String,
22    pub refresh_token: String,
23    pub expires_at: DateTime<Utc>,
24    pub client_id: String,
25    pub token_endpoint: String,
26    pub scope: String,
27    pub created_at: DateTime<Utc>,
28}
29
30impl fmt::Debug for StoredCredential {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        match self {
33            Self::Token { .. } => f.write_str("Token([REDACTED])"),
34            Self::Oauth(credential) => credential.fmt(f),
35        }
36    }
37}
38
39impl fmt::Debug for OauthCredential {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        f.debug_struct("OauthCredential")
42            .field("expires_at", &self.expires_at)
43            .field("client_id", &self.client_id)
44            .field("scope", &self.scope)
45            .field("created_at", &self.created_at)
46            .finish_non_exhaustive()
47    }
48}
49
50impl StoredCredential {
51    fn register_secrets(&self) {
52        match self {
53            Self::Token { token } => crate::redact::register(token),
54            Self::Oauth(credential) => {
55                crate::redact::register(&credential.access_token);
56                crate::redact::register(&credential.refresh_token);
57            }
58        }
59    }
60}
61
62static OS_STORE: OnceLock<std::result::Result<(), String>> = OnceLock::new();
63
64/// Connects to the OS keyring: Keychain on macOS, Secret Service on Linux.
65pub fn init_os_store() -> Result<()> {
66    let ready = OS_STORE.get_or_init(|| {
67        #[cfg(target_os = "macos")]
68        let store = apple_native_keyring_store::keychain::Store::new();
69        #[cfg(target_os = "linux")]
70        let store = zbus_secret_service_keyring_store::Store::new();
71        store
72            .map(|store| keyring_core::set_default_store(store))
73            .map_err(|error| error.to_string())
74    });
75    ready
76        .clone()
77        .map_err(|reason| YtError::NoKeyring { reason }.into())
78}
79
80pub async fn load(account: &str) -> Result<Option<StoredCredential>> {
81    let account = account.to_string();
82    let stored = blocking(move || match entry(&account)?.get_password() {
83        Ok(text) => Ok(Some(text)),
84        Err(keyring_core::Error::NoEntry) => Ok(None),
85        Err(error) => Err(keyring_error(error)),
86    })
87    .await?;
88    let Some(text) = stored else {
89        return Ok(None);
90    };
91    let credential: StoredCredential = serde_json::from_str(&text).map_err(|_| {
92        YtError::Keyring("the stored credential is unreadable; sign in again".into())
93    })?;
94    credential.register_secrets();
95    Ok(Some(credential))
96}
97
98pub async fn save(account: &str, credential: &StoredCredential) -> Result<()> {
99    credential.register_secrets();
100    let account = account.to_string();
101    let text = serde_json::to_string(credential).into_diagnostic()?;
102    blocking(move || entry(&account)?.set_password(&text).map_err(keyring_error)).await
103}
104
105pub async fn delete(account: &str) -> Result<()> {
106    let account = account.to_string();
107    blocking(move || match entry(&account)?.delete_credential() {
108        Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
109        Err(error) => Err(keyring_error(error)),
110    })
111    .await
112}
113
114fn entry(account: &str) -> Result<keyring_core::Entry> {
115    keyring_core::Entry::new(SERVICE, account).map_err(keyring_error)
116}
117
118fn keyring_error(error: keyring_core::Error) -> miette::Report {
119    match error {
120        keyring_core::Error::NoDefaultStore | keyring_core::Error::NoStorageAccess(_) => {
121            YtError::NoKeyring {
122                reason: error.to_string(),
123            }
124            .into()
125        }
126        other => YtError::Keyring(other.to_string()).into(),
127    }
128}
129
130/// Keyring calls may block on an OS dialog, so they run off the async runtime.
131async fn blocking<T: Send + 'static>(
132    work: impl FnOnce() -> Result<T> + Send + 'static,
133) -> Result<T> {
134    tokio::task::spawn_blocking(work).await.into_diagnostic()?
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    fn use_mock_store() {
142        keyring_core::set_default_store(keyring_core::mock::Store::new().unwrap());
143    }
144
145    fn oauth() -> StoredCredential {
146        let now = Utc::now();
147        StoredCredential::Oauth(OauthCredential {
148            access_token: "access-token-1234567890".into(),
149            refresh_token: "refresh-token-1234567890".into(),
150            expires_at: now,
151            client_id: "yt-cli".into(),
152            token_endpoint: "https://acme.youtrack.cloud/hub/api/rest/oauth2/token".into(),
153            scope: "service-id".into(),
154            created_at: now,
155        })
156    }
157
158    #[tokio::test]
159    async fn round_trips_through_the_keyring() {
160        use_mock_store();
161        let account = "jane@roundtrip.youtrack.cloud";
162        assert_eq!(load(account).await.unwrap(), None);
163        let credential = oauth();
164        save(account, &credential).await.unwrap();
165        assert_eq!(load(account).await.unwrap(), Some(credential));
166        delete(account).await.unwrap();
167        assert_eq!(load(account).await.unwrap(), None);
168        delete(account).await.unwrap();
169    }
170
171    #[test]
172    fn json_shape_is_tagged_by_kind() {
173        let text = serde_json::to_string(&StoredCredential::Token {
174            token: "perm-abc".into(),
175        })
176        .unwrap();
177        assert_eq!(text, r#"{"kind":"token","token":"perm-abc"}"#);
178    }
179
180    #[test]
181    fn debug_never_shows_secrets() {
182        let text = format!("{:?}", oauth());
183        assert!(
184            !text.contains("access-token") && !text.contains("refresh-token"),
185            "{text}"
186        );
187    }
188
189    #[tokio::test]
190    async fn loaded_secrets_are_redacted_in_logs() {
191        use_mock_store();
192        let account = "jane@redact.youtrack.cloud";
193        save(
194            account,
195            &StoredCredential::Token {
196                token: "perm-cmVkYWN0.dGVzdA==.loaded1234".into(),
197            },
198        )
199        .await
200        .unwrap();
201        load(account).await.unwrap();
202        assert_eq!(
203            crate::redact::redact("perm-cmVkYWN0.dGVzdA==.loaded1234"),
204            "[REDACTED]"
205        );
206    }
207}