From 5611e1fa34119c017bd30910a5caa2ea49a2db92 Mon Sep 17 00:00:00 2001 From: Alexey Maslennikov Date: Thu, 29 Jun 2023 22:00:48 +0300 Subject: [PATCH] feat: unconditionally decorate request with jwtDecode (#297) --- README.md | 17 ++-- jwt.d.ts | 2 +- jwt.js | 70 ++++++++-------- test/jwt.test.js | 119 ++------------------------- test/namespace.test.js | 35 ++------ test/options.test.js | 172 +++++++++++++++++++++++++++++++++++++++ test/types/jwt.test-d.ts | 1 - 7 files changed, 235 insertions(+), 181 deletions(-) create mode 100644 test/options.test.js diff --git a/README.md b/README.md index 4e95995..b9b43d1 100644 --- a/README.md +++ b/README.md @@ -432,10 +432,13 @@ fastify.get("/", async (request, reply) => { ### `namespace` -To define multiple JWT validators on the same routes, you may use the `namespace` option. -You can combine this with custom names for `jwtVerify` and `jwtSign`. +To define multiple JWT validators on the same routes, you may use the +`namespace` option. You can combine this with custom names for `jwtVerify`, +`jwtDecode`, and `jwtSign`. -When you omit the `jwtVerify` and `jwtSign` options, the default function name will be `JwtVerify` and `JwtSign`. +When you omit the `jwtVerify`, `jwtDecode`, or `jwtSign` options, the default +function name will be `JwtVerify`, `JwtDecode` and +`JwtSign` correspondingly. #### Example with namespace @@ -445,12 +448,16 @@ const fastify = require('fastify') fastify.register(jwt, { secret: 'test', namespace: 'security', + // will decorate request with `securityVerify`, `securitySign`, + // and default `securityJwtDecode` since no custom alias provided jwtVerify: 'securityVerify', jwtSign: 'securitySign' }) fastify.register(jwt, { secret: 'fastify', + // will decorate request with default `airDropJwtVerify`, `airDropJwtSign`, + // and `airDropJwtDecode` since no custom aliases provided namespace: 'airDrop' }) @@ -642,9 +649,7 @@ For your convenience, `request.jwtVerify()` will look for the token in the cooki ### request.jwtDecode([options,] callback) -Decode a JWT without verifying - -As of 3.2.0, decorated when `options.jwtDecode` is truthy. Will become non-conditionally decorated in 4.0.0. This avoid breaking change that would effect fastify-auth0-verify. +Decode a JWT without verifying. `options` must be an `Object` and can contain `verify` and `decode` options. diff --git a/jwt.d.ts b/jwt.d.ts index 14d8db5..76ce6d3 100644 --- a/jwt.d.ts +++ b/jwt.d.ts @@ -127,7 +127,7 @@ declare namespace fastifyJwt { decodedToken: {[k: string]: any} ) => boolean | Promise | SignPayloadType | Promise formatUser?: (payload: SignPayloadType) => UserType - jwtDecode?: boolean | string + jwtDecode?: string namespace?: string jwtVerify?: string jwtSign?: string diff --git a/jwt.js b/jwt.js index 1eabbbe..d8e7aed 100644 --- a/jwt.js +++ b/jwt.js @@ -18,6 +18,10 @@ const messages = { authorizationTokenUnsigned: 'Unsigned authorization token' } +function isString (x) { + return Object.prototype.toString.call(x) === '[object String]' +} + function wrapStaticSecretInCallback (secret) { return function (request, payload, cb) { return cb(null, secret) @@ -54,13 +58,39 @@ function convertTemporalProps (options, isVerifyOptions) { return formatedOptions } -function fastifyJwt (fastify, options, next) { - if (!options.secret) { - return next(new Error('missing secret')) +function validateOptions (options) { + assert(options.secret, 'missing secret') + assert(!options.options, 'options prefix is deprecated') + + assert(!options.jwtVerify || isString(options.jwtVerify), 'Invalid options.jwtVerify') + assert(!options.jwtDecode || isString(options.jwtDecode), 'Invalid options.jwtDecode') + assert(!options.jwtSign || isString(options.jwtSign), 'Invalid options.jwtSign') + + if ( + options.sign && + options.sign.algorithm && + options.sign.algorithm.includes('RS') && + (typeof options.secret === 'string' || + options.secret instanceof Buffer) + ) { + throw new Error('RSA Signatures set as Algorithm in the options require a private and public key to be set as the secret') } + if ( + options.sign && + options.sign.algorithm && + options.sign.algorithm.includes('ES') && + (typeof options.secret === 'string' || + options.secret instanceof Buffer) + ) { + throw new Error('ECDSA Signatures set as Algorithm in the options require a private and public key to be set as the secret') + } +} - if (options.options) { - return next(new Error('options prefix is deprecated')) +function fastifyJwt (fastify, options, next) { + try { + validateOptions(options) + } catch (e) { + return next(e) } const { @@ -119,25 +149,6 @@ function fastifyJwt (fastify, options, next) { const BadRequestError = createError('FST_JWT_BAD_REQUEST', messagesOptions.badRequestErrorMessage, 400) const BadCookieRequestError = createError('FST_JWT_BAD_COOKIE_REQUEST', messagesOptions.badCookieRequestErrorMessage, 400) - if ( - signOptions && - signOptions.algorithm && - signOptions.algorithm.includes('RS') && - (typeof secret === 'string' || - secret instanceof Buffer) - ) { - return next(new Error('RSA Signatures set as Algorithm in the options require a private and public key to be set as the secret')) - } - if ( - signOptions && - signOptions.algorithm && - signOptions.algorithm.includes('ES') && - (typeof secret === 'string' || - secret instanceof Buffer) - ) { - return next(new Error('ECDSA Signatures set as Algorithm in the options require a private and public key to be set as the secret')) - } - const jwtDecorator = { decode, options: { @@ -156,6 +167,7 @@ function fastifyJwt (fastify, options, next) { let jwtDecodeName = 'jwtDecode' let jwtVerifyName = 'jwtVerify' let jwtSignName = 'jwtSign' + if (namespace) { if (!fastify.jwt) { fastify.decorateRequest(decoratorName, null) @@ -167,7 +179,7 @@ function fastifyJwt (fastify, options, next) { } fastify.jwt[namespace] = jwtDecorator - jwtDecodeName = jwtDecode ? (typeof jwtDecode === 'string' ? jwtDecode : 'jwtDecode') : `${namespace}JwtDecode` + jwtDecodeName = jwtDecode || `${namespace}JwtDecode` jwtVerifyName = jwtVerify || `${namespace}JwtVerify` jwtSignName = jwtSign || `${namespace}JwtSign` } else { @@ -175,13 +187,7 @@ function fastifyJwt (fastify, options, next) { fastify.decorate('jwt', jwtDecorator) } - // Temporary conditional to prevent breaking changes by exposing `jwtDecode`, - // which already exists in fastify-auth0-verify. - // If jwtDecode has been requested, or plugin is configured to use a namespace. - // TODO Remove conditional when fastify-jwt >=4.x.x - if (jwtDecode || namespace) { - fastify.decorateRequest(jwtDecodeName, requestDecode) - } + fastify.decorateRequest(jwtDecodeName, requestDecode) fastify.decorateRequest(jwtVerifyName, requestVerify) fastify.decorateReply(jwtSignName, replySign) diff --git a/test/jwt.test.js b/test/jwt.test.js index b9a7f91..e391d22 100644 --- a/test/jwt.test.js +++ b/test/jwt.test.js @@ -35,7 +35,7 @@ test('export', function (t) { }) test('register', function (t) { - t.plan(20) + t.plan(17) t.test('Expose jwt methods', function (t) { t.plan(8) @@ -49,39 +49,6 @@ test('register', function (t) { } }) - fastify.get('/methods', function (request, reply) { - t.notOk(request.jwtDecode) - t.ok(request.jwtVerify) - t.ok(reply.jwtSign) - }) - - fastify.ready(function () { - t.ok(fastify.jwt.decode) - t.ok(fastify.jwt.options) - t.ok(fastify.jwt.sign) - t.ok(fastify.jwt.verify) - t.ok(fastify.jwt.cookie) - }) - - fastify.inject({ - method: 'get', - url: '/methods' - }) - }) - - t.test('Expose jwt methods - 3.x.x conditional jwtDecode', function (t) { - t.plan(8) - - const fastify = Fastify() - fastify.register(jwt, { - secret: 'test', - cookie: { - cookieName: 'token', - signed: false - }, - jwtDecode: true - }) - fastify.get('/methods', function (request, reply) { t.ok(request.jwtDecode) t.ok(request.jwtVerify) @@ -287,80 +254,6 @@ test('register', function (t) { }) }) - t.test('RS/ES algorithm in sign options and secret as string', function (t) { - t.plan(2) - - t.test('RS algorithm (Must return an error)', function (t) { - t.plan(1) - - const fastify = Fastify() - fastify.register(jwt, { - secret: 'test', - sign: { - algorithm: 'RS256', - aud: 'Some audience', - iss: 'Some issuer', - sub: 'Some subject' - } - }).ready(function (error) { - t.equal(error.message, 'RSA Signatures set as Algorithm in the options require a private and public key to be set as the secret') - }) - }) - - t.test('ES algorithm (Must return an error)', function (t) { - t.plan(1) - const fastify = Fastify() - fastify.register(jwt, { - secret: 'test', - sign: { - algorithm: 'ES256', - aud: 'Some audience', - iss: 'Some issuer', - sub: 'Some subject' - } - }).ready(function (error) { - t.equal(error.message, 'ECDSA Signatures set as Algorithm in the options require a private and public key to be set as the secret') - }) - }) - }) - - t.test('RS/ES algorithm in sign options and secret as a Buffer', function (t) { - t.plan(2) - - t.test('RS algorithm (Must return an error)', function (t) { - t.plan(1) - - const fastify = Fastify() - fastify.register(jwt, { - secret: Buffer.from('some secret', 'base64'), - sign: { - algorithm: 'RS256', - aud: 'Some audience', - iss: 'Some issuer', - sub: 'Some subject' - } - }).ready(function (error) { - t.equal(error.message, 'RSA Signatures set as Algorithm in the options require a private and public key to be set as the secret') - }) - }) - - t.test('ES algorithm (Must return an error)', function (t) { - t.plan(1) - const fastify = Fastify() - fastify.register(jwt, { - secret: Buffer.from('some secret', 'base64'), - sign: { - algorithm: 'ES256', - aud: 'Some audience', - iss: 'Some issuer', - sub: 'Some subject' - } - }).ready(function (error) { - t.equal(error.message, 'ECDSA Signatures set as Algorithm in the options require a private and public key to be set as the secret') - }) - }) - }) - async function runWithSecret (t, secret) { const fastify = Fastify() fastify.register(jwt, { secret }) @@ -2718,7 +2611,7 @@ test('expose decode token for plugin extension', function (t) { t.plan(3) const fastify = Fastify() - fastify.register(jwt, { secret: 'test', jwtDecode: true }) + fastify.register(jwt, { secret: 'test' }) fastify.post('/sign', async function (request, reply) { const token = await reply.jwtSign(request.body) @@ -2814,7 +2707,7 @@ test('support extended config contract', function (t) { } const fastify = Fastify() - fastify.register(jwt, { secret: 'test', jwtDecode: true }) + fastify.register(jwt, { secret: 'test' }) fastify.post('/sign', async function (request, reply) { const token = await reply.jwtSign(request.body, extConfig) @@ -2955,8 +2848,7 @@ test('supporting time definitions for "maxAge", "expiresIn" and "notBefore"', as }, decode: { complete: true - }, - jwtDecode: true + } } const oneDayInSeconds = 24 * 60 * 60 @@ -3066,8 +2958,7 @@ test('global user options should not be modified', async function (t) { }, decode: { complete: true - }, - jwtDecode: true + } } const fastify = Fastify() diff --git a/test/namespace.test.js b/test/namespace.test.js index 23676a6..8ed80dc 100644 --- a/test/namespace.test.js +++ b/test/namespace.test.js @@ -16,18 +16,17 @@ test('Unable to add the namespace twice', function (t) { test('multiple namespace', async function (t) { const fastify = Fastify() - fastify.register(jwt, { namespace: 'aaa', secret: 'test', verify: { extractToken: (request) => request.headers.customauthheader }, jwtDecode: true }) + fastify.register(jwt, { namespace: 'aaa', secret: 'test', verify: { extractToken: (request) => request.headers.customauthheader } }) fastify.register(jwt, { namespace: 'bbb', secret: 'sea', verify: { extractToken: (request) => request.headers.customauthheader }, jwtVerify: 'verifyCustom', jwtSign: 'signCustom', jwtDecode: 'decodeCustom' }) - fastify.register(jwt, { namespace: 'ccc', secret: 'tset', verify: { extractToken: (request) => request.headers.customauthheader } }) fastify.post('/sign/:namespace', async function (request, reply) { switch (request.params.namespace) { case 'aaa': return reply.aaaJwtSign(request.body) - case 'ccc': - return reply.cccJwtSign(request.body) - default: + case 'bbb': return reply.signCustom(request.body) + default: + reply.code(501).send({ message: `Namespace ${request.params.namespace} is not implemented correctly` }) } }) @@ -35,19 +34,19 @@ test('multiple namespace', async function (t) { switch (request.params.namespace) { case 'aaa': return request.aaaJwtVerify() - default: + case 'bbb': return request.verifyCustom() + default: + reply.code(501).send({ message: `Namespace ${request.params.namespace} is not implemented correctly` }) } }) fastify.get('/decode/:namespace', async function (request, reply) { switch (request.params.namespace) { case 'aaa': - return request.jwtDecode() + return request.aaaJwtDecode() case 'bbb': return request.decodeCustom() - case 'ccc': - return request.cccJwtDecode() default: reply.code(501).send({ message: `Namespace ${request.params.namespace} is not implemented correctly` }) } @@ -93,14 +92,6 @@ test('multiple namespace', async function (t) { const tokenB = signResponse.payload t.ok(tokenB) - signResponse = await fastify.inject({ - method: 'post', - url: '/sign/ccc', - payload: { foo: 'tset' } - }) - const tokenC = signResponse.payload - t.ok(tokenC) - verifyResponse = await fastify.inject({ method: 'get', url: '/verify/bbb', @@ -139,14 +130,4 @@ test('multiple namespace', async function (t) { }) t.equal(verifyResponseBBB.statusCode, 200) t.match(verifyResponseBBB.json(), { foo: 'sky' }) - - const verifyResponseCCC = await fastify.inject({ - method: 'get', - url: '/decode/ccc', - headers: { - customauthheader: tokenC - } - }) - t.equal(verifyResponseCCC.statusCode, 200) - t.match(verifyResponseCCC.json(), { foo: 'tset' }) }) diff --git a/test/options.test.js b/test/options.test.js new file mode 100644 index 0000000..a90ca39 --- /dev/null +++ b/test/options.test.js @@ -0,0 +1,172 @@ +'use strict' + +const test = require('tap').test +const Fastify = require('fastify') +const jwt = require('../jwt') + +test('Options validation', function (t) { + t.plan(3) + + t.test('Options are required', function (t) { + t.plan(1) + + const fastify = Fastify() + fastify.register(jwt).ready((error) => { + t.equal(error.message, 'missing secret') + }) + }) + + t.test('Request method aliases', function (t) { + t.plan(6) + + t.test('jwtDecode fail', function (t) { + t.plan(1) + + const fastify = Fastify() + fastify.register(jwt, { + secret: 'sec', + jwtDecode: true + }).ready((error) => { + t.equal(error.message, 'Invalid options.jwtDecode') + }) + }) + + t.test('jwtDecode success', function (t) { + t.plan(1) + + const fastify = Fastify() + fastify.register(jwt, { + secret: 'sec', + jwtDecode: 'hello' + }).ready((error) => { + t.error(error) + }) + }) + + t.test('jwtVerify fail', function (t) { + t.plan(1) + + const fastify = Fastify() + fastify.register(jwt, { + secret: 'sec', + jwtVerify: 123 + }).ready((error) => { + t.equal(error.message, 'Invalid options.jwtVerify') + }) + }) + + t.test('jwtVerify success', function (t) { + t.plan(1) + + const fastify = Fastify() + fastify.register(jwt, { + secret: 'sec', + jwtVerify: String('hello') + }).ready((error) => { + t.error(error) + }) + }) + + t.test('jwtSign fail', function (t) { + t.plan(1) + + const fastify = Fastify() + fastify.register(jwt, { + secret: 'sec', + jwtSign: {} + }).ready((error) => { + t.equal(error && error.message, 'Invalid options.jwtSign') + }) + }) + + t.test('jwtSign success', function (t) { + t.plan(1) + + const fastify = Fastify() + fastify.register(jwt, { + secret: 'sec', + jwtSign: '' + }).ready((error) => { + t.error(error) + }) + }) + }) + + t.test('Secret formats', function (t) { + t.plan(2) + + t.test('RS/ES algorithm in sign options and secret as string', function (t) { + t.plan(2) + + t.test('RS algorithm (Must return an error)', function (t) { + t.plan(1) + + const fastify = Fastify() + fastify.register(jwt, { + secret: 'test', + sign: { + algorithm: 'RS256', + aud: 'Some audience', + iss: 'Some issuer', + sub: 'Some subject' + } + }).ready(function (error) { + t.equal(error && error.message, 'RSA Signatures set as Algorithm in the options require a private and public key to be set as the secret') + }) + }) + + t.test('ES algorithm (Must return an error)', function (t) { + t.plan(1) + const fastify = Fastify() + fastify.register(jwt, { + secret: 'test', + sign: { + algorithm: 'ES256', + aud: 'Some audience', + iss: 'Some issuer', + sub: 'Some subject' + } + }).ready(function (error) { + t.equal(error && error.message, 'ECDSA Signatures set as Algorithm in the options require a private and public key to be set as the secret') + }) + }) + }) + + t.test('RS/ES algorithm in sign options and secret as a Buffer', function (t) { + t.plan(2) + + t.test('RS algorithm (Must return an error)', function (t) { + t.plan(1) + + const fastify = Fastify() + fastify.register(jwt, { + secret: Buffer.from('some secret', 'base64'), + sign: { + algorithm: 'RS256', + aud: 'Some audience', + iss: 'Some issuer', + sub: 'Some subject' + } + }).ready(function (error) { + t.equal(error.message, 'RSA Signatures set as Algorithm in the options require a private and public key to be set as the secret') + }) + }) + + t.test('ES algorithm (Must return an error)', function (t) { + t.plan(1) + const fastify = Fastify() + fastify.register(jwt, { + secret: Buffer.from('some secret', 'base64'), + sign: { + algorithm: 'ES256', + aud: 'Some audience', + iss: 'Some issuer', + sub: 'Some subject' + } + }).ready(function (error) { + t.equal(error.message, 'ECDSA Signatures set as Algorithm in the options require a private and public key to be set as the secret') + }) + }) + }) + }) +}) diff --git a/test/types/jwt.test-d.ts b/test/types/jwt.test-d.ts index bae6a0c..3bdf1ee 100644 --- a/test/types/jwt.test-d.ts +++ b/test/types/jwt.test-d.ts @@ -61,7 +61,6 @@ const jwtOptions: FastifyJWTOptions = { : payload; return { name: objectPayload.userName } }, - jwtDecode: true, namespace: 'security', jwtVerify: 'securityVerify', jwtSign: 'securitySign'