Skip to content

Commit 0db54bd

Browse files
authored
Merge branch 'main' into claude/issue-576-bearer-auth-info
2 parents a74fa91 + c2ab696 commit 0db54bd

5 files changed

Lines changed: 94 additions & 29 deletions

File tree

docs/handler-authoring.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,16 @@ transport layer, before dispatch hydration) still read
319319
`context.caller_identity` for legitimate cache / rate-limit keying;
320320
the composite mutation happens later, in `_build_request_context`.
321321

322+
To discriminate auth flows inside a handler — e.g. when a signed-request
323+
buyer and a bearer-token buyer hit the same handler and you want
324+
flow-specific authorization — read `ctx.auth_info.kind`. On bearer
325+
flows the dispatch helper synthesizes
326+
`AuthInfo(kind="bearer", principal=...)` from `current_principal`, so
327+
`ctx.auth_info.kind == "bearer"` is the typed predicate (no
328+
`ctx.auth_info is None` check needed for authenticated bearer
329+
traffic). Signed-request flows carry `kind="signed_request"` /
330+
`"http_sig"` directly from the verifier middleware.
331+
322332
#### Pattern 2a — custom middleware (when the shipped one doesn't fit)
323333

324334
Subclass `BearerTokenAuthMiddleware` to tighten the discovery bypass,

examples/mcp_with_auth_middleware.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,10 @@ async def get_products(self, params: Any, context: ToolContext | None = None) ->
7171
# principal label by the time a handler sees it. On bearer
7272
# flows like this one ``auth_principal`` is sourced from the
7373
# :data:`adcp.server.auth.current_principal` ContextVar that
74-
# the middleware populates.
74+
# the middleware populates, and the dispatch helper
75+
# synthesizes ``ctx.auth_info`` as
76+
# ``AuthInfo(kind="bearer", principal=...)`` so handlers can
77+
# discriminate flows via ``ctx.auth_info.kind == "bearer"``.
7578
tenant = context.tenant_id if context is not None else None
7679
return products_response(_products_for_tenant(tenant))
7780

src/adcp/decisioning/context.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -453,8 +453,14 @@ class RequestContext(ToolContext, Generic[TMeta]):
453453
``ctx.caller_identity`` for cache scoping; the dispatch adapter
454454
sets ``caller_identity = account.id`` so caching scopes per
455455
resolved account, not per raw auth principal.
456-
:param auth_info: Optional verified principal info. ``None`` when
457-
the request is unauthenticated (dev / ``'derived'`` fixtures).
456+
:param auth_info: Optional verified principal info. On bearer-token
457+
flows the dispatch helper synthesizes
458+
``AuthInfo(kind="bearer", principal=...)`` from the
459+
:data:`adcp.server.auth.current_principal` ContextVar so adopters
460+
can branch on ``ctx.auth_info.kind == "bearer"`` (the typed
461+
flow discriminator) without reaching into framework-private
462+
state. ``None`` when the request is unauthenticated (dev /
463+
``'derived'`` fixtures).
458464
:param now: Monotonic timestamp for the request — adopters use
459465
this rather than ``datetime.now()`` directly so tests can
460466
inject deterministic clocks.
@@ -485,7 +491,10 @@ class RequestContext(ToolContext, Generic[TMeta]):
485491
* **Bearer-token flows** — sourced from the
486492
:data:`adcp.server.auth.current_principal` ContextVar that
487493
:class:`BearerTokenAuthMiddleware` populates
488-
(``Principal.caller_identity`` from the validator).
494+
(``Principal.caller_identity`` from the validator). The
495+
dispatch helper also synthesizes
496+
``AuthInfo(kind="bearer", principal=...)`` so adopters can
497+
discriminate the flow via ``ctx.auth_info.kind == "bearer"``.
489498
490499
Read it for per-principal ACLs *within* an account ("can
491500
principal X mutate this buy?"). ``None`` for unauthenticated

src/adcp/decisioning/dispatch.py

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,12 +1009,13 @@ def _build_request_context(
10091009
:param auth_info: Optional verified principal info — when present
10101010
and carrying a non-``None`` principal, ``auth_principal`` is
10111011
populated from ``auth_info.principal``. Otherwise the helper
1012-
falls back to :data:`adcp.server.auth.current_principal` —
1013-
the ContextVar :class:`BearerTokenAuthMiddleware` populates —
1014-
so bearer-flow callers get a typed read for "who's calling?"
1015-
without reaching into framework-private state. Returns
1016-
``None`` outside both flows (no-op for unauthenticated dev
1017-
fixtures).
1012+
synthesizes an :class:`AuthInfo` (``kind="bearer"``,
1013+
``credential=None``) from :data:`adcp.server.auth.current_principal`
1014+
— the ContextVar :class:`BearerTokenAuthMiddleware` populates —
1015+
so bearer-flow callers get both a typed ``ctx.auth_info`` and
1016+
``ctx.auth_principal`` read without reaching into framework-
1017+
private state. ``ctx.auth_info`` stays ``None`` outside both
1018+
flows (no-op for unauthenticated dev fixtures).
10181019
:param store: The AccountStore that produced ``account``. Required
10191020
for the production cache-isolation guarantee; the dispatch
10201021
adapter always supplies it. Test fixtures may pass ``None``
@@ -1028,34 +1029,45 @@ def _build_request_context(
10281029
# Local import to avoid a circular at module-load time. dispatch.py
10291030
# is imported by serve.py; context.py and accounts.py both reach
10301031
# back into adcp.decisioning, so the cycle is real if we hoist.
1031-
from adcp.decisioning.context import RequestContext
1032+
from adcp.decisioning.context import AuthInfo, RequestContext
10321033
from adcp.decisioning.resolve import _NotYetWiredResolver
10331034

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

1054-
auth_principal = (
1055-
auth_info.principal
1056-
if auth_info is not None and auth_info.principal is not None
1057-
else _current_principal.get()
1058-
)
1061+
if auth_info is None:
1062+
bearer_principal = _current_principal.get()
1063+
if bearer_principal is not None:
1064+
auth_info = AuthInfo(
1065+
kind="bearer",
1066+
principal=bearer_principal,
1067+
credential=None,
1068+
)
1069+
1070+
auth_principal = auth_info.principal if auth_info is not None else None
10591071

10601072
# ctx_metadata credential gate — fail-closed before any platform
10611073
# method sees the metadata. Buyers can populate ``context``

tests/test_decisioning_dispatch.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -436,10 +436,12 @@ def test_build_request_context_with_no_auth() -> None:
436436

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

445447
tool_ctx = ToolContext()
@@ -449,10 +451,39 @@ def test_build_request_context_falls_back_to_bearer_context_var() -> None:
449451
ctx = _build_request_context(tool_ctx, account, None)
450452
finally:
451453
current_principal.reset(token)
452-
assert ctx.auth_info is None
454+
assert ctx.auth_info is not None
455+
assert ctx.auth_info.kind == "bearer"
456+
assert ctx.auth_info.principal == "principal-from-bearer"
453457
assert ctx.auth_principal == "principal-from-bearer"
454458

455459

460+
def test_build_request_context_bearer_auth_info_does_not_warn() -> None:
461+
"""The synthesized bearer ``AuthInfo`` passes ``credential=None``
462+
explicitly so :meth:`AuthInfo.__post_init__` skips the flat-field
463+
synthesis branch and its :class:`DeprecationWarning`. Pinning this
464+
behavior so adopters on bearer flows don't see a stack-trace
465+
warning every request. See ``src/adcp/decisioning/context.py``
466+
lines 396-426 for the synthesis branch."""
467+
import warnings
468+
469+
from adcp.server.auth import current_principal
470+
471+
tool_ctx = ToolContext()
472+
account: Account[Any] = Account(id="acct")
473+
token = current_principal.set("principal-from-bearer")
474+
try:
475+
with warnings.catch_warnings(record=True) as captured:
476+
warnings.simplefilter("always")
477+
_build_request_context(tool_ctx, account, None)
478+
finally:
479+
current_principal.reset(token)
480+
deprecations = [w for w in captured if issubclass(w.category, DeprecationWarning)]
481+
assert deprecations == [], (
482+
f"Bearer-flow synthesis must not emit DeprecationWarning, got: "
483+
f"{[str(w.message) for w in deprecations]}"
484+
)
485+
486+
456487
def test_build_request_context_auth_info_takes_precedence_over_bearer_var() -> None:
457488
"""When both ``AuthInfo`` and the bearer ContextVar are populated
458489
(e.g. a custom middleware stack that hydrates both), the explicit

0 commit comments

Comments
 (0)