diff --git a/CHANGELOG.md b/CHANGELOG.md index 1848ad9f..aa9f5341 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # next version +- Let an extension register its own UCP OAuth scope. The supported scopes were a private class constant, so a plugin that adds a UCP capability of its own had no way to make its scope grantable: the token request threw `Unsupported OAuth scope`, and a consent flow that swallowed the error burned its one-time handle and told the buyer the authorization link had expired. Tag a scope provider with `swag_agentic_commerce.ucp.oauth_scope_provider` and its scope is advertised in `scopes_supported` on `/.well-known/oauth-authorization-server` and accepted in an authorization request. A request that omits the scope still gets only the three built-in ones; an extension scope has to be asked for by name. An unregistered scope is still rejected -- now with the supported set named in the message, which is what made this hard to diagnose. - Require Shopware `6.5.8` or newer. `6.5.0.0` through `6.5.7.4` were listed as compatible but could never install: those versions ship Symfony 6.3, while both the extension's own routes and the UCP SDK need Symfony 6.4. Installation therefore ended in a Composer error about `symfony/config` that a merchant cannot act on. Such a shop now sees the extension as incompatible; updating to `6.5.8.x` fixes that and stays inside the same minor. - Let Shopware install the UCP SDK instead of shipping it inside the archive. 1.3.0 put the SDK in the plugin's own `vendor/` and loaded the autoloader Composer had generated for it, because Shopware does not load a plugin's `vendor/autoload.php` by itself. Every shop that installed it then carried two separate package registries: FroshTools reported `2 autoloaders registered`, and a question as ordinary as which version of a package is installed could be answered from the plugin's copy instead of the shop's. The extension now carries no dependencies at all. It has Shopware run `composer require` on install and update instead, which is what that mechanism is there for on a zip-installed extension: the extension itself resolves from the `custom/plugins/*` path repository every Shopware project declares, and the SDK version it names comes from Packagist into the shop's own `vendor/`. Nothing changes for a shop that installs through Composer. What is new is that installing has to reach Packagist -- without it the install stops with a Composer error, rather than leaving an extension that cannot run. - Switch the extension off instead of taking the shop down when the SDK is missing. An update extracts the new files one request before Shopware runs Composer, so an active extension boots at least once without the dependency it needs. On 1.3.0 every page answered `500` from that moment on, the storefront included, until someone installed the SDK by hand. The extension now registers no services, routes or feeds in that state, and writes to `var/log/swag-agentic-commerce.log` what is missing and the command that fixes it. Once the SDK is there it picks up again on its own. The check asks not only whether an SDK is present but whether it is the version this release names, so a shop still holding the previous one waits instead of running new code against an old SDK. The README has a _Troubleshooting_ section covering what that state looks like, how to leave it, and what to expect when updating from 1.3.0 or older. A cluster setup is the exception: Shopware never runs Composer for a plugin there, so nothing would ever install the requirements, and the extension refuses the install rather than reporting success and then doing nothing. diff --git a/CHANGELOG_de-DE.md b/CHANGELOG_de-DE.md index f741b7b1..df2ed938 100644 --- a/CHANGELOG_de-DE.md +++ b/CHANGELOG_de-DE.md @@ -1,5 +1,6 @@ # next version +- Erweiterungen können jetzt eigene UCP-OAuth-Scopes registrieren. Bisher war die Liste der zulässigen Scopes fest im Code hinterlegt, sodass ein Plugin mit eigener UCP-Capability seinen Scope nicht freischalten konnte: Die Token-Anfrage schlug mit `Unsupported OAuth scope` fehl. Weil das Plugin diesen Fehler still abfing, der Autorisierungslink aber schon eingelöst war, sah der Käufer nur die Meldung, der Link sei abgelaufen. Jetzt genügt ein Scope-Provider mit dem Service-Tag `swag_agentic_commerce.ucp.oauth_scope_provider`: Sein Scope wird in `scopes_supported` unter `/.well-known/oauth-authorization-server` veröffentlicht und in Autorisierungsanfragen akzeptiert. Ein Client, der keinen Scope angibt, erhält wie bisher nur die drei eingebauten Scopes; den Scope einer Erweiterung muss er ausdrücklich anfordern. Nicht registrierte Scopes werden weiterhin abgelehnt, die Fehlermeldung führt jetzt aber alle unterstützten Scopes auf -- genau diese Angabe fehlte bei der Fehlersuche. - Die Erweiterung setzt jetzt Shopware `6.5.8` oder neuer voraus. `6.5.0.0` bis `6.5.7.4` wurden als kompatibel angezeigt, ließen sich aber nie installieren: Diese Versionen enthalten Symfony 6.3, während sowohl die Routen der Erweiterung als auch das UCP-SDK Symfony 6.4 benötigen. Die Installation brach deshalb mit einer Composer-Fehlermeldung zu `symfony/config` ab, mit der ein Händler nichts anfangen kann. In betroffenen Shops wird die Erweiterung jetzt als nicht kompatibel angezeigt; ein Update auf `6.5.8.x` behebt das und bleibt innerhalb derselben Minor-Version. - Das UCP SDK wird nicht mehr mitgeliefert, sondern von Shopware installiert. 1.3.0 legte es in das plugin-eigene `vendor/` und lud den Autoloader, den Composer dafür erzeugt hatte -- denn das `vendor/autoload.php` einer Erweiterung lädt Shopware nicht von sich aus. Damit führte jeder Shop zwei getrennte Paketverzeichnisse: FroshTools meldete `2 autoloaders registered`, und die Frage, welche Version eines Pakets installiert ist, konnte aus der Kopie der Erweiterung beantwortet werden statt aus der des Shops. Die Erweiterung bringt jetzt keine Abhängigkeiten mehr mit, sondern lässt Shopware bei Installation und Update `composer require` ausführen -- dafür ist dieser Weg bei Erweiterungen aus einer ZIP-Datei gedacht. Die Erweiterung selbst wird dabei über das Pfad-Repository `custom/plugins/*` aufgelöst, das jedes Shopware-Projekt mitbringt, das festgelegte SDK kommt von Packagist in das `vendor/` des Shops. Für Shops, die über Composer installieren, ändert sich nichts. Neu ist, dass die Installation Packagist erreichen muss: Ohne Internetzugang bricht sie mit einem Composer-Fehler ab, statt eine unvollständige Erweiterung zu hinterlassen. - Fehlt das SDK, schaltet sich die Erweiterung ab, statt den Shop lahmzulegen. Ein Update entpackt die neuen Dateien, bevor Shopware Composer ausführt -- eine aktive Erweiterung startet also mindestens einmal ohne die Abhängigkeit, die sie braucht. In 1.3.0 antwortete daraufhin jede Seite mit `500`, die Storefront eingeschlossen, bis jemand das SDK von Hand nachinstallierte. Jetzt registriert die Erweiterung in diesem Zustand weder Services noch Routen oder Feeds und schreibt nach `var/log/swag-agentic-commerce.log`, was fehlt und mit welchem Befehl es sich beheben lässt. Sobald das SDK da ist, arbeitet sie ohne weiteres Zutun normal weiter. Geprüft wird dabei nicht nur, ob überhaupt ein SDK vorhanden ist, sondern ob es die Version ist, die diese Erweiterung vorgibt: Ein Shop, der noch die vorherige Version hat, wartet lieber ab, als neuen Code mit einem alten SDK auszuführen. Die README beschreibt unter _Troubleshooting_, woran man diesen Zustand erkennt, wie man ihn verlässt und was beim Update von 1.3.0 oder älter zu erwarten ist. Cluster-Setups sind die Ausnahme: Dort führt Shopware für ein Plugin grundsätzlich kein Composer aus, die Anforderungen würden also nie installiert. Deshalb verweigert die Erweiterung dort die Installation, statt Erfolg zu melden und dann nichts zu tun. diff --git a/docs/public-api-boundaries.md b/docs/public-api-boundaries.md index f67b140a..94bd5019 100644 --- a/docs/public-api-boundaries.md +++ b/docs/public-api-boundaries.md @@ -20,13 +20,21 @@ Public contracts: extensions. A third party extends the class, implements `getTechnicalName()` and `buildProviderContext()`, and tags its service with that tag; `AgenticCommerceProductExportProviderRegistry` picks it up via `tagged_iterator`. +- `Swag\AgenticCommerce\Ucp\Identity\AbstractUcpOAuthScopeProvider` plus the + `swag_agentic_commerce.ucp.oauth_scope_provider` service tag for UCP OAuth scopes. A third party + extends the class, returns its scopes from `getScopes()`, and tags its service; + `UcpOAuthScopeRegistry` merges them with the scopes this plugin's own capabilities answer for. + Registered scopes are advertised in `scopes_supported` on + `/.well-known/oauth-authorization-server` and are grantable in an authorization request; an + unregistered scope is still rejected with `Unsupported OAuth scope`. The extension authorizes its + own routes -- the scope string is carried on the token, nothing else is granted by registering it. Internal by default: -Every class, interface, trait, and enum under `src/` carries `@internal`, except the provider base -class listed above. Test classes under `tests/` carry `@internal` too, so the BC checker does not -capture them. Do not replace this with a per-namespace list; such a list silently omits namespaces -added later. +Every class, interface, trait, and enum under `src/` carries `@internal`, except the base classes +listed above. Test classes under `tests/` carry `@internal` too, so the BC checker does not capture +them. Do not replace this with a per-namespace list; such a list silently omits namespaces added +later. If a PHP class should become a public extension point, document its contract here before removing `@internal`. diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index 1a8e5920..3c21fb5c 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -110,6 +110,7 @@ use Swag\AgenticCommerce\Ucp\Identity\CleanupExpiredOAuthTokensTask; use Swag\AgenticCommerce\Ucp\Identity\CleanupExpiredOAuthTokensTaskHandler; use Swag\AgenticCommerce\Ucp\Identity\ShopwareIdentityLinkingAdapter; +use Swag\AgenticCommerce\Ucp\Identity\UcpOAuthScopeRegistry; use Swag\AgenticCommerce\Ucp\Mcp\Api\UcpMcpProxyController; use Swag\AgenticCommerce\Ucp\Mcp\Routing\StoreApiMcpRouteScopeWhitelist; use Swag\AgenticCommerce\Ucp\Mcp\Tool\UcpCartCancelTool; @@ -322,6 +323,9 @@ $services->set(UcpExtensionAvailability::class) ->arg('$identityLinkingAdapterIterable', tagged_iterator('ucp_sdk.adapter.identity_linking')); + $services->set(UcpOAuthScopeRegistry::class) + ->arg('$scopeProviders', tagged_iterator('swag_agentic_commerce.ucp.oauth_scope_provider')); + // Tagged service registrations. $services->set(ShopwareIdentityLinkingAdapter::class) diff --git a/src/Ucp/Identity/AbstractUcpOAuthScopeProvider.php b/src/Ucp/Identity/AbstractUcpOAuthScopeProvider.php new file mode 100644 index 00000000..c22fa5ef --- /dev/null +++ b/src/Ucp/Identity/AbstractUcpOAuthScopeProvider.php @@ -0,0 +1,24 @@ + + */ + abstract public function getScopes(): array; +} diff --git a/src/Ucp/Identity/ShopwareIdentityLinkingAdapter.php b/src/Ucp/Identity/ShopwareIdentityLinkingAdapter.php index 7d891768..51f901ce 100644 --- a/src/Ucp/Identity/ShopwareIdentityLinkingAdapter.php +++ b/src/Ucp/Identity/ShopwareIdentityLinkingAdapter.php @@ -20,19 +20,11 @@ #[Package('framework')] final class ShopwareIdentityLinkingAdapter implements IdentityLinkingAdapterInterface { - /** - * @var list - */ - private const SUPPORTED_SCOPES = [ - 'dev.ucp.shopping.cart:manage', - 'dev.ucp.shopping.order:read', - 'dev.ucp.shopping.order:manage', - ]; - public function __construct( private readonly SalesChannelContextResolver $contextResolver, private readonly DoctrineDbalUcpOAuthStore $oauthStore, private readonly OAuthClientBindingValidator $clientBindingValidator = new OAuthClientBindingValidator(), + private readonly UcpOAuthScopeRegistry $scopeRegistry = new UcpOAuthScopeRegistry(), ) { } @@ -44,7 +36,7 @@ public function getMetadata(RequestContext $context): OAuthMetadata issuer: $baseUri, authorizationEndpoint: $baseUri.'/ucp/v1/oauth/authorize', tokenEndpoint: $baseUri.'/ucp/v1/oauth/token', - scopesSupported: self::SUPPORTED_SCOPES, + scopesSupported: $this->scopeRegistry->supported(), grantTypesSupported: ['authorization_code', 'refresh_token'], tokenEndpointAuthMethodsSupported: ['none'], ); @@ -63,7 +55,7 @@ public function authorize(OAuthAuthorizationRequest $request, RequestContext $co throw new OAuthException('Only PKCE S256 code challenge method is supported.'); } - $scope = $this->normalizeScope($request->scope); + $scope = $this->scopeRegistry->normalize($request->scope); $salesChannel = $this->contextResolver->resolveSalesChannel($context); $customerContext = $this->contextResolver->resolve($this->contextToken($context), $context); $customer = $customerContext->getCustomer(); @@ -210,21 +202,6 @@ private function baseUri(RequestContext $context): string return rtrim($baseUri, '/'); } - private function normalizeScope(string $scope): string - { - $requested = array_values(array_filter(explode(' ', trim($scope)), static fn (string $entry): bool => '' !== $entry)); - if ([] === $requested) { - return implode(' ', self::SUPPORTED_SCOPES); - } - - $unsupported = array_values(array_diff($requested, self::SUPPORTED_SCOPES)); - if ([] !== $unsupported) { - throw new OAuthException(\sprintf('Unsupported OAuth scope "%s".', $unsupported[0])); - } - - return implode(' ', array_values(array_unique($requested))); - } - private function contextToken(RequestContext $context): string { $token = $context->headers[strtolower(PlatformRequest::HEADER_CONTEXT_TOKEN)] ?? $context->headers['sw-context-token'] ?? null; diff --git a/src/Ucp/Identity/UcpOAuthScopeRegistry.php b/src/Ucp/Identity/UcpOAuthScopeRegistry.php new file mode 100644 index 00000000..7b37c9c0 --- /dev/null +++ b/src/Ucp/Identity/UcpOAuthScopeRegistry.php @@ -0,0 +1,77 @@ + + */ + private const CORE_SCOPES = [ + 'dev.ucp.shopping.cart:manage', + 'dev.ucp.shopping.order:read', + 'dev.ucp.shopping.order:manage', + ]; + + /** + * @var list|null + */ + private ?array $supported = null; + + /** + * @param iterable $scopeProviders + */ + public function __construct(private readonly iterable $scopeProviders = []) + { + } + + /** + * Every grantable scope: the core ones plus whatever extensions registered. + * + * @return list + */ + public function supported(): array + { + if (null === $this->supported) { + $scopes = self::CORE_SCOPES; + + foreach ($this->scopeProviders as $provider) { + $scopes = array_merge($scopes, $provider->getScopes()); + } + + $this->supported = array_values(array_unique($scopes)); + } + + return $this->supported; + } + + /** + * An empty request gets the core scopes only, so installing an extension never widens what a + * client that omits `scope` is granted; anything unsupported is rejected. + */ + public function normalize(string $scope): string + { + $supported = $this->supported(); + + $requested = array_values(array_filter(explode(' ', trim($scope)), static fn (string $entry): bool => '' !== $entry)); + if ([] === $requested) { + return implode(' ', self::CORE_SCOPES); + } + + $unsupported = array_values(array_diff($requested, $supported)); + if ([] !== $unsupported) { + throw new OAuthException(\sprintf('Unsupported OAuth scope "%s". Supported scopes: %s.', $unsupported[0], implode(' ', $supported))); + } + + return implode(' ', array_values(array_unique($requested))); + } +} diff --git a/tests/Unit/ShopwareIdentityLinkingAdapterTest.php b/tests/Unit/ShopwareIdentityLinkingAdapterTest.php index 7b1f43c8..bae02cee 100644 --- a/tests/Unit/ShopwareIdentityLinkingAdapterTest.php +++ b/tests/Unit/ShopwareIdentityLinkingAdapterTest.php @@ -10,8 +10,11 @@ use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; use Shopware\Core\System\SalesChannel\Context\SalesChannelContextPersister; use Shopware\Core\System\SalesChannel\Context\SalesChannelContextServiceInterface; +use Swag\AgenticCommerce\Ucp\Identity\AbstractUcpOAuthScopeProvider; use Swag\AgenticCommerce\Ucp\Identity\DoctrineDbalUcpOAuthStore; +use Swag\AgenticCommerce\Ucp\Identity\OAuthClientBindingValidator; use Swag\AgenticCommerce\Ucp\Identity\ShopwareIdentityLinkingAdapter; +use Swag\AgenticCommerce\Ucp\Identity\UcpOAuthScopeRegistry; use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelContextResolver; use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelDomainResolver; use Ucp\Sdk\Exception\OAuthException; @@ -25,14 +28,8 @@ final class ShopwareIdentityLinkingAdapterTest extends TestCase protected function setUp(): void { - $contextResolver = new SalesChannelContextResolver( - new SalesChannelDomainResolver($this->createMock(EntityRepository::class)), - $this->createMock(SalesChannelContextServiceInterface::class), - $this->createMock(SalesChannelContextPersister::class), - ); - $this->adapter = new ShopwareIdentityLinkingAdapter( - $contextResolver, + $this->contextResolver(), new DoctrineDbalUcpOAuthStore($this->createMock(Connection::class)), ); } @@ -84,4 +81,39 @@ public function testRefreshTokenExchangeRequiresClientId(): void new RequestContext('shop.example'), ); } + + #[Test] + public function testTheOAuthMetadataAdvertisesCoreAndRegisteredScopes(): void + { + $adapter = new ShopwareIdentityLinkingAdapter( + $this->contextResolver(), + new DoctrineDbalUcpOAuthStore($this->createMock(Connection::class)), + new OAuthClientBindingValidator(), + new UcpOAuthScopeRegistry([new class extends AbstractUcpOAuthScopeProvider { + public function getScopes(): array + { + return ['com.shopware.quote:manage']; + } + }]), + ); + + static::assertSame( + [ + 'dev.ucp.shopping.cart:manage', + 'dev.ucp.shopping.order:read', + 'dev.ucp.shopping.order:manage', + 'com.shopware.quote:manage', + ], + $adapter->getMetadata(new RequestContext('shop.example'))->scopesSupported, + ); + } + + private function contextResolver(): SalesChannelContextResolver + { + return new SalesChannelContextResolver( + new SalesChannelDomainResolver($this->createMock(EntityRepository::class)), + $this->createMock(SalesChannelContextServiceInterface::class), + $this->createMock(SalesChannelContextPersister::class), + ); + } } diff --git a/tests/Unit/UcpOAuthScopeRegistryTest.php b/tests/Unit/UcpOAuthScopeRegistryTest.php new file mode 100644 index 00000000..bf70e580 --- /dev/null +++ b/tests/Unit/UcpOAuthScopeRegistryTest.php @@ -0,0 +1,96 @@ +supported()); + } + + #[Test] + public function testCoreScopesStayGrantableAlongsideARegisteredOne(): void + { + $registry = $this->registryWith('com.vendor.scope:manage'); + + static::assertSame( + implode(' ', self::CORE_SCOPES), + $registry->normalize(implode(' ', self::CORE_SCOPES)), + ); + } + + #[Test] + public function testARegisteredScopeIsAdvertisedAndAccepted(): void + { + $registry = $this->registryWith('com.vendor.scope:manage'); + + static::assertContains('com.vendor.scope:manage', $registry->supported()); + static::assertSame('com.vendor.scope:manage', $registry->normalize('com.vendor.scope:manage')); + } + + #[Test] + public function testAnUnregisteredScopeIsRejectedAndTheMessageNamesTheSupportedSet(): void + { + $registry = $this->registryWith('com.vendor.scope:manage'); + + $this->expectException(OAuthException::class); + $this->expectExceptionMessage('Unsupported OAuth scope "com.vendor.other:manage". Supported scopes: dev.ucp.shopping.cart:manage dev.ucp.shopping.order:read dev.ucp.shopping.order:manage com.vendor.scope:manage.'); + + $registry->normalize('com.vendor.other:manage'); + } + + #[Test] + public function testAnEmptyRequestGrantsOnlyTheCoreScopes(): void + { + $registry = $this->registryWith('com.vendor.scope:manage'); + + static::assertSame( + 'dev.ucp.shopping.cart:manage dev.ucp.shopping.order:read dev.ucp.shopping.order:manage', + $registry->normalize(' '), + ); + } + + #[Test] + public function testAScopeRegisteredTwiceIsAdvertisedOnce(): void + { + $registry = $this->registryWith('com.vendor.scope:manage', 'com.vendor.scope:manage'); + + static::assertSame([...self::CORE_SCOPES, 'com.vendor.scope:manage'], $registry->supported()); + } + + private function registryWith(string ...$scopes): UcpOAuthScopeRegistry + { + $providers = array_map( + static fn (string $scope): AbstractUcpOAuthScopeProvider => new class($scope) extends AbstractUcpOAuthScopeProvider { + public function __construct(private readonly string $scope) + { + } + + public function getScopes(): array + { + return [$this->scope]; + } + }, + $scopes, + ); + + return new UcpOAuthScopeRegistry($providers); + } +}