rudimentary support for totp 2fa
authorJustin Wind <justin.wind+git@gmail.com>
Sun, 24 Sep 2023 21:05:35 +0000 (14:05 -0700)
committerJustin Wind <justin.wind+git@gmail.com>
Mon, 25 Sep 2023 17:56:33 +0000 (10:56 -0700)
lib/authenticator.js
lib/enum.js
lib/session-manager.js
lib/template/ia-html.js
lib/template/index.js
lib/template/otp-html.js [new file with mode: 0644]
package-lock.json
package.json
test/lib/authenticator.js
test/lib/session-manager.js
test/lib/template/otp-html.js [new file with mode: 0644]

index ba5d9217a86ca03efdaebd07c7b833338ddc7cce..da6b7180f4c4e8019d6f2bed82063d4ea057ba71 100644 (file)
@@ -4,11 +4,18 @@ 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);
 
 class Authenticator {
+  /**
+   * @typedef {Object} AuthInfo
+   * @property {String} identifier
+   * @property {String} credentials
+   * @property {String=} otpKey
+   */
   /**
    * @param {Console} logger
    * @param {*} db
@@ -55,15 +62,19 @@ class Authenticator {
     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.
   }
 
 
   /**
    * Check local auth entries.
    * Sets ctx.authenticatedId if valid.
+   * Sets ctx.otpNeeded if account has otpKey.
    * @param {String} identifier
    * @param {String} credential
    * @param {Object} ctx
+   * @returns {Promise<Boolean>}
    */
   async isValidIdentifierCredential(identifier, credential, ctx) {
     const _scope = _fileScope('isValidIdentifierCredential');
@@ -79,16 +90,14 @@ class Authenticator {
       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 {
-        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 or unsupported type of stored credential', { identifier, ctx });
-        }
+        this.logger.error(_scope, 'failed, unknown or unsupported type of stored credential', { identifier, ctx });
       }
 
       if (this.authnEnabled.includes('DEBUG_ANY')) {
@@ -97,6 +106,7 @@ class Authenticator {
 
       if (isValid) {
         ctx.authenticationId = identifier;
+        ctx.otpNeeded = !!authData?.otpKey;
         await this.db.authenticationSuccess(dbCtx, identifier);
       }
     }); // dbCtx
@@ -109,7 +119,7 @@ class Authenticator {
    * Check system PAM.
    * @param {String} identifier
    * @param {String} credential
-   * @returns {Boolean}
+   * @returns {Promise<Boolean>}
    */
   async _isValidPAMIdentifier(identifier, credential) {
     const _scope = _fileScope('_isValidPAMIdentifier');
@@ -130,11 +140,38 @@ class Authenticator {
   }
 
 
+  /**
+   * 
+   * @param {Object} state
+   * @param {String} state.key
+   * @param {Number} state.attempt
+   * @param {Number} state.epochMs
+   * @param {String} otp
+   * @returns {String} Enum.OTPResult
+   */
+  checkOTP(state, otp) {
+    const totp = new this.TOTP({
+      ...this.options?.authenticator?.otpOptions,
+      ...state,
+    });
+    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;
+  }
+
+
   /**
    * Check for valid Basic auth, updates ctx with identifier if valid.
    * @param {String} credentials
    * @param {Object} ctx
-   * @returns {Boolean}
+   * @returns {Promise<Boolean>}
    */
   async isValidBasic(credentials, ctx) {
     const _scope = _fileScope('isValidBasic');
@@ -150,6 +187,7 @@ class Authenticator {
    * Determine which Authorization header is available, and if it is valid.
    * @param {String} authorizationHeader
    * @param {Object} ctx
+   * @returns {Promise<Boolean>}
    */
   async isValidAuthorization(authorizationHeader, ctx) {
     const _scope = _fileScope('isValidAuthorization');
@@ -378,12 +416,10 @@ class Authenticator {
         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;
-      }
+    } else if (sessionAlsoValid
+    &&         await this.sessionCheck(req, res, ctx, undefined, false, false)) {
+      this.logger.debug(_scope, 'valid session', { ctx, sessionAlsoValid });
+      return true;
     }
 
     this.logger.debug(_scope, 'invalid authorization', { ctx, sessionAlsoValid });
index 8f2471a4127d41b4625cf366f4614aa35fd1e4f7..66e2856b453a3184e720044890b4e77aae40d8b4 100644 (file)
@@ -12,6 +12,11 @@ const Enum = mergeDeep(DingusEnum, {
     WWWAuthenticate: 'WWW-Authenticate',
   },
   SessionCookie: 'squeepSession',
+  OTPResult: {
+    Valid: 'Valid',
+    InvalidSoftFail: 'InvalidSoftFail',
+    InvalidHardFail: 'InvalidHardFail',
+  },
 });
 
 module.exports = freezeDeep(Enum);
index 43990cc376e4ee8b16a35092c2bf81620b3a1b9a..48ec11ab2a3428a19991fcce67553d5dd692129e 100644 (file)
@@ -58,7 +58,7 @@ class SessionManager {
     if (this.options.authenticator.secureAuthOnly) {
       cookieParts.push('Secure');
     }
-    if (typeof(maxAge) === 'number') {
+    if (typeof maxAge === 'number') {
       cookieParts.push(`Max-Age=${maxAge}`);
     }
     if (path) {
@@ -79,7 +79,21 @@ class SessionManager {
     const _scope = _fileScope('getAdminLogin');
     this.logger.debug(_scope, 'called', { ctx });
 
-    res.end(Template.LoginHTML(ctx, this.options));
+    // If there is already a valid session established, and if a
+    // redirect is present, follow it.  Why?  Maybe someone had
+    // multiple tabs open, their session expired, and their browser
+    // restarted, reloading all of them, and they have relogged in
+    // on one and just want to hit reload on the others to go back
+    // to where they were.
+    const redirect = ctx.queryParams['r'];
+    if (ctx.authenticationId && redirect) {
+      res.statusCode = 302;
+      res.setHeader(Enum.Header.Location, redirect);
+      res.end();
+    } else {
+      res.end(Template.LoginHTML(ctx, this.options));
+    }
+
     this.logger.info(_scope, 'finished', { ctx })
   }
 
@@ -95,113 +109,98 @@ class SessionManager {
 
     ctx.errors = [];
 
-    const redirect = ctx.queryParams['r'] || './';
-
-    // Only attempt user login if no IndieAuth profile is set
-    if (!this.options.authenticator.authnEnabled.includes('indieAuth') || !ctx.parsedBody['me']) {
-
-      const identifier = ctx.parsedBody['identifier'];
-      const credential = ctx.parsedBody['credential']; // N.B. Logger must specifically mask this field from ctx.
-
-      const isValidLocalIdentifier = await this.authenticator.isValidIdentifierCredential(identifier, credential, ctx);
-      if (!isValidLocalIdentifier) {
-        ctx.errors.push('Invalid username or password');
-      }
-
-      if (ctx.errors.length) {
-        res.end(Template.LoginHTML(ctx, this.options));
-        return;
-      }
+    // Check if this was an OTP entry attempt.
+    if (await this._otpSubmission(res, ctx)) {
+      // OTP path was taken, either successful entry and session creation, or re-prompting for otp.
+      return;
+    }
 
-      // Valid auth, persist the authenticated session
-      ctx.session = {
-        authenticatedIdentifier: ctx.authenticationId,
-      };
-      await this._sessionCookieSet(res, ctx.session, this.cookieLifespan);
-      res.statusCode = 302;
-      res.setHeader(Enum.Header.Location, redirect);
-      res.end();
-      this.logger.info(_scope, 'finished local', { ctx });
+    if (await this._localUserAuth(res, ctx)) {
+      // Local auth path was taken.
       return;
     }
 
     // Otherwise, carry on with IndieAuth handshake.
+
+    // Is profile a reasonable url?
     let me, meAutoScheme, session, authorizationEndpoint;
     try {
       me = new URL(ctx.parsedBody['me']);
       meAutoScheme = !!ctx.parsedBody['me_auto_scheme'];
-
     } catch (e) {
       this.logger.debug(_scope, 'failed to parse supplied profile url', { ctx });
       ctx.errors.push(`Unable to understand '${ctx.parsedBody['me']}' as a profile URL.`);
     }
 
-    if (this.options.authenticator.authnEnabled.includes('indieAuth')
-    &&  me) {
-      let profile;
+    if (ctx.errors.length) {
+      res.end(Template.LoginHTML(ctx, this.options));
+      return;
+    }
+
+    let profile;
+    profile = await this.indieAuthCommunication.fetchProfile(me);
+    if ((!profile?.metadata)
+    &&  meAutoScheme) {
+      this.logger.debug(_scope, 'trying http fallback', { ctx });
+      me.protocol = 'http';
       profile = await this.indieAuthCommunication.fetchProfile(me);
-      if ((!profile?.metadata)
-      &&  meAutoScheme) {
-        this.logger.debug(_scope, 'trying http fallback', { ctx });
-        me.protocol = 'http';
-        profile = await this.indieAuthCommunication.fetchProfile(me);
+    }
+    if (!profile?.metadata) {
+      this.logger.debug(_scope, 'failed to find any profile information at url', { ctx });
+      ctx.errors.push(`No profile information was found at '${me}'.`);
+    } else {
+      // fetch and parse me for 'authorization_endpoint' relation links
+      try {
+        authorizationEndpoint = new URL(profile.metadata.authorizationEndpoint);
+      } catch (e) {
+        ctx.errors.push(`Unable to understand the authorization endpoint ('${profile.metadata.authorizationEndpoint}') indicated by that profile ('${me}') as a URL.`);
       }
-      if (!profile?.metadata) {
-        this.logger.debug(_scope, 'failed to find any profile information at url', { ctx });
-        ctx.errors.push(`No profile information was found at '${me}'.`);
-      } else {
-        // fetch and parse me for 'authorization_endpoint' relation links
-        try {
-          authorizationEndpoint = new URL(profile.metadata.authorizationEndpoint);
-        } catch (e) {
-          ctx.errors.push(`Unable to understand the authorization endpoint ('${profile.metadata.authorizationEndpoint}') indicated by that profile ('${me}') as a URL.`);
-        }
 
-        if (profile.metadata.issuer) {
-          // Validate issuer
-          try {
-            const issuer = new URL(profile.metadata.issuer);
-            if (issuer.hash
-            ||  issuer.search
-            ||  issuer.protocol.toLowerCase() !== 'https:') { // stupid URL trailing colon thing
-              this.logger.debug(_scope, 'supplied issuer url invalid', { ctx });
-              ctx.errors.push('Authorization server provided an invalid issuer field.');
-            }
-          } catch (e) {
-            this.logger.debug(_scope, 'failed to parse supplied issuer url', { ctx });
-            ctx.errors.push('Authorization server provided an unparsable issuer field.');
+      if (profile.metadata.issuer) {
+        // Validate issuer
+        try {
+          const issuer = new URL(profile.metadata.issuer);
+          if (issuer.hash
+          ||  issuer.search
+          ||  issuer.protocol.toLowerCase() !== 'https:') { // stupid URL trailing colon thing
+            this.logger.debug(_scope, 'supplied issuer url invalid', { ctx });
+            ctx.errors.push('Authorization server provided an invalid issuer field.');
           }
-        } else {
-          this.logger.debug(_scope, 'no issuer in metadata, assuming legacy mode', { ctx });
-          // Strict 20220212 compliance would error here.
-          // ctx.errors.push('Authorization server did not provide issuer field, as required by current specification.');
+        } catch (e) {
+          this.logger.debug(_scope, 'failed to parse supplied issuer url', { ctx });
+          ctx.errors.push('Authorization server provided an unparsable issuer field.');
         }
+      } else {
+        this.logger.debug(_scope, 'no issuer in metadata, assuming legacy mode', { ctx });
+        // Strict 20220212 compliance would error here.
+        // ctx.errors.push('Authorization server did not provide issuer field, as required by current specification.');
       }
+    }
 
-      if (authorizationEndpoint) {
-        const pkce = await IndieAuthCommunication.generatePKCE();
-
-        const state = randomUUID();
-        session = {
-          authorizationEndpoint: authorizationEndpoint.href,
-          state,
-          codeVerifier: pkce.codeVerifier,
-          me,
-          redirect,
-          issuer: profile.metadata.issuer,
-        };
-
-        // Update auth endpoint parameters
-        Object.entries({
-          'response_type': 'code',
-          'client_id': this.options.dingus.selfBaseUrl,
-          'redirect_uri': `${this.options.dingus.selfBaseUrl}admin/_ia`,
-          'state': session.state,
-          'code_challenge': pkce.codeChallenge,
-          'code_challenge_method': pkce.codeChallengeMethod,
-          'me': me,
-        }).forEach(([name, value]) => authorizationEndpoint.searchParams.set(name, value));
-      }
+    if (authorizationEndpoint) {
+      const pkce = await IndieAuthCommunication.generatePKCE();
+
+      const state = randomUUID();
+      const redirect = ctx.queryParams['r'] || './';
+      session = {
+        authorizationEndpoint: authorizationEndpoint.href,
+        state,
+        codeVerifier: pkce.codeVerifier,
+        me,
+        redirect,
+        issuer: profile.metadata.issuer,
+      };
+
+      // Update auth endpoint parameters
+      Object.entries({
+        'response_type': 'code',
+        'client_id': this.options.dingus.selfBaseUrl,
+        'redirect_uri': `${this.options.dingus.selfBaseUrl}admin/_ia`,
+        'state': session.state,
+        'code_challenge': pkce.codeChallenge,
+        'code_challenge_method': pkce.codeChallengeMethod,
+        'me': me,
+      }).forEach(([name, value]) => authorizationEndpoint.searchParams.set(name, value));
     }
 
     if (ctx.errors.length) {
@@ -218,6 +217,126 @@ class SessionManager {
   }
 
 
+  /**
+   * Check if processing an OTP entry attempt.  If not, resume login flow.
+   * If so, validate otp and establish session, else reprompt for OTP, or
+   * return to login entry after too many failures.
+   * @param {*} res
+   * @param {*} ctx
+   * @returns {Boolean} true if otp was handled, otherwise false indicates further login processing needed
+   */
+  async _otpSubmission(res, ctx) {
+    const _scope = _fileScope('_otpSubmission');
+
+    // Are we processing an OTP entry attempt?
+    const { otp, state: stateBox } = ctx.parsedBody;
+    let state;
+    try {
+      if (stateBox) {
+        state = await this.mysteryBox.unpack(stateBox);
+      }
+    } catch (e) {
+      this.logger.debug(_scope, 'failed to unpack otp state', { error: e, ctx });
+      // Ignore and continue back to main login
+      return false;
+    }
+    if (otp && state) {
+      const OTPResult = await this.authenticator.checkOTP(state, otp);
+      switch (OTPResult) {
+        case Enum.OTPResult.Valid:
+          // successful otp entry
+          // Valid auth, persist the authenticated session
+          ctx.session = {
+            authenticatedIdentifier: state.authenticationId,
+          };
+          await this._sessionCookieSet(res, ctx.session, this.cookieLifespan);
+          res.statusCode = 302;
+          res.setHeader(Enum.Header.Location, state.redirect);
+          res.end();
+          this.logger.info(_scope, 'finished otp', { ctx });
+          return true;
+
+        case Enum.OTPResult.InvalidSoftFail:
+          // retry otp entry
+          ctx.otpNeeded = true;
+          ctx.errors.push('Invalid OTP token.');
+          ctx.otpState = await this.mysteryBox.pack({
+            ...state,
+            attempt: state.attempt + 1,
+          });
+          res.end(Template.OTPHTML(ctx, this.options));
+          this.logger.info(_scope, 'finished otp, invalid, request again', { ctx });
+          return true;
+
+        case Enum.OTPResult.InvalidHardFail:
+          // return to initial login
+          this.logger.debug(_scope, 'too many otp failures', { ctx, state });
+          ctx.errors.push('Invalid OTP token, and too many failures.  Try again.');
+          return false;
+
+        default:
+          throw new RangeError('Unexpected OTPResult');
+      }
+    }
+    // not in otp flow
+    return false;
+  }
+
+  /**
+   * 
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async _localUserAuth(res, ctx) {
+    const _scope = _fileScope('_localUserAuth');
+
+    // If Indiauth enabled and profile was submitted, defer to that.
+    if (this.options.authenticator.authnEnabled.includes('indieAuth')
+    &&  ctx.parsedBody['me']) {
+      return false;
+    }
+
+    const redirect = ctx.queryParams['r'] || './';
+    const identifier = ctx.parsedBody['identifier'];
+    const credential = ctx.parsedBody['credential']; // N.B. Logger must specifically mask this field from ctx.
+
+    // N.B. validity check also sets autenticationId and otpNeeded on ctx
+    const isValidLocalIdentifier = await this.authenticator.isValidIdentifierCredential(identifier, credential, ctx);
+    if (!isValidLocalIdentifier) {
+      ctx.errors.push('Invalid username or password');
+    }
+
+    if (ctx.errors.length) {
+      res.end(Template.LoginHTML(ctx, this.options));
+      return true;
+    }
+
+    // If OTP exists for valid identifier, follow that flow.
+    if (ctx.otpNeeded) {
+      ctx.otpState = await this.mysteryBox.pack({
+        authenticatedIdentifier: ctx.authenticationId,
+        epochMs: Date.now(),
+        attempt: 0,
+        redirect,
+      });
+      res.end(Template.OTPHTML(ctx, this.options));
+      this.logger.info(_scope, 'finished local, otp required', { ctx });
+      return true;
+    }
+
+    // Valid auth, persist the authenticated session
+    ctx.session = {
+      authenticatedIdentifier: ctx.authenticationId,
+    };
+    await this._sessionCookieSet(res, ctx.session, this.cookieLifespan);
+    res.statusCode = 302;
+    res.setHeader(Enum.Header.Location, redirect);
+    res.end();
+    this.logger.info(_scope, 'finished local, no otp', { ctx });
+    return true;
+  }
+
+
   /**
    * GET request to remove current credentials.
    * @param {http.ServerResponse} res
@@ -254,7 +373,7 @@ class SessionManager {
 
     // Unpack cookie to restore session data
 
-    const [ cookieName, cookieValue ] = common.splitFirst((ctx.cookie || ''), '=', '');
+    const [cookieName, cookieValue] = common.splitFirst((ctx.cookie || ''), '=', '');
     if (cookieName !== Enum.SessionCookie) {
       this.logger.debug(_scope, 'no cookie', { ctx });
       ctx.errors.push('missing required cookie');
index 00e2089338759627d26ace5ca8e8eeee2354ddcd..5de476ae04b880d2d94e6e3b1bd3db359aee2095 100644 (file)
@@ -2,6 +2,10 @@
 
 const { TemplateHelper: th } = require('@squeep/html-template-helper');
 
+/**
+ *
+ * @returns {String}
+ */
 function mainContent() {
   return [
     `<section>
@@ -31,7 +35,7 @@ module.exports = (ctx, options) => {
     ],
   };
   // Ensure there is always an error to report, even if we do not have one, as we ended up here somehow.
-  if (!ctx.errors || !ctx.errors.length) {
+  if (!ctx?.errors?.length) {
     ctx.errors = [
       'Unknown Error &mdash; we are not sure what just happened',
     ];
index 69c70b93b2a869b785cdde7b7cc7d9edf2700948..c77123c5a40deec041d716bdc659a30b42c14a30 100644 (file)
@@ -2,8 +2,10 @@
 
 const IAHTML = require('./ia-html');
 const LoginHTML = require('./login-html');
+const OTPHTML = require('./otp-html');
 
 module.exports = {
   IAHTML,
   LoginHTML,
+  OTPHTML,
 };
\ No newline at end of file
diff --git a/lib/template/otp-html.js b/lib/template/otp-html.js
new file mode 100644 (file)
index 0000000..cf7cef6
--- /dev/null
@@ -0,0 +1,54 @@
+'use strict';
+
+const { TemplateHelper: th } = require('@squeep/html-template-helper');
+
+/**
+ * Login form, continued.
+ */
+
+function otpSection(ctx, options) {
+  const otpBlurb = (options.otpBlurb || []).map((x) => '\t'.repeat(6) + x).join('\n');
+  return `\t\t\t<section class="otp">
+\t\t\t\t<form action="" method="POST">
+\t\t\t\t\t<fieldset>
+\t\t\t\t\t\t<legend>Two-Factor Authentication</legend>
+\t\t\t\t\t\t<label for="otp">OTP Code</label>
+\t\t\t\t\t\t<input id="otp" name="otp" value="">
+\t\t\t\t\t\t<br>
+\t\t\t\t\t\t<button>Confirm</button>
+${otpBlurb}
+\t\t\t\t\t</fieldset>
+\t\t\t\t\t<input type="hidden" name="state" value="${ctx.otpState}">
+\t\t\t\t</form>
+\t\t\t</section`;
+}
+
+
+/**
+ * Render 2fs form.
+ * @param {Object} ctx
+ * @param {String[]=} ctx.errors
+ * @param {String} ctx.otpState
+ * @param {Object} options
+ * @param {String[]=} options.authenticator.otpBlurb
+ * @param {String[]=} options.authenticator.loginBlurb
+ * @param {Object} options.manager
+ * @param {String} options.manager.pageTitle
+ * @param {String=} options.manager.logoUrl
+ * @param {Object} options.dingus
+ * @param {String} options.dingus.selfBaseUrl
+ * @returns {String}
+ */
+module.exports = (ctx, options) => {
+  const htmlOptions = {
+    pageTitle: options.manager.pageTitle,
+    logoUrl: options.manager.logoUrl,
+    footerEntries: options.manager.footerEntries,
+    otpBlurb: options.authenticator?.otpBlurb,
+  };
+  const mainContent = [
+    ...(options.authenticator?.loginBlurb || []),
+    otpSection(ctx, htmlOptions),
+  ];
+  return th.htmlPage(2, ctx, htmlOptions, mainContent);
+};
\ No newline at end of file
index f7b64c0730f59c3fdf6361a61b0ebd2c7db02205..5e8cb2fd8884edec3ce1314333f34bd2fa302d8e 100644 (file)
         "@squeep/api-dingus": "v2.0.0",
         "@squeep/html-template-helper": "git+https://git.squeep.com/squeep-html-template-helper#v1.4.0",
         "@squeep/indieauth-helper": "^1.3.0",
-        "@squeep/mystery-box": "^2.0.1"
+        "@squeep/mystery-box": "^2.0.1",
+        "@squeep/totp": "git+https://git.squeep.com/squeep-totp#v1.1.0"
       },
       "devDependencies": {
-        "eslint": "^8.48.0",
+        "eslint": "^8.50.0",
         "eslint-plugin-node": "^11.1.0",
         "eslint-plugin-promise": "^6.1.1",
         "eslint-plugin-security": "^1.7.1",
         "mocha": "^10.2.0",
         "nyc": "^15.1.0",
         "pre-commit": "^1.2.2",
-        "sinon": "^15.2.0"
+        "sinon": "^16.0.0"
       },
       "engines": {
         "node": "^14 >=14.18 || >=15.7"
       },
       "optionalDependencies": {
-        "argon2": "^0.31.0",
+        "argon2": "^0.31.1",
         "node-linux-pam": "^0.2.1"
       }
     },
       }
     },
     "node_modules/@babel/code-frame": {
-      "version": "7.22.10",
-      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.10.tgz",
-      "integrity": "sha512-/KKIMG4UEL35WmI9OlvMhurwtytjvXoFcGNrOvyG9zIzA8YmPjVtIZUf7b05+TPO7G7/GEmLHDaoCgACHl9hhA==",
+      "version": "7.22.13",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz",
+      "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==",
       "dev": true,
       "dependencies": {
-        "@babel/highlight": "^7.22.10",
+        "@babel/highlight": "^7.22.13",
         "chalk": "^2.4.2"
       },
       "engines": {
       }
     },
     "node_modules/@babel/compat-data": {
-      "version": "7.22.9",
-      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.22.9.tgz",
-      "integrity": "sha512-5UamI7xkUcJ3i9qVDS+KFDEK8/7oJ55/sJMB1Ge7IEapr7KfdfV/HErR+koZwOfd+SgtFKOKRhRakdg++DcJpQ==",
+      "version": "7.22.20",
+      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.22.20.tgz",
+      "integrity": "sha512-BQYjKbpXjoXwFW5jGqiizJQQT/aC7pFm9Ok1OWssonuguICi264lbgMzRp2ZMmRSlfkX6DsWDDcsrctK8Rwfiw==",
       "dev": true,
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/core": {
-      "version": "7.22.11",
-      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.22.11.tgz",
-      "integrity": "sha512-lh7RJrtPdhibbxndr6/xx0w8+CVlY5FJZiaSz908Fpy+G0xkBFTvwLcKJFF4PJxVfGhVWNebikpWGnOoC71juQ==",
+      "version": "7.22.20",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.22.20.tgz",
+      "integrity": "sha512-Y6jd1ahLubuYweD/zJH+vvOY141v4f9igNQAQ+MBgq9JlHS2iTsZKn1aMsb3vGccZsXI16VzTBw52Xx0DWmtnA==",
       "dev": true,
       "dependencies": {
         "@ampproject/remapping": "^2.2.0",
-        "@babel/code-frame": "^7.22.10",
-        "@babel/generator": "^7.22.10",
-        "@babel/helper-compilation-targets": "^7.22.10",
-        "@babel/helper-module-transforms": "^7.22.9",
-        "@babel/helpers": "^7.22.11",
-        "@babel/parser": "^7.22.11",
-        "@babel/template": "^7.22.5",
-        "@babel/traverse": "^7.22.11",
-        "@babel/types": "^7.22.11",
+        "@babel/code-frame": "^7.22.13",
+        "@babel/generator": "^7.22.15",
+        "@babel/helper-compilation-targets": "^7.22.15",
+        "@babel/helper-module-transforms": "^7.22.20",
+        "@babel/helpers": "^7.22.15",
+        "@babel/parser": "^7.22.16",
+        "@babel/template": "^7.22.15",
+        "@babel/traverse": "^7.22.20",
+        "@babel/types": "^7.22.19",
         "convert-source-map": "^1.7.0",
         "debug": "^4.1.0",
         "gensync": "^1.0.0-beta.2",
       }
     },
     "node_modules/@babel/generator": {
-      "version": "7.22.10",
-      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.10.tgz",
-      "integrity": "sha512-79KIf7YiWjjdZ81JnLujDRApWtl7BxTqWD88+FFdQEIOG8LJ0etDOM7CXuIgGJa55sGOwZVwuEsaLEm0PJ5/+A==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.15.tgz",
+      "integrity": "sha512-Zu9oWARBqeVOW0dZOjXc3JObrzuqothQ3y/n1kUtrjCoCPLkXUwMvOo/F/TCfoHMbWIFlWwpZtkZVb9ga4U2pA==",
       "dev": true,
       "dependencies": {
-        "@babel/types": "^7.22.10",
+        "@babel/types": "^7.22.15",
         "@jridgewell/gen-mapping": "^0.3.2",
         "@jridgewell/trace-mapping": "^0.3.17",
         "jsesc": "^2.5.1"
       }
     },
     "node_modules/@babel/helper-compilation-targets": {
-      "version": "7.22.10",
-      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.10.tgz",
-      "integrity": "sha512-JMSwHD4J7SLod0idLq5PKgI+6g/hLD/iuWBq08ZX49xE14VpVEojJ5rHWptpirV2j020MvypRLAXAO50igCJ5Q==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz",
+      "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==",
       "dev": true,
       "dependencies": {
         "@babel/compat-data": "^7.22.9",
-        "@babel/helper-validator-option": "^7.22.5",
+        "@babel/helper-validator-option": "^7.22.15",
         "browserslist": "^4.21.9",
         "lru-cache": "^5.1.1",
         "semver": "^6.3.1"
       }
     },
     "node_modules/@babel/helper-environment-visitor": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz",
-      "integrity": "sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==",
+      "version": "7.22.20",
+      "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz",
+      "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==",
       "dev": true,
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/helper-module-imports": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.5.tgz",
-      "integrity": "sha512-8Dl6+HD/cKifutF5qGd/8ZJi84QeAKh+CEe1sBzz8UayBBGg1dAIJrdHOcOM5b2MpzWL2yuotJTtGjETq0qjXg==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz",
+      "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==",
       "dev": true,
       "dependencies": {
-        "@babel/types": "^7.22.5"
+        "@babel/types": "^7.22.15"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/helper-module-transforms": {
-      "version": "7.22.9",
-      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.22.9.tgz",
-      "integrity": "sha512-t+WA2Xn5K+rTeGtC8jCsdAH52bjggG5TKRuRrAGNM/mjIbO4GxvlLMFOEz9wXY5I2XQ60PMFsAG2WIcG82dQMQ==",
+      "version": "7.22.20",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.22.20.tgz",
+      "integrity": "sha512-dLT7JVWIUUxKOs1UnJUBR3S70YK+pKX6AbJgB2vMIvEkZkrfJDbYDJesnPshtKV4LhDOR3Oc5YULeDizRek+5A==",
       "dev": true,
       "dependencies": {
-        "@babel/helper-environment-visitor": "^7.22.5",
-        "@babel/helper-module-imports": "^7.22.5",
+        "@babel/helper-environment-visitor": "^7.22.20",
+        "@babel/helper-module-imports": "^7.22.15",
         "@babel/helper-simple-access": "^7.22.5",
         "@babel/helper-split-export-declaration": "^7.22.6",
-        "@babel/helper-validator-identifier": "^7.22.5"
+        "@babel/helper-validator-identifier": "^7.22.20"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/helper-validator-identifier": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz",
-      "integrity": "sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==",
+      "version": "7.22.20",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
+      "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==",
       "dev": true,
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/helper-validator-option": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.5.tgz",
-      "integrity": "sha512-R3oB6xlIVKUnxNUxbmgq7pKjxpru24zlimpE8WK47fACIlM0II/Hm1RS8IaOI7NgCr6LNS+jl5l75m20npAziw==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz",
+      "integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==",
       "dev": true,
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/helpers": {
-      "version": "7.22.11",
-      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.22.11.tgz",
-      "integrity": "sha512-vyOXC8PBWaGc5h7GMsNx68OH33cypkEDJCHvYVVgVbbxJDROYVtexSk0gK5iCF1xNjRIN2s8ai7hwkWDq5szWg==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.22.15.tgz",
+      "integrity": "sha512-7pAjK0aSdxOwR+CcYAqgWOGy5dcfvzsTIfFTb2odQqW47MDfv14UaJDY6eng8ylM2EaeKXdxaSWESbkmaQHTmw==",
       "dev": true,
       "dependencies": {
-        "@babel/template": "^7.22.5",
-        "@babel/traverse": "^7.22.11",
-        "@babel/types": "^7.22.11"
+        "@babel/template": "^7.22.15",
+        "@babel/traverse": "^7.22.15",
+        "@babel/types": "^7.22.15"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/highlight": {
-      "version": "7.22.10",
-      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.10.tgz",
-      "integrity": "sha512-78aUtVcT7MUscr0K5mIEnkwxPE0MaxkR5RxRwuHaQ+JuU5AmTPhY+do2mdzVTnIJJpyBglql2pehuBIWHug+WQ==",
+      "version": "7.22.20",
+      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz",
+      "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==",
       "dev": true,
       "dependencies": {
-        "@babel/helper-validator-identifier": "^7.22.5",
+        "@babel/helper-validator-identifier": "^7.22.20",
         "chalk": "^2.4.2",
         "js-tokens": "^4.0.0"
       },
       }
     },
     "node_modules/@babel/parser": {
-      "version": "7.22.11",
-      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.11.tgz",
-      "integrity": "sha512-R5zb8eJIBPJriQtbH/htEQy4k7E2dHWlD2Y2VT07JCzwYZHBxV5ZYtM0UhXSNMT74LyxuM+b1jdL7pSesXbC/g==",
+      "version": "7.22.16",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.16.tgz",
+      "integrity": "sha512-+gPfKv8UWeKKeJTUxe59+OobVcrYHETCsORl61EmSkmgymguYk/X5bp7GuUIXaFsc6y++v8ZxPsLSSuujqDphA==",
       "dev": true,
       "bin": {
         "parser": "bin/babel-parser.js"
       }
     },
     "node_modules/@babel/template": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.5.tgz",
-      "integrity": "sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz",
+      "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==",
       "dev": true,
       "dependencies": {
-        "@babel/code-frame": "^7.22.5",
-        "@babel/parser": "^7.22.5",
-        "@babel/types": "^7.22.5"
+        "@babel/code-frame": "^7.22.13",
+        "@babel/parser": "^7.22.15",
+        "@babel/types": "^7.22.15"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/traverse": {
-      "version": "7.22.11",
-      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.11.tgz",
-      "integrity": "sha512-mzAenteTfomcB7mfPtyi+4oe5BZ6MXxWcn4CX+h4IRJ+OOGXBrWU6jDQavkQI9Vuc5P+donFabBfFCcmWka9lQ==",
+      "version": "7.22.20",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.20.tgz",
+      "integrity": "sha512-eU260mPZbU7mZ0N+X10pxXhQFMGTeLb9eFS0mxehS8HZp9o1uSnFeWQuG1UPrlxgA7QoUzFhOnilHDp0AXCyHw==",
       "dev": true,
       "dependencies": {
-        "@babel/code-frame": "^7.22.10",
-        "@babel/generator": "^7.22.10",
-        "@babel/helper-environment-visitor": "^7.22.5",
+        "@babel/code-frame": "^7.22.13",
+        "@babel/generator": "^7.22.15",
+        "@babel/helper-environment-visitor": "^7.22.20",
         "@babel/helper-function-name": "^7.22.5",
         "@babel/helper-hoist-variables": "^7.22.5",
         "@babel/helper-split-export-declaration": "^7.22.6",
-        "@babel/parser": "^7.22.11",
-        "@babel/types": "^7.22.11",
+        "@babel/parser": "^7.22.16",
+        "@babel/types": "^7.22.19",
         "debug": "^4.1.0",
         "globals": "^11.1.0"
       },
       }
     },
     "node_modules/@babel/types": {
-      "version": "7.22.11",
-      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.11.tgz",
-      "integrity": "sha512-siazHiGuZRz9aB9NpHy9GOs9xiQPKnMzgdr493iI1M67vRXpnEq8ZOOKzezC5q7zwuQ6sDhdSp4SD9ixKSqKZg==",
+      "version": "7.22.19",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.19.tgz",
+      "integrity": "sha512-P7LAw/LbojPzkgp5oznjE6tQEIWbp4PkkfrZDINTro9zgBRtI324/EYsiSI7lhPbpIQ+DCeR2NNmMWANGGfZsg==",
       "dev": true,
       "dependencies": {
         "@babel/helper-string-parser": "^7.22.5",
-        "@babel/helper-validator-identifier": "^7.22.5",
+        "@babel/helper-validator-identifier": "^7.22.19",
         "to-fast-properties": "^2.0.0"
       },
       "engines": {
       }
     },
     "node_modules/@eslint-community/regexpp": {
-      "version": "4.8.0",
-      "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.8.0.tgz",
-      "integrity": "sha512-JylOEEzDiOryeUnFbQz+oViCXS0KsvR1mvHkoMiu5+UiBvy+RYX7tzlIIIEstF/gVa2tj9AQXk3dgnxv6KxhFg==",
+      "version": "4.8.1",
+      "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.8.1.tgz",
+      "integrity": "sha512-PWiOzLIUAjN/w5K17PoF4n6sKBw0gqLHPhywmYHP4t1VFQQVYeb1yWsJwnMVEMl3tUHME7X/SJPZLmtG7XBDxQ==",
       "dev": true,
       "engines": {
         "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
       }
     },
     "node_modules/@eslint/js": {
-      "version": "8.48.0",
-      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.48.0.tgz",
-      "integrity": "sha512-ZSjtmelB7IJfWD2Fvb7+Z+ChTIKWq6kjda95fLcQKNS5aheVHn4IkfgRQE3sIIzTcSLwLcLZUD9UBt+V7+h+Pw==",
+      "version": "8.50.0",
+      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.50.0.tgz",
+      "integrity": "sha512-NCC3zz2+nvYd+Ckfh87rA47zfu2QsQpvc6k1yzTk+b9KzRj0wkGa8LSoGOXN6Zv4lRf/EIoZ80biDh9HOI+RNQ==",
       "dev": true,
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
       }
     },
     "node_modules/@humanwhocodes/config-array": {
-      "version": "0.11.10",
-      "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz",
-      "integrity": "sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==",
+      "version": "0.11.11",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.11.tgz",
+      "integrity": "sha512-N2brEuAadi0CcdeMXUkhbZB84eskAc8MEX1By6qEchoVywSgXPIjou4rYsl0V3Hj0ZnuGycGCjdNgockbzeWNA==",
       "dev": true,
       "dependencies": {
         "@humanwhocodes/object-schema": "^1.2.1",
         "node": "^14 >=14.18.0 || >=15.7.0"
       }
     },
+    "node_modules/@squeep/totp": {
+      "version": "1.1.0",
+      "resolved": "git+https://git.squeep.com/squeep-totp#381355dd8d70451179cfbde204177ed89675c3a3",
+      "license": "ISC",
+      "dependencies": {
+        "base32.js": "^0.1.0",
+        "qrcode-svg": "^1.1.0"
+      },
+      "engines": {
+        "node": ">14"
+      }
+    },
     "node_modules/@squeep/web-linking": {
       "version": "1.0.8",
       "resolved": "https://registry.npmjs.org/@squeep/web-linking/-/web-linking-1.0.8.tgz",
       }
     },
     "node_modules/@types/http-cache-semantics": {
-      "version": "4.0.1",
-      "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz",
-      "integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ=="
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.2.tgz",
+      "integrity": "sha512-FD+nQWA2zJjh4L9+pFXqWOi0Hs1ryBCfI+985NjluQ1p8EYtoLvjLOKidXBtZ4/IcxDX4o8/E8qDS3540tNliw=="
     },
     "node_modules/abbrev": {
       "version": "1.1.1",
       }
     },
     "node_modules/argon2": {
-      "version": "0.31.0",
-      "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.31.0.tgz",
-      "integrity": "sha512-r56NWwlE3tjD/FIqL1T+V4Ka+Mb5yMF35w1YWHpwpEjeONXBUbxmjhWkWqY63mse8lpcZ+ZZIGpKL+s+qXhyfg==",
+      "version": "0.31.1",
+      "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.31.1.tgz",
+      "integrity": "sha512-ik2xnJrLXazya7m4Nz1XfBSRjXj8Koq8qF9PsQC8059p20ifWc9zx/hgU3ItZh/3TnwXkv0RbhvjodPkmFf0bg==",
       "hasInstallScript": true,
       "optional": true,
       "dependencies": {
       "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
       "devOptional": true
     },
+    "node_modules/base32.js": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz",
+      "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==",
+      "engines": {
+        "node": ">=0.12.0"
+      }
+    },
     "node_modules/binary-extensions": {
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
       }
     },
     "node_modules/caniuse-lite": {
-      "version": "1.0.30001524",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001524.tgz",
-      "integrity": "sha512-Jj917pJtYg9HSJBF95HVX3Cdr89JUyLT4IZ8SvM5aDRni95swKgYi3TgYLH5hnGfPE/U1dg6IfZ50UsIlLkwSA==",
+      "version": "1.0.30001534",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001534.tgz",
+      "integrity": "sha512-vlPVrhsCS7XaSh2VvWluIQEzVhefrUQcEsQWSS5A5V+dM07uv1qHeQzAOTGIMy9i3e9bH15+muvI/UHojVgS/Q==",
       "dev": true,
       "funding": [
         {
       }
     },
     "node_modules/electron-to-chromium": {
-      "version": "1.4.503",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.503.tgz",
-      "integrity": "sha512-LF2IQit4B0VrUHFeQkWhZm97KuJSGF2WJqq1InpY+ECpFRkXd8yTIaTtJxsO0OKDmiBYwWqcrNaXOurn2T2wiA==",
+      "version": "1.4.523",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.523.tgz",
+      "integrity": "sha512-9AreocSUWnzNtvLcbpng6N+GkXnCcBR80IQkxRC9Dfdyg4gaWNUPBujAHUpKkiUkoSoR9UlhA4zD/IgBklmhzg==",
       "dev": true
     },
     "node_modules/emoji-regex": {
       }
     },
     "node_modules/eslint": {
-      "version": "8.48.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.48.0.tgz",
-      "integrity": "sha512-sb6DLeIuRXxeM1YljSe1KEx9/YYeZFQWcV8Rq9HfigmdDEugjLEVEa1ozDjL6YDjBpQHPJxJzze+alxi4T3OLg==",
+      "version": "8.50.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.50.0.tgz",
+      "integrity": "sha512-FOnOGSuFuFLv/Sa+FDVRZl4GGVAAFFi8LecRsI5a1tMO5HIE8nCm4ivAlzt4dT3ol/PaaGC0rJEEXQmHJBGoOg==",
       "dev": true,
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.2.0",
         "@eslint-community/regexpp": "^4.6.1",
         "@eslint/eslintrc": "^2.1.2",
-        "@eslint/js": "8.48.0",
-        "@humanwhocodes/config-array": "^0.11.10",
+        "@eslint/js": "8.50.0",
+        "@humanwhocodes/config-array": "^0.11.11",
         "@humanwhocodes/module-importer": "^1.0.1",
         "@nodelib/fs.walk": "^1.2.8",
         "ajv": "^6.12.4",
       }
     },
     "node_modules/flatted": {
-      "version": "3.2.7",
-      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz",
-      "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
+      "version": "3.2.9",
+      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz",
+      "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==",
       "dev": true
     },
     "node_modules/foreground-child": {
       }
     },
     "node_modules/globals": {
-      "version": "13.21.0",
-      "resolved": "https://registry.npmjs.org/globals/-/globals-13.21.0.tgz",
-      "integrity": "sha512-ybyme3s4yy/t/3s35bewwXKOf7cvzfreG2lH0lZl0JB7I4GxRP2ghxOK/Nb9EkRXdbBXZLfq/p/0W2JUONB/Gg==",
+      "version": "13.22.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-13.22.0.tgz",
+      "integrity": "sha512-H1Ddc/PbZHTDVJSnj8kWptIRSD6AM3pK+mKytuIVF4uoBV7rshFlhhvA58ceJ5wp3Er58w6zj7bykMpYXt3ETw==",
       "dev": true,
       "dependencies": {
         "type-fest": "^0.20.2"
       }
     },
     "node_modules/microformats-parser": {
-      "version": "1.4.1",
-      "resolved": "https://registry.npmjs.org/microformats-parser/-/microformats-parser-1.4.1.tgz",
-      "integrity": "sha512-BSg9Y/Aik8hvvme/fkxnXMRvTKuVwOeTapeZdaPQ+92DEubyM31iMtwbgFZ1383om643UvfYY5G23E9s1FY2KQ==",
+      "version": "1.5.2",
+      "resolved": "https://registry.npmjs.org/microformats-parser/-/microformats-parser-1.5.2.tgz",
+      "integrity": "sha512-EcHm8zxEm3CggOLgILfxCo2wDiJEOnACzpV/FXWGLaRk24ECei+JkoWNdKdo2vzo/Pww9EvrQNeQsdv4JuHy7Q==",
       "dependencies": {
         "parse5": "^6.0.0"
       },
       "engines": {
-        "node": ">=10"
+        "node": ">=14"
       }
     },
     "node_modules/mime-db": {
         "node": ">=6"
       }
     },
+    "node_modules/qrcode-svg": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/qrcode-svg/-/qrcode-svg-1.1.0.tgz",
+      "integrity": "sha512-XyQCIXux1zEIA3NPb0AeR8UMYvXZzWEhgdBgBjH9gO7M48H9uoHzviNz8pXw3UzrAcxRRRn9gxHewAVK7bn9qw==",
+      "bin": {
+        "qrcode-svg": "bin/qrcode-svg.js"
+      }
+    },
     "node_modules/queue-microtask": {
       "version": "1.2.3",
       "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
       "devOptional": true
     },
     "node_modules/resolve": {
-      "version": "1.22.4",
-      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.4.tgz",
-      "integrity": "sha512-PXNdCiPqDqeUou+w1C2eTQbNfxKSuMxqTCuvlmmMsk1NWHL5fRrhY6Pl0qEYYc6+QqGClco1Qj8XnjPego4wfg==",
+      "version": "1.22.6",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.6.tgz",
+      "integrity": "sha512-njhxM7mV12JfufShqGy3Rz8j11RPdLy4xi15UurGJeoHLfJpVXKdh3ueuOqbYUcDZnffr6X739JBo5LzyahEsw==",
       "dev": true,
       "dependencies": {
         "is-core-module": "^2.13.0",
       "devOptional": true
     },
     "node_modules/sinon": {
-      "version": "15.2.0",
-      "resolved": "https://registry.npmjs.org/sinon/-/sinon-15.2.0.tgz",
-      "integrity": "sha512-nPS85arNqwBXaIsFCkolHjGIkFo+Oxu9vbgmBJizLAhqe6P2o3Qmj3KCUoRkfhHtvgDhZdWD3risLHAUJ8npjw==",
+      "version": "16.0.0",
+      "resolved": "https://registry.npmjs.org/sinon/-/sinon-16.0.0.tgz",
+      "integrity": "sha512-B8AaZZm9CT5pqe4l4uWJztfD/mOTa7dL8Qo0W4+s+t74xECOgSZDDQCBjNgIK3+n4kyxQrSTv2V5ul8K25qkiQ==",
       "dev": true,
       "dependencies": {
         "@sinonjs/commons": "^3.0.0",
       }
     },
     "node_modules/tar": {
-      "version": "6.1.15",
-      "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.15.tgz",
-      "integrity": "sha512-/zKt9UyngnxIT/EAGYuxaMYgOIJiP81ab9ZfkILq4oNLPFX50qyYmu7jRj9qeXoxmJHjGlbH0+cm2uy1WCs10A==",
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz",
+      "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==",
       "optional": true,
       "dependencies": {
         "chownr": "^2.0.0",
       "devOptional": true
     },
     "node_modules/uuid": {
-      "version": "9.0.0",
-      "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
-      "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==",
+      "version": "9.0.1",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
+      "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
+      "funding": [
+        "https://github.com/sponsors/broofa",
+        "https://github.com/sponsors/ctavan"
+      ],
       "bin": {
         "uuid": "dist/bin/uuid"
       }
       }
     },
     "@babel/code-frame": {
-      "version": "7.22.10",
-      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.10.tgz",
-      "integrity": "sha512-/KKIMG4UEL35WmI9OlvMhurwtytjvXoFcGNrOvyG9zIzA8YmPjVtIZUf7b05+TPO7G7/GEmLHDaoCgACHl9hhA==",
+      "version": "7.22.13",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz",
+      "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==",
       "dev": true,
       "requires": {
-        "@babel/highlight": "^7.22.10",
+        "@babel/highlight": "^7.22.13",
         "chalk": "^2.4.2"
       },
       "dependencies": {
       }
     },
     "@babel/compat-data": {
-      "version": "7.22.9",
-      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.22.9.tgz",
-      "integrity": "sha512-5UamI7xkUcJ3i9qVDS+KFDEK8/7oJ55/sJMB1Ge7IEapr7KfdfV/HErR+koZwOfd+SgtFKOKRhRakdg++DcJpQ==",
+      "version": "7.22.20",
+      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.22.20.tgz",
+      "integrity": "sha512-BQYjKbpXjoXwFW5jGqiizJQQT/aC7pFm9Ok1OWssonuguICi264lbgMzRp2ZMmRSlfkX6DsWDDcsrctK8Rwfiw==",
       "dev": true
     },
     "@babel/core": {
-      "version": "7.22.11",
-      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.22.11.tgz",
-      "integrity": "sha512-lh7RJrtPdhibbxndr6/xx0w8+CVlY5FJZiaSz908Fpy+G0xkBFTvwLcKJFF4PJxVfGhVWNebikpWGnOoC71juQ==",
+      "version": "7.22.20",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.22.20.tgz",
+      "integrity": "sha512-Y6jd1ahLubuYweD/zJH+vvOY141v4f9igNQAQ+MBgq9JlHS2iTsZKn1aMsb3vGccZsXI16VzTBw52Xx0DWmtnA==",
       "dev": true,
       "requires": {
         "@ampproject/remapping": "^2.2.0",
-        "@babel/code-frame": "^7.22.10",
-        "@babel/generator": "^7.22.10",
-        "@babel/helper-compilation-targets": "^7.22.10",
-        "@babel/helper-module-transforms": "^7.22.9",
-        "@babel/helpers": "^7.22.11",
-        "@babel/parser": "^7.22.11",
-        "@babel/template": "^7.22.5",
-        "@babel/traverse": "^7.22.11",
-        "@babel/types": "^7.22.11",
+        "@babel/code-frame": "^7.22.13",
+        "@babel/generator": "^7.22.15",
+        "@babel/helper-compilation-targets": "^7.22.15",
+        "@babel/helper-module-transforms": "^7.22.20",
+        "@babel/helpers": "^7.22.15",
+        "@babel/parser": "^7.22.16",
+        "@babel/template": "^7.22.15",
+        "@babel/traverse": "^7.22.20",
+        "@babel/types": "^7.22.19",
         "convert-source-map": "^1.7.0",
         "debug": "^4.1.0",
         "gensync": "^1.0.0-beta.2",
       }
     },
     "@babel/generator": {
-      "version": "7.22.10",
-      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.10.tgz",
-      "integrity": "sha512-79KIf7YiWjjdZ81JnLujDRApWtl7BxTqWD88+FFdQEIOG8LJ0etDOM7CXuIgGJa55sGOwZVwuEsaLEm0PJ5/+A==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.15.tgz",
+      "integrity": "sha512-Zu9oWARBqeVOW0dZOjXc3JObrzuqothQ3y/n1kUtrjCoCPLkXUwMvOo/F/TCfoHMbWIFlWwpZtkZVb9ga4U2pA==",
       "dev": true,
       "requires": {
-        "@babel/types": "^7.22.10",
+        "@babel/types": "^7.22.15",
         "@jridgewell/gen-mapping": "^0.3.2",
         "@jridgewell/trace-mapping": "^0.3.17",
         "jsesc": "^2.5.1"
       }
     },
     "@babel/helper-compilation-targets": {
-      "version": "7.22.10",
-      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.10.tgz",
-      "integrity": "sha512-JMSwHD4J7SLod0idLq5PKgI+6g/hLD/iuWBq08ZX49xE14VpVEojJ5rHWptpirV2j020MvypRLAXAO50igCJ5Q==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz",
+      "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==",
       "dev": true,
       "requires": {
         "@babel/compat-data": "^7.22.9",
-        "@babel/helper-validator-option": "^7.22.5",
+        "@babel/helper-validator-option": "^7.22.15",
         "browserslist": "^4.21.9",
         "lru-cache": "^5.1.1",
         "semver": "^6.3.1"
       }
     },
     "@babel/helper-environment-visitor": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz",
-      "integrity": "sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==",
+      "version": "7.22.20",
+      "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz",
+      "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==",
       "dev": true
     },
     "@babel/helper-function-name": {
       }
     },
     "@babel/helper-module-imports": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.5.tgz",
-      "integrity": "sha512-8Dl6+HD/cKifutF5qGd/8ZJi84QeAKh+CEe1sBzz8UayBBGg1dAIJrdHOcOM5b2MpzWL2yuotJTtGjETq0qjXg==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz",
+      "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==",
       "dev": true,
       "requires": {
-        "@babel/types": "^7.22.5"
+        "@babel/types": "^7.22.15"
       }
     },
     "@babel/helper-module-transforms": {
-      "version": "7.22.9",
-      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.22.9.tgz",
-      "integrity": "sha512-t+WA2Xn5K+rTeGtC8jCsdAH52bjggG5TKRuRrAGNM/mjIbO4GxvlLMFOEz9wXY5I2XQ60PMFsAG2WIcG82dQMQ==",
+      "version": "7.22.20",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.22.20.tgz",
+      "integrity": "sha512-dLT7JVWIUUxKOs1UnJUBR3S70YK+pKX6AbJgB2vMIvEkZkrfJDbYDJesnPshtKV4LhDOR3Oc5YULeDizRek+5A==",
       "dev": true,
       "requires": {
-        "@babel/helper-environment-visitor": "^7.22.5",
-        "@babel/helper-module-imports": "^7.22.5",
+        "@babel/helper-environment-visitor": "^7.22.20",
+        "@babel/helper-module-imports": "^7.22.15",
         "@babel/helper-simple-access": "^7.22.5",
         "@babel/helper-split-export-declaration": "^7.22.6",
-        "@babel/helper-validator-identifier": "^7.22.5"
+        "@babel/helper-validator-identifier": "^7.22.20"
       }
     },
     "@babel/helper-simple-access": {
       "dev": true
     },
     "@babel/helper-validator-identifier": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz",
-      "integrity": "sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==",
+      "version": "7.22.20",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
+      "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==",
       "dev": true
     },
     "@babel/helper-validator-option": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.5.tgz",
-      "integrity": "sha512-R3oB6xlIVKUnxNUxbmgq7pKjxpru24zlimpE8WK47fACIlM0II/Hm1RS8IaOI7NgCr6LNS+jl5l75m20npAziw==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz",
+      "integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==",
       "dev": true
     },
     "@babel/helpers": {
-      "version": "7.22.11",
-      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.22.11.tgz",
-      "integrity": "sha512-vyOXC8PBWaGc5h7GMsNx68OH33cypkEDJCHvYVVgVbbxJDROYVtexSk0gK5iCF1xNjRIN2s8ai7hwkWDq5szWg==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.22.15.tgz",
+      "integrity": "sha512-7pAjK0aSdxOwR+CcYAqgWOGy5dcfvzsTIfFTb2odQqW47MDfv14UaJDY6eng8ylM2EaeKXdxaSWESbkmaQHTmw==",
       "dev": true,
       "requires": {
-        "@babel/template": "^7.22.5",
-        "@babel/traverse": "^7.22.11",
-        "@babel/types": "^7.22.11"
+        "@babel/template": "^7.22.15",
+        "@babel/traverse": "^7.22.15",
+        "@babel/types": "^7.22.15"
       }
     },
     "@babel/highlight": {
-      "version": "7.22.10",
-      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.10.tgz",
-      "integrity": "sha512-78aUtVcT7MUscr0K5mIEnkwxPE0MaxkR5RxRwuHaQ+JuU5AmTPhY+do2mdzVTnIJJpyBglql2pehuBIWHug+WQ==",
+      "version": "7.22.20",
+      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz",
+      "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==",
       "dev": true,
       "requires": {
-        "@babel/helper-validator-identifier": "^7.22.5",
+        "@babel/helper-validator-identifier": "^7.22.20",
         "chalk": "^2.4.2",
         "js-tokens": "^4.0.0"
       },
       }
     },
     "@babel/parser": {
-      "version": "7.22.11",
-      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.11.tgz",
-      "integrity": "sha512-R5zb8eJIBPJriQtbH/htEQy4k7E2dHWlD2Y2VT07JCzwYZHBxV5ZYtM0UhXSNMT74LyxuM+b1jdL7pSesXbC/g==",
+      "version": "7.22.16",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.16.tgz",
+      "integrity": "sha512-+gPfKv8UWeKKeJTUxe59+OobVcrYHETCsORl61EmSkmgymguYk/X5bp7GuUIXaFsc6y++v8ZxPsLSSuujqDphA==",
       "dev": true
     },
     "@babel/template": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.5.tgz",
-      "integrity": "sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz",
+      "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==",
       "dev": true,
       "requires": {
-        "@babel/code-frame": "^7.22.5",
-        "@babel/parser": "^7.22.5",
-        "@babel/types": "^7.22.5"
+        "@babel/code-frame": "^7.22.13",
+        "@babel/parser": "^7.22.15",
+        "@babel/types": "^7.22.15"
       }
     },
     "@babel/traverse": {
-      "version": "7.22.11",
-      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.11.tgz",
-      "integrity": "sha512-mzAenteTfomcB7mfPtyi+4oe5BZ6MXxWcn4CX+h4IRJ+OOGXBrWU6jDQavkQI9Vuc5P+donFabBfFCcmWka9lQ==",
+      "version": "7.22.20",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.20.tgz",
+      "integrity": "sha512-eU260mPZbU7mZ0N+X10pxXhQFMGTeLb9eFS0mxehS8HZp9o1uSnFeWQuG1UPrlxgA7QoUzFhOnilHDp0AXCyHw==",
       "dev": true,
       "requires": {
-        "@babel/code-frame": "^7.22.10",
-        "@babel/generator": "^7.22.10",
-        "@babel/helper-environment-visitor": "^7.22.5",
+        "@babel/code-frame": "^7.22.13",
+        "@babel/generator": "^7.22.15",
+        "@babel/helper-environment-visitor": "^7.22.20",
         "@babel/helper-function-name": "^7.22.5",
         "@babel/helper-hoist-variables": "^7.22.5",
         "@babel/helper-split-export-declaration": "^7.22.6",
-        "@babel/parser": "^7.22.11",
-        "@babel/types": "^7.22.11",
+        "@babel/parser": "^7.22.16",
+        "@babel/types": "^7.22.19",
         "debug": "^4.1.0",
         "globals": "^11.1.0"
       },
       }
     },
     "@babel/types": {
-      "version": "7.22.11",
-      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.11.tgz",
-      "integrity": "sha512-siazHiGuZRz9aB9NpHy9GOs9xiQPKnMzgdr493iI1M67vRXpnEq8ZOOKzezC5q7zwuQ6sDhdSp4SD9ixKSqKZg==",
+      "version": "7.22.19",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.19.tgz",
+      "integrity": "sha512-P7LAw/LbojPzkgp5oznjE6tQEIWbp4PkkfrZDINTro9zgBRtI324/EYsiSI7lhPbpIQ+DCeR2NNmMWANGGfZsg==",
       "dev": true,
       "requires": {
         "@babel/helper-string-parser": "^7.22.5",
-        "@babel/helper-validator-identifier": "^7.22.5",
+        "@babel/helper-validator-identifier": "^7.22.19",
         "to-fast-properties": "^2.0.0"
       }
     },
       }
     },
     "@eslint-community/regexpp": {
-      "version": "4.8.0",
-      "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.8.0.tgz",
-      "integrity": "sha512-JylOEEzDiOryeUnFbQz+oViCXS0KsvR1mvHkoMiu5+UiBvy+RYX7tzlIIIEstF/gVa2tj9AQXk3dgnxv6KxhFg==",
+      "version": "4.8.1",
+      "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.8.1.tgz",
+      "integrity": "sha512-PWiOzLIUAjN/w5K17PoF4n6sKBw0gqLHPhywmYHP4t1VFQQVYeb1yWsJwnMVEMl3tUHME7X/SJPZLmtG7XBDxQ==",
       "dev": true
     },
     "@eslint/eslintrc": {
       }
     },
     "@eslint/js": {
-      "version": "8.48.0",
-      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.48.0.tgz",
-      "integrity": "sha512-ZSjtmelB7IJfWD2Fvb7+Z+ChTIKWq6kjda95fLcQKNS5aheVHn4IkfgRQE3sIIzTcSLwLcLZUD9UBt+V7+h+Pw==",
+      "version": "8.50.0",
+      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.50.0.tgz",
+      "integrity": "sha512-NCC3zz2+nvYd+Ckfh87rA47zfu2QsQpvc6k1yzTk+b9KzRj0wkGa8LSoGOXN6Zv4lRf/EIoZ80biDh9HOI+RNQ==",
       "dev": true
     },
     "@humanwhocodes/config-array": {
-      "version": "0.11.10",
-      "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz",
-      "integrity": "sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==",
+      "version": "0.11.11",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.11.tgz",
+      "integrity": "sha512-N2brEuAadi0CcdeMXUkhbZB84eskAc8MEX1By6qEchoVywSgXPIjou4rYsl0V3Hj0ZnuGycGCjdNgockbzeWNA==",
       "dev": true,
       "requires": {
         "@humanwhocodes/object-schema": "^1.2.1",
       "resolved": "https://registry.npmjs.org/@squeep/mystery-box/-/mystery-box-2.0.1.tgz",
       "integrity": "sha512-is8Uqvcm27pm49ZaQL/KOizo89SiTuTZElQoyuPX34TvcnsOTN2eN2YxdVSwKusZwYi+mmE8brrAtBcM/Ttpwg=="
     },
+    "@squeep/totp": {
+      "version": "git+https://git.squeep.com/squeep-totp#381355dd8d70451179cfbde204177ed89675c3a3",
+      "from": "@squeep/totp@git+https://git.squeep.com/squeep-totp#v1.1.0",
+      "requires": {
+        "base32.js": "^0.1.0",
+        "qrcode-svg": "^1.1.0"
+      }
+    },
     "@squeep/web-linking": {
       "version": "1.0.8",
       "resolved": "https://registry.npmjs.org/@squeep/web-linking/-/web-linking-1.0.8.tgz",
       }
     },
     "@types/http-cache-semantics": {
-      "version": "4.0.1",
-      "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz",
-      "integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ=="
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.2.tgz",
+      "integrity": "sha512-FD+nQWA2zJjh4L9+pFXqWOi0Hs1ryBCfI+985NjluQ1p8EYtoLvjLOKidXBtZ4/IcxDX4o8/E8qDS3540tNliw=="
     },
     "abbrev": {
       "version": "1.1.1",
       }
     },
     "argon2": {
-      "version": "0.31.0",
-      "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.31.0.tgz",
-      "integrity": "sha512-r56NWwlE3tjD/FIqL1T+V4Ka+Mb5yMF35w1YWHpwpEjeONXBUbxmjhWkWqY63mse8lpcZ+ZZIGpKL+s+qXhyfg==",
+      "version": "0.31.1",
+      "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.31.1.tgz",
+      "integrity": "sha512-ik2xnJrLXazya7m4Nz1XfBSRjXj8Koq8qF9PsQC8059p20ifWc9zx/hgU3ItZh/3TnwXkv0RbhvjodPkmFf0bg==",
       "optional": true,
       "requires": {
         "@mapbox/node-pre-gyp": "^1.0.11",
       "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
       "devOptional": true
     },
+    "base32.js": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz",
+      "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ=="
+    },
     "binary-extensions": {
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
       "devOptional": true
     },
     "caniuse-lite": {
-      "version": "1.0.30001524",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001524.tgz",
-      "integrity": "sha512-Jj917pJtYg9HSJBF95HVX3Cdr89JUyLT4IZ8SvM5aDRni95swKgYi3TgYLH5hnGfPE/U1dg6IfZ50UsIlLkwSA==",
+      "version": "1.0.30001534",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001534.tgz",
+      "integrity": "sha512-vlPVrhsCS7XaSh2VvWluIQEzVhefrUQcEsQWSS5A5V+dM07uv1qHeQzAOTGIMy9i3e9bH15+muvI/UHojVgS/Q==",
       "dev": true
     },
     "chalk": {
       }
     },
     "electron-to-chromium": {
-      "version": "1.4.503",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.503.tgz",
-      "integrity": "sha512-LF2IQit4B0VrUHFeQkWhZm97KuJSGF2WJqq1InpY+ECpFRkXd8yTIaTtJxsO0OKDmiBYwWqcrNaXOurn2T2wiA==",
+      "version": "1.4.523",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.523.tgz",
+      "integrity": "sha512-9AreocSUWnzNtvLcbpng6N+GkXnCcBR80IQkxRC9Dfdyg4gaWNUPBujAHUpKkiUkoSoR9UlhA4zD/IgBklmhzg==",
       "dev": true
     },
     "emoji-regex": {
       "dev": true
     },
     "eslint": {
-      "version": "8.48.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.48.0.tgz",
-      "integrity": "sha512-sb6DLeIuRXxeM1YljSe1KEx9/YYeZFQWcV8Rq9HfigmdDEugjLEVEa1ozDjL6YDjBpQHPJxJzze+alxi4T3OLg==",
+      "version": "8.50.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.50.0.tgz",
+      "integrity": "sha512-FOnOGSuFuFLv/Sa+FDVRZl4GGVAAFFi8LecRsI5a1tMO5HIE8nCm4ivAlzt4dT3ol/PaaGC0rJEEXQmHJBGoOg==",
       "dev": true,
       "requires": {
         "@eslint-community/eslint-utils": "^4.2.0",
         "@eslint-community/regexpp": "^4.6.1",
         "@eslint/eslintrc": "^2.1.2",
-        "@eslint/js": "8.48.0",
-        "@humanwhocodes/config-array": "^0.11.10",
+        "@eslint/js": "8.50.0",
+        "@humanwhocodes/config-array": "^0.11.11",
         "@humanwhocodes/module-importer": "^1.0.1",
         "@nodelib/fs.walk": "^1.2.8",
         "ajv": "^6.12.4",
       }
     },
     "flatted": {
-      "version": "3.2.7",
-      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz",
-      "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
+      "version": "3.2.9",
+      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz",
+      "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==",
       "dev": true
     },
     "foreground-child": {
       }
     },
     "globals": {
-      "version": "13.21.0",
-      "resolved": "https://registry.npmjs.org/globals/-/globals-13.21.0.tgz",
-      "integrity": "sha512-ybyme3s4yy/t/3s35bewwXKOf7cvzfreG2lH0lZl0JB7I4GxRP2ghxOK/Nb9EkRXdbBXZLfq/p/0W2JUONB/Gg==",
+      "version": "13.22.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-13.22.0.tgz",
+      "integrity": "sha512-H1Ddc/PbZHTDVJSnj8kWptIRSD6AM3pK+mKytuIVF4uoBV7rshFlhhvA58ceJ5wp3Er58w6zj7bykMpYXt3ETw==",
       "dev": true,
       "requires": {
         "type-fest": "^0.20.2"
       }
     },
     "microformats-parser": {
-      "version": "1.4.1",
-      "resolved": "https://registry.npmjs.org/microformats-parser/-/microformats-parser-1.4.1.tgz",
-      "integrity": "sha512-BSg9Y/Aik8hvvme/fkxnXMRvTKuVwOeTapeZdaPQ+92DEubyM31iMtwbgFZ1383om643UvfYY5G23E9s1FY2KQ==",
+      "version": "1.5.2",
+      "resolved": "https://registry.npmjs.org/microformats-parser/-/microformats-parser-1.5.2.tgz",
+      "integrity": "sha512-EcHm8zxEm3CggOLgILfxCo2wDiJEOnACzpV/FXWGLaRk24ECei+JkoWNdKdo2vzo/Pww9EvrQNeQsdv4JuHy7Q==",
       "requires": {
         "parse5": "^6.0.0"
       }
       "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==",
       "dev": true
     },
+    "qrcode-svg": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/qrcode-svg/-/qrcode-svg-1.1.0.tgz",
+      "integrity": "sha512-XyQCIXux1zEIA3NPb0AeR8UMYvXZzWEhgdBgBjH9gO7M48H9uoHzviNz8pXw3UzrAcxRRRn9gxHewAVK7bn9qw=="
+    },
     "queue-microtask": {
       "version": "1.2.3",
       "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
       "devOptional": true
     },
     "resolve": {
-      "version": "1.22.4",
-      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.4.tgz",
-      "integrity": "sha512-PXNdCiPqDqeUou+w1C2eTQbNfxKSuMxqTCuvlmmMsk1NWHL5fRrhY6Pl0qEYYc6+QqGClco1Qj8XnjPego4wfg==",
+      "version": "1.22.6",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.6.tgz",
+      "integrity": "sha512-njhxM7mV12JfufShqGy3Rz8j11RPdLy4xi15UurGJeoHLfJpVXKdh3ueuOqbYUcDZnffr6X739JBo5LzyahEsw==",
       "dev": true,
       "requires": {
         "is-core-module": "^2.13.0",
       "devOptional": true
     },
     "sinon": {
-      "version": "15.2.0",
-      "resolved": "https://registry.npmjs.org/sinon/-/sinon-15.2.0.tgz",
-      "integrity": "sha512-nPS85arNqwBXaIsFCkolHjGIkFo+Oxu9vbgmBJizLAhqe6P2o3Qmj3KCUoRkfhHtvgDhZdWD3risLHAUJ8npjw==",
+      "version": "16.0.0",
+      "resolved": "https://registry.npmjs.org/sinon/-/sinon-16.0.0.tgz",
+      "integrity": "sha512-B8AaZZm9CT5pqe4l4uWJztfD/mOTa7dL8Qo0W4+s+t74xECOgSZDDQCBjNgIK3+n4kyxQrSTv2V5ul8K25qkiQ==",
       "dev": true,
       "requires": {
         "@sinonjs/commons": "^3.0.0",
       "dev": true
     },
     "tar": {
-      "version": "6.1.15",
-      "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.15.tgz",
-      "integrity": "sha512-/zKt9UyngnxIT/EAGYuxaMYgOIJiP81ab9ZfkILq4oNLPFX50qyYmu7jRj9qeXoxmJHjGlbH0+cm2uy1WCs10A==",
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz",
+      "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==",
       "optional": true,
       "requires": {
         "chownr": "^2.0.0",
       "devOptional": true
     },
     "uuid": {
-      "version": "9.0.0",
-      "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
-      "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg=="
+      "version": "9.0.1",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
+      "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="
     },
     "webidl-conversions": {
       "version": "3.0.1",
index a1a55c42765181e3a46cd112b1a67fd152a1f65a..a1b37a526406191430a467bc75921d0dfbfab31c 100644 (file)
     "@squeep/api-dingus": "v2.0.0",
     "@squeep/html-template-helper": "git+https://git.squeep.com/squeep-html-template-helper#v1.4.0",
     "@squeep/indieauth-helper": "^1.3.0",
-    "@squeep/mystery-box": "^2.0.1"
+    "@squeep/mystery-box": "^2.0.1",
+    "@squeep/totp": "git+https://git.squeep.com/squeep-totp#v1.1.0"
   },
   "optionalDependencies": {
-    "argon2": "^0.31.0",
+    "argon2": "^0.31.1",
     "node-linux-pam": "^0.2.1"
   },
   "devDependencies": {
-    "eslint": "^8.48.0",
+    "eslint": "^8.50.0",
     "eslint-plugin-node": "^11.1.0",
     "eslint-plugin-promise": "^6.1.1",
     "eslint-plugin-security": "^1.7.1",
@@ -49,6 +50,6 @@
     "mocha": "^10.2.0",
     "nyc": "^15.1.0",
     "pre-commit": "^1.2.2",
-    "sinon": "^15.2.0"
+    "sinon": "^16.0.0"
   }
 }
index 6cac48faabc9b73fba373739777ac7fe8daa7751..75761689556c9adda18a7773901037c34bb2b71b 100644 (file)
@@ -6,7 +6,6 @@ const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-requi
 const Authenticator = require('../../lib/authenticator');
 const stubLogger = require('../stub-logger');
 const stubDb = require('../stub-db');
-const Errors = require('../../lib/errors');
 const Enum = require('../../lib/enum');
 const Config = require('../stub-config');
 
@@ -267,6 +266,40 @@ describe('Authenticator', function () {
     });
   }); // isValidCookieAuth
 
+  describe('checkOTP', function () {
+    let state, otp;
+    this.beforeEach(function () {
+      sinon.stub(authenticator.TOTP.prototype, 'validate').returns(true);
+      state = {
+        key: Buffer.from('12345678901234567890'),
+        attempt: 0,
+        epochMs: Date.now(),
+      };
+      otp = '000000';
+    });
+    it('covers valid OTP entry', function () {
+      const result = authenticator.checkOTP(state, otp);
+      assert.strictEqual(result, Enum.OTPResult.Valid);
+    });
+    it('covers invalid OTP entry', function () {
+      authenticator.TOTP.prototype.validate.returns(false);
+      const result = authenticator.checkOTP(state, otp);
+      assert.strictEqual(result, Enum.OTPResult.InvalidSoftFail);
+    });
+    it('covers invalid OTP entry, too many failures', function () {
+      state.attempt = 10;
+      authenticator.TOTP.prototype.validate.returns(false);
+      const result = authenticator.checkOTP(state, otp);
+      assert.strictEqual(result, Enum.OTPResult.InvalidHardFail);
+    });
+    it('covers invalid OTP entry', function () {
+      state.epochMs = 0;
+      authenticator.TOTP.prototype.validate.returns(false);
+      const result = authenticator.checkOTP(state, otp);
+      assert.strictEqual(result, Enum.OTPResult.InvalidHardFail);
+    });
+  }); // checkOTP
+
   describe('sessionCheck', function () {
     let cookie, req, res, loginPath, required, profilesAllowed;
     beforeEach(function () {
index a3efeafce3b812fcfd3961393e4e015a5986afad..79abbf007e8805ab05018aa77a5aae541489c62c 100644 (file)
@@ -7,6 +7,7 @@ const assert = require('assert');
 const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
 
 const SessionManager = require('../../lib/session-manager');
+const Enum = require('../../lib/enum');
 const Config = require('../stub-config');
 const stubLogger = require('../stub-logger');
 
@@ -25,9 +26,11 @@ describe('SessionManager', function () {
       params: {},
       queryParams: {},
       parsedBody: {},
+      errors: [],
     };
     stubAuthenticator = {
       isValidIdentifierCredential: sinon.stub(),
+      checkOTP: sinon.stub(),
     };
     manager = new SessionManager(stubLogger, stubAuthenticator, options);
     sinon.stub(manager.indieAuthCommunication);
@@ -61,12 +64,27 @@ describe('SessionManager', function () {
   }); // _sessionCookieSet
 
   describe('getAdminLogin', function () {
-    it('covers', async function () {
+    it('covers no session', async function () {
+      await manager.getAdminLogin(res, ctx);
+    });
+    it('covers established session', async function () {
+      ctx.authenticationId = 'identifier';
+      ctx.queryParams['r'] = '/admin';
       await manager.getAdminLogin(res, ctx);
+      assert.strictEqual(res.statusCode, 302);
+      assert(res.setHeader.called);
     });
   }); // getAdminLogin
 
   describe('postAdminLogin', function () {
+    beforeEach(function () {
+      sinon.stub(manager, '_otpSubmission').resolves(false);
+    });
+    it('covers otp submission', async function () {
+      manager._otpSubmission.resolves(true);
+      await manager.postAdminLogin(res, ctx);
+      assert(res.end.notCalled);
+    });
     it('covers valid local', async function () {
       ctx.parsedBody.identifier = 'user';
       ctx.parsedBody.credential = 'password';
@@ -115,15 +133,15 @@ describe('SessionManager', function () {
     });
     it('covers profile scheme fallback', async function () {
       ctx.parsedBody.me = 'https://example.com/profile';
-      ctx.parsedBody.me_auto_scheme = '1';
+      ctx.parsedBody['me_auto_scheme'] = '1';
       manager.indieAuthCommunication.fetchProfile
         .onCall(0).resolves()
         .onCall(1).resolves({
-        metadata: {
-          issuer: 'https://example.com/',
-          authorizationEndpoint: 'https://example.com/auth',
-        },
-      });
+          metadata: {
+            issuer: 'https://example.com/',
+            authorizationEndpoint: 'https://example.com/auth',
+          },
+        });
       await manager.postAdminLogin(res, ctx);
       assert.strictEqual(res.statusCode, 302);
 
@@ -165,6 +183,103 @@ describe('SessionManager', function () {
     }); // living-standard-20220212
   }); // postAdminLogin
 
+  describe('_otpSubmission', function () {
+    beforeEach(function () {
+      sinon.useFakeTimers(new Date());
+      sinon.stub(manager.mysteryBox, 'unpack').resolves({
+        authenticatedIdentifier: 'identifier',
+        attempt: 0,
+        epochMs: Date.now(),
+      });
+      manager.authenticator.checkOTP.resolves(Enum.OTPResult.Valid);
+      ctx.parsedBody.state = 'state_data';
+      ctx.parsedBody.otp = '123456';
+    });
+    it('returns false if no otp state', async function () {
+      delete ctx.parsedBody.state;
+      const result = await manager._otpSubmission(res, ctx);
+      assert(manager.mysteryBox.unpack.notCalled);
+      assert.strictEqual(result, false);
+    });
+    it('returns false when presented with invalid otp state', async function () {
+      manager.mysteryBox.unpack.rejects();
+      const result = await manager._otpSubmission(res, ctx);
+      assert(manager.mysteryBox.unpack.called);
+      assert.strictEqual(result, false);
+    });
+    it('returns true when submitted otp is invalid, but allowed to retry', async function () {
+      manager.authenticator.checkOTP.resolves(Enum.OTPResult.InvalidSoftFail);
+      const result = await manager._otpSubmission(res, ctx);
+      assert(manager.mysteryBox.unpack.called);
+      assert.strictEqual(result, true);
+      assert(res.end.called);
+    });
+    it('returns false when submitted otp is invalid and too many attempts', async function () {
+      manager.mysteryBox.unpack.resolves({
+        authenticatedIdentifier: 'identifier',
+        attempt: 10,
+        epochMs: Date.now(),
+      });
+      manager.authenticator.checkOTP.resolves(Enum.OTPResult.InvalidHardFail);
+      const result = await manager._otpSubmission(res, ctx);
+      assert(manager.mysteryBox.unpack.called);
+      assert.strictEqual(result, false);
+    });
+    it('returns false when submitted otp is invalid and too much time has passed', async function () {
+      manager.mysteryBox.unpack.resolves({
+        authenticatedIdentifier: 'identifier',
+        attempt: 0,
+        epochMs: Date.now() - 99999999,
+      });
+      manager.authenticator.checkOTP.resolves(Enum.OTPResult.InvalidHardFail);
+      const result = await manager._otpSubmission(res, ctx);
+      assert(manager.mysteryBox.unpack.called);
+      assert.strictEqual(result, false);
+    });
+    it('returns true when submitted otp is valid', async function () {
+      const result = await manager._otpSubmission(res, ctx);
+      assert(res.end.called);
+      assert.strictEqual(result, true);
+    });
+    it('covers unexpected otp response', async function () {
+      manager.authenticator.checkOTP.resolves('wrong');
+      assert.rejects(() => manager._otpSubmission(res, ctx), RangeError);
+    });
+  }); // _otpSubmission
+
+  describe('_localUserAuth', function () {
+    beforeEach(function () {
+      ctx.parsedBody.identifier = 'identifier';
+      ctx.parsedBody.credential = 'credential';
+      manager.authenticator.isValidIdentifierCredential.resolves(true);
+      sinon.stub(manager.mysteryBox, 'pack').resolves('box');
+    });
+    it('returns false if indieauth available', async function () {
+      ctx.parsedBody.me = 'https://example.com/';
+      const result = await manager._localUserAuth(res, ctx);
+      assert.strictEqual(result, false);
+    });
+    it('returns true if identifier is invalid', async function () {
+      manager.authenticator.isValidIdentifierCredential.resolves(false);
+      const result = await manager._localUserAuth(res, ctx);
+      assert.strictEqual(result, true);
+      assert(manager.authenticator.isValidIdentifierCredential.called);
+      assert(res.end.called);
+    });
+    it('returns true if valid identifier', async function () {
+      const result = await manager._localUserAuth(res, ctx);
+      assert.strictEqual(result, true);
+      assert(res.end.called);
+    });
+    it('returns true if valid identifier requires otp entry', async function () {
+      ctx.otpNeeded = true;
+      const result = await manager._localUserAuth(res, ctx);
+      assert.strictEqual(result, true);
+      assert(manager.mysteryBox.pack.called);
+      assert(res.end.called);
+    });
+  }); // _localUserAuth
+
   describe('getAdminLogout', function () {
     it('covers', async function () {
       await manager.getAdminLogout(res, ctx);
diff --git a/test/lib/template/otp-html.js b/test/lib/template/otp-html.js
new file mode 100644 (file)
index 0000000..5aa3d8b
--- /dev/null
@@ -0,0 +1,38 @@
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const { OTPHTML } = require('../../../lib/template');
+const lintHtml = require('../../lint-html');
+
+describe('Template OTPHTML', function () {
+  let ctx, options;
+  beforeEach(function () {
+    ctx = {};
+    options = {
+      authenticator: {
+        otpBlurb: ['otp info'],
+      },
+      manager: {
+        pageTitle: 'page',
+      },
+      dingus: {
+        selfBaseUrl: 'https://example.com/',
+      },
+    };
+  });
+
+  it('renders', function () {
+    ctx.errors = ['an error', 'another error'];
+    const result = OTPHTML(ctx, options);
+    lintHtml(result);
+    assert(result);
+  });
+
+  it('covers empty error', function () {
+    const result = OTPHTML(ctx, options);
+    lintHtml(result);
+    assert(result);
+  });
+
+});
\ No newline at end of file