jwtpeek 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jwtpeek contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # jwtpeek
2
+
3
+ **Decode and inspect a JWT in your terminal.** See the header, the payload, and
4
+ a human-readable expiry verdict — without pasting your token into a website.
5
+ **Zero dependencies, fully offline, nothing uploaded.**
6
+
7
+ ```bash
8
+ npx jwtpeek eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
9
+ ```
10
+
11
+ ```
12
+ Header
13
+ {
14
+ "alg": "HS256",
15
+ "typ": "JWT"
16
+ }
17
+
18
+ Payload
19
+ {
20
+ "sub": "1234567890",
21
+ "name": "John Doe",
22
+ "iat": 1516239022
23
+ }
24
+
25
+ Claims
26
+ issued 2018-01-18 01:30:22 UTC 8y 150d ago
27
+ ```
28
+
29
+ ## Why
30
+
31
+ Debugging auth means constantly asking *"what's actually in this token, and has
32
+ it expired?"* The usual answers are bad: paste it into **jwt.io** (your token —
33
+ often a live credential — now lives in a browser tab and maybe a third party's
34
+ logs), or hand-assemble a pipeline nobody remembers:
35
+
36
+ ```bash
37
+ echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | python -m json.tool # and base64 -d vs -D differs across macOS/Linux…
38
+ ```
39
+
40
+ `jwtpeek` is one command. It runs entirely on your machine, makes no network
41
+ requests, and prints the time claims (`exp`, `iat`, `nbf`, …) as real dates plus
42
+ "expires in 3h 21m" / "EXPIRED 2d ago".
43
+
44
+ ## Decode, not verify — read this
45
+
46
+ > **jwtpeek never checks the signature.** It shows you what a token *says*, not
47
+ > whether it's authentic. Anyone can forge a token that decodes cleanly. Never
48
+ > trust decoded contents for an authorization decision — verify the signature
49
+ > against the issuer's key first (that needs a secret/public key, which is out
50
+ > of scope for a decoder).
51
+
52
+ ## Usage
53
+
54
+ ```bash
55
+ jwtpeek <token> # header, payload & expiry (human-readable)
56
+ jwtpeek <token> --json # {header, payload, signature, expired, notYetValid}
57
+ jwtpeek <token> --header # only the decoded header (JSON)
58
+ jwtpeek <token> --payload # only the decoded payload (JSON)
59
+
60
+ pbpaste | jwtpeek # read the token from stdin
61
+ echo "$AUTH_HEADER" | jwtpeek # a leading "Bearer "/"Authorization:" is stripped
62
+ ```
63
+
64
+ Pipe-friendly: the decoded output goes to **stdout**, the "not verified" safety
65
+ note goes to **stderr**, so `jwtpeek "$t" --json | jq .payload` stays clean.
66
+
67
+ ### Scripting with the exit code
68
+
69
+ ```
70
+ 0 decoded OK and not expired (or no exp claim)
71
+ 1 decoded OK but the token is expired
72
+ 2 not a valid JWT (decode error)
73
+ ```
74
+
75
+ ```bash
76
+ jwtpeek "$TOKEN" >/dev/null 2>&1 && echo "still valid" || echo "expired or invalid"
77
+ ```
78
+
79
+ ## Install
80
+
81
+ ```bash
82
+ npm install -g jwtpeek # then: jwtpeek <token>
83
+ # or just run it once:
84
+ npx jwtpeek <token>
85
+ ```
86
+
87
+ Requires Node ≥ 18. There is also a byte-for-byte Python port:
88
+ `pip install jwtpeek` ([jwtpeek-py](https://github.com/jjdoor/jwtpeek-py)).
89
+
90
+ ## License
91
+
92
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const core = require('../src/core.js');
6
+ const VERSION = require('../package.json').version;
7
+
8
+ // ----- tiny color helpers (no dep) -----
9
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR && !process.argv.includes('--no-color');
10
+ const col = (c, s) => (useColor ? `\x1b[${c}m${s}\x1b[0m` : s);
11
+ const red = (s) => col('31', s), green = (s) => col('32', s), yellow = (s) => col('33', s),
12
+ dim = (s) => col('2', s), bold = (s) => col('1', s), cyan = (s) => col('36', s);
13
+
14
+ const HELP = `${bold('jwtpeek')} — decode & inspect a JWT in your terminal. Decodes only, never verifies.
15
+
16
+ ${bold('Usage')}
17
+ jwtpeek <token> Decode a token and show header, payload & expiry
18
+ jwtpeek <token> --json Machine-readable {header, payload, signature, ...}
19
+ jwtpeek <token> --header Print only the decoded header (JSON)
20
+ jwtpeek <token> --payload Print only the decoded payload (JSON)
21
+ pbpaste | jwtpeek Read the token from stdin (pipe)
22
+ echo "$AUTH" | jwtpeek "Bearer "/"Authorization:" prefixes are stripped
23
+
24
+ ${bold('Options')}
25
+ --json Full structure as JSON (stdout stays pure; notes go to stderr)
26
+ --header Only the header object
27
+ --payload Only the payload object
28
+ --no-color Disable ANSI colors
29
+ -v, --version
30
+ -h, --help
31
+
32
+ ${bold('Exit')} 0 valid (or no exp) · 1 expired · 2 decode error
33
+ `;
34
+
35
+ function fail(msg) {
36
+ process.stderr.write(red(`jwtpeek: ${msg}\n`));
37
+ process.exit(2);
38
+ }
39
+
40
+ function readStdin() {
41
+ try { return fs.readFileSync(0, 'utf8'); } catch { return ''; }
42
+ }
43
+
44
+ // "2018-01-18T02:30:22.000Z" -> "2018-01-18 02:30:22 UTC"
45
+ function isoUTC(ms) {
46
+ return new Date(ms).toISOString().replace('T', ' ').replace(/\.\d+Z$/, '') + ' UTC';
47
+ }
48
+
49
+ function indentJSON(obj) {
50
+ return JSON.stringify(obj, null, 2).split('\n').map((l) => ' ' + l).join('\n');
51
+ }
52
+
53
+ function claimVerdict(key, ms, exp, nowMs) {
54
+ if (key === 'exp') {
55
+ return exp.expired
56
+ ? red(`EXPIRED ${core.formatDuration(nowMs - ms)} ago`)
57
+ : green(`expires in ${core.formatDuration(ms - nowMs)}`);
58
+ }
59
+ if (key === 'nbf') {
60
+ return nowMs < ms
61
+ ? yellow(`not valid for ${core.formatDuration(ms - nowMs)}`)
62
+ : dim(`valid since ${core.formatDuration(nowMs - ms)} ago`);
63
+ }
64
+ return ms <= nowMs
65
+ ? dim(`${core.formatDuration(nowMs - ms)} ago`)
66
+ : dim(`in ${core.formatDuration(ms - nowMs)}`);
67
+ }
68
+
69
+ function printHuman(decoded, exp, nowMs) {
70
+ const out = [bold('Header'), indentJSON(decoded.header), '',
71
+ bold('Payload'), indentJSON(decoded.payload)];
72
+
73
+ const rows = [];
74
+ for (const [key, label] of core.TIME_CLAIMS) {
75
+ if (!(key in decoded.payload)) continue;
76
+ const ms = core.normalizeEpochMs(decoded.payload[key]);
77
+ if (ms == null) continue;
78
+ rows.push({ key, label, ms });
79
+ }
80
+ if (rows.length) {
81
+ out.push('', bold('Claims'));
82
+ const w = Math.max(...rows.map((r) => r.label.length));
83
+ for (const r of rows) {
84
+ out.push(` ${cyan(r.label.padEnd(w))} ${dim(isoUTC(r.ms))} ${claimVerdict(r.key, r.ms, exp, nowMs)}`);
85
+ }
86
+ }
87
+ process.stdout.write(out.join('\n') + '\n');
88
+ }
89
+
90
+ function main() {
91
+ const argv = process.argv.slice(2);
92
+ if (argv.includes('-h') || argv.includes('--help')) { process.stdout.write(HELP); process.exit(0); }
93
+ if (argv.includes('-v') || argv.includes('--version')) { process.stdout.write(VERSION + '\n'); process.exit(0); }
94
+ if (argv.length === 0 && process.stdin.isTTY) { process.stdout.write(HELP); process.exit(0); }
95
+
96
+ const flags = new Set(argv.filter((a) => a.startsWith('-')));
97
+ const positional = argv.filter((a) => !a.startsWith('-'));
98
+
99
+ let raw = positional[0];
100
+ if (!raw) {
101
+ if (process.stdin.isTTY) fail('no token given (pass it as an argument or pipe it in)');
102
+ raw = readStdin();
103
+ }
104
+ const token = core.stripToken(raw);
105
+ if (!token) fail('no token given (pass it as an argument or pipe it in)');
106
+
107
+ let decoded;
108
+ try { decoded = core.decodeJwt(token); }
109
+ catch (e) { fail(e.message); }
110
+
111
+ const nowMs = Date.now();
112
+ const exp = core.evaluateExpiry(decoded.payload, nowMs);
113
+
114
+ if (flags.has('--header')) {
115
+ process.stdout.write(JSON.stringify(decoded.header, null, 2) + '\n');
116
+ } else if (flags.has('--payload')) {
117
+ process.stdout.write(JSON.stringify(decoded.payload, null, 2) + '\n');
118
+ } else if (flags.has('--json')) {
119
+ process.stdout.write(JSON.stringify({
120
+ header: decoded.header,
121
+ payload: decoded.payload,
122
+ signature: decoded.signature,
123
+ expired: exp.expired,
124
+ notYetValid: exp.notYetValid,
125
+ }, null, 2) + '\n');
126
+ } else {
127
+ printHuman(decoded, exp, nowMs);
128
+ }
129
+
130
+ // Safety note always to stderr so it never pollutes stdout / pipes.
131
+ process.stderr.write(dim('note: signature NOT verified — jwtpeek decodes only\n'));
132
+ if (exp.notYetValid) process.stderr.write(yellow('warning: token is not valid yet (nbf is in the future)\n'));
133
+
134
+ process.exit(exp.expired ? 1 : 0);
135
+ }
136
+
137
+ main();
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "jwtpeek",
3
+ "version": "0.1.0",
4
+ "description": "Decode & inspect JWT tokens in your terminal — header, payload, and human-readable expiry. Zero dependencies, fully offline, nothing uploaded.",
5
+ "bin": {
6
+ "jwtpeek": "bin/cli.js"
7
+ },
8
+ "main": "src/core.js",
9
+ "scripts": {
10
+ "test": "node --test"
11
+ },
12
+ "keywords": [
13
+ "jwt",
14
+ "jwt-decode",
15
+ "json-web-token",
16
+ "jwt-cli",
17
+ "token",
18
+ "decode",
19
+ "auth",
20
+ "oauth",
21
+ "oidc",
22
+ "cli",
23
+ "devtools",
24
+ "debug"
25
+ ],
26
+ "files": [
27
+ "bin",
28
+ "src",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "license": "MIT",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/jjdoor/jwtpeek.git"
39
+ },
40
+ "homepage": "https://github.com/jjdoor/jwtpeek#readme",
41
+ "bugs": {
42
+ "url": "https://github.com/jjdoor/jwtpeek/issues"
43
+ }
44
+ }
package/src/core.js ADDED
@@ -0,0 +1,159 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * jwtpeek core — pure JWT decoding + claim classification.
5
+ *
6
+ * No fs, no clock, no network: every function takes its inputs explicitly so
7
+ * this and the Python port behave identically and stay unit-testable.
8
+ *
9
+ * IMPORTANT: jwtpeek DECODES, it does not VERIFY. A JWT's signature is what
10
+ * proves the payload wasn't tampered with; we never check it. Treat decoded
11
+ * contents as untrusted unless you've verified the signature with the issuer's
12
+ * key.
13
+ */
14
+
15
+ const UNITS = { y: 31536000000, d: 86400000, h: 3600000, m: 60000, s: 1000 };
16
+
17
+ // Registered date claims (RFC 7519 + common OIDC) mapped to a display label.
18
+ // This is also the print order; `exp` is last so the validity verdict lands
19
+ // as the kicker.
20
+ const TIME_CLAIMS = [
21
+ ['iat', 'issued'],
22
+ ['nbf', 'not before'],
23
+ ['auth_time', 'auth time'],
24
+ ['updated_at', 'updated'],
25
+ ['exp', 'expires'],
26
+ ];
27
+
28
+ /**
29
+ * Strip the noise that creeps in when a token is copy-pasted: surrounding
30
+ * quotes, an "Authorization:"/"Bearer " prefix, and any wrapping whitespace
31
+ * (including the line breaks a wrapped terminal paste introduces).
32
+ *
33
+ * @param {*} input
34
+ * @returns {string}
35
+ */
36
+ function stripToken(input) {
37
+ let s = String(input == null ? '' : input).trim();
38
+ s = s.replace(/^['"]+|['"]+$/g, '');
39
+ s = s.replace(/^Authorization:\s*/i, '');
40
+ s = s.replace(/^Bearer\s+/i, '');
41
+ s = s.replace(/\s+/g, '');
42
+ return s;
43
+ }
44
+
45
+ /**
46
+ * Decode one base64url segment to a UTF-8 string. Throws on non-base64url so
47
+ * callers can report a precise error instead of silently mangling bytes.
48
+ *
49
+ * @param {string} seg
50
+ * @returns {string}
51
+ */
52
+ function b64urlToString(seg) {
53
+ if (!/^[A-Za-z0-9_-]+$/.test(seg)) throw new Error('not base64url');
54
+ let b64 = seg.replace(/-/g, '+').replace(/_/g, '/');
55
+ while (b64.length % 4) b64 += '=';
56
+ return Buffer.from(b64, 'base64').toString('utf8');
57
+ }
58
+
59
+ function parseSegment(seg, which) {
60
+ if (!seg) throw new Error(`${which} segment is empty`);
61
+ let json;
62
+ try { json = b64urlToString(seg); }
63
+ catch { throw new Error(`${which} is not valid base64url`); }
64
+ let obj;
65
+ try { obj = JSON.parse(json); }
66
+ catch { throw new Error(`${which} is not valid JSON`); }
67
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
68
+ throw new Error(`${which} is not a JSON object`);
69
+ }
70
+ return obj;
71
+ }
72
+
73
+ /**
74
+ * Decode a JWT into { header, payload, signature }. Accepts a 2-part unsecured
75
+ * token (alg: none) as well as the usual 3-part form. Throws an Error with a
76
+ * human message on anything that isn't a structurally valid JWT.
77
+ *
78
+ * Does NOT verify the signature — see the module note.
79
+ *
80
+ * @param {string} token
81
+ * @returns {{ header: object, payload: object, signature: string }}
82
+ */
83
+ function decodeJwt(token) {
84
+ const t = String(token == null ? '' : token).trim();
85
+ if (!t) throw new Error('empty token');
86
+ const parts = t.split('.');
87
+ if (parts.length < 2 || parts.length > 3) {
88
+ throw new Error(`not a JWT: expected 2 or 3 dot-separated parts, got ${parts.length}`);
89
+ }
90
+ return {
91
+ header: parseSegment(parts[0], 'header'),
92
+ payload: parseSegment(parts[1], 'payload'),
93
+ signature: parts.length === 3 ? parts[2] : '',
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Normalize a JWT NumericDate to epoch milliseconds. Per RFC 7519 these are
99
+ * *seconds*, but some issuers wrongly emit milliseconds — detect that (a real
100
+ * seconds value won't reach 1e12 until the year 5138) and pass it through.
101
+ * Returns null for anything that isn't a finite number.
102
+ *
103
+ * @param {*} value
104
+ * @returns {number|null}
105
+ */
106
+ function normalizeEpochMs(value) {
107
+ if (typeof value !== 'number' || !Number.isFinite(value)) return null;
108
+ return value >= 1e12 ? Math.round(value) : Math.round(value * 1000);
109
+ }
110
+
111
+ /**
112
+ * Evaluate validity windows against a supplied `nowMs` (no clock in here).
113
+ * `expired`/`notYetValid` are null when the corresponding claim is absent.
114
+ *
115
+ * @param {object} payload
116
+ * @param {number} nowMs
117
+ * @returns {{ expMs:number|null, nbfMs:number|null, expired:boolean|null, notYetValid:boolean|null }}
118
+ */
119
+ function evaluateExpiry(payload, nowMs) {
120
+ const expMs = normalizeEpochMs(payload && payload.exp);
121
+ const nbfMs = normalizeEpochMs(payload && payload.nbf);
122
+ return {
123
+ expMs,
124
+ nbfMs,
125
+ expired: expMs == null ? null : nowMs >= expMs,
126
+ notYetValid: nbfMs == null ? null : nowMs < nbfMs,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Compact, two-unit-max human span, e.g. "1d 3h", "7y 2d", "45s". Sign is
132
+ * ignored (callers phrase "ago"/"in" themselves).
133
+ *
134
+ * @param {number} ms
135
+ * @returns {string}
136
+ */
137
+ function formatDuration(ms) {
138
+ ms = Math.abs(Math.round(Number(ms) || 0));
139
+ if (ms < 1000) return '0s';
140
+ const order = [['y', UNITS.y], ['d', UNITS.d], ['h', UNITS.h], ['m', UNITS.m], ['s', UNITS.s]];
141
+ const parts = [];
142
+ let rem = ms;
143
+ for (const [label, size] of order) {
144
+ if (rem >= size) {
145
+ const n = Math.floor(rem / size);
146
+ rem -= n * size;
147
+ parts.push(`${n}${label}`);
148
+ if (parts.length === 2) break;
149
+ } else if (parts.length) {
150
+ break;
151
+ }
152
+ }
153
+ return parts.length ? parts.join(' ') : '0s';
154
+ }
155
+
156
+ module.exports = {
157
+ UNITS, TIME_CLAIMS, stripToken, b64urlToString, decodeJwt,
158
+ normalizeEpochMs, evaluateExpiry, formatDuration,
159
+ };