Skip to content

[MCP] Resolve the token audience from the endpoint's owner - #2029

Open
fashxp wants to merge 3 commits into
feature/mcp-1309-server-config-managementfrom
feature/mcp-1309-resource-derivation
Open

[MCP] Resolve the token audience from the endpoint's owner#2029
fashxp wants to merge 3 commits into
feature/mcp-1309-server-config-managementfrom
feature/mcp-1309-resource-derivation

Conversation

@fashxp

@fashxp fashxp commented Sep 3, 2026

Copy link
Copy Markdown
Member

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_mcp firewall gets the audience its token is checked against.

The problem

OAuthAccessTokenAuthenticator derived the resource URI from a path hardcoded in this bundle
(<host>/pimcore-mcp). That is wrong in three ways:

  • It is a prefix, not an endpoint. Nothing serves /pimcore-mcp itself, and no protected resource is
    registered 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.
  • It uses the request host rather than the issuer. Resources are registered under the issuer, so behind any
    proxy the two differ and a correctly issued token is refused at the endpoint it was issued for.
  • It only ever describes this bundle's own servers. The endpoints behind that firewall belong to other
    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, which
still leaves any other bundle's endpoint unreachable.

What this does

Resolves the audience from whoever owns the endpoint. A new RequestResourceResolver finds the most
specific 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 the pmcp_
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. RequestResourceResolverTest covers the Studio server case, an
endpoint 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 until
it 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 the 09_MCP_Server_Management.md documentation.

🤖 Generated with Claude Code

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
Copilot AI balanced review requested due to automatic review settings September 3, 2026 21:32
@fashxp fashxp added this to the 2026.3.0 milestone Sep 3, 2026

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

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());

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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(), '/');

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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
@sonarqubecloud

sonarqubecloud Bot commented Sep 3, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants