diff --git a/Readme.md b/Readme.md index a62b561..a4f4cde 100644 --- a/Readme.md +++ b/Readme.md @@ -47,6 +47,13 @@ sent to the client (optional) * `contentType`: If the content to be sent is anything other than `application/json`, then the `contentType` property must be set (optional) * `bearerType`: string specifying the Bearer string (optional) +* `specCompliance`: +Configure how this plugin follow the spec. Accept either +[`rfc6749`](https://datatracker.ietf.org/doc/html/rfc6749) or +[`rfc6750`](https://datatracker.ietf.org/doc/html/rfc6750). +Default is set to `rfc6750`. + * `rfc6749` is about the generic OAuth2.0 protocol which allows token type to be case-insensitive. + * `rfc6750` is about the Bearer Token Usage which forces the token type to be exact match. * `function auth (key, req) {}` : this function will test if `key` is a valid token. The function must return a literal `true` if the key is accepted or a literal `false` if rejected. The function may also return a promise that resolves to @@ -68,6 +75,7 @@ The default configuration object is: keys: new Set(), contentType: undefined, bearerType: 'Bearer', + specCompliance: 'rfc6750', errorResponse: (err) => { return {error: err.message} }, diff --git a/index.js b/index.js index 154f267..a633912 100644 --- a/index.js +++ b/index.js @@ -25,14 +25,17 @@ function fastifyBearerAuth (fastify, options, done) { done(new FST_BEARER_AUTH_INVALID_LOG_LEVEL(options.verifyErrorLogLevel)) } - if (options.addHook === true) { - fastify.addHook('onRequest', verifyBearerAuthFactory(options)) - } else { - fastify.decorate('verifyBearerAuthFactory', verifyBearerAuthFactory) - fastify.decorate('verifyBearerAuth', verifyBearerAuthFactory(options)) + try { + if (options.addHook === true) { + fastify.addHook('onRequest', verifyBearerAuthFactory(options)) + } else { + fastify.decorate('verifyBearerAuthFactory', verifyBearerAuthFactory) + fastify.decorate('verifyBearerAuth', verifyBearerAuthFactory(options)) + } + done() + } catch (err) { + done(err) } - - done() } module.exports = fp(fastifyBearerAuth, { diff --git a/lib/errors.js b/lib/errors.js index 037538a..d10b371 100644 --- a/lib/errors.js +++ b/lib/errors.js @@ -5,9 +5,11 @@ const { createError } = require('@fastify/error') const FST_BEARER_AUTH_INVALID_KEYS_OPTION_TYPE = createError('FST_BEARER_AUTH_INVALID_KEYS_OPTION_TYPE', 'options.keys has to be an Array or a Set') const FST_BEARER_AUTH_INVALID_LOG_LEVEL = createError('FST_BEARER_AUTH_INVALID_LOG_LEVEL', 'fastify.log does not have level \'%s\'') const FST_BEARER_AUTH_KEYS_OPTION_INVALID_KEY_TYPE = createError('FST_BEARER_AUTH_KEYS_OPTION_INVALID_KEY_TYPE', 'options.keys has to contain only string entries') +const FST_BEARER_AUTH_INVALID_SPEC = createError('FST_BEARER_AUTH_INVALID_SPEC', 'options.specCompliance has to be set to \'rfc6750\' or \'rfc6749\'') module.exports = { FST_BEARER_AUTH_INVALID_KEYS_OPTION_TYPE, FST_BEARER_AUTH_INVALID_LOG_LEVEL, - FST_BEARER_AUTH_KEYS_OPTION_INVALID_KEY_TYPE + FST_BEARER_AUTH_KEYS_OPTION_INVALID_KEY_TYPE, + FST_BEARER_AUTH_INVALID_SPEC } diff --git a/lib/verify-bearer-auth-factory.js b/lib/verify-bearer-auth-factory.js index 01af3f2..4a9b37e 100644 --- a/lib/verify-bearer-auth-factory.js +++ b/lib/verify-bearer-auth-factory.js @@ -3,9 +3,15 @@ const authenticate = require('./authenticate') const { FST_BEARER_AUTH_INVALID_KEYS_OPTION_TYPE, - FST_BEARER_AUTH_KEYS_OPTION_INVALID_KEY_TYPE + FST_BEARER_AUTH_KEYS_OPTION_INVALID_KEY_TYPE, + FST_BEARER_AUTH_INVALID_SPEC } = require('./errors') +const validSpecs = new Set([ + 'rfc6749', + 'rfc6750' +]) + const defaultOptions = { keys: [], auth: undefined, @@ -13,10 +19,11 @@ const defaultOptions = { return { error: err.message } }, contentType: undefined, - bearerType: 'Bearer' + bearerType: 'Bearer', + specCompliance: 'rfc6750' } -module.exports = function verifyBearerAuthFactory (options) { +module.exports = function verifyBearerAuthFactory (options, done) { const _options = Object.assign({}, defaultOptions, options) if (_options.keys instanceof Set) { _options.keys = Array.from(_options.keys) @@ -27,7 +34,11 @@ module.exports = function verifyBearerAuthFactory (options) { throw new FST_BEARER_AUTH_INVALID_KEYS_OPTION_TYPE() } - const { keys, errorResponse, contentType, bearerType, auth, addHook = true, verifyErrorLogLevel = 'error' } = _options + const { keys, errorResponse, contentType, bearerType, specCompliance, auth, addHook = true, verifyErrorLogLevel = 'error' } = _options + + if (validSpecs.has(specCompliance) === false) { + throw new FST_BEARER_AUTH_INVALID_SPEC() + } for (let i = 0, il = keys.length; i < il; ++i) { if (typeof keys[i] !== 'string') { @@ -36,8 +47,18 @@ module.exports = function verifyBearerAuthFactory (options) { keys[i] = Buffer.from(keys[i]) } - const bearerTypePrefix = bearerType + ' ' const bearerTypePrefixLength = bearerType.length + 1 + const bearerTypePrefix = specCompliance === 'rfc6750' + ? bearerType + ' ' + : bearerType.toLowerCase() + ' ' + + const verifyBearerType = specCompliance === 'rfc6750' + ? function (authorizationHeader) { + return authorizationHeader.substring(0, bearerTypePrefixLength) !== bearerTypePrefix + } + : function (authorizationHeader) { + return authorizationHeader.substring(0, bearerTypePrefixLength).toLowerCase() !== bearerTypePrefix + } function handleUnauthorized (request, reply, done, message) { const noHeaderError = Error(message) @@ -57,7 +78,7 @@ module.exports = function verifyBearerAuthFactory (options) { return handleUnauthorized(request, reply, done, 'missing authorization header') } - if (authorizationHeader.substring(0, bearerTypePrefixLength) !== bearerTypePrefix) { + if (verifyBearerType(authorizationHeader)) { return handleUnauthorized(request, reply, done, 'invalid authorization header') } diff --git a/test/spec-compliance-invalid.test.js b/test/spec-compliance-invalid.test.js new file mode 100644 index 0000000..d07d3a0 --- /dev/null +++ b/test/spec-compliance-invalid.test.js @@ -0,0 +1,15 @@ +'use strict' + +const tap = require('tap') +const test = tap.test +const Fastify = require('fastify') +const plugin = require('../') +const { FST_BEARER_AUTH_INVALID_SPEC } = require('../lib/errors') + +test('throws FST_BEARER_AUTH_INVALID_SPEC when invalid value for specCompliance was used', async (t) => { + t.plan(1) + + const fastify = Fastify() + + t.rejects(() => fastify.register(plugin, { keys: new Set(['123456']), specCompliance: 'invalid' }), new FST_BEARER_AUTH_INVALID_SPEC()) +}) diff --git a/test/spec-compliance-rfc-6749.test.js b/test/spec-compliance-rfc-6749.test.js new file mode 100644 index 0000000..4bbdcf7 --- /dev/null +++ b/test/spec-compliance-rfc-6749.test.js @@ -0,0 +1,85 @@ +'use strict' + +const tap = require('tap') +const test = tap.test +const fastify = require('fastify')() +const plugin = require('../') + +fastify.register(plugin, { keys: new Set(['123456']), specCompliance: 'rfc6749' }) + +fastify.get('/test', (req, res) => { + res.send({ hello: 'world' }) +}) + +test('bearerType starting with capital letter', async (t) => { + t.plan(2) + + const response = await fastify.inject({ + method: 'GET', + url: '/test', + headers: { + authorization: 'Bearer 123456' + } + }) + + t.equal(response.statusCode, 200) + t.same(JSON.parse(response.body), { hello: 'world' }) +}) + +test('bearerType all lowercase', async (t) => { + t.plan(2) + + const response = await fastify.inject({ + method: 'GET', + url: '/test', + headers: { + authorization: 'bearer 123456' + } + }) + + t.equal(response.statusCode, 200) + t.same(JSON.parse(response.body), { hello: 'world' }) +}) + +test('bearerType all uppercase', async (t) => { + t.plan(2) + + const response = await fastify.inject({ + method: 'GET', + url: '/test', + headers: { + authorization: 'Bearer 123456' + } + }) + + t.equal(response.statusCode, 200) + t.same(JSON.parse(response.body), { hello: 'world' }) +}) + +test('invalid key route fails correctly', async (t) => { + t.plan(2) + const response = await fastify.inject({ + method: 'GET', + url: '/test', + headers: { + authorization: 'bearer 987654' + } + }) + + t.equal(response.statusCode, 401) + t.match(JSON.parse(response.body).error, /invalid authorization header/) +}) + +test('missing space between bearerType and key fails correctly', async (t) => { + t.plan(2) + + const response = await fastify.inject({ + method: 'GET', + url: '/test', + headers: { + authorization: 'bearer123456' + } + }) + t.equal(response.statusCode, 401) + t.match(JSON.parse(response.body).error, /invalid authorization header/) +}) diff --git a/test/spec-compliance-rfc-6750.test.js b/test/spec-compliance-rfc-6750.test.js new file mode 100644 index 0000000..a17f731 --- /dev/null +++ b/test/spec-compliance-rfc-6750.test.js @@ -0,0 +1,85 @@ +'use strict' + +const tap = require('tap') +const test = tap.test +const fastify = require('fastify')() +const plugin = require('../') + +fastify.register(plugin, { keys: new Set(['123456']), specCompliance: 'rfc6750' }) + +fastify.get('/test', (req, res) => { + res.send({ hello: 'world' }) +}) + +test('bearerType starting with capital letter', async (t) => { + t.plan(2) + + const response = await fastify.inject({ + method: 'GET', + url: '/test', + headers: { + authorization: 'Bearer 123456' + } + }) + + t.equal(response.statusCode, 200) + t.same(JSON.parse(response.body), { hello: 'world' }) +}) + +test('bearerType all lowercase', async (t) => { + t.plan(2) + + const response = await fastify.inject({ + method: 'GET', + url: '/test', + headers: { + authorization: 'bearer 123456' + } + }) + + t.equal(response.statusCode, 401) + t.match(JSON.parse(response.body).error, /invalid authorization header/) +}) + +test('bearerType all uppercase', async (t) => { + t.plan(2) + + const response = await fastify.inject({ + method: 'GET', + url: '/test', + headers: { + authorization: 'Bearer 123456' + } + }) + + t.equal(response.statusCode, 200) + t.same(JSON.parse(response.body), { hello: 'world' }) +}) + +test('invalid key route fails correctly', async (t) => { + t.plan(2) + const response = await fastify.inject({ + method: 'GET', + url: '/test', + headers: { + authorization: 'bearer 987654' + } + }) + + t.equal(response.statusCode, 401) + t.match(JSON.parse(response.body).error, /invalid authorization header/) +}) + +test('missing space between bearerType and key fails correctly', async (t) => { + t.plan(2) + + const response = await fastify.inject({ + method: 'GET', + url: '/test', + headers: { + authorization: 'bearer123456' + } + }) + t.equal(response.statusCode, 401) + t.match(JSON.parse(response.body).error, /invalid authorization header/) +}) diff --git a/types/index.d.ts b/types/index.d.ts index 9831873..11e0c27 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -20,6 +20,7 @@ declare namespace fastifyBearerAuth { errorResponse?: (err: Error) => { error: string }; contentType?: string; bearerType?: string; + specCompliance?: 'rfc6749' | 'rfc6750'; addHook?: boolean; verifyErrorLogLevel?: string; } diff --git a/types/index.test-d.ts b/types/index.test-d.ts index 3baa21e..1ba10e7 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -49,6 +49,7 @@ expectAssignable<{ errorResponse?: (err: Error) => { error: string }; contentType?: string; bearerType?: string; + specCompliance?: 'rfc6749' | 'rfc6750'; verifyErrorLogLevel? : string; }>(pluginOptionsAuthPromise)