Skip to content

feat: support multi-challenge WWW-Authenticate headers#185

Merged
grandizzy merged 15 commits intomainfrom
fix/multi-challenge-www-authenticate
Mar 31, 2026
Merged

feat: support multi-challenge WWW-Authenticate headers#185
grandizzy merged 15 commits intomainfrom
fix/multi-challenge-www-authenticate

Conversation

@grandizzy
Copy link
Copy Markdown
Contributor

@grandizzy grandizzy commented Mar 31, 2026

Client now matches challenges by provider.supports(method, intent) instead of assuming a single challenge, mirroring the mppx TypeScript SDK.

Changes:

  • split_payment_challenges: splits a combined header into individual Payment challenge slices
  • parse_www_authenticate_all: handles both separate headers and combined multi-challenge headers
  • Client (PaymentExt + PaymentMiddleware) iterates all challenges and selects the first one the provider supports
  • HttpError::NoSupportedChallenge for when no offered challenge matches

Prompted by: georgen

@grandizzy grandizzy self-assigned this Mar 31, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 31, 2026

⚠️ Changelog not found.

A changelog entry is required before merging. We've generated a suggested changelog based on your changes:

Preview
---
mpp: minor
---

Added multi-challenge support to `PaymentExt` and `PaymentMiddleware`: both now parse all `WWW-Authenticate` challenges (including multiple header instances and comma-separated challenges per RFC 9110) and select the first one the provider supports via `provider.supports(method, intent)`. Added `HttpError::NoSupportedChallenge` variant for when no offered challenge matches the provider's supported methods.

Add changelog to commit this to your branch.

@grandizzy grandizzy force-pushed the fix/multi-challenge-www-authenticate branch 2 times, most recently from f17f8a9 to b644d00 Compare March 31, 2026 14:17
@grandizzy grandizzy force-pushed the fix/multi-challenge-www-authenticate branch from b644d00 to 9c007fe Compare March 31, 2026 14:29
@grandizzy grandizzy marked this pull request as ready for review March 31, 2026 14:38
Comment on lines +332 to +362
/// Find the first `tempo` method challenge from one or more WWW-Authenticate
/// header values.
///
/// This is a convenience wrapper around [`parse_www_authenticate_all`] that
/// filters for `method="tempo"` and returns the first match.
///
/// # Examples
///
/// ```
/// use mpp::protocol::core::find_tempo_challenge;
///
/// let header = concat!(
/// r#"Payment id="a", realm="api", method="tempo", intent="charge", request="e30", "#,
/// r#"Payment id="b", realm="api", method="stripe", intent="charge", request="e30""#,
/// );
/// let challenge = find_tempo_challenge(vec![header]).unwrap();
/// assert_eq!(challenge.id, "a");
/// assert_eq!(challenge.method.as_str(), "tempo");
/// ```
pub fn find_tempo_challenge<'a>(
headers: impl IntoIterator<Item = &'a str>,
) -> Result<PaymentChallenge> {
parse_www_authenticate_all(headers)
.into_iter()
.find(|r| r.as_ref().is_ok_and(|c| c.method.as_str() == "tempo"))
.unwrap_or_else(|| {
Err(MppError::invalid_challenge_reason(
"No Payment challenge with method=\"tempo\" found",
))
})
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be in the tempo module?

decofe and others added 4 commits March 31, 2026 14:50
Move the Tempo-specific find_tempo_challenge function from
protocol::core::headers to protocol::methods::tempo where it
belongs. Re-export from protocol::core for backward compatibility.

Co-authored-by: grandizzy <38490174+grandizzy@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d445a-5f14-754e-93cc-84e2bf98afc1
Co-authored-by: grandizzy <38490174+grandizzy@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d445a-5f14-754e-93cc-84e2bf98afc1
Co-authored-by: grandizzy <38490174+grandizzy@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d445a-5f14-754e-93cc-84e2bf98afc1
The methods module is gated behind feature = "server" or feature = "tempo",
so the re-export must have the same cfg gate.

Co-authored-by: grandizzy <38490174+grandizzy@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d445a-5f14-754e-93cc-84e2bf98afc1
#[test]
fn test_parse_www_authenticate_all_multi_challenge() {
let header = concat!(
r#"Payment id="t1", realm="r", method="tempo", intent="charge", request="e30", "#,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we also add a test hwich includes non-payment authentication headers e.g. Bearer

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added some more integration tests with 358cc68

/// assert_eq!(challenge.id, "a");
/// assert_eq!(challenge.method.as_str(), "tempo");
/// ```
pub fn find_tempo_challenge<'a>(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we may need something more robust than this

the client should iterate through all challenges and compare against their own set of supported methods (intent/method) -- we could select naively from that.

With this implementation i think there is a chance the server returns (session, charge) but the client only accepts charge

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed in 1c8b258 to mimic mppx

…rsing

Verify that Bearer, Basic, and other non-Payment schemes are silently
ignored by parse_www_authenticate_all, both as separate header values
and mixed within a single header value.

Co-authored-by: grandizzy <38490174+grandizzy@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d445a-5f14-754e-93cc-84e2bf98afc1
@grandizzy grandizzy marked this pull request as draft March 31, 2026 15:35
decofe and others added 8 commits March 31, 2026 15:35
The tempo module is gated behind feature = "tempo" only, not
feature = "server". Align the re-export cfg accordingly.

Co-authored-by: grandizzy <38490174+grandizzy@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d445a-5f14-754e-93cc-84e2bf98afc1
Replace single-challenge parsing with multi-challenge matching in
both fetch.rs and middleware.rs. The client now parses all challenges
from the 402 response and selects the first one the provider supports
(by method+intent), matching mppx's Fetch.ts behavior.

Remove find_tempo_challenge — it was a Tempo-specific shortcut that
is superseded by the generic provider.supports() matching.

Add HttpError::NoSupportedChallenge for when no challenge matches.

Co-authored-by: grandizzy <38490174+grandizzy@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d445a-5f14-754e-93cc-84e2bf98afc1
Co-authored-by: grandizzy <38490174+grandizzy@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d445a-5f14-754e-93cc-84e2bf98afc1
Cover the 5 key scenarios for the new challenge selection logic:
- Server offers multiple methods, provider selects supported one
- First supported challenge is picked when provider supports many
- NoSupportedChallenge error when no match exists
- Challenges spread across separate WWW-Authenticate headers
- Intent mismatch correctly rejected (tempo/session vs tempo/charge)

Co-Authored-By: grandizzy <38490174+grandizzy@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d44fc-2a43-73a8-92b6-167ceb7b388f
Co-Authored-By: grandizzy <38490174+grandizzy@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d44fc-2a43-73a8-92b6-167ceb7b388f
Co-Authored-By: grandizzy <38490174+grandizzy@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d44fc-2a43-73a8-92b6-167ceb7b388f
Co-Authored-By: grandizzy <38490174+grandizzy@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d44fc-2a43-73a8-92b6-167ceb7b388f
Co-Authored-By: grandizzy <38490174+grandizzy@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d44fc-2a43-73a8-92b6-167ceb7b388f
@grandizzy grandizzy marked this pull request as ready for review March 31, 2026 18:05
Co-Authored-By: grandizzy <38490174+grandizzy@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d44fc-2a43-73a8-92b6-167ceb7b388f
@grandizzy grandizzy merged commit 2f20798 into main Mar 31, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants