Zum Inhalt springen

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:

  1. Snippet embed (src/Snippet.php) — prints the pob.js <script> tag on wp_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.
  2. 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.
  3. 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/.

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.

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:

  1. Deactivate and delete the old plugin (folder punchout-ninja). Never run both: each would print its own script tag.
  2. Upload and activate this one, as above.
  3. Enter the settings again from the portal; they are not carried over.
  4. 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).

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

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

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

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 (…/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 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.

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.

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.

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.

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:

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

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.

  • Single use. sha256(jti) is inserted into {prefix}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. 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: every consume() first deletes rows whose expires_at has 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 — and get_transient() + set_transient() is not atomic either.
  • 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. A short reason code goes to error_log() and only when the site runs with WP_DEBUG. 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 and to the ?next= query parameter, so even a forged token can only ever redirect within this shop — and wp_safe_redirect() is the belt to that braces.
  • Administrators are refused, see above.

bin/selftest.php needs only PHP 8.1+, ext-sodium and ext-json, so it can be dropped into any shop container.

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

Terminal-Fenster
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, hooks
uninstall.php drops the table and both options
phpunit.xml.dist
bin/selftest.php standalone verifier self-test
src/Settings.php WooCommerce → punchout.dev (Settings API)
src/Snippet.php pob.js embed on wp_footer
src/CartMarker.php data-punchout-cart, block cart + classic cart
src/Activator.php dbDelta of the jti table, defaults, upgrade guard
src/Login/TokenVerifier.php token parsing + Ed25519 verification
src/Login/LoginClaims.php verified claims (readonly DTO)
src/Login/TokenRejected.php rejection with a short reason code
src/Login/NonceStore.php single-use jti store (INSERT = the check)
src/Login/Endpoint.php GET /wp-json/punchout-dev/v1/login
tests/bootstrap.php WordPress stubs
tests/FakeWpdb.php in-memory SQLite double of $wpdb
tests/TokenVerifierTest.php incl. the bridge reference vector
tests/NonceStoreTest.php
tests/SettingsTest.php
tests/RenderTest.php the script tag and the cart marker
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
  • 2.0.0 — the technical identifiers carry the product name: folder, slug and text domain punchout-dev, namespace PunchoutDev\WooCommerce, the PUNCHOUT_DEV_* constants, the punchout_dev_* options, the table {prefix}punchout_dev_login_jti and 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.