const { Communication: IndieAuthCommunication } = require('@squeep/indieauth-helper');
const { MysteryBox } = require('@squeep/mystery-box');
const { TOTP } = require('@squeep/totp');
-const { randomUUID } = require('crypto');
+const { randomUUID } = require('node:crypto');
const common = require('./common');
const Enum = require('./enum');
const Template = require('./template');
const _fileScope = common.fileScope(__filename);
+/**
+ * @typedef {import('node:http')} http
+ * @typedef {import('./authenticator')} Authenticator
+ */
+/**
+ * @typedef {object} ConsoleLike
+ * @property {Function} debug log debug
+ * @property {Function} error log error
+ * @property {Function} info log info
+ */
+
class SessionManager {
/**
- * @param {Console} logger
- * @param {Authenticator} authenticator
- * @param {Object} options
- * @param {Object} options.authenticator
- * @param {String[]} options.authenticator.authnEnabled
- * @param {Number=} options.authenticator.inactiveSessionLifespanSeconds
- * @param {Boolean} options.authenticator.secureAuthOnly
- * @param {Object=} options.dingus
- * @param {String=} options.dingus.proxyPrefix
- * @param {String} options.dingus.selfBaseUrl
- * @param {Object} options.manager
- * @param {String} options.manager.pageTitle
+ * @param {ConsoleLike} logger logger
+ * @param {Authenticator} authenticator authenticator instance
+ * @param {object} options options
+ * @param {object} options.authenticator authenticator instance options
+ * @param {string[]} options.authenticator.authnEnabled authentication methods enabled
+ * @param {number=} options.authenticator.inactiveSessionLifespanSeconds session timeout
+ * @param {boolean} options.authenticator.secureAuthOnly allow only https
+ * @param {object=} options.dingus dingus options
+ * @param {string=} options.dingus.proxyPrefix prefix on route paths
+ * @param {string} options.dingus.selfBaseUrl base url
+ * @param {object} options.manager manager options
+ * @param {string} options.manager.pageTitle page title
*/
constructor(logger, authenticator, options) {
this.logger = logger;
/**
* Set or update our session cookie.
- * @param {http.ServerResponse} res
- * @param {Object=} session
- * @param {Number=} maxAge
- * @param {String=} path
+ * @param {http.ServerResponse} res respoonse
+ * @param {object=} session session object
+ * @param {number=} maxAge session validity in seconds
+ * @param {string=} path session cookie path
*/
async _sessionCookieSet(res, session, maxAge = this.cookieLifespan, path = '/') {
const cookieName = Enum.SessionCookie;
/**
* Remove any current session cookie.
- * @param {http.ServerResponse} res
- * @param {String} path
+ * @param {http.ServerResponse} res response
+ * @param {string} path session cookie path
*/
async _sessionCookieClear(res, path = '/') {
await this._sessionCookieSet(res, undefined, 0, path);
}
+ /**
+ * @typedef {(pagePathLevel: number, ctx: object, htmlOptions: object) => void} AppTemplateCallback
+ */
+
/**
* GET request for establishing admin session.
- * @param {http.ServerResponse} res
- * @param {Object} ctx
+ * @param {http.ServerResponse} res response
+ * @param {object} ctx context
+ * @param {AppTemplateCallback} appCb function to mogrify template htmlOptions
*/
- async getAdminLogin(res, ctx) {
+ async getAdminLogin(res, ctx, appCb) {
const _scope = _fileScope('getAdminLogin');
this.logger.debug(_scope, 'called', { ctx });
res.setHeader(Enum.Header.Location, redirect);
res.end();
} else {
- res.end(Template.LoginHTML(ctx, this.options));
+ res.end(Template.LoginHTML(ctx, this.options, appCb));
}
this.logger.info(_scope, 'finished', { ctx });
/**
* POST request for taking form data to establish admin session.
- * @param {http.ServerResponse} res
- * @param {Object} ctx
+ * @param {http.ServerResponse} res response
+ * @param {object} ctx context
+ * @param {AppTemplateCallback} appCb function to mogrify template htmlOptions
*/
- async postAdminLogin(res, ctx) {
+ async postAdminLogin(res, ctx, appCb) {
const _scope = _fileScope('postAdminLogin');
this.logger.debug(_scope, 'called', { ctx });
ctx.errors = [];
// Check if this was an OTP entry attempt.
- if (await this._otpSubmission(res, ctx)) {
+ if (await this._otpSubmission(res, ctx, appCb)) {
// OTP path was taken, either successful entry and session creation, or re-prompting for otp.
return;
}
- if (await this._localUserAuth(res, ctx)) {
+ if (await this._localUserAuth(res, ctx, appCb)) {
// Local auth path was taken.
return;
}
try {
me = new URL(ctx.parsedBody['me']);
meAutoScheme = !!ctx.parsedBody['me_auto_scheme'];
- } catch (e) {
+ } catch (e) { // eslint-disable-line no-unused-vars
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 (ctx.errors.length) {
- res.end(Template.LoginHTML(ctx, this.options));
+ res.end(Template.LoginHTML(ctx, this.options, appCb));
return;
}
// fetch and parse me for 'authorization_endpoint' relation links
try {
authorizationEndpoint = new URL(profile.metadata.authorizationEndpoint);
- } catch (e) {
+ } catch (e) { // eslint-disable-line no-unused-vars
ctx.errors.push(`Unable to understand the authorization endpoint ('${profile.metadata.authorizationEndpoint}') indicated by that profile ('${me}') as a URL.`);
}
this.logger.debug(_scope, 'supplied issuer url invalid', { ctx });
ctx.errors.push('Authorization server provided an invalid issuer field.');
}
- } catch (e) {
+ } catch (e) { // eslint-disable-line no-unused-vars
this.logger.debug(_scope, 'failed to parse supplied issuer url', { ctx });
ctx.errors.push('Authorization server provided an unparsable issuer field.');
}
}
if (ctx.errors.length) {
- res.end(Template.LoginHTML(ctx, this.options));
+ res.end(Template.LoginHTML(ctx, this.options, appCb));
return;
}
/**
- * @typedef {Object} OTPState
- * @property {String} authenticatedIdentifier
- * @property {Buffer|String} key
- * @property {Number} attempt
- * @property {Number} epochMs
- * @property {String} redirect
+ * @typedef {object} OTPState
+ * @property {string} authenticatedIdentifier identifier of logging-in user
+ * @property {Buffer | string} key otp key
+ * @property {number} attempt counter
+ * @property {number} epochMs started
+ * @property {string} redirect where to go after successful otp entry
*/
/**
- * @param {OTPState} otpState
+ * @param {OTPState} otpState otp state
*/
static _validateOTPState(otpState) {
if (!otpState.authenticatedIdentifier) {
* 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 {http.ServerResponse} res
- * @param {Object} ctx
- * @param {Object} ctx.parsedBody
- * @param {String} ctx.parsedBody.state
- * @param {String} ctx.parsedBody.otp
- * @returns {Promise<Boolean>} true if otp was handled, otherwise false indicates further login processing needed
+ * @param {http.ServerResponse} res response
+ * @param {object} ctx context
+ * @param {object} ctx.parsedBody submitted data
+ * @param {string} ctx.parsedBody.state packed state
+ * @param {string} ctx.parsedBody.otp entered code
+ * @param {AppTemplateCallback} appCb function to mogrify template htmlOptions
+ * @returns {Promise<boolean>} true if otp was handled, otherwise false indicates further login processing needed
*/
- async _otpSubmission(res, ctx) {
+ async _otpSubmission(res, ctx, appCb) {
const _scope = _fileScope('_otpSubmission');
const {
// Ignore and continue back to main login.
return false;
}
- /** @type OTPState */
+ /** @type {OTPState} */
let state;
try {
state = await this.mysteryBox.unpack(stateBox);
if (!otp) {
// Nothing submitted, but valid state, just present otp form again, do not count as attempt.
ctx.otpState = stateBox;
- res.end(Template.OTPHTML(ctx, this.options));
+ res.end(Template.OTPHTML(ctx, this.options, appCb));
this.logger.info(_scope, 'finished otp, nothing entered, request again', { ctx });
return true;
}
...state,
attempt: state.attempt + 1,
});
- res.end(Template.OTPHTML(ctx, this.options));
+ res.end(Template.OTPHTML(ctx, this.options, appCb));
this.logger.info(_scope, 'finished otp, invalid, request again', { ctx });
return true;
/**
*
- * @param {http.ServerResponse} res
- * @param {Object} ctx
- * @returns {Promise<Boolean>} true if handled, false if flow should continue
+ * @param {http.ServerResponse} res response
+ * @param {object} ctx context
+ * @param {AppTemplateCallback} appCb function to mogrify template htmlOptions
+ * @returns {Promise<boolean>} true if handled, false if flow should continue
*/
- async _localUserAuth(res, ctx) {
+ async _localUserAuth(res, ctx, appCb) {
const _scope = _fileScope('_localUserAuth');
// If Indiauth enabled and profile was submitted, defer to that.
}
if (ctx.errors.length) {
- res.end(Template.LoginHTML(ctx, this.options));
+ res.end(Template.LoginHTML(ctx, this.options, appCb));
return true;
}
attempt: 0,
redirect,
});
- res.end(Template.OTPHTML(ctx, this.options));
+ res.end(Template.OTPHTML(ctx, this.options, appCb));
this.logger.info(_scope, 'finished local, otp required', { ctx });
return true;
}
/**
* GET request to remove current credentials.
- * @param {http.ServerResponse} res
- * @param {Object} ctx
+ * @param {http.ServerResponse} res response
+ * @param {object} ctx context
*/
async getAdminLogout(res, ctx) {
const _scope = _fileScope('getAdminLogout');
/**
* GET request for returning IndieAuth redirect.
* This currently only redeems a scope-less profile.
- * @param {http.ServerResponse} res
- * @param {Object} ctx
+ * @param {http.ServerResponse} res response
+ * @param {object} ctx context
+ * @param {AppTemplateCallback} appCb function to mogrify template htmlOptions
*/
- async getAdminIA(res, ctx) {
+ async getAdminIA(res, ctx, appCb) {
const _scope = _fileScope('getAdminIA');
this.logger.debug(_scope, 'called', { ctx });
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');
+ this.logger.debug(_scope, 'could not unpack cookie', { error: e });
ctx.errors.push('invalid cookie');
}
}
let redeemProfileUrl;
try {
redeemProfileUrl = new URL(ctx.session.authorizationEndpoint);
- } catch (e) {
+ } catch (e) { // eslint-disable-line no-unused-vars
this.logger.debug(_scope, 'failed to parse restored session authorization endpoint as url', { ctx });
ctx.errors.push('invalid cookie');
}
if (ctx.errors.length) {
await this._sessionCookieClear(res);
- res.end(Template.IAHTML(ctx, this.options));
+ res.end(Template.IAHTML(ctx, this.options, appCb));
return;
}
}
+ /**
+ * @typedef {object} AuthInfo
+ * @property {string} identifier identifier
+ * @property {string} credential hashed credential
+ * @property {string=} otpKey otp key
+ */
/**
* Page for modifying credentials and OTP.
- * @param {http.ServerResponse} res
- * @param {Object} ctx
+ * @param {http.ServerResponse} res response
+ * @param {object} ctx context
+ * @param {AppTemplateCallback} appCb function to mogrify template htmlOptions
*/
- async getAdminSettings(res, ctx) {
+ async getAdminSettings(res, ctx, appCb) {
const _scope = _fileScope('getAdminSettings');
this.logger.debug(_scope, 'called', { ctx });
ctx.errors.push('An error was encountered. Sorry that is not very helpful.');
}
- res.end(Template.SettingsHTML(ctx, this.options));
+ res.end(Template.SettingsHTML(ctx, this.options, appCb));
this.logger.info(_scope, 'finished', { ctx });
}
/**
* Page for modifying credentials and OTP.
- * @param {http.ServerResponse} res
- * @param {Object} ctx
+ * @param {http.ServerResponse} res response
+ * @param {object} ctx context
+ * @param {AppTemplateCallback} appCb function to mogrify template htmlOptions
*/
- async postAdminSettings(res, ctx) {
+ async postAdminSettings(res, ctx, appCb) {
const _scope = _fileScope('postAdminSettings');
this.logger.debug(_scope, 'called', { ctx });
await this._otpDisable(dbCtx, ctx, authData);
return;
- case 'confirm':
+ case 'confirm':
await this._otpConfirm(dbCtx, ctx);
return;
ctx.errors.push('An error was encountered. Sorry that is not very helpful.');
}
- res.end(Template.SettingsHTML(ctx, this.options));
+ res.end(Template.SettingsHTML(ctx, this.options, appCb));
this.logger.info(_scope, 'finished', { ctx });
}
/**
* Submission to disable OTP.
- * @param {*} dbCtx
- * @param {*} ctx
- * @param {AuthInfo} authData
+ * @param {*} dbCtx db context
+ * @param {*} ctx context
+ * @param {AuthInfo} authData auth info
*/
async _otpDisable(dbCtx, ctx, authData) {
const _scope = _fileScope('_otpDisable');
/**
* Submission to enable OTP.
- * @param {Object} ctx
+ * @param {object} ctx context
*/
async _otpEnable(ctx) {
const _scope = _fileScope('_otpEnable');
/**
* Submission to confirm enabling OTP.
- * @param {*} dbCtx
- * @param {Object} ctx
+ * @param {*} dbCtx db context
+ * @param {object} ctx context
*/
async _otpConfirm(dbCtx, ctx) {
const _scope = _fileScope('_otpConfirm');
/**
* Submission to set new credential.
- * @param {*} dbCtx
- * @param {Object} ctx
- * @param {AuthInfo} authData
+ * @param {*} dbCtx db context
+ * @param {object} ctx context
+ * @param {AuthInfo} authData auth info
*/
async _credentialUpdate(dbCtx, ctx, authData) {
const _scope = _fileScope('_credentialUpdate');
}
}
-
}
module.exports = SessionManager;