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
10 changes: 10 additions & 0 deletions docs/handler-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,16 @@ transport layer, before dispatch hydration) still read
`context.caller_identity` for legitimate cache / rate-limit keying;
the composite mutation happens later, in `_build_request_context`.

To discriminate auth flows inside a handler — e.g. when a signed-request
buyer and a bearer-token buyer hit the same handler and you want
flow-specific authorization — read `ctx.auth_info.kind`. On bearer
flows the dispatch helper synthesizes
`AuthInfo(kind="bearer", principal=...)` from `current_principal`, so
`ctx.auth_info.kind == "bearer"` is the typed predicate (no
`ctx.auth_info is None` check needed for authenticated bearer
traffic). Signed-request flows carry `kind="signed_request"` /
`"http_sig"` directly from the verifier middleware.

#### Pattern 2a — custom middleware (when the shipped one doesn't fit)

Subclass `BearerTokenAuthMiddleware` to tighten the discovery bypass,
Expand Down
5 changes: 4 additions & 1 deletion examples/mcp_with_auth_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,10 @@ async def get_products(self, params: Any, context: ToolContext | None = None) ->
# principal label by the time a handler sees it. On bearer
# flows like this one ``auth_principal`` is sourced from the
# :data:`adcp.server.auth.current_principal` ContextVar that
# the middleware populates.
# the middleware populates, and the dispatch helper
# synthesizes ``ctx.auth_info`` as
# ``AuthInfo(kind="bearer", principal=...)`` so handlers can
# discriminate flows via ``ctx.auth_info.kind == "bearer"``.
tenant = context.tenant_id if context is not None else None
return products_response(_products_for_tenant(tenant))

Expand Down
15 changes: 12 additions & 3 deletions src/adcp/decisioning/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,8 +453,14 @@ class RequestContext(ToolContext, Generic[TMeta]):
``ctx.caller_identity`` for cache scoping; the dispatch adapter
sets ``caller_identity = account.id`` so caching scopes per
resolved account, not per raw auth principal.
:param auth_info: Optional verified principal info. ``None`` when
the request is unauthenticated (dev / ``'derived'`` fixtures).
:param auth_info: Optional verified principal info. On bearer-token
flows the dispatch helper synthesizes
``AuthInfo(kind="bearer", principal=...)`` from the
:data:`adcp.server.auth.current_principal` ContextVar so adopters
can branch on ``ctx.auth_info.kind == "bearer"`` (the typed
flow discriminator) without reaching into framework-private
state. ``None`` when the request is unauthenticated (dev /
``'derived'`` fixtures).
:param now: Monotonic timestamp for the request — adopters use
this rather than ``datetime.now()`` directly so tests can
inject deterministic clocks.
Expand Down Expand Up @@ -485,7 +491,10 @@ class RequestContext(ToolContext, Generic[TMeta]):
* **Bearer-token flows** — sourced from the
:data:`adcp.server.auth.current_principal` ContextVar that
:class:`BearerTokenAuthMiddleware` populates
(``Principal.caller_identity`` from the validator).
(``Principal.caller_identity`` from the validator). The
dispatch helper also synthesizes
``AuthInfo(kind="bearer", principal=...)`` so adopters can
discriminate the flow via ``ctx.auth_info.kind == "bearer"``.

Read it for per-principal ACLs *within* an account ("can
principal X mutate this buy?"). ``None`` for unauthenticated
Expand Down
52 changes: 32 additions & 20 deletions src/adcp/decisioning/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -1009,12 +1009,13 @@ def _build_request_context(
:param auth_info: Optional verified principal info — when present
and carrying a non-``None`` principal, ``auth_principal`` is
populated from ``auth_info.principal``. Otherwise the helper
falls back to :data:`adcp.server.auth.current_principal` —
the ContextVar :class:`BearerTokenAuthMiddleware` populates —
so bearer-flow callers get a typed read for "who's calling?"
without reaching into framework-private state. Returns
``None`` outside both flows (no-op for unauthenticated dev
fixtures).
synthesizes an :class:`AuthInfo` (``kind="bearer"``,
``credential=None``) from :data:`adcp.server.auth.current_principal`
— the ContextVar :class:`BearerTokenAuthMiddleware` populates —
so bearer-flow callers get both a typed ``ctx.auth_info`` and
``ctx.auth_principal`` read without reaching into framework-
private state. ``ctx.auth_info`` stays ``None`` outside both
flows (no-op for unauthenticated dev fixtures).
:param store: The AccountStore that produced ``account``. Required
for the production cache-isolation guarantee; the dispatch
adapter always supplies it. Test fixtures may pass ``None``
Expand All @@ -1028,34 +1029,45 @@ def _build_request_context(
# Local import to avoid a circular at module-load time. dispatch.py
# is imported by serve.py; context.py and accounts.py both reach
# back into adcp.decisioning, so the cycle is real if we hoist.
from adcp.decisioning.context import RequestContext
from adcp.decisioning.context import AuthInfo, RequestContext
from adcp.decisioning.resolve import _NotYetWiredResolver

# ``auth_principal`` is the typed "who's calling?" read for
# adopter handlers. Two sources populate it:
# ``auth_info`` / ``auth_principal`` are the typed reads adopter
# handlers use. Two sources populate them:
#
# * Signed-request flows hydrate ``AuthInfo`` upstream and the
# adapter passes it as ``auth_info``; ``auth_info.principal``
# carries the verified caller label.
# * Bearer-token flows (:class:`BearerTokenAuthMiddleware`) never
# construct an ``AuthInfo``; they stash the principal in the
# :data:`adcp.server.auth.current_principal` ContextVar instead.
# Read it as the fallback so bearer adopters can gate on
# ``ctx.auth_principal`` without reaching into the framework-
# private ContextVar themselves. ``.get()`` returns ``None``
# outside a bearer flow — that's the desired no-op for non-
# bearer callers (signed-request without ``AuthInfo``,
# unauthenticated dev fixtures).
# Synthesize one here so bearer adopters can branch on
# ``ctx.auth_info.kind == "bearer"`` (the typed flow
# discriminator) without reaching into the framework-private
# ContextVar themselves. ``credential=None`` is passed
# explicitly so :meth:`AuthInfo.__post_init__` skips the
# flat-field synthesis path and the accompanying
# :class:`DeprecationWarning` (see context.py:396-426): the
# sentinel default fires synthesis, an explicit ``None`` does
# not. We don't know the bearer's ``key_id`` / ``scopes`` —
# bearer tokens are opaque to the SDK — so we leave those
# fields at their dataclass defaults; adopters who want richer
# data should write their own ``context_factory``.
#
# Local import keeps the layering local — read the bearer ContextVar
# without forcing a top-level dep on adcp.server.auth.
from adcp.server.auth import current_principal as _current_principal

auth_principal = (
auth_info.principal
if auth_info is not None and auth_info.principal is not None
else _current_principal.get()
)
if auth_info is None:
bearer_principal = _current_principal.get()
if bearer_principal is not None:
auth_info = AuthInfo(
kind="bearer",
principal=bearer_principal,
credential=None,
)

auth_principal = auth_info.principal if auth_info is not None else None

# ctx_metadata credential gate — fail-closed before any platform
# method sees the metadata. Buyers can populate ``context``
Expand Down
41 changes: 36 additions & 5 deletions tests/test_decisioning_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -436,10 +436,12 @@ def test_build_request_context_with_no_auth() -> None:

def test_build_request_context_falls_back_to_bearer_context_var() -> None:
"""Bearer-flow callers populate :data:`adcp.server.auth.current_principal`
via :class:`BearerTokenAuthMiddleware`; the dispatch helper must read
the ContextVar when no ``AuthInfo`` is provided so adopters can read
``ctx.auth_principal`` instead of reaching into framework-private
state. Regression test for issue #571."""
via :class:`BearerTokenAuthMiddleware`; the dispatch helper must
synthesize a typed ``AuthInfo(kind="bearer", ...)`` from the
ContextVar when no ``AuthInfo`` is provided so adopters can branch
on ``ctx.auth_info.kind`` and read ``ctx.auth_principal`` without
reaching into framework-private state. Regression test for issues
#571 (auth_principal) and #576 (auth_info.kind)."""
from adcp.server.auth import current_principal

tool_ctx = ToolContext()
Expand All @@ -449,10 +451,39 @@ def test_build_request_context_falls_back_to_bearer_context_var() -> None:
ctx = _build_request_context(tool_ctx, account, None)
finally:
current_principal.reset(token)
assert ctx.auth_info is None
assert ctx.auth_info is not None
assert ctx.auth_info.kind == "bearer"
assert ctx.auth_info.principal == "principal-from-bearer"
assert ctx.auth_principal == "principal-from-bearer"


def test_build_request_context_bearer_auth_info_does_not_warn() -> None:
"""The synthesized bearer ``AuthInfo`` passes ``credential=None``
explicitly so :meth:`AuthInfo.__post_init__` skips the flat-field
synthesis branch and its :class:`DeprecationWarning`. Pinning this
behavior so adopters on bearer flows don't see a stack-trace
warning every request. See ``src/adcp/decisioning/context.py``
lines 396-426 for the synthesis branch."""
import warnings

from adcp.server.auth import current_principal

tool_ctx = ToolContext()
account: Account[Any] = Account(id="acct")
token = current_principal.set("principal-from-bearer")
try:
with warnings.catch_warnings(record=True) as captured:
warnings.simplefilter("always")
_build_request_context(tool_ctx, account, None)
finally:
current_principal.reset(token)
deprecations = [w for w in captured if issubclass(w.category, DeprecationWarning)]
assert deprecations == [], (
f"Bearer-flow synthesis must not emit DeprecationWarning, got: "
f"{[str(w.message) for w in deprecations]}"
)


def test_build_request_context_auth_info_takes_precedence_over_bearer_var() -> None:
"""When both ``AuthInfo`` and the bearer ContextVar are populated
(e.g. a custom middleware stack that hydrates both), the explicit
Expand Down
Loading