atproto_oauth/
dpop.rs

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