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