--- /dev/null
+'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 {
+ 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.secureCookie = options.authenticator.secureAuthOnly ? ' Secure;' : '';
+ this.cookieLifespan = 60 * 60 * 24 * 32;
+ }
+
+
+ /**
+ * Set or update our session cookie.
+ * @param {http.ServerResponse} res
+ * @param {Object} session
+ * @param {Number} maxAge
+ */
+ async _sessionCookieSet(res, session, maxAge) {
+ const cookieName = 'WSHas';
+ const secureSession = session && await this.mysteryBox.pack(session) || '';
+ const cookie = [
+ `${cookieName}=${secureSession}`,
+ 'HttpOnly',
+ this.secureCookie,
+ `Max-Age: ${maxAge}`,
+ ].join('; ');
+ 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.adminLoginHTML(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 = [];
+
+ // Only attempt user login if no IndieAuth profile is set
+ if (!ctx.parsedBody['me']) {
+ this.logger.debug(_scope, 'no indieauth profile, trying identifier', { ctx });
+
+ const identifier = ctx.parsedBody['identifier'];
+ const credential = ctx.parsedBody['credential'];
+
+ 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.adminLoginHTML(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, './');
+ 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 (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,
+ };
+
+ 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.adminLoginHTML(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);
+ res.statusCode = 302;
+ res.setHeader(Enum.Header.Location, './');
+ 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 !== 'WSHas') {
+ 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.adminIAHTML(ctx, this.options));
+ return;
+ }
+
+ // set cookie as auth valid, redirect to admin
+ ctx.session = {
+ authenticatedProfile: ctx.session.me,
+ };
+
+ await this._sessionCookieSet(res, ctx.session, this.cookieLifespan);
+ res.statusCode = 302;
+ res.setHeader(Enum.Header.Location, './');
+ res.end();
+
+ this.logger.info(_scope, 'finished', { ctx })
+ }
+
+
+}
+
+module.exports = SessionManager;
\ No newline at end of file