Skip to content

[OAuth] Multi-application OAuth: public contracts, audience binding, scope catalogue - #2022

Draft
fashxp wants to merge 22 commits into
feature/mcp-1309-server-config-managementfrom
feature/oauth-multi-application
Draft

[OAuth] Multi-application OAuth: public contracts, audience binding, scope catalogue#2022
fashxp wants to merge 22 commits into
feature/mcp-1309-server-config-managementfrom
feature/oauth-multi-application

Conversation

@fashxp

@fashxp fashxp commented Aug 31, 2026

Copy link
Copy Markdown
Member

Changes in this pull request

Makes the embedded OAuth 2.1 server usable by more than one application, and closes the gap that created: with a second protected resource in play, an unbound token would have opened all of them.

1. Public contracts

Drops @internal from the types a bundle needs to act as a resource server, and adds two for scopes.

Type Why it is public
TokenValidatorInterface validate a bearer token without duplicating signature, expiry, audience and revocation checks
ResolvedAccess its return type
ResourceRegistryInterface register an endpoint as a protected resource, which is what makes its RFC 9728 metadata resolvable
ProtectedResource / ProtectedResourceMetadata argument and return types of the above
ScopeProviderInterface / ScopeRegistryInterface contribute your own scope namespace

CanonicalUri stays internal: the registry and the validator canonicalise on both sides, so callers never need it.

2. Audience binding (RFC 8707), with resource required

A client names the resource it wants a token for; the server validates it against the registry, refuses an unknown one, stamps the canonical form as aud, and TokenValidatorInterface::validate() refuses a token whose audience does not name the resource it is presented at. The binding survives a refresh.

resource is mandatory. An authorization request that omits it is refused with invalid_request. The alternative — accepting an audience-less token everywhere — leaves exactly the cross-application hole this PR exists to close, and there is no OAuth surface in the wild yet whose compatibility is worth preserving.

The practical consequence is that clients which do not implement RFC 8707 stop working, and that is deliberate. Two were found and fixed on the application side rather than by relaxing the rule: Swagger UI (passes the resource via additionalQueryStringParams) and MCP Inspector, which sends resource only when it can discover protected-resource metadata from the endpoint it was given.

Why now rather than later. It was deferred on the reasoning that per-application admission covers access control. That holds within an application; it does not hold between them. Once data-hub-simple-rest became a second resource, a token consented to for one application authenticated against the other as the same Pimcore user. Adding the second resource is what made the pre-existing gap exploitable.

3. The binding is carried in the token record, not in a forked league

An earlier revision mirrored two league/oauth2-server internals to move the resource through the flow. Both are gone. A nullable resource column on the token record carries it instead:

  • src/OAuth/Server/ResponseType/ResourceBearerTokenResponse.php is deleted.
  • LoopbackAuthCodeGrant::completeAuthorizationRequest() is reduced to a six-line hook that captures the resource and delegates to parent::, rather than reproducing league's body.

The commit deletes 185 lines against 282 added (the additions are mostly the migration, the record plumbing and tests). The point is not the count but that no upstream body is reproduced any more, so the flow no longer breaks silently when league changes internally.

4. Scope catalogue

Scopes were hardcoded in four places. They now come from tagged ScopeProviderInterface services, so a bundle ships its own namespace (datahub:read) instead of reusing mcp:*, and discovery advertises the union. A dynamic registration that omits scope now yields no scope rather than "whichever provider loaded first", which depended on bundle registration order.

5. A functional break this surfaced

Enforcing aud exposed a mismatch that was invisible while nothing compared it: MCP resources register as <issuer>/pimcore-mcp/studio/<slug>, but the authenticator validated against <host>/pimcore-mcp. A client that discovered a server and correctly requested a token for it was then refused at that very server. The authenticator now derives the per-server URI the same way registration does. Verified live: a token bound to …/studio/<slug> authenticates there, and the same token is refused at a different resource.

6. Documentation

New OAuth-Protected Applications page: the authorization-server / resource-server split, the public contracts, the anatomy of an application, and a blueprint covering both integration shapes actually in use (a firewall, as the MCP servers do; an existing request pipeline, as Data Hub Simple REST does). The surrounding pages are reframed so OAuth reads as a platform capability rather than an MCP feature, and MCP server management states it is a configuration topic independent of how a caller authenticated.

Two corrections where docs promised more than the code delivered: the MCP page claimed the authenticator "validates the token's signature and audience" when it did not, and resource URIs were described as an isolation boundary before they were one. The new page has an explicit "what the platform does not do yet" section, which still names scope enforcement.

Known gaps, stated plainly

  • Scopes are advertised and consented to but never enforced at call time. A token with no scope behaves like a fully scoped one. Documented; not fixed here.
  • The consent screen does not yet show which resource is being authorised, and renders a scope it does not recognise (datahub:read) as the raw string, because the label map in studio-ui-bundle lists only mcp:*.

Verification

Unit suite green: 871 tests, 1967 assertions, including new coverage for resource validation, canonicalisation, and a request that names no resource.

Manually end-to-end against a running stack: the real authorization-code + PKCE flow, a token carrying aud, accepted at its own resource and refused at another; an authorization request without resource refused with invalid_request; and the binding intact across a refresh. Discovery verified through the 401 challenge and the metadata document, for the Studio MCP servers and for both Data Hub Simple REST surfaces.

php-cs-fixer clean on the changed paths. PHPStan not run locally (no dev dependencies in the parent project); note the static-analysis failure on this PR is at DependencyInjection/Configuration.php:867, a file this branch does not touch, inherited from the base branch.

Copilot AI balanced review requested due to automatic review settings August 31, 2026 16:36

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.

Pull request overview

Exposes five OAuth resource-server contracts as public API by removing @internal annotations and clarifying URI handling.

Changes:

  • Publishes token validation contracts and result DTOs.
  • Publishes protected-resource registry contracts and DTOs.
  • Documents registry URI canonicalisation.

Review Contract

  1. Claim: Public resource-server API without behavior changes.
  2. Root cause: Not fully addressed; audience validation is still absent (EmbeddedTokenValidator.php:92-94).
  3. Call sites: Existing callers were reviewed; no missed references found.
  4. Boundary: Audience enforcement belongs in the validator implementation.
  5. Compatibility: No immediate BC break, but this creates a new compatibility commitment.
  6. Tests: A wrong-audience regression test is required before publication.
  7. Docs: Runtime registration does not fully honor the new canonicalisation documentation (ConfigProtectedResourceRegistry.php:57-60).
  8. Risk: Resource servers could accept tokens issued for another audience; maintainer security review is recommended.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/OAuth/Dto/ResolvedAccess.php Publishes the validated-access result DTO.
src/OAuth/Dto/ProtectedResourceMetadata.php Publishes the metadata result DTO.
src/OAuth/Dto/ProtectedResource.php Publishes resource registration data and revises URI guidance.
src/OAuth/Contract/TokenValidatorInterface.php Publishes token validation integration contract.
src/OAuth/Contract/ResourceRegistryInterface.php Publishes protected-resource registration contract.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +26 to +28
* Public API. Bundles that expose their own OAuth-protected endpoints implement
* an authenticator against this contract rather than duplicating token parsing,
* signature and revocation checks.
Comment on lines +20 to +21
* `$canonicalUri` need not be pre-canonicalised: the registry canonicalises on
* registration and on every lookup.
@fashxp fashxp changed the title [OAuth] Open the resource-server contracts as public API [OAuth] Open the resource-server contracts as public API and reframe the docs Aug 31, 2026
@fashxp fashxp added this to the 2026.3.0 milestone Aug 31, 2026
@fashxp fashxp changed the title [OAuth] Open the resource-server contracts as public API and reframe the docs [OAuth] Multi-application OAuth: public contracts, audience binding, scope catalogue Aug 31, 2026
@wwidergoldpimcore
wwidergoldpimcore force-pushed the feature/mcp-1309-server-config-management branch from 590203a to 5d4fe87 Compare September 1, 2026 14:54
wwidergoldpimcore and others added 14 commits September 2, 2026 10:23
First step of #1309: a first-class, config-managed "MCP server" model, backend
only (no UI, no endpoint yet).

- McpServerDefinition / McpServerAccess value objects, with fromArray()/toArray()
  as the single (de)serialization boundary so the shipped symfony-config seed and
  the settings-store JSON map onto one shape. Access mirrors the SavedSearch/Grid
  sharing model (owner + shareGlobal + sharedUsers[] + sharedRoles[]), with users
  and roles in separate lists so a shared id is never ambiguous.
- McpServerConfigRepository over Pimcore's LocationAwareConfigRepository, mirroring
  PerspectiveConfigRepository: shipped defaults from the new studio_mcp_servers
  node, runtime servers from the configured write target (settings-store or
  symfony-config, deployer-switchable via config_location.studio_mcp_servers).
- Config tree + config_location node + prependCustomConfig wiring; the repository
  is registered and its config/storage args are set in the extension.

Inert by default: the node defaults to an empty map and nothing consumes the
repository yet. Tool registration, the per-URL endpoint, access enforcement and
OAuth discovery follow in later steps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…istry

Second step of #1309: the mechanism by which tools become available to assign
to a server. No server/endpoint yet.

- McpToolInterface: a tool describes itself via getDefinition() (name, title,
  description, MCP annotations, JSON schemas) and runs via execute(). Native
  contract — the bundle does not depend on the MCP SDK; the per-server endpoint
  (later step) maps these onto the wire types.
- Implementing the interface auto-applies the McpToolRegistry::TAG (via
  registerForAutoconfiguration in the bundle), and McpToolRegistry collects the
  tagged tools through an #[AutowireIterator], name-keyed, rejecting duplicates.
  McpToolPass guards against a hand-written tag on a non-tool service.
- McpToolDefinition::requiredScope() derives the OAuth scope from the tool's
  readOnly annotation (read-only -> mcp:read, else mcp:write; unannotated
  defaults to write, fail-safe) — the basis for the operation-level scope
  enforcement tracked for a later step. Mirrors the agent bundle's PR #118
  ToolAnnotations, authored and enforced server-side.
- PingTool: a built-in, dependency-free read-only tool, so a server can be
  exercised end-to-end without the agent bundle.

Inert by default: the registry is populated but nothing consumes it yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nd discovery

Makes step-1 testable end to end without a UI: a configured server is reachable
over MCP at /pimcore-mcp/studio/{server} under the shared pimcore_mcp firewall,
so it accepts the OAuth bearer.

- Adds the mcp/sdk dependency (^0.7, matching the agent bundle) and McpServerFactory,
  which assembles a tools-only Mcp\Server per definition. Each assigned tool is
  resolved from the registry and bridged onto the SDK: the native execute(array)
  is wrapped in a handler that reads the call arguments (via RequestContext /
  CallToolRequest) and maps the result to CallToolResult, so tools stay SDK-agnostic.
- McpServerController resolves the definition by URL slug, enforces per-server access
  (McpServerAccessResolver — admin/global/owner/user/role, mirroring the bundle's
  config sharing), and runs the streamable-HTTP transport with an explicit middleware
  stack (dropping the SDK's Dns-rebinding middleware, incompatible with a proxy). The
  route is namespaced under /studio/ and declared explicitly so it is neither swept
  under the Studio API prefix nor colliding with other bundles' /pimcore-mcp/ routes.
- The extension advertises each enabled server as an RFC 9728 protected resource
  (derived from the issuer), so the per-server 401 challenge and discovery resolve.
- A dedicated MCP session cache pool keeps sessions isolated from other bundles.

Verified live: unauthenticated -> 401 + WWW-Authenticate with the per-server
resource_metadata + scope; the resource metadata resolves 200; an authenticated
initialize -> tools/list -> tools/call ping returns "pong".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expose the location-aware MCP server configuration over the Studio API so a
UI can manage servers without touching symfony-config or the settings store:

- Servers: GET/POST/PUT/DELETE under /pimcore-studio/api/mcp/servers, one
  action per controller, guarded by a new `mcp_servers` user permission.
- Tools: GET /pimcore-studio/api/mcp/tools returns the registry's tool
  catalogue (name, title, description, required scope, read-only/destructive
  hints) for assignment to a server.

The service derives a server's advertised OAuth scopes from its tools'
required scope, preserves the owner across updates, locks the url slug to the
id, and builds the serving URL from the OAuth issuer. Response DTOs flatten
the access model (owner/shareGlobal/sharedUsers/sharedRoles) and each carries
a pre-response event. Adds the MCP OpenAPI tag and translation keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…g installs

The mcp_servers permission is created by the installer on a fresh install, but
that runs only once — instances updating from an earlier version never get the
row. Without it the Studio permission voter cannot resolve the attribute and
denies every user (admins included, since the admin bypass lives inside the
vote which never runs for an unsupported attribute), so the MCP server
management endpoints return 403.

The migration inserts the definition idempotently (guarded on existence, so it
is a no-op on a fresh install or a forward-merge replay) and drops the cached
permission-key list in postUp so the change takes effect without a separate
cache clear.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lists

Rework the per-server access model on the agent bundle's run/update pattern,
but kept deny-by-default and keyed by id:

- Two levels (McpServerPermission Read/Write, write implies read). Read = see
  the server, view its config, copy the URL, connect a client at runtime;
  Write = read plus edit, re-share and delete.
- Access is a grid: owner (implicit write) + global read flag + user/role share
  entries, each carrying a level (McpServerAccessEntry). The stored/​submitted
  shapes tolerate the earlier flat id lists, reading them as read grants.
- The resolver answers a requested level: admin, then owner, then an
  authoritative direct-user entry, then a granting role, then global-read,
  else deny. It backs both the Studio API and the runtime serving endpoint
  (which asks for Read).

Endpoint gating follows the single-permission path: mcp_servers now gates only
create and the tool catalogue. List/get are ungated and filtered/asserted by
read access, so a user a server is shared with — with no manage permission —
still sees it and copies its URL; update/delete assert write. The response
carries the caller's resolved permissions (currentUserPermissions) plus the
grid, so the UI can mirror the agent-bundle sharing editor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a setup/configuration page for the opt-in OAuth 2.1 authorization server
under Installation and Configuration (enabling, key generation, exposing the
root-level endpoints, the auth-code + PKCE flow, client onboarding, and a full
config reference), and link it from the install guide.

Also update the MCP Server Infrastructure doc's authenticator-chain table,
which was stale: the pimcore_mcp firewall now runs OAuthAccessTokenAuthenticator
(JWT bearers) between the internal token and PAT authenticators.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two docs for the runtime MCP server feature:

- Extending/Providing MCP Tools: the McpToolInterface contract, the
  auto-applied pimcore.studio_backend.mcp_tool tag, annotations-to-scope
  mapping, and McpToolResult.
- Development Details/MCP Server Management: the mcp_servers permission, the
  settings-store write target, the Studio API surface, the read/write sharing
  model (deny-by-default, resolution order), and the Studio master/detail
  management UI including its read-only behavior.

Both are cross-linked with the existing MCP infrastructure and OAuth docs and
registered in their section indexes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop the bundle's parallel MCP tool framework in favour of the mcp/sdk types
the agent bundle already uses, so studio tools and agent tools share one
contract:

- Delete McpToolInterface, McpToolDefinition, McpToolAnnotations, McpToolResult
  (and the now-dead DuplicateMcpToolException). A tool is now a plain service
  with an #[McpTool] method returning a CallToolResult.
- Tools opt in explicitly with the pimcore.studio_backend.mcp_tool tag (no
  auto-tag-by-interface); McpToolPass reflects the attribute into the registry
  and builds a service locator. Tool names must be unique.
- McpToolRegistry hands out McpToolReference descriptors (SDK Tool metadata +
  class/method); McpServerFactory registers them straight onto the SDK builder's
  addTool([class, method], ...) with a generated + normalized input schema — the
  former bridge closure and result mapping are gone.
- The only Pimcore-specific concern kept is the OAuth scope, now a one-line
  McpScopes::forReadOnly() helper over the tool's readOnlyHint.
- PingTool becomes the reference #[McpTool] example; the tool-authoring doc is
  rewritten around #[McpTool]/#[Schema]/CallToolResult.

Server management, sharing, the mcp_servers permission, the API and routes are
unchanged. Full cross-bundle unification of registries/routes is out of scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Configurations are meant to be portable across instances where the same user
or role carries a different numeric id, so the sharing model now identifies
users and roles by their (unique) name — matching how the agent bundle does it:

- McpServerAccess.owner and McpServerAccessEntry are name-based; the stored
  shape and the API (McpServer.owner, McpServerAccessGrant.name) use names.
- McpServerAccessResolver matches owner/user entries on the current user's name
  and resolves the user's role ids to names via RoleResolverInterface (the same
  id->name resolution the agent bundle performs).
- The service stamps the owner from getCurrentUser()->getName().

The feature is experimental and unreleased, so no id->name back-compat is kept;
the tolerant deserializer now reads a bare string as a read grant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… OAuth page

Match the OAuth server doc to the removal of the pre-registered `clients` config
and the client_credentials machine path (that change lives on the OAuth branch):
onboarding is DCR/CIMD only, and non-interactive access uses the PAT
authenticator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the single read/write level with three independent capabilities on an
MCP server, matching the two-checkbox sharing the UI wants:

- A share entry is now { name, canAccess, canEdit }; being listed at all grants
  a read-only view. canAccess = connect a client at runtime; canEdit = change
  the config. They no longer imply each other.
- The resolver returns { view, access, edit } (union of the user's own and role
  entries): view = admin OR public OR listed; access = public OR a granting
  entry (admins do NOT get access implicitly); edit = admin OR a granting entry.
- shareGlobal is the "public" flag: any authenticated user may view and use
  (not edit) the server.
- The owner (creator) is auto-listed with full capabilities on save, so they
  keep view/use/edit of their own server.
- The Studio API gates get on view, put/delete on edit; the runtime serving
  endpoint requires access. The server list is filtered to viewable servers.

currentUserPermissions is now { canView, canAccess, canEdit }.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… page

Re-document pre-registered clients on the embedded OAuth 2.1 server page, matching
the restored public-only support: an onboarding option alongside DCR and CIMD,
each entry a client_id with a redirect_uris allow-list, no secret / confidential /
service_user and no Client Credentials grant (machine access uses the PAT
authenticator). Adds the clients config-reference row and a note to prefer
pre-registration over open DCR when clients are known up front.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Studio-API write path moved MCP server access to name-based, two-capability
grants, but the symfony-config `studio_mcp_servers` tree still typed identities
as integers — so a YAML-configured server with a string `owner` failed at
container compile with `Expected "int", but got "string"`, and shared_users /
shared_roles could not carry the {name, can_access, can_edit} grid at all.

Align the file-config `access` node with the settings-store shape the repository
feeds into McpServerAccess::fromArray:
- owner is a scalar username (was integerNode)
- shared_users / shared_roles are grants of { name, can_access, can_edit }, and a
  bare string is accepted as a view-only grant (was integerPrototype)

Regression test processes the node and the full tree with a string owner and the
capability grid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@wwidergoldpimcore
wwidergoldpimcore force-pushed the feature/mcp-1309-server-config-management branch from 5d4fe87 to e529a61 Compare September 2, 2026 09:54
wwidergoldpimcore and others added 7 commits September 2, 2026 14:15
Per the refined permission model (#1452), the owner is now symmetric with an
admin: implicit Config Read + Config Edit, but MCP Server Access must be granted
explicitly — so nobody, not even the owner or an admin, has default access to a
server's runtime.

- McpServerAccessResolver: the owner resolves to view + edit (not access), like
  an admin. Access stays public-or-explicit-entry only.
- McpServerConfigurationService: stop seeding the owner into the sharing grid
  with full capabilities; the owner's read/edit is implicit, and they add
  themselves to the user list to grant access.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The MCP Server Management page still described the original two-level read/write
sharing (owner with implicit write). Rewrite it to the current model: three
independent capabilities — Config Read, Config Edit, MCP Server Access — where
the owner and admins have implicit read+edit but must be granted access
explicitly, and a public server grants read+access (not edit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bundles that expose their own OAuth-protected endpoints need to validate tokens
and register their endpoints as protected resources, which @internal forbade.
No behaviour change: docblocks only.

Co-Authored-By: Claude <noreply@anthropic.com>
Adds an OAuth-Protected Applications page: the two roles, the public contracts,
and a blueprint for adding an application, with Pimcore MCP and Data Hub Simple
REST as the first two. Also corrects the MCP page, which claimed the
authenticator validates the audience; it does not.

Co-Authored-By: Claude <noreply@anthropic.com>
register() stored the caller's URI verbatim while the newly public docblock said
the registry canonicalises, so the RFC 9728 document could echo a non-canonical
resource. Unreadable key material now returns null instead of escaping as a 500.
Corrects the blueprint doc, which described a firewall its stated reference
implementation does not use.

Co-Authored-By: Claude <noreply@anthropic.com>
…ible

Implements RFC 8707: a client names the resource it wants a token for, the server
validates it, stamps it as `aud` and refuses the token elsewhere. Without this any
token opened every protected resource, which only became exploitable once a second
one existed. Tokens with no audience stay valid everywhere, so clients that do not
ask are unaffected. Scopes are now contributed by tagged providers, so a bundle can
ship its own namespace instead of reusing `mcp:*`.

Co-Authored-By: Claude <noreply@anthropic.com>
Enforcing `aud` exposed a mismatch: resources are registered as
<issuer>/pimcore-mcp/studio/<slug> but the authenticator validated against
<host>/pimcore-mcp, so a token a client correctly requested for the server it
discovered was refused at that very server. Also stamps the canonical form of the
resource, and makes a dynamic registration that omits `scope` deterministic rather
than depending on bundle registration order.

Co-Authored-By: Claude <noreply@anthropic.com>
The resource is now mandatory: a control the client can decline is not a control,
and an unbound token was accepted by every protected resource of this server.

Replaces two mirrored league methods with a resource column on the token record.
completeAuthorizationRequest and BearerTokenResponse::generateHttpResponse were
duplicated wholesale to smuggle one field through league's payloads; the binding is
now written when the code and refresh token are issued and read back by id, so both
mirrors are gone and the audience is queryable. Recovery also moves out of
validateClient, which was the wrong layer.

Co-Authored-By: Claude <noreply@anthropic.com>
@fashxp
fashxp force-pushed the feature/oauth-multi-application branch from 0c8eca6 to 09d86e0 Compare September 2, 2026 13:09
@sonarqubecloud

sonarqubecloud Bot commented Sep 2, 2026

Copy link
Copy Markdown

@fashxp

fashxp commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

replaced by #2028 and #2029

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