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:
- Snippet embed —
src/Resources/views/storefront/base.html.twigextends the storefront’sbase.html.twigand appends thepob.js<script>tag tobase_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. - Cart marker (new in 1.2.0) —
src/Resources/views/storefront/page/checkout/cart/index.html.twigwraps the line-item table in<div data-punchout-cart>, so punchout.dev finds the cart without any cart detection for this shop. - 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 ownAccountService::loginById(), and redirects to the shop path carried in the token (normally the cart page with?__pob_sid=…, where the punchout session continues).
The snippet embed
Section titled “The snippet embed”Configuration
Section titled “Configuration”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:
bin/console system:config:set PunchoutDev.config.snippetUrl https://js.punchout.dev/pob.jsbin/console system:config:set --json PunchoutDev.config.snippetEnabled truebin/console system:config:get PunchoutDev.config.snippetUrlThe rendered tag, with every value attribute-escaped:
<script async src="…/pob.js" data-shop-id="pob_live_…" data-api="…"></script>The cart marker
Section titled “The cart marker”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.
Setup self-test
Section titled “Setup self-test”The shop owner creates a one-time setup link in the portal (Setup tab, valid 60 minutes by default):
- Put 2–3 different articles in the cart.
- Open the cart page with the setup link appended (
…/checkout/cart?pob_setup=…). - 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).
The login endpoint
Section titled “The login endpoint”Why it exists
Section titled “Why it exists”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 /.
Token format
Section titled “Token format”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).
Interop with the bridge
Section titled “Interop with the bridge”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:
php bin/selftest.php --verify "$(cat token.txt)" "test=$(cat public_key.txt)" \ pob_live_test0000000000000000 1757930060Where 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.
Install
Section titled “Install”Copy this directory to custom/plugins/PunchoutDev in the shop, then:
bin/console plugin:refreshbin/console plugin:install --activate PunchoutDevbin/console cache:clearplugin:install runs the plugin’s migration, so Migration1757930000LoginJti
creates punchout_dev_login_jti. Without that table every login fails closed
into the guest fallback.
Coming from PunchoutNinja 1.x
Section titled “Coming from PunchoutNinja 1.x”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:
bin/console plugin:uninstall PunchoutNinjarm -r custom/plugins/PunchoutNinja# copy this directory to custom/plugins/PunchoutDev, thenbin/console plugin:refreshbin/console plugin:install --activate PunchoutDevbin/console cache:clearNothing 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.
Configuration
Section titled “Configuration”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).
Login path in the portal
Section titled “Login path in the portal”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.
Security notes
Section titled “Security notes”- Single use.
sha256(jti)is inserted intopunchout_dev_login_jti, whose primary key is that hash — theINSERTis the check. The first request to present a token wins; every later one hits the unique constraint and is refused (reasonreplay). 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 thejti. 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 asreplay;bin/console plugin:update PunchoutDevcreates it.) Markers are swept opportunistically: everyconsume()first deletes rows whoseexpires_athas 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 instancecache.objectis aTagAwareAdapterwhose 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.
expis enforced with no grace at all. Clock skew is tolerated oniatonly (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) andReferrer-Policy: no-referrer(the next page’sReferercannot carry the token to a third party). - No open redirect.
nextmust 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 unverifiednextused 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 dispatchesCustomerLoginEvent; the storefront’s own subscriber migrates the session. The plugin sets no cookies and touches no session state itself.
Development
Section titled “Development”Self-test (no Shopware bootstrap)
Section titled “Self-test (no Shopware bootstrap)”bin/selftest.php needs only PHP 8.2+, ext-sodium and ext-json, so it can be
dropped into any shop container.
# generate a keypair, mint a token, verify it, run the negative casesphp 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:
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'Unit tests
Section titled “Unit tests”tests/TokenVerifierTest.php is a plain PHPUnit test — it loads the service
classes directly, so it runs without a Shopware kernel:
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:
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.
Layout
Section titled “Layout”composer.json plugin metadata (2.0.1)phpunit.xml.distbin/selftest.php standalone verifier self-testsrc/PunchoutDev.php plugin classsrc/Resources/config/config.xml plugin settingssrc/Resources/config/routes.xml controller route importsrc/Resources/config/services.xml DI wiringsrc/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 markersrc/Service/TokenVerifier.php token parsing + Ed25519 verificationsrc/Service/LoginClaims.php verified claims (readonly DTO)src/Service/TokenRejected.php rejection with a short reason codesrc/Migration/Migration1757930000LoginJti.php creates punchout_dev_login_jtisrc/Service/NonceStore.php single-use jti store (INSERT = the check)src/Storefront/Controller/LoginController.php GET /punchout-dev/logintests/TokenVerifierTest.phptests/NonceStoreTest.php runs on in-memory SQLite, no kernel neededtests/LoginControllerTest.php guest fallback, mocks, needs the shop's autoloaderChangelog
Section titled “Changelog”- 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’snext— 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’snextlike 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 namepunchout-dev/shopware-plugin, config domainPunchoutDev.config, route/punchout-dev/login, tablepunchout_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.
punchout.dev · hello@punchout.dev