Skip to content

[OAuth] Public resource-server contracts, an extensible scope catalogue, and resource-bound tokens - #2028

Merged
wwidergoldpimcore merged 14 commits into
feature/oauth-1308-basic-integrationfrom
feature/oauth-1308-resource-binding
Sep 4, 2026
Merged

[OAuth] Public resource-server contracts, an extensible scope catalogue, and resource-bound tokens#2028
wwidergoldpimcore merged 14 commits into
feature/oauth-1308-basic-integrationfrom
feature/oauth-1308-resource-binding

Conversation

@fashxp

@fashxp fashxp commented Sep 3, 2026

Copy link
Copy Markdown
Member

Splits the OAuth half out of #2022, so the authorization server can land on a different release than
the MCP server configuration. #2022 will be closed; its MCP-related changes follow in a second PR
once feature/mcp-1309-server-config-management is rebased onto this branch.

What this does

Opens the resource-server contracts as public API. ResourceRegistryInterface,
TokenValidatorInterface, ProtectedResource, ProtectedResourceMetadata and ResolvedAccess lose
@internal. Any bundle can now make its endpoints an OAuth resource server; Data Hub Simple REST is
the first consumer (pimcore/data-hub-simple-rest#356).

Makes the scope catalogue extensible. Scopes were a hard-coded ['mcp:read', 'mcp:write'] in
ScopeRepository. A bundle now contributes its own by tagging a ScopeProviderInterface service with
pimcore_studio_backend.oauth.scope_provider; the registry is what the metadata document advertises.
The bundle's own MCP scopes move into McpScopeProvider unchanged.

Binds tokens to a resource (RFC 8707). The authorization request names the resource it wants a
token for, the resource is stamped onto the token as aud, and it is enforced when the token is
presented, so a token minted for one resource is refused at another. The binding travels with the
authorization code, so the token request does not repeat it and a refresh keeps the original resource.
A resource column on the token record carries it.

Fails closed at both ends. A request naming no resource, or an unknown one, is refused; a token
carrying no audience is refused everywhere rather than accepted everywhere. Previously only one end
was guarded, so any unbound token that appeared would have opened every protected resource at once.

Says what to do when a request is refused. No discovery document lists an authorization server's
resources, so a refusal was a dead end. It now names the known resources, and distinguishes "this
server has no protected resources configured" from "you named one that does not exist".

Behaviour changes

  • resource is required on the authorization request. Clients that do not implement RFC 8707 must add
    it. MCP clients already send it, and Data Hub Simple REST's Swagger UI adds it for the same reason.
  • A token with no aud is no longer accepted. OAuth is unreleased, so nothing is in circulation.
  • Registration that omits scope yields no scope, rather than inheriting whichever scope happened to
    be registered first, which depended on bundle registration order.

Verified

Unit suite green (828 tests), PHPStan clean. Driven end to end against a demo instance together with
pimcore/data-hub-simple-rest#356: dynamic client registration, authorization, consent, token, refresh,
and an MCP tools/list call over the resulting token. A token minted for the REST resource is refused
at the MCP endpoint and vice versa.

Not in scope

OAuthAccessTokenAuthenticator is untouched. The endpoints behind the pimcore_mcp firewall are owned
by other bundles, and deriving their audience belongs with the MCP server configuration in the
follow-up PR.

🤖 Generated with Claude Code

fashxp and others added 8 commits September 3, 2026 16:44
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>
The `aud` claim carried the client's spelling of the resource, so every consumer
had to canonicalise before comparing. A registration that omits `scope` no longer
falls back to the first registered scope, which depended on bundle order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ej6YARj6HakERFuERFuDF9
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>
The server requires a request to name its resource, but the validator still
honoured a token without one at every resource, so a single token that reached
either end unbound opened every protected resource of the server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ej6YARj6HakERFuERFuDF9
No discovery document lists an authorization server's resources, so a refusal was
a dead end. It now names them, and says plainly when none are configured, which
is otherwise indistinguishable from naming the wrong one.

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 16:38
@fashxp fashxp added this to the 2026.3.0 milestone Sep 3, 2026
@fashxp fashxp self-assigned this 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

Core audience propagation lacks automated regression coverage, and multiple changed comments document the opposite security behavior.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds extensible OAuth scopes, public resource-server contracts, and fail-closed RFC 8707 audience binding.

Changes:

  • Introduces tagged scope providers and a shared scope registry.
  • Persists resource bindings through authorization-code and refresh flows.
  • Enforces token audiences and documents third-party resource-server integration.

Review assessment: The implementation addresses the root cause at the grant, persistence, and validation boundaries. Call sites and DI wiring were updated without identified public API breakage, but core token-exchange paths lack regression coverage and several comments contradict fail-closed behavior.

File summaries
File Description
tests/Unit/OAuth/Token/EmbeddedTokenValidatorTest.php Tests audience validation.
tests/Unit/OAuth/Server/ScopeRepositoryTest.php Tests registry-backed scopes.
tests/Unit/OAuth/Server/Grant/LoopbackAuthCodeGrantTest.php Tests resource request validation.
tests/Unit/OAuth/Server/ClientRegistrarTest.php Tests extensible registration scopes.
tests/Unit/OAuth/Registry/ConfigProtectedResourceRegistryTest.php Tests resource canonicalization.
tests/Unit/OAuth/Controller/AuthorizationServerMetadataControllerTest.php Tests advertised scopes.
src/PimcoreStudioBackendBundle.php Registers the scope compiler pass.
src/OAuth/Token/EmbeddedTokenValidator.php Enforces token audiences.
src/OAuth/Server/RequestType/ResourceAuthorizationRequest.php Carries authorization resources.
src/OAuth/Server/Repository/TokenRecordStoreInterface.php Adds resource persistence APIs.
src/OAuth/Server/Repository/TokenRecordStore.php Stores token resources.
src/OAuth/Server/Repository/ScopeRepository.php Uses the scope registry.
src/OAuth/Server/Repository/RefreshTokenRepository.php Persists refresh audiences.
src/OAuth/Server/Grant/ResourceRefreshTokenGrant.php Preserves audiences on refresh.
src/OAuth/Server/Grant/LoopbackAuthCodeGrant.php Validates and propagates resources.
src/OAuth/Server/Entity/AccessTokenEntity.php Emits the aud claim.
src/OAuth/Server/ClientRegistrar.php Validates registered scopes dynamically.
src/OAuth/Server/AuthorizationServerFactory.php Wires resource-aware grants.
src/OAuth/Scope/McpScopeProvider.php Contributes MCP scopes.
src/OAuth/Registry/ScopeRegistry.php Aggregates scope providers.
src/OAuth/Registry/ConfigProtectedResourceRegistry.php Canonicalizes registered resources.
src/OAuth/Dto/ResolvedAccess.php Marks access results public.
src/OAuth/Dto/ProtectedResourceMetadata.php Marks metadata public.
src/OAuth/Dto/ProtectedResource.php Marks resources public.
src/OAuth/Controller/AuthorizationServerMetadataController.php Advertises registry scopes.
src/OAuth/Contract/TokenValidatorInterface.php Opens token validation publicly.
src/OAuth/Contract/ScopeRegistryInterface.php Defines the public catalogue API.
src/OAuth/Contract/ScopeProviderInterface.php Defines scope contributions.
src/OAuth/Contract/ResourceRegistryInterface.php Opens resource registration publicly.
src/Migrations/Version20260901120000.php Adds the resource column.
src/Entity/OAuth/OAuthTokenRecord.php Maps stored resources.
src/DependencyInjection/CompilerPass/OAuthScopeProviderPass.php Validates tagged providers.
doc/04_Development_Details/README.md Links OAuth integration guidance.
doc/04_Development_Details/08_MCP_Server.md Reframes MCP OAuth usage.
doc/04_Development_Details/07_OAuth_Protected_Applications.md Documents resource-server integration.
doc/02_Installation_and_Configuration/README.md Generalizes OAuth introduction.
doc/02_Installation_and_Configuration/06_OAuth_Server.md Documents required resource binding.
config/oauth.yaml Registers scope services.
Review details
  • Files reviewed: 38/38 changed files
  • Comments generated: 8
  • Review effort level: Balanced

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

Comment thread src/OAuth/Server/Entity/AccessTokenEntity.php
Comment thread src/OAuth/Server/Grant/LoopbackAuthCodeGrant.php
Comment thread src/OAuth/Server/Grant/ResourceRefreshTokenGrant.php
Comment thread doc/04_Development_Details/07_OAuth_Protected_Applications.md Outdated
Comment thread src/OAuth/Server/Entity/AccessTokenEntity.php
Comment thread src/OAuth/Server/Grant/ResourceRefreshTokenGrant.php Outdated
Comment thread src/OAuth/Server/Repository/RefreshTokenRepository.php Outdated
Comment thread tests/Unit/OAuth/Server/Grant/LoopbackAuthCodeGrantTest.php
fashxp and others added 4 commits September 3, 2026 18:57
The audience claim, the authorization-code round trip and the refresh binding had
no tests, though each keys off a league-internal payload field. Prose written
before the validator failed closed still described the old fail-open behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ej6YARj6HakERFuERFuDF9
…king

league returns null for an absent state or code challenge and its setters reject
null, so copying them onto the resource-carrying request turned a spec-legal
request into an uncaught TypeError and a 500 before consent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ej6YARj6HakERFuERFuDF9
It described the earlier design where both surfaces shared one resource. They are
separate resources, and a token minted for either is refused at the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ej6YARj6HakERFuERFuDF9
A full pass found claims that never held or stopped holding: dynamic registration
does issue client secrets, the resource-registration step contradicted itself on
gating and on the URI source, and two statements promised MCP specifics this
bundle does not ship.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ej6YARj6HakERFuERFuDF9
fashxp and others added 2 commits September 4, 2026 00:43
RFC 8707: the token named a resource, so it should carry only the scopes that
resource can process, and the consent screen should say so. Narrowing happens on
the authorization request, since that is what the screen renders; the granted set
is then reported in the `scope` response parameter league omits entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ej6YARj6HakERFuERFuDF9
`scopes_supported` reads as advisory in both pages, and the note saying scopes are
not enforced now understates what happens at issuance.

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

@wwidergoldpimcore
wwidergoldpimcore merged commit 92789bc into feature/oauth-1308-basic-integration Sep 4, 2026
20 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 4, 2026
@jcPimcore
jcPimcore deleted the feature/oauth-1308-resource-binding branch September 11, 2026 11:27
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants