[v1.x] Add AuthSettings.validate_token_resource to check a bearer token's resource (#3451)
Some checks failed
Main branch checks / checks (push) Has been cancelled
Some checks failed
Main branch checks / checks (push) Has been cancelled
This commit is contained in:
parent
2106335745
commit
8c2fa6eaba
2 changed files with 175 additions and 9 deletions
|
|
@ -6,6 +6,7 @@ import time
|
|||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.authentication import AuthCredentials
|
||||
from starlette.datastructures import Headers
|
||||
from starlette.requests import Request
|
||||
|
|
@ -265,6 +266,56 @@ class TestBearerAuthBackend:
|
|||
assert user.access_token == valid_access_token
|
||||
|
||||
|
||||
class SingleTokenVerifier:
|
||||
"""A `TokenVerifier` that knows exactly one token."""
|
||||
|
||||
def __init__(self, access_token: AccessToken) -> None:
|
||||
self.access_token = access_token
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
return self.access_token if token == self.access_token.token else None
|
||||
|
||||
|
||||
RS = "https://api.example.com/mcp"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("resource_server_url", "token_resource", "accepted"),
|
||||
[
|
||||
(None, "https://other.example.com/mcp", True), # nothing configured to compare against
|
||||
(None, None, True),
|
||||
(RS, None, False), # the verifier did not report what the token was issued for
|
||||
(RS, RS, True),
|
||||
(RS, RS + "/", True),
|
||||
(RS, "https://API.EXAMPLE.COM:443/mcp", True), # same URL, different spelling
|
||||
(RS, "https://api.example.com", False),
|
||||
(RS, RS + "/child", False),
|
||||
(RS, "https://api.example.com/other", False),
|
||||
(RS, "https://other.example.com/mcp", False),
|
||||
(RS, "api.example.com", False), # not a URL
|
||||
],
|
||||
)
|
||||
async def test_backend_accepts_only_tokens_issued_for_its_resource(
|
||||
resource_server_url: str | None, token_resource: str | None, accepted: bool
|
||||
):
|
||||
"""With `resource_server_url` set, only a token whose `resource` (RFC 8707) is that URL is
|
||||
accepted and anything else is treated like an unrecognized token (spec-mandated audience
|
||||
check); without it the verifier's answer stands (SDK-defined, the default wiring)."""
|
||||
token = AccessToken(token="t", client_id="c", scopes=["read"], resource=token_resource)
|
||||
backend = BearerAuthBackend(
|
||||
SingleTokenVerifier(token),
|
||||
resource_server_url=AnyHttpUrl(resource_server_url) if resource_server_url else None,
|
||||
)
|
||||
|
||||
result = await backend.authenticate(Request({"type": "http", "headers": [(b"authorization", b"Bearer t")]}))
|
||||
|
||||
if accepted:
|
||||
assert result is not None and result[1].access_token == token
|
||||
else:
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
class TestRequireAuthMiddleware:
|
||||
"""Tests for the RequireAuthMiddleware class."""
|
||||
|
|
|
|||
33
tests/server/auth/test_settings.py
Normal file
33
tests/server/auth/test_settings.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import warnings
|
||||
|
||||
import pytest
|
||||
from pydantic import AnyHttpUrl, ValidationError
|
||||
|
||||
from mcp.server.auth.settings import AuthSettings
|
||||
|
||||
ISSUER = AnyHttpUrl("https://auth.example.com")
|
||||
RESOURCE = AnyHttpUrl("https://mcp.example.com/mcp")
|
||||
|
||||
|
||||
def test_validate_token_resource_requires_a_resource_server_url():
|
||||
"""SDK-defined: asking the bearer gate to compare tokens against `resource_server_url` without
|
||||
configuring one is refused at construction time rather than silently comparing nothing."""
|
||||
AuthSettings(issuer_url=ISSUER, resource_server_url=RESOURCE, validate_token_resource=True)
|
||||
with pytest.raises(ValidationError, match="validate_token_resource requires resource_server_url"):
|
||||
AuthSettings(issuer_url=ISSUER, resource_server_url=None, validate_token_resource=True)
|
||||
|
||||
|
||||
def test_leaving_validate_token_resource_unset_warns_when_a_resource_server_url_is_configured():
|
||||
"""Unset behaves as False but says so: a resource server that has not chosen gets a
|
||||
`DeprecationWarning` pointing at its own `AuthSettings(...)` call (3.0 flips the default)."""
|
||||
with pytest.warns(DeprecationWarning, match="validate_token_resource") as record:
|
||||
settings = AuthSettings(issuer_url=ISSUER, resource_server_url=RESOURCE)
|
||||
assert settings.validate_token_resource is None
|
||||
assert record[0].filename == __file__
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kwargs", [{"validate_token_resource": False}, {"resource_server_url": None}])
|
||||
def test_an_explicit_choice_or_no_resource_server_url_does_not_warn(kwargs: dict[str, object]):
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error")
|
||||
AuthSettings.model_validate({"issuer_url": ISSUER, "resource_server_url": RESOURCE, **kwargs})
|
||||
Loading…
Reference in a new issue