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 _ => {
328 return Err(DpopError::proof(
329 "Invalid or unsupported JWK \"alg\" (Algorithm) Parameter value",
330 ));
331 }
332 };
333 if kty != Some(expected) {
334 return Err(DpopError::proof(
335 "Invalid or unsupported JWK \"alg\" (Algorithm) Parameter value",
336 ));
337 }
338 if expected == "OKP" && jwk.get("crv").and_then(Value::as_str) != curve {
339 return Err(DpopError::proof(
340 "JWK \"crv\" Parameter and algorithm name mismatch",
341 ));
342 }
343 if let Some(values) = operations
344 && values.iter().any(|v| {
345 ![
346 "encrypt",
347 "decrypt",
348 "sign",
349 "verify",
350 "deriveKey",
351 "deriveBits",
352 "wrapKey",
353 "unwrapKey",
354 ]
355 .contains(&v.as_str().unwrap_or_default())
356 })
357 {
358 return Err(DpopError::proof("value must be enumeration (string)"));
359 }
360 let private = jwk
361 .get("priv")
362 .is_some_and(|v| JsValue::from(v.clone()).is_truthy());
363 if operations.map_or(private, |values| {
364 values.iter().any(|v| v.as_str() != Some("verify"))
365 }) {
366 return Err(DpopError::proof(format!(
367 "Unsupported key usage for an {name} key"
368 )));
369 }
370 if expected == "EC" && jwk.get("crv").and_then(Value::as_str) != curve {
371 return Err(DpopError::proof(
372 "JWK \"crv\" does not match the requested algorithm",
373 ));
374 }
375 let can_verify = operations.is_none_or(|values| !values.is_empty());
376 let mut public = jwk
377 .as_object()
378 .cloned()
379 .ok_or_else(|| DpopError::proof("DPoP proof header must include a public jwk"))?;
380 for field in ["priv", "alg", "use", "key_ops"] {
383 _ = public.remove(field);
384 }
385 Ok((Value::Object(public), can_verify))
386}
387fn check_dpop_rsa_size(algorithm: &str, jwk: &Value) -> Result<(), DpopError> {
388 if (algorithm.starts_with("RS") || algorithm.starts_with("PS"))
389 && let Some(n) = jwk.get("n").and_then(Value::as_str)
390 && let Ok(bytes) = crate::jwt::decode_compact_part(n, false)
391 && let Some((first, byte)) = bytes.iter().enumerate().find(|(_, byte)| **byte != 0)
392 && (bytes.len() - first) * 8 - (byte.leading_zeros() as usize) < 2048
393 {
394 return Err(DpopError::proof(format!(
395 "{algorithm} requires key modulusLength to be 2048 bits or larger"
396 )));
397 }
398 Ok(())
399}
400pub async fn verify_dpop_proof(
401 options: &VerifyDpopProofOptions<'_>,
402) -> Result<VerifiedDpopProof, DpopError> {
403 let parts = options.proof_jwt.split('.').collect::<Vec<_>>();
404 let [header, payload, signature] = parts.as_slice() else {
405 return Err(DpopError::proof("DPoP proof must be a compact JWT"));
406 };
407 if options.proof_jwt.is_empty() {
408 return Err(DpopError::proof("DPoP proof must be a compact JWT"));
409 }
410 let raw_header = decode(header)?;
411 if !matches!(raw_header, JsValue::Object(_)) {
412 return Err(DpopError::proof(
413 "Protected Header must be a top-level JSON object",
414 ));
415 }
416 if raw_header.get("typ").and_then(JsValue::as_str) != Some(DPOP_PROOF_TYPE) {
417 return Err(DpopError::proof("DPoP proof typ must be \"dpop+jwt\""));
418 }
419 let raw_alg = raw_header.get("alg");
420 if raw_alg.is_none_or(|value| !value.is_truthy()) {
421 return Err(DpopError::proof(
422 "DPoP proof must use an asymmetric JWS algorithm",
423 ));
424 }
425 let Some(algorithm) = raw_alg.and_then(JsValue::as_str) else {
426 return Err(DpopError::ordinary(
427 "alg.startsWith is not a function. (In 'alg.startsWith(\"HS\")', 'alg.startsWith' is undefined)",
428 ));
429 };
430 if algorithm == "none" || algorithm.starts_with("HS") {
431 return Err(DpopError::proof(
432 "DPoP proof must use an asymmetric JWS algorithm",
433 ));
434 }
435 let permitted = options.signing_algorithms.as_ref().map_or_else(
436 || DPOP_SIGNING_ALGORITHMS.contains(&algorithm),
437 |values| values.iter().any(|value| value == algorithm),
438 );
439 if !permitted {
440 return Err(DpopError::proof(
441 "DPoP proof uses an unsupported JWS algorithm",
442 ));
443 }
444 let header = raw_header
445 .to_json_value()
446 .map_err(|error| DpopError::ordinary(error.to_string()))?;
447 let jwk = header
448 .get("jwk")
449 .filter(|value| value.is_object())
450 .ok_or_else(|| DpopError::proof("DPoP proof header must include a public jwk"))?;
451 if jwk.get("kty").and_then(Value::as_str) == Some("oct") {
452 return Err(DpopError::proof("DPoP proof jwk must be asymmetric"));
453 }
454 for field in ["d", "p", "q", "dp", "dq", "qi", "oth", "k"] {
455 if jwk.get(field).is_some() {
456 return Err(DpopError::proof(
457 "DPoP proof jwk must not contain private key material",
458 ));
459 }
460 }
461 let (public, can_verify) = prepare_dpop_public_key(algorithm, jwk)?;
462 let map = header
463 .as_object()
464 .ok_or_else(|| DpopError::proof("Protected Header must be a top-level JSON object"))?;
465 crate::jwt::validate_critical_header(map, false)
466 .map_err(|error| DpopError::proof(auth_message(error)))?;
467 let input = format!(
468 "{}.{}",
469 parts.first().copied().unwrap_or_default(),
470 parts.get(1).copied().unwrap_or_default()
471 );
472 let signature_bytes = crate::jwt::decode_compact_part(signature, false)
473 .map_err(|_| DpopError::proof("Failed to base64url decode the signature"))?;
474 if !can_verify {
475 return Err(DpopError::proof(
476 "CryptoKey does not support this operation, its usages must include verify.",
477 ));
478 }
479 check_dpop_rsa_size(algorithm, &public)?;
480 let native_algorithm = if algorithm == "Ed25519" {
481 Some(crate::jwt::JwtAlgorithm::EdDsa)
482 } else {
483 crate::jwt::JwtAlgorithm::from_str(algorithm).ok()
484 };
485 let valid = if let Some(algorithm) = native_algorithm {
486 crate::jwt::crypto::verify(algorithm, &public, input.as_bytes(), &signature_bytes)
487 .unwrap_or(false)
488 } else {
489 let algorithm = jsonwebtoken::Algorithm::from_str(algorithm).map_err(|_| {
490 DpopError::proof("Invalid or unsupported JWK \"alg\" (Algorithm) Parameter value")
491 })?;
492 let jwk: jsonwebtoken::jwk::Jwk =
493 serde_json::from_value(public).map_err(|error| DpopError::proof(error.to_string()))?;
494 let key = jsonwebtoken::DecodingKey::from_jwk(&jwk)
495 .map_err(|error| DpopError::proof(error.to_string()))?;
496 jsonwebtoken::crypto::verify(
497 &URL_SAFE_NO_PAD.encode(&signature_bytes),
498 input.as_bytes(),
499 &key,
500 algorithm,
501 )
502 .unwrap_or(false)
503 };
504 if !valid {
505 return Err(DpopError::proof("signature verification failed"));
506 }
507 let payload = decode(payload)
508 .map_err(|_| DpopError::proof("Failed to parse the decoded payload as JSON"))?;
509 if !matches!(payload, JsValue::Object(_)) {
510 return Err(DpopError::proof(
511 "JWT Claims Set must be a top-level JSON object",
512 ));
513 }
514 for field in ["iat", "nbf", "exp"] {
515 if payload
516 .get(field)
517 .is_some_and(|value| !matches!(value, JsValue::Number(_)))
518 {
519 return Err(DpopError::proof(format!(
520 "\"{field}\" claim must be a number"
521 )));
522 }
523 }
524 let clock = json!(Utc::now().timestamp()).as_f64().unwrap_or_default();
527 if payload
528 .get("nbf")
529 .and_then(JsValue::as_f64)
530 .is_some_and(|nbf| nbf > clock)
531 {
532 return Err(DpopError::proof("\"nbf\" claim timestamp check failed"));
533 }
534 if payload
535 .get("exp")
536 .and_then(JsValue::as_f64)
537 .is_some_and(|exp| exp <= clock)
538 {
539 return Err(DpopError::proof("\"exp\" claim timestamp check failed"));
540 }
541 let htm = string_claim(&payload, "htm");
542 let htu = string_claim(&payload, "htu");
543 let jti = string_claim(&payload, "jti");
544 let iat = payload
545 .get("iat")
546 .and_then(JsValue::as_f64)
547 .filter(|value| value.is_finite());
548 let (Some(htm), Some(htu), Some(jti), Some(iat)) = (htm, htu, jti, iat) else {
549 return Err(DpopError::proof(
550 "DPoP proof must include htm, htu, jti, and iat claims",
551 ));
552 };
553 if jti.encode_utf16().count() > 512 {
554 return Err(DpopError::proof("DPoP proof jti is too large"));
555 }
556 if htm.to_uppercase() != options.method.to_uppercase() {
557 return Err(DpopError::proof(
558 "DPoP proof htm does not match the request method",
559 ));
560 }
561 let normalized =
562 normalize_dpop_htu(options.url).map_err(|error| DpopError::proof(error.message))?;
563 let proof_htu = normalize_dpop_htu(htu).map_err(|error| DpopError::proof(error.message))?;
564 if proof_htu != normalized {
565 return Err(DpopError::proof(
566 "DPoP proof htu does not match the request URL",
567 ));
568 }
569 let now = options.now_seconds.unwrap_or(clock);
570 let age = options.proof_max_age_seconds.unwrap_or(300.0);
571 if iat > now + 5.0 || now - iat > age {
572 return Err(DpopError::proof(
573 "DPoP proof iat is outside the accepted window",
574 ));
575 }
576 let ath = string_claim(&payload, "ath");
577 if options.require_ath && ath.is_none() {
578 return Err(DpopError::proof("DPoP proof must include an ath claim"));
579 }
580 if let Some(token) = options.access_token
581 && ath != Some(derive_dpop_ath(token).as_str())
582 {
583 return Err(DpopError::proof(
584 "DPoP proof ath does not match the access token",
585 ));
586 }
587 let jkt = derive_dpop_jkt(jwk)?;
588 if options.expected_jkt.is_some_and(|expected| expected != jkt) {
589 return Err(DpopError::proof(
590 "DPoP proof key does not match the bound token",
591 ));
592 }
593 let replay_key = derive_dpop_ath(&format!(
594 "{jkt}\n{}\n{normalized}\n{jti}",
595 htm.to_uppercase()
596 ));
597 let expires_at = date(iat + age);
598 if let Some(store) = options.replay_store
599 && !store
600 .reserve(&DpopReplayReservation {
601 key: replay_key.clone(),
602 expires_at,
603 now: date(now),
604 })
605 .await?
606 {
607 return Err(DpopError::proof("DPoP proof jti has already been used"));
608 }
609 Ok(VerifiedDpopProof {
610 jwk: jwk.clone(),
611 jkt,
612 jti: jti.into(),
613 htm: htm.into(),
614 htu: normalized,
615 iat,
616 ath: ath.map(str::to_owned),
617 replay_key,
618 expires_at,
619 })
620}
621
622#[derive(Debug, Clone, Serialize, serde::Deserialize, PartialEq, Eq)]
623pub struct AccessTokenAuthorization {
624 pub scheme: String,
625 pub token: String,
626}
627fn js_whitespace(c: char) -> bool {
628 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}')
629}
630pub fn parse_access_token_authorization(
631 authorization: Option<&str>,
632) -> Option<AccessTokenAuthorization> {
633 let value = authorization?.trim_matches(js_whitespace);
634 if value.is_empty() {
635 return None;
636 }
637 let unknown = || {
638 Some(AccessTokenAuthorization {
639 scheme: "Unknown".into(),
640 token: value.into(),
641 })
642 };
643 let Some((split, _)) = value.char_indices().find(|(_, c)| js_whitespace(*c)) else {
644 return unknown();
645 };
646 let (scheme, rest) = value.split_at(split);
647 let token = rest.trim_matches(js_whitespace);
648 if !scheme
649 .chars()
650 .next()
651 .is_some_and(|c| c.is_ascii_alphabetic())
652 || !scheme
653 .chars()
654 .all(|c| c.is_ascii_alphanumeric() || "!#$%&'*+.^_`|~-".contains(c))
655 || token.is_empty()
656 || token.contains(['\n', '\r', '\u{2028}', '\u{2029}'])
657 {
658 return unknown();
659 }
660 let scheme = if scheme.eq_ignore_ascii_case("bearer") {
661 "Bearer"
662 } else if scheme.eq_ignore_ascii_case("dpop") {
663 "DPoP"
664 } else {
665 return unknown();
666 };
667 Some(AccessTokenAuthorization {
668 scheme: scheme.into(),
669 token: token.into(),
670 })
671}
672pub fn strip_access_token_authorization_scheme(token: &str) -> String {
673 parse_access_token_authorization(Some(token))
674 .map_or_else(|| token.into(), |authorization| authorization.token)
675}
676pub struct EnforceDpopBindingParams<'a> {
677 pub payload: &'a Value,
678 pub authorization: &'a AccessTokenAuthorization,
679 pub proof_jwt: Option<&'a str>,
680 pub method: &'a str,
681 pub url: &'a str,
682 pub replay_store: Option<&'a dyn DpopReplayStore>,
683 pub proof_max_age_seconds: Option<f64>,
684 pub signing_algorithms: Option<Vec<String>>,
685}
686impl<'a> EnforceDpopBindingParams<'a> {
687 pub fn new(
688 payload: &'a Value,
689 authorization: &'a AccessTokenAuthorization,
690 method: &'a str,
691 url: &'a str,
692 ) -> Self {
693 Self {
694 payload,
695 authorization,
696 proof_jwt: None,
697 method,
698 url,
699 replay_store: None,
700 proof_max_age_seconds: None,
701 signing_algorithms: None,
702 }
703 }
704}
705pub async fn enforce_dpop_binding(params: &EnforceDpopBindingParams<'_>) -> Result<(), DpopError> {
706 let Some(jkt) = get_dpop_jkt_from_payload(params.payload)? else {
707 if params.authorization.scheme == DPOP_AUTHORIZATION_SCHEME {
708 return Err(create_dpop_binding_error(
709 "invalid_token",
710 "DPoP authorization requires a DPoP-bound access token",
711 ));
712 }
713 return Ok(());
714 };
715 if params.authorization.scheme != DPOP_AUTHORIZATION_SCHEME {
716 return Err(create_dpop_binding_error(
717 "invalid_token",
718 "DPoP-bound access token requires the DPoP authorization scheme",
719 ));
720 }
721 let proof = params
722 .proof_jwt
723 .filter(|value| !value.is_empty())
724 .ok_or_else(|| DpopError::proof("DPoP proof header is required"))?;
725 let mut options = VerifyDpopProofOptions::new(proof, params.method, params.url);
726 options.access_token = Some(¶ms.authorization.token);
727 options.expected_jkt = Some(jkt);
728 options.require_ath = true;
729 options.replay_store = params.replay_store;
730 options.proof_max_age_seconds = params.proof_max_age_seconds;
731 options.signing_algorithms = params.signing_algorithms.clone();
732 _ = verify_dpop_proof(&options).await?;
733 Ok(())
734}