use crate::auth::error::TokenVerificationError;
use crate::core::http::parse_cache_control_max_age;
use crate::core::HttpClient;
use std::collections::HashMap;
use std::sync::RwLock;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use x509_parser::prelude::{FromDer, X509Certificate};
const SESSION_COOKIE_CERTS_URL: &str =
"https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys";
const MIN_CACHE_TTL: Duration = Duration::from_secs(60 * 60);
struct Cached {
keys: HashMap<String, (String, String)>,
fetched_at: Instant,
ttl: Duration,
}
pub struct SessionCookieCertCache {
url: String,
http: HttpClient,
cache: RwLock<Option<Cached>>,
refresh_lock: Mutex<()>,
}
impl SessionCookieCertCache {
pub fn new(http: HttpClient) -> Self {
Self {
url: SESSION_COOKIE_CERTS_URL.to_string(),
http,
cache: RwLock::new(None),
refresh_lock: Mutex::new(()),
}
}
pub async fn public_key(&self, kid: &str) -> Result<(String, String), TokenVerificationError> {
if let Some(key) = self.cached_key(kid) {
return Ok(key);
}
let _guard = self.refresh_lock.lock().await;
if let Some(key) = self.cached_key(kid) {
return Ok(key);
}
self.refresh().await?;
self.cached_key(kid)
.ok_or(TokenVerificationError::InvalidSignature)
}
fn cached_key(&self, kid: &str) -> Option<(String, String)> {
let guard = self.cache.read().ok()?;
let cached = guard.as_ref()?;
if cached.fetched_at.elapsed() > cached.ttl {
return None;
}
cached.keys.get(kid).cloned()
}
async fn refresh(&self) -> Result<(), TokenVerificationError> {
let response = self
.http
.inner()
.get(&self.url)
.send()
.await
.map_err(|e| TokenVerificationError::Jwks(e.to_string()))?;
let ttl = response
.headers()
.get(reqwest::header::CACHE_CONTROL)
.and_then(|v| v.to_str().ok())
.and_then(parse_cache_control_max_age)
.unwrap_or(MIN_CACHE_TTL);
let certs: HashMap<String, String> = response
.json()
.await
.map_err(|e| TokenVerificationError::Jwks(e.to_string()))?;
let mut keys = HashMap::with_capacity(certs.len());
for (kid, pem) in certs {
let (n, e) = rsa_components_from_certificate_pem(&pem)?;
keys.insert(kid, (n, e));
}
let mut guard = self
.cache
.write()
.map_err(|_| TokenVerificationError::Jwks("cert cache lock poisoned".to_string()))?;
*guard = Some(Cached {
keys,
fetched_at: Instant::now(),
ttl,
});
Ok(())
}
}
fn rsa_components_from_certificate_pem(
pem: &str,
) -> Result<(String, String), TokenVerificationError> {
use base64::Engine;
let (_, pem) = x509_parser::pem::parse_x509_pem(pem.as_bytes())
.map_err(|e| TokenVerificationError::Jwks(format!("invalid certificate PEM: {e}")))?;
let (_, cert) = X509Certificate::from_der(&pem.contents)
.map_err(|e| TokenVerificationError::Jwks(format!("invalid certificate DER: {e}")))?;
let spki = &cert.tbs_certificate.subject_pki;
let public_key = spki
.parsed()
.map_err(|e| TokenVerificationError::Jwks(format!("unsupported public key: {e}")))?;
let rsa_key = match public_key {
x509_parser::public_key::PublicKey::RSA(rsa) => rsa,
other => {
return Err(TokenVerificationError::Jwks(format!(
"expected an RSA public key, found {other:?}"
)))
}
};
let n = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(strip_leading_zero(rsa_key.modulus));
let e = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(strip_leading_zero(rsa_key.exponent));
Ok((n, e))
}
fn strip_leading_zero(bytes: &[u8]) -> &[u8] {
match bytes {
[0x00, rest @ ..] if !rest.is_empty() => rest,
_ => bytes,
}
}
#[cfg(test)]
mod tests {
use super::*;
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const TEST_PRIVATE_KEY_PEM: &str = include_str!("../../../tests/fixtures/test_private_key.pem");
const TEST_CERT_PEM: &str = include_str!("../../../tests/fixtures/test_cert.pem");
#[test]
fn extracts_the_correct_rsa_public_key_from_a_certificate() {
let (n, e) = rsa_components_from_certificate_pem(TEST_CERT_PEM).unwrap();
let claims = json!({
"sub": "someone",
"iss": "issuer",
"aud": "audience",
"exp": 9_999_999_999i64,
});
let signing_key = EncodingKey::from_rsa_pem(TEST_PRIVATE_KEY_PEM.as_bytes()).unwrap();
let token = encode(&Header::new(Algorithm::RS256), &claims, &signing_key).unwrap();
let decoding_key = DecodingKey::from_rsa_components(&n, &e).unwrap();
let mut validation = Validation::new(Algorithm::RS256);
validation.set_audience(&["audience"]);
validation.set_issuer(&["issuer"]);
decode::<serde_json::Value>(&token, &decoding_key, &validation)
.expect("token should verify against the key extracted from the certificate");
}
#[test]
fn rejects_a_malformed_certificate() {
let err = rsa_components_from_certificate_pem("not a certificate").unwrap_err();
assert!(matches!(err, TokenVerificationError::Jwks(_)));
}
#[tokio::test]
async fn fetches_and_caches_certs_from_the_endpoint() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/certs"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({ "test-kid": TEST_CERT_PEM })),
)
.expect(1)
.mount(&server)
.await;
let cache = SessionCookieCertCache {
url: format!("{}/certs", server.uri()),
http: HttpClient::default(),
cache: RwLock::new(None),
refresh_lock: Mutex::new(()),
};
let (n, e) = cache.public_key("test-kid").await.unwrap();
let expected = rsa_components_from_certificate_pem(TEST_CERT_PEM).unwrap();
assert_eq!((n, e), expected);
cache.public_key("test-kid").await.unwrap();
}
}