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
22 changed files with 1830 additions and 14 deletions
|
|
@ -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.
|
||||
|
|
|
|||
82
examples/snippets/clients/identity_assertion_client.py
Normal file
82
examples/snippets/clients/identity_assertion_client.py
Normal 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()
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
103
examples/snippets/servers/identity_assertion_server.py
Normal file
103
examples/snippets/servers/identity_assertion_server.py
Normal 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)
|
||||
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):
|
||||
|
|
|
|||
411
tests/client/auth/extensions/test_identity_assertion.py
Normal file
411
tests/client/auth/extensions/test_identity_assertion.py
Normal 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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue