# Login token specification

The pbl1 login token a custom shop plugin verifies for auto-login: wire format, payload, the checks in order, the response and the test vector.

URL: https://punchout.dev/docs/plugins/custom/login-token/

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

    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

| 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

1. **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.kind` one of the three values.
2. **kid** — pick the configured public key; unknown `kid` → reject. (`kid` selects a key; nothing else in the payload is trusted yet.)
3. **signature** — Ed25519 verify; a failure ends it.
4. **`iss` == `punchout-bridge`**, then **`op` == `punchout_login`**, then **`aud` == your configured tenant id** (a token minted for another shop must not work here).
5. **validity window** — reject if `now > exp + skew` (*expired*) or `now < iat - skew`, with `skew` ≤ 60 s. Never widen this: the TTL is what bounds a stolen URL.
6. **single use** — store `jti` (with `exp` for cleanup) and reject a `jti` you have already seen, atomically. A token is spent by the first request that presents it, successful or not.
7. **`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

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

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