#![forbid(unsafe_code)]
#![warn(clippy::pedantic)]
#![deny(missing_docs)]
use std::collections::HashMap;
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
pub sub: String,
pub email: String,
pub name: String,
pub iss: String,
pub aud: String,
pub exp: i64,
pub iat: i64,
pub jti: String,
}
#[derive(Debug, thiserror::Error)]
pub enum VerifyError {
#[error("malformed jwks: {0}")]
Jwks(String),
#[error("token header has no kid")]
MissingKid,
#[error("no verification key for kid {0:?}")]
UnknownKid(String),
#[error("token verification failed: {0}")]
Jwt(#[from] jsonwebtoken::errors::Error),
#[cfg(feature = "fetch")]
#[error("jwks fetch failed: {0}")]
Fetch(String),
}
pub struct Verifier {
keys: HashMap<String, DecodingKey>,
validation: Validation,
}
impl Verifier {
pub fn from_jwks_value(
jwks: &serde_json::Value,
issuer: &str,
audience: &str,
) -> Result<Self, VerifyError> {
let entries = jwks
.get("keys")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| VerifyError::Jwks("missing \"keys\" array".to_string()))?;
let mut keys = HashMap::new();
for jwk in entries {
let kty = jwk.get("kty").and_then(serde_json::Value::as_str);
if kty != Some("RSA") {
continue;
}
let kid = jwk
.get("kid")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| VerifyError::Jwks("rsa jwk missing \"kid\"".to_string()))?;
let n = jwk
.get("n")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| VerifyError::Jwks(format!("jwk {kid} missing \"n\"")))?;
let e = jwk
.get("e")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| VerifyError::Jwks(format!("jwk {kid} missing \"e\"")))?;
let key = DecodingKey::from_rsa_components(n, e)
.map_err(|err| VerifyError::Jwks(format!("jwk {kid}: {err}")))?;
keys.insert(kid.to_string(), key);
}
let mut validation = Validation::new(Algorithm::RS256);
validation.set_issuer(&[issuer]);
validation.set_audience(&[audience]);
Ok(Self { keys, validation })
}
#[must_use]
pub fn key_count(&self) -> usize {
self.keys.len()
}
pub fn verify(&self, token: &str) -> Result<Claims, VerifyError> {
let header = decode_header(token)?;
let kid = header.kid.ok_or(VerifyError::MissingKid)?;
let key = self.keys.get(&kid).ok_or(VerifyError::UnknownKid(kid))?;
let data = decode::<Claims>(token, key, &self.validation)?;
Ok(data.claims)
}
}
#[cfg(feature = "fetch")]
impl Verifier {
pub async fn from_jwks_url(
url: &str,
issuer: &str,
audience: &str,
) -> Result<Self, VerifyError> {
let body = reqwest::get(url)
.await
.map_err(|e| VerifyError::Fetch(e.to_string()))?
.error_for_status()
.map_err(|e| VerifyError::Fetch(e.to_string()))?
.json::<serde_json::Value>()
.await
.map_err(|e| VerifyError::Fetch(e.to_string()))?;
Self::from_jwks_value(&body, issuer, audience)
}
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
use jsonwebtoken::{EncodingKey, Header};
use rsa::pkcs8::DecodePublicKey;
use rsa::traits::PublicKeyParts;
use sha2::{Digest, Sha256};
const TEST_PRIVATE_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCb8A4bMels7eEL\n\
TCFMcXyJTMdQ0k2klAxkPjPRHdAtwzV+6uLjKBlI2NTubkUl0BcKLKpX1G9mDjci\n\
Qiga5frQ/QX9IedKGdU3aLo0BvpewAK2TmRwDe48i3d+uGWPbDcDd9DG0hEz6ugd\n\
CXeCcXekdcsX8e8bF9E4FYK+2DaI9xiVhMQS8xSY7Mt3Os/T8GBT27/JdKkzoX2Z\n\
ntYiOOhxwcwztS+W8y1qVD6b39hPCo2vbl+nyFdN6tz00L/qakJMV4AJjXOYhT5N\n\
XBYII92vmlznYNWNTtBymmaGhes7RbIb9AvgC2PJZdk57Sxl0tP71CsIX2P/RjPD\n\
AoExpOfDAgMBAAECggEAKwLNGUIslM+OJZwTiS66P3KufUPsh4sQWevwRes3uw+f\n\
Z0jpWOd8BeRM4xEGQJZDbJqCR6SAL4GXQntF7Zlmk5NevgHGdmFmtphL18Le9xh2\n\
BwvbVy74ebmsNYct+B/MksfPDa/ub8gIys2MKa4bZoDZCltAbNQmcJY6UGJ5tFAp\n\
ItKFgoA8wnxJroUGcw1r7B8WRyxBGxSqjYVmwtRyUcbea1gCVfCXGwkkVgv42Miu\n\
YY6C7y9e0zlwcAXdkZGTgfYlz/hiBWd2xAg+tGV2bwVBRlX3Io1qcNUEphOHDp+l\n\
iTf/E8DkLvln3J9DsBD6jscWE8lK8HDzLHPpT1EkSQKBgQDQ7KW2so/ZMPMvLo+R\n\
j61XUqvRW9sJgsQvZsukGt+dbq/YDJho6J0mu2OC8Sag2ZvLezGmGdjMkUHIvza/\n\
3KpHau4vPG0LByqZv2sF+9XAOLo5YAVPZlZFb8egYH7X2bOmVaupyGKSsmcMUsHa\n\
x7jZf84vIY033RcLhirjDDg6ywKBgQC/Evw1CXby8WVKBPNB8pr7VKE6oy6DK76d\n\
TESJiirq1Z8am1WKxOzvo9OlZPibfuZKeY/XiF2fj5YHWc1xR7S7+OPMlwZdVx64\n\
h7Iv3j9yFS7jwX1AVxmOB/b48ki+QEItTEiQJqEQXiFEGr0MgI6fTj/opKEltv5L\n\
6cfdqOCP6QKBgQCu4LchQzvnV/Lm1nl0JSi6REfvuYyR3HRtHQVuOtRcig8EsB5P\n\
Cg6pIgd8znBACYY//8GiQFZZfWjsKSoh1QpvN1FiFplLttbw1Oo3mwHjoVg3uGkZ\n\
ehbSjmsxkjP6Z47ZtzI2rrXcBxr8lLURdUYEQNeMWfBEB3tHuSli3ZKfmwKBgQCn\n\
YfdEcuUbz7H+lLWQqPlxgGK5HmhJilGyNDS6FCqii76ULU1Tgk1ZZLesZPaQKSuO\n\
RE1o71GszLkN+XJKcRl3rYHJIOf3brE/z8edvWDxDHOGG2MgsOx3Cq0kygJFf785\n\
NWE/vkdMMlmL8qx3vkqybXb40vdENbkxQTvQBvepuQKBgGHKxTj314X4xObeQ5BY\n\
yteRm758Id7MoieW0dYZC62a05STaiyCB5ulVCijNa66616uxyAMhquKru9xT1Bt\n\
zGUi4GKlyqqAH7webJPHDR58z5Jj4XqAblYzRFY3nqSAd6lxdjMdIftQi0xrG6+x\n\
UOsbyJ6S44rLeDtZ9KGxR0gS\n\
-----END PRIVATE KEY-----\n";
const TEST_PUBLIC_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm/AOGzHpbO3hC0whTHF8\n\
iUzHUNJNpJQMZD4z0R3QLcM1furi4ygZSNjU7m5FJdAXCiyqV9RvZg43IkIoGuX6\n\
0P0F/SHnShnVN2i6NAb6XsACtk5kcA3uPIt3frhlj2w3A3fQxtIRM+roHQl3gnF3\n\
pHXLF/HvGxfROBWCvtg2iPcYlYTEEvMUmOzLdzrP0/BgU9u/yXSpM6F9mZ7WIjjo\n\
ccHMM7UvlvMtalQ+m9/YTwqNr25fp8hXTerc9NC/6mpCTFeACY1zmIU+TVwWCCPd\n\
r5pc52DVjU7QcppmhoXrO0WyG/QL4AtjyWXZOe0sZdLT+9QrCF9j/0YzwwKBMaTn\n\
wwIDAQAB\n\
-----END PUBLIC KEY-----\n";
const ISSUER: &str = "authentication-service";
const AUDIENCE: &str = "main-x-service";
fn test_jwks() -> (serde_json::Value, String) {
let pub_key = rsa::RsaPublicKey::from_public_key_pem(TEST_PUBLIC_PEM).expect("parse pub");
let n_bytes = pub_key.n().to_bytes_be();
let e_bytes = pub_key.e().to_bytes_be();
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD;
let n = b64.encode(&n_bytes);
let e = b64.encode(&e_bytes);
let kid = b64.encode(Sha256::digest(&n_bytes));
let jwks = serde_json::json!({
"keys": [{
"kty": "RSA", "use": "sig", "alg": "RS256",
"kid": kid, "n": n, "e": e,
}]
});
(jwks, kid)
}
fn sign(kid: &str, claims: &Claims) -> String {
let mut header = Header::new(Algorithm::RS256);
header.kid = Some(kid.to_string());
let key = EncodingKey::from_rsa_pem(TEST_PRIVATE_PEM.as_bytes()).expect("encoding key");
jsonwebtoken::encode(&header, claims, &key).expect("encode")
}
fn claims(exp_offset: i64) -> Claims {
let now = 1_900_000_000; Claims {
sub: "11111111-1111-1111-1111-111111111111".to_string(),
email: "alice@example.com".to_string(),
name: "Alice".to_string(),
iss: ISSUER.to_string(),
aud: AUDIENCE.to_string(),
exp: now + exp_offset,
iat: now,
jti: "22222222-2222-2222-2222-222222222222".to_string(),
}
}
#[test]
fn valid_token_round_trips_claims() {
let (jwks, kid) = test_jwks();
let verifier = Verifier::from_jwks_value(&jwks, ISSUER, AUDIENCE).expect("build");
assert_eq!(verifier.key_count(), 1);
let token = sign(&kid, &claims(3600));
let got = verifier.verify(&token).expect("verify");
assert_eq!(got.sub, "11111111-1111-1111-1111-111111111111");
assert_eq!(got.email, "alice@example.com");
assert_eq!(got.iss, ISSUER);
assert_eq!(got.aud, AUDIENCE);
}
#[test]
fn expired_token_is_rejected() {
let (jwks, kid) = test_jwks();
let verifier = Verifier::from_jwks_value(&jwks, ISSUER, AUDIENCE).expect("build");
let token = sign(&kid, &claims(-10_000_000_000));
assert!(matches!(verifier.verify(&token), Err(VerifyError::Jwt(_))));
}
#[test]
fn wrong_audience_is_rejected() {
let (jwks, kid) = test_jwks();
let verifier =
Verifier::from_jwks_value(&jwks, ISSUER, "some-other-service").expect("build");
let token = sign(&kid, &claims(3600));
assert!(matches!(verifier.verify(&token), Err(VerifyError::Jwt(_))));
}
#[test]
fn wrong_issuer_is_rejected() {
let (jwks, kid) = test_jwks();
let verifier =
Verifier::from_jwks_value(&jwks, "some-other-issuer", AUDIENCE).expect("build");
let token = sign(&kid, &claims(3600));
assert!(matches!(verifier.verify(&token), Err(VerifyError::Jwt(_))));
}
#[test]
fn unknown_kid_is_rejected() {
let (jwks, kid) = test_jwks();
let verifier = Verifier::from_jwks_value(&jwks, ISSUER, AUDIENCE).expect("build");
let token = sign("not-a-known-kid", &claims(3600));
assert!(matches!(
verifier.verify(&token),
Err(VerifyError::UnknownKid(_))
));
let _ = kid;
}
#[test]
fn tampered_signature_is_rejected() {
let (jwks, kid) = test_jwks();
let verifier = Verifier::from_jwks_value(&jwks, ISSUER, AUDIENCE).expect("build");
let token = sign(&kid, &claims(3600));
let mut bytes = token.into_bytes();
let last = bytes.len() - 1;
bytes[last] = if bytes[last] == b'A' { b'B' } else { b'A' };
let tampered = String::from_utf8(bytes).unwrap();
assert!(verifier.verify(&tampered).is_err());
}
#[test]
fn garbage_token_is_rejected() {
let (jwks, _) = test_jwks();
let verifier = Verifier::from_jwks_value(&jwks, ISSUER, AUDIENCE).expect("build");
assert!(verifier.verify("not.a.jwt").is_err());
assert!(verifier.verify("").is_err());
}
#[test]
fn empty_jwks_builds_but_rejects_everything() {
let jwks = serde_json::json!({ "keys": [] });
let verifier = Verifier::from_jwks_value(&jwks, ISSUER, AUDIENCE).expect("build");
assert_eq!(verifier.key_count(), 0);
let (_, kid) = test_jwks();
let token = sign(&kid, &claims(3600));
assert!(matches!(
verifier.verify(&token),
Err(VerifyError::UnknownKid(_))
));
}
#[test]
fn jwks_without_keys_array_errors() {
let jwks = serde_json::json!({ "not_keys": [] });
assert!(matches!(
Verifier::from_jwks_value(&jwks, ISSUER, AUDIENCE),
Err(VerifyError::Jwks(_))
));
}
#[test]
fn non_rsa_keys_are_skipped() {
let jwks = serde_json::json!({
"keys": [{ "kty": "EC", "kid": "ec-1", "crv": "P-256", "x": "a", "y": "b" }]
});
let verifier = Verifier::from_jwks_value(&jwks, ISSUER, AUDIENCE).expect("build");
assert_eq!(verifier.key_count(), 0);
}
}