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                return Err(JWTError::TokenExpired.into());
757            }
758
759    // 3. VERIFY SIGNATURE
760    let content = format!("{}.{}", encoded_header, encoded_payload);
761    let signature_bytes = URL_SAFE_NO_PAD
762        .decode(encoded_signature)
763        .map_err(|_| JWTError::InvalidSignature)?;
764
765    validate(&key_data, &signature_bytes, content.as_bytes())
766        .map_err(|_| JWTError::SignatureVerificationFailed)?;
767
768    // 4. CALCULATE AND RETURN JWK THUMBPRINT
769    thumbprint(&wrapped_jwk).map_err(|e| e.into())
770}
771
772#[cfg(test)]
773mod tests {
774    use super::*;
775
776    #[test]
777    fn test_is_dpop_error_invalid_dpop_proof() {
778        let header = r#"DPoP algs="RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES256K ES384 ES512", error="invalid_dpop_proof", error_description="DPoP proof required""#;
779        assert!(is_dpop_error(header));
780    }
781
782    #[test]
783    fn test_is_dpop_error_use_dpop_nonce() {
784        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""#;
785        assert!(is_dpop_error(header));
786    }
787
788    #[test]
789    fn test_is_dpop_error_other_error() {
790        let header =
791            r#"DPoP algs="ES256", error="invalid_token", error_description="Token is invalid""#;
792        assert!(!is_dpop_error(header));
793    }
794
795    #[test]
796    fn test_is_dpop_error_no_error_field() {
797        let header = r#"DPoP algs="ES256", error_description="Some description""#;
798        assert!(!is_dpop_error(header));
799    }
800
801    #[test]
802    fn test_is_dpop_error_not_dpop_header() {
803        let header = r#"Bearer error="invalid_token""#;
804        assert!(!is_dpop_error(header));
805    }
806
807    #[test]
808    fn test_is_dpop_error_empty_string() {
809        assert!(!is_dpop_error(""));
810    }
811
812    #[test]
813    fn test_is_dpop_error_minimal_valid() {
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_unquoted_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_whitespace_handling() {
826        let header =
827            r#"  DPoP  algs="ES256"  ,  error="use_dpop_nonce"  ,  error_description="test"  "#;
828        assert!(is_dpop_error(header));
829    }
830
831    #[test]
832    fn test_is_dpop_error_case_sensitive_scheme() {
833        let header = r#"dpop error="invalid_dpop_proof""#;
834        assert!(!is_dpop_error(header));
835    }
836
837    #[test]
838    fn test_is_dpop_error_case_sensitive_error_value() {
839        let header = r#"DPoP error="INVALID_DPOP_PROOF""#;
840        assert!(!is_dpop_error(header));
841    }
842
843    #[test]
844    fn test_is_dpop_error_malformed_quotes() {
845        let header = r#"DPoP error="invalid_dpop_proof"#;
846        assert!(is_dpop_error(header));
847    }
848
849    #[test]
850    fn test_is_dpop_error_multiple_error_fields() {
851        let header = r#"DPoP error="invalid_token", algs="ES256", error="invalid_dpop_proof""#;
852        // Should match the first error field found
853        assert!(!is_dpop_error(header));
854    }
855
856    #[test]
857    fn test_extract_jwk_thumbprint_with_known_jwt() -> anyhow::Result<()> {
858        // Test with the provided DPoP JWT
859        let dpop_jwt = "eyJhbGciOiJFUzI1NiIsImp3ayI6eyJhbGciOiJFUzI1NiIsImNydiI6IlAtMjU2Iiwia2lkIjoiZGlkOmtleTp6RG5hZVpVeEFhZDJUbkRYTjFaZWprcFV4TWVvMW9mNzF6NGVackxLRFRtaEQzOEQ3Iiwia3R5IjoiRUMiLCJ1c2UiOiJzaWciLCJ4IjoiaG56dDlSSGppUDBvMFJJTEZacEdjX0phenJUb1pHUzF1d0d5R3JleUNNbyIsInkiOiJzaXJhU2FGU09md3FrYTZRdnR3aUJhM0FKUi14eEhQaWVWZkFhZEhQQ0JRIn0sInR5cCI6ImRwb3Arand0In0.eyJqdGkiOiI2NDM0ZmFlNC00ZTYxLTQ1NDEtOTNlZC1kMzQ5ZjRiMTQ1NjEiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9haXBkZXYudHVubi5kZXYvb2F1dGgvdG9rZW4iLCJpYXQiOjE3NDk3NjQ1MTl9.GkoB00Y-68djRHLhO5-PayNV8PWcQI1pwZaAUL3Hzppj-ga6SKMyGpPwY4kcGdHM7lvvisNkzvd7RjEmdDtnjQ";
860
861        let thumbprint = extract_jwk_thumbprint(dpop_jwt)?;
862
863        // Verify the thumbprint is a valid base64url string
864        assert_eq!(thumbprint.len(), 43); // SHA-256 base64url encoded is 43 characters
865        assert!(!thumbprint.contains('=')); // No padding in base64url
866        assert!(!thumbprint.contains('+')); // No + in base64url
867        assert!(!thumbprint.contains('/')); // No / in base64url
868
869        // Verify that the thumbprint is deterministic (same result every time)
870        let thumbprint2 = extract_jwk_thumbprint(dpop_jwt)?;
871        assert_eq!(thumbprint, thumbprint2);
872
873        assert_eq!(thumbprint, "lkqoPsebYpatLxSM4WCfAvOHqxU7X5RKlg-0_cobwSU");
874
875        Ok(())
876    }
877
878    #[test]
879    fn test_extract_jwk_thumbprint_invalid_jwt_format() {
880        // Test with invalid JWT format (not 3 parts)
881        let invalid_jwt = "invalid.jwt";
882        let result = extract_jwk_thumbprint(invalid_jwt);
883        assert!(result.is_err());
884        assert!(result.unwrap_err().to_string().contains("expected 3 parts"));
885    }
886
887    #[test]
888    fn test_extract_jwk_thumbprint_invalid_header() {
889        // Test with invalid base64 in header
890        let invalid_jwt = "invalid-base64.eyJqdGkiOiJ0ZXN0In0.signature";
891        let result = extract_jwk_thumbprint(invalid_jwt);
892        assert!(result.is_err());
893        assert!(
894            result
895                .unwrap_err()
896                .to_string()
897                .contains("Invalid JWT header")
898        );
899    }
900
901    #[test]
902    fn test_extract_jwk_thumbprint_missing_jwk() -> anyhow::Result<()> {
903        // Create a valid JWT header without a JWK field
904        let header = serde_json::json!({
905            "alg": "ES256",
906            "typ": "dpop+jwt"
907        });
908        let encoded_header = base64::engine::general_purpose::URL_SAFE_NO_PAD
909            .encode(serde_json::to_string(&header)?.as_bytes());
910
911        let jwt_without_jwk = format!("{}.eyJqdGkiOiJ0ZXN0In0.signature", encoded_header);
912        let result = extract_jwk_thumbprint(&jwt_without_jwk);
913        assert!(result.is_err());
914        assert!(
915            result
916                .unwrap_err()
917                .to_string()
918                .contains("Missing required claim: jwk")
919        );
920
921        Ok(())
922    }
923
924    #[test]
925    fn test_extract_jwk_thumbprint_with_generated_dpop() -> anyhow::Result<()> {
926        // Test with a DPoP JWT generated by our own functions
927        use atproto_identity::key::{KeyType, generate_key};
928
929        let key_data = generate_key(KeyType::P256Private)?;
930        let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
931
932        let thumbprint = extract_jwk_thumbprint(&dpop_token)?;
933
934        // Verify the thumbprint properties
935        assert_eq!(thumbprint.len(), 43);
936        assert!(!thumbprint.contains('='));
937        assert!(!thumbprint.contains('+'));
938        assert!(!thumbprint.contains('/'));
939
940        // Verify deterministic behavior
941        let thumbprint2 = extract_jwk_thumbprint(&dpop_token)?;
942        assert_eq!(thumbprint, thumbprint2);
943
944        Ok(())
945    }
946
947    #[test]
948    fn test_extract_jwk_thumbprint_different_keys_different_thumbprints() -> anyhow::Result<()> {
949        // Test that different keys produce different thumbprints
950        use atproto_identity::key::{KeyType, generate_key};
951
952        let key1 = generate_key(KeyType::P256Private)?;
953        let key2 = generate_key(KeyType::P256Private)?;
954
955        let (dpop_token1, _, _) = auth_dpop(&key1, "POST", "https://example.com/token")?;
956        let (dpop_token2, _, _) = auth_dpop(&key2, "POST", "https://example.com/token")?;
957
958        let thumbprint1 = extract_jwk_thumbprint(&dpop_token1)?;
959        let thumbprint2 = extract_jwk_thumbprint(&dpop_token2)?;
960
961        assert_ne!(thumbprint1, thumbprint2);
962
963        Ok(())
964    }
965
966    // === DPOP VALIDATION TESTS ===
967
968    #[test]
969    fn test_validate_dpop_jwt_with_known_jwt() -> anyhow::Result<()> {
970        // Test with the provided DPoP JWT - but this will fail timestamp validation
971        // since the iat is from 2024. We'll use a permissive config.
972        let dpop_jwt = "eyJhbGciOiJFUzI1NiIsImp3ayI6eyJhbGciOiJFUzI1NiIsImNydiI6IlAtMjU2Iiwia2lkIjoiZGlkOmtleTp6RG5hZVpVeEFhZDJUbkRYTjFaZWprcFV4TWVvMW9mNzF6NGVackxLRFRtaEQzOEQ3Iiwia3R5IjoiRUMiLCJ1c2UiOiJzaWciLCJ4IjoiaG56dDlSSGppUDBvMFJJTEZacEdjX0phenJUb1pHUzF1d0d5R3JleUNNbyIsInkiOiJzaXJhU2FGU09md3FrYTZRdnR3aUJhM0FKUi14eEhQaWVWZkFhZEhQQ0JRIn0sInR5cCI6ImRwb3Arand0In0.eyJqdGkiOiI2NDM0ZmFlNC00ZTYxLTQ1NDEtOTNlZC1kMzQ5ZjRiMTQ1NjEiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9haXBkZXYudHVubi5kZXYvb2F1dGgvdG9rZW4iLCJpYXQiOjE3NDk3NjQ1MTl9.GkoB00Y-68djRHLhO5-PayNV8PWcQI1pwZaAUL3Hzppj-ga6SKMyGpPwY4kcGdHM7lvvisNkzvd7RjEmdDtnjQ";
973
974        // Use a very permissive config to account for old timestamp
975        let config = DpopValidationConfig {
976            expected_http_method: Some("POST".to_string()),
977            expected_http_uri: Some("https://aipdev.tunn.dev/oauth/token".to_string()),
978            expected_access_token_hash: None,
979            max_age_seconds: 365 * 24 * 60 * 60, // 1 year
980            allow_future_iat: true,
981            clock_skew_tolerance_seconds: 365 * 24 * 60 * 60, // 1 year
982            expected_nonce_values: Vec::new(),
983            now: 1,
984        };
985
986        let thumbprint = validate_dpop_jwt(dpop_jwt, &config)?;
987
988        // Verify the thumbprint matches what we expect
989        assert_eq!(thumbprint, "lkqoPsebYpatLxSM4WCfAvOHqxU7X5RKlg-0_cobwSU");
990        assert_eq!(thumbprint.len(), 43);
991
992        Ok(())
993    }
994
995    #[test]
996    fn test_validate_dpop_jwt_with_generated_jwt() -> anyhow::Result<()> {
997        // Test with a freshly generated DPoP JWT
998        use atproto_identity::key::{KeyType, generate_key};
999
1000        let key_data = generate_key(KeyType::P256Private)?;
1001        let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1002
1003        let config = DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1004        let thumbprint = validate_dpop_jwt(&dpop_token, &config)?;
1005
1006        // Verify the thumbprint properties
1007        assert_eq!(thumbprint.len(), 43);
1008        assert!(!thumbprint.contains('='));
1009        assert!(!thumbprint.contains('+'));
1010        assert!(!thumbprint.contains('/'));
1011
1012        Ok(())
1013    }
1014
1015    #[test]
1016    fn test_validate_dpop_jwt_invalid_format() {
1017        let config = DpopValidationConfig::default();
1018
1019        // Test invalid JWT format
1020        let result = validate_dpop_jwt("invalid.jwt", &config);
1021        assert!(result.is_err());
1022        assert!(result.unwrap_err().to_string().contains("expected 3 parts"));
1023    }
1024
1025    #[test]
1026    fn test_validate_dpop_jwt_invalid_typ() -> anyhow::Result<()> {
1027        // Create a JWT with wrong typ field
1028        let header = serde_json::json!({
1029            "alg": "ES256",
1030            "typ": "JWT", // Wrong type, should be "dpop+jwt"
1031            "jwk": {
1032                "kty": "EC",
1033                "crv": "P-256",
1034                "x": "test",
1035                "y": "test"
1036            }
1037        });
1038        let encoded_header = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header)?.as_bytes());
1039        let jwt = format!("{}.eyJqdGkiOiJ0ZXN0In0.signature", encoded_header);
1040
1041        let config = DpopValidationConfig::default();
1042        let result = validate_dpop_jwt(&jwt, &config);
1043        assert!(result.is_err());
1044        assert!(
1045            result
1046                .unwrap_err()
1047                .to_string()
1048                .contains("Invalid token type")
1049        );
1050
1051        Ok(())
1052    }
1053
1054    #[test]
1055    fn test_validate_dpop_jwt_unsupported_algorithm() -> anyhow::Result<()> {
1056        // Create a JWT with unsupported algorithm
1057        let header = serde_json::json!({
1058            "alg": "HS256", // Unsupported algorithm
1059            "typ": "dpop+jwt",
1060            "jwk": {
1061                "kty": "EC",
1062                "crv": "P-256",
1063                "x": "test",
1064                "y": "test"
1065            }
1066        });
1067        let encoded_header = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header)?.as_bytes());
1068        let jwt = format!("{}.eyJqdGkiOiJ0ZXN0In0.signature", encoded_header);
1069
1070        let config = DpopValidationConfig::default();
1071        let result = validate_dpop_jwt(&jwt, &config);
1072        assert!(result.is_err());
1073        assert!(
1074            result
1075                .unwrap_err()
1076                .to_string()
1077                .contains("Unsupported JWT algorithm")
1078        );
1079
1080        Ok(())
1081    }
1082
1083    #[test]
1084    fn test_validate_dpop_jwt_missing_jwk() -> anyhow::Result<()> {
1085        // Create a JWT without JWK field
1086        let header = serde_json::json!({
1087            "alg": "ES256",
1088            "typ": "dpop+jwt"
1089            // Missing jwk field
1090        });
1091        let encoded_header = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header)?.as_bytes());
1092        let jwt = format!("{}.eyJqdGkiOiJ0ZXN0In0.signature", encoded_header);
1093
1094        let config = DpopValidationConfig::default();
1095        let result = validate_dpop_jwt(&jwt, &config);
1096        assert!(result.is_err());
1097        assert!(
1098            result
1099                .unwrap_err()
1100                .to_string()
1101                .contains("Missing required claim: jwk")
1102        );
1103
1104        Ok(())
1105    }
1106
1107    #[test]
1108    fn test_validate_dpop_jwt_missing_claims() -> anyhow::Result<()> {
1109        use atproto_identity::key::{KeyType, generate_key};
1110
1111        let key_data = generate_key(KeyType::P256Private)?;
1112        let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1113
1114        // Modify the token to remove jti claim
1115        let parts: Vec<&str> = dpop_token.split('.').collect();
1116        let payload = serde_json::json!({
1117            // Missing jti
1118            "htm": "POST",
1119            "htu": "https://example.com/token",
1120            "iat": chrono::Utc::now().timestamp()
1121        });
1122        let encoded_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload)?.as_bytes());
1123        let modified_jwt = format!("{}.{}.{}", parts[0], encoded_payload, parts[2]);
1124
1125        let config = DpopValidationConfig::default();
1126        let result = validate_dpop_jwt(&modified_jwt, &config);
1127        assert!(result.is_err());
1128        assert!(
1129            result
1130                .unwrap_err()
1131                .to_string()
1132                .contains("Missing required claim: jti")
1133        );
1134
1135        Ok(())
1136    }
1137
1138    #[test]
1139    fn test_validate_dpop_jwt_http_method_mismatch() -> anyhow::Result<()> {
1140        use atproto_identity::key::{KeyType, generate_key};
1141
1142        let key_data = generate_key(KeyType::P256Private)?;
1143        let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1144
1145        // Config expects GET but token has POST
1146        let config = DpopValidationConfig::for_authorization("GET", "https://example.com/token");
1147        let result = validate_dpop_jwt(&dpop_token, &config);
1148        assert!(result.is_err());
1149        assert!(
1150            result
1151                .unwrap_err()
1152                .to_string()
1153                .contains("HTTP method mismatch")
1154        );
1155
1156        Ok(())
1157    }
1158
1159    #[test]
1160    fn test_validate_dpop_jwt_http_uri_mismatch() -> anyhow::Result<()> {
1161        use atproto_identity::key::{KeyType, generate_key};
1162
1163        let key_data = generate_key(KeyType::P256Private)?;
1164        let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1165
1166        // Config expects different URI
1167        let config = DpopValidationConfig::for_authorization("POST", "https://different.com/token");
1168        let result = validate_dpop_jwt(&dpop_token, &config);
1169        assert!(result.is_err());
1170        assert!(
1171            result
1172                .unwrap_err()
1173                .to_string()
1174                .contains("HTTP URI mismatch")
1175        );
1176
1177        Ok(())
1178    }
1179
1180    #[test]
1181    fn test_validate_dpop_jwt_require_access_token_hash() -> anyhow::Result<()> {
1182        use atproto_identity::key::{KeyType, generate_key};
1183
1184        let key_data = generate_key(KeyType::P256Private)?;
1185        let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1186
1187        // Config requires ath but auth_dpop doesn't include it
1188        let config = DpopValidationConfig::for_resource_request(
1189            "POST",
1190            "https://example.com/token",
1191            "access_token",
1192        );
1193        let result = validate_dpop_jwt(&dpop_token, &config);
1194        assert!(result.is_err());
1195        assert!(
1196            result
1197                .unwrap_err()
1198                .to_string()
1199                .contains("Missing required claim: ath")
1200        );
1201
1202        Ok(())
1203    }
1204
1205    #[test]
1206    fn test_validate_dpop_jwt_with_resource_request() -> anyhow::Result<()> {
1207        use atproto_identity::key::{KeyType, generate_key};
1208
1209        let key_data = generate_key(KeyType::P256Private)?;
1210        let access_token = "test_access_token";
1211        let (dpop_token, _, _) = request_dpop(
1212            &key_data,
1213            "GET",
1214            "https://example.com/resource",
1215            access_token,
1216        )?;
1217
1218        // Config for resource request (requires ath)
1219        let config = DpopValidationConfig::for_resource_request(
1220            "GET",
1221            "https://example.com/resource",
1222            access_token,
1223        );
1224        let thumbprint = validate_dpop_jwt(&dpop_token, &config)?;
1225
1226        assert_eq!(thumbprint.len(), 43);
1227
1228        Ok(())
1229    }
1230
1231    #[test]
1232    fn test_validate_dpop_jwt_timestamp_validation() -> anyhow::Result<()> {
1233        use atproto_identity::key::{KeyType, generate_key};
1234
1235        let key_data = generate_key(KeyType::P256Private)?;
1236
1237        // Create a token with old timestamp
1238        let old_time = chrono::Utc::now().timestamp() as u64 - 3600; // 1 hour ago
1239
1240        let public_key_data = to_public(&key_data)?;
1241        let dpop_jwk: JwkEcKey = (&public_key_data).try_into()?;
1242
1243        let header = Header {
1244            type_: Some("dpop+jwt".to_string()),
1245            algorithm: Some("ES256".to_string()),
1246            json_web_key: Some(dpop_jwk),
1247            key_id: None,
1248        };
1249
1250        let claims = Claims::new(JoseClaims {
1251            json_web_token_id: Some(Ulid::new().to_string()),
1252            http_method: Some("POST".to_string()),
1253            http_uri: Some("https://example.com/token".to_string()),
1254            issued_at: Some(old_time),
1255            ..Default::default()
1256        });
1257
1258        let old_token = mint(&key_data, &header, &claims)?;
1259
1260        // Use strict config with short max_age
1261        let config = DpopValidationConfig {
1262            expected_http_method: Some("POST".to_string()),
1263            expected_http_uri: Some("https://example.com/token".to_string()),
1264            max_age_seconds: 60, // 1 minute
1265            ..Default::default()
1266        };
1267
1268        let result = validate_dpop_jwt(&old_token, &config);
1269        assert!(result.is_err());
1270        assert!(result.unwrap_err().to_string().contains("Token too old"));
1271
1272        Ok(())
1273    }
1274
1275    #[test]
1276    fn test_validate_dpop_jwt_different_keys_different_thumbprints() -> anyhow::Result<()> {
1277        use atproto_identity::key::{KeyType, generate_key};
1278
1279        let key1 = generate_key(KeyType::P256Private)?;
1280        let key2 = generate_key(KeyType::P256Private)?;
1281
1282        let (dpop_token1, _, _) = auth_dpop(&key1, "POST", "https://example.com/token")?;
1283        let (dpop_token2, _, _) = auth_dpop(&key2, "POST", "https://example.com/token")?;
1284
1285        let config = DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1286
1287        let thumbprint1 = validate_dpop_jwt(&dpop_token1, &config)?;
1288        let thumbprint2 = validate_dpop_jwt(&dpop_token2, &config)?;
1289
1290        assert_ne!(thumbprint1, thumbprint2);
1291
1292        Ok(())
1293    }
1294
1295    #[test]
1296    fn test_validate_dpop_jwt_config_for_authorization() {
1297        let config = DpopValidationConfig::for_authorization("POST", "https://example.com/auth");
1298
1299        assert_eq!(config.expected_http_method, Some("POST".to_string()));
1300        assert_eq!(
1301            config.expected_http_uri,
1302            Some("https://example.com/auth".to_string())
1303        );
1304        assert_eq!(config.max_age_seconds, 60);
1305        assert!(!config.allow_future_iat);
1306        assert_eq!(config.clock_skew_tolerance_seconds, 30);
1307    }
1308
1309    #[test]
1310    fn test_validate_dpop_jwt_config_for_resource_request() {
1311        let config = DpopValidationConfig::for_resource_request(
1312            "GET",
1313            "https://example.com/resource",
1314            "access_token",
1315        );
1316
1317        assert_eq!(config.expected_http_method, Some("GET".to_string()));
1318        assert_eq!(
1319            config.expected_http_uri,
1320            Some("https://example.com/resource".to_string())
1321        );
1322        assert_eq!(config.max_age_seconds, 60);
1323        assert!(!config.allow_future_iat);
1324        assert_eq!(config.clock_skew_tolerance_seconds, 30);
1325    }
1326
1327    #[test]
1328    fn test_validate_dpop_jwt_permissive_config() -> anyhow::Result<()> {
1329        use atproto_identity::key::{KeyType, generate_key};
1330
1331        let key_data = generate_key(KeyType::P256Private)?;
1332        let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1333
1334        let now = chrono::Utc::now().timestamp();
1335
1336        // Very permissive config - doesn't validate method/URI
1337        let config = DpopValidationConfig {
1338            expected_http_method: None,
1339            expected_http_uri: None,
1340            expected_access_token_hash: None,
1341            max_age_seconds: 3600,
1342            allow_future_iat: true,
1343            clock_skew_tolerance_seconds: 300,
1344            expected_nonce_values: Vec::new(),
1345            now,
1346        };
1347
1348        let thumbprint = validate_dpop_jwt(&dpop_token, &config)?;
1349        assert_eq!(thumbprint.len(), 43);
1350
1351        Ok(())
1352    }
1353
1354    #[test]
1355    fn test_validate_dpop_jwt_nonce_validation_success() -> anyhow::Result<()> {
1356        use atproto_identity::key::{KeyType, generate_key};
1357
1358        let key_data = generate_key(KeyType::P256Private)?;
1359
1360        // Create a DPoP token with a nonce by manually building it
1361        let (_, header, mut claims) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1362
1363        // Add nonce to claims
1364        let test_nonce = "test_nonce_12345";
1365        claims
1366            .private
1367            .insert("nonce".to_string(), test_nonce.into());
1368
1369        // Create the token with nonce
1370        let dpop_token = mint(&key_data, &header, &claims)?;
1371
1372        // Create config with expected nonce values
1373        let mut config =
1374            DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1375        config.expected_nonce_values = vec![test_nonce.to_string(), "other_nonce".to_string()];
1376
1377        let thumbprint = validate_dpop_jwt(&dpop_token, &config)?;
1378        assert_eq!(thumbprint.len(), 43);
1379
1380        Ok(())
1381    }
1382
1383    #[test]
1384    fn test_validate_dpop_jwt_nonce_validation_failure() -> anyhow::Result<()> {
1385        use atproto_identity::key::{KeyType, generate_key};
1386
1387        let key_data = generate_key(KeyType::P256Private)?;
1388
1389        // Create a DPoP token with a specific nonce
1390        let (_, header, mut claims) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1391
1392        // Add a nonce that won't match the expected values
1393        let token_nonce = "token_nonce_that_wont_match";
1394        claims
1395            .private
1396            .insert("nonce".to_string(), token_nonce.into());
1397
1398        // Create the token with nonce
1399        let dpop_token = mint(&key_data, &header, &claims)?;
1400
1401        // Create config with different nonce values (not matching the token)
1402        let mut config =
1403            DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1404        config.expected_nonce_values = vec![
1405            "expected_nonce_1".to_string(),
1406            "expected_nonce_2".to_string(),
1407        ];
1408
1409        let result = validate_dpop_jwt(&dpop_token, &config);
1410        assert!(result.is_err());
1411        let error_msg = result.unwrap_err().to_string();
1412        assert!(error_msg.contains("Invalid nonce"));
1413
1414        Ok(())
1415    }
1416
1417    #[test]
1418    fn test_validate_dpop_jwt_nonce_missing_when_required() -> anyhow::Result<()> {
1419        use atproto_identity::key::{KeyType, generate_key};
1420
1421        let key_data = generate_key(KeyType::P256Private)?;
1422        let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1423
1424        // Modify the token to remove the nonce claim
1425        let parts: Vec<&str> = dpop_token.split('.').collect();
1426        let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1])?;
1427        let mut payload: serde_json::Value = serde_json::from_slice(&payload_bytes)?;
1428
1429        // Remove the nonce field
1430        payload.as_object_mut().unwrap().remove("nonce");
1431
1432        let modified_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload)?.as_bytes());
1433        let modified_jwt = format!("{}.{}.{}", parts[0], modified_payload, parts[2]);
1434
1435        // Create config that requires nonce validation
1436        let mut config =
1437            DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1438        config.expected_nonce_values = vec!["required_nonce".to_string()];
1439
1440        let result = validate_dpop_jwt(&modified_jwt, &config);
1441        assert!(result.is_err());
1442        assert!(
1443            result
1444                .unwrap_err()
1445                .to_string()
1446                .contains("Missing required claim: nonce")
1447        );
1448
1449        Ok(())
1450    }
1451
1452    #[test]
1453    fn test_validate_dpop_jwt_nonce_empty_array_skips_validation() -> anyhow::Result<()> {
1454        use atproto_identity::key::{KeyType, generate_key};
1455
1456        let key_data = generate_key(KeyType::P256Private)?;
1457        let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1458
1459        // Create config with empty nonce values (should skip nonce validation)
1460        let config = DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1461        assert!(config.expected_nonce_values.is_empty());
1462
1463        let thumbprint = validate_dpop_jwt(&dpop_token, &config)?;
1464        assert_eq!(thumbprint.len(), 43);
1465
1466        Ok(())
1467    }
1468}