Skip to main content

firebase_admin/auth/session_cookie/
certs.rs

1//! Fetching and caching Google's X.509 signing certificates used to verify
2//! Firebase session cookies.
3//!
4//! Session cookies are verified against a different key set than ID tokens:
5//! `https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys`,
6//! which returns a flat `{ "<kid>": "<PEM-encoded X.509 certificate>" }`
7//! object rather than a JWK Set. This was confirmed by inspecting both the
8//! official Node.js and Python Admin SDKs (`COOKIE_CERT_URI` /
9//! `ID_TOKEN_CERT_URI` in `token-verifier.ts` / `_token_gen.py`) and by
10//! fetching the endpoint directly, since a Firebase-Admin-SDK-agnostic RS256
11//! JWK Set is *also* published for ID tokens at a different URL and it would
12//! be easy to wrongly assume both token types share one key set.
13
14use crate::auth::error::TokenVerificationError;
15use crate::core::http::parse_cache_control_max_age;
16use crate::core::HttpClient;
17use std::collections::HashMap;
18use std::sync::RwLock;
19use std::time::{Duration, Instant};
20use tokio::sync::Mutex;
21use x509_parser::prelude::{FromDer, X509Certificate};
22
23const SESSION_COOKIE_CERTS_URL: &str =
24    "https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys";
25
26/// The minimum amount of time a cached certificate set is trusted before a
27/// refresh is attempted, even if the response did not specify a
28/// `Cache-Control` max-age.
29const MIN_CACHE_TTL: Duration = Duration::from_secs(60 * 60);
30
31struct Cached {
32    /// RSA public key components (`n`, `e`, base64url-encoded), extracted
33    /// from each certificate's SubjectPublicKeyInfo, keyed by `kid`.
34    keys: HashMap<String, (String, String)>,
35    fetched_at: Instant,
36    ttl: Duration,
37}
38
39/// Fetches and caches the X.509 certificates Google publishes for verifying
40/// Firebase session cookies.
41///
42/// Mirrors [`crate::auth::id_token::JwksCache`]'s single-flight refresh
43/// design: concurrent lookups that miss the cache at the same time share one
44/// in-flight fetch rather than each issuing their own HTTP request.
45pub struct SessionCookieCertCache {
46    url: String,
47    http: HttpClient,
48    cache: RwLock<Option<Cached>>,
49    refresh_lock: Mutex<()>,
50}
51
52impl SessionCookieCertCache {
53    /// Creates a cache pointed at the standard session-cookie certs endpoint.
54    pub fn new(http: HttpClient) -> Self {
55        Self {
56            url: SESSION_COOKIE_CERTS_URL.to_string(),
57            http,
58            cache: RwLock::new(None),
59            refresh_lock: Mutex::new(()),
60        }
61    }
62
63    /// Returns the RSA public key components (`n`, `e`, base64url-encoded)
64    /// for the given key id, fetching or refreshing the cache as needed.
65    pub async fn public_key(&self, kid: &str) -> Result<(String, String), TokenVerificationError> {
66        if let Some(key) = self.cached_key(kid) {
67            return Ok(key);
68        }
69
70        let _guard = self.refresh_lock.lock().await;
71
72        if let Some(key) = self.cached_key(kid) {
73            return Ok(key);
74        }
75
76        self.refresh().await?;
77
78        self.cached_key(kid)
79            .ok_or(TokenVerificationError::InvalidSignature)
80    }
81
82    fn cached_key(&self, kid: &str) -> Option<(String, String)> {
83        let guard = self.cache.read().ok()?;
84        let cached = guard.as_ref()?;
85        if cached.fetched_at.elapsed() > cached.ttl {
86            return None;
87        }
88        cached.keys.get(kid).cloned()
89    }
90
91    async fn refresh(&self) -> Result<(), TokenVerificationError> {
92        let response = self
93            .http
94            .inner()
95            .get(&self.url)
96            .send()
97            .await
98            .map_err(|e| TokenVerificationError::Jwks(e.to_string()))?;
99
100        let ttl = response
101            .headers()
102            .get(reqwest::header::CACHE_CONTROL)
103            .and_then(|v| v.to_str().ok())
104            .and_then(parse_cache_control_max_age)
105            .unwrap_or(MIN_CACHE_TTL);
106
107        let certs: HashMap<String, String> = response
108            .json()
109            .await
110            .map_err(|e| TokenVerificationError::Jwks(e.to_string()))?;
111
112        let mut keys = HashMap::with_capacity(certs.len());
113        for (kid, pem) in certs {
114            let (n, e) = rsa_components_from_certificate_pem(&pem)?;
115            keys.insert(kid, (n, e));
116        }
117
118        let mut guard = self
119            .cache
120            .write()
121            .map_err(|_| TokenVerificationError::Jwks("cert cache lock poisoned".to_string()))?;
122        *guard = Some(Cached {
123            keys,
124            fetched_at: Instant::now(),
125            ttl,
126        });
127
128        Ok(())
129    }
130}
131
132/// Extracts the RSA public key modulus (`n`) and exponent (`e`), both
133/// base64url-encoded, from a PEM-encoded X.509 certificate's
134/// SubjectPublicKeyInfo.
135///
136/// Deliberately uses a real X.509 parser (`x509-parser`) rather than feeding
137/// the certificate PEM directly to `jsonwebtoken::DecodingKey::from_rsa_pem`:
138/// that function does not parse the X.509 `Certificate` structure at all —
139/// it walks the raw DER for the first RSA/EC/Ed25519 OID it finds and treats
140/// everything after it as key material, which happens to locate the right
141/// bytes for typical certificates but isn't a structural guarantee. Parsing
142/// the certificate properly and extracting `tbs_certificate.subject_pki`
143/// removes that ambiguity for a security-critical verification path.
144fn rsa_components_from_certificate_pem(
145    pem: &str,
146) -> Result<(String, String), TokenVerificationError> {
147    use base64::Engine;
148
149    let (_, pem) = x509_parser::pem::parse_x509_pem(pem.as_bytes())
150        .map_err(|e| TokenVerificationError::Jwks(format!("invalid certificate PEM: {e}")))?;
151    let (_, cert) = X509Certificate::from_der(&pem.contents)
152        .map_err(|e| TokenVerificationError::Jwks(format!("invalid certificate DER: {e}")))?;
153
154    let spki = &cert.tbs_certificate.subject_pki;
155    let public_key = spki
156        .parsed()
157        .map_err(|e| TokenVerificationError::Jwks(format!("unsupported public key: {e}")))?;
158
159    let rsa_key = match public_key {
160        x509_parser::public_key::PublicKey::RSA(rsa) => rsa,
161        other => {
162            return Err(TokenVerificationError::Jwks(format!(
163                "expected an RSA public key, found {other:?}"
164            )))
165        }
166    };
167
168    let n = base64::engine::general_purpose::URL_SAFE_NO_PAD
169        .encode(strip_leading_zero(rsa_key.modulus));
170    let e = base64::engine::general_purpose::URL_SAFE_NO_PAD
171        .encode(strip_leading_zero(rsa_key.exponent));
172    Ok((n, e))
173}
174
175/// DER `INTEGER` encoding prepends a `0x00` byte when the most-significant
176/// bit of the first "real" byte would otherwise be mistaken for a sign bit.
177/// JWK's `n`/`e` fields are unsigned big-endian integers with no such
178/// padding, so it must be stripped before base64url-encoding — leaving it in
179/// would corrupt every RSA key whose modulus/exponent happens to have a
180/// leading 1 bit (i.e. most real-world RSA moduli).
181fn strip_leading_zero(bytes: &[u8]) -> &[u8] {
182    match bytes {
183        [0x00, rest @ ..] if !rest.is_empty() => rest,
184        _ => bytes,
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
192    use serde_json::json;
193    use wiremock::matchers::{method, path};
194    use wiremock::{Mock, MockServer, ResponseTemplate};
195
196    const TEST_PRIVATE_KEY_PEM: &str = include_str!("../../../tests/fixtures/test_private_key.pem");
197    /// A self-signed X.509 certificate generated from `TEST_PRIVATE_KEY_PEM`
198    /// (`openssl req -new -x509 -key test_private_key.pem ...`), in the same
199    /// PEM shape Google's session-cookie certs endpoint returns.
200    const TEST_CERT_PEM: &str = include_str!("../../../tests/fixtures/test_cert.pem");
201
202    #[test]
203    fn extracts_the_correct_rsa_public_key_from_a_certificate() {
204        let (n, e) = rsa_components_from_certificate_pem(TEST_CERT_PEM).unwrap();
205
206        // Prove the extracted (n, e) are the *correct* key, not just
207        // syntactically well-formed: sign a token with the certificate's
208        // matching private key, then verify it using only the
209        // freshly-extracted components. If `strip_leading_zero` or the SPKI
210        // extraction were subtly wrong, this round trip would fail.
211        let claims = json!({
212            "sub": "someone",
213            "iss": "issuer",
214            "aud": "audience",
215            "exp": 9_999_999_999i64,
216        });
217        let signing_key = EncodingKey::from_rsa_pem(TEST_PRIVATE_KEY_PEM.as_bytes()).unwrap();
218        let token = encode(&Header::new(Algorithm::RS256), &claims, &signing_key).unwrap();
219
220        let decoding_key = DecodingKey::from_rsa_components(&n, &e).unwrap();
221        let mut validation = Validation::new(Algorithm::RS256);
222        validation.set_audience(&["audience"]);
223        validation.set_issuer(&["issuer"]);
224        decode::<serde_json::Value>(&token, &decoding_key, &validation)
225            .expect("token should verify against the key extracted from the certificate");
226    }
227
228    #[test]
229    fn rejects_a_malformed_certificate() {
230        let err = rsa_components_from_certificate_pem("not a certificate").unwrap_err();
231        assert!(matches!(err, TokenVerificationError::Jwks(_)));
232    }
233
234    #[tokio::test]
235    async fn fetches_and_caches_certs_from_the_endpoint() {
236        let server = MockServer::start().await;
237        Mock::given(method("GET"))
238            .and(path("/certs"))
239            .respond_with(
240                ResponseTemplate::new(200).set_body_json(json!({ "test-kid": TEST_CERT_PEM })),
241            )
242            .expect(1)
243            .mount(&server)
244            .await;
245
246        let cache = SessionCookieCertCache {
247            url: format!("{}/certs", server.uri()),
248            http: HttpClient::default(),
249            cache: RwLock::new(None),
250            refresh_lock: Mutex::new(()),
251        };
252
253        let (n, e) = cache.public_key("test-kid").await.unwrap();
254        let expected = rsa_components_from_certificate_pem(TEST_CERT_PEM).unwrap();
255        assert_eq!((n, e), expected);
256
257        // Second lookup should hit the cache, not the mock's `expect(1)`.
258        cache.public_key("test-kid").await.unwrap();
259    }
260}