A library for ATProtocol identities.
1//! DPoP (Demonstration of Proof-of-Possession) implementation.
2//!
3//! RFC 9449 compliant DPoP token generation with automatic retry middleware
4//! for nonce challenges and ES256 signature support.
5
6use crate::errors::{JWKError, JWTError};
7use anyhow::Result;
8use atproto_identity::key::{KeyData, to_public, validate};
9use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
10use elliptic_curve::JwkEcKey;
11use reqwest::header::HeaderValue;
12use reqwest_chain::Chainer;
13use ulid::Ulid;
14
15use crate::{
16 errors::DpopError,
17 jwk::{WrappedJsonWebKey, thumbprint, to_key_data},
18 jwt::{Claims, Header, JoseClaims, mint},
19 pkce::challenge,
20};
21
22/// Retry middleware for handling DPoP nonce challenges in HTTP requests.
23///
24/// This struct implements the `Chainer` trait to automatically retry requests
25/// when the server responds with a "use_dpop_nonce" error, adding the required
26/// nonce to the DPoP proof before retrying.
27#[derive(Clone)]
28pub struct DpopRetry {
29 /// The JWT header for the DPoP proof.
30 pub header: Header,
31 /// The JWT claims for the DPoP proof.
32 pub claims: Claims,
33 /// The cryptographic key data used to sign the DPoP proof.
34 pub key_data: KeyData,
35
36 /// Whether to check the response body for DPoP errors in addition to headers.
37 pub check_response_body: bool,
38}
39
40impl DpopRetry {
41 /// Creates a new DpopRetry instance with the provided header, claims, and key data.
42 ///
43 /// # Arguments
44 /// * `header` - The JWT header for the DPoP proof
45 /// * `claims` - The JWT claims for the DPoP proof
46 /// * `key_data` - The cryptographic key data for signing
47 pub fn new(
48 header: Header,
49 claims: Claims,
50 key_data: KeyData,
51 check_response_body: bool,
52 ) -> Self {
53 DpopRetry {
54 header,
55 claims,
56 key_data,
57 check_response_body,
58 }
59 }
60}
61
62/// Implementation of the `Chainer` trait for handling DPoP nonce challenges.
63///
64/// This middleware intercepts HTTP responses with 400/401 status codes and
65/// "use_dpop_nonce" errors, extracts the DPoP-Nonce header, and retries
66/// the request with an updated DPoP proof containing the nonce.
67///
68/// This does not evaluate the response body to determine if a DPoP error was
69/// returned. Only the returned "WWW-Authenticate" header is evaluated. This
70/// is the expected and defined behavior per RFC7235 sections 3.1 and 4.1.
71#[async_trait::async_trait]
72impl Chainer for DpopRetry {
73 type State = ();
74
75 /// Handles the retry logic for DPoP nonce challenges.
76 ///
77 /// # Arguments
78 /// * `result` - The result of the HTTP request
79 /// * `_state` - Unused state (unit type)
80 /// * `request` - The mutable request to potentially retry
81 ///
82 /// # Returns
83 /// * `Ok(Some(response))` - Original response if no retry needed
84 /// * `Ok(None)` - Retry the request with updated DPoP proof
85 /// * `Err(error)` - Error if retry logic fails
86 async fn chain(
87 &self,
88 result: Result<reqwest::Response, reqwest_middleware::Error>,
89 _state: &mut Self::State,
90 request: &mut reqwest::Request,
91 ) -> Result<Option<reqwest::Response>, reqwest_middleware::Error> {
92 let response = result?;
93
94 let status_code = response.status();
95
96 let dpop_status_code = status_code == 400 || status_code == 401;
97 if !dpop_status_code {
98 return Ok(Some(response));
99 };
100
101 let headers = response.headers().clone();
102 let www_authenticate_header = headers.get("WWW-Authenticate");
103 let www_authenticate_value = www_authenticate_header.and_then(|value| value.to_str().ok());
104 let dpop_header_error = www_authenticate_value.is_some_and(is_dpop_error);
105
106 if !dpop_header_error && !self.check_response_body {
107 return Ok(Some(response));
108 };
109
110 if self.check_response_body {
111 let response_body = match response.json::<serde_json::Value>().await {
112 Err(err) => {
113 return Err(reqwest_middleware::Error::Middleware(
114 DpopError::ResponseBodyParsingFailed(err).into(),
115 ));
116 }
117 Ok(value) => value,
118 };
119 if let Some(response_body_obj) = response_body.as_object() {
120 let error_value = response_body_obj
121 .get("error")
122 .and_then(|value| value.as_str())
123 .unwrap_or("placeholder_unknown_error");
124
125 if error_value != "invalid_dpop_proof" && error_value != "use_dpop_nonce" {
126 return Err(reqwest_middleware::Error::Middleware(
127 DpopError::UnexpectedOAuthError {
128 error: error_value.to_string(),
129 }
130 .into(),
131 ));
132 }
133 } else {
134 return Err(reqwest_middleware::Error::Middleware(
135 DpopError::ResponseBodyObjectParsingFailed.into(),
136 ));
137 }
138 };
139
140 let dpop_header = headers
141 .get("DPoP-Nonce")
142 .and_then(|value| value.to_str().ok());
143
144 if dpop_header.is_none() {
145 return Err(reqwest_middleware::Error::Middleware(
146 DpopError::MissingDpopNonceHeader.into(),
147 ));
148 }
149 let dpop_header = dpop_header.unwrap();
150
151 let dpop_proof_header = self.header.clone();
152 let mut dpop_proof_claim = self.claims.clone();
153 dpop_proof_claim
154 .private
155 .insert("nonce".to_string(), dpop_header.to_string().into());
156
157 let dpop_proof_token = mint(&self.key_data, &dpop_proof_header, &dpop_proof_claim)
158 .map_err(|err| {
159 reqwest_middleware::Error::Middleware(DpopError::TokenMintingFailed(err).into())
160 })?;
161
162 request.headers_mut().insert(
163 "DPoP",
164 HeaderValue::from_str(&dpop_proof_token).map_err(|err| {
165 reqwest_middleware::Error::Middleware(DpopError::HeaderCreationFailed(err).into())
166 })?,
167 );
168
169 Ok(None)
170 }
171}
172
173/// Parses the value of the "WWW-Authenticate" header and returns true if the inner "error" field is either "invalid_dpop_proof" or "use_dpop_nonce".
174///
175/// This function parses DPoP challenge headers to determine if the server is requesting
176/// a DPoP proof or indicating that the provided proof is invalid.
177///
178/// # Arguments
179/// * `value` - The WWW-Authenticate header value to parse
180///
181/// # Returns
182/// * `true` if the error field indicates a DPoP-related error
183/// * `false` if no DPoP error is found or the header format is invalid
184///
185/// # Examples
186/// ```no_run
187/// use atproto_oauth::dpop::is_dpop_error;
188///
189/// // Valid DPoP error: invalid_dpop_proof
190/// let header1 = r#"DPoP algs="ES256", error="invalid_dpop_proof", error_description="DPoP proof required""#;
191/// assert!(is_dpop_error(header1));
192///
193/// // Valid DPoP error: use_dpop_nonce
194/// let header2 = r#"DPoP algs="ES256", error="use_dpop_nonce", error_description="Authorization server requires nonce in DPoP proof""#;
195/// assert!(is_dpop_error(header2));
196///
197/// // Non-DPoP error
198/// let header3 = r#"DPoP algs="ES256", error="invalid_token", error_description="Token is invalid""#;
199/// assert!(!is_dpop_error(header3));
200///
201/// // Non-DPoP authentication scheme
202/// let header4 = r#"Bearer error="invalid_token""#;
203/// assert!(!is_dpop_error(header4));
204/// ```
205pub fn is_dpop_error(value: &str) -> bool {
206 // Check if the header starts with "DPoP"
207 if !value.trim_start().starts_with("DPoP") {
208 return false;
209 }
210
211 // Remove the "DPoP" scheme prefix and parse the parameters
212 let params_part = value.trim_start().strip_prefix("DPoP").unwrap_or("").trim();
213
214 // Split by commas and look for error field
215 for part in params_part.split(',') {
216 let trimmed = part.trim();
217
218 // Look for error="value" pattern
219 if let Some(equals_pos) = trimmed.find('=') {
220 let (key, value_part) = trimmed.split_at(equals_pos);
221 let key = key.trim();
222
223 if key == "error" {
224 // Extract the quoted value
225 let value_part = &value_part[1..]; // Skip the '='
226 let value_part = value_part.trim();
227
228 // Remove surrounding quotes if present (handle malformed quotes too)
229 let error_value = if let Some(stripped) = value_part.strip_prefix('"') {
230 if value_part.ends_with('"') && value_part.len() >= 2 {
231 &value_part[1..value_part.len() - 1]
232 } else {
233 stripped // Remove leading quote even if no closing quote
234 }
235 } else if let Some(stripped) = value_part.strip_suffix('"') {
236 stripped // Remove trailing quote if no leading quote
237 } else {
238 value_part
239 };
240
241 return error_value == "invalid_dpop_proof" || error_value == "use_dpop_nonce";
242 }
243 }
244 }
245
246 false
247}
248
249/// Creates a DPoP proof token for OAuth authorization requests.
250///
251/// Generates a JWT with the required DPoP claims for proving possession
252/// of the private key during OAuth authorization flows.
253///
254/// # Arguments
255/// * `key_data` - The cryptographic key data for signing the proof
256/// * `http_method` - The HTTP method of the request (e.g., "POST")
257/// * `http_uri` - The full URI of the authorization endpoint
258///
259/// # Returns
260/// A tuple containing:
261/// * The signed JWT token as a string
262/// * The JWT header used for signing
263/// * The JWT claims used in the token
264///
265/// # Errors
266/// Returns an error if key conversion or token minting fails.
267pub fn auth_dpop(
268 key_data: &KeyData,
269 http_method: &str,
270 http_uri: &str,
271) -> anyhow::Result<(String, Header, Claims)> {
272 build_dpop(key_data, http_method, http_uri, None)
273}
274
275/// Creates a DPoP proof token for OAuth resource requests.
276///
277/// Generates a JWT with the required DPoP claims for proving possession
278/// of the private key when making requests to protected resources using
279/// an OAuth access token.
280///
281/// # Arguments
282/// * `key_data` - The cryptographic key data for signing the proof
283/// * `http_method` - The HTTP method of the request (e.g., "GET")
284/// * `http_uri` - The full URI of the resource endpoint
285/// * `oauth_access_token` - The OAuth access token being used
286///
287/// # Returns
288/// A tuple containing:
289/// * The signed JWT token as a string
290/// * The JWT header used for signing
291/// * The JWT claims used in the token
292///
293/// # Errors
294/// Returns an error if key conversion or token minting fails.
295pub fn request_dpop(
296 key_data: &KeyData,
297 http_method: &str,
298 http_uri: &str,
299 oauth_access_token: &str,
300) -> anyhow::Result<(String, Header, Claims)> {
301 build_dpop(key_data, http_method, http_uri, Some(oauth_access_token))
302}
303
304fn build_dpop(
305 key_data: &KeyData,
306 http_method: &str,
307 http_uri: &str,
308 access_token: Option<&str>,
309) -> anyhow::Result<(String, Header, Claims)> {
310 let now = chrono::Utc::now();
311
312 let public_key_data = to_public(key_data)?;
313 let dpop_jwk: JwkEcKey = (&public_key_data).try_into()?;
314
315 let header = Header {
316 type_: Some("dpop+jwt".to_string()),
317 algorithm: Some("ES256".to_string()),
318 json_web_key: Some(dpop_jwk),
319 key_id: None,
320 };
321
322 let auth = access_token.map(challenge);
323 let issued_at = Some(now.timestamp() as u64);
324 let expiration = Some((now + chrono::Duration::seconds(30)).timestamp() as u64);
325
326 let claims = Claims::new(JoseClaims {
327 auth,
328 expiration,
329 http_method: Some(http_method.to_string()),
330 http_uri: Some(http_uri.to_string()),
331 issued_at,
332 json_web_token_id: Some(Ulid::new().to_string()),
333 ..Default::default()
334 });
335
336 let token = mint(key_data, &header, &claims)?;
337
338 Ok((token, header, claims))
339}
340
341/// Extracts the JWK thumbprint from a DPoP JWT.
342///
343/// This function parses a DPoP JWT, extracts the JWK from the JWT header,
344/// and computes the RFC 7638 thumbprint of that JWK. The thumbprint can
345/// be used to uniquely identify the key used in the DPoP proof.
346///
347/// # Arguments
348/// * `dpop_jwt` - The DPoP JWT token as a string
349///
350/// # Returns
351/// * `Ok(String)` - The base64url-encoded SHA-256 thumbprint of the JWK
352/// * `Err(anyhow::Error)` - If JWT parsing, JWK extraction, or thumbprint calculation fails
353///
354/// # Errors
355/// This function will return an error if:
356/// - The JWT format is invalid (not 3 parts separated by dots)
357/// - The JWT header cannot be base64 decoded or parsed as JSON
358/// - The header does not contain a "jwk" field
359/// - The JWK cannot be converted to the required format
360/// - Thumbprint calculation fails
361///
362/// # Examples
363/// ```
364/// use atproto_oauth::dpop::extract_jwk_thumbprint;
365///
366/// let dpop_jwt = "eyJhbGciOiJFUzI1NiIsImp3ayI6eyJhbGciOiJFUzI1NiIsImNydiI6IlAtMjU2Iiwia2lkIjoiZGlkOmtleTp6RG5hZVpVeEFhZDJUbkRYTjFaZWprcFV4TWVvMW9mNzF6NGVackxLRFRtaEQzOEQ3Iiwia3R5IjoiRUMiLCJ1c2UiOiJzaWciLCJ4IjoiaG56dDlSSGppUDBvMFJJTEZacEdjX0phenJUb1pHUzF1d0d5R3JleUNNbyIsInkiOiJzaXJhU2FGU09md3FrYTZRdnR3aUJhM0FKUi14eEhQaWVWZkFhZEhQQ0JRIn0sInR5cCI6ImRwb3Arand0In0.eyJqdGkiOiI2NDM0ZmFlNC00ZTYxLTQ1NDEtOTNlZC1kMzQ5ZjRiMTQ1NjEiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9haXBkZXYudHVubi5kZXYvb2F1dGgvdG9rZW4iLCJpYXQiOjE3NDk3NjQ1MTl9.GkoB00Y-68djRHLhO5-PayNV8PWcQI1pwZaAUL3Hzppj-ga6SKMyGpPwY4kcGdHM7lvvisNkzvd7RjEmdDtnjQ";
367/// let thumbprint = extract_jwk_thumbprint(dpop_jwt)?;
368/// assert_eq!(thumbprint.len(), 43); // SHA-256 base64url is 43 characters
369/// # Ok::<(), Box<dyn std::error::Error>>(())
370/// ```
371pub fn extract_jwk_thumbprint(dpop_jwt: &str) -> Result<String> {
372 // Split the JWT into its three parts
373 let parts: Vec<&str> = dpop_jwt.split('.').collect();
374 if parts.len() != 3 {
375 return Err(JWTError::InvalidFormat.into());
376 }
377
378 let encoded_header = parts[0];
379
380 // Decode the header
381 let header_bytes = URL_SAFE_NO_PAD
382 .decode(encoded_header)
383 .map_err(|_| JWTError::InvalidHeader)?;
384
385 // Parse the header as JSON
386 let header_json: serde_json::Value =
387 serde_json::from_slice(&header_bytes).map_err(|_| JWTError::InvalidHeader)?;
388
389 // Extract the JWK from the header
390 let jwk_value = header_json
391 .get("jwk")
392 .ok_or_else(|| JWTError::MissingClaim {
393 claim: "jwk".to_string(),
394 })?;
395
396 // Filter the JWK to only include core fields that JwkEcKey expects
397 let jwk_object = jwk_value
398 .as_object()
399 .ok_or_else(|| JWKError::MissingField {
400 field: "jwk object".to_string(),
401 })?;
402
403 // Extract only the core JWK fields that JwkEcKey supports
404 let mut filtered_jwk = serde_json::Map::new();
405 for field in ["kty", "crv", "x", "y", "d"] {
406 if let Some(value) = jwk_object.get(field) {
407 filtered_jwk.insert(field.to_string(), value.clone());
408 }
409 }
410
411 // Convert the filtered JWK JSON to a JwkEcKey
412 let jwk_ec_key: JwkEcKey = serde_json::from_value(serde_json::Value::Object(filtered_jwk))
413 .map_err(|e| JWKError::SerializationError {
414 message: e.to_string(),
415 })?;
416
417 // Create a WrappedJsonWebKey to use with the thumbprint function
418 let wrapped_jwk = WrappedJsonWebKey {
419 kid: None, // We don't need the kid for thumbprint calculation
420 alg: None, // We don't need the alg for thumbprint calculation
421 _use: None, // We don't need the use for thumbprint calculation
422 jwk: jwk_ec_key,
423 };
424
425 // Calculate and return the thumbprint
426 thumbprint(&wrapped_jwk).map_err(|e| e.into())
427}
428
429/// Configuration for DPoP JWT validation.
430///
431/// This struct allows callers to specify what aspects of the DPoP JWT should be validated.
432#[cfg_attr(debug_assertions, derive(Debug))]
433#[derive(Clone)]
434pub struct DpopValidationConfig {
435 /// Expected HTTP method (e.g., "POST", "GET"). If None, method validation is skipped.
436 pub expected_http_method: Option<String>,
437 /// Expected HTTP URI. If None, URI validation is skipped.
438 pub expected_http_uri: Option<String>,
439 /// Expected access token hash. If Some, the `ath` claim must match this value.
440 pub expected_access_token_hash: Option<String>,
441 /// Maximum age of the token in seconds. Default is 60 seconds.
442 pub max_age_seconds: u64,
443 /// Whether to allow tokens with future `iat` times (for clock skew tolerance).
444 pub allow_future_iat: bool,
445 /// Clock skew tolerance in seconds (default 30 seconds).
446 pub clock_skew_tolerance_seconds: u64,
447 /// Array of valid nonce values. If not empty, the `nonce` claim must be present and match one of these values.
448 pub expected_nonce_values: Vec<String>,
449 /// Current timestamp for validation purposes.
450 pub now: i64,
451}
452
453impl Default for DpopValidationConfig {
454 fn default() -> Self {
455 let now = chrono::Utc::now().timestamp();
456 Self {
457 expected_http_method: None,
458 expected_http_uri: None,
459 expected_access_token_hash: None,
460 max_age_seconds: 60,
461 allow_future_iat: false,
462 clock_skew_tolerance_seconds: 30,
463 expected_nonce_values: Vec::new(),
464 now,
465 }
466 }
467}
468
469impl DpopValidationConfig {
470 /// Create a new validation config for authorization requests (no access token hash required).
471 pub fn for_authorization(http_method: &str, http_uri: &str) -> Self {
472 Self {
473 expected_http_method: Some(http_method.to_string()),
474 expected_http_uri: Some(http_uri.to_string()),
475 expected_access_token_hash: None,
476 ..Default::default()
477 }
478 }
479
480 /// Create a new validation config for resource requests (access token hash required).
481 pub fn for_resource_request(http_method: &str, http_uri: &str, access_token: &str) -> Self {
482 Self {
483 expected_http_method: Some(http_method.to_string()),
484 expected_http_uri: Some(http_uri.to_string()),
485 expected_access_token_hash: Some(challenge(access_token)),
486 ..Default::default()
487 }
488 }
489}
490
491/// Validates a DPoP JWT and returns the JWK thumbprint if validation succeeds.
492///
493/// This function performs comprehensive validation of a DPoP JWT including:
494/// - JWT structure and format validation
495/// - Header validation (typ, alg, jwk fields)
496/// - Claims validation (jti, htm, htu, iat, and optionally ath and nonce)
497/// - Cryptographic signature verification using the embedded JWK
498/// - Timestamp validation with configurable tolerances
499/// - Nonce validation against expected values (if configured)
500///
501/// # Arguments
502/// * `dpop_jwt` - The DPoP JWT token as a string
503/// * `config` - Validation configuration specifying what to validate
504///
505/// # Returns
506/// * `Ok(String)` - The base64url-encoded SHA-256 thumbprint of the validated JWK
507/// * `Err(anyhow::Error)` - If any validation step fails
508///
509/// # Errors
510/// This function will return an error if:
511/// - The JWT format is invalid
512/// - Required header fields are missing or invalid
513/// - Required claims are missing or invalid
514/// - The signature verification fails
515/// - Timestamp validation fails
516/// - HTTP method or URI don't match expected values
517///
518/// # Examples
519/// ```no_run
520/// use atproto_oauth::dpop::{validate_dpop_jwt, DpopValidationConfig};
521///
522/// let dpop_jwt = "eyJhbGciOiJFUzI1NiIsImp3ayI6eyJhbGciOiJFUzI1NiIsImNydiI6IlAtMjU2Iiwia2lkIjoiZGlkOmtleTp6RG5hZVpVeEFhZDJUbkRYTjFaZWprcFV4TWVvMW9mNzF6NGVackxLRFRtaEQzOEQ3Iiwia3R5IjoiRUMiLCJ1c2UiOiJzaWciLCJ4IjoiaG56dDlSSGppUDBvMFJJTEZacEdjX0phenJUb1pHUzF1d0d5R3JleUNNbyIsInkiOiJzaXJhU2FGU09md3FrYTZRdnR3aUJhM0FKUi14eEhQaWVWZkFhZEhQQ0JRIn0sInR5cCI6ImRwb3Arand0In0.eyJqdGkiOiI2NDM0ZmFlNC00ZTYxLTQ1NDEtOTNlZC1kMzQ5ZjRiMTQ1NjEiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9haXBkZXYudHVubi5kZXYvb2F1dGgvdG9rZW4iLCJpYXQiOjE3NDk3NjQ1MTl9.GkoB00Y-68djRHLhO5-PayNV8PWcQI1pwZaAUL3Hzppj-ga6SKMyGpPwY4kcGdHM7lvvisNkzvd7RjEmdDtnjQ";
523/// let mut config = DpopValidationConfig::for_authorization("POST", "https://aipdev.tunn.dev/oauth/token");
524/// config.max_age_seconds = 9000000;
525/// let thumbprint = validate_dpop_jwt(dpop_jwt, &config)?;
526/// assert_eq!(thumbprint.len(), 43); // SHA-256 base64url is 43 characters
527/// # Ok::<(), Box<dyn std::error::Error>>(())
528/// ```
529pub fn validate_dpop_jwt(dpop_jwt: &str, config: &DpopValidationConfig) -> Result<String> {
530 // Split the JWT into its three parts
531 let parts: Vec<&str> = dpop_jwt.split('.').collect();
532 if parts.len() != 3 {
533 return Err(JWTError::InvalidFormat.into());
534 }
535
536 let (encoded_header, encoded_payload, encoded_signature) = (parts[0], parts[1], parts[2]);
537
538 // 1. DECODE AND VALIDATE HEADER
539 let header_bytes = URL_SAFE_NO_PAD
540 .decode(encoded_header)
541 .map_err(|_| JWTError::InvalidHeader)?;
542
543 let header_json: serde_json::Value =
544 serde_json::from_slice(&header_bytes).map_err(|_| JWTError::InvalidHeader)?;
545
546 // Validate typ field
547 let typ = header_json
548 .get("typ")
549 .and_then(|v| v.as_str())
550 .ok_or_else(|| JWTError::MissingClaim {
551 claim: "typ".to_string(),
552 })?;
553
554 if typ != "dpop+jwt" {
555 return Err(JWTError::InvalidTokenType {
556 expected: "dpop+jwt".to_string(),
557 actual: typ.to_string(),
558 }
559 .into());
560 }
561
562 // Validate alg field
563 let alg = header_json
564 .get("alg")
565 .and_then(|v| v.as_str())
566 .ok_or_else(|| JWTError::MissingClaim {
567 claim: "alg".to_string(),
568 })?;
569
570 if !matches!(alg, "ES256" | "ES384" | "ES256K") {
571 return Err(JWTError::UnsupportedAlgorithm {
572 algorithm: alg.to_string(),
573 key_type: "EC".to_string(),
574 }
575 .into());
576 }
577
578 // Extract and validate JWK
579 let jwk_value = header_json
580 .get("jwk")
581 .ok_or_else(|| JWTError::MissingClaim {
582 claim: "jwk".to_string(),
583 })?;
584
585 let jwk_object = jwk_value
586 .as_object()
587 .ok_or_else(|| JWKError::MissingField {
588 field: "jwk object".to_string(),
589 })?;
590
591 // Filter the JWK to only include core fields that JwkEcKey expects
592 let mut filtered_jwk = serde_json::Map::new();
593 for field in ["kty", "crv", "x", "y", "d"] {
594 if let Some(value) = jwk_object.get(field) {
595 filtered_jwk.insert(field.to_string(), value.clone());
596 }
597 }
598
599 let jwk_ec_key: JwkEcKey = serde_json::from_value(serde_json::Value::Object(filtered_jwk))
600 .map_err(|e| JWKError::SerializationError {
601 message: e.to_string(),
602 })?;
603
604 // Create WrappedJsonWebKey for further operations
605 let wrapped_jwk = WrappedJsonWebKey {
606 kid: None,
607 alg: Some(alg.to_string()),
608 _use: Some("sig".to_string()),
609 jwk: jwk_ec_key,
610 };
611
612 // Convert JWK to KeyData for signature verification
613 let key_data = to_key_data(&wrapped_jwk)?;
614
615 // 2. DECODE AND VALIDATE PAYLOAD/CLAIMS
616 let payload_bytes = URL_SAFE_NO_PAD
617 .decode(encoded_payload)
618 .map_err(|_| JWTError::InvalidPayload)?;
619
620 let claims: serde_json::Value =
621 serde_json::from_slice(&payload_bytes).map_err(|_| JWTError::InvalidPayloadJson)?;
622
623 // Validate required claims
624 // jti (JWT ID) - required for replay protection
625 claims
626 .get("jti")
627 .and_then(|v| v.as_str())
628 .ok_or_else(|| JWTError::MissingClaim {
629 claim: "jti".to_string(),
630 })?;
631
632 if let Some(expected_method) = &config.expected_http_method {
633 // htm (HTTP method) - validate if expected method is specified
634 let htm =
635 claims
636 .get("htm")
637 .and_then(|v| v.as_str())
638 .ok_or_else(|| JWTError::MissingClaim {
639 claim: "htm".to_string(),
640 })?;
641
642 if htm != expected_method {
643 return Err(JWTError::HttpMethodMismatch {
644 expected: expected_method.clone(),
645 actual: htm.to_string(),
646 }
647 .into());
648 }
649 }
650
651 if let Some(expected_uri) = &config.expected_http_uri {
652 // htu (HTTP URI) - validate if expected URI is specified
653 let htu =
654 claims
655 .get("htu")
656 .and_then(|v| v.as_str())
657 .ok_or_else(|| JWTError::MissingClaim {
658 claim: "htu".to_string(),
659 })?;
660
661 if htu != expected_uri {
662 return Err(JWTError::HttpUriMismatch {
663 expected: expected_uri.clone(),
664 actual: htu.to_string(),
665 }
666 .into());
667 }
668 }
669
670 // iat (issued at) - validate timestamp
671 let iat = claims
672 .get("iat")
673 .and_then(|v| v.as_u64())
674 .ok_or_else(|| JWTError::MissingClaim {
675 claim: "iat".to_string(),
676 })?;
677
678 // Check if token is too old
679 if config.now as u64 > iat + config.max_age_seconds + config.clock_skew_tolerance_seconds {
680 return Err(JWTError::InvalidTimestamp {
681 reason: format!(
682 "Token too old: issued at {} but max age is {} seconds",
683 iat, config.max_age_seconds
684 ),
685 }
686 .into());
687 }
688
689 // Check if token is from the future (unless allowed)
690 if !config.allow_future_iat && iat > config.now as u64 + config.clock_skew_tolerance_seconds {
691 return Err(JWTError::InvalidTimestamp {
692 reason: format!(
693 "Token from future: issued at {} but current time is {}",
694 iat, config.now
695 ),
696 }
697 .into());
698 }
699
700 // ath (access token hash) - validate if required
701 if let Some(expected_ath) = &config.expected_access_token_hash {
702 let ath =
703 claims
704 .get("ath")
705 .and_then(|v| v.as_str())
706 .ok_or_else(|| JWTError::MissingClaim {
707 claim: "ath".to_string(),
708 })?;
709
710 if ath != expected_ath {
711 return Err(JWTError::AccessTokenHashMismatch.into());
712 }
713 }
714
715 // nonce - validate if required
716 if !config.expected_nonce_values.is_empty() {
717 let nonce = claims
718 .get("nonce")
719 .and_then(|v| v.as_str())
720 .ok_or_else(|| JWTError::MissingClaim {
721 claim: "nonce".to_string(),
722 })?;
723
724 if !config.expected_nonce_values.contains(&nonce.to_string()) {
725 return Err(JWTError::InvalidNonce {
726 nonce: nonce.to_string(),
727 }
728 .into());
729 }
730 }
731
732 // exp (expiration) - validate if present
733 if let Some(exp_value) = claims.get("exp")
734 && let Some(exp) = exp_value.as_u64()
735 && config.now as u64 >= exp
736 {
737 return Err(JWTError::TokenExpired.into());
738 }
739
740 // 3. VERIFY SIGNATURE
741 let content = format!("{}.{}", encoded_header, encoded_payload);
742 let signature_bytes = URL_SAFE_NO_PAD
743 .decode(encoded_signature)
744 .map_err(|_| JWTError::InvalidSignature)?;
745
746 validate(&key_data, &signature_bytes, content.as_bytes())
747 .map_err(|_| JWTError::SignatureVerificationFailed)?;
748
749 // 4. CALCULATE AND RETURN JWK THUMBPRINT
750 thumbprint(&wrapped_jwk).map_err(|e| e.into())
751}
752
753#[cfg(test)]
754mod tests {
755 use super::*;
756
757 #[test]
758 fn test_is_dpop_error_invalid_dpop_proof() {
759 let header = r#"DPoP algs="RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES256K ES384 ES512", error="invalid_dpop_proof", error_description="DPoP proof required""#;
760 assert!(is_dpop_error(header));
761 }
762
763 #[test]
764 fn test_is_dpop_error_use_dpop_nonce() {
765 let header = r#"DPoP algs="RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES256K ES384 ES512", error="use_dpop_nonce", error_description="Authorization server requires nonce in DPoP proof""#;
766 assert!(is_dpop_error(header));
767 }
768
769 #[test]
770 fn test_is_dpop_error_other_error() {
771 let header =
772 r#"DPoP algs="ES256", error="invalid_token", error_description="Token is invalid""#;
773 assert!(!is_dpop_error(header));
774 }
775
776 #[test]
777 fn test_is_dpop_error_no_error_field() {
778 let header = r#"DPoP algs="ES256", error_description="Some description""#;
779 assert!(!is_dpop_error(header));
780 }
781
782 #[test]
783 fn test_is_dpop_error_not_dpop_header() {
784 let header = r#"Bearer error="invalid_token""#;
785 assert!(!is_dpop_error(header));
786 }
787
788 #[test]
789 fn test_is_dpop_error_empty_string() {
790 assert!(!is_dpop_error(""));
791 }
792
793 #[test]
794 fn test_is_dpop_error_minimal_valid() {
795 let header = r#"DPoP error="invalid_dpop_proof""#;
796 assert!(is_dpop_error(header));
797 }
798
799 #[test]
800 fn test_is_dpop_error_unquoted_value() {
801 let header = r#"DPoP error=invalid_dpop_proof"#;
802 assert!(is_dpop_error(header));
803 }
804
805 #[test]
806 fn test_is_dpop_error_whitespace_handling() {
807 let header =
808 r#" DPoP algs="ES256" , error="use_dpop_nonce" , error_description="test" "#;
809 assert!(is_dpop_error(header));
810 }
811
812 #[test]
813 fn test_is_dpop_error_case_sensitive_scheme() {
814 let header = r#"dpop error="invalid_dpop_proof""#;
815 assert!(!is_dpop_error(header));
816 }
817
818 #[test]
819 fn test_is_dpop_error_case_sensitive_error_value() {
820 let header = r#"DPoP error="INVALID_DPOP_PROOF""#;
821 assert!(!is_dpop_error(header));
822 }
823
824 #[test]
825 fn test_is_dpop_error_malformed_quotes() {
826 let header = r#"DPoP error="invalid_dpop_proof"#;
827 assert!(is_dpop_error(header));
828 }
829
830 #[test]
831 fn test_is_dpop_error_multiple_error_fields() {
832 let header = r#"DPoP error="invalid_token", algs="ES256", error="invalid_dpop_proof""#;
833 // Should match the first error field found
834 assert!(!is_dpop_error(header));
835 }
836
837 #[test]
838 fn test_extract_jwk_thumbprint_with_known_jwt() -> anyhow::Result<()> {
839 // Test with the provided DPoP JWT
840 let dpop_jwt = "eyJhbGciOiJFUzI1NiIsImp3ayI6eyJhbGciOiJFUzI1NiIsImNydiI6IlAtMjU2Iiwia2lkIjoiZGlkOmtleTp6RG5hZVpVeEFhZDJUbkRYTjFaZWprcFV4TWVvMW9mNzF6NGVackxLRFRtaEQzOEQ3Iiwia3R5IjoiRUMiLCJ1c2UiOiJzaWciLCJ4IjoiaG56dDlSSGppUDBvMFJJTEZacEdjX0phenJUb1pHUzF1d0d5R3JleUNNbyIsInkiOiJzaXJhU2FGU09md3FrYTZRdnR3aUJhM0FKUi14eEhQaWVWZkFhZEhQQ0JRIn0sInR5cCI6ImRwb3Arand0In0.eyJqdGkiOiI2NDM0ZmFlNC00ZTYxLTQ1NDEtOTNlZC1kMzQ5ZjRiMTQ1NjEiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9haXBkZXYudHVubi5kZXYvb2F1dGgvdG9rZW4iLCJpYXQiOjE3NDk3NjQ1MTl9.GkoB00Y-68djRHLhO5-PayNV8PWcQI1pwZaAUL3Hzppj-ga6SKMyGpPwY4kcGdHM7lvvisNkzvd7RjEmdDtnjQ";
841
842 let thumbprint = extract_jwk_thumbprint(dpop_jwt)?;
843
844 // Verify the thumbprint is a valid base64url string
845 assert_eq!(thumbprint.len(), 43); // SHA-256 base64url encoded is 43 characters
846 assert!(!thumbprint.contains('=')); // No padding in base64url
847 assert!(!thumbprint.contains('+')); // No + in base64url
848 assert!(!thumbprint.contains('/')); // No / in base64url
849
850 // Verify that the thumbprint is deterministic (same result every time)
851 let thumbprint2 = extract_jwk_thumbprint(dpop_jwt)?;
852 assert_eq!(thumbprint, thumbprint2);
853
854 assert_eq!(thumbprint, "lkqoPsebYpatLxSM4WCfAvOHqxU7X5RKlg-0_cobwSU");
855
856 Ok(())
857 }
858
859 #[test]
860 fn test_extract_jwk_thumbprint_invalid_jwt_format() {
861 // Test with invalid JWT format (not 3 parts)
862 let invalid_jwt = "invalid.jwt";
863 let result = extract_jwk_thumbprint(invalid_jwt);
864 assert!(result.is_err());
865 assert!(result.unwrap_err().to_string().contains("expected 3 parts"));
866 }
867
868 #[test]
869 fn test_extract_jwk_thumbprint_invalid_header() {
870 // Test with invalid base64 in header
871 let invalid_jwt = "invalid-base64.eyJqdGkiOiJ0ZXN0In0.signature";
872 let result = extract_jwk_thumbprint(invalid_jwt);
873 assert!(result.is_err());
874 assert!(
875 result
876 .unwrap_err()
877 .to_string()
878 .contains("Invalid JWT header")
879 );
880 }
881
882 #[test]
883 fn test_extract_jwk_thumbprint_missing_jwk() -> anyhow::Result<()> {
884 // Create a valid JWT header without a JWK field
885 let header = serde_json::json!({
886 "alg": "ES256",
887 "typ": "dpop+jwt"
888 });
889 let encoded_header = base64::engine::general_purpose::URL_SAFE_NO_PAD
890 .encode(serde_json::to_string(&header)?.as_bytes());
891
892 let jwt_without_jwk = format!("{}.eyJqdGkiOiJ0ZXN0In0.signature", encoded_header);
893 let result = extract_jwk_thumbprint(&jwt_without_jwk);
894 assert!(result.is_err());
895 assert!(
896 result
897 .unwrap_err()
898 .to_string()
899 .contains("Missing required claim: jwk")
900 );
901
902 Ok(())
903 }
904
905 #[test]
906 fn test_extract_jwk_thumbprint_with_generated_dpop() -> anyhow::Result<()> {
907 // Test with a DPoP JWT generated by our own functions
908 use atproto_identity::key::{KeyType, generate_key};
909
910 let key_data = generate_key(KeyType::P256Private)?;
911 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
912
913 let thumbprint = extract_jwk_thumbprint(&dpop_token)?;
914
915 // Verify the thumbprint properties
916 assert_eq!(thumbprint.len(), 43);
917 assert!(!thumbprint.contains('='));
918 assert!(!thumbprint.contains('+'));
919 assert!(!thumbprint.contains('/'));
920
921 // Verify deterministic behavior
922 let thumbprint2 = extract_jwk_thumbprint(&dpop_token)?;
923 assert_eq!(thumbprint, thumbprint2);
924
925 Ok(())
926 }
927
928 #[test]
929 fn test_extract_jwk_thumbprint_different_keys_different_thumbprints() -> anyhow::Result<()> {
930 // Test that different keys produce different thumbprints
931 use atproto_identity::key::{KeyType, generate_key};
932
933 let key1 = generate_key(KeyType::P256Private)?;
934 let key2 = generate_key(KeyType::P256Private)?;
935
936 let (dpop_token1, _, _) = auth_dpop(&key1, "POST", "https://example.com/token")?;
937 let (dpop_token2, _, _) = auth_dpop(&key2, "POST", "https://example.com/token")?;
938
939 let thumbprint1 = extract_jwk_thumbprint(&dpop_token1)?;
940 let thumbprint2 = extract_jwk_thumbprint(&dpop_token2)?;
941
942 assert_ne!(thumbprint1, thumbprint2);
943
944 Ok(())
945 }
946
947 // === DPOP VALIDATION TESTS ===
948
949 #[test]
950 fn test_validate_dpop_jwt_with_known_jwt() -> anyhow::Result<()> {
951 // Test with the provided DPoP JWT - but this will fail timestamp validation
952 // since the iat is from 2024. We'll use a permissive config.
953 let dpop_jwt = "eyJhbGciOiJFUzI1NiIsImp3ayI6eyJhbGciOiJFUzI1NiIsImNydiI6IlAtMjU2Iiwia2lkIjoiZGlkOmtleTp6RG5hZVpVeEFhZDJUbkRYTjFaZWprcFV4TWVvMW9mNzF6NGVackxLRFRtaEQzOEQ3Iiwia3R5IjoiRUMiLCJ1c2UiOiJzaWciLCJ4IjoiaG56dDlSSGppUDBvMFJJTEZacEdjX0phenJUb1pHUzF1d0d5R3JleUNNbyIsInkiOiJzaXJhU2FGU09md3FrYTZRdnR3aUJhM0FKUi14eEhQaWVWZkFhZEhQQ0JRIn0sInR5cCI6ImRwb3Arand0In0.eyJqdGkiOiI2NDM0ZmFlNC00ZTYxLTQ1NDEtOTNlZC1kMzQ5ZjRiMTQ1NjEiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9haXBkZXYudHVubi5kZXYvb2F1dGgvdG9rZW4iLCJpYXQiOjE3NDk3NjQ1MTl9.GkoB00Y-68djRHLhO5-PayNV8PWcQI1pwZaAUL3Hzppj-ga6SKMyGpPwY4kcGdHM7lvvisNkzvd7RjEmdDtnjQ";
954
955 // Use a very permissive config to account for old timestamp
956 let config = DpopValidationConfig {
957 expected_http_method: Some("POST".to_string()),
958 expected_http_uri: Some("https://aipdev.tunn.dev/oauth/token".to_string()),
959 expected_access_token_hash: None,
960 max_age_seconds: 365 * 24 * 60 * 60, // 1 year
961 allow_future_iat: true,
962 clock_skew_tolerance_seconds: 365 * 24 * 60 * 60, // 1 year
963 expected_nonce_values: Vec::new(),
964 now: 1,
965 };
966
967 let thumbprint = validate_dpop_jwt(dpop_jwt, &config)?;
968
969 // Verify the thumbprint matches what we expect
970 assert_eq!(thumbprint, "lkqoPsebYpatLxSM4WCfAvOHqxU7X5RKlg-0_cobwSU");
971 assert_eq!(thumbprint.len(), 43);
972
973 Ok(())
974 }
975
976 #[test]
977 fn test_validate_dpop_jwt_with_generated_jwt() -> anyhow::Result<()> {
978 // Test with a freshly generated DPoP JWT
979 use atproto_identity::key::{KeyType, generate_key};
980
981 let key_data = generate_key(KeyType::P256Private)?;
982 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
983
984 let config = DpopValidationConfig::for_authorization("POST", "https://example.com/token");
985 let thumbprint = validate_dpop_jwt(&dpop_token, &config)?;
986
987 // Verify the thumbprint properties
988 assert_eq!(thumbprint.len(), 43);
989 assert!(!thumbprint.contains('='));
990 assert!(!thumbprint.contains('+'));
991 assert!(!thumbprint.contains('/'));
992
993 Ok(())
994 }
995
996 #[test]
997 fn test_validate_dpop_jwt_invalid_format() {
998 let config = DpopValidationConfig::default();
999
1000 // Test invalid JWT format
1001 let result = validate_dpop_jwt("invalid.jwt", &config);
1002 assert!(result.is_err());
1003 assert!(result.unwrap_err().to_string().contains("expected 3 parts"));
1004 }
1005
1006 #[test]
1007 fn test_validate_dpop_jwt_invalid_typ() -> anyhow::Result<()> {
1008 // Create a JWT with wrong typ field
1009 let header = serde_json::json!({
1010 "alg": "ES256",
1011 "typ": "JWT", // Wrong type, should be "dpop+jwt"
1012 "jwk": {
1013 "kty": "EC",
1014 "crv": "P-256",
1015 "x": "test",
1016 "y": "test"
1017 }
1018 });
1019 let encoded_header = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header)?.as_bytes());
1020 let jwt = format!("{}.eyJqdGkiOiJ0ZXN0In0.signature", encoded_header);
1021
1022 let config = DpopValidationConfig::default();
1023 let result = validate_dpop_jwt(&jwt, &config);
1024 assert!(result.is_err());
1025 assert!(
1026 result
1027 .unwrap_err()
1028 .to_string()
1029 .contains("Invalid token type")
1030 );
1031
1032 Ok(())
1033 }
1034
1035 #[test]
1036 fn test_validate_dpop_jwt_unsupported_algorithm() -> anyhow::Result<()> {
1037 // Create a JWT with unsupported algorithm
1038 let header = serde_json::json!({
1039 "alg": "HS256", // Unsupported algorithm
1040 "typ": "dpop+jwt",
1041 "jwk": {
1042 "kty": "EC",
1043 "crv": "P-256",
1044 "x": "test",
1045 "y": "test"
1046 }
1047 });
1048 let encoded_header = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header)?.as_bytes());
1049 let jwt = format!("{}.eyJqdGkiOiJ0ZXN0In0.signature", encoded_header);
1050
1051 let config = DpopValidationConfig::default();
1052 let result = validate_dpop_jwt(&jwt, &config);
1053 assert!(result.is_err());
1054 assert!(
1055 result
1056 .unwrap_err()
1057 .to_string()
1058 .contains("Unsupported JWT algorithm")
1059 );
1060
1061 Ok(())
1062 }
1063
1064 #[test]
1065 fn test_validate_dpop_jwt_missing_jwk() -> anyhow::Result<()> {
1066 // Create a JWT without JWK field
1067 let header = serde_json::json!({
1068 "alg": "ES256",
1069 "typ": "dpop+jwt"
1070 // Missing jwk field
1071 });
1072 let encoded_header = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header)?.as_bytes());
1073 let jwt = format!("{}.eyJqdGkiOiJ0ZXN0In0.signature", encoded_header);
1074
1075 let config = DpopValidationConfig::default();
1076 let result = validate_dpop_jwt(&jwt, &config);
1077 assert!(result.is_err());
1078 assert!(
1079 result
1080 .unwrap_err()
1081 .to_string()
1082 .contains("Missing required claim: jwk")
1083 );
1084
1085 Ok(())
1086 }
1087
1088 #[test]
1089 fn test_validate_dpop_jwt_missing_claims() -> anyhow::Result<()> {
1090 use atproto_identity::key::{KeyType, generate_key};
1091
1092 let key_data = generate_key(KeyType::P256Private)?;
1093 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1094
1095 // Modify the token to remove jti claim
1096 let parts: Vec<&str> = dpop_token.split('.').collect();
1097 let payload = serde_json::json!({
1098 // Missing jti
1099 "htm": "POST",
1100 "htu": "https://example.com/token",
1101 "iat": chrono::Utc::now().timestamp()
1102 });
1103 let encoded_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload)?.as_bytes());
1104 let modified_jwt = format!("{}.{}.{}", parts[0], encoded_payload, parts[2]);
1105
1106 let config = DpopValidationConfig::default();
1107 let result = validate_dpop_jwt(&modified_jwt, &config);
1108 assert!(result.is_err());
1109 assert!(
1110 result
1111 .unwrap_err()
1112 .to_string()
1113 .contains("Missing required claim: jti")
1114 );
1115
1116 Ok(())
1117 }
1118
1119 #[test]
1120 fn test_validate_dpop_jwt_http_method_mismatch() -> anyhow::Result<()> {
1121 use atproto_identity::key::{KeyType, generate_key};
1122
1123 let key_data = generate_key(KeyType::P256Private)?;
1124 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1125
1126 // Config expects GET but token has POST
1127 let config = DpopValidationConfig::for_authorization("GET", "https://example.com/token");
1128 let result = validate_dpop_jwt(&dpop_token, &config);
1129 assert!(result.is_err());
1130 assert!(
1131 result
1132 .unwrap_err()
1133 .to_string()
1134 .contains("HTTP method mismatch")
1135 );
1136
1137 Ok(())
1138 }
1139
1140 #[test]
1141 fn test_validate_dpop_jwt_http_uri_mismatch() -> anyhow::Result<()> {
1142 use atproto_identity::key::{KeyType, generate_key};
1143
1144 let key_data = generate_key(KeyType::P256Private)?;
1145 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1146
1147 // Config expects different URI
1148 let config = DpopValidationConfig::for_authorization("POST", "https://different.com/token");
1149 let result = validate_dpop_jwt(&dpop_token, &config);
1150 assert!(result.is_err());
1151 assert!(
1152 result
1153 .unwrap_err()
1154 .to_string()
1155 .contains("HTTP URI mismatch")
1156 );
1157
1158 Ok(())
1159 }
1160
1161 #[test]
1162 fn test_validate_dpop_jwt_require_access_token_hash() -> anyhow::Result<()> {
1163 use atproto_identity::key::{KeyType, generate_key};
1164
1165 let key_data = generate_key(KeyType::P256Private)?;
1166 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1167
1168 // Config requires ath but auth_dpop doesn't include it
1169 let config = DpopValidationConfig::for_resource_request(
1170 "POST",
1171 "https://example.com/token",
1172 "access_token",
1173 );
1174 let result = validate_dpop_jwt(&dpop_token, &config);
1175 assert!(result.is_err());
1176 assert!(
1177 result
1178 .unwrap_err()
1179 .to_string()
1180 .contains("Missing required claim: ath")
1181 );
1182
1183 Ok(())
1184 }
1185
1186 #[test]
1187 fn test_validate_dpop_jwt_with_resource_request() -> anyhow::Result<()> {
1188 use atproto_identity::key::{KeyType, generate_key};
1189
1190 let key_data = generate_key(KeyType::P256Private)?;
1191 let access_token = "test_access_token";
1192 let (dpop_token, _, _) = request_dpop(
1193 &key_data,
1194 "GET",
1195 "https://example.com/resource",
1196 access_token,
1197 )?;
1198
1199 // Config for resource request (requires ath)
1200 let config = DpopValidationConfig::for_resource_request(
1201 "GET",
1202 "https://example.com/resource",
1203 access_token,
1204 );
1205 let thumbprint = validate_dpop_jwt(&dpop_token, &config)?;
1206
1207 assert_eq!(thumbprint.len(), 43);
1208
1209 Ok(())
1210 }
1211
1212 #[test]
1213 fn test_validate_dpop_jwt_timestamp_validation() -> anyhow::Result<()> {
1214 use atproto_identity::key::{KeyType, generate_key};
1215
1216 let key_data = generate_key(KeyType::P256Private)?;
1217
1218 // Create a token with old timestamp
1219 let old_time = chrono::Utc::now().timestamp() as u64 - 3600; // 1 hour ago
1220
1221 let public_key_data = to_public(&key_data)?;
1222 let dpop_jwk: JwkEcKey = (&public_key_data).try_into()?;
1223
1224 let header = Header {
1225 type_: Some("dpop+jwt".to_string()),
1226 algorithm: Some("ES256".to_string()),
1227 json_web_key: Some(dpop_jwk),
1228 key_id: None,
1229 };
1230
1231 let claims = Claims::new(JoseClaims {
1232 json_web_token_id: Some(Ulid::new().to_string()),
1233 http_method: Some("POST".to_string()),
1234 http_uri: Some("https://example.com/token".to_string()),
1235 issued_at: Some(old_time),
1236 ..Default::default()
1237 });
1238
1239 let old_token = mint(&key_data, &header, &claims)?;
1240
1241 // Use strict config with short max_age
1242 let config = DpopValidationConfig {
1243 expected_http_method: Some("POST".to_string()),
1244 expected_http_uri: Some("https://example.com/token".to_string()),
1245 max_age_seconds: 60, // 1 minute
1246 ..Default::default()
1247 };
1248
1249 let result = validate_dpop_jwt(&old_token, &config);
1250 assert!(result.is_err());
1251 assert!(result.unwrap_err().to_string().contains("Token too old"));
1252
1253 Ok(())
1254 }
1255
1256 #[test]
1257 fn test_validate_dpop_jwt_different_keys_different_thumbprints() -> anyhow::Result<()> {
1258 use atproto_identity::key::{KeyType, generate_key};
1259
1260 let key1 = generate_key(KeyType::P256Private)?;
1261 let key2 = generate_key(KeyType::P256Private)?;
1262
1263 let (dpop_token1, _, _) = auth_dpop(&key1, "POST", "https://example.com/token")?;
1264 let (dpop_token2, _, _) = auth_dpop(&key2, "POST", "https://example.com/token")?;
1265
1266 let config = DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1267
1268 let thumbprint1 = validate_dpop_jwt(&dpop_token1, &config)?;
1269 let thumbprint2 = validate_dpop_jwt(&dpop_token2, &config)?;
1270
1271 assert_ne!(thumbprint1, thumbprint2);
1272
1273 Ok(())
1274 }
1275
1276 #[test]
1277 fn test_validate_dpop_jwt_config_for_authorization() {
1278 let config = DpopValidationConfig::for_authorization("POST", "https://example.com/auth");
1279
1280 assert_eq!(config.expected_http_method, Some("POST".to_string()));
1281 assert_eq!(
1282 config.expected_http_uri,
1283 Some("https://example.com/auth".to_string())
1284 );
1285 assert_eq!(config.max_age_seconds, 60);
1286 assert!(!config.allow_future_iat);
1287 assert_eq!(config.clock_skew_tolerance_seconds, 30);
1288 }
1289
1290 #[test]
1291 fn test_validate_dpop_jwt_config_for_resource_request() {
1292 let config = DpopValidationConfig::for_resource_request(
1293 "GET",
1294 "https://example.com/resource",
1295 "access_token",
1296 );
1297
1298 assert_eq!(config.expected_http_method, Some("GET".to_string()));
1299 assert_eq!(
1300 config.expected_http_uri,
1301 Some("https://example.com/resource".to_string())
1302 );
1303 assert_eq!(config.max_age_seconds, 60);
1304 assert!(!config.allow_future_iat);
1305 assert_eq!(config.clock_skew_tolerance_seconds, 30);
1306 }
1307
1308 #[test]
1309 fn test_validate_dpop_jwt_permissive_config() -> anyhow::Result<()> {
1310 use atproto_identity::key::{KeyType, generate_key};
1311
1312 let key_data = generate_key(KeyType::P256Private)?;
1313 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1314
1315 let now = chrono::Utc::now().timestamp();
1316
1317 // Very permissive config - doesn't validate method/URI
1318 let config = DpopValidationConfig {
1319 expected_http_method: None,
1320 expected_http_uri: None,
1321 expected_access_token_hash: None,
1322 max_age_seconds: 3600,
1323 allow_future_iat: true,
1324 clock_skew_tolerance_seconds: 300,
1325 expected_nonce_values: Vec::new(),
1326 now,
1327 };
1328
1329 let thumbprint = validate_dpop_jwt(&dpop_token, &config)?;
1330 assert_eq!(thumbprint.len(), 43);
1331
1332 Ok(())
1333 }
1334
1335 #[test]
1336 fn test_validate_dpop_jwt_nonce_validation_success() -> anyhow::Result<()> {
1337 use atproto_identity::key::{KeyType, generate_key};
1338
1339 let key_data = generate_key(KeyType::P256Private)?;
1340
1341 // Create a DPoP token with a nonce by manually building it
1342 let (_, header, mut claims) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1343
1344 // Add nonce to claims
1345 let test_nonce = "test_nonce_12345";
1346 claims
1347 .private
1348 .insert("nonce".to_string(), test_nonce.into());
1349
1350 // Create the token with nonce
1351 let dpop_token = mint(&key_data, &header, &claims)?;
1352
1353 // Create config with expected nonce values
1354 let mut config =
1355 DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1356 config.expected_nonce_values = vec![test_nonce.to_string(), "other_nonce".to_string()];
1357
1358 let thumbprint = validate_dpop_jwt(&dpop_token, &config)?;
1359 assert_eq!(thumbprint.len(), 43);
1360
1361 Ok(())
1362 }
1363
1364 #[test]
1365 fn test_validate_dpop_jwt_nonce_validation_failure() -> anyhow::Result<()> {
1366 use atproto_identity::key::{KeyType, generate_key};
1367
1368 let key_data = generate_key(KeyType::P256Private)?;
1369
1370 // Create a DPoP token with a specific nonce
1371 let (_, header, mut claims) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1372
1373 // Add a nonce that won't match the expected values
1374 let token_nonce = "token_nonce_that_wont_match";
1375 claims
1376 .private
1377 .insert("nonce".to_string(), token_nonce.into());
1378
1379 // Create the token with nonce
1380 let dpop_token = mint(&key_data, &header, &claims)?;
1381
1382 // Create config with different nonce values (not matching the token)
1383 let mut config =
1384 DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1385 config.expected_nonce_values = vec![
1386 "expected_nonce_1".to_string(),
1387 "expected_nonce_2".to_string(),
1388 ];
1389
1390 let result = validate_dpop_jwt(&dpop_token, &config);
1391 assert!(result.is_err());
1392 let error_msg = result.unwrap_err().to_string();
1393 assert!(error_msg.contains("Invalid nonce"));
1394
1395 Ok(())
1396 }
1397
1398 #[test]
1399 fn test_validate_dpop_jwt_nonce_missing_when_required() -> anyhow::Result<()> {
1400 use atproto_identity::key::{KeyType, generate_key};
1401
1402 let key_data = generate_key(KeyType::P256Private)?;
1403 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1404
1405 // Modify the token to remove the nonce claim
1406 let parts: Vec<&str> = dpop_token.split('.').collect();
1407 let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1])?;
1408 let mut payload: serde_json::Value = serde_json::from_slice(&payload_bytes)?;
1409
1410 // Remove the nonce field
1411 payload.as_object_mut().unwrap().remove("nonce");
1412
1413 let modified_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload)?.as_bytes());
1414 let modified_jwt = format!("{}.{}.{}", parts[0], modified_payload, parts[2]);
1415
1416 // Create config that requires nonce validation
1417 let mut config =
1418 DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1419 config.expected_nonce_values = vec!["required_nonce".to_string()];
1420
1421 let result = validate_dpop_jwt(&modified_jwt, &config);
1422 assert!(result.is_err());
1423 assert!(
1424 result
1425 .unwrap_err()
1426 .to_string()
1427 .contains("Missing required claim: nonce")
1428 );
1429
1430 Ok(())
1431 }
1432
1433 #[test]
1434 fn test_validate_dpop_jwt_nonce_empty_array_skips_validation() -> anyhow::Result<()> {
1435 use atproto_identity::key::{KeyType, generate_key};
1436
1437 let key_data = generate_key(KeyType::P256Private)?;
1438 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1439
1440 // Create config with empty nonce values (should skip nonce validation)
1441 let config = DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1442 assert!(config.expected_nonce_values.is_empty());
1443
1444 let thumbprint = validate_dpop_jwt(&dpop_token, &config)?;
1445 assert_eq!(thumbprint.len(), 43);
1446
1447 Ok(())
1448 }
1449}