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
8 changed files with 1830 additions and 14 deletions

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,
)
)