Commit graph ruby-sdk/lib/mcp/client/oauth/client_credentials_provider.rb
Author SHA1 Message Date
Koichi ITO
4327fc8373 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.
2026-06-28 20:59:37 +09:00
Koichi ITO
2289db84f8 Support OAuth client_credentials grant in OAuth client
## Motivation and Context

The MCP authorization specification and SEP-1046 allow confidential clients to
obtain tokens through the OAuth 2.1 `client_credentials` grant: a machine-to-machine flow
with no user, no browser redirect, and no authorization code. The Ruby SDK
previously supported only the interactive authorization-code flow, so it could not
authenticate against a server that requires `client_credentials`. This makes
the `auth/client-credentials-basic` conformance scenario pass and is removed from
the expected failures list.

Following the TypeScript SDK (`ClientCredentialsProvider`) and the Python SDK
(`ClientCredentialsOAuthProvider`), the grant gets its own provider class rather than
overloading the authorization-code provider. The change adds:

- `MCP::Client::OAuth::ClientCredentialsProvider`, a dedicated provider
  for the grant. `client_id` and `client_secret` are mandatory
  (the grant is for confidential clients), `token_endpoint_auth_method` is
  `client_secret_basic` (default) or `client_secret_post` -- `none` is
  rejected because an unauthenticated `client_credentials` request is
  meaningless. It has no redirect arguments, so a credentials-only
  client never supplies the redirect URI / handlers that grant has no use for.
- `MCP::Client::OAuth::StorageBackedProvider`, an internal module that
  shares the token / client-information persistence between the two provider classes
  so `Flow` can treat any provider uniformly.
- `Provider#authorization_flow` (`:authorization_code`) and
  `ClientCredentialsProvider#authorization_flow` (`:client_credentials`).
  `Flow#run!` dispatches on this rather than inspecting
  `client_metadata[:grant_types]`, which is protocol metadata for
  the authorization server, not an SDK control signal.
- `run_client_credentials!` shares the same PRM / AS metadata discovery
  and Communication Security checks as the interactive flow, then
  exchanges `grant_type=client_credentials` at the token endpoint via
  the existing `post_to_token_endpoint` (which already applies
  `client_secret_basic`). There is no PKCE, redirect, or `offline_access`
  augmentation, because the grant does not issue a refresh token
  (OAuth 2.1 Section 4.3.3).
- `conformance/client.rb` builds a `ClientCredentialsProvider` for
  the `auth/client-credentials-*` scenarios, reusing the harness-injected
  `client_id` / `client_secret`.

## How Has This Been Tested?

New `ClientCredentialsProvider` tests cover: credentials are stored as
client information; `authorization_flow` is `:client_credentials`;
`client_secret_post` is accepted; missing `client_id` / `client_secret`
and `token_endpoint_auth_method: "none"` are rejected; and the token helpers
delegate to storage.

New `Flow` tests cover: a `client_credentials` provider exchanges
`grant_type=client_credentials` with HTTP Basic auth, saves the token,
and never contacts the authorization or registration endpoint;
the requested scope is taken from PRM `scopes_supported`; and a standard
`Provider` runs the authorization-code grant regardless of what its
`client_metadata[:grant_types]` lists. A `Provider` test asserts
`authorization_flow` is `:authorization_code`.

Conformance: `auth/client-credentials-basic` now passes 7/7 with no failures.

## Breaking Changes

None. `ClientCredentialsProvider` is a new class; the existing `Provider` keeps
its required `redirect_uri` / `redirect_handler` / `callback_handler` arguments
and its behaviour is unchanged. Existing interactive clients are unaffected.
2026-06-13 00:12:19 +09:00