Skip to main content

authplane_sdk/
dpop.rs

1use base64::Engine;
2use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3use jsonwebtoken::jwk::Jwk;
4use jsonwebtoken::{
5    Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, decode_header,
6};
7use serde::{Deserialize, Serialize};
8use serde_json::{Map, Value};
9use sha2::{Digest, Sha256};
10use uuid::Uuid;
11
12use crate::constants::{dpop_claims, jwk_params};
13use crate::errors::validation_error;
14use crate::{AuthplaneError, VerifierError};
15
16/// Supported DPoP signing algorithms.
17///
18/// This slice is the source of truth for
19/// `dpop_signing_alg_values_supported` in the PRM document (RFC 9728 §2).
20/// JSON arrays are not order-significant by spec, but the
21/// `[ES256, RS256]` order is held stable so conformance fixtures can
22/// assert it byte-for-byte.
23pub const SUPPORTED_DPOP_ALGORITHMS: &[Algorithm] = &[Algorithm::ES256, Algorithm::RS256];
24
25/// Reject an algorithm that is not in [`SUPPORTED_DPOP_ALGORITHMS`].
26///
27/// Centralises the membership check that was hand-inlined at the
28/// outbound-proof construction sites (`create_dpop_proof`,
29/// `DpopProvider::with_options`). Returns
30/// `AuthplaneError::Auth(validation_error)`.
31///
32/// The inbound-verifier path (`verify_dpop_proof`) and the per-resource
33/// allowlist setter (`InboundDPoPOptions::with_allowed_proof_algorithms`)
34/// use the same algorithm set but bind it into their own error types
35/// (`VerifierError::InvalidClaims` and
36/// `InboundDPoPOptionsError::UnsupportedAlgorithm` respectively). They
37/// consult the constant directly rather than the helper because their
38/// `Result` return types differ.
39pub(crate) fn ensure_supported_dpop_alg(algorithm: Algorithm) -> Result<(), AuthplaneError> {
40    if SUPPORTED_DPOP_ALGORITHMS.contains(&algorithm) {
41        return Ok(());
42    }
43    Err(validation_error(&format!(
44        "DPoP algorithm {algorithm:?} is not supported; use one of {SUPPORTED_DPOP_ALGORITHMS:?}"
45    )))
46}
47
48#[derive(Debug, Clone)]
49pub struct DpopProofOptions {
50    pub private_key_pem: String,
51    pub public_jwk: Value,
52    pub algorithm: Algorithm,
53    pub key_id: Option<String>,
54    pub nonce: Option<String>,
55    /// Proof lifetime in seconds. Emitted as `exp = iat + proof_ttl_seconds`.
56    /// Defaults to 300 (5 minutes).
57    pub proof_ttl_seconds: Option<u64>,
58}
59
60#[derive(Debug, Clone, Copy)]
61pub struct DpopVerificationOptions<'a> {
62    pub expected_access_token: Option<&'a str>,
63    pub expected_nonce: Option<&'a str>,
64    pub allowed_algorithms: &'a [Algorithm],
65    pub clock_skew_seconds: u64,
66    pub max_age_seconds: u64,
67}
68
69/// Owned variant of [`DpopVerificationOptions`] for storing verification
70/// options without lifetime constraints.
71#[derive(Debug, Clone)]
72pub struct DpopVerificationOptionsOwned {
73    pub expected_access_token: Option<String>,
74    pub expected_nonce: Option<String>,
75    pub allowed_algorithms: Vec<Algorithm>,
76    pub clock_skew_seconds: u64,
77    pub max_age_seconds: u64,
78}
79
80impl DpopVerificationOptionsOwned {
81    pub fn as_ref(&self) -> DpopVerificationOptions<'_> {
82        DpopVerificationOptions {
83            expected_access_token: self.expected_access_token.as_deref(),
84            expected_nonce: self.expected_nonce.as_deref(),
85            allowed_algorithms: &self.allowed_algorithms,
86            clock_skew_seconds: self.clock_skew_seconds,
87            max_age_seconds: self.max_age_seconds,
88        }
89    }
90}
91
92/// Request-level context for the unified verify path (RFC 9449 §7).
93///
94/// When the SDK's unified [`AuthplaneResource::verify_with_context`]
95/// entrypoint receives a `DpopRequestContext`, it uses the token's
96/// `cnf.jkt` claim to decide whether sender-constraint validation must
97/// run:
98///
99/// * Token **without** `cnf.jkt`: the request context is informational —
100///   verification succeeds as a bearer token and `proof` is ignored.
101///   This is the catalog case
102///   `rfc9449-bearer-token-with-request-context-and-no-proof-must-still-verify-as-bearer`.
103/// * Token **with** `cnf.jkt` but `proof == None`: verification MUST
104///   reject with [`VerifierError::DpopProofMissing`]. This is the
105///   catalog case
106///   `rfc9449-dpop-bound-token-with-request-context-and-no-proof-must-be-rejected-via-main-verify-path`.
107/// * Token **with** `cnf.jkt` and `proof = Some(_)`: the proof is fully
108///   validated, including `cnf.jkt` ↔ `jwk` thumbprint match and `ath`
109///   binding against the access token.
110///
111/// [`AuthplaneResource::verify_with_context`]:
112///     crate::resource::AuthplaneResource::verify_with_context
113/// [`VerifierError::DpopProofMissing`]:
114///     crate::verified_claims::VerifierError::DpopProofMissing
115///
116/// Carries only the request shape (RFC 9449 §4.3 inputs to `htm`/`htu`/`ath`
117/// validation) plus the optional `nonce` echo. The replay store and other
118/// DPoP policy knobs (`max_proof_age_seconds`, `clock_skew_seconds`,
119/// `allowed_proof_algorithms`, `required`) live on the resource via
120/// [`InboundDPoPOptions`] — per-resource policy, applied automatically.
121///
122/// All fields are private so every context carries the normalization
123/// applied at construction (uppercased method; trimmed proof/nonce
124/// with blanks collapsed to `None` — never `Some("")`). Read through
125/// the same-named accessors.
126#[derive(Clone, Debug)]
127pub struct DpopRequestContext {
128    pub(crate) method: String,
129    pub(crate) url: String,
130    pub(crate) proof: Option<String>,
131    pub(crate) nonce: Option<String>,
132}
133
134impl DpopRequestContext {
135    /// Build a request context from a single, already-extracted proof.
136    ///
137    /// Normalizes on construction: `method` is uppercased, `proof` and
138    /// `nonce` are trimmed and blank values collapse to `None` — so a
139    /// context can never carry `Some("")`, which downstream mode
140    /// dispatch and proof verification would otherwise disagree about.
141    ///
142    /// When the proof comes from raw header values, use
143    /// [`DpopRequestContext::from_header_values`] instead — it also
144    /// enforces the RFC 9449 §4.3 #1 cardinality rule.
145    pub fn new(method: &str, url: &str, proof: Option<&str>, nonce: Option<&str>) -> Self {
146        Self {
147            method: method.to_ascii_uppercase(),
148            url: url.to_string(),
149            proof: proof
150                .map(|s| s.trim().to_string())
151                .filter(|s| !s.is_empty()),
152            nonce: nonce
153                .map(|s| s.trim().to_string())
154                .filter(|s| !s.is_empty()),
155        }
156    }
157
158    /// The HTTP method (`htm`), uppercased at construction.
159    pub fn method(&self) -> &str {
160        &self.method
161    }
162
163    /// The request URL (`htu`).
164    pub fn url(&self) -> &str {
165        &self.url
166    }
167
168    /// The DPoP proof JWT, when the request carried exactly one.
169    pub fn proof(&self) -> Option<&str> {
170        self.proof.as_deref()
171    }
172
173    /// The `DPoP-Nonce` echo, when the request carried a non-blank one.
174    pub fn nonce(&self) -> Option<&str> {
175        self.nonce.as_deref()
176    }
177
178    /// Build a request context from raw header values, enforcing
179    /// RFC 9449 §4.3 #1: at most one `DPoP` header per request.
180    ///
181    /// * Zero non-blank values in `proofs` is the bearer-only path
182    ///   (`proof = None`) — blank or empty header lines (as emitted by
183    ///   some proxies) do not count as proofs.
184    /// * Two or more reject with [`VerifierError::DpopMultipleProofs`],
185    ///   surfaced as a `DPoP`-scheme challenge carrying
186    ///   `error="invalid_dpop_proof"` (RFC 9449 §7.1).
187    /// * RFC 9110 §5.3 lets an intermediary fold repeated field lines
188    ///   into one comma-separated value, so each item is also split on
189    ///   `,` before counting — a JWS compact serialization never
190    ///   contains a literal comma, so the split is lossless. Two proofs
191    ///   folded into one line by a proxy still reject.
192    ///
193    /// Framework adapters reduce to header extraction plus this call
194    /// (`authplane-mcp`'s `dpop_request_context_from_axum` does exactly
195    /// that); hand-rolled integrations get the same §4.3 enforcement by
196    /// routing every inbound `DPoP` header value through `proofs`.
197    pub fn from_header_values<I, T>(
198        method: &str,
199        url: &str,
200        proofs: I,
201        nonce: Option<&str>,
202    ) -> Result<Self, VerifierError>
203    where
204        I: IntoIterator<Item = T>,
205        T: AsRef<str>,
206    {
207        let mut proof: Option<String> = None;
208        for value in proofs {
209            // Full split, not `splitn`: leading blank pieces would eat a
210            // bounded split's budget and fold two trailing proofs into
211            // one (",,a,b"). `split` is a lazy iterator and the loop
212            // early-returns on the second non-blank piece, so the scan
213            // stays O(header length) with no allocation.
214            for piece in value.as_ref().split(',') {
215                let piece = piece.trim();
216                if piece.is_empty() {
217                    continue;
218                }
219                if proof.is_some() {
220                    return Err(VerifierError::DpopMultipleProofs);
221                }
222                proof = Some(piece.to_string());
223            }
224        }
225        Ok(Self::new(method, url, proof.as_deref(), nonce))
226    }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct VerifiedDpopProof {
231    pub jti: String,
232    pub iat: i64,
233    pub method: String,
234    pub url: String,
235    pub nonce: Option<String>,
236    pub ath: Option<String>,
237    pub jkt: String,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize)]
241struct DpopClaims {
242    htm: String,
243    htu: String,
244    iat: i64,
245    jti: String,
246    #[serde(skip_serializing_if = "Option::is_none")]
247    nonce: Option<String>,
248    #[serde(skip_serializing_if = "Option::is_none")]
249    ath: Option<String>,
250    /// RFC 9449 §4.2 — optional `exp` claim. When present the proof
251    /// MUST be rejected if expired.
252    #[serde(skip_serializing_if = "Option::is_none")]
253    exp: Option<i64>,
254}
255
256pub fn dpop_ath(access_token: &str) -> String {
257    let mut hasher = Sha256::new();
258    hasher.update(access_token.as_bytes());
259    URL_SAFE_NO_PAD.encode(hasher.finalize())
260}
261
262pub fn create_dpop_proof(
263    method: &str,
264    target_url: &str,
265    access_token: Option<&str>,
266    options: &DpopProofOptions,
267) -> Result<String, AuthplaneError> {
268    ensure_supported_dpop_alg(options.algorithm)?;
269    let normalized_method = method.trim().to_ascii_uppercase();
270    if normalized_method.is_empty() {
271        return Err(validation_error("DPoP method must not be empty"));
272    }
273    let normalized_url = normalize_htu(target_url).map_err(|message| validation_error(&message))?;
274    let mut header = Header::new(options.algorithm);
275    header.typ = Some(dpop_claims::TYP_DPOP_JWT.to_string());
276    header.kid = options.key_id.clone();
277    header.jwk = Some(
278        serde_json::from_value::<Jwk>(options.public_jwk.clone())
279            .map_err(|error| validation_error(&format!("invalid DPoP public_jwk: {error}")))?,
280    );
281
282    let iat = unix_now();
283    let ttl = options.proof_ttl_seconds.unwrap_or(300);
284    let claims = DpopClaims {
285        htm: normalized_method,
286        htu: normalized_url,
287        iat,
288        jti: Uuid::new_v4().to_string(),
289        nonce: options.nonce.clone(),
290        ath: access_token.map(dpop_ath),
291        exp: Some(iat + ttl as i64),
292    };
293
294    let encoding_key = build_encoding_key(options)?;
295    jsonwebtoken::encode(&header, &claims, &encoding_key)
296        .map_err(|error| validation_error(&format!("failed to sign DPoP proof: {error}")))
297}
298
299pub fn verify_dpop_proof(
300    proof: &str,
301    expected_method: &str,
302    expected_url: &str,
303    options: DpopVerificationOptions<'_>,
304) -> Result<VerifiedDpopProof, VerifierError> {
305    let header = decode_header(proof).map_err(|error| VerifierError::InvalidClaims {
306        message: format!("invalid DPoP header: {error}"),
307    })?;
308    let typ = header.typ.ok_or_else(|| VerifierError::InvalidClaims {
309        message: "DPoP header missing typ".to_string(),
310    })?;
311    if typ != dpop_claims::TYP_DPOP_JWT {
312        return Err(VerifierError::InvalidClaims {
313            message: format!(
314                "DPoP typ must be {}, got {typ:?}",
315                dpop_claims::TYP_DPOP_JWT
316            ),
317        });
318    }
319    if !options.allowed_algorithms.contains(&header.alg) {
320        return Err(VerifierError::InvalidClaims {
321            message: format!("DPoP algorithm {:?} is not allowed", header.alg),
322        });
323    }
324    if !SUPPORTED_DPOP_ALGORITHMS.contains(&header.alg) {
325        return Err(VerifierError::InvalidClaims {
326            message: format!(
327                "DPoP algorithm {:?} is not in supported set {:?}",
328                header.alg, SUPPORTED_DPOP_ALGORITHMS
329            ),
330        });
331    }
332    let jwk = header.jwk.ok_or_else(|| VerifierError::InvalidClaims {
333        message: "DPoP header missing jwk".to_string(),
334    })?;
335    let decoding_key =
336        DecodingKey::from_jwk(&jwk).map_err(|error| VerifierError::InvalidClaims {
337            message: format!("invalid DPoP jwk: {error}"),
338        })?;
339
340    let mut validation = Validation::new(header.alg);
341    validation.validate_exp = false;
342    validation.validate_nbf = false;
343    validation.required_spec_claims.clear();
344    let decoded = decode::<DpopClaims>(proof, &decoding_key, &validation).map_err(|error| {
345        VerifierError::InvalidSignature {
346            message: format!("DPoP signature validation failed: {error}"),
347        }
348    })?;
349    let claims = decoded.claims;
350
351    let expected_method = expected_method.trim().to_ascii_uppercase();
352    if claims.htm != expected_method {
353        return Err(VerifierError::InvalidClaims {
354            message: format!(
355                "DPoP htm mismatch: expected {expected_method:?}, got {:?}",
356                claims.htm
357            ),
358        });
359    }
360    let expected_url =
361        normalize_htu(expected_url).map_err(|message| VerifierError::InvalidClaims { message })?;
362    if claims.htu != expected_url {
363        return Err(VerifierError::InvalidClaims {
364            message: format!(
365                "DPoP htu mismatch: expected {expected_url:?}, got {:?}",
366                claims.htu
367            ),
368        });
369    }
370
371    let now = unix_now();
372    let max_age = options.max_age_seconds as i64;
373    let skew = options.clock_skew_seconds as i64;
374    if claims.iat > now + skew {
375        return Err(VerifierError::InvalidClaims {
376            message: format!(
377                "DPoP iat is in the future (iat={}, now={}, leeway={}s)",
378                claims.iat, now, options.clock_skew_seconds
379            ),
380        });
381    }
382    if now - claims.iat > max_age + skew {
383        return Err(VerifierError::InvalidClaims {
384            message: format!(
385                "DPoP proof is too old (iat={}, now={}, max_age={}s, skew={}s)",
386                claims.iat, now, options.max_age_seconds, options.clock_skew_seconds
387            ),
388        });
389    }
390    // RFC 9449 §4.2 — honour `exp` when the AS includes it.
391    if let Some(exp) = claims.exp
392        && exp < now - skew
393    {
394        return Err(VerifierError::InvalidClaims {
395            message: format!("DPoP proof has expired (exp={exp}, now={now}, skew={skew}s)"),
396        });
397    }
398    if claims.jti.trim().is_empty() {
399        return Err(VerifierError::InvalidClaims {
400            message: "DPoP jti must not be empty".to_string(),
401        });
402    }
403
404    match (options.expected_nonce, claims.nonce.as_deref()) {
405        (Some(expected), Some(actual)) if expected == actual => {}
406        (Some(expected), Some(actual)) => {
407            return Err(VerifierError::InvalidClaims {
408                message: format!("DPoP nonce mismatch: expected {expected:?}, got {actual:?}"),
409            });
410        }
411        (Some(expected), None) => {
412            return Err(VerifierError::InvalidClaims {
413                message: format!("DPoP nonce {expected:?} required but missing"),
414            });
415        }
416        (None, _) => {}
417    }
418
419    if let Some(token) = options.expected_access_token {
420        let expected_ath = dpop_ath(token);
421        let actual_ath = claims
422            .ath
423            .as_deref()
424            .ok_or_else(|| VerifierError::InvalidClaims {
425                message: "DPoP ath is required for token-bound verification".to_string(),
426            })?;
427        if expected_ath != actual_ath {
428            return Err(VerifierError::InvalidClaims {
429                message: "DPoP ath mismatch".to_string(),
430            });
431        }
432    }
433
434    let jwk_json =
435        extract_jwk_header_json(proof).map_err(|message| VerifierError::InvalidClaims {
436            message: format!("invalid DPoP jwk header: {message}"),
437        })?;
438    let jkt = jwk_thumbprint_sha256(&jwk_json).map_err(|message| VerifierError::InvalidClaims {
439        message: format!("invalid DPoP jwk thumbprint: {message}"),
440    })?;
441
442    Ok(VerifiedDpopProof {
443        jti: claims.jti,
444        iat: claims.iat,
445        method: claims.htm,
446        url: claims.htu,
447        nonce: claims.nonce,
448        ath: claims.ath,
449        jkt,
450    })
451}
452
453/// Verify a DPoP proof and atomically register its `jti` with the supplied
454/// replay store. Returns [`VerifierError::DpopReplayDetected`] if the
455/// proof's `jti` had already been observed.
456///
457/// This is the async equivalent of [`verify_dpop_proof`] with
458/// `replay_store` plumbed in. The proof's expiry (`iat + max_age`) is
459/// passed to [`crate::DpopReplayStore::check_and_store`] as the entry's
460/// expiry timestamp so stale entries can be evicted.
461///
462/// **For DPoP-bound access tokens** (`cnf.jkt` is set), callers MUST
463/// compare the access token's `cnf.jkt` to the verified proof's `jkt` —
464/// and they MUST do so BEFORE this function is called, or use the safer
465/// [`verify_dpop_proof_with_jkt_and_replay`] which folds both checks
466/// into one atomic operation. Doing the `cnf.jkt` check after this
467/// function returns is unsafe: an attacker who knows a legitimate
468/// `jti` value can submit a proof bearing that `jti` with the wrong
469/// `jkt`; the `jti` will be registered in the replay store before the
470/// caller's external `jkt` comparison runs, locking out the legitimate
471/// proof carrying the same `jti`.
472pub async fn verify_dpop_proof_with_replay(
473    proof: &str,
474    expected_method: &str,
475    expected_url: &str,
476    options: DpopVerificationOptions<'_>,
477    replay_store: &dyn crate::dpop_replay::DpopReplayStore,
478) -> Result<VerifiedDpopProof, VerifierError> {
479    let verified = verify_dpop_proof(proof, expected_method, expected_url, options)?;
480    let expires_at = verified.iat + options.max_age_seconds as i64;
481    let stored = replay_store
482        .check_and_store(&verified.jti, expires_at)
483        .await?;
484    if !stored {
485        return Err(VerifierError::DpopReplayDetected);
486    }
487    Ok(verified)
488}
489
490/// Verify a DPoP proof, check that its `jkt` matches the expected value
491/// **before** registering its `jti` in the replay store, then atomically
492/// commit the `jti`. Use this when verifying a proof against a
493/// DPoP-bound access token whose `cnf.jkt` claim must match.
494///
495/// The order of operations matters: an attacker who knows a legitimate
496/// `jti` could otherwise submit a proof carrying that `jti` with the
497/// wrong `jkt` and force the legitimate proof to be rejected as a
498/// replay. By comparing `jkt` first, an attacker-supplied wrong-`jkt`
499/// proof never reaches the replay store and the legitimate `jti` slot
500/// remains available.
501pub async fn verify_dpop_proof_with_jkt_and_replay(
502    proof: &str,
503    expected_method: &str,
504    expected_url: &str,
505    options: DpopVerificationOptions<'_>,
506    expected_jkt: &str,
507    replay_store: &dyn crate::dpop_replay::DpopReplayStore,
508) -> Result<VerifiedDpopProof, VerifierError> {
509    let verified = verify_dpop_proof(proof, expected_method, expected_url, options)?;
510    if verified.jkt != expected_jkt {
511        return Err(VerifierError::DpopBindingMismatch {
512            message: format!(
513                "DPoP cnf.jkt mismatch: token expects {:?}, proof has {:?}",
514                expected_jkt, verified.jkt
515            ),
516        });
517    }
518    let expires_at = verified.iat + options.max_age_seconds as i64;
519    let stored = replay_store
520        .check_and_store(&verified.jti, expires_at)
521        .await?;
522    if !stored {
523        return Err(VerifierError::DpopReplayDetected);
524    }
525    Ok(verified)
526}
527
528pub fn jwk_thumbprint_sha256(jwk: &Value) -> Result<String, String> {
529    let kty = jwk
530        .get(jwk_params::KTY)
531        .and_then(Value::as_str)
532        .ok_or_else(|| "jwk missing kty".to_string())?;
533    let canonical = match kty {
534        jwk_params::KTY_RSA => canonical_jwk_json(&[
535            (jwk_params::E, get_required_jwk_field(jwk, jwk_params::E)?),
536            (jwk_params::KTY, jwk_params::KTY_RSA),
537            (jwk_params::N, get_required_jwk_field(jwk, jwk_params::N)?),
538        ])?,
539        jwk_params::KTY_EC => canonical_jwk_json(&[
540            (
541                jwk_params::CRV,
542                get_required_jwk_field(jwk, jwk_params::CRV)?,
543            ),
544            (jwk_params::KTY, jwk_params::KTY_EC),
545            (jwk_params::X, get_required_jwk_field(jwk, jwk_params::X)?),
546            (jwk_params::Y, get_required_jwk_field(jwk, jwk_params::Y)?),
547        ])?,
548        jwk_params::KTY_OKP => canonical_jwk_json(&[
549            (
550                jwk_params::CRV,
551                get_required_jwk_field(jwk, jwk_params::CRV)?,
552            ),
553            (jwk_params::KTY, jwk_params::KTY_OKP),
554            (jwk_params::X, get_required_jwk_field(jwk, jwk_params::X)?),
555        ])?,
556        _ => return Err(format!("unsupported jwk kty {kty:?} for thumbprint")),
557    };
558
559    let mut hasher = Sha256::new();
560    hasher.update(canonical.as_bytes());
561    Ok(URL_SAFE_NO_PAD.encode(hasher.finalize()))
562}
563
564fn get_required_jwk_field<'a>(jwk: &'a Value, field: &str) -> Result<&'a str, String> {
565    jwk.get(field)
566        .and_then(Value::as_str)
567        .filter(|value| !value.is_empty())
568        .ok_or_else(|| format!("jwk missing {field}"))
569}
570
571fn build_encoding_key(options: &DpopProofOptions) -> Result<EncodingKey, AuthplaneError> {
572    let key = match options.algorithm {
573        Algorithm::RS256 | Algorithm::RS384 | Algorithm::RS512 => {
574            EncodingKey::from_rsa_pem(options.private_key_pem.as_bytes())
575        }
576        Algorithm::ES256 | Algorithm::ES384 => {
577            EncodingKey::from_ec_pem(options.private_key_pem.as_bytes())
578        }
579        Algorithm::EdDSA => EncodingKey::from_ed_pem(options.private_key_pem.as_bytes()),
580        _ => {
581            return Err(validation_error(&format!(
582                "unsupported DPoP signing algorithm {:?}",
583                options.algorithm
584            )));
585        }
586    };
587    key.map_err(|error| validation_error(&format!("invalid DPoP private key: {error}")))
588}
589
590fn canonical_jwk_json(fields: &[(&str, &str)]) -> Result<String, String> {
591    let object = fields
592        .iter()
593        .map(|(key, value)| ((*key).to_string(), Value::String((*value).to_string())))
594        .collect::<Map<String, Value>>();
595    serde_json::to_string(&object).map_err(|error| error.to_string())
596}
597
598fn extract_jwk_header_json(proof: &str) -> Result<Value, String> {
599    let header_segment = proof
600        .split('.')
601        .next()
602        .ok_or_else(|| "malformed compact jwt".to_string())?;
603    let decoded = URL_SAFE_NO_PAD
604        .decode(header_segment)
605        .map_err(|error| error.to_string())?;
606    let header_json: Value = serde_json::from_slice(&decoded).map_err(|error| error.to_string())?;
607    header_json
608        .get("jwk")
609        .cloned()
610        .ok_or_else(|| "header missing jwk".to_string())
611}
612
613fn normalize_htu(raw: &str) -> Result<String, String> {
614    let mut url = url::Url::parse(raw).map_err(|error| error.to_string())?;
615    // RFC 9449 §4.3 step 10 expects htu to be the "URL of the resource the
616    // request is targeted at", with query string and fragment stripped.
617    // Userinfo (`user:pass@host`) carries credentials and is never part of
618    // the resource identifier — strip it so a request that omits userinfo
619    // and a request that includes it produce the same htu for matching.
620    url.set_query(None);
621    url.set_fragment(None);
622    // `set_username` / `set_password` only return `Err(())` on cannot-be-a-base
623    // URLs (e.g. `data:` / `mailto:`). DPoP `htu` is always an absolute http(s)
624    // resource URL by RFC 9449 §4.2, so these always succeed for valid inputs;
625    // for any pathological case the URL is left as-is (no userinfo to strip
626    // means nothing to strip).
627    let _ = url.set_username("");
628    let _ = url.set_password(None);
629    Ok(url.to_string())
630}
631
632use crate::time_utils::unix_now_secs_i64 as unix_now;
633
634#[cfg(test)]
635mod tests {
636    use super::{
637        DpopProofOptions, DpopRequestContext, DpopVerificationOptions, create_dpop_proof, dpop_ath,
638        ensure_supported_dpop_alg, jwk_thumbprint_sha256, verify_dpop_proof,
639        verify_dpop_proof_with_jkt_and_replay,
640    };
641    use crate::dpop_replay::{DpopReplayStore, InMemoryDpopReplayStore};
642    use crate::{AuthplaneError, VerifierError};
643    use jsonwebtoken::Algorithm;
644    use serde_json::json;
645
646    const TEST_PRIVATE_PEM: &str = include_str!("../tests/fixtures/test-private.pem");
647    const TEST_EC_PRIVATE_PEM: &str = include_str!("../tests/fixtures/test-ec-private.pem");
648    const TEST_RSA_N: &str = "pza1Jk6AXrea2P-TlgPStQO4PJ8H4mCz3qaW-PqscKygy31-_T-XNpYlH948O-hS3eN0bKLLKJetWx8bSWxBlMMW4DlV-vv32kO-phwPGE0BbQ2rMfZXfEKwKbcU_hTQv3_yfo6eugv3g_9bZR16MaNOWL0fWTmmcYoD7j8mODWoTgwGnHoriRE9wLgHOkXSJ-lnV4gR3Wa0HdI1Th91kve4mMC4DxxpzZ37xh5d0wyExHSb9bssowS70hts0JD-TX46MSpgVoCcZfBefyJ9JKoVgxVZ2aYGsdR8pwVRSRYUf2CYDvKyUZ8HfoWBv4JwBO0AVqT5Eb5F-X375fULQQ";
649    const TEST_RSA_E: &str = "AQAB";
650    const TEST_EC_X: &str = "w7JAoU_gJbZJvV-zCOvU9yFJq0FNC_edCMRM78P8eQQ";
651    const TEST_EC_Y: &str = "wQg1EytcsEmGrM70Gb53oluoDbVhCZ3Uq3hHMslHVb4";
652
653    fn rsa_options(nonce: Option<&str>) -> DpopProofOptions {
654        DpopProofOptions {
655            private_key_pem: TEST_PRIVATE_PEM.to_string(),
656            public_jwk: json!({
657                "kty": "RSA",
658                "kid": "test-kid",
659                "use": "sig",
660                "alg": "RS256",
661                "n": TEST_RSA_N,
662                "e": TEST_RSA_E
663            }),
664            algorithm: Algorithm::RS256,
665            key_id: Some("test-kid".to_string()),
666            nonce: nonce.map(ToString::to_string),
667            proof_ttl_seconds: None,
668        }
669    }
670
671    #[test]
672    fn ath_is_base64url_sha256() {
673        let ath = dpop_ath("abc123");
674        assert_eq!(ath, "bKE9UspwyIPg8LsQHkJaiehiTeUdstI5JZOvaoQRgJA");
675    }
676
677    #[test]
678    fn jwk_thumbprint_matches_expected_shape() {
679        let jwk = json!({
680            "kty": "RSA",
681            "n": TEST_RSA_N,
682            "e": TEST_RSA_E
683        });
684        let thumbprint = jwk_thumbprint_sha256(&jwk).expect("thumbprint");
685        assert!(!thumbprint.is_empty());
686    }
687
688    #[test]
689    fn create_dpop_proof_signs_compact_jwt() {
690        let proof = create_dpop_proof(
691            "POST",
692            "https://api.example.com/mcp",
693            Some("token-value"),
694            &rsa_options(Some("nonce-1")),
695        )
696        .expect("proof");
697        assert_eq!(proof.split('.').count(), 3);
698    }
699
700    #[test]
701    fn create_dpop_proof_supports_es256() {
702        let proof = create_dpop_proof(
703            "POST",
704            "https://api.example.com/mcp",
705            None,
706            &DpopProofOptions {
707                private_key_pem: TEST_EC_PRIVATE_PEM.to_string(),
708                public_jwk: json!({
709                    "kty": "EC",
710                    "kid": "ec-test-kid",
711                    "use": "sig",
712                    "alg": "ES256",
713                    "crv": "P-256",
714                    "x": TEST_EC_X,
715                    "y": TEST_EC_Y
716                }),
717                algorithm: Algorithm::ES256,
718                key_id: Some("ec-test-kid".to_string()),
719                nonce: None,
720                proof_ttl_seconds: None,
721            },
722        )
723        .expect("proof");
724        assert_eq!(proof.split('.').count(), 3);
725    }
726
727    #[test]
728    fn verify_dpop_proof_accepts_valid_proof() {
729        let proof = create_dpop_proof(
730            "POST",
731            "https://api.example.com/mcp",
732            Some("token-value"),
733            &rsa_options(Some("nonce-1")),
734        )
735        .expect("proof");
736
737        let verified = verify_dpop_proof(
738            &proof,
739            "POST",
740            "https://api.example.com/mcp",
741            DpopVerificationOptions {
742                expected_access_token: Some("token-value"),
743                expected_nonce: Some("nonce-1"),
744                allowed_algorithms: &[Algorithm::RS256],
745                clock_skew_seconds: 30,
746                max_age_seconds: 300,
747            },
748        )
749        .expect("valid proof");
750
751        assert_eq!(verified.method, "POST");
752        assert_eq!(verified.url, "https://api.example.com/mcp");
753        assert_eq!(verified.nonce.as_deref(), Some("nonce-1"));
754        assert_eq!(
755            verified.ath.as_deref(),
756            Some(dpop_ath("token-value").as_str())
757        );
758        assert!(!verified.jkt.is_empty());
759    }
760
761    #[test]
762    fn verify_dpop_proof_rejects_wrong_nonce() {
763        let proof = create_dpop_proof(
764            "POST",
765            "https://api.example.com/mcp",
766            Some("token-value"),
767            &rsa_options(Some("nonce-1")),
768        )
769        .expect("proof");
770
771        let err = verify_dpop_proof(
772            &proof,
773            "POST",
774            "https://api.example.com/mcp",
775            DpopVerificationOptions {
776                expected_access_token: Some("token-value"),
777                expected_nonce: Some("nonce-2"),
778                allowed_algorithms: &[Algorithm::RS256],
779                clock_skew_seconds: 30,
780                max_age_seconds: 300,
781            },
782        )
783        .expect_err("nonce mismatch should fail");
784        assert!(err.to_string().contains("nonce mismatch"));
785    }
786
787    #[test]
788    fn verify_dpop_proof_rejects_wrong_method() {
789        let proof = create_dpop_proof(
790            "POST",
791            "https://api.example.com/mcp",
792            Some("token-value"),
793            &rsa_options(None),
794        )
795        .expect("proof");
796
797        let err = verify_dpop_proof(
798            &proof,
799            "GET",
800            "https://api.example.com/mcp",
801            DpopVerificationOptions {
802                expected_access_token: Some("token-value"),
803                expected_nonce: None,
804                allowed_algorithms: &[Algorithm::RS256],
805                clock_skew_seconds: 30,
806                max_age_seconds: 300,
807            },
808        )
809        .expect_err("method mismatch should fail");
810        assert!(err.to_string().contains("htm mismatch"));
811    }
812
813    #[test]
814    fn verify_dpop_proof_rejects_wrong_ath() {
815        let proof = create_dpop_proof(
816            "POST",
817            "https://api.example.com/mcp",
818            Some("token-value"),
819            &rsa_options(None),
820        )
821        .expect("proof");
822
823        let err = verify_dpop_proof(
824            &proof,
825            "POST",
826            "https://api.example.com/mcp",
827            DpopVerificationOptions {
828                expected_access_token: Some("other-token"),
829                expected_nonce: None,
830                allowed_algorithms: &[Algorithm::RS256],
831                clock_skew_seconds: 30,
832                max_age_seconds: 300,
833            },
834        )
835        .expect_err("ath mismatch should fail");
836        assert!(err.to_string().contains("ath mismatch"));
837    }
838
839    #[test]
840    fn create_dpop_proof_rejects_empty_method() {
841        let err = create_dpop_proof("", "https://api.example.com/mcp", None, &rsa_options(None))
842            .expect_err("empty method should fail");
843        assert!(err.to_string().contains("method must not be empty"));
844    }
845
846    #[test]
847    fn verify_dpop_proof_rejects_missing_required_nonce() {
848        let proof = create_dpop_proof(
849            "POST",
850            "https://api.example.com/mcp",
851            Some("token-value"),
852            &rsa_options(None),
853        )
854        .expect("proof");
855
856        let err = verify_dpop_proof(
857            &proof,
858            "POST",
859            "https://api.example.com/mcp",
860            DpopVerificationOptions {
861                expected_access_token: Some("token-value"),
862                expected_nonce: Some("nonce-required"),
863                allowed_algorithms: &[Algorithm::RS256],
864                clock_skew_seconds: 30,
865                max_age_seconds: 300,
866            },
867        )
868        .expect_err("missing nonce should fail");
869        assert!(err.to_string().contains("required but missing"));
870    }
871
872    #[test]
873    fn verify_dpop_proof_rejects_disallowed_algorithm() {
874        let proof = create_dpop_proof(
875            "POST",
876            "https://api.example.com/mcp",
877            Some("token-value"),
878            &rsa_options(None),
879        )
880        .expect("proof");
881
882        let err = verify_dpop_proof(
883            &proof,
884            "POST",
885            "https://api.example.com/mcp",
886            DpopVerificationOptions {
887                expected_access_token: Some("token-value"),
888                expected_nonce: None,
889                allowed_algorithms: &[Algorithm::ES256],
890                clock_skew_seconds: 30,
891                max_age_seconds: 300,
892            },
893        )
894        .expect_err("disallowed alg should fail");
895        assert!(err.to_string().contains("not allowed"));
896    }
897
898    #[test]
899    fn ensure_supported_dpop_alg_accepts_es256_and_rs256() {
900        ensure_supported_dpop_alg(Algorithm::ES256).expect("ES256 supported");
901        ensure_supported_dpop_alg(Algorithm::RS256).expect("RS256 supported");
902    }
903
904    #[test]
905    fn ensure_supported_dpop_alg_rejects_other_algorithms() {
906        let err = ensure_supported_dpop_alg(Algorithm::HS256)
907            .expect_err("HS256 must be rejected for outbound DPoP");
908        let AuthplaneError::Auth(auth_error) = err else {
909            panic!("expected auth error");
910        };
911        assert_eq!(auth_error.code, "validation_error");
912        assert!(auth_error.message.contains("HS256"));
913        assert!(auth_error.message.contains("ES256"));
914        assert!(auth_error.message.contains("RS256"));
915    }
916
917    /// Regression: a wrong-jkt proof must NOT register its jti in the replay
918    /// store. An attacker who knows a legitimate jti could otherwise submit
919    /// a proof carrying that jti with the wrong jkt; the previous
920    /// `verify_dpop_proof_with_replay` registered the jti before any jkt
921    /// comparison ran, locking out the legitimate proof carrying the same
922    /// jti as a "replay". The new `verify_dpop_proof_with_jkt_and_replay`
923    /// compares jkt FIRST and leaves the slot free on mismatch.
924    #[tokio::test]
925    async fn verify_dpop_proof_with_jkt_and_replay_leaves_jti_slot_free_on_jkt_mismatch() {
926        let proof = create_dpop_proof(
927            "POST",
928            "https://api.example.com/mcp",
929            Some("token-value"),
930            &rsa_options(None),
931        )
932        .expect("proof");
933
934        // Peek the proof's jti without touching the replay store.
935        let peeked = verify_dpop_proof(
936            &proof,
937            "POST",
938            "https://api.example.com/mcp",
939            DpopVerificationOptions {
940                expected_access_token: Some("token-value"),
941                expected_nonce: None,
942                allowed_algorithms: &[Algorithm::RS256],
943                clock_skew_seconds: 30,
944                max_age_seconds: 300,
945            },
946        )
947        .expect("peek");
948
949        let replay_store = InMemoryDpopReplayStore::new();
950
951        // Submit with the WRONG jkt — must fail BEFORE the jti commit.
952        let err = verify_dpop_proof_with_jkt_and_replay(
953            &proof,
954            "POST",
955            "https://api.example.com/mcp",
956            DpopVerificationOptions {
957                expected_access_token: Some("token-value"),
958                expected_nonce: None,
959                allowed_algorithms: &[Algorithm::RS256],
960                clock_skew_seconds: 30,
961                max_age_seconds: 300,
962            },
963            "definitely-not-the-real-jkt",
964            &replay_store,
965        )
966        .await
967        .expect_err("wrong jkt must reject");
968
969        assert!(matches!(err, VerifierError::DpopBindingMismatch { .. }));
970
971        // The jti slot must still be free: a fresh check_and_store with the
972        // peeked jti returns true (newly stored). If the buggy old order had
973        // run, this would return false (slot already taken) and the
974        // legitimate proof would be locked out as a replay.
975        let stored = replay_store
976            .check_and_store(&peeked.jti, peeked.iat + 300)
977            .await
978            .expect("check_and_store");
979        assert!(
980            stored,
981            "wrong-jkt proof leaked its jti into the replay store"
982        );
983    }
984
985    #[test]
986    fn from_header_values_zero_proofs_is_bearer_path() {
987        let ctx = DpopRequestContext::from_header_values(
988            "post",
989            "https://api.example.com/mcp",
990            Vec::<&str>::new(),
991            None,
992        )
993        .expect("zero proofs is valid");
994        assert_eq!(ctx.method, "POST");
995        assert_eq!(ctx.url, "https://api.example.com/mcp");
996        assert_eq!(ctx.proof, None);
997        assert_eq!(ctx.nonce, None);
998    }
999
1000    #[test]
1001    fn from_header_values_single_proof_is_trimmed() {
1002        let ctx = DpopRequestContext::from_header_values(
1003            "POST",
1004            "https://api.example.com/mcp",
1005            ["  proof-jwt  "],
1006            Some(" server-nonce "),
1007        )
1008        .expect("one proof is valid");
1009        assert_eq!(ctx.proof.as_deref(), Some("proof-jwt"));
1010        assert_eq!(ctx.nonce.as_deref(), Some("server-nonce"));
1011    }
1012
1013    #[test]
1014    fn from_header_values_multiple_proofs_rejected() {
1015        // RFC 9449 §4.3 #1 — more than one DPoP header value rejects
1016        // before any proof validation.
1017        let err = DpopRequestContext::from_header_values(
1018            "POST",
1019            "https://api.example.com/mcp",
1020            ["first", "second"],
1021            None,
1022        )
1023        .expect_err("two proofs must reject");
1024        assert!(matches!(err, VerifierError::DpopMultipleProofs));
1025    }
1026
1027    #[test]
1028    fn from_header_values_comma_folded_proofs_rejected() {
1029        // RFC 9110 §5.3 — a proxy may fold two DPoP field lines into one
1030        // comma-separated value. Still two proofs, still §4.3 #1. The
1031        // ",,first,second" shape guards against a bounded split whose
1032        // budget is eaten by leading blank pieces.
1033        for folded in ["first,second", "first, second", ",,first,second"] {
1034            let err = DpopRequestContext::from_header_values(
1035                "POST",
1036                "https://api.example.com/mcp",
1037                [folded],
1038                None,
1039            )
1040            .expect_err("comma-folded proofs must reject");
1041            assert!(matches!(err, VerifierError::DpopMultipleProofs));
1042        }
1043    }
1044
1045    #[test]
1046    fn from_header_values_blank_line_does_not_count_as_proof() {
1047        // An empty `DPoP:` line emitted by a proxy must not turn a
1048        // legitimate single-proof request into a §4.3 rejection.
1049        let ctx = DpopRequestContext::from_header_values(
1050            "POST",
1051            "https://api.example.com/mcp",
1052            ["", "proof-jwt"],
1053            None,
1054        )
1055        .expect("blank line plus one proof is a single-proof request");
1056        assert_eq!(ctx.proof(), Some("proof-jwt"));
1057    }
1058
1059    #[test]
1060    fn from_header_values_whitespace_only_proof_is_bearer_path() {
1061        // A lone whitespace-only DPoP header normalizes to proof-absent —
1062        // never `Some("")`, which mode dispatch and proof verification
1063        // would disagree about.
1064        let ctx = DpopRequestContext::from_header_values(
1065            "POST",
1066            "https://api.example.com/mcp",
1067            ["   "],
1068            None,
1069        )
1070        .expect("whitespace-only header is proof-absent");
1071        assert_eq!(ctx.proof(), None);
1072    }
1073
1074    #[test]
1075    fn new_normalizes_blank_proof_to_none() {
1076        let ctx = DpopRequestContext::new("post", "https://api.example.com/mcp", Some("  "), None);
1077        assert_eq!(ctx.proof(), None);
1078        assert_eq!(ctx.method, "POST");
1079    }
1080}