initial commit
[squeep-authentication-module] / lib / session-manager.js
diff --git a/lib/session-manager.js b/lib/session-manager.js
new file mode 100644 (file)
index 0000000..f7cd33a
--- /dev/null
@@ -0,0 +1,309 @@
+'use strict';
+
+/**
+ * Here we process activities which support login sessions.
+ */
+
+const { Communication: IndieAuthCommunication } = require('@squeep/indieauth-helper');
+const { MysteryBox } = require('@squeep/mystery-box');
+const common = require('./common');
+const Enum = require('./enum');
+const Template = require('./template');
+
+const _fileScope = common.fileScope(__filename);
+
+class SessionManager {
+  /**
+   * @param {Console} logger
+   * @param {Authenticator} authenticator
+   * @param {Object} options
+   * @param {Object} options.authenticator
+   * @param {String[]} options.authenticator.authnEnabled
+   * @param {Object} options.authenticator.secureAuthOnly
+   * @param {Object} options.dingus
+   * @param {Object} options.dingus.proxyPrefix
+   * @param {Object} options.dingus.selfBaseUrl
+   */
+  constructor(logger, authenticator, options) {
+    this.logger = logger;
+    this.authenticator = authenticator;
+    this.options = options;
+    this.indieAuthCommunication = new IndieAuthCommunication(logger, options);
+    this.mysteryBox = new MysteryBox(logger, options);
+
+    this.cookieLifespan = 60 * 60 * 24 * 32;
+  }
+
+
+  /**
+   * Set or update our session cookie.
+   * @param {http.ServerResponse} res
+   * @param {Object} session
+   * @param {Number} maxAge
+   * @param {String} path
+   */
+  async _sessionCookieSet(res, session, maxAge, path = '/') {
+    const _scope = _fileScope('_sessionCookieSet');
+
+    const cookieName = Enum.SessionCookie;
+    const secureSession = session && await this.mysteryBox.pack(session) || '';
+    const cookieParts = [
+      `${cookieName}=${secureSession}`,
+      'HttpOnly',
+    ];
+    if (this.options.authenticator.secureAuthOnly) {
+      cookieParts.push('Secure');
+    }
+    if (typeof(maxAge) === 'number') {
+      cookieParts.push(`Max-Age=${maxAge}`);
+    }
+    if (path) {
+      cookieParts.push(`Path=${this.options.dingus.proxyPrefix}${path}`);
+    }
+    const cookie = cookieParts.join('; ');
+    this.logger.debug(_scope, 'session cookie', { cookie, session })
+    res.setHeader(Enum.Header.SetCookie, cookie);
+  }
+
+
+  /**
+   * GET request for establishing admin session.
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async getAdminLogin(res, ctx) {
+    const _scope = _fileScope('getAdminLogin');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    res.end(Template.LoginHTML(ctx, this.options));
+    this.logger.info(_scope, 'finished', { ctx })
+  }
+
+
+  /**
+   * POST request for taking form data to establish admin session.
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async postAdminLogin(res, ctx) {
+    const _scope = _fileScope('postAdminLogin');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    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;
+      }
+
+      // 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 });
+      return;
+    }
+
+    let me, session, authorizationEndpoint;
+    try {
+      me = new URL(ctx.parsedBody['me']);
+    } 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) {
+      const profile = await this.indieAuthCommunication.fetchProfile(me);
+      if (!profile || !profile.authorizationEndpoint) {
+        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.authorizationEndpoint);
+        } catch (e) {
+          ctx.errors.push(`Unable to understand the authorization endpoint ('${profile.authorizationEndpoint}') indicated by that profile ('${me}') as a URL.`);
+        }
+      }
+
+      if (authorizationEndpoint) {
+        const pkce = await IndieAuthCommunication.generatePKCE();
+        session = {
+          authorizationEndpoint: authorizationEndpoint.href,
+          state: ctx.requestId,
+          codeVerifier: pkce.codeVerifier,
+          me,
+          redirect,
+        };
+
+        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) {
+      res.end(Template.LoginHTML(ctx, this.options));
+      return;
+    }
+
+    await this._sessionCookieSet(res, session, this.cookieLifespan);
+    res.setHeader(Enum.Header.Location, authorizationEndpoint.href);
+    res.statusCode = 302; // Found
+    res.end();
+
+    this.logger.info(_scope, 'finished indieauth', { ctx })
+  }
+
+
+  /**
+   * GET request to remove current credentials.
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async getAdminLogout(res, ctx) {
+    const _scope = _fileScope('getAdminLogout');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    this._sessionCookieSet(res, '', 0);
+
+    const redirect = ctx.queryParams['r'] || './';
+
+    res.statusCode = 302;
+    res.setHeader(Enum.Header.Location, redirect);
+    res.end();
+
+    this.logger.info(_scope, 'finished', { ctx });
+  }
+
+
+  /**
+   * GET request for returning IndieAuth redirect.
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async getAdminIA(res, ctx) {
+    const _scope = _fileScope('getAdminIA');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    ctx.errors = [];
+    ctx.session = {};
+
+    // Unpack cookie to restore session data
+
+    const [ cookieName, cookieValue ] = common.splitFirst((ctx.cookie || ''), '=', '');
+    if (cookieName !== Enum.SessionCookie) {
+      this.logger.debug(_scope, 'no cookie', { ctx });
+      ctx.errors.push('missing required cookie');
+    } else {
+      try {
+        ctx.session = await this.mysteryBox.unpack(cookieValue);
+        this.logger.debug(_scope, 'restored session from cookie', { ctx });
+      } catch (e) {
+        this.logger.debug(_scope, 'could not unpack cookie');
+        ctx.errors.push('invalid cookie');
+      }
+    }
+
+    // Validate unpacked session values
+
+    // Add any auth errors
+    if (ctx.queryParams['error']) {
+      ctx.errors.push(ctx.queryParams['error']);
+      if (ctx.queryParams['error_description']) {
+        ctx.errors.push(ctx.queryParams['error_description']);
+      }
+    }
+
+    // check stuff
+    if (ctx.queryParams['state'] !== ctx.session.state) {
+      this.logger.debug(_scope, 'state mismatch', { ctx });
+      ctx.errors.push('invalid state');
+    }
+
+    const code = ctx.queryParams['code'];
+    if (!code) {
+      this.logger.debug(_scope, 'missing code', { ctx });
+      ctx.errors.push('invalid code');
+    }
+
+    let redeemProfileUrl;
+    try {
+      redeemProfileUrl = new URL(ctx.session.authorizationEndpoint);
+    } catch (e) {
+      this.logger.debug(_scope, 'failed to parse restored session authorization endpoint as url', { ctx });
+      ctx.errors.push('invalid cookie');
+    }
+    let profile;
+    if (redeemProfileUrl) {
+      profile = await this.indieAuthCommunication.redeemProfileCode(redeemProfileUrl, code, ctx.session.codeVerifier, this.options.dingus.selfBaseUrl, `${this.options.dingus.selfBaseUrl}admin/_ia`);
+      if (!profile) {
+        this.logger.debug(_scope, 'no profile from code redemption', { ctx });
+        ctx.errors.push('did not get a profile response from authorization endpoint code redemption');
+      } else if (!profile.me) {
+        this.logger.debug(_scope, 'no profile me identifier from code redemption', { ctx });
+        ctx.errors.push('did not get \'me\' value from authorization endpoint code redemption');
+      } else if (profile.me !== ctx.session.me) {
+        this.logger.debug(_scope, 'mis-matched canonical me from redeemed profile', { ctx, profile });
+        const newProfileUrl = new URL(profile.me);
+        // Rediscover auth endpoint for the new returned profile.
+        const newProfile = await this.indieAuthCommunication.fetchProfile(newProfileUrl);
+        if (newProfile.authorizationEndpoint !== ctx.session.authorizationEndpoint) {
+          this.logger.debug(_scope, 'mis-matched auth endpoints between provided me and canonical me', { ctx, profile, newProfile });
+          ctx.errors.push('canonical profile url provided by authorization endpoint is not handled by that endpoint, cannot continue');
+        } else {
+          // The endpoints match, all is okay, update our records.
+          ctx.session.me = profile.me;
+        }
+      }
+    }
+
+    if (ctx.errors.length) {
+      await this._sessionCookieSet(res, '', 0);
+      res.end(Template.IAHTML(ctx, this.options));
+      return;
+    }
+
+    const redirect = ctx.session.redirect || './';
+
+    // Set cookie as auth valid, redirect to original location.
+    ctx.session = {
+      authenticatedProfile: ctx.session.me,
+    };
+
+    await this._sessionCookieSet(res, ctx.session, this.cookieLifespan);
+    res.statusCode = 302;
+    res.setHeader(Enum.Header.Location, redirect);
+    res.end();
+
+    this.logger.info(_scope, 'finished', { ctx })
+  }
+
+
+}
+
+module.exports = SessionManager;
\ No newline at end of file