arcly-http-identity 0.9.0

CIAM building-block engine for arcly-http: identity store traits, password hashing, MFA (TOTP), passwordless & account lifecycle, risk signals, and an OIDC provider — storage-agnostic, zero-lock, all I/O behind traits.
Documentation
//! DPoP — Demonstration of Proof-of-Possession (RFC 9449), i.e. sender-constrained
//! access tokens (H3).
//!
//! A bearer token that leaks can be replayed by anyone. DPoP binds the token to
//! a key the client holds: the access token carries `cnf.jkt` (the SHA-256
//! thumbprint of the client's public JWK — minted via
//! [`JwtService::issue_access_bound_cnf`](arcly_http_core::auth::JwtService::issue_access_bound_cnf)),
//! and every request also carries a `DPoP` header: a short JWT **signed by the
//! client's private key** and bound to the HTTP method + URI. The resource
//! server verifies that proof and checks its key thumbprint equals the token's
//! `cnf.jkt` — so a stolen token is useless without the client's key.
//!
//! This module verifies the proof (signature via the **embedded** JWK, `htm`,
//! `htu`, `iat` freshness, and single-use `jti` replay) and exposes the
//! thumbprint for the binding check. Only EC P-256 (ES256) proofs are supported
//! — the common WebCrypto default — keeping the JWK surface small and auditable.

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};

/// A verified DPoP proof: the thumbprint of the presenting key plus the bound
/// request coordinates.
#[derive(Debug, Clone)]
pub struct DpopProof {
    /// RFC 7638 SHA-256 JWK thumbprint (base64url) — compare to the token's `cnf.jkt`.
    pub jkt: String,
    pub htm: String,
    pub htu: String,
    pub jti: String,
    pub iat: u64,
}

/// Replay guard for proof `jti`s. A proof is single-use within the acceptance
/// window; back with Redis (per-key TTL) in production.
#[async_trait]
pub trait DpopReplayStore: Send + Sync + 'static {
    /// Record `jti`; return `false` if it was already seen (replay).
    async fn check_and_store(&self, jti: &str, ttl_secs: u64) -> Result<bool>;
}

/// The embedded public JWK carried in a DPoP proof header (EC only).
#[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,
}

/// Verifies DPoP proofs. Stateless except for the injected replay store.
pub struct DpopVerifier {
    replay: std::sync::Arc<dyn DpopReplayStore>,
    /// Acceptable clock skew for `iat` (seconds).
    leeway_secs: u64,
}

impl DpopVerifier {
    pub fn new(replay: std::sync::Arc<dyn DpopReplayStore>) -> Self {
        Self {
            replay,
            leeway_secs: 60,
        }
    }

    /// Verify a `DPoP` proof header against the request's method + full URI.
    /// Returns the proof (incl. `jkt`) on success. Fails closed on a bad
    /// signature, `typ`/`alg` mismatch, `htm`/`htu` mismatch, stale `iat`, or a
    /// replayed `jti`.
    pub async fn verify(
        &self,
        dpop_header: &str,
        http_method: &str,
        http_uri: &str,
    ) -> Result<DpopProof> {
        // 1. Parse the JOSE header and pull the embedded public JWK.
        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);
        }
        // jsonwebtoken doesn't expose the custom `jwk` header field, so parse the
        // raw header segment ourselves.
        let jwk = parse_header_jwk(dpop_header)?;
        if jwk.kty != "EC" || jwk.crv != "P-256" {
            return Err(IdentityError::InvalidToken);
        }

        // 2. Verify the signature with the embedded key (proof-of-possession).
        let decoding = ec_p256_decoding_key(&jwk)?;
        let mut validation = Validation::new(Algorithm::ES256);
        validation.validate_exp = false; // DPoP proofs use iat + a short window
        validation.required_spec_claims.clear();
        let data = decode::<DpopClaims>(dpop_header, &decoding, &validation)
            .map_err(|_| IdentityError::InvalidToken)?;
        let claims = data.claims;

        // 3. Bind to this exact request.
        if !claims.htm.eq_ignore_ascii_case(http_method) {
            return Err(IdentityError::InvalidToken);
        }
        if !uri_matches(&claims.htu, http_uri) {
            return Err(IdentityError::InvalidToken);
        }

        // 4. Freshness.
        let now = unix_now();
        if claims.iat + self.leeway_secs < now || claims.iat > now + self.leeway_secs {
            return Err(IdentityError::InvalidToken);
        }

        // 5. Single-use jti (replay defence).
        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,
        })
    }

    /// Confirm a verified proof matches an access token's `cnf.jkt` claim
    /// (constant-time). This is the binding check a resource server runs.
    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)
        }
    }
}

// ─── Resource-server guard ──────────────────────────────────────────────────

/// Enforces DPoP proof-of-possession on a protected route (the resource-server
/// side of RFC 9449). Reads the access token's `cnf.jkt` (already decoded onto
/// [`RequestContext`] by the boundary), the request's `DPoP` proof header, and
/// the method + reconstructed URI, then verifies the proof and confirms the
/// thumbprint binding.
///
/// ## Not a sync `Guard` — an async guard, like `DistributedRateLimit`
///
/// The framework's [`Guard`](arcly_http_core::auth::guards::Guard) trait is
/// **synchronous and zero-I/O by design** (it inspects already-decoded claims).
/// A DPoP check must do per-request I/O — the single-use `jti` replay lookup —
/// so it cannot be a `Guard`. It follows the same shape the framework already
/// uses for `DistributedRateLimit`: an async method awaited at the top of the
/// handler (`dpop.enforce(&ctx).await?`), returning an error that converts to an
/// HTTP response. This is an established pattern in the codebase, not a new one.
///
/// Default posture is **downgrade-safe**: a token *without* `cnf.jkt` (a plain
/// bearer token) passes — an attacker can't strip `cnf` from a signed bound
/// token to bypass the check. Call [`require_bound`](Self::require_bound) to also
/// reject unbound tokens (all access tokens must be DPoP).
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,
        }
    }

    /// Scheme used to reconstruct the request URI when `X-Forwarded-Proto` is
    /// absent (default `"https"`). Set `"http"` for local development.
    pub fn default_scheme(mut self, scheme: &'static str) -> Self {
        self.default_scheme = scheme;
        self
    }

    /// Reject access tokens that are **not** sender-constrained (no `cnf.jkt`),
    /// so every request on the route must present a DPoP proof.
    pub fn require_bound(mut self) -> Self {
        self.require_bound = true;
        self
    }

    /// Enforce the binding for the current request.
    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 {
            // Unbound bearer token.
            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
    }

    /// The pure core: verify `dpop_header` against `method`/`htu` and confirm it
    /// matches `cnf_jkt`. Exposed so callers can bind to a URI they compute
    /// themselves (e.g. behind an unusual proxy setup).
    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)
    }

    /// Reconstruct the request URI (scheme://host/path) for `htu` comparison.
    /// Honours a trusted `X-Forwarded-Proto`; `htu` matching ignores the query.
    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())
    }
}

/// RFC 7638 JWK thumbprint for an EC key: SHA-256 over the canonical JSON
/// `{"crv":..,"kty":..,"x":..,"y":..}` (members in lexicographic order, no
/// whitespace), base64url-encoded.
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()))
}

/// Build an ES256 verification key from an EC P-256 JWK (x/y are base64url
/// 32-byte coordinates → uncompressed SEC1 point `04 || x || y`).
fn ec_p256_decoding_key(jwk: &EcJwk) -> Result<DecodingKey> {
    // jsonwebtoken supports building an EC key from the base64url coordinates.
    DecodingKey::from_ec_components(&jwk.x, &jwk.y).map_err(|_| IdentityError::InvalidToken)
}

/// Extract the `jwk` object from the proof's JOSE header without a full JWT lib
/// (jsonwebtoken hides custom header fields).
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)
}

/// Compare `htu` to the request URI, ignoring query/fragment per RFC 9449 §4.3.
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)
}

/// In-memory [`DpopReplayStore`] — tests / dev only (unbounded; use Redis in prod).
#[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); // opportunistic GC
        if seen.contains_key(jti) {
            return Ok(false);
        }
        seen.insert(jti.to_owned(), now + ttl_secs);
        Ok(true)
    }
}