Skip to content

Commit 39758e2

Browse files
yannj-frLucaButBoring
authored andcommitted
Implement RFC 7523 Section 2.2 for client_credentials
1 parent fc8331c commit 39758e2

File tree

2 files changed

+33
-1
lines changed

2 files changed

+33
-1
lines changed

src/mcp/client/auth.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ class JWTParameters(BaseModel):
7474

7575
issuer: str | None = Field(default=None, description="Issuer for JWT assertions.")
7676
subject: str | None = Field(default=None, description="Subject identifier for JWT assertions.")
77+
audience: str | None = Field(default=None, description="Audience for JWT assertions.")
7778
claims: dict[str, Any] | None = Field(default=None, description="Additional claims for JWT assertions.")
7879
jwt_signing_algorithm: str | None = Field(default="RS256", description="Algorithm for signing JWT assertions.")
7980
jwt_signing_key: str | None = Field(default=None, description="Private key for JWT signing.")
@@ -465,6 +466,37 @@ async def _exchange_token_client_credentials(self) -> httpx.Request:
465466
raise OAuthTokenError("Missing client_secret in Basic auth flow")
466467
raw_auth = f"{self.context.client_info.client_id}:{self.context.client_info.client_secret}"
467468
headers["Authorization"] = f"Basic {base64.b64encode(raw_auth.encode()).decode()}"
469+
elif self.context.client_metadata.token_endpoint_auth_method == "private_key_jwt":
470+
# Use JWT assertion for client authentication
471+
if not self.context.jwt_parameters:
472+
raise OAuthTokenError("Missing JWT parameters for private_key_jwt flow")
473+
if not self.context.jwt_parameters.jwt_signing_key:
474+
raise OAuthTokenError("Missing JWT signing key for private_key_jwt flow")
475+
if not self.context.jwt_parameters.jwt_signing_algorithm:
476+
raise OAuthTokenError("Missing JWT signing algorithm for private_key_jwt flow")
477+
478+
now = int(time.time())
479+
claims = {
480+
"iss": self.context.jwt_parameters.issuer,
481+
"sub": self.context.jwt_parameters.subject,
482+
"aud": self.context.jwt_parameters.audience if self.context.jwt_parameters.audience else token_url,
483+
"exp": now + self.context.jwt_parameters.jwt_lifetime_seconds,
484+
"iat": now,
485+
"jti": str(uuid4()),
486+
}
487+
claims.update(self.context.jwt_parameters.claims or {})
488+
489+
assertion = jwt.encode(
490+
claims,
491+
self.context.jwt_parameters.jwt_signing_key,
492+
algorithm=self.context.jwt_parameters.jwt_signing_algorithm or "RS256",
493+
)
494+
# When using private_key_jwt, in a client_credentials flow, we use RFC 7523 Section 2.2
495+
token_data["client_assertion"] = assertion
496+
token_data["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
497+
# We need to set the audience to the token endpoint, the audience is difference from the one in claims
498+
# it represents the resource server that will validate the token
499+
token_data["audience"] = self.context.get_resource_url()
468500

469501
return httpx.Request("POST", token_url, data=token_data, headers=headers)
470502

src/mcp/shared/auth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ class OAuthMetadata(BaseModel):
121121
response_modes_supported: list[Literal["query", "fragment", "form_post"]] | None = None
122122
grant_types_supported: list[str] | None = None
123123
token_endpoint_auth_methods_supported: list[str] | None = None
124-
token_endpoint_auth_signing_alg_values_supported: None = None
124+
token_endpoint_auth_signing_alg_values_supported: list[str] | None = None
125125
service_documentation: AnyHttpUrl | None = None
126126
ui_locales_supported: list[str] | None = None
127127
op_policy_uri: AnyHttpUrl | None = None

0 commit comments

Comments
 (0)