[MCP] Resolve the token audience from the endpoint's owner - #2029
Conversation
The authenticator derived the resource from a path hardcoded here, so only this bundle's own MCP servers could be reached with an audience-bound token; an endpoint registered by another bundle never matched. It now resolves the most specific registered resource covering the request, and declines when none does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ej6YARj6HakERFuERFuDF9
There was a problem hiding this comment.
🟡 Changes recommended
Issuer-based discovery remains inconsistent, and query-bearing resources can select the wrong audience.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Moves MCP OAuth audience resolution to the protected-resource registry rather than a hardcoded Studio path.
Changes:
- Adds issuer-aware, segment-boundary resource resolution.
- Injects resolved audiences into the MCP authenticator.
- Adds unit coverage and dependency-injection wiring.
Assessment:
- Correct service boundary and no identified public API break.
- Incomplete across discovery call sites; query-bearing resources can also resolve ambiguously.
- Unit tests do not cover these end-to-end cases.
File summaries
| File | Description |
|---|---|
src/OAuth/Resolver/RequestResourceResolver.php |
Resolves the most specific resource. |
src/OAuth/Resolver/RequestResourceResolverInterface.php |
Defines the resolver contract. |
src/Security/Authenticator/Mcp/OAuthAccessTokenAuthenticator.php |
Uses the resolved audience. |
src/DependencyInjection/PimcoreStudioBackendExtension.php |
Injects the configured issuer. |
config/oauth.yaml |
Registers resolver services. |
tests/Unit/OAuth/Resolver/RequestResourceResolverTest.php |
Tests resource matching. |
tests/Unit/Security/Authenticator/Mcp/OAuthAccessTokenAuthenticatorTest.php |
Tests authenticator integration. |
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 2
- Review effort level: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // expressed the same way. Behind a proxy the request host is not the | ||
| // issuer, and comparing the two would refuse a correctly issued token. | ||
| $base = rtrim($this->issuer ?? $request->getSchemeAndHttpHost(), '/'); | ||
| $target = CanonicalUri::canonicalize($base . $request->getPathInfo()); |
There was a problem hiding this comment.
Fixed in 66703b5. You are right that ranking on strlen($resource->canonicalUri) made the choice a property of the identifier rather than the request, so a longer query string could outrank the resource that actually matched.
covers() is now coveredPathLength(), returning the matched path length (or -1), and the ranking uses that, so only the path participates. A resource carrying a query or fragment is refused outright: RFC 8707 §2 says a resource identifier must not have a fragment and should not have a query, and since the URI built here is origin + getPathInfo() such a resource can never be what the endpoint is. Matching it on path alone would only let it shadow the correct one. Two tests cover it: a query-bearing resource does not shadow the real one, and a query-bearing resource alone resolves to nothing.
| // Resources are registered under the issuer, so the request has to be | ||
| // expressed the same way. Behind a proxy the request host is not the | ||
| // issuer, and comparing the two would refuse a correctly issued token. | ||
| $base = rtrim($this->issuer ?? $request->getSchemeAndHttpHost(), '/'); |
There was a problem hiding this comment.
Confirmed and fixed in 66703b5. This PR did introduce the divergence: before it the authenticator also used the request host, so all three agreed (on the wrong value). Deriving the audience from the issuer without moving discovery left an unauthenticated client pointed at a metadata document that resolves to nothing.
McpAuthenticationEntryPoint and ProtectedResourceMetadataController now take the issuer and derive the same way ($this->issuer ?? $request->getSchemeAndHttpHost()), wired from %pimcore_studio_backend.oauth.issuer%. Two tests cover the proxied case end to end: the challenge emitted for a request on internal.local points at the issuer host, and the metadata document is found when the request arrives on a host other than the issuer.
The authenticator now holds a token to the resource registered for the endpoint being called, so an endpoint whose owner registers none does not accept OAuth. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ej6YARj6HakERFuERFuDF9
Deriving the audience from the issuer while the 401 challenge and the metadata lookup still used the request host sent clients to a document that resolved to nothing. Ranking on the whole identifier also let a long query outrank the resource that actually matched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ej6YARj6HakERFuERFuDF9
|



Second half of the split out of #2022, which is closed in favour of this and #2028. This one carries the MCP
side: how an endpoint behind the
pimcore_mcpfirewall gets the audience its token is checked against.The problem
OAuthAccessTokenAuthenticatorderived the resource URI from a path hardcoded in this bundle(
<host>/pimcore-mcp). That is wrong in three ways:/pimcore-mcpitself, and no protected resource isregistered for it, so with the audience enforced ([OAuth] Public resource-server contracts, an extensible scope catalogue, and resource-bound tokens #2028) an audience-bound token could never match.
proxy the two differ and a correctly issued token is refused at the endpoint it was issued for.
bundles too, and
/pimcore-mcp/agent/{group}from the Agent bundle could never match a hardcoded Studio path.The earlier attempt in #2022 fixed the first two by hardcoding
/pimcore-mcp/studio/<slug>instead, whichstill leaves any other bundle's endpoint unreachable.
What this does
Resolves the audience from whoever owns the endpoint. A new
RequestResourceResolverfinds the mostspecific registered protected resource covering the request, and the authenticator validates against that.
This bundle needs no per-consumer path knowledge: a bundle that registers its endpoint as a protected resource
works, which is the same contract Data Hub Simple REST already follows.
Matching is origin equality plus a path prefix that only matches on a segment boundary, the same rule a
standards-based client applies when deciding whether a resource covers an endpoint, so the server's check
agrees with what the client was told is allowed. Longest match wins, so a resource registered for one server
takes precedence over a broader one. The URI is built from the configured issuer when set.
When no registered resource covers the request the authenticator declines rather than guessing. Declining
leaves the rest of the chain intact, so PAT and session authentication are unaffected on endpoints that have
not opted in.
Compatibility
Nothing changes for a credential that is not a JWT-shaped bearer:
supports()still declines thepmcp_prefix, opaque PATs and session-bridge requests, so they never reach this code. An endpoint whose owner
registers no protected resource keeps working exactly as before on its other credentials, and simply does not
accept OAuth until it registers one. Registration is the switch.
Verified
Unit suite green (898 tests), PHPStan clean.
RequestResourceResolverTestcovers the Studio server case, anendpoint owned by another bundle, an unregistered endpoint, segment-boundary matching, longest-match
precedence, and issuer-versus-host derivation.
Follow-up
The Agent bundle registers no protected resource, so OAuth on
/pimcore-mcp/agent/*stays unavailable untilit does. That is a separate change in that repository, and this PR does not regress it: OAuth was never usable
there.
Once #2028 merges, the remaining MCP residue from #2022 lands on top of this: swapping the scope provider's
literals for
McpScopes::READ/WRITE, and the09_MCP_Server_Management.mddocumentation.🤖 Generated with Claude Code