Commit graph ruby-sdk/lib/mcp/client/oauth/client_credentials_provider.rb
Author SHA1 Message Date
Koichi ITO
09d4bf41aa Let the embedding application refuse an authorization request
## Motivation and Context

An MCP server chooses the authorization server its users sign in to. It names one in `authorization_servers`
in its Protected Resource Metadata, and states the scopes it wants in `scopes_supported`
or in the `WWW-Authenticate` challenge. Nothing binds either choice to the server's own identity.

No origin rule can settle this, because an authorization server is legitimately a different origin
from the resource it protects. Validating that a token was issued for the intended audience is a responsibility
the specification places on MCP servers rather than on clients, and there is no portable way for a client
to perform it either: access tokens are frequently opaque, and an authorization server is not required to honor
the `resource` parameter or to report an audience in the token response.

Two things follow from that, and they are separate.

The first is a judgement, and it belongs to whoever embeds this SDK, because an application often knows
which providers its users deal with. `authorization_request_validator` is handed an `AuthorizationRequest` describing
the authorization server, the scopes, the MCP server, and the RFC 8707 resource, and can refuse.
It is one object rather than keyword arguments so that later revisions of the specification can add to it
without changing the shape implementers wrote. It runs on all three grants, and on the authorization-code grant it
runs before dynamic client registration, which is why scope resolution moved above `ensure_client_registered`:
a refusal must not leave this client registered at an authorization server the host just rejected.

The second is not a judgement and is enforced here rather than asked about. `refresh!` rediscovers
the authorization server on every call, so the server named for a session can differ from the one
the stored tokens came from. SEP-2352 already binds client credentials to their issuer,
but that check passes when the issuer was never recorded and is deliberately skipped for a Client ID Metadata Document URL,
which is portable across authorization servers. The issuer that minted the tokens is now recorded with them,
and `refresh!` refuses to present them to a different one. The transport answers that refusal by running
a full authorization, which brings the new authorization server to the validator above. Tokens stored before
this carry no issuer and keep refreshing.

The hook decides whether to proceed, not what to ask for. The scopes reach the authorization server unchanged either way,
because the specification requires a client to treat the scopes in the challenge as authoritative for the operation,
and narrowing them silently would break that.

## How Has This Been Tested?

New tests in `test/mcp/client/oauth/flow_test.rb` assert what the validator receives, that a refusal raises
before any registration request is sent and before the redirect handler runs, and that the `client_credentials`
and `jwt-bearer` grants consult it too. Others cover the issuer recorded on authorization, the refusal to refresh against
a different one, and that tokens stored without an issuer still refresh. A test in `test/mcp/client/oauth/http_oauth_test.rb`
drives the two halves together: a stale issuer makes the refresh fail, the transport reauthorizes, and the validator sees
the newly named authorization server while no refresh token is ever posted to it. Each of them fails with its half of
this change removed. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes.

## Breaking Changes

Refreshing now fails when the authorization server named for a session differs from the one that issued the stored tokens,
and the caller reauthorizes instead. Tokens stored before this change carry no recorded issuer and are unaffected
until their next authorization. The validator itself is additive: it defaults to `nil`, and a provider that does not offer
it authorizes as before.
2026-08-31 02:33:10 +09:00
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