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
81 changes: 76 additions & 5 deletions agentscore_commerce/checkout.py
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,76 @@ def _resolve_identity_metadata(ctx: CheckoutContext) -> dict[str, Any] | None:
return build_identity_metadata(mode="wallet", wallet=wallet, linked_wallets=linked_wallets)


def _header_is_payment_credential(orig: Any, key: str) -> bool:
lk = key.lower()
if lk in ("payment-signature", "x-payment"):
return True
if lk == "authorization":
value = orig.get("authorization")
return isinstance(value, str) and value.startswith("Payment ")
return False


class _StrippedHeaders:
"""Read-only view over a framework ``headers`` mapping that hides credentials.

Hides the payment-credential headers (``x-payment`` / ``payment-signature`` /
an ``Authorization: Payment`` value) while supporting the ``.get`` / ``[]`` /
``in`` / ``.items`` / iteration access patterns hooks use.
"""

def __init__(self, orig: Any) -> None:
self._orig = orig

def get(self, key: str, default: Any = None) -> Any:
if _header_is_payment_credential(self._orig, key):
return default
return self._orig.get(key, default)

def __getitem__(self, key: str) -> Any:
if _header_is_payment_credential(self._orig, key):
raise KeyError(key)
return self._orig[key]

def __contains__(self, key: str) -> bool:
if _header_is_payment_credential(self._orig, key):
return False
return key in self._orig

def items(self) -> Any:
return [(k, v) for k, v in self._orig.items() if not _header_is_payment_credential(self._orig, k)]

def __iter__(self) -> Any:
return iter(k for k in self._orig if not _header_is_payment_credential(self._orig, k))


class _RawHeaderStripProxy:
"""Wrap the native request so ``.headers`` hides payment credentials.

Every other attribute (``.json``, ``.scope``, mppx's fetch surface, ...)
delegates to the original request unchanged.
"""

def __init__(self, raw: Any, headers: _StrippedHeaders) -> None:
object.__setattr__(self, "_raw", raw)
object.__setattr__(self, "headers", headers)

def __getattr__(self, name: str) -> Any:
return getattr(object.__getattribute__(self, "_raw"), name)


def _strip_payment_headers_from_raw(raw: Any) -> Any:
"""Return ``raw`` with the payment-credential headers hidden.

Hooks that read the native request (``ctx.request.raw``) on the malformed
re-challenge then see a discovery leg. Non-header-bearing ``raw`` (or
``None``) passes through unchanged.
"""
if raw is None or not hasattr(raw, "headers"):
return raw
return _RawHeaderStripProxy(raw, _StrippedHeaders(raw.headers))


class Checkout:
"""High-level agent-commerce orchestrator.

Expand Down Expand Up @@ -2685,10 +2755,11 @@ def _strip_payment_headers(self, request: CheckoutRequest) -> CheckoutRequest:
Re-entering handle() with it treats the request as a discovery
(no-credential) request: pre_validate + pricing + minting + compose run
their fresh path, and the gate/assess and settle are skipped. Turns a
malformed-credential request into a clean 402 re-challenge. The raw
request is left intact; compose_mppx reads it only best-effort under a
try/except, while the stripped headers are what the shape check, gate
dispatch, and recipient minting read.
malformed-credential request into a clean 402 re-challenge. The native
request (``raw``) is stripped in lockstep with ``headers`` so hooks that
read ``ctx.request.raw`` (e.g. ``mint_multichain_recipients``, which
parses the MPP credential off the raw ``Authorization: Payment`` header)
also see a discovery leg instead of throwing on the junk credential.
"""
headers = {
k: v
Expand All @@ -2698,7 +2769,7 @@ def _strip_payment_headers(self, request: CheckoutRequest) -> CheckoutRequest:
or (k.lower() == "authorization" and v.startswith("Payment "))
)
}
return dataclasses.replace(request, headers=headers)
return dataclasses.replace(request, headers=headers, raw=_strip_payment_headers_from_raw(request.raw))

async def _emit_402(
self,
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "agentscore-commerce"
version = "2.5.8"
version = "2.5.9"
description = "Agent commerce SDK for Python — identity middleware (FastAPI, Flask, Django, AIOHTTP, Sanic, ASGI) + payment helpers + 402 builders + discovery + Stripe multichain. The full merchant-side toolkit for AgentScore-powered agent commerce."
readme = "README.md"
license = "MIT"
Expand Down
71 changes: 71 additions & 0 deletions tests/test_credential_precheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,31 @@
MppxComposeOutcome,
PricingResult,
)
from agentscore_commerce.errors import CheckoutValidationError
from agentscore_commerce.payment import TempoRailSpec, X402BaseRailSpec, malformed_payment_credential


class _RawHeaders:
"""A framework-style headers mapping (case-insensitive .get) for a fake raw request."""

def __init__(self, mapping: dict[str, str]) -> None:
self._m = {k.lower(): v for k, v in mapping.items()}

def get(self, key: str, default: Any = None) -> Any:
return self._m.get(key.lower(), default)

def items(self) -> Any:
return list(self._m.items())

def __iter__(self) -> Any:
return iter(self._m)


class _RawReq:
def __init__(self, headers: dict[str, str]) -> None:
self.headers = _RawHeaders(headers)


VALID_MPP = (
"Payment "
+ base64.b64encode(
Expand Down Expand Up @@ -73,6 +96,54 @@ async def _compose(_ctx: Any) -> MppxComposeOutcome:
assert "pre_validate" in calls


@pytest.mark.asyncio
async def test_rechallenge_strips_credential_from_raw_request_too() -> None:
# Regression: the re-challenge must be a discovery leg for EVERY view of the
# request, including the native ``ctx.request.raw`` that hooks like
# ``mint_multichain_recipients`` read. A hook parsing the MPP credential off
# ``ctx.request.raw`` and raising on junk (the martin-estate shape) would
# otherwise turn the fresh-402 re-challenge back into a 401 dead end.
raw_auth_seen: dict[str, Any] = {}

async def _mint(ctx: Any) -> dict[str, str]:
raw = ctx.request.raw
auth = raw.headers.get("authorization") if raw is not None else None
raw_auth_seen["value"] = auth
if auth is not None and auth.startswith("Payment "):
raise CheckoutValidationError(
code="invalid_credential",
message="The Authorization: Payment header is not a valid MPP credential.",
action="retry_without_credential",
status=401,
)
return {"tempo": "0xtempo"}

async def _compose(_ctx: Any) -> MppxComposeOutcome:
return MppxComposeOutcome(status=402, headers={"www-authenticate": 'Payment realm="fresh"'})

checkout = Checkout(
rails={"tempo": TempoRailSpec(recipient="0x" + "00" * 19 + "dEaD")},
url="https://api.example/purchase",
pre_validate=lambda _ctx: {},
compute_pricing=lambda _ctx: PricingResult(amount_usd=10.0),
compose_mppx=_compose,
mint_recipients=_mint,
)
req = CheckoutRequest(
method="POST",
url="https://api.example/purchase",
headers={"authorization": "Payment total-garbage!!!"},
body={"item": "wine"},
raw=_RawReq({"authorization": "Payment total-garbage!!!", "x-wallet-address": "0xabc"}),
)
result = await checkout.handle(req)
assert result.status == 402
assert result.settle_phase == "credential_malformed"
# The hook ran on the re-entry and saw a raw with the credential stripped;
# non-payment headers (x-wallet-address) still pass through.
assert raw_auth_seen["value"] is None


@pytest.mark.asyncio
async def test_junk_x402_header_rechallenges_with_fresh_402_discovery_flow() -> None:
calls: list[str] = []
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.