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 src/adcp/decisioning/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,11 @@ def _build_ctx(
the next dispatch.
"""
auth_info = self._extract_auth_info(tool_ctx)
# Pop adcp.auth_info after extraction so the AuthInfo object doesn't
# survive into RequestContext.metadata, where it would be opaque to
# downstream serializers. Mirrors adcp.buyer_agent on the next line.
if tool_ctx.metadata:
tool_ctx.metadata.pop("adcp.auth_info", None)
buyer_agent = tool_ctx.metadata.pop("adcp.buyer_agent", None) if tool_ctx.metadata else None
return _build_request_context(
tool_ctx,
Expand Down
30 changes: 29 additions & 1 deletion src/adcp/server/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,16 +400,44 @@ def auth_context_factory(meta: RequestMetadata) -> ToolContext:
:class:`ToolContext` — agents that want a typed subclass
(e.g. :class:`~adcp.server.AccountAwareToolContext`) should copy
the three-line body and return their own subclass instead.

Also sets ``metadata["adcp.auth_info"]`` to a typed
:class:`~adcp.decisioning.AuthInfo` when the request is
authenticated, so :meth:`~adcp.decisioning.PlatformHandler._extract_auth_info`
surfaces a non-``None`` :attr:`~adcp.decisioning.RequestContext.auth_info`
for bearer flows — the same typed surface signed-request flows already
populate. ``credential`` is ``None`` for bearer flows because inbound
bearer tokens are not for upstream propagation; adopters who need
:class:`~adcp.decisioning.BuyerAgentRegistry` dispatch must supply a
typed credential in a custom ``context_factory`` subclass.

``adcp.auth_info`` is server-internal and never wire-echoed by the
framework. Do not pass ``ctx.metadata`` wholesale to a JSON serializer
— the ``AuthInfo`` object is not JSON-serializable.
"""
principal_identity = current_principal.get()
principal_metadata = current_principal_metadata.get() or {}
combined_metadata: dict[str, Any] = {
**principal_metadata,
"tool_name": meta.tool_name,
"transport": meta.transport,
}
if principal_identity is not None:
# Lazy import to keep module-load order safe — decisioning.context
# imports adcp.server.base but not adcp.server.auth, so there is no
# circular dependency, but hoisting this to module level would create
# one if the import graph ever changes. Call-time import matches
# the pattern already used in dispatch._build_request_context.
from adcp.decisioning.context import AuthInfo # noqa: PLC0415

combined_metadata["adcp.auth_info"] = AuthInfo(
kind="bearer",
principal=principal_identity,
credential=None, # explicit None: no synthesis, no DeprecationWarning
)
return ToolContext(
request_id=meta.request_id,
caller_identity=current_principal.get(),
caller_identity=principal_identity,
tenant_id=current_tenant.get(),
metadata=combined_metadata,
)
Expand Down
38 changes: 38 additions & 0 deletions tests/test_auth_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,44 @@ def test_auth_context_factory_with_no_principal() -> None:

assert ctx.caller_identity is None
assert ctx.tenant_id is None
assert "adcp.auth_info" not in (ctx.metadata or {})


def test_auth_context_factory_populates_auth_info_when_authenticated() -> None:
"""auth_context_factory must set ctx.metadata['adcp.auth_info'] to a typed
AuthInfo(kind='bearer') when a principal is present, so ctx.auth_info is
non-None for bearer flows in downstream RequestContext. Regression guard
for issue #576."""
from adcp.decisioning.context import AuthInfo
from adcp.server import RequestMetadata

principal_token = current_principal.set("alice")
tenant_token = current_tenant.set("t1")
try:
meta = RequestMetadata(tool_name="get_products", transport="mcp")
ctx = auth_context_factory(meta)
finally:
current_principal.reset(principal_token)
current_tenant.reset(tenant_token)

info = ctx.metadata.get("adcp.auth_info")
assert isinstance(info, AuthInfo), f"expected AuthInfo, got {type(info)}"
assert info.kind == "bearer"
assert info.principal == "alice"
assert info.credential is None # inbound tokens are not for upstream propagation


def test_auth_context_factory_omits_auth_info_without_principal() -> None:
"""Non-discovery requests with no principal (principal=None) must NOT set
adcp.auth_info in metadata — the key is only set when authenticated."""
from adcp.server import RequestMetadata

# Use a non-discovery tool so this test is distinct from
# test_auth_context_factory_with_no_principal above.
meta = RequestMetadata(tool_name="get_products", transport="mcp")
ctx = auth_context_factory(meta)

assert "adcp.auth_info" not in (ctx.metadata or {})


# Full-stack composition (middleware + create_mcp_server + handler) is
Expand Down
Loading