├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── __mock__ └── cron.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── error.ts ├── index.ts ├── jwtAuth.ts └── storage │ ├── __mock__ │ └── storage.ts │ ├── index.ts │ ├── interface.ts │ └── storage.ts ├── test ├── fileStorage.int.test.ts ├── index.test.ts └── storage.test.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | /lib 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es6: true, 4 | node: true 5 | }, 6 | extends: [ 7 | "eslint:recommended", 8 | "plugin:prettier/recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ], 12 | plugins: ["@typescript-eslint"], 13 | globals: { 14 | Atomics: "readonly", 15 | SharedArrayBuffer: "readonly" 16 | }, 17 | parserOptions: { 18 | ecmaVersion: 2018, 19 | sourceType: "module" 20 | }, 21 | parser: "@typescript-eslint/parser", 22 | rules: {} 23 | }; 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | /lib 4 | /coverage 5 | /authcerts -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 12 4 | - lts/* 5 | before_script: 6 | - "curl -H 'Cache-Control: no-cache' https://raw.githubusercontent.com/fossas/fossa-cli/master/install.sh | sudo bash" 7 | script: 8 | - fossa init 9 | - fossa analyze 10 | test: 11 | - npm run test 12 | after_success: 13 | - codecov -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Hansen Wang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/WangHansen/jwt-auth.svg?branch=master)](https://travis-ci.org/WangHansen/jwt-auth) 2 | [![codecov](https://codecov.io/gh/WangHansen/jwt-auth/branch/master/graph/badge.svg)](https://codecov.io/gh/WangHansen/jwt-auth) 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 4 | [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FWangHansen%2Fjwt-auth.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2FWangHansen%2Fjwt-auth?ref=badge_shield) 5 | 6 | 7 |
8 |

9 | 12 | 13 |

JWT Auth

14 | 15 |

16 | A light weight authentication library that supports key rotation and revokation list. 17 |

18 |

19 | 20 | 21 | 22 | ## Table of Contents 23 | 24 | - [Another auth library?](#about-the-project) 25 | - [Getting Started](#getting-started) 26 | - [Prerequisites](#prerequisites) 27 | - [Installation](#installation) 28 | - [Usage](#usage) 29 | - [API](#api) 30 | - [Persistent Storage](#persistent-storage) 31 | - [File Storage](#file-storage) 32 | - [Write your own persistent storage](#write-your-own-persistent-storage) 33 | - [Contributing](#contributing) 34 | - [License](#license) 35 | - [Contact](#contact) 36 | 37 | 38 | 39 | ## Another auth library? 40 | 41 | There are a lot of authentication libraries out there that deals with JWT, probably the most popular one(the one that I used a lot in my project) is the passport-jwt library used together with passport. However, the library has the few problems: 42 | - Need to be used with passport.js 43 | > This may not be a problem to some people, but I find passport.js a bit difficult to use since it's a black box model (I don't understand the magic happening behind the scene). 44 | - Need to talk to DB 45 | > The [official example](http://www.passportjs.org/packages/passport-jwt/#configure-strategy) in documentation contains a query to db in order to authenticate the user, which I believe is against the natural, being stateless, of JWT. 46 | - Doesn't handle key rotation 47 | - Doesn't handle key revocation 48 | 49 | In order to address these problems, I decided to make this open source library. 50 | 51 | 52 | 53 | ## Getting Started 54 | 55 | ### Prerequisites 56 | 57 | I have this tested from Node version 12 and above, make sure you have the right version 58 | 59 | ### Installation 60 | 61 | Install with npm 62 | 63 | ```JS 64 | npm install --save @hansenw/jwt-auth 65 | ``` 66 | 67 | 68 | 69 | ## Usage 70 | 71 | ### Simple Usage 72 | ```javascript 73 | // authService.js 74 | import JWTAuth from "@hansenw/jwt-auth"; 75 | 76 | const JWT = new JWTAuth(); 77 | export default JWT; 78 | 79 | // to use in other files 80 | import jwt from "./authService"; 81 | 82 | // to generate a jwt token 83 | const token = jwt.sign({ /* some payload */ }); 84 | 85 | // to verify 86 | try { 87 | const payload = jwt.verify(token); 88 | // ... 89 | } catch (e) { 90 | // cannot be verified 91 | } 92 | 93 | // to revoke 94 | await jwt.revoke(token); 95 | jwt.verify(token); // this will throw JWTRevoked 96 | ``` 97 | 98 | ### With Express 99 | ```javascript 100 | import * as express from "express"; 101 | import JWTAuth from "@hansenw/jwt-auth"; 102 | 103 | const app = express(); 104 | const jwt = new JWTAuth(); 105 | 106 | app.post("/login", async (req, res, next) => { 107 | const { username, password } = req.body; 108 | 109 | // Your own logic .. to verify credentials 110 | const match = authenticate(username, password); 111 | 112 | if (match) { 113 | const jwtpayload = { username }; 114 | const token = jwt.sign(payload); 115 | res.set({ 116 | "Access-Control-Expose-Headers": "Authorization", 117 | Authorization: "Bearer " + token, 118 | }) 119 | .json({ 120 | message: "Login success", 121 | }); 122 | } else { 123 | // handle failure logic 124 | } 125 | }) 126 | 127 | // middleware for protecting api 128 | function authGuard(req, res, next) { 129 | // getting token from header 130 | const header = req.headers["authorization"]; 131 | const token = header ? header.split(" ")[1] : ""; 132 | if (!token) { 133 | return next(new Error("No auth token")); 134 | } 135 | // verify token validity 136 | try { 137 | const payload = jwt.verify(token); 138 | // if token is valid, attach the payload to req object 139 | req.payload = payload; 140 | } catch (e) { 141 | // token invalid, can be handled differently based on the error 142 | } 143 | } 144 | 145 | app.post("/protected", authGuard, async (req, res, next) => { 146 | // get JWT payload 147 | const payload = req.payload; 148 | 149 | // if user info is ever needed 150 | const user = await db.collection("user").find({ username: payload.username }); 151 | 152 | res.json({ message: "Authorized user only" }) 153 | }) 154 | 155 | // start the express app 156 | app.listen(3000) 157 | ``` 158 | 159 | ### Advanced Usage with TS 160 | > Customze what to store in the revocation list, be default revocation list contain items on type { jti: string, exp: number } 161 | 162 | ```typescript 163 | import JWTAuth, { RevocationListItem } from "@hansenw/jwt-auth"; 164 | 165 | interface RevocListItem extends RevocationListItem { 166 | ip: string; 167 | } 168 | 169 | const jwt = new JWTAuth(); 170 | 171 | const token = jwt.sign({ /* some payload */ }); 172 | 173 | // to verify 174 | try { 175 | const payload = jwt.verify(token); 176 | // ... 177 | } catch (e) { 178 | // cannot be verified 179 | } 180 | 181 | // to revoke 182 | await jwt.revoke(token, (payload) => ({ 183 | jti: payload.jti, 184 | exp: payload.exp, 185 | ip: req.ip, 186 | })); 187 | jwt.verify(token); // this will throw JWTRevoked 188 | ``` 189 | 190 | ### Microservice 191 | 192 | If you want to build your own auth server or auth service within the microservices, check out this [jwt-jwks-client](https://github.com/WangHansen/jwt-jwks-client) library I made that can be used together with this one. 193 | 194 | #### server.ts 195 | ```typescript 196 | import * as express from "express"; 197 | import JWTAuth from "@hansenw/jwt-auth"; 198 | 199 | const app = express() 200 | const authService = new JwtAuth(); 201 | 202 | app.post("/login", (req: Request, res: Response) => { 203 | // Replace with your own matching logic 204 | if (req.body.username === "admin" && req.body.password === "password") { 205 | const token = authService.sign({ userId: "admin" }); 206 | return ( 207 | res 208 | .set("authorization", token) 209 | .send("Authorized") 210 | ); 211 | } 212 | res.status(401).send("Not authorized"); 213 | }); 214 | 215 | // Expose jwks through an API 216 | app.get("/jwks", (req: Request, res: Response) => { 217 | res.json(authService.JWKS(true)); 218 | }); 219 | ``` 220 | 221 | #### Client 222 | ```ts 223 | import * as express from "express"; 224 | import JwksClient from "jwt-jwks-client"; 225 | 226 | const authClient = new JwksClient({ 227 | jwksUri: "http://localhost:3000/jwks", 228 | secure: false, 229 | }); 230 | 231 | app.get("/secret", async (req: Request, res: Response) => { 232 | const token = req.headers.authorization; 233 | if (token) { 234 | // Verify the token here 235 | await authClient.verify(token); 236 | return res.send("This is a secret page"); 237 | } 238 | return res.send(`You are not authorized to see the secret page`); 239 | }); 240 | ``` 241 | See complete example [here](https://github.com/WangHansen/jwt-jwks-client/tree/master/example) 242 | 243 | ## API 244 | 245 | ### Constructor 246 | __Class: JWTAuth__ 247 | ```javascript 248 | const jwt = new JWTAuth(options: JwtAuthOptions); 249 | ``` 250 | `JwtAuthOptions`: 251 | - `algorithm?`: can be ['RSA' | 'EC' | 'OKP' | 'oct'], __Default__: "EC" 252 | - `crvOrSize?`: `` key size (in bits) or named curve ('crv') for "EC", __Default__: 2048 for RSA, 'P-256' for EC, 'Ed25519' for OKP and 256 for oct. 253 | - `amount?`: `` number of keys kept in rotation, __Default__: 3 254 | - `interval?`: `` [cron](https://github.com/kelektiv/node-cron#cron-ranges) expression for how often to generate a new key, __Default__: "00 00 */4 * * *": every 4 hour, generate a new token 255 | > Make sure the token expire time is less than the interval that a new token is generated 256 | - `signSkip?`: `` number of keys skipped when generating a new token, __Default__: 1 257 | > By default, there are 3 keys stored, and by setting this to 1, every time a new token is signed, only the last 2 keys will be used since the first key will be removed after the rotation. 258 | - `tokenAge?`: `` token expire time in zeit/ms, __Default__ '10m' 259 | 260 | ### Methods 261 | #### `jwt.sign(payload: object, options?: JWT.SignOptions)` 262 | Generate a new jwt token 263 | ```javascript 264 | const token = jwt.sign(payload, options?); 265 | ``` 266 | - `payload`: `` 267 | - `options`: `` see [jose](https://github.com/panva/jose/blob/HEAD/docs/README.md#jwtsignpayload-key-options) 268 | - `algorithm`: `` The algorithm to use 269 | - `audience`: `` | `string[]` JWT Audience, "aud" claim value, if provided it will replace "aud" found in the payload 270 | - `expiresIn`: `` JWT Expiration Time, "exp" claim value, specified as string which is added to the current unix epoch timestamp e.g. `24 hours`, `20 m`, `60s`, etc., if provided it will replace Expiration Time found in the payload 271 | - `header`: `` JWT Header object 272 | - `iat`: `` When true it pushes the "iat" to the JWT Header. **Default:** 'true' 273 | - `issuer`: `` JWT Issuer, "iss" claim value, if provided it will replace "iss" found in the payload 274 | - `jti`: `` JWT ID, "jti" claim value, if provided it will replace "jti" found in the payload 275 | - `kid`: `` When true it pushes the key's "kid" to the JWT Header. **Default:** 'true' for asymmetric keys, 'false' for symmetric keys. 276 | - `notBefore`: `` JWT Not Before, "nbf" claim value, specified as string which is added to the current unix epoch timestamp e.g. `24 hours`, `20 m`, `60s`, etc., if provided it will replace Not Before found in the payload 277 | - `now`: `` Date object to be used instead of the current unix epoch timestamp. **Default:** 'new Date()' 278 | - `subject`: `` JWT subject, "sub" claim value, if provided it will replace "sub" found in the payload 279 | 280 | --- 281 | 282 | #### `jwt.verify(token: string, options?: JWT.SignOptions) throws` 283 | Verify the validity of a JWT token 284 | ```javascript 285 | try { 286 | const payload = jwt.verify(token, options?); 287 | } catch (error) { 288 | // possible error: JWTClaimInvalid, JWTExpired, JWTMalformed, JWTRevoked 289 | } 290 | ``` 291 | - `payload`: `` 292 | - `options`: `` see [jose](https://github.com/panva/jose/blob/HEAD/docs/README.md#jwtverifytoken-keyorstore-options) 293 | - `algorithms`: `string[]` Array of expected signing algorithms. JWT signed with an algorithm not found in this option will be rejected. **Default:** accepts all algorithms available on the passed key (or keys in the keystore) 294 | - `audience`: `` | `string[]` Expected audience value(s). When string an exact match must be found in the payload, when array at least one must be matched. 295 | - `typ`: `` Expected JWT "typ" Header Parameter value. An exact match must be found in the JWT header. **Default:** 'undefined' unless a `profile` with a specific value is used, in which case this option will be ignored. 296 | - `clockTolerance`: `` Clock Tolerance for comparing timestamps, provided as timespan string e.g. `120s`, `2 minutes`, etc. **Default:** no clock tolerance 297 | - `complete`: `` When false only the parsed payload is returned, otherwise an object with a parsed header, payload, the key that verified and the base64url encoded signature will be returned **Default:** 'false' 298 | - `crit`: `string[]` Array of Critical Header Parameter names to recognize. **Default:** '[]' 299 | - `ignoreExp`: `` When true will not be validating the "exp" claim value to be in the future from now. **Default:** 'false' 300 | - `ignoreIat`: `` When true will not be validating the "iat" claim value to be in the past from now if expiration is not present. **Default:** 'false' 301 | - `ignoreNbf`: `` When true will not be validating the "nbf" claim value to be in the past from now. **Default:** 'false' 302 | - `issuer`: `` Expected issuer value. An exact match must be found in the payload. 303 | - `jti`: `` Expected jti value. An exact match must be found in the payload. 304 | - `maxTokenAge`: `` When provided the payload is checked to have the "iat" claim and its value is validated not to be older than the provided timespan string e.g. `30m`, `24 hours`. Do not confuse with maxAuthAge option. 305 | - `now`: `` Date object to be used instead of the current unix epoch timestamp. **Default:** 'new Date()' 306 | - `subject`: `` Expected subject value. An exact match must be found in the payload. 307 | 308 | --- 309 | 310 | #### `jwt.revoke(token: string, revocListHandler?: Function) throws` 311 | Revoke an already issued JWT token 312 | ```javascript 313 | try { 314 | await jwt.revoke(token) 315 | } catch (error) { 316 | // failed to revoke a token 317 | } 318 | ``` 319 | - `token`: `` JWT token 320 | - `revocListHandler`: `` a function that takes `payload` as the parameter and return a object to be saved in the revocation list. __Default__: (payload) => ({ jti: payload.jti, exp: payload.exp }) 321 | > The item returned must at least have fields `jti` and `exp`, JWTAuth internally use `jti` to determine if a token is revoked and use `exp` to clear the expired token. 322 | 323 | --- 324 | 325 | #### `jwt.JWKS(isPrivate: boolean)` 326 | Return all keys in the format of [JWKS](https://tools.ietf.org/html/rfc7517) 327 | ```javascript 328 | const jwks = jwt.JWKS(); 329 | ``` 330 | - `isPrivate`: `` whether to return the private key part 331 | 332 | --- 333 | 334 | #### `jwt.rotate()` 335 | Rotate the key sets by removing the oldest key and generating a new key 336 | ```javascript 337 | await jwt.rotate(); 338 | ``` 339 | 340 | --- 341 | 342 | #### `jwt.revokeKey(kid: string)` 343 | Manually remove a key from key set, __Note__: this may cause all the JWT signed with this kid invalid 344 | ```javascript 345 | await jwt.revokeKey(kid); 346 | ``` 347 | - `kid`: `` the id of the key to be removed 348 | 349 | --- 350 | 351 | #### `jwt.reset()` 352 | Remove all current keys and generate a new set, __Note__: !!this will cause all the JWT signed previously invalid 353 | ```javascript 354 | await jwt.reset(); 355 | ``` 356 | 357 | ## Persistent Storage 358 | 359 | It is important to save the generated keys to a persistent storage, so that application crashes and restart would not result in all authenticated users log out 360 | 361 | ### File Storage 362 | 363 | By default, this library comes with one storage plugin--local file storage, this storage tries to store all the data in a folder on local disk. 364 | > Since the keys are very sensitive and secretive data, I don't think it is safe to send them on to the internet, thus I only provide the file storage so that it can be securely stored. If you want to store it with databases, please see [write your own persisten plugin](write-your-own-persistent-storage) 365 | 366 | ```javascript 367 | import JwtAuth, { FileStorage } from "@hansenw/jwt-auth"; 368 | 369 | const jwtAuth = new JwtAuth(); 370 | 371 | const fileStore = new FileStorage(); 372 | 373 | // this is async beacuse it will try to load keys from storage 374 | await jwtAuth.setStorage(fileStore); 375 | 376 | // after the storage is set, every time key rotation happens 377 | // keys will be automatically saved to file storage 378 | await jwtAuth.rotate(); 379 | ``` 380 | 381 | #### API 382 | 383 | ##### Constructor 384 | __Class: FileStorage__ 385 | ```javascript 386 | const jwt = new FileStorage(options: FileStorageConfig); 387 | ``` 388 | `FileStorageConfig`: 389 | - `diskPath?`: `` path to where to store the files, __Default__: "./authcerts" 390 | - `keysFilename?`: `` name of the file for storing all JWKs, __Default__: ".keys.json" 391 | - `clientsFilename?`: `` name of the file for storing all clients data **[This is currently useless, will be used later on when implementing a client library that can be used]**, __Default__: ".clients.json" 392 | - `revocListFilename?`: `` name of the file for storing all revoked JWTs, __Default__: ".revocList.json" 393 | 394 | ### Write your own persistent storage 395 | 396 | Make sure you extend the Storage abstract provided by the library 397 | 398 | ```typescript 399 | abstract class Storage { 400 | abstract loadKeys(): Promise; 401 | abstract saveKeys(keys: JSONWebKeySet): Promise; 402 | abstract loadRevocationList(): Promise | undefined>; 403 | abstract saveRevocationList(list: Array): Promise; 404 | } 405 | ``` 406 | 407 | All you need is to provide 4 methods for storing and retriving data from the persistent storage 408 | 409 | #### Methods 410 | 411 | All methods on Storage will be called automatically with the auth library so you do't need to worry about calling them 412 | 413 | Here is the list of actions that happens with auth library assuming the storage is used: 414 | 415 | Auth library method | Storage method 416 | --------------------|--------------- 417 | rotate | saveKeys 418 | revokeKey | saveKeys 419 | reset | saveKeys 420 | revoke | saveRevocList 421 | 422 | 423 | 424 | 425 | ## Roadmap 426 | 427 | - [x] implement a persistent storage 428 | - [x] document persistent storage 429 | - [x] implement a client library for distributed system/microservices 430 | 431 | 432 | 433 | ## Contributing 434 | 435 | Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **greatly appreciated**. 436 | 437 | 1. Fork the Project 438 | 2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) 439 | 3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) 440 | 4. Push to the Branch (`git push origin feature/AmazingFeature`) 441 | 5. Open a Pull Request 442 | 443 | 444 | 445 | ## License 446 | 447 | Distributed under the MIT License. See `LICENSE` for more information. 448 | 449 | 450 | [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FWangHansen%2Fjwt-auth.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2FWangHansen%2Fjwt-auth?ref=badge_large) -------------------------------------------------------------------------------- /__mock__/cron.ts: -------------------------------------------------------------------------------- 1 | export class CronJob { 2 | constructor( 3 | cronTime, 4 | onTick, 5 | onComplete, 6 | start, 7 | timezone, 8 | context, 9 | runOnInit, 10 | unrefTimeout 11 | ) { 12 | // mock cronjob 13 | } 14 | 15 | start() { 16 | // strat 17 | } 18 | 19 | stop() { 20 | // stop 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // Respect "browser" field in package.json when resolving modules 12 | // browser: false, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/private/var/folders/yt/lrk_g6l13xd5bssk8h_j1d8h0000gn/T/jest_dx", 16 | 17 | // Automatically clear mock calls and instances between every test 18 | clearMocks: true, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | collectCoverage: true, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | // collectCoverageFrom: null, 25 | 26 | // The directory where Jest should output its coverage files 27 | coverageDirectory: "coverage", 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | // coveragePathIgnorePatterns: [ 31 | // "/node_modules/" 32 | // ], 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | // coverageReporters: [ 36 | // "json", 37 | // "text", 38 | // "lcov", 39 | // "clover" 40 | // ], 41 | 42 | // An object that configures minimum threshold enforcement for coverage results 43 | // coverageThreshold: null, 44 | 45 | // A path to a custom dependency extractor 46 | // dependencyExtractor: null, 47 | 48 | // Make calling deprecated APIs throw helpful error messages 49 | // errorOnDeprecated: false, 50 | 51 | // Force coverage collection from ignored files using an array of glob patterns 52 | // forceCoverageMatch: [], 53 | 54 | // A path to a module which exports an async function that is triggered once before all test suites 55 | // globalSetup: null, 56 | 57 | // A path to a module which exports an async function that is triggered once after all test suites 58 | // globalTeardown: null, 59 | 60 | // A set of global variables that need to be available in all test environments 61 | // globals: {}, 62 | 63 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 64 | // maxWorkers: "50%", 65 | 66 | // An array of directory names to be searched recursively up from the requiring module's location 67 | // moduleDirectories: [ 68 | // "node_modules" 69 | // ], 70 | 71 | // An array of file extensions your modules use 72 | // moduleFileExtensions: [ 73 | // "js", 74 | // "json", 75 | // "jsx", 76 | // "ts", 77 | // "tsx", 78 | // "node" 79 | // ], 80 | 81 | // A map from regular expressions to module names that allow to stub out resources with a single module 82 | // moduleNameMapper: {}, 83 | 84 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 85 | // modulePathIgnorePatterns: [], 86 | 87 | // Activates notifications for test results 88 | // notify: false, 89 | 90 | // An enum that specifies notification mode. Requires { notify: true } 91 | // notifyMode: "failure-change", 92 | 93 | // A preset that is used as a base for Jest's configuration 94 | preset: "ts-jest", 95 | 96 | // Run tests from one or more projects 97 | // projects: null, 98 | 99 | // Use this configuration option to add custom reporters to Jest 100 | // reporters: undefined, 101 | 102 | // Automatically reset mock state between every test 103 | // resetMocks: false, 104 | 105 | // Reset the module registry before running each individual test 106 | // resetModules: false, 107 | 108 | // A path to a custom resolver 109 | // resolver: null, 110 | 111 | // Automatically restore mock state between every test 112 | // restoreMocks: false, 113 | 114 | // The root directory that Jest should scan for tests and modules within 115 | // rootDir: null, 116 | 117 | // A list of paths to directories that Jest should use to search for files in 118 | // roots: [ 119 | // "" 120 | // ], 121 | 122 | // Allows you to use a custom runner instead of Jest's default test runner 123 | // runner: "jest-runner", 124 | 125 | // The paths to modules that run some code to configure or set up the testing environment before each test 126 | // setupFiles: [], 127 | 128 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 129 | // setupFilesAfterEnv: [], 130 | 131 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 132 | // snapshotSerializers: [], 133 | 134 | // The test environment that will be used for testing 135 | testEnvironment: "node" 136 | 137 | // Options that will be passed to the testEnvironment 138 | // testEnvironmentOptions: {}, 139 | 140 | // Adds a location field to test results 141 | // testLocationInResults: false, 142 | 143 | // The glob patterns Jest uses to detect test files 144 | // testMatch: [ 145 | // "**/__tests__/**/*.[jt]s?(x)", 146 | // "**/?(*.)+(spec|test).[tj]s?(x)" 147 | // ], 148 | 149 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 150 | // testPathIgnorePatterns: [ 151 | // "/node_modules/" 152 | // ], 153 | 154 | // The regexp pattern or array of patterns that Jest uses to detect test files 155 | // testRegex: [], 156 | 157 | // This option allows the use of a custom results processor 158 | // testResultsProcessor: null, 159 | 160 | // This option allows use of a custom test runner 161 | // testRunner: "jasmine2", 162 | 163 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 164 | // testURL: "http://localhost", 165 | 166 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 167 | // timers: "real", 168 | 169 | // A map from regular expressions to paths to transformers 170 | // transform: null, 171 | 172 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 173 | // transformIgnorePatterns: [ 174 | // "/node_modules/" 175 | // ], 176 | 177 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 178 | // unmockedModulePathPatterns: undefined, 179 | 180 | // Indicates whether each individual test should be reported during the run 181 | // verbose: null, 182 | 183 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 184 | // watchPathIgnorePatterns: [], 185 | 186 | // Whether to use watchman for file crawling 187 | // watchman: true, 188 | }; 189 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hansenw/jwt-auth", 3 | "version": "0.11.1", 4 | "description": "A complete JWT auth library that provides key rotation, token revocation and persistent key storage", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "test": "jest", 10 | "prepare": "rm -rf lib/ && npm run build", 11 | "prepublishOnly": "npm test", 12 | "postversion": "git push && git push --tags" 13 | }, 14 | "keywords": [ 15 | "microservice", 16 | "auth", 17 | "authentication", 18 | "jwt", 19 | "jwks", 20 | "key rotation", 21 | "jwt revoke", 22 | "token revocation" 23 | ], 24 | "files": [ 25 | "lib/**/*" 26 | ], 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/WangHansen/jwt-auth.git" 30 | }, 31 | "author": "Hansen Wang", 32 | "license": "MIT", 33 | "dependencies": { 34 | "@types/express": "^4.17.13", 35 | "cron": "^1.8.2", 36 | "debug": "^4.3.4", 37 | "jose": "^2.0.5" 38 | }, 39 | "devDependencies": { 40 | "@types/cron": "^1.7.3", 41 | "@types/jest": "^26.0.24", 42 | "@typescript-eslint/eslint-plugin": "^4.33.0", 43 | "@typescript-eslint/parser": "^4.33.0", 44 | "codecov": "^3.8.3", 45 | "eslint": "^7.32.0", 46 | "eslint-config-prettier": "^6.15.0", 47 | "eslint-plugin-prettier": "^3.4.1", 48 | "jest": "^26.6.3", 49 | "prettier": "^2.6.2", 50 | "ts-jest": "^26.5.6", 51 | "ts-node": "^9.1.1", 52 | "typescript": "^4.6.3" 53 | }, 54 | "bugs": { 55 | "url": "https://github.com/WangHansen/jwt-auth.git/issues" 56 | }, 57 | "homepage": "https://github.com/WangHansen/jwt-auth.git#readme", 58 | "directories": { 59 | "lib": "lib", 60 | "test": "test" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/error.ts: -------------------------------------------------------------------------------- 1 | export class JWTRevoked extends Error { 2 | code: string; 3 | constructor(message?: string) { 4 | super(message); 5 | this.message = message ?? "token has been revoked"; 6 | this.name = this.constructor.name; 7 | this.code = "ERR_JWT_REVOKED"; 8 | Error.captureStackTrace(this, this.constructor); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import FileStorage from "./storage"; 2 | import JWTAuth from "./jwtAuth"; 3 | import { Storage } from "./storage/interface"; 4 | 5 | export * from "./jwtAuth"; 6 | export { FileStorage, Storage }; 7 | export default JWTAuth; 8 | 9 | // item save in revocation list 10 | export interface RevocationListItem { 11 | jti: string; 12 | exp: number; // exp time in millsecs 13 | } 14 | -------------------------------------------------------------------------------- /src/jwtAuth.ts: -------------------------------------------------------------------------------- 1 | import { 2 | JWKS, 3 | JWT, 4 | JSONWebKeySet, 5 | Curves, 6 | keyType, 7 | BasicParameters, 8 | } from "jose"; 9 | import debug from "debug"; 10 | import * as crypto from "crypto"; 11 | import { Storage } from "./storage/interface"; 12 | import { JWTRevoked } from "./error"; 13 | import { CronJob } from "cron"; 14 | import { RevocationListItem } from "./index"; 15 | 16 | export interface JwtAuthOptions { 17 | algorithm?: keyType; // default "EC" 18 | crvOrSize?: Curves | number; // key size (in bits) or named curve ('crv') for "EC" 19 | amount?: number; // number of keys kept in rotation 20 | interval?: string; // cron expression for how often to generate a new key 21 | signSkip?: number; // number of keys skipped in signing, default 1 22 | tokenAge?: string; // token expire time in zeit/ms, default '10m' for access token 23 | } 24 | 25 | interface JWTAuthData extends JSONWebKeySet { 26 | revocList: T[]; 27 | } 28 | 29 | const KEYGENOPT: BasicParameters = { use: "sig" }; 30 | 31 | export default class JWTAuth { 32 | private storage: Storage | null = null; 33 | private keystore: JWKS.KeyStore; 34 | private keyIds: string[] = []; 35 | private revocationList: T[] = []; 36 | private cronJob: CronJob | null; 37 | private config: Required = { 38 | algorithm: "EC", 39 | crvOrSize: "P-256", 40 | amount: 3, 41 | signSkip: 1, 42 | interval: "00 00 */4 * * *", 43 | tokenAge: "30m", 44 | }; 45 | 46 | public logger = debug("jwt-auth"); 47 | 48 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 49 | revokeCallback = ({ payload: { jti, exp } }): T => ({ jti, exp } as T); 50 | 51 | constructor(config?: JwtAuthOptions) { 52 | this.config = this.configCheck(config); 53 | const { interval } = this.config; 54 | this.keystore = new JWKS.KeyStore(); 55 | this.fillKeystore(); 56 | this.cronJob = new CronJob( 57 | interval, 58 | async function () { 59 | await this.rotate(); 60 | }, 61 | null, 62 | true, 63 | undefined, 64 | this 65 | ); 66 | } 67 | 68 | /** 69 | * validate the options passed to constructor 70 | * @param {JwtAuthOptions} [config] - configuration 71 | * @returns {JwtAuthOptions} - merged configuration 72 | */ 73 | private configCheck(config?: JwtAuthOptions): Required { 74 | if (!config) return this.config; 75 | const { amount, signSkip } = config; 76 | if (amount && amount < 3) { 77 | config.amount = 3; // minimum key number is 3 78 | } 79 | if (signSkip && signSkip >= (config.amount || this.config.amount)) { 80 | throw new Error( 81 | "Number of keys skipped for signing must be small than total number of keys" 82 | ); 83 | } 84 | // TODO: add more config checks 85 | return Object.assign(this.config, config); 86 | } 87 | 88 | /** 89 | * Read the key files from storage to replace the generated keys 90 | * currently in keystore and generate keys if not enough key is 91 | * found. Also restart the cron job to rotate keys 92 | */ 93 | public async setStorage(storage: Storage): Promise { 94 | const { interval } = this.config; 95 | this.storage = storage; 96 | // load keys, clients and revoclist if they exists 97 | await this.loadFromStorage(); 98 | this.fillKeystore(); 99 | await this.saveKeys(); 100 | this.cronJob = new CronJob( 101 | interval, 102 | function () { 103 | this.rotate(); 104 | }, 105 | null, 106 | true, 107 | undefined, 108 | this 109 | ); 110 | } 111 | 112 | // if there is not enough keys, generate more, also update keyid list 113 | private fillKeystore(): void { 114 | const { amount, algorithm, crvOrSize } = this.config; 115 | while (this.keystore.size < amount) { 116 | this.keystore.generateSync(algorithm, crvOrSize, KEYGENOPT); 117 | } 118 | this.updateKeyIds(); 119 | } 120 | 121 | private generateJTI(): string { 122 | const hash = crypto.createHash("sha256"); 123 | const rand = 124 | new Date().getTime().toString(36) + Math.random().toString(36).slice(2); 125 | return hash.update(rand).digest("base64"); 126 | } 127 | 128 | private updateKeyIds(): void { 129 | this.keyIds = this.keystore.all().map((key) => key.kid); 130 | this.logger("All keys in store: ", this.keyIds); 131 | } 132 | 133 | private async loadFromStorage(): Promise { 134 | if (!this.storage) return; 135 | await Promise.all([this.loadKeys(), this.loadRevocList()]); 136 | } 137 | 138 | private async loadKeys(): Promise { 139 | if (!this.storage) { 140 | throw new Error("No persistent storage provided"); 141 | } 142 | this.logger("loading keys from storage"); 143 | const JWKSet = await this.storage.loadKeys(); 144 | if (JWKSet?.keys) { 145 | this.keystore = JWKS.asKeyStore(JWKSet); 146 | } 147 | } 148 | 149 | private async loadRevocList(): Promise { 150 | if (!this.storage) { 151 | throw new Error("No persistent storage provided"); 152 | } 153 | this.logger("loading revocation list from storage"); 154 | this.revocationList = (await this.storage.loadRevocationList()) || []; 155 | } 156 | 157 | private async saveKeys(): Promise { 158 | if (!this.storage) { 159 | throw new Error("No persistent storage provided"); 160 | } 161 | this.logger("saving revocation list to storage"); 162 | await this.storage.saveKeys(this.JWKS(true)); 163 | } 164 | 165 | private async saveRevocList(): Promise { 166 | if (!this.storage) { 167 | throw new Error("No persistent storage provided"); 168 | } 169 | this.logger("saving revocation list to storage"); 170 | await this.storage.saveRevocationList(this.revocationList); 171 | } 172 | 173 | //-----------------------// 174 | // getters // 175 | //-----------------------// 176 | 177 | get data(): JWTAuthData { 178 | return { 179 | keys: this.JWKS().keys, 180 | revocList: this.revocationList, 181 | }; 182 | } 183 | 184 | get blacklist(): T[] { 185 | return this.revocationList; 186 | } 187 | 188 | //-----------------------// 189 | // public // 190 | //-----------------------// 191 | 192 | JWKS(isPrivate = false): JSONWebKeySet { 193 | return this.keystore.toJWKS(isPrivate); 194 | } 195 | 196 | /** 197 | * Remove the oldest key and replace it with a new key 198 | */ 199 | async rotate(): Promise { 200 | this.logger("rotating keys"); 201 | const { amount, algorithm, crvOrSize } = this.config; 202 | await this.keystore.generate(algorithm, crvOrSize, KEYGENOPT); 203 | this.updateKeyIds(); 204 | let amountToRemove = this.keystore.size - amount; 205 | while (amountToRemove > 0) { 206 | const keyToRemove = this.keystore.get({ 207 | kid: this.keyIds[0], 208 | }); 209 | this.logger(`old key ${keyToRemove.kid} removed`); 210 | this.keystore.remove(keyToRemove); 211 | this.updateKeyIds(); 212 | amountToRemove--; 213 | } 214 | if (this.storage) { 215 | await this.saveKeys(); 216 | } 217 | } 218 | 219 | /** 220 | * Revoke one key for verifying and signing 221 | * Note: this may cause all the JWT signed with this kid 222 | * to be revoked 223 | * @param {string} kid - id of the key to be removed 224 | */ 225 | async revokeKey(kid: string): Promise { 226 | const keyToRemove = this.keystore.get({ kid }); 227 | this.logger(`key ${kid} revoked`); 228 | this.keystore.remove(keyToRemove); 229 | this.fillKeystore(); 230 | if (this.storage) { 231 | await this.saveKeys(); 232 | } 233 | } 234 | 235 | /** 236 | * Revoke all keys in the keystore 237 | * Note: this will cause all JWTs signed to be invalid 238 | */ 239 | async reset(): Promise { 240 | this.logger("remove all existing keys and generating new ones"); 241 | this.keystore = new JWKS.KeyStore(); 242 | this.fillKeystore(); 243 | if (this.storage) { 244 | await this.saveKeys(); 245 | } 246 | } 247 | 248 | /** 249 | * Create a JWT token with custom payload and options 250 | * @param {object} payload - payload of jwt 251 | * @param {JWK.SignOptions} [options] 252 | * @returns {string} token 253 | */ 254 | sign(payload: Record, options?: JWT.SignOptions): string { 255 | options = options || {}; 256 | const keyIndex = Math.floor( 257 | Math.random() * (this.keystore.size - this.config.signSkip) + 258 | this.config.signSkip 259 | ); 260 | const key = this.keystore.get({ 261 | kid: this.keyIds[keyIndex], 262 | }); 263 | if (!options.expiresIn) { 264 | options.expiresIn = this.config.tokenAge; 265 | } 266 | if (!options.jti) { 267 | options.jti = this.generateJTI(); 268 | } 269 | return JWT.sign(payload, key, options); 270 | } 271 | 272 | /** 273 | * Verify a JWT token with current keystore 274 | * @param {string} jwt 275 | * @param {JWT.VerifyOptions} options 276 | */ 277 | verify( 278 | jwt: string, 279 | options?: JWT.SignOptions 280 | ): Record | never { 281 | options = options || {}; 282 | let revoked = false; 283 | const payload = JWT.verify(jwt, this.keystore, options) as Record< 284 | string, 285 | unknown 286 | >; 287 | const newRevokedList: T[] = []; 288 | for (const item of this.revocationList) { 289 | const { jti, exp } = item as RevocationListItem; 290 | if (new Date() > new Date(exp * 1000)) { 291 | continue; 292 | } else if (payload.jti === jti) { 293 | revoked = true; 294 | } else { 295 | newRevokedList.push(item); 296 | } 297 | } 298 | if (revoked) { 299 | throw new JWTRevoked(); 300 | } 301 | this.revocationList = newRevokedList; 302 | return payload; 303 | } 304 | 305 | /** 306 | * Callback is used to trasform payload into a format which 307 | * is saved in the revocation list, default format is: 308 | * { id: , exp: } 309 | * @description 310 | * By default, jti is used to identify which token in revoked. 311 | * Default callback returns an object containing both expire 312 | * time and jti, which then is saved into the revocation list. 313 | * So the list looks like: [{ id: , exp: }] 314 | * The exp is used by to remove it from the list once the time 315 | * has passed its exp time. 316 | * 317 | * @callback revocListHandler 318 | * @param {JWT.completeResult} jwt - jwt object containing header, payload, signature 319 | * @returns {any} object that will be pushed into the list 320 | */ 321 | /** 322 | * Revoke access to a specific token 323 | * @param {string} jwtToken 324 | * @param {revocListHandler} callback 325 | */ 326 | async revoke( 327 | jwtToken: string, 328 | revocListHandler = ({ payload }) => 329 | ({ 330 | jti: payload.jti, 331 | exp: payload.exp, 332 | } as T) 333 | ): Promise { 334 | const jwtObj = JWT.decode(jwtToken, { complete: true }); 335 | this.revocationList.push(revocListHandler(jwtObj)); 336 | if (this.storage) { 337 | await this.saveRevocList(); 338 | } 339 | } 340 | } 341 | -------------------------------------------------------------------------------- /src/storage/__mock__/storage.ts: -------------------------------------------------------------------------------- 1 | export const mockLoadFromFile = jest 2 | .fn() 3 | .mockImplementation(() => Promise.resolve()); 4 | 5 | export const mockSaveToFile = jest 6 | .fn() 7 | .mockImplementation(() => Promise.resolve()); 8 | 9 | export const mockLoadKeys = jest 10 | .fn() 11 | .mockImplementation(() => Promise.resolve()); 12 | 13 | export const mockLoadClients = jest 14 | .fn() 15 | .mockImplementation(() => Promise.resolve()); 16 | 17 | export const mockLoadRevocationList = jest 18 | .fn() 19 | .mockImplementation(() => Promise.resolve()); 20 | 21 | export const mockSaveKeys = jest 22 | .fn() 23 | .mockImplementation(() => Promise.resolve()); 24 | 25 | export const mockSaveClients = jest 26 | .fn() 27 | .mockImplementation(() => Promise.resolve()); 28 | 29 | export const mockSaveRevocationList = jest 30 | .fn() 31 | .mockImplementation(() => Promise.resolve()); 32 | 33 | const mock = jest.fn().mockImplementation(() => { 34 | return { 35 | loadKeys: mockLoadKeys, 36 | saveKeys: mockSaveKeys, 37 | loadClients: mockLoadClients, 38 | saveClients: mockSaveClients, 39 | loadRevocationList: mockLoadRevocationList, 40 | saveRevocationList: mockSaveRevocationList, 41 | }; 42 | }); 43 | 44 | export default mock; 45 | -------------------------------------------------------------------------------- /src/storage/index.ts: -------------------------------------------------------------------------------- 1 | import FileStorage, { FileStorageConfig } from "./storage"; 2 | 3 | export { FileStorageConfig }; 4 | export default FileStorage; 5 | -------------------------------------------------------------------------------- /src/storage/interface.ts: -------------------------------------------------------------------------------- 1 | import { JSONWebKeySet } from "jose"; 2 | import { RevocationListItem } from "../index"; 3 | 4 | export abstract class Storage { 5 | abstract loadKeys(): Promise; 6 | abstract saveKeys(keys: JSONWebKeySet): Promise; 7 | abstract loadRevocationList(): Promise | undefined>; 8 | abstract saveRevocationList(list: Array): Promise; 9 | } 10 | -------------------------------------------------------------------------------- /src/storage/storage.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import debug from "debug"; 3 | import { JSONWebKeySet } from "jose"; 4 | import { Storage } from "./interface"; 5 | 6 | export interface RevocationListItem { 7 | jti: string; 8 | exp: number; 9 | } 10 | 11 | export interface FileStorageConfig { 12 | diskPath: string; 13 | keysFilename: string; 14 | revocListFilename: string; 15 | } 16 | 17 | export default class FileStorage extends Storage { 18 | private config = { 19 | diskPath: "./authcerts", 20 | keysFilename: ".keys.json", 21 | revocListFilename: ".revocList.json", 22 | }; 23 | private keysFilepath: string; 24 | private revocListFilepath: string; 25 | 26 | private logger = debug("jwt-auth:filestore"); 27 | 28 | constructor(config?: FileStorageConfig) { 29 | super(); 30 | this.config = Object.assign(this.config, config || {}); 31 | const { diskPath, keysFilename, revocListFilename } = this.config; 32 | if (!fs.existsSync(diskPath)) { 33 | fs.mkdirSync(diskPath); 34 | } 35 | this.keysFilepath = `${diskPath}/${keysFilename}`; 36 | this.revocListFilepath = `${diskPath}/${revocListFilename}`; 37 | } 38 | 39 | /** 40 | * Load data from a file 41 | * 42 | * @param {string} filepath 43 | * @returns {Promise} data 44 | */ 45 | private async loadFromFile(filepath: string): Promise | never { 46 | this.logger(`loading from file ${filepath}`); 47 | let filehandle: fs.promises.FileHandle | undefined, 48 | data = ""; 49 | 50 | try { 51 | filehandle = await fs.promises.open(filepath, "r"); 52 | data = await filehandle.readFile({ encoding: "utf8" }); 53 | } catch (e) { 54 | // file doesn't exists 55 | this.logger("file doesn't exists"); 56 | } finally { 57 | if (filehandle != undefined) await filehandle.close(); 58 | } 59 | 60 | return data; 61 | } 62 | 63 | /** 64 | * Save data to a file 65 | * 66 | * @param data - string to be written to a file 67 | * @param filepath 68 | */ 69 | private async saveToFile(data: string, filepath: string): Promise { 70 | this.logger(`saving to file ${filepath}`); 71 | const fd = await fs.promises.open(filepath, "w"); 72 | await fd.write(data); 73 | await fd.close(); 74 | } 75 | 76 | async loadKeys(): Promise { 77 | const str = await this.loadFromFile(this.keysFilepath); 78 | return str ? JSON.parse(str) : undefined; 79 | } 80 | 81 | async saveKeys(keys: JSONWebKeySet): Promise { 82 | await this.saveToFile(JSON.stringify(keys), this.keysFilepath); 83 | } 84 | 85 | async loadRevocationList(): Promise { 86 | const str = await this.loadFromFile(this.revocListFilepath); 87 | return str ? JSON.parse(str) : undefined; 88 | } 89 | 90 | async saveRevocationList(list: RevocationListItem[]): Promise { 91 | await this.saveToFile(JSON.stringify(list), this.revocListFilepath); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /test/fileStorage.int.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import JWTAuth from "../src"; 3 | import FileStorage from "../src/storage"; 4 | jest.mock("cron"); 5 | 6 | describe("JWTAuth FileStorage Integration Tests: ", () => { 7 | beforeEach(() => { 8 | if (fs.existsSync("./authcerts")) { 9 | fs.rmdirSync("./authcerts", { recursive: true }); 10 | } 11 | }); 12 | test("JWTAuth should generate keys and save to FileStorage", async () => { 13 | const storage = new FileStorage(); 14 | const jwtauth = new JWTAuth(); 15 | const jwks = jwtauth.JWKS(true); 16 | await jwtauth.setStorage(storage); 17 | const keys = await storage.loadKeys(); 18 | expect(keys).toEqual(jwks); 19 | }); 20 | 21 | test("JWTAuth should read keys from FileStorage", async () => { 22 | const temp = new JWTAuth(); 23 | const jwks = temp.JWKS(); 24 | const storage = new FileStorage(); 25 | await storage.saveKeys(jwks); 26 | const jwtauth = new JWTAuth(); 27 | await jwtauth.setStorage(storage); 28 | const keys = jwtauth.JWKS(); 29 | expect(keys).toEqual(jwks); 30 | }); 31 | 32 | test("JWTAuth should save keys at rotate", async () => { 33 | const storage = new FileStorage(); 34 | const jwtauth = new JWTAuth(); 35 | await jwtauth.setStorage(storage); 36 | await jwtauth.rotate(); 37 | const jwks = jwtauth.JWKS(true); 38 | const keys = await storage.loadKeys(); 39 | expect(keys).toEqual(jwks); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import JWTAuth, { JwtAuthOptions } from "../src"; 2 | import FileStorage from "../src/storage"; 3 | import { JWKS } from "jose"; 4 | import { CronJob } from "cron"; 5 | import { JWTRevoked } from "../src/error"; 6 | jest.mock("../src/storage"); 7 | jest.mock("cron"); 8 | 9 | describe("JWTAuth Tests: ", () => { 10 | let storageMock: FileStorage; 11 | 12 | beforeAll(() => { 13 | storageMock = new FileStorage(); 14 | }); 15 | 16 | describe("Constructor tests: ", () => { 17 | test("should be able to create instance without options", () => { 18 | const auth = new JWTAuth() as any; 19 | expect(auth).toBeInstanceOf(JWTAuth); 20 | expect(auth.keystore).toBeInstanceOf(JWKS.KeyStore); 21 | expect(auth.keystore.size).toBe(3); 22 | expect(auth.cronJob).toBeInstanceOf(CronJob); 23 | }); 24 | }); 25 | 26 | describe("configCheck tests:", () => { 27 | test("should correct options to have minimum 3 keys", () => { 28 | const opt = { amount: 1 }; 29 | const auth = new JWTAuth(opt); 30 | const config = (auth as any).config; 31 | expect(config.amount).toBe(3); 32 | }); 33 | 34 | test("should merge options with default values", () => { 35 | const opts: JwtAuthOptions = { 36 | algorithm: "RSA", 37 | crvOrSize: 2048, 38 | amount: 4, 39 | signSkip: 2, 40 | interval: "* */6 * * * *", 41 | tokenAge: "15m", 42 | }; 43 | const auth = new JWTAuth(opts); 44 | const config = (auth as any).config; 45 | expect(config.algorithm).toBe(opts.algorithm); 46 | expect(config.crvOrSize).toBe(opts.crvOrSize); 47 | expect(config.amount).toBe(opts.amount); 48 | expect(config.signSkip).toBe(opts.signSkip); 49 | expect(config.interval).toBe(opts.interval); 50 | expect(config.tokenAge).toBe(opts.tokenAge); 51 | }); 52 | 53 | test("should throw exception if keys skipped is greater than amount of keys in total", () => { 54 | expect(() => { 55 | new JWTAuth({ amount: 3, signSkip: 3 }); 56 | }).toThrow(); 57 | expect(() => { 58 | new JWTAuth({ amount: 5, signSkip: 5 }); 59 | }).toThrow(); 60 | expect(() => { 61 | new JWTAuth({ amount: 4, signSkip: 1 }); 62 | }).not.toThrow(); 63 | expect(() => { 64 | new JWTAuth({ signSkip: 2 }); 65 | }).not.toThrow(); 66 | }); 67 | }); 68 | 69 | describe("setStorage test ", () => { 70 | test("should load from storage and then fill the keystore", async () => { 71 | const auth: any = new JWTAuth(); 72 | const spyLoad = jest 73 | .spyOn(auth, "loadFromStorage") 74 | .mockImplementation(() => Promise.resolve()); 75 | const spyFill = jest 76 | .spyOn(auth, "fillKeystore") 77 | .mockImplementation(() => Promise.resolve()); 78 | await auth.setStorage(storageMock); 79 | expect(spyLoad).toBeCalled(); 80 | expect(spyFill).toBeCalled(); 81 | expect(auth.cronJob).toBeInstanceOf(CronJob); 82 | }); 83 | }); 84 | 85 | describe("private methods tests: ", () => { 86 | test("fillKeystore should fill the keystore with keys", async () => { 87 | const auth = new JWTAuth() as any; 88 | const spy = jest.spyOn(auth, "updateKeyIds"); 89 | auth.fillKeystore(); 90 | expect(auth.keystore.size).toBe(3); 91 | expect(spy).toBeCalled(); 92 | }); 93 | test("fillKeystore should fill the keystore with keys", async () => { 94 | const auth = new JWTAuth() as any; 95 | await auth.setStorage(storageMock); 96 | const spy = jest.spyOn(auth, "updateKeyIds"); 97 | auth.fillKeystore(); 98 | expect(auth.keystore.size).toBe(3); 99 | expect(spy).toBeCalled(); 100 | }); 101 | test("generateJTI should generate a unique string", () => { 102 | const auth = new JWTAuth() as any; 103 | const callTimes = 10; 104 | const set = new Set(); 105 | const spy = jest.spyOn(auth, "generateJTI"); 106 | for (let i = 0; i < callTimes; i++) { 107 | const jti = auth.generateJTI(); 108 | expect(set.has(jti)).toBe(false); 109 | set.add(jti); 110 | } 111 | expect(spy).toBeCalledTimes(callTimes); 112 | }); 113 | 114 | test("loadFromStorage should load keys, clients and revoclist", async () => { 115 | const auth = new JWTAuth() as any; 116 | await auth.setStorage(storageMock); 117 | const spyk = jest 118 | .spyOn(auth, "loadKeys") 119 | .mockImplementationOnce(() => Promise.resolve()); 120 | const spyl = jest 121 | .spyOn(auth, "loadRevocList") 122 | .mockImplementationOnce(() => Promise.resolve()); 123 | await auth.loadFromStorage(); 124 | expect(spyk).toBeCalled(); 125 | expect(spyl).toBeCalled(); 126 | }); 127 | 128 | test("loadFromStorage should return if no storage is set", async () => { 129 | const auth = new JWTAuth() as any; 130 | const spyk = jest 131 | .spyOn(auth, "loadKeys") 132 | .mockImplementationOnce(() => Promise.resolve()); 133 | const spyl = jest 134 | .spyOn(auth, "loadRevocList") 135 | .mockImplementationOnce(() => Promise.resolve()); 136 | await auth.loadFromStorage(); 137 | expect(spyk).not.toBeCalled(); 138 | expect(spyl).not.toBeCalled(); 139 | }); 140 | 141 | test("loadKeys should call loadkeys on storage", async () => { 142 | const auth = new JWTAuth() as any; 143 | await auth.setStorage(storageMock); 144 | await auth.loadKeys(); 145 | expect(storageMock.loadKeys).toBeCalled(); 146 | }); 147 | 148 | test("loadKeys should throw if no storage is set", async () => { 149 | const auth = new JWTAuth() as any; 150 | try { 151 | await auth.loadKeys(); 152 | } catch (error) { 153 | expect(error).toBeDefined(); 154 | expect((error as any).message).toBe("No persistent storage provided"); 155 | } 156 | expect(storageMock.loadKeys).not.toBeCalled(); 157 | }); 158 | 159 | test("loadRevocList should call loadRevocList on storage", async () => { 160 | const auth = new JWTAuth() as any; 161 | await auth.setStorage(storageMock); 162 | await auth.loadRevocList(); 163 | expect(storageMock.loadRevocationList).toBeCalled(); 164 | }); 165 | 166 | test("loadRevocList should throw if no storage is set", async () => { 167 | const auth = new JWTAuth() as any; 168 | try { 169 | await auth.loadRevocList(); 170 | } catch (error) { 171 | expect(error).toBeDefined(); 172 | expect((error as any).message).toBe("No persistent storage provided"); 173 | } 174 | expect(storageMock.loadRevocationList).not.toBeCalled(); 175 | }); 176 | 177 | test("saveKeys should call storage save with private keys", async () => { 178 | const auth = new JWTAuth() as any; 179 | await auth.setStorage(storageMock); 180 | await auth.saveKeys(); 181 | expect(storageMock.saveKeys).toBeCalledWith(auth.JWKS(true)); 182 | }); 183 | 184 | test("saveKeys should throw if no storage is set", async () => { 185 | const auth = new JWTAuth() as any; 186 | try { 187 | await auth.saveKeys(); 188 | } catch (error) { 189 | expect(error).toBeDefined(); 190 | expect((error as any).message).toBe("No persistent storage provided"); 191 | } 192 | expect(storageMock.saveKeys).not.toBeCalled(); 193 | }); 194 | 195 | test("saveRevocList should call storage save with revocationList", async () => { 196 | const auth = new JWTAuth() as any; 197 | await auth.setStorage(storageMock); 198 | await auth.saveRevocList(); 199 | expect(storageMock.saveRevocationList).toBeCalledWith( 200 | auth.revocationList 201 | ); 202 | }); 203 | 204 | test("saveRevocList should throw if no storage is set", async () => { 205 | const auth = new JWTAuth() as any; 206 | try { 207 | await auth.saveRevocList(); 208 | } catch (error) { 209 | expect(error).toBeDefined(); 210 | expect((error as any).message).toBe("No persistent storage provided"); 211 | } 212 | expect(storageMock.saveRevocationList).not.toBeCalled(); 213 | }); 214 | }); 215 | 216 | describe("getters tests: ", () => { 217 | test("should return corresponding data, keys and recovList", async () => { 218 | const auth = new JWTAuth() as any; 219 | const jwksobj = { keys: [{ kid: "123" }] }; 220 | const revocList = [{ jti: "456" }]; 221 | const keystore = auth.keystore; 222 | jest.spyOn(keystore, "toJWKS").mockImplementation(() => jwksobj); 223 | auth.revocationList = revocList; 224 | const data = auth.data; 225 | const blist = auth.blacklist; 226 | const jwks = auth.JWKS(); 227 | expect(data).toStrictEqual({ 228 | keys: jwksobj.keys, 229 | revocList, 230 | }); 231 | expect(jwks).toBe(jwksobj); 232 | expect(blist).toBe(revocList); 233 | }); 234 | }); 235 | 236 | describe("public methods tests: ", () => { 237 | test("rotate should remove the oldest key and generate a new key and sync", async () => { 238 | const auth: any = new JWTAuth(); 239 | jest.spyOn(auth, "saveKeys").mockImplementation(() => Promise.resolve()); 240 | const keyIds = auth.keyIds; 241 | const jwks = auth.JWKS(); 242 | await auth.rotate(); 243 | const newKeyids = auth.keyIds; 244 | const newJwks = auth.JWKS(); 245 | expect(keyIds).toHaveLength(3); 246 | expect(jwks.keys).toHaveLength(3); 247 | expect(newKeyids).toHaveLength(3); 248 | expect(newJwks.keys).toHaveLength(3); 249 | for (let i = 1; i < 3; i++) { 250 | expect(keyIds[i]).toBe(newKeyids[i - 1]); 251 | expect(jwks.keys[i]).toStrictEqual(newJwks.keys[i - 1]); 252 | } 253 | }); 254 | 255 | test("revokeKey should remove the key based on id", async () => { 256 | const auth: any = new JWTAuth(); 257 | const keyIds = auth.keyIds; 258 | const keyid = keyIds[0]; 259 | await auth.revokeKey(keyid); 260 | const newKeyids = auth.keyIds; 261 | expect(newKeyids).not.toContain(keyIds); 262 | }); 263 | 264 | test("revokeKey should call saveKeys if storage is present", async () => { 265 | const auth: any = new JWTAuth(); 266 | await auth.setStorage(storageMock); 267 | const spy = jest.spyOn(auth, "saveKeys").mockResolvedValueOnce({}); 268 | const keyIds = auth.keyIds; 269 | const keyid = keyIds[0]; 270 | await auth.revokeKey(keyid); 271 | const newKeyids = auth.keyIds; 272 | expect(newKeyids).not.toContain(keyIds); 273 | expect(spy).toBeCalled(); 274 | }); 275 | 276 | test("reset should remove all old key and generate a new set", async () => { 277 | const auth: any = new JWTAuth(); 278 | const keyIds = auth.keyIds; 279 | await auth.reset(); 280 | const newKeyids = auth.keyIds; 281 | for (const id of keyIds) { 282 | expect(newKeyids).not.toContain(id); 283 | } 284 | }); 285 | 286 | test("reset should call saveKeys if storage is present", async () => { 287 | const auth: any = new JWTAuth(); 288 | await auth.setStorage(storageMock); 289 | const spy = jest.spyOn(auth, "saveKeys").mockResolvedValueOnce({}); 290 | const keyIds = auth.keyIds; 291 | await auth.reset(); 292 | const newKeyids = auth.keyIds; 293 | for (const id of keyIds) { 294 | expect(newKeyids).not.toContain(id); 295 | } 296 | expect(spy).toBeCalled(); 297 | }); 298 | }); 299 | 300 | describe("JWT tests: ", () => { 301 | test("token signed should be verified", async () => { 302 | const auth: any = new JWTAuth(); 303 | jest.spyOn(auth, "generateJTI"); 304 | const jwt = auth.sign({ username: "test" }); 305 | expect(auth.generateJTI).toBeCalled(); 306 | expect(auth.verify(jwt)).toHaveProperty("username", "test"); 307 | }); 308 | 309 | test("token revoked should not be verified", async () => { 310 | const auth: any = new JWTAuth(); 311 | const spy = jest.spyOn(auth, "saveRevocList"); 312 | const jwt = auth.sign({ username: "test" }, { jti: "123" }); 313 | auth.revoke(jwt); 314 | expect(spy).not.toHaveBeenCalled(); 315 | expect(auth.revocationList).toHaveLength(1); 316 | expect(auth.revocationList[0]).toHaveProperty("jti", "123"); 317 | expect(() => auth.verify(jwt)).toThrow(JWTRevoked); 318 | }); 319 | 320 | test("token revoked should be saved if storage is set", async () => { 321 | const auth: any = new JWTAuth(); 322 | const spy = jest.spyOn(auth, "saveRevocList"); 323 | await auth.setStorage(storageMock); 324 | const jwt = auth.sign({ username: "test" }, { jti: "234" }); 325 | auth.revoke(jwt); 326 | expect(spy).toBeCalled(); 327 | expect(auth.revocationList).toHaveLength(1); 328 | expect(auth.revocationList[0]).toHaveProperty("jti", "234"); 329 | expect(() => auth.verify(jwt)).toThrow(JWTRevoked); 330 | }); 331 | 332 | test("verify function should remove old revoken token id", async () => { 333 | const auth: any = new JWTAuth(); 334 | const expiredRevoc = { 335 | jti: "111", 336 | exp: new Date().getTime() / 1000 - 1, 337 | }; 338 | const revoked = { 339 | jti: "222", 340 | exp: new Date().getTime() / 1000 + 10, 341 | }; 342 | auth.revocationList.push(expiredRevoc); 343 | auth.revocationList.push(revoked); 344 | const jwt = auth.sign({ username: "test" }, { jti: "333" }); 345 | auth.verify(jwt); 346 | expect(auth.revocationList).toHaveLength(1); 347 | expect(auth.revocationList[0]).toStrictEqual(revoked); 348 | }); 349 | }); 350 | }); 351 | -------------------------------------------------------------------------------- /test/storage.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import FileStorage from "../src/storage"; 3 | 4 | describe("FileStorage Tests: ", () => { 5 | const defaultDiskPath = "./authcerts", 6 | keysFilename = ".keys.json", 7 | revocListFilename = ".revocList.json"; 8 | 9 | beforeAll(() => { 10 | if (fs.existsSync(defaultDiskPath)) { 11 | fs.rmdirSync(defaultDiskPath, { recursive: true }); 12 | } 13 | }); 14 | 15 | afterAll(() => { 16 | if (fs.existsSync(defaultDiskPath)) { 17 | fs.rmdirSync(defaultDiskPath, { recursive: true }); 18 | } 19 | }); 20 | 21 | describe("Constructor tests: ", () => { 22 | test("should create the dir if it doesn't exists", () => { 23 | expect(fs.existsSync(defaultDiskPath)).toBe(false); 24 | const storage = new FileStorage() as any; 25 | expect(fs.existsSync(defaultDiskPath)).toBe(true); 26 | expect(storage.keysFilepath).toBe(`${defaultDiskPath}/${keysFilename}`); 27 | expect(storage.revocListFilepath).toBe( 28 | `${defaultDiskPath}/${revocListFilename}` 29 | ); 30 | }); 31 | 32 | test("should expect config passed in for creating files", () => { 33 | const customConfig = { 34 | diskPath: "./authtest", 35 | keysFilename: "keys.txt", 36 | revocListFilename: "revocs.txt", 37 | }; 38 | expect(fs.existsSync(customConfig.diskPath)).toBe(false); 39 | const storage = new FileStorage(customConfig) as any; 40 | expect(fs.existsSync(customConfig.diskPath)).toBe(true); 41 | expect(storage.keysFilepath).toBe( 42 | `${customConfig.diskPath}/${customConfig.keysFilename}` 43 | ); 44 | expect(storage.revocListFilepath).toBe( 45 | `${customConfig.diskPath}/${customConfig.revocListFilename}` 46 | ); 47 | fs.rmdirSync(customConfig.diskPath, { recursive: true }); 48 | }); 49 | }); 50 | 51 | describe("private methods tests: ", () => { 52 | test("loadFromFile should read file and return its content", async () => { 53 | const filePath = "./authcerts/test_read.txt"; 54 | const fd = await fs.promises.open(filePath, "w"); 55 | const data = "testdatatestdata"; 56 | await fd.write(data); 57 | await fd.close(); 58 | const storage = new FileStorage() as any; 59 | const res = await storage.loadFromFile(filePath); 60 | expect(res).toEqual(data); 61 | }); 62 | 63 | test("saveToFile should save content into file", async () => { 64 | const filePath = "./authcerts/test_write.txt"; 65 | const data = "writetestdata"; 66 | const storage = new FileStorage() as any; 67 | await storage.saveToFile(data, filePath); 68 | const filehandle = await fs.promises.open(filePath, "r"); 69 | const data_r = await filehandle.readFile({ encoding: "utf8" }); 70 | expect(data_r).toEqual(data); 71 | }); 72 | 73 | test("loadFromFile should read content from saveToFile", async () => { 74 | const filePath = "./authcerts/test_int.txt"; 75 | const data = "somerandomedata"; 76 | const storage = new FileStorage() as any; 77 | await storage.saveToFile(data, filePath); 78 | const read = await storage.loadFromFile(filePath); 79 | expect(read).toEqual(data); 80 | }); 81 | }); 82 | 83 | describe("public methods tests: ", () => { 84 | const loadTestCases = ["loadKeys", "loadRevocationList"]; 85 | for (const funcName of loadTestCases) { 86 | test(`${funcName} should call loadFromFile internally and parse to json`, async () => { 87 | const storage = new FileStorage() as any; 88 | const data = { test: Math.random().toString(36) }; 89 | const spy = jest 90 | .spyOn(storage, "loadFromFile") 91 | .mockResolvedValue(JSON.stringify(data)); 92 | const read = await storage[funcName](); 93 | expect(spy).toHaveBeenCalled(); 94 | expect(data).toEqual(read); 95 | }); 96 | } 97 | 98 | const saveTestCases = [ 99 | { 100 | func: "saveKeys", 101 | data: { keys: [{ e: "e", d: "d" }] }, 102 | }, 103 | { 104 | func: "saveRevocationList", 105 | data: [{ jti: "somejti", exp: new Date() }], 106 | }, 107 | ]; 108 | for (const t of saveTestCases) { 109 | test(`${t.func} should call saveToFile internally and stringify data`, async () => { 110 | const storage = new FileStorage() as any; 111 | const spy = jest.spyOn(storage, "saveToFile").mockResolvedValue({}); 112 | await storage[t.func](t.data); 113 | expect(spy).toHaveBeenCalled(); 114 | }); 115 | } 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "module": "commonjs", 5 | "noImplicitThis": false, 6 | "noImplicitAny": false, 7 | "declaration": true, 8 | "outDir": "./lib", 9 | "strict": true, 10 | "sourceMap": true, 11 | "moduleResolution": "node", 12 | "types": ["node", "jest", "jose", "cron", "express"], 13 | }, 14 | "include": ["src"], 15 | "exclude": ["node_modules", "lib", "tests"] 16 | } 17 | --------------------------------------------------------------------------------