Login-Token: Spezifikation
A punchout launch redirects the browser to {shop_origin}{login_path}?token=<token>. The plugin verifies the token, logs the browser into the named customer account and redirects to next. This file is the contract; core/login_token.py is the reference implementation and verify_login_token the reference verifier.
Wire format
Abschnitt betitelt „Wire format“pbl1.<b64url(payload)>.<b64url(signature)>b64url is RFC 4648 §5 (-/_) without padding — reject =, +, / and any other character. payload is UTF-8 JSON; signature is 64 bytes of Ed25519 over the ASCII bytes of "pbl1." + <b64url(payload)> (the first two token parts including the dot, not the decoded JSON). Verify the signature against the raw 32-byte public key you were configured with; you never hold a private key and can never mint a token.
Payload
Abschnitt betitelt „Payload“| field | value |
|---|---|
iss |
always punchout-bridge |
kid |
which public key signed this (e.g. k1) — a rotation ships a new kid while the old key stays configured |
aud |
the tenant id (pob_live_…) this shop is onboarded as |
iat, exp |
unix seconds; exp - iat is the TTL (120 s by default) |
jti |
unique token id — the single-use marker |
op |
always punchout_login |
protocol |
oci, cxml or ids — which launch minted it (verifiers accept any non-empty value, so a future protocol needs no plugin change) |
customer |
{"kind": "id"|"number"|"email", "value": "…"} — which shop customer to log in as, kind per the tenant’s configured customer_ref_kind |
next |
relative path to redirect to after the login, e.g. /checkout/cart?__pob_sid=… |
Canonical JSON. The payload is serialised with sorted keys, no whitespace and no ASCII escaping — Python’s json.dumps(obj, separators=(",",":"), sort_keys=True, ensure_ascii=False). Do not re-serialise the payload to check the signature: verify over the received b64url(payload) string exactly as it arrived, and read claims from the parsed object.
What to check, in this order
Abschnitt betitelt „What to check, in this order“- format —
pbl1.prefix, exactly three dot-separated parts, strict unpadded b64url, payload parses as a JSON object, every field above present and of the right type,customer.kindone of the three values. - kid — pick the configured public key; unknown
kid→ reject. (kidselects a key; nothing else in the payload is trusted yet.) - signature — Ed25519 verify; a failure ends it.
iss==punchout-bridge, thenop==punchout_login, thenaud== your configured tenant id (a token minted for another shop must not work here).- validity window — reject if
now > exp + skew(expired) ornow < iat - skew, withskew≤ 60 s. Never widen this: the TTL is what bounds a stolen URL. - single use — store
jti(withexpfor cleanup) and reject ajtiyou have already seen, atomically. A token is spent by the first request that presents it, successful or not. next— must start with/and not with//or/\(both are off-site redirects), and must contain no backslash, whitespace or control character. Redirect only to a path that passes this; never to a value derived from the request.
Response
Abschnitt betitelt „Response“Answer the login request with Cache-Control: no-store and Referrer-Policy: no-referrer, so the token is neither cached nor handed to the next page in Referer. Never log the token, the signature, the jti or customer.value; log a fixed reason word and nothing else. On any failure, fall back to a normal guest landing on next’s path — the punchout itself works without the login, and a hard error would break a working cart.
Test vector
Abschnitt betitelt „Test vector“seed.txt (base64url of the 32-byte Ed25519 seed — test key, never a real one; it is sha256("punchout-bridge login-token test vector")), public_key.txt (what a plugin is configured with), token.txt (one token), claims.json (its decoded payload). The vector verifies for tenant pob_live_test0000000000000000 with kid test at any now between iat and exp; re-minting with those inputs must reproduce token.txt byte for byte.
punchout.dev · hello@punchout.dev