fastmcp/tests/server/auth/oauth_proxy/test_identity_assertion.py

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"]