use super::auth_params::{escape_quoted_value, Tokenizer};
use super::challenge::PaymentChallenge;
use super::credential::PaymentCredential;
use super::receipt::Receipt;
use super::types::{base64url_decode, base64url_encode, Base64UrlJson, IntentName, MethodName};
use crate::error::{MppError, Result};
use std::borrow::Cow;
use std::collections::HashMap;
const MAX_TOKEN_LEN: usize = 16 * 1024;
macro_rules! require_param {
($params:expr, $key:literal) => {
$params.get($key).ok_or_else(|| {
MppError::invalid_challenge_reason(format!("Missing '{}' field", $key))
})?
};
}
pub fn extract_payment_scheme(header: &str) -> Option<&str> {
let mut tokens = Tokenizer::new(header);
while let Some(Ok(token)) = tokens.next() {
if token.is_payment_scheme() {
if let Some(credentials) = tokens.credentials() {
return Some(credentials);
}
}
}
None
}
pub const WWW_AUTHENTICATE_HEADER: &str = "www-authenticate";
pub const AUTHORIZATION_HEADER: &str = "authorization";
pub const PAYMENT_AUTHORIZATION_HEADER: &str = "Payment-Authorization";
pub const PAYMENT_RECEIPT_HEADER: &str = "payment-receipt";
pub const PAYMENT_SCHEME: &str = "Payment";
pub fn with_private_cache_control(value: Option<&str>) -> String {
match value {
Some(v) if !v.trim().is_empty() => {
let has_private = v
.split(',')
.any(|directive| directive.trim().eq_ignore_ascii_case("private"));
if has_private {
v.to_string()
} else {
format!("{v}, private")
}
}
_ => "private".to_string(),
}
}
fn is_iso8601_timestamp(s: &str) -> bool {
time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).is_ok()
}
fn is_valid_digest_format(d: &str) -> bool {
let Some(value) = d.strip_prefix("sha-256=") else {
return false;
};
let value = value
.strip_prefix(':')
.and_then(|value| value.strip_suffix(':'))
.unwrap_or(value);
!value.is_empty()
&& value.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'-' | b'_' | b'=')
})
}
fn is_valid_intent_name(value: &str) -> bool {
!value.is_empty()
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
}
fn is_valid_method_name(value: &str) -> bool {
let mut chars = value.chars();
matches!(chars.next(), Some(first) if first.is_ascii_lowercase())
&& chars.all(|character| {
character.is_ascii_lowercase()
|| character.is_ascii_digit()
|| matches!(character, ':' | '_' | '-')
})
}
fn validate_request(request_b64: &str) -> Result<()> {
let request_bytes = base64url_decode(request_b64)?;
serde_json::from_slice::<serde_json::Map<String, serde_json::Value>>(&request_bytes).map_err(
|e| MppError::invalid_challenge_reason(format!("Invalid JSON in request field: {}", e)),
)?;
Ok(())
}
pub(super) fn validate_challenge_fields(
id: &str,
intent: &str,
request: &str,
expires: Option<&str>,
digest: Option<&str>,
opaque: Option<&str>,
) -> Result<()> {
if id.is_empty() {
return Err(MppError::invalid_challenge_reason(
"Empty 'id' parameter".to_string(),
));
}
if !is_valid_intent_name(intent) {
return Err(MppError::invalid_challenge_reason(format!(
"Invalid intent: \"{}\".",
intent
)));
}
validate_request(request)?;
if digest.is_some_and(|digest| !is_valid_digest_format(digest)) {
return Err(MppError::invalid_challenge_reason("Invalid digest format"));
}
if expires.is_some_and(|expires| !is_iso8601_timestamp(expires)) {
return Err(MppError::invalid_challenge_reason(
"Invalid expires timestamp",
));
}
if opaque.is_some_and(|opaque| base64url_decode(opaque).is_err()) {
return Err(MppError::invalid_challenge_reason(
"Invalid opaque: expected base64url",
));
}
Ok(())
}
pub fn parse_www_authenticate(header: &str) -> Result<PaymentChallenge> {
let mut tokens = Tokenizer::new(header);
match tokens.next() {
Some(Ok(token)) if token.is_payment_scheme() => read_challenge(&mut tokens),
_ => Err(MppError::invalid_challenge_reason(
"Expected 'Payment' scheme".to_string(),
)),
}
}
fn read_challenge(tokens: &mut Tokenizer<'_>) -> Result<PaymentChallenge> {
let mut params = HashMap::new();
while let Some((name, value)) = tokens.param()? {
let key = name.to_ascii_lowercase();
if key == "request" && value.len() > MAX_TOKEN_LEN {
return Err(MppError::invalid_challenge_reason(format!(
"Request parameter exceeds maximum length of {} bytes",
MAX_TOKEN_LEN
)));
}
if params.contains_key(&key) {
return Err(MppError::invalid_challenge_reason(format!(
"Duplicate parameter: {}",
name
)));
}
params.insert(key, value.into_owned());
}
let id = require_param!(params, "id").clone();
let realm = require_param!(params, "realm").clone();
let method_raw = require_param!(params, "method").clone();
if !is_valid_method_name(&method_raw) {
return Err(MppError::invalid_challenge_reason(format!(
"Invalid method: \"{}\". Must match method-name ABNF.",
method_raw
)));
}
let method = MethodName::new(method_raw);
let intent = IntentName::from_wire(require_param!(params, "intent"));
let request = Base64UrlJson::from_raw(require_param!(params, "request"));
let digest = params.get("digest").cloned();
let expires = params.get("expires").cloned();
let opaque = params.get("opaque").map(Base64UrlJson::from_raw);
validate_challenge_fields(
&id,
intent.as_str(),
request.raw(),
expires.as_deref(),
digest.as_deref(),
opaque.as_ref().map(Base64UrlJson::raw),
)?;
Ok(PaymentChallenge {
id,
realm,
method,
intent,
request,
expires,
description: params.get("description").cloned(),
digest,
opaque,
header: super::parse_advertised_credential_header(
params.get("header").map(String::as_str),
)?,
})
}
pub fn parse_www_authenticate_all<'a>(
headers: impl IntoIterator<Item = &'a str>,
) -> Vec<Result<PaymentChallenge>> {
headers.into_iter().flat_map(parse_challenges).collect()
}
pub fn parse_www_authenticate_all_bytes<'a>(
headers: impl IntoIterator<Item = &'a [u8]>,
) -> Vec<Result<PaymentChallenge>> {
headers
.into_iter()
.flat_map(|value| parse_challenges(&decode_latin1(value)))
.collect()
}
fn parse_challenges(header: &str) -> Vec<Result<PaymentChallenge>> {
let mut tokens = Tokenizer::new(header);
let mut challenges = Vec::new();
while let Some(Ok(token)) = tokens.next() {
if token.is_payment_scheme() {
challenges.push(read_challenge(&mut tokens));
}
}
challenges
}
fn decode_latin1(value: &[u8]) -> Cow<'_, str> {
match std::str::from_utf8(value) {
Ok(ascii) if ascii.is_ascii() => Cow::Borrowed(ascii),
_ => Cow::Owned(value.iter().copied().map(char::from).collect()),
}
}
pub fn format_www_authenticate(challenge: &PaymentChallenge) -> Result<String> {
if !is_valid_method_name(challenge.method.as_str()) {
return Err(MppError::invalid_challenge_reason(format!(
"Invalid method: \"{}\". Must match method-name ABNF.",
challenge.method
)));
}
validate_challenge_fields(
&challenge.id,
challenge.intent.as_str(),
challenge.request.raw(),
challenge.expires.as_deref(),
challenge.digest.as_deref(),
challenge.opaque.as_ref().map(Base64UrlJson::raw),
)?;
let mut parts = vec![
format!("id=\"{}\"", escape_quoted_value(&challenge.id)?),
format!("realm=\"{}\"", escape_quoted_value(&challenge.realm)?),
format!(
"method=\"{}\"",
escape_quoted_value(challenge.method.as_str())?
),
format!(
"intent=\"{}\"",
escape_quoted_value(challenge.intent.as_str())?
),
format!(
"request=\"{}\"",
escape_quoted_value(challenge.request.raw())?
),
];
if let Some(ref expires) = challenge.expires {
parts.push(format!("expires=\"{}\"", escape_quoted_value(expires)?));
}
if let Some(ref description) = challenge.description {
parts.push(format!(
"description=\"{}\"",
escape_quoted_value(description)?
));
}
if let Some(ref digest) = challenge.digest {
parts.push(format!("digest=\"{}\"", escape_quoted_value(digest)?));
}
if let Some(header) = super::parse_advertised_credential_header(challenge.header.as_deref())? {
parts.push(format!("header=\"{}\"", escape_quoted_value(&header)?));
}
if let Some(ref opaque) = challenge.opaque {
parts.push(format!("opaque=\"{}\"", escape_quoted_value(opaque.raw())?));
}
Ok(format!("Payment {}", parts.join(", ")))
}
pub fn format_www_authenticate_many(challenges: &[PaymentChallenge]) -> Result<Vec<String>> {
challenges.iter().map(format_www_authenticate).collect()
}
pub fn parse_authorization(header: &str) -> Result<PaymentCredential> {
let payment_part = extract_payment_scheme(header)
.ok_or_else(|| MppError::malformed_credential("Expected 'Payment' scheme"))?;
let token = payment_part.get(8..).unwrap_or("").trim();
if token.len() > MAX_TOKEN_LEN {
return Err(MppError::malformed_credential(format!(
"Token exceeds maximum length of {} bytes",
MAX_TOKEN_LEN
)));
}
let decoded =
base64url_decode(token).map_err(|_| MppError::malformed_credential("Invalid base64url"))?;
let mut credential: PaymentCredential = serde_json::from_slice(&decoded)
.map_err(|e| MppError::malformed_credential(format!("Invalid credential JSON: {}", e)))?;
credential.challenge.header = super::parse_advertised_credential_header(
credential.challenge.header.as_deref(),
)
.map_err(|_| {
MppError::malformed_credential(
"Unsupported credential header: must be Payment-Authorization",
)
})?;
let echo = &credential.challenge;
validate_challenge_fields(
&echo.id,
echo.intent.as_str(),
echo.request.raw(),
echo.expires.as_deref(),
echo.digest.as_deref(),
echo.opaque.as_ref().map(Base64UrlJson::raw),
)
.map_err(|error| match error {
MppError::InvalidChallenge {
reason: Some(reason),
..
} => MppError::malformed_credential(reason),
error => MppError::malformed_credential(error.to_string()),
})?;
Ok(credential)
}
pub fn format_authorization(credential: &PaymentCredential) -> Result<String> {
let json = serde_json::to_string(credential)?;
let encoded = base64url_encode(json.as_bytes());
Ok(format!("Payment {}", encoded))
}
pub fn parse_receipt(header: &str) -> Result<Receipt> {
let token = header.trim();
if token.len() > MAX_TOKEN_LEN {
return Err(MppError::InvalidReceipt(format!(
"Receipt exceeds maximum length of {} bytes",
MAX_TOKEN_LEN
)));
}
let decoded = base64url_decode(token)?;
let receipt: Receipt = serde_json::from_slice(&decoded)
.map_err(|e| MppError::InvalidReceipt(format!("Invalid receipt JSON: {}", e)))?;
if !is_iso8601_timestamp(&receipt.timestamp) {
return Err(MppError::InvalidReceipt(
"Invalid timestamp format: expected ISO 8601".to_string(),
));
}
Ok(receipt)
}
pub fn format_receipt(receipt: &Receipt) -> Result<String> {
let json = serde_json::to_string(receipt)?;
Ok(base64url_encode(json.as_bytes()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::core::challenge::tests::test_challenge;
use crate::protocol::core::types::{PayloadType, ReceiptStatus};
use crate::protocol::core::PaymentPayload;
#[test]
fn test_with_private_cache_control() {
assert_eq!(with_private_cache_control(None), "private");
assert_eq!(with_private_cache_control(Some("")), "private");
assert_eq!(with_private_cache_control(Some(" ")), "private");
assert_eq!(
with_private_cache_control(Some("no-store")),
"no-store, private"
);
assert_eq!(
with_private_cache_control(Some("public, max-age=60")),
"public, max-age=60, private"
);
assert_eq!(with_private_cache_control(Some("private")), "private");
assert_eq!(
with_private_cache_control(Some("private, max-age=0")),
"private, max-age=0"
);
assert_eq!(with_private_cache_control(Some(" PRIVATE ")), " PRIVATE ");
}
#[test]
fn test_parse_www_authenticate() {
let challenge = test_challenge();
let header = format_www_authenticate(&challenge).unwrap();
let parsed = parse_www_authenticate(&header).unwrap();
assert_eq!(parsed.id, "abc123");
assert_eq!(parsed.realm, "api");
assert_eq!(parsed.method.as_str(), "tempo");
assert_eq!(parsed.intent.as_str(), "charge");
assert_eq!(parsed.expires, Some("2024-01-01T00:00:00Z".to_string()));
let request: serde_json::Value = parsed.request.decode_value().unwrap();
assert_eq!(request["amount"], "10000");
}
#[test]
fn test_parse_www_authenticate_rejects_invalid_expires_timestamp() {
let header = r#"Payment id="abc", realm="api", method="tempo", intent="charge", request="e30", expires="not-a-date""#;
let err = parse_www_authenticate(header).unwrap_err();
assert!(err.to_string().contains("Invalid expires timestamp"));
}
#[test]
fn test_parse_www_authenticate_case_insensitive() {
let header =
r#"payment id="test", realm="api", method="tempo", intent="charge", request="e30""#;
let parsed = parse_www_authenticate(header).unwrap();
assert_eq!(parsed.id, "test");
let header2 =
r#"PAYMENT id="test2", realm="api", method="tempo", intent="charge", request="e30""#;
let parsed2 = parse_www_authenticate(header2).unwrap();
assert_eq!(parsed2.id, "test2");
}
#[test]
fn test_parse_www_authenticate_leading_whitespace() {
let header =
r#" Payment id="test", realm="api", method="tempo", intent="charge", request="e30""#;
let parsed = parse_www_authenticate(header).unwrap();
assert_eq!(parsed.id, "test");
}
#[test]
fn test_parse_www_authenticate_with_description() {
let mut challenge = test_challenge();
challenge.description = Some("Pay \"here\" now".to_string());
let header = format_www_authenticate(&challenge).unwrap();
assert!(header.contains("description=\"Pay \\\"here\\\" now\""));
let parsed = parse_www_authenticate(&header).unwrap();
assert_eq!(parsed.description, Some("Pay \"here\" now".to_string()));
}
#[test]
fn test_parse_www_authenticate_rejects_unterminated_quoted_string() {
let header = r#"Payment id="abc", realm="api", method="tempo", intent="charge", request="e30", description="oops"#;
let err = parse_www_authenticate(header).unwrap_err();
assert!(err.to_string().contains("Unterminated quoted-string"));
}
#[test]
fn test_parse_www_authenticate_all() {
let headers = vec![
"Bearer token",
r#"Payment id="a", realm="api", method="tempo", intent="charge", request="e30""#,
"Basic xyz",
r#"Payment id="b", realm="api", method="base", intent="charge", request="e30""#,
];
let results = parse_www_authenticate_all(headers);
assert_eq!(results.len(), 2);
let first = results[0].as_ref().unwrap();
assert_eq!(first.id, "a");
let second = results[1].as_ref().unwrap();
assert_eq!(second.id, "b");
}
#[test]
fn test_parse_www_authenticate_all_merged() {
let merged = r#"Payment id="a", realm="api", method="tempo", intent="charge", request="e30", Payment id="b", realm="api", method="stripe", intent="charge", request="e30""#;
let results = parse_www_authenticate_all(vec![merged]);
assert_eq!(results.len(), 2);
assert_eq!(results[0].as_ref().unwrap().id, "a");
assert_eq!(results[0].as_ref().unwrap().method.as_str(), "tempo");
assert_eq!(results[1].as_ref().unwrap().id, "b");
assert_eq!(results[1].as_ref().unwrap().method.as_str(), "stripe");
}
#[test]
fn test_format_www_authenticate_many() {
let c1 = test_challenge();
let mut c2 = test_challenge();
c2.id = "def456".to_string();
c2.method = "base".into();
let headers = format_www_authenticate_many(&[c1, c2]).unwrap();
assert_eq!(headers.len(), 2);
assert!(headers[0].contains("abc123"));
assert!(headers[1].contains("def456"));
}
#[test]
fn test_parse_authorization() {
let challenge = test_challenge();
let credential = PaymentCredential::with_source(
challenge.to_echo(),
"did:pkh:eip155:42431:0x123",
PaymentPayload::transaction("0xabc"),
);
let header = format_authorization(&credential).unwrap();
let parsed = parse_authorization(&header).unwrap();
assert_eq!(parsed.challenge.id, "abc123");
assert_eq!(
parsed.source,
Some("did:pkh:eip155:42431:0x123".to_string())
);
let charge_payload: PaymentPayload = parsed.charge_payload().unwrap();
assert_eq!(charge_payload.signed_tx(), Some("0xabc"));
assert_eq!(charge_payload.payload_type(), PayloadType::Transaction);
}
#[test]
fn test_parse_receipt() {
let receipt = Receipt {
status: ReceiptStatus::Success,
method: "tempo".into(),
timestamp: "2024-01-01T00:00:00Z".to_string(),
reference: "0xabc123".to_string(),
external_id: None,
subscription_id: None,
extensions: serde_json::Map::new(),
};
let header = format_receipt(&receipt).unwrap();
let parsed = parse_receipt(&header).unwrap();
assert_eq!(parsed.status, ReceiptStatus::Success);
assert_eq!(parsed.method.as_str(), "tempo");
assert_eq!(parsed.reference, "0xabc123");
}
#[test]
fn test_receipt_subscription_id_round_trip() {
let receipt = Receipt {
status: ReceiptStatus::Success,
method: "tempo".into(),
timestamp: "2024-01-01T00:00:00Z".to_string(),
reference: "0xabc123".to_string(),
external_id: None,
subscription_id: Some("sub_123".to_string()),
extensions: serde_json::Map::new(),
};
let header = format_receipt(&receipt).unwrap();
let parsed = parse_receipt(&header).unwrap();
assert_eq!(parsed.subscription_id.as_deref(), Some("sub_123"));
}
#[test]
fn test_parse_receipt_preserves_foreign_subscription_id() {
let json = r#"{"status":"success","method":"tempo","timestamp":"2024-01-01T00:00:00Z","reference":"0xabc123","subscriptionId":"sub_123"}"#;
let header = base64url_encode(json.as_bytes());
let parsed = parse_receipt(&header).unwrap();
assert_eq!(parsed.subscription_id.as_deref(), Some("sub_123"));
}
#[test]
fn test_receipt_preserves_method_extension_fields() {
let json = r#"{"status":"success","method":"tempo","timestamp":"2024-01-01T00:00:00Z","reference":"0xabc123","originTxHash":"0xdef456"}"#;
let parsed = parse_receipt(&base64url_encode(json.as_bytes())).unwrap();
assert_eq!(parsed.extensions["originTxHash"], "0xdef456");
let reparsed = parse_receipt(&format_receipt(&parsed).unwrap()).unwrap();
assert_eq!(reparsed.extensions["originTxHash"], "0xdef456");
}
#[test]
fn test_parse_invalid_scheme() {
let result = parse_www_authenticate("Basic realm=\"test\"");
assert!(result.is_err());
}
#[test]
fn test_parse_missing_required_field() {
let result = parse_www_authenticate("Payment id=\"abc\", realm=\"api\"");
assert!(result.is_err());
}
#[test]
fn test_parse_authorization_missing_payment_scheme() {
let result = parse_authorization("Bearer abc123");
assert!(matches!(result, Err(MppError::MalformedCredential(_))));
}
#[test]
fn test_parse_authorization_invalid_base64url() {
let result = parse_authorization("Payment !");
assert!(matches!(result, Err(MppError::MalformedCredential(_))));
}
#[test]
fn test_parse_authorization_invalid_json() {
let token = base64url_encode(b"not valid json");
let result = parse_authorization(&format!("Payment {}", token));
assert!(matches!(result, Err(MppError::MalformedCredential(_))));
}
#[test]
fn test_parse_authorization_missing_challenge_fields() {
let json = r#"{"challenge":{"id":"abc"},"payload":{}}"#;
let token = base64url_encode(json.as_bytes());
let result = parse_authorization(&format!("Payment {}", token));
assert!(matches!(result, Err(MppError::MalformedCredential(_))));
}
#[test]
fn test_credential_roundtrip_with_optional_fields() {
let mut challenge = test_challenge();
challenge.expires = Some("2025-06-01T00:00:00Z".to_string());
challenge.digest = Some("sha-256=abc123".to_string());
let credential = PaymentCredential::with_source(
challenge.to_echo(),
"did:pkh:eip155:42431:0x123",
PaymentPayload::transaction("0xabc"),
);
let header = format_authorization(&credential).unwrap();
let parsed = parse_authorization(&header).unwrap();
assert_eq!(
parsed.challenge.expires,
Some("2025-06-01T00:00:00Z".to_string())
);
assert_eq!(parsed.challenge.digest, Some("sha-256=abc123".to_string()));
}
#[test]
fn test_credential_roundtrip_without_source() {
let challenge = test_challenge();
let credential =
PaymentCredential::new(challenge.to_echo(), PaymentPayload::transaction("0xabc"));
let header = format_authorization(&credential).unwrap();
let parsed = parse_authorization(&header).unwrap();
assert!(parsed.source.is_none());
}
#[test]
fn test_parse_receipt_invalid_status() {
let json = r#"{"status":"failed","method":"tempo","timestamp":"2024-01-01T00:00:00Z","reference":"0xabc"}"#;
let token = base64url_encode(json.as_bytes());
let result = parse_receipt(&token);
assert!(matches!(result, Err(MppError::InvalidReceipt(_))));
}
#[test]
fn test_parse_authorization_invalid_digest_format() {
let mut challenge = test_challenge();
challenge.digest = Some("invalid-digest-format".to_string());
let credential = PaymentCredential::with_source(
challenge.to_echo(),
"did:pkh:eip155:42431:0x123",
PaymentPayload::transaction("0xabc"),
);
let json = serde_json::to_string(&credential).unwrap();
let token = base64url_encode(json.as_bytes());
let result = parse_authorization(&format!("Payment {}", token));
assert!(matches!(result, Err(MppError::MalformedCredential(_))));
}
#[test]
fn test_parse_authorization_rejects_non_sha256_digest() {
let mut challenge = test_challenge();
challenge.digest = Some("sha-512=abc123".to_string());
let credential = PaymentCredential::with_source(
challenge.to_echo(),
"did:pkh:eip155:42431:0x123",
PaymentPayload::transaction("0xabc"),
);
let json = serde_json::to_string(&credential).unwrap();
let token = base64url_encode(json.as_bytes());
let result = parse_authorization(&format!("Payment {}", token));
assert!(matches!(result, Err(MppError::MalformedCredential(_))));
}
#[test]
fn test_parse_www_authenticate_invalid_digest_format() {
let header = r#"Payment id="abc", realm="api", method="tempo", intent="charge", request="e30", digest="invalid-digest-format""#;
let result = parse_www_authenticate(header);
assert!(result.is_err());
}
#[test]
fn test_parse_www_authenticate_rejects_non_sha256_digest() {
let header = r#"Payment id="abc", realm="api", method="tempo", intent="charge", request="e30", digest="sha-512=abc""#;
let result = parse_www_authenticate(header);
assert!(result.is_err());
}
#[test]
fn test_parse_www_authenticate_invalid_request_json() {
let header = r#"Payment id="abc", realm="api", method="tempo", intent="charge", request="bm90IGpzb24""#;
let result = parse_www_authenticate(header);
assert!(result.is_err());
}
#[test]
fn test_parse_www_authenticate_rejects_oversized_request_parameter() {
let oversized_request = "a".repeat(MAX_TOKEN_LEN + 1);
let header = format!(
r#"Payment id="abc", realm="api", method="tempo", intent="charge", request="{}""#,
oversized_request
);
let err = parse_www_authenticate(&header).unwrap_err();
assert!(err.to_string().contains("Request parameter exceeds"));
}
#[test]
fn test_parse_www_authenticate_accepts_request_parameter_at_the_limit() {
let json = format!(r#"{{"a":"{}"}}"#, "x".repeat(MAX_TOKEN_LEN / 4 * 3 - 8));
let request = base64url_encode(json.as_bytes());
assert_eq!(request.len(), MAX_TOKEN_LEN);
for value in [format!("\"{request}\""), request.clone()] {
let header = format!(
r#"Payment id="abc", realm="api", method="tempo", intent="charge", request={value}"#
);
let challenge = parse_www_authenticate(&header).unwrap();
assert_eq!(challenge.request.raw(), request);
}
}
#[test]
fn test_roundtrip_preserves_request() {
let original_request = serde_json::json!({
"amount": "5000",
"currency": "0xabc",
"nested": {"key": "value"}
});
let mut challenge = test_challenge();
challenge.request = Base64UrlJson::from_value(&original_request).unwrap();
let header = format_www_authenticate(&challenge).unwrap();
let parsed = parse_www_authenticate(&header).unwrap();
assert_eq!(parsed.request.raw(), challenge.request.raw());
let decoded: serde_json::Value = parsed.request.decode_value().unwrap();
assert_eq!(decoded, original_request);
}
#[test]
fn test_extract_payment_scheme_single() {
let header = "Payment eyJhYmMi";
let result = extract_payment_scheme(header);
assert!(result.is_some());
assert!(result.unwrap().starts_with("Payment "));
}
#[test]
fn test_extract_payment_scheme_mixed() {
let header = "Bearer token123, Payment eyJhYmMi";
let result = extract_payment_scheme(header);
assert!(result.is_some());
assert_eq!(result.unwrap(), "Payment eyJhYmMi");
}
#[test]
fn test_extract_payment_scheme_not_found() {
assert!(extract_payment_scheme("Bearer token123").is_none());
assert!(extract_payment_scheme("Basic abc123").is_none());
}
#[test]
fn test_extract_payment_scheme_case_insensitive() {
let header = "Bearer xxx, payment eyJhYmMi";
let result = extract_payment_scheme(header);
assert!(result.is_some());
}
#[test]
fn test_extract_payment_scheme_accepts_tab() {
assert_eq!(
extract_payment_scheme("Bearer xxx, pAyMeNt\teyJhYmMi"),
Some("pAyMeNt\teyJhYmMi")
);
assert!(extract_payment_scheme("PaymentX eyJhYmMi").is_none());
}
#[test]
fn test_extract_payment_scheme_ignores_quoted_text() {
assert_eq!(
extract_payment_scheme(r#"Digest username="a, Payment b", Payment abc=="#),
Some("Payment abc==")
);
assert!(extract_payment_scheme(r#"Digest username="a, Payment b""#).is_none());
}
#[test]
fn test_extract_payment_scheme_requires_a_token() {
assert!(extract_payment_scheme("Payment ").is_none());
assert_eq!(
extract_payment_scheme("Payment , Payment abc"),
Some("Payment abc")
);
}
#[test]
fn test_parse_authorization_mixed_schemes() {
let challenge = test_challenge();
let credential = PaymentCredential::with_source(
challenge.to_echo(),
"did:pkh:eip155:42431:0x123",
PaymentPayload::transaction("0xabc"),
);
let formatted = format_authorization(&credential).unwrap();
let mixed = format!("Bearer some-token, {}", formatted);
let parsed = parse_authorization(&mixed).unwrap();
assert_eq!(parsed.challenge.id, "abc123");
}
#[test]
fn test_parse_www_authenticate_rejects_duplicate_params() {
let header = r#"Payment id="a", realm="api", method="tempo", intent="charge", request="e30", id="b""#;
let err = parse_www_authenticate(header).unwrap_err();
assert!(err.to_string().contains("Duplicate parameter"));
}
#[test]
fn test_parse_www_authenticate_accepts_mixed_case_param_names() {
let header =
r#"Payment ID="abc123", Realm="api", Method="tempo", Intent="charge", Request="e30""#;
let challenge = parse_www_authenticate(header).unwrap();
assert_eq!(challenge.id, "abc123");
assert_eq!(challenge.realm, "api");
assert_eq!(challenge.method.as_str(), "tempo");
assert_eq!(challenge.intent.as_str(), "charge");
}
#[test]
fn test_parse_www_authenticate_rejects_case_variant_duplicate_params() {
let header = r#"Payment id="a", realm="api", method="tempo", intent="charge", request="e30", ID="b""#;
let err = parse_www_authenticate(header).unwrap_err();
assert!(err.to_string().contains("Duplicate parameter: ID"));
}
#[test]
fn test_parse_www_authenticate_rejects_empty_id() {
let header =
r#"Payment id="", realm="api", method="tempo", intent="charge", request="e30""#;
let err = parse_www_authenticate(header).unwrap_err();
assert!(err.to_string().contains("Empty 'id'"));
}
#[test]
fn test_parse_www_authenticate_accepts_canonical_method_names() {
for method in ["tempo", "x402", "tempo-v2", "a:b", "a_b", "a1:b_2-c"] {
let header = format!(
r#"Payment id="abc", realm="api", method="{method}", intent="charge", request="e30""#
);
let challenge = parse_www_authenticate(&header).unwrap();
assert_eq!(challenge.method.as_str(), method);
}
}
#[test]
fn test_parse_www_authenticate_rejects_invalid_method_name_digit_prefix() {
let header =
r#"Payment id="abc", realm="api", method="1tempo", intent="charge", request="e30""#;
let err = parse_www_authenticate(header).unwrap_err();
assert!(err.to_string().contains("Invalid method"));
}
#[test]
fn test_parse_www_authenticate_rejects_invalid_method_names() {
for method in [
"", "123", "-tempo", ":tempo", "_tempo", "*", "tempo!", "Tempo",
] {
let header = format!(
r#"Payment id="abc", realm="api", method="{method}", intent="charge", request="e30""#
);
let err = parse_www_authenticate(&header).unwrap_err();
assert!(err.to_string().contains("Invalid method"));
}
}
#[test]
fn test_parse_www_authenticate_decodes_unicode_escapes() {
for (escaped, expected) in [
(
r"em dash \u2014 and coffee \u2615",
"em dash \u{2014} and coffee \u{2615}",
),
(r"grinning \ud83d\ude00 face", "grinning \u{1f600} face"),
("café naïve", "café naïve"),
(r"lone \ud83d here", "lone \u{fffd} here"),
(r"lone \ude00 here", "lone \u{fffd} here"),
(r"not an escape \\u2014", r"not an escape \u2014"),
(r"short \u12 tail", "short u12 tail"),
(r"ascii api\u0061", "ascii apia"),
] {
let header = format!(
r#"Payment id="abc", realm="api", method="tempo", intent="charge", request="e30", description="{escaped}""#
);
let challenge = parse_www_authenticate(&header).unwrap();
assert_eq!(
challenge.description.as_deref(),
Some(expected),
"{escaped}"
);
}
}
#[test]
fn test_format_www_authenticate_escapes_unicode_as_utf16() {
let mut challenge = test_challenge();
challenge.description = Some("Payment \u{2014} coffee \u{2615} \u{1f600}".to_string());
let header = format_www_authenticate(&challenge).unwrap();
assert!(header.contains(r#"description="Payment \u2014 coffee \u2615 \ud83d\ude00""#));
let parsed = parse_www_authenticate(&header).unwrap();
assert_eq!(parsed.description, challenge.description);
assert_eq!(parsed.realm, challenge.realm);
assert_eq!(parsed.method, challenge.method);
assert_eq!(parsed.intent, challenge.intent);
}
#[test]
fn test_format_www_authenticate_emits_valid_header_values() {
for text in [
"caf\u{e9} \u{a3}5",
"bell\u{7}",
"\u{0}\u{1}\u{1f}\u{7f}\u{80}\u{ff}",
"tab\there",
"1 \u{d7} Classmatic \u{2014} General Admission \u{1f39f}\u{fe0f}",
] {
let mut challenge = test_challenge();
challenge.realm = text.to_string();
challenge.description = Some(text.to_string());
let header = format_www_authenticate(&challenge).unwrap();
assert!(
header
.bytes()
.all(|byte| byte == b'\t' || (0x20..=0x7e).contains(&byte)),
"{header:?}"
);
let value = axum::http::HeaderValue::from_str(&header).unwrap();
let parsed = parse_www_authenticate(value.to_str().unwrap()).unwrap();
assert_eq!(parsed.realm, text);
assert_eq!(parsed.description.as_deref(), Some(text));
}
}
#[test]
fn test_format_www_authenticate_rejects_line_breaks() {
for text in ["Line one\r\nLine two", "Line one\nLine two", "Line one\r"] {
let mut challenge = test_challenge();
challenge.description = Some(text.to_string());
assert!(format_www_authenticate(&challenge).is_err(), "{text:?}");
}
}
#[test]
fn test_format_www_authenticate_rejects_unparseable_challenges() {
let mut empty_id = test_challenge();
empty_id.id = String::new();
let mut invalid_method = test_challenge();
invalid_method.method = "a b".into();
let mut invalid_base64 = test_challenge();
invalid_base64.request = Base64UrlJson::from_raw("not-valid!!!");
let mut invalid_json = test_challenge();
invalid_json.request = Base64UrlJson::from_raw("bm90IGpzb24");
let mut invalid_intent = test_challenge();
invalid_intent.intent = "payment plan".into();
let mut request_not_object = test_challenge();
request_not_object.request = Base64UrlJson::from_raw("W10");
let mut invalid_opaque = test_challenge();
invalid_opaque.opaque = Some(Base64UrlJson::from_raw("a|b"));
for challenge in [
empty_id,
invalid_method,
invalid_base64,
invalid_json,
invalid_intent,
request_not_object,
invalid_opaque,
] {
assert!(
format_www_authenticate(&challenge).is_err(),
"{challenge:?}"
);
}
}
#[test]
fn test_parse_www_authenticate_accepts_standard_base64_request() {
use base64::engine::general_purpose::STANDARD;
use base64::Engine as _;
let payload = r#"{"amount":"94","currency":"0x20c000000000000000000000b9537d11c60e8b50","methodDetails":{"chainId":4217},"recipient":"0x8A739f3A6f40194C0128904bC387e63d9C0577A4"}"#;
let request_b64 = STANDARD.encode(payload.as_bytes());
assert!(request_b64.ends_with('='));
let header = format!(
r#"Payment id="test-123", realm="mpp-hosting", method="tempo", intent="charge", request="{request_b64}", description="VPS provisioning", expires="2026-03-24T21:20:34Z""#,
);
let challenge = parse_www_authenticate(&header).unwrap();
assert_eq!(challenge.id, "test-123");
assert_eq!(challenge.method.to_string(), "tempo");
assert_eq!(challenge.intent.to_string(), "charge");
let decoded: serde_json::Value = challenge.request.decode().unwrap();
assert_eq!(decoded["amount"], "94");
}
#[test]
fn test_parse_receipt_rejects_non_iso8601_timestamp() {
let wire = "eyJtZXRob2QiOiJ0ZW1wbyIsInJlZmVyZW5jZSI6IjB4YWJjIiwic3RhdHVzIjoic3VjY2VzcyIsInRpbWVzdGFtcCI6IkphbiAyOSAyMDI2IDEyOjAwIn0";
let err = parse_receipt(wire).unwrap_err();
assert!(matches!(err, MppError::InvalidReceipt(_)));
assert!(err.to_string().contains("timestamp"));
}
#[test]
fn test_parse_www_authenticate_all_multi_challenge() {
let header = concat!(
r#"Payment id="t1", realm="api", method="tempo", intent="charge", request="e30", "#,
r#"Payment id="s1", realm="api", method="stripe", intent="charge", request="e30""#,
);
let results = parse_www_authenticate_all(vec![header]);
assert_eq!(results.len(), 2);
assert_eq!(results[0].as_ref().unwrap().method.as_str(), "tempo");
assert_eq!(results[1].as_ref().unwrap().method.as_str(), "stripe");
}
#[test]
fn test_parse_www_authenticate_all_ignores_non_payment_schemes() {
let headers = vec![
"Bearer token123",
r#"Payment id="t1", realm="api", method="tempo", intent="charge", request="e30""#,
"Basic dXNlcjpwYXNz",
];
let results = parse_www_authenticate_all(headers);
assert_eq!(results.len(), 1);
assert_eq!(results[0].as_ref().unwrap().method.as_str(), "tempo");
let mixed = concat!(
"Bearer token123, ",
r#"Payment id="s1", realm="api", method="stripe", intent="charge", request="e30""#,
);
let results = parse_www_authenticate_all(vec![mixed]);
assert_eq!(results.len(), 1);
assert_eq!(results[0].as_ref().unwrap().method.as_str(), "stripe");
}
fn second_challenge_header() -> &'static str {
r#"Payment id="second", realm="api.example.com", method="tempo", intent="charge", request="e30""#
}
#[test]
fn test_parse_www_authenticate_all_ignores_scheme_like_text_in_quoted_values() {
type Field = (
fn(&mut PaymentChallenge, &str),
fn(&PaymentChallenge) -> &str,
);
let description: Field = (
|c, v| c.description = Some(v.to_string()),
|c| c.description.as_deref().unwrap(),
);
let id: Field = (|c, v| c.id = v.to_string(), |c| &c.id);
let realm: Field = (|c, v| c.realm = v.to_string(), |c| &c.realm);
let cases = [
(description, "Agentcash card payment test"),
(description, "Payment at the start"),
(description, r#"Use "Payment now", then retry \"#),
(description, "comma, Payment fake challenge"),
(id, "id with Payment text"),
(realm, "Payment realm"),
];
for ((set, get), value) in cases {
let mut first = test_challenge();
first.id = "first".to_string();
set(&mut first, value);
let header = format!(
"{}, {}",
format_www_authenticate(&first).unwrap(),
second_challenge_header()
);
let results = parse_www_authenticate_all([header.as_str()]);
assert_eq!(results.len(), 2, "{value:?}: {results:?}");
assert_eq!(get(results[0].as_ref().unwrap()), value);
assert_eq!(results[1].as_ref().unwrap().id, "second");
}
}
#[test]
fn test_parse_www_authenticate_all_among_other_schemes() {
let header = format!(
"Bearer error_description=\"use Payment challenge\", {}, \
Digest realm=\"fallback Payment realm\", {}",
r#"Payment id="first", realm="api.example.com", method="stripe", intent="charge", request="e30""#,
second_challenge_header().replacen("Payment ", "pAyMeNt\t", 1),
);
let ids: Vec<_> = parse_www_authenticate_all([header.as_str()])
.into_iter()
.map(|result| result.unwrap().id)
.collect();
assert_eq!(ids, ["first", "second"]);
}
#[test]
fn test_parse_www_authenticate_all_ignores_payment_inside_other_scheme() {
for header in [
r#"Bearer error_description="use Payment challenge""#,
r#"Bearer realm="x, Payment id=evil, realm=api, method=tempo, intent=charge, request=e30, x=y""#,
] {
assert!(parse_www_authenticate_all([header]).is_empty(), "{header}");
}
}
#[test]
fn test_parsers_agree_on_param_named_payment() {
let header = r#"Payment id="abc", payment ="x", realm="api", method="tempo", intent="charge", request="e30""#;
assert_eq!(parse_www_authenticate(header).unwrap().id, "abc");
let all = parse_www_authenticate_all([header]);
assert_eq!(all.len(), 1);
assert_eq!(all[0].as_ref().unwrap().id, "abc");
}
#[test]
fn test_parsers_agree_on_text_before_the_scheme() {
let challenge =
r#"Payment id="abc", realm="api", method="tempo", intent="charge", request="e30""#;
for (prefix, accepted) in [
(" \t", true),
(", ", true),
("\u{b}", false),
("\u{a0}", false),
] {
let header = format!("{prefix}{challenge}");
assert_eq!(
parse_www_authenticate(&header).is_ok(),
accepted,
"{header:?}"
);
assert_eq!(
parse_www_authenticate_all([header.as_str()]).len(),
usize::from(accepted),
"{header:?}"
);
}
}
#[test]
fn test_parse_www_authenticate_accepts_whitespace_around_equals() {
let header =
"Payment id = \"abc\", realm= \"api\", method =\"tempo\", intent\t=\t\"charge\", request = e30";
let parsed = parse_www_authenticate(header).unwrap();
assert_eq!(parsed.id, "abc");
assert_eq!(parsed.realm, "api");
assert_eq!(parsed.method.as_str(), "tempo");
assert_eq!(parsed.intent.as_str(), "charge");
assert_eq!(parsed.request.raw(), "e30");
}
#[test]
fn test_parse_www_authenticate_keeps_empty_trailing_param() {
for tail in ["description=", "description=,", "description=,\t"] {
let header = format!(
r#"Payment id="abc", realm="api", method="tempo", intent="charge", request="e30", {tail}"#
);
let single = parse_www_authenticate(&header).unwrap();
assert_eq!(single.description.as_deref(), Some(""), "{header:?}");
let all = parse_www_authenticate_all([header.as_str()]);
let listed = all[0].as_ref().unwrap();
assert_eq!(listed.description.as_deref(), Some(""), "{header:?}");
}
}
#[test]
fn test_parse_www_authenticate_advertises_payment_authorization_header() {
let header = concat!(
r#"Payment id="abc", realm="api", method="tempo", intent="charge", "#,
r#"request="e30", header="Payment-Authorization""#,
);
let parsed = parse_www_authenticate(header).unwrap();
assert_eq!(parsed.header.as_deref(), Some("Payment-Authorization"));
assert_eq!(parsed.credential_header(), "Payment-Authorization");
}
#[test]
fn test_parse_www_authenticate_omits_default_authorization_header() {
let header = concat!(
r#"Payment id="abc", realm="api", method="tempo", intent="charge", "#,
r#"request="e30", header="Authorization""#,
);
let parsed = parse_www_authenticate(header).unwrap();
assert!(parsed.header.is_none());
assert_eq!(parsed.credential_header(), "Authorization");
assert!(!format_www_authenticate(&parsed)
.unwrap()
.contains("header="));
}
#[test]
fn test_parse_www_authenticate_rejects_unsupported_credential_header() {
for name in [
"not a header",
"Cookie",
"Proxy-Authorization",
"Content-Length",
"X-Payment-Authorization",
] {
let header = format!(
r#"Payment id="abc", realm="api", method="tempo", intent="charge", request="e30", header="{name}""#,
);
let err = parse_www_authenticate(&header).unwrap_err();
assert!(
err.to_string().contains("Unsupported credential header"),
"{name}: {err}"
);
}
}
#[test]
fn test_parse_www_authenticate_keeps_credential_header_spelling() {
let header = concat!(
r#"Payment id="abc", realm="api", method="tempo", intent="charge", "#,
r#"request="e30", header="payment-authorization""#,
);
let parsed = parse_www_authenticate(header).unwrap();
assert_eq!(parsed.header.as_deref(), Some("payment-authorization"));
assert_eq!(parsed.credential_header(), "payment-authorization");
}
#[test]
fn test_format_www_authenticate_rejects_unsupported_credential_header() {
let mut challenge = test_challenge();
challenge.header = Some("Cookie".to_string());
assert!(format_www_authenticate(&challenge).is_err());
}
#[test]
fn test_parse_authorization_rejects_unsupported_credential_header() {
let mut challenge = test_challenge();
challenge.header = Some("Cookie".to_string());
let credential =
PaymentCredential::new(challenge.to_echo(), PaymentPayload::transaction("0xabc"));
let header = format_authorization(&credential).unwrap();
assert!(matches!(
parse_authorization(&header),
Err(MppError::MalformedCredential(_))
));
}
fn wire_challenge(overrides: &[(&str, &str)]) -> serde_json::Value {
let mut challenge = serde_json::json!({
"id": "abc",
"realm": "api",
"method": "tempo",
"intent": "charge",
"request": "e30",
});
for (key, value) in overrides {
challenge[*key] = (*value).into();
}
challenge
}
fn parse_wire_challenge_as_header(challenge: &serde_json::Value) -> Result<PaymentChallenge> {
let params: Vec<String> = challenge
.as_object()
.unwrap()
.iter()
.map(|(key, value)| format!("{key}=\"{}\"", value.as_str().unwrap()))
.collect();
parse_www_authenticate(&format!("Payment {}", params.join(", ")))
}
fn parse_wire_challenge_as_echo(challenge: &serde_json::Value) -> Result<PaymentCredential> {
let credential = serde_json::json!({
"challenge": challenge,
"payload": {"type": "transaction", "signature": "0xabc"},
});
parse_authorization(&format!(
"Payment {}",
base64url_encode(credential.to_string().as_bytes())
))
}
#[test]
fn test_bound_fields_are_validated_on_every_wire_path() {
for case in [
("id", ""),
("intent", ""),
("intent", "Charge Me"),
("intent", "charge|x"),
("request", "W10"),
("request", "e30|e30"),
("request", "bm90IGpzb24"),
("expires", "tomorrow"),
("expires", "2025-01-15T12:00:00Z|x"),
("digest", "sha-512=abc"),
("digest", "sha-256=X|Payment-Authorization"),
("opaque", "Payment-Authorization|"),
("opaque", "not base64url"),
] {
let challenge = wire_challenge(&[case]);
assert!(
parse_wire_challenge_as_header(&challenge).is_err(),
"header: {case:?}"
);
assert!(
serde_json::from_value::<PaymentChallenge>(challenge.clone()).is_err(),
"serde: {case:?}"
);
assert!(
matches!(
parse_wire_challenge_as_echo(&challenge),
Err(MppError::MalformedCredential(_))
),
"echo: {case:?}"
);
}
}
#[test]
fn test_bound_fields_are_not_normalized() {
let challenge = wire_challenge(&[
("intent", "Charge-2"),
("expires", "2025-01-15T12:00:00Z"),
(
"digest",
"sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:",
),
("opaque", "eyJwaSI6InBpXzEyMyJ9"),
("header", "Payment-Authorization"),
]);
let from_header = parse_wire_challenge_as_header(&challenge).unwrap();
let from_serde: PaymentChallenge = serde_json::from_value(challenge.clone()).unwrap();
let echo = parse_wire_challenge_as_echo(&challenge).unwrap().challenge;
assert_eq!(from_header.intent.as_str(), "Charge-2");
assert_eq!(from_serde.intent.as_str(), "Charge-2");
assert_eq!(echo.intent.as_str(), "Charge-2");
assert_eq!(
serde_json::to_value(&from_header).unwrap(),
serde_json::to_value(&from_serde).unwrap()
);
assert_eq!(serde_json::to_value(&from_serde).unwrap(), challenge);
}
#[test]
fn test_deserialized_challenge_header_matches_header_parser() {
let challenge = wire_challenge(&[("header", "Cookie")]);
assert!(parse_wire_challenge_as_header(&challenge).is_err());
assert!(serde_json::from_value::<PaymentChallenge>(challenge).is_err());
let challenge = wire_challenge(&[("header", "Authorization")]);
let from_header = parse_wire_challenge_as_header(&challenge).unwrap();
let from_serde: PaymentChallenge = serde_json::from_value(challenge).unwrap();
assert_eq!(from_header.header, None);
assert_eq!(from_serde.header, None);
}
#[test]
fn test_parse_authorization_rejects_shifted_hmac_slots() {
let signed = PaymentChallenge::with_secret_key_full(
"shifted-slot-secret",
"api",
"tempo",
"charge",
Base64UrlJson::from_raw("e30"),
None,
None,
None,
None,
Some("Payment-Authorization"),
);
let shifted_id = crate::protocol::core::compute_challenge_id(
"shifted-slot-secret",
"api",
"tempo",
"charge",
"e30",
None,
None,
Some("Payment-Authorization|"),
);
assert_eq!(signed.id, shifted_id);
let shifted = wire_challenge(&[("id", &signed.id), ("opaque", "Payment-Authorization|")]);
assert!(parse_wire_challenge_as_echo(&shifted).is_err());
}
#[test]
fn test_credential_echo_includes_description() {
let mut challenge = test_challenge();
challenge.description = Some("Pay for caf\u{e9}".to_string());
let credential =
PaymentCredential::new(challenge.to_echo(), PaymentPayload::transaction("0xabc"));
let header = format_authorization(&credential).unwrap();
let wire: serde_json::Value =
serde_json::from_slice(&base64url_decode(&header[8..]).unwrap()).unwrap();
assert_eq!(wire["challenge"]["description"], "Pay for caf\u{e9}");
assert_eq!(
parse_authorization(&header).unwrap().challenge.description,
challenge.description
);
let credential = PaymentCredential::new(
test_challenge().to_echo(),
PaymentPayload::transaction("0xabc"),
);
let header = format_authorization(&credential).unwrap();
let wire: serde_json::Value =
serde_json::from_slice(&base64url_decode(&header[8..]).unwrap()).unwrap();
assert!(wire["challenge"].get("description").is_none());
}
#[test]
fn test_parse_authorization_accepts_legacy_object_opaque() {
let opaque =
Base64UrlJson::from_value(&serde_json::json!({"pi": "pi_3abc123XYZ"})).unwrap();
let challenge = PaymentChallenge::with_secret_key_full(
"legacy-opaque-secret",
"api.example.com",
"tempo",
"charge",
Base64UrlJson::from_raw("eyJhbW91bnQiOiIxMDAwIn0"),
None,
None,
None,
Some(opaque),
None,
);
let credential = |opaque: serde_json::Value| {
let json = serde_json::json!({
"challenge": {
"id": challenge.id,
"realm": "api.example.com",
"method": "tempo",
"intent": "charge",
"request": "eyJhbW91bnQiOiIxMDAwIn0",
"opaque": opaque,
},
"payload": {"type": "transaction", "signature": "0x1234"},
});
format!("Payment {}", base64url_encode(json.to_string().as_bytes()))
};
let echo = parse_authorization(&credential(serde_json::json!({"pi": "pi_3abc123XYZ"})))
.unwrap()
.challenge;
let opaque = echo.opaque.as_ref().map(|opaque| opaque.raw());
assert_eq!(opaque, Some("eyJwaSI6InBpXzNhYmMxMjNYWVoifQ"));
assert_eq!(
echo.id,
crate::protocol::core::compute_challenge_id(
"legacy-opaque-secret",
&echo.realm,
echo.method.as_str(),
echo.intent.as_str(),
echo.request.raw(),
None,
None,
opaque,
)
);
assert!(parse_authorization(&credential(serde_json::json!({"pi": 123}))).is_err());
assert!(parse_authorization(&credential(serde_json::json!(["pi"]))).is_err());
}
#[test]
fn test_credential_echo_roundtrip_includes_header() {
let mut challenge = test_challenge();
challenge.header = Some("Payment-Authorization".to_string());
let credential =
PaymentCredential::new(challenge.to_echo(), PaymentPayload::transaction("0xabc"));
let header = format_authorization(&credential).unwrap();
let parsed = parse_authorization(&header).unwrap();
assert_eq!(
parsed.challenge.header.as_deref(),
Some("Payment-Authorization")
);
}
}