use jerrycan_core::http::StatusCode;
use jerrycan_core::{Error, Result};
use serde::Deserialize;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
const DEFAULT_JWKS_TTL: Duration = Duration::from_secs(3600);
const DEFAULT_LEEWAY_SECS: u64 = 60;
const DEFAULT_MIN_REFETCH: Duration = Duration::from_secs(60);
fn unauth(message: impl Into<String>) -> Error {
Error::new(StatusCode::UNAUTHORIZED, "JC0401", message)
}
#[derive(Clone, Debug, Deserialize)]
pub struct Jwk {
pub kty: String,
#[serde(default)]
pub kid: Option<String>,
#[serde(default)]
pub alg: Option<String>,
#[serde(default)]
pub n: Option<String>,
#[serde(default)]
pub e: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct Jwks {
pub keys: Vec<Jwk>,
}
impl Jwks {
fn find(&self, kid: &str) -> Option<Jwk> {
self.keys
.iter()
.find(|k| k.kty.eq_ignore_ascii_case("RSA") && k.kid.as_deref() == Some(kid))
.cloned()
}
}
pub type JwksFuture<'a> = Pin<Box<dyn Future<Output = Result<Jwks>> + Send + 'a>>;
pub trait JwksSource: Send + Sync {
fn fetch<'a>(&'a self, jwks_uri: &'a str) -> JwksFuture<'a>;
}
#[derive(Clone, Debug, Deserialize)]
pub struct IdTokenClaims {
pub sub: String,
pub iss: String,
pub exp: u64,
#[serde(default)]
pub iat: Option<u64>,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub nonce: Option<String>,
#[serde(flatten)]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl IdTokenClaims {
pub fn email_verified(&self) -> bool {
match self.extra.get("email_verified") {
Some(serde_json::Value::Bool(b)) => *b,
Some(serde_json::Value::String(s)) => s.eq_ignore_ascii_case("true"),
_ => false,
}
}
}
struct CacheEntry {
jwks: Jwks,
fetched_at: Instant,
}
#[derive(Clone)]
pub struct Verifier {
issuers: Vec<String>,
jwks_uri: String,
audiences: Vec<String>,
leeway_secs: u64,
expected_nonce: Option<String>,
ttl: Duration,
min_refetch: Duration,
source: Arc<dyn JwksSource>,
cache: Arc<Mutex<Option<CacheEntry>>>,
}
impl Verifier {
pub fn google(audience: impl Into<String>) -> Self {
Self::custom(
[
"https://accounts.google.com".to_string(),
"accounts.google.com".to_string(),
],
"https://www.googleapis.com/oauth2/v3/certs",
[audience.into()],
)
}
pub fn apple(audience: impl Into<String>) -> Self {
Self::custom(
["https://appleid.apple.com".to_string()],
"https://appleid.apple.com/auth/keys",
[audience.into()],
)
}
pub fn custom(
issuers: impl IntoIterator<Item = String>,
jwks_uri: impl Into<String>,
audiences: impl IntoIterator<Item = String>,
) -> Self {
Self {
issuers: issuers.into_iter().collect(),
jwks_uri: jwks_uri.into(),
audiences: audiences.into_iter().collect(),
leeway_secs: DEFAULT_LEEWAY_SECS,
expected_nonce: None,
ttl: DEFAULT_JWKS_TTL,
min_refetch: DEFAULT_MIN_REFETCH,
source: Arc::new(HttpJwksSource::new()),
cache: Arc::new(Mutex::new(None)),
}
}
#[must_use]
pub fn with_source(mut self, source: Arc<dyn JwksSource>) -> Self {
self.source = source;
self
}
#[must_use]
pub fn with_ttl(mut self, ttl: Duration) -> Self {
self.ttl = ttl;
self
}
#[must_use]
pub fn with_leeway(mut self, secs: u64) -> Self {
self.leeway_secs = secs;
self
}
#[must_use]
pub fn with_min_refetch_interval(mut self, interval: Duration) -> Self {
self.min_refetch = interval;
self
}
#[must_use]
pub fn require_nonce(mut self, nonce: impl Into<String>) -> Self {
self.expected_nonce = Some(nonce.into());
self
}
#[must_use]
pub fn add_audience(mut self, audience: impl Into<String>) -> Self {
self.audiences.push(audience.into());
self
}
pub async fn verify(&self, token: &str) -> Result<IdTokenClaims> {
let header = decode_header(token).map_err(|_| unauth("id token: malformed JWS header"))?;
if header.alg != Algorithm::RS256 {
return Err(unauth("id token: unsupported alg (only RS256 is accepted)"));
}
let kid = header
.kid
.ok_or_else(|| unauth("id token: JWS header has no kid"))?;
let jwk = self.key_for_kid(&kid).await?;
let decoding_key = decoding_key(&jwk)?;
let mut validation = Validation::new(Algorithm::RS256);
validation.set_issuer(&self.issuers);
validation.set_audience(&self.audiences);
validation.leeway = self.leeway_secs;
validation.validate_exp = true;
validation.validate_nbf = true;
let data =
decode::<IdTokenClaims>(token, &decoding_key, &validation).map_err(map_jwt_error)?;
let claims = data.claims;
if let Some(expected) = &self.expected_nonce
&& claims.nonce.as_deref() != Some(expected.as_str())
{
return Err(unauth("id token: nonce mismatch"));
}
Ok(claims)
}
async fn key_for_kid(&self, kid: &str) -> Result<Jwk> {
enum Plan {
Use(Jwk),
Refetch,
Fail,
}
let plan = {
let guard = self.cache.lock().expect("jwks cache mutex poisoned");
match guard.as_ref() {
None => Plan::Refetch,
Some(entry) => {
let age = entry.fetched_at.elapsed();
match entry.jwks.find(kid) {
Some(jwk) if age < self.ttl => Plan::Use(jwk),
Some(_) => Plan::Refetch,
None if age >= self.min_refetch => Plan::Refetch,
None => Plan::Fail,
}
}
}
};
match plan {
Plan::Use(jwk) => Ok(jwk),
Plan::Fail => Err(unauth("id token: no JWKS key matches the token kid")),
Plan::Refetch => {
let jwks = self.source.fetch(&self.jwks_uri).await?;
let found = jwks.find(kid);
*self.cache.lock().expect("jwks cache mutex poisoned") = Some(CacheEntry {
jwks,
fetched_at: Instant::now(),
});
found.ok_or_else(|| unauth("id token: no JWKS key matches the token kid"))
}
}
}
}
fn decoding_key(jwk: &Jwk) -> Result<DecodingKey> {
if !jwk.kty.eq_ignore_ascii_case("RSA") {
return Err(unauth("id token: matched JWKS key is not RSA"));
}
let (Some(n), Some(e)) = (&jwk.n, &jwk.e) else {
return Err(unauth("id token: RSA JWK is missing modulus/exponent"));
};
DecodingKey::from_rsa_components(n, e)
.map_err(|_| unauth("id token: RSA JWK has invalid modulus/exponent"))
}
fn map_jwt_error(e: jsonwebtoken::errors::Error) -> Error {
use jsonwebtoken::errors::ErrorKind;
let reason = match e.kind() {
ErrorKind::InvalidToken => "malformed token",
ErrorKind::InvalidSignature => "bad signature",
ErrorKind::InvalidIssuer => "wrong issuer",
ErrorKind::InvalidAudience => "wrong audience",
ErrorKind::ExpiredSignature => "expired",
ErrorKind::ImmatureSignature => "not yet valid (nbf)",
ErrorKind::InvalidAlgorithm => "wrong algorithm",
ErrorKind::MissingRequiredClaim(_) => "missing a required claim",
_ => "verification failed",
};
unauth(format!("id token: {reason}"))
}
fn fetch_url_allowed(url: &str) -> bool {
let Some((scheme, rest)) = url.split_once("://") else {
return false;
};
if scheme.eq_ignore_ascii_case("https") {
return true;
}
if !scheme.eq_ignore_ascii_case("http") {
return false;
}
let authority = rest
.split(['/', '?', '#'])
.next()
.expect("split always yields at least one element");
if authority.contains('@') {
return false;
}
let host = if let Some(after) = authority.strip_prefix('[') {
match after.split_once(']') {
Some((inner, _port)) => inner,
None => return false,
}
} else {
authority.rsplit_once(':').map_or(authority, |(h, _)| h)
};
host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
}
#[derive(Clone)]
pub struct HttpJwksSource {
client: hyper_util::client::legacy::Client<
hyper_rustls::HttpsConnector<hyper_util::client::legacy::connect::HttpConnector>,
http_body_util::Full<bytes::Bytes>,
>,
}
impl Default for HttpJwksSource {
fn default() -> Self {
Self::new()
}
}
impl HttpJwksSource {
pub fn new() -> Self {
let connector = hyper_rustls::HttpsConnectorBuilder::new()
.with_provider_and_webpki_roots(rustls::crypto::ring::default_provider())
.expect("ring provider supports rustls' safe default protocol versions")
.https_or_http()
.enable_http1()
.build();
let client =
hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new())
.build(connector);
Self { client }
}
}
impl JwksSource for HttpJwksSource {
fn fetch<'a>(&'a self, jwks_uri: &'a str) -> JwksFuture<'a> {
Box::pin(async move {
use http_body_util::BodyExt;
if !fetch_url_allowed(jwks_uri) {
return Err(Error::internal(
"id token: refusing to fetch a JWKS over plaintext http:// from a non-loopback host",
));
}
let request = hyper::Request::builder()
.method(hyper::Method::GET)
.uri(jwks_uri)
.header(hyper::header::ACCEPT, "application/json")
.body(http_body_util::Full::new(bytes::Bytes::new()))
.map_err(|e| {
Error::internal(format!("id token: building JWKS request failed: {e}"))
})?;
let response = self
.client
.request(request)
.await
.map_err(|_| Error::internal("id token: JWKS request failed"))?;
if !response.status().is_success() {
return Err(Error::internal(format!(
"id token: JWKS endpoint returned status {}",
response.status().as_u16()
)));
}
let bytes = response
.into_body()
.collect()
.await
.map_err(|_| Error::internal("id token: reading JWKS body failed"))?
.to_bytes();
serde_json::from_slice::<Jwks>(&bytes)
.map_err(|_| Error::internal("id token: JWKS body was not a valid JSON key set"))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use jsonwebtoken::{EncodingKey, Header, encode};
use serde_json::json;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
const K1_PEM: &[u8] = b"-----BEGIN RSA PRIVATE KEY-----
MIIEogIBAAKCAQEAsK9jEh54JKNv1D2tz5Xm6fkwU2vGqQHcCaciHJFF7XupV6BU
2UjYnFFBkoUoal8K6DL20nA6zQfLf2TO39Q+5rn+lLPOLVInaw4suFRcUuHqtP++
sFPauStGC2liskWu2Z+lqb8lllJSBnpyu0PPGE8jclM2OydUlGW7+RwZjEJPSMGX
3c2D8b152uT09Yb1vonv4IJv+a5qDS6+gqcQJsXUIBnEyFHa1ci7hA4vkDsgln0+
V/pb3ot5BdO8me50Z0XoTtcGVTv/ZMVutreMlboxEwbhGvM83sh0jhG8L0gUswcu
wiWfs97pRe3QBaXt4HZaDVV0sViaaV3MYiDo4wIDAQABAoIBAE3qIfm5Bwk9K5EA
XBgZRjmyol1/Px2DjOmS0wefBqPJ7y0Nrq0dIyyX5p866k4yOGiaJN87D6sfv10f
8tygx9ZOehZQTmENBAYBO7ZTuVzxdGO6DfjLGb6jdyGMKTJtaURd0xvOh8BI8BQc
RmEPb5GMQJjnWhhu6S0Bygl6G0gOrrCdVVGOiZFo0Wt/jewucbqGaELTzn/T7E7x
8FcRSV9lar0IKZ+xquzcbroK5oTf1vgA5mQavAtmwavzkRP9bjCmPSVfkXkQ9LIb
1r3QwAA0poKwp4YLuEC6+ZX2FhliMoxQDqam3NnmQW6Nxxnlvl/2YDDRSGoCjyTQ
owq7jaUCgYEA8ddpMIyvrW8LEQk0nHfR89h9erHXSqhg2aeKt7aYKKP5p+SK2fy4
SfLpD3VtRNhE12y5+MKXqDVDJ41W17R9SHm/RkWWTb6Yzxp+s9Dq/2mePZfHBz7l
V3Zj834z7b7pT8dgijUJF22Fl4QCGT4h/bD6kPALe3O15ahTZIr9Bj0CgYEAuwdy
r5BB63gPOrJ9xG6pt/4LMWUAtgCrZBcxOYEps0PT+tqzmwRnS765vvJuuPupEDZP
r1tLN82p+hAN+62wnF/uh62g4DIYeYyqobVocLG9QwcKRqx0Ku4Y6o1dX8Ecyopy
bgPllhUCTxaoo9bmDRwAq/Be0ZysTWVXInvLvZ8CgYAg8/svVFwzw6e8YIa8s072
bQ9cApOVZrAbuEqckdLV5tID4I5S+a6a1PCQ3K1Q7i8jM3t7u/gyQV+vKgElT0Cq
+XvotV6vpULpJXESS2tZ9ihLuDy0bguOCWHBMfcddCAScNZkvqlIefH0HVaz3dV/
3femfC70WWX1ryP91Tp4+QKBgF8At4bqljGP+NxuEmiXdeqaRwE+NxA8YtMi3MRD
EfWXfLQuJ5GUuQvGw/90kj2wx/4OOIfwrdKYy8DUKuYvIkksibOtxMxdZgVIKNyf
k3+7KVJE3zlrHE86RrnOOSIMrB1OGjY8EIEeBuA5uEwROyZplQXBwchj9zoRQiOo
EqQtAoGAENCYQnAdXCvnFB8GewpA0wnhwXJq1yPMVKb/VxraCbnfqHiJT+SUnSaG
x3D27dr7bFAItNBGtGJ/X6+fCxsrYukKrSDrb3I6XSqpFCzo5UHwitRz7dQQOyYt
87SPayioJDSeAytxSLT639U/7N8vCeTxB9SvgZRk40a5kB50/Dw=
-----END RSA PRIVATE KEY-----";
const K1_N: &str = "sK9jEh54JKNv1D2tz5Xm6fkwU2vGqQHcCaciHJFF7XupV6BU2UjYnFFBkoUoal8K6DL20nA6zQfLf2TO39Q-5rn-lLPOLVInaw4suFRcUuHqtP--sFPauStGC2liskWu2Z-lqb8lllJSBnpyu0PPGE8jclM2OydUlGW7-RwZjEJPSMGX3c2D8b152uT09Yb1vonv4IJv-a5qDS6-gqcQJsXUIBnEyFHa1ci7hA4vkDsgln0-V_pb3ot5BdO8me50Z0XoTtcGVTv_ZMVutreMlboxEwbhGvM83sh0jhG8L0gUswcuwiWfs97pRe3QBaXt4HZaDVV0sViaaV3MYiDo4w";
const K1_E: &str = "AQAB";
const K2_PEM: &[u8] = b"-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA3T4yqTf7Jwd/kcP9FhPrD4wjwDxFWRG52RW3dxZJeYmGVxMU
2SlxaOFCOBbCY8HYu/t6SrE2M0PdPWBMl6PfaNuEWOz51SzUeQsA6vCWsp1zE95M
YQGsJXF1x1MXcSjUkghMC0zzcBpxMU+09L0gngFsDTZfeZSuSJH7bMdsZZanj2aV
N3YRn6bCd/jeFlMjurV6XnZTkUcKZU/0pIiSP17oCohU5ban3vzpIcVdWGAJUUz8
/PXLMuKxPxa1oiHhBqiYfunGZr6Md3gAWyjYmat0E4oKfpd1FOmYAigXABNvSiJB
orWNxyRM1Phuqjmqjo8gj9nU1OTh/NBT+hkrUwIDAQABAoIBAAtqlxxsR4kEfSWZ
woijVnubyd5C6JaEBd4nK9YxwY1LhMADSdUCAK3syqWF2OhGq4/162keY9xEBY8D
5DcChG0u+G3KuuYFmqEtPtkZnKYBFz+Jb7TeM3KHn9dZsdnA/oG+JaO8FiAwi1XZ
Z4dCYHXVBrs7XF8z8OF3jhQCAtV5SIJiJjUKtYgqaxWb6w4GFBSZX3Br7ZxsgkEt
lnIPh/YUI733J91zbWxN5mQ7FLyeCGFxah+K0QwoEZwKKXvlzlSQKYLL7AKLOXDX
qlgt0E0vB5PTxndNx65nja8vzYuQLQw8LW18CobIpbEeEy6y/xLTAQf3gky5an15
Elt4H6ECgYEA/sLMVomyaOtKuT/Jt2MZUEgym00cLUO93TmPsUPrAz7pBklUG8nD
IuMfF8a3MUhJaio/fr2KsLA3bB/D8oQOcJ/5RcogowYSQlG5/rrlAL8wF01Ar5tT
fPdjHQZaJRbqHqsSirMh5X39xIYpnpSKYXlfhHecsc5rk7cziaDfPd8CgYEA3lGq
pvhiq1i/6mYvKw+6ufpKcmUsrUHnE7WAxWfmt3R+BUIlNrVJ3m5O/shSXQEtFn7D
I2cdaF+6nNvQ+z+jtKeH3pPOgr2wu1sNVYNi3E2YpIYKiR4XQ++OyKmzWBpjK66u
OM/XAzbpGHD5fB326iszSbXSYT/tIq+GQvTP2Q0CgYEAhCXxrrXweKIMeblP3jOm
btF0hsBh7EzmULnKAo6TenSIlX02BtAKy676cu/eGM9BXbOaihixt2NA7HIxxzue
7ebde8kUUtwUXphcHXk+zrtdq8ij1DODBCCjJewkmHahbNUaYh33aD6JgwaA0kSE
33kBBgqxmj3T6aSvNCXhhwsCgYEAp5vIXcOLmAT8A3rwerWMIGRLtj0C1siFrz06
jRmNPqhLzikVJ068Fz7wvXNHbSjS1k/RTKKT8Dmj1lh/ELzk7fEUJUEoAzeBw26c
+ehpIxA5UWhhDwkpnyU/b5dJR9X1CFzUqq4/OwQt7ihWXzW0Ds1tCFhU+M6aOHk+
bsJk5Q0CgYB0efHtGCHKSimoXGgwj0wsImqUYDuuVo00inG+8jBo1CEmVlO0Hk++
j4DUu0ODfOwEJSnCbqHKcuU1atUCMWzZzfsoP8JzJnemQwJV0FpDlIEqrRfR/S3i
f8sK4lep78Mx9ojs+u8a7fU3rOzqRoFcatjdno2JkI1Hd5siRAX1MA==
-----END RSA PRIVATE KEY-----";
const K2_N: &str = "3T4yqTf7Jwd_kcP9FhPrD4wjwDxFWRG52RW3dxZJeYmGVxMU2SlxaOFCOBbCY8HYu_t6SrE2M0PdPWBMl6PfaNuEWOz51SzUeQsA6vCWsp1zE95MYQGsJXF1x1MXcSjUkghMC0zzcBpxMU-09L0gngFsDTZfeZSuSJH7bMdsZZanj2aVN3YRn6bCd_jeFlMjurV6XnZTkUcKZU_0pIiSP17oCohU5ban3vzpIcVdWGAJUUz8_PXLMuKxPxa1oiHhBqiYfunGZr6Md3gAWyjYmat0E4oKfpd1FOmYAigXABNvSiJBorWNxyRM1Phuqjmqjo8gj9nU1OTh_NBT-hkrUw";
const K2_E: &str = "AQAB";
const ISS: &str = "https://accounts.google.com";
const AUD: &str = "my-client-id.apps.googleusercontent.com";
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock after epoch")
.as_secs()
}
fn rsa_jwk(kid: &str, n: &str, e: &str) -> Jwk {
Jwk {
kty: "RSA".to_string(),
kid: Some(kid.to_string()),
alg: Some("RS256".to_string()),
n: Some(n.to_string()),
e: Some(e.to_string()),
}
}
fn sign(pem: &[u8], kid: &str, claims: &serde_json::Value) -> String {
let mut header = Header::new(Algorithm::RS256);
header.kid = Some(kid.to_string());
let key = EncodingKey::from_rsa_pem(pem).expect("valid RSA PEM");
encode(&header, claims, &key).expect("sign RS256")
}
fn good_claims() -> serde_json::Value {
let n = now();
json!({
"iss": ISS,
"aud": AUD,
"sub": "user-abc-123",
"email": "person@example.com",
"email_verified": true,
"iat": n - 10,
"exp": n + 3600,
})
}
struct TestSource {
jwks: Mutex<Jwks>,
fetches: AtomicU64,
}
impl TestSource {
fn new(keys: Vec<Jwk>) -> Arc<Self> {
Arc::new(Self {
jwks: Mutex::new(Jwks { keys }),
fetches: AtomicU64::new(0),
})
}
fn set(&self, keys: Vec<Jwk>) {
self.jwks.lock().expect("test jwks poisoned").keys = keys;
}
fn fetch_count(&self) -> u64 {
self.fetches.load(Ordering::SeqCst)
}
}
impl JwksSource for TestSource {
fn fetch<'a>(&'a self, _uri: &'a str) -> JwksFuture<'a> {
self.fetches.fetch_add(1, Ordering::SeqCst);
let jwks = self.jwks.lock().expect("test jwks poisoned").clone();
Box::pin(async move { Ok(jwks) })
}
}
fn verifier_with(source: Arc<dyn JwksSource>) -> Verifier {
Verifier::custom([ISS.to_string()], "https://unused/jwks", [AUD.to_string()])
.with_source(source)
}
#[tokio::test]
async fn happy_path_verifies_signature_and_returns_claims() {
let source = TestSource::new(vec![rsa_jwk("kid1", K1_N, K1_E)]);
let v = verifier_with(source.clone());
let token = sign(K1_PEM, "kid1", &good_claims());
let claims = v.verify(&token).await.expect("valid token must verify");
assert_eq!(claims.sub, "user-abc-123");
assert_eq!(claims.iss, ISS);
assert_eq!(claims.email.as_deref(), Some("person@example.com"));
assert!(
claims.email_verified(),
"email_verified:true must be readable"
);
v.verify(&token).await.expect("second verify");
assert_eq!(source.fetch_count(), 1, "fresh cache must not refetch");
}
#[tokio::test]
async fn apple_style_string_email_verified_is_read_as_true() {
let source = TestSource::new(vec![rsa_jwk("kid1", K1_N, K1_E)]);
let v = verifier_with(source);
let n = now();
let token = sign(
K1_PEM,
"kid1",
&json!({
"iss": ISS, "aud": AUD, "sub": "s", "email_verified": "true",
"iat": n - 10, "exp": n + 3600,
}),
);
let claims = v.verify(&token).await.expect("verify");
assert!(
claims.email_verified(),
"string \"true\" must count as verified"
);
}
#[tokio::test]
async fn wrong_audience_is_rejected() {
let source = TestSource::new(vec![rsa_jwk("kid1", K1_N, K1_E)]);
let v = verifier_with(source);
let mut claims = good_claims();
claims["aud"] = json!("some-other-app");
let token = sign(K1_PEM, "kid1", &claims);
let err = v.verify(&token).await.unwrap_err();
assert_eq!(err.status().as_u16(), 401, "wrong aud must be 401");
}
#[tokio::test]
async fn wrong_issuer_is_rejected() {
let source = TestSource::new(vec![rsa_jwk("kid1", K1_N, K1_E)]);
let v = verifier_with(source);
let mut claims = good_claims();
claims["iss"] = json!("https://evil.example.com");
let token = sign(K1_PEM, "kid1", &claims);
let err = v.verify(&token).await.unwrap_err();
assert_eq!(err.status().as_u16(), 401, "wrong iss must be 401");
}
#[tokio::test]
async fn expired_token_is_rejected() {
let source = TestSource::new(vec![rsa_jwk("kid1", K1_N, K1_E)]);
let v = verifier_with(source).with_leeway(0);
let n = now();
let token = sign(
K1_PEM,
"kid1",
&json!({ "iss": ISS, "aud": AUD, "sub": "s", "iat": n - 7200, "exp": n - 3600 }),
);
let err = v.verify(&token).await.unwrap_err();
assert_eq!(err.status().as_u16(), 401, "expired must be 401");
}
#[tokio::test]
async fn not_yet_valid_nbf_is_rejected() {
let source = TestSource::new(vec![rsa_jwk("kid1", K1_N, K1_E)]);
let v = verifier_with(source).with_leeway(0);
let n = now();
let token = sign(
K1_PEM,
"kid1",
&json!({ "iss": ISS, "aud": AUD, "sub": "s", "nbf": n + 3600, "exp": n + 7200 }),
);
let err = v.verify(&token).await.unwrap_err();
assert_eq!(err.status().as_u16(), 401, "future nbf must be 401");
}
#[tokio::test]
async fn tampered_signature_is_rejected() {
let source = TestSource::new(vec![rsa_jwk("kid1", K1_N, K1_E)]);
let v = verifier_with(source);
let token = sign(K1_PEM, "kid1", &good_claims());
let mut bytes = token.into_bytes();
let last = bytes.last_mut().expect("non-empty token");
*last = if *last == b'A' { b'B' } else { b'A' };
let tampered = String::from_utf8(bytes).expect("still ascii");
let err = v.verify(&tampered).await.unwrap_err();
assert_eq!(err.status().as_u16(), 401, "bad signature must be 401");
}
#[tokio::test]
async fn signature_from_a_key_not_in_the_jwks_is_rejected() {
let source = TestSource::new(vec![rsa_jwk("kid1", K1_N, K1_E)]);
let v = verifier_with(source);
let token = sign(K2_PEM, "kid1", &good_claims());
let err = v.verify(&token).await.unwrap_err();
assert_eq!(err.status().as_u16(), 401, "mismatched key must be 401");
}
#[tokio::test]
async fn hs256_token_is_rejected_before_key_lookup() {
let source = TestSource::new(vec![rsa_jwk("kid1", K1_N, K1_E)]);
let v = verifier_with(source.clone());
let mut header = Header::new(Algorithm::HS256);
header.kid = Some("kid1".to_string());
let token = encode(
&header,
&good_claims(),
&EncodingKey::from_secret(b"attacker-chosen"),
)
.expect("sign hs256");
let err = v.verify(&token).await.unwrap_err();
assert_eq!(err.status().as_u16(), 401, "HS256 must be 401");
assert_eq!(
source.fetch_count(),
0,
"a non-RS256 token must be rejected before any JWKS fetch"
);
}
#[tokio::test]
async fn unknown_kid_refetches_once_and_verifies_after_rotation() {
let source = TestSource::new(vec![rsa_jwk("kid1", K1_N, K1_E)]);
let v = verifier_with(source.clone()).with_min_refetch_interval(Duration::ZERO);
let t1 = sign(K1_PEM, "kid1", &good_claims());
v.verify(&t1).await.expect("kid1 verifies");
assert_eq!(source.fetch_count(), 1);
source.set(vec![
rsa_jwk("kid1", K1_N, K1_E),
rsa_jwk("kid2", K2_N, K2_E),
]);
let t2 = sign(K2_PEM, "kid2", &good_claims());
let claims = v.verify(&t2).await.expect("rotated kid2 verifies");
assert_eq!(claims.sub, "user-abc-123");
assert_eq!(
source.fetch_count(),
2,
"unknown kid must refetch exactly once"
);
}
#[tokio::test]
async fn bogus_kid_within_cooldown_does_not_refetch() {
let source = TestSource::new(vec![rsa_jwk("kid1", K1_N, K1_E)]);
let v = verifier_with(source.clone());
v.verify(&sign(K1_PEM, "kid1", &good_claims()))
.await
.expect("prime cache");
assert_eq!(source.fetch_count(), 1);
let bogus = sign(K1_PEM, "nope-unknown-kid", &good_claims());
let err = v.verify(&bogus).await.unwrap_err();
assert_eq!(err.status().as_u16(), 401);
assert_eq!(
source.fetch_count(),
1,
"unknown kid inside the cooldown must not trigger a refetch"
);
}
#[tokio::test]
async fn expired_cache_refetches_on_next_verify() {
let source = TestSource::new(vec![rsa_jwk("kid1", K1_N, K1_E)]);
let v = verifier_with(source.clone()).with_ttl(Duration::ZERO);
let token = sign(K1_PEM, "kid1", &good_claims());
v.verify(&token).await.expect("first");
v.verify(&token).await.expect("second");
assert_eq!(
source.fetch_count(),
2,
"a zero-TTL cache must refetch every verify"
);
}
#[tokio::test]
async fn nonce_must_match_when_required() {
let source = TestSource::new(vec![rsa_jwk("kid1", K1_N, K1_E)]);
let v = verifier_with(source).require_nonce("expected-nonce-xyz");
let mut claims = good_claims();
claims["nonce"] = json!("expected-nonce-xyz");
v.verify(&sign(K1_PEM, "kid1", &claims))
.await
.expect("matching nonce verifies");
claims["nonce"] = json!("attacker-nonce");
let err = v.verify(&sign(K1_PEM, "kid1", &claims)).await.unwrap_err();
assert_eq!(err.status().as_u16(), 401, "wrong nonce must be 401");
let err = v
.verify(&sign(K1_PEM, "kid1", &good_claims()))
.await
.unwrap_err();
assert_eq!(err.status().as_u16(), 401, "missing nonce must be 401");
}
#[tokio::test]
async fn google_preset_accepts_both_issuer_spellings() {
let source = TestSource::new(vec![rsa_jwk("kid1", K1_N, K1_E)]);
for iss in ["https://accounts.google.com", "accounts.google.com"] {
let v = Verifier::google("client-xyz").with_source(source.clone());
let n = now();
let token = sign(
K1_PEM,
"kid1",
&json!({ "iss": iss, "aud": "client-xyz", "sub": "s", "iat": n - 10, "exp": n + 3600 }),
);
v.verify(&token)
.await
.unwrap_or_else(|e| panic!("google preset must accept iss {iss}: {e}"));
}
}
#[test]
fn jwks_fetch_url_guard_allows_https_and_loopback_only() {
assert!(fetch_url_allowed("https://appleid.apple.com/auth/keys"));
assert!(fetch_url_allowed("http://127.0.0.1:8080/jwks"));
assert!(fetch_url_allowed("http://localhost/jwks"));
assert!(fetch_url_allowed("http://[::1]:9000/jwks"));
assert!(!fetch_url_allowed("http://evil.example.com/jwks"));
assert!(!fetch_url_allowed("http://localhost.evil.com/jwks"));
assert!(!fetch_url_allowed("http://127.0.0.1@evil.com/jwks"));
assert!(!fetch_url_allowed("ftp://127.0.0.1/jwks"));
assert!(!fetch_url_allowed("not-a-url"));
}
#[tokio::test]
async fn http_jwks_source_refuses_plaintext_non_loopback_without_network() {
let source = HttpJwksSource::new();
let err = source
.fetch("http://keys.evil.example.com/jwks")
.await
.unwrap_err();
assert!(
err.message().contains("refusing"),
"expected the plaintext guard, got: {err}"
);
}
#[tokio::test]
async fn http_jwks_source_fetches_and_parses_over_a_real_localhost_socket() {
use jerrycan_core::http::header;
use jerrycan_core::{App, JcBody, Response, get};
let jwks_json = json!({ "keys": [ {
"kty": "RSA", "kid": "kid1", "alg": "RS256", "use": "sig",
"n": K1_N, "e": K1_E,
} ] })
.to_string();
let body_for_route = jwks_json.clone();
let app = App::new().route(
"/jwks",
get(move || {
let body = body_for_route.clone();
async move {
let mut resp: Response =
jerrycan_core::http::Response::new(JcBody::full(body.into_bytes()));
resp.headers_mut().insert(
header::CONTENT_TYPE,
header::HeaderValue::from_static("application/json"),
);
Ok::<_, Error>(resp)
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral port");
let addr = listener.local_addr().expect("local_addr");
let server = tokio::spawn(async move {
let _ = app.serve_with(listener).await;
});
let jwks_uri = format!("http://{addr}/jwks");
let v = Verifier::custom([ISS.to_string()], jwks_uri, [AUD.to_string()]);
let token = sign(K1_PEM, "kid1", &good_claims());
let claims = v
.verify(&token)
.await
.expect("verify via real http JWKS fetch");
assert_eq!(claims.sub, "user-abc-123");
server.abort();
}
}