firebase_admin/auth/id_token/
jwks.rs1use 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
19const 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
41pub struct JwksCache {
50 url: String,
51 http: HttpClient,
52 cache: RwLock<Option<Cached>>,
53 refresh_lock: Mutex<()>,
54}
55
56impl JwksCache {
57 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 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 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) .mount(&server)
225 .await;
226
227 let cache = cache_pointed_at(&server).await;
228
229 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}