Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changelog/vain-tigers-dance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
mpp: patch
---

Fixed `base64url_decode` to accept standard base64 (`+`, `/`, `=` padding) in addition to URL-safe base64, following Postel's law and aligning with the mppx TypeScript SDK behavior. Added tests covering standard base64 with padding, URL-safe without padding, and standard alphabet without padding in both `types.rs` and `headers.rs`.
26 changes: 26 additions & 0 deletions src/protocol/core/headers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -845,6 +845,32 @@ mod tests {
assert!(err.to_string().contains("Invalid method"));
}

#[test]
fn test_parse_www_authenticate_accepts_standard_base64_request() {
// Reproduces real-world interop issue: server sends the `request`
// field as standard base64 ('+', '/', '=' padding) instead of
// base64url (no padding). The parser should accept both variants,
// matching the mppx TypeScript SDK behavior.
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());
// Verify it has padding
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() {
// {"method":"tempo","reference":"0xabc","status":"success","timestamp":"Jan 29 2026 12:00"}
Expand Down
50 changes: 48 additions & 2 deletions src/protocol/core/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,10 +245,27 @@ pub fn base64url_encode(data: &[u8]) -> String {
URL_SAFE_NO_PAD.encode(data)
}

/// Decode a base64url string (no padding) to bytes.
/// Decode a base64url string to bytes.
///
/// Accepts both URL-safe (`-`, `_`) and standard (`+`, `/`) alphabets,
/// with or without `=` padding. This follows Postel's law — be strict in
/// what you produce (see [`base64url_encode`]) but lenient in what you
/// accept — and aligns with the mppx TypeScript SDK which also accepts
/// both variants.
pub fn base64url_decode(input: &str) -> Result<Vec<u8>> {
// Normalize standard base64 to URL-safe alphabet and strip padding.
let normalized: String = input
.chars()
.filter(|c| *c != '=')
.map(|c| match c {
'+' => '-',
'/' => '_',
other => other,
})
.collect();

URL_SAFE_NO_PAD
.decode(input)
.decode(&normalized)
.map_err(|e| MppError::InvalidBase64Url(format!("Invalid base64url: {}", e)))
}

Expand Down Expand Up @@ -421,6 +438,35 @@ mod tests {
assert!(base64url_decode(noisy).is_err());
}

#[test]
fn test_base64url_decode_accepts_standard_base64_with_padding() {
// Standard base64 ('+', '/') with '=' padding — as produced by
// some servers that use RFC 4648 §4 instead of §5.
let data = b"hello world";
let standard = base64::engine::general_purpose::STANDARD.encode(data);
assert!(standard.contains('=') || standard.contains('+') || standard.contains('/'));
let decoded = base64url_decode(&standard).unwrap();
assert_eq!(decoded, data);
}

#[test]
fn test_base64url_decode_accepts_url_safe_without_padding() {
let data = b"hello world";
let encoded = base64url_encode(data);
let decoded = base64url_decode(&encoded).unwrap();
assert_eq!(decoded, data);
}

#[test]
fn test_base64url_decode_accepts_standard_alphabet_no_padding() {
// Standard alphabet but padding stripped — another common variant.
let data = b"test data with special chars: <>?";
let standard = base64::engine::general_purpose::STANDARD.encode(data);
let no_pad = standard.trim_end_matches('=');
let decoded = base64url_decode(no_pad).unwrap();
assert_eq!(decoded, data);
}

#[test]
fn test_base64url_json() {
let value = serde_json::json!({"amount": "1000", "currency": "pathUSD"});
Expand Down