use std::sync::Arc;
use std::time::Duration;
use base64::Engine;
use chrono::Utc;
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
use moka::future::Cache;
use ring::digest::{digest, SHA256};
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const DEFAULT_IAT_LEEWAY: Duration = Duration::from_secs(60);
pub const DEFAULT_JTI_CACHE_CAPACITY: u64 = 100_000;
#[derive(Debug, Error)]
pub enum DpopError {
#[error("dpop proof header invalid: {0}")]
Header(String),
#[error("dpop proof signature failed: {0}")]
Signature(String),
#[error("dpop proof claims invalid: {0}")]
Claims(String),
#[error("dpop proof iat outside leeway window")]
IatOutOfWindow,
#[error("dpop proof jti replayed")]
Replayed,
#[error("unsupported jwk kty: {0}")]
UnsupportedKty(String),
#[error("jwk missing required field: {0}")]
MissingJwkField(&'static str),
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DpopProof {
pub jti: String,
pub htm: String,
pub htu: String,
pub iat: i64,
#[serde(default)]
pub ath: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ValidatedDpop {
pub jkt: String,
pub proof: DpopProof,
}
#[derive(Clone)]
pub struct DpopValidator {
inner: Arc<DpopInner>,
}
struct DpopInner {
iat_leeway: Duration,
jti_cache: Cache<String, ()>,
allowed_algorithms: Vec<Algorithm>,
}
impl DpopValidator {
pub fn new() -> Self {
Self::with_leeway(DEFAULT_IAT_LEEWAY)
}
pub fn with_leeway(iat_leeway: Duration) -> Self {
let ttl = iat_leeway * 2;
Self {
inner: Arc::new(DpopInner {
iat_leeway,
jti_cache: Cache::builder()
.max_capacity(DEFAULT_JTI_CACHE_CAPACITY)
.time_to_live(ttl)
.build(),
allowed_algorithms: vec![
Algorithm::ES256,
Algorithm::ES384,
Algorithm::PS256,
Algorithm::PS384,
Algorithm::PS512,
Algorithm::EdDSA,
],
}),
}
}
pub fn with_algorithms(self, algorithms: Vec<Algorithm>) -> Self {
let ttl = self.inner.iat_leeway * 2;
Self {
inner: Arc::new(DpopInner {
iat_leeway: self.inner.iat_leeway,
jti_cache: Cache::builder()
.max_capacity(DEFAULT_JTI_CACHE_CAPACITY)
.time_to_live(ttl)
.build(),
allowed_algorithms: algorithms,
}),
}
}
pub async fn validate(&self, proof_jwt: &str) -> Result<ValidatedDpop, DpopError> {
let header = decode_header(proof_jwt).map_err(|e| DpopError::Header(e.to_string()))?;
if header.typ.as_deref() != Some("dpop+jwt") {
return Err(DpopError::Header(format!(
"typ must be \"dpop+jwt\", got {:?}",
header.typ
)));
}
if !self.inner.allowed_algorithms.contains(&header.alg) {
return Err(DpopError::Header(format!(
"alg {:?} not in allowed set",
header.alg
)));
}
let jwk_value = header
.jwk
.ok_or_else(|| DpopError::Header("missing jwk in header".into()))?;
let jwk_json = serde_json::to_value(&jwk_value)
.map_err(|e| DpopError::Header(format!("jwk re-serialize: {e}")))?;
let decoding_key = DecodingKey::from_jwk(&jwk_value)
.map_err(|e| DpopError::Signature(format!("decoding key: {e}")))?;
let mut validation = Validation::new(header.alg);
validation.required_spec_claims.clear();
validation.validate_exp = false;
validation.validate_aud = false;
let data = decode::<DpopProof>(proof_jwt, &decoding_key, &validation)
.map_err(|e| DpopError::Signature(e.to_string()))?;
let claims = data.claims;
let now = Utc::now().timestamp();
let leeway = self.inner.iat_leeway.as_secs() as i64;
if (now - claims.iat).abs() > leeway {
return Err(DpopError::IatOutOfWindow);
}
if self.inner.jti_cache.get(&claims.jti).await.is_some() {
return Err(DpopError::Replayed);
}
self.inner.jti_cache.insert(claims.jti.clone(), ()).await;
let jkt = jwk_thumbprint(&jwk_json)?;
Ok(ValidatedDpop { jkt, proof: claims })
}
}
impl Default for DpopValidator {
fn default() -> Self {
Self::new()
}
}
pub fn jwk_thumbprint(jwk: &serde_json::Value) -> Result<String, DpopError> {
let kty = jwk
.get("kty")
.and_then(|v| v.as_str())
.ok_or(DpopError::MissingJwkField("kty"))?;
let mut canonical = serde_json::Map::new();
match kty {
"EC" => {
for field in ["crv", "kty", "x", "y"] {
let v = jwk
.get(field)
.ok_or(DpopError::MissingJwkField(match field {
"crv" => "crv",
"kty" => "kty",
"x" => "x",
"y" => "y",
_ => "unknown",
}))?;
canonical.insert(field.into(), v.clone());
}
}
"RSA" => {
for field in ["e", "kty", "n"] {
let v = jwk
.get(field)
.ok_or(DpopError::MissingJwkField(match field {
"e" => "e",
"kty" => "kty",
"n" => "n",
_ => "unknown",
}))?;
canonical.insert(field.into(), v.clone());
}
}
"OKP" => {
for field in ["crv", "kty", "x"] {
let v = jwk
.get(field)
.ok_or(DpopError::MissingJwkField(match field {
"crv" => "crv",
"kty" => "kty",
"x" => "x",
_ => "unknown",
}))?;
canonical.insert(field.into(), v.clone());
}
}
other => return Err(DpopError::UnsupportedKty(other.into())),
}
let canonical_json =
serde_json::to_string(&canonical).map_err(|e| DpopError::Header(e.to_string()))?;
let hash = digest(&SHA256, canonical_json.as_bytes());
Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash.as_ref()))
}