Skip to main content

arcly_http_identity/
dpop.rs

1//! DPoP — Demonstration of Proof-of-Possession (RFC 9449), i.e. sender-constrained
2//! access tokens (H3).
3//!
4//! A bearer token that leaks can be replayed by anyone. DPoP binds the token to
5//! a key the client holds: the access token carries `cnf.jkt` (the SHA-256
6//! thumbprint of the client's public JWK — minted via
7//! [`JwtService::issue_access_bound_cnf`](arcly_http_core::auth::JwtService::issue_access_bound_cnf)),
8//! and every request also carries a `DPoP` header: a short JWT **signed by the
9//! client's private key** and bound to the HTTP method + URI. The resource
10//! server verifies that proof and checks its key thumbprint equals the token's
11//! `cnf.jkt` — so a stolen token is useless without the client's key.
12//!
13//! This module verifies the proof (signature via the **embedded** JWK, `htm`,
14//! `htu`, `iat` freshness, and single-use `jti` replay) and exposes the
15//! thumbprint for the binding check. Only EC P-256 (ES256) proofs are supported
16//! — the common WebCrypto default — keeping the JWK surface small and auditable.
17
18use std::collections::HashMap;
19use std::sync::RwLock;
20use std::time::{SystemTime, UNIX_EPOCH};
21
22use async_trait::async_trait;
23use base64::engine::general_purpose::URL_SAFE_NO_PAD;
24use base64::Engine;
25use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
26use serde::Deserialize;
27use sha2::{Digest, Sha256};
28use subtle::ConstantTimeEq;
29
30use arcly_http_core::web::RequestContext;
31
32use crate::error::{IdentityError, Result};
33
34/// A verified DPoP proof: the thumbprint of the presenting key plus the bound
35/// request coordinates.
36#[derive(Debug, Clone)]
37pub struct DpopProof {
38    /// RFC 7638 SHA-256 JWK thumbprint (base64url) — compare to the token's `cnf.jkt`.
39    pub jkt: String,
40    pub htm: String,
41    pub htu: String,
42    pub jti: String,
43    pub iat: u64,
44}
45
46/// Replay guard for proof `jti`s. A proof is single-use within the acceptance
47/// window; back with Redis (per-key TTL) in production.
48#[async_trait]
49pub trait DpopReplayStore: Send + Sync + 'static {
50    /// Record `jti`; return `false` if it was already seen (replay).
51    async fn check_and_store(&self, jti: &str, ttl_secs: u64) -> Result<bool>;
52}
53
54/// The embedded public JWK carried in a DPoP proof header (EC only).
55#[derive(Debug, Clone, Deserialize)]
56struct EcJwk {
57    kty: String,
58    crv: String,
59    x: String,
60    y: String,
61}
62
63#[derive(Debug, Deserialize)]
64struct DpopClaims {
65    htm: String,
66    htu: String,
67    jti: String,
68    iat: u64,
69}
70
71/// Verifies DPoP proofs. Stateless except for the injected replay store.
72pub struct DpopVerifier {
73    replay: std::sync::Arc<dyn DpopReplayStore>,
74    /// Acceptable clock skew for `iat` (seconds).
75    leeway_secs: u64,
76}
77
78impl DpopVerifier {
79    pub fn new(replay: std::sync::Arc<dyn DpopReplayStore>) -> Self {
80        Self {
81            replay,
82            leeway_secs: 60,
83        }
84    }
85
86    /// Verify a `DPoP` proof header against the request's method + full URI.
87    /// Returns the proof (incl. `jkt`) on success. Fails closed on a bad
88    /// signature, `typ`/`alg` mismatch, `htm`/`htu` mismatch, stale `iat`, or a
89    /// replayed `jti`.
90    pub async fn verify(
91        &self,
92        dpop_header: &str,
93        http_method: &str,
94        http_uri: &str,
95    ) -> Result<DpopProof> {
96        // 1. Parse the JOSE header and pull the embedded public JWK.
97        let header = decode_header(dpop_header).map_err(|_| IdentityError::InvalidToken)?;
98        if header.typ.as_deref() != Some("dpop+jwt") {
99            return Err(IdentityError::InvalidToken);
100        }
101        if header.alg != Algorithm::ES256 {
102            return Err(IdentityError::InvalidToken);
103        }
104        // jsonwebtoken doesn't expose the custom `jwk` header field, so parse the
105        // raw header segment ourselves.
106        let jwk = parse_header_jwk(dpop_header)?;
107        if jwk.kty != "EC" || jwk.crv != "P-256" {
108            return Err(IdentityError::InvalidToken);
109        }
110
111        // 2. Verify the signature with the embedded key (proof-of-possession).
112        let decoding = ec_p256_decoding_key(&jwk)?;
113        let mut validation = Validation::new(Algorithm::ES256);
114        validation.validate_exp = false; // DPoP proofs use iat + a short window
115        validation.required_spec_claims.clear();
116        let data = decode::<DpopClaims>(dpop_header, &decoding, &validation)
117            .map_err(|_| IdentityError::InvalidToken)?;
118        let claims = data.claims;
119
120        // 3. Bind to this exact request.
121        if !claims.htm.eq_ignore_ascii_case(http_method) {
122            return Err(IdentityError::InvalidToken);
123        }
124        if !uri_matches(&claims.htu, http_uri) {
125            return Err(IdentityError::InvalidToken);
126        }
127
128        // 4. Freshness.
129        let now = unix_now();
130        if claims.iat + self.leeway_secs < now || claims.iat > now + self.leeway_secs {
131            return Err(IdentityError::InvalidToken);
132        }
133
134        // 5. Single-use jti (replay defence).
135        if !self
136            .replay
137            .check_and_store(&claims.jti, 2 * self.leeway_secs)
138            .await?
139        {
140            return Err(IdentityError::InvalidToken);
141        }
142
143        Ok(DpopProof {
144            jkt: jwk_thumbprint(&jwk),
145            htm: claims.htm,
146            htu: claims.htu,
147            jti: claims.jti,
148            iat: claims.iat,
149        })
150    }
151
152    /// Confirm a verified proof matches an access token's `cnf.jkt` claim
153    /// (constant-time). This is the binding check a resource server runs.
154    pub fn confirm_binding(proof: &DpopProof, cnf_jkt: &str) -> Result<()> {
155        if proof.jkt.as_bytes().ct_eq(cnf_jkt.as_bytes()).into() {
156            Ok(())
157        } else {
158            Err(IdentityError::Forbidden)
159        }
160    }
161}
162
163// ─── Resource-server guard ──────────────────────────────────────────────────
164
165/// Enforces DPoP proof-of-possession on a protected route (the resource-server
166/// side of RFC 9449). Reads the access token's `cnf.jkt` (already decoded onto
167/// [`RequestContext`] by the boundary), the request's `DPoP` proof header, and
168/// the method + reconstructed URI, then verifies the proof and confirms the
169/// thumbprint binding.
170///
171/// ## Not a sync `Guard` — an async guard, like `DistributedRateLimit`
172///
173/// The framework's [`Guard`](arcly_http_core::auth::guards::Guard) trait is
174/// **synchronous and zero-I/O by design** (it inspects already-decoded claims).
175/// A DPoP check must do per-request I/O — the single-use `jti` replay lookup —
176/// so it cannot be a `Guard`. It follows the same shape the framework already
177/// uses for `DistributedRateLimit`: an async method awaited at the top of the
178/// handler (`dpop.enforce(&ctx).await?`), returning an error that converts to an
179/// HTTP response. This is an established pattern in the codebase, not a new one.
180///
181/// Default posture is **downgrade-safe**: a token *without* `cnf.jkt` (a plain
182/// bearer token) passes — an attacker can't strip `cnf` from a signed bound
183/// token to bypass the check. Call [`require_bound`](Self::require_bound) to also
184/// reject unbound tokens (all access tokens must be DPoP).
185pub struct DpopResourceGuard {
186    verifier: std::sync::Arc<DpopVerifier>,
187    default_scheme: &'static str,
188    require_bound: bool,
189}
190
191impl DpopResourceGuard {
192    pub fn new(verifier: std::sync::Arc<DpopVerifier>) -> Self {
193        Self {
194            verifier,
195            default_scheme: "https",
196            require_bound: false,
197        }
198    }
199
200    /// Scheme used to reconstruct the request URI when `X-Forwarded-Proto` is
201    /// absent (default `"https"`). Set `"http"` for local development.
202    pub fn default_scheme(mut self, scheme: &'static str) -> Self {
203        self.default_scheme = scheme;
204        self
205    }
206
207    /// Reject access tokens that are **not** sender-constrained (no `cnf.jkt`),
208    /// so every request on the route must present a DPoP proof.
209    pub fn require_bound(mut self) -> Self {
210        self.require_bound = true;
211        self
212    }
213
214    /// Enforce the binding for the current request.
215    pub async fn enforce(&self, ctx: &RequestContext) -> Result<()> {
216        let claims = ctx.claims().ok_or(IdentityError::InvalidCredentials)?;
217        let cnf_jkt = claims
218            .get("cnf")
219            .and_then(|c| c.get("jkt"))
220            .and_then(|v| v.as_str());
221
222        let Some(cnf_jkt) = cnf_jkt else {
223            // Unbound bearer token.
224            return if self.require_bound {
225                Err(IdentityError::Forbidden)
226            } else {
227                Ok(())
228            };
229        };
230
231        let dpop = ctx.header("dpop").ok_or(IdentityError::InvalidToken)?;
232        let htu = self.reconstruct_htu(ctx);
233        self.enforce_parts(cnf_jkt, dpop, ctx.method().as_str(), &htu)
234            .await
235    }
236
237    /// The pure core: verify `dpop_header` against `method`/`htu` and confirm it
238    /// matches `cnf_jkt`. Exposed so callers can bind to a URI they compute
239    /// themselves (e.g. behind an unusual proxy setup).
240    pub async fn enforce_parts(
241        &self,
242        cnf_jkt: &str,
243        dpop_header: &str,
244        method: &str,
245        htu: &str,
246    ) -> Result<()> {
247        let proof = self.verifier.verify(dpop_header, method, htu).await?;
248        DpopVerifier::confirm_binding(&proof, cnf_jkt)
249    }
250
251    /// Reconstruct the request URI (scheme://host/path) for `htu` comparison.
252    /// Honours a trusted `X-Forwarded-Proto`; `htu` matching ignores the query.
253    fn reconstruct_htu(&self, ctx: &RequestContext) -> String {
254        let scheme = ctx
255            .header("x-forwarded-proto")
256            .unwrap_or(self.default_scheme);
257        let host = ctx.header("host").unwrap_or("");
258        format!("{scheme}://{host}{}", ctx.path())
259    }
260}
261
262/// RFC 7638 JWK thumbprint for an EC key: SHA-256 over the canonical JSON
263/// `{"crv":..,"kty":..,"x":..,"y":..}` (members in lexicographic order, no
264/// whitespace), base64url-encoded.
265fn jwk_thumbprint(jwk: &EcJwk) -> String {
266    let canonical = format!(
267        r#"{{"crv":"{}","kty":"{}","x":"{}","y":"{}"}}"#,
268        jwk.crv, jwk.kty, jwk.x, jwk.y
269    );
270    URL_SAFE_NO_PAD.encode(Sha256::digest(canonical.as_bytes()))
271}
272
273/// Build an ES256 verification key from an EC P-256 JWK (x/y are base64url
274/// 32-byte coordinates → uncompressed SEC1 point `04 || x || y`).
275fn ec_p256_decoding_key(jwk: &EcJwk) -> Result<DecodingKey> {
276    // jsonwebtoken supports building an EC key from the base64url coordinates.
277    DecodingKey::from_ec_components(&jwk.x, &jwk.y).map_err(|_| IdentityError::InvalidToken)
278}
279
280/// Extract the `jwk` object from the proof's JOSE header without a full JWT lib
281/// (jsonwebtoken hides custom header fields).
282fn parse_header_jwk(token: &str) -> Result<EcJwk> {
283    let header_b64 = token.split('.').next().ok_or(IdentityError::InvalidToken)?;
284    let bytes = URL_SAFE_NO_PAD
285        .decode(header_b64)
286        .map_err(|_| IdentityError::InvalidToken)?;
287    let value: serde_json::Value =
288        serde_json::from_slice(&bytes).map_err(|_| IdentityError::InvalidToken)?;
289    let jwk = value.get("jwk").ok_or(IdentityError::InvalidToken)?;
290    serde_json::from_value(jwk.clone()).map_err(|_| IdentityError::InvalidToken)
291}
292
293/// Compare `htu` to the request URI, ignoring query/fragment per RFC 9449 §4.3.
294fn uri_matches(htu: &str, request_uri: &str) -> bool {
295    fn normalize(u: &str) -> &str {
296        u.split(['?', '#']).next().unwrap_or(u)
297    }
298    normalize(htu) == normalize(request_uri)
299}
300
301fn unix_now() -> u64 {
302    SystemTime::now()
303        .duration_since(UNIX_EPOCH)
304        .map(|d| d.as_secs())
305        .unwrap_or(0)
306}
307
308/// In-memory [`DpopReplayStore`] — tests / dev only (unbounded; use Redis in prod).
309#[derive(Default)]
310pub struct MemoryDpopReplayStore {
311    seen: RwLock<HashMap<String, u64>>,
312}
313
314impl MemoryDpopReplayStore {
315    pub fn new() -> Self {
316        Self::default()
317    }
318}
319
320#[async_trait]
321impl DpopReplayStore for MemoryDpopReplayStore {
322    async fn check_and_store(&self, jti: &str, ttl_secs: u64) -> Result<bool> {
323        let mut seen = self.seen.write().unwrap();
324        let now = unix_now();
325        seen.retain(|_, exp| *exp > now); // opportunistic GC
326        if seen.contains_key(jti) {
327            return Ok(false);
328        }
329        seen.insert(jti.to_owned(), now + ttl_secs);
330        Ok(true)
331    }
332}