@claude-agent/jwt-explain 1.0.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.
Files changed (3) hide show
  1. package/bin/cli.js +331 -0
  2. package/package.json +37 -0
  3. package/src/index.js +653 -0
package/bin/cli.js ADDED
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * jwt-explain CLI
5
+ * Decode and explain JWT tokens in plain English
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const {
11
+ decode,
12
+ validate,
13
+ explain,
14
+ format,
15
+ isExpired,
16
+ getTimeUntilExpiry,
17
+ getRegisteredClaims,
18
+ getCommonClaims,
19
+ getAlgorithms
20
+ } = require('../src/index.js');
21
+
22
+ const HELP = `
23
+ jwt-explain - Decode and explain JWT tokens in plain English
24
+
25
+ USAGE:
26
+ jwt-explain <token> Explain a JWT token
27
+ jwt-explain -d <token> Decode and show raw parts
28
+ jwt-explain -v <token> Validate token structure
29
+ jwt-explain -e <token> Check if token is expired
30
+ jwt-explain --claims List registered JWT claims
31
+ jwt-explain --algorithms List signing algorithms
32
+
33
+ OPTIONS:
34
+ -d, --decode Decode and show raw header/payload JSON
35
+ -v, --validate Validate token structure
36
+ -e, --expiry Show expiration status
37
+ -D, --detailed Show detailed explanations
38
+ --no-color Disable colored output
39
+ -j, --json Output as JSON
40
+ --claims List all registered claims
41
+ --algorithms List all algorithms
42
+ -h, --help Show this help
43
+ -V, --version Show version
44
+
45
+ EXAMPLES:
46
+ jwt-explain eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
47
+ jwt-explain -d "Bearer eyJhbGciOiJIUzI1NiI..."
48
+ jwt-explain -v eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
49
+ jwt-explain -e eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
50
+ jwt-explain --claims
51
+ jwt-explain --algorithms
52
+
53
+ NOTES:
54
+ - This tool decodes JWTs but does NOT verify signatures
55
+ - Never trust a decoded JWT without verification
56
+ - "Bearer " prefix is automatically stripped
57
+
58
+ Built autonomously by Claude (Anthropic's AI)
59
+ https://github.com/claude-agent-tools/jwt-explain
60
+ `;
61
+
62
+ function main() {
63
+ const args = process.argv.slice(2);
64
+
65
+ // Parse options
66
+ let mode = 'explain';
67
+ let detailed = false;
68
+ let useColor = process.stdout.isTTY !== false;
69
+ let json = false;
70
+ let token = null;
71
+
72
+ for (let i = 0; i < args.length; i++) {
73
+ const arg = args[i];
74
+
75
+ if (arg === '-h' || arg === '--help') {
76
+ console.log(HELP);
77
+ process.exit(0);
78
+ }
79
+
80
+ if (arg === '-V' || arg === '--version') {
81
+ const pkg = require('../package.json');
82
+ console.log(pkg.version);
83
+ process.exit(0);
84
+ }
85
+
86
+ if (arg === '-D' || arg === '--detailed') {
87
+ detailed = true;
88
+ continue;
89
+ }
90
+
91
+ if (arg === '--no-color') {
92
+ useColor = false;
93
+ continue;
94
+ }
95
+
96
+ if (arg === '-j' || arg === '--json') {
97
+ json = true;
98
+ continue;
99
+ }
100
+
101
+ if (arg === '-d' || arg === '--decode') {
102
+ mode = 'decode';
103
+ continue;
104
+ }
105
+
106
+ if (arg === '-v' || arg === '--validate') {
107
+ mode = 'validate';
108
+ continue;
109
+ }
110
+
111
+ if (arg === '-e' || arg === '--expiry') {
112
+ mode = 'expiry';
113
+ continue;
114
+ }
115
+
116
+ if (arg === '--claims') {
117
+ mode = 'claims';
118
+ continue;
119
+ }
120
+
121
+ if (arg === '--algorithms') {
122
+ mode = 'algorithms';
123
+ continue;
124
+ }
125
+
126
+ if (arg.startsWith('-')) {
127
+ console.error(`Unknown option: ${arg}`);
128
+ console.error('Use --help for usage information');
129
+ process.exit(2);
130
+ }
131
+
132
+ token = arg;
133
+ }
134
+
135
+ // Handle info modes
136
+ if (mode === 'claims') {
137
+ const claims = { ...getRegisteredClaims(), ...getCommonClaims() };
138
+ if (json) {
139
+ console.log(JSON.stringify(claims, null, 2));
140
+ } else {
141
+ const bold = useColor ? '\x1b[1m' : '';
142
+ const cyan = useColor ? '\x1b[36m' : '';
143
+ const blue = useColor ? '\x1b[34m' : '';
144
+ const magenta = useColor ? '\x1b[35m' : '';
145
+ const reset = useColor ? '\x1b[0m' : '';
146
+
147
+ console.log(`${bold}Registered Claims:${reset}`);
148
+ console.log('');
149
+ for (const [key, info] of Object.entries(getRegisteredClaims())) {
150
+ console.log(` ${blue}${key.padEnd(10)}${reset} ${info.name} - ${info.description}`);
151
+ }
152
+ console.log('');
153
+ console.log(`${bold}Common Claims:${reset}`);
154
+ console.log('');
155
+ for (const [key, info] of Object.entries(getCommonClaims())) {
156
+ console.log(` ${magenta}${key.padEnd(15)}${reset} ${info.name} - ${info.description}`);
157
+ }
158
+ }
159
+ process.exit(0);
160
+ }
161
+
162
+ if (mode === 'algorithms') {
163
+ const algs = getAlgorithms();
164
+ if (json) {
165
+ console.log(JSON.stringify(algs, null, 2));
166
+ } else {
167
+ const bold = useColor ? '\x1b[1m' : '';
168
+ const cyan = useColor ? '\x1b[36m' : '';
169
+ const green = useColor ? '\x1b[32m' : '';
170
+ const yellow = useColor ? '\x1b[33m' : '';
171
+ const red = useColor ? '\x1b[31m' : '';
172
+ const reset = useColor ? '\x1b[0m' : '';
173
+
174
+ console.log(`${bold}JWT Signing Algorithms:${reset}`);
175
+ console.log('');
176
+
177
+ const byType = { symmetric: [], asymmetric: [], none: [] };
178
+ for (const [key, info] of Object.entries(algs)) {
179
+ byType[info.type].push({ key, ...info });
180
+ }
181
+
182
+ console.log(`${bold}Symmetric (shared secret):${reset}`);
183
+ for (const alg of byType.symmetric) {
184
+ console.log(` ${green}${alg.key.padEnd(8)}${reset} ${alg.description}`);
185
+ }
186
+
187
+ console.log('');
188
+ console.log(`${bold}Asymmetric (public/private key):${reset}`);
189
+ for (const alg of byType.asymmetric) {
190
+ console.log(` ${cyan}${alg.key.padEnd(8)}${reset} ${alg.description}`);
191
+ }
192
+
193
+ console.log('');
194
+ console.log(`${bold}Insecure:${reset}`);
195
+ for (const alg of byType.none) {
196
+ console.log(` ${red}${alg.key.padEnd(8)}${reset} ${alg.description}`);
197
+ }
198
+ }
199
+ process.exit(0);
200
+ }
201
+
202
+ // Modes that require a token
203
+ if (!token) {
204
+ console.error('Error: No JWT token provided');
205
+ console.error('Usage: jwt-explain <token>');
206
+ console.error('Use --help for more information');
207
+ process.exit(2);
208
+ }
209
+
210
+ // Handle different modes
211
+ switch (mode) {
212
+ case 'decode': {
213
+ const decoded = decode(token);
214
+ if (!decoded) {
215
+ console.error('Error: Invalid JWT token format');
216
+ process.exit(1);
217
+ }
218
+
219
+ if (json) {
220
+ console.log(JSON.stringify({
221
+ header: decoded.header,
222
+ payload: decoded.payload,
223
+ signature: decoded.signature
224
+ }, null, 2));
225
+ } else {
226
+ const bold = useColor ? '\x1b[1m' : '';
227
+ const cyan = useColor ? '\x1b[36m' : '';
228
+ const reset = useColor ? '\x1b[0m' : '';
229
+
230
+ console.log(`${bold}Header:${reset}`);
231
+ console.log(cyan + JSON.stringify(decoded.header, null, 2) + reset);
232
+ console.log('');
233
+ console.log(`${bold}Payload:${reset}`);
234
+ console.log(cyan + JSON.stringify(decoded.payload, null, 2) + reset);
235
+ console.log('');
236
+ console.log(`${bold}Signature:${reset}`);
237
+ console.log(decoded.signature);
238
+ }
239
+ break;
240
+ }
241
+
242
+ case 'validate': {
243
+ const result = validate(token);
244
+
245
+ if (json) {
246
+ console.log(JSON.stringify(result, null, 2));
247
+ } else {
248
+ const bold = useColor ? '\x1b[1m' : '';
249
+ const green = useColor ? '\x1b[32m' : '';
250
+ const red = useColor ? '\x1b[31m' : '';
251
+ const yellow = useColor ? '\x1b[33m' : '';
252
+ const reset = useColor ? '\x1b[0m' : '';
253
+
254
+ console.log(`${bold}Validation Result:${reset}`);
255
+ console.log('');
256
+
257
+ if (result.valid) {
258
+ console.log(`${green}✓ Token has valid JWT structure${reset}`);
259
+ } else {
260
+ console.log(`${red}✗ Token has invalid JWT structure${reset}`);
261
+ }
262
+
263
+ if (result.errors.length > 0) {
264
+ console.log('');
265
+ console.log(`${bold}Errors:${reset}`);
266
+ for (const error of result.errors) {
267
+ console.log(` ${red}✗ ${error}${reset}`);
268
+ }
269
+ }
270
+
271
+ if (result.warnings.length > 0) {
272
+ console.log('');
273
+ console.log(`${bold}Warnings:${reset}`);
274
+ for (const warning of result.warnings) {
275
+ console.log(` ${yellow}⚠ ${warning}${reset}`);
276
+ }
277
+ }
278
+ }
279
+
280
+ process.exit(result.valid ? 0 : 1);
281
+ }
282
+
283
+ case 'expiry': {
284
+ const result = getTimeUntilExpiry(token);
285
+
286
+ if (result.error) {
287
+ console.error(`Error: ${result.error}`);
288
+ process.exit(1);
289
+ }
290
+
291
+ if (json) {
292
+ console.log(JSON.stringify(result, null, 2));
293
+ } else {
294
+ const bold = useColor ? '\x1b[1m' : '';
295
+ const green = useColor ? '\x1b[32m' : '';
296
+ const red = useColor ? '\x1b[31m' : '';
297
+ const yellow = useColor ? '\x1b[33m' : '';
298
+ const reset = useColor ? '\x1b[0m' : '';
299
+
300
+ if (result.neverExpires) {
301
+ console.log(`${yellow}⚠ Token has no expiration${reset}`);
302
+ } else if (result.expired) {
303
+ console.log(`${red}✗ Token expired ${result.expiredAgoText} ago${reset}`);
304
+ } else {
305
+ console.log(`${green}✓ Token expires in ${result.expiresInText}${reset}`);
306
+ }
307
+ }
308
+
309
+ process.exit(result.expired ? 1 : 0);
310
+ }
311
+
312
+ case 'explain':
313
+ default: {
314
+ const result = explain(token);
315
+
316
+ if (json) {
317
+ // Remove circular references and functions for JSON output
318
+ const jsonSafe = JSON.parse(JSON.stringify(result));
319
+ console.log(JSON.stringify(jsonSafe, null, 2));
320
+ } else {
321
+ console.log(format(result, { color: useColor, detailed }));
322
+ }
323
+
324
+ if (!result.valid) process.exit(1);
325
+ if (result.validation && !result.validation.valid) process.exit(1);
326
+ break;
327
+ }
328
+ }
329
+ }
330
+
331
+ main();
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@claude-agent/jwt-explain",
3
+ "version": "1.0.0",
4
+ "description": "Decode and explain JWT tokens in plain English - understand headers, claims, and expiration",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "jwt-explain": "bin/cli.js"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test test/*.test.js"
11
+ },
12
+ "keywords": [
13
+ "jwt",
14
+ "json-web-token",
15
+ "decode",
16
+ "explain",
17
+ "parse",
18
+ "token",
19
+ "auth",
20
+ "authentication",
21
+ "cli"
22
+ ],
23
+ "author": "Claude Agent <claude-agent@agentmail.to>",
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/claude-agent-tools/jwt-explain.git"
28
+ },
29
+ "homepage": "https://github.com/claude-agent-tools/jwt-explain#readme",
30
+ "engines": {
31
+ "node": ">=14.0.0"
32
+ },
33
+ "files": [
34
+ "src/",
35
+ "bin/"
36
+ ]
37
+ }
package/src/index.js ADDED
@@ -0,0 +1,653 @@
1
+ /**
2
+ * jwt-explain
3
+ * Decode and explain JWT tokens in plain English
4
+ * Zero dependencies
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ // Standard JWT claims and their descriptions
10
+ const REGISTERED_CLAIMS = {
11
+ iss: { name: 'Issuer', description: 'Who issued this token' },
12
+ sub: { name: 'Subject', description: 'Who/what this token is about' },
13
+ aud: { name: 'Audience', description: 'Who this token is intended for' },
14
+ exp: { name: 'Expiration Time', description: 'When this token expires', isDate: true },
15
+ nbf: { name: 'Not Before', description: 'Token not valid before this time', isDate: true },
16
+ iat: { name: 'Issued At', description: 'When this token was issued', isDate: true },
17
+ jti: { name: 'JWT ID', description: 'Unique identifier for this token' }
18
+ };
19
+
20
+ // Common custom claims
21
+ const COMMON_CLAIMS = {
22
+ name: { name: 'Name', description: 'Full name of the user' },
23
+ given_name: { name: 'Given Name', description: 'First name' },
24
+ family_name: { name: 'Family Name', description: 'Last name' },
25
+ email: { name: 'Email', description: 'Email address' },
26
+ email_verified: { name: 'Email Verified', description: 'Whether email has been verified' },
27
+ phone_number: { name: 'Phone Number', description: 'Phone number' },
28
+ picture: { name: 'Picture', description: 'Profile picture URL' },
29
+ locale: { name: 'Locale', description: 'User locale/language' },
30
+ zoneinfo: { name: 'Time Zone', description: 'User time zone' },
31
+ updated_at: { name: 'Updated At', description: 'Last profile update time', isDate: true },
32
+ auth_time: { name: 'Auth Time', description: 'When authentication occurred', isDate: true },
33
+ nonce: { name: 'Nonce', description: 'Value to prevent replay attacks' },
34
+ acr: { name: 'Auth Context Class', description: 'Authentication context class reference' },
35
+ amr: { name: 'Auth Methods', description: 'Authentication methods used' },
36
+ azp: { name: 'Authorized Party', description: 'The party the token was issued to' },
37
+ at_hash: { name: 'Access Token Hash', description: 'Hash of the access token' },
38
+ c_hash: { name: 'Code Hash', description: 'Hash of the authorization code' },
39
+ scope: { name: 'Scope', description: 'Permissions granted by this token' },
40
+ permissions: { name: 'Permissions', description: 'Specific permissions granted' },
41
+ roles: { name: 'Roles', description: 'User roles' },
42
+ groups: { name: 'Groups', description: 'Group memberships' }
43
+ };
44
+
45
+ // Algorithm descriptions
46
+ const ALGORITHMS = {
47
+ HS256: { name: 'HMAC SHA-256', type: 'symmetric', description: 'HMAC with SHA-256 (symmetric key)' },
48
+ HS384: { name: 'HMAC SHA-384', type: 'symmetric', description: 'HMAC with SHA-384 (symmetric key)' },
49
+ HS512: { name: 'HMAC SHA-512', type: 'symmetric', description: 'HMAC with SHA-512 (symmetric key)' },
50
+ RS256: { name: 'RSA SHA-256', type: 'asymmetric', description: 'RSA signature with SHA-256' },
51
+ RS384: { name: 'RSA SHA-384', type: 'asymmetric', description: 'RSA signature with SHA-384' },
52
+ RS512: { name: 'RSA SHA-512', type: 'asymmetric', description: 'RSA signature with SHA-512' },
53
+ ES256: { name: 'ECDSA P-256', type: 'asymmetric', description: 'ECDSA using P-256 curve and SHA-256' },
54
+ ES384: { name: 'ECDSA P-384', type: 'asymmetric', description: 'ECDSA using P-384 curve and SHA-384' },
55
+ ES512: { name: 'ECDSA P-521', type: 'asymmetric', description: 'ECDSA using P-521 curve and SHA-512' },
56
+ PS256: { name: 'RSA-PSS SHA-256', type: 'asymmetric', description: 'RSA-PSS signature with SHA-256' },
57
+ PS384: { name: 'RSA-PSS SHA-384', type: 'asymmetric', description: 'RSA-PSS signature with SHA-384' },
58
+ PS512: { name: 'RSA-PSS SHA-512', type: 'asymmetric', description: 'RSA-PSS signature with SHA-512' },
59
+ EdDSA: { name: 'Edwards-curve DSA', type: 'asymmetric', description: 'Edwards-curve Digital Signature Algorithm' },
60
+ none: { name: 'None', type: 'none', description: 'Unsigned token (insecure!)' }
61
+ };
62
+
63
+ /**
64
+ * Base64URL decode
65
+ * @param {string} str - Base64URL encoded string
66
+ * @returns {string} Decoded string
67
+ */
68
+ function base64UrlDecode(str) {
69
+ // Replace URL-safe characters
70
+ let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
71
+
72
+ // Add padding if needed
73
+ while (base64.length % 4) {
74
+ base64 += '=';
75
+ }
76
+
77
+ // Decode
78
+ return Buffer.from(base64, 'base64').toString('utf8');
79
+ }
80
+
81
+ /**
82
+ * Base64URL encode
83
+ * @param {string} str - String to encode
84
+ * @returns {string} Base64URL encoded string
85
+ */
86
+ function base64UrlEncode(str) {
87
+ return Buffer.from(str, 'utf8')
88
+ .toString('base64')
89
+ .replace(/\+/g, '-')
90
+ .replace(/\//g, '_')
91
+ .replace(/=+$/, '');
92
+ }
93
+
94
+ /**
95
+ * Decode a JWT token without verification
96
+ * @param {string} token - JWT token string
97
+ * @returns {object|null} Decoded token or null if invalid
98
+ */
99
+ function decode(token) {
100
+ if (typeof token !== 'string' || !token.trim()) {
101
+ return null;
102
+ }
103
+
104
+ token = token.trim();
105
+
106
+ // Remove "Bearer " prefix if present
107
+ if (token.toLowerCase().startsWith('bearer ')) {
108
+ token = token.slice(7).trim();
109
+ }
110
+
111
+ const parts = token.split('.');
112
+
113
+ if (parts.length !== 3) {
114
+ return null;
115
+ }
116
+
117
+ try {
118
+ const headerJson = base64UrlDecode(parts[0]);
119
+ const payloadJson = base64UrlDecode(parts[1]);
120
+
121
+ const header = JSON.parse(headerJson);
122
+ const payload = JSON.parse(payloadJson);
123
+
124
+ return {
125
+ token,
126
+ header,
127
+ payload,
128
+ signature: parts[2],
129
+ parts: {
130
+ header: parts[0],
131
+ payload: parts[1],
132
+ signature: parts[2]
133
+ }
134
+ };
135
+ } catch (e) {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Check if a token is a valid JWT structure
142
+ * @param {string} token - Token to validate
143
+ * @returns {object} Validation result
144
+ */
145
+ function validate(token) {
146
+ const errors = [];
147
+ const warnings = [];
148
+
149
+ if (typeof token !== 'string' || !token.trim()) {
150
+ return { valid: false, errors: ['Token must be a non-empty string'], warnings: [] };
151
+ }
152
+
153
+ token = token.trim();
154
+
155
+ // Remove Bearer prefix
156
+ if (token.toLowerCase().startsWith('bearer ')) {
157
+ token = token.slice(7).trim();
158
+ }
159
+
160
+ const parts = token.split('.');
161
+
162
+ if (parts.length !== 3) {
163
+ errors.push(`JWT must have 3 parts separated by dots, found ${parts.length}`);
164
+ return { valid: false, errors, warnings };
165
+ }
166
+
167
+ // Check each part is valid base64url
168
+ const partNames = ['header', 'payload', 'signature'];
169
+ for (let i = 0; i < 3; i++) {
170
+ if (!parts[i]) {
171
+ // Empty signature is valid for alg: none tokens
172
+ if (i === 2) continue; // Check signature later after decoding header
173
+ errors.push(`${partNames[i]} part is empty`);
174
+ } else if (!/^[A-Za-z0-9_-]+$/.test(parts[i])) {
175
+ errors.push(`${partNames[i]} contains invalid base64url characters`);
176
+ }
177
+ }
178
+
179
+ if (errors.length > 0) {
180
+ return { valid: false, errors, warnings };
181
+ }
182
+
183
+ // Try to decode header and payload
184
+ let header, payload;
185
+
186
+ try {
187
+ const headerJson = base64UrlDecode(parts[0]);
188
+ header = JSON.parse(headerJson);
189
+ } catch {
190
+ errors.push('Header is not valid JSON');
191
+ }
192
+
193
+ try {
194
+ const payloadJson = base64UrlDecode(parts[1]);
195
+ payload = JSON.parse(payloadJson);
196
+ } catch {
197
+ errors.push('Payload is not valid JSON');
198
+ }
199
+
200
+ if (errors.length > 0) {
201
+ return { valid: false, errors, warnings };
202
+ }
203
+
204
+ // Validate header structure
205
+ if (typeof header !== 'object' || header === null) {
206
+ errors.push('Header must be a JSON object');
207
+ } else {
208
+ if (!header.alg) {
209
+ errors.push('Header missing required "alg" (algorithm) field');
210
+ } else if (header.alg === 'none') {
211
+ warnings.push('Algorithm is "none" - token is unsigned and should not be trusted');
212
+ }
213
+
214
+ if (!header.typ) {
215
+ warnings.push('Header missing "typ" field (optional but recommended)');
216
+ } else if (header.typ !== 'JWT') {
217
+ warnings.push(`Header "typ" is "${header.typ}", expected "JWT"`);
218
+ }
219
+ }
220
+
221
+ // Validate payload structure
222
+ if (typeof payload !== 'object' || payload === null) {
223
+ errors.push('Payload must be a JSON object');
224
+ } else {
225
+ // Check expiration
226
+ if (payload.exp) {
227
+ const expTime = payload.exp * 1000;
228
+ if (expTime < Date.now()) {
229
+ warnings.push('Token has expired');
230
+ }
231
+ } else {
232
+ warnings.push('Token has no expiration (exp) - may be valid indefinitely');
233
+ }
234
+
235
+ // Check not before
236
+ if (payload.nbf) {
237
+ const nbfTime = payload.nbf * 1000;
238
+ if (nbfTime > Date.now()) {
239
+ warnings.push('Token is not yet valid (nbf is in the future)');
240
+ }
241
+ }
242
+
243
+ // Check issued at
244
+ if (payload.iat) {
245
+ const iatTime = payload.iat * 1000;
246
+ if (iatTime > Date.now()) {
247
+ warnings.push('Token issued in the future (iat is in the future)');
248
+ }
249
+ }
250
+ }
251
+
252
+ return {
253
+ valid: errors.length === 0,
254
+ errors,
255
+ warnings
256
+ };
257
+ }
258
+
259
+ /**
260
+ * Check if a token is expired
261
+ * @param {string} token - JWT token
262
+ * @returns {object} Expiration status
263
+ */
264
+ function isExpired(token) {
265
+ const decoded = decode(token);
266
+
267
+ if (!decoded) {
268
+ return { error: 'Invalid token', expired: null, expiresAt: null };
269
+ }
270
+
271
+ const { payload } = decoded;
272
+
273
+ if (!payload.exp) {
274
+ return { expired: false, expiresAt: null, neverExpires: true };
275
+ }
276
+
277
+ const expiresAt = new Date(payload.exp * 1000);
278
+ const now = new Date();
279
+ const expired = expiresAt < now;
280
+
281
+ return {
282
+ expired,
283
+ expiresAt,
284
+ expiresIn: expired ? null : expiresAt - now,
285
+ expiredAgo: expired ? now - expiresAt : null
286
+ };
287
+ }
288
+
289
+ /**
290
+ * Get the time until expiration
291
+ * @param {string} token - JWT token
292
+ * @returns {object} Time until expiration
293
+ */
294
+ function getTimeUntilExpiry(token) {
295
+ const result = isExpired(token);
296
+
297
+ if (result.error) {
298
+ return { error: result.error };
299
+ }
300
+
301
+ if (result.neverExpires) {
302
+ return { neverExpires: true };
303
+ }
304
+
305
+ if (result.expired) {
306
+ const ago = result.expiredAgo;
307
+ return {
308
+ expired: true,
309
+ expiredAgo: ago,
310
+ expiredAgoText: formatDuration(ago)
311
+ };
312
+ }
313
+
314
+ const remaining = result.expiresIn;
315
+ return {
316
+ expired: false,
317
+ expiresIn: remaining,
318
+ expiresInText: formatDuration(remaining)
319
+ };
320
+ }
321
+
322
+ /**
323
+ * Format a duration in milliseconds to human-readable
324
+ * @param {number} ms - Duration in milliseconds
325
+ * @returns {string}
326
+ */
327
+ function formatDuration(ms) {
328
+ const seconds = Math.floor(ms / 1000);
329
+ const minutes = Math.floor(seconds / 60);
330
+ const hours = Math.floor(minutes / 60);
331
+ const days = Math.floor(hours / 24);
332
+
333
+ if (days > 0) {
334
+ const remainingHours = hours % 24;
335
+ return `${days} day${days !== 1 ? 's' : ''}${remainingHours > 0 ? `, ${remainingHours} hour${remainingHours !== 1 ? 's' : ''}` : ''}`;
336
+ }
337
+
338
+ if (hours > 0) {
339
+ const remainingMinutes = minutes % 60;
340
+ return `${hours} hour${hours !== 1 ? 's' : ''}${remainingMinutes > 0 ? `, ${remainingMinutes} minute${remainingMinutes !== 1 ? 's' : ''}` : ''}`;
341
+ }
342
+
343
+ if (minutes > 0) {
344
+ const remainingSeconds = seconds % 60;
345
+ return `${minutes} minute${minutes !== 1 ? 's' : ''}${remainingSeconds > 0 ? `, ${remainingSeconds} second${remainingSeconds !== 1 ? 's' : ''}` : ''}`;
346
+ }
347
+
348
+ return `${seconds} second${seconds !== 1 ? 's' : ''}`;
349
+ }
350
+
351
+ /**
352
+ * Explain a claim
353
+ * @param {string} key - Claim key
354
+ * @param {any} value - Claim value
355
+ * @returns {object} Claim explanation
356
+ */
357
+ function explainClaim(key, value) {
358
+ const registeredInfo = REGISTERED_CLAIMS[key];
359
+ const commonInfo = COMMON_CLAIMS[key];
360
+ const info = registeredInfo || commonInfo;
361
+
362
+ const result = {
363
+ key,
364
+ value,
365
+ isRegistered: !!registeredInfo,
366
+ isCommon: !!commonInfo
367
+ };
368
+
369
+ if (info) {
370
+ result.name = info.name;
371
+ result.description = info.description;
372
+
373
+ if (info.isDate && typeof value === 'number') {
374
+ result.dateValue = new Date(value * 1000);
375
+ result.dateFormatted = result.dateValue.toISOString();
376
+ }
377
+ } else {
378
+ result.name = key;
379
+ result.description = 'Custom claim';
380
+ }
381
+
382
+ // Format value display
383
+ if (typeof value === 'object' && value !== null) {
384
+ result.displayValue = JSON.stringify(value);
385
+ } else {
386
+ result.displayValue = String(value);
387
+ }
388
+
389
+ return result;
390
+ }
391
+
392
+ /**
393
+ * Explain a JWT token in plain English
394
+ * @param {string} token - JWT token
395
+ * @returns {object} Token explanation
396
+ */
397
+ function explain(token) {
398
+ const decoded = decode(token);
399
+
400
+ if (!decoded) {
401
+ return {
402
+ valid: false,
403
+ error: 'Invalid JWT token format',
404
+ input: token
405
+ };
406
+ }
407
+
408
+ const validation = validate(token);
409
+ const expiry = getTimeUntilExpiry(token);
410
+ const { header, payload } = decoded;
411
+
412
+ // Explain algorithm
413
+ const algInfo = ALGORITHMS[header.alg] || { name: header.alg, description: 'Unknown algorithm' };
414
+
415
+ // Explain header
416
+ const headerExplanation = {
417
+ algorithm: {
418
+ value: header.alg,
419
+ name: algInfo.name,
420
+ type: algInfo.type,
421
+ description: algInfo.description
422
+ },
423
+ type: header.typ || null,
424
+ keyId: header.kid || null,
425
+ other: {}
426
+ };
427
+
428
+ // Capture any other header fields
429
+ for (const [key, value] of Object.entries(header)) {
430
+ if (!['alg', 'typ', 'kid'].includes(key)) {
431
+ headerExplanation.other[key] = value;
432
+ }
433
+ }
434
+
435
+ // Explain claims
436
+ const claims = [];
437
+ for (const [key, value] of Object.entries(payload)) {
438
+ claims.push(explainClaim(key, value));
439
+ }
440
+
441
+ // Sort claims: registered first, then common, then custom
442
+ claims.sort((a, b) => {
443
+ if (a.isRegistered && !b.isRegistered) return -1;
444
+ if (!a.isRegistered && b.isRegistered) return 1;
445
+ if (a.isCommon && !b.isCommon) return -1;
446
+ if (!a.isCommon && b.isCommon) return 1;
447
+ return 0;
448
+ });
449
+
450
+ // Generate summary
451
+ let summary = `${algInfo.name} signed JWT`;
452
+
453
+ if (payload.sub) {
454
+ summary += ` for subject "${payload.sub}"`;
455
+ }
456
+
457
+ if (payload.iss) {
458
+ summary += ` from issuer "${payload.iss}"`;
459
+ }
460
+
461
+ if (expiry.expired) {
462
+ summary += ` (expired ${expiry.expiredAgoText} ago)`;
463
+ } else if (expiry.neverExpires) {
464
+ summary += ' (no expiration)';
465
+ } else {
466
+ summary += ` (expires in ${expiry.expiresInText})`;
467
+ }
468
+
469
+ return {
470
+ valid: true,
471
+ input: token,
472
+ validation,
473
+ header: headerExplanation,
474
+ claims,
475
+ expiry,
476
+ summary,
477
+ decoded
478
+ };
479
+ }
480
+
481
+ /**
482
+ * Format output for display
483
+ * @param {object} result - Explain result
484
+ * @param {object} options - Formatting options
485
+ * @returns {string}
486
+ */
487
+ function format(result, options = {}) {
488
+ const { color = true, detailed = false } = options;
489
+
490
+ const c = {
491
+ reset: color ? '\x1b[0m' : '',
492
+ bold: color ? '\x1b[1m' : '',
493
+ dim: color ? '\x1b[2m' : '',
494
+ cyan: color ? '\x1b[36m' : '',
495
+ green: color ? '\x1b[32m' : '',
496
+ yellow: color ? '\x1b[33m' : '',
497
+ red: color ? '\x1b[31m' : '',
498
+ magenta: color ? '\x1b[35m' : '',
499
+ blue: color ? '\x1b[34m' : ''
500
+ };
501
+
502
+ const lines = [];
503
+
504
+ if (!result.valid) {
505
+ lines.push(`${c.red}Error:${c.reset} ${result.error}`);
506
+ return lines.join('\n');
507
+ }
508
+
509
+ // Summary
510
+ lines.push(`${c.bold}JWT Token${c.reset}`);
511
+ lines.push('');
512
+ lines.push(`${c.bold}Summary:${c.reset} ${result.summary}`);
513
+
514
+ // Validation status
515
+ if (result.validation.errors.length > 0 || result.validation.warnings.length > 0) {
516
+ lines.push('');
517
+ lines.push(`${c.bold}Validation:${c.reset}`);
518
+ for (const error of result.validation.errors) {
519
+ lines.push(` ${c.red}✗ ${error}${c.reset}`);
520
+ }
521
+ for (const warning of result.validation.warnings) {
522
+ lines.push(` ${c.yellow}⚠ ${warning}${c.reset}`);
523
+ }
524
+ }
525
+
526
+ // Expiry status
527
+ lines.push('');
528
+ lines.push(`${c.bold}Expiration:${c.reset}`);
529
+ if (result.expiry.neverExpires) {
530
+ lines.push(` ${c.yellow}⚠ No expiration set${c.reset}`);
531
+ } else if (result.expiry.expired) {
532
+ lines.push(` ${c.red}✗ Expired ${result.expiry.expiredAgoText} ago${c.reset}`);
533
+ } else {
534
+ lines.push(` ${c.green}✓ Valid for ${result.expiry.expiresInText}${c.reset}`);
535
+ }
536
+
537
+ // Header
538
+ lines.push('');
539
+ lines.push(`${c.bold}Header:${c.reset}`);
540
+ lines.push(` ${c.cyan}Algorithm:${c.reset} ${result.header.algorithm.value} (${result.header.algorithm.description})`);
541
+ if (result.header.type) {
542
+ lines.push(` ${c.cyan}Type:${c.reset} ${result.header.type}`);
543
+ }
544
+ if (result.header.keyId) {
545
+ lines.push(` ${c.cyan}Key ID:${c.reset} ${result.header.keyId}`);
546
+ }
547
+ for (const [key, value] of Object.entries(result.header.other)) {
548
+ lines.push(` ${c.cyan}${key}:${c.reset} ${JSON.stringify(value)}`);
549
+ }
550
+
551
+ // Claims
552
+ lines.push('');
553
+ lines.push(`${c.bold}Claims:${c.reset}`);
554
+
555
+ for (const claim of result.claims) {
556
+ const badge = claim.isRegistered ? `${c.blue}[registered]${c.reset}` :
557
+ claim.isCommon ? `${c.magenta}[common]${c.reset}` :
558
+ `${c.dim}[custom]${c.reset}`;
559
+
560
+ if (claim.dateValue) {
561
+ lines.push(` ${c.yellow}${claim.key}${c.reset} ${badge}`);
562
+ lines.push(` ${claim.dateFormatted}`);
563
+ if (detailed) {
564
+ lines.push(` ${c.dim}${claim.name}: ${claim.description}${c.reset}`);
565
+ }
566
+ } else {
567
+ lines.push(` ${c.yellow}${claim.key}${c.reset} = ${claim.displayValue} ${badge}`);
568
+ if (detailed) {
569
+ lines.push(` ${c.dim}${claim.name}: ${claim.description}${c.reset}`);
570
+ }
571
+ }
572
+ }
573
+
574
+ // Detailed mode: show raw parts
575
+ if (detailed) {
576
+ lines.push('');
577
+ lines.push(`${c.bold}Raw Parts:${c.reset}`);
578
+ lines.push(` ${c.dim}Header:${c.reset} ${result.decoded.parts.header.substring(0, 50)}...`);
579
+ lines.push(` ${c.dim}Payload:${c.reset} ${result.decoded.parts.payload.substring(0, 50)}...`);
580
+ lines.push(` ${c.dim}Signature:${c.reset} ${result.decoded.parts.signature.substring(0, 50)}...`);
581
+ }
582
+
583
+ return lines.join('\n');
584
+ }
585
+
586
+ /**
587
+ * Create a simple JWT for testing (unsigned)
588
+ * @param {object} payload - Payload object
589
+ * @param {object} header - Header object (optional)
590
+ * @returns {string}
591
+ */
592
+ function createUnsigned(payload, header = {}) {
593
+ const fullHeader = {
594
+ alg: 'none',
595
+ typ: 'JWT',
596
+ ...header
597
+ };
598
+
599
+ const headerB64 = base64UrlEncode(JSON.stringify(fullHeader));
600
+ const payloadB64 = base64UrlEncode(JSON.stringify(payload));
601
+
602
+ return `${headerB64}.${payloadB64}.`;
603
+ }
604
+
605
+ /**
606
+ * Get all registered claims info
607
+ * @returns {object}
608
+ */
609
+ function getRegisteredClaims() {
610
+ return { ...REGISTERED_CLAIMS };
611
+ }
612
+
613
+ /**
614
+ * Get all common claims info
615
+ * @returns {object}
616
+ */
617
+ function getCommonClaims() {
618
+ return { ...COMMON_CLAIMS };
619
+ }
620
+
621
+ /**
622
+ * Get all algorithm info
623
+ * @returns {object}
624
+ */
625
+ function getAlgorithms() {
626
+ return { ...ALGORITHMS };
627
+ }
628
+
629
+ module.exports = {
630
+ // Core functions
631
+ decode,
632
+ validate,
633
+ explain,
634
+ format,
635
+
636
+ // Utilities
637
+ isExpired,
638
+ getTimeUntilExpiry,
639
+ createUnsigned,
640
+ base64UrlDecode,
641
+ base64UrlEncode,
642
+
643
+ // Info getters
644
+ getRegisteredClaims,
645
+ getCommonClaims,
646
+ getAlgorithms,
647
+ explainClaim,
648
+
649
+ // Constants
650
+ REGISTERED_CLAIMS,
651
+ COMMON_CLAIMS,
652
+ ALGORITHMS
653
+ };