Skip to content

Support private_key_jwt Client Authentication for the client_credentials Grant#432

Open
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:client_credentials_jwt
Open

Support private_key_jwt Client Authentication for the client_credentials Grant#432
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:client_credentials_jwt

Conversation

@koic

@koic koic commented Jun 28, 2026

Copy link
Copy Markdown
Member

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.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant