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}