rudimentary support for totp 2fa
[squeep-authentication-module] / test / lib / session-manager.js
index daff0501e3f0cab2cd8a00a414cf01c4a30ab303..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';
@@ -84,7 +102,9 @@ describe('SessionManager', function () {
     it('covers valid profile', async function () {
       ctx.parsedBody.me = 'https://example.com/profile';
       manager.indieAuthCommunication.fetchProfile.resolves({
-        authorizationEndpoint: 'https://example.com/auth',
+        metadata: {
+          authorizationEndpoint: 'https://example.com/auth',
+        },
       });
       await manager.postAdminLogin(res, ctx);
       assert.strictEqual(res.statusCode, 302);
@@ -104,13 +124,162 @@ describe('SessionManager', function () {
     it('covers invalid profile response endpoint', async function () {
       ctx.parsedBody.me = 'https://example.com/profile';
       manager.indieAuthCommunication.fetchProfile.resolves({
-        authorizationEndpoint: 'not an auth endpoint',
+        metadata: {
+          authorizationEndpoint: 'not an auth endpoint',
+        },
       });
       await manager.postAdminLogin(res, ctx);
       assert(!res.setHeader.called);
     });
+    it('covers profile scheme fallback', async function () {
+      ctx.parsedBody.me = 'https://example.com/profile';
+      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',
+          },
+        });
+      await manager.postAdminLogin(res, ctx);
+      assert.strictEqual(res.statusCode, 302);
+
+    });
+    describe('living-standard-20220212', function () {
+      it('covers valid profile', async function () {
+        ctx.parsedBody.me = 'https://example.com/profile';
+        manager.indieAuthCommunication.fetchProfile.resolves({
+          metadata: {
+            issuer: 'https://example.com/',
+            authorizationEndpoint: 'https://example.com/auth',
+          },
+        });
+        await manager.postAdminLogin(res, ctx);
+        assert.strictEqual(res.statusCode, 302);
+      });
+      it('covers bad issuer url', async function () {
+        ctx.parsedBody.me = 'https://example.com/profile';
+        manager.indieAuthCommunication.fetchProfile.resolves({
+          metadata: {
+            issuer: 'http://example.com/?bah#foo',
+            authorizationEndpoint: 'https://example.com/auth',
+          },
+        });
+        await manager.postAdminLogin(res, ctx);
+        assert(!res.setHeader.called);
+      });
+      it('covers unparsable issuer url', async function () {
+        ctx.parsedBody.me = 'https://example.com/profile';
+        manager.indieAuthCommunication.fetchProfile.resolves({
+          metadata: {
+            issuer: 'not a url',
+            authorizationEndpoint: 'https://example.com/auth',
+          },
+        });
+        await manager.postAdminLogin(res, ctx);
+        assert(!res.setHeader.called);
+      });
+    }); // 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);
@@ -128,7 +297,9 @@ describe('SessionManager', function () {
         me,
       });
       manager.indieAuthCommunication.fetchProfile.resolves({
-        authorizationEndpoint,
+        metadata: {
+          authorizationEndpoint,
+        },
       });
       sinon.stub(manager.mysteryBox, 'unpack').resolves({
         authorizationEndpoint,
@@ -242,13 +413,48 @@ describe('SessionManager', function () {
       });
       manager.indieAuthCommunication.fetchProfile.restore();
       sinon.stub(manager.indieAuthCommunication, 'fetchProfile').resolves({
-        authorizationEndpoint: 'https://elsewhere.example.com/auth',
+        metadata: {
+          authorizationEndpoint: 'https://elsewhere.example.com/auth',
+        },
       });
 
       await manager.getAdminIA(res, ctx);
 
       assert(ctx.errors.length);
     });
+    describe('living-standard-20220212', function () {
+      beforeEach(function () {
+        manager.indieAuthCommunication.fetchProfile.resolves({
+          metadata: {
+            authorizationEndpoint,
+            issuer: 'https://example.com/',
+          },
+        });
+        manager.mysteryBox.unpack.resolves({
+          authorizationEndpoint,
+          issuer: 'https://example.com/',
+          state,
+          me,
+        });
+      });
+      it('covers valid', async function () {
+        ctx.queryParams['state'] = state;
+        ctx.queryParams['code'] = 'codeCodeCode';
+        ctx.queryParams['iss'] = 'https://example.com/';
+
+        await manager.getAdminIA(res, ctx);
+
+        assert.strictEqual(res.statusCode, 302);
+      });
+      it('covers mis-matched issuer', async function () {
+        ctx.queryParams['state'] = state;
+        ctx.queryParams['code'] = 'codeCodeCode';
+
+        await manager.getAdminIA(res, ctx);
+
+        assert(ctx.errors.length);
+      });
+    }); // living-standard-20220212
   }); // getAdminIA
 
 }); // SessionManager
\ No newline at end of file