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
64pub 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
130async 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}