mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-10-13 08:23:21 +02:00
526 lines
20 KiB
Python
526 lines
20 KiB
Python
"""Configured authentication sources retain stable principal identities."""
|
|
|
|
import json
|
|
from collections.abc import AsyncGenerator
|
|
from types import SimpleNamespace
|
|
from typing import Any, cast
|
|
from urllib.parse import parse_qs
|
|
|
|
import httpx2
|
|
import pytest
|
|
from fastmcp_tasks.context import (
|
|
TaskContextSnapshot,
|
|
_apply_snapshot_to_context,
|
|
get_task_scope,
|
|
)
|
|
from mcp.server.auth.middleware.auth_context import auth_context_var
|
|
from mcp.server.auth.middleware.bearer_auth import (
|
|
AuthenticatedUser,
|
|
authorization_context,
|
|
)
|
|
from mcp.server.auth.provider import AccessToken as SDKAccessToken
|
|
from mcp.server.auth.provider import principal_components
|
|
from mcp.server.context import ServerRequestContext
|
|
from mcp.server.request_state import authenticated_principal
|
|
from mcp_types import InputRequiredResult
|
|
from starlette.testclient import TestClient
|
|
|
|
from fastmcp import Context, FastMCP
|
|
from fastmcp.server.auth import (
|
|
AccessToken,
|
|
MultiAuth,
|
|
RemoteAuthProvider,
|
|
TokenVerifier,
|
|
)
|
|
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
|
from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier
|
|
from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair
|
|
from fastmcp.server.dependencies import get_access_token
|
|
from fastmcp.server.sessions import UserSession, current_principal
|
|
from fastmcp.utilities.tests import asgi_server
|
|
from tests.server.http.test_session_owner_enforcement import (
|
|
INITIALIZE_REQUEST,
|
|
MCP_HEADERS,
|
|
TOOLS_LIST_REQUEST,
|
|
)
|
|
from tests.server.test_mrtr_guards import _ask, _elicit
|
|
|
|
|
|
class StoredVerifier(TokenVerifier):
|
|
def __init__(self, bearer: str) -> None:
|
|
super().__init__()
|
|
self.bearer = bearer
|
|
self.result = AccessToken(
|
|
token=bearer,
|
|
client_id="shared",
|
|
subject="reader",
|
|
scopes=[],
|
|
claims={"iss": "same", "sub": "reader"},
|
|
original_client_id="supplied",
|
|
)
|
|
|
|
async def verify_token(self, token: str) -> AccessToken | None:
|
|
return self.result if token == self.bearer else None
|
|
|
|
|
|
async def test_named_sources_preserve_raw_fields_and_source_token() -> None:
|
|
first, second = StoredVerifier("one"), StoredVerifier("two")
|
|
auth = MultiAuth(verifiers={"company": first, "partner": second})
|
|
a, b = await auth.verify_token("one"), await auth.verify_token("two")
|
|
assert a is not None and b is not None
|
|
assert a is not first.result
|
|
assert first.result.client_id == "shared"
|
|
assert first.result.original_client_id == "supplied"
|
|
assert json.loads(a.client_id) == ["company", "shared", "same", "reader"]
|
|
assert json.loads(b.client_id) == ["partner", "shared", "same", "reader"]
|
|
assert a.original_client_id == b.original_client_id == "shared"
|
|
assert principal_components(a) != principal_components(b)
|
|
assert a.claims == first.result.claims
|
|
assert a.subject == first.result.subject
|
|
assert a.token == first.result.token
|
|
|
|
|
|
async def test_source_order_and_reconstruction_keep_identities() -> None:
|
|
first, second = StoredVerifier("one"), StoredVerifier("two")
|
|
left = MultiAuth(verifiers={"company": first, "partner": second})
|
|
right = MultiAuth(verifiers={"partner": second, "company": first})
|
|
a, b = await left.verify_token("one"), await right.verify_token("one")
|
|
assert a is not None and b is not None
|
|
assert principal_components(a) == principal_components(b)
|
|
|
|
|
|
async def test_nested_sources_keep_the_inner_namespace() -> None:
|
|
inner = MultiAuth(verifiers={"company": StoredVerifier("one")})
|
|
outer = MultiAuth(server=inner, server_source_id="interactive")
|
|
result = await outer.verify_token("one")
|
|
assert result is not None
|
|
outer_name, inner_id, issuer, subject = json.loads(result.client_id)
|
|
assert subject == "reader"
|
|
assert issuer == "same"
|
|
assert outer_name == "interactive"
|
|
assert json.loads(inner_id) == ["company", "shared", "same", "reader"]
|
|
assert result.original_client_id == "shared"
|
|
|
|
|
|
@pytest.mark.parametrize("name", ["", " ", " company"])
|
|
def test_names_are_nonempty_and_unambiguous(name: str) -> None:
|
|
with pytest.raises(ValueError, match="source IDs"):
|
|
MultiAuth(verifiers={name: StoredVerifier("one")})
|
|
|
|
|
|
def test_opaque_sources_are_stable_and_duplicates_require_names() -> None:
|
|
first = MultiAuth(verifiers=StoredVerifier("one"))
|
|
restarted = MultiAuth(verifiers=StoredVerifier("new-credentials"))
|
|
assert first._source_ids == restarted._source_ids
|
|
with pytest.raises(ValueError, match="unique"):
|
|
MultiAuth(verifiers=[StoredVerifier("one"), StoredVerifier("two")])
|
|
|
|
|
|
def test_configured_source_ids_are_stable_and_duplicates_require_names() -> None:
|
|
first = JWTVerifier(
|
|
jwks_uri="https://one.example/keys", issuer="https://one.example"
|
|
)
|
|
second = JWTVerifier(
|
|
jwks_uri="https://two.example/keys", issuer="https://two.example"
|
|
)
|
|
a, b = MultiAuth(verifiers=[first, second]), MultiAuth(verifiers=[second, first])
|
|
assert a._source_ids == list(reversed(b._source_ids))
|
|
assert MultiAuth(verifiers=first)._source_ids == a._source_ids[:1]
|
|
with pytest.raises(ValueError, match="unique"):
|
|
MultiAuth(verifiers=[first, first])
|
|
|
|
|
|
async def test_ownership_consumers_agree_and_snapshot_preserves_raw_token() -> None:
|
|
auth = MultiAuth(
|
|
verifiers={"company": StoredVerifier("one"), "partner": StoredVerifier("two")}
|
|
)
|
|
tokens = [await auth.verify_token(t) for t in ["one", "two"]]
|
|
contexts = []
|
|
identities = []
|
|
task_scopes = []
|
|
for token in tokens:
|
|
assert token is not None
|
|
marker = auth_context_var.set(AuthenticatedUser(token))
|
|
try:
|
|
assert get_access_token() is token
|
|
identities.append(current_principal())
|
|
assert current_principal() == authenticated_principal(
|
|
cast(ServerRequestContext[Any, Any], SimpleNamespace())
|
|
)
|
|
task_scopes.append(get_task_scope())
|
|
contexts.append(authorization_context(AuthenticatedUser(token)))
|
|
restored = AccessToken.model_validate_json(token.model_dump_json())
|
|
assert principal_components(restored) == principal_components(token)
|
|
snapshot = TaskContextSnapshot(access_token_json=token.model_dump_json())
|
|
_apply_snapshot_to_context(snapshot)
|
|
assert current_principal() == identities[-1]
|
|
restored_access_token = get_access_token()
|
|
assert restored_access_token is not None
|
|
assert restored_access_token.claims == token.claims
|
|
assert restored_access_token.original_client_id == "shared"
|
|
assert get_task_scope() == task_scopes[-1]
|
|
finally:
|
|
auth_context_var.reset(marker)
|
|
assert identities[0] != identities[1]
|
|
assert contexts[0] != contexts[1]
|
|
assert task_scopes[0] != task_scopes[1]
|
|
|
|
|
|
def test_plain_token_identity_retains_legacy_representation() -> None:
|
|
token = AccessToken(
|
|
token="one",
|
|
client_id="client",
|
|
subject="reader",
|
|
scopes=[],
|
|
claims={"iss": "issuer", "sub": "reader"},
|
|
)
|
|
assert principal_components(token) == ("client", "issuer", "reader")
|
|
marker = auth_context_var.set(AuthenticatedUser(token))
|
|
try:
|
|
assert get_task_scope() == "client|reader"
|
|
assert authorization_context(AuthenticatedUser(token)) == {
|
|
"client_id": "client",
|
|
"issuer": "issuer",
|
|
"subject": "reader",
|
|
}
|
|
finally:
|
|
auth_context_var.reset(marker)
|
|
|
|
|
|
async def test_introspection_endpoints_keep_session_and_transport_ownership_separate() -> (
|
|
None
|
|
):
|
|
def respond(request: httpx2.Request) -> httpx2.Response:
|
|
active = (
|
|
request.url.host == "one.example" and b"token=one" in request.content
|
|
) or (request.url.host == "two.example" and b"token=two" in request.content)
|
|
return httpx2.Response(
|
|
200, json={"active": active, "client_id": "shared", "scope": ""}
|
|
)
|
|
|
|
async with httpx2.AsyncClient(transport=httpx2.MockTransport(respond)) as http:
|
|
sources = [
|
|
IntrospectionTokenVerifier(
|
|
introspection_url="https://" + host + "/introspect",
|
|
client_id="application",
|
|
client_secret="synthetic",
|
|
http_client=http,
|
|
)
|
|
for host in ["one.example", "two.example"]
|
|
]
|
|
auth = MultiAuth(verifiers=sources)
|
|
server = FastMCP("Configured sources", auth=auth)
|
|
|
|
@server.tool
|
|
async def store(value: str, session: UserSession) -> str:
|
|
await session.set("value", value)
|
|
return value
|
|
|
|
@server.tool
|
|
async def read(session: UserSession) -> str | None:
|
|
return await session.get("value")
|
|
|
|
app = server.http_app(stateless_http=True, json_response=True)
|
|
with TestClient(app, base_url="http://127.0.0.1") as client:
|
|
|
|
def call(token: str, name: str, args: dict[str, Any]) -> Any:
|
|
response = client.post(
|
|
"/mcp",
|
|
headers={**MCP_HEADERS, "authorization": "Bearer " + token},
|
|
json={
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "tools/call",
|
|
"params": {"name": name, "arguments": args},
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
return response.json()["result"]["structuredContent"]["result"]
|
|
|
|
assert call("one", "store", {"value": "saved"}) == "saved"
|
|
assert call("two", "read", {}) is None
|
|
assert call("two", "store", {"value": "other"}) == "other"
|
|
assert call("one", "read", {}) == "saved"
|
|
|
|
app = server.http_app(stateless_http=False, json_response=True)
|
|
with TestClient(app, base_url="http://127.0.0.1") as client:
|
|
initialized = client.post(
|
|
"/mcp",
|
|
headers={**MCP_HEADERS, "authorization": "Bearer one"},
|
|
json=INITIALIZE_REQUEST,
|
|
)
|
|
assert initialized.status_code == 200
|
|
session_id = initialized.headers["mcp-session-id"]
|
|
response = client.post(
|
|
"/mcp",
|
|
headers={
|
|
**MCP_HEADERS,
|
|
"authorization": "Bearer two",
|
|
"mcp-session-id": session_id,
|
|
"mcp-protocol-version": "2024-11-05",
|
|
},
|
|
json=TOOLS_LIST_REQUEST,
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
|
|
async def test_claims_only_subjects_keep_user_ownership_separate() -> None:
|
|
verifier = StoredVerifier("one")
|
|
verifier.result.subject = None
|
|
verifier.result.claims["sub"] = "alice"
|
|
auth = MultiAuth(verifiers={"company": verifier})
|
|
alice = await auth.verify_token("one")
|
|
verifier.result.claims = {**verifier.result.claims, "sub": "bob"}
|
|
bob = await auth.verify_token("one")
|
|
assert alice is not None and bob is not None
|
|
assert alice.subject is None and bob.subject is None
|
|
scopes = []
|
|
for token in (alice, bob):
|
|
marker = auth_context_var.set(AuthenticatedUser(token))
|
|
try:
|
|
scopes.append(get_task_scope())
|
|
finally:
|
|
auth_context_var.reset(marker)
|
|
assert scopes[0] != scopes[1]
|
|
|
|
|
|
@pytest.fixture
|
|
async def configured_auth() -> AsyncGenerator[MultiAuth, None]:
|
|
def respond(request: httpx2.Request) -> httpx2.Response:
|
|
bearer = parse_qs(request.content.decode())["token"][0]
|
|
source = (request.url.host or "").split(".")[0]
|
|
return httpx2.Response(
|
|
200,
|
|
json={
|
|
"active": bearer == source,
|
|
"client_id": "shared",
|
|
"scope": "",
|
|
},
|
|
)
|
|
|
|
async with httpx2.AsyncClient(transport=httpx2.MockTransport(respond)) as http:
|
|
yield MultiAuth(
|
|
verifiers=[
|
|
IntrospectionTokenVerifier(
|
|
introspection_url=f"https://{name}.example/introspect",
|
|
client_id="application",
|
|
client_secret="synthetic",
|
|
http_client=http,
|
|
)
|
|
for name in ("one", "two")
|
|
]
|
|
)
|
|
|
|
|
|
async def test_configured_sources_keep_sse_message_ownership_separate(
|
|
configured_auth: MultiAuth,
|
|
) -> None:
|
|
server = FastMCP("SSE identities", auth=configured_auth)
|
|
async with asgi_server(server, transport="sse") as running:
|
|
async with running.http_client() as http:
|
|
async with http.stream(
|
|
"GET",
|
|
running.url,
|
|
headers={"accept": "text/event-stream", "authorization": "Bearer one"},
|
|
) as response:
|
|
assert response.status_code == 200
|
|
message_path = None
|
|
async for line in response.aiter_lines():
|
|
if line.startswith("data: "):
|
|
message_path = line.removeprefix("data: ")
|
|
break
|
|
assert message_path is not None
|
|
message_url = str(httpx2.URL(running.url).join(message_path))
|
|
other = await http.post(
|
|
message_url,
|
|
headers={"authorization": "Bearer two"},
|
|
json=INITIALIZE_REQUEST,
|
|
)
|
|
assert other.status_code == 404
|
|
same = await http.post(
|
|
message_url,
|
|
headers={"authorization": "Bearer one"},
|
|
json=INITIALIZE_REQUEST,
|
|
)
|
|
assert same.status_code == 202
|
|
|
|
|
|
async def test_configured_sources_keep_saved_request_state_separate(
|
|
configured_auth: MultiAuth,
|
|
) -> None:
|
|
server = FastMCP("Continued requests", auth=configured_auth)
|
|
|
|
@server.tool
|
|
async def continue_work(ctx: Context) -> str | InputRequiredResult:
|
|
if ctx.request_state is not None:
|
|
return ctx.request_state
|
|
return _ask(
|
|
_elicit("confirmation", "Continue?", "answer"), "confirmation", "saved"
|
|
)
|
|
|
|
app = server.http_app(stateless_http=True, json_response=True)
|
|
with TestClient(app, base_url="http://127.0.0.1") as client:
|
|
headers = {
|
|
**MCP_HEADERS,
|
|
"mcp-protocol-version": "2026-07-28",
|
|
"mcp-method": "tools/call",
|
|
"mcp-name": "continue_work",
|
|
}
|
|
request = {
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "tools/call",
|
|
"params": {
|
|
"name": "continue_work",
|
|
"arguments": {},
|
|
"_meta": {
|
|
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
|
"io.modelcontextprotocol/clientCapabilities": {
|
|
"elicitation": {"form": {}}
|
|
},
|
|
},
|
|
},
|
|
}
|
|
first = client.post(
|
|
"/mcp", headers={**headers, "authorization": "Bearer one"}, json=request
|
|
)
|
|
assert first.status_code == 200, first.text
|
|
saved = first.json()["result"]["requestState"]
|
|
assert saved != "saved"
|
|
request["params"]["requestState"] = saved
|
|
other = client.post(
|
|
"/mcp", headers={**headers, "authorization": "Bearer two"}, json=request
|
|
)
|
|
assert "error" in other.json()
|
|
same = client.post(
|
|
"/mcp", headers={**headers, "authorization": "Bearer one"}, json=request
|
|
)
|
|
assert same.json()["result"]["content"][0]["text"] == "saved"
|
|
|
|
|
|
async def test_sdk_verifier_tokens_retain_original_client_id_after_serialization() -> (
|
|
None
|
|
):
|
|
sdk_result = SDKAccessToken(token="one", client_id="client", scopes=[])
|
|
|
|
class SDKVerifier(TokenVerifier):
|
|
async def verify_token(self, token: str) -> AccessToken | None:
|
|
return cast(AccessToken, sdk_result)
|
|
|
|
auth = MultiAuth(verifiers=SDKVerifier())
|
|
token = await auth.verify_token("one")
|
|
assert token is not None
|
|
assert sdk_result.client_id == "client"
|
|
restored = AccessToken.model_validate_json(token.model_dump_json())
|
|
assert restored.original_client_id == "client"
|
|
assert restored.client_id == token.client_id
|
|
|
|
|
|
async def test_source_token_subclass_fields_survive_qualification() -> None:
|
|
class DetailedToken(AccessToken):
|
|
tenant: str
|
|
|
|
verifier = StoredVerifier("one")
|
|
verifier.result = DetailedToken(
|
|
token="one", client_id="client", scopes=[], tenant="company"
|
|
)
|
|
auth = MultiAuth(verifiers=verifier)
|
|
token = await auth.verify_token("one")
|
|
assert isinstance(token, DetailedToken)
|
|
assert token.tenant == "company"
|
|
assert verifier.result.client_id == "client"
|
|
assert verifier.result.original_client_id is None
|
|
assert token.original_client_id == "client"
|
|
|
|
|
|
async def test_configured_issuer_list_keeps_task_ownership_distinct(
|
|
rsa_key_pair: RSAKeyPair,
|
|
) -> None:
|
|
issuers = ["https://company.example", "https://partner.example"]
|
|
verifier = JWTVerifier(public_key=rsa_key_pair.public_key, issuer=issuers)
|
|
auth = MultiAuth(verifiers=verifier)
|
|
scopes = []
|
|
for issuer in issuers:
|
|
bearer = rsa_key_pair.create_token(
|
|
issuer=issuer, subject="reader", additional_claims={"client_id": "shared"}
|
|
)
|
|
token = await auth.verify_token(bearer)
|
|
assert token is not None
|
|
assert token.original_client_id == "shared"
|
|
assert token.claims["iss"] == issuer
|
|
marker = auth_context_var.set(AuthenticatedUser(token))
|
|
try:
|
|
scopes.append(get_task_scope())
|
|
finally:
|
|
auth_context_var.reset(marker)
|
|
assert scopes[0] != scopes[1]
|
|
|
|
|
|
async def test_explicit_subjects_keep_task_ownership_distinct() -> None:
|
|
verifier = StoredVerifier("one")
|
|
verifier.result.claims = {"iss": "same"}
|
|
auth = MultiAuth(verifiers={"company": verifier})
|
|
scopes = []
|
|
for subject in ("alice", "bob"):
|
|
verifier.result.subject = subject
|
|
token = await auth.verify_token("one")
|
|
assert token is not None
|
|
assert token.subject == subject
|
|
assert "sub" not in token.claims
|
|
marker = auth_context_var.set(AuthenticatedUser(token))
|
|
try:
|
|
scopes.append(get_task_scope())
|
|
finally:
|
|
auth_context_var.reset(marker)
|
|
assert scopes[0] != scopes[1]
|
|
|
|
|
|
@pytest.mark.parametrize("kind", ["introspection", "jwt"])
|
|
def test_remote_provider_identity_tracks_its_configured_verifier(kind: str) -> None:
|
|
def configured(endpoint: str, source_id: str | None = None) -> MultiAuth:
|
|
verifier = (
|
|
IntrospectionTokenVerifier(
|
|
introspection_url=endpoint,
|
|
client_id="application",
|
|
client_secret="synthetic",
|
|
)
|
|
if kind == "introspection"
|
|
else JWTVerifier(jwks_uri=endpoint)
|
|
)
|
|
server = RemoteAuthProvider(
|
|
token_verifier=verifier,
|
|
authorization_servers=[],
|
|
base_url="https://service.example",
|
|
)
|
|
return MultiAuth(server=server, server_source_id=source_id)
|
|
|
|
first = configured("https://company.example/verify")
|
|
rebuilt = configured("https://company.example/verify")
|
|
other = configured("https://partner.example/verify")
|
|
assert first._source_ids == rebuilt._source_ids
|
|
assert first._source_ids != other._source_ids
|
|
assert configured("https://company.example/verify", "company")._source_ids == [
|
|
"company"
|
|
]
|
|
|
|
|
|
def test_proxy_source_identity_tracks_upstream_configuration() -> None:
|
|
def configured(
|
|
authority: str, secret: str = "synthetic", source_id: str | None = None
|
|
) -> MultiAuth:
|
|
proxy = OAuthProxy(
|
|
upstream_authorization_endpoint=f"https://{authority}/authorize",
|
|
upstream_token_endpoint=f"https://{authority}/token",
|
|
upstream_client_id="application",
|
|
upstream_client_secret=secret,
|
|
token_verifier=StoredVerifier("one"),
|
|
base_url="https://service.example",
|
|
)
|
|
return MultiAuth(server=proxy, server_source_id=source_id)
|
|
|
|
first = configured("company.example")
|
|
restarted = configured("company.example", "rotated")
|
|
other = configured("partner.example")
|
|
assert first._source_ids == restarted._source_ids
|
|
assert first._source_ids != other._source_ids
|
|
assert configured("company.example", source_id="company")._source_ids == ["company"]
|