diff --git a/Gemfile b/Gemfile index fd061bd7..913f61c1 100644 --- a/Gemfile +++ b/Gemfile @@ -30,6 +30,7 @@ gem "yard-sorbet", "~> 0.9" if RUBY_VERSION >= "3.1" group :test do gem "event_stream_parser", ">= 1.0" gem "faraday", ">= 2.0" + gem "jwt" gem "minitest", "~> 5.1", require: false gem "mocha" gem "webmock" diff --git a/README.md b/README.md index 7a71fb27..e0df4662 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Detailed guides are available at https://ruby.sdk.modelcontextprotocol.io. ## Features -- Build [MCP servers](https://ruby.sdk.modelcontextprotocol.io/server/) that expose tools, prompts, and resources to any MCP host +- Build [MCP servers](https://ruby.sdk.modelcontextprotocol.io/server/) that expose tools, prompts, and resources to any MCP host, with OAuth 2.1 resource-server protection - Build [MCP clients](https://ruby.sdk.modelcontextprotocol.io/client/) that connect to any MCP server, with automatic lifecycle negotiation and OAuth 2.1 authorization - Speak every standard transport: stdio and Streamable HTTP (including SSE), with a Rails integration - Cover the full protocol surface: server-to-client requests, multi round-trip requests, notifications, progress, logging, cancellation, completions, and pagination diff --git a/docs/_client/authorization.md b/docs/_client/authorization.md index a673a198..38fd8ebc 100644 --- a/docs/_client/authorization.md +++ b/docs/_client/authorization.md @@ -329,3 +329,8 @@ An authorization server that changes between authorization and refresh is caught present a refresh token to a different one, even when the client identity is portable across authorization servers as a Client ID Metadata Document URL is. The transport answers that refusal by running a full authorization, which brings the new authorization server back here for you to accept or refuse. Tokens stored before this behavior shipped carry no issuer and keep refreshing; the binding applies from their next authorization. + +## Server Side + +Protecting a server as an OAuth 2.1 resource server, verifying bearer tokens and serving the Protected Resource Metadata that this client discovers, +is documented on the server [Authorization](/server/authorization/) page. diff --git a/docs/_server/authorization.md b/docs/_server/authorization.md new file mode 100644 index 00000000..7fb32612 --- /dev/null +++ b/docs/_server/authorization.md @@ -0,0 +1,329 @@ +--- +layout: default +title: Authorization +nav_order: 19 +--- + +# Authorization + +Per the [MCP authorization specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization), +an HTTP-based MCP server acts as an OAuth 2.1 resource server: it validates bearer tokens and never issues them. +Token issuance belongs to an external authorization server (Auth0, Keycloak, Doorkeeper, etc.). +`MCP::Server::OAuth` provides the pieces the spec requires: + +1. A token verifier (`JWTVerifier`, `IntrospectionVerifier`, or your own `verify(token)` object) +2. Bearer enforcement with RFC 6750 challenges (401 `invalid_token`, 403 `insufficient_scope` for scope step-up), + built into the streamable HTTP transport via `token_verifier:` +3. A Protected Resource Metadata document (RFC 9728) that tells clients which authorization server protects this resource + +Wired together in a `config.ru`, the three look like this: the metadata document is served outside the protected scope, +and the transport on the MCP endpoint enforces the verifier and the required scopes: + +```ruby +# config.ru +require "mcp" + +metadata = MCP::Server::OAuth::ProtectedResourceMetadata.new( + resource: "https://mcp.example.com/mcp", # the URL clients connect to + authorization_servers: ["https://as.example.com"], # the issuer URL + scopes_supported: ["mcp:tools"], +) + +# The metadata document is how unauthenticated clients bootstrap, so it is served at the top of the stack, +# outside the bearer-protected scope, at the well-known path derived from `resource`. +use(MCP::Server::OAuth::ProtectedResourceMetadataMiddleware, metadata) + +verifier = MCP::Server::OAuth::JWTVerifier.new( + resource_metadata: metadata, # accepts only tokens its authorization server issued for this resource + jwks_uri: "https://as.example.com/.well-known/jwks.json", +) +server = MCP::Server.new(name: "my_server", tools: [SomeTool]) + +map("/mcp") do + run(MCP::Server::Transports::StreamableHTTPTransport.new( + server, + token_verifier: verifier, + required_scopes: ["mcp:tools"], + resource_metadata: metadata, + )) +end +``` + +`ProtectedResourceMetadata` also takes `resource_name:` and `resource_documentation:` for the human-readable members of the document, +`bearer_methods_supported:` (`["header"]` by default, the only method the transport accepts), and `extra:` for further RFC 9728 members +such as `jwks_uri`; the members shown above are validated at construction and cannot be overridden through `extra:`. +`scopes_supported:` must be an Array; `offline_access` is dropped from it, since the specification tells a protected resource not to advertise it, +and the member is omitted when nothing is left. +`resource:` is published exactly as given, so pass the canonical URL without a trailing slash, the form the specification prefers for interoperability, +unless the slash is significant for your resource; the verifiers check `aud` against that same value. + +Every `POST`, `GET` (SSE), and `DELETE` request is verified, per HTTP request; SSE streams are verified when opened, +and a stream whose token expires while open is closed at its next keepalive tick (every 30 seconds on the legacy `GET` stream, +every `listen_keepalive_interval:` on a `subscriptions/listen` stream, so disabling that keepalive disables the re-check as well). +Revocation is not re-checked on an open stream; a deployment that must cut streams on revocation needs short-lived tokens. +An authenticated stream is also closed after `max_stream_lifetime:` seconds, whichever comes first; the default matches `session_idle_timeout:` at 30 minutes. +That cap is what bounds a stream whose token reports no expiry at all, since `exp` is optional in an RFC 7662 introspection response and +a token without it never counts as expired. Pass `max_stream_lifetime: nil` to remove the cap, and understand that a stream opened with +an expiry-less token then runs until either side closes the connection. A stream opened without a token (no `token_verifier:`) is never capped. +Tokens are accepted from the `Authorization` header only, never from a query string, and a token longer than 8192 bytes +is rejected as `invalid_token` before any verification. A session is additionally bound to the token identity that initialized it, +so a stolen session ID cannot be driven with a different principal's token; the mismatch is answered exactly like an unknown session (404), +so a guessed session ID is not confirmed to exist. +The binding compares the token's `iss`, `sub`, and `client_id`; a token that carries neither `sub` nor `client_id` +(both are optional in an RFC 7662 introspection response) records no identity, so a session it initializes is not bound. +An RFC 9068 JWT access token carries both, so in practice the gap concerns the introspection path. +Have the authorization server emit at least one of them, or bind sessions by other means with a `session_request_validator`; +see [Session Ownership](/server/transports/#session-ownership). + +For composition at the Rack layer instead (e.g. sharing one authenticator across apps), wrap a plain transport with +`MCP::Server::OAuth::Middleware` - the transport picks the verified token up from the Rack env either way. +When a browser-based client is involved, run the CORS middleware before bearer enforcement so preflight `OPTIONS` requests +are not answered with 401, and expose the `WWW-Authenticate` response header, or the browser withholds the challenge that points +the client at the metadata document. +`Mcp-Session-Id` in the snippet below is unrelated to authorization: a handshake-lifecycle client running in a browser cannot keep its session without it, +while the modern lifecycle carries no session. `ProtectedResourceMetadataMiddleware` answers preflights and sets `Access-Control-Allow-Origin: *` on its own, +since the document is meant to be fetched cross-origin: + +```ruby +use Rack::Cors do + allow do + origins "*" # restrict in production + resource "*", headers: :any, methods: [:get, :post, :delete, :options], expose: ["Mcp-Session-Id", "WWW-Authenticate"] + end +end +``` + +The middleware takes the same options as the transport: + +```ruby +use MCP::Server::OAuth::Middleware, token_verifier: verifier, required_scopes: ["mcp:tools"], resource_metadata: metadata +run MCP::Server::Transports::StreamableHTTPTransport.new(server) +``` + +Either way these keywords shape the enforcement: + +- `token_verifier:` - the verifier; the keywords below require it +- `required_scopes:` - scopes every request must carry; a token lacking one is answered with 403 `insufficient_scope` +- `resource_metadata:` - the `ProtectedResourceMetadata` whose `well_known_url` the challenges point at, or `resource_metadata_url:` + when the document is served elsewhere (the URL wins over the document) +- `scope_matcher:` - a callable receiving a required scope and the token's granted scopes, for scope hierarchies, + which the 2026-07-28 revision requires servers to honor; by default a required scope must appear in the token verbatim +- `max_stream_lifetime:` - seconds an authenticated SSE stream may stay open before the client must present its token again, + 30 minutes by default; `nil` removes the cap. Transport-only: the Rack middleware wraps requests, not streams + +```ruby +scope_matcher: ->(required_scope, granted_scopes) { granted_scopes.include?("mcp:all") || granted_scopes.include?(required_scope) } +``` + +Bearer enforcement applies to the handshake lifecycle and the modern lifecycle alike, and in `stateless: true` mode as well, +where there is no session to bind and every request stands on its own token. + +## Rails + +With the [mount approach](/server/transports/#rails-mount), mount the transport in `config/routes.rb` and add the metadata middleware to the stack, +as the initializer below does; it serves the RFC 9728 path derived from the resource URL (`/.well-known/oauth-protected-resource/mcp` for +a resource at `/mcp`) ahead of the routes: + +```ruby +# config/routes.rb +Rails.application.routes.draw do + mount transport => "/mcp" +end +``` + +The [controller approach](/server/transports/#rails-controller) takes the same keywords on its per-request transport: `handle_request` verifies the bearer token +before reading the body, exactly as the mounted transport does, and the verified `AccessToken` reaches the tools of the per-request `MCP::Server` +as `server_context.auth_info`. The metadata document is served by the middleware either way. + +Build the verifier and the metadata once and share them across requests. A `JWTVerifier` caches the JWKS on the instance, +so one built per request would fetch the keys on every call, and a document assembled per request from what the request says, +the `Host` header for instance, would hand the sender the `aud` and `iss` the verifier checks against: + +```ruby +# config/initializers/mcp_oauth.rb +MCP_METADATA = MCP::Server::OAuth::ProtectedResourceMetadata.new( + resource: "https://mcp.example.com/mcp", + authorization_servers: ["https://as.example.com"], + scopes_supported: ["mcp:tools"], +) +MCP_VERIFIER = MCP::Server::OAuth::JWTVerifier.new( + resource_metadata: MCP_METADATA, + jwks_uri: "https://as.example.com/.well-known/jwks.json", +) +Rails.application.config.middleware.use(MCP::Server::OAuth::ProtectedResourceMetadataMiddleware, MCP_METADATA) + +# app/controllers/mcp_controller.rb +class McpController < ActionController::API + def create + server = MCP::Server.new(name: "my_server", tools: [SomeTool]) + transport = MCP::Server::Transports::StreamableHTTPTransport.new( + server, + stateless: true, + serve_subscriptions_listen: false, + token_verifier: MCP_VERIFIER, + required_scopes: ["mcp:tools"], + resource_metadata: MCP_METADATA, + ) + status, headers, body = transport.handle_request(request) + + render(json: body.first, status: status, headers: headers) + end +end +``` + +## Setting Up the Authorization Server + +The SDK covers the resource-server half only, as the specification has since its 2025-06-18 revision and as the reference SDKs do. +Token issuance belongs to an authorization server you already run or subscribe to (Auth0, Keycloak, Okta, Microsoft Entra ID, +Doorkeeper, and the like), and before a verifier can accept anything, that server must be able to issue tokens for this MCP server: + +- Register the MCP server's canonical URL (the `resource` of its Protected Resource Metadata, such as `https://mcp.example.com/mcp`) + as an API or resource at the authorization server. MCP clients request tokens with that URL as the RFC 8707 `resource` parameter; + an authorization server that does not honor the parameter usually offers an equivalent setting (an API identifier or audience). + Either way, the issued token must name the canonical URL, because the verifiers reject any other audience. +- Define the scopes the server requires (`required_scopes:` here, `scopes_supported:` in the metadata) so that clients can request them + and the authorization server can grant them. +- Collect what the verifier validates against: the JWKS URL for `JWTVerifier`, or the introspection endpoint plus client credentials issued to + this MCP server for `IntrospectionVerifier`. + +The authorization server's issuer URL goes into `authorization_servers:` of the metadata, which is what `JWTVerifier` checks `iss` against; +the built-in verifiers serve one authorization server, so the list holds one entry. + +## Choosing a Verifier + +The verifier decides how a presented token is checked, and the choice follows what the authorization server issues: +JWTs can be validated locally, opaque tokens only by asking the authorization server, and anything else fits behind the custom contract. + +| Verifier | Token type | Dependencies | Notes | +|---------------------------------------------|---------------|---------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `MCP::Server::OAuth::JWTVerifier` | JWT | `jwt` gem (lazy-required) | Local validation of signature (JWKS endpoint, static JWKS, or single key), `iss`, `aud`, `exp`, and `nbf`. The default algorithm allowlist is asymmetric-only; HMAC requires explicit opt-in. | +| `MCP::Server::OAuth::IntrospectionVerifier` | Opaque or JWT | None (stdlib `Net::HTTP`) | Asks the authorization server via RFC 7662 Token Introspection on every request, so revocation takes effect immediately. | +| Custom object | Any | None | Anything responding to `verify(token)` that returns an `MCP::Server::OAuth::AccessToken` or raises `MCP::Server::OAuth::InvalidTokenError`. Another `MCP::Server::OAuth::Error` or a returned `nil` is treated as a rejection too. | + +Both verifiers take the document as `resource_metadata:` and check `aud` against its `resource`, the canonical resource URL; +`JWTVerifier` checks `iss` against its authorization server as well. This is the RFC 8707 audience check that keeps a token issued +for another service from being replayed against your MCP server, and it cannot drift from the document, since the verifiers have +no audience setting of their own. Each built-in verifier serves one authorization server: `JWTVerifier` holds one key set and refuses +a document naming several, and `IntrospectionVerifier` asks one server's endpoint, which reports the tokens of any other server +the document names as inactive. A resource trusting several authorization servers needs a custom verifier. + +`JWTVerifier` takes its key material as exactly one of `jwks_uri:` (fetched over https, or http on loopback, +cached for `jwks_cache_ttl:` seconds, 300 by default, and rejected when the document exceeds 4 MiB), `jwks:` (a static JWKS Hash), +or `key:` (a single verification key: an `OpenSSL::PKey` for asymmetric algorithms, a String only as an HMAC secret; a `JWT::JWK` belongs in `jwks:`). +`algorithms:` is the allowlist of signature algorithms, asymmetric only by default (`RS*`, `PS*`, `ES*`, and `EdDSA`); +`none` is never accepted, HMAC (`HS*`) cannot share an allowlist with asymmetric algorithms, and `EdDSA` additionally needs the `jwt-eddsa` gem. +`leeway:` (0 by default) tolerates clock skew when checking `exp` and `nbf`; `open_timeout:` / `read_timeout:` bound the JWKS fetch (5 seconds each). +When a refresh fails, whether the endpoint errors or cannot be reached, the cached keys keep serving for `jwks_max_stale:` seconds past +the TTL (3600 by default; 0 disables the fallback), after which verification fails until the endpoint recovers. HMAC is an explicit opt-in because +a shared secret lets any holder mint tokens: + +```ruby +MCP::Server::OAuth::JWTVerifier.new( + resource_metadata: metadata, + key: ENV.fetch("HMAC_SECRET"), + algorithms: ["HS256"], +) +``` + +Opaque tokens, and any deployment that must see revocation immediately, go through RFC 7662 introspection instead. +The MCP server authenticates to the introspection endpoint with client credentials of its own, which the authorization server issues to it as a client: + +```ruby +verifier = MCP::Server::OAuth::IntrospectionVerifier.new( + resource_metadata: metadata, + introspection_endpoint: "https://as.example.com/oauth/introspect", + client_id: "mcp-resource-server", # credentials issued to this MCP server + client_secret: ENV.fetch("INTROSPECTION_CLIENT_SECRET"), +) +``` + +`client_auth_method:` selects how those credentials are sent (`:client_secret_basic` by default, `:client_secret_post`, or `:none` for an endpoint that +does not authenticate callers), and `open_timeout:` / `read_timeout:` bound each call (5 seconds each by default), and a response over 4 MiB is rejected. +The endpoint must use https except on loopback. + +`IntrospectionVerifier` costs one round-trip to the authorization server per request, and a request that fails to authenticate costs the same: +every syntactically valid token reaches the introspection endpoint, known or not, each request holding a server thread for the length of that +round-trip (the bearer syntax and token length checks that run first bound the cost of a request, not the number of requests). +Any introspection-based verifier behaves this way, the reference SDKs included. Prefer `JWTVerifier`, which validates locally, +for public or high-traffic endpoints; where introspection is required, rate-limit ahead of the transport (at a proxy or as Rack middleware), +and note that the authorization server can throttle its introspection endpoint per resource server, since every call carries the resource server's client credentials. + +## Accessing the Token in Handlers + +On success the verified `MCP::Server::OAuth::AccessToken` is threaded through to every handler as `server_context.auth_info` +(when the underlying `server_context` is a Hash, `server_context[:auth_info]` also works): + +```ruby +class WhoamiTool < MCP::Tool + description "Reports the authenticated user" + + class << self + def call(server_context:) + auth_info = server_context.auth_info + + MCP::Tool::Response.new([{ + type: "text", + text: "You are #{auth_info.subject} (scopes: #{auth_info.scopes.join(", ")})", + }]) + end + end +end +``` + +`AccessToken` exposes `subject`, `client_id`, `scopes`, `expires_at`, `issuer`, `audience`, `resource`, and the full `claims` Hash. +For per-operation checks beyond the transport-level `required_scopes`, `server_context.require_scopes!("admin")` rejects the request with +a JSON-RPC error naming the missing scopes, and `auth_info.scope?("admin")` supports custom handling; both honor the transport's `scope_matcher:`, +so a hierarchical scope scheme is applied the same way at the endpoint gate and inside handlers +(a custom verifier that returns its own object instead of an `AccessToken` is matched at the endpoint gate only). Authentication is per HTTP request +and never cached on the MCP session, so token expiry takes effect mid-session; revocation does too when the verifier can observe it, +which `IntrospectionVerifier` does on every request, while a locally validated JWT stays accepted until it expires. + +`require_scopes!` raises `MCP::Server::OAuth::InsufficientScopeError`, which the server turns into a JSON-RPC invalid-request error (`-32600`) +whose `data` names the missing scopes; the HTTP status stays 200 and no `WWW-Authenticate` challenge is sent, +because step-up challenges (403 `insufficient_scope`) are the job of the transport-level `required_scopes:` gate. +The Python and TypeScript SDKs split endpoint-level challenges from in-handler authorization the same way. +Without bearer authentication `auth_info` is `nil` and `require_scopes!` fails closed. +Use it where one operation needs more than the endpoint as a whole: + +```ruby +class DeleteRecordTool < MCP::Tool + description "Deletes a record" + + class << self + def call(id:, server_context:) + server_context.require_scopes!("records:write") + + MCP::Tool::Response.new([{ type: "text", text: "deleted #{id}" }]) + end + end +end +``` + +The challenges the transport emits parse cleanly with this SDK's own client ([Authorization](/client/authorization/)), +which discovers the Protected Resource Metadata from the `WWW-Authenticate` challenge and runs the full authorization flow automatically. +See `examples/streamable_http_server_oauth.rb` for a runnable server; its `DEV_MODE=1` switch stands in for an authorization server +in local experiments only, signing demo tokens itself with a secret generated at boot, and `ISSUER` with `JWKS_URI` points it at a real one. + +Custom transports (or non-Rack stacks) can verify tokens themselves and pass the result via `Server#handle_json(request, auth_info: access_token)`, +or set `env[MCP::Server::OAuth::ENV_KEY]` upstream of the streamable HTTP transport. + +## Response Reference + +Each failure is answered with the status and challenge the specification assigns to it; this is what a client sees in each case. +A challenge carries `resource_metadata` when `resource_metadata:` or `resource_metadata_url:` is configured, +and `scope` when `required_scopes:` is set or the metadata document advertises `scopes_supported`: + +| Situation | Response | +|--------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------| +| No `Authorization` header | 401 with a bare `WWW-Authenticate: Bearer` challenge and no error code (RFC 6750 Section 3.1) | +| Token rejected by the verifier (expired, wrong audience, bad signature, `nil` returned, or another `OAuth::Error`) | 401 `error="invalid_token"`, the verifier's message, or a default one, as `error_description` | +| Valid token lacking a scope from `required_scopes:` | 403 `error="insufficient_scope"` with the required `scope` list and `resource_metadata` | +| `Authorization` header with another scheme, or not carrying exactly one token | 400 `error="invalid_request"` | +| Valid token used against a session initialized by another principal | 404 as for an unknown session | +| Verifier raised anything outside the `OAuth::Error` hierarchy (JWKS or introspection endpoint down) | 500 without a challenge, reported through the exception reporter | +| `require_scopes!` failure inside a handler | JSON-RPC `-32600` in the normal 200 response, no HTTP challenge | + +## Client Side + +Running the authorization flow against a protected server, from the `WWW-Authenticate` challenge of a `401 Unauthorized` response through discovery, +PKCE, and token refresh, is documented on the client [Authorization](/client/authorization/) page. diff --git a/docs/_server/configuration.md b/docs/_server/configuration.md index 9480df8a..073d7ccb 100644 --- a/docs/_server/configuration.md +++ b/docs/_server/configuration.md @@ -1,7 +1,7 @@ --- layout: default title: Configuration -nav_order: 20 +nav_order: 21 --- # Configuration diff --git a/docs/_server/custom-methods.md b/docs/_server/custom-methods.md index 85dc87a3..7c9da45b 100644 --- a/docs/_server/custom-methods.md +++ b/docs/_server/custom-methods.md @@ -1,7 +1,7 @@ --- layout: default title: Custom Methods -nav_order: 21 +nav_order: 22 --- # Custom Methods diff --git a/docs/_server/index.md b/docs/_server/index.md index 5d938f1f..df2e381a 100644 --- a/docs/_server/index.md +++ b/docs/_server/index.md @@ -25,6 +25,8 @@ It implements the Model Context Protocol specification, handling model context r - Supports roots (server-to-client filesystem boundary queries; deprecated as of 2026-07-28) - Supports sampling (server-to-client LLM completion requests; deprecated as of 2026-07-28) - Supports cursor-based pagination for list operations +- Supports OAuth 2.1 resource-server protection (bearer verification, RFC 9728 Protected Resource Metadata, RFC 6750 challenges); + see [Authorization](/server/authorization/) - Supports cancellation of in-flight requests on both server and client (notifications/cancelled) ## Supported Methods diff --git a/docs/_server/server-context.md b/docs/_server/server-context.md index f45f5766..56584dd6 100644 --- a/docs/_server/server-context.md +++ b/docs/_server/server-context.md @@ -1,7 +1,7 @@ --- layout: default title: Server Context -nav_order: 19 +nav_order: 20 --- # Server Context @@ -29,6 +29,14 @@ Note that the exception reporter does not receive this user-defined hash, and in callbacks omit it unless you opt in with `instrument_server_context`. See the [Configuration](/server/configuration/) page for the arguments they receive. +## Authenticated Identity + +When the Streamable HTTP transport verifies bearer tokens (see [Authorization](/server/authorization/)), the verified +`MCP::Server::OAuth::AccessToken` is available to handlers as `server_context.auth_info` (`server_context[:auth_info]` when the context +is a plain Hash), and `server_context.require_scopes!("some:scope")` rejects the current operation with a JSON-RPC error when the token +lacks a scope. Without bearer authentication `auth_info` is `nil` and `require_scopes!` fails closed; +see [Accessing the Token in Handlers](/server/authorization/#accessing-the-token-in-handlers). + ## Request-specific `_meta` Parameter The MCP protocol supports a special [`_meta` parameter](https://modelcontextprotocol.io/specification/latest/basic#general-fields) in requests that allows clients to pass request-specific metadata. The server automatically extracts this parameter and makes it available to tools and prompts as a nested field within the `server_context`. diff --git a/docs/_server/transports.md b/docs/_server/transports.md index 009c5062..fdd637a2 100644 --- a/docs/_server/transports.md +++ b/docs/_server/transports.md @@ -245,9 +245,10 @@ by `max_listen_subscriptions:`. ### Session Ownership `StreamableHTTPTransport` issues a random `SecureRandom.uuid` session ID and validates incoming requests by session -existence and idle timeout only. It does not bind a session to a user, because the transport never receives -an authenticated identity on its own. A caller that obtains a valid session ID could therefore act on that session, -so binding a session to a user is the deploying application's responsibility (the MCP spec frames this as a SHOULD). +existence and idle timeout only. Without bearer authentication it does not bind a session to a user, because the transport +then receives no authenticated identity on its own. A caller that obtains a valid session ID could therefore act on that session, +so binding a session to a user is the deploying application's responsibility (the MCP spec frames this as a SHOULD); +with `token_verifier:` configured the transport does it itself, as described at the end of this section. The primary control is the `session_request_validator`. It is called as `->(request, session_id) { true | false }` on every non-`initialize` POST, GET, and DELETE against an existing session (including notification and response POSTs, @@ -262,15 +263,18 @@ transport = MCP::Server::Transports::StreamableHTTPTransport.new( ) ``` -Without a validator the transport does not enforce ownership. As a limited defense in depth (not authentication), +Without a validator or bearer authentication the transport does not enforce ownership. As a limited defense in depth (not authentication), it also records the `Origin` header at `initialize` and rejects a later request whose `Origin` differs, but only when both are present - a non-browser client that omits `Origin` (e.g. `curl` or a script) is not stopped by this check. Enforcing ownership against a determined attacker requires supplying the validator with an authenticated principal. +Bearer authentication configured with `token_verifier:` supplies one automatically: each session is then also bound +to the token identity that initialized it; see [Authorization](/server/authorization/). Requests of the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) carry no `Mcp-Session-Id` and touch no stored session, so there is no session to steal, and neither the validator nor the recorded-`Origin` comparison runs for them (the per-request `Origin` validation of the DNS rebinding protection above still applies); -on that path, authorization is enforced per request by the deploying application. +on that path, bearer enforcement configured with `token_verifier:` still applies to every request; without it, +authorization is enforced per request by the deploying application. ### Request Size Limits diff --git a/docs/examples.md b/docs/examples.md index 819f4c11..9c962b62 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -16,6 +16,7 @@ Runnable examples live in [`examples/`](https://github.com/modelcontextprotocol/ - [`http_server.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/http_server.rb) - a Rack-based Streamable HTTP server with session management and SSE support - [`http_client.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/http_client.rb) - a client driving the HTTP server through all MCP protocol methods - [`streamable_http_server.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/streamable_http_server.rb) - an SSE-focused server with tools that trigger notifications and progress updates +- [`streamable_http_server_oauth.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/streamable_http_server_oauth.rb) - a Streamable HTTP server protected as an OAuth 2.1 resource server, with a `whoami` tool that reads the verified token; `DEV_MODE=1` signs demo tokens locally - [`streamable_http_client.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/streamable_http_client.rb) - an interactive, menu-driven client for testing the SSE stream Each script is standalone and run from the repository root: diff --git a/docs/index.md b/docs/index.md index ee275fc6..724fe2af 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,7 +10,7 @@ The official Ruby SDK for the [Model Context Protocol](https://modelcontextproto ## Features -- Build [MCP servers](/server/) that expose tools, prompts, and resources to any MCP host +- Build [MCP servers](/server/) that expose tools, prompts, and resources to any MCP host, with OAuth 2.1 resource-server protection - Build [MCP clients](/client/) that connect to any MCP server, with automatic lifecycle negotiation and OAuth 2.1 authorization - Speak every standard transport: stdio and Streamable HTTP (including SSE), with a Rails integration - Cover the full protocol surface: server-to-client requests, multi round-trip requests, notifications, progress, logging, cancellation, completions, and pagination @@ -110,7 +110,7 @@ For comprehensive documentation, see: - [Installation](/installation/) - installing the gem and optional feature dependencies - [Examples](/examples/) - runnable example scripts and a complete Rails application - [Protocol Versions](/protocol-versions/) - supported versions, the era model, and client negotiation -- [Building Servers](server/) - transports, discovery, tools, prompts, resources, server-to-client requests, multi round-trip requests, notifications, protocol utilities, and configuration +- [Building Servers](server/) - transports, discovery, tools, prompts, resources, server-to-client requests, multi round-trip requests, notifications, protocol utilities, configuration, and OAuth 2.1 authorization - [Building Clients](client/) - transports, lifecycle negotiation, multi round-trip requests, and OAuth 2.1 authorization - [Extensions](/extensions/) - capability extensions and MCP Apps diff --git a/docs/installation.md b/docs/installation.md index 91aaee1a..fe9c14eb 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -35,7 +35,17 @@ Or install it yourself as: $ gem install mcp ``` -You may need to add additional dependencies depending on which features you wish to access. For example, the HTTP client transport requires the `faraday` gem: +You may need to add additional dependencies depending on which features you wish to access. + +Verifying JWT access tokens on the server with `MCP::Server::OAuth::JWTVerifier` requires the `jwt` gem (and `jwt-eddsa` as well for EdDSA-signed tokens), +which the verifier loads only when it is instantiated; `MCP::Server::OAuth::IntrospectionVerifier` needs nothing beyond the standard library. +See [Authorization](/server/authorization/). + +```ruby +gem "jwt" +``` + +The HTTP client transport requires the `faraday` gem: ```ruby gem "faraday", ">= 2.0" diff --git a/examples/README.md b/examples/README.md index af1828a7..89b727d6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -109,7 +109,30 @@ $ ruby examples/streamable_http_server.rb The server will start on `http://localhost:9393` and provide detailed instructions for testing SSE functionality. -### 6. Streamable HTTP Client (`streamable_http_client.rb`) +### 6. OAuth Resource Server (`streamable_http_server_oauth.rb`) + +A streamable HTTP server protected as an OAuth 2.1 resource server per the MCP authorization specification. + +**Features:** + +- Bearer token enforcement built into the transport (`token_verifier:`), answering 401/403 `WWW-Authenticate` challenges +- Protected Resource Metadata (RFC 9728) served at `/.well-known/oauth-protected-resource` +- JWT verification against the authorization server's JWKS (`ISSUER` and `JWKS_URI`), or HS256 with a secret generated at boot in the explicit development mode (`DEV_MODE=1`) +- A `whoami` tool that reads the verified token from `server_context.auth_info` + +**Usage:** + +```console +$ DEV_MODE=1 ruby examples/streamable_http_server_oauth.rb +``` + +The server will start on `http://localhost:9393` and print curl commands, including a ready-to-use development token, +for exercising the 401 challenge, the metadata document, and authenticated tool calls. Without `DEV_MODE=1` or a `JWKS_URI` pointing at +an authorization server, the example refuses to start. + +Requires the `jwt` gem (`gem install jwt`). + +### 7. Streamable HTTP Client (`streamable_http_client.rb`) An interactive client that connects to the SSE stream and provides a menu-driven interface for testing SSE functionality. @@ -141,7 +164,7 @@ The client will: - Provide an interactive menu to trigger notifications - Display all received SSE events in real-time -### 7. Rails Server (`rails/`) +### 8. Rails Server (`rails/`) A minimal Rails application that mounts `StreamableHTTPTransport` in its routes, following the "Rails (mount)" pattern from the top-level README. It demonstrates class-based tools in `app/tools/` and a resource with a read handler. @@ -156,7 +179,7 @@ $ bundle exec puma --port 9292 The MCP endpoint is available at `http://localhost:9292/mcp`. See [`rails/README.md`](rails/README.md) for a full curl-based walkthrough. -### 8. Modern Lifecycle HTTP Server / Client (`modern_http_server.rb`, `modern_http_client.rb`) +### 9. Modern Lifecycle HTTP Server / Client (`modern_http_server.rb`, `modern_http_client.rb`) A server and client pair demonstrating the 2026-07-28 modern lifecycle (SEP-2575), which replaces the `initialize` handshake and per-session state with sessionless, self-contained requests. diff --git a/examples/streamable_http_server_oauth.rb b/examples/streamable_http_server_oauth.rb new file mode 100644 index 00000000..ffdb6f9a --- /dev/null +++ b/examples/streamable_http_server_oauth.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +# An MCP server protected as an OAuth 2.1 resource server per the MCP authorization specification: +# +# - Bearer tokens are required on every MCP request; missing/invalid tokens answer 401 with a `WWW-Authenticate` challenge +# pointing at the Protected Resource Metadata document. +# - The Protected Resource Metadata (RFC 9728) is served at /.well-known/oauth-protected-resource so clients can discover +# the authorization server. +# - Tools read the verified token as `server_context.auth_info`. +# +# The MCP server itself never issues tokens: bring your own authorization server (Auth0, Keycloak, doorkeeper, etc.) +# and point the verifier at its JWKS endpoint: +# +# ISSUER=https://as.example.com \ +# JWKS_URI=https://as.example.com/.well-known/jwks.json \ +# ruby examples/streamable_http_server_oauth.rb +# +# Without an authorization server at hand, opt in to development mode. It verifies HS256 JWTs with a secret generated at boot +# (or `DEV_SECRET` when set) and prints a matching demo token. The example refuses to start when neither `JWKS_URI` nor +# `DEV_MODE=1` is given, so the shared-secret path can never be reached by accident, and when both are given: +# +# DEV_MODE=1 ruby examples/streamable_http_server_oauth.rb +# +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) +require "mcp" +require "rack/cors" +require "rackup" +require "json" +require "jwt" +require "logger" +require "securerandom" + +RESOURCE_URL = ENV.fetch("RESOURCE_URL", "http://localhost:9393") +ISSUER = ENV.fetch("ISSUER", "http://localhost:9000") +JWKS_URI = ENV["JWKS_URI"] +DEV_MODE = ENV["DEV_MODE"] == "1" + +unless JWKS_URI || DEV_MODE + warn(<<~MESSAGE) + Refusing to start: no token verifier is configured. + + Point the example at your authorization server's JWKS endpoint: + ISSUER=https://as.example.com JWKS_URI=https://as.example.com/.well-known/jwks.json ruby #{$PROGRAM_NAME} + + Or opt in to development mode, which signs demo tokens with a secret generated at boot: + DEV_MODE=1 ruby #{$PROGRAM_NAME} + MESSAGE + + exit(1) +end + +# The two modes are mutually exclusive: the demo token printed in development mode is signed with the boot-time secret, +# which a JWKS-backed verifier would reject, so combining them would print a token that never works. +if JWKS_URI && DEV_MODE + warn("Refusing to start: JWKS_URI and DEV_MODE=1 are mutually exclusive; pick one.") + + exit(1) +end + +# Development mode only. The secret is generated per boot unless DEV_SECRET is set, so no shared secret ships with the example. +DEV_SECRET = DEV_MODE ? ENV.fetch("DEV_SECRET") { SecureRandom.hex(32) } : nil + +# Tool that reports who the authenticated caller is. +class WhoamiTool < MCP::Tool + tool_name "whoami" + description "Reports the authenticated subject, client, and scopes" + + class << self + def call(server_context:) + auth_info = server_context.auth_info + + MCP::Tool::Response.new([{ + type: "text", + text: JSON.pretty_generate( + subject: auth_info.subject, + client_id: auth_info.client_id, + scopes: auth_info.scopes, + issuer: auth_info.issuer, + ), + }]) + end + end +end + +server = MCP::Server.new(name: "oauth_example_server", tools: [WhoamiTool]) + +# RFC 9728 Protected Resource Metadata: tells clients which authorization server issues tokens for +# this resource and which scopes exist. +metadata = MCP::Server::OAuth::ProtectedResourceMetadata.new( + resource: RESOURCE_URL, + authorization_servers: [ISSUER], + scopes_supported: ["mcp:tools"], + resource_name: "MCP OAuth Example Server", +) + +# The verifier takes its expected `iss` and `aud` from the metadata document. +verifier = if JWKS_URI + MCP::Server::OAuth::JWTVerifier.new( + resource_metadata: metadata, + jwks_uri: JWKS_URI, + ) +else + # Development mode: HS256 with the boot-time secret. Anyone holding the secret can mint tokens, + # so this path is reachable only behind the explicit DEV_MODE=1 opt-in above. + MCP::Server::OAuth::JWTVerifier.new( + resource_metadata: metadata, + key: DEV_SECRET, + algorithms: ["HS256"], + ) +end + +# The transport enforces bearer authentication itself. Alternatively, wrap a plain transport with +# `MCP::Server::OAuth::Middleware` to compose the same enforcement at the Rack layer. +transport = MCP::Server::Transports::StreamableHTTPTransport.new( + server, + token_verifier: verifier, + required_scopes: ["mcp:tools"], + resource_metadata: metadata, +) + +rack_app = Rack::Builder.new do + # Enable CORS to allow browser-based MCP clients (e.g., MCP Inspector). + # CORS must run before bearer enforcement: the browser's preflight OPTIONS request carries no Authorization header, + # and a 401 answered to the preflight would fail CORS closed before the client could authenticate. + # WARNING: origins("*") allows all origins. Restrict this in production. + use(Rack::Cors) do + allow do + origins("*") + resource( + "*", + headers: :any, + methods: [:get, :post, :delete, :options], + expose: ["Mcp-Session-Id", "WWW-Authenticate"], + ) + end + end + + use(Rack::CommonLogger, Logger.new($stdout)) + + # The metadata document is how unauthenticated clients bootstrap, so it is served above the bearer-protected transport, + # at the well-known path derived from RESOURCE_URL. + use(MCP::Server::OAuth::ProtectedResourceMetadataMiddleware, metadata) + + map("/") do + run(transport) + end +end + +token_steps = if DEV_MODE + demo_token = JWT.encode( + { + iss: ISSUER, + aud: RESOURCE_URL, + sub: "demo-user", + client_id: "demo-client", + scope: "mcp:tools", + exp: Time.now.to_i + 3600, + }, + DEV_SECRET, + "HS256", + ) + + <<~STEPS + 3. Authenticated requests succeed (development token, valid for 1 hour): + curl -i #{RESOURCE_URL} \\ + -H "Authorization: Bearer #{demo_token}" \\ + -H "Accept: application/json, text/event-stream" \\ + --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' + + 4. Call the whoami tool with the session ID from step 3: + curl -i #{RESOURCE_URL} \\ + -H "Authorization: Bearer #{demo_token}" \\ + -H "Mcp-Session-Id: YOUR_SESSION_ID" \\ + -H "Accept: application/json, text/event-stream" \\ + --json '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"whoami","arguments":{}}}' + STEPS +else + <<~STEPS + 3. Obtain an access token for #{RESOURCE_URL} from #{ISSUER}, repeat step 1 with + -H "Authorization: Bearer YOUR_TOKEN", and call the whoami tool with the session ID + from that response. + STEPS +end + +puts <<~MESSAGE + === MCP OAuth Resource Server Example === + + Starting server on #{RESOURCE_URL}#{DEV_MODE ? " (development mode)" : ""} + + 1. Unauthenticated requests get a 401 challenge: + curl -i #{RESOURCE_URL} \\ + -H "Accept: application/json, text/event-stream" \\ + --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' + + 2. The challenge points at the Protected Resource Metadata: + curl -i #{metadata.well_known_url} + + #{token_steps} + Press Ctrl+C to stop the server +MESSAGE + +Rackup::Handler.get("puma").run(rack_app, Port: 9393, Host: "localhost") diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 051a8928..13f5fd1b 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -13,6 +13,7 @@ require_relative "server_context" require_relative "server/capabilities" require_relative "server/input_required_result" +require_relative "server/oauth" require_relative "server/pagination" require_relative "server/pending_response" require_relative "server/request_state_security" @@ -274,10 +275,13 @@ def initialize( # @param session [ServerSession, nil] Per-connection session. Passed by # `ServerSession#handle` for session-scoped notification delivery. # When `nil`, progress and logging notifications from tool handlers are silently skipped. + # @param auth_info [Server::OAuth::AccessToken, nil] The verified access token + # for the current request, passed by the transport layer and exposed to + # handlers as `server_context.auth_info`. # @return [Hash, nil] The JSON-RPC response, or `nil` for notifications. - def handle(request, session: nil) + def handle(request, session: nil, auth_info: nil) JsonRpcHandler.handle(request) do |method, request_id| - handle_request(request, method, session: session, related_request_id: request_id) + handle_request(request, method, session: session, related_request_id: request_id, auth_info: auth_info) end end @@ -287,10 +291,12 @@ def handle(request, session: nil) # @param session [ServerSession, nil] Per-connection session. Passed by # `ServerSession#handle_json` for session-scoped notification delivery. # When `nil`, progress and logging notifications from tool handlers are silently skipped. + # @param auth_info [Server::OAuth::AccessToken, nil] The verified access token + # for the current request, as in `handle`. # @return [String, nil] The JSON-RPC response as JSON, or `nil` for notifications. - def handle_json(request, session: nil) + def handle_json(request, session: nil, auth_info: nil) JsonRpcHandler.handle_json(request) do |method, request_id| - handle_request(request, method, session: session, related_request_id: request_id) + handle_request(request, method, session: session, related_request_id: request_id, auth_info: auth_info) end end @@ -595,7 +601,7 @@ def schema_contains_ref?(schema) end end - def handle_request(request, method, session: nil, related_request_id: nil) + def handle_request(request, method, session: nil, related_request_id: nil, auth_info: nil) # A well-formed notification carries no JSON-RPC id and receives no response. # If a client erroneously sends a notification-only method with an id, the message # is framed as a request; since notification methods have no request handler, @@ -686,25 +692,25 @@ def handle_request(request, method, session: nil, related_request_id: nil) when Methods::INITIALIZE init(params, session: session) when Methods::RESOURCES_READ - contents = read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) + contents = read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info) # An SEP-2322 `input_required` result must not be wrapped as `contents` or stamped with SEP-2549 cache hints. contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents) when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE validate_resource_subscription_params!(params) - handler_result = dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) + handler_result = dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info) subscription_result(handler_result) when Methods::TOOLS_CALL - call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) + call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info) when Methods::PROMPTS_GET - get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) + get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info) when Methods::COMPLETION_COMPLETE - complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) + complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info) when Methods::LOGGING_SET_LEVEL configure_logging_level(params, session: session) else - dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) + dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info) end client = session&.client || @client add_instrumentation_data(client: client) if client @@ -755,6 +761,15 @@ def handle_request(request, method, session: nil, related_request_id: nil) rescue CancelledError => e add_instrumentation_data(cancelled: true, cancellation_reason: e.reason) next JsonRpcHandler::NO_RESPONSE + rescue OAuth::InsufficientScopeError => e + # A handler's `require_scopes!` failure is a client authorization error, not a server fault: + # surface it as an invalid-request JSON-RPC error whose message names the missing scopes. + # HTTP-level 403 challenges remain the transport `required_scopes` gate's job. + report_exception(e, { request: request }) + add_instrumentation_data(error: :insufficient_scope) + converted = RequestHandlerError.new(e.message, request, error_type: :invalid_request, original_error: e) + reported_exception = converted + raise converted rescue RequestHandlerError => e report_exception(e.original_error || e, { request: request }) add_instrumentation_data(error: e.error_type) @@ -1219,7 +1234,7 @@ def list_tools(request) apply_cache_metadata({ tools: page[:items], nextCursor: page[:next_cursor] }.compact) end - def call_tool(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) + def call_tool(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil) tool_name = request[:name] tool = tools[tool_name] @@ -1254,12 +1269,13 @@ def call_tool(request, session: nil, related_request_id: nil, cancellation: nil, response = call_tool_with_args( tool, arguments, - server_context_with_meta(request), + server_context_with_meta(request, auth_info: auth_info), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, + auth_info: auth_info, retry_fields: mrtr_retry_fields(request), ) # An SEP-2322 `input_required` result is not a tool result: output schema @@ -1273,9 +1289,11 @@ def call_tool(request, session: nil, related_request_id: nil, cancellation: nil, result, content_provided: response.respond_to?(:content_provided?) && response.content_provided?, ) - rescue RequestHandlerError, CancelledError + rescue RequestHandlerError, CancelledError, OAuth::InsufficientScopeError # CancelledError is intentionally not wrapped so `handle_request` can turn it into - # `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec. + # `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec, and + # `OAuth::InsufficientScopeError` so `require_scopes!` failures keep their + # scope-naming message instead of the generic internal-error shape. raise rescue => e # `e.message` is deliberately not included: it can carry internals (class, method @@ -1295,7 +1313,7 @@ def list_prompts(request) apply_cache_metadata({ prompts: page[:items], nextCursor: page[:next_cursor] }.compact) end - def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) + def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil) prompt_name = request[:name] prompt = @prompts[prompt_name] unless prompt @@ -1323,6 +1341,7 @@ def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, + auth_info: auth_info, ) call_prompt_template_with_args(prompt, prompt_args, server_context) @@ -1435,7 +1454,7 @@ def apply_cache_metadata(result) { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "private" }.merge(result) end - def complete(params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) + def complete(params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil) validate_completion_params!(params) result = dispatch_optional_context_handler( @@ -1445,6 +1464,7 @@ def complete(params, session: nil, related_request_id: nil, cancellation: nil, e related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, + auth_info: auth_info, ) normalize_completion_result(result) @@ -1453,7 +1473,7 @@ def complete(params, session: nil, related_request_id: nil, cancellation: nil, e # Invokes `resources/read` via the registered handler. If the handler block opts in to `server_context:`, # pass an `MCP::ServerContext` so the handler can observe cancellation via `server_context.cancelled?` or # `server_context.raise_if_cancelled!`. - def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) + def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil) dispatch_optional_context_handler( @handlers[Methods::RESOURCES_READ], request, @@ -1461,6 +1481,7 @@ def read_resource_contents(request, session: nil, related_request_id: nil, cance related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, + auth_info: auth_info, ) end @@ -1468,7 +1489,7 @@ def read_resource_contents(request, session: nil, related_request_id: nil, cance # `completion_handler`, `resources_subscribe_handler`, `resources_unsubscribe_handler`, or `define_custom_method`. # Existing handlers that only accept `params` are called unchanged; handlers that declare a `server_context:` # keyword receive an `MCP::ServerContext` wrapping the raw server context with cancellation plumbing. - def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) + def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil) return handler.call(params) unless handler_declares_server_context?(handler) server_context = build_server_context( @@ -1477,6 +1498,7 @@ def dispatch_optional_context_handler(handler, params, session: nil, related_req related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, + auth_info: auth_info, ) handler.call(params, server_context: server_context) end @@ -1501,13 +1523,13 @@ def handler_declares_server_context?(handler) # Builds an `MCP::ServerContext` used to give a handler access to session-scoped helpers # (progress, cancellation, nested server-to-client requests). - def build_server_context(request:, session:, related_request_id:, cancellation:, envelope: nil) + def build_server_context(request:, session:, related_request_id:, cancellation:, envelope: nil, auth_info: nil) meta_source = request.is_a?(Hash) ? request : {} progress_token = meta_source.dig(:_meta, :progressToken) progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id) retry_fields = mrtr_retry_fields(meta_source) ServerContext.new( - server_context_with_meta(meta_source), + server_context_with_meta(meta_source, auth_info: auth_info), progress: progress, notification_target: session, related_request_id: related_request_id, @@ -1515,6 +1537,7 @@ def build_server_context(request:, session:, related_request_id:, cancellation:, envelope: envelope, input_responses: retry_fields[:input_responses], request_state: retry_fields[:request_state], + auth_info: auth_info, ) end @@ -1574,7 +1597,7 @@ def accepts_server_context?(method_object) end end - def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, retry_fields: nil) + def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil, retry_fields: nil) # Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized # at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments; # it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down. @@ -1592,6 +1615,7 @@ def call_tool_with_args(tool, arguments, context, progress_token: nil, session: envelope: envelope, input_responses: retry_fields&.fetch(:input_responses, nil), request_state: retry_fields&.fetch(:request_state, nil), + auth_info: auth_info, ) tool.call(**args, server_context: server_context) else @@ -1609,15 +1633,18 @@ def call_prompt_template_with_args(prompt, args, server_context) raw_result.is_a?(InputRequiredResult) ? raw_result : raw_result.to_h end - def server_context_with_meta(request) + def server_context_with_meta(request, auth_info: nil) meta = request[:_meta] - if meta && server_context.is_a?(Hash) - context = server_context.dup - context[:_meta] = meta + return server_context if meta.nil? && auth_info.nil? + + if server_context.is_a?(Hash) || server_context.nil? + context = (server_context || {}).dup + context[:_meta] = meta if meta + context[:auth_info] = auth_info if auth_info context - elsif meta && server_context.nil? - { _meta: meta } else + # A custom (non-Hash) user context is passed through untouched; handlers on that path read + # the token via `server_context.auth_info` instead. server_context end end diff --git a/lib/mcp/server/oauth.rb b/lib/mcp/server/oauth.rb new file mode 100644 index 00000000..6da11c41 --- /dev/null +++ b/lib/mcp/server/oauth.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative "oauth/errors" + +module MCP + class Server + # OAuth 2.1 resource-server support per the MCP authorization specification: bearer token verification, + # Protected Resource Metadata (RFC 9728) serving, and RFC 6750 `WWW-Authenticate` challenges. + # The MCP server never acts as an authorization server; token issuance belongs to an external provider. + # https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization + module OAuth + # The Rack env key under which the transport's built-in enforcement and `Middleware` store + # the verified `AccessToken`. The streamable HTTP transport reads this key and threads the value through + # to handlers as `server_context.auth_info`. Custom integrations that verify tokens themselves can set + # the same key to opt in to that propagation. + ENV_KEY = "mcp.auth_info" + + autoload :AccessToken, "mcp/server/oauth/access_token" + autoload :Authenticator, "mcp/server/oauth/authenticator" + autoload :Challenge, "mcp/server/oauth/challenge" + autoload :IntrospectionVerifier, "mcp/server/oauth/introspection_verifier" + autoload :JWTVerifier, "mcp/server/oauth/jwt_verifier" + autoload :Middleware, "mcp/server/oauth/middleware" + autoload :ProtectedResourceMetadata, "mcp/server/oauth/protected_resource_metadata" + autoload :ProtectedResourceMetadataMiddleware, "mcp/server/oauth/protected_resource_metadata_middleware" + autoload :TokenVerifier, "mcp/server/oauth/token_verifier" + end + end +end diff --git a/lib/mcp/server/oauth/access_token.rb b/lib/mcp/server/oauth/access_token.rb new file mode 100644 index 00000000..e9ec6863 --- /dev/null +++ b/lib/mcp/server/oauth/access_token.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module MCP + class Server + module OAuth + # The result of a successful token verification: the resource-server-side view of an access token. + # Instances are produced by a `TokenVerifier` and reach tool/prompt/resource handlers as `server_context.auth_info`. + # + # The field set is the union of what the official SDKs converged on (Python `AccessToken` and TypeScript `AuthInfo`): + # + # - `token` - the raw bearer token as presented by the client + # - `client_id` - the OAuth client the token was issued to + # - `scopes` - granted scopes as an array of strings + # - `expires_at` - expiry as an Integer unix timestamp, or nil when the token does not expire + # - `subject` - the end user (`sub` claim), when known + # - `issuer` - the authorization server that issued the token (`iss` claim) + # - `audience` - the raw `aud` value (String or Array), when known + # - `resource` - the canonical RFC 8707 resource identifier the verifier matched the token against, + # useful for tenancy checks inside handlers + # - `claims` - the full claim/introspection-response Hash for anything not covered by the named fields + class AccessToken + attr_reader :token, :client_id, :scopes, :expires_at, :subject, :issuer, :audience, :resource, :claims + + def initialize(token:, client_id: nil, scopes: [], expires_at: nil, subject: nil, issuer: nil, audience: nil, resource: nil, claims: {}) + @token = token + @client_id = client_id + @scopes = scopes + @expires_at = expires_at + @subject = subject + @issuer = issuer + @audience = audience + @resource = resource + @claims = claims + @scope_matcher = nil + end + + # Per RFC 7519 Section 4.1.4, a token must not be accepted on or after its expiry time. + # Tokens without an expiry never count as expired here; verifiers that require an expiry must enforce that themselves + # (`JWTVerifier` does, the TypeScript SDK rejects such tokens outright, and Python accepts them like this class — + # the divergence is deliberate: opaque-token verifiers may have no expiry to report even though the authorization server enforces one). + def expired?(now: Time.now.to_i) + return false if expires_at.nil? + + expires_at <= now + end + + # Whether the token grants `scope`: exact membership by default. When the authenticator attached its `scope_matcher:` + # (see `with_scope_matcher`), that callable decides instead, so a hierarchical scope scheme is honored the same way + # at the endpoint gate and inside handlers via `require_scopes!`. + def scope?(scope) + return scopes.include?(scope.to_s) if @scope_matcher.nil? + + !!@scope_matcher.call(scope.to_s, scopes) + end + + # A copy of this token whose `scope?` consults `matcher`, a `(required_scope, granted_scopes) -> Boolean` callable. + # The receiver is left untouched; a nil matcher returns the receiver itself. + def with_scope_matcher(matcher) + return self if matcher.nil? + + dup.tap { |copy| copy.scope_matcher = matcher } + end + + # Mirrors `to_h`: the default `inspect` would print `@token`, leaking the credential into exception reports and debug output. + def inspect + "#<#{self.class.name} #{to_h.inspect}>" + end + + # Omits `token` so the result is safe to log without leaking the credential. + def to_h + { + client_id: client_id, + scopes: scopes, + expires_at: expires_at, + subject: subject, + issuer: issuer, + audience: audience, + resource: resource, + claims: claims, + }.compact + end + + protected + + attr_writer :scope_matcher + end + end + end +end diff --git a/lib/mcp/server/oauth/authenticator.rb b/lib/mcp/server/oauth/authenticator.rb new file mode 100644 index 00000000..ca8867e2 --- /dev/null +++ b/lib/mcp/server/oauth/authenticator.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require_relative "access_token" +require_relative "challenge" +require_relative "errors" + +module MCP + class Server + module OAuth + # The verification core shared by `Middleware` and the streamable HTTP transport's built-in enforcement: + # extracts the bearer token from a Rack env, runs it through the verifier, checks scopes, and maps each failure + # to the RFC 6750 challenge response it deserves. + # + # - `token_verifier` - anything responding to `verify(token)`; see `TokenVerifier` for the contract. + # Built-ins: `JWTVerifier`, `IntrospectionVerifier`. + # - `required_scopes` - scopes the token must include, all of them; a shortfall produces 403 `insufficient_scope` (step-up). + # - `resource_metadata` - a `ProtectedResourceMetadata`; its well-known URL and `scopes_supported` feed the challenges. + # Pass `resource_metadata_url:` instead to point at a document served elsewhere. + # - `scope_matcher` - optional callable `(required_scope, granted_scopes) -> Boolean` for deployments + # whose scopes form a hierarchy; the MCP authorization specification requires a broader scope to satisfy + # the narrower scopes it implies, which exact membership (the default) cannot express. The matcher also rides + # on the returned `AccessToken`, so `require_scopes!` and `scope?` inside handlers apply the same semantics. + class Authenticator + BEARER_PATTERN = /\ABearer\s+(?\S+)\z/i.freeze + private_constant :BEARER_PATTERN + + # Upper bound on the presented token, applied before any verification so oversized garbage neither reaches + # cryptographic parsing nor gets forwarded to an introspection endpoint. Real-world access tokens stay far below this; + # web servers cap the whole header section around 16 KiB. + MAX_TOKEN_BYTES = 8192 + + def initialize(token_verifier:, required_scopes: [], resource_metadata: nil, resource_metadata_url: nil, scope_matcher: nil) + @verifier = token_verifier + @required_scopes = Array(required_scopes) + @resource_metadata = resource_metadata + @resource_metadata_url = resource_metadata_url || resource_metadata&.well_known_url + @scope_matcher = scope_matcher + end + + # @param env [Hash] the Rack env + # @return [AccessToken] + # @raise [MissingTokenError, InvalidRequestError, InvalidTokenError, InsufficientScopeError] + def authenticate(env) + token = extract_bearer_token(env) + + access_token = @verifier.verify(token) + raise InvalidTokenError if access_token.nil? + + access_token = attach_scope_matcher(access_token) + check_scopes!(access_token) + access_token + end + + # Maps an `OAuth::Error` raised by `authenticate` (or by a handler's `require_scopes!`) to the Rack response carrying + # the matching challenge. An `Error` outside the four built-in classes, such as one a custom verifier defines, + # is a token rejection and answers 401 like an invalid token; anything else is a programming error. + def challenge_response(error) + case error + when MissingTokenError + Challenge.missing_token_response(scope: scope_hint, resource_metadata: @resource_metadata_url) + when InvalidRequestError + Challenge.invalid_request_response(error_description: error.message, resource_metadata: @resource_metadata_url) + when InsufficientScopeError + Challenge.insufficient_scope_response( + error_description: error.message, + scope: scope_hint(error.required_scopes), + resource_metadata: @resource_metadata_url, + ) + when Error + Challenge.invalid_token_response( + error_description: error.message, + scope: scope_hint, + resource_metadata: @resource_metadata_url, + ) + else + raise ArgumentError, "unexpected error class: #{error.class}" + end + end + + private + + def extract_bearer_token(env) + header = env["HTTP_AUTHORIZATION"] + raise MissingTokenError if header.nil? || header.empty? + + # Matched as bytes: a Rack server hands the header over as ASCII-8BIT, but a test request or a framework can tag it UTF-8, + # and a regexp raises on an invalid byte sequence in a UTF-8 string. As bytes, such a token simply fails verification. + match = BEARER_PATTERN.match(header.b) + raise InvalidRequestError, "Authorization header must carry a single Bearer token" if match.nil? + + token = match[:token] + raise InvalidTokenError, "Token exceeds the maximum accepted length" if token.bytesize > MAX_TOKEN_BYTES + + token + end + + def check_scopes!(access_token) + missing_scopes = @required_scopes.reject { |scope| scope_satisfied?(scope, access_token) } + return if missing_scopes.empty? + + raise InsufficientScopeError.new( + "Token is missing required scopes: #{missing_scopes.join(", ")}", + required_scopes: @required_scopes, + ) + end + + # A verifier result that is not an `AccessToken` (a duck-typed object from a custom verifier) keeps its own `scope?`; + # the endpoint gate below still applies the matcher to it directly. + def attach_scope_matcher(access_token) + return access_token if @scope_matcher.nil? || !access_token.respond_to?(:with_scope_matcher) + + access_token.with_scope_matcher(@scope_matcher) + end + + # A verifier result without a `scopes` reader (a duck-typed object offering only `scope?`) keeps its own judgement, + # since the matcher needs the granted list to reason about a hierarchy. + def scope_satisfied?(scope, access_token) + return access_token.scope?(scope) if @scope_matcher.nil? || !access_token.respond_to?(:scopes) + + @scope_matcher.call(scope, access_token.scopes) + end + + # The scope hint the spec recommends including in challenges: the scopes of the failing operation when known, + # the scopes this authenticator enforces otherwise, or the resource's advertised scopes as a fallback. + # `offline_access` is never advertised: refresh token issuance is not a resource requirement per + # the MCP authorization specification. + def scope_hint(operation_scopes = []) + scopes = operation_scopes + scopes = @required_scopes if scopes.empty? + scopes = Array(@resource_metadata&.scopes_supported) if scopes.empty? + scopes -= ["offline_access"] + + scopes.empty? ? nil : scopes.join(" ") + end + end + end + end +end diff --git a/lib/mcp/server/oauth/challenge.rb b/lib/mcp/server/oauth/challenge.rb new file mode 100644 index 00000000..b1e9a6e0 --- /dev/null +++ b/lib/mcp/server/oauth/challenge.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require "json" + +module MCP + class Server + module OAuth + # Stateless builders for RFC 6750 Bearer challenges and the Rack error responses that carry them. + # `Authenticator` uses these for every rejection; they are also public so custom integrations can emit + # spec-shaped 401/403 responses without pulling in the middleware. + # + # The header output is the exact counterpart of `MCP::Client::OAuth::Discovery.parse_www_authenticate`: + # everything built here parses back into the same parameters on the client side. + # https://www.rfc-editor.org/rfc/rfc6750#section-3 + module Challenge + CONTENT_TYPE_JSON = { "content-type" => "application/json" }.freeze + private_constant :CONTENT_TYPE_JSON + + class << self + # Returns a `WWW-Authenticate` header value such as + # `Bearer error="invalid_token", error_description="...", scope="a b", resource_metadata="https://..."`. + # Parameters are quoted-string encoded per RFC 7235; nil parameters are omitted. + def build(error: nil, error_description: nil, scope: nil, resource_metadata: nil) + parameters = [] + parameters << %(error="#{quote(error)}") if error + parameters << %(error_description="#{quote(error_description)}") if error_description + parameters << %(scope="#{quote(scope)}") if scope + parameters << %(resource_metadata="#{quote(resource_metadata)}") if resource_metadata + + parameters.empty? ? "Bearer" : "Bearer #{parameters.join(", ")}" + end + + # 401 with `error="invalid_token"`: the presented token is expired, revoked, or otherwise invalid. + # https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization + def invalid_token_response(error_description: nil, scope: nil, resource_metadata: nil) + challenge_response( + 401, + error: "invalid_token", + error_description: error_description, + scope: scope, + resource_metadata: resource_metadata, + ) + end + + # 401 without an error code: RFC 6750 Section 3.1 says the challenge answering a request that + # carried no authentication information at all should not include an error code or other error information. + def missing_token_response(scope: nil, resource_metadata: nil) + challenge_response(401, scope: scope, resource_metadata: resource_metadata) + end + + # 403 with `error="insufficient_scope"`: the token is valid but lacks a scope the current operation requires. + # Clients treat this as a step-up signal and re-authorize with the scopes advertised in `scope`. + def insufficient_scope_response(error_description: nil, scope: nil, resource_metadata: nil) + challenge_response( + 403, + error: "insufficient_scope", + error_description: error_description, + scope: scope, + resource_metadata: resource_metadata, + ) + end + + # 400 with `error="invalid_request"`: the request itself is malformed (e.g. an `Authorization` header with another scheme + # or without exactly one token). + def invalid_request_response(error_description: nil, resource_metadata: nil) + challenge_response( + 400, + error: "invalid_request", + error_description: error_description, + resource_metadata: resource_metadata, + ) + end + + private + + def challenge_response(status, error: nil, error_description: nil, scope: nil, resource_metadata: nil) + # The description reaches the header (quoted below) and the JSON body alike; normalized once here + # so a custom verifier's message with invalid bytes cannot make `to_json` raise either. + error_description = utf8(error_description) if error_description + www_authenticate = build( + error: error, + error_description: error_description, + scope: scope, + resource_metadata: resource_metadata, + ) + return [status, { "www-authenticate" => www_authenticate }, []] if error.nil? + + body = { error: error } + body[:error_description] = error_description if error_description + + [status, CONTENT_TYPE_JSON.merge("www-authenticate" => www_authenticate), [body.to_json]] + end + + # Encodes a parameter value as an RFC 7230 quoted-string interior: `\` and `"` become quoted-pairs. + # Control characters are replaced with spaces because they cannot appear in a header value at all; + # leaving CR/LF in would let attacker-influenced text (e.g. an error message quoting a bad token) + # split the response header, and other control characters make strict servers reject the response outright. + # Invalid byte sequences are replaced first: a custom verifier's message need not be valid UTF-8, + # and `gsub` raising on it would escape the rescue that builds the challenge, turning the 401 into a 500. + def quote(value) + utf8(value).gsub(/[[:cntrl:]]/, " ").gsub(/[\\"]/) { |character| "\\#{character}" } + end + + # Normalizes a value to valid UTF-8. `scrub` alone only repairs strings tagged UTF-8: + # Rack hands header values to a custom verifier as ASCII-8BIT, where every byte counts as valid, + # so a message assembled from them would still make `to_json` raise. + def utf8(value) + value.to_s.dup.force_encoding(Encoding::UTF_8).scrub("?") + end + end + end + end + end +end diff --git a/lib/mcp/server/oauth/errors.rb b/lib/mcp/server/oauth/errors.rb new file mode 100644 index 00000000..48310262 --- /dev/null +++ b/lib/mcp/server/oauth/errors.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module MCP + class Server + module OAuth + # Error hierarchy for the OAuth 2.1 resource-server role. Each error carries the RFC 6750 Section 3.1 + # registered error code that `Challenge` places in the `WWW-Authenticate` response header. + # https://www.rfc-editor.org/rfc/rfc6750#section-3.1 + class Error < StandardError + attr_reader :error_code + + # `error_code` defaults to the code a token rejection carries, so a custom verifier can subclass this + # and `raise MyError, "..."` without knowing about the keyword. `Authenticator#challenge_response` + # answers any such error with 401 `invalid_token`, which is what the default names; without a default, + # the missing keyword would raise `ArgumentError` inside `verify` and surface as HTTP 500 instead. + def initialize(message = nil, error_code: "invalid_token") + super(message) + @error_code = error_code + end + end + + # The request is malformed (e.g. an `Authorization` header that does not carry a Bearer token). Maps to HTTP 400. + class InvalidRequestError < Error + def initialize(message = "The request is malformed") + super(message, error_code: "invalid_request") + end + end + + # The access token is missing, expired, revoked, or otherwise invalid. + # Maps to HTTP 401. `TokenVerifier` implementations raise this to reject a token. + class InvalidTokenError < Error + def initialize(message = "The access token is invalid") + super(message, error_code: "invalid_token") + end + end + + # The request carried no credentials at all. A subclass of `InvalidTokenError` so existing rescue clauses keep working, + # but distinguished because RFC 6750 Section 3.1 says the challenge answering a credential-less request should not include + # an error code. + class MissingTokenError < InvalidTokenError + def initialize(message = "Missing Authorization header") + super + end + end + + # The token is valid but lacks a scope the resource requires. Maps to HTTP 403, whose `WWW-Authenticate` challenge signals + # step-up authorization (the scope challenge handling of the MCP authorization specification) to the client. + class InsufficientScopeError < Error + attr_reader :required_scopes + + def initialize(message = "The request requires higher privileges", required_scopes: []) + super(message, error_code: "insufficient_scope") + @required_scopes = required_scopes + end + end + end + end +end diff --git a/lib/mcp/server/oauth/introspection_verifier.rb b/lib/mcp/server/oauth/introspection_verifier.rb new file mode 100644 index 00000000..efcc457e --- /dev/null +++ b/lib/mcp/server/oauth/introspection_verifier.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require "json" +require "net/http" +require "uri" +require "mcp/client/oauth/discovery" +require_relative "token_verifier" + +module MCP + class Server + module OAuth + # Verifies access tokens by asking the authorization server via OAuth 2.0 Token Introspection (RFC 7662). + # Works with opaque tokens and needs no extra dependencies. + # + # verifier = MCP::Server::OAuth::IntrospectionVerifier.new( + # introspection_endpoint: "https://as.example.com/oauth/introspect", + # client_id: "mcp-resource-server", + # client_secret: ENV["INTROSPECTION_CLIENT_SECRET"], + # resource_metadata: metadata, # the ProtectedResourceMetadata this server publishes; its `resource` is the expected `aud` + # ) + # + # Every `verify` call hits the endpoint; responses are intentionally not cached so token revocation takes + # effect within the authorization server's own latency, not ours. + # + # `audience` is required: the introspection response's `aud` (or `resource`) must cover it, + # which is the RFC 8707 audience check the MCP authorization specification demands of every resource server. + # It keeps a token issued for another resource from being replayed here. An authorization server that cannot + # audience-bind its tokens needs a custom verifier, and the deployment should understand what it is giving up. + class IntrospectionVerifier < TokenVerifier + # Raised when the introspection endpoint is unreachable or misbehaves. Deliberately not an `OAuth::Error`: + # an unavailable authorization server is an internal failure (HTTP 500), not a problem with the client's token. + class IntrospectionError < StandardError; end + + # Named after the RFC 7591 `token_endpoint_auth_method` values, the same vocabulary the client side uses. + CLIENT_AUTH_METHODS = [:client_secret_basic, :client_secret_post, :none].freeze + private_constant :CLIENT_AUTH_METHODS + + def initialize( + introspection_endpoint:, + resource_metadata:, + client_id: nil, + client_secret: nil, + client_auth_method: :client_secret_basic, + open_timeout: 5, + read_timeout: 5 + ) + super() + + unless Client::OAuth::Discovery.secure_url?(introspection_endpoint) + raise ArgumentError, "introspection_endpoint must use https (http is allowed only on loopback): #{introspection_endpoint.inspect}" + end + # The RFC 8707 audience check keeps tokens issued for other resources from being replayed against this server; + # the expected value is the `resource` of the document this server publishes. + audience = resource_from(resource_metadata) + require_expected_claim!(:audience, audience) + require_positive_number!(:open_timeout, open_timeout) + require_positive_number!(:read_timeout, read_timeout) + unless CLIENT_AUTH_METHODS.include?(client_auth_method) + raise ArgumentError, "client_auth_method must be one of #{CLIENT_AUTH_METHODS.join(", ")}" + end + if client_auth_method != :none && client_id.nil? + raise ArgumentError, "client_id is required for client_auth_method #{client_auth_method.inspect}" + end + + @introspection_endpoint = introspection_endpoint + @client_id = client_id + @client_secret = client_secret + @client_auth_method = client_auth_method + @audience = audience + @open_timeout = open_timeout + @read_timeout = read_timeout + end + + def verify(token) + claims = introspect(token) + + raise InvalidTokenError, "Token is not active" unless claims["active"] == true + + validate_audience!(claims) + + access_token = access_token_from_claims(token, claims, resource: @audience) + # An active-but-expired introspection response would be an authorization server bug, but expiry is this verifier's contractual duty, + # so it is enforced here rather than trusted. + raise InvalidTokenError, "Token expired" if access_token.expired? + + access_token + end + + private + + def introspect(token) + uri = URI.parse(@introspection_endpoint) + request = Net::HTTP::Post.new(uri.request_uri) + request["Accept"] = "application/json" + + form = { "token" => token } + case @client_auth_method + when :client_secret_basic + request["Authorization"] = "Basic #{basic_credentials}" + when :client_secret_post + form["client_id"] = @client_id + form["client_secret"] = @client_secret.to_s + end + request.set_form_data(form) + + body = "".dup + + Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @open_timeout, read_timeout: @read_timeout) do |http| + http.request(request) do |response| + unless response.is_a?(Net::HTTPOK) + raise IntrospectionError, "Introspection endpoint responded with status #{response.code}" + end + + response.read_body do |chunk| + body << chunk + if body.bytesize > MAX_UPSTREAM_RESPONSE_BYTES + raise IntrospectionError, "Introspection response exceeded #{MAX_UPSTREAM_RESPONSE_BYTES} bytes" + end + end + end + end + + parsed = JSON.parse(body) + unless parsed.is_a?(Hash) + raise IntrospectionError, "Introspection endpoint returned a non-object JSON document" + end + + parsed + rescue JSON::ParserError + raise IntrospectionError, "Introspection endpoint returned invalid JSON" + end + + # RFC 6749 Section 2.3.1: the client id and secret are form-urlencoded before being joined and base64-encoded, + # so a `:` or another reserved character in either survives the round trip; `Net::HTTP#basic_auth` skips that step. + def basic_credentials + credentials = "#{URI.encode_www_form_component(@client_id)}:#{URI.encode_www_form_component(@client_secret.to_s)}" + + [credentials].pack("m0") + end + + def validate_audience!(claims) + audience_values = Array(claims["aud"]) + Array(claims["resource"]) + return if audience_values.include?(@audience) + + raise InvalidTokenError, "Invalid audience" + end + end + end + end +end diff --git a/lib/mcp/server/oauth/jwt_verifier.rb b/lib/mcp/server/oauth/jwt_verifier.rb new file mode 100644 index 00000000..71584505 --- /dev/null +++ b/lib/mcp/server/oauth/jwt_verifier.rb @@ -0,0 +1,322 @@ +# frozen_string_literal: true + +require "json" +require "net/http" +require "uri" +require "mcp/client/oauth/discovery" +require_relative "token_verifier" + +# This file is autoloaded only when `JWTVerifier` is referenced, so the `jwt` dependency does not affect users of other verifiers. +begin + require "jwt" +rescue LoadError + raise LoadError, "The 'jwt' gem is required to use MCP::Server::OAuth::JWTVerifier. Add it to your Gemfile: gem 'jwt'" +end + +module MCP + class Server + module OAuth + # Verifies JWT access tokens locally: signature (via a JWKS endpoint, a static JWKS document, or a single key), `iss`, `aud`, `exp`, and `nbf`. + # + # + # verifier = MCP::Server::OAuth::JWTVerifier.new( + # resource_metadata: metadata, # the ProtectedResourceMetadata this server publishes + # jwks_uri: "https://as.example.com/.well-known/jwks.json", + # ) + # + # `aud` is checked against the document's `resource`, the canonical resource URL spec-conformant authorization servers + # put into `aud` via the RFC 8707 `resource` parameter, and `iss` against the document's authorization server, of which + # there must be exactly one: this verifier holds one key set, so it verifies the tokens of one issuer. + # + # `alg: none` is refused whatever `algorithms:` says, and HMAC and asymmetric algorithms cannot share one allowlist, which defeats HS256 key-confusion attacks: + # a token symmetrically signed with the public key bytes never meets an HMAC verifier holding that key. The default allowlist contains only asymmetric algorithms. + # Pass `algorithms: ["HS256"]` together with `key: shared_secret` only when the authorization server genuinely issues HMAC-signed tokens, and understand + # that anyone holding the secret can mint tokens. + class JWTVerifier < TokenVerifier + # Raised when the JWKS endpoint cannot be fetched or parsed. Deliberately + # not an `OAuth::Error`: an unreachable key set is an internal failure + # (HTTP 500), not a problem with the client's token. + class JWKSFetchError < StandardError; end + + DEFAULT_ALGORITHMS = ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512", "EdDSA"].freeze + + HMAC_ALGORITHM_PATTERN = /\AHS\d+\z/i.freeze + private_constant :HMAC_ALGORITHM_PATTERN + + # Minimum seconds between JWKS refetches triggered by an unknown `kid`, so a stream of forged tokens cannot hammer the JWKS endpoint. + JWKS_REFETCH_COOLDOWN = 30 + + # The RFC 7519 NumericDate claims the jwt gem compares with the clock under the `verify_expiration` and + # `verify_not_before` options passed in `decode`; `iat` joins the list only if its verification is ever enabled. + NUMERIC_DATE_CLAIMS = ["exp", "nbf"].freeze + private_constant :NUMERIC_DATE_CLAIMS + + def initialize(resource_metadata:, jwks_uri: nil, jwks: nil, key: nil, algorithms: DEFAULT_ALGORITHMS, leeway: 0, jwks_cache_ttl: 300, jwks_max_stale: 3600, open_timeout: 5, read_timeout: 5) + super() + + issuer = issuer_from(resource_metadata) + audience = resource_from(resource_metadata) + require_expected_claim!(:issuer, issuer) + require_expected_claim!(:audience, audience) + require_non_negative_number!(:leeway, leeway) + require_non_negative_number!(:jwks_cache_ttl, jwks_cache_ttl) + require_non_negative_number!(:jwks_max_stale, jwks_max_stale) + require_positive_number!(:open_timeout, open_timeout) + require_positive_number!(:read_timeout, read_timeout) + + key_sources = [jwks_uri, jwks, key].compact + raise ArgumentError, "exactly one of jwks_uri, jwks, or key is required" unless key_sources.size == 1 + + if jwks_uri && !Client::OAuth::Discovery.secure_url?(jwks_uri) + raise ArgumentError, "jwks_uri must use https (http is allowed only on loopback): #{jwks_uri.inspect}" + end + + @issuer = issuer + @audience = audience + @jwks_uri = jwks_uri + @static_jwks = jwks && deep_symbolize(jwks) + @key = key + @algorithms = validate_algorithms!(algorithms, key) + @leeway = leeway + @jwks_cache_ttl = jwks_cache_ttl + @jwks_max_stale = jwks_max_stale + @open_timeout = open_timeout + @read_timeout = read_timeout + @cached_jwks = nil + @jwks_fetched_at = nil + @kid_refetch_at = nil + @refetch_failed_at = nil + @state_mutex = Mutex.new + @fetch_mutex = Mutex.new + end + + def verify(token) + validate_time_claims!(token) + claims, _header = decode(token) + + access_token_from_claims(token, claims, resource: @audience) + rescue JWT::ExpiredSignature + raise InvalidTokenError, "Token expired" + rescue JWT::ImmatureSignature + raise InvalidTokenError, "Token not yet valid" + rescue JWT::InvalidIssuerError + raise InvalidTokenError, "Invalid issuer" + rescue JWT::InvalidAudError + raise InvalidTokenError, "Invalid audience" + rescue JWT::DecodeError + # Covers malformed tokens, signature mismatches, disallowed algorithms (including `alg: none`), missing required claims, + # and unknown key IDs. The generic message is deliberate: it goes into the `WWW-Authenticate` header, and detailing why + # verification failed would only help an attacker probe. + raise InvalidTokenError, "Invalid token" + end + + private + + def decode(token) + options = { + algorithms: @algorithms, + iss: @issuer, + verify_iss: true, + aud: @audience, + verify_aud: true, + verify_expiration: true, + verify_not_before: true, + # `verify_expiration` alone lets a token without `exp` through, and a token that never expires cannot be aged out. + # Spec-conformant access tokens always carry `exp` (RFC 9068 requires it). + required_claims: ["exp"], + leeway: @leeway, + } + + if @key + JWT.decode(token, @key, true, options) + elsif @static_jwks + JWT.decode(token, nil, true, options.merge(jwks: @static_jwks)) + else + JWT.decode(token, nil, true, options.merge(jwks: jwk_loader)) + end + end + + # The jwt gem calls `to_i` on `exp` and `nbf` while verifying, so a claim of the wrong type raises inside the gem + # instead of failing the token. The unverified payload is inspected first: a claim that is neither a finite number + # nor a digit string makes the token invalid, the same answer `expiry_from_claims` gives an introspection response. + def validate_time_claims!(token) + payload, = JWT.decode(token, nil, false) + raise InvalidTokenError, "Invalid token" unless payload.is_a?(Hash) + + NUMERIC_DATE_CLAIMS.each do |name| + value = payload[name] + next if value.nil? || (value.is_a?(Numeric) && value.finite?) || (value.is_a?(String) && value.match?(/\A\d+\z/)) + + raise InvalidTokenError, "Malformed #{name} claim" + end + end + + # `none` is never acceptable, and HMAC must not share an allowlist with asymmetric algorithms: an attacker who picks `alg` + # would otherwise sign with the public key bytes as the HMAC secret. A String key can only be an HMAC secret, + # so it is refused for asymmetric algorithms at construction rather than failing every verification later, and a `JWT::JWK` + # is refused outright because the decode path would reject every token signed by it. + def validate_algorithms!(algorithms, key) + if key.is_a?(JWT::JWK::KeyBase) + raise ArgumentError, "key must be an OpenSSL::PKey or an HMAC secret String; pass a JWK through jwks: { keys: [jwk.export] }" + end + + list = Array(algorithms).map(&:to_s) + raise ArgumentError, "algorithms must name at least one signature algorithm" if list.empty? + + if list.any? { |algorithm| algorithm.casecmp?("none") } + raise ArgumentError, "algorithms must not include none: unsigned tokens would be accepted" + end + + hmac, asymmetric = list.partition { |algorithm| algorithm.match?(HMAC_ALGORITHM_PATTERN) } + + if hmac.any? && asymmetric.any? + raise ArgumentError, "algorithms must not mix HMAC (#{hmac.join(", ")}) with asymmetric algorithms (#{asymmetric.join(", ")})" + end + if key.is_a?(String) && hmac.empty? + raise ArgumentError, "key must be an OpenSSL::PKey for asymmetric algorithms; a String key is an HMAC secret and needs an HS* allowlist" + end + + list + end + + # Loader for the `jwt` gem's `jwks:` option. The gem invokes it once per decode and again with `kid_not_found: true` when + # the token's `kid` is not in the returned set, which is the signal that the authorization server may have rotated its keys. + def jwk_loader + lambda do |options| + load_jwks(options) + end + end + + # Refreshes the JWKS cache without performing network I/O under a lock other verifications wait on: only cache-state reads + # and swaps are synchronized, one thread fetches at a time, and the remaining threads keep verifying against the previous key + # set in the meantime. A failed refresh also falls back to that set, so a flaky JWKS endpoint degrades to slightly stale keys + # instead of taking verification down, but only for `jwks_max_stale` seconds past the TTL: beyond that the failure surfaces, + # so a key set the authorization server retired cannot stay trusted indefinitely. A failed unknown-`kid` refetch starts + # the cooldown all the same, or the endpoint would be retried on every such token while it is down. + def load_jwks(options) + # The key set and its fetch time are read as one snapshot and judged together: a refresh another thread completes + # in the meantime must not lend its fetch time to the set this thread is holding. + cached, fetched_at, refetch = @state_mutex.synchronize { [@cached_jwks, @jwks_fetched_at, refetch_required?(options)] } + unless refetch + # A refresh that failed moments ago is not retried yet, and keys past the stale bound are not served meanwhile. + raise JWKSFetchError, "JWKS endpoint could not be fetched" if cached && stale_beyond_bound?(fetched_at) + + return cached + end + + if @fetch_mutex.try_lock + begin + fresh = fetch_jwks + @state_mutex.synchronize do + @cached_jwks = fresh + @jwks_fetched_at = monotonic_now + @refetch_failed_at = nil + @kid_refetch_at = monotonic_now if options[:kid_not_found] + end + + fresh + rescue JWKSFetchError + @state_mutex.synchronize do + @refetch_failed_at = monotonic_now + @kid_refetch_at = monotonic_now if options[:kid_not_found] + end + raise if cached.nil? || stale_beyond_bound?(fetched_at) + + cached + ensure + @fetch_mutex.unlock + end + elsif cached && !stale_beyond_bound?(fetched_at) + cached + else + # Cold start, or a cached set past its stale bound, while another thread performs the fetch: wait for that fetch + # instead of failing the request with an empty or outdated key set. A fetch time that moved means the refresh succeeded, + # and its set is served the way the fetching thread serves it; an unchanged one means the refresh failed, + # and the set it left behind is judged by the bound. + @fetch_mutex.synchronize {} + fetched, refreshed_at = @state_mutex.synchronize { [@cached_jwks, @jwks_fetched_at] } + if fetched.nil? || (refreshed_at == fetched_at && stale_beyond_bound?(refreshed_at)) + raise JWKSFetchError, "JWKS endpoint could not be fetched" + end + + fetched + end + end + + # An unknown `kid` refetches immediately (the usual key-rotation case) but at most once per cooldown window, so forged tokens with + # random `kid`s cannot turn every request into a JWKS fetch. Outside of a `kid` miss, the cache is refreshed only when the TTL lapses. + def refetch_required?(options) + return true if @cached_jwks.nil? + + if options[:kid_not_found] + return @kid_refetch_at.nil? || monotonic_now - @kid_refetch_at >= JWKS_REFETCH_COOLDOWN + end + # A refresh that just failed is not retried on every request while the endpoint stays down. + return false if @refetch_failed_at && monotonic_now - @refetch_failed_at < JWKS_REFETCH_COOLDOWN + + monotonic_now - @jwks_fetched_at >= @jwks_cache_ttl + end + + # Whether a key set fetched at `fetched_at` has outlived its TTL by more than `jwks_max_stale`, the point past which + # a refresh failure is no longer bridged with the stale keys. + def stale_beyond_bound?(fetched_at) + monotonic_now - fetched_at > @jwks_cache_ttl + @jwks_max_stale + end + + def fetch_jwks + uri = URI.parse(@jwks_uri) + request = Net::HTTP::Get.new(uri.request_uri, { "Accept" => "application/json" }) + body = "".dup + + Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @open_timeout, read_timeout: @read_timeout) do |http| + http.request(request) do |response| + unless response.is_a?(Net::HTTPOK) + raise JWKSFetchError, "JWKS endpoint responded with status #{response.code}" + end + + response.read_body do |chunk| + body << chunk + if body.bytesize > MAX_UPSTREAM_RESPONSE_BYTES + raise JWKSFetchError, "JWKS response exceeded #{MAX_UPSTREAM_RESPONSE_BYTES} bytes" + end + end + end + end + + parsed = JSON.parse(body, symbolize_names: true) + raise JWKSFetchError, "JWKS endpoint returned a non-object JSON document" unless parsed.is_a?(Hash) + # A document without a `keys` array would replace a good cache with one that verifies nothing. + raise JWKSFetchError, "JWKS endpoint returned a document without a keys array" unless parsed[:keys].is_a?(Array) + # So would a set with a member the jwt gem cannot turn into a key: the gem rejects the whole set, and unlike an unknown `kid` + # that rejection never triggers a refetch, so the document counts as a failed refresh instead of replacing the cache. + unless parsed[:keys].all? { |key| key.is_a?(Hash) } + raise JWKSFetchError, "JWKS endpoint returned a key that is not a JSON object" + end + + begin + JWT::JWK::Set.new(parsed) + rescue JWT::JWKError => e + raise JWKSFetchError, "JWKS endpoint returned a key set that cannot be loaded: #{e.message}" + end + + parsed + rescue JSON::ParserError + raise JWKSFetchError, "JWKS endpoint returned invalid JSON" + rescue SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError, IOError, Net::HTTPBadResponse, Net::ProtocolError => e + # A connection-level failure is the same event as an HTTP error from the caller's point of view: the key set could not be refreshed, + # and `load_jwks` decides whether the cached one still bridges the gap. + raise JWKSFetchError, "JWKS endpoint could not be fetched: #{e.class}" + end + + def monotonic_now + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + + # The `jwt` gem's JWKS handling expects symbol keys; user-supplied static documents commonly arrive as parsed JSON with string keys. + def deep_symbolize(jwks) + JSON.parse(JSON.generate(jwks), symbolize_names: true) + end + end + end + end +end diff --git a/lib/mcp/server/oauth/middleware.rb b/lib/mcp/server/oauth/middleware.rb new file mode 100644 index 00000000..df525203 --- /dev/null +++ b/lib/mcp/server/oauth/middleware.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require_relative "authenticator" +require_relative "challenge" +require_relative "errors" + +module MCP + class Server + module OAuth + # Rack middleware that enforces bearer authentication for everything it wraps, per RFC 6750 and the MCP authorization specification: + # + # use MCP::Server::OAuth::Middleware, + # token_verifier: verifier, + # required_scopes: ["mcp:tools"], + # resource_metadata: metadata + # run transport + # + # See `Authenticator` for the option semantics; this class only adapts it to the Rack middleware calling convention. + # The streamable HTTP transport embeds the same authenticator via its `token_verifier:` option, so use this middleware when composition + # at the Rack layer is preferable (for example to share one authenticator across several apps). + # + # Tokens are accepted from the `Authorization` header only, never from a query string. On success the verified `AccessToken` is stored in + # `env[MCP::Server::OAuth::ENV_KEY]`, where the streamable HTTP transport picks it up and exposes it to handlers as `server_context.auth_info`. + # + # Because the middleware wraps the whole app, POST, GET (SSE), and DELETE requests are protected uniformly. Mount the metadata document outside + # the protected scope: it is how unauthenticated clients bootstrap. When a browser-based client is involved, run the CORS middleware before + # this one; otherwise its preflight OPTIONS request dies here with a 401 that the browser will not let the client see. + class Middleware + def initialize(app, token_verifier:, required_scopes: [], resource_metadata: nil, resource_metadata_url: nil, scope_matcher: nil) + @app = app + @authenticator = Authenticator.new( + token_verifier: token_verifier, + required_scopes: required_scopes, + resource_metadata: resource_metadata, + resource_metadata_url: resource_metadata_url, + scope_matcher: scope_matcher, + ) + end + + def call(env) + begin + env[ENV_KEY] = @authenticator.authenticate(env) + rescue Error => e + return @authenticator.challenge_response(e) + rescue => e + # A misbehaving verifier (e.g. an unreachable JWKS or introspection endpoint) is an internal failure: report it + # and answer 500 without a challenge, so the client does not discard a perfectly good token. + MCP.configuration.exception_reporter.call(e, { middleware: self.class.name }) + + return [500, { "content-type" => "application/json" }, [{ error: "server_error" }.to_json]] + end + + # Outside the rescue on purpose: only verification is this middleware's business, and a failure in the wrapped app + # must reach whatever handles that app's errors, as it does with the Python SDK's bearer middleware. + @app.call(env) + end + end + end + end +end diff --git a/lib/mcp/server/oauth/protected_resource_metadata.rb b/lib/mcp/server/oauth/protected_resource_metadata.rb new file mode 100644 index 00000000..f33f5b8e --- /dev/null +++ b/lib/mcp/server/oauth/protected_resource_metadata.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require "json" +require "uri" +require "mcp/client/oauth/discovery" + +module MCP + class Server + module OAuth + # OAuth 2.0 Protected Resource Metadata (RFC 9728) for an MCP server. + # The MCP authorization spec requires servers to publish this document so clients can discover which authorization servers can + # issue tokens for the resource. + # + # - `resource` - the canonical resource identifier: the URL clients connect to, without a fragment. Clients send this back to + # the authorization server as the RFC 8707 `resource` parameter. + # - `authorization_servers` - issuer URLs of the authorization servers that protect this resource (at least one). + # - `scopes_supported` - an Array of the scope strings clients may request (also the default scope hint in `WWW-Authenticate` challenges); + # `offline_access` is dropped, and a result with nothing left omits the member. + # - `bearer_methods_supported` - defaults to `["header"]`: the SDK accepts bearer tokens only in the `Authorization` header, + # never in a query string. + # - `extra` - additional RFC 9728 fields (e.g. `jwks_uri`, `resource_signing_alg_values_supported`, `resource_policy_uri`) merged into + # the document as given; the members above cannot be overridden through it. + # + # https://www.rfc-editor.org/rfc/rfc9728 + # https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery + class ProtectedResourceMetadata + WELL_KNOWN_PATH_PREFIX = "/.well-known/oauth-protected-resource" + + DEFAULT_BEARER_METHODS_SUPPORTED = ["header"].freeze + private_constant :DEFAULT_BEARER_METHODS_SUPPORTED + + # The members `initialize` validates. `extra` may not name them, or the merge in `to_h` would undo the validation. + VALIDATED_MEMBERS = ["resource", "authorization_servers", "scopes_supported", "resource_name", "resource_documentation", "bearer_methods_supported"].freeze + private_constant :VALIDATED_MEMBERS + + attr_reader :resource, :authorization_servers, :scopes_supported, :resource_name, :resource_documentation, :bearer_methods_supported, :extra + + def initialize(resource:, authorization_servers:, scopes_supported: nil, resource_name: nil, resource_documentation: nil, bearer_methods_supported: DEFAULT_BEARER_METHODS_SUPPORTED, extra: {}) + @resource_uri = parse_resource_uri(resource) + @resource = resource + + servers = Array(authorization_servers) + raise ArgumentError, "authorization_servers must contain at least one issuer URL" if servers.empty? + + servers.each do |server| + next if Client::OAuth::Discovery.secure_url?(server) + + raise ArgumentError, "authorization_servers must use https (http is allowed only on loopback): #{server.inspect}" + end + + overridden = extra.keys.map(&:to_s) & VALIDATED_MEMBERS + unless overridden.empty? + raise ArgumentError, "extra must not override #{overridden.join(", ")}; pass them as keyword arguments so they are validated" + end + + @authorization_servers = servers + @scopes_supported = advertisable_scopes(scopes_supported) + @resource_name = resource_name + @resource_documentation = resource_documentation + @bearer_methods_supported = bearer_methods_supported + @extra = extra + end + + def to_h + { + resource: resource, + authorization_servers: authorization_servers, + scopes_supported: scopes_supported, + resource_name: resource_name, + resource_documentation: resource_documentation, + bearer_methods_supported: bearer_methods_supported, + }.compact.merge(extra) + end + + def to_json(*args) + to_h.to_json(*args) + end + + # The path-inserted well-known path per RFC 9728 Section 3: a resource of `https://example.com/mcp` is described at + # `/.well-known/oauth-protected-resource/mcp`, and a resource at the origin root is described at `/.well-known/oauth-protected-resource`. + # The path math intentionally matches what `MCP::Client::OAuth::Discovery.protected_resource_metadata_urls` probes. + def well_known_path + path = @resource_uri.path + path = "" if path == "/" + + "#{WELL_KNOWN_PATH_PREFIX}#{path}" + end + + # The absolute URL of the metadata document. Pass this to `Middleware` (or `Challenge`) as `resource_metadata_url` so 401/403 challenges point + # clients at the document. + def well_known_url + port = @resource_uri.port && @resource_uri.port != @resource_uri.default_port ? ":#{@resource_uri.port}" : "" + + "#{@resource_uri.scheme}://#{@resource_uri.host}#{port}#{well_known_path}" + end + + private + + # The MCP authorization specification tells protected resources not to advertise `offline_access`: refresh token issuance + # is the authorization server's business, not a resource requirement. `Authenticator` already drops it from the `scope` parameter of + # a `WWW-Authenticate` challenge, so dropping it here keeps the document and the challenges saying the same thing. + # https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization + # Nothing left to advertise drops the member from the document instead of emitting `[]`, which RFC 9728 tells servers to omit, + # and a non-Array is refused because a bare String would be ambiguous between one scope and a list. + def advertisable_scopes(scopes_supported) + return if scopes_supported.nil? + raise ArgumentError, "scopes_supported must be an Array of scope strings" unless scopes_supported.is_a?(Array) + + scopes = scopes_supported.map(&:to_s) - ["offline_access"] + + scopes.empty? ? nil : scopes + end + + def parse_resource_uri(resource) + uri = begin + URI.parse(resource.to_s) + rescue URI::InvalidURIError + raise ArgumentError, "resource must be a valid URI: #{resource.inspect}" + end + + unless ["http", "https"].include?(uri.scheme.to_s.downcase) && uri.host && !uri.host.empty? + raise ArgumentError, "resource must be an absolute http(s) URL: #{resource.inspect}" + end + + if uri.fragment + raise ArgumentError, "resource must not contain a fragment per RFC 8707: #{resource.inspect}" + end + + unless Client::OAuth::Discovery.secure_url?(resource.to_s) + raise ArgumentError, "resource must use https (http is allowed only on loopback): #{resource.inspect}" + end + + uri + end + end + end + end +end diff --git a/lib/mcp/server/oauth/protected_resource_metadata_middleware.rb b/lib/mcp/server/oauth/protected_resource_metadata_middleware.rb new file mode 100644 index 00000000..97b46ec0 --- /dev/null +++ b/lib/mcp/server/oauth/protected_resource_metadata_middleware.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "json" + +module MCP + class Server + module OAuth + # Rack middleware that serves a Protected Resource Metadata document (RFC 9728) at its well-known path, + # with the CORS headers browser-based MCP clients need, and passes every other request down the stack. + # The Rack shape of the Python SDK's `create_protected_resource_routes` and the TypeScript SDK's `mcpAuthMetadataRouter`. + # + # Use it at the top of the stack: the well-known path lives at the origin root, not under the MCP endpoint + # (`/.well-known/oauth-protected-resource/mcp` for a resource at `/mcp`), so inside a `map` block it would never see that path. + # The metadata is how unauthenticated clients bootstrap, so it belongs above any bearer enforcement. + # + # metadata = MCP::Server::OAuth::ProtectedResourceMetadata.new(...) + # use MCP::Server::OAuth::ProtectedResourceMetadataMiddleware, metadata + # map("/mcp") { run(transport) } + class ProtectedResourceMetadataMiddleware + ALLOWED_METHODS = "GET, HEAD, OPTIONS" + private_constant :ALLOWED_METHODS + + # @param app [#call] the rest of the Rack stack + # @param metadata [ProtectedResourceMetadata] the document to serve, which also names the path it is served at + def initialize(app, metadata) + # The document is published as `to_json`, so only the class whose serialization is a validated RFC 9728 document will do. + unless metadata.is_a?(ProtectedResourceMetadata) + raise ArgumentError, "metadata must be a ProtectedResourceMetadata (got #{metadata.class})" + end + + @app = app + @well_known_path = metadata.well_known_path + @metadata_json = metadata.to_json + end + + def call(env) + # An exact match only: a deeper well-known path describes another resource on this host (RFC 9728), + # and every other path is the application's. + return @app.call(env) unless env["PATH_INFO"] == @well_known_path + + case env["REQUEST_METHOD"] + when "GET" + metadata_response + when "HEAD" + metadata_response(head: true) + when "OPTIONS" + preflight_response + else + method_not_allowed_response + end + end + + private + + def metadata_response(head: false) + headers = { + "content-type" => "application/json", + "cache-control" => "public, max-age=3600", + "access-control-allow-origin" => "*", + } + + [200, headers, head ? [] : [@metadata_json]] + end + + def preflight_response + headers = { + "access-control-allow-origin" => "*", + "access-control-allow-methods" => ALLOWED_METHODS, + "access-control-allow-headers" => "*", + } + + [204, headers, []] + end + + def method_not_allowed_response + headers = { + "content-type" => "application/json", + "allow" => ALLOWED_METHODS, + } + + [405, headers, [{ error: "method_not_allowed" }.to_json]] + end + end + end + end +end diff --git a/lib/mcp/server/oauth/token_verifier.rb b/lib/mcp/server/oauth/token_verifier.rb new file mode 100644 index 00000000..0f0d0b62 --- /dev/null +++ b/lib/mcp/server/oauth/token_verifier.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require_relative "access_token" +require_relative "errors" + +module MCP + class Server + module OAuth + # The single integration point of the resource-server role: token in, `AccessToken` out. `Authenticator` accepts any object + # that responds to `verify(token)`, so subclassing is optional; this base class documents the contract and shares the claim mapping + # used by the built-in verifiers. + # + # Implementations MUST raise `InvalidTokenError` when the token is invalid, and that includes an expired token: expiry enforcement + # is part of this contract, not something the caller re-checks (the built-in verifiers honor their configured clock leeway when doing so). + # Returning nil is also treated as a rejection by `Authenticator`, with a generic message. Any other exception is treated as + # an internal error (HTTP 500), not as a client failure. + # + # Error messages are echoed into the `WWW-Authenticate` response header, so they must never contain token material or other secrets. + class TokenVerifier + # Upper bound for response bodies read from authorization-server endpoints (JWKS, introspection). Matches the bound the client-side + # OAuth support applies to authorization-server responses; the documents involved are orders of magnitude smaller. + MAX_UPSTREAM_RESPONSE_BYTES = 4 * 1024 * 1024 + + # @param token [String] the raw bearer token from the `Authorization` header + # @return [AccessToken] + # @raise [InvalidTokenError] when the token is invalid, expired, or not meant for this resource + def verify(token) + raise NotImplementedError, "#{self.class.name}#verify is not implemented" + end + + private + + # Guards an expected `iss` or `aud` value at construction. The built-in verifiers compare tokens against these values, + # and a nil or empty expectation would silently turn the comparison off (the jwt gem skips a check whose expected value is nil), + # so a misconfiguration such as an unset environment variable fails loudly instead. + def require_expected_claim!(name, value) + return if value.is_a?(String) && !value.empty? + + claim = name == :issuer ? "iss" : "aud" + + raise ArgumentError, "#{name} must be a non-empty String (got #{value.inspect}); without it the #{claim} check would be skipped" + end + + # The expected `aud` is the RFC 9728 document's `resource`: the canonical URL tokens are issued for, and the one this server publishes, + # so the verifier cannot drift from it. + def resource_from(resource_metadata) + metadata_member(resource_metadata, :resource) + end + + # The expected `iss` comes from the document as well: its authorization servers are issuer identifiers (RFC 9728), + # and a JWT verifier holds one key set, so it verifies the tokens of exactly one of them. A document naming several + # would advertise an authorization server whose tokens this verifier rejects, so it is refused outright. + def issuer_from(resource_metadata) + servers = metadata_member(resource_metadata, :authorization_servers) + return servers.first if servers.one? + + raise ArgumentError, <<~MESSAGE + JWTVerifier verifies the tokens of one authorization server, and resource_metadata names \ + #{servers.size}; publish one, or verify with a custom verifier that holds each issuer's keys + MESSAGE + end + + # A document standing in for `ProtectedResourceMetadata` must still hand over the shapes that class guarantees, + # so a wrong one fails here with a named member rather than deeper in with a `NoMethodError`. + def metadata_member(resource_metadata, name) + unless resource_metadata.respond_to?(name) + raise ArgumentError, "resource_metadata must be a ProtectedResourceMetadata (got #{resource_metadata.class})" + end + + value = resource_metadata.public_send(name) + shape = name == :resource ? "a String" : "an Array of Strings" + valid = name == :resource ? value.is_a?(String) : value.is_a?(Array) && value.all?(String) + raise ArgumentError, "resource_metadata.#{name} must be #{shape} (got #{value.inspect})" unless valid + + value + end + + # Maps a string-keyed claims Hash (JWT payload or RFC 7662 introspection response; the relevant claim names are identical) to an `AccessToken`. + def access_token_from_claims(token, claims, resource: nil) + AccessToken.new( + token: token, + client_id: claims["client_id"] || claims["azp"], + scopes: scopes_from_claims(claims), + expires_at: expiry_from_claims(claims), + subject: claims["sub"], + issuer: claims["iss"], + audience: claims["aud"], + resource: resource, + claims: claims, + ) + end + + # RFC 8693 style `scope` is a space-delimited string, but some authorization servers emit an array of scope strings instead. + def scopes_from_claims(claims) + scope = claims["scope"] + + scope.is_a?(Array) ? scope.map(&:to_s) : scope.to_s.split + end + + # `exp` is a numeric timestamp in a JWT payload and in an introspection response alike, but a lenient authorization server may emit it as + # a numeric string. Any other shape is rejected as an invalid token: passed through, it would make `AccessToken#expired?` raise on comparison + # and turn a malformed upstream response into an internal error. + def expiry_from_claims(claims) + exp = claims["exp"] + + case exp + when nil + nil + when Numeric + # JSON parses `1e1000` to Infinity, whose `to_i` raises. + raise InvalidTokenError, "Malformed exp claim" unless exp.finite? + + exp.to_i + when /\A\d+\z/ + exp.to_i + else + raise InvalidTokenError, "Malformed exp claim" + end + end + + def require_non_negative_number!(name, value) + return if value.is_a?(Numeric) && value.finite? && value >= 0 + + raise ArgumentError, "#{name} must be a non-negative finite number (got #{value.inspect})" + end + + def require_positive_number!(name, value) + return if value.is_a?(Numeric) && value.finite? && value.positive? + + raise ArgumentError, "#{name} must be a positive finite number (got #{value.inspect})" + end + end + end + end +end diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index b406d6df..adac02a8 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -93,6 +93,15 @@ class InvalidJsonError < StandardError; end # 15-second default; pass `listen_keepalive_interval: nil` when an upstream proxy pings the stream. DEFAULT_LISTEN_KEEPALIVE_INTERVAL = 15 + # How long an authenticated SSE stream may stay open before it is closed and the client has to present its token again. + # Bearer enforcement is per HTTP request, and a stream is one request, so without this an open stream outlives + # every later token check. The token's own expiry usually arrives first; the cap is what bounds a stream whose token reports + # no expiry at all (`exp` is optional in an RFC 7662 introspection response). Neither the specification nor the reference SDKs + # put a ceiling on a stream, so the value comes from this transport instead: it matches `DEFAULT_SESSION_IDLE_TIMEOUT`, + # because a handshake session already ends after that long without a request, which would leave a longer cap inert, + # and a sessionless `subscriptions/listen` stream has no other bound at all. + DEFAULT_MAX_STREAM_LIFETIME = DEFAULT_SESSION_IDLE_TIMEOUT + # Creates a Streamable HTTP transport that can be mounted as a Rack app. # # @param server [MCP::Server] the server whose requests this transport dispatches. @@ -138,6 +147,28 @@ class InvalidJsonError < StandardError; end # @param server_to_client_request_timeout [Numeric] seconds a server-to-client request waits for its # response before the transport stops waiting and raises `MCP::Server::RequestTimeoutError`. # Defaults to `DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT` (600); individual calls override it with `timeout:`. + # @param token_verifier [#verify, nil] enables built-in OAuth 2.1 bearer enforcement: anything responding to + # `verify(token) -> MCP::Server::OAuth::AccessToken | nil` (see `MCP::Server::OAuth::TokenVerifier` for + # the contract and the built-in JWT/introspection implementations). Every POST, GET, and DELETE is verified + # per HTTP request; SSE streams are verified when opened, matching the per-request model of the Python and + # TypeScript SDKs. The verified token reaches handlers as `server_context.auth_info`. Without a verifier + # the transport still honors a token placed in `env["mcp.auth_info"]` by `MCP::Server::OAuth::Middleware` + # or a custom integration. + # @param required_scopes [Array] scopes the token must include, all of them; a shortfall is + # rejected with HTTP 403 `insufficient_scope` (step-up). Requires `token_verifier`. + # @param resource_metadata [MCP::Server::OAuth::ProtectedResourceMetadata, nil] the RFC 9728 document describing + # this resource; its well-known URL and `scopes_supported` feed the `WWW-Authenticate` challenges. + # The document itself is served above the transport by `MCP::Server::OAuth::ProtectedResourceMetadataMiddleware`. + # Requires `token_verifier`. + # @param resource_metadata_url [String, nil] explicit challenge URL when the metadata document is served elsewhere; + # wins over `resource_metadata.well_known_url`. Requires `token_verifier`. + # @param scope_matcher [#call, nil] optional `(required_scope, granted_scopes) -> Boolean` for hierarchical scope schemes; + # defaults to exact membership. Requires `token_verifier`. + # @param max_stream_lifetime [Numeric, nil] seconds an authenticated SSE stream may stay open before it is closed + # and the client must present its token again, `DEFAULT_MAX_STREAM_LIFETIME` (1800) by default. The token's own + # expiry closes the stream earlier when it comes first; the cap is what bounds a stream whose token reports no + # expiry. `nil` removes the cap, leaving such a stream open until either side disconnects. Streams opened without + # a token are never capped. def initialize( server, stateless: false, @@ -152,7 +183,13 @@ def initialize( max_listen_subscriptions: DEFAULT_MAX_LISTEN_SUBSCRIPTIONS, listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL, serve_subscriptions_listen: true, - server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT + server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT, + token_verifier: nil, + required_scopes: [], + resource_metadata: nil, + resource_metadata_url: nil, + scope_matcher: nil, + max_stream_lifetime: DEFAULT_MAX_STREAM_LIFETIME ) super(server) # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock, origin: origin_header }`. @@ -227,6 +264,28 @@ def initialize( @server_to_client_request_timeout = server_to_client_request_timeout + if token_verifier + @oauth_authenticator = OAuth::Authenticator.new( + token_verifier: token_verifier, + required_scopes: required_scopes, + resource_metadata: resource_metadata, + resource_metadata_url: resource_metadata_url, + scope_matcher: scope_matcher, + ) + elsif !Array(required_scopes).empty? || resource_metadata || resource_metadata_url || scope_matcher + # OAuth options without a verifier would look protected while enforcing nothing. + raise ArgumentError, + "required_scopes, resource_metadata, resource_metadata_url, and scope_matcher require token_verifier" + else + @oauth_authenticator = nil + end + + unless max_stream_lifetime.nil? || (max_stream_lifetime.is_a?(Numeric) && max_stream_lifetime.positive?) + raise ArgumentError, "max_stream_lifetime must be a positive number or nil" + end + + @max_stream_lifetime = max_stream_lifetime + start_reaper_thread if @session_idle_timeout end @@ -281,6 +340,11 @@ def handle_request(request) rebinding_error = validate_dns_rebinding(request) return rebinding_error if rebinding_error + # Bearer enforcement runs after the DNS-rebinding rejection (which must stay the cheapest gate and + # never trigger verifier work) and before any body read, so an unauthenticated request costs no parsing. + auth_error = authenticate_request(request) + return auth_error if auth_error + # Header-primary era routing (SEP-2575). An `MCP-Protocol-Version` header naming a version outside # every supported list routes to the sessionless modern path, so an unknown future version receives # the spec-mandated `-32022` with the supported list instead of the legacy path's generic invalid-request error. @@ -739,13 +803,13 @@ def handle_modern(request, header_version, body_string: nil) # to the dispatcher as unimplemented (404 with `-32601`) - the refusal a host that cannot # serve an open SSE stream needs, instead of a `Proc` body it can never call. if body[:method] == Methods::SUBSCRIPTIONS_LISTEN && serves_subscriptions_listen? - return handle_subscriptions_listen(body) + return handle_subscriptions_listen(body, auth_info: request.env[OAuth::ENV_KEY]) end session = modern_session notifications = @mutex.synchronize { @modern_request_sinks[session.session_id] = [] } begin - response = @server.handle(body, session: session) + response = @server.handle(body, session: session, auth_info: request.env[OAuth::ENV_KEY]) ensure @mutex.synchronize { @modern_request_sinks.delete(session.session_id) } end @@ -837,7 +901,7 @@ def validate_modern_headers(request, body, header_version) # (= the listen request id) in `_meta`. A graceful teardown (transport `close`) sends a `SubscriptionsListenResult` # response; an abrupt disconnect sends nothing. A keepalive comment frame is written every # `listen_keepalive_interval` seconds so a dropped connection frees its slot. - def handle_subscriptions_listen(body) + def handle_subscriptions_listen(body, auth_info: nil) request_id = body[:id] params = body[:params] @@ -879,7 +943,7 @@ def handle_subscriptions_listen(body) return too_many_listen_subscriptions_response(request_id) end - [200, SSE_HEADERS.dup, listen_sse_body(request_id, honored_filter(filter))] + [200, SSE_HEADERS.dup, listen_sse_body(request_id, honored_filter(filter), auth_info)] end def listen_subscriptions_full? @@ -929,7 +993,7 @@ def first # and only then does the entry become eligible for delivery. A concurrent notification between # the insert and the acknowledgement write skips the inactive entry, # enforcing the SEP-2575 rule that no notification precedes the acknowledgement. - def listen_sse_body(request_id, honored) + def listen_sse_body(request_id, honored, auth_info = nil) ListenStreamBody.new do |stream| rejected = false @mutex.synchronize do @@ -937,7 +1001,15 @@ def listen_sse_body(request_id, honored) (@max_listen_subscriptions && @listen_subscriptions.size >= @max_listen_subscriptions) rejected = true else - @listen_subscriptions[request_id] = { stream: stream, filter: honored, active: false, write_mutex: Mutex.new } + # The expiry of the token that authenticated the listen request is kept with the stream + # so the keepalive loop can close the stream once that token expires. + @listen_subscriptions[request_id] = { + stream: stream, + filter: honored, + active: false, + write_mutex: Mutex.new, + expires_at: stream_token_expiry(auth_info), + } end end @@ -983,6 +1055,10 @@ def start_listen_keepalive_thread(request_id) Thread.new do while listen_subscription_active?(request_id) + # Checked ahead of each tick so the stream does not outlive the token that opened it. + # Revocation is not re-checked: the reference SDKs verify a stream at open only. + break if listen_subscription_token_expired?(request_id) + sleep(@listen_keepalive_interval) send_listen_keepalive_ping(request_id) end @@ -1004,6 +1080,40 @@ def listen_subscription_active?(request_id) @mutex.synchronize { @listen_subscriptions.key?(request_id) } end + def listen_subscription_token_expired?(request_id) + expires_at = @mutex.synchronize do + subscription = @listen_subscriptions[request_id] + subscription && subscription[:expires_at] + end + + token_expiry_passed?(expires_at) + end + + # The deadline at which a stream authenticated by `auth_info` is closed: whichever comes first of the token's own + # expiry and `max_stream_lifetime:` seconds from now. Only the deadline is retained with the stream, + # never the `AccessToken` itself, which would park the raw bearer credential in `@sessions` or `@listen_subscriptions` + # for the life of the stream. The cap is what bounds a stream whose token reports no expiry (`exp` is optional in + # an RFC 7662 introspection response), so "a stream does not outlive its credential" holds even when the credential + # never says when it ends. A stream opened without a token is not capped: there is no credential to bound, + # and capping it would change a transport used without `token_verifier:`. + def stream_token_expiry(auth_info) + return unless auth_info + + deadlines = [] + token_expiry = auth_info.expires_at if auth_info.respond_to?(:expires_at) + # A custom verifier that breaks the `AccessToken` contract with an expiry that is not a number must not fail + # the stream while it is being set up: the value is ignored and only the cap applies. + deadlines << token_expiry if token_expiry.is_a?(Numeric) + deadlines << Time.now.to_i + @max_stream_lifetime if @max_stream_lifetime + + deadlines.min + end + + # Same rule as `AccessToken#expired?`: no expiry means the token never expires here. + def token_expiry_passed?(expires_at) + !expires_at.nil? && expires_at <= Time.now.to_i + end + # Resolves the stream under the lock, then writes outside it so a stalled reader cannot block # every other subscription on `@mutex`. A write error propagates to end the keepalive loop. def send_listen_keepalive_ping(request_id) @@ -1258,8 +1368,10 @@ def handle_post(request, body_string: nil) # Ownership gate for every request against an existing session, applied uniformly to notifications, client responses, # and regular requests. This covers write paths beyond tool calls - notably `notifications/cancelled`, which would # otherwise let a stolen session ID cancel a victim's in-flight request. `initialize` is exempt (it establishes the session). - if !@stateless && session_id && !validate_session_request(request, session_id) - return forbidden_response + if !@stateless && session_id + rejection = session_request_rejection(request, session_id) + + return rejection if rejection end if notification?(body) @@ -1268,14 +1380,14 @@ def handle_post(request, body_string: nil) # branches; without it a custom notification handler could run without a live session. return session_not_found_response if !@stateless && !session_active?(session_id) - dispatch_notification(body_string, session_id) + dispatch_notification(body_string, session_id, auth_info: request.env[OAuth::ENV_KEY]) handle_accepted elsif response?(body) return session_not_found_response if !@stateless && !session_exists?(session_id) handle_response(body, session_id: session_id) else - handle_regular_request(body_string, session_id, related_request_id: body[:id]) + handle_regular_request(body_string, session_id, related_request_id: body[:id], auth_info: request.env[OAuth::ENV_KEY]) end end rescue StandardError => e @@ -1299,16 +1411,20 @@ def handle_get(request) return missing_session_id_response unless session_id + # The ownership gate runs before the session is touched, so a rejected request cannot refresh the idle timer of a session it does not own. + # The visible outcome is unchanged: an unknown session passes the gate and fails the lookup, and an expired one fails it, both with the same 404. + rejection = session_request_rejection(request, session_id) + return rejection if rejection + error_response = validate_and_touch_session(session_id) return error_response if error_response - return forbidden_response unless validate_session_request(request, session_id) protocol_version_error = validate_protocol_version_header(request) return protocol_version_error if protocol_version_error return session_already_connected_response if get_session_stream(session_id) - setup_sse_stream(session_id) + setup_sse_stream(session_id, request.env[OAuth::ENV_KEY]) end def handle_delete(request) @@ -1324,7 +1440,8 @@ def handle_delete(request) return missing_session_id_response unless (session_id = extract_session_id(request)) return session_not_found_response unless session_exists?(session_id) - return forbidden_response unless validate_session_request(request, session_id) + rejection = session_request_rejection(request, session_id) + return rejection if rejection protocol_version_error = validate_protocol_version_header(request) return protocol_version_error if protocol_version_error @@ -1388,23 +1505,47 @@ def extract_session_id(request) # Session-ownership gate for requests against an existing session (the spec's session-binding guidance). # The session ID alone is unguessable but not proof of ownership, so a stolen ID must not silently grant access. - # Two layers, both returning `false` to trigger a 403: + # Three layers, each answering with the rejection response it returns. The built-in layers run before + # the custom validator so a permissive `session_request_validator` cannot bypass them: # # - Built-in Origin consistency (defense in depth, not authentication): if the session recorded an `Origin` - # at `initialize` and this request carries a different one, reject. Both must be present to compare, + # at `initialize` and this request carries a different one, reject with 403. Both must be present to compare, # so non-browser clients that send no `Origin` are unaffected. - # - The application-supplied `session_request_validator`, which can enforce true ownership when it has - # an authenticated principal. - def validate_session_request(request, session_id) + # - Built-in principal binding (active when bearer authentication is in play): a session initialized under + # one token identity refuses requests verified as a different one. The answer is the same 404 an unknown session gets, + # so a guessed session ID is not confirmed to exist; it carries no `WWW-Authenticate` challenge either, + # because the token itself is valid and re-authorization would not help. + # - The application-supplied `session_request_validator`, which can enforce ownership policy beyond the built-in layers; + # a falsy return rejects with 403. + # + # Returns nil when the request may proceed. + def session_request_rejection(request, session_id) session = @mutex.synchronize { @sessions[session_id] } - return true unless session + return unless session session_origin = session[:origin] request_origin = request.env["HTTP_ORIGIN"] - return false if session_origin && request_origin && session_origin != request_origin - return @session_request_validator.call(request, session_id) if @session_request_validator + return forbidden_response if session_origin && request_origin && session_origin != request_origin + return session_not_found_response unless session_principal_matches?(session, request) - true + if @session_request_validator && !@session_request_validator.call(request, session_id) + return forbidden_response + end + + nil + end + + # A session bound to a principal at `initialize` only accepts requests verified as the same `issuer`, `subject`, + # and `client_id` triple (client-credentials tokens without a `sub` bind on `client_id` alone; nil members compare equal). + # The issuer takes part so a subject and client id pair repeated across identity providers behind a custom verifier + # does not collide. A token whose subject and client id are both nil records no binding, because there is no identity to compare. + def session_principal_matches?(session, request) + return true if session[:auth_subject].nil? && session[:auth_client_id].nil? + + token = request.env[OAuth::ENV_KEY] + return false if token.nil? + + token.issuer == session[:auth_issuer] && token.subject == session[:auth_subject] && token.client_id == session[:auth_client_id] end def validate_accept_header(request, required_types) @@ -1541,7 +1682,7 @@ def notification?(body) # Dispatches a client-originated notification (e.g. `notifications/cancelled`, # `notifications/initialized`) through the server so it can update session state. - def dispatch_notification(body_string, session_id) + def dispatch_notification(body_string, session_id, auth_info: nil) server_session = nil if @stateless server_session = ephemeral_session @@ -1552,7 +1693,7 @@ def dispatch_notification(body_string, session_id) end end - dispatch_handle_json(body_string, server_session) + dispatch_handle_json(body_string, server_session, auth_info: auth_info) rescue => e MCP.configuration.exception_reporter.call(e, { error: "Failed to dispatch notification" }) end @@ -1581,6 +1722,7 @@ def handle_response(body, session_id:) def handle_initialization(request, body_string, body) session_id = nil + auth_info = request.env[OAuth::ENV_KEY] if @stateless server_session = ephemeral_session @@ -1606,9 +1748,15 @@ def handle_initialization(request, body_string, body) get_sse_stream: nil, server_session: server_session, last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC), - # Captured for the built-in Origin-consistency defense in `validate_session_request`. + # Captured for the built-in Origin-consistency defense in `session_request_rejection`. # Not authentication. origin: request.env["HTTP_ORIGIN"], + # Principal binding per the spec's session-binding guidance: the session is bound to the token identity that initialized it, + # and `session_request_rejection` rejects later requests presenting a token for a different principal. All three fields are nil + # when the request was not bearer-authenticated, which disables the check. + auth_issuer: auth_info&.issuer, + auth_subject: auth_info&.subject, + auth_client_id: auth_info&.client_id, } true end @@ -1617,7 +1765,7 @@ def handle_initialization(request, body_string, body) return too_many_sessions_response unless inserted end - response = server_session.handle_json(body_string) + response = server_session.handle_json(body_string, auth_info: auth_info) # `initialize_request?` matches on the method alone, so an `initialize` sent without # an id (framed as a notification) reaches here. `Server#init` marks the session initialized, @@ -1660,7 +1808,7 @@ def too_many_sessions_response ) end - def handle_regular_request(body_string, session_id, related_request_id: nil) + def handle_regular_request(body_string, session_id, related_request_id: nil, auth_info: nil) server_session = nil if @stateless @@ -1684,9 +1832,9 @@ def handle_regular_request(body_string, session_id, related_request_id: nil) end if session_id && !@stateless && !@enable_json_response - handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id) + handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id, auth_info: auth_info) else - response = dispatch_handle_json(body_string, server_session) + response = dispatch_handle_json(body_string, server_session, auth_info: auth_info) # `Server#handle_json` returns `nil` when cancellation has suppressed the JSON-RPC response per spec. # Mirror the notification path and ack with 202 instead of returning a 200 with a `nil` Rack body, @@ -1700,7 +1848,7 @@ def handle_regular_request(body_string, session_id, related_request_id: nil) # Returns the POST response as an SSE stream so the server can send # JSON-RPC requests and notifications during request processing. # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server - def handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: nil) + def handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: nil, auth_info: nil) body = proc do |stream| @mutex.synchronize do session = @sessions[session_id] @@ -1715,7 +1863,7 @@ def handle_request_with_sse_response(body_string, session_id, server_session, re end begin - response = dispatch_handle_json(body_string, server_session) + response = dispatch_handle_json(body_string, server_session, auth_info: auth_info) send_to_stream(stream, response) if response ensure @@ -1754,11 +1902,11 @@ def active_stream(session, related_request_id: nil) end end - def dispatch_handle_json(body_string, server_session) + def dispatch_handle_json(body_string, server_session, auth_info: nil) if server_session - server_session.handle_json(body_string) + server_session.handle_json(body_string, auth_info: auth_info) else - @server.handle_json(body_string) + @server.handle_json(body_string, auth_info: auth_info) end end @@ -1829,6 +1977,27 @@ def session_active?(session_id) active end + # Built-in OAuth 2.1 bearer enforcement (active when `token_verifier:` is configured). + # Returns nil on success, storing the verified `AccessToken` in `env["mcp.auth_info"]` where the dispatch paths + # and `session_request_rejection` read it; returns the RFC 6750 challenge response on failure. OPTIONS is exempt: + # CORS preflights never carry credentials, and rejecting them here would fail CORS closed for browser clients + # while the transport still answers the preflight itself with 405 (an upstream CORS middleware normally intercepts it). + def authenticate_request(request) + return if @oauth_authenticator.nil? + return if request.env["REQUEST_METHOD"] == "OPTIONS" + + request.env[OAuth::ENV_KEY] = @oauth_authenticator.authenticate(request.env) + nil + rescue OAuth::Error => e + @oauth_authenticator.challenge_response(e) + rescue => e + # A misbehaving verifier (e.g. an unreachable JWKS or introspection endpoint) is an internal failure: + # report it and answer 500 without a challenge, so the client does not discard a perfectly good token. + MCP.configuration.exception_reporter.call(e, { transport: self.class.name }) + + [500, { "content-type" => "application/json" }, [{ error: "server_error" }.to_json]] + end + # Per MCP 2025-11-25, servers MUST validate the `Origin` header and SHOULD bind only to localhost # to prevent DNS rebinding attacks against locally bound MCP servers. Protection is on by default; # pass `dns_rebinding_protection: false` to disable it (e.g. when an upstream proxy or middleware already @@ -1955,24 +2124,30 @@ def request_id_conflict_response ) end - def setup_sse_stream(session_id) - body = create_sse_body(session_id) + def setup_sse_stream(session_id, auth_info = nil) + body = create_sse_body(session_id, auth_info) [200, SSE_HEADERS.dup, body] end - def create_sse_body(session_id) + def create_sse_body(session_id, auth_info = nil) proc do |stream| - stored = store_stream_for_session(session_id, stream) + stored = store_stream_for_session(session_id, stream, auth_info) start_keepalive_thread(session_id) if stored end end - def store_stream_for_session(session_id, stream) + # The expiry of the token that authenticated the GET is kept with the stream so the keepalive loop can close + # the stream once that token expires. + def store_stream_for_session(session_id, stream, auth_info = nil) @mutex.synchronize do session = @sessions[session_id] if session && !session[:get_sse_stream] session[:get_sse_stream] = stream + session[:get_sse_stream_expires_at] = stream_token_expiry(auth_info) + # The expiry is nil for a stream opened without a token, and callers read a falsy return as "not stored", + # so the stream itself is the return value. + stream else # Either session was removed, or another request already established a stream. stream.close @@ -1985,14 +2160,24 @@ def store_stream_for_session(session_id, stream) def start_keepalive_thread(session_id) Thread.new do + token_expired = false while session_active_with_stream?(session_id) + # Checked ahead of each tick so the stream does not outlive the token that opened it. + # Revocation is not re-checked: the reference SDKs verify a stream at open only. + if get_stream_token_expired?(session_id) + token_expired = true + break + end + sleep(30) send_keepalive_ping(session_id) end rescue StandardError => e MCP.configuration.exception_reporter.call(e, { session_id: session_id }) ensure - cleanup_session(session_id) + # An expired token ends the stream alone: every other request of the session is verified on its own, + # and the client may reopen the stream with a fresh token. + token_expired ? close_get_stream(session_id) : cleanup_session(session_id) end end @@ -2000,6 +2185,28 @@ def session_active_with_stream?(session_id) @mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:get_sse_stream] } end + def get_stream_token_expired?(session_id) + expires_at = @mutex.synchronize do + session = @sessions[session_id] + session && session[:get_sse_stream_expires_at] + end + + token_expiry_passed?(expires_at) + end + + # Detaches and closes the session's GET stream, leaving the session in place. + def close_get_stream(session_id) + stream = @mutex.synchronize do + session = @sessions[session_id] + next unless session + + session.delete(:get_sse_stream_expires_at) + session.delete(:get_sse_stream) + end + + close_stream_safely(stream) if stream + end + def send_keepalive_ping(session_id) # Resolve the stream under the lock, then write outside it so a stalled reader # cannot block every other session on `@mutex`. diff --git a/lib/mcp/server_context.rb b/lib/mcp/server_context.rb index 33b20752..c8d90e0d 100644 --- a/lib/mcp/server_context.rb +++ b/lib/mcp/server_context.rb @@ -15,8 +15,11 @@ class ServerContext # (the same access model as the envelope readers). attr_reader :input_responses, :request_state - def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil, envelope: nil, - input_responses: nil, request_state: nil) + # The verified `MCP::Server::OAuth::AccessToken` for the current request, or nil when the request was not bearer-authenticated. + # This reader is the canonical accessor; when the underlying context is a Hash the same value is also reachable as `server_context[:auth_info]`. + attr_reader :auth_info + + def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil, envelope: nil, input_responses: nil, request_state: nil, auth_info: nil) @context = context @progress = progress @notification_target = notification_target @@ -25,6 +28,7 @@ def initialize(context, progress:, notification_target:, related_request_id: nil @envelope = envelope @input_responses = input_responses @request_state = request_state + @auth_info = auth_info end # Reads one entry of {#input_responses} by its `inputRequests` key, tolerating symbol or string keys. @@ -89,6 +93,26 @@ def require_client_capability!(*path) raise Server::MissingRequiredClientCapabilityError, required end + # Whether the current request was bearer-authenticated. + def authenticated? + !@auth_info.nil? + end + + # Guards the current operation on OAuth scopes the token must carry, for authorization decisions finer-grained than + # the transport-wide `required_scopes`. Raises `Server::OAuth::InsufficientScopeError`, which surfaces as + # a JSON-RPC invalid-request error naming the missing scopes. HTTP-level 403 step-up challenges remain the job of + # the transport's `required_scopes` gate, matching how the Python and TypeScript SDKs split endpoint-level challenges + # from in-handler authorization. The transport's `scope_matcher:` applies here as well: the authenticator attaches it + # to the token it hands over, so a scope hierarchy is judged the same way at the gate and in handlers. + def require_scopes!(*scopes) + raise ArgumentError, "at least one scope is required" if scopes.empty? + + missing_scopes = scopes.reject { |scope| @auth_info&.scope?(scope) } + return if missing_scopes.empty? + + raise Server::OAuth::InsufficientScopeError.new("Token is missing required scopes: #{missing_scopes.join(", ")}", required_scopes: scopes) + end + # Reports progress for the current tool operation. # The notification is automatically scoped to the originating session. # diff --git a/lib/mcp/server_session.rb b/lib/mcp/server_session.rb index cfee2ab8..042b0dc4 100644 --- a/lib/mcp/server_session.rb +++ b/lib/mcp/server_session.rb @@ -120,12 +120,22 @@ def cancel_request(request_id:, reason: nil) MCP.configuration.exception_reporter.call(e, { notification: "cancelled", request_id: request_id }) end - def handle(request) - @server.handle(request, session: self) + # Accepts the request either as a positional Hash or as bare keyword arguments (`session.handle(jsonrpc: "2.0", ...)`), + # a calling style that predates this method having keyword parameters. `auth_info:` is honored only alongside a positional request: + # in the bare-keyword style every keyword belongs to the request body, and the body is attacker-authored JSON, + # so a captured `auth_info` keyword is folded back into the request rather than trusted as a verified credential. + def handle(request = nil, auth_info: nil, **request_keywords) + if request.nil? + request = request_keywords + request[:auth_info] = auth_info unless auth_info.nil? + auth_info = nil + end + + @server.handle(request, session: self, auth_info: auth_info) end - def handle_json(request_json) - @server.handle_json(request_json, session: self) + def handle_json(request_json, auth_info: nil) + @server.handle_json(request_json, session: self, auth_info: auth_info) end # Called by `Server#init` during the initialization handshake. diff --git a/test/mcp/server/oauth/access_token_test.rb b/test/mcp/server/oauth/access_token_test.rb new file mode 100644 index 00000000..8440253e --- /dev/null +++ b/test/mcp/server/oauth/access_token_test.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class Server + module OAuth + class AccessTokenTest < Minitest::Test + def test_defaults + access_token = AccessToken.new(token: "abc") + + assert_equal("abc", access_token.token) + assert_nil(access_token.client_id) + assert_empty(access_token.scopes) + assert_nil(access_token.expires_at) + assert_nil(access_token.subject) + assert_nil(access_token.issuer) + assert_nil(access_token.audience) + assert_nil(access_token.resource) + assert_empty(access_token.claims) + end + + def test_inspect_omits_the_token + access_token = AccessToken.new(token: "secret-credential", subject: "user-1") + + refute_includes(access_token.inspect, "secret-credential") + assert_includes(access_token.inspect, "user-1") + end + + def test_expired_is_false_without_expiry + access_token = AccessToken.new(token: "abc") + + refute_predicate(access_token, :expired?) + end + + def test_expired_is_false_before_expiry + access_token = AccessToken.new(token: "abc", expires_at: 1000) + + refute(access_token.expired?(now: 999)) + end + + def test_expired_is_true_at_expiry + access_token = AccessToken.new(token: "abc", expires_at: 1000) + + assert(access_token.expired?(now: 1000)) + end + + def test_expired_is_true_after_expiry + access_token = AccessToken.new(token: "abc", expires_at: 1000) + + assert(access_token.expired?(now: 1001)) + end + + def test_scope_predicate + access_token = AccessToken.new(token: "abc", scopes: ["mcp:tools", "mcp:resources"]) + + assert(access_token.scope?("mcp:tools")) + assert(access_token.scope?(:"mcp:tools")) + refute(access_token.scope?("admin")) + end + + def test_with_scope_matcher_consults_the_matcher_on_a_copy + access_token = AccessToken.new(token: "abc", scopes: ["mcp:all"]) + matcher = ->(required, granted) { granted.include?("mcp:all") || granted.include?(required) } + + matched = access_token.with_scope_matcher(matcher) + + assert(matched.scope?("mcp:tools")) + refute(access_token.scope?("mcp:tools")) + assert_equal(access_token.to_h, matched.to_h) + assert_same(access_token, access_token.with_scope_matcher(nil)) + end + + def test_with_scope_matcher_works_on_frozen_tokens_and_keeps_the_subclass + subclass = Class.new(AccessToken) + access_token = subclass.new(token: "abc", scopes: ["mcp:all"]).freeze + matcher = ->(_required, granted) { granted.include?("mcp:all") } + + matched = access_token.with_scope_matcher(matcher) + + assert(matched.scope?("mcp:tools")) + assert_instance_of(subclass, matched) + assert_predicate(access_token, :frozen?) + end + + def test_to_h_omits_token_and_nil_fields + access_token = AccessToken.new( + token: "secret-token", + client_id: "client-1", + scopes: ["mcp:tools"], + expires_at: 1000, + subject: "user-1", + issuer: "https://as.example.com", + audience: "https://mcp.example.com", + resource: "https://mcp.example.com", + claims: { "sub" => "user-1" }, + ) + + hash = access_token.to_h + + refute_includes(hash.values.join, "secret-token") + assert_equal("client-1", hash[:client_id]) + assert_equal(["mcp:tools"], hash[:scopes]) + assert_equal(1000, hash[:expires_at]) + assert_equal("user-1", hash[:subject]) + assert_equal("https://as.example.com", hash[:issuer]) + assert_equal("https://mcp.example.com", hash[:audience]) + assert_equal("https://mcp.example.com", hash[:resource]) + assert_equal({ "sub" => "user-1" }, hash[:claims]) + + minimal = AccessToken.new(token: "abc").to_h + + refute(minimal.key?(:client_id)) + refute(minimal.key?(:expires_at)) + end + end + end + end +end diff --git a/test/mcp/server/oauth/authenticator_test.rb b/test/mcp/server/oauth/authenticator_test.rb new file mode 100644 index 00000000..f53be2e2 --- /dev/null +++ b/test/mcp/server/oauth/authenticator_test.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require "test_helper" +require "mcp/client/oauth/discovery" + +module MCP + class Server + module OAuth + class AuthenticatorTest < Minitest::Test + RESOURCE_METADATA_URL = "https://mcp.example.com/.well-known/oauth-protected-resource/mcp" + + class StubVerifier + def initialize(result) + @result = result + end + + def verify(_token) + @result + end + end + + def test_authenticate_returns_the_verified_access_token + access_token = AccessToken.new(token: "abc", scopes: ["mcp:tools"]) + + result = authenticator(token_verifier: StubVerifier.new(access_token)).authenticate(bearer_env) + + assert_same(access_token, result) + end + + def test_authenticate_attaches_the_scope_matcher_to_the_token + access_token = AccessToken.new(token: "abc", scopes: ["mcp:all"]) + matcher = ->(required, granted) { granted.include?("mcp:all") || granted.include?(required) } + + result = authenticator(token_verifier: StubVerifier.new(access_token), required_scopes: ["mcp:tools"], scope_matcher: matcher).authenticate(bearer_env) + + # The gate accepted `mcp:all` for `mcp:tools`; the token handed to handlers must judge scopes the same way. + assert(result.scope?("mcp:tools")) + refute(access_token.scope?("mcp:tools")) + end + + def test_scope_matcher_leaves_a_duck_typed_result_to_its_own_scope_check + duck = Object.new + duck.define_singleton_method(:scope?) { |scope| scope == "mcp:tools" } + matcher = ->(_required, _granted) { raise "the matcher must not be consulted without a scopes list" } + + result = authenticator(token_verifier: StubVerifier.new(duck), required_scopes: ["mcp:tools"], scope_matcher: matcher).authenticate(bearer_env) + + assert_same(duck, result) + end + + def test_nil_required_scopes_means_no_scope_requirement + access_token = AccessToken.new(token: "abc") + + result = authenticator(token_verifier: StubVerifier.new(access_token), required_scopes: nil).authenticate(bearer_env) + + assert_same(access_token, result) + end + + def test_missing_authorization_header_raises_missing_token_error + error = assert_raises(MissingTokenError) do + authenticator(token_verifier: StubVerifier.new(nil)).authenticate({}) + end + + assert_equal("Missing Authorization header", error.message) + end + + def test_token_over_the_maximum_length_is_rejected_before_verification + verifier = mock + verifier.expects(:verify).never + oversized_token = "a" * (Authenticator::MAX_TOKEN_BYTES + 1) + + error = assert_raises(InvalidTokenError) do + authenticator(token_verifier: verifier).authenticate({ "HTTP_AUTHORIZATION" => "Bearer #{oversized_token}" }) + end + + assert_equal("Token exceeds the maximum accepted length", error.message) + end + + def test_token_at_the_maximum_length_is_verified + access_token = AccessToken.new(token: "abc") + token = "a" * Authenticator::MAX_TOKEN_BYTES + + result = authenticator(token_verifier: StubVerifier.new(access_token)).authenticate({ "HTTP_AUTHORIZATION" => "Bearer #{token}" }) + + assert_same(access_token, result) + end + + def test_insufficient_scope_challenge_uses_the_operation_scopes + challenge = authenticator( + token_verifier: StubVerifier.new(nil), required_scopes: ["mcp:base"] + ).challenge_response(InsufficientScopeError.new(required_scopes: ["mcp:admin"])) + + status, headers, _body = challenge + + assert_equal(403, status) + + params = MCP::Client::OAuth::Discovery.parse_www_authenticate(headers["www-authenticate"]) + + # The 2026-07-28 authorization spec scopes the challenge to the failing operation, not to everything the endpoint could ever require. + assert_equal("mcp:admin", params["scope"]) + end + + def test_insufficient_scope_challenge_falls_back_to_required_scopes + challenge = authenticator(token_verifier: StubVerifier.new(nil), required_scopes: ["mcp:base"]).challenge_response(InsufficientScopeError.new) + + _status, headers, _body = challenge + params = MCP::Client::OAuth::Discovery.parse_www_authenticate(headers["www-authenticate"]) + + assert_equal("mcp:base", params["scope"]) + end + + def test_scope_hint_never_advertises_offline_access + challenge = authenticator( + token_verifier: StubVerifier.new(nil), required_scopes: ["offline_access", "mcp:tools"] + ).challenge_response(InvalidTokenError.new) + + _status, headers, _body = challenge + params = MCP::Client::OAuth::Discovery.parse_www_authenticate(headers["www-authenticate"]) + + assert_equal("mcp:tools", params["scope"]) + end + + def test_challenge_response_answers_other_oauth_errors_as_invalid_tokens + custom_error = Class.new(Error) do + def initialize(message = "rejected by policy") + super(message, error_code: "custom_rejection") + end + end + + status, headers, _body = authenticator(token_verifier: StubVerifier.new(nil)).challenge_response(custom_error.new) + + assert_equal(401, status) + + params = MCP::Client::OAuth::Discovery.parse_www_authenticate(headers["www-authenticate"]) + + assert_equal("invalid_token", params["error"]) + assert_equal("rejected by policy", params["error_description"]) + end + + def test_a_custom_error_subclass_needs_no_error_code_keyword + # A verifier that subclasses `Error` without its own initializer must be raisable the ordinary way. + # Without a default the missing keyword would raise `ArgumentError` inside `verify`, which the transport + # reports as HTTP 500 instead of answering the challenge below. + custom_error = Class.new(Error) + raised = assert_raises(custom_error) { raise custom_error, "rejected by policy" } + + assert_equal("invalid_token", raised.error_code) + + status, headers, _body = authenticator(token_verifier: StubVerifier.new(nil)).challenge_response(raised) + + assert_equal(401, status) + + params = MCP::Client::OAuth::Discovery.parse_www_authenticate(headers["www-authenticate"]) + + assert_equal("invalid_token", params["error"]) + assert_equal("rejected by policy", params["error_description"]) + end + + def test_invalid_utf8_bytes_in_the_header_reach_the_verifier_as_a_token + verifier = mock + verifier.expects(:verify).with { |token| token.b == "abc\xFFdef".b }.returns(nil) + header = "Bearer abc\xFFdef".dup.force_encoding(Encoding::UTF_8) + + assert_raises(InvalidTokenError) do + authenticator(token_verifier: verifier).authenticate({ "HTTP_AUTHORIZATION" => header }) + end + end + + def test_challenge_response_rejects_unexpected_error_classes + assert_raises(ArgumentError) do + authenticator(token_verifier: StubVerifier.new(nil)).challenge_response(RuntimeError.new) + end + end + + private + + def authenticator(token_verifier:, required_scopes: [], scope_matcher: nil) + Authenticator.new(token_verifier: token_verifier, required_scopes: required_scopes, resource_metadata_url: RESOURCE_METADATA_URL, scope_matcher: scope_matcher) + end + + def bearer_env + { "HTTP_AUTHORIZATION" => "Bearer abc" } + end + end + end + end +end diff --git a/test/mcp/server/oauth/challenge_test.rb b/test/mcp/server/oauth/challenge_test.rb new file mode 100644 index 00000000..2b6bdac1 --- /dev/null +++ b/test/mcp/server/oauth/challenge_test.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require "test_helper" +require "mcp/client/oauth/discovery" + +module MCP + class Server + module OAuth + class ChallengeTest < Minitest::Test + def test_build_with_all_parameters + header = Challenge.build( + error: "invalid_token", + error_description: "Token expired", + scope: "mcp:tools mcp:resources", + resource_metadata: "https://mcp.example.com/.well-known/oauth-protected-resource/mcp", + ) + + assert_equal( + 'Bearer error="invalid_token", error_description="Token expired", ' \ + 'scope="mcp:tools mcp:resources", ' \ + 'resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"', + header, + ) + end + + def test_build_without_parameters + assert_equal("Bearer", Challenge.build) + end + + def test_build_escapes_quotes_and_backslashes + header = Challenge.build(error_description: 'bad "token" with \\ inside') + + assert_equal('Bearer error_description="bad \\"token\\" with \\\\ inside"', header) + end + + def test_build_strips_header_injection_characters + header = Challenge.build(error_description: "line one\r\nSet-Cookie: evil=1") + + refute_includes(header, "\r") + refute_includes(header, "\n") + end + + def test_build_strips_other_control_characters + header = Challenge.build(error_description: "null\x00byte and\ttab") + + assert_equal('Bearer error_description="null byte and tab"', header) + end + + def test_build_scrubs_invalid_byte_sequences + header = Challenge.build(error_description: "bad \xFF byte") + + assert_predicate(header, :valid_encoding?) + assert_equal('Bearer error_description="bad ? byte"', header) + end + + def test_build_normalizes_binary_strings_to_utf8 + # Rack hands header values over as ASCII-8BIT, where every byte is "valid" and `scrub` is a no-op. + header = Challenge.build(error_description: "bad \xFF byte".b) + + assert_equal(Encoding::UTF_8, header.encoding) + assert_predicate(header, :valid_encoding?) + assert_equal('Bearer error_description="bad ? byte"', header) + end + + def test_build_round_trips_with_client_discovery_parser + header = Challenge.build( + error: "insufficient_scope", + error_description: 'needs "admin" scope, got \\ none', + scope: "mcp:tools admin", + resource_metadata: "https://mcp.example.com/.well-known/oauth-protected-resource", + ) + + params = MCP::Client::OAuth::Discovery.parse_www_authenticate(header) + + assert_equal("insufficient_scope", params["error"]) + assert_equal('needs "admin" scope, got \\ none', params["error_description"]) + assert_equal("mcp:tools admin", params["scope"]) + assert_equal("https://mcp.example.com/.well-known/oauth-protected-resource", params["resource_metadata"]) + end + + def test_invalid_token_response + status, headers, body = Challenge.invalid_token_response( + error_description: "Token expired", + scope: "mcp:tools", + resource_metadata: "https://mcp.example.com/.well-known/oauth-protected-resource", + ) + + assert_equal(401, status) + assert_equal("application/json", headers["content-type"]) + + params = MCP::Client::OAuth::Discovery.parse_www_authenticate(headers["www-authenticate"]) + + assert_equal("invalid_token", params["error"]) + assert_equal("Token expired", params["error_description"]) + assert_equal("mcp:tools", params["scope"]) + assert_equal("https://mcp.example.com/.well-known/oauth-protected-resource", params["resource_metadata"]) + + parsed_body = JSON.parse(body.join) + + assert_equal("invalid_token", parsed_body["error"]) + assert_equal("Token expired", parsed_body["error_description"]) + end + + def test_missing_token_response_carries_no_error_code + status, headers, body = Challenge.missing_token_response( + scope: "mcp:tools", + resource_metadata: "https://mcp.example.com/.well-known/oauth-protected-resource", + ) + + assert_equal(401, status) + assert_empty(body) + refute(headers.key?("content-type")) + + params = MCP::Client::OAuth::Discovery.parse_www_authenticate(headers["www-authenticate"]) + + refute(params.key?("error")) + assert_equal("mcp:tools", params["scope"]) + assert_equal("https://mcp.example.com/.well-known/oauth-protected-resource", params["resource_metadata"]) + end + + def test_insufficient_scope_response + status, headers, body = Challenge.insufficient_scope_response( + error_description: "Requires the admin scope", + scope: "mcp:tools admin", + resource_metadata: "https://mcp.example.com/.well-known/oauth-protected-resource", + ) + + assert_equal(403, status) + + params = MCP::Client::OAuth::Discovery.parse_www_authenticate(headers["www-authenticate"]) + + assert_equal("insufficient_scope", params["error"]) + assert_equal("mcp:tools admin", params["scope"]) + assert_equal("insufficient_scope", JSON.parse(body.join)["error"]) + end + + def test_invalid_request_response + status, headers, body = Challenge.invalid_request_response(error_description: "Unsupported authorization scheme") + + assert_equal(400, status) + + params = MCP::Client::OAuth::Discovery.parse_www_authenticate(headers["www-authenticate"]) + + assert_equal("invalid_request", params["error"]) + assert_equal("invalid_request", JSON.parse(body.join)["error"]) + end + + def test_responses_omit_missing_parameters + _status, headers, body = Challenge.invalid_token_response + + assert_equal('Bearer error="invalid_token"', headers["www-authenticate"]) + refute(JSON.parse(body.join).key?("error_description")) + end + end + end + end +end diff --git a/test/mcp/server/oauth/end_to_end_test.rb b/test/mcp/server/oauth/end_to_end_test.rb new file mode 100644 index 00000000..30577626 --- /dev/null +++ b/test/mcp/server/oauth/end_to_end_test.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require "test_helper" +require "json" +require "jwt" +require "rack" +require "webmock/minitest" +require "faraday" +require "mcp/client/http" +require "mcp/client/oauth" + +module MCP + class Server + module OAuth + # End-to-end coverage of the resource-server role using the SDK's own + # OAuth client: the real `MCP::Client::HTTP` + client_credentials provider + # talks to a real Rack stack (Protected Resource Metadata app + bearer + # Middleware + StreamableHTTPTransport) routed through WebMock. Only + # the authorization server is stubbed. + # + # Flow under test: 401 challenge -> PRM discovery -> AS metadata discovery + # -> token grant -> authenticated initialize + tools/call, with the tool + # observing the verified token via `server_context.auth_info`. + class EndToEndTest < Minitest::Test + MCP_URL = "https://mcp.example.com/mcp" + ISSUER = "https://as.example.com" + SIGNING_SECRET = "end-to-end-test-secret" + + class WhoamiTool < Tool + tool_name "whoami" + description "Reports the authenticated subject" + + class << self + def call(server_context:) + observations << server_context.auth_info + Tool::Response.new([{ type: "text", text: server_context.auth_info.subject.to_s }]) + end + + def observations + @observations ||= [] + end + end + end + + def setup + WhoamiTool.observations.clear + + @server = MCP::Server.new(name: "e2e_server", tools: [WhoamiTool]) + @transport = Transports::StreamableHTTPTransport.new(@server, enable_json_response: true) + + @metadata = ProtectedResourceMetadata.new( + resource: MCP_URL, + authorization_servers: [ISSUER], + scopes_supported: ["mcp:tools"], + ) + + verifier = JWTVerifier.new( + resource_metadata: @metadata, + key: SIGNING_SECRET, + algorithms: ["HS256"], + ) + + metadata = @metadata + transport = @transport + rack_app = Rack::Builder.new do + use(ProtectedResourceMetadataMiddleware, metadata) + + map("/mcp") do + use(Middleware, token_verifier: verifier, required_scopes: ["mcp:tools"], resource_metadata: metadata) + run(transport) + end + end + + stub_request(:any, %r{\Ahttps://mcp\.example\.com/}).to_rack(rack_app) + stub_authorization_server + end + + def teardown + @transport.close + WebMock.reset! + end + + def test_client_discovers_metadata_obtains_token_and_calls_tool + oauth = MCP::Client::OAuth::ClientCredentialsProvider.new( + client_id: "service-client", + client_secret: "service-secret", + ) + client = MCP::Client.new(transport: MCP::Client::HTTP.new(url: MCP_URL, oauth: oauth)) + client.connect + + tools = client.tools + + assert_equal(["whoami"], tools.map(&:name)) + + response = client.call_tool(tool: tools.first, arguments: {}) + + assert_equal("service-account", response.dig("result", "content", 0, "text")) + + # The verified token reached the tool with the claims the AS issued. + access_token = WhoamiTool.observations.fetch(0) + + assert_equal("service-account", access_token.subject) + assert_equal("service-client", access_token.client_id) + assert_equal(["mcp:tools"], access_token.scopes) + assert_equal(ISSUER, access_token.issuer) + assert_equal(MCP_URL, access_token.audience) + + # The client discovered the PRM document and requested a token. + assert_requested(:get, "https://mcp.example.com#{@metadata.well_known_path}") + assert_requested(:post, "#{ISSUER}/token") + end + + def test_unauthenticated_request_receives_spec_shaped_challenge + response = Faraday.post(MCP_URL) do |request| + request.headers["Content-Type"] = "application/json" + request.headers["Accept"] = "application/json, text/event-stream" + request.body = JSON.generate(jsonrpc: "2.0", id: 1, method: "initialize", params: {}) + end + + assert_equal(401, response.status) + + params = MCP::Client::OAuth::Discovery.parse_www_authenticate(response.headers["WWW-Authenticate"]) + + # RFC 6750 Section 3.1: no error code when the request carried no credentials. + refute(params.key?("error")) + assert_equal("https://mcp.example.com#{@metadata.well_known_path}", params["resource_metadata"]) + assert_equal("mcp:tools", params["scope"]) + end + + private + + def stub_authorization_server + stub_request(:get, "#{ISSUER}/.well-known/oauth-authorization-server").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: ISSUER, + token_endpoint: "#{ISSUER}/token", + grant_types_supported: ["client_credentials"], + token_endpoint_auth_methods_supported: ["client_secret_basic"], + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + ), + ) + + jwt = JWT.encode( + { + iss: ISSUER, + aud: MCP_URL, + sub: "service-account", + client_id: "service-client", + scope: "mcp:tools", + exp: Time.now.to_i + 3600, + }, + SIGNING_SECRET, + "HS256", + ) + + stub_request(:post, "#{ISSUER}/token").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate(access_token: jwt, token_type: "Bearer", expires_in: 3600), + ) + end + end + end + end +end diff --git a/test/mcp/server/oauth/introspection_verifier_test.rb b/test/mcp/server/oauth/introspection_verifier_test.rb new file mode 100644 index 00000000..a3cb43dd --- /dev/null +++ b/test/mcp/server/oauth/introspection_verifier_test.rb @@ -0,0 +1,298 @@ +# frozen_string_literal: true + +require "test_helper" +require "webmock/minitest" + +module MCP + class Server + module OAuth + class IntrospectionVerifierTest < Minitest::Test + ENDPOINT = "https://as.example.com/oauth/introspect" + AUDIENCE = "https://mcp.example.com/mcp" + METADATA = ProtectedResourceMetadata.new(resource: AUDIENCE, authorization_servers: ["https://as.example.com"]) + + def test_verifies_an_active_token_and_maps_fields + stub_introspection( + "active" => true, + "client_id" => "client-1", + "scope" => "mcp:tools mcp:resources", + "exp" => Time.now.to_i + 3600, + "sub" => "user-1", + "iss" => "https://as.example.com", + "aud" => AUDIENCE, + ) + + access_token = verifier.verify("opaque-token") + + assert_equal("opaque-token", access_token.token) + assert_equal("client-1", access_token.client_id) + assert_equal(["mcp:tools", "mcp:resources"], access_token.scopes) + assert_equal("user-1", access_token.subject) + assert_equal("https://as.example.com", access_token.issuer) + assert_equal(AUDIENCE, access_token.audience) + assert_equal(AUDIENCE, access_token.resource) + end + + def test_maps_an_array_valued_scope_claim + stub_introspection("active" => true, "aud" => AUDIENCE, "scope" => ["mcp:tools", "mcp:resources"]) + + assert_equal(["mcp:tools", "mcp:resources"], verifier.verify("opaque-token").scopes) + end + + def test_sends_token_with_basic_client_authentication + stub_introspection("active" => true, "aud" => AUDIENCE) + + verifier.verify("opaque-token") + + assert_requested(:post, ENDPOINT) do |request| + credentials = Base64.strict_encode64("rs-client:rs-secret") + + request.headers["Authorization"] == "Basic #{credentials}" && URI.decode_www_form(request.body).to_h == { "token" => "opaque-token" } + end + end + + def test_sends_client_credentials_in_body_with_post_authentication + stub_introspection("active" => true, "aud" => AUDIENCE) + + verifier(client_auth_method: :client_secret_post).verify("opaque-token") + + assert_requested(:post, ENDPOINT) do |request| + form = URI.decode_www_form(request.body).to_h + + request.headers["Authorization"].nil? && form == { "token" => "opaque-token", "client_id" => "rs-client", "client_secret" => "rs-secret" } + end + end + + def test_basic_client_authentication_form_encodes_the_credentials + stub_introspection("active" => true, "aud" => AUDIENCE) + + verifier(client_id: "rs:client/1", client_secret: "s e%cret").verify("opaque-token") + + assert_requested(:post, ENDPOINT) do |request| + # RFC 6749 Section 2.3.1: each half is form-urlencoded before the pair is base64-encoded, so the `:` in the id survives. + credentials = ["rs%3Aclient%2F1:s+e%25cret"].pack("m0") + + request.headers["Authorization"] == "Basic #{credentials}" + end + end + + def test_none_authentication_sends_only_the_token + stub_introspection("active" => true, "aud" => AUDIENCE) + + verifier(client_auth_method: :none, client_id: nil, client_secret: nil).verify("opaque-token") + + assert_requested(:post, ENDPOINT) do |request| + request.headers["Authorization"].nil? && URI.decode_www_form(request.body).to_h == { "token" => "opaque-token" } + end + end + + def test_rejects_inactive_token + stub_introspection("active" => false) + + error = assert_raises(InvalidTokenError) { verifier.verify("opaque-token") } + + assert_equal("Token is not active", error.message) + end + + def test_rejects_response_without_active_field + stub_introspection("client_id" => "client-1") + + assert_raises(InvalidTokenError) { verifier.verify("opaque-token") } + end + + def test_rejects_audience_mismatch + stub_introspection("active" => true, "aud" => "https://other.example.com") + + error = assert_raises(InvalidTokenError) { verifier.verify("opaque-token") } + + assert_equal("Invalid audience", error.message) + end + + def test_accepts_audience_in_aud_array + stub_introspection("active" => true, "aud" => ["https://other.example.com", AUDIENCE]) + + assert_equal(["https://other.example.com", AUDIENCE], verifier.verify("opaque-token").audience) + end + + def test_accepts_audience_in_resource_member + stub_introspection("active" => true, "resource" => AUDIENCE) + + assert_equal(AUDIENCE, verifier.verify("opaque-token").resource) + end + + def test_rejects_active_response_without_audience_when_audience_configured + stub_introspection("active" => true) + + error = assert_raises(InvalidTokenError) { verifier.verify("opaque-token") } + + assert_equal("Invalid audience", error.message) + end + + def test_requires_the_resource_metadata_document + [nil, "", :metadata, 42, { resource: AUDIENCE }].each do |metadata| + error = assert_raises(ArgumentError, metadata.inspect) do + IntrospectionVerifier.new(introspection_endpoint: ENDPOINT, client_id: "c", resource_metadata: metadata) + end + + assert_includes(error.message, "resource_metadata must be a ProtectedResourceMetadata") + end + end + + def test_takes_the_audience_from_the_resource_metadata + stub_introspection("active" => true, "aud" => AUDIENCE) + + assert_equal(AUDIENCE, verifier.verify("opaque-token").resource) + end + + def test_rejects_expired_token_by_exp + stub_introspection("active" => true, "aud" => AUDIENCE, "exp" => Time.now.to_i - 60) + + error = assert_raises(InvalidTokenError) { verifier.verify("opaque-token") } + + assert_equal("Token expired", error.message) + end + + def test_rejects_a_malformed_exp_member_as_an_invalid_token + stub_introspection("active" => true, "aud" => AUDIENCE, "exp" => "next week") + + error = assert_raises(InvalidTokenError) { verifier.verify("opaque-token") } + + assert_equal("Malformed exp claim", error.message) + end + + def test_rejects_a_non_finite_exp_member_as_an_invalid_token + # JSON parses `1e1000` to Infinity, whose `to_i` would raise instead of failing the token. + stub_request(:post, ENDPOINT).to_return( + status: 200, + body: %({"active":true,"aud":"#{AUDIENCE}","exp":1e1000}), + headers: { "Content-Type" => "application/json" }, + ) + + error = assert_raises(InvalidTokenError) { verifier.verify("opaque-token") } + + assert_equal("Malformed exp claim", error.message) + end + + def test_requires_positive_finite_timeouts + [{ open_timeout: 0 }, { read_timeout: Float::INFINITY }].each do |option| + assert_raises(ArgumentError, option.inspect) do + IntrospectionVerifier.new(introspection_endpoint: ENDPOINT, client_id: "c", resource_metadata: METADATA, **option) + end + end + end + + def test_accepts_a_numeric_string_exp_member + stub_introspection("active" => true, "aud" => AUDIENCE, "exp" => (Time.now.to_i + 3600).to_s) + + access_token = verifier.verify("opaque-token") + + assert_kind_of(Integer, access_token.expires_at) + refute_predicate(access_token, :expired?) + end + + def test_endpoint_error_is_not_an_invalid_token_error + stub_request(:post, ENDPOINT).to_return(status: 503) + + error = assert_raises(IntrospectionVerifier::IntrospectionError) { verifier.verify("opaque-token") } + + assert_equal("Introspection endpoint responded with status 503", error.message) + end + + def test_invalid_json_is_not_an_invalid_token_error + stub_request(:post, ENDPOINT).to_return(status: 200, body: "not json") + + assert_raises(IntrospectionVerifier::IntrospectionError) { verifier.verify("opaque-token") } + end + + def test_rejects_non_object_json_response + stub_request(:post, ENDPOINT).to_return(status: 200, body: "[]") + + error = assert_raises(IntrospectionVerifier::IntrospectionError) { verifier.verify("opaque-token") } + + assert_equal("Introspection endpoint returned a non-object JSON document", error.message) + end + + def test_rejects_oversized_response_body + stub_request(:post, ENDPOINT).to_return( + status: 200, + body: "a" * (TokenVerifier::MAX_UPSTREAM_RESPONSE_BYTES + 1), + ) + + error = assert_raises(IntrospectionVerifier::IntrospectionError) { verifier.verify("opaque-token") } + + assert_includes(error.message, "Introspection response exceeded") + end + + def test_timeout_propagates_as_infrastructure_error + stub_request(:post, ENDPOINT).to_timeout + + assert_raises(Errno::ETIMEDOUT, Net::OpenTimeout) { verifier.verify("opaque-token") } + end + + def test_rejects_non_loopback_http_endpoint + error = assert_raises(ArgumentError) do + IntrospectionVerifier.new( + introspection_endpoint: "http://as.example.com/introspect", + client_id: "c", + resource_metadata: METADATA, + ) + end + + assert_includes(error.message, "introspection_endpoint must use https") + end + + def test_allows_loopback_http_endpoint + verifier = IntrospectionVerifier.new( + introspection_endpoint: "http://localhost:9000/introspect", + client_auth_method: :none, + resource_metadata: METADATA, + ) + + stub_request(:post, "http://localhost:9000/introspect").to_return( + status: 200, + body: JSON.generate("active" => true, "aud" => AUDIENCE), + headers: { "Content-Type" => "application/json" }, + ) + + assert_equal("opaque-token", verifier.verify("opaque-token").token) + end + + def test_rejects_unknown_client_auth_method + error = assert_raises(ArgumentError) do + IntrospectionVerifier.new(introspection_endpoint: ENDPOINT, client_id: "c", client_auth_method: :jwt, resource_metadata: METADATA) + end + + assert_includes(error.message, "client_auth_method must be one of") + end + + def test_requires_client_id_unless_none + error = assert_raises(ArgumentError) do + IntrospectionVerifier.new(introspection_endpoint: ENDPOINT, resource_metadata: METADATA) + end + + assert_includes(error.message, "client_id is required") + end + + private + + def verifier(client_auth_method: :client_secret_basic, client_id: "rs-client", client_secret: "rs-secret", resource_metadata: METADATA) + IntrospectionVerifier.new( + introspection_endpoint: ENDPOINT, + client_id: client_id, + client_secret: client_secret, + client_auth_method: client_auth_method, + resource_metadata: resource_metadata, + ) + end + + def stub_introspection(response_body) + stub_request(:post, ENDPOINT).to_return( + status: 200, + body: JSON.generate(response_body), + headers: { "Content-Type" => "application/json" }, + ) + end + end + end + end +end diff --git a/test/mcp/server/oauth/jwt_verifier_test.rb b/test/mcp/server/oauth/jwt_verifier_test.rb new file mode 100644 index 00000000..29181b04 --- /dev/null +++ b/test/mcp/server/oauth/jwt_verifier_test.rb @@ -0,0 +1,540 @@ +# frozen_string_literal: true + +require "test_helper" +require "jwt" +require "openssl" +require "webmock/minitest" + +module MCP + class Server + module OAuth + class JWTVerifierTest < Minitest::Test + ISSUER = "https://as.example.com" + AUDIENCE = "https://mcp.example.com/mcp" + JWKS_URI = "https://as.example.com/.well-known/jwks.json" + METADATA = ProtectedResourceMetadata.new(resource: AUDIENCE, authorization_servers: [ISSUER]) + + def setup + @rsa_key = OpenSSL::PKey::RSA.new(2048) + @jwk = JWT::JWK.new(@rsa_key, { use: "sig", alg: "RS256" }) + @jwks = { keys: [@jwk.export] } + end + + def test_verifies_a_valid_token_and_maps_claims + stub_jwks + token = encode(claims) + + access_token = jwks_verifier.verify(token) + + assert_equal(token, access_token.token) + assert_equal("client-1", access_token.client_id) + assert_equal(["mcp:tools", "mcp:resources"], access_token.scopes) + assert_equal(claims["exp"], access_token.expires_at) + assert_equal("user-1", access_token.subject) + assert_equal(ISSUER, access_token.issuer) + assert_equal(AUDIENCE, access_token.audience) + assert_equal(AUDIENCE, access_token.resource) + assert_equal("user-1", access_token.claims["sub"]) + end + + def test_falls_back_to_azp_for_client_id + stub_jwks + token = encode(claims.tap { |c| c.delete("client_id") }.merge("azp" => "azp-client")) + + assert_equal("azp-client", jwks_verifier.verify(token).client_id) + end + + def test_rejects_expired_token + stub_jwks + token = encode(claims.merge("exp" => Time.now.to_i - 60)) + + error = assert_raises(InvalidTokenError) { jwks_verifier.verify(token) } + + assert_equal("Token expired", error.message) + end + + def test_rejects_token_not_yet_valid + stub_jwks + token = encode(claims.merge("nbf" => Time.now.to_i + 600)) + + error = assert_raises(InvalidTokenError) { jwks_verifier.verify(token) } + + assert_equal("Token not yet valid", error.message) + end + + def test_leeway_tolerates_recent_expiry + stub_jwks + token = encode(claims.merge("exp" => Time.now.to_i - 5)) + + verifier = jwks_verifier(leeway: 30) + + assert_equal("user-1", verifier.verify(token).subject) + end + + def test_rejects_audience_mismatch + stub_jwks + token = encode(claims.merge("aud" => "https://other.example.com")) + + error = assert_raises(InvalidTokenError) { jwks_verifier.verify(token) } + + assert_equal("Invalid audience", error.message) + end + + def test_rejects_issuer_mismatch + stub_jwks + token = encode(claims.merge("iss" => "https://evil.example.com")) + + error = assert_raises(InvalidTokenError) { jwks_verifier.verify(token) } + + assert_equal("Invalid issuer", error.message) + end + + def test_rejects_alg_none + stub_jwks + token = JWT.encode(claims, nil, "none") + + error = assert_raises(InvalidTokenError) { jwks_verifier.verify(token) } + + assert_equal("Invalid token", error.message) + end + + def test_rejects_hs256_key_confusion + stub_jwks + forged = JWT.encode(claims, @rsa_key.public_key.to_pem, "HS256") + + error = assert_raises(InvalidTokenError) { jwks_verifier.verify(forged) } + + assert_equal("Invalid token", error.message) + end + + def test_rejects_garbage_token + stub_jwks + + assert_raises(InvalidTokenError) { jwks_verifier.verify("not.a.jwt") } + end + + def test_rejects_token_without_exp + stub_jwks + token = encode(claims.tap { |c| c.delete("exp") }) + + error = assert_raises(InvalidTokenError) { jwks_verifier.verify(token) } + + # A token the issuer forgot to expire would otherwise be valid forever. + assert_equal("Invalid token", error.message) + end + + def test_caches_jwks_across_verifications + stub = stub_jwks + verifier = jwks_verifier + + verifier.verify(encode(claims)) + verifier.verify(encode(claims)) + + assert_requested(stub, times: 1) + end + + def test_unknown_kid_triggers_one_refetch_with_cooldown + rotated_key = OpenSSL::PKey::RSA.new(2048) + rotated_jwk = JWT::JWK.new(rotated_key, { use: "sig", alg: "RS256" }) + stale_then_rotated = stub_request(:get, JWKS_URI).to_return( + { body: JSON.generate(keys: [@jwk.export]), headers: { "Content-Type" => "application/json" } }, + { body: JSON.generate(keys: [@jwk.export, rotated_jwk.export]), headers: { "Content-Type" => "application/json" } }, + ) + verifier = jwks_verifier + + verifier.verify(encode(claims)) + + rotated_token = JWT.encode(claims, rotated_key, "RS256", { kid: rotated_jwk[:kid] }) + + assert_equal("user-1", verifier.verify(rotated_token).subject) + assert_requested(stale_then_rotated, times: 2) + + unknown_key = OpenSSL::PKey::RSA.new(2048) + unknown_jwk = JWT::JWK.new(unknown_key, { use: "sig", alg: "RS256" }) + unknown_token = JWT.encode(claims, unknown_key, "RS256", { kid: unknown_jwk[:kid] }) + + # Within the cooldown window an unknown kid must not trigger another fetch. + assert_raises(InvalidTokenError) { verifier.verify(unknown_token) } + assert_requested(stale_then_rotated, times: 2) + end + + def test_jwks_endpoint_failure_is_not_an_invalid_token_error + stub_request(:get, JWKS_URI).to_return(status: 500) + + error = assert_raises(JWTVerifier::JWKSFetchError) { jwks_verifier.verify(encode(claims)) } + + assert_equal("JWKS endpoint responded with status 500", error.message) + end + + def test_stale_jwks_is_served_when_a_refresh_fails + stub_request(:get, JWKS_URI).to_return( + { body: JSON.generate(@jwks), headers: { "Content-Type" => "application/json" } }, + { status: 500 }, + ) + verifier = jwks_verifier(jwks_cache_ttl: 0) + + assert_equal("user-1", verifier.verify(encode(claims)).subject) + + # The TTL of zero forces a refresh, whose failure must fall back to the cached key set instead of failing the verification. + assert_equal("user-1", verifier.verify(encode(claims)).subject) + end + + def test_rejects_oversized_jwks_response + stub_request(:get, JWKS_URI).to_return( + status: 200, + body: "a" * (TokenVerifier::MAX_UPSTREAM_RESPONSE_BYTES + 1), + ) + + error = assert_raises(JWTVerifier::JWKSFetchError) { jwks_verifier.verify(encode(claims)) } + + assert_includes(error.message, "JWKS response exceeded") + end + + def test_rejects_non_object_jwks_response + stub_request(:get, JWKS_URI).to_return(status: 200, body: "[]") + + error = assert_raises(JWTVerifier::JWKSFetchError) { jwks_verifier.verify(encode(claims)) } + + assert_equal("JWKS endpoint returned a non-object JSON document", error.message) + end + + def test_rejects_non_loopback_http_jwks_uri + error = assert_raises(ArgumentError) do + JWTVerifier.new(resource_metadata: METADATA, jwks_uri: "http://as.example.com/jwks.json") + end + + assert_includes(error.message, "jwks_uri must use https") + end + + def test_static_jwks_with_string_keys + verifier = JWTVerifier.new(resource_metadata: METADATA, jwks: JSON.parse(JSON.generate(@jwks))) + + assert_equal("user-1", verifier.verify(encode(claims)).subject) + end + + def test_static_key + verifier = JWTVerifier.new(resource_metadata: METADATA, key: @rsa_key.public_key) + token = JWT.encode(claims, @rsa_key, "RS256") + + assert_equal("user-1", verifier.verify(token).subject) + end + + def test_hs256_requires_explicit_opt_in + secret = "shared-secret" + token = JWT.encode(claims, secret, "HS256") + + opted_in = JWTVerifier.new(resource_metadata: METADATA, key: secret, algorithms: ["HS256"]) + + assert_equal("user-1", opted_in.verify(token).subject) + end + + def test_requires_exactly_one_key_source + assert_raises(ArgumentError) { JWTVerifier.new(resource_metadata: METADATA) } + assert_raises(ArgumentError) do + JWTVerifier.new(resource_metadata: METADATA, jwks_uri: JWKS_URI, key: "secret") + end + end + + def test_requires_the_resource_metadata_document + # The jwt gem skips a check whose expected value is nil, so the expected `iss` and `aud` come from the published document, + # never from a bare value that could be nil or empty. + [nil, "", { resource: AUDIENCE }, 42].each do |metadata| + error = assert_raises(ArgumentError, metadata.inspect) { JWTVerifier.new(resource_metadata: metadata, jwks_uri: JWKS_URI) } + + assert_includes(error.message, "resource_metadata must be a ProtectedResourceMetadata") + end + end + + def test_a_stand_in_document_must_hand_over_the_expected_member_shapes + # A duck-typed document that answers the members with the wrong shapes fails by name, not with a `NoMethodError` deeper in. + string_servers = Struct.new(:resource, :authorization_servers).new(AUDIENCE, ISSUER) + error = assert_raises(ArgumentError) { JWTVerifier.new(resource_metadata: string_servers, jwks_uri: JWKS_URI) } + + assert_includes(error.message, "resource_metadata.authorization_servers must be an Array of Strings") + + symbol_resource = Struct.new(:resource, :authorization_servers).new(:mcp, [ISSUER]) + error = assert_raises(ArgumentError) { JWTVerifier.new(resource_metadata: symbol_resource, jwks_uri: JWKS_URI) } + + assert_includes(error.message, "resource_metadata.resource must be a String") + end + + def test_takes_issuer_and_audience_from_the_resource_metadata + stub_jwks + verifier = JWTVerifier.new(resource_metadata: METADATA, jwks_uri: JWKS_URI) + + access_token = verifier.verify(encode(claims)) + + assert_equal(ISSUER, access_token.issuer) + assert_equal(AUDIENCE, access_token.resource) + end + + def test_a_document_naming_several_authorization_servers_is_refused + # One key set verifies one issuer's tokens; a document advertising a second server would send clients for tokens + # this verifier rejects. + metadata = ProtectedResourceMetadata.new(resource: AUDIENCE, authorization_servers: [ISSUER, "https://as2.example.com"]) + + error = assert_raises(ArgumentError) { JWTVerifier.new(resource_metadata: metadata, jwks_uri: JWKS_URI) } + + assert_includes(error.message, "one authorization server, and resource_metadata names 2") + end + + def test_rejects_none_in_the_algorithm_allowlist + ["none", "NONE"].each do |none| + error = assert_raises(ArgumentError) do + JWTVerifier.new(resource_metadata: METADATA, jwks_uri: JWKS_URI, algorithms: ["RS256", none]) + end + + assert_includes(error.message, "must not include none") + end + end + + def test_rejects_an_empty_algorithm_allowlist + assert_raises(ArgumentError) { JWTVerifier.new(resource_metadata: METADATA, jwks_uri: JWKS_URI, algorithms: []) } + end + + def test_rejects_mixing_hmac_with_asymmetric_algorithms + # With both families allowed, an HS256 token signed with the public key bytes would verify against that key. + error = assert_raises(ArgumentError) do + JWTVerifier.new(resource_metadata: METADATA, key: @rsa_key.public_key, algorithms: ["RS256", "HS256"]) + end + + assert_includes(error.message, "must not mix HMAC") + end + + def test_rejects_a_string_key_for_asymmetric_algorithms + error = assert_raises(ArgumentError) do + JWTVerifier.new(resource_metadata: METADATA, key: @rsa_key.public_key.to_pem) + end + + assert_includes(error.message, "key must be an OpenSSL::PKey") + end + + def test_rejects_a_jwk_object_as_the_key + # The decode path cannot use a JWK handed in as `key:`, so accepting it would fail every verification instead of the setup. + error = assert_raises(ArgumentError) do + JWTVerifier.new(resource_metadata: METADATA, key: @jwk) + end + + assert_includes(error.message, "pass a JWK through jwks:") + end + + def test_accepts_an_array_aud_claim_that_names_the_audience + stub_jwks + + assert_equal("user-1", jwks_verifier.verify(encode(claims.merge("aud" => ["https://other.example.com", AUDIENCE]))).subject) + + error = assert_raises(InvalidTokenError) { jwks_verifier.verify(encode(claims.merge("aud" => ["https://other.example.com"]))) } + + assert_equal("Invalid audience", error.message) + end + + def test_failed_unknown_kid_refetch_starts_the_cooldown + stub = stub_request(:get, JWKS_URI).to_return( + { body: JSON.generate(@jwks), headers: { "Content-Type" => "application/json" } }, + { status: 500 }, + ) + verifier = jwks_verifier + verifier.verify(encode(claims)) + + unknown_key = OpenSSL::PKey::RSA.new(2048) + unknown_jwk = JWT::JWK.new(unknown_key, { use: "sig", alg: "RS256" }) + unknown_token = JWT.encode(claims, unknown_key, "RS256", { kid: unknown_jwk[:kid] }) + + assert_raises(InvalidTokenError) { verifier.verify(unknown_token) } + assert_requested(stub, times: 2) + + # The refetch failed, but the endpoint must not be asked again for the next unknown kid within the cooldown. + assert_raises(InvalidTokenError) { verifier.verify(unknown_token) } + assert_requested(stub, times: 2) + end + + def test_connection_failure_on_refresh_is_bridged_with_the_cached_jwks + stub_request(:get, JWKS_URI).to_return(body: JSON.generate(@jwks), headers: { "Content-Type" => "application/json" }).then.to_raise(Errno::ECONNREFUSED) + verifier = jwks_verifier(jwks_cache_ttl: 0) + + assert_equal("user-1", verifier.verify(encode(claims)).subject) + assert_equal("user-1", verifier.verify(encode(claims)).subject) + end + + def test_a_concurrent_refresh_does_not_serve_keys_beyond_the_stale_bound + stub_jwks + verifier = jwks_verifier(jwks_cache_ttl: 0, jwks_max_stale: 0) + verifier.verify(encode(claims)) + + # Another thread holds the fetch lock, as one does in the middle of a refresh; the cached keys are past the bound, + # so this request must wait for that refresh and then judge the outcome instead of falling back to them. + fetch_mutex = verifier.instance_variable_get(:@fetch_mutex) + holder = Thread.new { fetch_mutex.synchronize { sleep(0.2) } } + sleep(0.02) + + assert_raises(JWTVerifier::JWKSFetchError) { verifier.verify(encode(claims)) } + ensure + holder&.join + end + + def test_a_concurrent_refresh_serves_the_cached_keys_within_the_stale_bound + stub_jwks + verifier = jwks_verifier(jwks_cache_ttl: 0) + verifier.verify(encode(claims)) + + fetch_mutex = verifier.instance_variable_get(:@fetch_mutex) + holder = Thread.new { fetch_mutex.synchronize { sleep(0.2) } } + sleep(0.02) + + assert_equal("user-1", verifier.verify(encode(claims)).subject) + ensure + holder&.join + end + + def test_a_failed_refresh_is_not_retried_on_every_request + stub = stub_request(:get, JWKS_URI).to_return( + { body: JSON.generate(@jwks), headers: { "Content-Type" => "application/json" } }, + { status: 500 }, + ) + verifier = jwks_verifier(jwks_cache_ttl: 0) + + 3.times { assert_equal("user-1", verifier.verify(encode(claims)).subject) } + + # The first refresh fails and starts the cooldown; the cached keys serve until it lapses without another attempt. + assert_requested(stub, times: 2) + end + + def test_a_jwks_document_without_a_keys_array_does_not_replace_the_cache + stub_request(:get, JWKS_URI).to_return( + { body: JSON.generate(@jwks), headers: { "Content-Type" => "application/json" } }, + { body: JSON.generate(keys: "bad"), headers: { "Content-Type" => "application/json" } }, + ) + verifier = jwks_verifier(jwks_cache_ttl: 0) + + assert_equal("user-1", verifier.verify(encode(claims)).subject) + assert_equal("user-1", verifier.verify(encode(claims)).subject) + end + + def test_a_bad_http_response_on_refresh_is_bridged_with_the_cached_jwks + stub_request(:get, JWKS_URI).to_return(body: JSON.generate(@jwks), headers: { "Content-Type" => "application/json" }).then.to_raise(Net::HTTPBadResponse) + verifier = jwks_verifier(jwks_cache_ttl: 0) + + assert_equal("user-1", verifier.verify(encode(claims)).subject) + assert_equal("user-1", verifier.verify(encode(claims)).subject) + end + + def test_rejects_time_claims_of_the_wrong_type_as_invalid_tokens + stub_jwks + # The jwt gem refuses to encode these, so the tokens are assembled by hand and signed with the test key. + [{ "exp" => true }, { "exp" => [] }, { "exp" => {} }, { "exp" => Time.now.to_i + 3600, "nbf" => true }].each do |overrides| + error = assert_raises(InvalidTokenError, overrides.inspect) { jwks_verifier.verify(hand_signed(claims.merge(overrides))) } + + assert_match(/\AMalformed (exp|nbf) claim\z/, error.message) + end + end + + def test_rejects_non_finite_or_negative_numeric_options + # `jwks_max_stale: nil` used to pass construction and then fail every cached verification on the `ttl + nil` arithmetic. + [{ leeway: -1 }, { leeway: Float::INFINITY }, { jwks_cache_ttl: Float::NAN }, { jwks_max_stale: -1 }, { jwks_max_stale: nil }, { open_timeout: 0 }, { read_timeout: "5" }].each do |option| + assert_raises(ArgumentError, option.inspect) { JWTVerifier.new(resource_metadata: METADATA, jwks_uri: JWKS_URI, **option) } + end + end + + def test_a_key_set_the_jwt_gem_cannot_load_does_not_replace_the_cache + # The gem rejects a set with an unloadable member as a whole, and that rejection never triggers a refetch, + # so such a document would otherwise fail every token until the TTL lapsed. + stub_request(:get, JWKS_URI).to_return( + { body: JSON.generate(@jwks), headers: { "Content-Type" => "application/json" } }, + { body: %({"keys":[{}]}), headers: { "Content-Type" => "application/json" } }, + ) + verifier = jwks_verifier(jwks_cache_ttl: 0) + + assert_equal("user-1", verifier.verify(encode(claims)).subject) + assert_equal("user-1", verifier.verify(encode(claims)).subject) + assert_equal(@jwks, verifier.instance_variable_get(:@cached_jwks)) + end + + def test_an_unloadable_key_set_on_a_cold_start_is_an_infrastructure_error + [%({"keys":[{}]}), %({"keys":[{"kty":"FOO"}]}), %({"keys":["nope"]})].each do |body| + stub_request(:get, JWKS_URI).to_return(body: body, headers: { "Content-Type" => "application/json" }) + + assert_raises(JWTVerifier::JWKSFetchError, body) { jwks_verifier.verify(encode(claims)) } + end + end + + def test_a_retired_key_set_snapshot_is_not_served_after_another_thread_refreshed + stub_jwks + verifier = jwks_verifier(jwks_cache_ttl: 0, jwks_max_stale: 0) + token = encode(claims) + + assert_equal("user-1", verifier.verify(token).subject) + + # Another thread rotates the cache to a set without this token's key at the very moment this thread finds + # the fetch lock taken; the snapshot this thread holds is past the bound and must be judged by its own fetch time, + # not by the refresh's. + rotated = { keys: [JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), { use: "sig", alg: "RS256" }).export] } + verifier.instance_variable_set(:@jwks_fetched_at, verifier.send(:monotonic_now) - 10) + taken_lock = Object.new + taken_lock.define_singleton_method(:try_lock) do + verifier.instance_variable_set(:@cached_jwks, rotated) + verifier.instance_variable_set(:@jwks_fetched_at, verifier.send(:monotonic_now)) + false + end + taken_lock.define_singleton_method(:synchronize) { |&block| block.call } + verifier.instance_variable_set(:@fetch_mutex, taken_lock) + + assert_raises(InvalidTokenError) { verifier.verify(token) } + end + + def test_stale_jwks_is_not_served_beyond_the_stale_bound + stub_request(:get, JWKS_URI).to_return( + { body: JSON.generate(@jwks), headers: { "Content-Type" => "application/json" } }, + { status: 500 }, + ) + verifier = jwks_verifier(jwks_cache_ttl: 0, jwks_max_stale: 0) + + assert_equal("user-1", verifier.verify(encode(claims)).subject) + + # With no stale allowance, the TTL lapse plus a failed refresh surfaces as the infrastructure error it is. + error = assert_raises(JWTVerifier::JWKSFetchError) { verifier.verify(encode(claims)) } + + assert_equal("JWKS endpoint responded with status 500", error.message) + end + + private + + def claims + @claims ||= { + "iss" => ISSUER, + "aud" => AUDIENCE, + "exp" => Time.now.to_i + 3600, + "sub" => "user-1", + "client_id" => "client-1", + "scope" => "mcp:tools mcp:resources", + } + @claims.dup + end + + def encode(payload) + JWT.encode(payload, @rsa_key, "RS256", { kid: @jwk[:kid] }) + end + + # `JWT.encode` validates claim types, so a token with a badly typed claim has to be assembled by hand. + def hand_signed(payload) + header = urlsafe(JSON.generate(alg: "RS256", kid: @jwk[:kid])) + body = urlsafe(JSON.generate(payload)) + signing_input = "#{header}.#{body}" + + "#{signing_input}.#{urlsafe(@rsa_key.sign(OpenSSL::Digest.new("SHA256"), signing_input))}" + end + + def urlsafe(bytes) + [bytes].pack("m0").tr("+/", "-_").delete("=") + end + + def jwks_verifier(leeway: 0, jwks_cache_ttl: 300, jwks_max_stale: 3600) + JWTVerifier.new(resource_metadata: METADATA, jwks_uri: JWKS_URI, leeway: leeway, jwks_cache_ttl: jwks_cache_ttl, jwks_max_stale: jwks_max_stale) + end + + def stub_jwks + stub_request(:get, JWKS_URI).to_return(body: JSON.generate(@jwks), headers: { "Content-Type" => "application/json" }) + end + end + end + end +end diff --git a/test/mcp/server/oauth/middleware_test.rb b/test/mcp/server/oauth/middleware_test.rb new file mode 100644 index 00000000..22c82013 --- /dev/null +++ b/test/mcp/server/oauth/middleware_test.rb @@ -0,0 +1,248 @@ +# frozen_string_literal: true + +require "test_helper" +require "mcp/client/oauth/discovery" + +module MCP + class Server + module OAuth + class MiddlewareTest < Minitest::Test + RESOURCE_METADATA_URL = "https://mcp.example.com/.well-known/oauth-protected-resource/mcp" + + class StubVerifier + def initialize(result) + @result = result + end + + def verify(_token) + raise @result if @result.is_a?(Class) || @result.is_a?(StandardError) + + @result + end + end + + class RecordingApp + attr_reader :last_env + + def call(env) + @last_env = env + + [200, { "content-type" => "application/json" }, ["{}"]] + end + end + + def setup + @app = RecordingApp.new + end + + def test_missing_authorization_header_returns_401_challenge_without_error_code + status, headers, body = middleware(token_verifier: StubVerifier.new(nil)).call({}) + + assert_equal(401, status) + assert_empty(body) + + params = MCP::Client::OAuth::Discovery.parse_www_authenticate(headers["www-authenticate"]) + + # RFC 6750 Section 3.1: a challenge answering a request without any authentication information should not include an error code. + refute(params.key?("error")) + refute(params.key?("error_description")) + assert_equal(RESOURCE_METADATA_URL, params["resource_metadata"]) + assert_nil(@app.last_env) + end + + def test_non_bearer_scheme_returns_400 + env = { "HTTP_AUTHORIZATION" => "Basic dXNlcjpwYXNz" } + + status, headers, _body = middleware(token_verifier: StubVerifier.new(nil)).call(env) + + assert_equal(400, status) + + params = MCP::Client::OAuth::Discovery.parse_www_authenticate(headers["www-authenticate"]) + + assert_equal("invalid_request", params["error"]) + end + + def test_verifier_raising_invalid_token_returns_401_with_description + verifier = StubVerifier.new(InvalidTokenError.new("Token expired")) + + status, headers, _body = middleware(token_verifier: verifier).call(bearer_env) + + assert_equal(401, status) + + params = MCP::Client::OAuth::Discovery.parse_www_authenticate(headers["www-authenticate"]) + + assert_equal("invalid_token", params["error"]) + assert_equal("Token expired", params["error_description"]) + end + + def test_verifier_message_with_invalid_bytes_still_returns_401 + verifier = StubVerifier.new(InvalidTokenError.new("bad \xFF byte")) + + status, headers, _body = middleware(token_verifier: verifier).call(bearer_env) + + assert_equal(401, status) + assert_predicate(headers["www-authenticate"], :valid_encoding?) + assert_includes(headers["www-authenticate"], 'error_description="bad ? byte"') + end + + def test_verifier_message_in_binary_encoding_still_returns_401 + # A verifier that quotes `env["HTTP_AUTHORIZATION"]` fragments builds an ASCII-8BIT message, + # on which `scrub` alone is a no-op; the JSON body must not raise on it either. + verifier = StubVerifier.new(InvalidTokenError.new("bad \xFF byte".b)) + + status, headers, body = middleware(token_verifier: verifier).call(bearer_env) + + assert_equal(401, status) + assert_includes(headers["www-authenticate"], 'error_description="bad ? byte"') + assert_equal("bad ? byte", JSON.parse(body.first)["error_description"]) + end + + def test_verifier_returning_nil_returns_generic_401 + status, headers, _body = middleware(token_verifier: StubVerifier.new(nil)).call(bearer_env) + + assert_equal(401, status) + + params = MCP::Client::OAuth::Discovery.parse_www_authenticate(headers["www-authenticate"]) + + assert_equal("The access token is invalid", params["error_description"]) + end + + def test_expired_access_token_returned_by_verifier_is_trusted + # Expiry enforcement is the verifier's contractual duty; re-checking it here would nullify the clock leeway + # a verifier was configured with. + expired = AccessToken.new(token: "abc", expires_at: Time.now.to_i - 60) + + status, _headers, _body = middleware(token_verifier: StubVerifier.new(expired)).call(bearer_env) + + assert_equal(200, status) + end + + def test_missing_required_scope_returns_403_step_up_challenge + access_token = AccessToken.new(token: "abc", scopes: ["mcp:tools"]) + verifier = StubVerifier.new(access_token) + + status, headers, _body = middleware(token_verifier: verifier, required_scopes: ["mcp:tools", "admin"]).call(bearer_env) + + assert_equal(403, status) + + params = MCP::Client::OAuth::Discovery.parse_www_authenticate(headers["www-authenticate"]) + + assert_equal("insufficient_scope", params["error"]) + assert_equal("Token is missing required scopes: admin", params["error_description"]) + assert_equal("mcp:tools admin", params["scope"]) + assert_equal(RESOURCE_METADATA_URL, params["resource_metadata"]) + end + + def test_401_scope_hint_falls_back_to_metadata_scopes_supported + metadata = ProtectedResourceMetadata.new( + resource: "https://mcp.example.com/mcp", + authorization_servers: ["https://as.example.com"], + scopes_supported: ["mcp:tools", "mcp:resources"], + ) + middleware = Middleware.new(@app, token_verifier: StubVerifier.new(nil), resource_metadata: metadata) + + _status, headers, _body = middleware.call({}) + + params = MCP::Client::OAuth::Discovery.parse_www_authenticate(headers["www-authenticate"]) + + assert_equal("mcp:tools mcp:resources", params["scope"]) + assert_equal(metadata.well_known_url, params["resource_metadata"]) + end + + def test_success_stores_access_token_in_env_and_calls_app + access_token = AccessToken.new(token: "abc", scopes: ["mcp:tools"]) + verifier = StubVerifier.new(access_token) + + status, _headers, _body = middleware(token_verifier: verifier, required_scopes: ["mcp:tools"]).call(bearer_env) + + assert_equal(200, status) + assert_same(access_token, @app.last_env[OAuth::ENV_KEY]) + end + + def test_bearer_scheme_is_case_insensitive + access_token = AccessToken.new(token: "abc") + + status, _headers, _body = middleware(token_verifier: StubVerifier.new(access_token)).call({ "HTTP_AUTHORIZATION" => "bearer abc" }) + + assert_equal(200, status) + end + + def test_scope_matcher_can_satisfy_required_scopes_hierarchically + access_token = AccessToken.new(token: "abc", scopes: ["mcp:all"]) + middleware = Middleware.new( + @app, + token_verifier: StubVerifier.new(access_token), + required_scopes: ["mcp:tools"], + resource_metadata_url: RESOURCE_METADATA_URL, + scope_matcher: ->(required_scope, granted_scopes) { + granted_scopes.include?("mcp:all") || granted_scopes.include?(required_scope) + }, + ) + + status, _headers, _body = middleware.call(bearer_env) + + assert_equal(200, status) + end + + def test_custom_oauth_error_from_the_verifier_answers_401 + custom_error = Class.new(Error) do + def initialize(message = "rejected by policy") + super(message, error_code: "custom_rejection") + end + end + + status, headers, _body = middleware(token_verifier: StubVerifier.new(custom_error.new)).call(bearer_env) + + assert_equal(401, status) + assert_includes(headers["www-authenticate"], 'error="invalid_token"') + assert_nil(@app.last_env) + end + + def test_invalid_utf8_bytes_in_the_authorization_header_answer_401 + header = "Bearer abc\xFFdef".dup.force_encoding(Encoding::UTF_8) + + status, headers, _body = middleware(token_verifier: StubVerifier.new(nil)).call({ "HTTP_AUTHORIZATION" => header }) + + assert_equal(401, status) + assert_includes(headers["www-authenticate"], 'error="invalid_token"') + end + + def test_errors_from_the_wrapped_app_are_not_swallowed + exploding_app = ->(_env) { raise "downstream failure" } + middleware = Middleware.new(exploding_app, token_verifier: StubVerifier.new(AccessToken.new(token: "abc")), resource_metadata_url: RESOURCE_METADATA_URL) + + error = assert_raises(RuntimeError) { middleware.call(bearer_env) } + + # Only verification is this middleware's business; the wrapped app's failures reach the app's own error handling. + assert_equal("downstream failure", error.message) + end + + def test_verifier_infrastructure_error_returns_500_without_challenge + verifier = StubVerifier.new(RuntimeError.new("JWKS endpoint down")) + reported = [] + MCP.stubs(:configuration).returns( + MCP::Configuration.new(exception_reporter: ->(exception, context) { reported << [exception, context] }), + ) + + status, headers, body = middleware(token_verifier: verifier).call(bearer_env) + + assert_equal(500, status) + refute(headers.key?("www-authenticate")) + assert_equal("server_error", JSON.parse(body.join)["error"]) + assert_equal(1, reported.size) + assert_equal("JWKS endpoint down", reported.first.first.message) + end + + private + + def middleware(token_verifier:, required_scopes: []) + Middleware.new(@app, token_verifier: token_verifier, required_scopes: required_scopes, resource_metadata_url: RESOURCE_METADATA_URL) + end + + def bearer_env + { "HTTP_AUTHORIZATION" => "Bearer abc" } + end + end + end + end +end diff --git a/test/mcp/server/oauth/protected_resource_metadata_middleware_test.rb b/test/mcp/server/oauth/protected_resource_metadata_middleware_test.rb new file mode 100644 index 00000000..deda3767 --- /dev/null +++ b/test/mcp/server/oauth/protected_resource_metadata_middleware_test.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require "test_helper" +require "rack/builder" +require "rack/mock" + +module MCP + class Server + module OAuth + class ProtectedResourceMetadataMiddlewareTest < Minitest::Test + WELL_KNOWN_PATH = "/.well-known/oauth-protected-resource/mcp" + + class RecordingApp + attr_reader :envs + + def initialize + @envs = [] + end + + def call(env) + @envs << env + + [200, { "content-type" => "text/plain" }, ["downstream"]] + end + end + + def setup + @metadata = ProtectedResourceMetadata.new( + resource: "https://mcp.example.com/mcp", + authorization_servers: ["https://as.example.com"], + scopes_supported: ["mcp:tools"], + ) + @app = RecordingApp.new + @middleware = ProtectedResourceMetadataMiddleware.new(@app, @metadata) + end + + def test_get_at_the_well_known_path_returns_the_document_with_cors_and_cache_headers + status, headers, body = @middleware.call(env_for("GET", WELL_KNOWN_PATH)) + + assert_equal(200, status) + assert_equal("application/json", headers["content-type"]) + assert_equal("public, max-age=3600", headers["cache-control"]) + assert_equal("*", headers["access-control-allow-origin"]) + + parsed = JSON.parse(body.join) + + assert_equal("https://mcp.example.com/mcp", parsed["resource"]) + assert_equal(["https://as.example.com"], parsed["authorization_servers"]) + assert_equal(["mcp:tools"], parsed["scopes_supported"]) + assert_empty(@app.envs) + end + + def test_head_returns_the_headers_without_a_body + status, headers, body = @middleware.call(env_for("HEAD", WELL_KNOWN_PATH)) + + assert_equal(200, status) + assert_equal("application/json", headers["content-type"]) + assert_empty(body) + end + + def test_options_returns_a_cors_preflight + status, headers, body = @middleware.call(env_for("OPTIONS", WELL_KNOWN_PATH)) + + assert_equal(204, status) + assert_equal("*", headers["access-control-allow-origin"]) + assert_equal("GET, HEAD, OPTIONS", headers["access-control-allow-methods"]) + assert_equal("*", headers["access-control-allow-headers"]) + assert_empty(body) + end + + def test_post_at_the_well_known_path_is_method_not_allowed + status, headers, body = @middleware.call(env_for("POST", WELL_KNOWN_PATH)) + + assert_equal(405, status) + assert_equal("GET, HEAD, OPTIONS", headers["allow"]) + assert_equal("method_not_allowed", JSON.parse(body.join)["error"]) + assert_empty(@app.envs) + end + + def test_passes_every_other_path_down_untouched + # A deeper well-known path describes another resource on this host, so it is the application's to answer. + paths = ["/mcp", "/", "/.well-known/oauth-protected-resource", "#{WELL_KNOWN_PATH}/", "#{WELL_KNOWN_PATH}/other"] + + paths.each do |path| + status, _headers, body = @middleware.call(env_for("GET", path)) + + assert_equal(200, status, path) + assert_equal(["downstream"], body, path) + end + + assert_equal(paths, @app.envs.map { |env| env["PATH_INFO"] }) + end + + def test_composes_with_rack_builder_above_a_mounted_endpoint + app = @app + metadata = @metadata + stack = Rack::Builder.new do + use(ProtectedResourceMetadataMiddleware, metadata) + map("/mcp") { run(app) } + end.to_app + + status, _headers, body = stack.call(Rack::MockRequest.env_for(WELL_KNOWN_PATH)) + + assert_equal(200, status) + assert_equal("https://mcp.example.com/mcp", JSON.parse(body.join)["resource"]) + + status, _headers, body = stack.call(Rack::MockRequest.env_for("/mcp")) + + assert_equal(200, status) + assert_equal(["downstream"], body) + end + + def test_requires_the_metadata_document_class + # A look-alike that names a path but serializes as an arbitrary object would be published as the document. + look_alike = Object.new + look_alike.define_singleton_method(:well_known_path) { WELL_KNOWN_PATH } + + [{ "resource" => "https://mcp.example.com/mcp" }, look_alike, nil].each do |metadata| + error = assert_raises(ArgumentError, metadata.inspect) { ProtectedResourceMetadataMiddleware.new(@app, metadata) } + + assert_includes(error.message, "metadata must be a ProtectedResourceMetadata") + end + end + + private + + def env_for(method, path) + { "REQUEST_METHOD" => method, "PATH_INFO" => path, "SCRIPT_NAME" => "" } + end + end + end + end +end diff --git a/test/mcp/server/oauth/protected_resource_metadata_test.rb b/test/mcp/server/oauth/protected_resource_metadata_test.rb new file mode 100644 index 00000000..2a64d197 --- /dev/null +++ b/test/mcp/server/oauth/protected_resource_metadata_test.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +require "test_helper" +require "mcp/client/oauth/discovery" + +module MCP + class Server + module OAuth + class ProtectedResourceMetadataTest < Minitest::Test + def test_requires_absolute_http_resource + error = assert_raises(ArgumentError) do + ProtectedResourceMetadata.new(resource: "not a url", authorization_servers: ["https://as.example.com"]) + end + assert_includes(error.message, "resource must be") + + assert_raises(ArgumentError) do + ProtectedResourceMetadata.new(resource: "/mcp", authorization_servers: ["https://as.example.com"]) + end + end + + def test_rejects_resource_with_fragment + error = assert_raises(ArgumentError) do + ProtectedResourceMetadata.new(resource: "https://mcp.example.com/mcp#frag", authorization_servers: ["https://as.example.com"]) + end + + assert_includes(error.message, "fragment") + end + + def test_requires_at_least_one_authorization_server + error = assert_raises(ArgumentError) do + ProtectedResourceMetadata.new(resource: "https://mcp.example.com", authorization_servers: []) + end + + assert_equal("authorization_servers must contain at least one issuer URL", error.message) + end + + def test_rejects_non_loopback_http_resource + error = assert_raises(ArgumentError) do + ProtectedResourceMetadata.new(resource: "http://mcp.example.com/mcp", authorization_servers: ["https://as.example.com"]) + end + + assert_includes(error.message, "resource must use https") + end + + def test_rejects_non_loopback_http_authorization_server + error = assert_raises(ArgumentError) do + ProtectedResourceMetadata.new(resource: "https://mcp.example.com/mcp", authorization_servers: ["http://as.example.com"]) + end + + assert_includes(error.message, "authorization_servers must use https") + end + + def test_extra_fields_are_merged_into_the_document + metadata = ProtectedResourceMetadata.new( + resource: "https://mcp.example.com/mcp", + authorization_servers: ["https://as.example.com"], + extra: { jwks_uri: "https://mcp.example.com/jwks.json" }, + ) + + assert_equal("https://mcp.example.com/jwks.json", metadata.to_h[:jwks_uri]) + assert_equal("https://mcp.example.com/jwks.json", JSON.parse(metadata.to_json)["jwks_uri"]) + end + + def test_extra_cannot_override_the_validated_members + members = ["resource", "authorization_servers", "scopes_supported", "resource_name", "resource_documentation", "bearer_methods_supported"] + + members.flat_map { |member| [{ member => "overridden" }, { member.to_sym => "overridden" }] }.each do |extra| + error = assert_raises(ArgumentError) do + ProtectedResourceMetadata.new(resource: "https://mcp.example.com/mcp", authorization_servers: ["https://as.example.com"], extra: extra) + end + + assert_includes(error.message, "extra must not override #{extra.keys.first}") + end + end + + def test_accepts_a_single_authorization_server_string + metadata = ProtectedResourceMetadata.new(resource: "https://mcp.example.com", authorization_servers: "https://as.example.com") + + assert_equal(["https://as.example.com"], metadata.authorization_servers) + end + + def test_to_h_uses_spec_field_names_and_compacts + metadata = ProtectedResourceMetadata.new( + resource: "https://mcp.example.com/mcp", + authorization_servers: ["https://as.example.com"], + scopes_supported: ["mcp:tools"], + resource_name: "Example MCP Server", + resource_documentation: "https://mcp.example.com/docs", + ) + + assert_equal( + { + resource: "https://mcp.example.com/mcp", + authorization_servers: ["https://as.example.com"], + scopes_supported: ["mcp:tools"], + resource_name: "Example MCP Server", + resource_documentation: "https://mcp.example.com/docs", + bearer_methods_supported: ["header"], + }, + metadata.to_h, + ) + + minimal = ProtectedResourceMetadata.new(resource: "https://mcp.example.com", authorization_servers: ["https://as.example.com"]) + + refute(minimal.to_h.key?(:scopes_supported)) + refute(minimal.to_h.key?(:resource_name)) + end + + def test_does_not_advertise_offline_access + metadata = ProtectedResourceMetadata.new( + resource: "https://mcp.example.com/mcp", + authorization_servers: ["https://as.example.com"], + scopes_supported: ["mcp:tools", "offline_access", "mcp:resources"], + ) + + # The specification tells protected resources not to advertise it, and the challenge builder already drops it, + # so the document must not disagree with the `scope` parameter the challenges carry. + assert_equal(["mcp:tools", "mcp:resources"], metadata.scopes_supported) + assert_equal(["mcp:tools", "mcp:resources"], metadata.to_h[:scopes_supported]) + end + + def test_omits_scopes_supported_when_nothing_is_left_to_advertise + [["offline_access"], []].each do |scopes_supported| + metadata = ProtectedResourceMetadata.new( + resource: "https://mcp.example.com/mcp", + authorization_servers: ["https://as.example.com"], + scopes_supported: scopes_supported, + ) + + # RFC 9728 has servers omit an empty member rather than advertise a resource with no scopes at all. + assert_nil(metadata.scopes_supported) + refute(metadata.to_h.key?(:scopes_supported)) + end + end + + def test_requires_scopes_supported_to_be_an_array + error = assert_raises(ArgumentError) do + ProtectedResourceMetadata.new( + resource: "https://mcp.example.com/mcp", + authorization_servers: ["https://as.example.com"], + scopes_supported: "mcp:tools mcp:resources", + ) + end + + assert_includes(error.message, "scopes_supported must be an Array") + end + + def test_to_json + metadata = ProtectedResourceMetadata.new(resource: "https://mcp.example.com", authorization_servers: ["https://as.example.com"]) + + parsed = JSON.parse(metadata.to_json) + + assert_equal("https://mcp.example.com", parsed["resource"]) + assert_equal(["https://as.example.com"], parsed["authorization_servers"]) + assert_equal(["header"], parsed["bearer_methods_supported"]) + end + + def test_well_known_path_for_root_resource + metadata = ProtectedResourceMetadata.new(resource: "https://mcp.example.com", authorization_servers: ["https://as.example.com"]) + + assert_equal("/.well-known/oauth-protected-resource", metadata.well_known_path) + end + + def test_well_known_path_treats_root_slash_as_empty + metadata = ProtectedResourceMetadata.new(resource: "https://mcp.example.com/", authorization_servers: ["https://as.example.com"]) + + assert_equal("/.well-known/oauth-protected-resource", metadata.well_known_path) + end + + def test_well_known_path_inserts_resource_path + metadata = ProtectedResourceMetadata.new(resource: "https://mcp.example.com/mcp", authorization_servers: ["https://as.example.com"]) + + assert_equal("/.well-known/oauth-protected-resource/mcp", metadata.well_known_path) + end + + def test_well_known_url_includes_non_default_port + metadata = ProtectedResourceMetadata.new(resource: "http://localhost:9393/mcp", authorization_servers: ["http://localhost:9000"]) + + assert_equal("http://localhost:9393/.well-known/oauth-protected-resource/mcp", metadata.well_known_url) + end + + def test_well_known_url_omits_default_port + metadata = ProtectedResourceMetadata.new(resource: "https://mcp.example.com:443/mcp", authorization_servers: ["https://as.example.com"]) + + assert_equal("https://mcp.example.com/.well-known/oauth-protected-resource/mcp", metadata.well_known_url) + end + + def test_well_known_url_matches_client_discovery_candidates + [ + "https://mcp.example.com", + "https://mcp.example.com/", + "https://mcp.example.com/mcp", + "http://localhost:9393/nested/mcp", + ].each do |resource| + metadata = ProtectedResourceMetadata.new( + resource: resource, + authorization_servers: ["https://as.example.com"], + ) + + candidates = MCP::Client::OAuth::Discovery.protected_resource_metadata_urls(server_url: resource) + + assert_includes(candidates, metadata.well_known_url, "for resource #{resource}") + end + end + end + end + end +end diff --git a/test/mcp/server/transports/streamable_http_transport_oauth_test.rb b/test/mcp/server/transports/streamable_http_transport_oauth_test.rb new file mode 100644 index 00000000..60b7fcc3 --- /dev/null +++ b/test/mcp/server/transports/streamable_http_transport_oauth_test.rb @@ -0,0 +1,814 @@ +# frozen_string_literal: true + +require "test_helper" +require "rack" +require "mcp/client/oauth/discovery" + +module MCP + class Server + module Transports + class StreamableHTTPTransportOAuthTest < ActiveSupport::TestCase + include InitializeParamsTestHelper + + RESOURCE_METADATA_URL = "https://mcp.example.com/.well-known/oauth-protected-resource/mcp" + + class StubVerifier + attr_reader :calls + + def initialize(tokens) + @tokens = tokens + @expired = [] + @calls = [] + end + + def expire(token) + @expired << token + end + + def verify(token) + @calls << token + raise OAuth::InvalidTokenError, "Token expired" if @expired.include?(token) + + @tokens[token] + end + end + + class ExplodingBody + def read(*) + raise "the request body must not be read before authentication" + end + end + + # A stream that records writes and whether it was closed. + class TestStream + def initialize + @buffer = "".dup + @closed = false + end + + def write(data) + raise IOError, "closed stream" if @closed + + @buffer << data + end + + def flush + end + + def close + @closed = true + end + + def closed? + @closed + end + end + + setup do + @server = Server.new(name: "oauth_test_server") + @server.define_tool(name: "whoami") do |server_context:| + subject = server_context.auth_info ? server_context.auth_info.subject.to_s : "anonymous" + Tool::Response.new([{ type: "text", text: subject }]) + end + + @verifier = StubVerifier.new( + "alice-token" => access_token("alice-token", subject: "alice", client_id: "client-a"), + "bob-token" => access_token("bob-token", subject: "bob", client_id: "client-b"), + "narrow-token" => access_token("narrow-token", subject: "alice", client_id: "client-a", scopes: ["other"]), + "svc-a-token" => access_token("svc-a-token", subject: nil, client_id: "svc-a"), + "svc-b-token" => access_token("svc-b-token", subject: nil, client_id: "svc-b"), + ) + @transports = [] + end + + teardown do + @transports.each(&:close) + end + + test "legacy initialize without a token is rejected with a bare 401 challenge" do + response = transport.handle_request(initialize_request) + + assert_equal 401, response[0] + + params = parse_challenge(response) + + refute params.key?("error") + assert_equal RESOURCE_METADATA_URL, params["resource_metadata"] + assert_equal "mcp:tools", params["scope"] + assert_empty @verifier.calls + end + + test "an unknown token is rejected with 401 invalid_token" do + response = transport.handle_request(initialize_request(token: "unknown-token")) + + assert_equal 401, response[0] + assert_equal "invalid_token", parse_challenge(response)["error"] + end + + test "an expired token is rejected with 401" do + @verifier.expire("alice-token") + + response = transport.handle_request(initialize_request(token: "alice-token")) + + assert_equal 401, response[0] + assert_equal "Token expired", parse_challenge(response)["error_description"] + end + + test "a token missing a required scope is rejected with 403 insufficient_scope" do + response = transport.handle_request(initialize_request(token: "narrow-token")) + + assert_equal 403, response[0] + + params = parse_challenge(response) + + assert_equal "insufficient_scope", params["error"] + assert_equal "mcp:tools", params["scope"] + assert_equal RESOURCE_METADATA_URL, params["resource_metadata"] + end + + test "a non-bearer authorization scheme is rejected with 400" do + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Basic dXNlcjpwYXNz" }, + initialize_body, + ) + + response = transport.handle_request(request) + + assert_equal 400, response[0] + assert_equal "invalid_request", parse_challenge(response)["error"] + end + + test "a valid token initializes a session" do + response = transport.handle_request(initialize_request(token: "alice-token")) + + assert_equal 200, response[0] + assert response[1]["mcp-session-id"] + end + + test "legacy GET and DELETE require a token" do + the_transport = transport + session_id = initialize_session(the_transport, token: "alice-token") + + unauthenticated_get = create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session_id }) + + assert_equal 401, the_transport.handle_request(unauthenticated_get)[0] + + authenticated_get = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id, "HTTP_AUTHORIZATION" => "Bearer alice-token" }, + ) + + assert_equal 200, the_transport.handle_request(authenticated_get)[0] + + unauthenticated_delete = create_rack_request("DELETE", "/", { "HTTP_MCP_SESSION_ID" => session_id }) + + assert_equal 401, the_transport.handle_request(unauthenticated_delete)[0] + + authenticated_delete = create_rack_request( + "DELETE", + "/", + { "HTTP_MCP_SESSION_ID" => session_id, "HTTP_AUTHORIZATION" => "Bearer alice-token" }, + ) + + assert_equal 200, the_transport.handle_request(authenticated_delete)[0] + end + + test "the modern path requires a token and threads auth_info to the handler" do + the_transport = transport + + unauthenticated = modern_rack_request(modern_body("tools/call", name: "whoami", arguments: {})) + + assert_equal 401, the_transport.handle_request(unauthenticated)[0] + + authenticated = modern_rack_request( + modern_body("tools/call", name: "whoami", arguments: {}), + headers: { "HTTP_AUTHORIZATION" => "Bearer alice-token" }, + ) + response = the_transport.handle_request(authenticated) + + assert_equal 200, response[0] + assert_equal "alice", JSON.parse(response[2][0]).dig("result", "content", 0, "text") + end + + test "subscriptions/listen requires a token" do + body = modern_body("subscriptions/listen", toolsListChanged: true) + + response = transport.handle_request(modern_rack_request(body)) + + assert_equal 401, response[0] + end + + test "dns rebinding rejection precedes authentication" do + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_HOST" => "evil.example.com" }, + initialize_body, + ) + + response = transport.handle_request(request) + + assert_equal 403, response[0] + refute response[1].key?("www-authenticate") + assert_empty @verifier.calls + end + + test "an unauthenticated request is rejected before the body is read" do + env = { + "REQUEST_METHOD" => "POST", + "PATH_INFO" => "/", + "CONTENT_TYPE" => "application/json", + "HTTP_ACCEPT" => "application/json, text/event-stream", + "rack.input" => ExplodingBody.new, + } + + response = transport.handle_request(Rack::Request.new(env)) + + assert_equal 401, response[0] + end + + test "legacy POST threads auth_info to tool handlers in JSON response mode" do + the_transport = transport(enable_json_response: true) + session_id = initialize_session(the_transport, token: "alice-token") + + response = the_transport.handle_request(tool_call_request(session_id, token: "alice-token")) + + assert_equal 200, response[0] + assert_equal "alice", JSON.parse(response[2][0]).dig("result", "content", 0, "text") + end + + test "legacy POST threads auth_info to tool handlers on the SSE response stream" do + the_transport = transport + session_id = initialize_session(the_transport, token: "alice-token") + + response = the_transport.handle_request(tool_call_request(session_id, token: "alice-token")) + + assert_equal 200, response[0] + + io = StringIO.new + response[2].call(io) + body = JSON.parse(io.string.match(/^data: (.+)$/)[1]) + + assert_equal "alice", body.dig("result", "content", 0, "text") + end + + test "auth_info placed in the env by external middleware reaches handlers" do + the_transport = transport_without_oauth(enable_json_response: true) + session_id = initialize_session(the_transport) + + request = tool_call_request(session_id) + request.env[OAuth::ENV_KEY] = access_token("external-token", subject: "external", client_id: "client-x") + + response = the_transport.handle_request(request) + + assert_equal "external", JSON.parse(response[2][0]).dig("result", "content", 0, "text") + end + + test "a session is bound to the principal that initialized it" do + the_transport = transport(enable_json_response: true) + session_id = initialize_session(the_transport, token: "alice-token") + + assert_equal 404, the_transport.handle_request(ping_request(session_id, token: "bob-token"))[0] + + bob_get = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id, "HTTP_AUTHORIZATION" => "Bearer bob-token" }, + ) + + assert_equal 404, the_transport.handle_request(bob_get)[0] + + bob_delete = create_rack_request( + "DELETE", + "/", + { "HTTP_MCP_SESSION_ID" => session_id, "HTTP_AUTHORIZATION" => "Bearer bob-token" }, + ) + + assert_equal 404, the_transport.handle_request(bob_delete)[0] + assert_equal 200, the_transport.handle_request(ping_request(session_id, token: "alice-token"))[0] + end + + test "the principal binding is not bypassed by a permissive session_request_validator" do + the_transport = transport( + enable_json_response: true, + session_request_validator: ->(_request, _session_id) { true }, + ) + session_id = initialize_session(the_transport, token: "alice-token") + + assert_equal 404, the_transport.handle_request(ping_request(session_id, token: "bob-token"))[0] + end + + test "client credentials sessions bind on client id" do + the_transport = transport(enable_json_response: true) + session_id = initialize_session(the_transport, token: "svc-a-token") + + assert_equal 404, the_transport.handle_request(ping_request(session_id, token: "svc-b-token"))[0] + assert_equal 200, the_transport.handle_request(ping_request(session_id, token: "svc-a-token"))[0] + end + + test "the principal binding includes the issuer" do + @verifier = StubVerifier.new( + "as1-token" => access_token( + "as1-token", + subject: "12345", + client_id: "mcp-client", + issuer: "https://as1.example.com", + ), + "as2-token" => access_token( + "as2-token", + subject: "12345", + client_id: "mcp-client", + issuer: "https://as2.example.com", + ), + ) + the_transport = transport(enable_json_response: true) + session_id = initialize_session(the_transport, token: "as1-token") + + assert_equal 404, the_transport.handle_request(ping_request(session_id, token: "as2-token"))[0] + assert_equal 200, the_transport.handle_request(ping_request(session_id, token: "as1-token"))[0] + end + + test "a principal mismatch is indistinguishable from an unknown session" do + the_transport = transport(enable_json_response: true) + session_id = initialize_session(the_transport, token: "alice-token") + + mismatch = the_transport.handle_request(ping_request(session_id, token: "bob-token")) + unknown = the_transport.handle_request(ping_request("no-such-session", token: "bob-token")) + + assert_equal 404, mismatch[0] + assert_equal unknown[0], mismatch[0] + assert_equal unknown[2], mismatch[2] + refute mismatch[1].key?("www-authenticate") + end + + test "a rejected GET does not refresh the idle timer of the session it targets" do + the_transport = transport(enable_json_response: true, session_idle_timeout: 60) + session_id = initialize_session(the_transport, token: "alice-token") + last_active_before = the_transport.instance_variable_get(:@sessions)[session_id][:last_active_at] + + bob_get = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id, "HTTP_AUTHORIZATION" => "Bearer bob-token" }, + ) + + assert_equal 404, the_transport.handle_request(bob_get)[0] + assert_equal last_active_before, the_transport.instance_variable_get(:@sessions)[session_id][:last_active_at] + end + + test "stateless mode verifies every request independently" do + the_transport = transport(stateless: true, enable_json_response: true) + + response = the_transport.handle_request(tool_call_request(nil, token: "alice-token")) + + assert_equal 200, response[0] + assert_equal "alice", JSON.parse(response[2][0]).dig("result", "content", 0, "text") + + assert_equal 401, the_transport.handle_request(tool_call_request(nil))[0] + end + + test "OPTIONS requests bypass authentication" do + response = transport.handle_request(create_rack_request("OPTIONS", "/", {})) + + assert_equal 405, response[0] + assert_empty @verifier.calls + end + + test "a crashing verifier returns 500 without a challenge" do + crashing_verifier = Object.new + def crashing_verifier.verify(_token) + raise "JWKS endpoint down" + end + + reported = [] + MCP.stubs(:configuration).returns( + MCP::Configuration.new(exception_reporter: ->(exception, context) { reported << [exception, context] }), + ) + the_transport = transport(token_verifier: crashing_verifier) + + response = the_transport.handle_request(initialize_request(token: "alice-token")) + + assert_equal 500, response[0] + refute response[1].key?("www-authenticate") + assert_equal 1, reported.size + end + + test "OAuth options without a verifier raise ArgumentError" do + error = assert_raises(ArgumentError) do + StreamableHTTPTransport.new(@server, required_scopes: ["mcp:tools"]) + end + + assert_includes error.message, "require token_verifier" + end + + test "an expired token is rejected on the next request even within a live session" do + the_transport = transport(enable_json_response: true) + session_id = initialize_session(the_transport, token: "alice-token") + + @verifier.expire("alice-token") + + assert_equal 401, the_transport.handle_request(ping_request(session_id, token: "alice-token"))[0] + end + + test "require_scopes! failures surface as JSON-RPC errors naming the scopes" do + @server.define_tool(name: "admin_tool") do |server_context:| + server_context.require_scopes!("mcp:admin") + Tool::Response.new([{ type: "text", text: "ok" }]) + end + the_transport = transport(enable_json_response: true) + session_id = initialize_session(the_transport, token: "alice-token") + + body = { + jsonrpc: "2.0", + method: "tools/call", + id: "admin-1", + params: { name: "admin_tool", arguments: {} }, + }.to_json + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_AUTHORIZATION" => "Bearer alice-token", + }, + body, + ) + + response = the_transport.handle_request(request) + + assert_equal 200, response[0] + + parsed = JSON.parse(response[2][0]) + + assert_equal(-32600, parsed.dig("error", "code")) + assert_equal "Token is missing required scopes: mcp:admin", parsed.dig("error", "data") + end + + test "require_scopes! applies the transport's scope_matcher, so a broader scope satisfies handlers too" do + @server.define_tool(name: "admin_tool") do |server_context:| + server_context.require_scopes!("mcp:admin") + Tool::Response.new([{ type: "text", text: "ok" }]) + end + @verifier = StubVerifier.new( + "root-token" => access_token("root-token", subject: "root", client_id: "client-r", scopes: ["mcp:all"]), + ) + matcher = ->(required, granted) { granted.include?("mcp:all") || granted.include?(required) } + the_transport = transport(enable_json_response: true, scope_matcher: matcher) + session_id = initialize_session(the_transport, token: "root-token") + + body = { + jsonrpc: "2.0", + method: "tools/call", + id: "admin-1", + params: { name: "admin_tool", arguments: {} }, + }.to_json + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_AUTHORIZATION" => "Bearer root-token", + }, + body, + ) + + response = the_transport.handle_request(request) + + assert_equal 200, response[0] + + parsed = JSON.parse(response[2][0]) + + refute parsed.key?("error") + assert_equal "ok", parsed.dig("result", "content", 0, "text") + end + + test "a custom OAuth::Error from the verifier answers 401 instead of escaping the transport" do + custom_error = Class.new(OAuth::Error) do + def initialize(message = "rejected by policy") + super(message, error_code: "custom_rejection") + end + end + raising_verifier = Object.new + raising_verifier.define_singleton_method(:verify) { |_token| raise custom_error } + the_transport = transport(token_verifier: raising_verifier) + + response = the_transport.handle_request(initialize_request(token: "any-token")) + + assert_equal 401, response[0] + assert_equal "invalid_token", parse_challenge(response)["error"] + end + + test "an Authorization header with invalid UTF-8 bytes answers 401, not 500" do + the_transport = transport + header = "Bearer alice\xFFtoken".dup.force_encoding(Encoding::UTF_8) + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => header }, + initialize_body, + ) + + response = the_transport.handle_request(request) + + assert_equal 401, response[0] + assert_equal "invalid_token", parse_challenge(response)["error"] + end + + test "a Middleware-wrapped transport carries the scope_matcher into require_scopes!" do + @server.define_tool(name: "admin_tool") do |server_context:| + server_context.require_scopes!("mcp:admin") + Tool::Response.new([{ type: "text", text: "ok" }]) + end + @verifier = StubVerifier.new( + "root-token" => access_token("root-token", subject: "root", client_id: "client-r", scopes: ["mcp:all"]), + ) + matcher = ->(required, granted) { granted.include?("mcp:all") || granted.include?(required) } + app = OAuth::Middleware.new( + transport_without_oauth(enable_json_response: true), + token_verifier: @verifier, + required_scopes: ["mcp:tools"], + resource_metadata_url: RESOURCE_METADATA_URL, + scope_matcher: matcher, + ) + + init_response = app.call(initialize_request(token: "root-token").env) + + assert_equal 200, init_response[0] + + session_id = init_response[1]["mcp-session-id"] + body = { + jsonrpc: "2.0", + method: "tools/call", + id: "admin-1", + params: { name: "admin_tool", arguments: {} }, + }.to_json + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_AUTHORIZATION" => "Bearer root-token", + }, + body, + ) + + response = app.call(request.env) + + assert_equal 200, response[0] + + parsed = JSON.parse(response[2][0]) + + refute parsed.key?("error") + assert_equal "ok", parsed.dig("result", "content", 0, "text") + end + + test "a GET stream is closed once the token that opened it expires, and the session survives" do + expired = Time.now.to_i - 1 + @verifier = StubVerifier.new( + "stale-token" => access_token("stale-token", subject: "alice", client_id: "client-a", expires_at: expired), + "fresh-token" => access_token("fresh-token", subject: "alice", client_id: "client-a"), + ) + the_transport = transport + session_id = initialize_session(the_transport, token: "fresh-token") + + response = the_transport.handle_request(get_request(session_id, token: "stale-token")) + + assert_equal 200, response[0] + + stream = TestStream.new + response[2].call(stream) + + assert wait_until { stream.closed? }, "the stream was not closed after its token expired" + + # The session is intact: its requests are verified on their own, and the stream can be reopened. + assert_equal 200, the_transport.handle_request(ping_request(session_id, token: "fresh-token"))[0] + assert_equal 200, the_transport.handle_request(get_request(session_id, token: "fresh-token"))[0] + end + + test "a GET stream stays open while its token is valid" do + the_transport = transport + session_id = initialize_session(the_transport, token: "alice-token") + + response = the_transport.handle_request(get_request(session_id, token: "alice-token")) + stream = TestStream.new + response[2].call(stream) + + refute wait_until(timeout: 0.1) { stream.closed? }, "the stream was closed although its token is valid" + end + + test "a subscriptions/listen stream is closed at the keepalive once its token expires" do + expired = Time.now.to_i - 1 + @verifier = StubVerifier.new( + "stale-token" => access_token("stale-token", subject: "alice", client_id: "client-a", expires_at: expired), + ) + the_transport = transport(listen_keepalive_interval: 0.01) + + response = the_transport.handle_request(modern_rack_request( + modern_body("subscriptions/listen", { notifications: { toolsListChanged: true } }), + headers: { "HTTP_AUTHORIZATION" => "Bearer stale-token" }, + )) + + assert_equal 200, response[0] + + stream = TestStream.new + response[2].call(stream) + + assert wait_until { stream.closed? }, "the listen stream was not closed after its token expired" + end + + test "a token without an expiry still bounds the stream by max_stream_lifetime" do + # `exp` is optional in an RFC 7662 introspection response, so without the cap such a stream would + # outlive every later token check. + token = access_token("no-exp", subject: "alice", client_id: "client-a") + the_transport = transport(max_stream_lifetime: 60) + + deadline = the_transport.send(:stream_token_expiry, token) + + assert_in_delta(Time.now.to_i + 60, deadline, 1) + end + + test "the token expiry wins when it comes before max_stream_lifetime" do + expires_at = Time.now.to_i + 5 + token = access_token("short", subject: "alice", client_id: "client-a", expires_at: expires_at) + the_transport = transport(max_stream_lifetime: 3600) + + assert_equal(expires_at, the_transport.send(:stream_token_expiry, token)) + end + + test "max_stream_lifetime nil leaves an expiry-less token unbounded" do + token = access_token("no-exp", subject: "alice", client_id: "client-a") + the_transport = transport(max_stream_lifetime: nil) + + assert_nil(the_transport.send(:stream_token_expiry, token)) + end + + test "a stream opened without a token is never capped" do + the_transport = transport(max_stream_lifetime: 60) + + assert_nil(the_transport.send(:stream_token_expiry, nil)) + end + + test "max_stream_lifetime must be a positive number or nil" do + assert_raises(ArgumentError) { transport(max_stream_lifetime: 0) } + assert_raises(ArgumentError) { transport(max_stream_lifetime: -1) } + end + + test "an expiry that is not a number is ignored while the stream is set up, leaving the cap" do + # A custom verifier breaking the `AccessToken` contract must not raise on the request path. + token = access_token("bad-exp", subject: "alice", client_id: "client-a", expires_at: "1234") + the_transport = transport(max_stream_lifetime: 60) + + deadline = the_transport.send(:stream_token_expiry, token) + + assert_in_delta(Time.now.to_i + 60, deadline, 1) + end + + private + + def access_token(token, subject:, client_id:, scopes: ["mcp:tools"], expires_at: nil, issuer: nil) + OAuth::AccessToken.new( + token: token, + subject: subject, + client_id: client_id, + scopes: scopes, + expires_at: expires_at, + issuer: issuer, + ) + end + + def get_request(session_id, token:) + create_rack_request( + "GET", + "/", + { + "HTTP_ACCEPT" => "text/event-stream", + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_AUTHORIZATION" => "Bearer #{token}", + }, + ) + end + + # Polls the block until it is truthy or the timeout elapses; returns the final outcome. + def wait_until(timeout: 2) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + loop do + return true if yield + return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline + + sleep(0.005) + end + end + + def transport(**options) + defaults = { + listen_keepalive_interval: nil, + token_verifier: @verifier, + required_scopes: ["mcp:tools"], + resource_metadata_url: RESOURCE_METADATA_URL, + } + built = StreamableHTTPTransport.new(@server, **defaults.merge(options)) + @transports << built + built + end + + def transport_without_oauth(**options) + built = StreamableHTTPTransport.new(@server, listen_keepalive_interval: nil, **options) + @transports << built + built + end + + def initialize_body + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json + end + + def initialize_request(token: nil) + headers = { "CONTENT_TYPE" => "application/json" } + headers["HTTP_AUTHORIZATION"] = "Bearer #{token}" if token + create_rack_request("POST", "/", headers, initialize_body) + end + + def initialize_session(the_transport, token: nil) + response = the_transport.handle_request(initialize_request(token: token)) + + assert_equal 200, response[0] + + response[1]["mcp-session-id"] + end + + def tool_call_request(session_id, token: nil) + headers = { "CONTENT_TYPE" => "application/json" } + headers["HTTP_MCP_SESSION_ID"] = session_id if session_id + headers["HTTP_AUTHORIZATION"] = "Bearer #{token}" if token + body = { + jsonrpc: "2.0", + method: "tools/call", + id: "call-1", + params: { name: "whoami", arguments: {} }, + }.to_json + create_rack_request("POST", "/", headers, body) + end + + def ping_request(session_id, token: nil) + headers = { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id } + headers["HTTP_AUTHORIZATION"] = "Bearer #{token}" if token + create_rack_request("POST", "/", headers, { jsonrpc: "2.0", method: "ping", id: "ping-1" }.to_json) + end + + def modern_body(method, params) + { + jsonrpc: "2.0", + method: method, + id: 1, + params: params.merge( + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + }, + ), + }.to_json + end + + def modern_rack_request(body_json, headers: {}) + parsed = JSON.parse(body_json) + env_headers = { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28", + "HTTP_MCP_METHOD" => parsed["method"], + } + name = parsed.dig("params", "name") || parsed.dig("params", "uri") + env_headers["HTTP_MCP_NAME"] = name if name + create_rack_request("POST", "/", env_headers.merge(headers), body_json) + end + + def parse_challenge(response) + MCP::Client::OAuth::Discovery.parse_www_authenticate(response[1]["www-authenticate"]) + end + + def create_rack_request(method, path, headers, body = nil) + default_accept = case method + when "POST" + { "HTTP_ACCEPT" => "application/json, text/event-stream" } + when "GET" + { "HTTP_ACCEPT" => "text/event-stream" } + else + {} + end + + env = { + "REQUEST_METHOD" => method, + "PATH_INFO" => path, + "rack.input" => StringIO.new(body.to_s), + }.merge(default_accept).merge(headers) + + Rack::Request.new(env) + end + end + end + end +end diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index bf032b2f..e1f40fa9 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -1537,6 +1537,23 @@ def string end end + test "GET stream keepalive detects a dead peer on a session opened without a token" do + session_id = initialize_test_session + dead_peer = Object.new + dead_peer.define_singleton_method(:write) { |_data| raise Errno::ECONNRESET } + dead_peer.define_singleton_method(:close) {} + # The keepalive tick is a fixed 30 seconds; skipping the wait lets the first ping run at once. + @transport.stubs(:sleep) + + response = @transport.handle_request(create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session_id })) + response[2].call(dead_peer) + + # Only the keepalive thread notices the dead peer; without it the session lingers until the idle timeout. + wait_until { !@transport.instance_variable_get(:@sessions).key?(session_id) } + + refute(@transport.instance_variable_get(:@sessions).key?(session_id)) + end + test "responds with 405 for unsupported methods" do request = create_rack_request( "PUT", diff --git a/test/mcp/server_context_test.rb b/test/mcp/server_context_test.rb index 10089c58..0cc38277 100644 --- a/test/mcp/server_context_test.rb +++ b/test/mcp/server_context_test.rb @@ -1101,6 +1101,74 @@ def template(args, server_context:) response[:result][:messages][0][:content][:text] end + test "ServerContext#auth_info exposes the verified access token" do + access_token = Server::OAuth::AccessToken.new(token: "abc", subject: "alice", scopes: ["mcp:tools"]) + progress = Progress.new(notification_target: mock, progress_token: nil) + + server_context = ServerContext.new(nil, progress: progress, notification_target: mock, auth_info: access_token) + + assert_same access_token, server_context.auth_info + assert_predicate server_context, :authenticated? + end + + test "ServerContext#auth_info is nil and authenticated? false without a token" do + progress = Progress.new(notification_target: mock, progress_token: nil) + + server_context = ServerContext.new(nil, progress: progress, notification_target: mock) + + assert_nil server_context.auth_info + refute_predicate server_context, :authenticated? + end + + test "ServerContext#require_scopes! passes when the token carries every scope" do + access_token = Server::OAuth::AccessToken.new(token: "abc", scopes: ["mcp:tools", "mcp:admin"]) + progress = Progress.new(notification_target: mock, progress_token: nil) + + server_context = ServerContext.new(nil, progress: progress, notification_target: mock, auth_info: access_token) + + assert_nil server_context.require_scopes!("mcp:tools", "mcp:admin") + end + + test "ServerContext#require_scopes! honors the scope matcher attached to the token" do + matcher = ->(required, granted) { granted.include?("mcp:all") || granted.include?(required) } + access_token = Server::OAuth::AccessToken.new(token: "abc", scopes: ["mcp:all"]).with_scope_matcher(matcher) + progress = Progress.new(notification_target: mock, progress_token: nil) + + server_context = ServerContext.new(nil, progress: progress, notification_target: mock, auth_info: access_token) + + assert_nil server_context.require_scopes!("mcp:tools", "mcp:admin") + end + + test "ServerContext#require_scopes! raises naming the missing scopes" do + access_token = Server::OAuth::AccessToken.new(token: "abc", scopes: ["mcp:tools"]) + progress = Progress.new(notification_target: mock, progress_token: nil) + + server_context = ServerContext.new(nil, progress: progress, notification_target: mock, auth_info: access_token) + + error = assert_raises(Server::OAuth::InsufficientScopeError) do + server_context.require_scopes!("mcp:admin") + end + + assert_equal "Token is missing required scopes: mcp:admin", error.message + assert_equal ["mcp:admin"], error.required_scopes + end + + test "ServerContext#require_scopes! raises when the request is unauthenticated" do + progress = Progress.new(notification_target: mock, progress_token: nil) + + server_context = ServerContext.new(nil, progress: progress, notification_target: mock) + + assert_raises(Server::OAuth::InsufficientScopeError) { server_context.require_scopes!("mcp:tools") } + end + + test "ServerContext#require_scopes! requires at least one scope" do + progress = Progress.new(notification_target: mock, progress_token: nil) + + server_context = ServerContext.new(nil, progress: progress, notification_target: mock) + + assert_raises(ArgumentError) { server_context.require_scopes! } + end + private def build_modern_server_context(notification_target, log_level: nil) diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 80162e5b..f6806111 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -4539,6 +4539,98 @@ def server_context refute response[:result].key?(:cacheScope) end + test "handle threads auth_info to tool handlers and the Hash context" do + observed = {} + server = Server.new(name: "auth_test_server", server_context: { user: "u1" }) + server.define_tool(name: "auth_echo") do |server_context:| + observed[:auth_info] = server_context.auth_info + observed[:context_key] = server_context[:auth_info] + Tool::Response.new([{ type: "text", text: "ok" }]) + end + access_token = Server::OAuth::AccessToken.new(token: "abc", subject: "alice") + + request = { + jsonrpc: "2.0", + method: Methods::TOOLS_CALL, + id: 1, + params: { name: "auth_echo", arguments: {} }, + } + response = server.handle(request, auth_info: access_token) + + assert response[:result] + assert_same access_token, observed[:auth_info] + assert_same access_token, observed[:context_key] + end + + test "handle leaves the Hash context untouched when no auth_info is given" do + observed = {} + server = Server.new(name: "auth_test_server", server_context: { user: "u1" }) + server.define_tool(name: "auth_echo") do |server_context:| + observed[:auth_info] = server_context.auth_info + observed[:context_key] = server_context[:auth_info] + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + request = { + jsonrpc: "2.0", + method: Methods::TOOLS_CALL, + id: 1, + params: { name: "auth_echo", arguments: {} }, + } + response = server.handle(request) + + assert response[:result] + assert_nil observed[:auth_info] + assert_nil observed[:context_key] + end + + test "ServerSession#handle forwards auth_info alongside a positional request" do + observed = {} + server = Server.new(name: "auth_test_server") + server.define_tool(name: "auth_echo") do |server_context:| + observed[:auth_info] = server_context.auth_info + Tool::Response.new([{ type: "text", text: "ok" }]) + end + session = ServerSession.new(server: server, transport: mock) + access_token = Server::OAuth::AccessToken.new(token: "abc", subject: "alice") + + request = { + jsonrpc: "2.0", + method: Methods::TOOLS_CALL, + id: 1, + params: { name: "auth_echo", arguments: {} }, + } + response = session.handle(request, auth_info: access_token) + + assert response[:result] + assert_same access_token, observed[:auth_info] + end + + test "ServerSession#handle does not trust an auth_info key splatted from a request body" do + observed = { called: false } + server = Server.new(name: "auth_test_server") + server.define_tool(name: "auth_echo") do |server_context:| + observed[:called] = true + observed[:auth_info] = server_context.auth_info + Tool::Response.new([{ type: "text", text: "ok" }]) + end + session = ServerSession.new(server: server, transport: mock) + + # An embedder splatting attacker-authored JSON (`session.handle(**parsed_body)`) must not let + # a top-level `auth_info` member become a verified credential. + response = session.handle( + jsonrpc: "2.0", + method: Methods::TOOLS_CALL, + id: 1, + params: { name: "auth_echo", arguments: {} }, + auth_info: { subject: "forged" }, + ) + + assert response[:result] + assert observed[:called] + assert_nil observed[:auth_info] + end + private # Builds a request carrying the SEP-2575 modern `_meta` envelope.