Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion __tests__/input.test.js
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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;
}
});
78 changes: 78 additions & 0 deletions __tests__/jwt.test.js
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -46,3 +47,80 @@ 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);
});
});

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;
}
});
8 changes: 8 additions & 0 deletions __tests__/output.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const {
outputPayload,
outputSignature,
outputTokenAsJson,
outputTokenAsEncoded,
outputVersion,
prettyJson,
} = require("../src/output.js");
Expand Down Expand Up @@ -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 =
Expand Down
26 changes: 21 additions & 5 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
Expand All @@ -43,4 +59,4 @@ const { decodeToken } = require("./src/jwt.js");
outputNicePayloadDates(decodedToken.payload);
outputSignature(decodedToken.signature);
}
})();
}
12 changes: 12 additions & 0 deletions src/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
69 changes: 68 additions & 1 deletion src/jwt.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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,
};
9 changes: 8 additions & 1 deletion src/output.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -67,7 +71,9 @@ function outputHelp() {
console.log("jwt-cli - JSON Web Token parser\n");
console.log(
chalk.yellow(
"Usage: jwt <encoded token> --secret=<optional signing secret> --output=json\n"
"Usage: jwt <encoded token> --secret=<optional signing secret> --output=json\n" +
"\n" +
"Usage: jwt encode --verbose --header.alg HS256 --secret=<optional signing secret>\n"
)
);
console.log("ℹ Documentation: https://www.npmjs.com/package/jwt-cli");
Expand All @@ -89,6 +95,7 @@ module.exports = {
outputPayload,
outputSignature,
outputTokenAsJson,
outputTokenAsEncoded,
outputVersion,
prettyJson,
};