bump package version to 1.5.0
[squeep-authentication-module] / lib / authenticator.js
index efa3d844e7502cd024c6c99da830342dfe04954f..3df0b9db17ccdf763ce13b6340e912b0d6545266 100644 (file)
@@ -4,91 +4,295 @@ const common = require('./common');
 const Enum = require('./enum');
 const Errors = require('./errors');
 const { MysteryBox } = require('@squeep/mystery-box');
+const { TOTP } = require('@squeep/totp');
 const { name: packageName } = require('../package');
 
 const _fileScope = common.fileScope(__filename);
 
+/**
+ * Wrangles the fiddly bits of authentication.
+ * Handles checking of credentials and otp codes, creating and validating
+ * cookie-based sessions, Basic auth.
+ * Interacts with the authentication database interface.
+ */
+
+/**
+ * @typedef {import('node:http')} http
+ */
+
 class Authenticator {
   /**
-   * @param {Console} logger
-   * @param {*} db
-   * @param {(dbCtx: any, identifier: String) => Promise<AuthInfo> } db.authenticationGet
-   * @param {(dbCtx: any, identifier: String) => Promise<void>} db.authenticationSuccess
-   * @param {((dbCtx: any) => Promise<any>) => Promise<void>} db.context
-   * @param {Object} options
-   * @param {Object} options.authenticator
-   * @param {Boolean} options.authenticator.secureAuthOnly
-   * @param {String[]} options.authenticator.forbiddenPAMIdentifiers
-   * @param {String[]} options.authenticator.authnEnabled
-   * @param {String[]=} options.authenticator.loginBlurb
-   * @param {String[]=} options.authenticator.indieAuthBlurb
-   * @param {String[]=} options.authenticator.userBlurb
+   * @typedef {object} AuthInfo
+   * @property {string} identifier identifier
+   * @property {string} credential hashed credential
+   * @property {string=} otpKey optional otp key
+   */
+  /**
+   * @callback DBContextExec
+   * @param {object} dbCtx
+   * @returns {Promise<any>}
+   */
+  /**
+   * @typedef {object} AuthDBInterface
+   * @property {(DBContextExec) => Promise<any>} context db context runner
+   * @property {(dbCtx: any, identifier: string) => Promise<AuthInfo>} authenticationGet get an authentication entry
+   * @property {(dbCtx: any, identifier: string) => Promise<void>} authenticationSuccess store a successful authentication event
+   * @property {(dbCtx: any, identifier: string, credential: string, otpKey: string=) => Promise<void>} authenticationUpsert update an authentication entry
+   * @property {(dbCtx: any, identifier: string, otpKey: string) => Promise<void>} authenticationUpdateOTPKey update otp key
+   * @property {(dbCtx: any, identifier: string, credential: AuthInfo) => Promise<void>} authenticationUpdateCredential update credential
+   */
+  /**
+   * @typedef {object} ConsoleLike
+   * @property {Function} debug log debug
+   * @property {Function} error log error
+   * @property {Function} info log info
+   */
+  /**
+   * @param {ConsoleLike} logger logger instance
+   * @param {AuthDBInterface} db db instance
+   * @param {object} options options
+   * @param {string | string[]} options.encryptionSecret encryption secret
+   * @param {object} options.authenticator authenticator options
+   * @param {boolean} options.authenticator.secureAuthOnly disable auth over non-https
+   * @param {string[]} options.authenticator.forbiddenPAMIdentifiers reject these identifiers for PAM auth
+   * @param {string[]} options.authenticator.authnEnabled in order of preference for storing new credentials
+   * @param {number=} options.authenticator.inactiveSessionLifespanSeconds session timeout
+   * @param {string[]=} options.authenticator.loginBlurb text for login page
+   * @param {string[]=} options.authenticator.indieAuthBlurb text for indieauth login section
+   * @param {string[]=} options.authenticator.userBlurb text for local user login section
+   * @param {string[]=} options.authenticator.otpBlurb text for otp entry
+   * @param {string=} options.dingus dingus options
+   * @param {string=} options.dingus.proxyPrefix base url prefix
    */
   constructor(logger, db, options) {
     this.logger = logger;
     this.db = db;
     this.options = options;
     this.basicRealm = options.authenticator.basicRealm || packageName;
-    this.secureAuthOnly = options.authenticator.secureAuthOnly;
+    this.secureAuthOnly = options.authenticator.secureAuthOnly ?? true;
+    this.proxyPrefix = options.dingus?.proxyPrefix ?? '';
 
+    // First construct map of all available code-supported auth mechanisms.
     this.authn = {
-      DEBUG_ANY: {},
       indieAuth: {},
+      plain: {},
     };
     try {
       this.authn.argon2 = require('argon2');
-    } catch (e) { /**/ }
+    } catch (e) { /**/ } // eslint-disable-line no-unused-vars
     try {
       this.authn.pam = require('node-linux-pam');
-      this.forbiddenPAMIdentifiers = options.authenticator.forbiddenPAMIdentifiers;
-    } catch (e) { /**/ }
-
-    this.authnEnabled = Object.keys(this.authn).filter((auth) => options.authenticator.authnEnabled.includes(auth));
-    this.logger.debug(_fileScope('constructor'), 'available mechanisms', { authn: this.authnEnabled });
+      this.forbiddenPAMIdentifiers = options.authenticator.forbiddenPAMIdentifiers ?? ['root'];
+    } catch (e) { /**/ } // eslint-disable-line no-unused-vars
+
+    // Track which authn methods we can change credentials et cetera.
+    const authnUpdatable = ['plain', 'argon2'];
+
+    // Filter configured mechanisms from available, note the first as which to use for setting new credentials.
+    this.authnEnabled = new Set();
+    this.authnPreferred = undefined; // For updating credentials
+    options.authenticator.authnEnabled.forEach((authn) => {
+      if (authn in this.authn) {
+        this.authnEnabled.add(authn);
+        if (!this.authnPreferred && authnUpdatable.includes(authn)) {
+          this.authnPreferred = authn;
+        }
+      }
+    });
+    this.logger.debug(_fileScope('constructor'), 'available mechanisms', { authn: Array.from(this.authnEnabled), preferred: this.authnPreferred });
 
-    if (this.authnEnabled.length === 0) {
+    if (this.authnEnabled.size === 0) {
       throw new Error('no authentication mechanisms available');
     }
 
-    this.mysteryBox = new MysteryBox(logger, options);
+    this.mysteryBox = new MysteryBox(options);
+    this.mysteryBox.on('statistics', common.mysteryBoxLogger(logger, _fileScope(this.constructor.name)));
+
+    this.cookieLifespan = options.authenticator.inactiveSessionLifespanSeconds || 60 * 60 * 24 * 32;
+
+    this.TOTP = TOTP; // For ease of test stubbing.
+  }
+
+
+  /**
+   * Populate the authentication database with a new identifier, the
+   * secured credential, and optionally an OTP key.
+   * @param {*} dbCtx db context
+   * @param {string} identifier identifier
+   * @param {string} credential plaintext
+   * @param {string=} otpKey otp key
+   * @returns {Promise<void>}
+   */
+  async createIdentifier(dbCtx, identifier, credential, otpKey = null) {
+    const _scope = _fileScope('createIdentifier');
+    try {
+      const secureCredential = await this._secureCredential(credential);
+      await this.db.authenticationUpsert(dbCtx, identifier, secureCredential, otpKey);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, identifier });
+      throw e;
+    }
+  }
+
+
+  /**
+   * Update the authentication database with a new secured credential
+   * for an indentifier.
+   * @param {*} dbCtx dbCtx
+   * @param {string} identifier identifier
+   * @param {string} credential plaintext
+   * @returns {Promise<void>}
+   */
+  async updateCredential(dbCtx, identifier, credential) {
+    const _scope = _fileScope('updateCredential');
+    try {
+      const secureCredential = await this._secureCredential(credential);
+      await this.db.authenticationUpdateCredential(dbCtx, identifier, secureCredential);
+      this.logger.info(_scope, 'success', { identifier });
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, identifier });
+      throw e;
+    }
+  }
+
+
+  /**
+   * Encode a plaintext credential in the preferred way to store in database.
+   * @param {string} credential plaintext
+   * @param {string=} authn authentication mechanism
+   * @returns {Promise<string>} encoded credential
+   */
+  async _secureCredential(credential, authn = this.authnPreferred) {
+    const _scope = _fileScope('_secureCredential');
+    try {
+      switch (authn) {
+        case 'plain':
+          return '$plain$' + credential;
+
+        case 'argon2':
+          return await this.authn.argon2.hash(credential, { type: this.authn.argon2.argon2id });
+
+        default:
+          throw new RangeError('Unknown authn');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, authn });
+      throw e;
+    }
+  }
+
+
+  /**
+   * Checks a supplied credential against supplied data.
+   * @param {AuthInfo} authData from database
+   * @param {string} credential plaintext
+   * @returns {Promise<boolean>} is valid
+   */
+  async _validateAuthDataCredential(authData, credential) {
+    const _scope = _fileScope('_validateAuthDataCredential');
+
+    if (!authData?.credential) {
+      this.logger.debug(_scope, 'failed, no authInfo');
+      return false;
+    }
+    if (authData.credential.startsWith('$argon2')
+    &&  this.authnEnabled.has('argon2')) {
+      return await this._isValidArgon2Identifier(authData, credential);
+    }
+    if (authData.credential.startsWith('$PAM$')
+    &&  this.authnEnabled.has('pam')) {
+      return await this._isValidPAMIdentifier(authData, credential);
+    }
+    if (authData.credential.startsWith('$plain$')
+    &&  this.authnEnabled.has('plain')) {
+      return this.constructor._isValidPlainIdentifier(authData, credential);
+    }
+    this.logger.error(_scope, 'failed, unknown or unsupported type of stored credential', { authData });
+    return false;
+  }
+
+
+  /**
+   * Check argon2.
+   * @param {AuthInfo} authData auth entry
+   * @param {string} credential to check
+   * @returns {Promise<boolean>} is valid
+   */
+  async _isValidArgon2Identifier(authData, credential) {
+    return await this.authn.argon2.verify(authData.credential, credential);
+  }
+
+
+  /**
+   * Check plaintext.
+   * @param {AuthInfo} authData auth entry
+   * @param {string} credential to check
+   * @returns {Promise<boolean>} is valid
+   */
+  static _isValidPlainIdentifier(authData, credential) {
+    return authData.credential.substring('$plain$'.length) === credential;
+  }
+
+
+  /**
+   * Check system PAM.
+   * @param {AuthInfo} authData auth entry
+   * @param {string} credential to check
+   * @returns {Promise<boolean>} is valid
+   */
+  async _isValidPAMIdentifier(authData, credential) {
+    const _scope = _fileScope('_isValidPAMIdentifier');
+    const { identifier } = authData;
+    let isValid = false;
+    if (this.forbiddenPAMIdentifiers.includes(identifier)) {
+      return false;
+    }
+    try {
+      await this.authn.pam.pamAuthenticatePromise({ username: identifier, password: credential });
+      isValid = true;
+    } catch (e) {
+      this.logger.debug(_scope, 'failed', { error: e });
+      if (!(e instanceof this.authn.pam.PamError)) {
+        throw e;
+      }
+    }
+    return isValid;
   }
 
 
   /**
    * Check local auth entries.
    * Sets ctx.authenticatedId if valid.
-   * @param {String} identifier
-   * @param {String} credential
-   * @param {Object} ctx
+   * Sets ctx.otpKey if account has otpKey.
+   * @param {string} identifier identifier
+   * @param {string} credential to check
+   * @param {object} ctx context
+   * @returns {Promise<boolean>} is valid
    */
   async isValidIdentifierCredential(identifier, credential, ctx) {
     const _scope = _fileScope('isValidIdentifierCredential');
-    this.logger.debug(_scope, 'called', { identifier, credential: '*'.repeat(credential.length), ctx });
+    this.logger.debug(_scope, 'called', { identifier, credential: '*'.repeat((credential || '').length), ctx });
 
     let isValid = false;
 
+    if (typeof credential === 'undefined') {
+      return isValid;
+    }
+
     await this.db.context(async (dbCtx) => {
       const authData = await this.db.authenticationGet(dbCtx, identifier);
       if (!authData) {
         this.logger.debug(_scope, 'failed, invalid identifier', { ctx, identifier });
-      } else {
-        if (authData.credential.startsWith('$argon2')
-        &&  this.authnEnabled.includes('argon2')) {
-          isValid = await this.authn.argon2.verify(authData.credential, credential);
-        } else if (authData.credential.startsWith('$PAM$')
-        &&         this.authnEnabled.includes('pam')) {
-          isValid = this._isValidPAMIdentifier(identifier, credential);
-        } else {
-          this.logger.error(_scope, 'failed, unknown type of stored credential', { identifier, ctx });
-        }
+        return;
       }
 
-      if (this.authnEnabled.includes('DEBUG_ANY')) {
-        isValid = true;
-      }
+      isValid = await this._validateAuthDataCredential(authData, credential);
 
       if (isValid) {
         ctx.authenticationId = identifier;
+        if (authData.otpKey) {
+          ctx.otpKey = authData.otpKey;
+        }
         await this.db.authenticationSuccess(dbCtx, identifier);
       }
     }); // dbCtx
@@ -98,40 +302,64 @@ class Authenticator {
 
 
   /**
-   * Check system PAM.
-   * @param {String} identifier
-   * @param {String} credential
-   * @returns {Boolean}
+   * @typedef {object} OTPState
+   * @property {string} key otp key
+   * @property {number} attempt count of attempts
+   * @property {number} epochMs when entry was initiated
    */
-  async _isValidPAMIdentifier(identifier, credential) {
-    const _scope = _fileScope('_isValidPAMIdentifier');
-    let isValid = false;
-    if (this.forbiddenPAMIdentifiers.includes(identifier)) {
-      return false;
+  /**
+   * Validate if an entered otp token matches the key.
+   * @param {OTPState} state otp state
+   * @param {string} otp to check
+   * @returns {Enum.OTPResult} result
+   */
+  checkOTP(state, otp) {
+    const totp = new this.TOTP({
+      keyEncoding: 'base32',
+      ...this.options?.authenticator?.otpOptions,
+      key: state.key,
+    });
+    const isValid = totp.validate(otp);
+    if (isValid) {
+      return Enum.OTPResult.Valid;
+    }
+    const otpAttemptsRemain = state.attempt <= (this.options?.authenticator?.otpAttemptsAllowed ?? 3);
+    const otpWithinWindow = state.epochMs + (this.options?.authenticator?.otpWindowMs ?? 600000) > Date.now();
+    if (otpAttemptsRemain && otpWithinWindow) {
+      return Enum.OTPResult.InvalidSoftFail;
     }
+    return Enum.OTPResult.InvalidHardFail;
+  }
+
+
+  /**
+   * Update the authentication database with a new otp key.
+   * @param {*} dbCtx db context
+   * @param {string} identifier identifier
+   * @param {string=} otpKey otp key
+   */
+  async updateOTPKey(dbCtx, identifier, otpKey) {
+    const _scope = _fileScope('updateOTPKey');
     try {
-      await this.authn.pam.pamAuthenticatePromise({ username: identifier, password: credential });
-      isValid = true;
+      await this.db.authenticationUpdateOTPKey(dbCtx, identifier, otpKey);
+      this.logger.info(_scope, 'otp key updated');
     } catch (e) {
-      this.logger.debug(_scope, 'failed', { error: e });
-      if (!(e instanceof this.authn.pam.PamError)) {
-        throw e;
-      }
+      this.logger.error(_scope, 'failed', { error: e, identifier });
     }
-    return isValid;
   }
 
 
   /**
    * Check for valid Basic auth, updates ctx with identifier if valid.
-   * @param {String} credentials
-   * @param {Object} ctx
-   * @returns {Boolean}
+   * @param {string} authValue basic auth value (base64)
+   * @param {object} ctx context
+   * @returns {Promise<boolean>} is valid
    */
-  async isValidBasic(credentials, ctx) {
+  async isValidBasic(authValue, ctx) {
     const _scope = _fileScope('isValidBasic');
     this.logger.debug(_scope, 'called', { ctx });
 
+    const credentials = Buffer.from(authValue, 'base64').toString('utf-8');
     const [identifier, credential] = common.splitFirst(credentials, ':', '');
 
     return this.isValidIdentifierCredential(identifier, credential, ctx);
@@ -140,8 +368,9 @@ class Authenticator {
 
   /**
    * Determine which Authorization header is available, and if it is valid.
-   * @param {String} authorizationHeader
-   * @param {Object} ctx
+   * @param {string} authorizationHeader request header
+   * @param {object} ctx context
+   * @returns {Promise<boolean>} is valid
    */
   async isValidAuthorization(authorizationHeader, ctx) {
     const _scope = _fileScope('isValidAuthorization');
@@ -151,8 +380,7 @@ class Authenticator {
     // eslint-disable-next-line sonarjs/no-small-switch
     switch (authMethod.toLowerCase()) {
       case 'basic': {
-        const credentials = Buffer.from(authString, 'base64').toString('utf-8');
-        return this.isValidBasic(credentials, ctx);
+        return this.isValidBasic(authString, ctx);
       }
 
       default:
@@ -164,7 +392,7 @@ class Authenticator {
 
   /**
    * Send a response requesting basic auth.
-   * @param {http.ServerResponse} res
+   * @param {http.ServerResponse} res response
    */
   requestBasic(res) {
     res.setHeader(Enum.Header.WWWAuthenticate, `Basic realm="${this.basicRealm}", charset="UTF-8"`);
@@ -172,40 +400,20 @@ class Authenticator {
   }
 
 
-  /**
-   * 
-   * @param {String} cookieHeader
-   */
-  static _cookieParse(cookieHeader) {
-    const cookie = {};
-    (cookieHeader || '').split(/; */).forEach((field) => {
-      const [ name, value ] = common.splitFirst(field, '=', null).map((x) => x && decodeURIComponent(x.trim()));
-      if (name && !(name in cookie)) {
-        if (value && value.startsWith('"') && value.endsWith('"')) {
-          cookie[name] = value.slice(1, -1); // eslint-disable-line security/detect-object-injection
-        } else {
-          cookie[name] = value; // eslint-disable-line security/detect-object-injection
-        }
-      }
-    });
-    return cookie;
-  }
-
-
   /**
    * Attempt to parse a session cookie, and determine if it contains an
    * authenticated user.
-   * Restores ctx.session from cookie data, sets ctx.authenticationId if identifier exists.
-   * @param {Object} ctx
-   * @param {String} cookieHeader
-   * @returns {Boolean}
+   * Restores ctx.session from cookie data, sets ctx.authenticationId to
+   * identifier or profile for session.
+   * @param {object} ctx context
+   * @param {object} ctx.cookie cookies object
+   * @returns {Promise<boolean>} is valid
    */
-  async isValidCookieAuth(ctx, cookieHeader) {
+  async isValidCookieAuth(ctx) {
     const _scope = _fileScope('isValidCookieAuth');
-    this.logger.debug(_scope, 'called', { ctx, cookieHeader });
+    this.logger.debug(_scope, 'called', { ctx });
 
-    const cookie = Authenticator._cookieParse(cookieHeader);
-    const cookieValue = cookie[Enum.SessionCookie];
+    const cookieValue = ctx.cookie?.[Enum.SessionCookie];
 
     if (!cookieValue) {
       return false;
@@ -215,7 +423,7 @@ class Authenticator {
       this.logger.debug(_scope, 'unpacked cookie', { ctx });
 
       const hasIdentifier = !!ctx.session.authenticatedIdentifier;
-      const hasProfile = !!ctx.session.authenticatedProfile && this.authnEnabled.includes('indieAuth');
+      const hasProfile = !!ctx.session.authenticatedProfile && this.authnEnabled.has('indieAuth');
       const isValid = hasIdentifier || hasProfile;
       if (isValid) {
         ctx.authenticationId = ctx.session.authenticatedIdentifier || ctx.session.authenticatedProfile;
@@ -223,7 +431,7 @@ class Authenticator {
 
       return isValid;
     } catch (e) {
-      this.logger.debug(_scope, 'could not unpack cookie', { error:e, ctx });
+      this.logger.debug(_scope, 'could not unpack cookie', { error: e, ctx });
       return false;
     }
   }
@@ -231,16 +439,23 @@ class Authenticator {
 
   /**
    * Check for a valid session.
-   * @param {http.ClientRequest} req
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx
-   * @param {String} loginPath
-   * @param {Boolean} required
-   * @param {Boolean} profilesAllowed
-   * @returns {Boolean}
+   * If a valid session cookie is present, refreshes the timeout for it.
+   * If not, and one is required, redirects to the login url.
+   * Convenience wrappers for option combinations:
+   * @see sessionRequired
+   * @see sessionRequiredLocal
+   * @see sessionOptional
+   * @see sessionOptionalLocal
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
+   * @param {string} loginPath url path to redirect to when login is required
+   * @param {boolean} required redirect to login url if no valid session
+   * @param {boolean} profilesAllowed if true, an indieauth session is valid, otherwise only identifier/credential
+   * @returns {Promise<boolean>} is valid session
    */
   async sessionCheck(req, res, ctx, loginPath, required = true, profilesAllowed = true) {
-    const _scope = _fileScope('check');
+    const _scope = _fileScope('sessionCheck');
     this.logger.debug(_scope, 'called', { ctx, loginPath, required, profilesAllowed });
 
     if (this.secureAuthOnly
@@ -253,27 +468,30 @@ class Authenticator {
       }
     }
 
-    const sessionCookie = req.getHeader(Enum.Header.Cookie);
-    if (sessionCookie
-    &&  await this.isValidCookieAuth(ctx, sessionCookie)
+    if (await this.isValidCookieAuth(ctx)
     &&  (ctx.session.authenticatedIdentifier
          || (profilesAllowed && ctx.session.authenticatedProfile))) {
       this.logger.debug(_scope, 'valid session cookie', { ctx });
+      // Refresh timeout on valid session.
+      common.addCookie(res, Enum.SessionCookie, ctx.cookie[Enum.SessionCookie], {
+        httpOnly: true,
+        maxAge: this.cookieLifespan,
+        sameSite: 'Lax',
+        path: `${this.proxyPrefix}/`,
+        secure: this.secureAuthOnly,
+      });
       return true;
     }
 
     if (required) {
       // Clear any existing invalid session
-      const cookieParts = [
-        `${Enum.SessionCookie}=""`,
-        'HttpOnly',
-        'Max-Age=0',
-        `Path=${this.options.dingus.proxyPrefix}/`,
-      ];
-      if (this.options.authenticator.secureAuthOnly) {
-        cookieParts.push('Secure');
-      }
-      res.setHeader(Enum.Header.SetCookie, cookieParts.join('; '));
+      common.addCookie(res, Enum.SessionCookie, '""', {
+        httpOnly: true,
+        maxAge: 0,
+        sameSite: 'Lax',
+        path: `${this.proxyPrefix}/`,
+        secure: this.secureAuthOnly,
+      });
 
       res.statusCode = 302;
       res.setHeader(Enum.Header.Location, `${loginPath}?r=${encodeURIComponent(req.url)}`);
@@ -286,11 +504,11 @@ class Authenticator {
 
   /**
    * Requires a valid session with a local identifier. Redirects to loginPath if not.
-   * @param {http.ClientRequest} req
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx
-   * @param {String} loginPath
-   * @returns {Boolean}
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
+   * @param {string} loginPath url path to redirect to when login is needed
+   * @returns {Promise<boolean>} is valid session
    */
   async sessionRequiredLocal(req, res, ctx, loginPath) {
     return this.sessionCheck(req, res, ctx, loginPath, true, false);
@@ -299,11 +517,11 @@ class Authenticator {
 
   /**
    * Requires a valid session with either a local identifier or a profile. Redirects to loginPath if not.
-   * @param {http.ClientRequest} req
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx
-   * @param {String} loginPath
-   * @returns {Boolean}
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
+   * @param {string} loginPath url path to redirect to when login is needed
+   * @returns {Promise<boolean>} is valid session
    */
   async sessionRequired(req, res, ctx, loginPath) {
     return this.sessionCheck(req, res, ctx, loginPath);
@@ -312,11 +530,10 @@ class Authenticator {
 
   /**
    * Check for a valid session with a local identifier, but do nothing if not.
-   * @param {http.ClientRequest} req
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx
-   * @param {String} loginPath
-   * @returns {Boolean}
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
+   * @returns {Promise<boolean>} is valid session
    */
   async sessionOptionalLocal(req, res, ctx) {
     return this.sessionCheck(req, res, ctx, undefined, false, false);
@@ -325,11 +542,10 @@ class Authenticator {
 
   /**
    * Check for a valid session with either a local identifier or a profile, but do nothing if not.
-   * @param {http.ClientRequest} req
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx
-   * @param {String} loginPath
-   * @returns {Boolean}
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
+   * @returns {Promise<boolean>} is valid session
    */
   async sessionOptional(req, res, ctx) {
     return this.sessionCheck(req, res, ctx, undefined, false);
@@ -338,23 +554,42 @@ class Authenticator {
 
   /**
    * Require auth for an API endpoint.
-   * Check for valid local identifier in session, or Authentication header.
+   * Check for valid local identifier in Authorization header;
+   * optionally fall back to session cookie if no header provided.
    * Prompts for Basic auth if not valid.
-   * @param {http.ClientRequest} req
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx
-   * @param {Boolean} sessionAlsoValid
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
+   * @param {boolean} sessionAlsoValid fall back to session if no authz header
+   * @returns {Promise<boolean>} is valid
    */
   async apiRequiredLocal(req, res, ctx, sessionAlsoValid = true) {
-    const validSession = sessionAlsoValid && this.sessionCheck(req, res, ctx, undefined, false, false);
-    const authorizationHeader = req.getHeader(Enum.Header.Authorization);
-    const validAuthorization = authorizationHeader && this.isValidAuthorization(authorizationHeader, ctx);
-    if (validSession || validAuthorization) {
-      return true;
+    const _scope = _fileScope('apiRequiredLocal');
+    this.logger.debug(_scope, 'called', { ctx, sessionAlsoValid });
+
+    try {
+      // If a Authorization header was provided, never consider session as a fallback.
+      const authorizationHeader = req.getHeader(Enum.Header.Authorization);
+      if (authorizationHeader) {
+        if (await this.isValidAuthorization(authorizationHeader, ctx)) {
+          this.logger.debug(_scope, 'valid authorization', { ctx, sessionAlsoValid });
+          return true;
+        }
+      } else if (sessionAlsoValid
+      &&         await this.sessionCheck(req, res, ctx, undefined, false, false)) {
+        this.logger.debug(_scope, 'valid session', { ctx, sessionAlsoValid });
+        return true;
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e });
+      throw e;
     }
+
+    this.logger.debug(_scope, 'invalid authorization', { ctx, sessionAlsoValid });
     this.requestBasic(res);
   }
 
+
 }
 
-module.exports = Authenticator;
\ No newline at end of file
+module.exports = Authenticator;