Skip to main content

mpp_br/protocol/core/
headers.rs

1//! Header parsing and formatting functions for Web Payment Auth.
2//!
3//! This module provides functions to parse and format the HTTP headers used
4//! in the Web Payment Auth protocol:
5//!
6//! - `WWW-Authenticate: Payment ...` - Challenge from server
7//! - `Authorization: Payment ...` - Credential from client  
8//! - `Payment-Receipt: ...` - Receipt from server
9//!
10//! The parser is implemented without regex for minimal dependencies.
11
12use super::challenge::{PaymentChallenge, PaymentCredential, Receipt};
13use super::types::{base64url_decode, base64url_encode, Base64UrlJson, IntentName, MethodName};
14use crate::error::{MppError, Result};
15use std::collections::HashMap;
16
17/// Maximum length for base64url-encoded tokens to prevent memory exhaustion DoS.
18const MAX_TOKEN_LEN: usize = 16 * 1024;
19
20/// Macro to extract a required parameter from the params map.
21macro_rules! require_param {
22    ($params:expr, $key:literal) => {
23        $params.get($key).ok_or_else(|| {
24            MppError::invalid_challenge_reason(format!("Missing '{}' field", $key))
25        })?
26    };
27}
28
29/// Strip the Payment scheme prefix (case-insensitive) from a header value.
30/// Returns the remainder of the header after the scheme, or None if not a Payment header.
31fn strip_payment_scheme(header: &str) -> Option<&str> {
32    let header = header.trim_start();
33    let scheme_len = PAYMENT_SCHEME.len();
34
35    if header.len() >= scheme_len
36        && header
37            .get(..scheme_len)
38            .is_some_and(|s| s.eq_ignore_ascii_case(PAYMENT_SCHEME))
39    {
40        header.get(scheme_len..)
41    } else {
42        None
43    }
44}
45
46/// Extract the `Payment` scheme from an Authorization header that may contain
47/// multiple comma-separated schemes (per RFC 9110).
48///
49/// Returns the `Payment ...` scheme string, or `None` if not found.
50/// This matches the TypeScript SDK's `Credential.extractPaymentScheme`.
51///
52/// # Examples
53///
54/// ```
55/// use mpp::protocol::core::extract_payment_scheme;
56///
57/// // Single Payment scheme
58/// assert!(extract_payment_scheme("Payment eyJhYmMi...").is_some());
59///
60/// // Mixed schemes (comma-separated per RFC 9110)
61/// let header = "Bearer token123, Payment eyJhYmMi...";
62/// let payment = extract_payment_scheme(header).unwrap();
63/// assert!(payment.starts_with("Payment "));
64///
65/// // No Payment scheme
66/// assert!(extract_payment_scheme("Bearer token123").is_none());
67/// ```
68pub fn extract_payment_scheme(header: &str) -> Option<&str> {
69    header.split(',').map(|s| s.trim()).find(|s| {
70        s.len() >= 8
71            && s.get(..8)
72                .is_some_and(|prefix| prefix.eq_ignore_ascii_case("payment "))
73    })
74}
75
76/// Escape a string for use in a quoted-string header value.
77/// Rejects CRLF to prevent header injection attacks.
78fn escape_quoted_value(s: &str) -> Result<String> {
79    if s.contains('\r') || s.contains('\n') {
80        return Err(MppError::invalid_challenge_reason(
81            "Header value contains invalid CRLF characters",
82        ));
83    }
84    Ok(s.replace('\\', "\\\\").replace('"', "\\\""))
85}
86
87/// Header name for payment challenges (from server)
88pub const WWW_AUTHENTICATE_HEADER: &str = "www-authenticate";
89
90/// Header name for payment credentials (from client)
91pub const AUTHORIZATION_HEADER: &str = "authorization";
92
93/// Header name for payment receipts (from server)
94pub const PAYMENT_RECEIPT_HEADER: &str = "payment-receipt";
95
96/// Scheme identifier for the Payment authentication scheme
97pub const PAYMENT_SCHEME: &str = "Payment";
98
99/// Parse key="value" pairs from an auth-param string.
100///
101/// This is a simple parser that handles:
102/// - Quoted string values with escaped quotes
103/// - Key=value without quotes for simple values
104/// - Comma or space separated parameters
105fn parse_auth_params(params_str: &str) -> Result<HashMap<String, String>> {
106    let mut params = HashMap::new();
107    let chars: Vec<char> = params_str.chars().collect();
108    let mut i = 0;
109
110    while i < chars.len() {
111        while i < chars.len() && (chars[i].is_whitespace() || chars[i] == ',') {
112            i += 1;
113        }
114        if i >= chars.len() {
115            break;
116        }
117
118        let key_start = i;
119        while i < chars.len() && chars[i] != '=' && !chars[i].is_whitespace() {
120            i += 1;
121        }
122        if i >= chars.len() || chars[i] != '=' {
123            while i < chars.len() && !chars[i].is_whitespace() && chars[i] != ',' {
124                i += 1;
125            }
126            continue;
127        }
128
129        let key: String = chars[key_start..i].iter().collect();
130        i += 1;
131
132        if i >= chars.len() {
133            break;
134        }
135
136        let value = if chars[i] == '"' {
137            i += 1;
138            let mut value = String::new();
139            while i < chars.len() && chars[i] != '"' {
140                if chars[i] == '\\' && i + 1 < chars.len() {
141                    i += 1;
142                    value.push(chars[i]);
143                } else {
144                    value.push(chars[i]);
145                }
146                i += 1;
147            }
148            if i < chars.len() {
149                i += 1;
150            }
151            value
152        } else {
153            let value_start = i;
154            while i < chars.len() && !chars[i].is_whitespace() && chars[i] != ',' {
155                i += 1;
156            }
157            chars[value_start..i].iter().collect()
158        };
159
160        if params.contains_key(&key) {
161            return Err(MppError::invalid_challenge_reason(format!(
162                "Duplicate parameter: {}",
163                key
164            )));
165        }
166        params.insert(key, value);
167    }
168
169    Ok(params)
170}
171
172/// Validate ISO 8601 / RFC 3339 timestamp format.
173fn is_iso8601_timestamp(s: &str) -> bool {
174    time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).is_ok()
175}
176
177/// Validate digest format.
178///
179/// Matches TypeScript SDK behavior: digest must start with `sha-256=`.
180fn is_valid_digest_format(d: &str) -> bool {
181    d.starts_with("sha-256=")
182}
183
184/// Parse a single WWW-Authenticate header into a PaymentChallenge.
185///
186/// Format: `Payment id="<id>", realm="<realm>", method="<method>", intent="<intent>", request="<base64url-json>"`
187///
188/// Parsing is case-insensitive for the scheme name per RFC 7235.
189///
190/// # Examples
191///
192/// ```
193/// use mpp::protocol::core::parse_www_authenticate;
194///
195/// let header = r#"Payment id="abc123", realm="api", method="tempo", intent="charge", request="eyJhbW91bnQiOiIxMDAwMCJ9""#;
196/// let challenge = parse_www_authenticate(header).unwrap();
197/// assert_eq!(challenge.id, "abc123");
198/// ```
199pub fn parse_www_authenticate(header: &str) -> Result<PaymentChallenge> {
200    let rest = strip_payment_scheme(header).ok_or_else(|| {
201        MppError::invalid_challenge_reason("Expected 'Payment' scheme".to_string())
202    })?;
203
204    let params_str = rest
205        .strip_prefix(' ')
206        .or_else(|| rest.strip_prefix('\t'))
207        .ok_or_else(|| {
208            MppError::invalid_challenge_reason("Expected space after 'Payment' scheme".to_string())
209        })?
210        .trim_start();
211    let params = parse_auth_params(params_str)?;
212
213    let id = require_param!(params, "id").clone();
214    if id.is_empty() {
215        return Err(MppError::invalid_challenge_reason(
216            "Empty 'id' parameter".to_string(),
217        ));
218    }
219    let realm = require_param!(params, "realm").clone();
220    let method_raw = require_param!(params, "method").clone();
221    if method_raw.is_empty() || !method_raw.chars().all(|c| c.is_ascii_lowercase()) {
222        return Err(MppError::invalid_challenge_reason(format!(
223            "Invalid method: \"{}\". Must match method-name ABNF.",
224            method_raw
225        )));
226    }
227    let method = MethodName::new(method_raw);
228    let intent = IntentName::new(require_param!(params, "intent"));
229    let request_b64 = require_param!(params, "request").clone();
230
231    let request_bytes = base64url_decode(&request_b64)?;
232    // Validate that the decoded bytes are valid JSON (matches TS SDK behavior)
233    let _ = serde_json::from_slice::<serde_json::Value>(&request_bytes).map_err(|e| {
234        MppError::invalid_challenge_reason(format!("Invalid JSON in request field: {}", e))
235    })?;
236    let request = Base64UrlJson::from_raw(request_b64);
237
238    let digest = params.get("digest").cloned();
239    if let Some(ref d) = digest {
240        if !is_valid_digest_format(d) {
241            return Err(MppError::invalid_challenge_reason("Invalid digest format"));
242        }
243    }
244
245    Ok(PaymentChallenge {
246        id,
247        realm,
248        method,
249        intent,
250        request,
251        expires: params.get("expires").cloned(),
252        description: params.get("description").cloned(),
253        digest,
254        opaque: params.get("opaque").map(Base64UrlJson::from_raw),
255    })
256}
257
258/// Parse all WWW-Authenticate headers that use the Payment scheme.
259///
260/// Returns a Vec of Results - one for each Payment header found.
261/// Non-Payment headers are skipped.
262///
263/// # Examples
264///
265/// ```
266/// use mpp::protocol::core::parse_www_authenticate_all;
267///
268/// let headers = vec![
269///     "Bearer token",
270///     "Payment id=\"abc\", realm=\"api\", method=\"tempo\", intent=\"charge\", request=\"e30\"",
271///     "Payment id=\"def\", realm=\"api\", method=\"base\", intent=\"charge\", request=\"e30\"",
272/// ];
273/// let challenges = parse_www_authenticate_all(headers);
274/// assert_eq!(challenges.len(), 2);
275/// ```
276pub fn parse_www_authenticate_all<'a>(
277    headers: impl IntoIterator<Item = &'a str>,
278) -> Vec<Result<PaymentChallenge>> {
279    headers
280        .into_iter()
281        .filter(|h| {
282            h.trim_start()
283                .get(..8)
284                .is_some_and(|s| s.eq_ignore_ascii_case("payment "))
285        })
286        .map(parse_www_authenticate)
287        .collect()
288}
289
290/// Format a PaymentChallenge as a WWW-Authenticate header value.
291///
292/// Format: `Payment id="<id>", realm="<realm>", method="<method>", intent="<intent>", request="<base64url-json>"`
293///
294/// # Examples
295///
296/// ```
297/// use mpp::protocol::core::{PaymentChallenge, format_www_authenticate};
298/// use mpp::protocol::core::types::Base64UrlJson;
299///
300/// let challenge = PaymentChallenge {
301///     id: "abc123".to_string(),
302///     realm: "api".to_string(),
303///     method: "tempo".into(),
304///     intent: "charge".into(),
305///     request: Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap(),
306///     expires: None,
307///     description: None,
308///     digest: None,
309///     opaque: None,
310/// };
311/// let header = format_www_authenticate(&challenge).unwrap();
312/// assert!(header.starts_with("Payment id=\"abc123\""));
313/// ```
314pub fn format_www_authenticate(challenge: &PaymentChallenge) -> Result<String> {
315    // Escape all quoted values to prevent header injection
316    let mut parts = vec![
317        format!("id=\"{}\"", escape_quoted_value(&challenge.id)?),
318        format!("realm=\"{}\"", escape_quoted_value(&challenge.realm)?),
319        format!(
320            "method=\"{}\"",
321            escape_quoted_value(challenge.method.as_str())?
322        ),
323        format!(
324            "intent=\"{}\"",
325            escape_quoted_value(challenge.intent.as_str())?
326        ),
327        format!(
328            "request=\"{}\"",
329            escape_quoted_value(challenge.request.raw())?
330        ),
331    ];
332
333    if let Some(ref expires) = challenge.expires {
334        parts.push(format!("expires=\"{}\"", escape_quoted_value(expires)?));
335    }
336
337    if let Some(ref description) = challenge.description {
338        parts.push(format!(
339            "description=\"{}\"",
340            escape_quoted_value(description)?
341        ));
342    }
343
344    if let Some(ref digest) = challenge.digest {
345        parts.push(format!("digest=\"{}\"", escape_quoted_value(digest)?));
346    }
347
348    if let Some(ref opaque) = challenge.opaque {
349        parts.push(format!("opaque=\"{}\"", escape_quoted_value(opaque.raw())?));
350    }
351
352    Ok(format!("Payment {}", parts.join(", ")))
353}
354
355/// Format multiple challenges as WWW-Authenticate header values.
356///
357/// Per spec, servers can send multiple headers with different payment options.
358///
359/// # Examples
360///
361/// ```
362/// use mpp::protocol::core::{PaymentChallenge, format_www_authenticate_many};
363/// use mpp::protocol::core::types::Base64UrlJson;
364///
365/// let challenge = PaymentChallenge {
366///     id: "abc123".to_string(),
367///     realm: "api".to_string(),
368///     method: "tempo".into(),
369///     intent: "charge".into(),
370///     request: Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap(),
371///     expires: None,
372///     description: None,
373///     digest: None,
374///     opaque: None,
375/// };
376/// let headers = format_www_authenticate_many(&[challenge]).unwrap();
377/// assert_eq!(headers.len(), 1);
378/// ```
379pub fn format_www_authenticate_many(challenges: &[PaymentChallenge]) -> Result<Vec<String>> {
380    challenges.iter().map(format_www_authenticate).collect()
381}
382
383/// Parse an Authorization header into a PaymentCredential.
384///
385/// Format: `Payment <base64url-json>`
386pub fn parse_authorization(header: &str) -> Result<PaymentCredential> {
387    let payment_part = extract_payment_scheme(header).ok_or_else(|| {
388        MppError::invalid_challenge_reason("Expected 'Payment' scheme".to_string())
389    })?;
390
391    // Strip "Payment " prefix to get the token
392    let token = payment_part.get(8..).unwrap_or("").trim();
393
394    // Enforce size limit to prevent memory exhaustion DoS
395    if token.len() > MAX_TOKEN_LEN {
396        return Err(MppError::invalid_challenge_reason(format!(
397            "Token exceeds maximum length of {} bytes",
398            MAX_TOKEN_LEN
399        )));
400    }
401
402    let decoded = base64url_decode(token)?;
403    let credential: PaymentCredential = serde_json::from_slice(&decoded).map_err(|e| {
404        MppError::invalid_challenge_reason(format!("Invalid credential JSON: {}", e))
405    })?;
406
407    if let Some(ref d) = credential.challenge.digest {
408        if !is_valid_digest_format(d) {
409            return Err(MppError::invalid_challenge_reason("Invalid digest format"));
410        }
411    }
412
413    Ok(credential)
414}
415
416/// Format a PaymentCredential as an Authorization header value.
417///
418/// Format: `Payment <base64url-json>`
419pub fn format_authorization(credential: &PaymentCredential) -> Result<String> {
420    let json = serde_json::to_string(credential)?;
421    let encoded = base64url_encode(json.as_bytes());
422    Ok(format!("Payment {}", encoded))
423}
424
425/// Parse a Payment-Receipt header into a Receipt.
426///
427/// Format: `<base64url-json>`
428pub fn parse_receipt(header: &str) -> Result<Receipt> {
429    let token = header.trim();
430
431    // Enforce size limit to prevent memory exhaustion DoS
432    if token.len() > MAX_TOKEN_LEN {
433        return Err(MppError::invalid_challenge_reason(format!(
434            "Receipt exceeds maximum length of {} bytes",
435            MAX_TOKEN_LEN
436        )));
437    }
438
439    let decoded = base64url_decode(token)?;
440    let receipt: Receipt = serde_json::from_slice(&decoded)
441        .map_err(|e| MppError::invalid_challenge_reason(format!("Invalid receipt JSON: {}", e)))?;
442
443    if !is_iso8601_timestamp(&receipt.timestamp) {
444        return Err(MppError::invalid_challenge_reason(
445            "Invalid timestamp format: expected ISO 8601".to_string(),
446        ));
447    }
448
449    Ok(receipt)
450}
451
452/// Format a Receipt as a Payment-Receipt header value.
453///
454/// Format: `<base64url-json>`
455pub fn format_receipt(receipt: &Receipt) -> Result<String> {
456    let json = serde_json::to_string(receipt)?;
457    Ok(base64url_encode(json.as_bytes()))
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463    use crate::protocol::core::types::{PayloadType, ReceiptStatus};
464    use crate::protocol::core::PaymentPayload;
465
466    fn test_challenge() -> PaymentChallenge {
467        PaymentChallenge {
468            id: "abc123".to_string(),
469            realm: "api".to_string(),
470            method: "tempo".into(),
471            intent: "charge".into(),
472            request: Base64UrlJson::from_value(&serde_json::json!({
473                "amount": "10000",
474                "currency": "0x123"
475            }))
476            .unwrap(),
477            expires: Some("2024-01-01T00:00:00Z".to_string()),
478            description: None,
479            digest: None,
480            opaque: None,
481        }
482    }
483
484    #[test]
485    fn test_parse_www_authenticate() {
486        let challenge = test_challenge();
487        let header = format_www_authenticate(&challenge).unwrap();
488        let parsed = parse_www_authenticate(&header).unwrap();
489
490        assert_eq!(parsed.id, "abc123");
491        assert_eq!(parsed.realm, "api");
492        assert_eq!(parsed.method.as_str(), "tempo");
493        assert_eq!(parsed.intent.as_str(), "charge");
494        assert_eq!(parsed.expires, Some("2024-01-01T00:00:00Z".to_string()));
495
496        // Verify request decodes correctly
497        let request: serde_json::Value = parsed.request.decode_value().unwrap();
498        assert_eq!(request["amount"], "10000");
499    }
500
501    #[test]
502    fn test_parse_www_authenticate_case_insensitive() {
503        let header =
504            r#"payment id="test", realm="api", method="tempo", intent="charge", request="e30""#;
505        let parsed = parse_www_authenticate(header).unwrap();
506        assert_eq!(parsed.id, "test");
507
508        let header2 =
509            r#"PAYMENT id="test2", realm="api", method="tempo", intent="charge", request="e30""#;
510        let parsed2 = parse_www_authenticate(header2).unwrap();
511        assert_eq!(parsed2.id, "test2");
512    }
513
514    #[test]
515    fn test_parse_www_authenticate_leading_whitespace() {
516        let header =
517            r#"  Payment id="test", realm="api", method="tempo", intent="charge", request="e30""#;
518        let parsed = parse_www_authenticate(header).unwrap();
519        assert_eq!(parsed.id, "test");
520    }
521
522    #[test]
523    fn test_parse_www_authenticate_with_description() {
524        let mut challenge = test_challenge();
525        challenge.description = Some("Pay \"here\" now".to_string());
526        let header = format_www_authenticate(&challenge).unwrap();
527
528        assert!(header.contains("description=\"Pay \\\"here\\\" now\""));
529
530        let parsed = parse_www_authenticate(&header).unwrap();
531        assert_eq!(parsed.description, Some("Pay \"here\" now".to_string()));
532    }
533
534    #[test]
535    fn test_parse_www_authenticate_all() {
536        let headers = vec![
537            "Bearer token",
538            r#"Payment id="a", realm="api", method="tempo", intent="charge", request="e30""#,
539            "Basic xyz",
540            r#"Payment id="b", realm="api", method="base", intent="charge", request="e30""#,
541        ];
542
543        let results = parse_www_authenticate_all(headers);
544        assert_eq!(results.len(), 2);
545
546        let first = results[0].as_ref().unwrap();
547        assert_eq!(first.id, "a");
548
549        let second = results[1].as_ref().unwrap();
550        assert_eq!(second.id, "b");
551    }
552
553    #[test]
554    fn test_format_www_authenticate_many() {
555        let c1 = test_challenge();
556        let mut c2 = test_challenge();
557        c2.id = "def456".to_string();
558        c2.method = "base".into();
559
560        let headers = format_www_authenticate_many(&[c1, c2]).unwrap();
561        assert_eq!(headers.len(), 2);
562        assert!(headers[0].contains("abc123"));
563        assert!(headers[1].contains("def456"));
564    }
565
566    #[test]
567    fn test_parse_authorization() {
568        let challenge = test_challenge();
569        let credential = PaymentCredential::with_source(
570            challenge.to_echo(),
571            "did:pkh:eip155:42431:0x123",
572            PaymentPayload::transaction("0xabc"),
573        );
574
575        let header = format_authorization(&credential).unwrap();
576        let parsed = parse_authorization(&header).unwrap();
577
578        assert_eq!(parsed.challenge.id, "abc123");
579        assert_eq!(
580            parsed.source,
581            Some("did:pkh:eip155:42431:0x123".to_string())
582        );
583        let charge_payload: PaymentPayload = parsed.charge_payload().unwrap();
584        assert_eq!(charge_payload.signed_tx(), Some("0xabc"));
585        assert_eq!(charge_payload.payload_type(), PayloadType::Transaction);
586    }
587
588    #[test]
589    fn test_parse_receipt() {
590        let receipt = Receipt {
591            status: ReceiptStatus::Success,
592            method: "tempo".into(),
593            timestamp: "2024-01-01T00:00:00Z".to_string(),
594            reference: "0xabc123".to_string(),
595        };
596
597        let header = format_receipt(&receipt).unwrap();
598        let parsed = parse_receipt(&header).unwrap();
599
600        assert_eq!(parsed.status, ReceiptStatus::Success);
601        assert_eq!(parsed.method.as_str(), "tempo");
602        assert_eq!(parsed.reference, "0xabc123");
603    }
604
605    #[test]
606    fn test_parse_invalid_scheme() {
607        let result = parse_www_authenticate("Basic realm=\"test\"");
608        assert!(result.is_err());
609    }
610
611    #[test]
612    fn test_parse_missing_required_field() {
613        let result = parse_www_authenticate("Payment id=\"abc\", realm=\"api\"");
614        assert!(result.is_err());
615    }
616
617    #[test]
618    fn test_parse_authorization_missing_payment_scheme() {
619        let result = parse_authorization("Bearer abc123");
620        assert!(result.is_err());
621    }
622
623    #[test]
624    fn test_parse_authorization_invalid_base64url() {
625        let result = parse_authorization("Payment !");
626        assert!(result.is_err());
627    }
628
629    #[test]
630    fn test_parse_authorization_invalid_json() {
631        let token = base64url_encode(b"not valid json");
632        let result = parse_authorization(&format!("Payment {}", token));
633        assert!(result.is_err());
634    }
635
636    #[test]
637    fn test_parse_authorization_missing_challenge_fields() {
638        let json = r#"{"challenge":{"id":"abc"},"payload":{}}"#;
639        let token = base64url_encode(json.as_bytes());
640        let result = parse_authorization(&format!("Payment {}", token));
641        assert!(result.is_err());
642    }
643
644    #[test]
645    fn test_credential_roundtrip_with_optional_fields() {
646        let mut challenge = test_challenge();
647        challenge.expires = Some("2025-06-01T00:00:00Z".to_string());
648        challenge.digest = Some("sha-256=abc123".to_string());
649
650        let credential = PaymentCredential::with_source(
651            challenge.to_echo(),
652            "did:pkh:eip155:42431:0x123",
653            PaymentPayload::transaction("0xabc"),
654        );
655
656        let header = format_authorization(&credential).unwrap();
657        let parsed = parse_authorization(&header).unwrap();
658
659        assert_eq!(
660            parsed.challenge.expires,
661            Some("2025-06-01T00:00:00Z".to_string())
662        );
663        assert_eq!(parsed.challenge.digest, Some("sha-256=abc123".to_string()));
664    }
665
666    #[test]
667    fn test_credential_roundtrip_without_source() {
668        let challenge = test_challenge();
669        let credential =
670            PaymentCredential::new(challenge.to_echo(), PaymentPayload::transaction("0xabc"));
671
672        let header = format_authorization(&credential).unwrap();
673        let parsed = parse_authorization(&header).unwrap();
674
675        assert!(parsed.source.is_none());
676    }
677
678    #[test]
679    fn test_parse_receipt_invalid_status() {
680        let json = r#"{"status":"failed","method":"tempo","timestamp":"2024-01-01T00:00:00Z","reference":"0xabc"}"#;
681        let token = base64url_encode(json.as_bytes());
682        let result = parse_receipt(&token);
683        assert!(result.is_err());
684    }
685
686    #[test]
687    fn test_parse_authorization_invalid_digest_format() {
688        let mut challenge = test_challenge();
689        challenge.digest = Some("invalid-digest-format".to_string());
690
691        let credential = PaymentCredential::with_source(
692            challenge.to_echo(),
693            "did:pkh:eip155:42431:0x123",
694            PaymentPayload::transaction("0xabc"),
695        );
696
697        // Manually serialize with the invalid digest intact
698        let json = serde_json::to_string(&credential).unwrap();
699        let token = base64url_encode(json.as_bytes());
700        let result = parse_authorization(&format!("Payment {}", token));
701        assert!(result.is_err());
702    }
703
704    #[test]
705    fn test_parse_authorization_rejects_non_sha256_digest() {
706        let mut challenge = test_challenge();
707        challenge.digest = Some("sha-512=abc123".to_string());
708
709        let credential = PaymentCredential::with_source(
710            challenge.to_echo(),
711            "did:pkh:eip155:42431:0x123",
712            PaymentPayload::transaction("0xabc"),
713        );
714
715        let json = serde_json::to_string(&credential).unwrap();
716        let token = base64url_encode(json.as_bytes());
717        let result = parse_authorization(&format!("Payment {}", token));
718        assert!(result.is_err());
719    }
720
721    #[test]
722    fn test_parse_www_authenticate_invalid_digest_format() {
723        let header = r#"Payment id="abc", realm="api", method="tempo", intent="charge", request="e30", digest="invalid-digest-format""#;
724        let result = parse_www_authenticate(header);
725        assert!(result.is_err());
726    }
727
728    #[test]
729    fn test_parse_www_authenticate_rejects_non_sha256_digest() {
730        let header = r#"Payment id="abc", realm="api", method="tempo", intent="charge", request="e30", digest="sha-512=abc""#;
731        let result = parse_www_authenticate(header);
732        assert!(result.is_err());
733    }
734
735    #[test]
736    fn test_parse_www_authenticate_invalid_request_json() {
737        // "not json" base64url-encoded is "bm90IGpzb24"
738        let header = r#"Payment id="abc", realm="api", method="tempo", intent="charge", request="bm90IGpzb24""#;
739        let result = parse_www_authenticate(header);
740        assert!(result.is_err());
741    }
742
743    #[test]
744    fn test_roundtrip_preserves_request() {
745        let original_request = serde_json::json!({
746            "amount": "5000",
747            "currency": "0xabc",
748            "nested": {"key": "value"}
749        });
750        let mut challenge = test_challenge();
751        challenge.request = Base64UrlJson::from_value(&original_request).unwrap();
752
753        let header = format_www_authenticate(&challenge).unwrap();
754        let parsed = parse_www_authenticate(&header).unwrap();
755
756        // The raw b64 should be preserved exactly
757        assert_eq!(parsed.request.raw(), challenge.request.raw());
758
759        // And should decode to the same value
760        let decoded: serde_json::Value = parsed.request.decode_value().unwrap();
761        assert_eq!(decoded, original_request);
762    }
763
764    #[test]
765    fn test_extract_payment_scheme_single() {
766        let header = "Payment eyJhYmMi";
767        let result = extract_payment_scheme(header);
768        assert!(result.is_some());
769        assert!(result.unwrap().starts_with("Payment "));
770    }
771
772    #[test]
773    fn test_extract_payment_scheme_mixed() {
774        let header = "Bearer token123, Payment eyJhYmMi";
775        let result = extract_payment_scheme(header);
776        assert!(result.is_some());
777        assert_eq!(result.unwrap(), "Payment eyJhYmMi");
778    }
779
780    #[test]
781    fn test_extract_payment_scheme_not_found() {
782        assert!(extract_payment_scheme("Bearer token123").is_none());
783        assert!(extract_payment_scheme("Basic abc123").is_none());
784    }
785
786    #[test]
787    fn test_extract_payment_scheme_case_insensitive() {
788        let header = "Bearer xxx, payment eyJhYmMi";
789        let result = extract_payment_scheme(header);
790        assert!(result.is_some());
791    }
792
793    #[test]
794    fn test_parse_authorization_mixed_schemes() {
795        let challenge = test_challenge();
796        let credential = PaymentCredential::with_source(
797            challenge.to_echo(),
798            "did:pkh:eip155:42431:0x123",
799            PaymentPayload::transaction("0xabc"),
800        );
801        let formatted = format_authorization(&credential).unwrap();
802
803        // Prepend a Bearer scheme to simulate mixed Authorization
804        let mixed = format!("Bearer some-token, {}", formatted);
805        let parsed = parse_authorization(&mixed).unwrap();
806        assert_eq!(parsed.challenge.id, "abc123");
807    }
808
809    #[test]
810    fn test_parse_www_authenticate_rejects_duplicate_params() {
811        let header = r#"Payment id="a", realm="api", method="tempo", intent="charge", request="e30", id="b""#;
812        let err = parse_www_authenticate(header).unwrap_err();
813        assert!(err.to_string().contains("Duplicate parameter"));
814    }
815
816    #[test]
817    fn test_parse_www_authenticate_rejects_empty_id() {
818        let header =
819            r#"Payment id="", realm="api", method="tempo", intent="charge", request="e30""#;
820        let err = parse_www_authenticate(header).unwrap_err();
821        assert!(err.to_string().contains("Empty 'id'"));
822    }
823
824    #[test]
825    fn test_parse_www_authenticate_rejects_invalid_method_name_dash() {
826        let header =
827            r#"Payment id="abc", realm="api", method="tempo-v2", intent="charge", request="e30""#;
828        let err = parse_www_authenticate(header).unwrap_err();
829        assert!(err.to_string().contains("Invalid method"));
830    }
831
832    #[test]
833    fn test_parse_www_authenticate_rejects_invalid_method_name_digit_prefix() {
834        let header =
835            r#"Payment id="abc", realm="api", method="1tempo", intent="charge", request="e30""#;
836        let err = parse_www_authenticate(header).unwrap_err();
837        assert!(err.to_string().contains("Invalid method"));
838    }
839
840    #[test]
841    fn test_parse_www_authenticate_rejects_mixed_case_method_name() {
842        let header =
843            r#"Payment id="abc", realm="api", method="Tempo", intent="charge", request="e30""#;
844        let err = parse_www_authenticate(header).unwrap_err();
845        assert!(err.to_string().contains("Invalid method"));
846    }
847
848    #[test]
849    fn test_parse_www_authenticate_accepts_standard_base64_request() {
850        // Reproduces real-world interop issue: server sends the `request`
851        // field as standard base64 ('+', '/', '=' padding) instead of
852        // base64url (no padding). The parser should accept both variants,
853        // matching the mppx TypeScript SDK behavior.
854        use base64::engine::general_purpose::STANDARD;
855        use base64::Engine as _;
856
857        let payload = r#"{"amount":"94","currency":"0x20c000000000000000000000b9537d11c60e8b50","methodDetails":{"chainId":4217},"recipient":"0x8A739f3A6f40194C0128904bC387e63d9C0577A4"}"#;
858        let request_b64 = STANDARD.encode(payload.as_bytes());
859        // Verify it has padding
860        assert!(request_b64.ends_with('='));
861
862        let header = format!(
863            r#"Payment id="test-123", realm="mpp-hosting", method="tempo", intent="charge", request="{request_b64}", description="VPS provisioning", expires="2026-03-24T21:20:34Z""#,
864        );
865        let challenge = parse_www_authenticate(&header).unwrap();
866        assert_eq!(challenge.id, "test-123");
867        assert_eq!(challenge.method.to_string(), "tempo");
868        assert_eq!(challenge.intent.to_string(), "charge");
869
870        let decoded: serde_json::Value = challenge.request.decode().unwrap();
871        assert_eq!(decoded["amount"], "94");
872    }
873
874    #[test]
875    fn test_parse_receipt_rejects_non_iso8601_timestamp() {
876        // {"method":"tempo","reference":"0xabc","status":"success","timestamp":"Jan 29 2026 12:00"}
877        // base64url encoded
878        let wire = "eyJtZXRob2QiOiJ0ZW1wbyIsInJlZmVyZW5jZSI6IjB4YWJjIiwic3RhdHVzIjoic3VjY2VzcyIsInRpbWVzdGFtcCI6IkphbiAyOSAyMDI2IDEyOjAwIn0";
879        let err = parse_receipt(wire).unwrap_err();
880        assert!(err.to_string().contains("timestamp"));
881    }
882}