├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── benchmark ├── create.js ├── index.js ├── secret.js └── verify.js ├── eslint.config.js ├── index.js ├── package.json ├── test ├── base64.test.js ├── constructor.test.js ├── create.test.js ├── hmac.test.js ├── integration.test.js ├── polyfill.js ├── secret.test.js ├── secretSync.test.js └── verify.test.js └── types ├── index.d.ts └── index.test-d.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behavior to automatically convert line endings 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | open-pull-requests-limit: 10 8 | 9 | - package-ecosystem: "npm" 10 | directory: "/" 11 | schedule: 12 | interval: "monthly" 13 | open-pull-requests-limit: 10 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - next 8 | - 'v*' 9 | paths-ignore: 10 | - 'docs/**' 11 | - '*.md' 12 | pull_request: 13 | paths-ignore: 14 | - 'docs/**' 15 | - '*.md' 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | test: 22 | permissions: 23 | contents: write 24 | pull-requests: write 25 | uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5 26 | with: 27 | license-check: true 28 | lint: true 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # Vim swap files 133 | *.swp 134 | 135 | # macOS files 136 | .DS_Store 137 | 138 | # Clinic 139 | .clinic 140 | 141 | # lock files 142 | bun.lockb 143 | package-lock.json 144 | pnpm-lock.yaml 145 | yarn.lock 146 | 147 | # editor files 148 | .vscode 149 | .idea 150 | 151 | #tap files 152 | .tap/ -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Jonathan Ong 4 | Copyright (c) 2015 Douglas Christopher Wilson 5 | Copyright (c) 2021 Fastify Collaborators 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in 15 | all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CSRF 2 | 3 | [![CI](https://github.com/fastify/csrf/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fastify/csrf/actions/workflows/ci.yml) 4 | [![NPM version](https://img.shields.io/npm/v/@fastify/csrf.svg?style=flat)](https://www.npmjs.com/package/@fastify/csrf) 5 | [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](https://github.com/neostandard/neostandard) 6 | 7 | Logic behind CSRF token creation and verification. 8 | 9 | Read [Understanding-CSRF](https://github.com/pillarjs/understanding-csrf) 10 | for more information on CSRF. Use this module to create custom CSRF middleware. 11 | 12 | Looking for a CSRF framework for your favorite framework that uses this 13 | module? 14 | 15 | * Express/connect: [csurf](https://www.npmjs.com/package/csurf) or 16 | [alt-xsrf](https://www.npmjs.com/package/alt-xsrf) 17 | * Koa: [koa-csrf](https://www.npmjs.com/package/koa-csrf) or 18 | [koa-atomic-session](https://www.npmjs.com/package/koa-atomic-session) 19 | 20 | This module is a fork of https://github.com/pillarjs/csrf at f0d66c91ea4be6d30a03bd311ed9518951d9c3e4. 21 | 22 | ### Install 23 | 24 | ```sh 25 | $ npm i @fastify/csrf 26 | ``` 27 | 28 | ### TypeScript 29 | 30 | This module includes a [TypeScript](https://www.typescriptlang.org/) 31 | declaration file to enable auto-complete in compatible editors and type 32 | information for TypeScript projects. 33 | 34 | ## API 35 | 36 | 37 | 38 | ```js 39 | const Tokens = require('@fastify/csrf') 40 | ``` 41 | 42 | ### new Tokens([options]) 43 | 44 | Create a new token generation/verification instance. The `options` argument is 45 | optional and will just use all defaults if missing. 46 | 47 | #### Options 48 | 49 | Tokens accept these properties in the options object. 50 | 51 | ##### algorithm 52 | 53 | The hash-algorithm to generate the token. Defaults to `sha256`. 54 | 55 | ##### saltLength 56 | 57 | The length of the internal salt to use, in characters. Internally, the salt 58 | is a base 62 string. Defaults to `8` characters. 59 | 60 | ##### secretLength 61 | 62 | The length of the secret to generate, in bytes. Note that the secret is 63 | passed around base-64 encoded and that this length refers to the underlying 64 | bytes, not the length of the base-64 string. Defaults to `18` bytes. 65 | 66 | ##### userInfo 67 | 68 | Require user-specific information in `tokens.create()` and 69 | `tokens.verify()`. 70 | 71 | ##### hmacKey 72 | 73 | When set, the `hmacKey` is used to generate the cryptographic HMAC hash instead of the default hash function. 74 | 75 | ##### validity 76 | 77 | The maximum validity of the token to generate, in milliseconds. Note that the epoch is 78 | passed around base-36 encoded. Defaults to `0` milliseconds (disabled). 79 | 80 | #### tokens.create(secret[, userInfo]) 81 | 82 | Create a new CSRF token attached to the given `secret`. The `secret` is a 83 | string, typically generated from the `tokens.secret()` or `tokens.secretSync()` 84 | methods. This token is what you should add into HTML `
` blocks and 85 | expect the user's browser to provide back. 86 | 87 | 88 | 89 | ```js 90 | const secret = tokens.secretSync() 91 | const token = tokens.create(secret) 92 | ``` 93 | 94 | The `userInfo` parameter can be used to protect against cookie tossing 95 | attacks (and similar) when the application is deployed with untrusted 96 | subdomains. It will encode some user-specific information within the 97 | token. It is used only if `userInfo: true` is passed to the 98 | constructor. 99 | 100 | #### tokens.secret(callback) 101 | 102 | Asynchronously create a new `secret`, which is a string. The secret is to 103 | be kept on the server, typically stored in a server-side session for the 104 | user. The secret should be at least per user. 105 | 106 | 107 | 108 | ```js 109 | tokens.secret(function (err, secret) { 110 | if (err) throw err 111 | // Do something with the secret 112 | }) 113 | ``` 114 | 115 | #### tokens.secret() 116 | 117 | Asynchronously create a new `secret` and return a `Promise`. Please see 118 | `tokens.secret(callback)` documentation for full details. 119 | 120 | **Note**: To use promises in Node.js _prior to 0.12_, promises must be 121 | "polyfilled" using `global.Promise = require('bluebird')`. 122 | 123 | 124 | 125 | ```js 126 | tokens.secret().then(function (secret) { 127 | // Do something with the secret 128 | }) 129 | ``` 130 | 131 | #### tokens.secretSync() 132 | 133 | A synchronous version of `tokens.secret(callback)`. Please see 134 | `tokens.secret(callback)` documentation for full details. 135 | 136 | 137 | 138 | ```js 139 | const secret = tokens.secretSync() 140 | ``` 141 | 142 | #### tokens.verify(secret, token[, userInfo]) 143 | 144 | Check whether a CSRF token is valid for the given `secret`, returning 145 | a Boolean. 146 | 147 | 148 | 149 | ```js 150 | if (!tokens.verify(secret, token)) { 151 | throw new Error('invalid token!') 152 | } 153 | ``` 154 | 155 | The `userInfo` parameter is required if `userInfo: true` was configured 156 | during initialization. The user-specific information must match what was 157 | passed in `tokens.create()`. 158 | 159 | ## License 160 | 161 | Licensed under [MIT](./LICENSE). 162 | -------------------------------------------------------------------------------- /benchmark/create.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | const benchmark = require('benchmark') 8 | const benchmarks = require('beautify-benchmark') 9 | const Tokens = require('..') 10 | 11 | /** 12 | * Globals for benchmark.js 13 | */ 14 | 15 | global.tokens = new Tokens() 16 | global.secret = global.tokens.secretSync() 17 | 18 | const suite = new benchmark.Suite() 19 | 20 | suite.add({ 21 | name: 'create', 22 | minSamples: 100, 23 | fn: 'const token = tokens.create(secret)' 24 | }) 25 | 26 | suite.on('start', function onCycle () { 27 | process.stdout.write(' create\n\n') 28 | }) 29 | 30 | suite.on('cycle', function onCycle (event) { 31 | benchmarks.add(event.target) 32 | }) 33 | 34 | suite.on('complete', function onComplete () { 35 | benchmarks.log() 36 | }) 37 | 38 | suite.run({ async: false }) 39 | -------------------------------------------------------------------------------- /benchmark/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fs = require('node:fs') 4 | const path = require('node:path') 5 | const spawn = require('node:child_process').spawn 6 | 7 | const exe = process.argv[0] 8 | const cwd = process.cwd() 9 | 10 | for (const dep in process.versions) { 11 | console.log(' %s@%s', dep, process.versions[dep]) 12 | } 13 | 14 | console.log('') 15 | 16 | runScripts(fs.readdirSync(__dirname)) 17 | 18 | function runScripts (fileNames) { 19 | const fileName = fileNames.shift() 20 | 21 | if (!fileName) return 22 | if (!/\.js$/i.test(fileName)) return runScripts(fileNames) 23 | if (fileName.toLowerCase() === 'index.js') return runScripts(fileNames) 24 | 25 | const fullPath = path.join(__dirname, fileName) 26 | 27 | console.log('> %s %s', exe, path.relative(cwd, fullPath)) 28 | 29 | const proc = spawn(exe, [fullPath], { 30 | stdio: 'inherit' 31 | }) 32 | 33 | proc.on('exit', function () { 34 | runScripts(fileNames) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /benchmark/secret.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | const benchmark = require('benchmark') 8 | const benchmarks = require('beautify-benchmark') 9 | const Tokens = require('..') 10 | 11 | /** 12 | * Globals for benchmark.js 13 | */ 14 | 15 | global.tokens = new Tokens() 16 | 17 | const suite = new benchmark.Suite() 18 | 19 | suite.add({ 20 | name: 'secretSync', 21 | minSamples: 100, 22 | fn: 'const secret = tokens.secretSync()' 23 | }) 24 | 25 | suite.add({ 26 | name: 'secret - callback', 27 | minSamples: 100, 28 | defer: true, 29 | fn: 'tokens.secret(function (err, secret) { deferred.resolve() })' 30 | }) 31 | 32 | if (global.Promise) { 33 | suite.add({ 34 | name: 'secret - promise', 35 | minSamples: 100, 36 | defer: true, 37 | fn: 'tokens.secret().then(function (secret) { deferred.resolve() })' 38 | }) 39 | } 40 | 41 | suite.on('start', function onCycle () { 42 | process.stdout.write(' secret\n\n') 43 | }) 44 | 45 | suite.on('cycle', function onCycle (event) { 46 | benchmarks.add(event.target) 47 | }) 48 | 49 | suite.on('complete', function onComplete () { 50 | benchmarks.log() 51 | }) 52 | 53 | suite.run({ async: false, delay: 0 }) 54 | -------------------------------------------------------------------------------- /benchmark/verify.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | const benchmark = require('benchmark') 8 | const benchmarks = require('beautify-benchmark') 9 | const Tokens = require('..') 10 | 11 | /** 12 | * Globals for benchmark.js 13 | */ 14 | 15 | global.tokens = new Tokens() 16 | global.secret = global.tokens.secretSync() 17 | 18 | const suite = new benchmark.Suite() 19 | 20 | suite.add({ 21 | name: 'verify - valid', 22 | minSamples: 100, 23 | setup: 'token = tokens.create(secret)', 24 | fn: 'const valid = tokens.verify(secret, token)' 25 | }) 26 | 27 | suite.add({ 28 | name: 'verify - invalid', 29 | minSamples: 100, 30 | setup: 'token = tokens.create(secret).replace(/[a-zA-Z]/g, "=")', 31 | fn: 'const valid = tokens.verify(secret, token)' 32 | }) 33 | 34 | suite.on('start', function onCycle () { 35 | process.stdout.write(' verify\n\n') 36 | }) 37 | 38 | suite.on('cycle', function onCycle (event) { 39 | benchmarks.add(event.target) 40 | }) 41 | 42 | suite.on('complete', function onComplete () { 43 | benchmarks.log() 44 | }) 45 | 46 | suite.run({ async: false }) 47 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = require('neostandard')({ 4 | ignores: require('neostandard').resolveIgnoresFromGitignore(), 5 | ts: true 6 | }) 7 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /*! 4 | * @fastify/csrf 5 | * Copyright(c) 2014 Jonathan Ong 6 | * Copyright(c) 2015 Douglas Christopher Wilson 7 | * Copyright(c) 2021-2022 Fastify Collaborators 8 | * MIT Licensed 9 | */ 10 | 11 | const crypto = require('node:crypto') 12 | 13 | /** 14 | * Token generation/verification class. 15 | * 16 | * @param {object} [options] 17 | * @param {number} [options.saltLength=8] The string length of the salt 18 | * @param {number} [options.secretLength=18] The byte length of the secret key 19 | * @param {number} [options.validity=0] The maximum milliseconds of validity of this token. 0 disables the check. 20 | * @param {boolean} [options.userInfo=false] Require userInfo on create() and verify() 21 | * @public 22 | */ 23 | function Tokens (options) { 24 | if (!(this instanceof Tokens)) { 25 | return new Tokens(options) 26 | } 27 | 28 | const opts = options || {} 29 | 30 | const algorithm = opts.algorithm !== undefined 31 | ? opts.algorithm 32 | : 'sha256' 33 | 34 | try { 35 | crypto 36 | .createHash(algorithm) 37 | } catch { 38 | throw new TypeError('option algorithm must be a supported hash-algorithm') 39 | } 40 | 41 | const saltLength = opts.saltLength !== undefined 42 | ? opts.saltLength 43 | : 8 44 | 45 | if (typeof saltLength !== 'number' || !Number.isFinite(saltLength) || saltLength < 1) { 46 | throw new TypeError('option saltLength must be finite number > 1') 47 | } 48 | 49 | const secretLength = opts.secretLength !== undefined 50 | ? opts.secretLength 51 | : 18 52 | 53 | if (typeof secretLength !== 'number' || !Number.isFinite(secretLength) || secretLength < 1) { 54 | throw new TypeError('option secretLength must be finite number > 1') 55 | } 56 | 57 | const validity = opts.validity !== undefined 58 | ? opts.validity 59 | : 0 60 | 61 | if (typeof validity !== 'number' || !Number.isFinite(validity) || validity < 0) { 62 | throw new TypeError('option validity must be finite number > 0') 63 | } 64 | 65 | const userInfo = opts.userInfo !== undefined 66 | ? opts.userInfo 67 | : false 68 | 69 | if (typeof userInfo !== 'boolean') { 70 | throw new TypeError('option userInfo must be a boolean') 71 | } 72 | 73 | const hmacKey = opts.hmacKey 74 | 75 | if (hmacKey) { 76 | try { 77 | // validate if the hmacKey is a valid format 78 | hashingStrategy(algorithm, hmacKey) 79 | } catch { 80 | throw new TypeError('option hmacKey must be a supported hmac key') 81 | } 82 | } 83 | 84 | this.algorithm = algorithm 85 | this.saltLength = saltLength 86 | this.saltGenerator = saltGenerator(saltLength) 87 | this.secretLength = secretLength 88 | this.validity = validity 89 | this.userInfo = userInfo 90 | this.hmacKey = hmacKey 91 | } 92 | 93 | /** 94 | * Create a new CSRF token. 95 | * 96 | * @param {string} secret The secret for the token. 97 | * @param {?string} userInfo The userInfo for the token. 98 | * @returns {string} 99 | * @public 100 | */ 101 | 102 | Tokens.prototype.create = function create (secret, userInfo) { 103 | if (!secret || typeof secret !== 'string') { 104 | throw new TypeError('argument secret is required') 105 | } 106 | const date = this.validity > 0 ? Date.now() : null 107 | 108 | if (this.userInfo) { 109 | if (typeof userInfo !== 'string') { 110 | throw new TypeError('argument userInfo is required to be a string') 111 | } 112 | } 113 | 114 | return this._tokenize(secret, this.saltGenerator(), date, userInfo, this.algorithm) 115 | } 116 | 117 | /** 118 | * Create a new secret key. 119 | * 120 | * @param {function} [callback] 121 | * @returns {string} 122 | * @public 123 | */ 124 | 125 | Tokens.prototype.secret = Buffer.isEncoding('base64url') 126 | ? function secret (callback) { 127 | if (callback !== undefined && typeof callback !== 'function') { 128 | throw new TypeError('argument callback must be a function') 129 | } 130 | 131 | if (!callback && !global.Promise) { 132 | throw new TypeError('argument callback is required') 133 | } 134 | 135 | if (callback) { 136 | crypto.randomBytes(this.secretLength, (err, buf) => { 137 | err 138 | ? callback(err) 139 | : callback(null, buf.toString('base64url')) 140 | }) 141 | return 142 | } 143 | 144 | return new Promise((resolve, reject) => { 145 | crypto.randomBytes(this.secretLength, (err, buf) => { 146 | err 147 | ? reject(err) 148 | : resolve(buf.toString('base64url')) 149 | }) 150 | }) 151 | } 152 | : function secret (callback) { 153 | if (callback !== undefined && typeof callback !== 'function') { 154 | throw new TypeError('argument callback must be a function') 155 | } 156 | 157 | if (!callback && !global.Promise) { 158 | throw new TypeError('argument callback is required') 159 | } 160 | 161 | if (callback) { 162 | return crypto.randomBytes(this.secretLength, function (err, buf) { 163 | err 164 | ? callback(err) 165 | : callback(null, buf 166 | .toString('base64') 167 | .replace(PLUS_GLOBAL_REGEXP, '-') 168 | .replace(SLASH_GLOBAL_REGEXP, '_') 169 | .replace(EQUAL_GLOBAL_REGEXP, '')) 170 | }) 171 | } 172 | 173 | return new Promise((resolve, reject) => { 174 | crypto.randomBytes(this.secretLength, (err, buf) => { 175 | err 176 | ? reject(err) 177 | : resolve(buf 178 | .toString('base64') 179 | .replace(PLUS_GLOBAL_REGEXP, '-') 180 | .replace(SLASH_GLOBAL_REGEXP, '_') 181 | .replace(EQUAL_GLOBAL_REGEXP, '')) 182 | }) 183 | }) 184 | } 185 | 186 | /** 187 | * Create a new secret key synchronously. 188 | * @returns {string} 189 | * @public 190 | */ 191 | 192 | Tokens.prototype.secretSync = Buffer.isEncoding('base64url') 193 | ? function secretSync () { 194 | return crypto.randomBytes(this.secretLength) 195 | .toString('base64url') 196 | } 197 | : function secretSync () { 198 | return crypto.randomBytes(this.secretLength) 199 | .toString('base64') 200 | .replace(PLUS_GLOBAL_REGEXP, '-') 201 | .replace(SLASH_GLOBAL_REGEXP, '_') 202 | .replace(EQUAL_GLOBAL_REGEXP, '') 203 | } 204 | 205 | /** 206 | * Tokenize a secret, salt, date and userInfo. 207 | * @returns {string} 208 | * @private 209 | */ 210 | 211 | Tokens.prototype._tokenize = Buffer.isEncoding('base64url') 212 | ? function _tokenize (secret, salt, date, userInfo, algorithm) { 213 | let toHash = '' 214 | 215 | if (date !== null) { 216 | toHash += date.toString(36) + '-' 217 | } 218 | 219 | if (typeof userInfo === 'string') { 220 | toHash += 221 | hashingStrategy(algorithm, this.hmacKey) 222 | .update(userInfo) 223 | .digest('base64url') 224 | .replace(MINUS_GLOBAL_REGEXP, '_') + '-' 225 | } 226 | 227 | toHash += salt 228 | 229 | return toHash + '-' + 230 | hashingStrategy(algorithm, this.hmacKey) 231 | .update(toHash + '-' + secret, 'ascii') 232 | .digest('base64url') 233 | } 234 | : function _tokenize (secret, salt, date, userInfo, algorithm) { 235 | let toHash = '' 236 | 237 | if (date !== null) { 238 | toHash += date.toString(36) + '-' 239 | } 240 | 241 | if (typeof userInfo === 'string') { 242 | toHash += hashingStrategy(algorithm, this.hmacKey) 243 | .update(userInfo) 244 | .digest('base64') 245 | .replace(PLUS_SLASH_GLOBAL_REGEXP, '_') 246 | .replace(EQUAL_GLOBAL_REGEXP, '') + '-' 247 | } 248 | 249 | toHash += salt 250 | 251 | return toHash + '-' + hashingStrategy(algorithm, this.hmacKey) 252 | .update(toHash + '-' + secret, 'ascii') 253 | .digest('base64') 254 | .replace(PLUS_GLOBAL_REGEXP, '-') 255 | .replace(SLASH_GLOBAL_REGEXP, '_') 256 | .replace(EQUAL_GLOBAL_REGEXP, '') 257 | } 258 | 259 | /** 260 | * Verify if a given token is valid for a given secret. 261 | * 262 | * @param {string} secret The secret for the token. 263 | * @param {string} token The token itself.s 264 | * @param {?string} userInfo The userInfo for the token. 265 | * @returns {string} 266 | * @public 267 | */ 268 | 269 | Tokens.prototype.verify = function verify (secret, token, userInfo) { 270 | if (!secret || typeof secret !== 'string') { 271 | return false 272 | } 273 | 274 | if (!token || typeof token !== 'string') { 275 | return false 276 | } 277 | 278 | if (this.userInfo && (!userInfo || typeof userInfo !== 'string')) { 279 | return false 280 | } 281 | 282 | let curIdx = 0 283 | let nextIdx = token.indexOf('-') 284 | if (nextIdx === -1) { 285 | return false 286 | } 287 | 288 | let date = null 289 | 290 | if (this.validity > 0) { 291 | date = parseInt(token.slice(curIdx, nextIdx), 36) 292 | 293 | if (Date.now() - date > this.validity) { 294 | return false 295 | } 296 | 297 | curIdx = nextIdx + 1 298 | nextIdx = token.indexOf('-', curIdx) 299 | 300 | if (nextIdx === -1) { 301 | return false 302 | } 303 | } 304 | 305 | if (this.userInfo) { 306 | // we skip the userInfo part, this will be verified with the hashing 307 | curIdx = nextIdx + 1 308 | nextIdx = token.indexOf('-', curIdx) 309 | 310 | if (nextIdx === -1) { 311 | return false 312 | } 313 | } 314 | 315 | const salt = token.slice(curIdx, nextIdx) 316 | 317 | const actual = Buffer.from(token) 318 | const expected = Buffer.from(this._tokenize(secret, salt, date, userInfo, this.algorithm)) 319 | 320 | // to avoid the exposure if the provided value has the correct length, we call 321 | // timingSafeEqual with the actual value. The additional length check itself is 322 | // timing safe. 323 | return crypto.timingSafeEqual( 324 | actual.length === expected.length 325 | ? expected 326 | : actual, 327 | actual 328 | ) && actual.length === expected.length 329 | } 330 | 331 | const EQUAL_GLOBAL_REGEXP = /=/g 332 | const PLUS_GLOBAL_REGEXP = /\+/g 333 | const SLASH_GLOBAL_REGEXP = /\//g 334 | const MINUS_GLOBAL_REGEXP = /-/g 335 | const PLUS_SLASH_GLOBAL_REGEXP = /[+/]/g 336 | 337 | function saltGenerator (saltLength) { 338 | const fnBody = [] 339 | 340 | fnBody.push('const base62 = \'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\'.split(\'\');', 'return function () {') 341 | const salt = [] 342 | for (let i = 0; i < saltLength; ++i) salt.push('base62[(62 * Math.random()) | 0]') 343 | fnBody.push('return ' + salt.join('+'), '}') 344 | return new Function(fnBody.join(''))() // eslint-disable-line no-new-func 345 | } 346 | 347 | function hashingStrategy (algorithm, key) { 348 | if (key) { 349 | return crypto.createHmac(algorithm, key) 350 | } 351 | return crypto.createHash(algorithm) 352 | } 353 | 354 | module.exports = Tokens 355 | module.exports.default = Tokens 356 | module.exports.Tokens = Tokens 357 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fastify/csrf", 3 | "description": "primary logic behind csrf tokens", 4 | "version": "8.0.1", 5 | "author": "Jonathan Ong (http://jongleberry.com)", 6 | "main": "index.js", 7 | "type": "commonjs", 8 | "types": "types/index.d.ts", 9 | "contributors": [ 10 | "Douglas Christopher Wilson ", 11 | "Matteo Collina { 7 | t.plan(1) 8 | t.assert.throws(() => new Tokens({ saltLength: 'bogus' }), new TypeError('option saltLength must be finite number > 1')) 9 | }) 10 | 11 | test('Tokens.constructor: instantiating Tokens with a numeric string for saltLength should throw', t => { 12 | t.plan(1) 13 | t.assert.throws(() => new Tokens({ saltLength: '5' }), new TypeError('option saltLength must be finite number > 1')) 14 | }) 15 | 16 | test('Tokens.constructor: instantiating Tokens with NaN for saltLength should throw', t => { 17 | t.plan(1) 18 | t.assert.throws(() => new Tokens({ saltLength: NaN }), new TypeError('option saltLength must be finite number > 1')) 19 | }) 20 | 21 | test('Tokens.constructor: instantiating Tokens with Infinity for saltLength should throw', t => { 22 | t.plan(1) 23 | t.assert.throws(() => new Tokens({ saltLength: Infinity }), new TypeError('option saltLength must be finite number > 1')) 24 | }) 25 | 26 | test('Tokens.constructor: instantiating Tokens with a string for secretLength should throw', t => { 27 | t.plan(1) 28 | t.assert.throws(() => new Tokens({ secretLength: 'bogus' }), new TypeError('option secretLength must be finite number > 1')) 29 | }) 30 | 31 | test('Tokens.constructor: instantiating Tokens with a numeric string for secretLength should throw', t => { 32 | t.plan(1) 33 | t.assert.throws(() => new Tokens({ secretLength: '5' }), new TypeError('option secretLength must be finite number > 1')) 34 | }) 35 | 36 | test('Tokens.constructor: instantiating Tokens with NaN for secretLength should throw', t => { 37 | t.plan(1) 38 | t.assert.throws(() => new Tokens({ secretLength: NaN }), new TypeError('option secretLength must be finite number > 1')) 39 | }) 40 | 41 | test('Tokens.constructor: instantiating Tokens with Infinity for secretLength should throw', t => { 42 | t.plan(1) 43 | t.assert.throws(() => new Tokens({ secretLength: Infinity }), new TypeError('option secretLength must be finite number > 1')) 44 | }) 45 | 46 | test('Tokens.constructor: instantiating Tokens with a string for validity should throw', t => { 47 | t.plan(1) 48 | t.assert.throws(() => new Tokens({ validity: 'bogus' }), new TypeError('option validity must be finite number > 0')) 49 | }) 50 | 51 | test('Tokens.constructor: instantiating Tokens with a numeric string for validity should throw', t => { 52 | t.plan(1) 53 | t.assert.throws(() => new Tokens({ validity: '5' }), new TypeError('option validity must be finite number > 0')) 54 | }) 55 | 56 | test('Tokens.constructor: instantiating Tokens with NaN for validity should throw', t => { 57 | t.plan(1) 58 | t.assert.throws(() => new Tokens({ validity: NaN }), new TypeError('option validity must be finite number > 0')) 59 | }) 60 | 61 | test('Tokens.constructor: instantiating Tokens with Infinity for validity should throw', t => { 62 | t.plan(1) 63 | t.assert.throws(() => new Tokens({ validity: Infinity }), new TypeError('option validity must be finite number > 0')) 64 | }) 65 | 66 | test('Tokens.constructor: instantiating Tokens with a non-boolean for userInfo should throw', t => { 67 | t.plan(1) 68 | t.assert.throws(() => new Tokens({ userInfo: 'bogus' }), new TypeError('option userInfo must be a boolean')) 69 | }) 70 | 71 | test('Tokens.constructor: instantiating Tokens without new creates still the Tokens-Instance', t => { 72 | t.plan(1) 73 | t.assert.ok(Tokens() instanceof Tokens, true) 74 | }) 75 | 76 | test('Tokens.constructor: instantiating Tokens with "invalid" for algorithm should throw', t => { 77 | t.plan(1) 78 | t.assert.throws(() => new Tokens({ algorithm: 'invalid' }), new TypeError('option algorithm must be a supported hash-algorithm')) 79 | }) 80 | -------------------------------------------------------------------------------- /test/create.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const Tokens = require('..') 5 | 6 | test('Tokens.create: should require secret', t => { 7 | t.plan(1) 8 | 9 | t.assert.throws(() => new Tokens().create(), new TypeError('argument secret is required')) 10 | }) 11 | 12 | test('Tokens.create: should reject non-string secret', t => { 13 | t.plan(1) 14 | 15 | t.assert.throws(() => new Tokens().create(42), new TypeError('argument secret is required')) 16 | }) 17 | 18 | test('Tokens.create: should reject empty string secret', t => { 19 | t.plan(1) 20 | 21 | t.assert.throws(() => new Tokens().create(''), new TypeError('argument secret is required')) 22 | }) 23 | 24 | test('Tokens.create: should create a token', t => { 25 | t.plan(1) 26 | 27 | const secret = new Tokens().secretSync() 28 | t.assert.ok(typeof new Tokens().create(secret) === 'string') 29 | }) 30 | 31 | test('Tokens.create: should always be the same length', t => { 32 | t.plan(1001) 33 | 34 | const secret = new Tokens().secretSync() 35 | const tokenLength = new Tokens().create(secret).length 36 | 37 | t.assert.deepStrictEqual(tokenLength, 52) 38 | 39 | for (let i = 0; i < 1000; i++) { 40 | t.assert.deepStrictEqual(new Tokens().create(secret).length, tokenLength) 41 | } 42 | }) 43 | 44 | test('Tokens.create: should not contain /, +, or =', t => { 45 | t.plan(4000) 46 | 47 | for (let i = 0; i < 1000; i++) { 48 | const token = new Tokens().create(new Tokens().secretSync()) 49 | t.assert.ok(!token.includes('/')) 50 | t.assert.ok(!token.includes('+')) 51 | t.assert.ok(!token.includes('=')) 52 | t.assert.ok(token.split('-').length - 1 >= 1, token) 53 | } 54 | }) 55 | 56 | test('Tokens.create: with userInfo should not contain /, +, or =', t => { 57 | t.plan(4000) 58 | 59 | for (let i = 0; i < 1000; i++) { 60 | const token = new Tokens({ userInfo: true }).create(new Tokens().secretSync(), 'foo') 61 | t.assert.ok(!token.includes('/')) 62 | t.assert.ok(!token.includes('+')) 63 | t.assert.ok(!token.includes('=')) 64 | t.assert.ok(token.split('-').length - 1 >= 2, token) 65 | } 66 | }) 67 | 68 | test('Tokens.create: with validity should not contain /, +, or =', t => { 69 | t.plan(4000) 70 | 71 | for (let i = 0; i < 1000; i++) { 72 | const token = new Tokens({ validity: 3600 }).create(new Tokens().secretSync()) 73 | t.assert.ok(!token.includes('/')) 74 | t.assert.ok(!token.includes('+')) 75 | t.assert.ok(!token.includes('=')) 76 | t.assert.ok(token.split('-').length - 1 >= 2, token) 77 | } 78 | }) 79 | 80 | test('Tokens.create: with validity and userInfo should not contain /, +, or =', t => { 81 | t.plan(4000) 82 | 83 | for (let i = 0; i < 1000; i++) { 84 | const token = new Tokens({ validity: 3600, userInfo: true }).create(new Tokens().secretSync(), 'foo') 85 | t.assert.ok(!token.includes('/')) 86 | t.assert.ok(!token.includes('+')) 87 | t.assert.ok(!token.includes('=')) 88 | t.assert.ok(token.split('-').length - 1 >= 3, token) 89 | } 90 | }) 91 | 92 | test('Tokens.create: should not collide', t => { 93 | t.plan(1000) 94 | 95 | const tokenSet = new Set() 96 | 97 | for (let i = 0; i < 1000; i++) { 98 | const token = new Tokens().create(new Tokens().secretSync()) 99 | t.assert.ok(!tokenSet.has(token)) 100 | tokenSet.add(token) 101 | } 102 | }) 103 | 104 | test('.create(): should reject undefined string userInfo (create)', t => { 105 | t.plan(1) 106 | 107 | const secret = new Tokens().secretSync() 108 | 109 | t.assert.throws(() => new Tokens({ userInfo: true }).create(secret), new TypeError('argument userInfo is required to be a string')) 110 | }) 111 | -------------------------------------------------------------------------------- /test/hmac.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const Tokens = require('..') 5 | 6 | require('./polyfill') 7 | 8 | test('Tokens.constructor: instantiating Tokens with a non string hmacKey should throw', t => { 9 | t.plan(1) 10 | t.assert.throws(() => new Tokens({ hmacKey: 123 }), new TypeError('option hmacKey must be a supported hmac key')) 11 | }) 12 | 13 | test('Tokens.secret: should create a secret', t => { 14 | t.plan(3) 15 | 16 | const { promise, resolve } = Promise.withResolvers() 17 | 18 | new Tokens({ hmacKey: 'foo' }).secret(function (err, secret) { 19 | t.assert.ifError(err) 20 | t.assert.ok(typeof secret === 'string') 21 | t.assert.deepStrictEqual(secret.length, 24) 22 | 23 | resolve() 24 | }) 25 | 26 | return promise 27 | }) 28 | 29 | test('Tokens.verify: should return `true` with valid tokens', t => { 30 | t.plan(1) 31 | 32 | const secret = new Tokens({ hmacKey: 'foo' }).secretSync() 33 | const token = new Tokens({ hmacKey: 'foo' }).create(secret) 34 | 35 | t.assert.deepStrictEqual(new Tokens({ hmacKey: 'foo' }).verify(secret, token), true) 36 | }) 37 | 38 | test('Tokens.verify: should return `false` with invalid secret', t => { 39 | t.plan(5) 40 | 41 | const secret = new Tokens({ hmacKey: 'foo' }).secretSync() 42 | const token = new Tokens({ hmacKey: 'foo' }).create(secret) 43 | 44 | t.assert.deepStrictEqual(new Tokens({ hmacKey: 'foo' }).verify(new Tokens().secretSync(), token), false) 45 | t.assert.deepStrictEqual(new Tokens({ hmacKey: 'foo' }).verify('invalid', token), false) 46 | t.assert.deepStrictEqual(new Tokens({ hmacKey: 'foo' }).verify(), false) 47 | t.assert.deepStrictEqual(new Tokens({ hmacKey: 'foo' }).verify([]), false) 48 | t.assert.deepStrictEqual(new Tokens({ hmacKey: 'foo' }).verify('invalid'), false) 49 | }) 50 | 51 | test('Tokens.verify: should return `false` with invalid tokens', t => { 52 | t.plan(4) 53 | 54 | const secret = new Tokens({ hmacKey: 'foo' }).secretSync() 55 | const token = new Tokens({ hmacKey: 'foo' }).create(secret) 56 | 57 | t.assert.deepStrictEqual(new Tokens({ hmacKey: 'foo' }).verify('invalid', token), false) 58 | t.assert.deepStrictEqual(new Tokens({ hmacKey: 'foo' }).verify(secret, undefined), false) 59 | t.assert.deepStrictEqual(new Tokens({ hmacKey: 'foo' }).verify(secret, []), false) 60 | t.assert.deepStrictEqual(new Tokens({ hmacKey: 'foo' }).verify(secret, 'hi'), false) 61 | }) 62 | 63 | test('Tokens.verify: should return `false` with different hmac key', t => { 64 | t.plan(2) 65 | 66 | const secret = new Tokens({ hmacKey: 'foo' }).secretSync() 67 | const token = new Tokens({ hmacKey: 'foo' }).create(secret) 68 | 69 | t.assert.deepStrictEqual(new Tokens({ hmacKey: 'foo' }).verify(secret, token), true) 70 | t.assert.deepStrictEqual(new Tokens({ hmacKey: 'bar' }).verify(secret, token), false) 71 | }) 72 | -------------------------------------------------------------------------------- /test/integration.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const Tokens = require('..') 5 | 6 | test('.create() and verify() with validity: should return `true` with valid tokens', t => { 7 | t.plan(1) 8 | 9 | const secret = new Tokens().secretSync() 10 | const token = new Tokens({ validity: 3600 }).create(secret) 11 | 12 | t.assert.deepStrictEqual(new Tokens({ validity: 3600 }).verify(secret, token), true) 13 | }) 14 | 15 | test('.create() and verify() with validity: should return `false` if current time is outside the validity interval', t => { 16 | t.plan(1) 17 | 18 | const fn = Date.now 19 | const now = Date.now() 20 | t.after(() => { Date.now = fn }) 21 | 22 | const secret = new Tokens().secretSync() 23 | Date.now = function () { return now } 24 | const token = new Tokens({ validity: 3600 }).create(secret) 25 | 26 | Date.now = function () { return now + 3601 } 27 | t.assert.deepStrictEqual(new Tokens({ validity: 3600 }).verify(secret, token), false) 28 | }) 29 | 30 | test('.create() and verify() with validity: should return `true` if current time is at the max of the validity interval', t => { 31 | t.plan(1) 32 | 33 | const fn = Date.now 34 | const now = Date.now() 35 | t.after(() => { Date.now = fn }) 36 | 37 | Date.now = function () { return now } 38 | const secret = new Tokens().secretSync() 39 | Date.now = function () { return now + 3600 } 40 | const token = new Tokens({ validity: 3600 }).create(secret) 41 | 42 | t.assert.deepStrictEqual(new Tokens({ validity: 3600 }).verify(secret, token), true, { secret, token, now }) 43 | }) 44 | 45 | test('.create() and verify() with validity: should return `false` for tokens with no date', t => { 46 | t.plan(1) 47 | 48 | const secret = new Tokens().secretSync() 49 | const token = new Tokens().create(secret) 50 | 51 | t.assert.deepStrictEqual(new Tokens({ validity: 3600 }).verify(secret, token), false) 52 | }) 53 | 54 | test('.create() and verify() with user info: should return `true` with valid tokens', t => { 55 | t.plan(1) 56 | 57 | const secret = new Tokens().secretSync() 58 | const token = new Tokens({ userInfo: true }).create(secret, 'foobar') 59 | 60 | t.assert.deepStrictEqual(new Tokens({ userInfo: true }).verify(secret, token, 'foobar'), true) 61 | }) 62 | 63 | test('.create() and verify() with user info: should return `false` if userInfo does not match', t => { 64 | t.plan(1) 65 | 66 | const secret = new Tokens().secretSync() 67 | const token = new Tokens({ userInfo: true }).create(secret, 'foo') 68 | 69 | t.assert.deepStrictEqual(new Tokens({ userInfo: true }).verify(secret, token, 'foobar'), false) 70 | }) 71 | 72 | test('.create() and verify() with user info: should return `false` if userInfo is not set in verify', t => { 73 | t.plan(1) 74 | 75 | const secret = new Tokens().secretSync() 76 | const token = new Tokens({ userInfo: true }).create(secret, 'foo') 77 | 78 | t.assert.deepStrictEqual(new Tokens({ userInfo: true }).verify(secret, token), false) 79 | }) 80 | 81 | test('.create() and verify() with user info: should return `false` if userInfo is not a string in verify', t => { 82 | t.plan(1) 83 | 84 | const secret = new Tokens().secretSync() 85 | const token = new Tokens({ userInfo: true }).create(secret, 'foo') 86 | 87 | t.assert.deepStrictEqual(new Tokens({ userInfo: true }).verify(secret, token, {}), false) 88 | }) 89 | 90 | test('.create() and verify() with user info: should return `false` for tokens with no userInfo', t => { 91 | t.plan(1) 92 | 93 | const secret = new Tokens().secretSync() 94 | const token = new Tokens({ userInfo: false }).create(secret) 95 | 96 | t.assert.deepStrictEqual(new Tokens({ userInfo: true }).verify(secret, token, 'foo'), false) 97 | }) 98 | 99 | test('.create() and verify() with validity: should return `false` for edge case', t => { 100 | t.plan(1) 101 | 102 | const secret = 'EA3SsAG5xtf42T6JJ7AbG7dj' 103 | const token = 'Sp5S2HvW-VV0ZStW3LNhD9ELehQVwzTBK7Is' 104 | 105 | t.assert.deepStrictEqual(new Tokens({ validity: 3600 }).verify(secret, token), false) 106 | }) 107 | 108 | test('.create() and verify() with user info: should return false for edge case', t => { 109 | t.plan(1) 110 | 111 | const secret = 'VotAvu5relpVeoVGif78oCjf' 112 | const token = '5Zmd5CxB-5jfruZ8pbOXRrtSCWFZhCaTFdMk' 113 | 114 | t.assert.deepStrictEqual(new Tokens({ userInfo: true }).verify(secret, token, 'foo'), false) 115 | }) 116 | 117 | test('.create() and verify() with user info: should return false for edge case', t => { 118 | t.plan(1) 119 | 120 | const secret = '5ZbtVlGipiWKCS028ySrZJjk' 121 | const token = 'PFPrCHKG-L_2yksIX8xmWpcnV-QJGmsndHC8' 122 | 123 | t.assert.deepStrictEqual(new Tokens({ userInfo: true }).verify(secret, token, 'foo'), false) 124 | }) 125 | 126 | test('.create() and verify() with validity: should use by default sha256 as algorithm', t => { 127 | t.plan(2) 128 | 129 | const secret = new Tokens().secretSync() 130 | const token = new Tokens({ userInfo: true }).create(secret, 'foobar') 131 | 132 | t.assert.deepStrictEqual(token.length, 96) 133 | t.assert.deepStrictEqual(new Tokens({ userInfo: true, algorithm: 'sha256' }).verify(secret, token, 'foobar'), true) 134 | }) 135 | 136 | test('.create() and verify() with validity: should be able to set sha1 as algorithm', t => { 137 | t.plan(2) 138 | 139 | const secret = new Tokens().secretSync() 140 | const token = new Tokens({ userInfo: true, algorithm: 'sha1' }).create(secret, 'foobar') 141 | 142 | t.assert.deepStrictEqual(token.length, 64) 143 | t.assert.deepStrictEqual(new Tokens({ userInfo: true, algorithm: 'sha1' }).verify(secret, token, 'foobar'), true) 144 | }) 145 | -------------------------------------------------------------------------------- /test/polyfill.js: -------------------------------------------------------------------------------- 1 | if (typeof Promise.withResolvers === 'undefined') { 2 | Promise.withResolvers = function () { 3 | let promiseResolve, promiseReject 4 | const promise = new Promise((resolve, reject) => { 5 | promiseResolve = resolve 6 | promiseReject = reject 7 | }) 8 | return { promise, resolve: promiseResolve, reject: promiseReject } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/secret.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const Tokens = require('..') 5 | 6 | require('./polyfill') 7 | 8 | test('Tokens.secret: should reject bad callback', t => { 9 | t.plan(1) 10 | 11 | t.assert.throws(() => new Tokens().secret(42), new TypeError('argument callback must be a function')) 12 | }) 13 | 14 | test('Tokens.secret: should create a secret', t => { 15 | t.plan(3) 16 | 17 | const { promise, resolve } = Promise.withResolvers() 18 | 19 | new Tokens().secret(function (err, secret) { 20 | t.assert.ifError(err) 21 | t.assert.ok(typeof secret === 'string') 22 | t.assert.deepStrictEqual(secret.length, 24) 23 | 24 | resolve() 25 | }) 26 | 27 | return promise 28 | }) 29 | 30 | test('Tokens.secret: with global Promise', async t => { 31 | t.plan(2) 32 | 33 | const secret = await new Tokens().secret() 34 | 35 | t.assert.ok(typeof secret === 'string') 36 | t.assert.deepStrictEqual(secret.length, 24) 37 | }) 38 | 39 | test('Tokens.secret: without global Promise', t => { 40 | t.plan(1) 41 | 42 | const promise = Promise 43 | global.Promise = undefined 44 | t.after(() => { global.Promise = promise }) 45 | 46 | t.assert.throws(() => new Tokens().secret(), new TypeError('argument callback is required')) 47 | }) 48 | 49 | test('Tokens.secret: without global Promise should reject bad callback', t => { 50 | t.plan(1) 51 | 52 | const promise = Promise 53 | global.Promise = undefined 54 | t.after(() => { global.Promise = promise }) 55 | t.assert.throws(() => new Tokens().secret(42), new TypeError('argument callback must be a function')) 56 | }) 57 | 58 | test('Tokens.secret: should not contain /, +, or =, Promise', async t => { 59 | t.plan(3000) 60 | 61 | for (let i = 0; i < 1000; i++) { 62 | const secret = await new Tokens().secret() 63 | t.assert.ok(!secret.includes('/')) 64 | t.assert.ok(!secret.includes('+')) 65 | t.assert.ok(!secret.includes('=')) 66 | } 67 | }) 68 | 69 | test('Tokens.secret: should not contain /, +, or =, callback', async t => { 70 | t.plan(4000) 71 | 72 | for (let i = 0; i < 1000; i++) { 73 | const { promise, resolve } = Promise.withResolvers() 74 | 75 | new Tokens().secret(function (err, secret) { 76 | t.assert.ifError(err) 77 | t.assert.ok(!secret.includes('/')) 78 | t.assert.ok(!secret.includes('+')) 79 | t.assert.ok(!secret.includes('=')) 80 | 81 | resolve() 82 | }) 83 | 84 | await promise 85 | } 86 | }) 87 | 88 | const mockRandomBytes = (t) => { 89 | const crypto = require('node:crypto') 90 | let oldCrypto 91 | t.before(() => { 92 | oldCrypto = crypto.randomBytes.bind(crypto) 93 | crypto.randomBytes = (_size, cb) => { 94 | cb(new Error('oh no')) 95 | } 96 | }) 97 | t.after(() => { 98 | crypto.randomBytes = oldCrypto 99 | }) 100 | } 101 | 102 | test('Tokens.secret: should handle error, Promise', async t => { 103 | t.plan(2) 104 | 105 | mockRandomBytes(t) 106 | 107 | try { 108 | await new Tokens().secret() 109 | } catch (err) { 110 | t.assert.ok(err instanceof Error) 111 | t.assert.ok(err.message === 'oh no') 112 | } 113 | }) 114 | 115 | test('Tokens.secret: should handle error, callback', t => { 116 | t.plan(2) 117 | 118 | mockRandomBytes(t) 119 | 120 | const { promise, resolve } = Promise.withResolvers() 121 | 122 | new Tokens().secret(function (err, _secret) { 123 | t.assert.ok(err instanceof Error) 124 | t.assert.ok(err.message === 'oh no') 125 | 126 | resolve() 127 | }) 128 | 129 | return promise 130 | }) 131 | -------------------------------------------------------------------------------- /test/secretSync.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const Tokens = require('..') 5 | 6 | test('Tokens.secretSync: should generate secret with specified byte length', t => { 7 | t.plan(2) 8 | 9 | // 3 bytes = 4 base-64 characters 10 | // 4 bytes = 6 base-64 characters 11 | t.assert.deepStrictEqual(new Tokens({ secretLength: 3 }).secretSync().length, 4) 12 | t.assert.deepStrictEqual(new Tokens({ secretLength: 4 }).secretSync().length, 6) 13 | }) 14 | 15 | test('Tokens.secretSync: should create a secret', t => { 16 | t.plan(2) 17 | 18 | const secret = new Tokens().secretSync() 19 | t.assert.ok(typeof secret === 'string') 20 | t.assert.deepStrictEqual(secret.length, 24) 21 | }) 22 | 23 | test('Tokens.secretSync: should not contain /, +, or = when using base64', t => { 24 | t.plan(3000) 25 | 26 | for (let i = 0; i < 1000; i++) { 27 | t.assert.ok(!new Tokens().secretSync().includes('/')) 28 | t.assert.ok(!new Tokens().secretSync().includes('+')) 29 | t.assert.ok(!new Tokens().secretSync().includes('=')) 30 | } 31 | }) 32 | -------------------------------------------------------------------------------- /test/verify.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const Tokens = require('..') 5 | 6 | test('Tokens.verify: should return `true` with valid tokens', t => { 7 | t.plan(1) 8 | 9 | const secret = new Tokens().secretSync() 10 | const token = new Tokens().create(secret) 11 | 12 | t.assert.deepStrictEqual(new Tokens().verify(secret, token), true) 13 | }) 14 | 15 | test('Tokens.verify: should return `false` with invalid secret', t => { 16 | t.plan(5) 17 | 18 | const secret = new Tokens().secretSync() 19 | const token = new Tokens().create(secret) 20 | 21 | t.assert.deepStrictEqual(new Tokens().verify(new Tokens().secretSync(), token), false) 22 | t.assert.deepStrictEqual(new Tokens().verify('invalid', token), false) 23 | t.assert.deepStrictEqual(new Tokens().verify(), false) 24 | t.assert.deepStrictEqual(new Tokens().verify([]), false) 25 | t.assert.deepStrictEqual(new Tokens().verify('invalid'), false) 26 | }) 27 | 28 | test('Tokens.verify: should return `false` with invalid tokens', t => { 29 | t.plan(4) 30 | 31 | const secret = new Tokens().secretSync() 32 | const token = new Tokens().create(secret) 33 | 34 | t.assert.deepStrictEqual(new Tokens().verify('invalid', token), false) 35 | t.assert.deepStrictEqual(new Tokens().verify(secret, undefined), false) 36 | t.assert.deepStrictEqual(new Tokens().verify(secret, []), false) 37 | t.assert.deepStrictEqual(new Tokens().verify(secret, 'hi'), false) 38 | }) 39 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | interface TokensConstructor { 2 | (options?: Tokens.Options & { userInfo: true }): Tokens.TokensUserinfo; 3 | (options?: Tokens.Options): Tokens.TokensSimple; 4 | 5 | new(options?: Tokens.Options & { userInfo: true }): Tokens.TokensUserinfo; 6 | new(options?: Tokens.Options): Tokens.TokensSimple; 7 | } 8 | 9 | declare namespace Tokens { 10 | interface TokensBase { 11 | /** 12 | * Create a new secret key. 13 | */ 14 | secret(callback: SecretCallback): void; 15 | secret(): Promise; 16 | 17 | /** 18 | * Create a new secret key synchronously. 19 | */ 20 | secretSync(): string; 21 | } 22 | 23 | export interface TokensSimple extends TokensBase { 24 | /** 25 | * Create a new CSRF token. 26 | */ 27 | create(secret: string): string; 28 | 29 | /** 30 | * Verify if a given token is valid for a given secret. 31 | */ 32 | verify(secret: string, token: string): boolean; 33 | } 34 | 35 | export interface TokensUserinfo extends TokensBase { 36 | /** 37 | * Create a new CSRF token. 38 | */ 39 | create(secret: string, userInfo: string): string; 40 | 41 | /** 42 | * Verify if a given token is valid for a given secret. 43 | */ 44 | verify(secret: string, token: string, userInfo: string): boolean; 45 | } 46 | 47 | export type SecretCallback = (err: Error | null, secret: string) => void 48 | 49 | export interface Options { 50 | /** 51 | * The algorithm used to generate the token 52 | * @default sha256 53 | */ 54 | algorithm?: string; 55 | 56 | /** 57 | * The string length of the salt 58 | * 59 | * @default 8 60 | */ 61 | saltLength?: number; 62 | /** 63 | * The byte length of the secret key 64 | * 65 | * @default 18 66 | */ 67 | secretLength?: number; 68 | 69 | /** 70 | * The maximum milliseconds of validity of this token. 0 disables the check. 71 | * 72 | * @default 0 73 | */ 74 | validity?: number; 75 | 76 | /** 77 | * Require userInfo on create() and verify() 78 | * 79 | * @default false 80 | */ 81 | userInfo?: boolean; 82 | 83 | /** 84 | * The HMAC key used to generate the cryptographic HMAC hash 85 | * 86 | */ 87 | hmacKey?: string | ArrayBuffer | Buffer | TypedArray | DataView | CryptoKey; 88 | } 89 | 90 | export const Tokens: TokensConstructor 91 | export { Tokens as default } 92 | } 93 | 94 | type TypedArray = 95 | | Int8Array 96 | | Uint8Array 97 | | Uint8ClampedArray 98 | | Int16Array 99 | | Uint16Array 100 | | Int32Array 101 | | Uint32Array 102 | | Float32Array 103 | | Float64Array 104 | 105 | declare function Tokens (...params: Parameters): ReturnType 106 | export = Tokens 107 | -------------------------------------------------------------------------------- /types/index.test-d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-new -- Testing constructor types, so no need to assign */ 2 | import { expectError, expectType } from 'tsd' 3 | import { Tokens } from '..' 4 | 5 | Tokens() 6 | new Tokens() 7 | Tokens({}) 8 | new Tokens({}) 9 | Tokens({ algorithm: 'sha1' }) 10 | Tokens({ algorithm: 'sha256' }) 11 | Tokens({ saltLength: 10 }) 12 | Tokens({ secretLength: 10 }) 13 | Tokens({ userInfo: true }) 14 | Tokens({ validity: 10000 }) 15 | Tokens({ hmacKey: 'foo' }) 16 | new Tokens({ saltLength: 10 }) 17 | new Tokens({ secretLength: 10 }) 18 | new Tokens({ userInfo: true }) 19 | new Tokens({ validity: 10000 }) 20 | 21 | expectError(Tokens('secret')) 22 | expectError(new Tokens('secret')) 23 | 24 | expectError(new Tokens({}).create('secret', 'userInfo')) 25 | expectError(new Tokens({ userInfo: false }).create('secret', 'userInfo')) 26 | expectError(new Tokens({ userInfo: true }).create('secret')) 27 | 28 | expectError(new Tokens({}).verify('secret', 'token', 'userinfo')) 29 | expectError(new Tokens({ userInfo: false }).verify('secret', 'token', 'userInfo')) 30 | expectError(new Tokens({ userInfo: true }).verify('secret', 'token')) 31 | 32 | expectError(new Tokens({ hmacKey: 123 })) 33 | 34 | expectType>(Tokens().secret()) 35 | expectType>(new Tokens().secret()) 36 | 37 | expectType(Tokens().secret((err, secret) => { 38 | expectType(err) 39 | expectType(secret) 40 | })) 41 | expectType(new Tokens().secret((err, secret) => { 42 | expectType(err) 43 | expectType(secret) 44 | })) 45 | 46 | expectType(Tokens().secretSync()) 47 | expectType(new Tokens().secretSync()) 48 | --------------------------------------------------------------------------------