Skip to content

[Server] Narrow authorization to the resource server role - #532

Merged
chr-hertel merged 23 commits into
modelcontextprotocol:mainfrom
chr-hertel:auth-resource-server-only
Oct 7, 2026
Merged

chr-hertel merged 23 commits into
modelcontextprotocol:mainfrom
chr-hertel:auth-resource-server-only

Conversation

@chr-hertel

Copy link
Copy Markdown
Member

Narrows the auth layer to the resource server role, see ADR 0002 - the proxy and client registration couldn't be hardened without becoming an authorization server, and the 2026-07-28 spec doesn't need them since the metadata points clients at the AS directly.

[BC Break] across Mcp\Server\Transport\Http\OAuth & the auth middleware, see CHANGELOG under 0.9.0.


  • Remove OAuthProxyMiddleware, ClientRegistrationMiddleware, ClientRegistrarInterface - DCR is deprecated in 2026-07-28 and the proxied flow conflicts with RFC 9207 iss validation and CIMD
  • Replace OAuthRequestMetaMiddleware with RequestContext::getAccessToken() - handlers get the validated token from the request context instead of _meta.oauth, and the body isn't re-encoded anymore
  • ScopePolicy on AuthorizationMiddleware for per-method/per-tool scopes incl. hierarchy => 403 insufficient_scope
  • JwtTokenValidator is final, uses firebase's CachedKeySet (refetch on unknown kid), enforces the alg allowlist, optional typ/leeway
  • ProtectedResourceMetadata requires resource, derives the metadata path & challenge URL from it, https except loopback
  • WWW-Authenticate exposed via CORS
  • Both OAuth examples are plain resource servers now

Checked Drupal's mcp_server/mcp_server_oauth, Sulu's SuluMcpBundle, API Platform, Shopware and symfony/mcp-bundle - none of them uses the removed or changed classes.

Not run against a live Keycloak or Entra yet - the examples need a manual check.

cc @Nyholm @CodeWithKyrian @soyuka WDYT?

@chr-hertel chr-hertel added Server Issues & PRs related to the Server component breaking change Breaking the Backwards Compatibility Promise auth Issues and PRs related to Authentication / OAuth labels Oct 5, 2026
private function escapeHeaderValue(string $value): string
{
return str_replace(['\\', '"'], ['\\\\', '\\"'], $value);
return str_replace(['\\', '"'], ['\\\\', '\\"'], preg_replace('/[\x00-\x1F\x7F]/', '', $value) ?? '');

@soyuka soyuka Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe worth a comment ? (for human readers :p)

}
}

return array_values(array_unique($required));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the readability but not a huge fan of the algorithm's complexity, imo a simple foreach loop with an indexed array would lower the complexity behind $required here. (nit)

Comment thread src/Server/Transport/Http/OAuth/ScopePolicy.php
Comment thread src/Server/Transport/Http/OAuth/ScopePolicy.php Outdated
soyuka
soyuka previously approved these changes Oct 6, 2026

@soyuka soyuka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice refactoring/cleanup!

@wachterjohannes

Copy link
Copy Markdown
Contributor

Thanks Chris. The narrowing makes sense to me. I checked SuluMcpBundle and symfony/mcp-bundle against the diff. Neither uses the removed or changed classes.

Needs a fix before merge

  1. An unknown kid returns a 500 on the fromIssuer() path. CachedKeySet::offsetGet throws \OutOfBoundsException. The catch in JwtTokenValidator.php:129 does not cover it. The same happens after a key rotation once the refetch limit is used up. An unreachable JWKS endpoint throws a PSR-18 exception that is not caught either. testRejectsUnknownKeyId uses a plain key array, so this path has no test. Please map these to 401 and add a test with a real CachedKeySet.
  2. Tokens without exp are accepted forever. php-jwt only checks exp when it is present. Please require a numeric exp.
  3. Keys without alg break. fromIssuer() passes $algorithms[0] as defaultAlg. With ['RS256', 'ES256'], EC keys without alg get tagged RS256. The CachedKeySet example in the docs passes no defaultAlg at all. I think Entra JWKS keys have no alg, so the Microsoft example may fail. I have not run it.
  4. The CHANGELOG misses some BC breaks. Protocol::processInput, BaseTransport::handleMessage and StreamableHttpTransport::handlePostRequest gained a parameter. Subclasses that override them with the old signature will fatal.

Smaller points

  • AccessToken::hasScope() ignores the implies hierarchy. The docs tell handlers to use it. A files:admin token passes the middleware but fails hasScope('files:write').
  • A non-Bearer scheme gets 400 invalid_request. RFC 6750 §3.1 asks for 401 without an error code, so the client sees no discovery challenge.
  • ProtectedResourceMetadata drops the query string of the resource. RFC 9728 §3.1 keeps it.
  • fromIssuer() does discovery at construction time. If the authorization server is down while the container builds, validation fails.
  • Missing tests: SecureUrl edge cases and 403 on batch requests.

Question on the token seam

RequestContext::getAccessToken() is only filled by the SDK's AuthorizationMiddleware. Both transports read the PSR-7 request attribute AccessToken::class. That is currently a docblock convention. Bundles that authenticate outside the SDK, like Sulu's, need a way to hand over a token. Could you document the attribute as public API? new AccessToken($scopes, $claims) is already public, so that would be enough.

Smoke test

I'm happy to run a smoke test against the Sulu MCP bundle once this is ready. Ping me when the findings are in.

* Remove OAuthProxyMiddleware, ClientRegistrationMiddleware and their interfaces (ADR 0002)
* Replace OAuthRequestMetaMiddleware with RequestContext::getAccessToken()
* Add ScopePolicy to AuthorizationMiddleware for 403 insufficient_scope step-up
* JwtTokenValidator: final, CachedKeySet with refetch on unknown kid, alg allowlist, token type, leeway
* ProtectedResourceMetadata: require resource, derive metadata path and challenge URL from it, enforce https
* Expose WWW-Authenticate via CORS
Keys without alg are tagged with the token's algorithm instead of the first allowed one.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

URL validation, metadata routing, scope validation, and global JWT leeway handling contain unresolved correctness and security issues.

5 open findings
What changed in this PR

Narrows OAuth support to a resource-server-only model and adds request-scoped token and scope enforcement.

Changes:

  • Removes OAuth proxy, dynamic registration, and legacy discovery abstractions.
  • Adds AccessToken, ScopePolicy, hardened JWT validation, and derived resource metadata.
  • Updates OAuth examples, tests, documentation, CORS, and ADRs.
File Description
tests/​Unit/​Server/​Transport/​StreamableHttpTransportTest.php Updates CORS header expectations.
tests/​Unit/​Server/​Transport/​Http/​OAuth/​StrictOidcDiscoveryMetadataPolicyTest.php Removes obsolete policy tests.
tests/​Unit/​Server/​Transport/​Http/​OAuth/​SecureUrlTest.php Tests secure URL validation.
tests/​Unit/​Server/​Transport/​Http/​OAuth/​ScopePolicyTest.php Tests scope policies and hierarchies.
tests/​Unit/​Server/​Transport/​Http/​OAuth/​ProtectedResourceMetadataTest.php Tests metadata validation and locations.
tests/​Unit/​Server/​Transport/​Http/​OAuth/​ProtectedResourceMetadataHandlerTest.php Updates metadata handler tests.
tests/​Unit/​Server/​Transport/​Http/​OAuth/​OidcDiscoveryTest.php Reworks JWKS discovery tests.
tests/​Unit/​Server/​Transport/​Http/​OAuth/​LenientOidcDiscoveryMetadataPolicyTest.php Removes obsolete policy tests.
tests/​Unit/​Server/​Transport/​Http/​OAuth/​JwksProviderTest.php Removes obsolete provider tests.
tests/​Unit/​Server/​Transport/​Http/​Middleware/​ProtectedResourceMetadataMiddlewareTest.php Tests derived metadata routing.
tests/​Unit/​Server/​Transport/​Http/​Middleware/​OAuthRequestMetaMiddlewareTest.php Removes legacy propagation tests.
tests/​Unit/​Server/​Transport/​Http/​Middleware/​OAuthProxyMiddlewareTest.php Removes proxy tests.
tests/​Unit/​Server/​Transport/​Http/​Middleware/​CorsMiddlewareTest.php Verifies exposed authentication header.
tests/​Unit/​Server/​Transport/​Http/​Middleware/​ClientRegistrationMiddlewareTest.php Removes registration tests.
tests/​Unit/​Server/​Transport/​Http/​Middleware/​AuthorizationMiddlewareTest.php Tests token and scope enforcement.
tests/​Unit/​Server/​Transport/​AccessTokenFlowTest.php Tests end-to-end token propagation.
tests/​Unit/​Server/​Authorization/​AccessTokenTest.php Tests the access-token value object.
src/​Server/​Transport/​TransportInterface.php Adds tokens to message callbacks.
src/​Server/​Transport/​StreamableHttpTransport.php Propagates validated tokens.
src/​Server/​Transport/​StatelessHttpTransport.php Propagates tokens statelessly.
src/​Server/​Transport/​ManagesTransportCallbacks.php Updates callback signatures.
src/​Server/​Transport/​Http/​OAuth/​StrictOidcDiscoveryMetadataPolicy.php Removes strict discovery policy.
src/​Server/​Transport/​Http/​OAuth/​SecureUrl.php Adds secure URL parsing.
src/​Server/​Transport/​Http/​OAuth/​Scopes.php Adds scope normalization.
src/​Server/​Transport/​Http/​OAuth/​ScopePolicy.php Adds request scope policies.
src/​Server/​Transport/​Http/​OAuth/​ProtectedResourceMetadata.php Derives and validates resource metadata.
src/​Server/​Transport/​Http/​OAuth/​OidcDiscoveryMetadataPolicyInterface.php Removes policy interface.
src/​Server/​Transport/​Http/​OAuth/​OidcDiscoveryInterface.php Removes discovery interface.
src/​Server/​Transport/​Http/​OAuth/​OidcDiscovery.php Narrows discovery to JWKS resolution.
src/​Server/​Transport/​Http/​OAuth/​LenientOidcDiscoveryMetadataPolicy.php Removes lenient policy.
src/​Server/​Transport/​Http/​OAuth/​JwtTokenValidator.php Hardens JWT validation and caching.
src/​Server/​Transport/​Http/​OAuth/​JwksProviderInterface.php Removes JWKS provider contract.
src/​Server/​Transport/​Http/​OAuth/​JwksProvider.php Removes legacy JWKS provider.
src/​Server/​Transport/​Http/​OAuth/​ClientRegistrarInterface.php Removes registration contract.
src/​Server/​Transport/​Http/​OAuth/​AuthorizationResult.php Returns validated access tokens.
src/​Server/​Transport/​Http/​Middleware/​ProtectedResourceMetadataMiddleware.php Routes derived metadata endpoints.
src/​Server/​Transport/​Http/​Middleware/​OAuthRequestMetaMiddleware.php Removes body metadata injection.
src/​Server/​Transport/​Http/​Middleware/​OAuthProxyMiddleware.php Removes authorization proxying.
src/​Server/​Transport/​Http/​Middleware/​CorsMiddleware.php Exposes WWW-Authenticate.
src/​Server/​Transport/​Http/​Middleware/​ClientRegistrationMiddleware.php Removes dynamic registration.
src/​Server/​Transport/​Http/​Middleware/​AuthorizationMiddleware.php Enforces bearer tokens and scopes.
src/​Server/​Transport/​BaseTransport.php Passes tokens through callbacks.
src/​Server/​Stateless/​StatelessProtocol.php Adds tokens to stateless contexts.
src/​Server/​RequestContext.php Exposes validated access tokens.
src/​Server/​Protocol.php Associates tokens with requests.
src/​Server/​Authorization/​AccessToken.php Adds the token value object.
src/​Exception/​ClientRegistrationException.php Removes registration exception.
phpunit.xml.dist Removes deleted example tests.
examples/​server/​oauth-microsoft/​tests/​Unit/​MicrosoftJwtTokenValidatorTest.php Removes custom validator tests.
examples/​server/​oauth-microsoft/​server.php Converts Entra example to a resource server.
examples/​server/​oauth-microsoft/​README.md Documents the revised Entra setup.
examples/​server/​oauth-microsoft/​MicrosoftJwtTokenValidator.php Removes insecure custom validator.
examples/​server/​oauth-microsoft/​McpElements.php Reads claims through AccessToken.
examples/​server/​oauth-microsoft/​env.example Removes obsolete secrets.
examples/​server/​oauth-microsoft/​docker-compose.yml Adds cache storage.
examples/​server/​oauth-keycloak/​server.php Adds resource-bound validation and scopes.
examples/​server/​oauth-keycloak/​README.md Documents revised Keycloak behavior.
examples/​server/​oauth-keycloak/​McpElements.php Reads claims through AccessToken.
examples/​server/​oauth-keycloak/​keycloak/​mcp-realm.json Binds tokens to the resource URI.
examples/​server/​oauth-keycloak/​docker-compose.yml Adds cache storage.
composer.json Suggests JWT and PSR-6 dependencies.
CHANGELOG.md Records authorization BC breaks.
adr/​README.md Lists the new ADR.
adr/​0002-resource-server-only.md Establishes resource-server-only scope.
adr/​0001-oauth-authorization-server-out-of-scope.md Records amendment by ADR 0002.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Server/Transport/Http/OAuth/JwtTokenValidator.php Outdated
Comment thread src/Server/Transport/Http/OAuth/SecureUrl.php
Comment thread src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php Outdated
Comment thread src/Server/Transport/Http/OAuth/ProtectedResourceMetadata.php Outdated
Comment thread src/Server/Transport/Http/OAuth/Scopes.php Outdated
@chr-hertel
chr-hertel force-pushed the auth-resource-server-only branch from 8be5fe1 to 0082fb1 Compare October 7, 2026 21:19
@chr-hertel
chr-hertel merged commit 3ea9810 into modelcontextprotocol:main Oct 7, 2026
28 checks passed
@chr-hertel
chr-hertel deleted the auth-resource-server-only branch October 7, 2026 22:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auth Issues and PRs related to Authentication / OAuth breaking change Breaking the Backwards Compatibility Promise Server Issues & PRs related to the Server component

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants