diff --git a/doc/api/hooks_server-side.md b/doc/api/hooks_server-side.md index 18de6b036..021f633f3 100644 --- a/doc/api/hooks_server-side.md +++ b/doc/api/hooks_server-side.md @@ -212,6 +212,50 @@ Things in context: I have no idea what this is useful for, someone else will have to add this description. +## preAuthorize +Called from: src/node/hooks/express/webaccess.js + +Things in context: + +1. req - the request object +2. res - the response object +3. next - bypass callback. If this is called instead of the normal callback then + all remaining access checks are skipped. + +This hook is called for each HTTP request before any authentication checks are +performed. Example uses: + +* Always grant access to static content. +* Process an OAuth callback. +* Drop requests from IP addresses that have failed N authentication checks + within the past X minutes. + +A preAuthorize function is always called for each request unless a preAuthorize +function from another plugin (if any) has already explicitly granted or denied +the request. + +You can pass the following values to the provided callback: + +* `[]` defers the access decision to the normal authentication and authorization + checks (or to a preAuthorize function from another plugin, if one exists). +* `[true]` immediately grants access to the requested resource, unless the + request is for an `/admin` page in which case it is treated the same as `[]`. + (This prevents buggy plugins from accidentally granting admin access to the + general public.) +* `[false]` immediately denies the request. The preAuthnFailure hook will be + called to handle the failure. + +Example: + +``` +exports.preAuthorize = (hookName, context, cb) => { + if (ipAddressIsFirewalled(context.req)) return cb([false]); + if (requestIsForStaticContent(context.req)) return cb([true]); + if (requestIsForOAuthCallback(context.req)) return cb([true]); + return cb([]); +}; +``` + ## authorize Called from: src/node/hooks/express/webaccess.js @@ -225,47 +269,23 @@ Things in context: This hook is called to handle authorization. It is especially useful for controlling access to specific paths. -A plugin's authorize function is typically called twice for each access: once -before authentication and again after. Specifically, it is called if all of the -following are true: +A plugin's authorize function is only called if all of the following are true: * The request is not for static content or an API endpoint. (Requests for static content and API endpoints are always authorized, even if unauthenticated.) -* Either authentication has not yet been performed (`context.req.session.user` - is undefined) or the user has successfully authenticated - (`context.req.session.user` is an object containing user-specific settings). -* If the user has successfully authenticated, the user is not an admin. (Admin - users are always authorized.) -* Either the request is for an `/admin` page or the `requireAuthentication` - setting is true. -* Either the request is for an `/admin` page, or the user has not yet - authenticated, or the user has authenticated and the `requireAuthorization` - setting is true. -* For pre-authentication invocations of a plugin's authorize function - (`context.req.session.user` is undefined), an authorize function from a - different plugin has not already caused the pre-authentication authorization - to pass or fail. -* For post-authentication invocations of a plugin's authorize function - (`context.req.session.user` is an object), an authorize function from a - different plugin has not already caused the post-authentication authorization - to pass or fail. +* The `requireAuthentication` and `requireAuthorization` settings are both true. +* The user has already successfully authenticated. +* The user is not an admin (admin users are always authorized). +* The path being accessed is not an `/admin` path (`/admin` paths can only be + accessed by admin users, and admin users are always authorized). +* An authorize function from a different plugin has not already caused + authorization to pass or fail. -For pre-authentication invocations of your authorize function, you can pass the -following values to the provided callback: +Note that the authorize hook cannot grant access to `/admin` pages. If admin +access is desired, the `is_admin` user setting must be set to true. This can be +set in the settings file or by the authenticate hook. -* `[true]`, `['create']`, or `['modify']` will immediately grant access without - requiring the user to authenticate. -* `[false]` will trigger authentication unless authentication is not required. -* `[]` or `undefined` will defer the decision to the next authorization plugin - (if any, otherwise it is the same as calling with `[false]`). - -**WARNING:** Your authorize function can be called for an `/admin` page even if -the user has not yet authenticated. It is your responsibility to fail or defer -authorization if you do not want to grant admin privileges to the general -public. - -For post-authentication invocations of your authorize function, you can pass the -following values to the provided callback: +You can pass the following values to the provided callback: * `[true]` or `['create']` will grant access to modify or create the pad if the request is for a pad, otherwise access is simply granted. (Access will be @@ -281,11 +301,6 @@ Example: ``` exports.authorize = (hookName, context, cb) => { const user = context.req.session.user; - if (!user) { - // The user has not yet authenticated so defer the pre-authentication - // authorization decision to the next plugin. - return cb([]); - } const path = context.req.path; // or context.resource if (isExplicitlyProhibited(user, path)) return cb([false]); if (isExplicitlyAllowed(user, path)) return cb([true]); @@ -395,6 +410,35 @@ exports.authFailure = (hookName, context, cb) => { }; ``` +## preAuthzFailure +Called from: src/node/hooks/express/webaccess.js + +Things in context: + +1. req - the request object +2. res - the response object + +This hook is called to handle a pre-authentication authorization failure. + +A plugin's preAuthzFailure function is only called if the pre-authentication +authorization failure was not already handled by a preAuthzFailure function from +another plugin. + +Calling the provided callback with `[true]` tells Etherpad that the failure was +handled and no further error handling is required. Calling the callback with +`[]` or `undefined` defers error handling to a preAuthzFailure function from +another plugin (if any, otherwise fall back to a generic 403 error page). + +Example: + +``` +exports.preAuthzFailure = (hookName, context, cb) => { + if (notApplicableToThisPlugin(context)) return cb([]); + context.res.status(403).send(renderFancy403Page(context.req)); + return cb([true]); +}; +``` + ## authnFailure Called from: src/node/hooks/express/webaccess.js @@ -435,7 +479,7 @@ Things in context: 1. req - the request object 2. res - the response object -This hook is called to handle an authorization failure. +This hook is called to handle a post-authentication authorization failure. A plugin's authzFailure function is only called if the authorization failure was not already handled by an authzFailure function from another plugin. diff --git a/src/node/hooks/express/webaccess.js b/src/node/hooks/express/webaccess.js index c9fd4e013..0f3a01ee7 100644 --- a/src/node/hooks/express/webaccess.js +++ b/src/node/hooks/express/webaccess.js @@ -24,6 +24,9 @@ exports.normalizeAuthzLevel = (level) => { return false; }; +// Exported so that tests can set this to 0 to avoid unnecessary test slowness. +exports.authnFailureDelayMs = 1000; + exports.checkAccess = (req, res, next) => { const hookResultMangle = (cb) => { return (err, data) => { @@ -31,12 +34,11 @@ exports.checkAccess = (req, res, next) => { }; }; + const requireAdmin = req.path.toLowerCase().indexOf('/admin') === 0; + // This may be called twice per access: once before authentication is checked and once after (if // settings.requireAuthorization is true). const authorize = (fail) => { - // Do not require auth for static paths and the API...this could be a bit brittle - if (req.path.match(/^\/(static|javascripts|pluginfw|api)/)) return next(); - const grant = (level) => { level = exports.normalizeAuthzLevel(level); if (!level) return fail(); @@ -51,35 +53,70 @@ exports.checkAccess = (req, res, next) => { user.padAuthorizations[padId] = level; return next(); }; - - if (req.path.toLowerCase().indexOf('/admin') !== 0) { - if (!settings.requireAuthentication) return grant('create'); - if (!settings.requireAuthorization && req.session && req.session.user) return grant('create'); - } - - if (req.session && req.session.user && req.session.user.is_admin) return grant('create'); - + const isAuthenticated = req.session && req.session.user; + if (isAuthenticated && req.session.user.is_admin) return grant('create'); + const requireAuthn = requireAdmin || settings.requireAuthentication; + if (!requireAuthn) return grant('create'); + if (!isAuthenticated) return grant(false); + if (requireAdmin && !req.session.user.is_admin) return grant(false); + if (!settings.requireAuthorization) return grant('create'); hooks.aCallFirst('authorize', {req, res, next, resource: req.path}, hookResultMangle(grant)); }; - // Access checking is done in three steps: + // Access checking is done in four steps: // - // 1) Try to just access the thing. If access fails (perhaps authentication has not yet completed, + // 1) Check the preAuthorize hook for early permit/deny (permit is only allowed for non-admin + // pages). If any plugin explicitly grants or denies access, skip the remaining steps. + // 2) Try to just access the thing. If access fails (perhaps authentication has not yet completed, // or maybe different credentials are required), go to the next step. - // 2) Try to authenticate. (Or, if already logged in, reauthenticate with different credentials if + // 3) Try to authenticate. (Or, if already logged in, reauthenticate with different credentials if // supported by the authn scheme.) If authentication fails, give the user a 401 error to // request new credentials. Otherwise, go to the next step. - // 3) Try to access the thing again. If this fails, give the user a 403 error. + // 4) Try to access the thing again. If this fails, give the user a 403 error. // // Plugins can use the 'next' callback (from the hook's context) to break out at any point (e.g., - // to process an OAuth callback). Plugins can use the authnFailure and authzFailure hooks to - // override the default error handling behavior (e.g., to redirect to a login page). + // to process an OAuth callback). Plugins can use the preAuthzFailure, authnFailure, and + // authzFailure hooks to override the default error handling behavior (e.g., to redirect to a + // login page). - let step1PreAuthenticate, step2Authenticate, step3Authorize; + let step1PreAuthorize, step2PreAuthenticate, step3Authenticate, step4Authorize; - step1PreAuthenticate = () => authorize(step2Authenticate); + step1PreAuthorize = () => { + // This aCallFirst predicate will cause aCallFirst to call the hook functions one at a time + // until one of them returns a non-empty list, with an exception: If the request is for an + // /admin page, truthy entries are filtered out before checking to see whether the list is + // empty. This prevents plugin authors from accidentally granting admin privileges to the + // general public. + const predicate = (results) => (results != null && + results.filter((x) => (!requireAdmin || !x)).length > 0); + hooks.aCallFirst('preAuthorize', {req, res, next}, (err, results) => { + if (err != null) { + httpLogger.error('Error in preAuthorize hook:', err); + return res.status(500).send('Internal Server Error'); + } + // Do not require auth for static paths and the API...this could be a bit brittle + if (req.path.match(/^\/(static|javascripts|pluginfw|api)/)) results.push(true); + if (requireAdmin) { + // Filter out all 'true' entries to prevent plugin authors from accidentally granting admin + // privileges to the general public. + results = results.filter((x) => !x); + } + if (results.length > 0) { + // Access was explicitly granted or denied. If any value is false then access is denied. + if (results.every((x) => x)) return next(); + return hooks.aCallFirst('preAuthzFailure', {req, res}, hookResultMangle((ok) => { + if (ok) return; + // No plugin handled the pre-authentication authorization failure. + res.status(403).send('Forbidden'); + })); + } + step2PreAuthenticate(); + }, predicate); + }; - step2Authenticate = () => { + step2PreAuthenticate = () => authorize(step3Authenticate); + + step3Authenticate = () => { if (settings.users == null) settings.users = {}; const ctx = {req, res, users: settings.users, next}; // If the HTTP basic auth header is present, extract the username and password so it can be @@ -107,7 +144,7 @@ exports.checkAccess = (req, res, next) => { // Delay the error response for 1s to slow down brute force attacks. setTimeout(() => { res.status(401).send('Authentication Required'); - }, 1000); + }, exports.authnFailureDelayMs); })); })); } @@ -122,11 +159,11 @@ exports.checkAccess = (req, res, next) => { let username = req.session.user.username; username = (username != null) ? username : ''; httpLogger.info(`Successful authentication from IP ${req.ip} for username ${username}`); - step3Authorize(); + step4Authorize(); })); }; - step3Authorize = () => authorize(() => { + step4Authorize = () => authorize(() => { return hooks.aCallFirst('authzFailure', {req, res}, hookResultMangle((ok) => { if (ok) return; return hooks.aCallFirst('authFailure', {req, res, next}, hookResultMangle((ok) => { @@ -137,7 +174,7 @@ exports.checkAccess = (req, res, next) => { })); }); - step1PreAuthenticate(); + step1PreAuthorize(); }; exports.secret = null; diff --git a/src/static/js/pluginfw/hooks.js b/src/static/js/pluginfw/hooks.js index 3b6be4d9a..13e36a645 100644 --- a/src/static/js/pluginfw/hooks.js +++ b/src/static/js/pluginfw/hooks.js @@ -61,14 +61,15 @@ exports.syncMapFirst = function (lst, fn) { return []; } -exports.mapFirst = function (lst, fn, cb) { +exports.mapFirst = function (lst, fn, cb, predicate) { + if (predicate == null) predicate = (x) => (x != null && x.length > 0); var i = 0; var next = function () { if (i >= lst.length) return cb(null, []); fn(lst[i++], function (err, result) { if (err) return cb(err); - if (result.length) return cb(null, result); + if (predicate(result)) return cb(null, result); next(); }); } @@ -142,7 +143,7 @@ exports.callFirst = function (hook_name, args) { }); } -function aCallFirst(hook_name, args, cb) { +function aCallFirst(hook_name, args, cb, predicate) { if (!args) args = {}; if (!cb) cb = function () {}; if (pluginDefs.hooks[hook_name] === undefined) return cb(null, []); @@ -151,20 +152,21 @@ function aCallFirst(hook_name, args, cb) { function (hook, cb) { hookCallWrapper(hook, hook_name, args, function (res) { cb(null, res); }); }, - cb + cb, + predicate ); } /* return a Promise if cb is not supplied */ -exports.aCallFirst = function (hook_name, args, cb) { +exports.aCallFirst = function (hook_name, args, cb, predicate) { if (cb === undefined) { return new Promise(function(resolve, reject) { aCallFirst(hook_name, args, function(err, res) { return err ? reject(err) : resolve(res); - }); + }, predicate); }); } else { - return aCallFirst(hook_name, args, cb); + return aCallFirst(hook_name, args, cb, predicate); } } diff --git a/tests/backend/specs/socketio.js b/tests/backend/specs/socketio.js index f6cd25a63..717c4f7b8 100644 --- a/tests/backend/specs/socketio.js +++ b/tests/backend/specs/socketio.js @@ -9,12 +9,16 @@ const server = require(m('node/server')); const setCookieParser = require(m('node_modules/set-cookie-parser')); const settings = require(m('node/utils/Settings')); const supertest = require(m('node_modules/supertest')); +const webaccess = require(m('node/hooks/express/webaccess')); const logger = log4js.getLogger('test'); let agent; let baseUrl; +let authnFailureDelayMsBackup; before(async function() { + authnFailureDelayMsBackup = webaccess.authnFailureDelayMs; + webaccess.authnFailureDelayMs = 0; // Speed up tests. settings.port = 0; settings.ip = 'localhost'; const httpServer = await server.start(); @@ -24,6 +28,7 @@ before(async function() { }); after(async function() { + webaccess.authnFailureDelayMs = authnFailureDelayMsBackup; await server.stop(); }); @@ -135,7 +140,6 @@ describe('socket.io access checks', function() { authorize = () => true; authorizeHooksBackup = plugins.hooks.authorize; plugins.hooks.authorize = [{hook_fn: (hookName, {req}, cb) => { - if (req.session.user == null) return cb([]); // Hasn't authenticated yet. return cb([authorize(req)]); }}]; await cleanUpPads(); diff --git a/tests/backend/specs/webaccess.js b/tests/backend/specs/webaccess.js index 029b41ddb..7bce670c4 100644 --- a/tests/backend/specs/webaccess.js +++ b/tests/backend/specs/webaccess.js @@ -6,11 +6,15 @@ const plugins = require(m('static/js/pluginfw/plugin_defs')); const server = require(m('node/server')); const settings = require(m('node/utils/Settings')); const supertest = require(m('node_modules/supertest')); +const webaccess = require(m('node/hooks/express/webaccess')); let agent; const logger = log4js.getLogger('test'); +let authnFailureDelayMsBackup; before(async function() { + authnFailureDelayMsBackup = webaccess.authnFailureDelayMs; + webaccess.authnFailureDelayMs = 0; // Speed up tests. settings.port = 0; settings.ip = 'localhost'; const httpServer = await server.start(); @@ -20,10 +24,11 @@ before(async function() { }); after(async function() { + webaccess.authnFailureDelayMs = authnFailureDelayMsBackup; await server.stop(); }); -describe('webaccess without any plugins', function() { +describe('webaccess: without plugins', function() { const backup = {}; before(async function() { @@ -95,7 +100,261 @@ describe('webaccess without any plugins', function() { }); }); -describe('webaccess with authnFailure, authzFailure, authFailure hooks', function() { +describe('webaccess: preAuthorize, authenticate, and authorize hooks', function() { + let callOrder; + const Handler = class { + constructor(hookName, suffix) { + this.called = false; + this.hookName = hookName; + this.innerHandle = () => []; + this.id = hookName + suffix; + this.checkContext = () => {}; + } + handle(hookName, context, cb) { + assert.equal(hookName, this.hookName); + assert(context != null); + assert(context.req != null); + assert(context.res != null); + assert(context.next != null); + this.checkContext(context); + assert(!this.called); + this.called = true; + callOrder.push(this.id); + return cb(this.innerHandle(context.req)); + } + }; + const handlers = {}; + const hookNames = ['preAuthorize', 'authenticate', 'authorize']; + const hooksBackup = {}; + const settingsBackup = {}; + + beforeEach(async function() { + callOrder = []; + hookNames.forEach((hookName) => { + // Create two handlers for each hook to test deferral to the next function. + const h0 = new Handler(hookName, '_0'); + const h1 = new Handler(hookName, '_1'); + handlers[hookName] = [h0, h1]; + hooksBackup[hookName] = plugins.hooks[hookName] || []; + plugins.hooks[hookName] = [{hook_fn: h0.handle.bind(h0)}, {hook_fn: h1.handle.bind(h1)}]; + }); + hooksBackup.preAuthzFailure = plugins.hooks.preAuthzFailure || []; + Object.assign(settingsBackup, settings); + settings.users = { + admin: {password: 'admin-password', is_admin: true}, + user: {password: 'user-password'}, + }; + }); + afterEach(async function() { + Object.assign(plugins.hooks, hooksBackup); + Object.assign(settings, settingsBackup); + }); + + describe('preAuthorize', function() { + beforeEach(async function() { + settings.requireAuthentication = false; + settings.requireAuthorization = false; + }); + + it('defers if it returns []', async function() { + await agent.get('/').expect(200); + // Note: The preAuthorize hook always runs even if requireAuthorization is false. + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']); + }); + it('bypasses authenticate and authorize hooks when true is returned', async function() { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + handlers.preAuthorize[0].innerHandle = () => [true]; + await agent.get('/').expect(200); + assert.deepEqual(callOrder, ['preAuthorize_0']); + }); + it('bypasses authenticate and authorize hooks when false is returned', async function() { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + handlers.preAuthorize[0].innerHandle = () => [false]; + await agent.get('/').expect(403); + assert.deepEqual(callOrder, ['preAuthorize_0']); + }); + it('bypasses authenticate and authorize hooks for static content, defers', async function() { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + await agent.get('/static/robots.txt').expect(200); + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']); + }); + it('cannot grant access to /admin', async function() { + handlers.preAuthorize[0].innerHandle = () => [true]; + await agent.get('/admin/').expect(401); + // Notes: + // * preAuthorize[1] is called despite preAuthorize[0] returning a non-empty list because + // 'true' entries are ignored for /admin/* requests. + // * The authenticate hook always runs for /admin/* requests even if + // settings.requireAuthentication is false. + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', + 'authenticate_0', 'authenticate_1']); + }); + it('can deny access to /admin', async function() { + handlers.preAuthorize[0].innerHandle = () => [false]; + await agent.get('/admin/').auth('admin', 'admin-password').expect(403); + assert.deepEqual(callOrder, ['preAuthorize_0']); + }); + it('runs preAuthzFailure hook when access is denied', async function() { + handlers.preAuthorize[0].innerHandle = () => [false]; + let called = false; + plugins.hooks.preAuthzFailure = [{hook_fn: (hookName, {req, res}, cb) => { + assert.equal(hookName, 'preAuthzFailure'); + assert(req != null); + assert(res != null); + assert(!called); + called = true; + res.status(200).send('injected'); + return cb([true]); + }}]; + await agent.get('/admin/').auth('admin', 'admin-password').expect(200, 'injected'); + assert(called); + }); + it('returns 500 if an exception is thrown', async function() { + handlers.preAuthorize[0].innerHandle = () => { throw new Error('exception test'); }; + await agent.get('/').expect(500); + }); + }); + + describe('authenticate', function() { + beforeEach(async function() { + settings.requireAuthentication = true; + settings.requireAuthorization = false; + }); + + it('is not called if !requireAuthentication and not /admin/*', async function() { + settings.requireAuthentication = false; + await agent.get('/').expect(200); + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']); + }); + it('is called if !requireAuthentication and /admin/*', async function() { + settings.requireAuthentication = false; + await agent.get('/admin/').expect(401); + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', + 'authenticate_0', 'authenticate_1']); + }); + it('defers if empty list returned', async function() { + await agent.get('/').expect(401); + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', + 'authenticate_0', 'authenticate_1']); + }); + it('does not defer if return [true], 200', async function() { + handlers.authenticate[0].innerHandle = (req) => { req.session.user = {}; return [true]; }; + await agent.get('/').expect(200); + // Note: authenticate_1 was not called because authenticate_0 handled it. + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']); + }); + it('does not defer if return [false], 401', async function() { + handlers.authenticate[0].innerHandle = (req) => [false]; + await agent.get('/').expect(401); + // Note: authenticate_1 was not called because authenticate_0 handled it. + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']); + }); + it('falls back to HTTP basic auth', async function() { + await agent.get('/').auth('user', 'user-password').expect(200); + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', + 'authenticate_0', 'authenticate_1']); + }); + it('passes settings.users in context', async function() { + handlers.authenticate[0].checkContext = ({users}) => { + assert.equal(users, settings.users); + }; + await agent.get('/').expect(401); + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', + 'authenticate_0', 'authenticate_1']); + }); + it('passes user, password in context if provided', async function() { + handlers.authenticate[0].checkContext = ({username, password}) => { + assert.equal(username, 'user'); + assert.equal(password, 'user-password'); + }; + await agent.get('/').auth('user', 'user-password').expect(200); + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', + 'authenticate_0', 'authenticate_1']); + }); + it('does not pass user, password in context if not provided', async function() { + handlers.authenticate[0].checkContext = ({username, password}) => { + assert(username == null); + assert(password == null); + }; + await agent.get('/').expect(401); + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', + 'authenticate_0', 'authenticate_1']); + }); + it('errors if req.session.user is not created', async function() { + handlers.authenticate[0].innerHandle = () => [true]; + await agent.get('/').expect(500); + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']); + }); + it('returns 500 if an exception is thrown', async function() { + handlers.authenticate[0].innerHandle = () => { throw new Error('exception test'); }; + await agent.get('/').expect(500); + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']); + }); + }); + + describe('authorize', function() { + beforeEach(async function() { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + }); + + it('is not called if !requireAuthorization (non-/admin)', async function() { + settings.requireAuthorization = false; + await agent.get('/').auth('user', 'user-password').expect(200); + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', + 'authenticate_0', 'authenticate_1']); + }); + it('is not called if !requireAuthorization (/admin)', async function() { + settings.requireAuthorization = false; + await agent.get('/admin/').auth('admin', 'admin-password').expect(200); + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', + 'authenticate_0', 'authenticate_1']); + }); + it('defers if empty list returned', async function() { + await agent.get('/').auth('user', 'user-password').expect(403); + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', + 'authenticate_0', 'authenticate_1', + 'authorize_0', 'authorize_1']); + }); + it('does not defer if return [true], 200', async function() { + handlers.authorize[0].innerHandle = () => [true]; + await agent.get('/').auth('user', 'user-password').expect(200); + // Note: authorize_1 was not called because authorize_0 handled it. + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', + 'authenticate_0', 'authenticate_1', + 'authorize_0']); + }); + it('does not defer if return [false], 403', async function() { + handlers.authorize[0].innerHandle = (req) => [false]; + await agent.get('/').auth('user', 'user-password').expect(403); + // Note: authorize_1 was not called because authorize_0 handled it. + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', + 'authenticate_0', 'authenticate_1', + 'authorize_0']); + }); + it('passes req.path in context', async function() { + handlers.authorize[0].checkContext = ({resource}) => { + assert.equal(resource, '/'); + }; + await agent.get('/').auth('user', 'user-password').expect(403); + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', + 'authenticate_0', 'authenticate_1', + 'authorize_0', 'authorize_1']); + }); + it('returns 500 if an exception is thrown', async function() { + handlers.authorize[0].innerHandle = () => { throw new Error('exception test'); }; + await agent.get('/').auth('user', 'user-password').expect(500); + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', + 'authenticate_0', 'authenticate_1', + 'authorize_0']); + }); + }); +}); + +describe('webaccess: authnFailure, authzFailure, authFailure hooks', function() { const Handler = class { constructor(hookName) { this.hookName = hookName;