[OAuth] Basic integration: embedded authorization & resource server - #1958
[OAuth] Basic integration: embedded authorization & resource server#1958wwidergoldpimcore wants to merge 33 commits into
Conversation
c768b60 to
9f3303b
Compare
mcop1
left a comment
There was a problem hiding this comment.
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.
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>
cdc825c to
3171d85
Compare
|
@mcop1 Thanks for the feedback. Regarding point 1: Other than that, I have implemented small fixes for point 2 and 3:
|
mcop1
left a comment
There was a problem hiding this comment.
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.
…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>
|




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).
league/oauth2-serverinstance behind/pimcore-oauth/*, opt-in and disabled by default.pimcore_mcpfirewall chain (additive; declinespmcp_and PAT tokens), resolving a token to a Pimcore user so existing ACLs apply. RFC 9068 JWTs with signature/expiry/issuer checks and revocation.401WWW-Authenticatechallenge on the MCP endpoint.authorization_code+ PKCE (S256) with a Studio UI consent handoff,client_credentialsfor service accounts, and refresh tokens; token and authorize endpoints./pimcore-studio/api(session-authenticated, documented in the OpenAPI spec).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):
studio-ui-bundle.Verified end-to-end against a local Pimcore instance: the
authorization_code+ PKCE round-trip (login → consent → code → token → resource server), theclient_credentialsgrant, discovery documents, the401challenge, and access-token revocation.🤖 Generated with Claude Code