Skip to content

Commit 19e4f2a

Browse files
authored
Describe the ID-JAG confidential-client rule as SDK policy, not a SEP-990 requirement (#3622)
1 parent afa7edc commit 19e4f2a

7 files changed

Lines changed: 19 additions & 13 deletions

File tree

‎docs/client/identity-assertion.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ The extension does not demand this; it is a deliberately stricter choice. This c
5959

6060
### A confidential client
6161

62-
`client_secret` is required; the constructor raises `ValueError` without one. The IETF profile underneath [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) reserves this grant for confidential clients, SEP-990 requires the client to authenticate, and this SDK enforces both by insisting on a shared secret. `token_endpoint_auth_method` picks where it travels: `client_secret_post` (the default, in the form body) or `client_secret_basic` (an HTTP Basic header). The profile also permits `private_key_jwt`; this provider does not support it.
62+
`client_secret` is required; the constructor raises `ValueError` without one. The IETF profile underneath [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) recommends this grant for confidential clients only, and [RFC 7521](https://datatracker.ietf.org/doc/html/rfc7521) leaves that policy to the authorization server. This SDK takes the conservative reading on both sides: the built-in authorization server refuses a client that has no shared secret, and this provider insists on one. `token_endpoint_auth_method` picks where it travels: `client_secret_post` (the default, in the form body) or `client_secret_basic` (an HTTP Basic header). The profile also permits `private_key_jwt`; this provider does not support it.
6363

6464
!!! tip
6565
Read `client_secret` from the environment or a secret manager, never from source control.
@@ -86,6 +86,7 @@ The SDK can also *be* the authorization server: `create_auth_routes` returns the
8686

8787
* `identity_assertion_enabled=True` gates everything. Off, which is the default, `/token` answers this grant with `unsupported_grant_type` even if you implemented the hook, and the metadata does not mention it. On, the metadata gains the `jwt-bearer` grant type and lists `urn:ietf:params:oauth:grant-profile:id-jag` in `authorization_grant_profiles_supported`, the field the extension uses to advertise support. (This SDK's client never reads it: it is provisioned for one issuer and simply asks.)
8888
* **`exchange_identity_assertion`** is the hook. Before it runs, the SDK has authenticated the client, refused public clients, and refused clients whose registration does not list the grant. You get an `IdentityAssertionParams` (the raw `assertion`, the requested `scopes` and `resource`) and return a plain `OAuthToken`.
89+
* Refusing public clients is SDK policy, not a spec requirement. The built-in server authenticates clients by shared secret only: it has no `private_key_jwt` support and doesn't resolve Client ID Metadata Documents yet ([#1801](https://github.com/modelcontextprotocol/python-sdk/issues/1801)), so a client identified by one can't use this grant here. A deployment that wants a different policy can swap the `/token` route that `create_auth_routes` returns for its own.
8990
* Dynamic client registration refuses this grant unconditionally, so `get_client` here serves a hand-provisioned client. An ID-JAG client cannot register itself into existence.
9091
* Half the class is refusals. `OAuthAuthorizationServerProvider` is the *whole* authorization server, so it also asks for the authorization-code flow; a server that signs users in as well implements those for real, and this one has exactly one door.
9192

‎examples/snippets/clients/identity_assertion_client.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
Obtaining the ID-JAG (logging into the IdP and the leg-1 exchange against it) is deployment-specific
99
and out of scope for the SDK; supply it through the `assertion_provider` callback. The callback
1010
receives the authorization server's issuer (the ID-JAG `aud`) and the MCP server's resource
11-
identifier (the ID-JAG `resource` claim). SEP-990 requires a confidential client, so a client secret
11+
identifier (the ID-JAG `resource` claim). The provider requires a confidential client, so a client secret
1212
is mandatory, and `issuer` is the authorization server the credentials are provisioned for - the
1313
provider fetches metadata from that issuer's well-known and never asks the resource server which AS
1414
to use.

‎examples/snippets/servers/identity_assertion_server.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ class IdentityAssertionProvider(OAuthAuthorizationServerProvider[AuthorizationCo
5050

5151
def __init__(self) -> None:
5252
self.access_tokens: dict[str, AccessToken] = {}
53-
# SEP-990 clients are pre-registered out of band (DCR refuses the grant) and must be
53+
# ID-JAG clients here are pre-registered out of band (DCR refuses the grant) and must be
5454
# confidential. `get_client` must return them, or the token endpoint 401s before the
5555
# exchange runs. Real deployments load these from their own store.
5656
self.clients: dict[str, OAuthClientInformationFull] = {

‎src/mcp/client/auth/extensions/identity_assertion.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ def __init__(
104104
server_url: The MCP server URL.
105105
storage: Token storage implementation.
106106
client_id: The OAuth client ID registered with the MCP authorization server.
107-
client_secret: The client secret. SEP-990 section 5.1 requires a confidential client.
107+
client_secret: The client secret. This provider supports confidential clients only.
108108
issuer: The issuer identifier of the MCP authorization server this client is provisioned
109109
for. Authorization-server metadata is fetched from this issuer's well-known and the
110110
ID-JAG and secret are sent only to its token endpoint.
@@ -115,7 +115,7 @@ def __init__(
115115
(default) or `client_secret_basic`.
116116
"""
117117
if not client_secret:
118-
raise ValueError("client_secret is required: SEP-990 mandates a confidential client")
118+
raise ValueError("client_secret is required: this provider supports confidential clients only")
119119
if not issuer:
120120
raise ValueError("issuer is required: the authorization server is configuration, not discovery")
121121
self._resource = resource_url_from_server_url(server_url)

‎src/mcp/server/auth/handlers/register.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,9 @@ async def handle(self, request: Request) -> Response:
9191
status_code=400,
9292
)
9393

94-
# SEP-990 §5.1 / draft-ietf-oauth-identity-assertion-authz-grant §8.1: the ID-JAG flow is
95-
# for confidential clients provisioned out of band. Refuse to grant it through DCR so a
96-
# self-registered client cannot reach the identity-assertion provider hook.
94+
# SDK policy: the ID-JAG flow is for confidential clients (a SHOULD in
95+
# draft-ietf-oauth-identity-assertion-authz-grant-04 §9.1) provisioned out of band. Refuse to
96+
# grant it through DCR so a self-registered client cannot reach the provider hook.
9797
if JWT_BEARER_GRANT_TYPE in client_metadata.grant_types:
9898
return PydanticJSONResponse(
9999
content=RegistrationErrorResponse(

‎src/mcp/server/auth/handlers/token.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -248,10 +248,10 @@ async def handle(self, request: Request):
248248
)
249249
)
250250

251-
# SEP-990 §5.1: only confidential clients may present an ID-JAG. ClientAuthenticator
252-
# already rejects a secret-based method with no stored secret; this additionally
253-
# rejects the public `none` method so an unauthenticated client never reaches the
254-
# provider hook.
251+
# SDK policy, adopting the SHOULD in draft-ietf-oauth-identity-assertion-authz-grant-04
252+
# §9.1: only confidential clients may present an ID-JAG. ClientAuthenticator already
253+
# rejects a secret-based method with no stored secret; this additionally rejects the
254+
# public `none` method so an unauthenticated client never reaches the provider hook.
255255
if not client_info.client_secret:
256256
# RFC 6749 §5.2: the client authenticated but is not permitted this grant, so
257257
# unauthorized_client (not invalid_client, which is for failed authentication).

‎tests/client/auth/extensions/test_identity_assertion.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import httpx2
1313
import pytest
14+
from inline_snapshot import snapshot
1415

1516
from mcp.client.auth import OAuthFlowError, OAuthTokenError
1617
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider, _origin
@@ -377,7 +378,7 @@ def test_empty_client_secret_is_rejected() -> None:
377378
async def assertion_provider(audience: str, resource: str) -> str:
378379
raise NotImplementedError
379380

380-
with pytest.raises(ValueError, match="client_secret is required"):
381+
with pytest.raises(ValueError) as exc_info:
381382
IdentityAssertionOAuthProvider(
382383
server_url=f"{RS}/mcp",
383384
storage=InMemoryStorage(),
@@ -387,6 +388,10 @@ async def assertion_provider(audience: str, resource: str) -> str:
387388
assertion_provider=assertion_provider,
388389
)
389390

391+
assert str(exc_info.value) == snapshot(
392+
"client_secret is required: this provider supports confidential clients only"
393+
)
394+
390395

391396
def test_empty_issuer_is_rejected() -> None:
392397
async def assertion_provider(audience: str, resource: str) -> str:

0 commit comments

Comments
 (0)