Zum Inhalt springen

Magento-2-Modul: README

Connects a Magento 2 shop (Open Source and Commerce) to 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.

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

Terminal-Fenster
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.

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:

Terminal-Fenster
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.

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:

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

From the command line:

Terminal-Fenster
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 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.

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

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

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.

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.

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

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:

Terminal-Fenster
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.

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