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