atproto_oauth/
dpop.rs

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/// ```
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/// ```
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}