1use 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 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#[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 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 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(¶ms.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}