Split the registration request model from the registered-client record (#3181)
This commit is contained in:
parent
b9422f1c9b
commit
d3ffe87960
3 changed files with 573 additions and 129 deletions
|
|
@ -2940,6 +2940,17 @@ REQUIREMENTS: dict[str, Requirement] = {
|
|||
transports=("streamable-http",),
|
||||
note="Auth is enforced at the HTTP layer; Cache-Control is an HTTP header.",
|
||||
),
|
||||
"hosting:auth:as:register-echo": Requirement(
|
||||
source="sdk",
|
||||
behavior=(
|
||||
"The bundled registration endpoint returns all registered metadata about the client "
|
||||
"in its 201 response (RFC 7591 §3.2.1) - the client's `application_type` rather than a "
|
||||
"substituted default, and `client_secret_expires_at` (0 when the secret never expires) "
|
||||
"whenever a `client_secret` is issued."
|
||||
),
|
||||
transports=("streamable-http",),
|
||||
note="Auth is enforced at the HTTP layer; the bundled AS is an ASGI app.",
|
||||
),
|
||||
"hosting:auth:as:register-error-response": Requirement(
|
||||
source="sdk",
|
||||
behavior=(
|
||||
|
|
@ -3660,6 +3671,19 @@ REQUIREMENTS: dict[str, Requirement] = {
|
|||
transports=("streamable-http",),
|
||||
note="OAuth is HTTP-only.",
|
||||
),
|
||||
"client-auth:dcr:substituted-metadata": Requirement(
|
||||
source="sdk",
|
||||
behavior=(
|
||||
"A 201 registration response whose echoed metadata the server substituted (RFC 7591 §3.2.1) - "
|
||||
"an unregistered application_type, null redirect_uris, extra grant types - completes the flow; "
|
||||
"substituted credentials the authorization-code flow cannot apply (an unimplemented "
|
||||
"token_endpoint_auth_method; private_key_jwt, whose assertion it has no key to sign; or a "
|
||||
"secret-based method with no client_secret issued) are instead reported as an "
|
||||
"OAuthRegistrationError before the record is persisted or authorization begins."
|
||||
),
|
||||
transports=("streamable-http",),
|
||||
note="OAuth is HTTP-only.",
|
||||
),
|
||||
"client-auth:dcr": Requirement(
|
||||
source=f"{SPEC_BASE_URL}/basic/authorization#dynamic-client-registration",
|
||||
behavior=(
|
||||
|
|
|
|||
|
|
@ -239,10 +239,42 @@ async def test_registration_with_invalid_metadata_is_rejected_with_400(
|
|||
|
||||
bad_scope = await http.post("/register", json=body | {"scope": "forbidden"})
|
||||
assert bad_scope.status_code == 400
|
||||
body = bad_scope.json()
|
||||
assert body["error"] == "invalid_client_metadata"
|
||||
bad_scope_body = bad_scope.json()
|
||||
assert bad_scope_body["error"] == "invalid_client_metadata"
|
||||
# The description embeds a set difference whose ordering is not stable, so assert the prefix.
|
||||
assert body["error_description"].startswith("Requested scopes are not valid: ")
|
||||
assert bad_scope_body["error_description"].startswith("Requested scopes are not valid: ")
|
||||
|
||||
# The server holds no client key to verify a private_key_jwt assertion, so it refuses to
|
||||
# confirm a registration whose every token request it would then reject (RFC 7591 §3.2.2).
|
||||
unsignable = await http.post("/register", json=body | {"token_endpoint_auth_method": "private_key_jwt"})
|
||||
assert unsignable.status_code == 400
|
||||
assert unsignable.json() == snapshot(
|
||||
{
|
||||
"error": "invalid_client_metadata",
|
||||
"error_description": "token_endpoint_auth_method 'private_key_jwt' is not supported",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@requirement("hosting:auth:as:register-echo")
|
||||
@pytest.mark.parametrize("application_type", ["web", "native"])
|
||||
async def test_registration_response_echoes_the_registered_application_type(
|
||||
as_app: tuple[httpx2.AsyncClient, InMemoryAuthorizationServerProvider],
|
||||
application_type: str,
|
||||
) -> None:
|
||||
"""The 201 body reflects the application_type the client registered (RFC 7591 §3.2.1)."""
|
||||
http, _ = as_app
|
||||
body = oauth_client_metadata().model_dump(mode="json", exclude_none=True)
|
||||
|
||||
response = await http.post("/register", json=body | {"application_type": application_type})
|
||||
|
||||
assert response.status_code == 201
|
||||
echoed = response.json()
|
||||
assert echoed["application_type"] == application_type
|
||||
# A secret was issued and no expiry is configured, so RFC 7591 §3.2.1 requires the
|
||||
# response to carry client_secret_expires_at, with 0 (present, not omitted) for "never".
|
||||
assert echoed["client_secret"]
|
||||
assert echoed["client_secret_expires_at"] == 0
|
||||
|
||||
|
||||
@requirement("hosting:auth:as:redirect-uri-binding")
|
||||
|
|
|
|||
|
|
@ -17,14 +17,16 @@ import mcp_types as types
|
|||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import ListToolsResult, Tool
|
||||
from pydantic import AnyHttpUrl
|
||||
from pydantic import AnyHttpUrl, AnyUrl
|
||||
|
||||
from mcp.client.auth import OAuthFlowError, OAuthRegistrationError
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata, ProtectedResourceMetadata
|
||||
from tests.interaction._connect import BASE_URL, mounted_app
|
||||
from tests.interaction._requirements import requirement
|
||||
from tests.interaction.auth._harness import (
|
||||
REDIRECT_URI,
|
||||
InMemoryTokenStorage,
|
||||
RecordedRequest,
|
||||
auth_settings,
|
||||
connect_with_oauth,
|
||||
|
|
@ -174,6 +176,91 @@ async def test_a_400_from_the_registration_endpoint_surfaces_as_a_registration_e
|
|||
assert [r.path for r in recorded if r.path in ("/authorize", "/token")] == []
|
||||
|
||||
|
||||
@requirement("client-auth:dcr:substituted-metadata")
|
||||
async def test_a_registration_response_with_substituted_metadata_completes_the_flow() -> None:
|
||||
"""A 201 whose echoed metadata differs from the request still yields a working client.
|
||||
|
||||
The shim replaces the real `/register` with a body that echoes an `application_type`
|
||||
outside OIDC Registration's set, a null `redirect_uris`, and an extra grant type - a
|
||||
substitution RFC 7591 §3.2.1 permits. The registration proceeds and, after `/register`
|
||||
stopped answering, the client authorizes and exchanges a token as normal.
|
||||
"""
|
||||
recorded, on_request = record_requests()
|
||||
provider = InMemoryAuthorizationServerProvider()
|
||||
server = Server("guarded", on_list_tools=list_tools)
|
||||
client_id = "substituted-client"
|
||||
provider.clients[client_id] = OAuthClientInformationFull(
|
||||
client_id=client_id,
|
||||
client_secret="s3cr3t",
|
||||
redirect_uris=[AnyUrl(REDIRECT_URI)],
|
||||
token_endpoint_auth_method="client_secret_post",
|
||||
scope="mcp",
|
||||
)
|
||||
body = json.dumps(
|
||||
{
|
||||
"client_id": client_id,
|
||||
"client_secret": "s3cr3t",
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
"application_type": "confidential",
|
||||
"redirect_uris": None,
|
||||
"grant_types": ["authorization_code", "refresh_token", "client_credentials"],
|
||||
}
|
||||
).encode()
|
||||
app_shim = shim(serve={"/register": (201, body)})
|
||||
storage = InMemoryTokenStorage()
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with connect_with_oauth(
|
||||
server, provider=provider, storage=storage, app_shim=app_shim, on_request=on_request
|
||||
) as (client, _):
|
||||
result = await client.list_tools()
|
||||
|
||||
assert result.tools[0].name == snapshot("probe")
|
||||
assert storage.client_info is not None
|
||||
assert storage.client_info.client_id == client_id
|
||||
assert storage.client_info.application_type == "confidential"
|
||||
assert [r.path for r in recorded].index("/register") < [r.path for r in recorded].index("/token")
|
||||
|
||||
|
||||
@requirement("client-auth:dcr:substituted-metadata")
|
||||
@pytest.mark.parametrize(
|
||||
"credentials",
|
||||
[
|
||||
pytest.param(
|
||||
{"client_secret": "s3cr3t", "token_endpoint_auth_method": "client_secret_jwt"}, id="unimplemented"
|
||||
),
|
||||
pytest.param({"client_secret": "s3cr3t", "token_endpoint_auth_method": "private_key_jwt"}, id="unsignable"),
|
||||
pytest.param({"token_endpoint_auth_method": "client_secret_post"}, id="secret-not-issued"),
|
||||
],
|
||||
)
|
||||
async def test_a_registration_assigning_unusable_credentials_surfaces_as_a_registration_error(
|
||||
credentials: dict[str, str],
|
||||
) -> None:
|
||||
"""A 201 assigning credentials this flow cannot apply is a registration error.
|
||||
|
||||
RFC 7591 §3.2.1 leaves it to the client to judge whether a substituted value makes the
|
||||
registration usable. The authorization-code flow authenticates with the minted secret, so
|
||||
an unimplemented method, `private_key_jwt` (an assertion it has no key to sign), and a
|
||||
secret-based method with no secret issued are all unusable; each is reported before
|
||||
the record is stored or any authorize/token request is made.
|
||||
"""
|
||||
recorded, on_request = record_requests()
|
||||
provider = InMemoryAuthorizationServerProvider()
|
||||
server = Server("guarded", on_list_tools=list_tools)
|
||||
body = json.dumps({"client_id": "unusable", **credentials}).encode()
|
||||
app_shim = shim(serve={"/register": (201, body)})
|
||||
storage = InMemoryTokenStorage()
|
||||
|
||||
with anyio.fail_after(5):
|
||||
with pytest.RaisesGroup(pytest.RaisesExc(OAuthRegistrationError), flatten_subgroups=True):
|
||||
await connect_with_oauth(
|
||||
server, provider=provider, storage=storage, app_shim=app_shim, on_request=on_request
|
||||
).__aenter__()
|
||||
|
||||
assert storage.client_info is None
|
||||
assert [r.path for r in recorded if r.path in ("/authorize", "/token")] == []
|
||||
|
||||
|
||||
@requirement("client-auth:prm-resource-mismatch")
|
||||
async def test_prm_with_a_mismatched_resource_aborts_the_flow_before_authorize() -> None:
|
||||
"""A PRM document whose `resource` does not cover the server URL aborts the flow.
|
||||
|
|
|
|||
Loading…
Reference in a new issue