## Motivation and Context
`MCP::Client::OAuth::Flow` takes an `http_client_factory:`, but `MCP::Client::HTTP` builds
the flow itself and passes none, so an application using `oauth:` could not add middleware to
the requests the flow makes: Protected Resource Metadata and authorization server metadata discovery,
dynamic client registration, and every token request.
The block that customizes the transport's Faraday connection does not help,
because that connection is bound to the MCP server URL and carries the `headers:` meant for that server,
and the authorization server is a different origin. The TypeScript SDK hands the transport's `fetch` to `auth()`
and the Python SDK sends the provider's requests through the transport's httpx client, so both let
an application instrument these requests; this SDK did not. (#558)
Providers now take `http_client_customizer:`, a callable invoked with the Faraday connection
the flow builds for its own requests, next to the other callables a provider already holds.
It runs after the SDK's defaults (`Accept: application/json`, no redirect following) and
between two middlewares of the SDK's own: `Flow::RequestedURLStamp`, registered before the customizer is invoked,
records the URL the flow asked for on the request environment, and `Flow::RequestedOriginGuard`, registered last,
sees what the customizer's middleware did to the request after that record. A request that would leave that origin,
by following a redirect or rewriting the URL, or that arrives without the record, is refused with
`Flow::DestinationMismatchError` before it reaches the adapter. The record lives on the environment rather than
in `env.request.context`, which stays the application's to fill or replace. The error is an `ArgumentError`
rather than an `AuthorizationError` so neither discovery nor the transport's refresh fallback can absorb
a configuration mistake. The boundary resembles the one the Python SDK keeps for its own requests,
which follows a redirect only within the origin; this flow follows none itself.
`Flow.build_http_client` assembles that connection and `Flow.new(http_client_factory:)` keeps replacing it whole.
Following done inside an adapter cannot be seen by any guard, so the documentation keeps that, `Accept-Encoding`,
and `raise_error` off the list of things a customizer may add, and notes that a recording middleware sees
client credentials and tokens.
Fixes#558.
## How Has This Been Tested?
New tests in `test/mcp/client/oauth/flow_test.rb`, `test/mcp/client/oauth/http_oauth_test.rb`,
and the three provider test files. Against the library before this change, every one of them fails,
on the unknown keyword or on the missing reader.
## Breaking Changes
None. The customizer is opt-in, and the guard cannot fire on the SDK's own middleware-free connection.
## Motivation and Context
`ClientCredentialsProvider` builds a fixed `client_credentials` token request: `grant_type`, `scope`,
`resource`, and the client authentication that `Flow#post_to_token_endpoint` adds. An authorization server
that requires further parameters, such as Auth0's `audience`, cannot be used through `oauth:` at all,
and giving up `oauth:` also gives up the bearer header and the 401-driven retry that the transport provides.
RFC 6749 Section 8.2 allows extension parameters on the token request, and the TypeScript SDK lets
a provider shape its token request through `prepareTokenRequest`. `ClientCredentialsProvider.new` now takes
`token_request_params:`, a Hash of String keys and values added to every token request the provider makes,
and `Flow#post_to_token_endpoint` reads the same name by duck typing, so a `Provider` or `CrossAppAccessProvider`
subclass that defines the method gets the same treatment on its authorization code exchange, refresh,
or `jwt-bearer` request.
`Provider.new` and `CrossAppAccessProvider.new` take the same keyword, held by `StorageBackedProvider` like
`authorization_request_validator`, so every bundled provider refuses a bad value before anything is stored.
The parameters go underneath the flow's own, so the SDK's values always win, and a key the SDK sets itself
(`Flow::RESERVED_TOKEN_REQUEST_PARAMS`) is refused rather than silently overridden: with `ArgumentError`
from the constructor, before any `client_information` is written, and again with `ArgumentError` from the flow,
before the token request is sent. Only Strings are accepted because `URI.encode_www_form` encodes other scalars
and Arrays in ways the caller did not write, and the provider keeps a frozen copy so a later change to
the caller's Hash cannot alter what is sent.
A Hash comparing keys by identity is refused too, since two equal keys are two entries there, sent twice
by a provider method or silently collapsed into one by the copy. The flow raises `ArgumentError` rather than
`AuthorizationError` because `MCP::Client::HTTP` treats a failed refresh as a reason to run the interactive flow,
which would then fail the same way after the user signed in.
Both refusals raise `Flow::InvalidTokenRequestParamsError`, a subclass of `ArgumentError`, so a caller can tell them
from an unknown keyword, and the copy duplicates keys as well as values, since `Hash` copies only keys
whose class is exactly `String`.
Fixes#554.
## How Has This Been Tested?
New tests cover every token request the parameters can ride (the `client_credentials` grant under
all three client authentication methods, the authorization code exchange, refresh, and `jwt-bearer`),
both refusal boundaries (the constructors of all three providers, and the flow for a provider defining the method),
and the transport surfacing a refused hook from a refresh attempt without starting the interactive flow.
All of them fail against the previous library.
## Breaking Changes
None. The keyword is optional and the hook is opt-in; a provider that does not define `token_request_params` sends
the same requests as before.
## 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.
## Motivation and Context
The MCP Enterprise Managed Authorization extension (SEP-990, modelcontextprotocol/modelcontextprotocol#990,
accepted/final, published in the ext-auth repository) lets enterprise identity providers govern MCP authorization:
the client exchanges an IdP-issued ID token for an Identity Assertion Authorization Grant (ID-JAG) via
RFC 8693 token exchange at the IdP, then presents the ID-JAG to the MCP authorization server with
the RFC 7523 `jwt-bearer` grant, authenticating with `client_secret_basic`.
The `auth/cross-app-access-complete-flow` conformance scenario exercises the complete flow and previously failed
its two core checks (the Ruby client ran the authorization-code flow instead).
This mirrors the merged TypeScript SDK implementation (`requestJwtAuthorizationGrant` / `CrossAppAccessProvider` in
typescript-sdk; the Python SDK tracks the same work in the open python-sdk#1721), adapted to this SDK's provider design:
- New `MCP::Client::OAuth::IDJAGTokenExchange.request` performs the RFC 8693 exchange at the IdP token endpoint
(`subject_token_type` id_token, `requested_token_type` id-jag, `audience` = the MCP authorization server's issuer,
`resource` = the canonical MCP server URL) and validates that the response's `issued_token_type` is an ID-JAG
before returning the assertion. The ID-JAG itself is treated as opaque, as in the TypeScript SDK.
- New `MCP::Client::OAuth::CrossAppAccessProvider` declares `authorization_flow :jwt_bearer` and takes the assertion
from a callable (`call(audience:, resource:)`), so the common `IDJAGTokenExchange` case, enterprise secret stores,
and tests all plug in the same way (the TypeScript provider's assertion callback works identically).
The MCP AS credentials are stored as `client_secret_basic` client information.
- `Flow` dispatches `:jwt_bearer` alongside `:client_credentials`: discovery, issuer validation, and endpoint security checks
are shared with `run!`, then `run_jwt_bearer!` requests the assertion with the validated issuer as the audience and posts
the `jwt-bearer` grant through the existing `post_to_token_endpoint`.
- The conformance client builds a `CrossAppAccessProvider` when the harness injects `idp_id_token` (exchanging it at
`idp_token_endpoint` with `idp_client_id`, like the TypeScript and Python conformance clients),
and `auth/cross-app-access-complete-flow` is removed from `conformance/expected_failures.yml`.
## How Has This Been Tested?
- New `test/mcp/client/oauth/id_jag_token_exchange_test.rb` asserts the exact RFC 8693 form parameters on the wire
and the response validation: a non-ID-JAG `issued_token_type` is rejected, as are a missing `access_token`,
non-2xx responses, unparseable bodies, and non-object JSON.
- New `test/mcp/client/oauth/cross_app_access_provider_test.rb` covers the provider surface:
stored `client_secret_basic` credentials, the `:jwt_bearer` flow declaration, audience/resource passthrough to
the assertion callable, constructor validation (missing client_id, missing client_secret, non-callable assertion provider),
and storage delegation.
- New tests in `test/mcp/client/oauth/flow_test.rb` run the full grant against WebMock stubs: the token request carries
`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, the assertion, the RFC 8707 `resource`, and HTTP Basic credentials,
with no DCR and no authorization-code machinery; the assertion callable receives the validated issuer as `audience`
and the canonical server URL as `resource`; an empty assertion aborts before the token endpoint is contacted.
## Breaking Changes
None. `IDJAGTokenExchange` and `CrossAppAccessProvider` are new, and the `Flow` dispatch only activates for providers
that declare `authorization_flow :jwt_bearer`.