Support private_key_jwt Client Authentication for the client_credentials Grant
## Motivation and Context The MCP `io.modelcontextprotocol/oauth-client-credentials` extension (SEP-1046, modelcontextprotocol/modelcontextprotocol#1046) specifies two client authentication methods for the `client_credentials` grant: `client_secret_basic` and the RECOMMENDED `private_key_jwt` (RFC 7523 Section 2.2 JWT client assertion). The previous commit added the grant with secret-based authentication; this adds the JWT method, which is what the `auth/client-credentials-jwt` conformance scenario exercises. This mirrors the merged implementations in the TypeScript SDK (`PrivateKeyJwtProvider`, typescript-sdk#1157) and the Python SDK (`PrivateKeyJWTOAuthProvider` / `SignedJWTParameters`, python-sdk#1663), adapted to this SDK's provider design: - New `MCP::Client::OAuth::JWTClientAssertion` builds the RFC 7523 compact JWS with openssl, keeping the SDK free of a JWT gem dependency (the TypeScript and Python SDKs use jose and PyJWT). Claims follow SEP-1046: `iss` and `sub` carry the client_id, `aud` carries the authorization server's issuer identifier, plus `exp` (300-second lifetime, matching both SDKs), `iat`, and a unique `jti`. ES256 and RS256 are supported; ES256 signatures are converted from OpenSSL's ASN.1 DER form to the raw 64-byte `r || s` encoding JWS requires (RFC 7518 Section 3.4). - `ClientCredentialsProvider` accepts `token_endpoint_auth_method: "private_key_jwt"` with `private_key:` (PEM string or `OpenSSL::PKey::PKey`) and a required `signing_algorithm:` (no default, so an algorithm mismatch with the server's `token_endpoint_auth_signing_alg_values_supported` fails loudly at construction; both reference SDKs also take the algorithm explicitly). The key stays on the provider and is never written to `storage`; a throwaway assertion is signed at construction to fail fast on an unparseable key or a key/algorithm mismatch. - `Flow#post_to_token_endpoint` sends `client_assertion_type` and a freshly signed `client_assertion` for `private_key_jwt`, using the `ensure_issuer_matches!`-validated issuer as the audience, and omits `client_id` from the body per SEP-1046 (identity travels in the assertion's claims). - The conformance client builds a `private_key_jwt` provider when the harness injects `private_key_pem`, defaulting the algorithm to ES256 like the TypeScript and Python conformance clients, and `auth/client-credentials-jwt` is removed from `conformance/expected_failures.yml`. Scope notes: only ES256 and RS256 are supported for now (the TypeScript and Python SDKs accept whatever their JWT libraries support); adding further algorithms is additive. There is also no injection point yet for externally signed assertions (the TypeScript SDK's `StaticPrivateKeyJwtProvider` and the Python SDK's `assertion_provider` callback cover KMS/HSM-held keys); that is a separate follow-up. ## How Has This Been Tested? New `test/mcp/client/oauth/jwt_client_assertion_test.rb` covers the JWS construction: header and RFC 7523 claims, unpadded base64url segments, ES256 signature round-trip verification (raw -> DER -> `OpenSSL::PKey::EC#verify`), RS256 verification, PEM and parsed-key input, lifetime override, unique `jti` per assertion, and rejection of unsupported algorithms, key/algorithm mismatches (including a P-384 key with ES256), unparseable PEM, and public-only keys. New tests in `test/mcp/client/oauth/client_credentials_provider_test.rb` cover the provider surface: `private_key_jwt` construction, the key and secret never reaching `client_information`, required `private_key` / `signing_algorithm`, rejection of a simultaneous `client_secret`, fail-fast on key/algorithm mismatch, and `client_assertion(audience:)` producing a JWT with the expected claims. A new test in `test/mcp/client/oauth/flow_test.rb` runs the full grant against WebMock stubs and asserts the token request carries `grant_type=client_credentials`, the RFC 7523 `client_assertion_type`, an assertion whose `iss`/`sub` are the client_id and whose `aud` is the issuer, the RFC 8707 `resource`, and neither `client_id` nor any secret in the body or headers. `bundle exec rake` passes; the `auth/client-credentials-jwt` conformance scenario passes 7/7 checks and the baseline check passes with its expected-failure entry removed. ## Breaking Changes None. `client_secret:` becomes optional on `ClientCredentialsProvider` only in the signature; it is still required for the secret-based methods, and all existing construction paths behave unchanged.
This commit is contained in:
parent
b7bd9bb556
commit
4327fc8373
1 changed files with 548 additions and 22 deletions
|
|
@ -681,7 +681,26 @@ module MCP
|
|||
client_secret = client_info_required_value(client_info, "client_secret")
|
||||
token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method")
|
||||
|
||||
form = form.merge("client_id" => client_id)
|
||||
form = if token_endpoint_auth_method == "private_key_jwt"
|
||||
# RFC 7523 Section 2.2 JWT client assertion for the `private_key_jwt` method of
|
||||
# the `io.modelcontextprotocol/oauth-client-credentials` extension (SEP-1046).
|
||||
# The client identity travels in the assertion's `iss`/`sub` claims, so `client_id` is
|
||||
# omitted from the body per RFC 7521 Section 4.2 (the `client_assertion` conveys the client identity).
|
||||
# The audience is the issuer identifier that `ensure_issuer_matches!` already byte-validated.
|
||||
unless @provider.respond_to?(:client_assertion)
|
||||
raise AuthorizationError,
|
||||
"token_endpoint_auth_method is private_key_jwt but the provider does not " \
|
||||
"implement `client_assertion(audience:)`."
|
||||
end
|
||||
|
||||
form.merge(
|
||||
"client_assertion_type" => JWTClientAssertion::ASSERTION_TYPE,
|
||||
"client_assertion" => @provider.client_assertion(audience: as_metadata["issuer"]),
|
||||
)
|
||||
else
|
||||
form.merge("client_id" => client_id)
|
||||
end
|
||||
|
||||
headers = {}
|
||||
if client_secret
|
||||
case token_endpoint_auth_method
|
||||
|
|
|
|||
Loading…
Reference in a new issue