From 892277c968ad40a8ce96097c1fb3021faab98dd6 Mon Sep 17 00:00:00 2001 From: Dave Ankin Date: Sat, 25 Nov 2023 20:15:21 -0500 Subject: [PATCH 1/3] #2 implementation --- index.js | 26 +++++++++++++++---- src/input.js | 12 +++++++++ src/jwt.js | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++- src/output.js | 9 ++++++- 4 files changed, 109 insertions(+), 7 deletions(-) diff --git a/index.js b/index.js index 8aa2fde..3927954 100755 --- a/index.js +++ b/index.js @@ -8,19 +8,20 @@ const { outputPayload, outputSignature, outputTokenAsJson, + outputTokenAsEncoded, outputVersion, } = require("./src/output.js"); -const { getToken, getArgument } = require("./src/input.js"); -const { decodeToken } = require("./src/jwt.js"); +const { getToken, getArgument, getCommand } = require("./src/input.js"); +const { decodeToken, encodeToken } = require("./src/jwt.js"); (async () => { const token = await getToken(process); const secret = getArgument("secret"); const output = getArgument("output"); const versionFlag = getArgument("version"); + const verboseFlag = getArgument("verbose"); const helpFlag = getArgument("help"); - const decodedToken = decodeToken(token, secret); if (versionFlag) { outputVersion(); @@ -32,9 +33,24 @@ const { decodeToken } = require("./src/jwt.js"); process.exit(0); } + const command = getCommand(); + switch (command) { + case "encode": + let header = getArgument("header"); + let body = getArgument("body"); + outputTokenAsEncoded(encodeToken(header, body, secret, { verboseFlag })); + break; + case "decode": + decode(token, secret, output); + break; + } +})(); + +function decode(token, secret, output) { + const decodedToken = decodeToken(token, secret); if (token === undefined) { process.exit(1); - } else if (output == "json") { + } else if (output === "json") { outputTokenAsJson(decodedToken); } else { outputJwtIoLink(token); @@ -43,4 +59,4 @@ const { decodeToken } = require("./src/jwt.js"); outputNicePayloadDates(decodedToken.payload); outputSignature(decodedToken.signature); } -})(); +} diff --git a/src/input.js b/src/input.js index e384d02..f1f8077 100644 --- a/src/input.js +++ b/src/input.js @@ -22,8 +22,20 @@ function getArgument(key) { return argv[key]; } +const validCommands = { encode: true, decode: true }; + +/** + * @return {'encode' | 'decode'} + */ +function getCommand() { + let positional = parseArgs(process.argv.slice(2))._; + let command = positional.shift(); + return validCommands[command] ? command : "decode"; +} + module.exports = { getToken, getArgument, + getCommand, pipedToken, }; diff --git a/src/jwt.js b/src/jwt.js index 0b185c9..0509c49 100644 --- a/src/jwt.js +++ b/src/jwt.js @@ -1,4 +1,5 @@ -const { createDecoder, createVerifier } = require("fast-jwt"); +const { randomBytes, generateKeyPairSync } = require("node:crypto"); +const { createDecoder, createVerifier, createSigner } = require("fast-jwt"); function decodeToken(token, secret) { if (token == undefined) { @@ -15,6 +16,72 @@ function decodeToken(token, secret) { return verifier(token); } +const algPrefixes = ["HS", "RS", "ES"]; + +function encodeToken(header, body = {}, secret = null, options = {}) { + if (!header) throw new Error("no header for encoding"); + const { alg } = header; + if (!alg) throw new Error("no header.algorithm for encoding"); + if (typeof alg !== "string") throw new Error("header.alg must be a string"); + + /** + * @type {'HS' | 'RS' | 'ES'} + */ + const algClass = algPrefixes.filter((e) => alg.startsWith(e)).pop(); + if (!algClass) + throw new Error(`not a known (${algPrefixes}) algorithm prefix: ${alg}`); + + if (!secret) { + let generatedPublicKey; + + switch (algClass) { + case "HS": { + secret = randomBytes(64).toString("hex"); + break; + } + + case "RS": { + const { publicKey, privateKey } = generateKeyPairSync("rsa", { + modulusLength: 1024, + publicKeyEncoding: { type: "pkcs1", format: "pem" }, + privateKeyEncoding: { type: "pkcs1", format: "pem" }, + }); + secret = privateKey; + generatedPublicKey = publicKey; + break; + } + + case "ES": { + const { publicKey, privateKey } = generateKeyPairSync("ec", { + namedCurve: "secp256k1", + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + }); + secret = privateKey; + generatedPublicKey = publicKey; + break; + } + } + + if (options.verboseFlag && generatedPublicKey) { + const verboseMessage = + "printing generated key because verbose mode is selected"; + let obj = { message: verboseMessage, secret, generatedPublicKey }; + console.log(JSON.stringify(obj, null, 2)); + } + } + + return createSigner({ + ...header, + algorithm: alg, + key: secret, + expiresIn: header.exp, + notBefore: header.nbf, + kid: algClass === "ES" ? "" + header.kid : header.kid, + })(body); +} + module.exports = { decodeToken, + encodeToken, }; diff --git a/src/output.js b/src/output.js index cb872e6..773c48c 100644 --- a/src/output.js +++ b/src/output.js @@ -30,6 +30,10 @@ function outputTokenAsJson(decodedToken) { process.stdout.write(JSON.stringify(decodedToken, null, 2)); } +function outputTokenAsEncoded(encodedToken) { + process.stdout.write(encodedToken); +} + function outputJwtIoLink(token) { const parts = token.split("."); console.log(chalk.yellow("\nTo verify on jwt.io:\n")); @@ -67,7 +71,9 @@ function outputHelp() { console.log("jwt-cli - JSON Web Token parser\n"); console.log( chalk.yellow( - "Usage: jwt --secret= --output=json\n" + "Usage: jwt --secret= --output=json\n" + + "\n" + + "Usage: jwt encode --verbose --header.alg HS256 --secret=\n" ) ); console.log("ℹ Documentation: https://www.npmjs.com/package/jwt-cli"); @@ -89,6 +95,7 @@ module.exports = { outputPayload, outputSignature, outputTokenAsJson, + outputTokenAsEncoded, outputVersion, prettyJson, }; From de66a076ebcc5ab9c29461317b500201d0f8c2cd Mon Sep 17 00:00:00 2001 From: Dave Ankin Date: Sat, 25 Nov 2023 20:35:45 -0500 Subject: [PATCH 2/3] basic tests for generated secret paths --- __tests__/jwt.test.js | 50 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/__tests__/jwt.test.js b/__tests__/jwt.test.js index ec85ce5..00432c9 100644 --- a/__tests__/jwt.test.js +++ b/__tests__/jwt.test.js @@ -1,4 +1,5 @@ const { decodeToken } = require("../src/jwt.js"); +const { encodeToken } = require("../src/jwt.js"); test("decode an undefined token", () => { expect(decodeToken(undefined)).toStrictEqual(undefined); @@ -46,3 +47,52 @@ test("decode a jwt with a secret", () => { signature: "bbnVJXHRSGcz5UbklFWC-_MCZQSucRVAwPfEbp5KoJ4", }); }); + +test("encode a jwt with any default algo (HS/RS/EC)", () => { + [ + "HS256", + "HS384", + "HS512", + "ES256", + "ES384", + "ES512", + "RS256", + "RS384", + "RS512", + ].forEach((supportedAlgo) => { + encodeToken({ alg: supportedAlgo }); + }); +}); + +test("does not accept not accepted type of algorithm (HS/RS/EC)", () => { + [ + // nothing wrong with these, probably support in the future + "PS256", + "PS384", + "PS512", + "EdDSA", + ].forEach((supportedAlgo) => { + let err; + try { + encodeToken({ alg: supportedAlgo }); + } catch (e) { + err = e; + } + expect(err).toBeTruthy(); + }); +}); + +test("encode a jwt with any default algo (HS/RS/EC) with secret as input", () => { + [ + { alg: "HS256", secret: "0now6GtrlwaCTcgI#" }, + { alg: "HS384", secret: "ekR2FspZ4X#FKvfJ" }, + { alg: "HS512", secret: "il4qOYD5#kAu3bwW" }, + ].forEach(({ alg, secret }) => { + let token = encodeToken({ alg }, { hello: "world" }, secret); + let decoded = decodeToken(token); + expect(decoded.header?.alg).toEqual(alg); + expect(decoded.payload?.hello).toEqual("world"); + expect(typeof decoded.payload?.iat).toBe("number"); + decodeToken(token, secret); + }); +}); From 9a25a4eeca7ea559fc0c40f338c75faf1ffb67b6 Mon Sep 17 00:00:00 2001 From: Dave Ankin Date: Sat, 25 Nov 2023 20:57:03 -0500 Subject: [PATCH 3/3] finish tests --- __tests__/input.test.js | 36 +++++++++++++++++++++++++++++++++++- __tests__/jwt.test.js | 28 ++++++++++++++++++++++++++++ __tests__/output.test.js | 8 ++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/__tests__/input.test.js b/__tests__/input.test.js index ddf9488..f3161b0 100644 --- a/__tests__/input.test.js +++ b/__tests__/input.test.js @@ -1,4 +1,9 @@ -const { getArgument, getToken, pipedToken } = require("../src/input.js"); +const { + getArgument, + getCommand, + getToken, + pipedToken, +} = require("../src/input.js"); const { Readable } = require("stream"); async function* tokenGenerator() { @@ -39,3 +44,32 @@ test("get token from stdin piped stream", async () => { "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.bbnVJXHRSGcz5UbklFWC-_MCZQSucRVAwPfEbp5KoJ4" ); }); + +test("get command works as expected", () => { + const oldArgv = process.argv; + const args = ["node", "index.js"]; + try { + // default is encode + process.argv = args.concat(["some-token", "--secret", "s"]); + let defaultCommand = getCommand(); + expect(defaultCommand).toEqual("decode"); + + // explicit works as well + process.argv = args.concat(["decode", "some-token", "--secret", "s"]); + let explicitCommand = getCommand(); + expect(explicitCommand).toEqual("decode"); + + // decode works when explicitly given + process.argv = args.concat([ + "encode", + "--header.alg", + "HS256", + "--body.sub", + "me", + ]); + let encodeCommand = getCommand(); + expect(encodeCommand).toEqual("encode"); + } finally { + process.argv = oldArgv; + } +}); diff --git a/__tests__/jwt.test.js b/__tests__/jwt.test.js index 00432c9..fe174b5 100644 --- a/__tests__/jwt.test.js +++ b/__tests__/jwt.test.js @@ -96,3 +96,31 @@ test("encode a jwt with any default algo (HS/RS/EC) with secret as input", () => decodeToken(token, secret); }); }); + +test("encoding fails when no or bad header or algo", () => { + expect(() => encodeToken(null)).toThrow(); + expect(() => encodeToken({})).toThrow(); + expect(() => encodeToken({ alg: 1 })).toThrow(); + expect(() => encodeToken({ alg: "1" })).toThrow(); +}); + +test("printing the generated public key", () => { + const originalLog = console.log; + const invocations = []; + console.log = function () { + invocations.push(arguments); + }; + + const verb = { verboseFlag: true }; + try { + encodeToken({ alg: "RS256" }); + expect(invocations.length).toBe(0); + encodeToken({ alg: "RS256" }, {}, null, verb); + expect(invocations.length).toBe(1); + expect(invocations[0]["0"]).toContain( + "printing generated key because verbose mode is selected" + ); + } finally { + console.log = originalLog; + } +}); diff --git a/__tests__/output.test.js b/__tests__/output.test.js index a8a1687..f586f7c 100644 --- a/__tests__/output.test.js +++ b/__tests__/output.test.js @@ -7,6 +7,7 @@ const { outputPayload, outputSignature, outputTokenAsJson, + outputTokenAsEncoded, outputVersion, prettyJson, } = require("../src/output.js"); @@ -83,6 +84,13 @@ test("output token as json", () => { stdoutWrite.mockRestore(); }); +test("output token as encoded", () => { + const stdoutWrite = jest.spyOn(process.stdout, "write"); + outputTokenAsEncoded(''); + expect(stdoutWrite).toHaveBeenCalled(); + stdoutWrite.mockRestore(); +}); + test("output jwt.io link", () => { const consoleSpy = jest.spyOn(console, "log"); const token =