Skip to main content

dbx_tools_auth/
client.rs

1use std::{sync::Arc, time::Duration};
2
3use time::{Duration as TimeDuration, OffsetDateTime};
4
5use crate::{CredentialStore, Error, Result, Token};
6
7#[async_trait::async_trait]
8/// Provider-specific acquisition; persistence and locking belong to `AuthClient`.
9pub trait TokenProvider: Send + Sync {
10    /// Acquire a new credential within the supplied login timeout.
11    async fn authenticate(&self, timeout: Duration) -> Result<Token>;
12    /// Perform an explicitly requested login; defaults to normal acquisition.
13    async fn login(&self, timeout: Duration) -> Result<Token> {
14        self.authenticate(timeout).await
15    }
16    /// Renew a credential without silently starting an interactive login.
17    async fn refresh(&self, token: &Token) -> Result<Token>;
18    /// Whether acquisition is safe without an explicit interactive login request.
19    fn can_authenticate_silently(&self) -> bool {
20        false
21    }
22}
23
24/// Shared lifecycle configuration embedded by every provider's binding options.
25#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
26pub struct AuthOptions {
27    /// Renew credentials this many seconds before expiry; negative values are allowed.
28    #[uniffi(default = 300)]
29    pub refresh_buffer_seconds: i64,
30    /// Maximum time spent waiting for the credential store's refresh lock.
31    #[uniffi(default = 30)]
32    pub lock_timeout_seconds: u64,
33    /// Maximum time allowed for a new interactive login.
34    #[uniffi(default = 3600)]
35    pub login_timeout_seconds: u64,
36    /// Logo URL or data URI displayed by the browser callback page.
37    #[uniffi(default = None)]
38    pub callback_image_src: Option<String>,
39}
40
41impl Default for AuthOptions {
42    fn default() -> Self {
43        Self {
44            refresh_buffer_seconds: 300,
45            lock_timeout_seconds: 30,
46            login_timeout_seconds: 3600,
47            callback_image_src: None,
48        }
49    }
50}
51
52impl AuthOptions {
53    /// Convert the cross-language refresh window to a signed Rust duration.
54    pub fn refresh_buffer(&self) -> TimeDuration {
55        TimeDuration::seconds(self.refresh_buffer_seconds)
56    }
57
58    /// Convert the cross-language lock timeout to a Rust duration.
59    pub fn lock_timeout(&self) -> Duration {
60        Duration::from_secs(self.lock_timeout_seconds)
61    }
62
63    /// Convert the cross-language login timeout to a Rust duration.
64    pub fn login_timeout(&self) -> Duration {
65        Duration::from_secs(self.login_timeout_seconds)
66    }
67}
68
69/// A provider wrapper that inherits the canonical credential lifecycle.
70///
71/// Implement only `auth_client`; the defaults retain locking, refresh, and
72/// refresh-token redaction in the shared client rather than in each provider.
73#[async_trait::async_trait]
74pub trait AuthSession: Send + Sync {
75    /// Return the shared client that owns this session's lifecycle.
76    fn auth_client(&self) -> &AuthClient;
77
78    /// Identify the active credential store.
79    fn store_name(&self) -> &'static str {
80        self.auth_client().store_name()
81    }
82
83    /// Resolve binding login policy: true forces login, false forbids it, None allows it.
84    async fn token_with_login(&self, login: Option<bool>) -> Result<Token> {
85        match login {
86            Some(true) => self.login().await,
87            Some(false) => self.token().await,
88            None => self.token_or_login().await,
89        }
90    }
91
92    /// Explicitly acquire and persist a new credential.
93    async fn login(&self) -> Result<Token> {
94        self.auth_client().login().await
95    }
96
97    /// Load or renew a credential without initiating an interactive login.
98    async fn token(&self) -> Result<Token> {
99        self.auth_client().token().await
100    }
101
102    /// Load or renew a credential, allowing login when none is stored.
103    async fn token_or_login(&self) -> Result<Token> {
104        self.auth_client().token_or_login().await
105    }
106
107    /// Renew the stored credential even when it has not reached its refresh window.
108    async fn force_refresh(&self) -> Result<Token> {
109        self.auth_client().force_refresh().await
110    }
111
112    /// Reuse another caller's replacement or renew the rejected credential.
113    async fn refresh_rejected_token(&self, stale: &str) -> Result<Token> {
114        self.auth_client().refresh_rejected_token(stale).await
115    }
116
117    /// Remove the persisted credential under its refresh lock.
118    async fn logout(&self) -> Result<()> {
119        self.auth_client().logout().await
120    }
121}
122
123/// Provider-neutral check-lock-check authentication and persistent token lifecycle.
124pub struct AuthClient {
125    key: String,
126    flow: Arc<dyn TokenProvider>,
127    store: Arc<dyn CredentialStore>,
128    options: AuthOptions,
129}
130
131impl AuthSession for AuthClient {
132    fn auth_client(&self) -> &AuthClient {
133        self
134    }
135}
136
137impl AuthClient {
138    /// Bind one credential identity to its acquisition provider and store.
139    pub fn new(
140        key: String,
141        flow: Arc<dyn TokenProvider>,
142        store: Arc<dyn CredentialStore>,
143        options: AuthOptions,
144    ) -> Self {
145        Self {
146            key,
147            flow,
148            store,
149            options,
150        }
151    }
152
153    /// Return the active store's backend identifier.
154    pub fn store_name(&self) -> &'static str {
155        self.store.name()
156    }
157
158    /// Acquire and persist a new credential under an exclusive refresh lock.
159    pub async fn login(&self) -> Result<Token> {
160        let cache_key = self.key.clone();
161        let lock = self
162            .store
163            .lock(&cache_key, self.options.lock_timeout())
164            .await?;
165        let result = async {
166            let (_, token) = tokio::try_join!(
167                self.store.prepare_write(),
168                self.flow.login(self.options.login_timeout())
169            )?;
170            self.store.save(&cache_key, &token).await?;
171            Ok(public_token(token))
172        }
173        .await;
174        release(lock, result).await
175    }
176
177    /// Load or refresh without starting an interactive login for a missing credential.
178    pub async fn token(&self) -> Result<Token> {
179        self.load_token(false).await
180    }
181
182    async fn load_token(&self, login: bool) -> Result<Token> {
183        let cache_key = self.key.clone();
184        let now = OffsetDateTime::now_utc();
185        if let Some(token) = self.store.load(&cache_key).await? {
186            if self.can_reuse(&token, now) {
187                return Ok(public_token(token));
188            }
189        }
190
191        let lock = self
192            .store
193            .lock(&cache_key, self.options.lock_timeout())
194            .await?;
195        let result = async {
196            let token = self.store.load(&cache_key).await?;
197            let now = OffsetDateTime::now_utc();
198            if let Some(token) = token.as_ref() {
199                if self.can_reuse(token, now) {
200                    return Ok(public_token(token.clone()));
201                }
202            }
203            self.renew(token, login).await
204        }
205        .await;
206        release(lock, result).await
207    }
208
209    /// Load or refresh, permitting login when the store has no credential.
210    pub async fn token_or_login(&self) -> Result<Token> {
211        self.load_token(true).await
212    }
213
214    /// Renew even before the refresh window, without forcing an interactive login.
215    pub async fn force_refresh(&self) -> Result<Token> {
216        self.refresh_rejected(None).await
217    }
218
219    /// Refresh a rejected token unless another process already replaced it.
220    pub async fn refresh_rejected_token(&self, stale_access_token: &str) -> Result<Token> {
221        self.refresh_rejected(Some(stale_access_token)).await
222    }
223
224    async fn refresh_rejected(&self, stale_access_token: Option<&str>) -> Result<Token> {
225        let cache_key = self.key.clone();
226        let lock = self
227            .store
228            .lock(&cache_key, self.options.lock_timeout())
229            .await?;
230        let result = async {
231            let token = self.store.load(&cache_key).await?;
232            if let (Some(stale), Some(current)) = (stale_access_token, token.as_ref()) {
233                if current.access_token != stale
234                    && self.can_reuse(current, OffsetDateTime::now_utc())
235                {
236                    return Ok(public_token(current.clone()));
237                }
238            }
239            self.renew(token, false).await
240        }
241        .await;
242        release(lock, result).await
243    }
244
245    /// Delete only this credential while holding the store's refresh lock.
246    pub async fn logout(&self) -> Result<()> {
247        let cache_key = self.key.clone();
248        let lock = self
249            .store
250            .lock(&cache_key, self.options.lock_timeout())
251            .await?;
252        let result = self.store.delete(&cache_key).await;
253        release(lock, result).await
254    }
255
256    fn can_reuse(&self, token: &Token, now: OffsetDateTime) -> bool {
257        token.is_valid(now) && !token.needs_refresh(now, self.options.refresh_buffer())
258    }
259
260    async fn renew(&self, token: Option<Token>, login: bool) -> Result<Token> {
261        if token.is_none() && !login && !self.flow.can_authenticate_silently() {
262            return Err(Error::LoginRequired(self.key.clone()));
263        }
264        self.store.prepare_write().await?;
265        let renewed = match token {
266            Some(token) => self.flow.refresh(&token).await?,
267            None => self.flow.authenticate(self.options.login_timeout()).await?,
268        };
269        self.store.save(&self.key, &renewed).await?;
270        Ok(public_token(renewed))
271    }
272}
273
274async fn release<T>(lock: Box<dyn crate::StorageLock>, result: Result<T>) -> Result<T> {
275    let released = lock.release().await;
276    match result {
277        Err(error) => Err(error),
278        Ok(value) => released.map(|()| value),
279    }
280}
281
282fn public_token(mut token: Token) -> Token {
283    token.refresh_token = None;
284    token
285}