mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
feat(mcp): challenge opaque caller bearers at connect and move Agent 365 sign-in onto the fixed production constants
Rework the Agent 365 sign-in provider for the guardrail shape 43189 landed on main: the OBO scope and resource come from the fixed AGENT_365_PROD_* constants instead of the removed resource_app_id/api_base fields, and the exchange runs through the shared TokenExchanger so the connect preflight and the tool call reuse one cached token per caller assertion. A present but non-JWS bearer is now rejected in preflight_caller_sign_in, so the connect answers 401 with the RFC 9728 challenge instead of letting the call reach tools/call and lose WWW-Authenticate in the JSON-RPC error. The OBO-only tool-call challenge in operations.py stays narrowed to token_exchange servers. Immutable rewrites (tuple, MappingProxyType, explicit None checks) keep the LIT002 total within the budget without a mutable-ok Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
072898ab19
commit
4236a43aa7
10 changed files with 146 additions and 124 deletions
|
|
@ -5971,10 +5971,7 @@ class MCPServerManager:
|
|||
if proxy_logging_obj is None:
|
||||
return hook_result
|
||||
|
||||
inbound_authorization: Final = next(
|
||||
(v for k, v in (raw_headers or {}).items() if isinstance(k, str) and k.lower() == "authorization"),
|
||||
"",
|
||||
)
|
||||
inbound_authorization: Final = _raw_header_value(raw_headers, "authorization") or ""
|
||||
incoming_bearer_token: Final = (
|
||||
inbound_authorization[len("bearer ") :] if inbound_authorization.lower().startswith("bearer ") else None
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1766,13 +1766,11 @@ def _challenge_missing_token_exchange_subject(
|
|||
The listing that fills a cold catalog absorbs the upstream 401 by design, so without this
|
||||
check a missing subject surfaces as an unknown-tool error instead of the challenge the
|
||||
warm path already raises. Gated to servers the key may reach so an unauthorized caller
|
||||
learns nothing about the catalog.
|
||||
learns nothing about the catalog. Guardrail-only sign-in is challenged at connect instead: a
|
||||
tool call's JSON-RPC error drops ``WWW-Authenticate``, so the guardrail's own rejection is
|
||||
the more useful answer there.
|
||||
"""
|
||||
from litellm.proxy._experimental.mcp_server.caller_sign_in import (
|
||||
caller_sign_in_for, # noqa: PLC0415 # lazy: caller_sign_in pulls the proxy graph
|
||||
)
|
||||
|
||||
if server is None or caller_sign_in_for(server, user_api_key_auth) is None:
|
||||
if server is None or server.auth_type != MCPAuth.oauth2_token_exchange:
|
||||
return
|
||||
if requested_server is not None and requested_server.server_id != server.server_id:
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1589,7 +1589,7 @@ if MCP_AVAILABLE:
|
|||
) -> bool:
|
||||
"""Sign-in challenges are issued only on a single-server connect the key's grant admits, so a key
|
||||
without access gets the grant's 403 instead of a sign-in it could not use."""
|
||||
if len(mcp_servers or ()) != 1:
|
||||
if mcp_servers is None or len(mcp_servers) != 1:
|
||||
return False
|
||||
allowed: Final = await operations._get_allowed_mcp_servers(
|
||||
user_api_key_auth=user_api_key_auth, mcp_servers=mcp_servers, client_ip=client_ip
|
||||
|
|
@ -1746,7 +1746,7 @@ if MCP_AVAILABLE:
|
|||
await operations._get_allowed_mcp_servers(
|
||||
user_api_key_auth=user_api_key_auth, mcp_servers=mcp_servers, client_ip=client_ip
|
||||
)
|
||||
if server and len(mcp_servers or ()) == 1
|
||||
if server and mcp_servers is not None and len(mcp_servers) == 1
|
||||
else ()
|
||||
)
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -100,6 +100,15 @@ class _EvaluateResponse(TypedDict, total=False):
|
|||
correlationId: ReadOnly[str]
|
||||
|
||||
|
||||
class _AdmissionMetadata(TypedDict):
|
||||
user_api_key_metadata: ReadOnly[dict | None]
|
||||
user_api_key_team_metadata: ReadOnly[dict | None]
|
||||
|
||||
|
||||
class _AdmissionProbe(TypedDict):
|
||||
metadata: ReadOnly[_AdmissionMetadata]
|
||||
|
||||
|
||||
class _ToolReference(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
|
|
@ -447,10 +456,8 @@ class Agent365Guardrail(CustomGuardrail):
|
|||
if not (self.default_on and server.keeps_caller_authorization):
|
||||
return None
|
||||
if user_api_key_auth is not None:
|
||||
probe: Final[
|
||||
dict[str, Mapping[str, object]]
|
||||
] = { # mutable-ok: should_run_guardrail takes a mutable data dict # pyright: ignore[reportUnknownVariableType] # UserAPIKeyAuth metadata dicts are untyped
|
||||
"metadata": { # mutable-ok: should_run_guardrail takes a mutable data dict
|
||||
probe: Final[_AdmissionProbe] = {
|
||||
"metadata": {
|
||||
"user_api_key_metadata": user_api_key_auth.metadata, # pyright: ignore[reportUnknownMemberType] # UserAPIKeyAuth.metadata is a raw dict
|
||||
"user_api_key_team_metadata": user_api_key_auth.team_metadata, # pyright: ignore[reportUnknownMemberType] # UserAPIKeyAuth.team_metadata is a raw dict
|
||||
}
|
||||
|
|
@ -477,12 +484,12 @@ class Agent365Guardrail(CustomGuardrail):
|
|||
async def preflight_caller_sign_in(
|
||||
self, server: MCPServer, user_api_key_auth: "UserAPIKeyAuth | None", subject_token: str
|
||||
) -> CallerSignInPreflight:
|
||||
"""The connect-time check the preemptive gate runs: a bearer Entra rejects gets the sign-in
|
||||
challenge here, where ``WWW-Authenticate`` still reaches the client, instead of surfacing as a
|
||||
JSON-RPC error on every tools/call. ``subject_token=None`` stays the challenge gate's job."""
|
||||
"""The connect-time check the preemptive gate runs: a bearer Entra rejects, or one it could never
|
||||
accept, gets the sign-in challenge here, where ``WWW-Authenticate`` still reaches the client, instead of
|
||||
surfacing as a JSON-RPC error on every tools/call. ``subject_token=None`` stays the challenge gate's job."""
|
||||
assertion: Final = entra_assertion(subject_token)
|
||||
if assertion is None:
|
||||
return SignedIn()
|
||||
return Rejected(detail="the caller's bearer is not an Entra token; sign in with Entra and retry")
|
||||
try:
|
||||
exchange_result: Final = await self._exchange_caller_assertion(assertion)
|
||||
except (httpx.HTTPError, LitellmTimeout, TimeoutError) as exc:
|
||||
|
|
|
|||
|
|
@ -1519,6 +1519,7 @@ class ProxyLogging:
|
|||
# (e.g. MCPJWTSigner) to independently verify the caller's identity
|
||||
# before re-signing an outbound token (FR-5 verify+re-sign).
|
||||
"incoming_bearer_token": kwargs.get("incoming_bearer_token"),
|
||||
"incoming_subject_token": kwargs.get("incoming_subject_token"),
|
||||
"metadata": synthetic_metadata,
|
||||
}
|
||||
user_api_key_auth: Final = kwargs.get("user_api_key_auth")
|
||||
|
|
|
|||
|
|
@ -136,13 +136,17 @@ def test_a_missing_or_malformed_caller_bearer_blocks_on_every_entry_point_whatev
|
|||
assert f"{rig.alias}-add" in rig.caller().list_tools().tools, "the catalog needs only the virtual key"
|
||||
for entry in ENTRY_POINTS:
|
||||
missing: Final = rig.caller(entry).call(f"{rig.alias}-add", {"entry": entry}, server_id=rig.server_id)
|
||||
assert missing.error is not None and REJECTED in missing.raw, f"{entry} without a bearer: {missing.raw}"
|
||||
malformed: Final = rig.caller(entry, "not-a-jws").call(
|
||||
f"{rig.alias}-add", {"entry": entry}, server_id=rig.server_id
|
||||
)
|
||||
assert malformed.error is not None and REJECTED in malformed.raw, f"{entry} opaque bearer: {malformed.raw}"
|
||||
for label, outcome in (("without a bearer", missing), ("opaque bearer", malformed)):
|
||||
assert outcome.error is not None, f"{entry} {label}: {outcome.raw}"
|
||||
if entry == "server_mcp":
|
||||
assert outcome.status == 401, f"{entry} {label} skips the connect sign-in challenge: {outcome.raw}"
|
||||
else:
|
||||
assert REJECTED in outcome.raw, f"{entry} {label}: {outcome.raw}"
|
||||
assert rig.upstream_tool_names() == ()
|
||||
expected: Final = 2 * len(ENTRY_POINTS)
|
||||
expected: Final = 2 * (len(ENTRY_POINTS) - 1)
|
||||
assert rig.guardrail_statuses("call_mcp_tool", expected) == ["guardrail_intervened"] * expected
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -171,91 +171,75 @@ def test_jwt_signer_verifies_the_bearer_that_admitted_the_call(gateway: Gateway,
|
|||
assert introspect_stub.drain() == ()
|
||||
|
||||
|
||||
AGENT_365_PARAMS: Final = {
|
||||
"guardrail": "agent_365",
|
||||
"mode": "pre_mcp_call",
|
||||
"default_on": True,
|
||||
"tenant_id": "00000000-0000-0000-0000-000000000000",
|
||||
"client_id": "22222222-2222-2222-2222-222222222222",
|
||||
"client_secret": "secret",
|
||||
}
|
||||
ENTRA_ISSUER: Final = "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0"
|
||||
GATEWAY_SCOPE: Final = "api://22222222-2222-2222-2222-222222222222/access_as_user"
|
||||
|
||||
|
||||
def test_agent_365_gated_server_challenges_at_connect_and_advertises_entra(gateway: Gateway, tmp_path: Path) -> None:
|
||||
def nothing(request: Request) -> Reply:
|
||||
return Reply(status=500)
|
||||
config: Final = _sign_in_config(dict(AGENT_365_PARAMS), tmp_path / "agent365.yaml")
|
||||
with (
|
||||
owned_proxy(gateway, tmp_path, {}, config=config) as candidate,
|
||||
mcp_peer() as peer,
|
||||
candidate.scenario() as scenario,
|
||||
):
|
||||
alias: Final = "a365" + uuid.uuid4().hex[:8]
|
||||
identity: Final = register_mcp(scenario, peer, alias)
|
||||
granted: Final = scenario.key(object_permission={"mcp_servers": [identity]})
|
||||
denied: Final = scenario.key(object_permission={"mcp_servers": ["no-mcp-servers"]})
|
||||
|
||||
with wire_server(nothing) as api:
|
||||
config: Final = _sign_in_config(
|
||||
{
|
||||
"guardrail": "agent_365",
|
||||
"mode": "pre_mcp_call",
|
||||
"default_on": True,
|
||||
"tenant_id": "00000000-0000-0000-0000-000000000000",
|
||||
"client_id": "22222222-2222-2222-2222-222222222222",
|
||||
"client_secret": "secret",
|
||||
"api_base": api.url,
|
||||
},
|
||||
tmp_path / "agent365.yaml",
|
||||
challenged: Final = _rpc(candidate, f"/mcp/{alias}", granted, {})
|
||||
assert challenged.status_code == 401, challenged.text
|
||||
authenticate: Final = challenged.headers.get("www-authenticate", "")
|
||||
assert f'resource_metadata="/.well-known/oauth-protected-resource/mcp/{alias}"' in authenticate
|
||||
assert 'error="invalid_token"' in authenticate
|
||||
|
||||
opaque: Final = _rpc(candidate, f"/mcp/{alias}", granted, {"Authorization": "Bearer not-a-jws"})
|
||||
assert opaque.status_code == 401, opaque.text
|
||||
assert f'resource_metadata="/.well-known/oauth-protected-resource/mcp/{alias}"' in opaque.headers.get(
|
||||
"www-authenticate", ""
|
||||
)
|
||||
with (
|
||||
owned_proxy(gateway, tmp_path, {}, config=config) as candidate,
|
||||
mcp_peer() as peer,
|
||||
candidate.scenario() as scenario,
|
||||
):
|
||||
alias: Final = "a365" + uuid.uuid4().hex[:8]
|
||||
identity: Final = register_mcp(scenario, peer, alias)
|
||||
granted: Final = scenario.key(object_permission={"mcp_servers": [identity]})
|
||||
denied: Final = scenario.key(object_permission={"mcp_servers": ["no-mcp-servers"]})
|
||||
|
||||
challenged: Final = _rpc(candidate, f"/mcp/{alias}", granted, {})
|
||||
assert challenged.status_code == 401, challenged.text
|
||||
authenticate: Final = challenged.headers.get("www-authenticate", "")
|
||||
assert f'resource_metadata="/.well-known/oauth-protected-resource/mcp/{alias}"' in authenticate
|
||||
assert 'error="invalid_token"' in authenticate
|
||||
discovery: Final = candidate.client.get(f"/.well-known/oauth-protected-resource/mcp/{alias}")
|
||||
assert discovery.status_code == 200, discovery.text
|
||||
document: Final = discovery.json()
|
||||
assert document["authorization_servers"] == [ENTRA_ISSUER]
|
||||
assert document["scopes_supported"] == [GATEWAY_SCOPE]
|
||||
|
||||
discovery: Final = candidate.client.get(f"/.well-known/oauth-protected-resource/mcp/{alias}")
|
||||
assert discovery.status_code == 200, discovery.text
|
||||
document: Final = discovery.json()
|
||||
assert document["authorization_servers"] == [
|
||||
"https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0"
|
||||
]
|
||||
assert document["scopes_supported"] == ["api://22222222-2222-2222-2222-222222222222/access_as_user"]
|
||||
|
||||
refused: Final = _rpc(candidate, f"/mcp/{alias}", denied, {})
|
||||
assert refused.status_code == 403, refused.text
|
||||
assert "www-authenticate" not in refused.headers
|
||||
assert api.drain() == ()
|
||||
refused: Final = _rpc(candidate, f"/mcp/{alias}", denied, {})
|
||||
assert refused.status_code == 403, refused.text
|
||||
assert "www-authenticate" not in refused.headers
|
||||
assert tool_calls(peer.drain()) == ()
|
||||
|
||||
|
||||
def test_challenge_and_prm_resolve_the_connected_case_variant(gateway: Gateway, tmp_path: Path) -> None:
|
||||
def nothing(request: Request) -> Reply:
|
||||
return Reply(status=500)
|
||||
config: Final = _sign_in_config(dict(AGENT_365_PARAMS), tmp_path / "agent365-case.yaml")
|
||||
with (
|
||||
owned_proxy(gateway, tmp_path, {}, config=config) as candidate,
|
||||
mcp_peer() as peer,
|
||||
candidate.scenario() as scenario,
|
||||
):
|
||||
alias: Final = "a365" + uuid.uuid4().hex[:8]
|
||||
identity: Final = register_mcp(scenario, peer, alias)
|
||||
granted: Final = scenario.key(object_permission={"mcp_servers": [identity]})
|
||||
connected_as: Final = alias.upper()
|
||||
|
||||
with wire_server(nothing) as api:
|
||||
config: Final = _sign_in_config(
|
||||
{
|
||||
"guardrail": "agent_365",
|
||||
"mode": "pre_mcp_call",
|
||||
"default_on": True,
|
||||
"tenant_id": "00000000-0000-0000-0000-000000000000",
|
||||
"client_id": "22222222-2222-2222-2222-222222222222",
|
||||
"client_secret": "secret",
|
||||
"api_base": api.url,
|
||||
},
|
||||
tmp_path / "agent365-case.yaml",
|
||||
)
|
||||
with (
|
||||
owned_proxy(gateway, tmp_path, {}, config=config) as candidate,
|
||||
mcp_peer() as peer,
|
||||
candidate.scenario() as scenario,
|
||||
):
|
||||
alias: Final = "a365" + uuid.uuid4().hex[:8]
|
||||
identity: Final = register_mcp(scenario, peer, alias)
|
||||
granted: Final = scenario.key(object_permission={"mcp_servers": [identity]})
|
||||
connected_as: Final = alias.upper()
|
||||
challenged: Final = _rpc(candidate, f"/mcp/{connected_as}", granted, {})
|
||||
assert challenged.status_code == 401, challenged.text
|
||||
authenticate: Final = challenged.headers.get("www-authenticate", "")
|
||||
assert f'resource_metadata="/.well-known/oauth-protected-resource/mcp/{connected_as}"' in authenticate
|
||||
assert 'error="invalid_token"' in authenticate
|
||||
|
||||
challenged: Final = _rpc(candidate, f"/mcp/{connected_as}", granted, {})
|
||||
assert challenged.status_code == 401, challenged.text
|
||||
authenticate: Final = challenged.headers.get("www-authenticate", "")
|
||||
assert f'resource_metadata="/.well-known/oauth-protected-resource/mcp/{connected_as}"' in authenticate
|
||||
assert 'error="invalid_token"' in authenticate
|
||||
|
||||
discovery: Final = candidate.client.get(f"/.well-known/oauth-protected-resource/mcp/{connected_as}")
|
||||
assert discovery.status_code == 200, discovery.text
|
||||
document: Final = discovery.json()
|
||||
assert document["authorization_servers"] == [
|
||||
"https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0"
|
||||
]
|
||||
assert document["scopes_supported"] == ["api://22222222-2222-2222-2222-222222222222/access_as_user"]
|
||||
assert api.drain() == ()
|
||||
discovery: Final = candidate.client.get(f"/.well-known/oauth-protected-resource/mcp/{connected_as}")
|
||||
assert discovery.status_code == 200, discovery.text
|
||||
document: Final = discovery.json()
|
||||
assert document["authorization_servers"] == [ENTRA_ISSUER]
|
||||
assert document["scopes_supported"] == [GATEWAY_SCOPE]
|
||||
assert tool_calls(peer.drain()) == ()
|
||||
|
|
|
|||
|
|
@ -3787,9 +3787,9 @@ async def test_protected_resource_metadata_resolves_the_connected_case_variant()
|
|||
use_standard_pattern=True,
|
||||
)
|
||||
|
||||
assert result["authorization_servers"] == [
|
||||
"https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0"
|
||||
]
|
||||
assert result["authorization_servers"] == (
|
||||
"https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0",
|
||||
)
|
||||
assert result["resource"] == "https://llm.example.com/mcp/CATALOG"
|
||||
|
||||
|
||||
|
|
@ -7367,21 +7367,21 @@ def test_caller_sign_in_protected_resource_response_names_jwt_issuers():
|
|||
with patch(_PATCH_ISSUERS, return_value=["https://idp.example.com"]):
|
||||
response = _caller_sign_in_protected_resource_response(_obo_server(scopes=["read"]), _OBO_RESOURCE)
|
||||
assert response == {
|
||||
"authorization_servers": ["https://idp.example.com"],
|
||||
"authorization_servers": ("https://idp.example.com",),
|
||||
"resource": _OBO_RESOURCE,
|
||||
"scopes_supported": ["read"],
|
||||
"scopes_supported": ("read",),
|
||||
}
|
||||
|
||||
|
||||
def test_caller_sign_in_protected_resource_response_scopes_default_empty():
|
||||
"""A scopeless OBO server reports scopes_supported as [] rather than None."""
|
||||
"""A scopeless OBO server reports scopes_supported as an empty array rather than None."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
_caller_sign_in_protected_resource_response,
|
||||
)
|
||||
|
||||
with patch(_PATCH_ISSUERS, return_value=["https://idp.example.com"]):
|
||||
response = _caller_sign_in_protected_resource_response(_obo_server(scopes=None), _OBO_RESOURCE)
|
||||
assert response["scopes_supported"] == []
|
||||
assert response["scopes_supported"] == ()
|
||||
|
||||
|
||||
def test_caller_sign_in_protected_resource_response_falls_back_when_no_issuer():
|
||||
|
|
@ -7440,7 +7440,7 @@ async def test_build_oauth_protected_resource_response_obo_end_to_end():
|
|||
mcp_server_name="obo_mcp",
|
||||
use_standard_pattern=True,
|
||||
)
|
||||
assert response["authorization_servers"] == ["https://idp.example.com"]
|
||||
assert response["authorization_servers"] == ("https://idp.example.com",)
|
||||
assert response["resource"] == "https://litellm.example.com/mcp/obo_mcp"
|
||||
finally:
|
||||
global_mcp_server_manager.registry.clear()
|
||||
|
|
|
|||
|
|
@ -1135,6 +1135,34 @@ class _ArgumentMasker(CustomGuardrail):
|
|||
return data
|
||||
|
||||
|
||||
class TestMcpBridgeHandsOverTheSubjectToken:
|
||||
"""The MCP manager separates the raw ``Authorization`` bearer from the caller's subject token (the
|
||||
bearer minus LiteLLM's own admission credentials). The bridge that turns the manager's kwargs into
|
||||
the guardrail's data dict has to carry the subject token, or every tool call looks anonymous."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_kwargs_reach_the_obo_exchange(self):
|
||||
exchanger: Final = StubTokenExchanger([_ok_exchange()])
|
||||
handler: Final = FakeHandler([_allow_response()])
|
||||
guardrail: Final = _make_guardrail(handler, exchanger=exchanger)
|
||||
proxy_logging: Final = ProxyLogging(user_api_key_cache=DualCache())
|
||||
manager_kwargs: Final = {
|
||||
"name": "send_email",
|
||||
"arguments": {"to": "user@example.com"},
|
||||
"server_name": "outlook_mcp",
|
||||
"user_api_key_auth": _user(),
|
||||
"incoming_bearer_token": "sk-1234",
|
||||
"incoming_subject_token": FAKE_ASSERTION,
|
||||
"headers": {"mcp-session-id": "sess-123"},
|
||||
}
|
||||
data: Final = proxy_logging._convert_mcp_to_llm_format(
|
||||
proxy_logging._create_mcp_request_object_from_kwargs(manager_kwargs), manager_kwargs
|
||||
)
|
||||
await _run(guardrail, data)
|
||||
assert [call[0] for call in exchanger.calls] == [FAKE_ASSERTION]
|
||||
assert handler.calls[0].json["tool"]["name"] == "send_email"
|
||||
|
||||
|
||||
class TestFinalArgumentsEvaluated:
|
||||
"""Agent 365 must judge the arguments that reach the upstream tool. A sibling guardrail that rewrites
|
||||
them must not be able to slip a different argument state past the verdict, whichever way the two
|
||||
|
|
@ -1185,20 +1213,17 @@ class TestCallerSignIn:
|
|||
assert guardrail.caller_sign_in(_server(auth_type=MCPAuth.oauth2), None) is None
|
||||
assert guardrail.caller_sign_in(_server(extra_headers=["authorization"]), None) is None
|
||||
|
||||
def test_opted_out_key_does_not_gate(self):
|
||||
class _OptedOut(Agent365Guardrail):
|
||||
def should_run_guardrail(self, data, event_type) -> bool:
|
||||
return False
|
||||
|
||||
guardrail: Final = _OptedOut(
|
||||
guardrail_name="a365-off",
|
||||
tenant_id="tenant-abc",
|
||||
client_id="client-xyz",
|
||||
client_secret="secret-123",
|
||||
token_exchanger=StubTokenExchanger(),
|
||||
default_on=True,
|
||||
def test_opted_out_key_or_team_does_not_gate(self):
|
||||
guardrail: Final = _make_guardrail(FakeHandler([]))
|
||||
opted_out_key: Final = UserAPIKeyAuth(
|
||||
api_key="k", user_id="u-1", metadata={"opted_out_global_guardrails": [guardrail.guardrail_name]}
|
||||
)
|
||||
assert guardrail.caller_sign_in(_server(), UserAPIKeyAuth(api_key="k", user_id="u-1")) is None
|
||||
opted_out_team: Final = UserAPIKeyAuth(
|
||||
api_key="k", user_id="u-1", team_metadata={"opted_out_global_guardrails": [guardrail.guardrail_name]}
|
||||
)
|
||||
assert guardrail.caller_sign_in(_server(), opted_out_key) is None
|
||||
assert guardrail.caller_sign_in(_server(), opted_out_team) is None
|
||||
assert guardrail.caller_sign_in(_server(), UserAPIKeyAuth(api_key="k", user_id="u-1")) is not None
|
||||
assert guardrail.caller_sign_in(_server(), None) is not None
|
||||
|
||||
def test_obo_server_with_provider_advertises_both_issuers_and_scopes(self, monkeypatch):
|
||||
|
|
@ -1269,11 +1294,12 @@ class TestPreflightCallerSignIn:
|
|||
assert verdict.fail_open is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_assertion_subject_signs_in_without_exchanging(self):
|
||||
async def test_non_assertion_subject_is_rejected_without_exchanging(self):
|
||||
exchanger: Final = StubTokenExchanger()
|
||||
guardrail: Final = _make_guardrail(FakeHandler([]), exchanger=exchanger)
|
||||
|
||||
verdict: Final = await guardrail.preflight_caller_sign_in(_server(), _user(), "opaque-bearer")
|
||||
|
||||
assert verdict == SignedIn()
|
||||
assert isinstance(verdict, Rejected)
|
||||
assert verdict.claims is None
|
||||
assert exchanger.calls == []
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ def test_convert_mcp_to_llm_format_returns_synthetic_data(proxy_logging, make_mc
|
|||
"user_api_key_hash": "hash",
|
||||
"user_api_key_request_route": "/mcp",
|
||||
"incoming_bearer_token": "tok",
|
||||
"incoming_subject_token": "a.b.c",
|
||||
},
|
||||
)
|
||||
snapshot = {
|
||||
|
|
@ -47,6 +48,7 @@ def test_convert_mcp_to_llm_format_returns_synthetic_data(proxy_logging, make_mc
|
|||
"mcp_tool_name": out["mcp_tool_name"],
|
||||
"mcp_arguments": out["mcp_arguments"],
|
||||
"incoming_bearer_token": out["incoming_bearer_token"],
|
||||
"incoming_subject_token": out["incoming_subject_token"],
|
||||
"message_role": out["messages"][0]["role"],
|
||||
}
|
||||
assert snapshot == {
|
||||
|
|
@ -55,6 +57,7 @@ def test_convert_mcp_to_llm_format_returns_synthetic_data(proxy_logging, make_mc
|
|||
"mcp_tool_name": "search",
|
||||
"mcp_arguments": {"q": "hello"},
|
||||
"incoming_bearer_token": "tok",
|
||||
"incoming_subject_token": "a.b.c",
|
||||
"message_role": "user",
|
||||
}
|
||||
|
||||
|
|
@ -66,12 +69,14 @@ def test_convert_mcp_to_llm_format_defaults_model(proxy_logging, make_mcp_reques
|
|||
"model": out["model"],
|
||||
"mcp_tool_name": out["mcp_tool_name"],
|
||||
"incoming_bearer_token": out["incoming_bearer_token"],
|
||||
"incoming_subject_token": out["incoming_subject_token"],
|
||||
"user_id": out["user_api_key_user_id"],
|
||||
}
|
||||
assert snapshot == {
|
||||
"model": "mcp-tool-call",
|
||||
"mcp_tool_name": "calculator",
|
||||
"incoming_bearer_token": None,
|
||||
"incoming_subject_token": None,
|
||||
"user_id": None,
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue