Zum Inhalt springen

Shopware-Plugin: README

Connects a Shopware 6.7 shop to punchout.dev: the snippet, the cart marker and the buyer auto-login. Every value the plugin needs comes from the portal. The plugin is shown as punchout.dev, and its technical identifiers carry the same name: the plugin class and folder PunchoutDev, the composer name punchout-dev/shopware-plugin, the config domain PunchoutDev.config, the route /punchout-dev/login and the table punchout_dev_login_jti.

Version 2.0.1. Three features, independent of each other:

  1. Snippet embed — src/Resources/views/storefront/base.html.twig extends the storefront’s base.html.twig and appends the pob.js <script> tag to base_body_script. That is the widget that reads the cart and posts it back to the buyer’s e-procurement system. Since 1.2.0 every attribute of that tag comes from the plugin configuration — there are no hard-coded URLs left.
  2. Cart marker (new in 1.2.0) — src/Resources/views/storefront/page/checkout/cart/index.html.twig wraps the line-item table in <div data-punchout-cart>, so punchout.dev finds the cart without any cart detection for this shop.
  3. Auto-login (new in 1.1.0) — GET /punchout-dev/login?token=… verifies a signed single-use token minted by punchout.dev, logs the mapped customer in through Shopware’s own AccountService::loginById(), and redirects to the shop path carried in the token (normally the cart page with ?__pob_sid=…, where the punchout session continues).

Administration → Extensions → punchout.dev → Configure → card Snippet. Like every setting of this plugin the three fields are sales-channel aware (system config domain PunchoutDev.config):

setting key meaning
Embed the snippet snippetEnabled off ⇒ no script tag at all. Default on; an unset value counts as on.
Snippet URL snippetUrl the pob.js URL from the embed tag in the portal, https://js.punchout.dev/pob.js. Empty ⇒ nothing is embedded.
API base URL (optional) apiBase development or self-hosting only. Empty ⇒ no data-api attribute.

data-shop-id is not a field of its own: the snippet reuses Shop ID (tenantId) from the Auto-login card, because it is the same shop on both legs. No shop ID ⇒ no script tag.

From the command line:

Terminal-Fenster
bin/console system:config:set PunchoutDev.config.snippetUrl https://js.punchout.dev/pob.js
bin/console system:config:set --json PunchoutDev.config.snippetEnabled true
bin/console system:config:get PunchoutDev.config.snippetUrl

The rendered tag, with every value attribute-escaped:

<script async src="…/pob.js" data-shop-id="pob_live_…" data-api="…"></script>

src/Resources/views/storefront/page/checkout/cart/index.html.twig extends the storefront cart page and wraps the block page_checkout_cart_product_table:

{% block page_checkout_cart_product_table %}
<div data-punchout-cart>
{{ parent() }}
</div>
{% endblock %}

That block renders <div class="card checkout-product-table"> with the column header and every line item — and nothing else. The order summary, the promotion form and the “proceed to checkout” button sit in page_checkout_aside_*, the article-number and shipping-cost forms in page_checkout_cart_add_product_and_shipping; none of them end up inside the marker. The wrapper is a plain div with no styling, which is layout-neutral: the card is an ordinary block-level element, not a grid or flex child.

Why it matters. Without a marker punchout.dev has to detect the cart for this theme, and a detected cart can break on the next theme update. [data-punchout-cart] takes precedence over any detected cart, so a shop running this plugin needs no cart detection at all and survives a redesign.

The marker is rendered whether or not the snippet is enabled; on its own it is inert.

The shop owner creates a one-time setup link in the portal (Setup tab, valid 60 minutes by default):

  1. Put 2–3 different articles in the cart.
  2. Open the cart page with the setup link appended (…/checkout/cart?pob_setup=…).
  3. Click Detect cart in the widget (on a German storefront the widget speaks German: Warenkorb erkennen).

With this plugin installed the widget shows “Shop plugin detected — the cart is already marked.” before the click, and the result reports the source Shop plugin. That is the self-test: it proves the marker is on the page, that the snippet is embedded with the right shop ID, and that punchout.dev can reach the shop. Confirming with Yes, that’s it records the source on the tenant and consumes the link; nothing about the cart’s layout is stored, because the marker is read at runtime.

If the widget instead reports Preset or Detected automatically, the marker did not reach the page — the usual causes are a theme that overrides page_checkout_cart_product_table without calling parent(), or a stale storefront cache (bin/console cache:clear).

Without it a punchout buyer shops as a guest: no customer-specific prices, no contract assortment. The bridge knows which shop customer the buyer’s procurement account maps to, so it hands the browser a short-lived, signed, single-use ticket instead of a password.

bridge --mint--> pbl1.<payload>.<sig>
browser --GET--> /punchout-dev/login?token=…
plugin verify → resolve customer → AccountService::loginById()
--302--> /checkout/cart?__pob_sid=… (logged in)
--302--> /checkout/cart?__pob_sid=… (any failure: guest punchout)

Every failure is a redirect, never an error page and never a login form. If the token cannot be used, the buyer still lands on the cart page and the punchout continues as a guest session. Only when there is no usable path at all does the endpoint fall back to /.

pbl1.<b64url(payload)>.<b64url(signature)>

b64url is RFC 4648 §5 without padding. payload is UTF-8 JSON:

key value
aud the shop ID — must equal the configured Shop ID
customer {"kind": "id"|"number"|"email", "value": "…"}
exp unix seconds, expiry
iat unix seconds, issued at
iss punchout-bridge
jti opaque single-use id, ≥ 16 bytes of entropy
kid key id, must be listed in Public key lines
next relative path, starts with /, never //
op punchout_login
protocol oci, cxml or ids

The signature is Ed25519 over the ASCII bytes of "pbl1." . <b64url(payload)> — the prefix plus the encoded payload, not the raw JSON. The plugin verifies the bytes exactly as received and never re-serialises the JSON, so the bridge is free to spell the JSON however it likes.

Checks, in order (src/Service/TokenVerifier.php): format → kid known → signature → iss/op → aud → iat (60 s skew allowed) and exp (no skew) → next is a relative path → jti/protocol/customer shape. The jti single-use check follows in the controller (src/Service/NonceStore.php).

The contract is fixtures/login-token/README.md in the bridge repo; services/api/src/pob_api/core/login_token.py is the reference implementation. The shipped reference vector (fixtures/login-token/token.txt) is asserted by tests/TokenVerifierTest.php::testAcceptsTheBridgeReferenceVector and can be re-checked from the command line:

Terminal-Fenster
php bin/selftest.php --verify "$(cat token.txt)" "test=$(cat public_key.txt)" \
pob_live_test0000000000000000 1757930060

Where this plugin differs from the reference verifier, and why:

point reference here
exp valid until exp + 60 s valid until exp — no grace (TokenVerifier::EXP_SKEW_SECONDS)
iat iat - 60 s same 60 s
next with a control character or whitespace rejected rejected — but the controls are stripped first, so a smuggled newline can never hide a // from the structural checks
protocol any non-empty string any non-empty string ≤ 32 chars
jti any non-empty string non-empty printable ASCII, 8–256 chars
reason codes format, kid, signature, … more granular (kid_unknown, signature_invalid, iat_future, operation, …) — log-only, never sent to the client

EXP_SKEW_SECONDS = 0 is the one place where this plugin is stricter than the contract. If a shop’s clock runs fast enough to reject live tokens — the symptom is reason expired on every login, while the punchout still works as a guest — set it to 60 to match the reference exactly.

customer.kind = "id" arrives as a dashed RFC 4122 uuid (018f0000-0000-7000-8000-000000000001). Shopware stores and matches the 32-character lower-case hex form, so the controller strips the dashes before Uuid::isValid(); both spellings work.

Copy this directory to custom/plugins/PunchoutDev in the shop, then:

Terminal-Fenster
bin/console plugin:refresh
bin/console plugin:install --activate PunchoutDev
bin/console cache:clear

plugin:install runs the plugin’s migration, so Migration1757930000LoginJti creates punchout_dev_login_jti. Without that table every login fails closed into the guest fallback.

2.0.0 renames every technical identifier, so to Shopware it is a different plugin, not an update of the old one. Never run both — each embeds its own pob.js script tag. Remove the old plugin first, then install this one:

Terminal-Fenster
bin/console plugin:uninstall PunchoutNinja
rm -r custom/plugins/PunchoutNinja
# copy this directory to custom/plugins/PunchoutDev, then
bin/console plugin:refresh
bin/console plugin:install --activate PunchoutDev
bin/console cache:clear

Nothing is carried over: enter every setting again from the portal, both the Snippet and the Auto-login card. Then change the Login path on the portal’s Auto-login tab to /punchout-dev/login (see Login path in the portal); until then an auto-login launch ends on the shop’s 404 page instead of the cart. The old punchout_ninja_login_jti table is no longer read and can be dropped.

Administration → Extensions → punchout.dev → Configure → card Auto-login. The settings are sales-channel aware (system config domain PunchoutDev.config):

setting key meaning
Enable auto-login enabled off ⇒ the route signs nobody in and redirects to the token’s next (/ only without a usable one); the punchout continues as a guest. Default on.
Shop ID tenantId the shop ID from the portal’s Embed tab; must equal the token’s aud. Rendered as the snippet’s data-shop-id.
Public key lines publicKeys one kid=<base64url public key> per line, exactly as the portal’s Auto-login tab shows it (Ed25519, 32 raw bytes, unpadded).

The Snippet card is documented under The snippet embed.

Take the public key lines from the portal’s Auto-login tab. During a key rotation, keep the old line in place until every minted token has expired; unknown kids are rejected outright (reason kid_unknown).

Enter it on the portal’s Auto-login tab (Login path), as a path on the shop’s own origin:

shop login path
normal install at the domain root /punchout-dev/login
the test shop under a subfolder /shopware-6.7.0.0/public/punchout-dev/login

The same applies to next: it is used verbatim. For the test shop punchout.dev must mint next = /shopware-6.7.0.0/public/checkout/cart?__pob_sid=…; the plugin does not prepend the storefront base path, it only makes the path absolute against the request’s scheme and host.

  • Single use. sha256(jti) is inserted into punchout_dev_login_jti, whose primary key is that hash — the INSERT is the check. The first request to present a token wins; every later one hits the unique constraint and is refused (reason replay). Two concurrent requests race in the database, not in PHP, so there is no window in which both succeed. Only the hash is stored, never the jti. The store fails closed: if the marker cannot be written for any reason — the migration has not run, the database is down — the login is not granted and the punchout continues as a guest. (A missing table therefore also surfaces as replay; bin/console plugin:update PunchoutDev creates it.) Markers are swept opportunistically: every consume() first deletes rows whose expires_at has passed, which is a cheap indexed delete and keeps the table at roughly one TTL’s worth of logins. A failed sweep is never fatal. Earlier versions used Shopware’s object cache pool and this did not hold. In a dev instance cache.object is a TagAwareAdapter whose backing store is not shared between requests, so the marker never survived to the next one and a replayed token logged in a second time. A cache is the wrong shape for this: it is allowed to forget, and forgetting here means a granted replay.
  • Short TTL. exp is enforced with no grace at all. Clock skew is tolerated on iat only (60 s), so a token cannot be extended by a fast clock.
  • No leakage. The token is never rendered, never echoed and never logged — only a short reason code goes to the log. Responses carry Cache-Control: no-store (no shared cache keeps the redirect) and Referrer-Policy: no-referrer (the next page’s Referer cannot carry the token to a third party).
  • No open redirect. next must be a relative path: it has to start with a single /, must not start with //, must not contain a backslash, and CR/LF plus every other C0 control is stripped before those checks so a smuggled newline cannot hide a //. The same validation is applied to the unverified next used for the guest fallback, so even a forged token can only ever redirect within this shop.
  • Customer resolution is narrow. Exactly one active, non-guest customer must match, and it must either be unbound or bound to the current sales channel. Zero matches or an ambiguous match ⇒ guest fallback.
  • Nothing is bypassed. The login goes through core’s AccountService, which restores the customer’s cart and dispatches CustomerLoginEvent; the storefront’s own subscriber migrates the session. The plugin sets no cookies and touches no session state itself.

bin/selftest.php needs only PHP 8.2+, ext-sodium and ext-json, so it can be dropped into any shop container.

Terminal-Fenster
# generate a keypair, mint a token, verify it, run the negative cases
php bin/selftest.php
# verify a token minted elsewhere (e.g. by the Python bridge)
php bin/selftest.php --verify "<token>" "<kid>=<b64url public key>" "<tenant id>" [now]

now is an optional unix timestamp; pass it to check a fixed test vector whose validity window has already passed.

Exit codes: 0 accepted / all assertions held, 1 rejected, 2 usage error. The no-argument run prints the generated kid, public-key line, tenant id and token so the round trip can be replayed from the minting side.

Inside the test shop’s container:

Terminal-Fenster
docker cp . docker-php-83-php-1:/tmp/pd/
docker exec docker-php-83-php-1 sh -lc 'cd /tmp/pd && php bin/selftest.php'

tests/TokenVerifierTest.php is a plain PHPUnit test — it loads the service classes directly, so it runs without a Shopware kernel:

Terminal-Fenster
docker exec docker-php-83-php-1 sh -lc \
'cd /var/www/html/shopware-6.7.0.0 && php vendor/bin/phpunit \
--no-configuration --bootstrap vendor/autoload.php \
custom/plugins/PunchoutDev/tests/TokenVerifierTest.php'

tests/LoginControllerTest.php covers the controller’s guest fallback — a switched-off or unconfigured plugin, a missing or malformed token, an unsafe next — with mocks instead of a kernel, so it needs the shop’s vendor/autoload.php for the Shopware and Symfony classes but no database. To run every suite against this checkout rather than the installed copy:

Terminal-Fenster
docker cp . docker-php-83-php-1:/tmp/pd/
docker exec docker-php-83-php-1 sh -lc \
'cd /var/www/html/shopware-6.7.0.0 && php vendor/bin/phpunit \
--no-configuration --bootstrap vendor/autoload.php /tmp/pd/tests'

NonceStoreTest runs against an in-memory SQLite database (skipped without pdo_sqlite): the statements NonceStore issues are portable, and what is under test is the behaviour — the INSERT is the single-use check and every failure is closed — not the MySQL DDL, which the migration owns.

composer.json plugin metadata (2.0.1)
phpunit.xml.dist
bin/selftest.php standalone verifier self-test
src/PunchoutDev.php plugin class
src/Resources/config/config.xml plugin settings
src/Resources/config/routes.xml controller route import
src/Resources/config/services.xml DI wiring
src/Resources/views/storefront/base.html.twig pob.js snippet embed (from config)
src/Resources/views/storefront/page/checkout/cart/index.html.twig
data-punchout-cart cart marker
src/Service/TokenVerifier.php token parsing + Ed25519 verification
src/Service/LoginClaims.php verified claims (readonly DTO)
src/Service/TokenRejected.php rejection with a short reason code
src/Migration/Migration1757930000LoginJti.php creates punchout_dev_login_jti
src/Service/NonceStore.php single-use jti store (INSERT = the check)
src/Storefront/Controller/LoginController.php GET /punchout-dev/login
tests/TokenVerifierTest.php
tests/NonceStoreTest.php runs on in-memory SQLite, no kernel needed
tests/LoginControllerTest.php guest fallback, mocks, needs the shop's autoloader
  • 2.0.1 (2026-10-05) — fix: with Enable auto-login switched off, or without a Shop ID or public key lines, the login route redirected to / and dropped the token’s next — the snippet never picked the punchout session up and the punchout ended instead of continuing as a guest. Those two cases now redirect to the token’s next like every other failure, after the same relative-path check (/ only when there is no usable path), as the WooCommerce and Magento plugins already do.
  • 2.0.0 — the technical identifiers carry the product name: plugin class and folder PunchoutDev, composer name punchout-dev/shopware-plugin, config domain PunchoutDev.config, route /punchout-dev/login, table punchout_dev_login_jti. To Shopware this is a new plugin; a 1.x shop switches over as described right after Install.
  • 1.2.1 — shown as punchout.dev (label, description, manufacturer and support links); the configuration speaks the portal’s words — Shop ID, Snippet URL, API base URL, Public key lines, Auto-login. Wording only.
  • 1.2.0 — cart marker; the snippet tag moves into the plugin configuration.
  • 1.1.0 — single-use login endpoint (auto-login).
  • 1.0.0 — snippet embed.