From dae8ae1e33ebe851ac4c4f007048b65abffee3c7 Mon Sep 17 00:00:00 2001 From: Arslan Date: Sun, 11 Jun 2023 21:25:31 +0500 Subject: [PATCH 1/6] update benchmarks --- README.md | 49 ++++++++++++++++++++++++++++++++ bench/decode.mjs | 71 +++++++++++++++++++++++----------------------- bench/sign.mjs | 72 ++++++++++++++++++++++++----------------------- bench/verify.mjs | 72 ++++++++++++++++++++++------------------------- index.d.ts | 4 +-- package.json | 4 ++- src/jwt_client.rs | 21 ++++++++++---- yarn.lock | 18 ++++++++++++ 8 files changed, 193 insertions(+), 118 deletions(-) diff --git a/README.md b/README.md index 3014b4d..85c35f3 100644 --- a/README.md +++ b/README.md @@ -1 +1,50 @@ # Faster JWT (rust bindings) with an (optional) LRU cache + + +--- +## Benchmarks + +Benchmarks were performed using [benchmark](https://www.npmjs.com/package/benchmark) package, against [jsonwebtoken](https://www.npmjs.com/package/jsonwebtoken), [jose](https://www.npmjs.com/package/jose) and [fast-jwt](https://www.npmjs.com/package/fast-jwt). It was not done to cast shadow over those packages, merely to check whether this package is performant enough or not. + +Machine Details: + - OS: Ubuntu 22.04 + - CPU: Intel i7 + - Memory: 16G + +**DISCLAIMER: Always take benchmarks like this with a grain of salt, as they may not always be indicative of good performance. And performance may not be the top thing to consider when choosing a package for solving your problem (unless the problem is that of performance itself). It would be best to perform these benchmarks on your own machine/deployment environment before making any decision.** + +### Signing Benchmark ([bench/sign.mjs](./bench/sign.mjs)) + +``` +jsonwebtoken x 1,982 ops/sec ±0.57% (189 runs sampled) +jose x 55,908 ops/sec ±0.78% (177 runs sampled) +fast-jwt x 52,656 ops/sec ±0.59% (186 runs sampled) +@carbonteq/jwt x 362,540 ops/sec ±0.35% (192 runs sampled) +@carbonteq/jwt#signClaims x 210,083 ops/sec ±2.29% (184 runs sampled) + +SUITE : Fastest is @carbonteq/jwt +``` + +### Verifying Token ([bench/verify.mjs](./bench/verify.mjs)) + +``` +jsonwebtoken x 2,068 ops/sec ±0.46% (187 runs sampled) +jose x 55,797 ops/sec ±0.29% (182 runs sampled) +fast-jwt x 68,474 ops/sec ±0.79% (182 runs sampled) +@carbonteq/jwt x 166,543 ops/sec ±0.73% (189 runs sampled) + +SUITE : Fastest is @carbonteq/jwt +``` + +### Decoding Token without Verification ([bench/decode.mjs](./bench/decode.mjs)) + +This package performs the worst here, but you'll probably not use this method much, seeing as you would likely be performing decoding with verification + +``` +jsonwebtoken x 257,054 ops/sec ±1.27% (190 runs sampled) +jose x 921,471 ops/sec ±0.64% (188 runs sampled) +fast-jwt x 519,612 ops/sec ±1.71% (186 runs sampled) +@carbonteq/jwt x 137,408 ops/sec ±2.11% (184 runs sampled) + +SUITE : Fastest is jose +``` diff --git a/bench/decode.mjs b/bench/decode.mjs index fbfa06e..b9f4e7d 100644 --- a/bench/decode.mjs +++ b/bench/decode.mjs @@ -1,10 +1,11 @@ +import { Claims, JwtClient } from '../index.js'; import bench from 'benchmark'; -import jwt from 'jsonwebtoken'; +import chalk from 'chalk'; import fastJwt from 'fast-jwt'; import * as jose from 'jose'; -import { Claims, JwtClient } from '../index.js'; +import jwt from 'jsonwebtoken'; -const suite = new bench.Suite(); +const suite = new bench.Suite('Decode (No Verification)'); const secret = 'somelongsecretasdbnakwfbjawf'; const minSamples = 100; @@ -21,53 +22,53 @@ const joseSign = async (payload) => { // return await jose.JWT.sign(payload, key); }; -const joseSigned = await joseSign(payload); +const joseToken = await joseSign(payload); const jwtSigned = jwt.sign(payload, secret); const signer = fastJwt.createSigner({ key: secret }); const fastJwtDecode = fastJwt.createDecoder(); -const fastJwtSigned = signer(payload); +const fastJwtToken = signer(payload); const claims = new Claims(JSON.stringify(payload), 60000); -const fasterJwtSigned = client.signClaims(claims); +const ctJwtToken = client.signClaims(claims); suite - .add('jsonwebtoken#decode', { - defer: true, - minSamples, - fn: function (deferred) { + .add( + 'jsonwebtoken', + () => { jwt.decode(jwtSigned); - deferred.resolve(); }, - }) - .add('jose#decode', { - defer: true, - minSamples, - fn: function (deferred) { - jose.decodeJwt(joseSigned); - deferred.resolve(); + { minSamples }, + ) + .add( + 'jose', + () => { + jose.decodeJwt(joseToken); }, - }) - .add('fastjwt#decode', { - defer: true, - minSamples, - fn: function (deferred) { - fastJwtDecode(fastJwtSigned); - deferred.resolve(); + { minSamples }, + ) + .add( + 'fast-jwt', + () => { + fastJwtDecode(fastJwtToken); }, - }) - .add('@carbonteq/jwt#decode', { - defer: true, - minSamples, - fn: function (deferred) { - client.decode(fasterJwtSigned); - deferred.resolve(); + { minSamples }, + ) + .add( + '@carbonteq/jwt', + () => { + client.decode(ctJwtToken); }, - }) + { minSamples }, + ) .on('cycle', (e) => { console.log(String(e.target)); }) .on('complete', function () { - console.log('\nFastest is ' + this.filter('fastest').map('name')); + console.log( + `\nSUITE <${this.name}>: Fastest is ${chalk.green( + this.filter('fastest').map('name'), + )}`, + ); }) - .run({ async: true }); + .run(); diff --git a/bench/sign.mjs b/bench/sign.mjs index 70aa8c4..299bf13 100644 --- a/bench/sign.mjs +++ b/bench/sign.mjs @@ -1,10 +1,11 @@ +import { Claims, JwtClient } from '../index.js'; import bench from 'benchmark'; -import jwt from 'jsonwebtoken'; +import chalk from 'chalk'; import fastJwt from 'fast-jwt'; import * as jose from 'jose'; -import { Claims, JwtClient } from '../index.js'; +import jwt from 'jsonwebtoken'; -const suite = new bench.Suite(); +const suite = new bench.Suite('Sign token'); const secret = 'somelongsecretasdbnakwfbjawf'; const minSamples = 100; @@ -22,53 +23,54 @@ const joseSign = async (payload) => { }; suite - .add('jsonwebtoken#sign', { - defer: true, - minSamples, - fn: function (deferred) { + .add( + 'jsonwebtoken', + () => { jwt.sign(payload, secret); - deferred.resolve(); }, - }) - .add('jose#sign', { - defer: true, - minSamples, - fn: function (deferred) { - joseSign(payload).then(() => deferred.resolve()); + { minSamples }, + ) + .add( + 'jose', + async (deferred) => { + await joseSign(payload); + deferred.resolve(); // const s = new jose.SignJWT(payload); // s.setProtectedHeader({ alg: "HS256" }).sign(encodedKey).then(defer); }, - }) - .add('fastjwt#sign', { - defer: true, - minSamples, - fn: function (deferred) { + { defer: true, minSamples }, + ) + .add( + 'fast-jwt', + () => { const signer = fastJwt.createSigner({ key: secret }); signer(payload); - deferred.resolve(); }, - }) - .add('@carbonteq/jwt#sign', { - defer: true, - minSamples, - fn: function (deferred) { + { minSamples }, + ) + .add( + '@carbonteq/jwt', + () => { client.sign(payload, 1000); - deferred.resolve(); }, - }) - .add('@carbonteq/jwt#signClaims', { - defer: true, - minSamples, - fn: function (deferred) { + { minSamples }, + ) + .add( + '@carbonteq/jwt#signClaims', + () => { const claims = new Claims(payload, 1000); client.signClaims(claims); - deferred.resolve(); }, - }) + { minSamples }, + ) .on('cycle', (e) => { console.log(String(e.target)); }) .on('complete', function () { - console.log('\nFastest is ' + this.filter('fastest').map('name')); + console.log( + `\nSUITE <${this.name}>: Fastest is ${chalk.green( + this.filter('fastest').map('name'), + )}`, + ); }) - .run({ async: true }); + .run(); diff --git a/bench/verify.mjs b/bench/verify.mjs index d1e7b09..b92a8a4 100644 --- a/bench/verify.mjs +++ b/bench/verify.mjs @@ -1,10 +1,11 @@ +import { Claims, JwtClient } from '../index.js'; import bench from 'benchmark'; -import jwt from 'jsonwebtoken'; +import chalk from 'chalk'; import fastJwt from 'fast-jwt'; import * as jose from 'jose'; -import { Claims, JwtClient } from '../index.js'; +import jwt from 'jsonwebtoken'; -const suite = new bench.Suite(); +const suite = new bench.Suite('Verify Token'); const secret = 'somelongsecretasdbnakwfbjawf'; const minSamples = 100; @@ -26,57 +27,50 @@ const joseSigned = await joseSign(payload); const jwtSigned = jwt.sign(payload, secret); const signer = fastJwt.createSigner({ key: secret }); -const fastJwtVerify = fastJwt.createVerifier({ key: secret }); const fastJwtSigned = signer(payload); const claims = new Claims(JSON.stringify(payload), expires_in); const fasterJwtSigned = client.signClaims(claims); suite - .add('jsonwebtoken#verify', { - defer: true, - minSamples, - fn: function (deferred) { + .add( + 'jsonwebtoken', + () => { jwt.verify(jwtSigned, secret); - deferred.resolve(); - }, - }) - .add('jose#verify', { - defer: true, - minSamples, - fn: function (deferred) { - jose.jwtVerify(joseSigned, encodedKey).then(() => deferred.resolve()); }, - }) - .add('fastjwt#verify', { - defer: true, - minSamples, - fn: function (deferred) { - const verifyIt = fastJwt.createVerifier({ key: secret }); - verifyIt(fastJwtSigned); + { minSamples }, + ) + .add( + 'jose', + async (deferred) => { + await jose.jwtVerify(joseSigned, encodedKey); deferred.resolve(); }, - }) - .add('fastjwt#verifyWithCache', { - defer: true, - minSamples, - fn: function (deferred) { + { defer: true, minSamples }, + ) + .add( + 'fast-jwt', + () => { + const fastJwtVerify = fastJwt.createVerifier({ key: secret }); fastJwtVerify(fastJwtSigned); - deferred.resolve(); }, - }) - .add('@carbonteq/jwt#verify', { - defer: true, - minSamples, - fn: function (deferred) { + { minSamples }, + ) + .add( + '@carbonteq/jwt', + () => { client.verify(fasterJwtSigned); - deferred.resolve(); }, - }) - .on('cycle', (e) => { + { minSamples }, + ) + .on('cycle', function (e) { console.log(String(e.target)); }) .on('complete', function () { - console.log('\nFastest is ' + this.filter('fastest').map('name')); + console.log( + `\nSUITE <${this.name}>: Fastest is ${chalk.green( + this.filter('fastest').map('name'), + )}`, + ); }) - .run({ async: true }); + .run(); diff --git a/index.d.ts b/index.d.ts index efac35c..b0c2339 100644 --- a/index.d.ts +++ b/index.d.ts @@ -27,6 +27,6 @@ export class JwtClient { static fromBufferKey(secretKey: Buffer): JwtClient sign(data: Record, expiresInSeconds: number, claimOpts?: ClaimOpts | undefined | null): string signClaims(claims: Claims): string - verify(token: string): boolean - verifyAndDecode(token: string): Claims + verify(token: string): Claims + decode(token: string): Claims } diff --git a/package.json b/package.json index 3935634..c6880b3 100644 --- a/package.json +++ b/package.json @@ -41,9 +41,11 @@ "@changesets/cli": "^2.26.1", "@napi-rs/cli": "^2.16.1", "@types/benchmark": "^2.1.2", + "@types/jsonwebtoken": "^9.0.2", "@types/node": "^20.2.6", "ava": "^5.1.1", "benchmark": "^2.1.4", + "chalk": "^5.2.0", "esbuild": "^0.18.0", "esbuild-runner": "^2.2.2", "fast-jwt": "^3.1.1", @@ -77,7 +79,7 @@ "test": "ava", "universal": "napi universal", "version": "changeset version && napi version", - "release": "changeset publish" + "release": "changeset publish --no-git-tag" }, "packageManager": "yarn@3.6.0" } diff --git a/src/jwt_client.rs b/src/jwt_client.rs index 58d2015..4761d31 100644 --- a/src/jwt_client.rs +++ b/src/jwt_client.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation}; use napi::bindgen_prelude::Buffer; use napi_derive::napi; @@ -10,6 +12,7 @@ pub struct JwtClient { decoding_key: DecodingKey, header: Header, validation: Validation, + no_valid: Validation, } #[napi] @@ -18,12 +21,16 @@ impl JwtClient { fn from_key(key: &[u8]) -> Self { let encoding_key = EncodingKey::from_secret(key); let decoding_key = DecodingKey::from_secret(key); + let mut no_valid = Validation::new(jsonwebtoken::Algorithm::HS256); + no_valid.required_spec_claims = HashSet::new(); + no_valid.insecure_disable_signature_validation(); Self { encoding_key, decoding_key, header: Header::default(), validation: Validation::default(), + no_valid, } } @@ -56,12 +63,7 @@ impl JwtClient { } #[napi] - pub fn verify(&self, token: String) -> bool { - jsonwebtoken::decode::(&token, &self.decoding_key, &self.validation).is_ok() - } - - #[napi] - pub fn verify_and_decode(&self, token: String) -> napi::Result { + pub fn verify(&self, token: String) -> napi::Result { let decode_res = jsonwebtoken::decode::(&token, &self.decoding_key, &self.validation); match decode_res { @@ -72,4 +74,11 @@ impl JwtClient { } } } + + #[napi] + pub fn decode(&self, token: String) -> Claims { + jsonwebtoken::decode::(&token, &self.decoding_key, &self.no_valid) + .unwrap() + .claims + } } diff --git a/yarn.lock b/yarn.lock index cf59fec..cac2dea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -48,9 +48,11 @@ __metadata: "@changesets/cli": ^2.26.1 "@napi-rs/cli": ^2.16.1 "@types/benchmark": ^2.1.2 + "@types/jsonwebtoken": ^9.0.2 "@types/node": ^20.2.6 ava: ^5.1.1 benchmark: ^2.1.4 + chalk: ^5.2.0 esbuild: ^0.18.0 esbuild-runner: ^2.2.2 fast-jwt: ^3.1.1 @@ -667,6 +669,15 @@ __metadata: languageName: node linkType: hard +"@types/jsonwebtoken@npm:^9.0.2": + version: 9.0.2 + resolution: "@types/jsonwebtoken@npm:9.0.2" + dependencies: + "@types/node": "*" + checksum: 3bb8d40e78d7eb53e427db6e9f0f22e0890cfee80965dcf741d08341814913afb211306de6e9847c6d241cc8e36f8a59090cbfdcc510ab7c81af9d650c5afe0e + languageName: node + linkType: hard + "@types/minimist@npm:^1.2.0": version: 1.2.2 resolution: "@types/minimist@npm:1.2.2" @@ -674,6 +685,13 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:*": + version: 20.3.0 + resolution: "@types/node@npm:20.3.0" + checksum: 613e878174febc0104dae210088645cbb5096a19ae5fdf57fbc06fa6ef71755b839858c2b313fb8c1df8b7c6660e1b5f7a64db2126af9d47d1d9238e2bc0a86d + languageName: node + linkType: hard + "@types/node@npm:^12.7.1": version: 12.20.55 resolution: "@types/node@npm:12.20.55" From b6de2c6e6b610b87f03dcee02439745b3c790330 Mon Sep 17 00:00:00 2001 From: Arslan Date: Sun, 11 Jun 2023 21:34:09 +0500 Subject: [PATCH 2/6] update tests --- README.md | 13 ++++++------ __test__/jwt-client.spec.ts | 41 +++++++++++++++++++------------------ src/jwt_client.rs | 1 + 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 85c35f3..9ca2d38 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,16 @@ # Faster JWT (rust bindings) with an (optional) LRU cache - --- + ## Benchmarks Benchmarks were performed using [benchmark](https://www.npmjs.com/package/benchmark) package, against [jsonwebtoken](https://www.npmjs.com/package/jsonwebtoken), [jose](https://www.npmjs.com/package/jose) and [fast-jwt](https://www.npmjs.com/package/fast-jwt). It was not done to cast shadow over those packages, merely to check whether this package is performant enough or not. Machine Details: - - OS: Ubuntu 22.04 - - CPU: Intel i7 - - Memory: 16G + +- OS: Ubuntu 22.04 +- CPU: Intel i7 +- Memory: 16G **DISCLAIMER: Always take benchmarks like this with a grain of salt, as they may not always be indicative of good performance. And performance may not be the top thing to consider when choosing a package for solving your problem (unless the problem is that of performance itself). It would be best to perform these benchmarks on your own machine/deployment environment before making any decision.** @@ -19,7 +20,7 @@ Machine Details: jsonwebtoken x 1,982 ops/sec ±0.57% (189 runs sampled) jose x 55,908 ops/sec ±0.78% (177 runs sampled) fast-jwt x 52,656 ops/sec ±0.59% (186 runs sampled) -@carbonteq/jwt x 362,540 ops/sec ±0.35% (192 runs sampled) +@carbonteq/jwt x 362,540 ops/sec ±0.35% (192 runs sampled) @carbonteq/jwt#signClaims x 210,083 ops/sec ±2.29% (184 runs sampled) SUITE : Fastest is @carbonteq/jwt @@ -38,7 +39,7 @@ SUITE : Fastest is @carbonteq/jwt ### Decoding Token without Verification ([bench/decode.mjs](./bench/decode.mjs)) -This package performs the worst here, but you'll probably not use this method much, seeing as you would likely be performing decoding with verification +This package performs the worst here, as I am not actually bypassing verification completely, but you'll probably not use this method much, seeing as you would likely be performing decoding with verification ``` jsonwebtoken x 257,054 ops/sec ±1.27% (190 runs sampled) diff --git a/__test__/jwt-client.spec.ts b/__test__/jwt-client.spec.ts index e3d6103..da984e8 100644 --- a/__test__/jwt-client.spec.ts +++ b/__test__/jwt-client.spec.ts @@ -3,20 +3,21 @@ import test from 'ava'; import * as jose from 'jose'; const secret = 'testsecretkeycanbeexposed'; +const normalExpiresIn = 10000; const secretEnc = new TextEncoder().encode(secret); const client = new JwtClient(secret); const testPayload = { user: 'test@carbonteq.dev' }; test('should create a valid token from payload', async (t) => { - const token = client.sign(testPayload, 10000); + const token = client.sign(testPayload, normalExpiresIn); const joseVerifyRes = await jose.jwtVerify(token, secretEnc); t.deepEqual(joseVerifyRes.payload?.data, testPayload); }); test('should create a valid token from claims', async (t) => { - const claims = new Claims(testPayload, 10000); + const claims = new Claims(testPayload, normalExpiresIn); const token = client.signClaims(claims); const joseVerifyRes = await jose.jwtVerify(token, secretEnc); @@ -24,45 +25,45 @@ test('should create a valid token from claims', async (t) => { }); test('created token should be valid', (t) => { - const token = client.sign(testPayload, 10000); + const token = client.sign(testPayload, normalExpiresIn); - t.true(client.verify(token)); + t.deepEqual(client.verify(token).data, testPayload); }); test('created (claims) token should be valid', (t) => { - const claims = new Claims(testPayload, 10000); + const claims = new Claims(testPayload, normalExpiresIn); const token = client.signClaims(claims); - t.true(client.verify(token)); + t.deepEqual(client.verify(token).data, testPayload); }); -test('decode output should give the correct payload data', (t) => { - const token = client.sign(testPayload, 10000); - const decoded = client.verifyAndDecode(token); +test('verifying after exp should return false', (t) => { + const claims = new Claims(testPayload, 2); + claims.exp = 10; // In the past + const token = client.signClaims(claims); - t.deepEqual(decoded.data, testPayload); + t.throws(() => client.verify(token)); }); -test('decode output should give the correct payload data for claims', (t) => { - const claims = new Claims(testPayload, 10000); - const token = client.signClaims(claims); - const decoded = client.verifyAndDecode(token); +test('decode output should give the correct payload data', (t) => { + const token = client.sign(testPayload, normalExpiresIn); + const decoded = client.decode(token); t.deepEqual(decoded.data, testPayload); }); -test('verifying after exp should return false', async (t) => { - const claims = new Claims(testPayload, 2); - claims.exp = 10; // In the past +test('decode output should give the correct payload data for claims', (t) => { + const claims = new Claims(testPayload, normalExpiresIn); const token = client.signClaims(claims); + const decoded = client.decode(token); - t.false(client.verify(token)); + t.deepEqual(decoded.data, testPayload); }); -test('decoding after exp should throw error', async (t) => { +test('decoding after exp should return payload', (t) => { const claims = new Claims(testPayload, 2); claims.exp = 10; // In the past const token = client.signClaims(claims); - t.throws(() => client.verifyAndDecode(token)); + t.deepEqual(client.decode(token).data, testPayload); }); diff --git a/src/jwt_client.rs b/src/jwt_client.rs index 4761d31..f7c3731 100644 --- a/src/jwt_client.rs +++ b/src/jwt_client.rs @@ -22,6 +22,7 @@ impl JwtClient { let encoding_key = EncodingKey::from_secret(key); let decoding_key = DecodingKey::from_secret(key); let mut no_valid = Validation::new(jsonwebtoken::Algorithm::HS256); + no_valid.validate_exp = false; no_valid.required_spec_claims = HashSet::new(); no_valid.insecure_disable_signature_validation(); From 5803dec66071a13116c54bc72f104e5e6aa4cf27 Mon Sep 17 00:00:00 2001 From: Arslan Date: Sun, 11 Jun 2023 21:42:54 +0500 Subject: [PATCH 3/6] update package.json --- package.json | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index c6880b3..dfa6c41 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,25 @@ "main": "index.js", "types": "index.d.ts", "scope": "@carbonteq", + "author": { + "name": "Muhammad Arslan", + "url": "https://github.com/volf52", + "email": "arsalan.karamat@carbonteq.com" + }, + "keywords": [ + "jwt", + "jsonwebtoken", + "napi", + "napi-rs", + "carbonteq", + "N-API" + ], "repository": { "type": "git", "url": "https://github.com/carbonteq/jwt" }, "bugs": { - "url": "https://github.com/carbonteq/hexapp/issues" + "url": "https://github.com/carbonteq/jwt/issues" }, "homepage": "https://github.com/carbonteq/jwt#readme", "sideEffects": false, From 0b8ab6dd5502fab75dad671d95152b5490de7677 Mon Sep 17 00:00:00 2001 From: Arslan Date: Fri, 23 Jun 2023 20:29:44 +0500 Subject: [PATCH 4/6] clean up consturctor, and remove decode and signClaims --- .changeset/twenty-cars-attend.md | 5 +++ bench/decode.mjs | 74 -------------------------------- bench/sign.mjs | 10 +---- bench/verify.mjs | 7 ++- index.d.ts | 5 +-- src/jwt_client.rs | 51 +++++++++++----------- 6 files changed, 34 insertions(+), 118 deletions(-) create mode 100644 .changeset/twenty-cars-attend.md delete mode 100644 bench/decode.mjs diff --git a/.changeset/twenty-cars-attend.md b/.changeset/twenty-cars-attend.md new file mode 100644 index 0000000..b26e1fd --- /dev/null +++ b/.changeset/twenty-cars-attend.md @@ -0,0 +1,5 @@ +--- +"@carbonteq/jwt": patch +--- + +Remove decode and signClaims, and clean up constructor diff --git a/bench/decode.mjs b/bench/decode.mjs deleted file mode 100644 index b9f4e7d..0000000 --- a/bench/decode.mjs +++ /dev/null @@ -1,74 +0,0 @@ -import { Claims, JwtClient } from '../index.js'; -import bench from 'benchmark'; -import chalk from 'chalk'; -import fastJwt from 'fast-jwt'; -import * as jose from 'jose'; -import jwt from 'jsonwebtoken'; - -const suite = new bench.Suite('Decode (No Verification)'); - -const secret = 'somelongsecretasdbnakwfbjawf'; -const minSamples = 100; - -const encodedKey = new TextEncoder().encode(secret); -const payload = { userId: 'abc123' }; - -const client = new JwtClient(secret); - -const joseSign = async (payload) => { - const s = new jose.SignJWT(payload); - return s.setProtectedHeader({ alg: 'HS256' }).sign(encodedKey); - // const key = jose.JWK.asKey(secret); - // return await jose.JWT.sign(payload, key); -}; - -const joseToken = await joseSign(payload); -const jwtSigned = jwt.sign(payload, secret); - -const signer = fastJwt.createSigner({ key: secret }); -const fastJwtDecode = fastJwt.createDecoder(); -const fastJwtToken = signer(payload); - -const claims = new Claims(JSON.stringify(payload), 60000); -const ctJwtToken = client.signClaims(claims); - -suite - .add( - 'jsonwebtoken', - () => { - jwt.decode(jwtSigned); - }, - { minSamples }, - ) - .add( - 'jose', - () => { - jose.decodeJwt(joseToken); - }, - { minSamples }, - ) - .add( - 'fast-jwt', - () => { - fastJwtDecode(fastJwtToken); - }, - { minSamples }, - ) - .add( - '@carbonteq/jwt', - () => { - client.decode(ctJwtToken); - }, - { minSamples }, - ) - .on('cycle', (e) => { - console.log(String(e.target)); - }) - .on('complete', function () { - console.log( - `\nSUITE <${this.name}>: Fastest is ${chalk.green( - this.filter('fastest').map('name'), - )}`, - ); - }) - .run(); diff --git a/bench/sign.mjs b/bench/sign.mjs index 299bf13..eb7777d 100644 --- a/bench/sign.mjs +++ b/bench/sign.mjs @@ -1,4 +1,4 @@ -import { Claims, JwtClient } from '../index.js'; +import { JwtClient } from '../index.js'; import bench from 'benchmark'; import chalk from 'chalk'; import fastJwt from 'fast-jwt'; @@ -55,14 +55,6 @@ suite }, { minSamples }, ) - .add( - '@carbonteq/jwt#signClaims', - () => { - const claims = new Claims(payload, 1000); - client.signClaims(claims); - }, - { minSamples }, - ) .on('cycle', (e) => { console.log(String(e.target)); }) diff --git a/bench/verify.mjs b/bench/verify.mjs index b92a8a4..dd97476 100644 --- a/bench/verify.mjs +++ b/bench/verify.mjs @@ -1,4 +1,4 @@ -import { Claims, JwtClient } from '../index.js'; +import { JwtClient } from '../index.js'; import bench from 'benchmark'; import chalk from 'chalk'; import fastJwt from 'fast-jwt'; @@ -29,8 +29,7 @@ const jwtSigned = jwt.sign(payload, secret); const signer = fastJwt.createSigner({ key: secret }); const fastJwtSigned = signer(payload); -const claims = new Claims(JSON.stringify(payload), expires_in); -const fasterJwtSigned = client.signClaims(claims); +const ctJwtSigned = client.sign(payload, expires_in); suite .add( @@ -59,7 +58,7 @@ suite .add( '@carbonteq/jwt', () => { - client.verify(fasterJwtSigned); + client.verify(ctJwtSigned); }, { minSamples }, ) diff --git a/index.d.ts b/index.d.ts index b0c2339..ffd134d 100644 --- a/index.d.ts +++ b/index.d.ts @@ -23,10 +23,7 @@ export class Claims { constructor(data: Record, expiresInSeconds: number, opts?: ClaimOpts | undefined | null) } export class JwtClient { - constructor(secretKey: string) - static fromBufferKey(secretKey: Buffer): JwtClient + constructor(secretKey: string | Buffer) sign(data: Record, expiresInSeconds: number, claimOpts?: ClaimOpts | undefined | null): string - signClaims(claims: Claims): string verify(token: string): Claims - decode(token: string): Claims } diff --git a/src/jwt_client.rs b/src/jwt_client.rs index f7c3731..161ca8b 100644 --- a/src/jwt_client.rs +++ b/src/jwt_client.rs @@ -1,7 +1,6 @@ -use std::collections::HashSet; - use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation}; use napi::bindgen_prelude::Buffer; +use napi::Either; use napi_derive::napi; use crate::claims::{ClaimOpts, Claims}; @@ -12,7 +11,7 @@ pub struct JwtClient { decoding_key: DecodingKey, header: Header, validation: Validation, - no_valid: Validation, + // no_valid: Validation, } #[napi] @@ -21,29 +20,26 @@ impl JwtClient { fn from_key(key: &[u8]) -> Self { let encoding_key = EncodingKey::from_secret(key); let decoding_key = DecodingKey::from_secret(key); - let mut no_valid = Validation::new(jsonwebtoken::Algorithm::HS256); - no_valid.validate_exp = false; - no_valid.required_spec_claims = HashSet::new(); - no_valid.insecure_disable_signature_validation(); + // let mut no_valid = Validation::new(jsonwebtoken::Algorithm::HS256); + // no_valid.validate_exp = false; + // no_valid.required_spec_claims = HashSet::new(); + // no_valid.insecure_disable_signature_validation(); Self { encoding_key, decoding_key, header: Header::default(), validation: Validation::default(), - no_valid, + // no_valid, } } #[napi(constructor)] - pub fn new(secret_key: String) -> Self { - let key = secret_key.as_bytes(); - Self::from_key(key) - } - - #[napi(factory)] - pub fn from_buffer_key(secret_key: Buffer) -> Self { - Self::from_key(&secret_key) + pub fn new(secret_key: Either) -> Self { + match secret_key { + Either::A(s) => Self::from_key(s.as_bytes()), + Either::B(buff) => Self::from_key(&buff), + } } #[napi] @@ -55,13 +51,14 @@ impl JwtClient { ) -> String { let claims = Claims::new(data, expires_in_seconds, claim_opts); - self.sign_claims(&claims) + jsonwebtoken::encode(&self.header, &claims, &self.encoding_key).unwrap() + // self.sign_claims(&claims) } - #[napi] - pub fn sign_claims(&self, claims: &Claims) -> String { - jsonwebtoken::encode(&self.header, claims, &self.encoding_key).unwrap() - } + // #[napi] + // pub fn sign_claims(&self, claims: &Claims) -> String { + // jsonwebtoken::encode(&self.header, claims, &self.encoding_key).unwrap() + // } #[napi] pub fn verify(&self, token: String) -> napi::Result { @@ -76,10 +73,10 @@ impl JwtClient { } } - #[napi] - pub fn decode(&self, token: String) -> Claims { - jsonwebtoken::decode::(&token, &self.decoding_key, &self.no_valid) - .unwrap() - .claims - } + // #[napi] + // pub fn decode(&self, token: String) -> Claims { + // jsonwebtoken::decode::(&token, &self.decoding_key, &self.no_valid) + // .unwrap() + // .claims + // } } From 4c4caffe63003c2cf3eab46d8dc2472d6acf06ab Mon Sep 17 00:00:00 2001 From: Arslan Date: Fri, 23 Jun 2023 20:38:03 +0500 Subject: [PATCH 5/6] remove dead code from jwt_client --- src/jwt_client.rs | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/src/jwt_client.rs b/src/jwt_client.rs index 161ca8b..f351187 100644 --- a/src/jwt_client.rs +++ b/src/jwt_client.rs @@ -11,7 +11,6 @@ pub struct JwtClient { decoding_key: DecodingKey, header: Header, validation: Validation, - // no_valid: Validation, } #[napi] @@ -20,17 +19,12 @@ impl JwtClient { fn from_key(key: &[u8]) -> Self { let encoding_key = EncodingKey::from_secret(key); let decoding_key = DecodingKey::from_secret(key); - // let mut no_valid = Validation::new(jsonwebtoken::Algorithm::HS256); - // no_valid.validate_exp = false; - // no_valid.required_spec_claims = HashSet::new(); - // no_valid.insecure_disable_signature_validation(); Self { encoding_key, decoding_key, header: Header::default(), validation: Validation::default(), - // no_valid, } } @@ -52,14 +46,8 @@ impl JwtClient { let claims = Claims::new(data, expires_in_seconds, claim_opts); jsonwebtoken::encode(&self.header, &claims, &self.encoding_key).unwrap() - // self.sign_claims(&claims) } - // #[napi] - // pub fn sign_claims(&self, claims: &Claims) -> String { - // jsonwebtoken::encode(&self.header, claims, &self.encoding_key).unwrap() - // } - #[napi] pub fn verify(&self, token: String) -> napi::Result { let decode_res = jsonwebtoken::decode::(&token, &self.decoding_key, &self.validation); @@ -72,11 +60,4 @@ impl JwtClient { } } } - - // #[napi] - // pub fn decode(&self, token: String) -> Claims { - // jsonwebtoken::decode::(&token, &self.decoding_key, &self.no_valid) - // .unwrap() - // .claims - // } } From c9295eb60aeea53ea52e5723887d7caf511b0a58 Mon Sep 17 00:00:00 2001 From: Arslan Date: Mon, 26 Jun 2023 21:09:58 +0500 Subject: [PATCH 6/6] add header/validation opts, algo selection and asymmetric key based signatures --- .changeset/mean-kangaroos-pump.md | 5 + Cargo.toml | 2 +- README.md | 15 +-- __test__/jwt-client.spec.ts | 48 ++------- index.d.ts | 171 +++++++++++++++++++++++++++++- index.js | 3 +- src/algorithm.rs | 56 ++++++++++ src/claims.rs | 45 +++----- src/errors.rs | 38 +++++++ src/header.rs | 90 ++++++++++++++++ src/jwt_client.rs | 166 +++++++++++++++++++++++------ src/lib.rs | 5 + src/validation.rs | 138 ++++++++++++++++++++++++ 13 files changed, 661 insertions(+), 121 deletions(-) create mode 100644 .changeset/mean-kangaroos-pump.md create mode 100644 src/algorithm.rs create mode 100644 src/errors.rs create mode 100644 src/header.rs create mode 100644 src/validation.rs diff --git a/.changeset/mean-kangaroos-pump.md b/.changeset/mean-kangaroos-pump.md new file mode 100644 index 0000000..289f0ea --- /dev/null +++ b/.changeset/mean-kangaroos-pump.md @@ -0,0 +1,5 @@ +--- +"@carbonteq/jwt": minor +--- + +Add asymmetric key based signatures, algorithm selection and other header & validation options diff --git a/Cargo.toml b/Cargo.toml index 46937ce..e798147 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ version = "0.0.0" crate-type = ["cdylib"] [dependencies] -jsonwebtoken = { version = "8.3.0", default-features = false } +jsonwebtoken = { version = "8.3.0" } # Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix napi = { version = "2.12.2", default-features = false, features = [ "napi4", diff --git a/README.md b/README.md index 9ca2d38..4de008f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Benchmarks were performed using [benchmark](https://www.npmjs.com/package/benchm Machine Details: - OS: Ubuntu 22.04 -- CPU: Intel i7 +- Processor: Intel(R) Core(TM) i7-10750H CPU @ 2.60GHz - Threads: 6 * 2 - Memory: 16G **DISCLAIMER: Always take benchmarks like this with a grain of salt, as they may not always be indicative of good performance. And performance may not be the top thing to consider when choosing a package for solving your problem (unless the problem is that of performance itself). It would be best to perform these benchmarks on your own machine/deployment environment before making any decision.** @@ -36,16 +36,3 @@ fast-jwt x 68,474 ops/sec ±0.79% (182 runs sampled) SUITE : Fastest is @carbonteq/jwt ``` - -### Decoding Token without Verification ([bench/decode.mjs](./bench/decode.mjs)) - -This package performs the worst here, as I am not actually bypassing verification completely, but you'll probably not use this method much, seeing as you would likely be performing decoding with verification - -``` -jsonwebtoken x 257,054 ops/sec ±1.27% (190 runs sampled) -jose x 921,471 ops/sec ±0.64% (188 runs sampled) -fast-jwt x 519,612 ops/sec ±1.71% (186 runs sampled) -@carbonteq/jwt x 137,408 ops/sec ±2.11% (184 runs sampled) - -SUITE : Fastest is jose -``` diff --git a/__test__/jwt-client.spec.ts b/__test__/jwt-client.spec.ts index da984e8..86585d5 100644 --- a/__test__/jwt-client.spec.ts +++ b/__test__/jwt-client.spec.ts @@ -16,54 +16,18 @@ test('should create a valid token from payload', async (t) => { t.deepEqual(joseVerifyRes.payload?.data, testPayload); }); -test('should create a valid token from claims', async (t) => { - const claims = new Claims(testPayload, normalExpiresIn); - const token = client.signClaims(claims); - const joseVerifyRes = await jose.jwtVerify(token, secretEnc); - - t.deepEqual(joseVerifyRes.payload?.data, testPayload); -}); - test('created token should be valid', (t) => { const token = client.sign(testPayload, normalExpiresIn); t.deepEqual(client.verify(token).data, testPayload); }); -test('created (claims) token should be valid', (t) => { - const claims = new Claims(testPayload, normalExpiresIn); - const token = client.signClaims(claims); - - t.deepEqual(client.verify(token).data, testPayload); -}); +test('verifying after exp should throw error', (t) => { + const claims = new Claims(testPayload, 1); + claims.exp = 10; -test('verifying after exp should return false', (t) => { - const claims = new Claims(testPayload, 2); - claims.exp = 10; // In the past const token = client.signClaims(claims); - - t.throws(() => client.verify(token)); -}); - -test('decode output should give the correct payload data', (t) => { - const token = client.sign(testPayload, normalExpiresIn); - const decoded = client.decode(token); - - t.deepEqual(decoded.data, testPayload); -}); - -test('decode output should give the correct payload data for claims', (t) => { - const claims = new Claims(testPayload, normalExpiresIn); - const token = client.signClaims(claims); - const decoded = client.decode(token); - - t.deepEqual(decoded.data, testPayload); -}); - -test('decoding after exp should return payload', (t) => { - const claims = new Claims(testPayload, 2); - claims.exp = 10; // In the past - const token = client.signClaims(claims); - - t.deepEqual(client.decode(token).data, testPayload); + t.throws(() => { + client.verify(token); + }); }); diff --git a/index.d.ts b/index.d.ts index ffd134d..137db44 100644 --- a/index.d.ts +++ b/index.d.ts @@ -3,27 +3,196 @@ /* auto-generated by NAPI-RS */ +export const enum Algorithm { + /** HMAC using SHA-256 */ + HS256 = 0, + /** HMAC using SHA-384 */ + HS384 = 1, + /** HMAC using SHA-512 */ + HS512 = 2, + /** ECDSA using SHA-256 */ + ES256 = 3, + /** ECDSA using SHA-384 */ + ES384 = 4, + /** RSASSA-PKCS1-v1_5 using SHA-256 */ + RS256 = 5, + /** RSASSA-PKCS1-v1_5 using SHA-384 */ + RS384 = 6, + /** RSASSA-PKCS1-v1_5 using SHA-512 */ + RS512 = 7, + /** RSASSA-PSS using SHA-256 */ + PS256 = 8, + /** RSASSA-PSS using SHA-384 */ + PS384 = 9, + /** RSASSA-PSS using SHA-512 */ + PS512 = 10, + /** Edwards-curve Digital Signature Algorithm (EdDSA) */ + EdDSA = 11 +} export interface ClaimOpts { + /** Recipient for which the JWT is intended */ aud?: string + /** Time at which the JWT was issued (as UTC timestamp, seconds from epoch time) */ iat?: Number + /** Issuer of JWT */ iss?: string + /** [JWT id] Unique identifier */ jti?: string + /** [not-before-time] Time before which the JWT must not be accepted for processing (as UTC timestamp, seconds from epoch time) */ nbf?: Number + /** Subject of JWT (the user) */ + sub?: string +} +export interface Header { + /** + * The algorithm used + * + * Defined in [RFC7515#4.1.1](https://tools.ietf.org/html/rfc7515#section-4.1.1). + * Default to `HS256` + */ + algorithm?: Algorithm + /** + * Content type + * + * Defined in [RFC7519#5.2](https://tools.ietf.org/html/rfc7519#section-5.2). + */ + contentType?: string + /** + * JSON Key URL + * + * Defined in [RFC7515#4.1.2](https://tools.ietf.org/html/rfc7515#section-4.1.2). + */ + jsonKeyUrl?: string + /** + * JSON Web Key + * + * Defined in [RFC7515#4.1.3](https://tools.ietf.org/html/rfc7515#section-4.1.3). + * Key ID + * + * Defined in [RFC7515#4.1.4](https://tools.ietf.org/html/rfc7515#section-4.1.4). + */ + keyId?: string + /** + * X.509 URL + * + * Defined in [RFC7515#4.1.5](https://tools.ietf.org/html/rfc7515#section-4.1.5). + */ + x5Url?: string + /** + * X.509 certificate chain. A Vec of base64 encoded ASN.1 DER certificates. + * + * Defined in [RFC7515#4.1.6](https://tools.ietf.org/html/rfc7515#section-4.1.6). + */ + x5CertChain?: Array + /** + * X.509 SHA1 certificate thumbprint + * + * Defined in [RFC7515#4.1.7](https://tools.ietf.org/html/rfc7515#section-4.1.7). + */ + x5CertThumbprint?: string + /** + * X.509 SHA256 certificate thumbprint + * + * Defined in [RFC7515#4.1.8](https://tools.ietf.org/html/rfc7515#section-4.1.8). + * + * This will be serialized/deserialized as "x5t#S256", as defined by the RFC. + */ + x5TS256CertThumbprint?: string +} +export interface JwtClientInitOpts { + header?: Header + validation?: Validation +} +export interface Validation { + /** + * If it contains a value, the validation will check that the `aud` field is a member of the + * audience provided and will error otherwise. + * + * Defaults to an empty collection. + */ + aud?: Array + /** + * Which claims are required to be present before starting the validation. + * This does not interact with the various `validate_*`. If you remove `exp` from that list, you still need + * to set `validate_exp` to `false`. + * The only value that will be used are "exp", "nbf", "aud", "iss", "sub". Anything else will be ignored. + * + * Defaults to `exp`. + */ + requiredSpecClaims?: Array + /** + * Add some leeway (in seconds) to the `exp` and `nbf` validation to + * account for clock skew. + * + * Defaults to `60`. + */ + leeway?: Number + /** + * Whether to validate the `exp` field. + * + * Defaults to `true`. + */ + validateExp?: boolean + /** + * Whether to validate the `nbf` field. + * + * It will return an error if the current timestamp is before the time in the `nbf` field. + * + * Defaults to `false`. + */ + validateNbf?: boolean + /** + * If it contains a value, the validation will check that the `sub` field is the same as the + * one provided and will error otherwise. + * + * Turned off by default. + */ sub?: string + /** + * The algorithm used to verify the signature. + * + * Defaults to `HS256`. + */ + algorithms?: Array + /** + * If it contains a value, the validation will check that the `iss` field is a member of the + * iss provided and will error otherwise. + * Use `set_issuer` to set it + * + * Defaults to an empty collection. + */ + iss?: Array + /** + * Whether to validate the JWT signature. + * + * Defaults to `true`. + */ + validateSignature?: boolean } export class Claims { data: Record + /** Time after which the JWT expires (as UTC timestamp, seconds from epoch time) */ exp: Number + /** Recipient for which the JWT is intended */ aud?: string + /** Time at which the JWT was issued (as UTC timestamp, seconds from epoch time) */ iat?: Number + /** Issuer of JWT */ iss?: string + /** [JWT id] Unique identifier */ jti?: string + /** [not-before-time] Time before which the JWT must not be accepted for processing (as UTC timestamp, seconds from epoch time) */ nbf?: Number + /** Subject of JWT (the user) */ sub?: string constructor(data: Record, expiresInSeconds: number, opts?: ClaimOpts | undefined | null) } export class JwtClient { - constructor(secretKey: string | Buffer) + /** For symetric key based signatures */ + constructor(secretKey: string | Buffer, opts?: JwtClientInitOpts | undefined | null) + /** For assymetric key based signatures */ + static withPubPrivKeys(pubKey: string | Buffer, privKey: string | Buffer, opts?: JwtClientInitOpts | undefined | null): JwtClient sign(data: Record, expiresInSeconds: number, claimOpts?: ClaimOpts | undefined | null): string + signClaims(claims: Claims): string verify(token: string): Claims } diff --git a/index.js b/index.js index c161b34..b174e08 100644 --- a/index.js +++ b/index.js @@ -252,7 +252,8 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { Claims, JwtClient } = nativeBinding +const { Algorithm, Claims, JwtClient } = nativeBinding +module.exports.Algorithm = Algorithm module.exports.Claims = Claims module.exports.JwtClient = JwtClient diff --git a/src/algorithm.rs b/src/algorithm.rs new file mode 100644 index 0000000..7b28b76 --- /dev/null +++ b/src/algorithm.rs @@ -0,0 +1,56 @@ +use napi::bindgen_prelude::{FromNapiValue, ToNapiValue}; +use napi_derive::napi; + +#[napi] +pub enum Algorithm { + /// HMAC using SHA-256 + HS256, + /// HMAC using SHA-384 + HS384, + /// HMAC using SHA-512 + HS512, + /// ECDSA using SHA-256 + ES256, + /// ECDSA using SHA-384 + ES384, + /// RSASSA-PKCS1-v1_5 using SHA-256 + RS256, + /// RSASSA-PKCS1-v1_5 using SHA-384 + RS384, + /// RSASSA-PKCS1-v1_5 using SHA-512 + RS512, + /// RSASSA-PSS using SHA-256 + PS256, + /// RSASSA-PSS using SHA-384 + PS384, + /// RSASSA-PSS using SHA-512 + PS512, + /// Edwards-curve Digital Signature Algorithm (EdDSA) + EdDSA, +} + +impl From for jsonwebtoken::Algorithm { + #[inline] + fn from(value: Algorithm) -> Self { + match value { + Algorithm::ES256 => jsonwebtoken::Algorithm::ES256, + Algorithm::ES384 => jsonwebtoken::Algorithm::ES384, + Algorithm::EdDSA => jsonwebtoken::Algorithm::EdDSA, + Algorithm::HS256 => jsonwebtoken::Algorithm::HS256, + Algorithm::HS384 => jsonwebtoken::Algorithm::HS384, + Algorithm::HS512 => jsonwebtoken::Algorithm::HS512, + Algorithm::PS256 => jsonwebtoken::Algorithm::PS256, + Algorithm::PS384 => jsonwebtoken::Algorithm::PS384, + Algorithm::PS512 => jsonwebtoken::Algorithm::PS512, + Algorithm::RS256 => jsonwebtoken::Algorithm::RS256, + Algorithm::RS384 => jsonwebtoken::Algorithm::RS384, + Algorithm::RS512 => jsonwebtoken::Algorithm::RS512, + } + } +} + +impl Default for Algorithm { + fn default() -> Self { + Self::HS256 + } +} diff --git a/src/claims.rs b/src/claims.rs index b61d733..2a9775f 100644 --- a/src/claims.rs +++ b/src/claims.rs @@ -5,68 +5,49 @@ use serde_json::{Map, Number, Value}; #[napi(object)] #[derive(Debug, Default, Serialize, Deserialize)] pub struct ClaimOpts { - // Recipient for which the JWT is intended + /// Recipient for which the JWT is intended #[serde(skip_serializing_if = "Option::is_none")] pub aud: Option, - // Time at which the JWT was issued (as UTC timestamp, seconds from epoch time) + /// Time at which the JWT was issued (as UTC timestamp, seconds from epoch time) #[serde(skip_serializing_if = "Option::is_none")] pub iat: Option, - // Issuer of JWT + /// Issuer of JWT #[serde(skip_serializing_if = "Option::is_none")] pub iss: Option, - // [JWT id] Unique identifier + /// [JWT id] Unique identifier #[serde(skip_serializing_if = "Option::is_none")] pub jti: Option, - // [not-before-time] Time before which the JWT must not be accepted for processing (as UTC timestamp, seconds from epoch time) + /// [not-before-time] Time before which the JWT must not be accepted for processing (as UTC timestamp, seconds from epoch time) #[serde(skip_serializing_if = "Option::is_none")] pub nbf: Option, - // Subject of JWT (the user) + /// Subject of JWT (the user) #[serde(skip_serializing_if = "Option::is_none")] pub sub: Option, } -// impl Default for ClaimOpts { -// fn default() -> Self { -// Self { -// aud: None, -// iat: None, -// iss: None, -// jti: None, -// nbf: None, -// sub: None, -// // aud: Default::default(), -// // iat: Default::default(), -// // iss: Default::default(), -// // jti: Default::default(), -// // nbf: Default::default(), -// // sub: Default::default(), -// } -// } -// } - #[napi] #[derive(Debug, Serialize, Deserialize)] pub struct Claims { pub data: Map, - // Time after which the JWT expires (as UTC timestamp, seconds from epoch time) + /// Time after which the JWT expires (as UTC timestamp, seconds from epoch time) pub exp: Number, - // Recipient for which the JWT is intended + /// Recipient for which the JWT is intended #[serde(skip_serializing_if = "Option::is_none")] pub aud: Option, - // Time at which the JWT was issued (as UTC timestamp, seconds from epoch time) + /// Time at which the JWT was issued (as UTC timestamp, seconds from epoch time) #[serde(skip_serializing_if = "Option::is_none")] pub iat: Option, - // Issuer of JWT + /// Issuer of JWT #[serde(skip_serializing_if = "Option::is_none")] pub iss: Option, - // [JWT id] Unique identifier + /// [JWT id] Unique identifier #[serde(skip_serializing_if = "Option::is_none")] pub jti: Option, - // [not-before-time] Time before which the JWT must not be accepted for processing (as UTC timestamp, seconds from epoch time) + /// [not-before-time] Time before which the JWT must not be accepted for processing (as UTC timestamp, seconds from epoch time) #[serde(skip_serializing_if = "Option::is_none")] pub nbf: Option, - // Subject of JWT (the user) + /// Subject of JWT (the user) #[serde(skip_serializing_if = "Option::is_none")] pub sub: Option, } diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..6bfe63e --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,38 @@ +pub enum Error { + InvalidKey(String), + TokenValidationFailed(String), + Generic(String), +} + +impl From for napi::Error { + fn from(value: Error) -> Self { + match value { + Error::InvalidKey(e) => Self::new(napi::Status::InvalidArg, e), + Error::TokenValidationFailed(e) => Self::new(napi::Status::GenericFailure, e), + Error::Generic(msg) => Self::new(napi::Status::Unknown, msg), + } + } +} + +impl From for Error { + fn from(value: jsonwebtoken::errors::Error) -> Self { + let msg = value.to_string(); + use jsonwebtoken::errors::ErrorKind; + + match value.kind() { + ErrorKind::InvalidRsaKey(_) | ErrorKind::InvalidEcdsaKey | ErrorKind::InvalidKeyFormat => { + Self::InvalidKey(msg) + } + ErrorKind::InvalidToken + | ErrorKind::InvalidSignature + | ErrorKind::ExpiredSignature + | ErrorKind::InvalidIssuer + | ErrorKind::MissingRequiredClaim(_) + | ErrorKind::InvalidAlgorithm + | ErrorKind::InvalidAudience + | ErrorKind::InvalidSubject + | ErrorKind::ImmatureSignature => Self::TokenValidationFailed(msg), + _ => Self::Generic(msg), + } + } +} diff --git a/src/header.rs b/src/header.rs new file mode 100644 index 0000000..24784d3 --- /dev/null +++ b/src/header.rs @@ -0,0 +1,90 @@ +use napi_derive::napi; + +use crate::algorithm::Algorithm; + +#[napi(object)] +#[derive(Default)] +pub struct Header { + /// The algorithm used + /// + /// Defined in [RFC7515#4.1.1](https://tools.ietf.org/html/rfc7515#section-4.1.1). + /// Default to `HS256` + pub algorithm: Option, + + /// Content type + /// + /// Defined in [RFC7519#5.2](https://tools.ietf.org/html/rfc7519#section-5.2). + pub content_type: Option, + + /// JSON Key URL + /// + /// Defined in [RFC7515#4.1.2](https://tools.ietf.org/html/rfc7515#section-4.1.2). + pub json_key_url: Option, + + /// JSON Web Key + /// + /// Defined in [RFC7515#4.1.3](https://tools.ietf.org/html/rfc7515#section-4.1.3). + // TODO: support jwk + // pub jwk: Option, + + /// Key ID + /// + /// Defined in [RFC7515#4.1.4](https://tools.ietf.org/html/rfc7515#section-4.1.4). + pub key_id: Option, + + /// X.509 URL + /// + /// Defined in [RFC7515#4.1.5](https://tools.ietf.org/html/rfc7515#section-4.1.5). + pub x5_url: Option, + + /// X.509 certificate chain. A Vec of base64 encoded ASN.1 DER certificates. + /// + /// Defined in [RFC7515#4.1.6](https://tools.ietf.org/html/rfc7515#section-4.1.6). + pub x5_cert_chain: Option>, + + /// X.509 SHA1 certificate thumbprint + /// + /// Defined in [RFC7515#4.1.7](https://tools.ietf.org/html/rfc7515#section-4.1.7). + pub x5_cert_thumbprint: Option, + + /// X.509 SHA256 certificate thumbprint + /// + /// Defined in [RFC7515#4.1.8](https://tools.ietf.org/html/rfc7515#section-4.1.8). + /// + /// This will be serialized/deserialized as "x5t#S256", as defined by the RFC. + pub x5t_s256_cert_thumbprint: Option, +} + +impl From
for jsonwebtoken::Header { + #[inline] + fn from(value: Header) -> Self { + jsonwebtoken::Header { + typ: Some(String::from("JWT")), + alg: value.algorithm.unwrap_or(Algorithm::HS256).into(), + cty: value.content_type, + jku: value.json_key_url, + kid: value.key_id, + x5u: value.x5_url, + x5c: value.x5_cert_chain, + x5t: value.x5_cert_thumbprint, + x5t_s256: value.x5t_s256_cert_thumbprint, + jwk: None, + } + } +} + +// impl Default for Header { +// #[inline] +// fn default() -> Self { +// Self { +// algorithm: Some(Algorithm::HS256), +// content_type: None, +// json_key_url: None, +// key_id: None, +// x5_url: None, +// x5_cert_chain: None, +// x5_cert_thumbprint: None, +// x5t_s256_cert_thumbprint: None, +// } +// } +// } diff --git a/src/jwt_client.rs b/src/jwt_client.rs index f351187..519a419 100644 --- a/src/jwt_client.rs +++ b/src/jwt_client.rs @@ -1,39 +1,141 @@ -use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation}; +use jsonwebtoken::{DecodingKey, EncodingKey}; use napi::bindgen_prelude::Buffer; use napi::Either; use napi_derive::napi; use crate::claims::{ClaimOpts, Claims}; +use crate::errors::Error; +use crate::header::Header; +use crate::validation::Validation; + +#[napi(object)] +#[derive(Default)] +pub struct JwtClientInitOpts { + pub header: Option
, + pub validation: Option, +} #[napi] pub struct JwtClient { - encoding_key: EncodingKey, - decoding_key: DecodingKey, - header: Header, - validation: Validation, + encoding_key: jsonwebtoken::EncodingKey, + decoding_key: jsonwebtoken::DecodingKey, + header: jsonwebtoken::Header, + validation: jsonwebtoken::Validation, +} + +#[inline] +fn get_encoding_key(key: &[u8], algorithm: jsonwebtoken::Algorithm) -> Result { + use jsonwebtoken::Algorithm as Alg; + + let enc_key_res = match algorithm { + // HMAC family + Alg::HS256 | Alg::HS384 | Alg::HS512 => Ok(EncodingKey::from_secret(key)), + + // RSA family + Alg::RS256 | Alg::RS384 | Alg::RS512 | Alg::PS256 | Alg::PS384 | Alg::PS512 => { + EncodingKey::from_rsa_pem(key) + } + + // EC family + Alg::ES256 | Alg::ES384 => EncodingKey::from_ec_pem(key), + + // ED family + Alg::EdDSA => EncodingKey::from_ed_pem(key), + }; + + enc_key_res.map_err(Error::from) +} + +#[inline] +fn get_decoding_key(key: &[u8], algorithm: jsonwebtoken::Algorithm) -> Result { + use jsonwebtoken::Algorithm as Alg; + + let dec_key_res = match algorithm { + // HMAC family + Alg::HS256 | Alg::HS384 | Alg::HS512 => Ok(DecodingKey::from_secret(key)), + + // RSA family + Alg::RS256 | Alg::RS384 | Alg::RS512 | Alg::PS256 | Alg::PS384 | Alg::PS512 => { + DecodingKey::from_rsa_pem(key) + } + + // EC family + Alg::ES256 | Alg::ES384 => DecodingKey::from_ec_pem(key), + + // ED family + Alg::EdDSA => DecodingKey::from_ed_pem(key), + }; + + dec_key_res.map_err(Error::from) } #[napi] impl JwtClient { - #[inline] - fn from_key(key: &[u8]) -> Self { - let encoding_key = EncodingKey::from_secret(key); - let decoding_key = DecodingKey::from_secret(key); + #[napi(constructor)] + /// For symetric key based signatures + pub fn new( + secret_key: Either, + opts: Option, + ) -> Result { + let opts = opts.unwrap_or_default(); + let header: jsonwebtoken::Header = opts.header.unwrap_or_default().into(); + let alg = header.alg; + let validation: jsonwebtoken::Validation = + opts.validation.unwrap_or_default().for_jsonwebtoken(alg); - Self { + let (encoding_key, decoding_key) = match secret_key { + Either::A(s) => { + let sb = s.as_bytes(); + let encoding_key = get_encoding_key(sb, alg)?; + let decoding_key = get_decoding_key(sb, alg)?; + + (encoding_key, decoding_key) + } + Either::B(buff) => { + let encoding_key = get_encoding_key(&buff, alg)?; + let decoding_key = get_decoding_key(&buff, alg)?; + + (encoding_key, decoding_key) + } + }; + + Ok(Self { + header, encoding_key, decoding_key, - header: Header::default(), - validation: Validation::default(), - } + validation, + }) } - #[napi(constructor)] - pub fn new(secret_key: Either) -> Self { - match secret_key { - Either::A(s) => Self::from_key(s.as_bytes()), - Either::B(buff) => Self::from_key(&buff), - } + #[napi(factory)] + /// For assymetric key based signatures + pub fn with_pub_priv_keys( + pub_key: Either, + priv_key: Either, + opts: Option, + ) -> Result { + let opts = opts.unwrap_or_default(); + let header: jsonwebtoken::Header = opts.header.unwrap_or_default().into(); + let alg = header.alg; + let validation: jsonwebtoken::Validation = + opts.validation.unwrap_or_default().for_jsonwebtoken(alg); + + let encoding_key = match priv_key { + Either::A(s) => get_encoding_key(s.as_bytes(), alg), + Either::B(buff) => get_encoding_key(&buff, alg), + }?; + + let decoding_key = match pub_key { + Either::A(s) => get_decoding_key(s.as_bytes(), header.alg), + Either::B(buff) => get_decoding_key(&buff, header.alg), + }?; + + Ok(Self { + header, + validation, + encoding_key, + decoding_key, + }) } #[napi] @@ -42,22 +144,26 @@ impl JwtClient { data: serde_json::Map, expires_in_seconds: u32, claim_opts: Option, - ) -> String { + ) -> napi::Result { let claims = Claims::new(data, expires_in_seconds, claim_opts); - jsonwebtoken::encode(&self.header, &claims, &self.encoding_key).unwrap() + jsonwebtoken::encode(&self.header, &claims, &self.encoding_key) + .map_err(Error::from) + .map_err(napi::Error::from) } #[napi] - pub fn verify(&self, token: String) -> napi::Result { - let decode_res = jsonwebtoken::decode::(&token, &self.decoding_key, &self.validation); + pub fn sign_claims(&self, claims: &Claims) -> napi::Result { + jsonwebtoken::encode(&self.header, claims, &self.encoding_key) + .map_err(Error::from) + .map_err(napi::Error::from) + } - match decode_res { - Ok(token_data) => napi::Result::Ok(token_data.claims), - Err(e) => { - let err = napi::Error::new(napi::Status::Unknown, e.to_string()); - napi::Result::Err(err) - } - } + #[napi] + pub fn verify(&self, token: String) -> napi::Result { + jsonwebtoken::decode::(&token, &self.decoding_key, &self.validation) + .map(|c| c.claims) + .map_err(Error::from) + .map_err(napi::Error::from) } } diff --git a/src/lib.rs b/src/lib.rs index d780fcf..59cdc54 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,8 +3,13 @@ // DISCLAIMER: Majority of the code and/or inspiration comes from @node-rs/jsonwebtoken. I have // just updated and modified the code to meet my own use cases and API design +mod algorithm; mod claims; +mod errors; +mod header; mod jwt_client; +mod validation; +pub use algorithm::Algorithm; pub use claims::{ClaimOpts, Claims}; pub use jwt_client::JwtClient; diff --git a/src/validation.rs b/src/validation.rs new file mode 100644 index 0000000..a895bdb --- /dev/null +++ b/src/validation.rs @@ -0,0 +1,138 @@ +use napi_derive::napi; +use serde_json::Number; + +use crate::algorithm::Algorithm; + +#[napi(object)] +#[derive(Default)] +pub struct Validation { + /// If it contains a value, the validation will check that the `aud` field is a member of the + /// audience provided and will error otherwise. + /// + /// Defaults to an empty collection. + pub aud: Option>, + /// Which claims are required to be present before starting the validation. + /// This does not interact with the various `validate_*`. If you remove `exp` from that list, you still need + /// to set `validate_exp` to `false`. + /// The only value that will be used are "exp", "nbf", "aud", "iss", "sub". Anything else will be ignored. + /// + /// Defaults to `exp`. + pub required_spec_claims: Option>, + /// Add some leeway (in seconds) to the `exp` and `nbf` validation to + /// account for clock skew. + /// + /// Defaults to `60`. + pub leeway: Option, + /// Whether to validate the `exp` field. + /// + /// Defaults to `true`. + pub validate_exp: Option, + /// Whether to validate the `nbf` field. + /// + /// It will return an error if the current timestamp is before the time in the `nbf` field. + /// + /// Defaults to `false`. + pub validate_nbf: Option, + /// If it contains a value, the validation will check that the `sub` field is the same as the + /// one provided and will error otherwise. + /// + /// Turned off by default. + pub sub: Option, + /// The algorithm used to verify the signature. + /// + /// Defaults to `HS256`. + pub algorithms: Option>, + /// If it contains a value, the validation will check that the `iss` field is a member of the + /// iss provided and will error otherwise. + /// Use `set_issuer` to set it + /// + /// Defaults to an empty collection. + pub iss: Option>, + /// Whether to validate the JWT signature. + /// + /// Defaults to `true`. + pub validate_signature: Option, +} + +// impl From for jsonwebtoken::Validation { +// #[inline] +// fn from(value: Validation) -> Self { +// let mut validation = Self::new(jsonwebtoken::Algorithm::HS256); +// if let Some(aud) = &value.aud { +// validation.set_audience(aud); +// } +// +// if let Some(required_spec_claims) = &value.required_spec_claims { +// validation.set_required_spec_claims(required_spec_claims); +// } +// +// if let Some(leeway) = value.leeway.and_then(|l| l.as_u64()) { +// validation.leeway = leeway; +// } +// +// if let Some(validate_exp) = value.validate_exp { +// validation.validate_exp = validate_exp; +// } +// +// if let Some(validate_nbf) = value.validate_nbf { +// validation.validate_nbf = validate_nbf; +// } +// +// validation.sub = value.sub; +// +// if let Some(algorithms) = &value.algorithms { +// validation.algorithms = algorithms.iter().map(|alg| alg.to_owned().into()).collect(); +// } +// +// if let Some(iss) = &value.iss { +// validation.set_issuer(iss); +// } +// +// if let Some(false) = value.validate_signature { +// validation.insecure_disable_signature_validation() +// } +// +// validation +// } +// } + +impl Validation { + pub fn for_jsonwebtoken(self, alg: jsonwebtoken::Algorithm) -> jsonwebtoken::Validation { + let mut validation = jsonwebtoken::Validation::new(alg); + if let Some(aud) = &self.aud { + validation.set_audience(aud); + } + + if let Some(required_spec_claims) = &self.required_spec_claims { + validation.set_required_spec_claims(required_spec_claims); + } + + if let Some(leeway) = self.leeway.and_then(|l| l.as_u64()) { + validation.leeway = leeway; + } + + if let Some(validate_exp) = self.validate_exp { + validation.validate_exp = validate_exp; + } + + if let Some(validate_nbf) = self.validate_nbf { + validation.validate_nbf = validate_nbf; + } + + validation.sub = self.sub; + + if let Some(algorithms) = &self.algorithms { + validation.algorithms = algorithms.iter().map(|alg| alg.to_owned().into()).collect(); + } + + if let Some(iss) = &self.iss { + validation.set_issuer(iss); + } + + if let Some(false) = self.validate_signature { + validation.insecure_disable_signature_validation() + } + + validation + } +}