{"tpkg":1,"name":"wings_jwt","version":"0.1.0","entry":"wings_jwt.tpr","files":[{"path":"example.tpr","content":"// wings_jwt example — login issues a token, /me requires it.\n//\n// tulpar build example.tpr && ./example\n// curl -s -X POST localhost:8484/login\n// curl -s localhost:8484/me -H \"Authorization: Bearer \"\n//\n// (Run from this directory so `import \"wings_jwt\"` resolves the sibling\n// wings_jwt.tpr; once published, `tulpar pkg add wings_jwt` handles it.)\n\nimport \"wings\";\nimport \"wings_jwt\" as jwt;\n\nstr SECRET = \"demo-secret-change-me\";\n\n// In a real app you'd check credentials here; we just mint a token.\nfunc login(req) {\n str token = jwt.sign_ttl({\"sub\": \"42\", \"role\": \"admin\"}, SECRET, 3600);\n return ok({\"token\": token});\n}\n\nfunc me(req) {\n json v = jwt.verify(jwt.from_header(req), SECRET);\n if (v[\"ok\"] == 0) {\n return unauthorized({\"error\": v[\"error\"]});\n }\n return ok({\"user\": v[\"claims\"][\"sub\"], \"role\": v[\"claims\"][\"role\"]});\n}\n\nfunc main() {\n post(\"/login\", login);\n get(\"/me\", me);\n serve();\n}\n"},{"path":"wings_jwt.test.tpr","content":"import \"test\";\nimport \"wings_jwt\";\n\n// Round-trip: a signed token verifies and returns the original claims.\nfunc run_roundtrip() {\n str tok = sign({\"sub\": \"42\", \"role\": \"admin\"}, \"topsecret\");\n json v = verify(tok, \"topsecret\");\n assert_eq_bool(v[\"ok\"] == 1, true);\n assert_eq_str(v[\"claims\"][\"sub\"], \"42\");\n assert_eq_str(v[\"claims\"][\"role\"], \"admin\");\n}\n\n// A token has exactly three dot-separated segments.\nfunc run_shape() {\n str tok = sign({\"a\": 1}, \"k\");\n json parts = split(tok, \".\");\n assert_eq_int(length(parts), 3);\n}\n\n// Wrong secret → signature mismatch, not a false accept.\nfunc run_wrong_secret() {\n str tok = sign({\"sub\": \"x\"}, \"right\");\n json v = verify(tok, \"wrong\");\n assert_eq_bool(v[\"ok\"] == 0, true);\n assert_eq_str(v[\"error\"], \"bad signature\");\n}\n\n// Tampering with the payload invalidates the signature.\nfunc run_tamper() {\n str tok = sign({\"role\": \"user\"}, \"k\");\n json parts = split(tok, \".\");\n // swap in a forged \"admin\" payload, keep the original signature\n str forged = parts[0] + \".\" + base64_encode(\"{\\\"role\\\":\\\"admin\\\"}\") + \".\" + parts[2];\n json v = verify(forged, \"k\");\n assert_eq_bool(v[\"ok\"] == 0, true);\n}\n\n// Malformed tokens are rejected, never crash.\nfunc run_malformed() {\n assert_eq_bool(verify(\"\", \"k\")[\"ok\"] == 0, true);\n assert_eq_bool(verify(\"only.two\", \"k\")[\"ok\"] == 0, true);\n assert_eq_str(verify(\"a.b.c.d\", \"k\")[\"error\"], \"malformed token\");\n}\n\n// sign_ttl stamps exp/iat; a positive ttl verifies, a past ttl is expired.\nfunc run_expiry() {\n str fresh = sign_ttl({\"sub\": \"1\"}, \"k\", 3600);\n assert_eq_bool(verify(fresh, \"k\")[\"ok\"] == 1, true);\n\n str stale = sign_ttl({\"sub\": \"1\"}, \"k\", 0 - 10);\n json v = verify(stale, \"k\");\n assert_eq_bool(v[\"ok\"] == 0, true);\n assert_eq_str(v[\"error\"], \"expired\");\n}\n\n// decode() reads claims without verifying (inspection only).\nfunc run_decode() {\n str tok = sign({\"sub\": \"99\", \"n\": 7}, \"k\");\n json c = decode(tok);\n assert_eq_str(c[\"sub\"], \"99\");\n assert_eq_int(c[\"n\"], 7);\n}\n\n// from_header() pulls the bearer token out of a request's headers.\nfunc run_from_header() {\n json req = {\"headers\": {\"Authorization\": \"Bearer abc.def.ghi\"}};\n assert_eq_str(from_header(req), \"abc.def.ghi\");\n json req2 = {\"headers\": {\"authorization\": \"xyz\"}};\n assert_eq_str(from_header(req2), \"xyz\");\n json req3 = {\"headers\": {}};\n assert_eq_str(from_header(req3), \"\");\n}\n\nprint(\"=== wings_jwt (HS256 signed tokens) ===\");\ntest(\"round-trip sign/verify\", \"run_roundtrip\");\ntest(\"token has 3 segments\", \"run_shape\");\ntest(\"wrong secret rejected\", \"run_wrong_secret\");\ntest(\"payload tamper rejected\", \"run_tamper\");\ntest(\"malformed token rejected\", \"run_malformed\");\ntest(\"ttl iat/exp + expiry\", \"run_expiry\");\ntest(\"decode without verify\", \"run_decode\");\ntest(\"bearer from header\", \"run_from_header\");\ntest_summary();\n"},{"path":"wings_jwt.tpr","content":"// wings_jwt — HMAC-SHA256 signed tokens for TulparLang / wings apps.\n//\n// Compact `header.payload.signature` tokens with base64url-encoded JSON\n// segments, signed with HS256 (HMAC-SHA256). Built entirely on the\n// runtime's `hmac_sha256` + `base64_encode/decode` primitives — no\n// external dependency, no DB, no FFI.\n//\n// Token shape (Tulpar-native HS256 variant):\n// base64url({\"alg\":\"HS256\",\"typ\":\"JWT\"})\n// . base64url()\n// . base64url(hmac_sha256(secret, \"
.\"))\n//\n// The signature segment encodes the *hex* HMAC digest, so tokens are\n// self-issued + self-verified by Tulpar services. (Wire-interop with\n// external RFC-7519 validators — which base64url the *raw* MAC bytes —\n// is a roadmap item; it needs a raw-bytes base64 path in the runtime.)\n//\n// Typical use (wings):\n// import \"wings\";\n// import \"wings_jwt\" as jwt;\n//\n// func login(req) {\n// // ... verify credentials ...\n// str token = jwt.sign_ttl({\"sub\": \"42\", \"role\": \"admin\"}, SECRET, 3600);\n// return ok({\"token\": token});\n// }\n//\n// func me(req) {\n// json v = jwt.verify(jwt.from_header(req), SECRET);\n// if (v[\"ok\"] == 0) { return unauthorized({\"error\": v[\"error\"]}); }\n// return ok({\"user\": v[\"claims\"][\"sub\"]});\n// }\n\n// ---- internal: key presence (a missing JSON key reads back as int 0,\n// so we can't distinguish \"absent\" from \"0\" without an explicit check) ----\nfunc _jwt_has(json obj, str k) {\n if (typeof(obj) != \"object\") { return false; }\n for (kk in keys(obj)) {\n if (kk == k) { return true; }\n }\n return false;\n}\n\n// ---- base64url (RFC 4648 §5) — standard base64 with +/ → -_ and no `=` ----\nfunc _jwt_b64url_encode(str s) {\n str b = base64_encode(s);\n b = replace(b, \"+\", \"-\");\n b = replace(b, \"/\", \"_\");\n b = replace(b, \"=\", \"\");\n return b;\n}\n\nfunc _jwt_b64url_decode(str s) {\n str b = replace(s, \"-\", \"+\");\n b = replace(b, \"_\", \"/\");\n int r = mod(length(b), 4);\n if (r == 2) { b = b + \"==\"; }\n if (r == 3) { b = b + \"=\"; }\n return base64_decode(b);\n}\n\n// Signature segment for a given signing input (\"
.\").\nfunc _jwt_signature(str signing_input, str secret) {\n return _jwt_b64url_encode(hmac_sha256(secret, signing_input));\n}\n\n// sign(claims, secret) -> compact token. Claims are any JSON object.\nfunc sign(json claims, str secret) {\n str header = _jwt_b64url_encode(\"{\\\"alg\\\":\\\"HS256\\\",\\\"typ\\\":\\\"JWT\\\"}\");\n str payload = _jwt_b64url_encode(toJson(claims));\n str signing_input = header + \".\" + payload;\n return signing_input + \".\" + _jwt_signature(signing_input, secret);\n}\n\n// sign_ttl(claims, secret, ttl_seconds) -> token with `iat` + `exp` stamped.\n// `exp` is checked automatically by verify().\nfunc sign_ttl(json claims, str secret, int ttl_seconds) {\n int now = timestamp();\n claims[\"iat\"] = now;\n claims[\"exp\"] = now + ttl_seconds;\n return sign(claims, secret);\n}\n\n// verify(token, secret) -> {\"ok\": 1, \"claims\": {...}}\n// | {\"ok\": 0, \"error\": \"\"}\n// Reasons: \"malformed token\", \"bad signature\", \"expired\".\nfunc verify(str token, str secret) {\n json parts = split(token, \".\");\n if (length(parts) != 3) {\n return {\"ok\": 0, \"error\": \"malformed token\"};\n }\n str signing_input = parts[0] + \".\" + parts[1];\n str expected = _jwt_signature(signing_input, secret);\n if (parts[2] != expected) {\n return {\"ok\": 0, \"error\": \"bad signature\"};\n }\n json claims = fromJson(_jwt_b64url_decode(parts[1]));\n if (_jwt_has(claims, \"exp\")) {\n if (timestamp() >= claims[\"exp\"]) {\n return {\"ok\": 0, \"error\": \"expired\"};\n }\n }\n return {\"ok\": 1, \"claims\": claims};\n}\n\n// decode(token) -> claims object WITHOUT verifying the signature.\n// For inspection/logging only — never trust these claims for auth.\n// Returns {} on a malformed token.\nfunc decode(str token) {\n json parts = split(token, \".\");\n if (length(parts) < 2) { return {}; }\n return fromJson(_jwt_b64url_decode(parts[1]));\n}\n\n// from_header(req) -> bearer token string, or \"\" if absent.\n// Tolerates \"Authorization\" / \"authorization\" casing and strips \"Bearer \".\nfunc from_header(json req) {\n json h = req[\"headers\"];\n if (typeof(h) != \"object\") { return \"\"; }\n str auth = \"\";\n if (_jwt_has(h, \"Authorization\")) { auth = h[\"Authorization\"]; }\n if (length(auth) == 0) {\n if (_jwt_has(h, \"authorization\")) { auth = h[\"authorization\"]; }\n }\n if (length(auth) == 0) { return \"\"; }\n if (length(auth) > 7) {\n if (substring(auth, 0, 7) == \"Bearer \") {\n return substring(auth, 7, length(auth));\n }\n }\n return auth;\n}\n"}]}