Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG_de-DE.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
16 changes: 12 additions & 4 deletions docs/public-api-boundaries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
4 changes: 4 additions & 0 deletions src/Resources/config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions src/Ucp/Identity/AbstractUcpOAuthScopeProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Swag\AgenticCommerce\Ucp\Identity;

use Shopware\Core\Framework\Log\Package;

/**
* Base class for extensions that add UCP OAuth scopes of their own.
*
* Extend it, return the scopes the extension's own UCP capability authorizes, and tag the
* service with `swag_agentic_commerce.ucp.oauth_scope_provider`. The scopes are advertised in
* `scopes_supported` on `/.well-known/oauth-authorization-server` and become grantable in an
* authorization request; every scope nobody registered is still rejected.
*/
#[Package('framework')]
abstract class AbstractUcpOAuthScopeProvider
{
/**
* @return list<string>
*/
abstract public function getScopes(): array;
}
29 changes: 3 additions & 26 deletions src/Ucp/Identity/ShopwareIdentityLinkingAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,11 @@
#[Package('framework')]
final class ShopwareIdentityLinkingAdapter implements IdentityLinkingAdapterInterface
{
/**
* @var list<string>
*/
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(),
) {
}

Expand All @@ -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'],
);
Expand All @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
77 changes: 77 additions & 0 deletions src/Ucp/Identity/UcpOAuthScopeRegistry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

declare(strict_types=1);

namespace Swag\AgenticCommerce\Ucp\Identity;

use Shopware\Core\Framework\Log\Package;
use Ucp\Sdk\Exception\OAuthException;

/** @internal */
#[Package('framework')]
final class UcpOAuthScopeRegistry
{
/**
* The scopes this plugin's own capabilities answer for.
*
* @var list<string>
*/
private const CORE_SCOPES = [
'dev.ucp.shopping.cart:manage',
'dev.ucp.shopping.order:read',
'dev.ucp.shopping.order:manage',
];

/**
* @var list<string>|null
*/
private ?array $supported = null;

/**
* @param iterable<AbstractUcpOAuthScopeProvider> $scopeProviders
*/
public function __construct(private readonly iterable $scopeProviders = [])
{
}

/**
* Every grantable scope: the core ones plus whatever extensions registered.
*
* @return list<string>
*/
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)));
}
}
46 changes: 39 additions & 7 deletions tests/Unit/ShopwareIdentityLinkingAdapterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)),
);
}
Expand Down Expand Up @@ -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),
);
}
}
Loading
Loading