Skip to content

Commit 7de2de4

Browse files
committed
fix(auth): allow public CIMD clients to use the jwt-bearer grant
The enterprise-managed authorization extension (section 5) lets a client that is not pre-registered use its Client ID Metadata Document URL as client_id and present an ID-JAG without client authentication. The token handler rejected every secretless client for this grant. Add OAuthAuthorizationServerProvider.is_metadata_document_client (default False). When it returns True for a secretless client, the handler accepts the grant if the ID-JAG's client_id claim names the requesting client, and otherwise answers invalid_grant. Advertise none in token_endpoint_auth_methods_supported when identity assertion is enabled. Fixes #3598
1 parent d639cf7 commit 7de2de4

5 files changed

Lines changed: 210 additions & 21 deletions

File tree

‎docs/client/identity-assertion.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ The SDK can also *be* the authorization server: `create_auth_routes` returns the
8585
```
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.)
88-
* **`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`.
88+
* **`exchange_identity_assertion`** is the hook. Before it runs, the SDK has authenticated the client, refused public clients (unless `is_metadata_document_client` reports the client was resolved from a Client ID Metadata Document and the ID-JAG's `client_id` claim names it), 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`.
8989
* 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.
9090
* 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.
9191

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

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from dataclasses import dataclass
55
from typing import Annotated, Any, Literal
66

7+
import jwt
78
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, TypeAdapter, ValidationError
89
from starlette.requests import Request
910

@@ -74,6 +75,13 @@ class TokenErrorResponse(BaseModel):
7475
error_uri: AnyHttpUrl | None = None
7576

7677

78+
def _unverified_client_id(assertion: str) -> object:
79+
try:
80+
return jwt.decode(assertion, options={"verify_signature": False}).get("client_id")
81+
except jwt.PyJWTError:
82+
return None
83+
84+
7785
# this is just an alias over OAuthToken; the only reason we do this
7886
# is to have some separation between the HTTP response type, and the
7987
# type returned by the provider
@@ -251,16 +259,28 @@ async def handle(self, request: Request):
251259
# SEP-990 §5.1: only confidential clients may present an ID-JAG. ClientAuthenticator
252260
# already rejects a secret-based method with no stored secret; this additionally
253261
# rejects the public `none` method so an unauthenticated client never reaches the
254-
# provider hook.
262+
# provider hook. ext-auth §5 exempts a client identified by its Client ID Metadata
263+
# Document, which cannot hold a secret.
255264
if not client_info.client_secret:
256-
# RFC 6749 §5.2: the client authenticated but is not permitted this grant, so
257-
# unauthorized_client (not invalid_client, which is for failed authentication).
258-
return self.response(
259-
TokenErrorResponse(
260-
error="unauthorized_client",
261-
error_description="The JWT bearer grant requires a confidential client",
265+
if not await self.provider.is_metadata_document_client(client_info):
266+
# RFC 6749 §5.2: the client authenticated but is not permitted this grant, so
267+
# unauthorized_client (not invalid_client, which is for failed authentication).
268+
return self.response(
269+
TokenErrorResponse(
270+
error="unauthorized_client",
271+
error_description="The JWT bearer grant requires a confidential client",
272+
)
273+
)
274+
# With no client authentication, the ID-JAG must at least name the requesting
275+
# client. Reading the claim unverified is safe: a mismatch only rejects, and the
276+
# provider verifies the signature before issuing anything.
277+
if _unverified_client_id(token_request.assertion) != client_info.client_id:
278+
return self.response(
279+
TokenErrorResponse(
280+
error="invalid_grant",
281+
error_description="The assertion was not issued to this client",
282+
)
262283
)
263-
)
264284

265285
params = IdentityAssertionParams(
266286
assertion=token_request.assertion,

‎src/mcp/server/auth/provider.py‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,22 @@ async def revoke_token(
305305
token: The token to revoke.
306306
"""
307307

308+
async def is_metadata_document_client(self, client: OAuthClientInformationFull) -> bool:
309+
"""Reports whether ``client`` was resolved from a Client ID Metadata Document (CIMD).
310+
311+
The enterprise-managed authorization extension lets a client that is not pre-registered
312+
use its CIMD URL as ``client_id`` and present an ID-JAG without client authentication.
313+
Return True for such clients to let them use the jwt-bearer grant with the ``none`` auth
314+
method. The default returns False, keeping the grant limited to confidential clients.
315+
316+
Args:
317+
client: The client presenting the assertion.
318+
319+
Returns:
320+
True if ``client`` was resolved from a Client ID Metadata Document.
321+
"""
322+
return False
323+
308324
async def exchange_identity_assertion(
309325
self,
310326
client: OAuthClientInformationFull,
@@ -324,19 +340,21 @@ async def exchange_identity_assertion(
324340
- require ``aud`` to identify this authorization server (its own issuer);
325341
- require a ``sub`` (RFC 7523 §3 makes it mandatory) identifying the end user;
326342
- reject replays - enforce ``exp``, and track ``jti`` for the assertion's lifetime;
327-
- require the ID-JAG's ``client_id`` claim to match the authenticated ``client`` - do
328-
NOT derive authorization from ``client.client_id`` alone, which for a confidential
329-
client is authenticated but for any client is ultimately self-asserted in the request;
343+
- require the ID-JAG's ``client_id`` claim to match ``client`` - do NOT derive
344+
authorization from ``client.client_id`` alone, which for a confidential client is
345+
authenticated but for any client is ultimately self-asserted in the request;
330346
- audience-restrict the issued access token to the resource named in the ID-JAG's
331347
``resource`` claim, not merely ``params.resource`` (which the client controls);
332348
- derive the granted scopes from the ID-JAG and policy rather than granting
333349
``params.scopes`` verbatim.
334350
335-
The handler guarantees ``client`` is confidential (it rejects clients without a stored
336-
secret before calling this hook), but the ID-JAG remains the authoritative grant.
351+
``client`` is either an authenticated confidential client or, when
352+
``is_metadata_document_client`` returns True, a public client identified by its Client ID
353+
Metadata Document. For the latter the handler has only checked the unverified ``client_id``
354+
claim against ``client.client_id``; either way the ID-JAG remains the authoritative grant.
337355
338356
Args:
339-
client: The authenticated client presenting the assertion.
357+
client: The client presenting the assertion.
340358
params: The validated jwt-bearer request parameters (the ID-JAG and indicators).
341359
342360
Returns:

‎src/mcp/server/auth/routes.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,9 +162,12 @@ def build_metadata(
162162
# SEP-990 / ext-auth §6: support for the ID-JAG flow is advertised as a grant PROFILE, not as
163163
# the jwt-bearer grant type (which an AS might support for other purposes).
164164
authorization_grant_profiles_supported: list[str] | None = None
165+
token_endpoint_auth_methods_supported = ["client_secret_post", "client_secret_basic"]
165166
if supports_identity_assertion:
166167
grant_types_supported.append(JWT_BEARER_GRANT_TYPE)
167168
authorization_grant_profiles_supported = [ID_JAG_GRANT_PROFILE]
169+
# ext-auth §5: a Client ID Metadata Document client may present an ID-JAG unauthenticated.
170+
token_endpoint_auth_methods_supported.append("none")
168171

169172
# Create metadata
170173
metadata = OAuthMetadata(
@@ -175,7 +178,7 @@ def build_metadata(
175178
response_types_supported=["code"],
176179
response_modes_supported=None,
177180
grant_types_supported=grant_types_supported,
178-
token_endpoint_auth_methods_supported=["client_secret_post", "client_secret_basic"],
181+
token_endpoint_auth_methods_supported=token_endpoint_auth_methods_supported,
179182
token_endpoint_auth_signing_alg_values_supported=None,
180183
service_documentation=service_documentation_url,
181184
ui_locales_supported=None,

‎tests/server/auth/test_identity_assertion.py‎

Lines changed: 153 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import time
55

66
import httpx2
7+
import jwt
78
import pytest
89
from httpx2 import ASGITransport
910
from pydantic import AnyHttpUrl
@@ -26,6 +27,12 @@
2627
VALID_ASSERTION = "valid-id-jag"
2728
CONFIDENTIAL_CLIENT_ID = "enterprise-client"
2829
CONFIDENTIAL_CLIENT_SECRET = "enterprise-secret"
30+
CIMD_CLIENT_ID = "https://client.example.com/client.json"
31+
IDP_KEY = "idp-signing-key-for-tests-only-0123456789"
32+
33+
34+
def id_jag(client_id: str) -> str:
35+
return jwt.encode({"client_id": client_id, "sub": "assertion-user"}, IDP_KEY, algorithm="HS256")
2936

3037

3138
class IdentityAssertionProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]):
@@ -35,6 +42,9 @@ def __init__(self) -> None:
3542
self.clients: dict[str, OAuthClientInformationFull] = {}
3643
self.tokens: dict[str, AccessToken] = {}
3744
self.last_params: IdentityAssertionParams | None = None
45+
self.last_client: OAuthClientInformationFull | None = None
46+
self.metadata_document_client_ids: set[str] = set()
47+
self.valid_assertions = {VALID_ASSERTION}
3848

3949
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
4050
return self.clients.get(client_id)
@@ -70,12 +80,16 @@ async def load_access_token(self, token: str) -> AccessToken | None:
7080
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
7181
raise NotImplementedError
7282

83+
async def is_metadata_document_client(self, client: OAuthClientInformationFull) -> bool:
84+
return client.client_id in self.metadata_document_client_ids
85+
7386
async def exchange_identity_assertion(
7487
self, client: OAuthClientInformationFull, params: IdentityAssertionParams
7588
) -> OAuthToken:
7689
self.last_params = params
90+
self.last_client = client
7791
# Stand-in for RFC 7523 §3 / SEP-990 §5.1 assertion validation.
78-
if params.assertion != VALID_ASSERTION:
92+
if params.assertion not in self.valid_assertions:
7993
raise TokenError(error="invalid_grant", error_description="assertion is not valid")
8094
assert client.client_id is not None
8195
scopes = params.scopes or ["mcp"]
@@ -146,8 +160,8 @@ def test_build_metadata_advertises_id_jag_profile_when_enabled():
146160
)
147161
assert JWT_BEARER_GRANT_TYPE in (enabled.grant_types_supported or [])
148162
assert enabled.authorization_grant_profiles_supported == [ID_JAG_GRANT_PROFILE]
149-
# The grant is confidential-only, so the `none` auth method is NOT advertised.
150-
assert "none" not in (enabled.token_endpoint_auth_methods_supported or [])
163+
# A Client ID Metadata Document client may present an ID-JAG with the `none` auth method.
164+
assert enabled.token_endpoint_auth_methods_supported == ["client_secret_post", "client_secret_basic", "none"]
151165

152166
disabled = build_metadata(
153167
AnyHttpUrl("https://auth.example.com"),
@@ -157,6 +171,7 @@ def test_build_metadata_advertises_id_jag_profile_when_enabled():
157171
)
158172
assert JWT_BEARER_GRANT_TYPE not in (disabled.grant_types_supported or [])
159173
assert disabled.authorization_grant_profiles_supported is None
174+
assert disabled.token_endpoint_auth_methods_supported == ["client_secret_post", "client_secret_basic"]
160175

161176

162177
@pytest.mark.anyio
@@ -219,7 +234,9 @@ async def test_identity_assertion_rejected_when_disabled(provider: IdentityAsser
219234
async def test_identity_assertion_rejects_public_client(
220235
client: httpx2.AsyncClient, provider: IdentityAssertionProvider
221236
):
222-
"""A public (auth method 'none') client cannot use the grant, even if it presents a valid assertion."""
237+
"""A public client not resolved from a metadata document cannot use the grant, even with an ID-JAG naming it."""
238+
assertion = id_jag("public-client")
239+
provider.valid_assertions.add(assertion)
223240
provider.clients["public-client"] = OAuthClientInformationFull(
224241
client_id="public-client",
225242
redirect_uris=None,
@@ -230,7 +247,7 @@ async def test_identity_assertion_rejects_public_client(
230247

231248
response = await client.post(
232249
"/token",
233-
data={"grant_type": JWT_BEARER_GRANT_TYPE, "client_id": "public-client", "assertion": VALID_ASSERTION},
250+
data={"grant_type": JWT_BEARER_GRANT_TYPE, "client_id": "public-client", "assertion": assertion},
234251
)
235252

236253
assert response.status_code == 400
@@ -397,3 +414,134 @@ async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
397414
with pytest.raises(TokenError) as excinfo:
398415
await bare.exchange_identity_assertion(client_info, params)
399416
assert excinfo.value.error == "unsupported_grant_type"
417+
assert await bare.is_metadata_document_client(client_info) is False
418+
419+
420+
def register_cimd_client(provider: IdentityAssertionProvider, grant_types: list[str]) -> OAuthClientInformationFull:
421+
client_info = OAuthClientInformationFull(
422+
client_id=CIMD_CLIENT_ID,
423+
redirect_uris=None,
424+
grant_types=grant_types,
425+
token_endpoint_auth_method="none",
426+
scope="mcp",
427+
)
428+
provider.clients[CIMD_CLIENT_ID] = client_info
429+
provider.metadata_document_client_ids.add(CIMD_CLIENT_ID)
430+
return client_info
431+
432+
433+
@pytest.mark.anyio
434+
async def test_metadata_document_client_exchanges_id_jag_without_client_authentication(
435+
client: httpx2.AsyncClient, provider: IdentityAssertionProvider
436+
):
437+
"""A public CIMD client may present an ID-JAG naming it with no client authentication (ext-auth §5)."""
438+
cimd_client = register_cimd_client(provider, [JWT_BEARER_GRANT_TYPE])
439+
assertion = id_jag(CIMD_CLIENT_ID)
440+
provider.valid_assertions.add(assertion)
441+
442+
response = await client.post(
443+
"/token",
444+
data={
445+
"grant_type": JWT_BEARER_GRANT_TYPE,
446+
"client_id": CIMD_CLIENT_ID,
447+
"assertion": assertion,
448+
"resource": "https://mcp.example.com/mcp",
449+
},
450+
)
451+
452+
assert response.status_code == 200, response.content
453+
assert provider.last_client is cimd_client
454+
assert provider.last_params is not None
455+
assert provider.last_params.assertion == assertion
456+
457+
458+
@pytest.mark.anyio
459+
@pytest.mark.parametrize(
460+
"assertion",
461+
[
462+
pytest.param(id_jag("https://other.example.com/client.json"), id="names-another-client"),
463+
pytest.param(jwt.encode({"sub": "assertion-user"}, IDP_KEY, algorithm="HS256"), id="no-client-id-claim"),
464+
pytest.param("not-a-jwt", id="malformed"),
465+
],
466+
)
467+
async def test_metadata_document_client_rejects_id_jag_not_issued_to_it(
468+
client: httpx2.AsyncClient, provider: IdentityAssertionProvider, assertion: str
469+
):
470+
"""Without client authentication, an ID-JAG whose `client_id` claim is not the requester is invalid_grant."""
471+
register_cimd_client(provider, [JWT_BEARER_GRANT_TYPE])
472+
provider.valid_assertions.add(assertion)
473+
474+
response = await client.post(
475+
"/token",
476+
data={"grant_type": JWT_BEARER_GRANT_TYPE, "client_id": CIMD_CLIENT_ID, "assertion": assertion},
477+
)
478+
479+
assert response.status_code == 400
480+
assert response.json() == {
481+
"error": "invalid_grant",
482+
"error_description": "The assertion was not issued to this client",
483+
}
484+
assert provider.last_params is None
485+
486+
487+
@pytest.mark.anyio
488+
async def test_metadata_document_client_without_the_grant_is_rejected(
489+
client: httpx2.AsyncClient, provider: IdentityAssertionProvider
490+
):
491+
"""A CIMD client whose metadata does not list the jwt-bearer grant is refused it."""
492+
register_cimd_client(provider, ["authorization_code"])
493+
494+
response = await client.post(
495+
"/token",
496+
data={"grant_type": JWT_BEARER_GRANT_TYPE, "client_id": CIMD_CLIENT_ID, "assertion": id_jag(CIMD_CLIENT_ID)},
497+
)
498+
499+
assert response.status_code == 400
500+
assert response.json()["error"] == "unsupported_grant_type"
501+
assert provider.last_params is None
502+
503+
504+
@pytest.mark.anyio
505+
@pytest.mark.parametrize("secret", [pytest.param("wrong-secret", id="wrong"), pytest.param(None, id="missing")])
506+
async def test_confidential_client_with_bad_secret_is_invalid_client(
507+
client: httpx2.AsyncClient, provider: IdentityAssertionProvider, secret: str | None
508+
):
509+
"""A confidential client still has to authenticate; the metadata-document path does not apply to it."""
510+
form = assertion_form()
511+
if secret is None:
512+
del form["client_secret"]
513+
else:
514+
form["client_secret"] = secret
515+
516+
response = await client.post("/token", data=form)
517+
518+
assert response.status_code == 401
519+
assert response.json()["error"] == "invalid_client"
520+
assert provider.last_params is None
521+
522+
523+
@pytest.mark.anyio
524+
async def test_metadata_document_client_rejected_when_identity_assertion_disabled(provider: IdentityAssertionProvider):
525+
"""With the grant disabled, a CIMD client is refused like every other client."""
526+
register_cimd_client(provider, [JWT_BEARER_GRANT_TYPE])
527+
routes = create_auth_routes(
528+
provider,
529+
issuer_url=AnyHttpUrl("https://auth.example.com"),
530+
client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=["mcp"]),
531+
revocation_options=RevocationOptions(enabled=False),
532+
identity_assertion_enabled=False,
533+
)
534+
transport = ASGITransport(app=Starlette(routes=routes))
535+
async with httpx2.AsyncClient(transport=transport, base_url="https://auth.example.com") as http:
536+
response = await http.post(
537+
"/token",
538+
data={
539+
"grant_type": JWT_BEARER_GRANT_TYPE,
540+
"client_id": CIMD_CLIENT_ID,
541+
"assertion": id_jag(CIMD_CLIENT_ID),
542+
},
543+
)
544+
545+
assert response.status_code == 400
546+
assert response.json()["error"] == "unsupported_grant_type"
547+
assert provider.last_params is None

0 commit comments

Comments
 (0)