├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── index.d.ts ├── index.js ├── index.test-d.ts ├── package.json ├── test.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | 9 | [*.md] 10 | trim_trailing_whitespace = false 11 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set 2 | * text=auto 3 | 4 | # Require Unix line endings 5 | * text eol=lf 6 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - master 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node-version: [ 10.x, 12.x, 14.x ] 16 | steps: 17 | - uses: actions/checkout@master 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v2.1.5 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - name: Install Dependencies 23 | run: | 24 | npm install --ignore-scripts 25 | - name: Run Tests 26 | run: | 27 | npm run test 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | out 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and not Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | 108 | # Stores VSCode versions used for testing VSCode extensions 109 | .vscode-test 110 | 111 | # yarn v2 112 | .yarn/cache 113 | .yarn/unplugged 114 | .yarn/build-state.yml 115 | .yarn/install-state.gz 116 | .pnp.* 117 | 118 | # Vim swap files 119 | *.swp 120 | 121 | # macOS files 122 | .DS_Store 123 | 124 | # lock files 125 | package-lock.json 126 | yarn.lock 127 | 128 | # editor files 129 | .vscode 130 | .idea 131 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Axel SHAÏTA 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 | # fastify-api-key 2 | 3 | ![CI](https://github.com/arkerone/fastify-api-key/workflows/CI/badge.svg) 4 | [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat)](https://standardjs.com/) 5 | 6 | Fastify plugin to authenticate HTTP requests based on api key and signature. 7 | 8 | ## Installation 9 | 10 | ``` 11 | $ npm install --save fastify-api-key 12 | ``` 13 | 14 | ## Usage 15 | 16 | This middleware authenticates callers using an api key and the signature of the request. 17 | 18 | ### Example 19 | 20 | This plugin decorates the fastify request with a `apiKeyVerify` function. You can use a global `onRequest` hook to 21 | define the verification process : 22 | 23 | ```javascript 24 | const fastify = require('fastify')() 25 | const { Unauthorized } = require('http-errors') 26 | 27 | const apiKeys = new Map() 28 | apiKeys.set('123456789', 'secret1') 29 | apiKeys.set('987654321', 'secret2') 30 | 31 | fastify.register(require('fastify-api-key'), { 32 | getSecret: (request, keyId, callback) => { 33 | const secret = apiKeys.get(keyId) 34 | if (!secret) { 35 | return callback(Unauthorized('Unknown client')) 36 | } 37 | callback(null, secret) 38 | }, 39 | }) 40 | 41 | fastify.addHook('onRequest', async (request, reply) => { 42 | try { 43 | await request.apiKeyVerify() 44 | } catch (err) { 45 | reply.send(err) 46 | } 47 | }) 48 | 49 | fastify.listen(3000, (err) => { 50 | if (err) throw err 51 | }) 52 | ``` 53 | 54 | It is possible (and recommanded) to wrap your authentication logic into a plugin : 55 | 56 | ```javascript 57 | const fp = require('fastify-plugin') 58 | 59 | module.exports = fp(async function (fastify, opts) { 60 | fastify.register(require('fastify-api-key'), { 61 | getSecret: (request, keyId, callback) => { 62 | callback(null, 'secret') 63 | }, 64 | }) 65 | fastify.decorate('authenticate', async function (request, reply) { 66 | try { 67 | await request.apiKeyVerify() 68 | } catch (err) { 69 | reply.send(err) 70 | } 71 | }) 72 | }) 73 | ``` 74 | 75 | Then use the `preValidation` of a route to protect it : 76 | 77 | ```javascript 78 | module.exports = async function (fastify, opts) { 79 | fastify.get( 80 | '/', { 81 | preValidation: [fastify.authenticate], 82 | }, async function (request, reply) { 83 | reply.send({ hello: 'world' }) 84 | }) 85 | } 86 | ``` 87 | 88 | ## API 89 | 90 | ### fastifyApiKey(options) 91 | 92 | Create an api key based authentication plugin using the given `options` : 93 | 94 | | Name | Type | Default | Description | 95 | | :---------------: | :-------------: | :-------------: | :---------------------------------------------- | 96 | | `getSecret` | `Function` | `-` | Invoked to retrieve the secret from the `keyId` component of the signature | 97 | | `requestLifetime` | `Number | null` | `300` | The lifetime of a request in seconds | 98 | 99 | #### options.getSecret (REQUIRED) 100 | 101 | A function with signature `function(request, keyId, callback)` to be invoked to retrieve the secret from the `keyId` 102 | component of the signature. 103 | 104 | - `request` (`FastifyRequest`) - The current fastify request. 105 | - `keyId` (`String`) - The api key used to retrieve the secret. 106 | - `callback` (`Function`) - A function with signature `function(err, secret)` to be invoked when the secret is 107 | retrieved. 108 | - `err` (`Error`) - The error that occurred. 109 | - `secret` (`String`) - The secret to use to verify the signature. 110 | 111 | ```javascript 112 | const fastify = require('fastify')() 113 | const { Unauthorized } = require('http-errors') 114 | 115 | const apiKeys = new Map() 116 | apiKeys.set('123456789', 'secret1') 117 | apiKeys.set('987654321', 'secret2') 118 | 119 | fastify.register(require('fastify-api-key'), { 120 | getSecret: (request, keyId, callback) => { 121 | const secret = apiKeys.get(keyId) 122 | if (!secret) { 123 | return callback(Unauthorized('Unknown client')) 124 | } 125 | callback(null, secret) 126 | }, 127 | }) 128 | ``` 129 | 130 | The `callback` parameter is optional. In the case `getSecret` must return a promise with the secret value: 131 | 132 | ```javascript 133 | const fastify = require('fastify')() 134 | const { Unauthorized } = require('http-errors') 135 | 136 | const apiKeys = new Map() 137 | apiKeys.set('123456789', 'secret1') 138 | apiKeys.set('987654321', 'secret2') 139 | 140 | fastify.register(require('fastify-api-key'), { 141 | getSecret: async (request, keyId) => { 142 | const secret = apiKeys.get(keyId) 143 | if (!secret) { 144 | return callback(Unauthorized('Unknown client')) 145 | } 146 | return secret 147 | }, 148 | }) 149 | ``` 150 | 151 | #### options.requestLifetime (OPTIONAL) 152 | 153 | The lifetime of a request in second, by default is set to 300 seconds, set it to `null` to disable it. This options is 154 | used if HTTP header "date" is used to create the signature. 155 | 156 | ### request.apiKeyVerify(callback) 157 | 158 | - `callback` (`Function`) - A function with signature `function(err)` to be invoked when the secret is retrieved. 159 | - `err` (`Error`) - The error that occurred. 160 | 161 | ```javascript 162 | fastify.get('/verify', function (request, reply) { 163 | request.apiKeyVerify(function (err) { 164 | return reply.send(err || { hello: 'world' }) 165 | }) 166 | }) 167 | ``` 168 | 169 | The `callback` parameter is optional. In the case `apiKeyVerify` return a promise. 170 | 171 | ```javascript 172 | fastify.get('/verify', async function (request, reply) { 173 | try { 174 | await request.apiKeyVerify() 175 | reply.send({ hello: 'world' }) 176 | } catch (err) { 177 | reply.send(err) 178 | } 179 | }) 180 | ``` 181 | 182 | ## HTTP signature scheme 183 | 184 | The signature is based on this 185 | draft ["Signing HTTP Messages"](https://tools.ietf.org/html/draft-cavage-http-signatures-09). Your application must 186 | provide to the client application both unique identifier : 187 | 188 | * **key** : A key used to identify the client application; 189 | * **shared secret**: A secret key shared between your application and the client application used to sign the requests 190 | and authenticate the client application. 191 | 192 | ### HTTP header 193 | 194 | The signature must be sent in the HTTP header "Authorization" with the authentication scheme "Signature" : 195 | 196 | ``` 197 | Authorization: Signature keyId="API_KEY",algorithm="hmac-sha256",headers="(request-target) host date digest content-length",signature="Base64(HMAC-SHA256(signing string))" 198 | ``` 199 | 200 | Let's see the different components of the signature : 201 | 202 | * **keyId (REQUIRED)** : The client application's key; 203 | * **algorithm (REQUIRED)** : The algorithm used to create the signature; 204 | * **header (OPTIONAL)** : The list of HTTP headers used to create the signature of the request. If specified, it should 205 | be a lowercased, quoted list of HTTP header fields, separated by a single space character. If not specified, 206 | the `Date` header is used by default therefore the client must send this `Date` header. Note : The list order is 207 | important, and must be specified in the order the HTTP header field-value pairs are concatenated together during 208 | signing. 209 | * **signature (REQUIRED)** : A base 64 encoded digital signature. The client uses the `algorithm` and `headers` 210 | signature parameters to form a canonicalized `signing string`. 211 | 212 | ### Signature string construction 213 | 214 | To generate the string that is signed with the shared secret and the `algorithm`, the client must use the values of each 215 | HTTP header field in the `headers` Signature parameter in the order they appear. 216 | 217 | To include the HTTP request target in the signature calculation, use the special `(request-target)` header field name. 218 | 219 | 1. If the header field name is `(request-target)` then generate the header field value by concatenating the lowercased 220 | HTTP method, an ASCII space, and the path pseudo-headers (example : get /protected); 221 | 2. Create the header field string by concatenating the lowercased header field name followed with an ASCII colon `:`, an 222 | ASCII space `` and the header field value. If there are multiple instances of the same header field, all header field 223 | values associated with the header field must be concatenated, separated by a ASCII comma and an ASCII space `,`, and 224 | used in the order in which they will appear in the HTTP request; 225 | 3. If value is not the last value then append an ASCII newline `\n`. 226 | 227 | To illustrate the rules specified above, assume a `headers` parameter list with the value 228 | of `(request-target) host date cache-control x-test` with the following HTTP request headers: 229 | 230 | ``` 231 | GET /protected HTTP/1.1 232 | Host: example.org 233 | Date: Tue, 10 Apr 2018 10:30:32 GMT 234 | x-test: Hello world 235 | Cache-Control: max-age=60 236 | Cache-Control: must-revalidate 237 | ``` 238 | 239 | For the HTTP request headers above, the corresponding signature string is: 240 | 241 | ``` 242 | (request-target): get /protected 243 | host: example.org 244 | date: Tue, 10 Apr 2018 10:30:32 GMT 245 | cache-control: max-age=60, must-revalidate 246 | x-test: Hello world 247 | ``` 248 | 249 | ### Signature creation 250 | 251 | In order to create a signature, a client must : 252 | 253 | 1. Create the signature string as described in [signature string construction](#signature-string-construction); 254 | 255 | 2. The `algorithm` and shared secret associated with `keyId` must then be used to generate a digital signature on the 256 | signature string; 257 | 258 | 3. The `signature` is then generated by base 64 encoding the output of the digital signature algorithm. 259 | 260 | ### Supported algorithms 261 | 262 | Currently supported algorithm names are: 263 | 264 | * hmac-sha1 265 | * hmac-sha256 266 | * hmac-sha512 267 | 268 | ## License 269 | 270 | Licensed under [MIT](./LICENSE). 271 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import * as fastify from 'fastify' 2 | 3 | 4 | export type GetSecretCallback = 5 | ((request: fastify.FastifyRequest, keyId: string, cb: (e: Error | null | undefined, secret: string | undefined) => void) => void) 6 | | ((request: fastify.FastifyRequest, keyId: string | undefined) => Promise) 7 | 8 | export interface FastifyApiKeyOptions { 9 | getSecret: GetSecretCallback; 10 | requestProperty?: string, 11 | requestLifetime?: number | null 12 | } 13 | 14 | 15 | export const fastifyApiKey: fastify.FastifyPluginCallback 16 | 17 | export default fastifyApiKey 18 | 19 | declare module 'fastify' { 20 | interface FastifyRequest { 21 | apiKeyVerify(): Promise 22 | 23 | apiKeyVerify(cb: (e: Error | null | undefined) => void): void 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const crypto = require('crypto') 4 | const util = require('util') 5 | const steed = require('steed') 6 | const fp = require('fastify-plugin') 7 | const { BadRequest, Unauthorized } = require('http-errors') 8 | 9 | const requiredParameters = ['keyid', 'algorithm', 'signature'] 10 | const availableAlgorithm = ['hmac-sha256', 'hmac-sha1', 'hmac-sha512'] 11 | 12 | const messages = { 13 | missingRequiredHeadersErrorMessage: (headers) => { 14 | return `Missing required HTTP headers : ${headers.join(', ')}` 15 | }, 16 | missingRequiredSignatureParamsErrorMessage: (parameters) => { 17 | return `Missing required signature parameters : ${parameters.join(', ')}` 18 | }, 19 | badHeaderFormatError: (header, expectedFormat) => { 20 | return `Bad value format for the HTTP header ${header}. Expected format : ${expectedFormat}` 21 | }, 22 | unsupportedAlgorithmErrorMessage: 'Unsupported algorithm', 23 | expiredRequestErrorMessage: 'Request has expired', 24 | invalidSignatureErrorMessage: 'Authorization signature is invalid' 25 | } 26 | 27 | function fastifyApiKey (fastify, options, next) { 28 | if (!options.getSecret) { 29 | return next(new Error('missing getSecret')) 30 | } 31 | 32 | const { 33 | getSecret: secretCallback, 34 | requestLifetime = 300 35 | } = options 36 | 37 | fastify.decorateRequest('apiKeyVerify', requestVerify) 38 | 39 | next() 40 | 41 | function requestVerify (next) { 42 | const request = this 43 | 44 | if (next === undefined) { 45 | return new Promise((resolve, reject) => { 46 | request.apiKeyVerify((err, value) => { 47 | err ? reject(err) : resolve(value) 48 | }) 49 | }) 50 | } 51 | 52 | if (!request.headers || !request.headers.authorization) { 53 | return next(new Unauthorized(messages.missingRequiredHeadersErrorMessage(['authorization']))) 54 | } 55 | 56 | let { authorization } = request.headers 57 | const scheme = 'signature' 58 | const prefix = authorization.substring(0, scheme.length).toLowerCase() 59 | if (prefix !== scheme) { 60 | return next(new BadRequest(messages.badHeaderFormatError('Authorization', 'Signature [params]'))) 61 | } 62 | 63 | authorization = authorization.substring(scheme.length).trim() 64 | const parts = authorization.split(',') 65 | const signatureParams = {} 66 | for (const part of parts) { 67 | const index = part.indexOf('="') 68 | const key = part.substring(0, index).toLowerCase() 69 | signatureParams[key] = part.substring(index + 2, part.length - 1) 70 | } 71 | 72 | const missingSignatureParams = [] 73 | for (const param of requiredParameters) { 74 | if (!signatureParams[param]) { 75 | missingSignatureParams.push(param) 76 | } 77 | } 78 | 79 | if (missingSignatureParams.length > 0) { 80 | return next(new BadRequest(messages.missingRequiredSignatureParamsErrorMessage(missingSignatureParams))) 81 | } 82 | 83 | signatureParams.headers = signatureParams.headers ? signatureParams.headers.toLowerCase().split(' ') : ['date'] 84 | 85 | if (!availableAlgorithm.includes(signatureParams.algorithm)) { 86 | return next(new BadRequest(messages.unsupportedAlgorithmErrorMessage)) 87 | } 88 | 89 | const missingRequiredHeaders = [] 90 | signatureParams.signingString = '' 91 | signatureParams.headers.forEach((header, index, arr) => { 92 | if (header === '(request-target)') { 93 | signatureParams.signingString += `(request-target): ${request.method.toLowerCase()} ${request.url}` 94 | } else if (request.headers[header]) { 95 | signatureParams.signingString += `${header}: ${request.headers[header]}` 96 | } else { 97 | missingRequiredHeaders.push(header) 98 | } 99 | if (index < arr.length - 1) { 100 | signatureParams.signingString += '\n' 101 | } 102 | }) 103 | 104 | if (missingRequiredHeaders.length > 0) { 105 | return next(new BadRequest(messages.missingRequiredHeadersErrorMessage(missingRequiredHeaders))) 106 | } 107 | 108 | if (signatureParams.headers.includes('date') && 109 | request.headers.date && 110 | requestLifetime) { 111 | const currentDate = new Date().getTime() 112 | const requestDate = Date.parse(request.headers.date) 113 | if (Number.isNaN(requestDate)) { 114 | return next(new BadRequest( 115 | messages.badHeaderFormatError('date', ', :: GMT'))) 116 | } 117 | 118 | if (Math.abs(currentDate - requestDate) >= requestLifetime * 1000) { 119 | return next(new BadRequest(messages.expiredRequestErrorMessage)) 120 | } 121 | } 122 | 123 | steed.waterfall([ 124 | function getSecret (cb) { 125 | const maybePromise = secretCallback(request, signatureParams.keyid, cb) 126 | if (util.types.isPromise(maybePromise)) { 127 | maybePromise.then(user => cb(null, user), cb) 128 | } 129 | }, 130 | function verify (secret, cb) { 131 | const algorithm = signatureParams.algorithm.split('-')[1] 132 | const hmac = crypto.createHmac(algorithm, secret) 133 | hmac.update(signatureParams.signingString) 134 | 135 | /* Use double hmac to protect against timing attacks */ 136 | let h1 = crypto.createHmac(algorithm, secret) 137 | h1 = h1.update(hmac.digest()).digest() 138 | let h2 = crypto.createHmac(algorithm, secret) 139 | h2 = h2.update(Buffer.from(signatureParams.signature, 'base64')).digest() 140 | 141 | if (!h1.equals(h2)) { 142 | return cb(new Unauthorized(messages.invalidSignatureErrorMessage)) 143 | } 144 | cb() 145 | }], next) 146 | } 147 | } 148 | 149 | module.exports = fp(fastifyApiKey, { 150 | fastify: '>=3.x', 151 | name: 'fastify-api-key' 152 | }) 153 | -------------------------------------------------------------------------------- /index.test-d.ts: -------------------------------------------------------------------------------- 1 | import {expectAssignable} from 'tsd' 2 | import fastify, { 3 | FastifyRequest 4 | } from 'fastify' 5 | import fastifyApiKey from '.' 6 | 7 | const app = fastify() 8 | 9 | app.register(fastifyApiKey, { 10 | getSecret: async function validatePromise(request, keyId) { 11 | expectAssignable(request) 12 | expectAssignable(keyId) 13 | }, 14 | requestLifetime: 300 15 | }) 16 | 17 | app.register(fastifyApiKey, { 18 | getSecret: function validateCallback(request, keyId, cb) { 19 | expectAssignable(request) 20 | expectAssignable(keyId) 21 | expectAssignable<(e: Error | null | undefined, secret: string | undefined) => void>(cb) 22 | }, 23 | requestLifetime: 300 24 | }) 25 | 26 | 27 | app.addHook("preHandler", async (request, reply) => { 28 | expectAssignable(request.apiKeyVerify) 29 | 30 | try { 31 | await request.apiKeyVerify(); 32 | } catch (err) { 33 | reply.send(err); 34 | } 35 | }); 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastify-api-key", 3 | "version": "1.0.2", 4 | "description": "Fastify plugin to authenticate HTTP requests based on api key and signature", 5 | "author": { 6 | "name": "Axel SHAÏTA", 7 | "email": "shaita.axel@gmail.com", 8 | "url": "https://www.codeheroes.fr" 9 | }, 10 | "license": "MIT", 11 | "keywords": [ 12 | "fastify", 13 | "authentication", 14 | "auth", 15 | "http", 16 | "api", 17 | "rest", 18 | "apiKey", 19 | "key", 20 | "signature", 21 | "plugin", 22 | "nodejs" 23 | ], 24 | "main": "index.js", 25 | "types": "index.d.ts", 26 | "scripts": { 27 | "lint": "standard", 28 | "lint:fix": "standard --fix", 29 | "test": "standard && tap test.js && tsd" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "https://github.com/arkerone/fastify-api-key" 34 | }, 35 | "bugs": { 36 | "url": "https://github.com/arkerone/fastify-api-key/issues" 37 | }, 38 | "homepage": "https://github.com/arkerone/fastify-api-key#readme", 39 | "devDependencies": { 40 | "@types/node": "^14.14.35", 41 | "fastify": "^3.14.0", 42 | "standard": "^16.0.3", 43 | "tap": "^14.11.0", 44 | "tsd": "^0.14.0", 45 | "typescript": "^4.2.3" 46 | }, 47 | "dependencies": { 48 | "fastify-plugin": "^3.0.0", 49 | "http-errors": "^1.8.0", 50 | "steed": "^1.1.3" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | const { test } = require('tap') 2 | const Fastify = require('fastify') 3 | const crypto = require('crypto') 4 | const apiKey = require('./index') 5 | 6 | test('register', (t) => { 7 | t.plan(2) 8 | 9 | t.test('Should expose api key methods', (t) => { 10 | t.plan(1) 11 | const fastify = Fastify() 12 | fastify.register(apiKey, { 13 | getSecret: async () => { 14 | return 'test' 15 | } 16 | }) 17 | fastify.get('/methods', (request) => { 18 | t.ok(request.apiKeyVerify) 19 | }) 20 | 21 | fastify.inject({ 22 | method: 'get', 23 | url: '/methods' 24 | }) 25 | }) 26 | 27 | t.test('should failed if "getSecret" is missing', (t) => { 28 | t.plan(1) 29 | const fastify = Fastify() 30 | fastify.register(apiKey).ready((error) => { 31 | t.is(error.message, 'missing getSecret') 32 | }) 33 | }) 34 | }) 35 | 36 | test('requestVerify', (t) => { 37 | t.plan(6) 38 | 39 | const keyId = '123456789' 40 | const secret = 'secret' 41 | const date = (new Date()).toString() 42 | const host = 'http://localhost' 43 | const signingString = `(request-target): get /verify\nhost: ${host}\ndate: ${date}` 44 | const signature = crypto.createHmac('sha1', secret).update(signingString).digest('base64') 45 | 46 | const authorization = `Signature keyId="${keyId}",algorithm="hmac-sha1",headers="(request-target) host date",signature="${signature}"` 47 | 48 | t.test('getSecret', (t) => { 49 | t.plan(3) 50 | 51 | async function runWithGetSecret (t, getSecret) { 52 | const fastify = Fastify() 53 | fastify.register(apiKey, { getSecret }) 54 | 55 | fastify.get('/verify', async (request) => { 56 | await request.apiKeyVerify() 57 | return 'test' 58 | }) 59 | 60 | await fastify.ready() 61 | 62 | const verifyResponse = await fastify.inject({ 63 | method: 'get', 64 | url: '/verify', 65 | headers: { 66 | authorization, 67 | date, 68 | host 69 | } 70 | }) 71 | 72 | t.is(verifyResponse.payload, 'test') 73 | } 74 | 75 | t.test('getSecret as a function with callback', (t) => { 76 | return runWithGetSecret(t, (request, keyId, callback) => { 77 | callback(null, secret) 78 | }) 79 | }) 80 | 81 | t.test('getSecret as a function returning a promise', (t) => { 82 | return runWithGetSecret(t, () => { 83 | return Promise.resolve(secret) 84 | }) 85 | }) 86 | 87 | t.test('getSecret as an async function', (t) => { 88 | return runWithGetSecret(t, async () => { 89 | return secret 90 | }) 91 | }) 92 | }) 93 | 94 | t.test('disable the checking of request expiration', (t) => { 95 | t.plan(1) 96 | const fastify = Fastify() 97 | 98 | fastify.register(apiKey, { 99 | requestLifetime: null, 100 | getSecret: (request, keyId, cb) => { 101 | cb(null, secret) 102 | } 103 | }) 104 | 105 | fastify.get('/verify', async (request) => { 106 | await request.apiKeyVerify() 107 | return 'test' 108 | }) 109 | 110 | const date = new Date('1970-01-01').toString() 111 | const signingString = `date: ${date}` 112 | const signature = crypto.createHmac('sha1', secret).update(signingString).digest('base64') 113 | 114 | const authorizationWithoutHeader = `Signature keyId="${keyId}",algorithm="hmac-sha1",signature="${signature}"` 115 | 116 | fastify.inject({ 117 | method: 'get', 118 | url: '/verify', 119 | headers: { 120 | authorization: authorizationWithoutHeader, 121 | date 122 | } 123 | }).then((response) => { 124 | t.is(response.payload, 'test') 125 | }) 126 | }) 127 | 128 | t.test('Authorization signature without headers value', (t) => { 129 | t.plan(1) 130 | const fastify = Fastify() 131 | 132 | fastify.register(apiKey, { 133 | getSecret: (request, keyId, cb) => { 134 | cb(null, secret) 135 | } 136 | }) 137 | 138 | fastify.get('/verify', async (request) => { 139 | await request.apiKeyVerify() 140 | return 'test' 141 | }) 142 | 143 | const signingString = `date: ${date}` 144 | const signature = crypto.createHmac('sha1', secret).update(signingString).digest('base64') 145 | 146 | const authorizationWithoutHeader = `Signature keyId="${keyId}",algorithm="hmac-sha1",signature="${signature}"` 147 | 148 | fastify.inject({ 149 | method: 'get', 150 | url: '/verify', 151 | headers: { 152 | authorization: authorizationWithoutHeader, 153 | date 154 | } 155 | }).then((response) => { 156 | t.is(response.payload, 'test') 157 | }) 158 | }) 159 | 160 | t.test('synchronous requestVerify', (t) => { 161 | t.plan(1) 162 | const fastify = Fastify() 163 | 164 | fastify.register(apiKey, { 165 | getSecret: (request, keyId, cb) => { 166 | cb(null, secret) 167 | } 168 | }) 169 | 170 | fastify.get('/verify', async (request) => { 171 | await request.apiKeyVerify() 172 | return 'test' 173 | }) 174 | 175 | fastify.inject({ 176 | method: 'get', 177 | url: '/verify', 178 | headers: { 179 | authorization, 180 | date, 181 | host 182 | } 183 | }).then((response) => { 184 | t.is(response.payload, 'test') 185 | }) 186 | }) 187 | 188 | t.test('asynchronous requestVerify', (t) => { 189 | t.plan(1) 190 | const fastify = Fastify() 191 | 192 | fastify.register(apiKey, { 193 | getSecret: (request, keyId, cb) => { 194 | cb(null, secret) 195 | } 196 | }) 197 | 198 | fastify.get('/verify', (request, reply) => { 199 | request.apiKeyVerify((error) => { 200 | return reply.send(error || 'test') 201 | }) 202 | }) 203 | 204 | fastify.inject({ 205 | method: 'get', 206 | url: '/verify', 207 | headers: { 208 | authorization, 209 | date, 210 | host 211 | } 212 | }).then((response) => { 213 | t.is(response.payload, 'test') 214 | }) 215 | }) 216 | 217 | t.test('errors', (t) => { 218 | t.plan(8) 219 | 220 | const signature = 'Signature keyid="123456789",algorithm="hmac-sha1",headers="host date",signature="signature"' 221 | const fastify = Fastify() 222 | fastify.register(apiKey, { 223 | getSecret: (request, keyId, cb) => { 224 | cb(null, 'secret') 225 | } 226 | }) 227 | 228 | fastify.get('/verify', (request, reply) => { 229 | request.apiKeyVerify().then(() => { 230 | return reply.send('test') 231 | }).catch((error) => { 232 | return reply.send(error) 233 | }) 234 | }) 235 | 236 | t.test('should failed if HTTP header "Authorization" is not present', 237 | (t) => { 238 | t.plan(2) 239 | 240 | fastify.inject({ 241 | method: 'get', 242 | url: '/verify' 243 | }).then((response) => { 244 | const error = JSON.parse(response.payload) 245 | t.is(error.message, 'Missing required HTTP headers : authorization') 246 | t.is(response.statusCode, 401) 247 | }) 248 | }) 249 | 250 | t.test('should failed if the auth scheme of the HTTP header "Authorization" is not valid', (t) => { 251 | t.plan(2) 252 | 253 | fastify.inject({ 254 | method: 'get', 255 | url: '/verify', 256 | headers: { 257 | authorization: 'Invalid Format' 258 | } 259 | }).then((response) => { 260 | const error = JSON.parse(response.payload) 261 | t.is(error.message, 262 | 'Bad value format for the HTTP header Authorization. Expected format : Signature [params]') 263 | t.is(response.statusCode, 400) 264 | }) 265 | }) 266 | 267 | t.test('should throw if the signature parameters are not valid', (t) => { 268 | t.plan(2) 269 | 270 | fastify.inject({ 271 | method: 'get', 272 | url: '/verify', 273 | headers: { 274 | authorization: 'Signature bad_params' 275 | } 276 | }).then((response) => { 277 | const error = JSON.parse(response.payload) 278 | t.is(error.message, 279 | 'Missing required signature parameters : keyid, algorithm, signature') 280 | t.is(response.statusCode, 400) 281 | }) 282 | }) 283 | 284 | t.test('should failed if the algorithm is not supported', (t) => { 285 | t.plan(2) 286 | 287 | fastify.inject({ 288 | method: 'get', 289 | url: '/verify', 290 | headers: { 291 | authorization: 'Signature keyid="123456789",algorithm="unknown_algorithm",headers="host date",signature="test"' 292 | } 293 | }).then((response) => { 294 | const error = JSON.parse(response.payload) 295 | t.is(error.message, 'Unsupported algorithm') 296 | t.is(response.statusCode, 400) 297 | }) 298 | }) 299 | 300 | t.test('should failed if required headers for signature are missing', (t) => { 301 | t.plan(2) 302 | 303 | fastify.inject({ 304 | method: 'get', 305 | url: '/verify', 306 | headers: { 307 | authorization: signature 308 | } 309 | }).then((response) => { 310 | const error = JSON.parse(response.payload) 311 | t.is(error.message, 'Missing required HTTP headers : date') 312 | t.is(response.statusCode, 400) 313 | }) 314 | }) 315 | 316 | t.test('should failed if the HTTP header date is malformed', (t) => { 317 | t.plan(2) 318 | 319 | fastify.inject({ 320 | method: 'get', 321 | url: '/verify', 322 | headers: { 323 | authorization: signature, 324 | date: 'malformed_date' 325 | } 326 | }).then((response) => { 327 | const error = JSON.parse(response.payload) 328 | t.is(error.message, 329 | 'Bad value format for the HTTP header date. Expected format : , :: GMT') 330 | t.is(response.statusCode, 400) 331 | }) 332 | }) 333 | 334 | t.test('should failed if the request is expired', (t) => { 335 | t.plan(2) 336 | 337 | fastify.inject({ 338 | method: 'get', 339 | url: '/verify', 340 | headers: { 341 | authorization: signature, 342 | date: 'Wed, 24 Mar 2021 06:00:00 GMT' 343 | } 344 | }).then((response) => { 345 | const error = JSON.parse(response.payload) 346 | t.is(error.message, 'Request has expired') 347 | t.is(response.statusCode, 400) 348 | }) 349 | }) 350 | 351 | t.test('should failed if the signature is invalid', (t) => { 352 | t.plan(2) 353 | 354 | fastify.inject({ 355 | method: 'get', 356 | url: '/verify', 357 | headers: { 358 | authorization: signature, 359 | date: (new Date()).toString() 360 | } 361 | }).then((response) => { 362 | const error = JSON.parse(response.payload) 363 | t.is(error.message, 'Authorization signature is invalid') 364 | t.is(response.statusCode, 401) 365 | }) 366 | }) 367 | }) 368 | }) 369 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "noEmit": true, 6 | "strict": true, 7 | "noImplicitAny": false, 8 | "esModuleInterop": true 9 | }, 10 | "files": [ 11 | "index.test-d.ts" 12 | ] 13 | } 14 | --------------------------------------------------------------------------------