diff --git a/docs/handler-authoring.md b/docs/handler-authoring.md index acd97de4..3514ff19 100644 --- a/docs/handler-authoring.md +++ b/docs/handler-authoring.md @@ -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, diff --git a/examples/mcp_with_auth_middleware.py b/examples/mcp_with_auth_middleware.py index 91c32094..e7e994a8 100644 --- a/examples/mcp_with_auth_middleware.py +++ b/examples/mcp_with_auth_middleware.py @@ -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)) diff --git a/src/adcp/decisioning/context.py b/src/adcp/decisioning/context.py index 16e5b63c..2a40980a 100644 --- a/src/adcp/decisioning/context.py +++ b/src/adcp/decisioning/context.py @@ -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. @@ -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 diff --git a/src/adcp/decisioning/dispatch.py b/src/adcp/decisioning/dispatch.py index e36a613a..2902254f 100644 --- a/src/adcp/decisioning/dispatch.py +++ b/src/adcp/decisioning/dispatch.py @@ -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`` @@ -1028,11 +1029,11 @@ 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`` @@ -1040,22 +1041,33 @@ def _build_request_context( # * 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`` diff --git a/tests/test_decisioning_dispatch.py b/tests/test_decisioning_dispatch.py index 057ffcce..a9ebbbce 100644 --- a/tests/test_decisioning_dispatch.py +++ b/tests/test_decisioning_dispatch.py @@ -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() @@ -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