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:
- Snippet embed (
ViewModel/Snippet.php,view/frontend/templates/snippet.phtml) — renders thepob.js<script>tag intobefore.body.endon 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. - 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. - 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
Abschnitt betitelt „Install“Unpack so that the module lands at app/code/PunchoutDev/Bridge, then:
bin/magento module:enable PunchoutDev_Bridgebin/magento setup:upgradebin/magento cache:flush# production mode only:bin/magento setup:di:compile && bin/magento setup:static-content:deploymodule: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— orcache:flushalone can keep serving the previoussnippet.phtml.
Coming from PunchoutNinja_Bridge 1.x
Abschnitt betitelt „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:
bin/magento module:disable PunchoutNinja_Bridgerm -rf app/code/PunchoutNinja# unpack this module to app/code/PunchoutDev/Bridge, then:bin/magento module:enable PunchoutDev_Bridgebin/magento setup:upgradebin/magento cache:flush# production mode only:bin/magento setup:di:compile && bin/magento setup:static-content:deployThe 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
Abschnitt betitelt „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:
<script async src="…/pob.js" data-shop-id="pob_live_…" data-api="…"></script>From the command line:
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.jsbin/magento cache:flushTake 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
Abschnitt betitelt „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
Abschnitt betitelt „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:
<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
Abschnitt betitelt „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):
- Verify: format →
kidselects 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) →nextis a relative path →jtishape →protocol/customer. - Single use:
sha256(jti)is INSERTed intopunchout_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. - 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. - Login:
Magento\Customer\Model\Session::loginById(), which regenerates the session id and dispatchescustomer_login— which is what makesMagento\Checkout\Observer\LoadCustomerQuoteObservermerge the guest quote into the customer’s quote, so the punchout cart survives the login. - Redirect 302 to
scheme://host[:port]of the store’s base URL +next, withCache-Control: no-storeandReferrer-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
Abschnitt betitelt „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.
registration.php, composer.json PunchoutDev_Bridge, punchout-dev/module-bridge 2.0.0etc/module.xml sequence: Magento_Store, Magento_Customer, Magento_Checkoutetc/config.xml defaults: enabled=1, snippet_enabled=1etc/acl.xml PunchoutDev_Bridge::config under Magento_Config::configetc/adminhtml/system.xml Stores → Configuration → punchout.dev → Generaletc/db_schema.xml, db_schema_whitelist.json punchout_dev_login_jti(jti_hash PK, expires_at, index)etc/frontend/routes.xml front name punchout-devetc/frontend/di.xml afterToHtml plugin on Magento\Checkout\Block\Cart\Gridview/frontend/layout/default.xml the snippet block in before.body.endview/frontend/templates/snippet.phtmlController/Login/Index.php GET /punchout-dev/loginModel/Config.php the six settings, normalisedModel/TokenVerifier.php pbl1 verification (ext-sodium only)Model/LoginClaims.php, Model/TokenRejected.phpModel/NonceStore.php single use, via the table's primary keyPlugin/CartMarker.php data-punchout-cartViewModel/Snippet.phpTest/Unit/ PHPUnit, no Magento bootstrapbin/selftest.php standalone token verifier checkThe 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:
php bin/selftest.php # verifier round trip + the shipped vector/path/to/vendor/bin/phpunit -c phpunit.xml.distTest/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
Abschnitt betitelt „Changelog“- 2.0.0 — the technical identifiers carry the punchout.dev name: module
PunchoutDev_Bridge(namespacePunchoutDev\Bridge), composer namepunchout-dev/module-bridge, config pathpunchout_dev/general, route/punchout-dev/login, tablepunchout_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’ssetup_versionand every config path are unchanged. - 1.0.0 — first release: snippet embed, cart marker, single-use login endpoint.
punchout.dev · hello@punchout.dev