mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-16 18:49:14 +02:00
995 lines
37 KiB
Python
995 lines
37 KiB
Python
"""Tests for server-side SEP-990 identity assertion (ID-JAG) support.
|
|
|
|
These exercise the OAuthProxy token endpoint end-to-end using a locally-minted
|
|
fake IdP JWT (a keypair is generated per test). The proxy's JWKS lookup is
|
|
served via httpx_mock, so no real network calls are made.
|
|
"""
|
|
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
import httpx2
|
|
import pytest
|
|
from joserfc import jwk, jwt
|
|
from key_value.aio.stores.memory import MemoryStore
|
|
from mcp.shared.auth import OAuthClientInformationFull
|
|
from pydantic import AnyUrl
|
|
|
|
from fastmcp import FastMCP
|
|
from fastmcp.server.auth import IdentityAssertion
|
|
from fastmcp.server.auth.identity_assertion import (
|
|
ID_JAG_GRANT_PROFILE,
|
|
ID_JAG_TYP,
|
|
JWT_BEARER_GRANT_TYPE,
|
|
)
|
|
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
|
from fastmcp.server.auth.oauth_proxy.models import ProxyDCRClient
|
|
from fastmcp.server.auth.providers.jwt import RSAKeyPair
|
|
from tests.server.auth.oauth_proxy.conftest import MockTokenVerifier
|
|
from tests.utilities.httpx2_mock import HTTPXMock
|
|
|
|
BASE_URL = "https://myserver.com"
|
|
ISSUER = "https://login.acme-corp.com"
|
|
JWKS_URI = "https://login.acme-corp.com/jwks"
|
|
RESOURCE = f"{BASE_URL}/mcp"
|
|
|
|
|
|
def _b64url_json(value: object) -> str:
|
|
"""Base64url-encode a JSON value as a JWT segment (no padding)."""
|
|
import base64
|
|
import json
|
|
|
|
raw = json.dumps(value).encode()
|
|
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
|
|
|
|
|
def _idp_jwks(key_pair: RSAKeyPair) -> dict:
|
|
"""Build a JWKS document from an RSA key pair's public key."""
|
|
public_key = jwk.import_key(key_pair.public_key, "RSA")
|
|
data = public_key.as_dict()
|
|
data["kid"] = "idp-key-1"
|
|
data["alg"] = "RS256"
|
|
return {"keys": [data]}
|
|
|
|
|
|
def _mint_id_jag(
|
|
key_pair: RSAKeyPair,
|
|
*,
|
|
issuer: str = ISSUER,
|
|
audience: str = BASE_URL,
|
|
subject: str = "employee@acme-corp.com",
|
|
typ: str = ID_JAG_TYP,
|
|
jti: str = "jti-1",
|
|
expires_in: int = 120,
|
|
scope: str | None = None,
|
|
include_iat: bool = True,
|
|
nbf_offset: int | None = None,
|
|
client_id: str | None = "mcp-client",
|
|
resource: str | None = RESOURCE,
|
|
claim_overrides: dict | None = None,
|
|
) -> str:
|
|
"""Mint a fake ID-JAG JWT with full control over header and claims.
|
|
|
|
`client_id` and `resource` default to values matching the standard test
|
|
client and this server's resource URL — SEP-990 binds the assertion to
|
|
both, and the exchange enforces the bindings. Pass `None` to omit.
|
|
`claim_overrides` is merged in last, for injecting malformed values.
|
|
"""
|
|
now = int(time.time())
|
|
header = {"alg": "RS256", "typ": typ, "kid": "idp-key-1"}
|
|
payload: dict = {
|
|
"iss": issuer,
|
|
"aud": audience,
|
|
"sub": subject,
|
|
"exp": now + expires_in,
|
|
"jti": jti,
|
|
}
|
|
if client_id is not None:
|
|
payload["client_id"] = client_id
|
|
if resource is not None:
|
|
payload["resource"] = resource
|
|
if include_iat:
|
|
payload["iat"] = now
|
|
if nbf_offset is not None:
|
|
payload["nbf"] = now + nbf_offset
|
|
if scope is not None:
|
|
payload["scope"] = scope
|
|
if claim_overrides:
|
|
payload.update(claim_overrides)
|
|
signing_key = jwk.import_key(key_pair.private_key.get_secret_value(), "RSA")
|
|
return jwt.encode(header, payload, signing_key, algorithms=["RS256"])
|
|
|
|
|
|
def _make_proxy(identity_assertion: IdentityAssertion | None) -> OAuthProxy:
|
|
return OAuthProxy(
|
|
upstream_authorization_endpoint="https://login.acme-corp.com/authorize",
|
|
upstream_token_endpoint="https://login.acme-corp.com/token",
|
|
upstream_client_id="upstream-client",
|
|
upstream_client_secret="upstream-secret",
|
|
token_verifier=MockTokenVerifier(),
|
|
base_url=BASE_URL,
|
|
jwt_signing_key="test-signing-key",
|
|
client_storage=MemoryStore(),
|
|
identity_assertion=identity_assertion,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def idp_key(rsa_key_pair: RSAKeyPair) -> RSAKeyPair:
|
|
return rsa_key_pair
|
|
|
|
|
|
@pytest.fixture
|
|
def config() -> IdentityAssertion:
|
|
# Explicit jwks_uris avoids OIDC discovery so only the JWKS fetch is mocked.
|
|
return IdentityAssertion(
|
|
trusted_issuers=[ISSUER],
|
|
jwks_uris={ISSUER: JWKS_URI},
|
|
)
|
|
|
|
|
|
async def _register_client(proxy: OAuthProxy) -> None:
|
|
"""Register the MCP client so the token endpoint can authenticate it."""
|
|
await proxy.register_client(
|
|
OAuthClientInformationFull(
|
|
client_id="mcp-client",
|
|
client_secret="mcp-secret",
|
|
redirect_uris=[AnyUrl("http://localhost/callback")],
|
|
grant_types=[JWT_BEARER_GRANT_TYPE],
|
|
)
|
|
)
|
|
|
|
|
|
async def _post_token(
|
|
proxy: OAuthProxy,
|
|
assertion: str,
|
|
*,
|
|
request_scope: str | None = None,
|
|
resource: str | None = None,
|
|
register: bool = True,
|
|
) -> httpx2.Response:
|
|
"""POST a jwt-bearer grant to the proxy's /token endpoint via an ASGI app.
|
|
|
|
When ``register`` is True the standard test client is registered first; pass
|
|
``register=False`` to exercise a client the caller has already stored.
|
|
"""
|
|
if register:
|
|
await _register_client(proxy)
|
|
app = FastMCP("ID-JAG Server", auth=proxy).http_app()
|
|
transport = httpx2.ASGITransport(app=app)
|
|
data = {
|
|
"grant_type": JWT_BEARER_GRANT_TYPE,
|
|
"assertion": assertion,
|
|
"client_id": "mcp-client",
|
|
"client_secret": "mcp-secret",
|
|
}
|
|
if request_scope is not None:
|
|
data["scope"] = request_scope
|
|
if resource is not None:
|
|
data["resource"] = resource
|
|
async with httpx2.AsyncClient(transport=transport, base_url=BASE_URL) as client:
|
|
return await client.post("/token", data=data)
|
|
|
|
|
|
class TestIdentityAssertionConfig:
|
|
def test_requires_trusted_issuers(self):
|
|
with pytest.raises(ValueError):
|
|
IdentityAssertion(trusted_issuers=[])
|
|
|
|
def test_rejects_blank_issuer(self):
|
|
with pytest.raises(ValueError):
|
|
IdentityAssertion(trusted_issuers=[" "])
|
|
|
|
@pytest.mark.parametrize("algorithm", ["ES256", "PS256", "RS384"])
|
|
def test_accepts_asymmetric_algorithm(self, algorithm: str):
|
|
IdentityAssertion(trusted_issuers=[ISSUER], algorithm=algorithm)
|
|
|
|
@pytest.mark.parametrize(
|
|
"algorithm", ["HS256", "EdDSA", "none", "", "RS999", "ES999"]
|
|
)
|
|
def test_rejects_incompatible_or_unsupported_algorithm(self, algorithm: str):
|
|
# HS* has no JWKS equivalent (shared secret, not a public key); EdDSA,
|
|
# typo'd variants like RS999, and other unimportable algorithms would
|
|
# otherwise surface as a 500 on the first exchange rather than a clean
|
|
# config error now. The allowlist is exactly what JWTVerifier supports.
|
|
with pytest.raises(ValueError):
|
|
IdentityAssertion(trusted_issuers=[ISSUER], algorithm=algorithm)
|
|
|
|
def test_defaults(self):
|
|
cfg = IdentityAssertion(trusted_issuers=[ISSUER])
|
|
assert cfg.access_token_expiry_seconds == 300
|
|
assert cfg.audience is None
|
|
assert cfg.jwks_uris is None
|
|
|
|
def test_per_issuer_algorithms_validated(self):
|
|
IdentityAssertion(trusted_issuers=[ISSUER], algorithms={ISSUER: "ES256"})
|
|
with pytest.raises(ValueError):
|
|
IdentityAssertion(trusted_issuers=[ISSUER], algorithms={ISSUER: "HS256"})
|
|
|
|
@pytest.mark.subprocess_heavy
|
|
def test_lazy_reexport_does_not_import_module(self):
|
|
# fastmcp.server.auth must not load identity_assertion (and its
|
|
# httpx2 dependency) eagerly — the re-export is lazy via __getattr__.
|
|
code = (
|
|
"import sys\n"
|
|
"import fastmcp.server.auth\n"
|
|
"loaded = [m for m in sys.modules if 'identity_assertion' in m]\n"
|
|
"assert not loaded, f'eagerly loaded: {loaded}'\n"
|
|
"from fastmcp.server.auth import IdentityAssertion\n"
|
|
"print('OK')\n"
|
|
)
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", code], capture_output=True, text=True
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
assert "OK" in result.stdout
|
|
|
|
|
|
class TestMetadataAdvertisement:
|
|
async def _metadata(self, proxy: OAuthProxy) -> dict:
|
|
app = FastMCP("ID-JAG Server", auth=proxy).http_app()
|
|
transport = httpx2.ASGITransport(app=app)
|
|
async with httpx2.AsyncClient(transport=transport, base_url=BASE_URL) as client:
|
|
resp = await client.get("/.well-known/oauth-authorization-server")
|
|
return resp.json()
|
|
|
|
async def test_advertises_grant_when_enabled(self, config: IdentityAssertion):
|
|
proxy = _make_proxy(config)
|
|
metadata = await self._metadata(proxy)
|
|
assert JWT_BEARER_GRANT_TYPE in metadata["grant_types_supported"]
|
|
assert (
|
|
ID_JAG_GRANT_PROFILE in metadata["authorization_grant_profiles_supported"]
|
|
)
|
|
|
|
async def test_not_advertised_when_disabled(self):
|
|
proxy = _make_proxy(None)
|
|
metadata = await self._metadata(proxy)
|
|
assert JWT_BEARER_GRANT_TYPE not in metadata["grant_types_supported"]
|
|
assert metadata.get("authorization_grant_profiles_supported") is None
|
|
|
|
async def test_advertises_none_auth_method_without_cimd(
|
|
self, config: IdentityAssertion
|
|
):
|
|
# DCR clients are public (token_endpoint_auth_method="none"), so when
|
|
# the jwt-bearer grant is advertised, `none` must be advertised too —
|
|
# even with CIMD (which also adds it) disabled.
|
|
proxy = OAuthProxy(
|
|
upstream_authorization_endpoint="https://login.acme-corp.com/authorize",
|
|
upstream_token_endpoint="https://login.acme-corp.com/token",
|
|
upstream_client_id="upstream-client",
|
|
upstream_client_secret="upstream-secret",
|
|
token_verifier=MockTokenVerifier(),
|
|
base_url=BASE_URL,
|
|
jwt_signing_key="test-signing-key",
|
|
client_storage=MemoryStore(),
|
|
identity_assertion=config,
|
|
enable_cimd=False,
|
|
)
|
|
metadata = await self._metadata(proxy)
|
|
assert JWT_BEARER_GRANT_TYPE in metadata["grant_types_supported"]
|
|
assert "none" in metadata["token_endpoint_auth_methods_supported"]
|
|
|
|
|
|
class TestTokenEndpoint:
|
|
async def test_happy_path_issues_token(
|
|
self,
|
|
idp_key: RSAKeyPair,
|
|
config: IdentityAssertion,
|
|
httpx_mock: HTTPXMock,
|
|
):
|
|
httpx_mock.add_response(url=JWKS_URI, json=_idp_jwks(idp_key))
|
|
proxy = _make_proxy(config)
|
|
assertion = _mint_id_jag(idp_key, scope="read write")
|
|
|
|
resp = await _post_token(proxy, assertion)
|
|
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["token_type"] == "Bearer"
|
|
assert body["access_token"]
|
|
# SEP-990: no refresh token is issued.
|
|
assert body.get("refresh_token") is None
|
|
assert body["expires_in"] == config.access_token_expiry_seconds
|
|
|
|
async def test_issued_token_carries_subject(
|
|
self,
|
|
idp_key: RSAKeyPair,
|
|
config: IdentityAssertion,
|
|
httpx_mock: HTTPXMock,
|
|
):
|
|
httpx_mock.add_response(url=JWKS_URI, json=_idp_jwks(idp_key))
|
|
proxy = _make_proxy(config)
|
|
proxy.set_mcp_path("/mcp")
|
|
assertion = _mint_id_jag(idp_key, subject="alice@acme-corp.com")
|
|
|
|
resp = await _post_token(proxy, assertion)
|
|
access_token = resp.json()["access_token"]
|
|
|
|
# The FastMCP-issued token validates via the proxy and exposes the subject.
|
|
loaded = await proxy.load_access_token(access_token)
|
|
assert loaded is not None
|
|
assert loaded.subject == "alice@acme-corp.com"
|
|
|
|
async def test_asserted_subject_flows_into_auth_context(
|
|
self,
|
|
idp_key: RSAKeyPair,
|
|
config: IdentityAssertion,
|
|
httpx_mock: HTTPXMock,
|
|
):
|
|
"""The issued token, verified via the same path the bearer-auth middleware
|
|
uses (`verify_token` -> `load_access_token`), exposes the asserted subject —
|
|
which is exactly what `get_access_token()` returns to a tool."""
|
|
httpx_mock.add_response(url=JWKS_URI, json=_idp_jwks(idp_key))
|
|
proxy = _make_proxy(config)
|
|
proxy.set_mcp_path("/mcp")
|
|
assertion = _mint_id_jag(
|
|
idp_key, subject="carol@acme-corp.com", scope="read write"
|
|
)
|
|
|
|
resp = await _post_token(proxy, assertion)
|
|
access_token = resp.json()["access_token"]
|
|
|
|
verified = await proxy.verify_token(access_token)
|
|
assert verified is not None
|
|
assert verified.subject == "carol@acme-corp.com"
|
|
assert "read" in verified.scopes and "write" in verified.scopes
|
|
|
|
async def test_grant_rejected_when_not_configured(
|
|
self,
|
|
idp_key: RSAKeyPair,
|
|
):
|
|
proxy = _make_proxy(None)
|
|
assertion = _mint_id_jag(idp_key)
|
|
|
|
resp = await _post_token(proxy, assertion)
|
|
|
|
assert resp.status_code == 400
|
|
assert resp.json()["error"] == "unsupported_grant_type"
|
|
|
|
async def test_request_scope_cannot_widen_assertion_scope(
|
|
self,
|
|
idp_key: RSAKeyPair,
|
|
config: IdentityAssertion,
|
|
httpx_mock: HTTPXMock,
|
|
):
|
|
"""A client whose assertion grants only `readonly` cannot obtain `admin`
|
|
by asking for it at the token endpoint. The request `scope` is not covered
|
|
by the signed assertion, so it may only narrow the granted set."""
|
|
httpx_mock.add_response(url=JWKS_URI, json=_idp_jwks(idp_key))
|
|
proxy = _make_proxy(config)
|
|
proxy.set_mcp_path("/mcp")
|
|
assertion = _mint_id_jag(idp_key, scope="readonly")
|
|
|
|
resp = await _post_token(proxy, assertion, request_scope="admin")
|
|
|
|
assert resp.status_code == 200
|
|
verified = await proxy.verify_token(resp.json()["access_token"])
|
|
assert verified is not None
|
|
assert "admin" not in verified.scopes
|
|
|
|
async def test_request_scope_narrows_assertion_scope(
|
|
self,
|
|
idp_key: RSAKeyPair,
|
|
config: IdentityAssertion,
|
|
httpx_mock: HTTPXMock,
|
|
):
|
|
"""When the request `scope` is a subset of the assertion's granted scopes,
|
|
the issued token carries only the intersection."""
|
|
httpx_mock.add_response(url=JWKS_URI, json=_idp_jwks(idp_key))
|
|
proxy = _make_proxy(config)
|
|
proxy.set_mcp_path("/mcp")
|
|
assertion = _mint_id_jag(idp_key, scope="read write")
|
|
|
|
resp = await _post_token(proxy, assertion, request_scope="read")
|
|
|
|
assert resp.status_code == 200
|
|
verified = await proxy.verify_token(resp.json()["access_token"])
|
|
assert verified is not None
|
|
assert "read" in verified.scopes
|
|
assert "write" not in verified.scopes
|
|
|
|
async def test_request_narrowing_preserves_required_scope(
|
|
self,
|
|
idp_key: RSAKeyPair,
|
|
httpx_mock: HTTPXMock,
|
|
):
|
|
"""A configured `required_scope` the assertion grants must always ride on
|
|
the issued token; the request `scope` may only narrow the optional
|
|
remainder. Requesting `read` must not drop the mandatory `admin`."""
|
|
httpx_mock.add_response(url=JWKS_URI, json=_idp_jwks(idp_key))
|
|
config = IdentityAssertion(
|
|
trusted_issuers=[ISSUER],
|
|
jwks_uris={ISSUER: JWKS_URI},
|
|
required_scopes=["admin"],
|
|
)
|
|
proxy = _make_proxy(config)
|
|
proxy.set_mcp_path("/mcp")
|
|
assertion = _mint_id_jag(idp_key, scope="admin read")
|
|
|
|
resp = await _post_token(proxy, assertion, request_scope="read")
|
|
|
|
assert resp.status_code == 200
|
|
verified = await proxy.verify_token(resp.json()["access_token"])
|
|
assert verified is not None
|
|
assert "admin" in verified.scopes
|
|
assert "read" in verified.scopes
|
|
|
|
async def test_request_for_required_scope_only(
|
|
self,
|
|
idp_key: RSAKeyPair,
|
|
httpx_mock: HTTPXMock,
|
|
):
|
|
"""Requesting only the required scope keeps it and narrows away the rest."""
|
|
httpx_mock.add_response(url=JWKS_URI, json=_idp_jwks(idp_key))
|
|
config = IdentityAssertion(
|
|
trusted_issuers=[ISSUER],
|
|
jwks_uris={ISSUER: JWKS_URI},
|
|
required_scopes=["admin"],
|
|
)
|
|
proxy = _make_proxy(config)
|
|
proxy.set_mcp_path("/mcp")
|
|
assertion = _mint_id_jag(idp_key, scope="admin read")
|
|
|
|
resp = await _post_token(proxy, assertion, request_scope="admin")
|
|
|
|
assert resp.status_code == 200
|
|
verified = await proxy.verify_token(resp.json()["access_token"])
|
|
assert verified is not None
|
|
assert "admin" in verified.scopes
|
|
assert "read" not in verified.scopes
|
|
|
|
|
|
@pytest.mark.httpx_mock(assert_all_responses_were_requested=False)
|
|
class TestValidationMatrix:
|
|
@pytest.fixture(autouse=True)
|
|
def _mock_jwks(self, idp_key: RSAKeyPair, httpx_mock: HTTPXMock):
|
|
# Optional: several matrix tests reject before any JWKS fetch happens.
|
|
httpx_mock.add_response(url=JWKS_URI, json=_idp_jwks(idp_key), is_optional=True)
|
|
|
|
async def test_untrusted_issuer_rejected(
|
|
self, idp_key: RSAKeyPair, config: IdentityAssertion
|
|
):
|
|
proxy = _make_proxy(config)
|
|
assertion = _mint_id_jag(idp_key, issuer="https://evil.example.com")
|
|
|
|
resp = await _post_token(proxy, assertion)
|
|
|
|
assert resp.status_code == 401
|
|
assert resp.json()["error"] == "invalid_grant"
|
|
|
|
async def test_wrong_audience_rejected(
|
|
self, idp_key: RSAKeyPair, config: IdentityAssertion
|
|
):
|
|
proxy = _make_proxy(config)
|
|
assertion = _mint_id_jag(idp_key, audience="https://other-server.com")
|
|
|
|
resp = await _post_token(proxy, assertion)
|
|
|
|
assert resp.status_code == 401
|
|
assert resp.json()["error"] == "invalid_grant"
|
|
|
|
async def test_wrong_typ_rejected(
|
|
self, idp_key: RSAKeyPair, config: IdentityAssertion
|
|
):
|
|
proxy = _make_proxy(config)
|
|
assertion = _mint_id_jag(idp_key, typ="JWT")
|
|
|
|
resp = await _post_token(proxy, assertion)
|
|
|
|
assert resp.status_code == 401
|
|
assert resp.json()["error"] == "invalid_grant"
|
|
|
|
async def test_expired_rejected(
|
|
self, idp_key: RSAKeyPair, config: IdentityAssertion
|
|
):
|
|
proxy = _make_proxy(config)
|
|
assertion = _mint_id_jag(idp_key, expires_in=-10)
|
|
|
|
resp = await _post_token(proxy, assertion)
|
|
|
|
assert resp.status_code == 401
|
|
assert resp.json()["error"] == "invalid_grant"
|
|
|
|
async def test_replayed_jti_rejected(
|
|
self, idp_key: RSAKeyPair, config: IdentityAssertion
|
|
):
|
|
proxy = _make_proxy(config)
|
|
assertion = _mint_id_jag(idp_key, jti="replay-me")
|
|
|
|
first = await _post_token(proxy, assertion)
|
|
second = await _post_token(proxy, assertion)
|
|
|
|
assert first.status_code == 200
|
|
assert second.status_code == 401
|
|
assert second.json()["error"] == "invalid_grant"
|
|
|
|
async def test_wrong_signature_rejected(
|
|
self, config: IdentityAssertion, rsa_key_pair_2: RSAKeyPair
|
|
):
|
|
# Sign with a different key than the one served in the JWKS.
|
|
other_key = rsa_key_pair_2
|
|
proxy = _make_proxy(config)
|
|
assertion = _mint_id_jag(other_key)
|
|
|
|
resp = await _post_token(proxy, assertion)
|
|
|
|
assert resp.status_code == 401
|
|
assert resp.json()["error"] == "invalid_grant"
|
|
|
|
async def test_missing_required_scope_rejected(self, idp_key: RSAKeyPair):
|
|
config = IdentityAssertion(
|
|
trusted_issuers=[ISSUER],
|
|
jwks_uris={ISSUER: JWKS_URI},
|
|
required_scopes=["admin"],
|
|
)
|
|
proxy = _make_proxy(config)
|
|
assertion = _mint_id_jag(idp_key, scope="read")
|
|
|
|
resp = await _post_token(proxy, assertion)
|
|
|
|
assert resp.status_code == 401
|
|
assert resp.json()["error"] == "invalid_grant"
|
|
|
|
async def test_future_nbf_rejected(
|
|
self, idp_key: RSAKeyPair, config: IdentityAssertion
|
|
):
|
|
# An ID-JAG whose not-before (`nbf`) claim is in the future is not yet
|
|
# valid and must be rejected.
|
|
proxy = _make_proxy(config)
|
|
assertion = _mint_id_jag(idp_key, nbf_offset=300)
|
|
|
|
resp = await _post_token(proxy, assertion)
|
|
|
|
assert resp.status_code == 401
|
|
assert resp.json()["error"] == "invalid_grant"
|
|
|
|
async def test_past_nbf_accepted(
|
|
self, idp_key: RSAKeyPair, config: IdentityAssertion
|
|
):
|
|
# An `nbf` in the past means the assertion is already valid.
|
|
proxy = _make_proxy(config)
|
|
assertion = _mint_id_jag(idp_key, nbf_offset=-60)
|
|
|
|
resp = await _post_token(proxy, assertion)
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json()["access_token"]
|
|
|
|
async def test_aud_matching_advertised_issuer_with_trailing_slash(
|
|
self, idp_key: RSAKeyPair, config: IdentityAssertion
|
|
):
|
|
# Metadata advertises the issuer exactly as pydantic renders base_url —
|
|
# a bare domain gains a trailing slash. An IdP that sets `aud` to that
|
|
# advertised value verbatim must be accepted, not rejected on the slash.
|
|
proxy = _make_proxy(config)
|
|
assertion = _mint_id_jag(idp_key, audience=f"{BASE_URL}/")
|
|
|
|
resp = await _post_token(proxy, assertion)
|
|
|
|
assert resp.status_code == 200
|
|
|
|
@pytest.mark.parametrize("claim", ["exp", "iat", "nbf"])
|
|
@pytest.mark.parametrize("bad_value", ["not-a-number", [], {}, True])
|
|
async def test_non_numeric_temporal_claim_rejected(
|
|
self,
|
|
idp_key: RSAKeyPair,
|
|
config: IdentityAssertion,
|
|
claim: str,
|
|
bad_value: object,
|
|
):
|
|
# A validly-signed assertion could still carry a malformed exp/iat/nbf
|
|
# (a misbehaving IdP); comparing against it must map to invalid_grant,
|
|
# not an unhandled TypeError. `True`/`False` are excluded even though
|
|
# bool subclasses int in Python.
|
|
proxy = _make_proxy(config)
|
|
assertion = _mint_id_jag(idp_key, claim_overrides={claim: bad_value})
|
|
|
|
resp = await _post_token(proxy, assertion)
|
|
|
|
assert resp.status_code == 401
|
|
assert resp.json()["error"] == "invalid_grant"
|
|
|
|
@pytest.mark.parametrize("bad_jti", [["a", "b"], {"x": 1}, 42])
|
|
async def test_non_string_jti_rejected(
|
|
self,
|
|
idp_key: RSAKeyPair,
|
|
config: IdentityAssertion,
|
|
bad_jti: object,
|
|
):
|
|
# An array/object jti is unhashable — the cache lookup would raise
|
|
# TypeError (a 500) instead of a clean invalid_grant.
|
|
proxy = _make_proxy(config)
|
|
assertion = _mint_id_jag(idp_key, claim_overrides={"jti": bad_jti})
|
|
|
|
resp = await _post_token(proxy, assertion)
|
|
|
|
assert resp.status_code == 401
|
|
assert resp.json()["error"] == "invalid_grant"
|
|
|
|
async def test_non_object_payload_rejected(
|
|
self, idp_key: RSAKeyPair, config: IdentityAssertion
|
|
):
|
|
# A JWT with a valid typ header but a JSON-array payload must map to a
|
|
# clean invalid_grant, not an unhandled 500 from calling `.get()` on a list.
|
|
header = _b64url_json({"alg": "RS256", "typ": ID_JAG_TYP, "kid": "idp-key-1"})
|
|
payload = _b64url_json([])
|
|
assertion = f"{header}.{payload}.signature"
|
|
|
|
resp = await _post_token(proxy=_make_proxy(config), assertion=assertion)
|
|
|
|
assert resp.status_code == 401
|
|
assert resp.json()["error"] == "invalid_grant"
|
|
|
|
async def test_non_object_header_rejected(
|
|
self, idp_key: RSAKeyPair, config: IdentityAssertion
|
|
):
|
|
# Same class of bug as the payload case: a JSON-array JOSE header must
|
|
# map to invalid_grant, not a 500 from `.get()` on a list.
|
|
header = _b64url_json([])
|
|
payload = _b64url_json({"iss": ISSUER, "sub": "x"})
|
|
assertion = f"{header}.{payload}.signature"
|
|
|
|
resp = await _post_token(proxy=_make_proxy(config), assertion=assertion)
|
|
|
|
assert resp.status_code == 401
|
|
assert resp.json()["error"] == "invalid_grant"
|
|
|
|
async def test_assertion_for_other_client_rejected(
|
|
self, idp_key: RSAKeyPair, config: IdentityAssertion
|
|
):
|
|
# SEP-990: the IdP signs which client the assertion was minted for.
|
|
# A registered client must not be able to redeem an assertion minted
|
|
# for a different client — with public clients, this signed binding is
|
|
# the control that stops cross-client redemption of leaked assertions.
|
|
assertion = _mint_id_jag(idp_key, client_id="some-other-client")
|
|
|
|
resp = await _post_token(proxy=_make_proxy(config), assertion=assertion)
|
|
|
|
assert resp.status_code == 401
|
|
assert resp.json()["error"] == "invalid_grant"
|
|
|
|
async def test_wrong_client_binding_does_not_consume_jti(
|
|
self, idp_key: RSAKeyPair, config: IdentityAssertion
|
|
):
|
|
# An assertion presented by the wrong client must be rejected WITHOUT
|
|
# its jti being recorded as consumed -- otherwise the client it
|
|
# actually belongs to would find that same jti already "replayed"
|
|
# when it (or a retry) presents a correctly-bound assertion. Two
|
|
# distinct, validly-signed tokens sharing one jti value is exactly
|
|
# the scenario jti replay tracking cares about, regardless of what
|
|
# else differs between them.
|
|
proxy = _make_proxy(config)
|
|
shared_jti = "jti-shared-client"
|
|
wrong_client = _mint_id_jag(
|
|
idp_key, client_id="some-other-client", jti=shared_jti
|
|
)
|
|
|
|
rejected = await _post_token(proxy, wrong_client)
|
|
assert rejected.status_code == 401
|
|
assert rejected.json()["error"] == "invalid_grant"
|
|
|
|
correct_client = _mint_id_jag(idp_key, client_id="mcp-client", jti=shared_jti)
|
|
accepted = await _post_token(proxy, correct_client, register=False)
|
|
assert accepted.status_code == 200
|
|
|
|
async def test_wrong_resource_binding_does_not_consume_jti(
|
|
self, idp_key: RSAKeyPair, config: IdentityAssertion
|
|
):
|
|
proxy = _make_proxy(config)
|
|
shared_jti = "jti-shared-resource"
|
|
wrong_resource = _mint_id_jag(
|
|
idp_key,
|
|
resource="https://other-server.example.com/mcp",
|
|
jti=shared_jti,
|
|
)
|
|
|
|
rejected = await _post_token(proxy, wrong_resource)
|
|
assert rejected.status_code == 401
|
|
assert rejected.json()["error"] == "invalid_grant"
|
|
|
|
correct_resource = _mint_id_jag(idp_key, jti=shared_jti) # default = RESOURCE
|
|
accepted = await _post_token(proxy, correct_resource, register=False)
|
|
assert accepted.status_code == 200
|
|
|
|
async def test_assertion_without_client_id_rejected(
|
|
self, idp_key: RSAKeyPair, config: IdentityAssertion
|
|
):
|
|
assertion = _mint_id_jag(idp_key, client_id=None)
|
|
|
|
resp = await _post_token(proxy=_make_proxy(config), assertion=assertion)
|
|
|
|
assert resp.status_code == 401
|
|
assert resp.json()["error"] == "invalid_grant"
|
|
|
|
async def test_assertion_for_other_resource_rejected(
|
|
self, idp_key: RSAKeyPair, config: IdentityAssertion
|
|
):
|
|
# The signed resource claim governs: an assertion minted for server A
|
|
# must not be redeemable at server B behind the same IdP.
|
|
assertion = _mint_id_jag(
|
|
idp_key, resource="https://other-server.example.com/mcp"
|
|
)
|
|
|
|
resp = await _post_token(proxy=_make_proxy(config), assertion=assertion)
|
|
|
|
assert resp.status_code == 401
|
|
assert resp.json()["error"] == "invalid_grant"
|
|
|
|
async def test_assertion_without_resource_rejected(
|
|
self, idp_key: RSAKeyPair, config: IdentityAssertion
|
|
):
|
|
# When the proxy knows its resource URL, an assertion that names no
|
|
# resource cannot be audience-restricted per SEP-990 and is rejected.
|
|
assertion = _mint_id_jag(idp_key, resource=None)
|
|
|
|
resp = await _post_token(proxy=_make_proxy(config), assertion=assertion)
|
|
|
|
assert resp.status_code == 401
|
|
assert resp.json()["error"] == "invalid_grant"
|
|
|
|
async def test_resource_mismatch_rejected(
|
|
self,
|
|
idp_key: RSAKeyPair,
|
|
config: IdentityAssertion,
|
|
):
|
|
# RFC 8707: a token request naming a different resource must get
|
|
# invalid_target (mirrors the authorize() invariant), not a token
|
|
# for this server. Rejection happens before any JWKS fetch.
|
|
proxy = _make_proxy(config)
|
|
assertion = _mint_id_jag(idp_key)
|
|
|
|
resp = await _post_token(
|
|
proxy, assertion, resource="https://other-server.example.com/mcp"
|
|
)
|
|
|
|
assert resp.status_code == 400
|
|
assert resp.json()["error"] == "invalid_target"
|
|
|
|
async def test_matching_resource_accepted(
|
|
self,
|
|
idp_key: RSAKeyPair,
|
|
config: IdentityAssertion,
|
|
):
|
|
# JWKS served by the class's autouse _mock_jwks fixture.
|
|
proxy = _make_proxy(config)
|
|
assertion = _mint_id_jag(idp_key)
|
|
|
|
resp = await _post_token(proxy, assertion, resource=f"{BASE_URL}/mcp")
|
|
|
|
assert resp.status_code == 200
|
|
|
|
async def test_jti_cache_does_not_grow_past_capacity(
|
|
self, idp_key: RSAKeyPair, config: IdentityAssertion
|
|
):
|
|
# Once the JTI cache is full of still-valid entries, further fresh
|
|
# assertions are rejected as overloaded WITHOUT being inserted, so the
|
|
# cache never grows beyond its cap.
|
|
proxy = _make_proxy(config)
|
|
validator = proxy._identity_assertion_validator
|
|
assert validator is not None
|
|
validator._jti_cache_max_size = 2
|
|
future = time.time() + 120
|
|
validator._jti_cache = {"filler-a": future, "filler-b": future}
|
|
|
|
for i in range(3):
|
|
assertion = _mint_id_jag(idp_key, jti=f"fresh-{i}")
|
|
resp = await _post_token(proxy, assertion)
|
|
assert resp.status_code == 401
|
|
assert resp.json()["error"] == "invalid_grant"
|
|
|
|
assert len(validator._jti_cache) == 2
|
|
|
|
|
|
class TestAlgorithmConfig:
|
|
"""Non-RS256 issuers work when `algorithm` is configured.
|
|
|
|
Lives outside TestValidationMatrix because that class's autouse
|
|
`_mock_jwks` fixture pre-registers an RSA JWKS for the same URL, and
|
|
pytest-httpx serves first-registered responses first.
|
|
"""
|
|
|
|
async def test_es256_issuer_supported_via_algorithm_config(
|
|
self, httpx_mock: HTTPXMock
|
|
):
|
|
ec_key = jwk.ECKey.generate_key("P-256")
|
|
jwks_entry = ec_key.as_dict(private=False)
|
|
jwks_entry["kid"] = "idp-ec-1"
|
|
jwks_entry["alg"] = "ES256"
|
|
httpx_mock.add_response(url=JWKS_URI, json={"keys": [jwks_entry]})
|
|
|
|
now = int(time.time())
|
|
header = {"alg": "ES256", "typ": ID_JAG_TYP, "kid": "idp-ec-1"}
|
|
payload = {
|
|
"iss": ISSUER,
|
|
"aud": BASE_URL,
|
|
"sub": "employee@acme-corp.com",
|
|
"exp": now + 120,
|
|
"iat": now,
|
|
"jti": "jti-es256-1",
|
|
"client_id": "mcp-client",
|
|
"resource": RESOURCE,
|
|
}
|
|
assertion = jwt.encode(header, payload, ec_key, algorithms=["ES256"])
|
|
|
|
es_config = IdentityAssertion(
|
|
trusted_issuers=[ISSUER],
|
|
jwks_uris={ISSUER: JWKS_URI},
|
|
algorithm="ES256",
|
|
)
|
|
resp = await _post_token(proxy=_make_proxy(es_config), assertion=assertion)
|
|
assert resp.status_code == 200
|
|
|
|
|
|
class TestIssuerKeyDiscovery:
|
|
async def test_jwks_discovered_via_oidc(
|
|
self, idp_key: RSAKeyPair, httpx_mock: HTTPXMock
|
|
):
|
|
# No explicit jwks_uris: the validator must discover the JWKS URI from
|
|
# the issuer's OIDC configuration document.
|
|
oidc_config_url = ISSUER.rstrip("/") + "/.well-known/openid-configuration"
|
|
httpx_mock.add_response(url=oidc_config_url, json={"jwks_uri": JWKS_URI})
|
|
httpx_mock.add_response(url=JWKS_URI, json=_idp_jwks(idp_key))
|
|
|
|
proxy = _make_proxy(IdentityAssertion(trusted_issuers=[ISSUER]))
|
|
assertion = _mint_id_jag(idp_key)
|
|
|
|
resp = await _post_token(proxy, assertion)
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json()["access_token"]
|
|
|
|
async def test_non_object_discovery_body_rejected(
|
|
self, idp_key: RSAKeyPair, httpx_mock: HTTPXMock
|
|
):
|
|
# A discovery endpoint returning valid JSON that isn't an object (e.g.
|
|
# a bare array) must map to invalid_grant, not a 500 on `.get()`.
|
|
oidc_config_url = ISSUER.rstrip("/") + "/.well-known/openid-configuration"
|
|
httpx_mock.add_response(url=oidc_config_url, json=[])
|
|
|
|
proxy = _make_proxy(IdentityAssertion(trusted_issuers=[ISSUER]))
|
|
assertion = _mint_id_jag(idp_key)
|
|
|
|
resp = await _post_token(proxy, assertion)
|
|
|
|
assert resp.status_code == 401
|
|
assert resp.json()["error"] == "invalid_grant"
|
|
|
|
async def test_failed_discovery_backs_off(
|
|
self, idp_key: RSAKeyPair, httpx_mock: HTTPXMock
|
|
):
|
|
# Discovery runs before signature verification, so repeated garbage
|
|
# with a trusted iss must not turn into an outbound HTTP call per
|
|
# request: after a failure, subsequent requests fast-fail without
|
|
# fetching until the cooldown elapses.
|
|
oidc_config_url = ISSUER.rstrip("/") + "/.well-known/openid-configuration"
|
|
httpx_mock.add_exception(
|
|
httpx2.ConnectError("connection refused"), url=oidc_config_url
|
|
)
|
|
|
|
proxy = _make_proxy(IdentityAssertion(trusted_issuers=[ISSUER]))
|
|
|
|
first = await _post_token(proxy, _mint_id_jag(idp_key, jti="jti-d1"))
|
|
assert first.status_code == 401
|
|
|
|
second = await _post_token(
|
|
proxy, _mint_id_jag(idp_key, jti="jti-d2"), register=False
|
|
)
|
|
assert second.status_code == 401
|
|
# Only the FIRST request hit the network; the second fast-failed
|
|
# inside the cooldown window.
|
|
assert len(httpx_mock.get_requests()) == 1
|
|
|
|
|
|
class TestRevocation:
|
|
async def test_revoked_id_jag_token_rejected(
|
|
self,
|
|
idp_key: RSAKeyPair,
|
|
httpx_mock: HTTPXMock,
|
|
):
|
|
# ID-JAG access tokens are self-contained — nothing upstream knows
|
|
# them, so revocation must be tracked locally. After revoke_token,
|
|
# load_access_token rejects the token for its remaining lifetime.
|
|
httpx_mock.add_response(url=JWKS_URI, json=_idp_jwks(idp_key))
|
|
proxy = _make_proxy(
|
|
IdentityAssertion(trusted_issuers=[ISSUER], jwks_uris={ISSUER: JWKS_URI})
|
|
)
|
|
resp = await _post_token(proxy, _mint_id_jag(idp_key))
|
|
assert resp.status_code == 200
|
|
issued = resp.json()["access_token"]
|
|
|
|
loaded = await proxy.load_access_token(issued)
|
|
assert loaded is not None
|
|
|
|
await proxy.revoke_token(loaded)
|
|
|
|
assert await proxy.load_access_token(issued) is None
|
|
|
|
|
|
class TestGrantTypeEnforcement:
|
|
"""The proxy dispatches the jwt-bearer grant itself, so it must enforce the
|
|
registered-grant-type constraint the SDK would otherwise apply: only clients
|
|
registered for the jwt-bearer grant may present an ID-JAG. DCR adds the grant
|
|
to registered clients when identity assertion is enabled."""
|
|
|
|
async def test_client_not_registered_for_jwt_bearer_rejected(
|
|
self,
|
|
idp_key: RSAKeyPair,
|
|
config: IdentityAssertion,
|
|
):
|
|
# The grant-type check rejects before any assertion validation, so no
|
|
# JWKS fetch occurs.
|
|
proxy = _make_proxy(config)
|
|
# A client registered only for the standard grants (e.g. before identity
|
|
# assertion was enabled). Stored directly so the DCR enabled-path does not
|
|
# add jwt-bearer for us.
|
|
await proxy._client_store.put(
|
|
key="mcp-client",
|
|
value=ProxyDCRClient(
|
|
client_id="mcp-client",
|
|
client_secret=None,
|
|
redirect_uris=[AnyUrl("http://localhost/callback")],
|
|
grant_types=["authorization_code", "refresh_token"],
|
|
token_endpoint_auth_method="none",
|
|
),
|
|
)
|
|
assertion = _mint_id_jag(idp_key, scope="read")
|
|
|
|
resp = await _post_token(proxy, assertion, register=False)
|
|
|
|
assert resp.status_code == 400
|
|
assert resp.json()["error"] == "unsupported_grant_type"
|
|
|
|
async def test_dcr_adds_jwt_bearer_when_enabled(self, config: IdentityAssertion):
|
|
proxy = _make_proxy(config)
|
|
await proxy.register_client(
|
|
OAuthClientInformationFull(
|
|
client_id="dcr-client",
|
|
redirect_uris=[AnyUrl("http://localhost/callback")],
|
|
grant_types=["authorization_code", "refresh_token"],
|
|
)
|
|
)
|
|
|
|
client = await proxy.get_client("dcr-client")
|
|
|
|
assert client is not None
|
|
assert JWT_BEARER_GRANT_TYPE in client.grant_types
|
|
|
|
async def test_dcr_does_not_add_jwt_bearer_when_disabled(self):
|
|
proxy = _make_proxy(None)
|
|
await proxy.register_client(
|
|
OAuthClientInformationFull(
|
|
client_id="dcr-client",
|
|
redirect_uris=[AnyUrl("http://localhost/callback")],
|
|
grant_types=["authorization_code", "refresh_token"],
|
|
)
|
|
)
|
|
|
|
client = await proxy.get_client("dcr-client")
|
|
|
|
assert client is not None
|
|
assert JWT_BEARER_GRANT_TYPE not in client.grant_types
|
|
|
|
async def test_dcr_registered_client_can_exchange(
|
|
self,
|
|
idp_key: RSAKeyPair,
|
|
config: IdentityAssertion,
|
|
httpx_mock: HTTPXMock,
|
|
):
|
|
"""A client that registers via DCR without the jwt-bearer grant can still
|
|
exchange an ID-JAG, because the enabled-path adds the grant on registration."""
|
|
httpx_mock.add_response(url=JWKS_URI, json=_idp_jwks(idp_key))
|
|
proxy = _make_proxy(config)
|
|
await proxy.register_client(
|
|
OAuthClientInformationFull(
|
|
client_id="mcp-client",
|
|
redirect_uris=[AnyUrl("http://localhost/callback")],
|
|
grant_types=["authorization_code", "refresh_token"],
|
|
)
|
|
)
|
|
assertion = _mint_id_jag(idp_key, scope="read")
|
|
|
|
resp = await _post_token(proxy, assertion, register=False)
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json()["access_token"]
|