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 std::fmt;
pub const DEFAULT_IAT_LEEWAY: Duration = Duration::from_secs(60);
pub const DEFAULT_JTI_CACHE_CAPACITY: u64 = 100_000;
pub enum DpopError {
Header(String),
Signature(String),
Claims(String),
IatOutOfWindow,
Replayed,
UnsupportedKty(String),
MissingJwkField(&'static str),
}
impl DpopError {
fn diagnostic_class(&self) -> &'static str {
match self {
Self::Header(_) => "header",
Self::Signature(_) => "signature",
Self::Claims(_) => "claims",
Self::IatOutOfWindow => "iat-window",
Self::Replayed => "replay",
Self::UnsupportedKty(_) => "unsupported-key-type",
Self::MissingJwkField(_) => "missing-key-field",
}
}
}
impl fmt::Display for DpopError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"DPoP validation failed (class={})",
self.diagnostic_class()
)
}
}
impl fmt::Debug for DpopError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("DpopError")
.field("class", &self.diagnostic_class())
.finish()
}
}
impl std::error::Error for DpopError {}
#[derive(Clone, Deserialize, Serialize)]
pub struct DpopProof {
pub jti: String,
pub htm: String,
pub htu: String,
pub iat: i64,
#[serde(default)]
pub ath: Option<String>,
}
impl fmt::Debug for DpopProof {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("DpopProof")
.field("jti_present", &!self.jti.is_empty())
.field("method_present", &!self.htm.is_empty())
.field("uri_present", &!self.htu.is_empty())
.field("issued_at_present", &true)
.field("access_token_hash_present", &self.ath.is_some())
.finish()
}
}
#[derive(Clone)]
pub struct ValidatedDpop {
pub jkt: String,
pub proof: DpopProof,
}
impl fmt::Debug for ValidatedDpop {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ValidatedDpop")
.field("thumbprint_present", &!self.jkt.is_empty())
.field("proof", &self.proof)
.finish()
}
}
#[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()))
}