49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled
398 lines
15 KiB
Python
398 lines
15 KiB
Python
"""Server-side SEP-990 Identity Assertion Authorization Grant (RFC 7523 jwt-bearer) handling."""
|
|
|
|
import secrets
|
|
import time
|
|
|
|
import httpx
|
|
import pytest
|
|
from httpx import ASGITransport
|
|
from pydantic import AnyHttpUrl
|
|
from starlette.applications import Starlette
|
|
|
|
from mcp.server.auth.provider import (
|
|
AccessToken,
|
|
AuthorizationCode,
|
|
AuthorizationParams,
|
|
IdentityAssertionParams,
|
|
OAuthAuthorizationServerProvider,
|
|
RefreshToken,
|
|
TokenError,
|
|
)
|
|
from mcp.server.auth.routes import build_metadata, create_auth_routes
|
|
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
|
|
from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken
|
|
|
|
ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag"
|
|
VALID_ASSERTION = "valid-id-jag"
|
|
CONFIDENTIAL_CLIENT_ID = "enterprise-client"
|
|
CONFIDENTIAL_CLIENT_SECRET = "enterprise-secret"
|
|
|
|
|
|
class IdentityAssertionProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]):
|
|
"""A provider that implements `exchange_identity_assertion`; everything else is unused here."""
|
|
|
|
def __init__(self) -> None:
|
|
self.clients: dict[str, OAuthClientInformationFull] = {}
|
|
self.tokens: dict[str, AccessToken] = {}
|
|
self.last_params: IdentityAssertionParams | None = None
|
|
|
|
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
|
return self.clients.get(client_id)
|
|
|
|
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
|
assert client_info.client_id is not None
|
|
self.clients[client_info.client_id] = client_info
|
|
|
|
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
|
|
raise NotImplementedError
|
|
|
|
async def load_authorization_code(
|
|
self, client: OAuthClientInformationFull, authorization_code: str
|
|
) -> AuthorizationCode | None:
|
|
raise NotImplementedError
|
|
|
|
async def exchange_authorization_code(
|
|
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
|
|
) -> OAuthToken:
|
|
raise NotImplementedError
|
|
|
|
async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None:
|
|
raise NotImplementedError
|
|
|
|
async def exchange_refresh_token(
|
|
self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]
|
|
) -> OAuthToken:
|
|
raise NotImplementedError
|
|
|
|
async def load_access_token(self, token: str) -> AccessToken | None:
|
|
return self.tokens.get(token)
|
|
|
|
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
|
|
raise NotImplementedError
|
|
|
|
async def exchange_identity_assertion(
|
|
self, client: OAuthClientInformationFull, params: IdentityAssertionParams
|
|
) -> OAuthToken:
|
|
self.last_params = params
|
|
# Stand-in for RFC 7523 §3 / SEP-990 §5.1 assertion validation.
|
|
if params.assertion != VALID_ASSERTION:
|
|
raise TokenError(error="invalid_grant", error_description="assertion is not valid")
|
|
assert client.client_id is not None
|
|
scopes = params.scopes or ["mcp"]
|
|
access = f"access_{secrets.token_hex(16)}"
|
|
self.tokens[access] = AccessToken(
|
|
token=access,
|
|
client_id=client.client_id,
|
|
scopes=scopes,
|
|
expires_at=int(time.time()) + 3600,
|
|
resource=params.resource,
|
|
subject="assertion-user",
|
|
)
|
|
return OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=" ".join(scopes))
|
|
|
|
|
|
@pytest.fixture
|
|
def provider() -> IdentityAssertionProvider:
|
|
prov = IdentityAssertionProvider()
|
|
# Pre-register a confidential client (DCR refuses the grant; see the DCR test).
|
|
prov.clients[CONFIDENTIAL_CLIENT_ID] = OAuthClientInformationFull(
|
|
client_id=CONFIDENTIAL_CLIENT_ID,
|
|
client_secret=CONFIDENTIAL_CLIENT_SECRET,
|
|
redirect_uris=None,
|
|
grant_types=[JWT_BEARER_GRANT_TYPE],
|
|
token_endpoint_auth_method="client_secret_post",
|
|
scope="mcp",
|
|
)
|
|
return prov
|
|
|
|
|
|
@pytest.fixture
|
|
def app(provider: IdentityAssertionProvider) -> Starlette:
|
|
routes = create_auth_routes(
|
|
provider,
|
|
issuer_url=AnyHttpUrl("https://auth.example.com"),
|
|
client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=["mcp"]),
|
|
revocation_options=RevocationOptions(enabled=False),
|
|
identity_assertion_enabled=True,
|
|
)
|
|
return Starlette(routes=routes)
|
|
|
|
|
|
@pytest.fixture
|
|
async def client(app: Starlette):
|
|
transport = ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="https://auth.example.com") as http:
|
|
yield http
|
|
|
|
|
|
def assertion_form(**overrides: str) -> dict[str, str]:
|
|
form = {
|
|
"grant_type": JWT_BEARER_GRANT_TYPE,
|
|
"client_id": CONFIDENTIAL_CLIENT_ID,
|
|
"client_secret": CONFIDENTIAL_CLIENT_SECRET,
|
|
"assertion": VALID_ASSERTION,
|
|
}
|
|
form.update(overrides)
|
|
return form
|
|
|
|
|
|
def test_build_metadata_advertises_id_jag_profile_when_enabled():
|
|
enabled = build_metadata(
|
|
AnyHttpUrl("https://auth.example.com"),
|
|
None,
|
|
ClientRegistrationOptions(),
|
|
RevocationOptions(),
|
|
supports_identity_assertion=True,
|
|
)
|
|
assert JWT_BEARER_GRANT_TYPE in (enabled.grant_types_supported or [])
|
|
assert enabled.authorization_grant_profiles_supported == [ID_JAG_GRANT_PROFILE]
|
|
# The grant is confidential-only, so the `none` auth method is NOT advertised.
|
|
assert "none" not in (enabled.token_endpoint_auth_methods_supported or [])
|
|
|
|
disabled = build_metadata(
|
|
AnyHttpUrl("https://auth.example.com"),
|
|
None,
|
|
ClientRegistrationOptions(),
|
|
RevocationOptions(),
|
|
)
|
|
assert JWT_BEARER_GRANT_TYPE not in (disabled.grant_types_supported or [])
|
|
assert disabled.authorization_grant_profiles_supported is None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_metadata_endpoint_lists_id_jag_profile(client: httpx.AsyncClient):
|
|
response = await client.get("/.well-known/oauth-authorization-server")
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert JWT_BEARER_GRANT_TYPE in body["grant_types_supported"]
|
|
assert body["authorization_grant_profiles_supported"] == [ID_JAG_GRANT_PROFILE]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_identity_assertion_success(client: httpx.AsyncClient, provider: IdentityAssertionProvider):
|
|
response = await client.post("/token", data=assertion_form(scope="mcp", resource="https://mcp.example.com/mcp"))
|
|
|
|
assert response.status_code == 200, response.content
|
|
body = response.json()
|
|
assert body["token_type"] == "Bearer"
|
|
assert "issued_token_type" not in body # plain RFC 6749 response under jwt-bearer
|
|
|
|
issued = await provider.load_access_token(body["access_token"])
|
|
assert issued is not None
|
|
assert issued.scopes == ["mcp"]
|
|
assert issued.subject == "assertion-user"
|
|
|
|
assert provider.last_params is not None
|
|
assert provider.last_params.assertion == VALID_ASSERTION
|
|
assert provider.last_params.scopes == ["mcp"]
|
|
assert provider.last_params.resource == "https://mcp.example.com/mcp"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_identity_assertion_invalid_assertion(client: httpx.AsyncClient):
|
|
response = await client.post("/token", data=assertion_form(assertion="forged"))
|
|
|
|
assert response.status_code == 400
|
|
assert response.json() == {"error": "invalid_grant", "error_description": "assertion is not valid"}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_identity_assertion_rejected_when_disabled(provider: IdentityAssertionProvider):
|
|
routes = create_auth_routes(
|
|
provider,
|
|
issuer_url=AnyHttpUrl("https://auth.example.com"),
|
|
client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=["mcp"]),
|
|
revocation_options=RevocationOptions(enabled=False),
|
|
identity_assertion_enabled=False,
|
|
)
|
|
app = Starlette(routes=routes)
|
|
transport = ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="https://auth.example.com") as http:
|
|
response = await http.post("/token", data=assertion_form())
|
|
|
|
assert response.status_code == 400
|
|
assert response.json()["error"] == "unsupported_grant_type"
|
|
assert provider.last_params is None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_identity_assertion_rejects_public_client(client: httpx.AsyncClient, provider: IdentityAssertionProvider):
|
|
"""A public (auth method 'none') client cannot use the grant, even if it presents a valid assertion."""
|
|
provider.clients["public-client"] = OAuthClientInformationFull(
|
|
client_id="public-client",
|
|
redirect_uris=None,
|
|
grant_types=[JWT_BEARER_GRANT_TYPE],
|
|
token_endpoint_auth_method="none",
|
|
scope="mcp",
|
|
)
|
|
|
|
response = await client.post(
|
|
"/token",
|
|
data={"grant_type": JWT_BEARER_GRANT_TYPE, "client_id": "public-client", "assertion": VALID_ASSERTION},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert response.json()["error"] == "unauthorized_client"
|
|
assert provider.last_params is None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_identity_assertion_rejects_secretless_confidential_client(
|
|
client: httpx.AsyncClient, provider: IdentityAssertionProvider
|
|
):
|
|
"""A client registered with a secret-based method but no stored secret fails authentication.
|
|
|
|
`ClientAuthenticator` rejects this misconfiguration as `invalid_client`, so the request never
|
|
reaches the jwt-bearer handler or the provider hook.
|
|
"""
|
|
provider.clients["secretless-client"] = OAuthClientInformationFull(
|
|
client_id="secretless-client",
|
|
client_secret=None,
|
|
redirect_uris=None,
|
|
grant_types=[JWT_BEARER_GRANT_TYPE],
|
|
token_endpoint_auth_method="client_secret_post",
|
|
scope="mcp",
|
|
)
|
|
|
|
response = await client.post(
|
|
"/token",
|
|
data={"grant_type": JWT_BEARER_GRANT_TYPE, "client_id": "secretless-client", "assertion": VALID_ASSERTION},
|
|
)
|
|
|
|
assert response.status_code == 401
|
|
body = response.json()
|
|
assert body["error"] == "invalid_client"
|
|
assert "no stored secret" in body["error_description"]
|
|
assert provider.last_params is None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_malformed_request_missing_assertion_is_invalid_request(client: httpx.AsyncClient):
|
|
"""A jwt-bearer request without the required `assertion` fails validation with invalid_request."""
|
|
response = await client.post(
|
|
"/token",
|
|
data={
|
|
"grant_type": JWT_BEARER_GRANT_TYPE,
|
|
"client_id": CONFIDENTIAL_CLIENT_ID,
|
|
"client_secret": CONFIDENTIAL_CLIENT_SECRET,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert response.json()["error"] == "invalid_request"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_client_without_the_grant_registered_is_rejected(
|
|
client: httpx.AsyncClient, provider: IdentityAssertionProvider
|
|
):
|
|
"""A confidential client whose registration omits the jwt-bearer grant is refused the grant."""
|
|
provider.clients["no-grant-client"] = OAuthClientInformationFull(
|
|
client_id="no-grant-client",
|
|
client_secret="s",
|
|
redirect_uris=None,
|
|
grant_types=["authorization_code"],
|
|
token_endpoint_auth_method="client_secret_post",
|
|
scope="mcp",
|
|
)
|
|
|
|
response = await client.post(
|
|
"/token",
|
|
data={
|
|
"grant_type": JWT_BEARER_GRANT_TYPE,
|
|
"client_id": "no-grant-client",
|
|
"client_secret": "s",
|
|
"assertion": VALID_ASSERTION,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert response.json()["error"] == "unsupported_grant_type"
|
|
assert provider.last_params is None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_dcr_refuses_to_register_the_jwt_bearer_grant(
|
|
client: httpx.AsyncClient, provider: IdentityAssertionProvider
|
|
):
|
|
"""Dynamic client registration rejects the jwt-bearer grant; the ID-JAG flow needs pre-registration."""
|
|
response = await client.post(
|
|
"/register",
|
|
json={
|
|
"redirect_uris": ["https://client.example.com/callback"],
|
|
"token_endpoint_auth_method": "client_secret_post",
|
|
"grant_types": ["authorization_code", JWT_BEARER_GRANT_TYPE],
|
|
"response_types": ["code"],
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
body = response.json()
|
|
assert body["error"] == "invalid_client_metadata"
|
|
assert JWT_BEARER_GRANT_TYPE in body["error_description"]
|
|
|
|
# A registration without the jwt-bearer grant still succeeds and is stored.
|
|
ok = await client.post(
|
|
"/register",
|
|
json={
|
|
"redirect_uris": ["https://client.example.com/callback"],
|
|
"token_endpoint_auth_method": "client_secret_post",
|
|
"grant_types": ["authorization_code"],
|
|
"response_types": ["code"],
|
|
},
|
|
)
|
|
assert ok.status_code == 201
|
|
assert ok.json()["client_id"] in provider.clients
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_default_provider_rejects_identity_assertion():
|
|
"""A provider that does not override `exchange_identity_assertion` rejects with unsupported_grant_type."""
|
|
|
|
class BareProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]):
|
|
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
|
raise NotImplementedError
|
|
|
|
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
|
raise NotImplementedError
|
|
|
|
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
|
|
raise NotImplementedError
|
|
|
|
async def load_authorization_code(
|
|
self, client: OAuthClientInformationFull, authorization_code: str
|
|
) -> AuthorizationCode | None:
|
|
raise NotImplementedError
|
|
|
|
async def exchange_authorization_code(
|
|
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
|
|
) -> OAuthToken:
|
|
raise NotImplementedError
|
|
|
|
async def load_refresh_token(
|
|
self, client: OAuthClientInformationFull, refresh_token: str
|
|
) -> RefreshToken | None:
|
|
raise NotImplementedError
|
|
|
|
async def exchange_refresh_token(
|
|
self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]
|
|
) -> OAuthToken:
|
|
raise NotImplementedError
|
|
|
|
async def load_access_token(self, token: str) -> AccessToken | None:
|
|
raise NotImplementedError
|
|
|
|
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
|
|
raise NotImplementedError
|
|
|
|
bare = BareProvider()
|
|
client_info = OAuthClientInformationFull(
|
|
redirect_uris=None,
|
|
client_id="c",
|
|
grant_types=[JWT_BEARER_GRANT_TYPE],
|
|
)
|
|
params = IdentityAssertionParams(assertion=VALID_ASSERTION)
|
|
with pytest.raises(TokenError) as excinfo:
|
|
await bare.exchange_identity_assertion(client_info, params)
|
|
assert excinfo.value.error == "unsupported_grant_type"
|