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:
parent
ecdf09d44f
commit
c0ecb70e24
11 changed files with 1830 additions and 14 deletions
213
src/mcp/client/auth/extensions/identity_assertion.py
Normal file
213
src/mcp/client/auth/extensions/identity_assertion.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue