diff --git a/auth.js b/auth.js deleted file mode 100644 index 9e568f5..0000000 --- a/auth.js +++ /dev/null @@ -1,208 +0,0 @@ -'use strict' - -const fp = require('fastify-plugin') -const reusify = require('reusify') - -const DEFAULT_RELATION = 'or' - -/** @type {typeof import('./types/index').fastifyAuth} */ -function fastifyAuth (fastify, opts, next) { - if (opts.defaultRelation && opts.defaultRelation !== 'or' && opts.defaultRelation !== 'and') { - return next(new Error("The value of default relation should be one of ['or', 'and']")) - } - - const pluginOptions = { - defaultRelation: opts.defaultRelation || DEFAULT_RELATION - } - - fastify.decorate('auth', auth(pluginOptions)) - next() -} - -/** @param {import('./types/index').FastifyAuthPluginOptions} pluginOptions */ -function auth (pluginOptions) { - return function (functions, opts) { - if (!Array.isArray(functions)) { - throw new TypeError('You must give an array of functions to the auth function') - } - if (!functions.length) { - throw new Error('Missing auth functions') - } - - const options = Object.assign({ - relation: pluginOptions.defaultRelation, - run: null - }, opts) - - if (options.relation !== 'or' && options.relation !== 'and') { - throw new Error('The value of options.relation should be one of [\'or\', \'and\']') - } - if (options.run && options.run !== 'all') { - throw new Error('The value of options.run must be \'all\'') - } - - const functionsLength = functions.length - for (let i = 0; i < functionsLength; i++) { - if (Array.isArray(functions[i]) === false) { - functions[i] = functions[i].bind(this) - } else { - const subArrayLength = functions[i].length - for (let j = 0; j < subArrayLength; j++) { - if (Array.isArray(functions[i][j])) { - throw new TypeError('Nesting sub-arrays is not supported') - } - functions[i][j] = functions[i][j].bind(this) - } - } - } - - const instance = reusify(Auth) - - function _auth (request, reply, done) { - const obj = instance.get() - - obj.request = request - obj.reply = reply - obj.done = done - obj.functions = this.functions - obj.options = this.options - obj.i = 0 - obj.j = 0 - obj.currentError = null - obj.skipFurtherErrors = false - obj.skipFurtherArrayErrors = false - - obj.nextAuth() - } - - return _auth.bind({ functions, options }) - - function Auth () { - this.next = null - this.i = 0 - this.j = 0 - this.functions = [] - this.options = {} - this.request = null - this.reply = null - this.done = null - this.currentError = null - this.skipFurtherErrors = false - this.skipFurtherArrayErrors = false - - const that = this - - this.nextAuth = function nextAuth (err) { - if (!that.skipFurtherErrors) that.currentError = err - - const func = that.functions[that.i++] - if (!func) { - return that.completeAuth() - } - - if (!Array.isArray(func)) { - that.processAuth(func, (err) => { - if (that.options.run !== 'all') that.currentError = err - - if (that.options.relation === 'and') { - if (err && that.options.run !== 'all') { - that.completeAuth() - } else { - if (err && that.options.run === 'all' && !that.skipFurtherErrors) { - that.skipFurtherErrors = true - that.currentError = err - } - that.nextAuth(err) - } - } else { - if (!err && that.options.run !== 'all') { - that.completeAuth() - } else { - if (!err && that.options.run === 'all') { - that.skipFurtherErrors = true - that.currentError = null - } - that.nextAuth(err) - } - } - }) - } else { - that.j = 0 - that.skipFurtherArrayErrors = false - that.processAuthArray(func, (err) => { - if (that.options.relation === 'and') { // sub-array relation is OR - if (!err && that.options.run !== 'all') { - that.nextAuth(err) - } else { - that.currentError = err - that.nextAuth(err) - } - } else { // sub-array relation is AND - if (err && that.options.run !== 'all') { - that.currentError = err - that.nextAuth(err) - } else { - if (!err && that.options.run !== 'all') { - that.currentError = null - return that.completeAuth() - } - that.nextAuth(err) - } - } - }) - } - } - - this.processAuthArray = function processAuthArray (funcs, callback, err) { - const func = funcs[that.j++] - if (!func) return callback(err) - - that.processAuth(func, (err) => { - if (that.options.relation === 'and') { // sub-array relation is OR - if (!err && that.options.run !== 'all') { - callback(err) - } else { - if (!err && that.options.run === 'all') { - that.skipFurtherArrayErrors = true - } - that.processAuthArray(funcs, callback, that.skipFurtherArrayErrors ? null : err) - } - } else { // sub-array relation is AND - if (err && that.options.run !== 'all') callback(err) - else that.processAuthArray(funcs, callback, err) - } - }) - } - - this.processAuth = function processAuth (func, callback) { - try { - const maybePromise = func(that.request, that.reply, callback) - - if (maybePromise && typeof maybePromise.then === 'function') { - maybePromise.then(() => callback(null), callback) - } - } catch (err) { - callback(err) - } - } - - this.completeAuth = function completeAuth () { - if (that.currentError && (!that.reply.raw.statusCode || that.reply.raw.statusCode < 400)) { - that.reply.code(401) - } else if (!that.currentError && that.reply.raw.statusCode && that.reply.raw.statusCode >= 400) { - that.reply.code(200) - } - - that.done(that.currentError) - instance.release(that) - } - } - } -} - -module.exports = fp(fastifyAuth, { - fastify: '5.x', - name: '@fastify/auth' -}) -module.exports.default = fastifyAuth -module.exports.fastifyAuth = fastifyAuth diff --git a/index.js b/index.js new file mode 100644 index 0000000..0f56f26 --- /dev/null +++ b/index.js @@ -0,0 +1,28 @@ +'use strict' + +const fp = require('fastify-plugin') +const createAuthHandler = require('./lib/auth-handler') +const { ALLOWED_RELATIONS, DEFAULT_RELATION } = require('./lib/constants') + +/** + * @type {typeof import('./types/index').fastifyAuth} + */ +function fastifyAuth (fastify, opts, next) { + const specifiedRelation = opts.defaultRelation + if (specifiedRelation && !ALLOWED_RELATIONS.includes(specifiedRelation)) { + const error = new Error(`The value of default relation should be one of [${ALLOWED_RELATIONS}], not "${specifiedRelation}"`) + return next(error) + } + const pluginOptions = { + defaultRelation: specifiedRelation || DEFAULT_RELATION + } + fastify.decorate('auth', createAuthHandler(pluginOptions)) + next() +} + +module.exports = fp(fastifyAuth, { + fastify: '5.x', + name: '@fastify/auth' +}) +module.exports.default = fastifyAuth +module.exports.fastifyAuth = fastifyAuth diff --git a/lib/auth-handler.js b/lib/auth-handler.js new file mode 100644 index 0000000..ef2742c --- /dev/null +++ b/lib/auth-handler.js @@ -0,0 +1,87 @@ +'use strict' + +const reusify = require('reusify') +const AuthState = require('./auth-state') +const { ALLOWED_RELATIONS } = require('./constants') + +/** + * @param { import('../types/index').FastifyAuthPluginOptions } pluginOptions + * @returns {( + * handlers: import('../types/index').FastifyAuthHandlerArray, + * authOptions?: import('../types/index').FastifyAuthOptions + * ) => (request: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply, done: (error?: Error) => void) => void} + */ +function createAuthHandler (pluginOptions) { + return function (handlers, authOptions) { + if (!Array.isArray(handlers)) { + throw new TypeError(`You must give an array of functions, not "${typeof handlers}"`) + } + if (!handlers.length) { + throw new Error('Missing auth functions') + } + handlers.forEach((fn, i) => { + if (!Array.isArray(fn)) { + handlers[i] = fn.bind(this) + return + } + fn.forEach((nestedFn, j) => { + if (Array.isArray(nestedFn)) { + throw new TypeError('Nesting sub-arrays is not supported') + } + fn[j] = nestedFn.bind(this) + }) + }) + const instance = reusify(AuthState) + const options = resolveAuthOptions(pluginOptions, authOptions) + return createRequestHandler(instance, handlers, options) + } +} + +/** + * @param {import('../types/index').FastifyAuthPluginOptions} pluginOptions + * @param {import('../types/index').FastifyAuthOptions} authOptions + * @returns {Required} + */ +function resolveAuthOptions (pluginOptions, authOptions) { + const resolvedOptions = { + relation: pluginOptions.defaultRelation, + run: null, + ...authOptions + } + const { relation, run } = resolvedOptions + if (!ALLOWED_RELATIONS.includes(relation)) { + throw new Error(`The value of default relation should be one of [${ALLOWED_RELATIONS}], not "${relation}"`) + } + if (run && run !== 'all') { + throw new Error(`The value of run option must be "all", not "${run}"`) + } + return resolvedOptions +} + +/** + * @param {{ get(): import('./auth-state'), release(obj: import('./auth-state')): void }} instance + * @param {import('../types/index').FastifyAuthHandlerArray} handlers + * @param {Required} options + * @returns {(request: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply, done: (error?: Error) => void) => void} + */ +function createRequestHandler (instance, handlers, options) { + return (request, reply, done) => { + const state = instance.get() + state.i = 0 + state.j = 0 + state.handlers = handlers + state.options = options + state.request = request + state.reply = reply + state.done = done + state.currentError = null + state.skipFurtherErrors = false + state.skipFurtherArrayErrors = false + state.onComplete = () => instance.release(state) + state.nextAuthHandler() + } +} + +module.exports = createAuthHandler +module.exports.resolveAuthOptions = resolveAuthOptions +module.exports.createRequestHandler = createRequestHandler diff --git a/lib/auth-state.js b/lib/auth-state.js new file mode 100644 index 0000000..93d7425 --- /dev/null +++ b/lib/auth-state.js @@ -0,0 +1,135 @@ +'use strict' + +class AuthState { + constructor () { + this.next = null + this.i = 0 + this.j = 0 + this.handlers = [] + this.options = {} + this.request = null + this.reply = null + this.done = null + this.currentError = null + this.skipFurtherErrors = false + this.skipFurtherArrayErrors = false + this.onComplete = null + } + + get #isRunAll () { + return this.options.run === 'all' + } + + get #isRelationAnd () { + return this.options.relation === 'and' + } + + nextAuthHandler (err) { + if (!this.skipFurtherErrors) { + this.currentError = err + } + const handler = this.handlers[this.i++] + if (!handler) { + return this.completeAuth() + } + if (!Array.isArray(handler)) { + return this.processAuth(handler, this.#handleSingleHandlerResult) + } + this.j = 0 + this.skipFurtherArrayErrors = false + this.processAuthArray(handler, this.#handleArrayHandlerResult) + } + + #handleSingleHandlerResult = (err) => { + if (!this.#isRunAll) { + this.currentError = err + } + if (this.#isRelationAnd) { + if (err && !this.#isRunAll) { + return this.completeAuth() + } + if (err && this.#isRunAll && !this.skipFurtherErrors) { + this.skipFurtherErrors = true + this.currentError = err + } + return this.nextAuthHandler(err) + } + if (!err && !this.#isRunAll) { + return this.completeAuth() + } + if (!err && this.#isRunAll) { + this.skipFurtherErrors = true + this.currentError = null + } + this.nextAuthHandler(err) + } + + #handleArrayHandlerResult = (err) => { + if (this.#isRelationAnd) { + if (!err && !this.#isRunAll) { + return this.nextAuthHandler(err) + } + this.currentError = err + return this.nextAuthHandler(err) + } + if (err && !this.#isRunAll) { + this.currentError = err + return this.nextAuthHandler(err) + } + if (!err && !this.#isRunAll) { + this.currentError = null + return this.completeAuth() + } + this.nextAuthHandler(err) + } + + processAuthArray (handlers, callback, err) { + const handler = handlers[this.j++] + if (!handler) { + return callback(err) + } + this.processAuth(handler, (err) => { + this.#handleArrayElementResult(handlers, callback, err) + }) + } + + #handleArrayElementResult = (handlers, callback, err) => { + if (this.#isRelationAnd) { + if (!err && !this.#isRunAll) { + return callback(err) + } + if (!err && this.#isRunAll) { + this.skipFurtherArrayErrors = true + } + return this.processAuthArray(handlers, callback, this.skipFurtherArrayErrors ? null : err) + } + if (err && !this.#isRunAll) { + return callback(err) + } + this.processAuthArray(handlers, callback, err) + } + + processAuth (handler, callback) { + try { + const maybePromise = handler(this.request, this.reply, callback) + if (maybePromise && typeof maybePromise.then === 'function') { + maybePromise.then(() => callback(null), callback) + } + } catch (err) { + callback(err) + } + } + + completeAuth () { + const code = this.reply.raw.statusCode + if (this.currentError && (!code || code < 400)) { + this.reply.code(401) + } else if (!this.currentError && code && code >= 400) { + this.reply.code(200) + } + this.done(this.currentError) + this.onComplete?.() + } +} + +module.exports = AuthState diff --git a/lib/constants.js b/lib/constants.js new file mode 100644 index 0000000..35b9963 --- /dev/null +++ b/lib/constants.js @@ -0,0 +1,12 @@ +'use strict' + +/** + * @type {{ + * DEFAULT_RELATION: import('../types/index').FastifyAuthRelation, + * ALLOWED_RELATIONS: import('../types/index').FastifyAuthRelation[] + * }} + */ +module.exports = { + DEFAULT_RELATION: 'or', + ALLOWED_RELATIONS: ['or', 'and'] +} diff --git a/package.json b/package.json index 4242cd5..e234d3f 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "url": "https://opencollective.com/fastify" } ], - "main": "auth.js", + "main": "index.js", "type": "commonjs", "types": "types/index.d.ts", "scripts": { diff --git a/test/auth.test.js b/test/auth.test.js index 867b61c..6162932 100644 --- a/test/auth.test.js +++ b/test/auth.test.js @@ -2,7 +2,7 @@ const { test } = require('node:test') const Fastify = require('fastify') -const fastifyAuth = require('../auth') +const fastifyAuth = require('../index') test('registering plugin with invalid default relation', (t, done) => { t.plan(2) @@ -12,7 +12,7 @@ test('registering plugin with invalid default relation', (t, done) => { fastify.ready((err) => { t.assert.ok(err) - t.assert.strictEqual(err.message, 'The value of default relation should be one of [\'or\', \'and\']') + t.assert.strictEqual(err.message, 'The value of default relation should be one of [or,and], not "auth"') done() }) }) @@ -106,7 +106,7 @@ test('Options: non-array functions input', (t, done) => { app.get('/', (_req, res) => res.send(42)) } catch (error) { t.assert.ok(error) - t.assert.strictEqual(error.message, 'You must give an array of functions to the auth function') + t.assert.strictEqual(error.message, 'You must give an array of functions, not "string"') } }) @@ -154,7 +154,7 @@ test('Options: faulty relation', (t, done) => { app.get('/', (_req, res) => res.send(42)) } catch (error) { t.assert.ok(error) - t.assert.strictEqual(error.message, 'The value of options.relation should be one of [\'or\', \'and\']') + t.assert.strictEqual(error.message, 'The value of default relation should be one of [or,and], not "foo"') } }) @@ -178,7 +178,7 @@ test('Options: faulty run', (t, done) => { app.get('/', (_req, res) => res.send(42)) } catch (error) { t.assert.ok(error) - t.assert.strictEqual(error.message, 'The value of options.run must be \'all\'') + t.assert.strictEqual(error.message, 'The value of run option must be "all", not "foo"') } }) diff --git a/test/example-async.js b/test/example-async.js index b96bd6c..0b02589 100644 --- a/test/example-async.js +++ b/test/example-async.js @@ -7,7 +7,7 @@ function build (opts) { fastify.register(require('@fastify/jwt'), { secret: 'supersecret' }) fastify.register(require('@fastify/leveldb'), { name: 'authdb-async' }) - fastify.register(require('../auth')) + fastify.register(require('../index')) fastify.register(routes) fastify.decorate('verifyJWTandLevel', verifyJWTandLevel) diff --git a/test/example-composited.js b/test/example-composited.js index adf8249..0440350 100644 --- a/test/example-composited.js +++ b/test/example-composited.js @@ -6,7 +6,7 @@ function build (opts) { const fastify = Fastify(opts) fastify - .register(require('../auth')) + .register(require('../index')) .after(routes) fastify.decorate('verifyNumber', verifyNumber) diff --git a/test/example.js b/test/example.js index cbd4d97..5bf95b2 100644 --- a/test/example.js +++ b/test/example.js @@ -26,7 +26,7 @@ function build (opts) { fastify.register(require('@fastify/jwt'), { secret: 'supersecret' }) fastify.register(require('@fastify/leveldb'), { name: 'authdb' }) - fastify.register(require('../auth')) // just 'fastify-auth' IRL + fastify.register(require('../index')) // just 'fastify-auth' IRL fastify.after(routes) fastify.decorate('verifyJWTandLevelDB', verifyJWTandLevelDB) diff --git a/types/index.d.ts b/types/index.d.ts index 2de3e06..128cd50 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -16,10 +16,7 @@ declare module 'fastify' { Reply extends FastifyReply = FastifyReply >( functions: fastifyAuth.FastifyAuthFunction[] | (fastifyAuth.FastifyAuthFunction | fastifyAuth.FastifyAuthFunction[])[], - options?: { - relation?: fastifyAuth.FastifyAuthRelation; - run?: 'all'; - } + options?: fastifyAuth.FastifyAuthOptions ): preHandlerHookHandler; } } @@ -28,6 +25,12 @@ type FastifyAuth = FastifyPluginCallback declare namespace fastifyAuth { export type FastifyAuthRelation = 'and' | 'or' + export type FastifyAuthRunMode = null | 'all' + + export type FastifyAuthHandlerArray = ( + | FastifyAuthFunction + | FastifyAuthFunction[] + )[] export type FastifyAuthFunction< Request extends FastifyRequest = FastifyRequest, @@ -39,6 +42,23 @@ declare namespace fastifyAuth { done: (error?: Error) => void ) => void + /** + * Options for individual auth handler + */ + export interface FastifyAuthOptions { + /** + * The relation between the functions for this specific handler. + * Overrides the defaultRelation from plugin options. + */ + relation?: FastifyAuthRelation; + /** + * Run mode for the auth functions. + * + * @default null + */ + run?: FastifyAuthRunMode; + } + /** * @link [`fastify-auth` options documentation](https://github.com/fastify/fastify-auth#options) */