Skip to main content

jsonwebtoken_jwks_cache/cache/
mod.rs

1#[cfg(test)]
2mod test;
3
4use core::fmt;
5use core::future::Future;
6
7use super::pem_set::PemMap;
8use jsonwebtoken::jwk::JwkSet;
9use spin::RwLock;
10use std::sync::Arc;
11use std::time::{Duration, SystemTime};
12use tokio::sync::Notify;
13use url::Url;
14
15struct CacheResetGuard {
16    cache_state: Arc<RwLock<JWKSCache>>,
17    notifier: Option<Arc<Notify>>,
18}
19
20impl CacheResetGuard {
21    pub fn finish_state_update(mut self, result: JWKSCache) {
22        if let Some(notifier) = self.notifier.take() {
23            *self.cache_state.write() = result;
24            notifier.notify_waiters();
25        }
26    }
27}
28
29impl Drop for CacheResetGuard {
30    fn drop(&mut self) {
31        if let Some(notifier) = self.notifier.take() {
32            *self.cache_state.write() = JWKSCache::Empty;
33            notifier.notify_waiters();
34        }
35    }
36}
37
38fn get_expiration(now: SystemTime, req: &reqwest::Request, res: &reqwest::Response) -> SystemTime {
39    now + http_cache_semantics::CachePolicy::new(req, res).time_to_live(now)
40}
41
42pub trait JwksSource: Clone + Send + Sync + 'static {
43    type Error: fmt::Debug + Send + Sync + 'static;
44
45    fn get_jwks_within_deadline(
46        self,
47        url: Url,
48        as_pkeys: bool,
49        now: SystemTime,
50        deadline: Duration,
51    ) -> impl Future<Output = Result<(JwkSet, SystemTime), RequestError<Self::Error>>>
52    + Send
53    + Sync
54    + 'static {
55        async move {
56            let result = tokio::time::timeout(deadline, self.get_jwks(url, as_pkeys, now)).await;
57
58            match result {
59                Ok(res) => res.map_err(RequestError::Client),
60                Err(_) => Err(RequestError::Timeout),
61            }
62        }
63    }
64
65    fn get_jwks(
66        self,
67        url: Url,
68        as_pkeys: bool,
69        now: SystemTime,
70    ) -> impl Future<Output = Result<(JwkSet, SystemTime), Self::Error>> + Send + Sync + 'static;
71}
72
73impl JwksSource for reqwest::Client {
74    type Error = reqwest::Error;
75
76    async fn get_jwks(
77        self,
78        url: Url,
79        as_pkeys: bool,
80        now: SystemTime,
81    ) -> Result<(JwkSet, SystemTime), Self::Error> {
82        let req = reqwest::Request::new(http::Method::GET, url.clone());
83        let res = reqwest::Client::builder()
84            .build()?
85            .execute(
86                // safety: because we control the request creation we can ensure its not a stateful stream and can be copied at all times
87                req.try_clone().expect("Request should be always copyable"),
88            )
89            .await?
90            .error_for_status()?;
91
92        let expiration = get_expiration(now, &req, &res);
93        let jwks = if as_pkeys {
94            res.json::<PemMap>().await?.into_rsa_jwk_set()
95        } else {
96            res.json::<JwkSet>().await?
97        };
98
99        Ok((jwks, expiration))
100    }
101}
102
103/// State machine of the JWKS cache
104#[derive(Debug, Clone, Default)]
105enum JWKSCache {
106    /// There is no data in cache, this is initial state
107    #[default]
108    Empty,
109    /// Cache is empty or expired, fetching of new content is ongoing.
110    /// Contains handle for awaiting for fetching to conclude
111    Fetching(Arc<Notify>),
112    /// Cache is valid, but content is being refreshed in the background
113    Refreshing { expires: SystemTime, jwks: JwkSet },
114    /// Cache is populated, but needs to be revalidated before use
115    Fetched { expires: SystemTime, jwks: JwkSet },
116}
117
118impl JWKSCache {
119    const fn is_refreshing(&self) -> bool {
120        matches!(self, Self::Refreshing { .. })
121    }
122}
123
124#[derive(Debug, thiserror::Error)]
125pub enum RequestError<E: fmt::Debug> {
126    #[error("Client error: {0}")]
127    Client(E),
128    #[error("Timeout for request completion reached")]
129    Timeout,
130}
131
132impl<E: fmt::Debug> RequestError<E> {
133    ///Returns `true` if request timed out.
134    pub const fn is_timeout(&self) -> bool {
135        matches!(self, Self::Timeout)
136    }
137}
138
139impl<T: fmt::Debug> From<T> for RequestError<T> {
140    fn from(value: T) -> Self {
141        Self::Client(value)
142    }
143}
144
145#[derive(Debug, Clone, Copy)]
146pub struct TimeoutSpec {
147    /// How many times to retry on failure (timeout or client error)
148    pub retries: u8,
149    /// How long to wait for a single response before retrying
150    pub retry_after: Duration,
151    /// Waiting between retries
152    pub backoff: Duration,
153    /// Total time for completion before considering failure
154    pub deadline: Duration,
155}
156
157impl Default for TimeoutSpec {
158    fn default() -> Self {
159        Self {
160            retries: 0,
161            retry_after: Duration::from_secs(10),
162            backoff: Duration::ZERO,
163            deadline: Duration::from_secs(10),
164        }
165    }
166}
167
168#[derive(Clone)]
169pub struct CachedJWKS<S> {
170    jwks_url: Url,
171    pkeys: bool,
172    update_period: Duration,
173    timeout_spec: TimeoutSpec,
174    cache_state: Arc<RwLock<JWKSCache>>,
175    source: S,
176}
177
178impl CachedJWKS<reqwest::Client> {
179    pub fn new(
180        jwks_url: Url,
181        // Period when to refresh in the background before expiration period
182        update_period: Duration,
183        timeout_spec: TimeoutSpec,
184    ) -> Result<Self, reqwest::Error> {
185        Ok(Self::from_source(
186            jwks_url,
187            false,
188            update_period,
189            timeout_spec,
190            reqwest::Client::builder().build()?,
191        ))
192    }
193
194    /// Load keys as a map of RSA pub keys
195    pub fn new_rsa_pkeys(
196        pkeys_url: Url,
197        // Period when to refresh in the background before expiration period
198        update_period: Duration,
199        timeout_spec: TimeoutSpec,
200    ) -> Result<Self, reqwest::Error> {
201        Ok(Self::from_source(
202            pkeys_url,
203            true,
204            update_period,
205            timeout_spec,
206            reqwest::Client::builder().build()?,
207        ))
208    }
209}
210
211impl<S: JwksSource> CachedJWKS<S> {
212    pub fn from_source(
213        jwks_url: Url,
214        pkeys: bool,
215        update_period: Duration,
216        timeout_spec: TimeoutSpec,
217        source: S,
218    ) -> Self {
219        assert!(
220            update_period > timeout_spec.deadline,
221            "Update period should be greater than timeout deadline"
222        );
223
224        Self {
225            jwks_url,
226            pkeys,
227            update_period,
228            timeout_spec,
229            cache_state: Default::default(),
230            source,
231        }
232    }
233
234    async fn request(
235        source: S,
236        url: Url,
237        as_pkeys: bool,
238        now: SystemTime,
239        timeout: TimeoutSpec,
240    ) -> Result<(JwkSet, SystemTime), RequestError<S::Error>> {
241        let perform = async {
242            let mut retries = 0u8;
243            loop {
244                match source
245                    .clone()
246                    .get_jwks_within_deadline(url.clone(), as_pkeys, now, timeout.retry_after)
247                    .await
248                {
249                    Ok(res) => return Ok(res),
250                    Err(err) => {
251                        if retries == timeout.retries {
252                            return Err(err);
253                        } else {
254                            retries += 1;
255                            tokio::time::sleep(timeout.backoff).await;
256                            continue;
257                        }
258                    }
259                }
260            }
261        };
262
263        tokio::time::timeout(timeout.deadline, perform)
264            .await
265            .map_err(|_| RequestError::Timeout)?
266    }
267
268    async fn update_notify(
269        &self,
270        now: SystemTime,
271    ) -> Result<Option<JwkSet>, RequestError<S::Error>> {
272        let notifier = if let Some(mut cached_state) = self.cache_state.try_write() {
273            let notifier = Arc::new(Notify::new());
274
275            *cached_state = JWKSCache::Fetching(notifier.clone());
276
277            notifier
278        } else {
279            return Ok(None);
280        };
281        let guard = CacheResetGuard {
282            cache_state: self.cache_state.clone(),
283            notifier: Some(notifier),
284        };
285
286        let result = Self::request(
287            self.source.clone(),
288            self.jwks_url.clone(),
289            self.pkeys,
290            now,
291            self.timeout_spec,
292        )
293        .await;
294
295        match result {
296            Ok((jwks, expires)) => {
297                guard.finish_state_update(JWKSCache::Fetched {
298                    expires,
299                    jwks: jwks.clone(),
300                });
301
302                Ok(Some(jwks))
303            }
304            // Could not fetch in time, let follow up request try again later
305            Err(err) => Err(err),
306        }
307    }
308
309    /// Trigger refresh of JWKS in the background when cached JWKS is stil valid but about to expire,
310    /// if process dies then we do not care if this completes
311    fn update_in_background(&self, now: SystemTime, old_jwks: JwkSet, old_expires: SystemTime) {
312        {
313            let mut cache_state = self.cache_state.write();
314
315            //Because concurrent readers of the state can acquire Fetched at the same time, we need
316            //to guard against multiple refresh attempts
317            if cache_state.is_refreshing() {
318                return;
319            }
320
321            *cache_state = JWKSCache::Refreshing {
322                expires: old_expires,
323                jwks: old_jwks,
324            };
325        }
326
327        let cache_state = self.cache_state.clone();
328        let jwks_url = self.jwks_url.clone();
329        let timeout_spec = self.timeout_spec;
330        let source = self.source.clone();
331        let as_pkeys = self.pkeys;
332
333        tokio::spawn(async move {
334            loop {
335                let result = Self::request(
336                    source.clone(),
337                    jwks_url.clone(),
338                    as_pkeys,
339                    now,
340                    timeout_spec,
341                )
342                .await;
343
344                if let Err(err) = &result {
345                    log::error!("Error while refreshing JWKS in the background: {err:?}");
346                }
347
348                let mut cache_state = cache_state.write();
349
350                let new_state = match cache_state.to_owned() {
351                    JWKSCache::Empty => match result {
352                        Ok((jwks, expires)) => JWKSCache::Fetched { expires, jwks },
353                        Err(_) => JWKSCache::Empty,
354                    },
355                    JWKSCache::Fetching(notify) => {
356                        if let Ok((jwks, expires)) = result {
357                            notify.notify_waiters();
358                            JWKSCache::Fetched { expires, jwks }
359                        } else {
360                            JWKSCache::Fetching(notify)
361                        }
362                    }
363                    JWKSCache::Refreshing { expires, .. } => {
364                        if let Ok((jwks, expires)) = result {
365                            JWKSCache::Fetched { expires, jwks }
366                        } else if SystemTime::now() >= expires {
367                            //Pending JWKs are already expired, invalidate it
368                            JWKSCache::Empty
369                        } else {
370                            //Attempt to refresh again
371                            continue;
372                        }
373                    }
374                    JWKSCache::Fetched { expires, jwks } => {
375                        if let Ok((jwks, expires)) = result {
376                            JWKSCache::Fetched { expires, jwks }
377                        } else {
378                            //We couldn't refresh successfully, but it is already fetched so don't care (but this branch is impossible anyway)
379                            JWKSCache::Fetched { expires, jwks }
380                        }
381                    }
382                };
383
384                *cache_state = new_state;
385                break;
386            }
387        });
388    }
389
390    pub async fn get(&self) -> Result<JwkSet, RequestError<S::Error>> {
391        let now = SystemTime::now();
392        loop {
393            let cached_state = self.cache_state.read().clone();
394
395            match cached_state {
396                JWKSCache::Empty => {
397                    if let Some(jwks) = self.update_notify(now).await? {
398                        return Ok(jwks);
399                    } else {
400                        // state changed since reading it, reload
401                        continue;
402                    }
403                }
404                JWKSCache::Fetching(notifier) => {
405                    notifier.notified().await;
406
407                    // we got notified about change in state, reload
408                    continue;
409                }
410                JWKSCache::Refreshing { expires: _, jwks } => {
411                    // Refresh mechanism should guarantee it will change the state before cache is no longer valid
412                    return Ok(jwks);
413                }
414                JWKSCache::Fetched { expires, jwks } => {
415                    if now >= expires {
416                        if let Some(jwks) = self.update_notify(now).await? {
417                            return Ok(jwks);
418                        } else {
419                            // state changed since reading it, reload
420                            continue;
421                        }
422                    }
423
424                    if now + self.update_period >= expires {
425                        self.update_in_background(now, jwks.clone(), expires);
426                    }
427
428                    return Ok(jwks);
429                }
430            }
431        }
432    }
433}