use std::collections::HashMap;
use std::sync::RwLock;
use std::time::{SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use arcly_http_core::web::RequestContext;
use crate::error::{IdentityError, Result};
#[derive(Debug, Clone)]
pub struct DpopProof {
pub jkt: String,
pub htm: String,
pub htu: String,
pub jti: String,
pub iat: u64,
}
#[async_trait]
pub trait DpopReplayStore: Send + Sync + 'static {
async fn check_and_store(&self, jti: &str, ttl_secs: u64) -> Result<bool>;
}
#[derive(Debug, Clone, Deserialize)]
struct EcJwk {
kty: String,
crv: String,
x: String,
y: String,
}
#[derive(Debug, Deserialize)]
struct DpopClaims {
htm: String,
htu: String,
jti: String,
iat: u64,
}
pub struct DpopVerifier {
replay: std::sync::Arc<dyn DpopReplayStore>,
leeway_secs: u64,
}
impl DpopVerifier {
pub fn new(replay: std::sync::Arc<dyn DpopReplayStore>) -> Self {
Self {
replay,
leeway_secs: 60,
}
}
pub async fn verify(
&self,
dpop_header: &str,
http_method: &str,
http_uri: &str,
) -> Result<DpopProof> {
let header = decode_header(dpop_header).map_err(|_| IdentityError::InvalidToken)?;
if header.typ.as_deref() != Some("dpop+jwt") {
return Err(IdentityError::InvalidToken);
}
if header.alg != Algorithm::ES256 {
return Err(IdentityError::InvalidToken);
}
let jwk = parse_header_jwk(dpop_header)?;
if jwk.kty != "EC" || jwk.crv != "P-256" {
return Err(IdentityError::InvalidToken);
}
let decoding = ec_p256_decoding_key(&jwk)?;
let mut validation = Validation::new(Algorithm::ES256);
validation.validate_exp = false; validation.required_spec_claims.clear();
let data = decode::<DpopClaims>(dpop_header, &decoding, &validation)
.map_err(|_| IdentityError::InvalidToken)?;
let claims = data.claims;
if !claims.htm.eq_ignore_ascii_case(http_method) {
return Err(IdentityError::InvalidToken);
}
if !uri_matches(&claims.htu, http_uri) {
return Err(IdentityError::InvalidToken);
}
let now = unix_now();
if claims.iat + self.leeway_secs < now || claims.iat > now + self.leeway_secs {
return Err(IdentityError::InvalidToken);
}
if !self
.replay
.check_and_store(&claims.jti, 2 * self.leeway_secs)
.await?
{
return Err(IdentityError::InvalidToken);
}
Ok(DpopProof {
jkt: jwk_thumbprint(&jwk),
htm: claims.htm,
htu: claims.htu,
jti: claims.jti,
iat: claims.iat,
})
}
pub fn confirm_binding(proof: &DpopProof, cnf_jkt: &str) -> Result<()> {
if proof.jkt.as_bytes().ct_eq(cnf_jkt.as_bytes()).into() {
Ok(())
} else {
Err(IdentityError::Forbidden)
}
}
}
pub struct DpopResourceGuard {
verifier: std::sync::Arc<DpopVerifier>,
default_scheme: &'static str,
require_bound: bool,
}
impl DpopResourceGuard {
pub fn new(verifier: std::sync::Arc<DpopVerifier>) -> Self {
Self {
verifier,
default_scheme: "https",
require_bound: false,
}
}
pub fn default_scheme(mut self, scheme: &'static str) -> Self {
self.default_scheme = scheme;
self
}
pub fn require_bound(mut self) -> Self {
self.require_bound = true;
self
}
pub async fn enforce(&self, ctx: &RequestContext) -> Result<()> {
let claims = ctx.claims().ok_or(IdentityError::InvalidCredentials)?;
let cnf_jkt = claims
.get("cnf")
.and_then(|c| c.get("jkt"))
.and_then(|v| v.as_str());
let Some(cnf_jkt) = cnf_jkt else {
return if self.require_bound {
Err(IdentityError::Forbidden)
} else {
Ok(())
};
};
let dpop = ctx.header("dpop").ok_or(IdentityError::InvalidToken)?;
let htu = self.reconstruct_htu(ctx);
self.enforce_parts(cnf_jkt, dpop, ctx.method().as_str(), &htu)
.await
}
pub async fn enforce_parts(
&self,
cnf_jkt: &str,
dpop_header: &str,
method: &str,
htu: &str,
) -> Result<()> {
let proof = self.verifier.verify(dpop_header, method, htu).await?;
DpopVerifier::confirm_binding(&proof, cnf_jkt)
}
fn reconstruct_htu(&self, ctx: &RequestContext) -> String {
let scheme = ctx
.header("x-forwarded-proto")
.unwrap_or(self.default_scheme);
let host = ctx.header("host").unwrap_or("");
format!("{scheme}://{host}{}", ctx.path())
}
}
fn jwk_thumbprint(jwk: &EcJwk) -> String {
let canonical = format!(
r#"{{"crv":"{}","kty":"{}","x":"{}","y":"{}"}}"#,
jwk.crv, jwk.kty, jwk.x, jwk.y
);
URL_SAFE_NO_PAD.encode(Sha256::digest(canonical.as_bytes()))
}
fn ec_p256_decoding_key(jwk: &EcJwk) -> Result<DecodingKey> {
DecodingKey::from_ec_components(&jwk.x, &jwk.y).map_err(|_| IdentityError::InvalidToken)
}
fn parse_header_jwk(token: &str) -> Result<EcJwk> {
let header_b64 = token.split('.').next().ok_or(IdentityError::InvalidToken)?;
let bytes = URL_SAFE_NO_PAD
.decode(header_b64)
.map_err(|_| IdentityError::InvalidToken)?;
let value: serde_json::Value =
serde_json::from_slice(&bytes).map_err(|_| IdentityError::InvalidToken)?;
let jwk = value.get("jwk").ok_or(IdentityError::InvalidToken)?;
serde_json::from_value(jwk.clone()).map_err(|_| IdentityError::InvalidToken)
}
fn uri_matches(htu: &str, request_uri: &str) -> bool {
fn normalize(u: &str) -> &str {
u.split(['?', '#']).next().unwrap_or(u)
}
normalize(htu) == normalize(request_uri)
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[derive(Default)]
pub struct MemoryDpopReplayStore {
seen: RwLock<HashMap<String, u64>>,
}
impl MemoryDpopReplayStore {
pub fn new() -> Self {
Self::default()
}
}
#[async_trait]
impl DpopReplayStore for MemoryDpopReplayStore {
async fn check_and_store(&self, jti: &str, ttl_secs: u64) -> Result<bool> {
let mut seen = self.seen.write().unwrap();
let now = unix_now();
seen.retain(|_, exp| *exp > now); if seen.contains_key(jti) {
return Ok(false);
}
seen.insert(jti.to_owned(), now + ttl_secs);
Ok(true)
}
}