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}