Support private_key_jwt Client Authentication for the client_credentials Grant#432
Open
koic wants to merge 1 commit into
Open
Support private_key_jwt Client Authentication for the client_credentials Grant#432koic wants to merge 1 commit into
private_key_jwt Client Authentication for the client_credentials Grant#432koic wants to merge 1 commit into
Conversation
…tials 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 file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation and Context
The MCP
io.modelcontextprotocol/oauth-client-credentialsextension (SEP-1046, modelcontextprotocol/modelcontextprotocol#1046) specifies two client authentication methods for theclient_credentialsgrant:client_secret_basicand the RECOMMENDEDprivate_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 theauth/client-credentials-jwtconformance 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:MCP::Client::OAuth::JWTClientAssertionbuilds 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:issandsubcarry the client_id,audcarries the authorization server's issuer identifier, plusexp(300-second lifetime, matching both SDKs),iat, and a uniquejti. ES256 and RS256 are supported; ES256 signatures are converted from OpenSSL's ASN.1 DER form to the raw 64-byter || sencoding JWS requires (RFC 7518 Section 3.4).ClientCredentialsProvideracceptstoken_endpoint_auth_method: "private_key_jwt"withprivate_key:(PEM string orOpenSSL::PKey::PKey) and a requiredsigning_algorithm:(no default, so an algorithm mismatch with the server'stoken_endpoint_auth_signing_alg_values_supportedfails loudly at construction; both reference SDKs also take the algorithm explicitly). The key stays on the provider and is never written tostorage; a throwaway assertion is signed at construction to fail fast on an unparseable key or a key/algorithm mismatch.Flow#post_to_token_endpointsendsclient_assertion_typeand a freshly signedclient_assertionforprivate_key_jwt, using theensure_issuer_matches!-validated issuer as the audience, and omitsclient_idfrom the body per SEP-1046 (identity travels in the assertion's claims).private_key_jwtprovider when the harness injectsprivate_key_pem, defaulting the algorithm to ES256 like the TypeScript and Python conformance clients, andauth/client-credentials-jwtis removed fromconformance/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
StaticPrivateKeyJwtProviderand the Python SDK'sassertion_providercallback 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.rbcovers 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, uniquejtiper 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.rbcover the provider surface:private_key_jwtconstruction, the key and secret never reachingclient_information, requiredprivate_key/signing_algorithm, rejection of a simultaneousclient_secret, fail-fast on key/algorithm mismatch, andclient_assertion(audience:)producing a JWT with the expected claims.A new test in
test/mcp/client/oauth/flow_test.rbruns the full grant against WebMock stubs and asserts the token request carriesgrant_type=client_credentials, the RFC 7523client_assertion_type, an assertion whoseiss/subare the client_id and whoseaudis the issuer, the RFC 8707resource, and neitherclient_idnor any secret in the body or headers.bundle exec rakepasses; theauth/client-credentials-jwtconformance scenario passes 7/7 checks and the baseline check passes with its expected-failure entry removed.Breaking Changes
None.
client_secret:becomes optional onClientCredentialsProvideronly in the signature; it is still required for the secret-based methods, and all existing construction paths behave unchanged.Types of changes
Checklist