Skip to main content

cratefield_auth_client/
jwks.rs

1//! Fetching and caching the issuer's public keys.
2//!
3//! Keys rotate, so a cache that never refreshes eventually rejects every
4//! token; a cache that refreshes on demand lets anyone drive traffic at
5//! the auth service by presenting tokens naming keys that do not exist.
6//! Both are handled: a TTL for the ordinary case, and a forced refetch
7//! on an unknown key id that is itself rate-limited.
8
9use std::sync::{Arc, RwLock};
10
11use base64ct::{Base64UrlUnpadded, Encoding};
12use bytes::Bytes;
13use cratefield_core::{Clock, HttpClient};
14use futures_util::lock::Mutex;
15use p256::ecdsa::VerifyingKey;
16use serde::Deserialize;
17
18use crate::verify::{Claims, VerifyError, peek_kid, verify_with};
19use crate::{JWKS_MIN_REFETCH_SECS, JWKS_TTL_SECS};
20
21#[derive(Deserialize)]
22struct Jwks {
23    keys: Vec<Jwk>,
24}
25
26#[derive(Deserialize)]
27struct Jwk {
28    kty: String,
29    crv: Option<String>,
30    kid: Option<String>,
31    x: Option<String>,
32    y: Option<String>,
33    /// Present only on a private key. Its presence is a bug at the
34    /// issuer, and this client refuses such a key rather than using it.
35    #[serde(default)]
36    d: Option<String>,
37}
38
39/// The cached key set and the times that govern refetching.
40#[derive(Default)]
41pub struct JwksCache {
42    keys: Vec<(String, VerifyingKey)>,
43    fetched_at: Option<i64>,
44    last_forced: Option<i64>,
45}
46
47impl JwksCache {
48    fn is_fresh(&self, now: i64) -> bool {
49        self.fetched_at.is_some_and(|at| now - at < JWKS_TTL_SECS)
50    }
51
52    fn may_force(&self, now: i64) -> bool {
53        self.last_forced
54            .is_none_or(|at| now - at >= JWKS_MIN_REFETCH_SECS)
55    }
56}
57
58/// Verifies tokens issued by one auth instance, for one
59/// registered client.
60pub struct AuthClient {
61    http: Arc<dyn HttpClient>,
62    clock: Arc<dyn Clock>,
63    issuer: String,
64    jwks_uri: String,
65    client_id: String,
66    /// Read-mostly: every verification reads it, only a refetch writes.
67    /// `RwLock` rather than `Mutex` for that reason, and because the
68    /// lint bans `Mutex` as shared mutable state (ADR 0007). This is
69    /// wiring state, not request state.
70    cache: RwLock<JwksCache>,
71    /// Held across a fetch, so concurrent verifications take turns rather
72    /// than each deciding to refetch. A cold cache, or the instant a TTL
73    /// lapses under load, otherwise sends one JWKS request per waiting
74    /// verification — and lets a slow fetch that started earlier land
75    /// after a faster one and overwrite the newer key set.
76    ///
77    /// An async mutex, and not a `std::sync::Mutex` held across the
78    /// `await`: this crate is wasm-safe (ADR 0200), where the standard
79    /// mutex has no thread primitives to block on.
80    flight: Mutex<()>,
81}
82
83impl AuthClient {
84    /// `issuer` is the auth service's base URL, exactly as it appears in
85    /// the `iss` claim. `client_id` is this app's registered id, and is
86    /// what every token's `aud` must equal.
87    #[must_use]
88    pub fn new(
89        http: Arc<dyn HttpClient>,
90        clock: Arc<dyn Clock>,
91        issuer: impl Into<String>,
92        client_id: impl Into<String>,
93    ) -> Self {
94        let issuer = issuer.into();
95        let jwks_uri = format!("{}/.well-known/jwks.json", issuer.trim_end_matches('/'));
96        Self {
97            http,
98            clock,
99            issuer,
100            jwks_uri,
101            client_id: client_id.into(),
102            cache: RwLock::new(JwksCache::default()),
103            flight: Mutex::new(()),
104        }
105    }
106
107    fn now(&self) -> i64 {
108        self.clock.now().unix_timestamp()
109    }
110
111    /// Verifies a bearer token and returns its claims.
112    ///
113    /// On an unknown key id the key set is refetched once — that is
114    /// what makes rotation invisible to a running app — but no more
115    /// often than [`crate::JWKS_MIN_REFETCH_SECS`].
116    ///
117    /// # Errors
118    ///
119    /// [`VerifyError`] describing the first check that failed. Callers
120    /// should log it and answer `401` without saying which.
121    pub async fn verify(&self, token: &str) -> Result<Claims, VerifyError> {
122        let now = self.now();
123        if !self.cached_is_fresh(now) {
124            self.refresh(now, false).await;
125        }
126        match self.verify_cached(token, now) {
127            Err(VerifyError::UnknownKey(kid)) => {
128                // A key we have not seen: either the issuer rotated, or
129                // someone is inventing key ids. Refetch at most once a
130                // minute, then decide for good.
131                if peek_kid(token).as_deref() == Some(kid.as_str()) {
132                    self.refresh(now, true).await;
133                }
134                self.verify_cached(token, now)
135            }
136            other => other,
137        }
138    }
139
140    fn cached_is_fresh(&self, now: i64) -> bool {
141        self.cache
142            .read()
143            .expect("jwks cache uncontended")
144            .is_fresh(now)
145    }
146
147    fn verify_cached(&self, token: &str, now: i64) -> Result<Claims, VerifyError> {
148        let cache = self.cache.read().expect("jwks cache uncontended");
149        verify_with(token, &cache.keys, &self.issuer, &self.client_id, now)
150    }
151
152    /// Fetches the key set. A failure leaves the previous keys in place:
153    /// a momentarily unreachable auth service must not invalidate every
154    /// session in a running app.
155    ///
156    /// One fetch at a time, and the freshness question is asked again
157    /// *after* taking the flight lock rather than only before it. A caller
158    /// that queued behind an in-flight refetch therefore finds the cache
159    /// fresh and returns without a second request, and — the half that
160    /// matters for rotation — a fetch that started earlier can no longer
161    /// land after a newer one and put the retired key set back.
162    ///
163    /// A forced refresh still goes ahead even when the cache is fresh:
164    /// that is the whole point of it, and the throttle below is what keeps
165    /// it from becoming one request per verification.
166    async fn refresh(&self, now: i64, forced: bool) {
167        let _flight = self.flight.lock().await;
168        // The guards are scoped: an `RwLock` guard must not be live across
169        // the `await` below, because the future this sits in has to be
170        // `Send` for axum's extractors.
171        {
172            let cache = self.cache.read().expect("jwks cache uncontended");
173            if !forced && cache.is_fresh(now) {
174                return;
175            }
176            if forced && !cache.may_force(now) {
177                return;
178            }
179        }
180        if forced {
181            self.cache
182                .write()
183                .expect("jwks cache uncontended")
184                .last_forced = Some(now);
185        }
186        match self.fetch(now).await {
187            Ok(keys) => {
188                let mut cache = self.cache.write().expect("jwks cache uncontended");
189                cache.keys = keys;
190                cache.fetched_at = Some(now);
191            }
192            Err(err) => {
193                tracing::warn!(
194                    error = %err,
195                    jwks_uri = %self.jwks_uri,
196                    "fetching the issuer's keys failed; keeping the previous set"
197                );
198            }
199        }
200    }
201
202    async fn fetch(&self, _now: i64) -> Result<Vec<(String, VerifyingKey)>, String> {
203        let request = http::Request::builder()
204            .method(http::Method::GET)
205            .uri(&self.jwks_uri)
206            .header(http::header::ACCEPT, "application/json")
207            .body(Bytes::new())
208            .map_err(|err| err.to_string())?;
209        let response = self
210            .http
211            .send(request)
212            .await
213            .map_err(|err| err.to_string())?;
214        if !response.status().is_success() {
215            return Err(format!("jwks endpoint answered {}", response.status()));
216        }
217        let jwks: Jwks = serde_json::from_slice(response.body()).map_err(|err| err.to_string())?;
218        Ok(jwks.keys.iter().filter_map(parse_key).collect())
219    }
220}
221
222/// Turns one JWK into a verifying key, skipping anything unusable.
223///
224/// A key carrying `d` is a private key, which must never appear in a
225/// public key set; it is skipped rather than used, so an issuer bug
226/// cannot become a client vulnerability.
227fn parse_key(jwk: &Jwk) -> Option<(String, VerifyingKey)> {
228    if jwk.d.is_some() {
229        tracing::error!(
230            kid = jwk.kid.as_deref().unwrap_or("?"),
231            "the issuer published a PRIVATE key in its JWKS; refusing to use it"
232        );
233        return None;
234    }
235    if jwk.kty != "EC" || jwk.crv.as_deref() != Some("P-256") {
236        return None;
237    }
238    let kid = jwk.kid.clone()?;
239    let x = Base64UrlUnpadded::decode_vec(jwk.x.as_deref()?).ok()?;
240    let y = Base64UrlUnpadded::decode_vec(jwk.y.as_deref()?).ok()?;
241    // Uncompressed SEC1: 0x04 || X || Y. Both coordinates are exactly
242    // 32 bytes for P-256; anything else is a malformed key.
243    if x.len() != 32 || y.len() != 32 {
244        return None;
245    }
246    let mut sec1 = Vec::with_capacity(65);
247    sec1.push(0x04);
248    sec1.extend_from_slice(&x);
249    sec1.extend_from_slice(&y);
250    let key = VerifyingKey::from_sec1_bytes(&sec1).ok()?;
251    Some((kid, key))
252}