WooCommerce-Plugin: README
Connects a WordPress + WooCommerce 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: folder, slug and text domain
punchout-dev, namespace PunchoutDev\WooCommerce, the PUNCHOUT_DEV_*
constants, the punchout_dev_* options and the REST route punchout-dev/v1.
Version 2.0.0. Three features, independent of each other:
- Snippet embed (
src/Snippet.php) — prints thepob.js<script>tag onwp_footer. That is the widget that reads the cart and posts it back to the buyer’s e-procurement system. Every attribute comes from the plugin settings; there is no hard-coded URL anywhere. - Cart marker (
src/CartMarker.php) — wraps the cart in<div data-punchout-cart>, so punchout.dev finds the cart without any cart detection for this shop and survives a theme or WooCommerce redesign. Both cart flavours are covered. - Auto-login (
src/Login/) —GET /wp-json/punchout-dev/v1/login?token=…verifies a signed single-use token minted by punchout.dev, logs the mapped WordPress user 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, WordPress 6.5, WooCommerce 8.0 (tested up to 10.6) and
ext-sodium. No Composer runtime dependency: the plugin ships as a zip and
autoloads its own src/.
Install
Abschnitt betitelt „Install“Plugins → Add New → Upload Plugin → punchout-dev-<version>.zip →
Install → Activate. (Or unzip into wp-content/plugins/punchout-dev and
activate from the plugins list.)
Activation creates one table, {prefix}punchout_dev_login_jti, and two
options, punchout_dev_settings and punchout_dev_db_version. Nothing else in
the database is touched, and deleting the plugin removes all three
(uninstall.php).
If you replace the files without deactivating first (an unzip over the old directory), the activation hook never runs. The plugin notices on the next request and creates the table itself — the settings page says whether it is there.
Coming from punchout-ninja 1.x
Abschnitt betitelt „Coming from punchout-ninja 1.x“WordPress treats 2.0.0 as a new plugin — its folder and main file are new — so nothing is taken over from the old one:
- Deactivate and delete the old plugin (folder
punchout-ninja). Never run both: each would print its own script tag. - Upload and activate this one, as above.
- Enter the settings again from the portal; they are not carried over.
- On the portal’s Auto-login tab, change the login path to
/wp-json/punchout-dev/v1/login(prefixed by the install’s sub-directory, if there is one).
Configuration
Abschnitt betitelt „Configuration“WooCommerce → punchout.dev (capability manage_options). The page opens
with a small brand head (the magenta mark, the wordmark, one line with the
portal link); the form below it is WordPress’s own. Everything lives in one
option, punchout_dev_settings:
| setting | key | meaning |
|---|---|---|
| Embed the snippet | snippet_enabled |
off ⇒ no script tag at all. Default on. The cart marker stays, which is inert on its own. |
| Snippet URL | snippet_url |
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) | api_base |
development or self-hosting only. Empty ⇒ no data-api attribute. |
| Enable auto-login | enabled |
off ⇒ the login route redirects without signing anyone in; the punchout continues as a guest. Default on. |
| Shop ID | 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 | 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>Take the public key lines from the portal’s Auto-login tab (kid=<base64url public key>). 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).
From the command line (no WP-CLI needed):
php -r 'define("WP_USE_THEMES", false); require "/path/to/wp-load.php"; update_option("punchout_dev_settings", [ "enabled" => true, "tenant_id" => "pob_live_…", "public_keys" => "k1=…", "snippet_enabled" => true, "snippet_url" => "https://js.punchout.dev/pob.js", "api_base" => "", ]);'Portal settings for this shop
Abschnitt betitelt „Portal settings for this shop“| portal field | value |
|---|---|
| Auto-login → Login path | /wp-json/punchout-dev/v1/login, prefixed by the install’s sub-directory |
| Auto-login → Customer reference | email address (recommended), customer number (= the WordPress login name) or customer ID (= the numeric WordPress user id) |
| General → Landing path | the cart page, e.g. /cart/ |
The settings page prints the exact login path for this install, sub-directory
included. next is used verbatim: the plugin only makes it absolute against
the site’s own scheme, host and port, it never prepends the sub-path — so for an
install under /wordpress-6.9.7/ punchout.dev must mint
next = /wordpress-6.9.7/cart/?__pob_sid=….
The cart marker
Abschnitt betitelt „The cart marker“[data-punchout-cart] takes precedence over any detected cart, so a shop
running this plugin needs no cart detection at all.
Block cart — filter render_block, priority 20, block woocommerce/cart:
<div data-punchout-cart> <div data-block-name="woocommerce/cart" class="wp-block-woocommerce-cart …">…</div></div>Priority 20 rather than the default 10 is deliberate: WooCommerce Blocks itself
filters render_block at 10 to stamp data-block-name onto the block’s
outermost tag, and wrapping first would hand our div those attributes.
The server HTML of that block is an empty shell — the line items are rendered by React from the Store API after load, inside the wrapper. Wrapping the block rather than an inner element is what makes the marker useful: the inner nodes do not exist yet when the filter runs, and the snippet waits for them before it captures.
Classic shortcode cart — woocommerce_before_cart_table (priority 0) opens
the div and woocommerce_after_cart_table (priority 100) closes it, wrapping
exactly the shop_table: line items and their column header, and nothing of the
totals, the coupon form or the checkout button.
Never the mini cart, never the checkout, never the order-received page. The
wrapper is a plain div with no class, style or id, which is layout-neutral.
Setup self-test
Abschnitt betitelt „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 (
…/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 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 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 reports Preset or Detected automatically instead, the
marker did not reach the page — the usual causes are a page that does not use
the woocommerce/cart block at all, or a caching plugin serving an older copy.
The login endpoint
Abschnitt betitelt „The login endpoint“Why it exists
Abschnitt betitelt „Why it exists“Without it a punchout buyer shops as a guest: no customer-specific prices, no contract assortment. The bridge knows which WordPress user 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--> /wp-json/punchout-dev/v1/login?token=…plugin verify → jti → resolve user → refuse admins → wp_set_auth_cookie() --302--> /cart/?__pob_sid=… (logged in) --302--> /cart/?__pob_sid=… (any failure: guest punchout)Every failure is a redirect, never an error page and never wp-login.php. 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 /.
The route has permission_callback => __return_true and no nonce: the token
is the credential. A nonce would be pointless — the request arrives from the
buyer’s procurement system, which has never seen this WordPress install, and the
token is already signed, audience-scoped, single-use and two minutes old at
most.
Token format
Abschnitt betitelt „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 |
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/Login/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 endpoint (src/Login/NonceStore.php), then user
resolution, then the administrator refusal.
User resolution
Abschnitt betitelt „User resolution“customer.kind |
resolved against | note |
|---|---|---|
email |
user_email |
recommended |
number |
user_login |
WooCommerce has no customer number. A WordPress user is identified by its login name, which is what a shop puts in the ERP’s customer-number field. |
id |
the numeric WordPress user id | the bridge’s id kind is opaque; anything that is not a plain positive integer simply does not resolve, which is the guest fallback. |
An administrator is never logged in this way. Any user with
manage_options is refused (reason administrator_refused) and gets a guest
punchout. The token only proves which shop customer a procurement account maps
to; handing a browser that arrived from a third-party system a session with
manage_options would turn a convenience feature into a takeover of the whole
site. A shop whose buyer really is an admin needs a second, ordinary customer
account.
On success the plugin calls wp_set_current_user(), wp_set_auth_cookie() and
then do_action('wp_login', …) — the last of which is how WooCommerce merges
the guest cart into the customer’s persistent cart.
Interop with the bridge
Abschnitt betitelt „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, administrator_refused, …) — 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.
The plugin is byte-for-byte compatible with the Shopware one: same wire format, same check order, same reason vocabulary.
?next= on the endpoint
Abschnitt betitelt „?next= on the endpoint“The endpoint accepts an optional next query parameter. It is used for one
thing only — as the guest-fallback target when there is no usable next inside
the token (a health check, a hand-written link, a token too mangled to parse).
It goes through exactly the same path validation as a signed next, so it can
never be anything but a same-origin relative path, and it never overrides a
verified token’s next.
Security notes
Abschnitt betitelt „Security notes“- Single use.
sha256(jti)is inserted into{prefix}punchout_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. A token is spent by the first request that presents it, successful or not — an administrator’s token is burned by the refusal. The store fails closed: if the marker cannot be written for any reason (the table is missing, the database is down) the login is not granted and the punchout continues as a guest. Markers are swept opportunistically: everyconsume()first deletes rows whoseexpires_athas passed, which keeps the table at roughly one TTL’s worth of logins. A failed sweep is never fatal. A transient would have been the wrong shape for this: an object cache is allowed to forget, and forgetting here means a granted replay — andget_transient()+set_transient()is not atomic either. - 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.
A short reason code goes to
error_log()and only when the site runs withWP_DEBUG. Responses carryCache-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 and to the?next=query parameter, so even a forged token can only ever redirect within this shop — andwp_safe_redirect()is the belt to that braces. - Administrators are refused, see above.
Development
Abschnitt betitelt „Development“Self-test (no WordPress bootstrap)
Abschnitt betitelt „Self-test (no WordPress bootstrap)“bin/selftest.php needs only PHP 8.1+, 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.
Unit tests
Abschnitt betitelt „Unit tests“The classes under test are pure PHP; tests/bootstrap.php loads them directly
and stubs the handful of WordPress escaping helpers they call, so the suite
needs no shop:
docker exec <php-container> sh -lc \ 'cd /var/www/html/<wp>/wp-content/plugins/punchout-dev && phpunit -c phpunit.xml.dist'NonceStoreTest drives the very same SQL against tests/FakeWpdb.php, an
in-memory SQLite double of the four $wpdb members NonceStore uses (skipped
without pdo_sqlite). What it asserts is the behaviour that matters — the
INSERT is the single-use check and every failure is closed — not the MySQL DDL,
which Activator owns.
punchout-dev.php plugin header, constants, autoloader, hooksuninstall.php drops the table and both optionsphpunit.xml.distbin/selftest.php standalone verifier self-testsrc/Settings.php WooCommerce → punchout.dev (Settings API)src/Snippet.php pob.js embed on wp_footersrc/CartMarker.php data-punchout-cart, block cart + classic cartsrc/Activator.php dbDelta of the jti table, defaults, upgrade guardsrc/Login/TokenVerifier.php token parsing + Ed25519 verificationsrc/Login/LoginClaims.php verified claims (readonly DTO)src/Login/TokenRejected.php rejection with a short reason codesrc/Login/NonceStore.php single-use jti store (INSERT = the check)src/Login/Endpoint.php GET /wp-json/punchout-dev/v1/logintests/bootstrap.php WordPress stubstests/FakeWpdb.php in-memory SQLite double of $wpdbtests/TokenVerifierTest.php incl. the bridge reference vectortests/NonceStoreTest.phptests/SettingsTest.phptests/RenderTest.php the script tag and the cart markerHooks used
Abschnitt betitelt „Hooks used“| hook | what for |
|---|---|
plugins_loaded |
wire the four features up, table upgrade guard |
init |
load_plugin_textdomain() |
admin_menu (99) |
the submenu under WooCommerce |
admin_init |
register_setting / sections / fields |
wp_footer (99) |
the pob.js script tag |
render_block (20) |
the data-punchout-cart wrapper on the cart block |
woocommerce_before_cart_table (0) |
opens the wrapper on the classic cart |
woocommerce_after_cart_table (100) |
closes it |
rest_api_init |
registers punchout-dev/v1/login |
plugin_action_links_… |
the Settings link on the plugins list |
before_woocommerce_init |
declares HPOS compatibility |
| activation hook | dbDelta of the jti table, default option |
Changelog
Abschnitt betitelt „Changelog“- 2.0.0 — the technical identifiers carry the product name: folder, slug
and text domain
punchout-dev, namespacePunchoutDev\WooCommerce, thePUNCHOUT_DEV_*constants, thepunchout_dev_*options, the table{prefix}punchout_dev_login_jtiand the REST route/wp-json/punchout-dev/v1/login. WordPress installs it as a new plugin; see the upgrade steps under Install. No behaviour change. - 1.0.1 — shown as punchout.dev (plugin header, menu entry, settings page); the settings page speaks the portal’s words — Shop ID, Snippet URL, API base URL, Public key lines, Auto-login — and carries a small brand head with the portal link. No behaviour change; every technical identifier is unchanged.
- 1.0.0 — first release: snippet embed, cart marker for the block and classic carts, single-use login endpoint.
punchout.dev · hello@punchout.dev