Commit 1d589ea
committed
Add OAuth 2.1 resource server support
## Motivation and Context
Per the MCP authorization specification, an HTTP-based MCP server acts as an OAuth 2.1 resource server,
but the SDK offered no server-side pieces for that role even though its client side already runs
the full authorization flow. This lands the self-contained primitives: bearer token verification
(JWT and RFC 7662 introspection verifiers plus a custom-verifier contract), RFC 6750 `WWW-Authenticate` challenges,
RFC 9728 Protected Resource Metadata serving, and a Rack middleware composing them. Token issuance is
intentionally out of scope; deployments bring their own authorization server.
The document is served by `ProtectedResourceMetadataMiddleware`, used at the top of the Rack stack, which answers its
well-known path itself and passes everything else down, the way the TypeScript SDK's metadata router and the Python SDK's
metadata routes do, so no mount at a hand-written path is needed.
The middleware accepts the document class alone, so nothing that merely names a path is ever published as the document.
### Why token issuance stays out
Leaving the authorization server role out follows the specification's own move: the 2025-03-26 revision had
the MCP server issue tokens itself, 2025-06-18 turned it into a resource server that discovers
an external authorization server through RFC 9728, and the reference SDKs went the same way
(the TypeScript SDK froze its authorization server helpers in a legacy package for v2 and points new servers at
a dedicated identity provider; the Python SDK keeps its embedded provider for compatibility only and tells
new servers not to use it).
### Token verification
The RFC 8707 audience check is mandatory on both built-in verifiers, JWT tokens must carry an expiry,
JWKS and introspection response bodies are read under a byte cap, refreshing a JWKS cache no longer blocks
other verifications on network I/O, oversized tokens are rejected before verification,
and challenges answering credential-less requests omit the error code per RFC 6750.
Scope checks accept a pluggable matcher because the 2026-07-28 revision requires honoring scope hierarchies.
The matcher rides on the verified token, so `require_scopes!` and `scope?` inside handlers judge a hierarchy
the same way the endpoint gate does.
A custom verifier's own result object, which carries no scopes list, keeps its own `scope?` judgement at the gate even
when a matcher is configured, and `required_scopes: nil` imposes no scope requirement, the same as leaving the option out.
`JWTVerifier` refuses at construction what would otherwise weaken it silently: a missing document as its source of `iss` and `aud`
(the jwt gem skips the check whose expected value is nil), `none` in the algorithm allowlist, an allowlist mixing HMAC
with asymmetric algorithms, and a String key for asymmetric algorithms, so a token signed with the public key bytes
as an HMAC secret never verifies. `ProtectedResourceMetadata` rejects an `extra` member that would override
a validated one such as `resource` or `authorization_servers`.
Both built-in verifiers take that document as `resource_metadata:` and read the expected `aud` from its `resource` and,
for `JWTVerifier`, the expected `iss` from its authorization server, so neither value is configured a second time and
neither can drift from what the server publishes; each verifier serves one authorization server, and `JWTVerifier`
refuses a document naming several rather than advertise a server whose tokens it would reject.
A stand-in document is held to the shapes the class guarantees, a String `resource` and an Array of String servers,
so a wrong one fails by member name at construction.
The `jwt` gem stays out of the gemspec: `JWTVerifier` lazily requires it, so only deployments that verify JWTs locally need it.
### Transport enforcement and session binding
The Streamable HTTP transport gains a `token_verifier` option that enforces bearer authentication on every legacy
and modern route ahead of any body parsing, with DNS rebinding checks still running first
so that an unauthenticated probe never triggers verifier work. The verified token travels to handlers as
`server_context.auth_info`, giving tools, prompts, resources, and completion handlers an authenticated identity to act on,
and `server_context.require_scopes!` rejects an operation whose token lacks a scope, naming the missing scopes in the JSON-RPC error.
Authentication is checked per HTTP request and never cached on the session, and each session is additionally bound to
the token identity that initialized it, so a leaked session ID cannot be driven with another principal's token even when
a custom `session_request_validator` is permissive. Composition with the OAuth Rack middleware keeps working:
the transport falls back to the token the middleware stored in the Rack env.
The middleware answers verification failures alone: a failure inside the wrapped app reaches that app's own error handling.
The session binding covers the token's issuer as well, so a subject and client id pair repeated across identity providers behind
a custom verifier does not collide, and a mismatch answers 404 exactly like an unknown session does, so a guessed session id
is not confirmed to exist. The gate runs before the session's idle timer is touched, so a rejected request cannot keep
someone else's session alive.
`ServerSession#handle` honors its `auth_info` keyword only alongside a positional request. In the bare-keyword calling style
every keyword belongs to the attacker-authored request body, so a stray `auth_info` member is folded back into the request
instead of being trusted as a verified credential.
### Hardening
Four further hardening measures are folded in. A stream whose token expires while it stays open
(the legacy GET stream and the `subscriptions/listen` stream alike) is closed at its next keepalive check instead of
outliving its credential. A malformed `exp` member from the authorization server is rejected as an invalid token instead of
raising on comparison. Challenge parameters are scrubbed of invalid byte sequences, so a custom verifier's message
cannot turn the 401 into a 500. The example refuses to boot without either a JWKS configuration or an explicit `DEV_MODE=1` opt-in,
and its HS256 secret is generated per boot instead of shipping with the repository.
It also refuses `JWKS_URI` together with `DEV_MODE=1`, since the demo token it prints is signed with the development secret.
`AccessToken#inspect` omits the raw token as `to_h` already did, and a stream retains only the expiry of its token, never the credential.
A verifier's other `OAuth::Error`s answer 401 like an invalid token instead of escaping the Rack call,
and the bearer header is parsed as bytes, so an invalid byte sequence in it is rejected instead of raising.
A JWK handed to `JWTVerifier` as `key:` is refused at construction, a failed unknown-`kid` refetch starts the cooldown
all the same, and refresh failures of every kind, connection errors included, fall back to the cached keys for at most `jwks_max_stale:` seconds past
the TTL (3600 by default) before verification fails. `IntrospectionVerifier` form-encodes
its client credentials per RFC 6749 Section 2.3.1 and takes its `aud` from the document like `JWTVerifier` does.
The stale bound holds under concurrent traffic as well: a request that finds another thread refreshing the key set waits for
that refresh once the cached keys are past `jwks_max_stale:`, instead of serving them, and a refresh that failed is not retried
on every request while the endpoint stays down. A JWKS document without a `keys` array never replaces a good cache, and
a malformed HTTP response from the endpoint is treated like any other refresh failure. A JWT whose `exp` or `nbf` claim is not
a number is rejected as an invalid token before the jwt gem compares it with the clock, an introspection `exp` that parses
to infinity is rejected the same way, and both verifiers refuse at construction a `leeway:`, cache TTL, stale bound, or
timeout that is negative or not finite, and a timeout of zero.
A `jwks_max_stale:` of nil is refused there too, since the bound arithmetic has no meaning for it. A key set the jwt gem
cannot load, a member that is not a JSON object or one it rejects outright, counts as a failed refresh instead of replacing
the cache: the gem refuses such a set as a whole and never asks for a refetch over it, so the cache would otherwise fail every
token until the TTL lapsed. The key set and its fetch time are judged as one snapshot, so a refresh another thread completes
in the meantime cannot lend its fetch time to a retired set, and a thread that waited for that refresh serves its result when
the fetch time moved and judges an unchanged set by the bound.
`ProtectedResourceMetadata` drops `offline_access` from `scopes_supported`, which the specification tells
a protected resource not to advertise and which the challenges already leave out, omits the member when nothing
is left rather than advertising an empty list, and requires an Array so a bare String cannot pass for one scope.
`OAuth::Error` defaults its `error_code` to `invalid_token`, so a custom verifier can subclass it and raise
without knowing about the keyword. An authenticated SSE stream is closed after `max_stream_lifetime:` seconds,
30 minutes by default like `session_idle_timeout:`, whichever of that and the token's own expiry comes first:
`exp` is optional in an introspection response, and without the cap a stream opened with an expiry-less token
would outlive every later token check. An expiry that is not a number is ignored there instead of failing the stream.
### Documentation and example
Document the resource-server role on an Authorization page of the documentation site, and ship a runnable example protected by
the transport's built-in bearer enforcement. The example runs CORS ahead of authentication because a browser preflight carries no
`Authorization` header, and a 401 answered to the preflight would fail CORS closed for browser-based clients.
The session-ownership and modern-lifecycle notes on the transports page now account for the transport-level bearer enforcement instead of
deferring authorization wholly to the deploying application. The server overview's feature list gains the matching bullet.
The page also lists what the authorization server must be set up with and shows an introspection-backed verifier next to the JWT one.
The Authorization page also spells out two limits left to deployers: a token carrying neither `sub` nor `client_id` binds no session,
and introspection forwards unauthenticated traffic to the authorization server one for one, so rate limiting belongs ahead of the transport.
It also gives the token length and response body caps, notes that revocation reaches an open session only through introspection,
and marks the challenge parameters that depend on configuration.
It tells deployers to publish `resource:` without a trailing slash, the canonical form the specification prefers,
which the verifiers check `aud` against.
The page's advice to build the verifier and the document once now names the reason beyond the JWKS cache: a document
assembled per request from what the request says would hand the sender the `aud` and `iss` the verifier checks against.
## How Has This Been Tested?
The end-to-end test drives the whole stack with the SDK's own OAuth client: 401 challenge, Protected Resource Metadata discovery,
token grant, and an authenticated tool call observing the token.
Unit tests cover each primitive: the JWT and introspection verifiers, the challenge builder, the metadata document and middleware,
the authenticator, the middleware, and `AccessToken`. Transport tests cover bearer enforcement ahead of any body read on every route,
`subscriptions/listen` included, the session binding across POST, GET, and DELETE together with its issuer member, the 404 answer being
indistinguishable from an unknown session, the GET gate leaving the idle timer untouched, and streams closing at the keepalive once
their token has expired. The hardening has tests of its own: a malformed `exp` is rejected as an invalid token, challenges stay valid UTF-8 for
UTF-8 and binary messages alike, and `inspect` omits the token.
Constructor validation and the handler-side matcher are covered too: each rejected `JWTVerifier` configuration,
the reserved `extra` members, and a `require_scopes!` satisfied through the transport's matcher end to end.
A verifier raising its own `OAuth::Error` and an `Authorization` header with invalid bytes are covered through
the authenticator, the middleware, and the transport; the JWKS tests cover the refused JWK key, the cooldown after
a failed refetch, the connection-error fallback, and the stale bound; the introspection tests cover the encoded
credentials and the audience check; a middleware-wrapped transport carries the scope matcher into `require_scopes!`.
Further tests pin the dropped `offline_access` and the omitted empty member, the String `scopes_supported` rejection,
an `OAuth::Error` subclass raised without the keyword answering 401, and the stream cap: an expiry-less token bounded by it,
a nearer token expiry winning, `nil` removing it, a token-less stream never capped, a non-number expiry leaving only the cap,
and 0 or a negative value refused.
The JWKS tests also cover the stale bound during a concurrent refresh, within it and beyond it, the cooldown after a failed
TTL refresh, a document without a `keys` array leaving the cache intact, and a malformed HTTP response bridged by the cached keys;
a wrongly typed `exp` or `nbf` claim in a JWT, an infinite introspection `exp`, and negative, non-finite, zero, or non-numeric
options are rejected. A duck-typed verifier result is left to its own scope check under a matcher, `required_scopes: nil` imposes
no requirement, an error raised inside the wrapped app passes through the middleware, and a GET stream opened without a token
still runs the keepalive that detects a dropped peer.
A nil `jwks_max_stale:` is refused, a key set the gem cannot load leaves the cache intact and is an infrastructure error on
a cold start, and a retired snapshot is not served after another thread's refresh.
The middleware tests cover the document, preflight, and method handling at the well-known path, other paths passing down,
composition above a mounted endpoint, and the document requirement; the verifier tests cover the `iss` and `aud` taken from
the document, a document naming several authorization servers being refused, and non-documents being rejected.
A stand-in document with wrongly shaped members and a path-naming look-alike handed to the middleware are refused by name.
The example was booted in development mode and driven with curl through the metadata document, the bare 401 challenge,
an authenticated `initialize`, and a `whoami` tool call; without configuration it refuses to start.
## Breaking Changes
None. A transport constructed without `token_verifier` behaves as before: the ownership gate keeps answering Origin
and validator rejections with 403, the principal binding stays inactive without bearer authentication, and the `auth_info` keywords
on `Server#handle`, `Server#handle_json`, and `ServerSession#handle` are additive. The `jwt` gem is a test-group dependency only.1 parent 44af58c commit 1d589ea
42 files changed
Lines changed: 5311 additions & 90 deletions
File tree
- docs
- _client
- _server
- examples
- lib/mcp
- server
- oauth
- transports
- test/mcp
- server
- oauth
- transports
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
27 | 27 | | |
28 | 28 | | |
29 | 29 | | |
| 30 | + | |
30 | 31 | | |
31 | 32 | | |
32 | 33 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
9 | | - | |
| 9 | + | |
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
329 | 329 | | |
330 | 330 | | |
331 | 331 | | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
| 28 | + | |
| 29 | + | |
28 | 30 | | |
29 | 31 | | |
30 | 32 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
29 | 29 | | |
30 | 30 | | |
31 | 31 | | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
32 | 40 | | |
33 | 41 | | |
34 | 42 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
245 | 245 | | |
246 | 246 | | |
247 | 247 | | |
248 | | - | |
249 | | - | |
250 | | - | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
251 | 252 | | |
252 | 253 | | |
253 | 254 | | |
| |||
262 | 263 | | |
263 | 264 | | |
264 | 265 | | |
265 | | - | |
| 266 | + | |
266 | 267 | | |
267 | 268 | | |
268 | 269 | | |
| 270 | + | |
| 271 | + | |
269 | 272 | | |
270 | 273 | | |
271 | 274 | | |
272 | 275 | | |
273 | | - | |
| 276 | + | |
| 277 | + | |
274 | 278 | | |
275 | 279 | | |
276 | 280 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
16 | 16 | | |
17 | 17 | | |
18 | 18 | | |
| 19 | + | |
19 | 20 | | |
20 | 21 | | |
21 | 22 | | |
| |||
0 commit comments