diff --git a/agentscore_commerce/checkout.py b/agentscore_commerce/checkout.py index 34e5e05..f8fa17a 100644 --- a/agentscore_commerce/checkout.py +++ b/agentscore_commerce/checkout.py @@ -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. @@ -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 @@ -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, diff --git a/pyproject.toml b/pyproject.toml index 34a7bf1..ae2be50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_credential_precheck.py b/tests/test_credential_precheck.py index 35484ee..0811989 100644 --- a/tests/test_credential_precheck.py +++ b/tests/test_credential_precheck.py @@ -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( @@ -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] = [] diff --git a/uv.lock b/uv.lock index 6707d7b..785c0b7 100644 --- a/uv.lock +++ b/uv.lock @@ -13,7 +13,7 @@ constraints = [{ name = "fastapi", specifier = "!=0.136.3" }] [[package]] name = "agentscore-commerce" -version = "2.5.8" +version = "2.5.9" source = { editable = "." } dependencies = [ { name = "agentscore-py" },