Skip to content

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

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

[OAuth] Basic integration: embedded authorization & resource server#1958
wwidergoldpimcore wants to merge 19 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 and others added 11 commits July 16, 2026 16:20
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>
@wwidergoldpimcore wwidergoldpimcore self-assigned this Jul 20, 2026
@wwidergoldpimcore wwidergoldpimcore added this to the 2026.3.0 milestone Jul 20, 2026
wwidergoldpimcore and others added 8 commits July 21, 2026 15:34
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>
@sonarqubecloud

Copy link
Copy Markdown

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.

1 participant