Skip to main content

spiral_auth/providers/
service.rs

1use std::fmt::{Debug, Formatter};
2use std::sync::Arc;
3use std::time::Duration;
4
5use tokio::sync::{Mutex, RwLock};
6use tokio::task::JoinHandle;
7
8use crate::{CredentialResult, Token, TokenSource};
9
10const DEFAULT_REFRESH_BEFORE: Duration = Duration::from_secs(60);
11const DEFAULT_RETRY_DELAY: Duration = Duration::from_secs(5);
12const MIN_USABLE_LIFETIME: Duration = Duration::from_secs(30);
13
14struct ServiceTokenState {
15    source: TokenSource,
16    cached: RwLock<Option<Token>>,
17    refresh: Mutex<()>,
18    refresh_before: Duration,
19    retry_delay: Duration,
20}
21
22/// Keeps a short-lived service token warm and returns only usable tokens.
23///
24/// Construction starts a background refresh. Concurrent refreshes are
25/// single-flighted, and a failed early refresh leaves the last usable token in
26/// place. [`token`](Self::token) returns only a token that remains usable
27/// for at least 30 seconds.
28pub struct RefreshingAuthTokenProvider {
29    state: Arc<ServiceTokenState>,
30    background: JoinHandle<()>,
31}
32
33impl RefreshingAuthTokenProvider {
34    /// Wrap a token source with background warming and early refresh.
35    pub fn new(source: TokenSource) -> Self {
36        Self::with_timing(source, DEFAULT_REFRESH_BEFORE, DEFAULT_RETRY_DELAY)
37    }
38
39    fn with_timing(source: TokenSource, refresh_before: Duration, retry_delay: Duration) -> Self {
40        let state = Arc::new(ServiceTokenState {
41            source,
42            cached: RwLock::new(None),
43            refresh: Mutex::new(()),
44            refresh_before,
45            retry_delay,
46        });
47        let background_state = Arc::clone(&state);
48        let background = tokio::spawn(async move {
49            background_state.refresh_loop().await;
50        });
51        Self { state, background }
52    }
53}
54
55impl Debug for RefreshingAuthTokenProvider {
56    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
57        f.debug_struct("RefreshingAuthTokenProvider")
58            .field("source", &self.state.source)
59            .finish_non_exhaustive()
60    }
61}
62
63impl Drop for RefreshingAuthTokenProvider {
64    fn drop(&mut self) {
65        self.background.abort();
66    }
67}
68
69impl RefreshingAuthTokenProvider {
70    /// A usable token, or `None` when this provider has no credentials to offer.
71    pub async fn token(&self) -> CredentialResult<Option<Token>> {
72        self.state
73            .token_with_lifetime(MIN_USABLE_LIFETIME)
74            .await
75            .map(Some)
76    }
77
78    /// A token this provider already holds, without network requests or prompts.
79    pub async fn cached_token(&self) -> CredentialResult<Option<Token>> {
80        self.state.cached_with_lifetime(MIN_USABLE_LIFETIME).await
81    }
82}
83
84impl_token_source!(RefreshingAuthTokenProvider, cached);
85
86impl ServiceTokenState {
87    async fn refresh_loop(self: Arc<Self>) {
88        loop {
89            if let Err(error) = self.token_with_lifetime(self.refresh_before).await {
90                tracing::warn!(?error, "failed to refresh service token");
91            }
92
93            tokio::time::sleep(self.next_refresh_delay().await).await;
94        }
95    }
96
97    async fn next_refresh_delay(&self) -> Duration {
98        let cached = self.cached.read().await;
99        let Some(token) = cached.as_ref() else {
100            return self.retry_delay;
101        };
102        match token.remaining_lifetime() {
103            Ok(remaining) if remaining > self.refresh_before => remaining - self.refresh_before,
104            _ => self.retry_delay,
105        }
106    }
107
108    async fn token_with_lifetime(&self, minimum_lifetime: Duration) -> CredentialResult<Token> {
109        if let Some(token) = self.cached_with_lifetime(minimum_lifetime).await? {
110            return Ok(token);
111        }
112
113        let _refresh = self.refresh.lock().await;
114        if let Some(token) = self.cached_with_lifetime(minimum_lifetime).await? {
115            return Ok(token);
116        }
117
118        let Some(token) = self.source.token().await? else {
119            bail!("service token source returned no token");
120        };
121        let remaining = token
122            .remaining_lifetime()
123            .map_err(|error| invalid!("service token is not a valid JWT: {error}"))?;
124        if remaining < MIN_USABLE_LIFETIME {
125            bail!("service token expires too soon");
126        }
127        *self.cached.write().await = Some(token.clone());
128        Ok(token)
129    }
130
131    async fn cached_with_lifetime(
132        &self,
133        minimum_lifetime: Duration,
134    ) -> CredentialResult<Option<Token>> {
135        let cached = self.cached.read().await;
136        let Some(token) = cached.as_ref() else {
137            return Ok(None);
138        };
139        let remaining = token
140            .remaining_lifetime()
141            .map_err(|error| invalid!("cached service token is not a valid JWT: {error}"))?;
142        Ok((remaining >= minimum_lifetime).then(|| token.clone()))
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use std::collections::VecDeque;
149    use std::sync::atomic::{AtomicUsize, Ordering};
150
151    use serde_json::json;
152
153    use super::*;
154    use crate::token::unsigned_jwt;
155
156    fn token(expires_in: Duration) -> Token {
157        let now = std::time::SystemTime::now()
158            .duration_since(std::time::SystemTime::UNIX_EPOCH)
159            .unwrap()
160            .as_secs();
161        unsigned_jwt(json!({ "exp": now + expires_in.as_secs() }))
162    }
163
164    struct SequenceProvider {
165        tokens: Mutex<VecDeque<Token>>,
166        calls: AtomicUsize,
167    }
168
169    impl Debug for SequenceProvider {
170        fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
171            f.debug_struct("SequenceProvider").finish_non_exhaustive()
172        }
173    }
174
175    impl SequenceProvider {
176        /// A usable token, or `None` when this provider has no credentials to offer.
177        pub async fn token(&self) -> CredentialResult<Option<Token>> {
178            self.calls.fetch_add(1, Ordering::SeqCst);
179            let mut tokens = self.tokens.lock().await;
180            Ok(tokens.pop_front().or_else(|| tokens.back().cloned()))
181        }
182    }
183
184    impl_token_source!(Arc<SequenceProvider>);
185
186    fn source(tokens: impl IntoIterator<Item = Token>) -> Arc<SequenceProvider> {
187        Arc::new(SequenceProvider {
188            tokens: Mutex::new(tokens.into_iter().collect()),
189            calls: AtomicUsize::new(0),
190        })
191    }
192
193    #[tokio::test]
194    async fn concurrent_getters_share_one_refresh() {
195        let source = source([token(Duration::from_secs(3_600))]);
196        let provider = TokenSource::from(RefreshingAuthTokenProvider::new(source.clone().into()));
197        let getters = (0..20).map(|_| {
198            let provider = provider.clone();
199            tokio::spawn(async move { provider.token().await.unwrap().unwrap() })
200        });
201        for getter in getters {
202            getter.await.unwrap();
203        }
204        assert_eq!(source.calls.load(Ordering::SeqCst), 1);
205    }
206
207    #[tokio::test]
208    async fn refreshes_early_in_the_background() {
209        let first = token(Duration::from_secs(90));
210        let second = token(Duration::from_secs(3_600));
211        let source = source([first.clone(), second.clone()]);
212        let provider = RefreshingAuthTokenProvider::with_timing(
213            source.clone().into(),
214            Duration::from_secs(100),
215            Duration::from_millis(5),
216        );
217
218        tokio::time::timeout(Duration::from_secs(1), async {
219            while source.calls.load(Ordering::SeqCst) < 2 {
220                tokio::time::sleep(Duration::from_millis(5)).await;
221            }
222        })
223        .await
224        .unwrap();
225
226        assert_eq!(provider.token().await.unwrap().unwrap(), second);
227    }
228
229    #[tokio::test]
230    async fn rejects_a_token_that_is_already_unusable() {
231        let source = source([token(Duration::from_secs(5))]);
232        let provider = RefreshingAuthTokenProvider::new(source.into());
233        let error = provider.token().await.unwrap_err();
234        assert!(error.to_string().contains("expires too soon"));
235    }
236}