Skip to content

[OAuth] Basic integration: embedded authorization & resource server - #1958

Draft
wwidergoldpimcore wants to merge 33 commits into
2026.xfrom
feature/oauth-1308-basic-integration
Draft

[OAuth] Basic integration: embedded authorization & resource server#1958
wwidergoldpimcore wants to merge 33 commits into
2026.xfrom
feature/oauth-1308-basic-integration

Conversation

@wwidergoldpimcore

Copy link
Copy Markdown
Contributor

Changes in this pull request

Refs pimcore/product-management#1308 (OAuth basic integration).

Adds an embedded, opt-in OAuth 2.1 authorization + resource server to the Studio Backend bundle, isolated from the application's global security configuration (it does not touch the global user providers or firewall map, so a project's existing OAuth setup is unaffected).

  • Isolated module — a self-contained league/oauth2-server instance behind /pimcore-oauth/*, opt-in and disabled by default.
  • Resource server — an OAuth JWT authenticator added to the existing pimcore_mcp firewall chain (additive; declines pmcp_ and PAT tokens), resolving a token to a Pimcore user so existing ACLs apply. RFC 9068 JWTs with signature/expiry/issuer checks and revocation.
  • Discovery — RFC 8414 authorization-server metadata, RFC 9728 protected-resource metadata, and the 401 WWW-Authenticate challenge on the MCP endpoint.
  • Authorization serverauthorization_code + PKCE (S256) with a Studio UI consent handoff, client_credentials for service accounts, and refresh tokens; token and authorize endpoints.
  • Consent API under /pimcore-studio/api (session-authenticated, documented in the OpenAPI spec).
  • Token persistence + revocation store.
  • Configuration under pimcore_studio_backend.oauth (opt-in), with unit tests and end-to-end verification of both grant flows.

Additional info

Deferred (tracked; out of scope for this slice):

  • Audience binding enforcement (currently a no-op) — a conformance requirement that must land before arbitrary external MCP clients are onboarded ([Translation] isFrontendDomain wrong value #1309 / #1310).
  • The Studio UI consent screen itself — the backend contract is in place and consumed by studio-ui-bundle.
  • Public / dynamic client registration (CIMD / DCR).

Verified end-to-end against a local Pimcore instance: the authorization_code + PKCE round-trip (login → consent → code → token → resource server), the client_credentials grant, discovery documents, the 401 challenge, and access-token revocation.

🤖 Generated with Claude Code

@wwidergoldpimcore wwidergoldpimcore self-assigned this Jul 20, 2026
@wwidergoldpimcore wwidergoldpimcore added this to the 2026.3.0 milestone Jul 20, 2026
@wwidergoldpimcore
wwidergoldpimcore force-pushed the feature/oauth-1308-basic-integration branch from c768b60 to 9f3303b Compare July 30, 2026 11:40

@mcop1 mcop1 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.

Some small things:

1.) Scopes are there, but not enforced. If mcp:read was granted, the client also gets mcp:write, with all permissions of the pimcore user. Iirc that was on purpose for the first version, just wanted to note it.

2.) Plain PKCE key gets accepted, https://github.com/pimcore/studio-backend-bundle/pull/1958/changes#diff-341c4d0069f3d2cf9223dbc7489a53f84a967e6c23e185b8e4030fa1e16d53d6R46 is metadata only. An additional check for S256 is probably needed.

3.) Do we delete entries from OAuthTokenRecord again? Couldnt find anything regarding it.

wwidergoldpimcore and others added 23 commits August 20, 2026 14:16
Introduce an opt-in, self-contained OAuth module under src/OAuth that adds
no wiring into the application's global security configuration.

- Add league/oauth2-server dependency.
- Contracts under OAuth/Contract: ResourceRegistryInterface,
  TokenValidatorInterface, TokenIssuerInterface, IdentityResolverInterface,
  IdentityMapperInterface.
- DTOs: ResolvedAccess, ProtectedResource, and the RFC 9728
  ProtectedResourceMetadata document.
- ConfigProtectedResourceRegistry: config-driven, multi-resource, keyed by
  canonical URI with normalisation-insensitive lookups.
- EmbeddedIdentityResolver: resolves a numeric token subject to a valid,
  enabled Pimcore user; rejects disabled/invalid users.
- CanonicalUri: canonicalises resource URIs (lowercase scheme+host, drop
  default ports, no fragment/trailing slash).
- New config tree pimcore_studio_backend.oauth (enabled/issuer/keys/clients/
  resources), disabled by default; the extension exposes oauth.* parameters
  and seeds the resource registry.
- Unit tests for CanonicalUri, the registry, and the identity resolver.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Validate OAuth JWT access tokens on the pimcore_mcp firewall and resolve them
to a Pimcore user, additive to the existing authenticator chain.

- EmbeddedTokenValidator: verifies a JWT signature against the configured
  public key, plus expiry and (when set) issuer via lcobucci/jwt; checks
  revocation and resolves the subject to a Pimcore user; returns granted
  scopes, audience and client id. The endpoint-vs-audience check is currently
  a placeholder that accepts any audience.
- TokenRevocationCheckerInterface with a null implementation that reports
  nothing as revoked until token persistence exists.
- OAuthAccessTokenAuthenticator: claims only JWT-shaped bearer tokens, declines
  the pmcp_ prefix, stays inert unless OAuth is enabled, and returns null on
  failure so later authenticators still run. Inserted before PatAuthenticator
  in the pimcore_mcp chain.
- Extension wires the validator public key/issuer and the authenticator enabled
  flag from configuration.
- Unit tests for token validation (valid, expired, wrong issuer, bad signature,
  revoked, unresolvable user, malformed, missing key) and the authenticator's
  support matrix and passport building.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address hardening findings from a security review of the resource-server
validator.

- Reject tokens without an `exp` claim. LooseValidAt only checks time claims
  that are present, so a token missing `exp` would otherwise never expire;
  fail closed instead. (StrictValidAt is unsuitable as it also mandates `nbf`,
  which is optional for our tokens.)
- Split the scope claim on any whitespace run so repeated spaces no longer
  produce empty scope entries.
- Add regression tests asserting rejection of a missing-`exp` token, an
  RS->HS256 algorithm-confusion token (HMAC-signed with the RSA public key),
  and a hand-crafted alg=none token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implement RFC 9728 discovery for the MCP resource server.

- ProtectedResourceMetadataController serves the Protected Resource Metadata
  document as JSON, keyed by the path suffix of the well-known URL; unknown
  resources return 404.
- Register the endpoint at the root path /.well-known/oauth-protected-resource
  via an explicit route (outside the API prefix) instead of the prefixed
  attribute loader.
- McpAuthenticationEntryPoint returns 401 for unauthenticated MCP requests and,
  when OAuth is enabled, adds a "WWW-Authenticate: Bearer" challenge advertising
  the resource metadata URL and mcp:read scope; plain 401 when disabled.
- Register the entry point on the pimcore_mcp firewall settings and set its
  enabled flag from configuration.
- Unit tests for the controller (metadata served / 404) and the entry point
  (challenge when enabled / plain 401 when disabled).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Symfony authenticator manager runs the firewall chain until an
authenticator returns a response; a successful authenticator whose
onAuthenticationSuccess() returns null does not stop it. PatAuthenticator
claimed every non-pmcp_ bearer, so after OAuthAccessTokenAuthenticator
authenticated a JWT successfully, PatAuthenticator still ran, failed to match
it against the PAT map, and its 401 overrode the success.

Decline JWT-shaped bearer tokens in PatAuthenticator::supports() so they are
handled only by OAuthAccessTokenAuthenticator, mirroring the existing pmcp_
exclusion that protects McpAccessTokenAuthenticator. Add a support-matrix test
covering opaque, pmcp_ and JWT bearers.

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

Stand up the isolated league authorization server that mints RFC 9068 JWT
access tokens, and expose the token and discovery endpoints.

- Entities: ClientEntity (with a service-user for client_credentials),
  ScopeEntity, and AccessTokenEntity — the latter replaces league's default
  token trait with a custom toString() emitting a space-delimited `scope`
  string, `client_id` and `iss` (resource `aud` binding is added later).
- Repositories: config-driven ClientRepository (with secret validation),
  ScopeRepository (mcp:read / mcp:write), and a stateless AccessTokenRepository
  (persistence/revocation land with refresh tokens).
- AuthorizationServerFactory builds a standalone server from the configured
  signing/encryption keys and enables the client_credentials grant.
- Endpoints (routed under /pimcore-oauth and the well-known path, outside the
  Studio API prefix): POST /pimcore-oauth/token and the RFC 8414 authorization
  server metadata document.
- Config: activate oauth.clients (with service_user), keys.private_key /
  encryption_key and access_token_ttl; preserve client-id keys verbatim.
- Unit tests for the JWT claim shape, client secret validation and scope
  resolution.

A client_credentials token issued by /pimcore-oauth/token is accepted by the
resource-server authenticator on the pimcore_mcp firewall, resolving to the
client's service user.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a token record store so issued OAuth artifacts can be revoked, and back the
resource-server revocation check with it (previously a no-op).

- OAuthTokenRecord entity + migration (bundle_studio_oauth_token): identifier,
  type, expiry, revoked flag, user/client, created-at; indexed by user and
  expiry.
- TokenRecordStore(Interface): persist (rejecting duplicate identifiers),
  revoke, isRevoked (blocklist — an unknown identifier counts as not revoked),
  and expired-record cleanup.
- AccessTokenRepository now records each issued token and answers
  revoke/isRevoked against the store.
- StoredTokenRevocationChecker replaces the null checker, so a revoked access
  token is rejected on its next request.
- Unit test for the revocation checker delegation.

A client-credentials token is now persisted on issue and rejected by the MCP
firewall once its record is revoked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the grant machinery the auth-code flow needs; the user-facing authorize +
consent endpoints follow separately.

- Entities: AuthCodeEntity, RefreshTokenEntity and UserEntity (the resource
  owner, whose identifier is the numeric Pimcore user id).
- Store-backed AuthCodeRepository and RefreshTokenRepository, so codes are
  one-time-use and refresh tokens are revoked on rotation (reuse detection).
- AuthorizationServerFactory now enables AuthCodeGrant (PKCE S256) and
  RefreshTokenGrant alongside client_credentials.
- Config: auth_code_ttl and refresh_token_ttl.
- Unit test for the auth-code repository.

client_credentials issuance and resource-server validation are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Complete the authorization-code + PKCE flow with a Studio UI consent handoff.

- AuthorizeController (GET /pimcore-oauth/authorize): validates the request,
  stashes it under an opaque id in a short-lived store, and redirects the
  browser to the Studio UI consent route.
- Consent API under the Studio API firewall (session-authenticated):
  GET /oauth/authorizations/{id} returns the client, scopes and acting user;
  POST /oauth/authorizations/{id} {approved} completes the request and returns
  the client redirect location carrying the code, state and RFC 9207 iss.
- PendingAuthorizationStore (cache-backed) and AuthorizationRequestValidator
  (re-validates the stored parameters into a league AuthorizationRequest).
- Authorization server metadata now advertises the authorization endpoint,
  the authorization_code/refresh_token grants, response_types and S256 PKCE.
- Config: consent_path for the redirect target.
- Unit test for the pending-authorization store.

Verified end to end: login, authorize, consent, approve, code+PKCE exchange
(access + refresh tokens), and resource-server acceptance as the consenting
Pimcore user.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Align the consent endpoints with the rest of the Studio API: they now extend
AbstractApiController, are documented with OpenAPI operation/parameter/response
attributes, and return serialized response schemas.

- Add an OAuth tag.
- Response schemas: AuthorizationConsent (+ client/user) and
  AuthorizationRedirect.
- AuthorizationDetailsController / AuthorizationApprovalController: Get/Post
  operations, path + request-body parameters, success responses referencing the
  schemas, and NotFoundException for an unknown/expired authorization id.

Response bodies are unchanged; the endpoints now appear in the OpenAPI document.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address review findings on the authorization server.

- Reject public (non-confidential) clients for the client_credentials grant:
  a public client carries no secret, so accepting it there would let one with a
  service_user obtain a service-account token with only its public client id.
- Validate at config time that a client with a service_user is confidential and
  defines a secret, so the footgun configuration is rejected on boot.
- Advertise the "none" token-endpoint auth method for public PKCE clients in the
  authorization server metadata (and fix its now-stale docblock).
- Give the pending-authorization store a dedicated filesystem-backed cache pool
  instead of cache.app, so the authorize request and the later consent approval
  share the same store regardless of the project's cache.app adapter.
- Test that a public client cannot authenticate for client_credentials.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Studio UI uses a browser router, so the authorize endpoint must redirect to
/pimcore-studio/oauth/consent (a hash-fragment path would not resolve). Update
the consent_path default accordingly; deployments with a different UI base can
still override it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The MCP entry point hardcoded the protected-resource metadata path
(/.well-known/oauth-protected-resource/pimcore-mcp) in the WWW-Authenticate
challenge. That baked in a single fixed resource path and no longer matched
when the protected endpoint differed. Build the URL from the request path
instead, so the challenge points at the metadata for the endpoint that was
actually requested.

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

Commit 1e3ddb8 changed McpAuthenticationEntryPoint to derive the 401
resource_metadata URL from the request path but left this test asserting the
previous hardcoded value. Update the expectation to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Let a client that is not pre-registered identify itself and complete the
authorization-code flow, so a native MCP client (e.g. Claude Code) can connect
without an open registration endpoint. All additions are opt-in and off by
default.

Client ID Metadata Documents (CIMD): a URL-form client_id is resolved by
fetching the client's metadata document. The fetch is SSRF-guarded (https only
outside a dev flag, optional host allow-list, private/loopback network blocked,
no redirects, size/time caps, and the document's own client_id must match the
URL) and cached. ClientRepository delegates URL-form ids to the resolver; such
clients are public (PKCE, no client_credentials). The authorization-server
metadata advertises client_id_metadata_document_supported.

Loopback redirects (RFC 8252): league applies the port-agnostic loopback match
only to the IP literals 127.0.0.1/[::1], and reports a redirect mismatch as
invalid_client. A dedicated validator plus a thin AuthCodeGrant subclass extend
the port exception, with localhost included behind allow_localhost_loopback_
redirect (default on). RFC 8252 marks localhost as NOT RECOMMENDED, so this
accommodates a not-recommended client choice; tracked upstream in
anthropics/claude-code#42765.

Config: client_id_metadata_documents (enabled, allowed_hosts, allow_insecure,
cache_ttl) and allow_localhost_loopback_redirect, plus a dedicated cache pool
for fetched documents.

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

- Wrap three lines over the 120-column limit (the CIMD resolver log call, the
  allow_insecure config info, and the token-store @throws docblock).
- Import LogicException and UniqueTokenIdentifierConstraintViolationException
  rather than referencing them by inline fully-qualified name.
- Drop the unused nullable return type on the identity-resolver test closures
  (PHPStan return.unusedType).
- Make the loopback redirect validator's properties readonly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Split the oversized addOAuthNode config builder by extracting the CIMD node
  into its own method (was over the 150-line limit).
- Replace the generic RuntimeException in the server factory with a dedicated
  MissingKeyMaterialException, and extract the repeated 'PT%dS' DateInterval
  construction into a helper.
- Reduce EmbeddedTokenValidator::validate to eight returns by extracting token
  parsing/verification into a helper; split the two assignments-in-expressions;
  add the /u flag to the scope-splitting regex.
- Define constants for the duplicated '$issuer'/'$enabled' DI argument names in
  the extension.
- Extract the memoisation assignment in the CIMD resolver and align the
  access-token scope-string arguments.

Kept intentionally: the unused $audience/$resourceUri parameters on
isAudienceAllowed are the deferred RFC 8707 audience-enforcement seam, and the
catch(Throwable) blocks are deliberate fail-closed boundaries on the token
endpoints and validator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add RFC 7591 Dynamic Client Registration as a second client-identity mechanism
next to CIMD, so a client can self-register when it does not present a CIMD URL.
Both are opt-in and off by default.

- POST /pimcore-oauth/register (ClientRegistrationController + ClientRegistrar)
  validates the request, creates a public or confidential client, persists it
  (OAuthClientRecord + migration, via DynamicClientStore) and returns the
  client_id and a one-time secret stored only as a SHA-256 hash; returns 404
  when disabled.
- ClientRepository now resolves clients from three sources in order: config,
  CIMD URL, and the dynamic store. Dynamic clients are limited to the
  interactive grants and never client_credentials.
- The authorization-server metadata advertises registration_endpoint (when DCR
  is enabled) in addition to client_id_metadata_document_supported (CIMD).
- Config: oauth.dynamic_client_registration.enabled (extracted into its own
  builder method), the /register route, and the wiring.

Verified: metadata advertises both; register -> authorize -> token recognises a
dynamic client; CIMD and loopback redirects still work. 70 OAuth + 17 Security
unit tests pass; PHPStan clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- OAuthClientRecord: reduce the constructor to seven parameters (S107) by
  deriving token_endpoint_auth_method from the confidential flag and stamping
  created_at internally.
- EmbeddedTokenValidator: drop the no-op isAudienceAllowed() method (S1172 x2).
  The audience is still captured for ResolvedAccess, and $resourceUri remains
  the interface seam for deferred RFC 8707 enforcement.
- ClientRegistrar: add the /u flag to the scope-splitting regex (S5867).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Browser-based MCP/OAuth clients (e.g. the MCP Inspector's browser-direct
mode, SPA clients) run discovery and the code->token exchange as cross-origin
fetches. The embedded authorization server sent no CORS headers and answered
OPTIONS preflights with 405, so the browser blocked those clients from reading
the responses. Native/server-side clients (Claude Code, IDE hosts, hosted
assistants doing the token exchange server-side) are unaffected either way.

Add a dedicated OAuthCorsSubscriber, scoped to the OAuth path prefixes
(/.well-known/oauth-, /pimcore-oauth/) and gated on oauth.enabled. It answers
the preflight and adds public, non-credentialed CORS: wildcard origin by
default, with an optional cors_allowed_origins allow-list. Credentials are
never enabled, so both the wildcard and the allow-list stay CORS-valid. This
is deliberately kept separate from the credentialed Studio-API CorsSubscriber,
whose model (Allow-Credentials + allow-list) is wrong for public, cookie-less
OAuth endpoints.

CORS on the resource endpoint /pimcore-mcp/mcp is out of scope here (#1309).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The authorization-server metadata advertises S256 as the only supported
code_challenge_method, but league/oauth2-server still accepts `plain` (and
defaults to it when the method is omitted), so a client could complete the
authorization-code flow with a plain PKCE challenge — contrary to OAuth 2.1
and to what the metadata promises.

Override validateAuthorizationRequest in LoopbackAuthCodeGrant to reject any
request whose code_challenge is present but whose code_challenge_method is not
S256, before delegating to the league grant. Enforcing at the authorize gate
is sufficient: only S256-protected codes are ever issued, so the token step
needs no change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TokenRecordStore::deleteExpired() existed but nothing ever called it, so the
bundle_studio_oauth_token table grew without bound. Expired tokens are already
rejected at validation time (the store only blocklists for revocation), so
this is table hygiene rather than a security fix.

Add OAuthTokenGcTask, a pimcore.maintenance.task that runs the single DELETE
each maintenance cycle, mirroring the existing McpAccessTokenGcTask for PATs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rebasing onto 2026.x re-merged the use-import block in
PimcoreStudioBackendExtension.php, interleaving the OAuth Security imports with
2026.x's Perspective imports. Re-sort alphabetically to satisfy the
`ordered_imports` php-cs-fixer rule. No behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@wwidergoldpimcore
wwidergoldpimcore force-pushed the feature/oauth-1308-basic-integration branch from cdc825c to 3171d85 Compare August 20, 2026 12:25
@wwidergoldpimcore

Copy link
Copy Markdown
Contributor Author

@mcop1 Thanks for the feedback.

Regarding point 1:
It's true that scopes aren't yet enforced, enforcement resurfaces in the next issue on MCP tool/server config management, where it ties into the related Pimcore permissions.

Other than that, I have implemented small fixes for point 2 and 3:

@mcop1 mcop1 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.

One small thing: We could add the enabled check from ClientRegistrationController to other public controllers as well. They would return 404 early then, instead of returning metadata, errors or something else.

Other than that: LGTM.

wwidergoldpimcore and others added 10 commits August 28, 2026 15:34
…e path

The embedded OAuth server is a user-delegation server for MCP clients: they
self-register (DCR / CIMD) and authenticate a logged-in Pimcore user via the
authorization_code + PKCE flow. The pre-registered `clients` config and the
client_credentials grant only added a machine/service-account path that
duplicates the existing PatAuthenticator (a static credential resolving to a
Pimcore user), so remove them:

- Remove the `oauth.clients` config node and its wiring.
- Remove the client_credentials grant from the authorization server.
- Remove `service_user` / ClientEntity::serviceUserId and the AccessToken
  service-user substitution.
- ClientRepository now serves only CIMD and dynamically-registered clients.

The interactive flow (authorization_code + PKCE, refresh_token), DCR, CIMD, and
both MCP authenticators are unchanged. Machine access uses the PAT authenticator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-introduce the `oauth.clients` config node dropped in b93b7cb, but
public-only: a pre-registered client is just a known client_id with an
allow-list of redirect_uris that authenticates a logged-in Pimcore user via
the authorization_code + PKCE flow. No secret, no service_user, no
client_credentials — the machine/service path stays removed and is served by
the PAT authenticator.

Why re-add it: dynamic_client_registration and client_id_metadata_documents
are both opt-in and default off, so a default deployment currently has no way
to onboard any client. Pre-registered clients are the zero-dependency
onboarding path and the natural pairing with a locked-down "DCR off" posture
for first-party clients (Studio MCP, Pimcore Agent).

- Configuration: `oauth.clients` map keyed by client_id, each with a required
  { name, redirect_uris } (redirect_uris non-empty, exact-match allow-list).
- ClientRepository: resolve config clients first (authoritative, and available
  even when DCR/CIMD are off), then CIMD, then the DCR store; all public
  (PKCE, no secret).
- Extension + config/oauth.yaml: wire the map into ClientRepository.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The consent id arrives from the request and flows into a cache key and the
consent API path. An id with a reserved character (":") threw and surfaced as a
500 instead of a 404, and a traversal segment could escape the intended lookup.
Reject anything but the exact opaque format (64 lowercase hex, as minted) in
PendingAuthorizationStore::get()/remove(): a malformed id resolves to "not
found" and never reaches the cache key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The consent details exposed only the client's self-chosen name, so a hostile
self-registered client could impersonate a trusted one. Add the redirect URI
host (where the authorization code is actually sent — the trustworthy signal)
and a "verified" flag that is true only for administrator-configured clients,
false for self-registered (DCR) or URL-identified (CIMD) ones.

- ClientEntity carries a preRegistered flag; ClientRepository sets it for config
  clients only.
- AuthorizationConsentClient gains redirectHost + verified; the details
  controller populates them via a new RedirectHost util.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The client_credentials grant was dropped, but the authorization-server metadata
(/.well-known/oauth-authorization-server) still listed it in
grant_types_supported, so a client could attempt a grant the server no longer
enables. Drop it (leaving authorization_code + refresh_token), and clean up the
stale TokenController docblock and a scope test that referenced the removed grant.

Adds a metadata-controller test asserting client_credentials is not advertised.

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>
… 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>
… 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>
…ue, and resource-bound tokens (#2028)

* [OAuth] Open the resource-server contracts as public API

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>

* [OAuth] Document the server as a platform capability, not an MCP feature

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>

* [OAuth] Honour the canonicalisation promise and fail closed on bad keys

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>

* [OAuth] Bind tokens to a resource and make the scope catalogue extensible

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>

* [OAuth] Stamp the canonical resource and keep registration deterministic

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

* [OAuth] Require a resource and carry the binding in the token record

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>

* [OAuth] Refuse a token that names no audience

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

* [OAuth] Name the known resources when a request names none

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

* [OAuth] Cover the resource binding seams and correct stale comments

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

* [OAuth] Keep an authorization request without optional parameters working

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

* [OAuth] Correct the Data Hub Simple REST resource example

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

* [OAuth] Correct the OAuth docs against what the code does

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

* [OAuth] Downscope a token to what its resource can process

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

* [OAuth] Document that a resource caps the scopes a token carries

`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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Version20260901120000 added the `resource` column with
`ALTER TABLE ... ADD COLUMN IF NOT EXISTS` (and `DROP COLUMN IF EXISTS` in
down()), a MariaDB-only extension that is a syntax error on MySQL. Build the
column change from the Doctrine schema diff instead, guarded on
hasTable()/hasColumn(), so the emitted DDL is portable across MySQL and MariaDB
and the migration stays idempotent for re-runs and cross-line forward-merges —
matching the pattern already used by Version20260601120000.

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

sonarqubecloud Bot commented Sep 4, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
1 New Major Issues (required ≤ 0)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants