use jsonwebtoken::{decode, Algorithm, DecodingKey, TokenData, Validation};
use moka::future::Cache;
#[derive(Clone, serde::Deserialize, serde::Serialize)]
pub struct Claims {
pub sub: String,
pub exp: usize,
#[serde(default)]
pub scope: String,
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub struct JwtVerifier {
key: DecodingKey,
validation: Validation,
cache: Cache<String, Claims>,
}
impl JwtVerifier {
pub fn new(jwks_pem: &[u8]) -> anyhow::Result<Self> {
Ok(Self {
key: DecodingKey::from_rsa_pem(jwks_pem)?,
validation: {
let mut v = Validation::new(Algorithm::RS256);
v.validate_nbf = true;
v
},
cache: Cache::builder()
.max_capacity(10_000)
.time_to_live(std::time::Duration::from_secs(300))
.build(),
})
}
pub fn with_leeway(mut self, secs: u64) -> Self {
self.validation.leeway = secs;
self
}
pub fn with_issuer(mut self, issuer: &str) -> Self {
self.validation.set_issuer(&[issuer]);
self.validation.required_spec_claims.insert("iss".into());
self
}
pub fn with_audience(mut self, audience: &str) -> Self {
self.validation.set_audience(&[audience]);
self.validation.required_spec_claims.insert("aud".into());
self
}
pub async fn verify(&self, token: &str) -> Option<Claims> {
if let Some(claims) = self.cache.get(token).await {
if (claims.exp as u64) < now_secs().saturating_sub(self.validation.leeway) {
return None;
}
return Some(claims);
}
let TokenData { claims, .. } = decode::<Claims>(token, &self.key, &self.validation).ok()?;
self.cache.insert(token.to_string(), claims.clone()).await;
Some(claims)
}
}
#[cfg(test)]
mod tests {
use super::*;
use jsonwebtoken::{encode, EncodingKey, Header};
const PRIV_PEM: &[u8] = include_bytes!("../tests/fixtures/jwt-test-priv.pem");
const OTHER_PRIV_PEM: &[u8] = include_bytes!("../tests/fixtures/jwt-test-other-priv.pem");
const PUB_PEM: &[u8] = include_bytes!("../tests/fixtures/jwt-test-pub.pem");
fn sign(key_pem: &[u8], claims: &Claims) -> String {
encode(
&Header::new(Algorithm::RS256),
claims,
&EncodingKey::from_rsa_pem(key_pem).unwrap(),
)
.unwrap()
}
#[tokio::test]
async fn valid_token_verifies() {
let verifier = JwtVerifier::new(PUB_PEM).unwrap();
let token = sign(
PRIV_PEM,
&Claims {
sub: "tenant-a".into(),
exp: now_secs() as usize + 3600,
scope: "read".into(),
},
);
let claims = verifier.verify(&token).await.expect("should verify");
assert_eq!(claims.sub, "tenant-a");
assert_eq!(claims.scope, "read");
}
fn sign_json(value: serde_json::Value) -> String {
encode(
&Header::new(Algorithm::RS256),
&value,
&EncodingKey::from_rsa_pem(PRIV_PEM).unwrap(),
)
.unwrap()
}
#[tokio::test]
async fn issuer_and_audience_enforced_when_configured() {
let exp = now_secs() + 3600;
let verifier = JwtVerifier::new(PUB_PEM)
.unwrap()
.with_issuer("https://issuer.test")
.with_audience("ferryman-edge");
let good = sign_json(serde_json::json!({
"sub": "t", "exp": exp, "iss": "https://issuer.test", "aud": "ferryman-edge"
}));
assert!(verifier.verify(&good).await.is_some());
for bad in [
serde_json::json!({"sub": "t", "exp": exp, "iss": "https://evil.test", "aud": "ferryman-edge"}),
serde_json::json!({"sub": "t", "exp": exp, "iss": "https://issuer.test", "aud": "other-svc"}),
serde_json::json!({"sub": "t", "exp": exp}),
] {
assert!(verifier.verify(&sign_json(bad)).await.is_none());
}
}
#[tokio::test]
async fn token_with_aud_rejected_when_no_audience_configured() {
let verifier = JwtVerifier::new(PUB_PEM).unwrap();
let token =
sign_json(serde_json::json!({"sub": "t", "exp": now_secs() + 3600, "aud": "x"}));
assert!(verifier.verify(&token).await.is_none());
}
#[tokio::test]
async fn not_yet_valid_token_rejected() {
let verifier = JwtVerifier::new(PUB_PEM).unwrap().with_leeway(0);
let now = now_secs();
let token = sign_json(serde_json::json!({"sub": "t", "exp": now + 3600, "nbf": now + 600}));
assert!(verifier.verify(&token).await.is_none());
}
#[tokio::test]
async fn bad_signature_rejected() {
let verifier = JwtVerifier::new(PUB_PEM).unwrap();
let token = sign(
OTHER_PRIV_PEM,
&Claims {
sub: "tenant-a".into(),
exp: now_secs() as usize + 3600,
scope: "read".into(),
},
);
assert!(verifier.verify(&token).await.is_none());
}
#[tokio::test]
async fn expired_token_rejected() {
let verifier = JwtVerifier::new(PUB_PEM).unwrap().with_leeway(0);
let token = sign(
PRIV_PEM,
&Claims {
sub: "tenant-a".into(),
exp: now_secs() as usize - 100,
scope: "read".into(),
},
);
assert!(verifier.verify(&token).await.is_none());
}
#[tokio::test]
async fn token_without_scope_accepts() {
#[derive(serde::Serialize)]
struct NoScope {
sub: String,
exp: usize,
}
let verifier = JwtVerifier::new(PUB_PEM).unwrap();
let token = encode(
&Header::new(Algorithm::RS256),
&NoScope {
sub: "tenant-a".into(),
exp: now_secs() as usize + 3600,
},
&EncodingKey::from_rsa_pem(PRIV_PEM).unwrap(),
)
.unwrap();
let claims = verifier.verify(&token).await.expect("should verify");
assert_eq!(claims.scope, "");
}
#[tokio::test]
async fn cached_token_rejected_after_exp_passes() {
let verifier = JwtVerifier::new(PUB_PEM).unwrap().with_leeway(0);
let token = sign(
PRIV_PEM,
&Claims {
sub: "tenant-a".into(),
exp: now_secs() as usize + 1,
scope: "read".into(),
},
);
assert!(verifier.verify(&token).await.is_some());
tokio::time::sleep(std::time::Duration::from_millis(2100)).await;
assert!(verifier.verify(&token).await.is_none());
}
}