# Shopware-Plugin: README

Die README aus dem Shopware-6-Plugin, auf Englisch: Snippet-Einbindung, Warenkorb-Markierung, Login-Endpunkt, Konfiguration, Entwicklung und Changelog.

URL: https://punchout.dev/de/docs/plugins/shopware/readme/

:::note
Diese Referenz gibt es nur auf Englisch. Sie wird bei jedem Build unverändert aus ihrer Quelldatei im Repository übernommen.
:::

Connects a Shopware 6.7 shop to [punchout.dev](https://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).

## The snippet embed

### 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:

```sh
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:

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

### 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`:

```twig
{% 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

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`).

## The login endpoint

### 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.

### Flow

```
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

```
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

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:

```sh
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.

### Install

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

```sh
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.

### 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:

```sh
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](#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

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](#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 `kid`s are rejected outright (reason `kid_unknown`).

### 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

- **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.

## Development

### 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.

```sh
# 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:

```sh
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

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

```sh
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:

```sh
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

```
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
```

## 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'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.
