Skip to content

Commit c32d05f

Browse files
committed
Describe the ID-JAG confidential-client rule as SDK policy
The docs, several comments and docstrings, and one ValueError said SEP-990 requires a confidential client for the jwt-bearer (ID-JAG) grant. It does not: the IETF draft it profiles makes that a SHOULD (section 9.1 in -04, not 8.1), and RFC 7521 leaves client authentication policy to the authorization server. Say that the SDK chose the stricter reading, that Client ID Metadata Document clients therefore cannot use the grant against the built-in authorization server, and how to apply a different policy. Which clients the authorization server accepts is unchanged. The only runtime difference is the text of the ValueError raised by IdentityAssertionOAuthProvider for an empty client_secret. Fixes #3598
1 parent 3d0c40d commit c32d05f

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. A client identified by a Client ID Metadata Document has no secret, so it can't use this grant here, and the built-in server doesn't resolve those documents yet ([#1801](https://github.com/modelcontextprotocol/python-sdk/issues/1801)). 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)