Support RFC 8693 token exchange for enterprise IdP flows (SEP-990) (#2988)

Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
Marcelo Trylesinski 2026-06-26 17:57:10 +02:00 • committed by GitHub
commit c0ecb70e24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1830 additions and 14 deletions

View file

@ -1451,6 +1451,37 @@ client_metadata = OAuthClientMetadata(
Under OIDC, omitting `application_type` defaults to `"web"`, which an authorization server may reject for the `localhost` redirect URIs native clients use; sending `"native"` avoids that. Non-OIDC servers ignore the parameter.
### Identity Assertion Authorization Grant for enterprise IdP flows (SEP-990)
The SDK now supports SEP-990's enterprise identity-provider policy controls. The client presents an Identity Assertion Authorization Grant (ID-JAG) - a signed JWT issued by the enterprise IdP - to the MCP authorization server using the RFC 7523 jwt-bearer grant (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, the ID-JAG as `assertion`), and receives an MCP access token. This matches the SEP-990 normative profile and interoperates with the other MCP SDKs. (Leg 1 - exchanging the user's IdP ID token for the ID-JAG against the IdP - is deployment-specific and out of scope for the SDK.) This is additive and opt-in on both sides; existing flows are unchanged.
On the client, `IdentityAssertionOAuthProvider` (in `mcp.client.auth.extensions.identity_assertion`) is an `httpx.Auth` that posts the jwt-bearer request. The ID-JAG is supplied lazily through an async `assertion_provider(audience, resource)` callback - `audience` is the authorization server's issuer (the ID-JAG `aud`) and `resource` is the MCP server's identifier (the ID-JAG `resource` claim):
```python
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider
async def fetch_id_jag(audience: str, resource: str) -> str:
# The ID-JAG must carry `audience` as `aud` and `resource` as its `resource` claim.
return await my_idp.issue_id_jag(audience=audience, resource=resource)
provider = IdentityAssertionOAuthProvider(
server_url="https://mcp.example.com/mcp",
storage=my_token_storage,
client_id="enterprise-mcp-client",
client_secret="enterprise-mcp-secret",
issuer="https://auth.example.com",
assertion_provider=fetch_id_jag,
)
```
SEP-990 §5.1 requires the client to authenticate; this SDK currently requires a shared secret, so `client_secret` is mandatory (`token_endpoint_auth_method` chooses `client_secret_post` (default) or `client_secret_basic`; the spec also permits `private_key_jwt`). The authorization server is configuration, not discovery: `issuer` is the AS the client is provisioned for, authorization-server metadata is fetched from that issuer's RFC 8414 well-known, and the resource server is never asked which AS to use - so a hostile resource server cannot redirect the ID-JAG or secret.
On the authorization server, set `AuthSettings(identity_assertion_enabled=True)` (or pass `identity_assertion_enabled=True` to `create_auth_routes`) and implement `exchange_identity_assertion` on your `OAuthAuthorizationServerProvider`. The method receives an `IdentityAssertionParams` (the ID-JAG `assertion`, requested scopes, and request `resource`) and returns a plain RFC 6749 `OAuthToken`. The flag gates both metadata advertisement and the token endpoint: when off, `/token` rejects the grant with `unsupported_grant_type` even if the provider implements the hook. When on, the metadata advertises the jwt-bearer grant and the `urn:ietf:params:oauth:grant-profile:id-jag` profile in `authorization_grant_profiles_supported` (the discovery mechanism per ext-auth §6).
The implementation is responsible for validating the assertion per RFC 7523 §3 and SEP-990 §5.1 - verify the signature/`iss`/`exp`/`typ`, require `aud` to be this AS, require the ID-JAG's `client_id` claim to match the authenticated client, audience-restrict the issued token to the ID-JAG's `resource` claim (not the client-controlled request `resource`), and derive scopes from the ID-JAG rather than granting the request verbatim. See `examples/snippets/servers/identity_assertion_server.py`, which fails closed. Two hardening points are enforced by the SDK: the handler rejects clients without a stored secret before calling the hook (and `ClientAuthenticator` itself now refuses a secret-based auth method registered without a secret), and Dynamic Client Registration refuses the jwt-bearer grant so the ID-JAG flow requires a pre-registered confidential client.
### 2025-11-25 and 2026-07-28 protocol fields modeled
`mcp_types` models the 2025-11-25 and 2026-07-28 protocol fields (e.g. `resultType`, `ttlMs`/`cacheScope` on cacheable results, `inputResponses`/`requestState` on retried requests), so inbound payloads carrying these keys parse into typed fields and round-trip. `ttlMs`/`cacheScope` default to `0`/`"private"` (immediately stale, not shared-cacheable); `resultType` defaults to `"complete"` on concrete results (`None` on `EmptyResult`); the server strips all of them from the wire at pre-2026 versions.

View file

@ -0,0 +1,82 @@
"""Client side of SEP-990 (enterprise IdP policy controls).
`IdentityAssertionOAuthProvider` presents an Identity Assertion Authorization Grant (ID-JAG) issued
by the enterprise IdP to the MCP authorization server using the RFC 7523 jwt-bearer grant
(`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, ID-JAG as `assertion`), and receives an
MCP access token. No browser redirect or dynamic client registration is involved.
Obtaining the ID-JAG (logging into the IdP and the leg-1 exchange against it) is deployment-specific
and out of scope for the SDK; supply it through the `assertion_provider` callback. The callback
receives the authorization server's issuer (the ID-JAG `aud`) and the MCP server's resource
identifier (the ID-JAG `resource` claim). SEP-990 requires a confidential client, so a client secret
is mandatory, and `issuer` is the authorization server the credentials are provisioned for - the
provider fetches metadata from that issuer's well-known and never asks the resource server which AS
to use.
"""
import asyncio
import httpx
from mcp import ClientSession
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
class InMemoryTokenStorage:
"""Demo in-memory token storage."""
def __init__(self) -> None:
self.tokens: OAuthToken | None = None
self.client_info: OAuthClientInformationFull | None = None
async def get_tokens(self) -> OAuthToken | None:
return self.tokens
async def set_tokens(self, tokens: OAuthToken) -> None:
self.tokens = tokens
async def get_client_info(self) -> OAuthClientInformationFull | None:
return self.client_info
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
self.client_info = client_info
async def fetch_id_jag(audience: str, resource: str) -> str:
"""Return the ID-JAG to present.
`audience` is the MCP authorization server's issuer (the ID-JAG `aud` claim); `resource` is the
MCP server's RFC 9728 identifier (the ID-JAG `resource` claim, which the AS audience-restricts
the issued token against). In production this exchanges the user's IdP ID token for an ID-JAG
against the enterprise identity provider.
"""
raise NotImplementedError("Obtain the ID-JAG from your enterprise identity provider")
async def main() -> None:
oauth_auth = IdentityAssertionOAuthProvider(
server_url="http://localhost:8001/mcp",
storage=InMemoryTokenStorage(),
client_id="enterprise-mcp-client",
client_secret="enterprise-mcp-secret",
issuer="http://localhost:8001",
assertion_provider=fetch_id_jag,
scope="user",
)
async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as http_client:
async with streamable_http_client("http://localhost:8001/mcp", http_client=http_client) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
print(f"Available tools: {[tool.name for tool in tools.tools]}")
def run() -> None:
asyncio.run(main())
if __name__ == "__main__":
run()

View file

@ -21,4 +21,5 @@ completion-client = "clients.completion_client:main"
direct-execution-server = "servers.direct_execution:main"
display-utilities-client = "clients.display_utilities:main"
oauth-client = "clients.oauth_client:run"
identity-assertion-client = "clients.identity_assertion_client:run"
elicitation-client = "clients.url_elicitation_client:run"

View file

@ -0,0 +1,103 @@
"""Authorization-server side of SEP-990 (enterprise IdP policy controls).
An authorization server enables the Identity Assertion Authorization Grant by setting
`identity_assertion_enabled=True` and implementing `exchange_identity_assertion` on its provider.
The client presents the IdP-issued ID-JAG using the RFC 7523 jwt-bearer grant; the provider
validates the assertion and mints an MCP access token.
Validating the ID-JAG is the provider's responsibility and is only stubbed here. A real
implementation MUST, per RFC 7523 §3 and SEP-990 §5.1:
- verify the JWT signature, `iss`, and `exp`, and that `typ` is `oauth-id-jag+jwt`;
- require `aud` to identify this authorization server;
- require the ID-JAG's `client_id` claim to match the authenticated client;
- audience-restrict the issued token to the resource named in the ID-JAG's `resource` claim
(NOT the client-supplied `params.resource`);
- derive the granted scopes from the ID-JAG and policy.
`_decode_and_validate_id_jag` below raises `NotImplementedError` so this snippet fails closed and
forces a real implementation. Wire the returned routes into a Starlette app with
`create_auth_routes(..., identity_assertion_enabled=True)`, or set
`AuthSettings(identity_assertion_enabled=True)` with `MCPServer`/`Server`.
"""
import secrets
import time
from dataclasses import dataclass
from mcp.server.auth.provider import (
AccessToken,
AuthorizationCode,
IdentityAssertionParams,
OAuthAuthorizationServerProvider,
RefreshToken,
)
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
@dataclass
class IdJagClaims:
"""The trusted claims extracted from a validated ID-JAG."""
subject: str # the end user the ID-JAG was issued for
client_id: str # the ID-JAG `client_id` claim; §5.1 requires it to match the authenticated client
resource: str # the MCP server the issued token must be audience-restricted to
scopes: list[str]
class IdentityAssertionProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]):
"""Authorization-server provider that accepts an ID-JAG via the RFC 7523 jwt-bearer grant."""
def __init__(self) -> None:
self.access_tokens: dict[str, AccessToken] = {}
# SEP-990 clients are pre-registered out of band (DCR refuses the grant) and must be
# confidential. `get_client` must return them, or the token endpoint 401s before the
# exchange runs. Real deployments load these from their own store.
self.clients: dict[str, OAuthClientInformationFull] = {
"enterprise-mcp-client": OAuthClientInformationFull(
client_id="enterprise-mcp-client",
client_secret="enterprise-mcp-secret",
redirect_uris=None,
grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"],
token_endpoint_auth_method="client_secret_post",
)
}
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
return self.clients.get(client_id)
async def exchange_identity_assertion(
self, client: OAuthClientInformationFull, params: IdentityAssertionParams
) -> OAuthToken:
claims = self._decode_and_validate_id_jag(params.assertion, client)
access_token = f"access_{secrets.token_hex(16)}"
self.access_tokens[access_token] = AccessToken(
token=access_token,
client_id=claims.client_id,
scopes=claims.scopes,
expires_at=int(time.time()) + 3600,
# Bind to the resource from the validated ID-JAG, not the client-controlled request.
resource=claims.resource,
subject=claims.subject,
)
# No refresh token: SEP-990 relies on the IdP re-issuing ID-JAGs to control session lifetime.
return OAuthToken(
access_token=access_token,
token_type="Bearer",
expires_in=3600,
scope=" ".join(claims.scopes),
)
def _decode_and_validate_id_jag(self, assertion: str, client: OAuthClientInformationFull) -> IdJagClaims:
"""Verify the ID-JAG and return its trusted claims, or reject the request.
Replace this stub with real RFC 7523 §3 / SEP-990 §5.1 validation. It fails closed - it
raises rather than trusting the assertion - so a copy of this example cannot accidentally
accept unverified tokens. RFC 7523 §3.1 / RFC 6749 §5.2 specify `invalid_grant` for a
rejected assertion.
"""
raise NotImplementedError("Validate the ID-JAG (signature, iss/aud/exp/typ, client_id, resource)")
async def load_access_token(self, token: str) -> AccessToken | None:
return self.access_tokens.get(token)

View file

@ -0,0 +1,213 @@
"""SEP-990 Identity Assertion Authorization Grant (RFC 7523 jwt-bearer) client provider.
`IdentityAssertionOAuthProvider` is the client side of SEP-990 leg 2: it presents an Identity
Assertion Authorization Grant (ID-JAG) - a signed JWT issued by the enterprise identity provider -
to the MCP authorization server's token endpoint using the RFC 7523 jwt-bearer grant
(`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, ID-JAG as `assertion`), and receives an
MCP access token.
The authorization server is configuration, not discovery. SEP-990's trust model is the inverse of
the default OAuth client's: the AS issuer is supplied at construction, authorization-server metadata
is fetched from that issuer's own RFC 8414 well-known, and the resource server is never asked which
AS to use - so it cannot redirect the ID-JAG or client secret elsewhere. There is no protected
resource metadata fetch, no dynamic client registration, and no server-driven scope selection.
Obtaining the ID-JAG (logging into the IdP and the leg-1 token exchange against it) is
deployment-specific and out of scope for the SDK. The caller supplies it through the
`assertion_provider` callback, which receives the configured issuer (the `aud` the ID-JAG must
carry) and the MCP server's resource identifier (the `resource` claim it must carry, per ext-auth
section 4.3), and returns the ID-JAG.
"""
import base64
import time
from collections.abc import AsyncGenerator, Awaitable, Callable
from typing import Literal
from urllib.parse import quote, urlsplit
import anyio
import httpx
from mcp.client.auth import OAuthFlowError, OAuthTokenError, TokenStorage
from mcp.client.auth.utils import (
build_oauth_authorization_server_metadata_discovery_urls,
create_oauth_metadata_request,
extract_field_from_www_auth,
extract_scope_from_www_auth,
handle_auth_metadata_response,
handle_token_response_scopes,
union_scopes,
validate_metadata_issuer,
)
from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken
from mcp.shared.auth_utils import calculate_token_expiry, resource_url_from_server_url
_DEFAULT_PORTS = {"https": 443, "http": 80}
def _origin(url: str) -> tuple[str, str, int | None]:
"""Return the (scheme, host, port) origin of a URL for same-origin comparison.
The port is normalized to the scheme's default so an explicit `:443`/`:80` compares equal to the
same origin written without a port.
"""
parsed = urlsplit(url)
port = parsed.port if parsed.port is not None else _DEFAULT_PORTS.get(parsed.scheme)
return (parsed.scheme, parsed.hostname or "", port)
class IdentityAssertionOAuthProvider(httpx.Auth):
"""`httpx.Auth` for the SEP-990 ID-JAG flow (RFC 7523 jwt-bearer grant) against a configured AS.
The authorization server `issuer` is fixed at construction; metadata is fetched from its
RFC 8414 well-known and the ID-JAG and client secret are sent only to that issuer's token
endpoint. The resource server is never consulted for AS selection. The ID-JAG is fetched lazily
from `assertion_provider` so a fresh assertion is used on each exchange.
Example:
```python
async def fetch_id_jag(audience: str, resource: str) -> str:
# `audience` is the configured issuer (the ID-JAG `aud`); `resource` is the MCP
# server's identifier (the ID-JAG `resource` claim). Obtaining the ID-JAG from the
# enterprise IdP is deployment-specific and not handled by the SDK.
return await my_idp.issue_id_jag(audience=audience, resource=resource)
provider = IdentityAssertionOAuthProvider(
server_url="https://mcp.example.com/mcp",
storage=my_token_storage,
client_id="my-client-id",
client_secret="my-client-secret",
issuer="https://auth.example.com",
assertion_provider=fetch_id_jag,
)
```
"""
requires_response_body = True
def __init__(
self,
server_url: str,
storage: TokenStorage,
client_id: str,
client_secret: str,
issuer: str,
assertion_provider: Callable[[str, str], Awaitable[str]],
scope: str | None = None,
token_endpoint_auth_method: Literal["client_secret_basic", "client_secret_post"] = "client_secret_post",
) -> None:
"""Initialize the identity-assertion OAuth provider.
Args:
server_url: The MCP server URL.
storage: Token storage implementation.
client_id: The OAuth client ID registered with the MCP authorization server.
client_secret: The client secret. SEP-990 section 5.1 requires a confidential client.
issuer: The issuer identifier of the MCP authorization server this client is provisioned
for. Authorization-server metadata is fetched from this issuer's well-known and the
ID-JAG and secret are sent only to its token endpoint.
assertion_provider: Async callback taking `(audience, resource)` - the configured issuer
and the MCP server's resource identifier - and returning the ID-JAG.
scope: Optional space-separated list of scopes to request.
token_endpoint_auth_method: Confidential-client auth method, either `client_secret_post`
(default) or `client_secret_basic`.
"""
if not client_secret:
raise ValueError("client_secret is required: SEP-990 mandates a confidential client")
if not issuer:
raise ValueError("issuer is required: the authorization server is configuration, not discovery")
self._resource = resource_url_from_server_url(server_url)
self._storage = storage
self._issuer = issuer
self._assertion_provider = assertion_provider
self._scope = scope
self._client = OAuthClientInformationFull(
client_id=client_id,
client_secret=client_secret,
redirect_uris=None,
grant_types=[JWT_BEARER_GRANT_TYPE],
token_endpoint_auth_method=token_endpoint_auth_method,
issuer=issuer,
)
self._token_endpoint: str | None = None
self._tokens: OAuthToken | None = None
self._expiry: float | None = None
self._lock = anyio.Lock()
self._initialized = False
def _build_token_request(self, scope: str | None, assertion: str) -> httpx.Request:
"""Build the RFC 7523 jwt-bearer token request, applying confidential-client auth."""
assert self._token_endpoint is not None
assert self._client.client_id is not None and self._client.client_secret is not None
data: dict[str, str] = {
"grant_type": JWT_BEARER_GRANT_TYPE,
"assertion": assertion,
"client_id": self._client.client_id,
"resource": self._resource,
}
if scope:
data["scope"] = scope
headers = {"Content-Type": "application/x-www-form-urlencoded"}
if self._client.token_endpoint_auth_method == "client_secret_basic":
# RFC 6749 section 2.3.1: URL-encode each part, then base64 the colon-joined pair.
encoded_id = quote(self._client.client_id, safe="")
encoded_secret = quote(self._client.client_secret, safe="")
credentials = base64.b64encode(f"{encoded_id}:{encoded_secret}".encode()).decode()
headers["Authorization"] = f"Basic {credentials}"
else:
data["client_secret"] = self._client.client_secret
return httpx.Request("POST", self._token_endpoint, data=data, headers=headers)
async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
async with self._lock:
if not self._initialized:
self._tokens = await self._storage.get_tokens()
self._expiry = calculate_token_expiry(self._tokens.expires_in) if self._tokens else None
self._initialized = True
if self._tokens and (self._expiry is None or time.time() <= self._expiry):
request.headers["Authorization"] = f"Bearer {self._tokens.access_token}"
response = yield request
if response.status_code == 401:
scope_to_request = self._scope
elif response.status_code == 403 and extract_field_from_www_auth(response, "error") == "insufficient_scope":
scope_to_request = union_scopes(self._scope, extract_scope_from_www_auth(response))
else:
return
# Discover ASM from the configured issuer's well-known. The RS is not consulted: both
# arguments are the issuer, so even the helper's legacy fallback resolves there.
if self._token_endpoint is None:
for url in build_oauth_authorization_server_metadata_discovery_urls(self._issuer, self._issuer):
asm_response = yield create_oauth_metadata_request(url)
ok, asm = await handle_auth_metadata_response(asm_response)
if not ok:
break
if asm is not None:
validate_metadata_issuer(asm, self._issuer)
token_endpoint = str(asm.token_endpoint)
if _origin(token_endpoint) != _origin(self._issuer):
raise OAuthFlowError(
f"Token endpoint {token_endpoint} is not on the configured issuer origin {self._issuer}"
)
self._token_endpoint = token_endpoint
break
if self._token_endpoint is None:
raise OAuthFlowError(f"No authorization server metadata at configured issuer {self._issuer}")
assertion = await self._assertion_provider(self._issuer, self._resource)
token_response = yield self._build_token_request(scope_to_request, assertion)
if token_response.status_code != 200:
body = (await token_response.aread()).decode(errors="replace")
raise OAuthTokenError(f"Token exchange failed ({token_response.status_code}): {body}")
tokens = await handle_token_response_scopes(token_response)
if tokens.scope is None:
tokens.scope = scope_to_request
self._tokens = tokens
self._expiry = calculate_token_expiry(tokens.expires_in)
await self._storage.set_tokens(tokens)
request.headers["Authorization"] = f"Bearer {tokens.access_token}"
yield request

View file

@ -417,9 +417,9 @@ class OAuthClientProvider(httpx.Auth):
async def _handle_token_response(self, response: httpx.Response) -> None:
"""Handle token exchange response."""
if response.status_code not in {200, 201}:
body = await response.aread() # pragma: no cover
body_text = body.decode("utf-8") # pragma: no cover
raise OAuthTokenError(f"Token exchange failed ({response.status_code}): {body_text}") # pragma: no cover
body = await response.aread()
body_text = body.decode("utf-8")
raise OAuthTokenError(f"Token exchange failed ({response.status_code}): {body_text}")
# Parse and validate response with scope validation
token_response = await handle_token_response_scopes(response)

View file

@ -12,7 +12,7 @@ from mcp.server.auth.errors import stringify_pydantic_error
from mcp.server.auth.json_response import PydanticJSONResponse
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, RegistrationError, RegistrationErrorCode
from mcp.server.auth.settings import ClientRegistrationOptions
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthClientMetadata
# this alias is a no-op; it's just to separate out the types exposed to the
# provider from what we use in the HTTP handler
@ -79,6 +79,21 @@ class RegistrationHandler:
status_code=400,
)
# SEP-990 §5.1 / draft-ietf-oauth-identity-assertion-authz-grant §8.1: the ID-JAG flow is
# for confidential clients provisioned out of band. Refuse to grant it through DCR so a
# self-registered client cannot reach the identity-assertion provider hook.
if JWT_BEARER_GRANT_TYPE in client_metadata.grant_types:
return PydanticJSONResponse(
content=RegistrationErrorResponse(
error="invalid_client_metadata",
error_description=(
f"grant_types must not include '{JWT_BEARER_GRANT_TYPE}'; "
"the identity-assertion grant requires a pre-registered client"
),
),
status_code=400,
)
# The MCP spec requires servers to use the authorization `code` flow
# with PKCE
if "code" not in client_metadata.response_types:

View file

@ -10,7 +10,12 @@ from starlette.requests import Request
from mcp.server.auth.errors import stringify_pydantic_error
from mcp.server.auth.json_response import PydanticJSONResponse
from mcp.server.auth.middleware.client_auth import AuthenticationError, ClientAuthenticator
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenError, TokenErrorCode
from mcp.server.auth.provider import (
IdentityAssertionParams,
OAuthAuthorizationServerProvider,
TokenError,
TokenErrorCode,
)
from mcp.shared.auth import OAuthToken
@ -40,7 +45,24 @@ class RefreshTokenRequest(BaseModel):
resource: str | None = Field(None, description="Resource indicator for the token")
TokenRequest = Annotated[AuthorizationCodeRequest | RefreshTokenRequest, Field(discriminator="grant_type")]
class JwtBearerRequest(BaseModel):
# RFC 7523 §2.1 JWT bearer authorization grant. SEP-990 leg 2: the client presents the
# enterprise IdP-issued ID-JAG to the MCP authorization server as the `assertion`.
grant_type: Literal["urn:ietf:params:oauth:grant-type:jwt-bearer"]
# See https://datatracker.ietf.org/doc/html/rfc7523#section-2.1
assertion: str = Field(..., description="The ID-JAG (a signed JWT) being presented as the grant")
scope: str | None = Field(None, description="Optional scope parameter")
client_id: str
# we use the client_secret param, per https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1
client_secret: str | None = None
# RFC 8707 resource indicator
resource: str | None = Field(None, description="Resource indicator for the token")
TokenRequest = Annotated[
AuthorizationCodeRequest | RefreshTokenRequest | JwtBearerRequest,
Field(discriminator="grant_type"),
]
token_request_adapter = TypeAdapter[TokenRequest](TokenRequest)
@ -62,6 +84,7 @@ TokenSuccessResponse = OAuthToken
class TokenHandler:
provider: OAuthAuthorizationServerProvider[Any, Any, Any]
client_authenticator: ClientAuthenticator
identity_assertion_enabled: bool = False
def response(self, obj: TokenSuccessResponse | TokenErrorResponse):
status_code = 200
@ -98,7 +121,7 @@ class TokenHandler:
form_data = await request.form()
# TODO(Marcelo): Can someone check if this `dict()` wrapper is necessary?
token_request = token_request_adapter.validate_python(dict(form_data))
except ValidationError as validation_error: # pragma: no cover
except ValidationError as validation_error:
return self.response(
TokenErrorResponse(
error="invalid_request",
@ -106,7 +129,7 @@ class TokenHandler:
)
)
if token_request.grant_type not in client_info.grant_types: # pragma: no cover
if token_request.grant_type not in client_info.grant_types:
return self.response(
TokenErrorResponse(
error="unsupported_grant_type",
@ -178,7 +201,7 @@ class TokenHandler:
except TokenError as e:
return self.response(TokenErrorResponse(error=e.error, error_description=e.error_description))
case RefreshTokenRequest(): # pragma: no branch
case RefreshTokenRequest():
refresh_token = await self.provider.load_refresh_token(client_info, token_request.refresh_token)
if refresh_token is None or refresh_token.client_id != token_request.client_id:
# if token belongs to different client, pretend it doesn't exist
@ -216,4 +239,37 @@ class TokenHandler:
except TokenError as e:
return self.response(TokenErrorResponse(error=e.error, error_description=e.error_description))
case JwtBearerRequest(): # pragma: no branch
if not self.identity_assertion_enabled:
return self.response(
TokenErrorResponse(
error="unsupported_grant_type",
error_description="The JWT bearer grant is not supported by this authorization server",
)
)
# SEP-990 §5.1: only confidential clients may present an ID-JAG. ClientAuthenticator
# already rejects a secret-based method with no stored secret; this additionally
# rejects the public `none` method so an unauthenticated client never reaches the
# provider hook.
if not client_info.client_secret:
# RFC 6749 §5.2: the client authenticated but is not permitted this grant, so
# unauthorized_client (not invalid_client, which is for failed authentication).
return self.response(
TokenErrorResponse(
error="unauthorized_client",
error_description="The JWT bearer grant requires a confidential client",
)
)
params = IdentityAssertionParams(
assertion=token_request.assertion,
scopes=token_request.scope.split(" ") if token_request.scope else None,
resource=token_request.resource,
)
try:
tokens = await self.provider.exchange_identity_assertion(client_info, params)
except TokenError as e:
return self.response(TokenErrorResponse(error=e.error, error_description=e.error_description))
return self.response(tokens)

View file

@ -96,6 +96,11 @@ class ClientAuthenticator:
f"Unsupported auth method: {client.token_endpoint_auth_method}"
)
# A client registered for a secret-based auth method but with no stored secret is
# misconfigured: nothing was actually verified above, so it must not pass authentication.
if client.token_endpoint_auth_method != "none" and not client.client_secret:
raise AuthenticationError("Client is registered for secret-based authentication but has no stored secret")
# If client from the store expects a secret, validate that the request provides
# that secret
if client.client_secret:

View file

@ -16,6 +16,19 @@ class AuthorizationParams(BaseModel):
resource: str | None = None # RFC 8707 resource indicator
class IdentityAssertionParams(BaseModel):
"""Validated parameters of a SEP-990 identity-assertion (RFC 7523 jwt-bearer) request.
Passed to ``OAuthAuthorizationServerProvider.exchange_identity_assertion``. ``assertion`` is the
ID-JAG (a signed JWT) the enterprise identity provider issued; the provider validates it per
RFC 7523 §3 and the SEP-990 §5.1 processing rules before issuing an access token.
"""
assertion: str # RFC 7523 §2.1: the JWT (ID-JAG) presented as the authorization grant
scopes: list[str] | None = None
resource: str | None = None # RFC 8707 resource indicator from the token request
class AuthorizationCode(BaseModel):
code: str
scopes: list[str]
@ -85,6 +98,8 @@ TokenErrorCode = Literal[
"unauthorized_client",
"unsupported_grant_type",
"invalid_scope",
# RFC 8707 §2: the requested resource (RFC 8707 indicator) is unknown or unsupported.
"invalid_target",
]
@ -270,6 +285,53 @@ class OAuthAuthorizationServerProvider(Protocol, Generic[AuthorizationCodeT, Ref
token: The token to revoke.
"""
async def exchange_identity_assertion(
self,
client: OAuthClientInformationFull,
params: IdentityAssertionParams,
) -> OAuthToken:
"""Exchanges an Identity Assertion Authorization Grant (ID-JAG) for an access token.
This is leg 2 of SEP-990: the client presents an ID-JAG - issued by the enterprise
identity provider - using the RFC 7523 ``urn:ietf:params:oauth:grant-type:jwt-bearer``
grant, and receives an access token for this MCP server. The default implementation
rejects every request as an unsupported grant type; override it to enable the grant.
The implementation is responsible for validating ``params.assertion`` per RFC 7523 §3
and the SEP-990 §5.1 processing rules, in particular:
- verify the JWT signature, ``iss``, and ``exp``, and that ``typ`` is ``oauth-id-jag+jwt``;
- require ``aud`` to identify this authorization server (its own issuer);
- require a ``sub`` (RFC 7523 §3 makes it mandatory) identifying the end user;
- reject replays - enforce ``exp``, and track ``jti`` for the assertion's lifetime;
- require the ID-JAG's ``client_id`` claim to match the authenticated ``client`` - do
NOT derive authorization from ``client.client_id`` alone, which for a confidential
client is authenticated but for any client is ultimately self-asserted in the request;
- audience-restrict the issued access token to the resource named in the ID-JAG's
``resource`` claim, not merely ``params.resource`` (which the client controls);
- derive the granted scopes from the ID-JAG and policy rather than granting
``params.scopes`` verbatim.
The handler guarantees ``client`` is confidential (it rejects clients without a stored
secret before calling this hook), but the ID-JAG remains the authoritative grant.
Args:
client: The authenticated client presenting the assertion.
params: The validated jwt-bearer request parameters (the ID-JAG and indicators).
Returns:
The OAuth token, containing the issued access token. A refresh token SHOULD NOT be
issued: SEP-990 relies on the IdP to control session lifetime via re-issued ID-JAGs.
Raises:
TokenError: If the assertion or request is invalid. Use ``invalid_grant`` for a
rejected assertion and ``invalid_target`` for an unknown ``resource``.
"""
raise TokenError(
error="unsupported_grant_type",
error_description="The JWT bearer grant is not supported by this authorization server",
)
def construct_redirect_uri(redirect_uri_base: str, **params: str | None) -> str:
parsed_uri = urlparse(redirect_uri_base)

View file

@ -17,7 +17,7 @@ from mcp.server.auth.handlers.token import TokenHandler
from mcp.server.auth.middleware.client_auth import ClientAuthenticator
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata
from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthMetadata, ProtectedResourceMetadata
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
@ -47,6 +47,9 @@ TOKEN_PATH = "/token"
REGISTRATION_PATH = "/register"
REVOCATION_PATH = "/revoke"
# SEP-990: leg 2 uses the RFC 7523 jwt-bearer grant; support is advertised as the ID-JAG profile.
ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag"
def cors_middleware(
handler: Callable[[Request], Response | Awaitable[Response]],
@ -67,6 +70,7 @@ def create_auth_routes(
service_documentation_url: AnyHttpUrl | None = None,
client_registration_options: ClientRegistrationOptions | None = None,
revocation_options: RevocationOptions | None = None,
identity_assertion_enabled: bool = False,
) -> list[Route]:
validate_issuer_url(issuer_url)
@ -77,6 +81,7 @@ def create_auth_routes(
service_documentation_url,
client_registration_options,
revocation_options,
supports_identity_assertion=identity_assertion_enabled,
)
client_authenticator = ClientAuthenticator(provider)
@ -103,7 +108,9 @@ def create_auth_routes(
Route(
TOKEN_PATH,
endpoint=cors_middleware(
TokenHandler(provider, client_authenticator).handle,
TokenHandler(
provider, client_authenticator, identity_assertion_enabled=identity_assertion_enabled
).handle,
["POST", "OPTIONS"],
),
methods=["POST", "OPTIONS"],
@ -147,10 +154,19 @@ def build_metadata(
service_documentation_url: AnyHttpUrl | None,
client_registration_options: ClientRegistrationOptions,
revocation_options: RevocationOptions,
supports_identity_assertion: bool = False,
) -> OAuthMetadata:
authorization_url = AnyHttpUrl(str(issuer_url).rstrip("/") + AUTHORIZATION_PATH)
token_url = AnyHttpUrl(str(issuer_url).rstrip("/") + TOKEN_PATH)
grant_types_supported = ["authorization_code", "refresh_token"]
# SEP-990 / ext-auth §6: support for the ID-JAG flow is advertised as a grant PROFILE, not as
# the jwt-bearer grant type (which an AS might support for other purposes).
authorization_grant_profiles_supported: list[str] | None = None
if supports_identity_assertion:
grant_types_supported.append(JWT_BEARER_GRANT_TYPE)
authorization_grant_profiles_supported = [ID_JAG_GRANT_PROFILE]
# Create metadata
metadata = OAuthMetadata(
issuer=issuer_url,
@ -159,7 +175,7 @@ def build_metadata(
scopes_supported=client_registration_options.valid_scopes,
response_types_supported=["code"],
response_modes_supported=None,
grant_types_supported=["authorization_code", "refresh_token"],
grant_types_supported=grant_types_supported,
token_endpoint_auth_methods_supported=["client_secret_post", "client_secret_basic"],
token_endpoint_auth_signing_alg_values_supported=None,
service_documentation=service_documentation_url,
@ -168,6 +184,7 @@ def build_metadata(
op_tos_uri=None,
introspection_endpoint=None,
code_challenge_methods_supported=["S256"],
authorization_grant_profiles_supported=authorization_grant_profiles_supported,
)
# Add registration endpoint if supported

View file

@ -27,6 +27,12 @@ class AuthSettings(BaseModel):
client_registration_options: ClientRegistrationOptions | None = None
revocation_options: RevocationOptions | None = None
required_scopes: list[str] | None = None
identity_assertion_enabled: bool = Field(
default=False,
description="Advertise and accept the SEP-990 Identity Assertion Authorization Grant "
"(the RFC 7523 jwt-bearer grant carrying an ID-JAG) at the token endpoint, for enterprise "
"IdP flows. The provider must implement `exchange_identity_assertion`.",
)
# Resource Server settings (when operating as RS only)
resource_server_url: AnyHttpUrl | None = Field(

View file

@ -724,6 +724,7 @@ class Server(Generic[LifespanResultT]):
service_documentation_url=auth.service_documentation_url,
client_registration_options=auth.client_registration_options,
revocation_options=auth.revocation_options,
identity_assertion_enabled=auth.identity_assertion_enabled,
)
)

View file

@ -964,6 +964,7 @@ class MCPServer(Generic[LifespanResultT]):
service_documentation_url=self.settings.auth.service_documentation_url,
client_registration_options=self.settings.auth.client_registration_options,
revocation_options=self.settings.auth.revocation_options,
identity_assertion_enabled=self.settings.auth.identity_assertion_enabled,
)
)

View file

@ -2,6 +2,9 @@ from typing import Any, Literal
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, ConfigDict, Field, field_validator
# RFC 7523 JWT bearer grant; SEP-990 leg 2 uses this to present the ID-JAG.
JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"
class OAuthToken(BaseModel):
"""See https://datatracker.ietf.org/doc/html/rfc6749#section-5.1"""
@ -168,6 +171,9 @@ class OAuthMetadata(BaseModel):
code_challenge_methods_supported: list[str] | None = None
client_id_metadata_document_supported: bool | None = None
authorization_response_iss_parameter_supported: bool | None = None
# SEP-990 / draft-ietf-oauth-identity-assertion-authz-grant §7.2: profiles whose grants the
# authorization server supports, e.g. `urn:ietf:params:oauth:grant-profile:id-jag`.
authorization_grant_profiles_supported: list[str] | None = None
class ProtectedResourceMetadata(BaseModel):

View file

@ -0,0 +1,411 @@
"""Unit tests for the standalone SEP-990 jwt-bearer `httpx.Auth`.
The provider's authorization server is configuration; these tests assert that authorization-server
metadata is fetched only from the configured issuer, that the resource server is never consulted for
AS selection, and that the ID-JAG and client secret reach only the issuer's token endpoint.
"""
import base64
import json
import urllib.parse
import httpx
import pytest
from mcp.client.auth import OAuthFlowError, OAuthTokenError
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider, _origin
from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken
ISSUER = "https://auth.example.com"
RS = "https://mcp.example.com"
ASM_PATH = "/.well-known/oauth-authorization-server"
OIDC_PATH = "/.well-known/openid-configuration"
class InMemoryStorage:
def __init__(self, tokens: OAuthToken | None = None) -> None:
self.tokens = tokens
async def get_tokens(self) -> OAuthToken | None:
return self.tokens
async def set_tokens(self, tokens: OAuthToken) -> None:
self.tokens = tokens
async def get_client_info(self) -> OAuthClientInformationFull | None:
raise NotImplementedError
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
raise NotImplementedError
def asm_body(*, issuer: str = ISSUER, token_endpoint: str | None = None) -> bytes:
return json.dumps(
{
"issuer": issuer,
"authorization_endpoint": f"{issuer}/authorize",
"token_endpoint": token_endpoint or f"{issuer}/token",
}
).encode()
def token_body(*, access_token: str = "issued-token", scope: str | None = None) -> bytes:
payload: dict[str, object] = {"access_token": access_token, "token_type": "Bearer", "expires_in": 3600}
if scope is not None:
payload["scope"] = scope
return json.dumps(payload).encode()
def make_provider(
storage: InMemoryStorage | None = None,
*,
scope: str | None = "mcp",
token_endpoint_auth_method: str = "client_secret_post",
record: list[tuple[str, str]] | None = None,
) -> IdentityAssertionOAuthProvider:
async def assertion_provider(audience: str, resource: str) -> str:
if record is not None:
record.append((audience, resource))
return "the-id-jag"
return IdentityAssertionOAuthProvider(
server_url=f"{RS}/mcp",
storage=storage if storage is not None else InMemoryStorage(),
client_id="test-client-id",
client_secret="test-client-secret",
issuer=ISSUER,
assertion_provider=assertion_provider,
scope=scope,
token_endpoint_auth_method=token_endpoint_auth_method, # type: ignore[arg-type]
)
def mock_transport(
requests: list[httpx.Request],
*,
asm: bytes | int = 200,
token: bytes | int = 200,
rs_first_status: int = 401,
rs_first_headers: dict[str, str] | None = None,
) -> httpx.MockTransport:
"""Build a `MockTransport` that records every request and serves the configured ASM and token.
`asm` / `token` are either a body (served as 200 JSON) or an int status (served with no body).
The MCP resource server's first response is `rs_first_status` (default 401) with optional
headers; subsequent RS requests return 200.
"""
rs_hits = 0
def handle(request: httpx.Request) -> httpx.Response:
nonlocal rs_hits
requests.append(request)
host, path = request.url.host, request.url.path
if host == "mcp.example.com":
rs_hits += 1
if rs_hits == 1:
return httpx.Response(rs_first_status, headers=rs_first_headers or {})
return httpx.Response(200, json={"ok": True})
if host == "auth.example.com" and path in (ASM_PATH, OIDC_PATH):
if isinstance(asm, int):
return httpx.Response(asm)
return httpx.Response(200, content=asm, headers={"content-type": "application/json"})
if host == "auth.example.com" and path == "/token":
if isinstance(token, int):
return httpx.Response(token, json={"error": "invalid_grant"})
return httpx.Response(200, content=token, headers={"content-type": "application/json"})
raise AssertionError(f"unexpected request: {request.method} {request.url}") # pragma: no cover
return httpx.MockTransport(handle)
def form(request: httpx.Request) -> dict[str, str]:
return dict(urllib.parse.parse_qsl(request.content.decode()))
@pytest.mark.anyio
async def test_on_401_exchanges_assertion_at_configured_issuer_and_retries() -> None:
"""A 401 fetches ASM from the configured issuer, posts the jwt-bearer grant, and retries."""
requests: list[httpx.Request] = []
record: list[tuple[str, str]] = []
storage = InMemoryStorage()
auth = make_provider(storage, record=record)
async with httpx.AsyncClient(
transport=mock_transport(requests, asm=asm_body(), token=token_body(scope="mcp")), auth=auth
) as http:
response = await http.post(f"{RS}/mcp")
assert [(r.method, str(r.url)) for r in requests] == [
("POST", f"{RS}/mcp"),
("GET", f"{ISSUER}{ASM_PATH}"),
("POST", f"{ISSUER}/token"),
("POST", f"{RS}/mcp"),
]
body = form(requests[2])
assert body == {
"grant_type": JWT_BEARER_GRANT_TYPE,
"assertion": "the-id-jag",
"client_id": "test-client-id",
"resource": f"{RS}/mcp",
"scope": "mcp",
"client_secret": "test-client-secret",
}
assert "Authorization" not in requests[2].headers
assert record == [(ISSUER, f"{RS}/mcp")]
assert response.status_code == 200
assert storage.tokens is not None
assert storage.tokens.access_token == "issued-token"
assert storage.tokens.scope == "mcp"
@pytest.mark.anyio
async def test_resource_server_metadata_is_never_consulted() -> None:
"""No PRM well-known and no RS-origin ASM well-known is ever fetched.
This is the by-construction property: the AS is configuration, so the resource server has no
input into where the ID-JAG or client secret go. Any GET to the RS host fails the test.
"""
requests: list[httpx.Request] = []
auth = make_provider()
async with httpx.AsyncClient(
transport=mock_transport(requests, asm=asm_body(), token=token_body()), auth=auth
) as http:
await http.post(f"{RS}/mcp")
rs_gets = [r for r in requests if r.url.host == "mcp.example.com" and r.method == "GET"]
assert rs_gets == []
assert all(r.url.host == "auth.example.com" for r in requests if r.method == "GET")
# No DCR was attempted anywhere.
assert not any(r.url.path == "/register" for r in requests)
@pytest.mark.anyio
async def test_asm_404_at_configured_issuer_raises_before_minting_assertion() -> None:
"""If the issuer's well-knowns 404, the flow fails closed and the assertion is never minted."""
requests: list[httpx.Request] = []
record: list[tuple[str, str]] = []
auth = make_provider(record=record)
async with httpx.AsyncClient(transport=mock_transport(requests, asm=404), auth=auth) as http:
with pytest.raises(OAuthFlowError, match="No authorization server metadata"):
await http.post(f"{RS}/mcp")
# Both RFC 8414 and OIDC well-knowns were tried at the configured issuer; nothing else.
assert [str(r.url) for r in requests if r.method == "GET"] == [f"{ISSUER}{ASM_PATH}", f"{ISSUER}{OIDC_PATH}"]
assert record == []
assert not any(r.url.path == "/token" for r in requests)
@pytest.mark.anyio
async def test_asm_5xx_stops_discovery_and_raises() -> None:
"""A 5xx at the issuer's well-known stops discovery without trying further URLs."""
requests: list[httpx.Request] = []
auth = make_provider()
async with httpx.AsyncClient(transport=mock_transport(requests, asm=500), auth=auth) as http:
with pytest.raises(OAuthFlowError, match="No authorization server metadata"):
await http.post(f"{RS}/mcp")
assert [str(r.url) for r in requests if r.method == "GET"] == [f"{ISSUER}{ASM_PATH}"]
@pytest.mark.anyio
async def test_asm_with_wrong_issuer_is_rejected_before_minting_assertion() -> None:
"""RFC 8414 section 3.3: metadata whose `issuer` differs from the configured one is rejected."""
requests: list[httpx.Request] = []
record: list[tuple[str, str]] = []
auth = make_provider(record=record)
async with httpx.AsyncClient(
transport=mock_transport(requests, asm=asm_body(issuer="https://other.example")), auth=auth
) as http:
with pytest.raises(OAuthFlowError, match="issuer mismatch"):
await http.post(f"{RS}/mcp")
assert record == []
assert not any(r.url.path == "/token" for r in requests)
@pytest.mark.anyio
async def test_asm_with_off_origin_token_endpoint_is_rejected_before_minting_assertion() -> None:
"""A `token_endpoint` off the configured issuer's origin is refused before any credential is sent."""
requests: list[httpx.Request] = []
record: list[tuple[str, str]] = []
auth = make_provider(record=record)
async with httpx.AsyncClient(
transport=mock_transport(requests, asm=asm_body(token_endpoint="https://other.example/token")), auth=auth
) as http:
with pytest.raises(OAuthFlowError, match="not on the configured issuer origin"):
await http.post(f"{RS}/mcp")
assert record == []
assert not any(r.url.path == "/token" for r in requests)
@pytest.mark.anyio
async def test_403_insufficient_scope_unions_challenged_scope_with_configured() -> None:
"""A 403 `insufficient_scope` re-exchanges with the union of configured and challenged scopes."""
requests: list[httpx.Request] = []
auth = make_provider(scope="mcp")
transport = mock_transport(
requests,
asm=asm_body(),
token=token_body(),
rs_first_status=403,
rs_first_headers={"WWW-Authenticate": 'Bearer error="insufficient_scope", scope="mcp files:write"'},
)
async with httpx.AsyncClient(transport=transport, auth=auth) as http:
response = await http.post(f"{RS}/mcp")
[token_req] = [r for r in requests if r.url.path == "/token"]
assert form(token_req)["scope"] == "mcp files:write"
assert response.status_code == 200
@pytest.mark.anyio
async def test_403_without_insufficient_scope_does_not_reauthorize() -> None:
"""A plain 403 (not `insufficient_scope`) is returned to the caller without re-exchanging."""
requests: list[httpx.Request] = []
record: list[tuple[str, str]] = []
auth = make_provider(record=record)
transport = mock_transport(requests, rs_first_status=403, rs_first_headers={"WWW-Authenticate": "Bearer"})
async with httpx.AsyncClient(transport=transport, auth=auth) as http:
response = await http.post(f"{RS}/mcp")
assert response.status_code == 403
assert record == []
assert [str(r.url) for r in requests] == [f"{RS}/mcp"]
@pytest.mark.anyio
async def test_token_endpoint_error_surfaces_as_oauth_token_error() -> None:
requests: list[httpx.Request] = []
auth = make_provider()
async with httpx.AsyncClient(transport=mock_transport(requests, asm=asm_body(), token=400), auth=auth) as http:
with pytest.raises(OAuthTokenError, match=r"Token exchange failed \(400\).*invalid_grant"):
await http.post(f"{RS}/mcp")
@pytest.mark.anyio
async def test_client_secret_basic_sends_basic_header_not_body_secret() -> None:
requests: list[httpx.Request] = []
auth = make_provider(token_endpoint_auth_method="client_secret_basic")
async with httpx.AsyncClient(
transport=mock_transport(requests, asm=asm_body(), token=token_body()), auth=auth
) as http:
await http.post(f"{RS}/mcp")
[token_req] = [r for r in requests if r.url.path == "/token"]
assert "client_secret" not in form(token_req)
decoded = base64.b64decode(token_req.headers["Authorization"].removeprefix("Basic ")).decode()
assert decoded == "test-client-id:test-client-secret"
@pytest.mark.anyio
async def test_stored_token_is_reused_without_reauthorizing() -> None:
"""A valid stored token is sent on the first request; on success no ASM or /token is fetched."""
requests: list[httpx.Request] = []
storage = InMemoryStorage(tokens=OAuthToken(access_token="cached", token_type="Bearer", expires_in=3600))
auth = make_provider(storage)
transport = mock_transport(requests, rs_first_status=200)
async with httpx.AsyncClient(transport=transport, auth=auth) as http:
response = await http.post(f"{RS}/mcp")
assert response.status_code == 200
assert [str(r.url) for r in requests] == [f"{RS}/mcp"]
assert requests[0].headers["Authorization"] == "Bearer cached"
@pytest.mark.anyio
async def test_second_401_re_exchanges_without_refetching_asm() -> None:
"""ASM is discovered once; a later 401 mints a fresh assertion against the cached token endpoint."""
requests: list[httpx.Request] = []
record: list[tuple[str, str]] = []
auth = make_provider(record=record)
rs_hits = 0
def handle(request: httpx.Request) -> httpx.Response:
nonlocal rs_hits
requests.append(request)
host, path = request.url.host, request.url.path
if host == "mcp.example.com":
rs_hits += 1
# First and third RS hits draw a 401; second and fourth succeed.
return httpx.Response(401 if rs_hits in (1, 3) else 200)
if host == "auth.example.com" and path == ASM_PATH:
return httpx.Response(200, content=asm_body(), headers={"content-type": "application/json"})
assert host == "auth.example.com" and path == "/token"
return httpx.Response(200, content=token_body(), headers={"content-type": "application/json"})
async with httpx.AsyncClient(transport=httpx.MockTransport(handle), auth=auth) as http:
await http.post(f"{RS}/mcp")
await http.post(f"{RS}/mcp")
asm_gets = [r for r in requests if r.url.path == ASM_PATH]
token_posts = [r for r in requests if r.url.path == "/token"]
assert len(asm_gets) == 1
assert len(token_posts) == 2
assert len(record) == 2
@pytest.mark.anyio
async def test_no_configured_scope_omits_scope_and_backfills_from_request() -> None:
"""With no configured scope and no scope in the token response, the stored token records None."""
requests: list[httpx.Request] = []
storage = InMemoryStorage()
auth = make_provider(storage, scope=None)
async with httpx.AsyncClient(
transport=mock_transport(requests, asm=asm_body(), token=token_body()), auth=auth
) as http:
await http.post(f"{RS}/mcp")
[token_req] = [r for r in requests if r.url.path == "/token"]
assert "scope" not in form(token_req)
assert storage.tokens is not None
assert storage.tokens.scope is None
def test_empty_client_secret_is_rejected() -> None:
async def assertion_provider(audience: str, resource: str) -> str:
raise NotImplementedError
with pytest.raises(ValueError, match="client_secret is required"):
IdentityAssertionOAuthProvider(
server_url=f"{RS}/mcp",
storage=InMemoryStorage(),
client_id="c",
client_secret="",
issuer=ISSUER,
assertion_provider=assertion_provider,
)
def test_empty_issuer_is_rejected() -> None:
async def assertion_provider(audience: str, resource: str) -> str:
raise NotImplementedError
with pytest.raises(ValueError, match="issuer is required"):
IdentityAssertionOAuthProvider(
server_url=f"{RS}/mcp",
storage=InMemoryStorage(),
client_id="c",
client_secret="s",
issuer="",
assertion_provider=assertion_provider,
)
def test_origin_normalizes_default_ports() -> None:
"""`_origin` treats an explicit scheme-default port as equal to the port-less form."""
assert _origin("https://host") == _origin("https://host:443")
assert _origin("http://host") == _origin("http://host:80")
assert _origin("https://host") != _origin("https://host:8443")
assert _origin("https://host") != _origin("https://other")

View file

@ -12,7 +12,7 @@ from inline_snapshot import Is, snapshot
from pydantic import AnyHttpUrl, AnyUrl
from mcp.client.auth import OAuthClientProvider, PKCEParameters
from mcp.client.auth.exceptions import OAuthFlowError
from mcp.client.auth.exceptions import OAuthFlowError, OAuthTokenError
from mcp.client.auth.utils import (
build_oauth_authorization_server_metadata_discovery_urls,
build_protected_resource_metadata_discovery_urls,
@ -2860,6 +2860,17 @@ async def test_handle_token_response_backfills_omitted_scope_from_request(
assert stored.scope == "read admin"
@pytest.mark.anyio
async def test_handle_token_response_raises_on_non_2xx_with_body(oauth_provider: OAuthClientProvider):
response = httpx.Response(
400,
json={"error": "invalid_grant"},
request=httpx.Request("POST", "https://auth.example.com/token"),
)
with pytest.raises(OAuthTokenError, match=r"Token exchange failed \(400\).*invalid_grant"):
await oauth_provider._handle_token_response(response)
@pytest.mark.anyio
async def test_handle_refresh_response_carries_prior_scope_and_refresh_token_when_omitted(
oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage

View file

@ -3689,6 +3689,64 @@ REQUIREMENTS: dict[str, Requirement] = {
"elsewhere into the auth provider's state, so the absence cannot be observed end to end."
),
),
"client-auth:identity-assertion": Requirement(
source="sdk",
behavior=(
"The identity-assertion provider (SEP-990) presents an enterprise IdP-issued ID-JAG to the MCP "
"authorization server via the RFC 7523 jwt-bearer grant, with no authorize or registration step, "
"and the issued bearer token authorizes subsequent requests."
),
transports=("streamable-http",),
note="OAuth is HTTP-only.",
),
"client-auth:identity-assertion:assertion-callback": Requirement(
source="sdk",
behavior=(
"The identity-assertion provider sources the ID-JAG from its async assertion_provider callback, "
"invoked with the authorization server's issuer as audience and the MCP server's resource "
"identifier, and sends it as `assertion` on the RFC 7523 jwt-bearer request."
),
transports=("streamable-http",),
note="OAuth is HTTP-only.",
),
"client-auth:identity-assertion:issuer-pinning": Requirement(
source="sdk",
behavior=(
"The identity-assertion provider's authorization server is configuration: metadata is "
"fetched only from the configured issuer's RFC 8414 well-known, the resource server is "
"never consulted for AS selection, and the ID-JAG and client secret are not sent unless "
"that metadata validates."
),
transports=("streamable-http",),
note="OAuth is HTTP-only.",
),
"client-auth:identity-assertion:disabled-rejected": Requirement(
source="sdk",
behavior=(
"When the authorization server has the identity-assertion grant disabled, the token endpoint "
"rejects it with unsupported_grant_type and the connection fails rather than issuing a token."
),
transports=("streamable-http",),
note="OAuth is HTTP-only.",
),
"client-auth:identity-assertion:invalid-assertion": Requirement(
source="sdk",
behavior=(
"A jwt-bearer request whose ID-JAG the authorization server rejects surfaces as an OAuth error "
"and the connection fails rather than proceeding with a bearer token."
),
transports=("streamable-http",),
note="OAuth is HTTP-only.",
),
"client-auth:identity-assertion:metadata-advertised": Requirement(
source="sdk",
behavior=(
"When the identity-assertion grant is enabled, the authorization-server metadata advertises the "
"jwt-bearer grant type and the id-jag grant profile in authorization_grant_profiles_supported."
),
transports=("streamable-http",),
note="OAuth is HTTP-only.",
),
# ═══════════════════════════════════════════════════════════════════════════
# stdio transport
# ═══════════════════════════════════════════════════════════════════════════

View file

@ -178,7 +178,10 @@ class HeadlessOAuth:
def auth_settings(
*, required_scopes: Sequence[str] = ("mcp",), valid_scopes: Sequence[str] | None = None
*,
required_scopes: Sequence[str] = ("mcp",),
valid_scopes: Sequence[str] | None = None,
identity_assertion_enabled: bool = False,
) -> AuthSettings:
"""Build `AuthSettings` for the co-hosted authorization + resource server.
@ -188,6 +191,10 @@ def auth_settings(
validation; tests pass a wider set when they need the protected-resource metadata's
`scopes_supported` (which mirrors `required_scopes`) to differ from what the client may
register or when AS metadata should advertise additional scopes such as `offline_access`.
`identity_assertion_enabled` advertises and accepts the SEP-990 ID-JAG grant (RFC 7523
jwt-bearer); the provider must implement `exchange_identity_assertion` for the endpoint to
issue tokens.
"""
required = list(required_scopes)
valid = list(valid_scopes) if valid_scopes is not None else required
@ -199,6 +206,7 @@ def auth_settings(
enabled=True, valid_scopes=valid, default_scopes=required
),
revocation_options=RevocationOptions(enabled=False),
identity_assertion_enabled=identity_assertion_enabled,
)

View file

@ -15,6 +15,7 @@ from mcp.server.auth.provider import (
AccessToken,
AuthorizationCode,
AuthorizationParams,
IdentityAssertionParams,
OAuthAuthorizationServerProvider,
RefreshToken,
TokenError,
@ -25,6 +26,10 @@ from tests.interaction._connect import BASE_URL
_TOKEN_LIFETIME_SECONDS = 3600
# The only ID-JAG assertion the in-memory provider accepts; any other value is rejected with
# invalid_grant, standing in for the signature/policy validation a real AS performs.
VALID_ASSERTION = "valid-id-jag"
class InMemoryAuthorizationServerProvider(
OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]
@ -71,6 +76,9 @@ class InMemoryAuthorizationServerProvider(
self.codes: dict[str, AuthorizationCode] = {}
self.refresh_tokens: dict[str, RefreshToken] = {}
self.access_tokens: dict[str, AccessToken] = {}
# The most recent jwt-bearer request the SDK handler passed to exchange_identity_assertion,
# for tests to assert what the client sent (None until the first exchange).
self.last_assertion_params: IdentityAssertionParams | None = None
def _next_expires_in(self) -> int:
self._tokens_issued += 1
@ -187,6 +195,28 @@ class InMemoryAuthorizationServerProvider(
refresh_token=new_refresh,
)
async def exchange_identity_assertion(
self, client: OAuthClientInformationFull, params: IdentityAssertionParams
) -> OAuthToken:
"""Validate the ID-JAG assertion and mint an MCP access token (RFC 7523 jwt-bearer / SEP-990).
Records `params` for inspection and rejects any assertion other than `VALID_ASSERTION` with
invalid_grant (standing in for signature/policy validation). The granted scopes are exactly
those the client requested; a real provider would derive them from the validated ID-JAG.
"""
self.last_assertion_params = params
assert client.client_id is not None
if params.assertion != VALID_ASSERTION:
raise TokenError(error="invalid_grant", error_description="assertion is not valid")
scopes = params.scopes if params.scopes is not None else self._default_scopes
access = self.mint_access_token(client_id=client.client_id, scopes=scopes, resource=params.resource)
return OAuthToken(
access_token=access,
token_type="Bearer",
expires_in=self._next_expires_in(),
scope=" ".join(scopes),
)
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
"""Not exercised by this suite; revocation is out of scope for the interaction tests."""
raise NotImplementedError

Some files were not shown because too many files have changed in this diff Show more