Skip to main content

alibi_plugins/
oauth2.rs

1//! `DPoP` proof verification and single-use reservations for OAuth resource servers.
2use alibi_core::utils::json::JsValue;
3use alibi_core::{AuthContext, AuthSchema, CreateVerification};
4use async_trait::async_trait;
5use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
6use chrono::{DateTime, Utc};
7use serde::Serialize;
8use serde_json::{Value, json};
9use sha2::{Digest as _, Sha256};
10use std::{collections::HashMap, str::FromStr, sync::Mutex};
11
12pub const DPOP_AUTHORIZATION_SCHEME: &str = "DPoP";
13pub const BEARER_AUTHORIZATION_SCHEME: &str = "Bearer";
14pub const DPOP_PROOF_TYPE: &str = "dpop+jwt";
15pub const DPOP_SIGNING_ALGORITHMS: [&str; 5] = ["EdDSA", "ES256", "ES512", "PS256", "RS256"];
16#[derive(Debug, Clone)]
17pub struct DpopError {
18    pub code: Option<String>,
19    pub message: String,
20}
21impl std::fmt::Display for DpopError {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        f.write_str(&self.message)
24    }
25}
26impl std::error::Error for DpopError {}
27impl DpopError {
28    fn proof(message: impl Into<String>) -> Self {
29        Self {
30            code: Some("invalid_dpop_proof".into()),
31            message: message.into(),
32        }
33    }
34    fn ordinary(message: impl Into<String>) -> Self {
35        Self {
36            code: None,
37            message: message.into(),
38        }
39    }
40}
41pub fn create_dpop_proof_error(code: &str, message: &str) -> DpopError {
42    DpopError {
43        code: Some(code.into()),
44        message: message.into(),
45    }
46}
47pub fn create_dpop_binding_error(code: &str, message: &str) -> DpopError {
48    create_dpop_proof_error(code, message)
49}
50pub fn is_dpop_proof_error(error: &DpopError) -> bool {
51    error.code.as_deref() == Some("invalid_dpop_proof")
52}
53pub fn is_dpop_binding_error(error: &DpopError) -> bool {
54    matches!(
55        error.code.as_deref(),
56        Some("invalid_dpop_proof" | "invalid_token")
57    )
58}
59#[derive(Debug, Clone)]
60pub struct DpopReplayReservation {
61    pub key: String,
62    /// None represents a reference Invalid Date, not an absent reservation.
63    pub expires_at: Option<DateTime<Utc>>,
64    pub now: Option<DateTime<Utc>>,
65}
66#[async_trait]
67pub trait DpopReplayStore: Send + Sync {
68    async fn reserve(&self, reservation: &DpopReplayReservation) -> Result<bool, DpopError>;
69}
70#[derive(Debug, Default)]
71pub struct InMemoryDpopReplayStore {
72    reservations: Mutex<HashMap<String, Option<DateTime<Utc>>>>,
73}
74pub fn create_in_memory_dpop_replay_store() -> InMemoryDpopReplayStore {
75    InMemoryDpopReplayStore::default()
76}
77#[async_trait]
78impl DpopReplayStore for InMemoryDpopReplayStore {
79    async fn reserve(&self, r: &DpopReplayReservation) -> Result<bool, DpopError> {
80        let mut rows = self
81            .reservations
82            .lock()
83            .map_err(|_| DpopError::ordinary("DPoP replay store lock poisoned"))?;
84        rows.retain(|_, expires| match (expires, r.now) {
85            (Some(expires), Some(now)) => *expires > now,
86            _ => true,
87        });
88        if rows.contains_key(&r.key) {
89            return Ok(false);
90        }
91        _ = rows.insert(r.key.clone(), r.expires_at);
92        Ok(true)
93    }
94}
95#[derive(Debug, Clone, Serialize)]
96#[serde(rename_all = "camelCase")]
97pub struct DpopVerificationReservation {
98    pub identifier: String,
99    pub value: String,
100    pub expires_at: Option<DateTime<Utc>>,
101}
102/// Single-use storage capability for applications and auth contexts.
103#[async_trait]
104pub trait DpopReplayReservations: Send + Sync {
105    async fn reserve_verification_value(
106        &self,
107        data: DpopVerificationReservation,
108    ) -> Result<bool, DpopError>;
109}
110#[async_trait]
111impl<S: AuthSchema> DpopReplayReservations for AuthContext<S> {
112    async fn reserve_verification_value(
113        &self,
114        data: DpopVerificationReservation,
115    ) -> Result<bool, DpopError> {
116        let expires_at = data
117            .expires_at
118            .ok_or_else(|| DpopError::ordinary("Invalid time value"))?;
119        self.verifications()
120            .reserve(CreateVerification {
121                identifier: data.identifier,
122                value: data.value,
123                expires_at,
124            })
125            .await
126            .map_err(|error| DpopError::ordinary(auth_message(error)))
127    }
128}
129pub struct DatabaseDpopReplayStore<'a> {
130    reservations: &'a dyn DpopReplayReservations,
131}
132pub fn create_dpop_replay_store(
133    reservations: &dyn DpopReplayReservations,
134) -> DatabaseDpopReplayStore<'_> {
135    DatabaseDpopReplayStore { reservations }
136}
137#[async_trait]
138impl DpopReplayStore for DatabaseDpopReplayStore<'_> {
139    async fn reserve(&self, r: &DpopReplayReservation) -> Result<bool, DpopError> {
140        self.reservations
141            .reserve_verification_value(DpopVerificationReservation {
142                identifier: format!("dpop-proof:{}", r.key),
143                value: r.key.clone(),
144                expires_at: r.expires_at,
145            })
146            .await
147    }
148}
149pub struct VerifyDpopProofOptions<'a> {
150    pub proof_jwt: &'a str,
151    pub method: &'a str,
152    pub url: &'a str,
153    pub access_token: Option<&'a str>,
154    pub expected_jkt: Option<&'a str>,
155    pub require_ath: bool,
156    pub now_seconds: Option<f64>,
157    pub proof_max_age_seconds: Option<f64>,
158    pub signing_algorithms: Option<Vec<String>>,
159    pub replay_store: Option<&'a dyn DpopReplayStore>,
160}
161impl<'a> VerifyDpopProofOptions<'a> {
162    pub fn new(proof_jwt: &'a str, method: &'a str, url: &'a str) -> Self {
163        Self {
164            proof_jwt,
165            method,
166            url,
167            access_token: None,
168            expected_jkt: None,
169            require_ath: false,
170            now_seconds: None,
171            proof_max_age_seconds: None,
172            signing_algorithms: None,
173            replay_store: None,
174        }
175    }
176}
177#[derive(Debug, Clone, Serialize)]
178#[serde(rename_all = "camelCase")]
179pub struct VerifiedDpopProof {
180    pub jwk: Value,
181    pub jkt: String,
182    pub jti: String,
183    pub htm: String,
184    pub htu: String,
185    pub iat: f64,
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub ath: Option<String>,
188    pub replay_key: String,
189    pub expires_at: Option<DateTime<Utc>>,
190}
191pub fn normalize_dpop_htu(input: &str) -> Result<String, DpopError> {
192    let parsed = url::Url::parse(input).map_err(|_| DpopError::ordinary("Invalid URL"))?;
193    if parsed.fragment().is_some_and(|value| !value.is_empty()) {
194        return Err(DpopError::ordinary(
195            "DPoP proof htu must not contain a fragment",
196        ));
197    }
198    Ok(format!(
199        "{}{}",
200        parsed.origin().ascii_serialization(),
201        parsed.path()
202    ))
203}
204pub fn derive_dpop_ath(access_token: &str) -> String {
205    URL_SAFE_NO_PAD.encode(Sha256::digest(access_token))
206}
207pub fn derive_dpop_jkt(jwk: &Value) -> Result<String, DpopError> {
208    let kind = jwk
209        .get("kty")
210        .and_then(Value::as_str)
211        .ok_or_else(|| DpopError::ordinary("Key must be a JSON Web Key"))?;
212    let fields: &[(&str, &str)] = match kind {
213        "EC" => &[
214            ("crv", "Curve"),
215            ("kty", "Key Type"),
216            ("x", "X Coordinate"),
217            ("y", "Y Coordinate"),
218        ],
219        "OKP" => &[
220            ("crv", "Subtype of Key Pair"),
221            ("kty", "Key Type"),
222            ("x", "Public Key"),
223        ],
224        "RSA" => &[("e", "Exponent"), ("kty", "Key Type"), ("n", "Modulus")],
225        "oct" => &[("k", "Key Value"), ("kty", "Key Type")],
226        "AKP" => &[
227            ("alg", "Algorithm"),
228            ("kty", "Key Type"),
229            ("pub", "Public key"),
230        ],
231        _ => {
232            return Err(DpopError::ordinary(
233                "\"kty\" (Key Type) Parameter missing or unsupported",
234            ));
235        }
236    };
237    let mut canonical = std::collections::BTreeMap::new();
238    for (field, label) in fields {
239        let value = jwk
240            .get(*field)
241            .and_then(Value::as_str)
242            .filter(|value| !value.is_empty() || (kind == "oct" && *field == "k"))
243            .ok_or_else(|| {
244                DpopError::ordinary(format!(
245                    "\"{field}\" ({label}) Parameter missing or invalid"
246                ))
247            })?;
248        _ = canonical.insert(*field, value);
249    }
250    let bytes =
251        serde_json::to_vec(&canonical).map_err(|error| DpopError::ordinary(error.to_string()))?;
252    Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(bytes)))
253}
254pub fn get_confirmation_jkt(confirmation: &Value) -> Option<&str> {
255    confirmation
256        .as_object()?
257        .get("jkt")?
258        .as_str()
259        .filter(|value| !value.is_empty())
260}
261pub fn get_dpop_jkt_from_payload(payload: &Value) -> Result<Option<&str>, DpopError> {
262    if payload.is_null() {
263        return Err(DpopError::ordinary(
264            "null is not an object (evaluating 'payload.cnf')",
265        ));
266    }
267    Ok(payload.get("cnf").and_then(get_confirmation_jkt))
268}
269fn auth_message(error: alibi_core::AuthError) -> String {
270    match error {
271        alibi_core::AuthError::Internal(message) | alibi_core::AuthError::Config(message) => {
272            message
273        }
274        other => other.to_string(),
275    }
276}
277fn date(seconds: f64) -> Option<DateTime<Utc>> {
278    let millis = (seconds * 1000.0).trunc().to_string().parse::<i64>().ok()?;
279    DateTime::from_timestamp_millis(millis)
280}
281fn decode(value: &str) -> Result<JsValue, DpopError> {
282    let bytes = crate::jwt::decode_compact_part(value, false)
283        .map_err(|_| DpopError::proof("Invalid Token or Protected Header formatting"))?;
284    alibi_core::utils::json::parse_value(&String::from_utf8_lossy(&bytes))
285        .map_err(|_| DpopError::proof("Invalid Token or Protected Header formatting"))
286}
287fn string_claim<'a>(payload: &'a JsValue, key: &str) -> Option<&'a str> {
288    payload.get(key)?.as_str().filter(|value| !value.is_empty())
289}
290fn prepare_dpop_public_key(algorithm: &str, jwk: &Value) -> Result<(Value, bool), DpopError> {
291    if jwk.get("ext").is_some_and(|v| !v.is_boolean()) {
292        return Err(DpopError::proof(
293            "\"ext\" (Extractable) Parameter must be a boolean",
294        ));
295    }
296    let operations = if let Some(value) = jwk.get("key_ops") {
297        let invalid = || {
298            DpopError::proof(
299                "\"key_ops\" (Key Operations) Parameter must be an array of unique strings",
300            )
301        };
302        let values = value.as_array().ok_or_else(invalid)?;
303        let mut unique = std::collections::HashSet::new();
304        for value in values {
305            let value = value.as_str().ok_or_else(invalid)?;
306            if !unique.insert(value) {
307                return Err(invalid());
308            }
309        }
310        Some(values)
311    } else {
312        None
313    };
314    let kty = jwk.get("kty").and_then(Value::as_str);
315    if !matches!(kty, Some("EC" | "OKP" | "RSA" | "AKP")) {
316        return Err(DpopError::proof(
317            "Unsupported \"kty\" (Key Type) Parameter value",
318        ));
319    }
320    let (expected, name, curve) = match algorithm {
321        "EdDSA" | "Ed25519" => ("OKP", "Ed25519", Some("Ed25519")),
322        "ES256" => ("EC", "ECDSA", Some("P-256")),
323        "ES384" => ("EC", "ECDSA", Some("P-384")),
324        "ES512" => ("EC", "ECDSA", Some("P-521")),
325        "RS256" | "RS384" | "RS512" => ("RSA", "RSASSA-PKCS1-v1_5", None),
326        "PS256" | "PS384" | "PS512" => ("RSA", "RSA-PSS", None),
327        "ML-DSA-44" | "ML-DSA-65" | "ML-DSA-87" => ("AKP", algorithm, None),
328        _ => {
329            return Err(DpopError::proof(
330                "Invalid or unsupported JWK \"alg\" (Algorithm) Parameter value",
331            ));
332        }
333    };
334    if kty != Some(expected) {
335        return Err(DpopError::proof(
336            "Invalid or unsupported JWK \"alg\" (Algorithm) Parameter value",
337        ));
338    }
339    if expected == "OKP" && jwk.get("crv").and_then(Value::as_str) != curve {
340        return Err(DpopError::proof(
341            "JWK \"crv\" Parameter and algorithm name mismatch",
342        ));
343    }
344    if let Some(values) = operations
345        && values.iter().any(|v| {
346            ![
347                "encrypt",
348                "decrypt",
349                "sign",
350                "verify",
351                "deriveKey",
352                "deriveBits",
353                "wrapKey",
354                "unwrapKey",
355            ]
356            .contains(&v.as_str().unwrap_or_default())
357        })
358    {
359        return Err(DpopError::proof("value must be enumeration (string)"));
360    }
361    if expected == "AKP" {
362        return prepare_dpop_mldsa_key(algorithm, jwk, operations);
363    }
364    let private = jwk
365        .get("priv")
366        .is_some_and(|v| JsValue::from(v.clone()).is_truthy());
367    if operations.map_or(private, |values| {
368        values.iter().any(|v| v.as_str() != Some("verify"))
369    }) {
370        return Err(DpopError::proof(format!(
371            "Unsupported key usage for an {name} key"
372        )));
373    }
374    if expected == "EC" && jwk.get("crv").and_then(Value::as_str) != curve {
375        return Err(DpopError::proof(
376            "JWK \"crv\" does not match the requested algorithm",
377        ));
378    }
379    let can_verify = operations.is_none_or(|values| !values.is_empty());
380    let mut public = jwk
381        .as_object()
382        .cloned()
383        .ok_or_else(|| DpopError::proof("DPoP proof header must include a public jwk"))?;
384    // JOSE imports using the protected algorithm, discards use/alg, and
385    // validates key_ops before passing the key to signature verification.
386    for field in ["priv", "alg", "use", "key_ops"] {
387        _ = public.remove(field);
388    }
389    Ok((Value::Object(public), can_verify))
390}
391fn mldsa_key_bytes(jwk: &Value, field: &str) -> Result<Vec<u8>, DpopError> {
392    let value = jwk
393        .get(field)
394        .ok_or_else(|| DpopError::proof("Invalid keyData"))?;
395    let encoded = JsValue::from(value.clone())
396        .coerce_string()
397        .map_err(DpopError::proof)?;
398    crate::jwt::decode_compact_part(&encoded, false)
399        .map_err(|_| DpopError::proof("Invalid keyData"))
400}
401fn mldsa_private_key_matches<P: ml_dsa::MlDsaParams>(seed: &[u8], public: &[u8]) -> bool {
402    use ml_dsa::Keypair as _;
403    let Ok(seed) = ml_dsa::Seed::try_from(seed) else {
404        return false;
405    };
406    ml_dsa::SigningKey::<P>::from_seed(&seed)
407        .verifying_key()
408        .encode()
409        .as_slice()
410        == public
411}
412fn prepare_dpop_mldsa_key(
413    algorithm: &str,
414    jwk: &Value,
415    operations: Option<&Vec<Value>>,
416) -> Result<(Value, bool), DpopError> {
417    if jwk.get("alg").and_then(Value::as_str) != Some(algorithm) {
418        return Err(DpopError::proof("JWK alg and alg option value mismatch"));
419    }
420    let private = jwk.get("priv");
421    let private_truthy = private.is_some_and(|value| JsValue::from(value.clone()).is_truthy());
422    let usage = if private.is_some() { "sign" } else { "verify" };
423    let valid_usage = operations.map_or_else(
424        || if private_truthy { "sign" } else { "verify" } == usage,
425        |values| values.iter().all(|value| value.as_str() == Some(usage)),
426    );
427    if !valid_usage {
428        return Err(DpopError::proof(format!(
429            "Unsupported key usage for a {algorithm} key"
430        )));
431    }
432    let invalid = || DpopError::proof("Invalid keyData");
433    let public = mldsa_key_bytes(jwk, "pub")?;
434    let expected = match algorithm {
435        "ML-DSA-44" => 1312,
436        "ML-DSA-65" => 1952,
437        _ => 2592,
438    };
439    if public.len() != expected {
440        return Err(invalid());
441    }
442    if private.is_some() {
443        let seed = mldsa_key_bytes(jwk, "priv")?;
444        let matches = match algorithm {
445            "ML-DSA-44" => mldsa_private_key_matches::<ml_dsa::MlDsa44>(&seed, &public),
446            "ML-DSA-65" => mldsa_private_key_matches::<ml_dsa::MlDsa65>(&seed, &public),
447            _ => mldsa_private_key_matches::<ml_dsa::MlDsa87>(&seed, &public),
448        };
449        if !matches {
450            return Err(invalid());
451        }
452        if operations.is_some_and(Vec::is_empty) {
453            return Err(DpopError::proof(
454                "Usages cannot be empty when importing a private key.",
455            ));
456        }
457    }
458    Ok((
459        jwk.clone(),
460        operations.is_none_or(|values| !values.is_empty()),
461    ))
462}
463fn verify_dpop_mldsa<P: ml_dsa::MlDsaParams>(jwk: &Value, input: &[u8], signature: &[u8]) -> bool {
464    use ml_dsa::{KeyInit as _, Verifier as _};
465    let Ok(bytes) = mldsa_key_bytes(jwk, "pub") else {
466        return false;
467    };
468    let Ok(key) = ml_dsa::VerifyingKey::<P>::new_from_slice(&bytes) else {
469        return false;
470    };
471    let Ok(signature) = ml_dsa::Signature::<P>::try_from(signature) else {
472        return false;
473    };
474    key.verify(input, &signature).is_ok()
475}
476fn check_dpop_rsa_size(algorithm: &str, jwk: &Value) -> Result<(), DpopError> {
477    if (algorithm.starts_with("RS") || algorithm.starts_with("PS"))
478        && let Some(n) = jwk.get("n").and_then(Value::as_str)
479        && let Ok(bytes) = crate::jwt::decode_compact_part(n, false)
480        && let Some((first, byte)) = bytes.iter().enumerate().find(|(_, byte)| **byte != 0)
481        && (bytes.len() - first) * 8 - (byte.leading_zeros() as usize) < 2048
482    {
483        return Err(DpopError::proof(format!(
484            "{algorithm} requires key modulusLength to be 2048 bits or larger"
485        )));
486    }
487    Ok(())
488}
489pub async fn verify_dpop_proof(
490    options: &VerifyDpopProofOptions<'_>,
491) -> Result<VerifiedDpopProof, DpopError> {
492    let parts = options.proof_jwt.split('.').collect::<Vec<_>>();
493    let [header, payload, signature] = parts.as_slice() else {
494        return Err(DpopError::proof("DPoP proof must be a compact JWT"));
495    };
496    if options.proof_jwt.is_empty() {
497        return Err(DpopError::proof("DPoP proof must be a compact JWT"));
498    }
499    let raw_header = decode(header)?;
500    if !matches!(raw_header, JsValue::Object(_)) {
501        return Err(DpopError::proof(
502            "Protected Header must be a top-level JSON object",
503        ));
504    }
505    if raw_header.get("typ").and_then(JsValue::as_str) != Some(DPOP_PROOF_TYPE) {
506        return Err(DpopError::proof("DPoP proof typ must be \"dpop+jwt\""));
507    }
508    let raw_alg = raw_header.get("alg");
509    if raw_alg.is_none_or(|value| !value.is_truthy()) {
510        return Err(DpopError::proof(
511            "DPoP proof must use an asymmetric JWS algorithm",
512        ));
513    }
514    let Some(algorithm) = raw_alg.and_then(JsValue::as_str) else {
515        return Err(DpopError::ordinary(
516            "alg.startsWith is not a function. (In 'alg.startsWith(\"HS\")', 'alg.startsWith' is undefined)",
517        ));
518    };
519    if algorithm == "none" || algorithm.starts_with("HS") {
520        return Err(DpopError::proof(
521            "DPoP proof must use an asymmetric JWS algorithm",
522        ));
523    }
524    let permitted = options.signing_algorithms.as_ref().map_or_else(
525        || DPOP_SIGNING_ALGORITHMS.contains(&algorithm),
526        |values| values.iter().any(|value| value == algorithm),
527    );
528    if !permitted {
529        return Err(DpopError::proof(
530            "DPoP proof uses an unsupported JWS algorithm",
531        ));
532    }
533    let header = raw_header
534        .to_json_value()
535        .map_err(|error| DpopError::ordinary(error.to_string()))?;
536    let jwk = header
537        .get("jwk")
538        .filter(|value| value.is_object())
539        .ok_or_else(|| DpopError::proof("DPoP proof header must include a public jwk"))?;
540    if jwk.get("kty").and_then(Value::as_str) == Some("oct") {
541        return Err(DpopError::proof("DPoP proof jwk must be asymmetric"));
542    }
543    for field in ["d", "p", "q", "dp", "dq", "qi", "oth", "k"] {
544        if jwk.get(field).is_some() {
545            return Err(DpopError::proof(
546                "DPoP proof jwk must not contain private key material",
547            ));
548        }
549    }
550    let (public, can_verify) = prepare_dpop_public_key(algorithm, jwk)?;
551    let map = header
552        .as_object()
553        .ok_or_else(|| DpopError::proof("Protected Header must be a top-level JSON object"))?;
554    crate::jwt::validate_critical_header(map, false)
555        .map_err(|error| DpopError::proof(auth_message(error)))?;
556    let input = format!(
557        "{}.{}",
558        parts.first().copied().unwrap_or_default(),
559        parts.get(1).copied().unwrap_or_default()
560    );
561    let signature_bytes = crate::jwt::decode_compact_part(signature, false)
562        .map_err(|_| DpopError::proof("Failed to base64url decode the signature"))?;
563    if algorithm.starts_with("ML-DSA-") && public.get("priv").is_some() {
564        return Err(DpopError::proof(format!(
565            "CryptoKey instances must be of type \"public\" for the {algorithm} algorithm"
566        )));
567    }
568    if !can_verify {
569        return Err(DpopError::proof(
570            "CryptoKey does not support this operation, its usages must include verify.",
571        ));
572    }
573    check_dpop_rsa_size(algorithm, &public)?;
574    let native_algorithm = if algorithm == "Ed25519" {
575        Some(crate::jwt::JwtAlgorithm::EdDsa)
576    } else {
577        crate::jwt::JwtAlgorithm::from_str(algorithm).ok()
578    };
579    let valid = if algorithm == "ML-DSA-44" {
580        verify_dpop_mldsa::<ml_dsa::MlDsa44>(&public, input.as_bytes(), &signature_bytes)
581    } else if algorithm == "ML-DSA-65" {
582        verify_dpop_mldsa::<ml_dsa::MlDsa65>(&public, input.as_bytes(), &signature_bytes)
583    } else if algorithm == "ML-DSA-87" {
584        verify_dpop_mldsa::<ml_dsa::MlDsa87>(&public, input.as_bytes(), &signature_bytes)
585    } else if let Some(algorithm) = native_algorithm {
586        crate::jwt::crypto::verify(algorithm, &public, input.as_bytes(), &signature_bytes)
587            .unwrap_or(false)
588    } else {
589        let algorithm = jsonwebtoken::Algorithm::from_str(algorithm).map_err(|_| {
590            DpopError::proof("Invalid or unsupported JWK \"alg\" (Algorithm) Parameter value")
591        })?;
592        let jwk: jsonwebtoken::jwk::Jwk =
593            serde_json::from_value(public).map_err(|error| DpopError::proof(error.to_string()))?;
594        let key = jsonwebtoken::DecodingKey::from_jwk(&jwk)
595            .map_err(|error| DpopError::proof(error.to_string()))?;
596        jsonwebtoken::crypto::verify(
597            &URL_SAFE_NO_PAD.encode(&signature_bytes),
598            input.as_bytes(),
599            &key,
600            algorithm,
601        )
602        .unwrap_or(false)
603    };
604    if !valid {
605        return Err(DpopError::proof("signature verification failed"));
606    }
607    let payload = decode(payload)
608        .map_err(|_| DpopError::proof("Failed to parse the decoded payload as JSON"))?;
609    if !matches!(payload, JsValue::Object(_)) {
610        return Err(DpopError::proof(
611            "JWT Claims Set must be a top-level JSON object",
612        ));
613    }
614    for field in ["iat", "nbf", "exp"] {
615        if payload
616            .get(field)
617            .is_some_and(|value| !matches!(value, JsValue::Number(_)))
618        {
619            return Err(DpopError::proof(format!(
620                "\"{field}\" claim must be a number"
621            )));
622        }
623    }
624    // JOSE checks temporal claims against its wall clock, independently of the
625    // caller's manual clock for the DPoP iat window.
626    let clock = json!(Utc::now().timestamp()).as_f64().unwrap_or_default();
627    if payload
628        .get("nbf")
629        .and_then(JsValue::as_f64)
630        .is_some_and(|nbf| nbf > clock)
631    {
632        return Err(DpopError::proof("\"nbf\" claim timestamp check failed"));
633    }
634    if payload
635        .get("exp")
636        .and_then(JsValue::as_f64)
637        .is_some_and(|exp| exp <= clock)
638    {
639        return Err(DpopError::proof("\"exp\" claim timestamp check failed"));
640    }
641    let htm = string_claim(&payload, "htm");
642    let htu = string_claim(&payload, "htu");
643    let jti = string_claim(&payload, "jti");
644    let iat = payload
645        .get("iat")
646        .and_then(JsValue::as_f64)
647        .filter(|value| value.is_finite());
648    let (Some(htm), Some(htu), Some(jti), Some(iat)) = (htm, htu, jti, iat) else {
649        return Err(DpopError::proof(
650            "DPoP proof must include htm, htu, jti, and iat claims",
651        ));
652    };
653    if jti.encode_utf16().count() > 512 {
654        return Err(DpopError::proof("DPoP proof jti is too large"));
655    }
656    if htm.to_uppercase() != options.method.to_uppercase() {
657        return Err(DpopError::proof(
658            "DPoP proof htm does not match the request method",
659        ));
660    }
661    let normalized =
662        normalize_dpop_htu(options.url).map_err(|error| DpopError::proof(error.message))?;
663    let proof_htu = normalize_dpop_htu(htu).map_err(|error| DpopError::proof(error.message))?;
664    if proof_htu != normalized {
665        return Err(DpopError::proof(
666            "DPoP proof htu does not match the request URL",
667        ));
668    }
669    let now = options.now_seconds.unwrap_or(clock);
670    let age = options.proof_max_age_seconds.unwrap_or(300.0);
671    if iat > now + 5.0 || now - iat > age {
672        return Err(DpopError::proof(
673            "DPoP proof iat is outside the accepted window",
674        ));
675    }
676    let ath = string_claim(&payload, "ath");
677    if options.require_ath && ath.is_none() {
678        return Err(DpopError::proof("DPoP proof must include an ath claim"));
679    }
680    if let Some(token) = options.access_token
681        && ath != Some(derive_dpop_ath(token).as_str())
682    {
683        return Err(DpopError::proof(
684            "DPoP proof ath does not match the access token",
685        ));
686    }
687    let jkt = derive_dpop_jkt(jwk)?;
688    if options.expected_jkt.is_some_and(|expected| expected != jkt) {
689        return Err(DpopError::proof(
690            "DPoP proof key does not match the bound token",
691        ));
692    }
693    let replay_key = derive_dpop_ath(&format!(
694        "{jkt}\n{}\n{normalized}\n{jti}",
695        htm.to_uppercase()
696    ));
697    let expires_at = date(iat + age);
698    if let Some(store) = options.replay_store
699        && !store
700            .reserve(&DpopReplayReservation {
701                key: replay_key.clone(),
702                expires_at,
703                now: date(now),
704            })
705            .await?
706    {
707        return Err(DpopError::proof("DPoP proof jti has already been used"));
708    }
709    Ok(VerifiedDpopProof {
710        jwk: jwk.clone(),
711        jkt,
712        jti: jti.into(),
713        htm: htm.into(),
714        htu: normalized,
715        iat,
716        ath: ath.map(str::to_owned),
717        replay_key,
718        expires_at,
719    })
720}
721
722#[derive(Debug, Clone, Serialize, serde::Deserialize, PartialEq, Eq)]
723pub struct AccessTokenAuthorization {
724    pub scheme: String,
725    pub token: String,
726}
727fn js_whitespace(c: char) -> bool {
728    matches!(c,'\u{0009}'..='\u{000d}'|'\u{0020}'|'\u{00a0}'|'\u{1680}'|'\u{2000}'..='\u{200a}'|'\u{2028}'|'\u{2029}'|'\u{202f}'|'\u{205f}'|'\u{3000}'|'\u{feff}')
729}
730pub fn parse_access_token_authorization(
731    authorization: Option<&str>,
732) -> Option<AccessTokenAuthorization> {
733    let value = authorization?.trim_matches(js_whitespace);
734    if value.is_empty() {
735        return None;
736    }
737    let unknown = || {
738        Some(AccessTokenAuthorization {
739            scheme: "Unknown".into(),
740            token: value.into(),
741        })
742    };
743    let Some((split, _)) = value.char_indices().find(|(_, c)| js_whitespace(*c)) else {
744        return unknown();
745    };
746    let (scheme, rest) = value.split_at(split);
747    let token = rest.trim_matches(js_whitespace);
748    if !scheme
749        .chars()
750        .next()
751        .is_some_and(|c| c.is_ascii_alphabetic())
752        || !scheme
753            .chars()
754            .all(|c| c.is_ascii_alphanumeric() || "!#$%&'*+.^_`|~-".contains(c))
755        || token.is_empty()
756        || token.contains(['\n', '\r', '\u{2028}', '\u{2029}'])
757    {
758        return unknown();
759    }
760    let scheme = if scheme.eq_ignore_ascii_case("bearer") {
761        "Bearer"
762    } else if scheme.eq_ignore_ascii_case("dpop") {
763        "DPoP"
764    } else {
765        return unknown();
766    };
767    Some(AccessTokenAuthorization {
768        scheme: scheme.into(),
769        token: token.into(),
770    })
771}
772pub fn strip_access_token_authorization_scheme(token: &str) -> String {
773    parse_access_token_authorization(Some(token))
774        .map_or_else(|| token.into(), |authorization| authorization.token)
775}
776pub struct EnforceDpopBindingParams<'a> {
777    pub payload: &'a Value,
778    pub authorization: &'a AccessTokenAuthorization,
779    pub proof_jwt: Option<&'a str>,
780    pub method: &'a str,
781    pub url: &'a str,
782    pub replay_store: Option<&'a dyn DpopReplayStore>,
783    pub proof_max_age_seconds: Option<f64>,
784    pub signing_algorithms: Option<Vec<String>>,
785}
786impl<'a> EnforceDpopBindingParams<'a> {
787    pub fn new(
788        payload: &'a Value,
789        authorization: &'a AccessTokenAuthorization,
790        method: &'a str,
791        url: &'a str,
792    ) -> Self {
793        Self {
794            payload,
795            authorization,
796            proof_jwt: None,
797            method,
798            url,
799            replay_store: None,
800            proof_max_age_seconds: None,
801            signing_algorithms: None,
802        }
803    }
804}
805pub async fn enforce_dpop_binding(params: &EnforceDpopBindingParams<'_>) -> Result<(), DpopError> {
806    let Some(jkt) = get_dpop_jkt_from_payload(params.payload)? else {
807        if params.authorization.scheme == DPOP_AUTHORIZATION_SCHEME {
808            return Err(create_dpop_binding_error(
809                "invalid_token",
810                "DPoP authorization requires a DPoP-bound access token",
811            ));
812        }
813        return Ok(());
814    };
815    if params.authorization.scheme != DPOP_AUTHORIZATION_SCHEME {
816        return Err(create_dpop_binding_error(
817            "invalid_token",
818            "DPoP-bound access token requires the DPoP authorization scheme",
819        ));
820    }
821    let proof = params
822        .proof_jwt
823        .filter(|value| !value.is_empty())
824        .ok_or_else(|| DpopError::proof("DPoP proof header is required"))?;
825    let mut options = VerifyDpopProofOptions::new(proof, params.method, params.url);
826    options.access_token = Some(&params.authorization.token);
827    options.expected_jkt = Some(jkt);
828    options.require_ath = true;
829    options.replay_store = params.replay_store;
830    options.proof_max_age_seconds = params.proof_max_age_seconds;
831    options.signing_algorithms = params.signing_algorithms.clone();
832    _ = verify_dpop_proof(&options).await?;
833    Ok(())
834}