// Paste this once per session, then call zpDecode(token).
window.zpDecode = function (jwt) {
const parts = String(jwt).trim().split(".");
if (parts.length !== 3) {
throw new Error("Not a JWT: expected 3 dot-separated segments, got " + parts.length);
}
const b64urlDecode = (s) => {
const pad = "=".repeat((4 - (s.length % 4)) % 4);
const b64 = (s + pad).replace(/-/g, "+").replace(/_/g, "/");
return decodeURIComponent(
atob(b64).split("").map((c) =>
"%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)
).join("")
);
};
const header = JSON.parse(b64urlDecode(parts[0]));
const claims = JSON.parse(b64urlDecode(parts[1]));
const nowSec = Math.floor(Date.now() / 1000);
const checks = {
alg_is_RS256: header.alg === "RS256",
aud_is_zennopay_checkout: claims.aud === "zennopay-checkout",
has_zennopay_intent_id: typeof claims["zennopay:intent_id"] === "string",
has_zennopay_amount: Number.isInteger(claims["zennopay:amount_usd_cents"]),
corridor_is_supported:
claims["zennopay:corridor"] === "th_promptpay" ||
claims["zennopay:corridor"] === "vn_vietqr",
has_jti: typeof claims.jti === "string" && claims.jti.length > 0,
iat_present: typeof claims.iat === "number",
exp_present: typeof claims.exp === "number",
not_expired: typeof claims.exp === "number" && claims.exp > nowSec,
session_window_ok:
typeof claims.exp === "number" &&
typeof claims.iat === "number" &&
claims.exp - claims.iat <= 600,
iat_fresh:
typeof claims.iat === "number" && nowSec - claims.iat <= 900,
};
console.log("%cheader", "font-weight:bold", header);
console.log("%cclaims", "font-weight:bold", claims);
console.log("%cchecks (client-side only — server still verifies signature, jti, JWKS, intent match)", "font-weight:bold", checks);
return { header, claims, checks };
};
console.log("zpDecode loaded. Call zpDecode('<jwt>')");