# Magento 2 module README

The README shipped inside the Magento 2 module PunchoutDev_Bridge: install, configuration, cart marker, login endpoint, CSP, tests and changelog.

URL: https://punchout.dev/docs/plugins/magento/readme/

Connects a Magento 2 shop (Open Source and Commerce) to
[punchout.dev](https://punchout.dev/): the snippet, the cart marker and the buyer
auto-login. Every value the module needs comes from the portal. The technical
identifiers carry the same name: the module name `PunchoutDev_Bridge`, the
composer name `punchout-dev/module-bridge`, the config path
`punchout_dev/general`, the ACL resource `PunchoutDev_Bridge::config` and the
route `/punchout-dev/login`.

Version **2.0.0**. Three features, independent of each other:

1. **Snippet embed** (`ViewModel/Snippet.php`, `view/frontend/templates/snippet.phtml`) —
   renders the `pob.js` `<script>` tag into `before.body.end` on every storefront
   page. That is the widget that reads the cart and posts it back to the buyer's
   e-procurement system. Every attribute comes from the module configuration;
   there is no hard-coded URL anywhere.
2. **Cart marker** (`Plugin/CartMarker.php`) — wraps the cart form in
   `<div data-punchout-cart>`, so punchout.dev finds the cart without any cart
   detection for this shop and survives a theme or Magento upgrade.
3. **Auto-login** (`Controller/Login/Index.php`, `Model/`) —
   `GET /punchout-dev/login?token=…` verifies a signed single-use token minted
   by punchout.dev, logs the mapped customer in and redirects to the shop path
   carried in the token (normally the cart page with `?__pob_sid=…`, where the
   punchout session continues).

Requires PHP 8.1+ with `ext-sodium` and `ext-json`, and Magento 2.4. **No
Composer runtime dependency**: the module ships as a folder that goes into
`app/code` and autoloads through `registration.php`.

## Install

Unpack so that the module lands at `app/code/PunchoutDev/Bridge`, then:

```sh
bin/magento module:enable PunchoutDev_Bridge
bin/magento setup:upgrade
bin/magento cache:flush
# production mode only:
bin/magento setup:di:compile && bin/magento setup:static-content:deploy
```

`module:enable` adds one entry to `app/etc/config.php`; `setup:upgrade` creates
one table, `punchout_dev_login_jti`, from `etc/db_schema.xml` and one
`setup_module` row. Nothing else in the database is touched.

> After *replacing* the module files in place, clear Magento's preprocessed
> template copy as well — `rm -rf var/view_preprocessed/pub/static/app/code/PunchoutDev`
> — or `cache:flush` alone can keep serving the previous `snippet.phtml`.

### Coming from PunchoutNinja_Bridge 1.x

To Magento, 2.0.0 is a new module, not an upgrade. Take the old one out first —
never run both, or every storefront page carries two script tags:

```sh
bin/magento module:disable PunchoutNinja_Bridge
rm -rf app/code/PunchoutNinja
# unpack this module to app/code/PunchoutDev/Bridge, then:
bin/magento module:enable PunchoutDev_Bridge
bin/magento setup:upgrade
bin/magento cache:flush
# production mode only:
bin/magento setup:di:compile && bin/magento setup:static-content:deploy
```

The old `punchout_ninja/general/*` rows are not read: enter the values from the
portal again under **Stores → Configuration → punchout.dev** (see Configuration).
The portal's Auto-login → **Login path** becomes `/punchout-dev/login`. What 1.x
left in the database — the table `punchout_ninja_login_jti`, its `setup_module`
row and those config rows — is unused and can be removed by hand.

## Configuration

**Stores → Configuration → punchout.dev → General** (ACL resource
`PunchoutDev_Bridge::config`). Six values under `punchout_dev/general/`, each
settable at default, website and store-view scope:

| setting                        | path                              | meaning                                                                                                        |
|--------------------------------|-----------------------------------|----------------------------------------------------------------------------------------------------------------|
| **Embed the snippet**          | `punchout_dev/general/snippet_enabled`   | No ⇒ no script tag at all. Default Yes. The cart marker stays, which is inert on its own.                  |
| **Snippet URL**                | `punchout_dev/general/snippet_url`       | the `pob.js` URL from the embed tag in the portal, `https://js.punchout.dev/pob.js`. Empty or non-http(s) ⇒ nothing is embedded. |
| **API base URL (optional)**    | `punchout_dev/general/api_base`          | development or self-hosting only. Empty ⇒ no `data-api` attribute.                                          |
| **Enable auto-login**          | `punchout_dev/general/enabled`           | No ⇒ the login route redirects without signing anyone in; the punchout continues as a guest. Default Yes.   |
| **Shop ID**                    | `punchout_dev/general/tenant_id`         | 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**           | `punchout_dev/general/public_keys`       | one `kid=<base64url public key>` per line, exactly as the portal's Auto-login tab shows it (Ed25519, 32 raw bytes, unpadded). `#` comments and blank lines ignored.|

`data-shop-id` is **not** a field of its own: the snippet reuses the shop ID,
because it is the same shop on both legs. No shop ID ⇒ no script tag.

The rendered tag, with every value escaped:

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

From the command line:

```sh
bin/magento config:set punchout_dev/general/tenant_id   pob_live_…
bin/magento config:set punchout_dev/general/public_keys "k1=<base64url public key>"
bin/magento config:set punchout_dev/general/snippet_url https://js.punchout.dev/pob.js
bin/magento cache:flush
```

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; an
unknown `kid` is rejected outright (reason `kid_unknown`).

### Portal settings for this shop

| portal field                         | value                                                                                |
|--------------------------------------|----------------------------------------------------------------------------------------|
| Auto-login → **Login path**          | `/punchout-dev/login`, prefixed by the install's sub-directory and `pub/` if it has one   |
| Auto-login → **Customer reference**  | email address (recommended) or customer ID (= the numeric customer entity id). **Customer number is not supported** |
| General → **Landing path**           | the cart page, e.g. `/checkout/cart/`                                                    |

`next` is used **verbatim**: the module only makes it absolute against the
store's own scheme, host and port, it never prepends the sub-path — so for an
install served from `/magento2-2.4.8/pub/` punchout.dev must mint
`next = /magento2-2.4.8/pub/checkout/cart/?__pob_sid=…`.

**Why `number` is unsupported.** Magento has no customer number: `increment_id`
belongs to orders, and there is no shipped customer attribute the ERP's
customer-number field maps to. A token with `customer.kind = "number"` is
therefore refused with reason `unsupported_ref_kind` and the buyer gets a guest
punchout, which still works. Map the buyer credential to the customer's e-mail
address or entity id instead.

## The cart marker

`[data-punchout-cart]` takes precedence over any detected cart, so a shop
running this module needs no cart detection at all.

`etc/frontend/di.xml` puts an `afterToHtml` interceptor on
`Magento\Checkout\Block\Cart\Grid` — the block the cart page layout
(`Magento_Checkout::view/frontend/layout/checkout_cart_index.xml`) registers as
`checkout.cart.form` with the template `Magento_Checkout::cart/form.phtml`:

```html
<div data-punchout-cart>
  <form action="…/checkout/cart/updatePost/" id="form-validate" class="form form-cart">
    <table id="shopping-cart-table" class="cart items data table">…</table>
  </form>
</div>
```

A block interceptor rather than a layout change, because the block class is the
same in Luma and in Hyvä (different templates, same class) — the marker follows
the data, not the theme.

**It is guarded by the block's layout name.** `Grid` is a generic class, and only
the instance named `checkout.cart.form` holds the real cart; every other
instance a theme or extension creates (a mini cart, a widget, a second listing)
is left alone, so a page can never carry two markers. The wrapper is a plain
`div` with no class, no id and no style: on the cart page it is an ordinary
block-level element around another block-level element, which is layout-neutral.

Never the mini-cart, never the checkout, never the admin: `etc/frontend/di.xml`
is a frontend-only file, and an empty cart (where `Grid` renders nothing) is left
unwrapped — an empty marker would only tell the bridge to capture nothing.

## The login endpoint

```
GET /punchout-dev/login?token=pbl1.<payload>.<sig>
```

Route `punchout_dev`, front name `punchout-dev`, declared in
`etc/frontend/routes.xml` so it can never resolve in the admin area. The
controller implements `HttpGetActionInterface` and returns a
`Magento\Framework\Controller\Result\Redirect` in every case.

**No form key, by design.** This is a GET and the *token* is the credential: the
request arrives from the buyer's procurement system, which has never seen this
Magento install and can hold no form key, and the token is already signed,
audience-scoped, single-use and two minutes old at most. Magento only validates
the form key on state-changing requests, so `CsrfAwareActionInterface` would be
noise.

**No administrator refusal is needed or possible.** Magento keeps admin users in
`admin_user` and shop customers in `customer_entity`, and
`Magento\Customer\Model\Session::loginById()` can only ever authenticate the
latter. A token cannot reach the backend however it is crafted.

What happens, in order (`Model/TokenVerifier.php`, the contract is
`fixtures/login-token/README.md` in the bridge repo):

1. **Verify**: format → `kid` selects a configured public key → Ed25519 signature
   over the bytes as received → `iss` / `op` → `aud` == the configured shop ID
   → validity window (`iat` +60 s skew, `exp` +0 s) → `next` is a relative path →
   `jti` shape → `protocol` / `customer`.
2. **Single use**: `sha256(jti)` is INSERTed into `punchout_dev_login_jti`,
   whose primary key is that hash. The INSERT *is* the check, so two concurrent
   presentations race in InnoDB rather than in PHP. The token is spent by the
   first request that presents it, successful or not.
3. **Customer**: `email` → `CustomerRepositoryInterface::get($email, $websiteId)`
   of the current store; `id` → `getById()` plus a website check; `number` →
   refused (see above). A customer still pending e-mail confirmation is refused.
4. **Login**: `Magento\Customer\Model\Session::loginById()`, which regenerates the
   session id and dispatches `customer_login` — which is what makes
   `Magento\Checkout\Observer\LoadCustomerQuoteObserver` merge the guest quote
   into the customer's quote, so the punchout cart survives the login.
5. **Redirect** 302 to `scheme://host[:port]` of the store's base URL + `next`,
   with `Cache-Control: no-store` and `Referrer-Policy: no-referrer`.

**Every failure is a guest redirect**, never an error page and never the login
form: disabled, not configured, a missing or bad token, replay, unknown customer,
unconfirmed customer, wrong website, unsupported ref kind, a failed
`loginById()`. The punchout works without the login; a hard error would break a
working cart. The target is the token's own `next` (structurally validated even
when the signature failed — see `TokenVerifier::peekNext()`), else the `next`
query parameter, else `/`.

Reason codes are logged at info level through the PSR logger
(`var/log/system.log`): `disabled`, `not_configured`, `token_missing`, the
verifier's own codes (`format`, `payload_b64`, `payload_json`,
`signature_b64`, `signature_length`, `signature_invalid`, `kid_missing`,
`kid_unknown`, `key_invalid`, `issuer`, `operation`, `audience`, `iat_invalid`,
`iat_future`, `exp_invalid`, `expired`, `next_invalid`, `jti_invalid`,
`protocol_invalid`, `customer_invalid`, `customer_kind`, `customer_value`),
`replay`, `unsupported_ref_kind`, `customer_unresolved`,
`customer_wrong_website`, `customer_unconfirmed`, `store_unresolved`,
`login_failed`. **The token, the signature and `customer.value` are never
logged.**

## Content-Security-Policy

Magento 2.4 ships `Magento_Csp`. Whether the storefront policy only *reports*
violations or *restricts* them is the shop's own setting; a storefront that
restricts must allow four things, or the widget breaks at a different step for
each one:

| directive     | allow                                                                  | because                                                     |
|---------------|------------------------------------------------------------------------|-------------------------------------------------------------|
| `script-src`  | the snippet host, `js.punchout.dev`                                    | the `pob.js` tag                                            |
| `connect-src` | the API host, `api.punchout.dev` (the **API base URL**'s host when one is set) | every call the widget makes: session pickup, cart reading, transfer |
| `style-src`   | `'unsafe-inline'`                                                      | the widget appends a `<style>` element to its shadow root   |
| `form-action` | the host of every buyer system that punches out here — its OCI `HOOK_URL`, cXML `BrowserFormPost` or IDS `hookurl` | the cart goes back to the buyer as a form POST from the shop page |

The module ships **no `csp_whitelist.xml`** and declares nothing else for
`Magento_Csp` either, because none of these values is fixed: the snippet and API
hosts follow the **Snippet URL** and **API base URL** settings, and the buyer
hosts differ per buyer. Put the hosts (`script-src`, `connect-src`,
`form-action`, each `type="host"`) into a `csp_whitelist.xml` of the shop's own
module. `'unsafe-inline'` is not a host and cannot go into a whitelist at all: it
is governed by the storefront policy's inline-styles setting in `Magento_Csp`'s
own configuration (`csp/policies/storefront/styles/inline`) — check that it is
enabled for the storefront, and turn it back on if a hardening step disabled it.

## Layout

```
registration.php, composer.json          PunchoutDev_Bridge, punchout-dev/module-bridge 2.0.0
etc/module.xml                           sequence: Magento_Store, Magento_Customer, Magento_Checkout
etc/config.xml                           defaults: enabled=1, snippet_enabled=1
etc/acl.xml                              PunchoutDev_Bridge::config under Magento_Config::config
etc/adminhtml/system.xml                 Stores → Configuration → punchout.dev → General
etc/db_schema.xml, db_schema_whitelist.json   punchout_dev_login_jti(jti_hash PK, expires_at, index)
etc/frontend/routes.xml                  front name punchout-dev
etc/frontend/di.xml                      afterToHtml plugin on Magento\Checkout\Block\Cart\Grid
view/frontend/layout/default.xml         the snippet block in before.body.end
view/frontend/templates/snippet.phtml
Controller/Login/Index.php               GET /punchout-dev/login
Model/Config.php                         the six settings, normalised
Model/TokenVerifier.php                  pbl1 verification (ext-sodium only)
Model/LoginClaims.php, Model/TokenRejected.php
Model/NonceStore.php                     single use, via the table's primary key
Plugin/CartMarker.php                    data-punchout-cart
ViewModel/Snippet.php
Test/Unit/                               PHPUnit, no Magento bootstrap
bin/selftest.php                         standalone token verifier check
```

## Tests

The classes under test are pure PHP; the suite stubs the handful of Magento
types they type-hint (`Test/Unit/magento-stubs.php`) and runs `NonceStore`
against in-memory SQLite, so no shop is booted and no database is needed. The
module ships no Composer dev dependency — use any PHPUnit 10+ that is around:

```sh
php bin/selftest.php                       # verifier round trip + the shipped vector
/path/to/vendor/bin/phpunit -c phpunit.xml.dist
```

`Test/Unit/TokenVerifierTest.php` asserts the reference vector from
`fixtures/login-token/` byte for byte — it is the interop lock between this
module and the bridge's Python implementation, and it must never be relaxed.

`bin/selftest.php --verify <token> <kid=b64url-public-key> <tenant-id> [now]`
verifies one live token inside the shop container, which is the quickest way to
tell a configuration problem from a signing problem.

## Changelog

- **2.0.0** — the technical identifiers carry the punchout.dev name: module
  `PunchoutDev_Bridge` (namespace `PunchoutDev\Bridge`), composer name
  `punchout-dev/module-bridge`, config path `punchout_dev/general`, route
  `/punchout-dev/login`, table `punchout_dev_login_jti`. A new module to
  Magento — see the 1.x section under Install. No change in behaviour.
- **1.0.1** — shown as punchout.dev (the configuration tab and section, the ACL
  title); the configuration speaks the portal's words — Shop ID, Snippet URL, API
  base URL, Public key lines, Auto-login. Wording only: `module.xml`'s
  `setup_version` and every config path are unchanged.
- **1.0.0** — first release: snippet embed, cart marker, single-use login
  endpoint.
