Skip to main content

firebase_admin/auth/id_token/
jwks.rs

1//! Fetching and caching Google's public signing keys (JWKS) for ID token
2//! verification.
3//!
4//! Keys are published at
5//! `https://www.googleapis.com/service_accounts/v1/metadata/jwk/securetoken@system.gserviceaccount.com`.
6
7use crate::auth::error::TokenVerificationError;
8use crate::core::http::parse_cache_control_max_age;
9use crate::core::HttpClient;
10use serde::Deserialize;
11use std::collections::HashMap;
12use std::sync::RwLock;
13use std::time::{Duration, Instant};
14use tokio::sync::Mutex;
15
16const SECURETOKEN_JWKS_URL: &str =
17    "https://www.googleapis.com/service_accounts/v1/metadata/jwk/securetoken@system.gserviceaccount.com";
18
19/// The minimum amount of time a cached key set is trusted before a refresh
20/// is attempted, even if the response did not specify a `Cache-Control` max-age.
21const MIN_CACHE_TTL: Duration = Duration::from_secs(60 * 60);
22
23#[derive(Debug, Deserialize)]
24struct JwkSet {
25    keys: Vec<Jwk>,
26}
27
28#[derive(Debug, Clone, Deserialize)]
29struct Jwk {
30    kid: String,
31    n: String,
32    e: String,
33}
34
35struct Cached {
36    keys: HashMap<String, Jwk>,
37    fetched_at: Instant,
38    ttl: Duration,
39}
40
41/// Fetches and caches Google's public keys used to verify Firebase ID tokens.
42///
43/// Concurrent lookups that miss the cache at the same time (e.g. a burst of
44/// `verify_id_token` calls right after Google rotates its signing keys) share
45/// a single in-flight refresh rather than each issuing their own HTTP
46/// request: an internal lock is held for the duration of the fetch, so
47/// callers that arrive while a refresh is already underway simply wait for
48/// it to finish and then re-check the now-populated cache.
49pub struct JwksCache {
50    url: String,
51    http: HttpClient,
52    cache: RwLock<Option<Cached>>,
53    refresh_lock: Mutex<()>,
54}
55
56impl JwksCache {
57    /// Creates a cache pointed at the standard securetoken JWKS endpoint.
58    pub fn new(http: HttpClient) -> Self {
59        Self {
60            url: SECURETOKEN_JWKS_URL.to_string(),
61            http,
62            cache: RwLock::new(None),
63            refresh_lock: Mutex::new(()),
64        }
65    }
66
67    /// Returns the RSA public key components (`n`, `e`, base64url-encoded)
68    /// for the given key id, fetching or refreshing the cache as needed.
69    pub async fn public_key(&self, kid: &str) -> Result<(String, String), TokenVerificationError> {
70        if let Some((n, e)) = self.cached_key(kid) {
71            return Ok((n, e));
72        }
73
74        let _guard = self.refresh_lock.lock().await;
75
76        // Another caller may have refreshed the cache while we were waiting
77        // for the lock; re-check before issuing a redundant request.
78        if let Some((n, e)) = self.cached_key(kid) {
79            return Ok((n, e));
80        }
81
82        self.refresh().await?;
83
84        self.cached_key(kid)
85            .ok_or(TokenVerificationError::InvalidSignature)
86    }
87
88    fn cached_key(&self, kid: &str) -> Option<(String, String)> {
89        let guard = self.cache.read().ok()?;
90        let cached = guard.as_ref()?;
91        if cached.fetched_at.elapsed() > cached.ttl {
92            return None;
93        }
94        cached
95            .keys
96            .get(kid)
97            .map(|jwk| (jwk.n.clone(), jwk.e.clone()))
98    }
99
100    async fn refresh(&self) -> Result<(), TokenVerificationError> {
101        let response = self
102            .http
103            .inner()
104            .get(&self.url)
105            .send()
106            .await
107            .map_err(|e| TokenVerificationError::Jwks(e.to_string()))?;
108
109        let ttl = response
110            .headers()
111            .get(reqwest::header::CACHE_CONTROL)
112            .and_then(|v| v.to_str().ok())
113            .and_then(parse_cache_control_max_age)
114            .unwrap_or(MIN_CACHE_TTL);
115
116        let jwk_set: JwkSet = response
117            .json()
118            .await
119            .map_err(|e| TokenVerificationError::Jwks(e.to_string()))?;
120
121        let keys = jwk_set
122            .keys
123            .into_iter()
124            .map(|jwk| (jwk.kid.clone(), jwk))
125            .collect();
126
127        let mut guard = self
128            .cache
129            .write()
130            .map_err(|_| TokenVerificationError::Jwks("jwks cache lock poisoned".to_string()))?;
131        *guard = Some(Cached {
132            keys,
133            fetched_at: Instant::now(),
134            ttl,
135        });
136
137        Ok(())
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use wiremock::matchers::{method, path};
145    use wiremock::{Mock, MockServer, ResponseTemplate};
146
147    fn sample_jwk_set_body() -> serde_json::Value {
148        serde_json::json!({
149            "keys": [
150                {
151                    "kid": "key-1",
152                    "kty": "RSA",
153                    "alg": "RS256",
154                    "use": "sig",
155                    "n": "test-n-value",
156                    "e": "AQAB",
157                }
158            ]
159        })
160    }
161
162    async fn cache_pointed_at(server: &MockServer) -> JwksCache {
163        let http = HttpClient::default();
164        JwksCache {
165            url: format!("{}/jwks", server.uri()),
166            http,
167            cache: RwLock::new(None),
168            refresh_lock: Mutex::new(()),
169        }
170    }
171
172    #[tokio::test]
173    async fn fetches_and_returns_a_known_key() {
174        let server = MockServer::start().await;
175        Mock::given(method("GET"))
176            .and(path("/jwks"))
177            .respond_with(ResponseTemplate::new(200).set_body_json(sample_jwk_set_body()))
178            .expect(1)
179            .mount(&server)
180            .await;
181
182        let cache = cache_pointed_at(&server).await;
183        let (n, e) = cache.public_key("key-1").await.unwrap();
184        assert_eq!(n, "test-n-value");
185        assert_eq!(e, "AQAB");
186    }
187
188    #[tokio::test]
189    async fn unknown_kid_after_refresh_is_invalid_signature() {
190        let server = MockServer::start().await;
191        Mock::given(method("GET"))
192            .and(path("/jwks"))
193            .respond_with(ResponseTemplate::new(200).set_body_json(sample_jwk_set_body()))
194            .mount(&server)
195            .await;
196
197        let cache = cache_pointed_at(&server).await;
198        let err = cache.public_key("does-not-exist").await.unwrap_err();
199        assert!(matches!(err, TokenVerificationError::InvalidSignature));
200    }
201
202    #[tokio::test]
203    async fn a_second_lookup_uses_the_cache_and_does_not_refetch() {
204        let server = MockServer::start().await;
205        Mock::given(method("GET"))
206            .and(path("/jwks"))
207            .respond_with(ResponseTemplate::new(200).set_body_json(sample_jwk_set_body()))
208            .expect(1)
209            .mount(&server)
210            .await;
211
212        let cache = cache_pointed_at(&server).await;
213        cache.public_key("key-1").await.unwrap();
214        cache.public_key("key-1").await.unwrap();
215    }
216
217    #[tokio::test]
218    async fn an_expired_cache_entry_triggers_a_refetch() {
219        let server = MockServer::start().await;
220        Mock::given(method("GET"))
221            .and(path("/jwks"))
222            .respond_with(ResponseTemplate::new(200).set_body_json(sample_jwk_set_body()))
223            .expect(2) // one fetch to seed, one to observe the actual refetch
224            .mount(&server)
225            .await;
226
227        let cache = cache_pointed_at(&server).await;
228
229        // Seed the cache normally, then backdate `fetched_at` so the entry
230        // reads as already past its TTL — proving `cached_key` treats a
231        // stale entry as a miss and `public_key` refetches, not just that a
232        // cold cache fetches once.
233        cache.public_key("key-1").await.unwrap();
234        {
235            let mut guard = cache.cache.write().unwrap();
236            let cached = guard.as_mut().unwrap();
237            cached.fetched_at = Instant::now() - cached.ttl - Duration::from_secs(1);
238        }
239
240        cache.public_key("key-1").await.unwrap();
241    }
242
243    #[tokio::test]
244    async fn malformed_response_body_surfaces_as_jwks_error() {
245        let server = MockServer::start().await;
246        Mock::given(method("GET"))
247            .and(path("/jwks"))
248            .respond_with(ResponseTemplate::new(200).set_body_string("not json"))
249            .mount(&server)
250            .await;
251
252        let cache = cache_pointed_at(&server).await;
253        let err = cache.public_key("key-1").await.unwrap_err();
254        assert!(matches!(err, TokenVerificationError::Jwks(_)));
255    }
256}