use jsonwebtoken::jwk::{AlgorithmParameters, EllipticCurve, Jwk, JwkSet};
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use boatramp_core::config::HandlerGraphqlTokenClaims;
pub(crate) struct TokenVerifier {
by_kid: HashMap<String, (DecodingKey, Algorithm)>,
sole: Option<(DecodingKey, Algorithm)>,
issuer: String,
audience: Option<String>,
}
impl TokenVerifier {
pub(crate) fn from_jwks_json(
jwks: &str,
issuer: &str,
audience: Option<&str>,
) -> Result<Self, String> {
let set: JwkSet = serde_json::from_str(jwks).map_err(|e| format!("parsing JWKS: {e}"))?;
let mut by_kid = HashMap::new();
for jwk in &set.keys {
let (Some(alg), Ok(key)) = (jwk_algorithm(jwk), DecodingKey::from_jwk(jwk)) else {
continue;
};
by_kid.insert(jwk.common.key_id.clone().unwrap_or_default(), (key, alg));
}
if by_kid.is_empty() {
return Err("JWKS held no usable signing keys".to_string());
}
let sole = (by_kid.len() == 1)
.then(|| by_kid.values().next().cloned())
.flatten();
Ok(Self {
by_kid,
sole,
issuer: issuer.to_string(),
audience: audience.map(str::to_string),
})
}
fn token_kid(token: &str) -> Option<String> {
decode_header(token).ok()?.kid
}
fn knows_kid(&self, kid: Option<&str>) -> bool {
match kid {
Some(kid) => self.by_kid.contains_key(kid),
None => self.sole.is_some(),
}
}
pub(crate) fn verify(&self, token: &str) -> Option<serde_json::Map<String, serde_json::Value>> {
let header = decode_header(token).ok()?;
let (key, alg) = match header.kid.as_deref() {
Some(kid) => self.by_kid.get(kid)?,
None => self.sole.as_ref()?,
};
let mut validation = Validation::new(*alg);
validation.set_issuer(&[&self.issuer]);
validation.validate_nbf = true; match &self.audience {
Some(aud) => validation.set_audience(&[aud]),
None => validation.validate_aud = false,
}
let data =
decode::<serde_json::Map<String, serde_json::Value>>(token, key, &validation).ok()?;
Some(data.claims)
}
}
fn jwk_algorithm(jwk: &Jwk) -> Option<Algorithm> {
match &jwk.algorithm {
AlgorithmParameters::RSA(_) => Some(Algorithm::RS256),
AlgorithmParameters::EllipticCurve(ec) => match ec.curve {
EllipticCurve::P256 => Some(Algorithm::ES256),
EllipticCurve::P384 => Some(Algorithm::ES384),
_ => None,
},
AlgorithmParameters::OctetKeyPair(okp) => match okp.curve {
EllipticCurve::Ed25519 => Some(Algorithm::EdDSA),
_ => None,
},
AlgorithmParameters::OctetKey(_) => None,
}
}
pub(crate) async fn verified_claims(
cfg: &HandlerGraphqlTokenClaims,
bearer: &str,
) -> Option<serde_json::Map<String, serde_json::Value>> {
resolve_verifier(cfg, bearer).await?.verify(bearer)
}
async fn resolve_verifier(
cfg: &HandlerGraphqlTokenClaims,
bearer: &str,
) -> Option<Arc<TokenVerifier>> {
let audience = cfg.audience.as_deref();
if let Some(env_name) = &cfg.jwks_env {
let jwks = std::env::var(env_name).ok()?;
return TokenVerifier::from_jwks_json(&jwks, &cfg.issuer, audience)
.ok()
.map(Arc::new);
}
if let Some(url) = &cfg.jwks_url {
return resolve_url_verifier(url, &cfg.issuer, audience, bearer).await;
}
None
}
fn jwks_cache() -> &'static Mutex<HashMap<String, Arc<TokenVerifier>>> {
static CACHE: OnceLock<Mutex<HashMap<String, Arc<TokenVerifier>>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
async fn resolve_url_verifier(
url: &str,
issuer: &str,
audience: Option<&str>,
bearer: &str,
) -> Option<Arc<TokenVerifier>> {
let token_kid = TokenVerifier::token_kid(bearer);
if let Some(cached) = jwks_cache().lock().ok().and_then(|c| c.get(url).cloned()) {
if cached.knows_kid(token_kid.as_deref()) {
return Some(cached);
}
}
let jwks = reqwest::Client::new()
.get(url)
.send()
.await
.ok()?
.error_for_status()
.ok()?
.text()
.await
.ok()?;
let verifier = Arc::new(TokenVerifier::from_jwks_json(&jwks, issuer, audience).ok()?);
if let Ok(mut cache) = jwks_cache().lock() {
cache.insert(url.to_string(), verifier.clone());
}
Some(verifier)
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
use ed25519_dalek::{Signer, SigningKey};
use jsonwebtoken::{encode, EncodingKey, Header};
const ISS: &str = "https://idp.test";
fn far_future() -> i64 {
4_102_444_800 }
fn hs256(secret: &[u8], kid: &str, audience: Option<&str>) -> TokenVerifier {
let mut by_kid = HashMap::new();
by_kid.insert(
kid.to_string(),
(DecodingKey::from_secret(secret), Algorithm::HS256),
);
TokenVerifier {
by_kid,
sole: None,
issuer: ISS.to_string(),
audience: audience.map(str::to_string),
}
}
fn hs256_token(secret: &[u8], kid: &str, claims: serde_json::Value) -> String {
let mut header = Header::new(Algorithm::HS256);
header.kid = Some(kid.to_string());
encode(&header, &claims, &EncodingKey::from_secret(secret)).unwrap()
}
#[test]
fn a_valid_token_yields_its_claims() {
let v = hs256(b"secret-0123456789", "k1", None);
let token = hs256_token(
b"secret-0123456789",
"k1",
serde_json::json!({ "iss": ISS, "exp": far_future(), "tid": "acme", "sub": "u42" }),
);
let claims = v.verify(&token).expect("verifies");
assert_eq!(claims["tid"], serde_json::json!("acme"));
assert_eq!(claims["sub"], serde_json::json!("u42"));
}
#[test]
fn rejections_are_fail_closed() {
let secret = b"secret-0123456789";
let v = hs256(secret, "k1", None);
assert!(v
.verify(&hs256_token(
secret,
"k1",
serde_json::json!({ "iss": "https://evil.test", "exp": far_future() })
))
.is_none());
assert!(v
.verify(&hs256_token(
secret,
"k1",
serde_json::json!({ "iss": ISS, "exp": 1_000_000_000 })
))
.is_none());
assert!(v
.verify(&hs256_token(
secret,
"other-kid",
serde_json::json!({ "iss": ISS, "exp": far_future() })
))
.is_none());
let good = hs256_token(
secret,
"k1",
serde_json::json!({ "iss": ISS, "exp": far_future() }),
);
assert!(v.verify(&format!("{good}x")).is_none());
assert!(v
.verify(&hs256_token(
b"a-different-secret-999",
"k1",
serde_json::json!({ "iss": ISS, "exp": far_future() })
))
.is_none());
}
#[test]
fn audience_is_enforced_when_pinned() {
let secret = b"secret-0123456789";
let v = hs256(secret, "k1", Some("orders-api"));
assert!(v
.verify(&hs256_token(
secret,
"k1",
serde_json::json!({ "iss": ISS, "aud": "other", "exp": far_future() })
))
.is_none());
assert!(v
.verify(&hs256_token(
secret,
"k1",
serde_json::json!({ "iss": ISS, "aud": "orders-api", "exp": far_future() })
))
.is_some());
}
fn b64url(bytes: &[u8]) -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
fn ed25519_token(key: &SigningKey, kid: &str, claims: serde_json::Value) -> String {
let header = b64url(
serde_json::json!({ "alg": "EdDSA", "typ": "JWT", "kid": kid })
.to_string()
.as_bytes(),
);
let payload = b64url(claims.to_string().as_bytes());
let signing_input = format!("{header}.{payload}");
let sig = key.sign(signing_input.as_bytes());
format!("{signing_input}.{}", b64url(&sig.to_bytes()))
}
#[test]
fn a_jwks_ed25519_key_verifies_a_real_token() {
let key = SigningKey::from_bytes(&[7u8; 32]); let jwks = serde_json::json!({ "keys": [ {
"kty": "OKP", "crv": "Ed25519", "kid": "app-1",
"x": b64url(key.verifying_key().as_bytes()),
} ] })
.to_string();
let v = TokenVerifier::from_jwks_json(&jwks, ISS, None).unwrap();
let token = ed25519_token(
&key,
"app-1",
serde_json::json!({ "iss": ISS, "exp": far_future(), "tid": "acme" }),
);
assert_eq!(v.verify(&token).unwrap()["tid"], serde_json::json!("acme"));
let forged = ed25519_token(
&SigningKey::from_bytes(&[9u8; 32]),
"app-1",
serde_json::json!({ "iss": ISS, "exp": far_future(), "tid": "acme" }),
);
assert!(v.verify(&forged).is_none());
}
}