4 * Here we wrangle activities which support login sessions, serving and
5 * processing the HTML forms a user interacts with.
8 const { Communication: IndieAuthCommunication
} = require('@squeep/indieauth-helper');
9 const { MysteryBox
} = require('@squeep/mystery-box');
10 const { TOTP
} = require('@squeep/totp');
11 const { randomUUID
} = require('crypto');
12 const common
= require('./common');
13 const Enum
= require('./enum');
14 const Template
= require('./template');
16 const _fileScope
= common
.fileScope(__filename
);
18 class SessionManager
{
20 * @param {Console} logger
21 * @param {Authenticator} authenticator
22 * @param {Object} options
23 * @param {Object} options.authenticator
24 * @param {String[]} options.authenticator.authnEnabled
25 * @param {Number=} options.authenticator.inactiveSessionLifespanSeconds
26 * @param {Boolean} options.authenticator.secureAuthOnly
27 * @param {Object=} options.dingus
28 * @param {String=} options.dingus.proxyPrefix
29 * @param {String} options.dingus.selfBaseUrl
30 * @param {Object} options.manager
31 * @param {String} options.manager.pageTitle
33 constructor(logger
, authenticator
, options
) {
35 this.authenticator
= authenticator
;
36 this.db
= authenticator
.db
; // TODO: take db arg in next major version bump
37 this.options
= options
;
38 this.proxyPrefix
= options
.dingus
?.proxyPrefix
?? '';
39 this.indieAuthCommunication
= new IndieAuthCommunication(logger
, options
);
40 this.mysteryBox
= new MysteryBox(options
);
41 this.mysteryBox
.on('statistics', common
.mysteryBoxLogger(logger
, _fileScope(this.constructor.name
)));
43 this.cookieLifespan
= options
.authenticator
.inactiveSessionLifespanSeconds
|| 60 * 60 * 24 * 32;
48 * Set or update our session cookie.
49 * @param {http.ServerResponse} res
50 * @param {Object=} session
51 * @param {Number=} maxAge
52 * @param {String=} path
54 async
_sessionCookieSet(res
, session
, maxAge
= this.cookieLifespan
, path
= '/') {
55 const cookieName
= Enum
.SessionCookie
;
56 const secureSession
= session
&& await
this.mysteryBox
.pack(session
) || '""';
57 common
.addCookie(res
, cookieName
, secureSession
, {
60 secure: this.options
.authenticator
.secureAuthOnly
,
61 maxAge: session
&& maxAge
|| 0,
68 * Remove any current session cookie.
69 * @param {http.ServerResponse} res
70 * @param {String} path
72 async
_sessionCookieClear(res
, path
= '/') {
73 await
this._sessionCookieSet(res
, undefined, 0, path
);
77 * @typedef {(pagePathLevel: Number, ctx: Object, htmlOptions: Object) => void} AppTemplateCallback
81 * GET request for establishing admin session.
82 * @param {http.ServerResponse} res
84 * @param {AppTemplateCallback} appCb
86 async
getAdminLogin(res
, ctx
, appCb
) {
87 const _scope
= _fileScope('getAdminLogin');
88 this.logger
.debug(_scope
, 'called', { ctx
});
90 // If there is already a valid session established, and if a
91 // redirect is present, follow it. Why? Maybe someone had
92 // multiple tabs open, their session expired, and their browser
93 // restarted, reloading all of them, and they have relogged in
94 // on one and just want to hit reload on the others to go back
95 // to where they were.
96 const redirect
= ctx
.queryParams
['r'];
97 if (ctx
.authenticationId
&& redirect
) {
99 res
.setHeader(Enum
.Header
.Location
, redirect
);
102 res
.end(Template
.LoginHTML(ctx
, this.options
, appCb
));
105 this.logger
.info(_scope
, 'finished', { ctx
});
110 * POST request for taking form data to establish admin session.
111 * @param {http.ServerResponse} res
112 * @param {Object} ctx
113 * @param {AppTemplateCallback} appCb
115 async
postAdminLogin(res
, ctx
, appCb
) {
116 const _scope
= _fileScope('postAdminLogin');
117 this.logger
.debug(_scope
, 'called', { ctx
});
121 // Check if this was an OTP entry attempt.
122 if (await
this._otpSubmission(res
, ctx
, appCb
)) {
123 // OTP path was taken, either successful entry and session creation, or re-prompting for otp.
127 if (await
this._localUserAuth(res
, ctx
, appCb
)) {
128 // Local auth path was taken.
132 // Otherwise, carry on with IndieAuth handshake.
134 // Is profile a reasonable url?
135 let me
, meAutoScheme
, session
, authorizationEndpoint
;
137 me
= new URL(ctx
.parsedBody
['me']);
138 meAutoScheme
= !!ctx
.parsedBody
['me_auto_scheme'];
140 this.logger
.debug(_scope
, 'failed to parse supplied profile url', { ctx
});
141 ctx
.errors
.push(`Unable to understand '${ctx.parsedBody['me']}' as a profile URL.`);
144 if (ctx
.errors
.length
) {
145 res
.end(Template
.LoginHTML(ctx
, this.options
, appCb
));
150 profile
= await
this.indieAuthCommunication
.fetchProfile(me
);
151 if ((!profile
?.metadata
)
153 this.logger
.debug(_scope
, 'trying http fallback', { ctx
});
154 me
.protocol
= 'http';
155 profile
= await
this.indieAuthCommunication
.fetchProfile(me
);
157 if (!profile
?.metadata
) {
158 this.logger
.debug(_scope
, 'failed to find any profile information at url', { ctx
});
159 ctx
.errors
.push(`No profile information was found at '${me}'.`);
161 // fetch and parse me for 'authorization_endpoint' relation links
163 authorizationEndpoint
= new URL(profile
.metadata
.authorizationEndpoint
);
165 ctx
.errors
.push(`Unable to understand the authorization endpoint ('${profile.metadata.authorizationEndpoint}') indicated by that profile ('${me}') as a URL.`);
168 if (profile
.metadata
.issuer
) {
171 const issuer
= new URL(profile
.metadata
.issuer
);
174 || issuer
.protocol
.toLowerCase() !== 'https:') { // stupid URL trailing colon thing
175 this.logger
.debug(_scope
, 'supplied issuer url invalid', { ctx
});
176 ctx
.errors
.push('Authorization server provided an invalid issuer field.');
179 this.logger
.debug(_scope
, 'failed to parse supplied issuer url', { ctx
});
180 ctx
.errors
.push('Authorization server provided an unparsable issuer field.');
183 this.logger
.debug(_scope
, 'no issuer in metadata, assuming legacy mode', { ctx
});
184 // Strict 20220212 compliance would error here.
185 // ctx.errors.push('Authorization server did not provide issuer field, as required by current specification.');
189 if (authorizationEndpoint
) {
190 const pkce
= await IndieAuthCommunication
.generatePKCE();
192 const state
= randomUUID();
193 const redirect
= ctx
.queryParams
['r'] || './';
195 authorizationEndpoint: authorizationEndpoint
.href
,
197 codeVerifier: pkce
.codeVerifier
,
200 issuer: profile
.metadata
.issuer
,
203 // Update auth endpoint parameters
205 'response_type': 'code',
206 'client_id': this.options
.dingus
.selfBaseUrl
,
207 'redirect_uri': `${this.options.dingus.selfBaseUrl}admin/_ia`,
208 'state': session
.state
,
209 'code_challenge': pkce
.codeChallenge
,
210 'code_challenge_method': pkce
.codeChallengeMethod
,
212 }).forEach(([name
, value
]) => authorizationEndpoint
.searchParams
.set(name
, value
));
215 if (ctx
.errors
.length
) {
216 res
.end(Template
.LoginHTML(ctx
, this.options
, appCb
));
220 await
this._sessionCookieSet(res
, session
);
221 res
.setHeader(Enum
.Header
.Location
, authorizationEndpoint
.href
);
222 res
.statusCode
= 302; // Found
225 this.logger
.info(_scope
, 'finished indieauth', { ctx
});
230 * @typedef {Object} OTPState
231 * @property {String} authenticatedIdentifier
232 * @property {Buffer|String} key
233 * @property {Number} attempt
234 * @property {Number} epochMs
235 * @property {String} redirect
238 * @param {OTPState} otpState
240 static _validateOTPState(otpState
) {
241 if (!otpState
.authenticatedIdentifier
) {
242 throw new Error('otp state missing authentication identifier');
245 throw new Error('otp state missing otp key');
247 if (!('attempt' in otpState
)) {
248 throw new Error('otp state missing attempt count');
250 if (!('epochMs' in otpState
)) {
251 throw new Error('otp state missing timestamp');
253 if (!otpState
.redirect
) {
254 throw new Error('otp state missing redirect');
260 * Check if processing an OTP entry attempt. If not, resume login flow.
261 * If so, validate otp and establish session, else reprompt for OTP, or
262 * return to login entry after too many failures.
263 * @param {http.ServerResponse} res
264 * @param {Object} ctx
265 * @param {Object} ctx.parsedBody
266 * @param {String} ctx.parsedBody.state
267 * @param {String} ctx.parsedBody.otp
268 * @returns {Promise<Boolean>} true if otp was handled, otherwise false indicates further login processing needed
270 async
_otpSubmission(res
, ctx
, appCb
) {
271 const _scope
= _fileScope('_otpSubmission');
277 // Are we processing an OTP entry attempt?
279 // Ignore and continue back to main login.
282 /** @type OTPState */
285 state
= await
this.mysteryBox
.unpack(stateBox
);
286 this.constructor._validateOTPState(state
);
288 this.logger
.debug(_scope
, 'failed to unpack otp state', { error: e
, ctx
});
289 // Ignore and continue back to main login.
294 // Nothing submitted, but valid state, just present otp form again, do not count as attempt.
295 ctx
.otpState
= stateBox
;
296 res
.end(Template
.OTPHTML(ctx
, this.options
, appCb
));
297 this.logger
.info(_scope
, 'finished otp, nothing entered, request again', { ctx
});
301 const OTPResult
= await
this.authenticator
.checkOTP(state
, otp
);
303 case Enum
.OTPResult
.Valid:
304 // Valid auth, persist the authenticated session
306 authenticatedIdentifier: state
.authenticatedIdentifier
,
308 await
this._sessionCookieSet(res
, ctx
.session
);
309 res
.statusCode
= 302;
310 res
.setHeader(Enum
.Header
.Location
, state
.redirect
);
312 this.logger
.info(_scope
, 'finished otp', { ctx
});
315 case Enum
.OTPResult
.InvalidSoftFail:
317 ctx
.errors
.push('Invalid OTP token.');
318 ctx
.otpState
= await
this.mysteryBox
.pack({
320 attempt: state
.attempt
+ 1,
322 res
.end(Template
.OTPHTML(ctx
, this.options
, appCb
));
323 this.logger
.info(_scope
, 'finished otp, invalid, request again', { ctx
});
326 case Enum
.OTPResult
.InvalidHardFail:
327 // Return to initial login.
328 this.logger
.debug(_scope
, 'too many otp failures', { ctx
});
329 ctx
.errors
.push('Unable to verify OTP token at this time. Try again.');
333 throw new RangeError('Unexpected OTPResult');
340 * @param {http.ServerResponse} res
341 * @param {Object} ctx
342 * @returns {Promise<Boolean>} true if handled, false if flow should continue
344 async
_localUserAuth(res
, ctx
, appCb
) {
345 const _scope
= _fileScope('_localUserAuth');
347 // If Indiauth enabled and profile was submitted, defer to that.
348 if (this.options
.authenticator
.authnEnabled
.includes('indieAuth')
349 && ctx
.parsedBody
['me']) {
353 const redirect
= ctx
.queryParams
['r'] || './';
354 const identifier
= ctx
.parsedBody
['identifier'];
355 const credential
= ctx
.parsedBody
['credential']; // N.B. Logger must specifically mask this field from ctx.
357 // N.B. validity check also sets authenticationId and maybe otpKey on ctx
358 const isValidLocalIdentifier
= await
this.authenticator
.isValidIdentifierCredential(identifier
, credential
, ctx
);
359 if (!isValidLocalIdentifier
) {
360 ctx
.errors
.push('Invalid username or password');
363 if (ctx
.errors
.length
) {
364 res
.end(Template
.LoginHTML(ctx
, this.options
, appCb
));
368 // If OTP exists for valid identifier, follow that flow.
370 ctx
.otpState
= await
this.mysteryBox
.pack({
371 authenticatedIdentifier: ctx
.authenticationId
,
377 res
.end(Template
.OTPHTML(ctx
, this.options
, appCb
));
378 this.logger
.info(_scope
, 'finished local, otp required', { ctx
});
382 // Valid auth, persist the authenticated session
384 authenticatedIdentifier: ctx
.authenticationId
,
386 await
this._sessionCookieSet(res
, ctx
.session
);
387 res
.statusCode
= 302;
388 res
.setHeader(Enum
.Header
.Location
, redirect
);
390 this.logger
.info(_scope
, 'finished local, no otp', { ctx
});
396 * GET request to remove current credentials.
397 * @param {http.ServerResponse} res
398 * @param {Object} ctx
400 async
getAdminLogout(res
, ctx
) {
401 const _scope
= _fileScope('getAdminLogout');
402 this.logger
.debug(_scope
, 'called', { ctx
});
404 await
this._sessionCookieClear(res
);
406 const redirect
= ctx
.queryParams
['r'] || './';
408 res
.statusCode
= 302;
409 res
.setHeader(Enum
.Header
.Location
, redirect
);
412 this.logger
.info(_scope
, 'finished', { ctx
});
417 * GET request for returning IndieAuth redirect.
418 * This currently only redeems a scope-less profile.
419 * @param {http.ServerResponse} res
420 * @param {Object} ctx
421 * @param {AppTemplateCallback} appCb
423 async
getAdminIA(res
, ctx
, appCb
) {
424 const _scope
= _fileScope('getAdminIA');
425 this.logger
.debug(_scope
, 'called', { ctx
});
430 // Unpack cookie to restore session data
432 const cookieValue
= ctx
.cookie
?.[Enum
.SessionCookie
];
434 this.logger
.debug(_scope
, 'no cookie', { ctx
});
435 ctx
.errors
.push('missing required cookie');
438 ctx
.session
= await
this.mysteryBox
.unpack(cookieValue
);
439 this.logger
.debug(_scope
, 'restored session from cookie', { ctx
});
441 this.logger
.debug(_scope
, 'could not unpack cookie');
442 ctx
.errors
.push('invalid cookie');
446 // Validate unpacked session values
449 // Add any auth errors
450 if (ctx
.queryParams
['error']) {
451 ctx
.errors
.push(ctx
.queryParams
['error']);
452 if (ctx
.queryParams
['error_description']) {
453 ctx
.errors
.push(ctx
.queryParams
['error_description']);
458 if (ctx
.queryParams
['state'] !== ctx
.session
.state
) {
459 this.logger
.debug(_scope
, 'state mismatch', { ctx
});
460 ctx
.errors
.push('invalid state');
463 const code
= ctx
.queryParams
['code'];
465 this.logger
.debug(_scope
, 'missing code', { ctx
});
466 ctx
.errors
.push('invalid code');
470 if (ctx
.session
.issuer
) {
471 if (ctx
.queryParams
['iss'] !== ctx
.session
.issuer
) {
472 this.logger
.debug(_scope
, 'issuer mismatch', { ctx
});
473 ctx
.errors
.push('invalid issuer');
476 this.logger
.debug(_scope
, 'no issuer in metadata, assuming legacy mode', { ctx
});
477 // Strict 20220212 compliance would error here. (Also earlier.)
478 // ctx.errors.push('invalid issuer');
481 let redeemProfileUrl
;
483 redeemProfileUrl
= new URL(ctx
.session
.authorizationEndpoint
);
485 this.logger
.debug(_scope
, 'failed to parse restored session authorization endpoint as url', { ctx
});
486 ctx
.errors
.push('invalid cookie');
489 if (redeemProfileUrl
) {
490 profile
= await
this.indieAuthCommunication
.redeemProfileCode(redeemProfileUrl
, code
, ctx
.session
.codeVerifier
, this.options
.dingus
.selfBaseUrl
, `${this.options.dingus.selfBaseUrl}admin/_ia`);
492 this.logger
.debug(_scope
, 'no profile from code redemption', { ctx
});
493 ctx
.errors
.push('did not get a profile response from authorization endpoint code redemption');
494 } else if (!profile
.me
) {
495 this.logger
.debug(_scope
, 'no profile me identifier from code redemption', { ctx
});
496 ctx
.errors
.push('did not get \'me\' value from authorization endpoint code redemption');
497 } else if (profile
.me
!== ctx
.session
.me
) {
498 this.logger
.debug(_scope
, 'mis-matched canonical me from redeemed profile', { ctx
, profile
});
499 const newProfileUrl
= new URL(profile
.me
);
500 // Rediscover auth endpoint for the new returned profile.
501 const newProfile
= await
this.indieAuthCommunication
.fetchProfile(newProfileUrl
);
502 if (newProfile
.metadata
.authorizationEndpoint
!== ctx
.session
.authorizationEndpoint
) {
503 this.logger
.debug(_scope
, 'mis-matched auth endpoints between provided me and canonical me', { ctx
, profile
, newProfile
});
504 ctx
.errors
.push('canonical profile url provided by authorization endpoint is not handled by that endpoint, cannot continue');
506 // The endpoints match, all is okay, update our records.
507 ctx
.session
.me
= profile
.me
;
512 if (ctx
.errors
.length
) {
513 await
this._sessionCookieClear(res
);
514 res
.end(Template
.IAHTML(ctx
, this.options
, appCb
));
518 const redirect
= ctx
.session
.redirect
|| './';
520 // Set cookie as auth valid, redirect to original location.
522 authenticatedProfile: ctx
.session
.me
,
525 await
this._sessionCookieSet(res
, ctx
.session
);
526 res
.statusCode
= 302;
527 res
.setHeader(Enum
.Header
.Location
, redirect
);
530 this.logger
.info(_scope
, 'finished', { ctx
});
535 * Page for modifying credentials and OTP.
536 * @param {http.ServerResponse} res
537 * @param {Object} ctx
538 * @param {AppTemplateCallback} appCb
540 async
getAdminSettings(res
, ctx
, appCb
) {
541 const _scope
= _fileScope('getAdminSettings');
542 this.logger
.debug(_scope
, 'called', { ctx
});
545 await
this.db
.context(async (dbCtx
) => {
546 const authData
= await
this.db
.authenticationGet(dbCtx
, ctx
.authenticationId
);
548 ctx
.errors
.push('Sorry, you do not seem to exist! <pre>¯\\_(ツ)_/¯</pre> Cannot do anything useful here!');
551 ctx
.otpKey
= authData
.otpKey
;
554 this.logger
.error(_scope
, 'failed', { ctx
, error: e
});
555 ctx
.errors
.push('An error was encountered. Sorry that is not very helpful.');
558 res
.end(Template
.SettingsHTML(ctx
, this.options
, appCb
));
559 this.logger
.info(_scope
, 'finished', { ctx
});
564 * Page for modifying credentials and OTP.
565 * @param {http.ServerResponse} res
566 * @param {Object} ctx
567 * @param {Object[]=} appNavLinks
568 * @param {AppTemplateCallback} appCb
570 async
postAdminSettings(res
, ctx
, appCb
) {
571 const _scope
= _fileScope('postAdminSettings');
572 this.logger
.debug(_scope
, 'called', { ctx
});
575 await
this.db
.context(async (dbCtx
) => {
576 const authData
= await
this.db
.authenticationGet(dbCtx
, ctx
.authenticationId
);
578 ctx
.errors
.push('Sorry, you do not seem to exist! <pre>¯\\_(ツ)_/¯</pre> Cannot do anything useful here!');
581 ctx
.otpKey
= authData
.otpKey
;
583 const otpSubmitButton
= ctx
.parsedBody
?.otp
;
584 switch (otpSubmitButton
) {
586 await
this._otpDisable(dbCtx
, ctx
, authData
);
590 await
this._otpConfirm(dbCtx
, ctx
);
594 await
this._otpEnable(ctx
);
598 const credentialSubmitButton
= ctx
.parsedBody
?.credential
;
599 switch (credentialSubmitButton
) { // eslint-disable-line sonarjs/no-small-switch
601 await
this._credentialUpdate(dbCtx
, ctx
, authData
);
606 this.logger
.error(_scope
, 'failed', { ctx
, error: e
});
607 ctx
.errors
.push('An error was encountered. Sorry that is not very helpful.');
610 res
.end(Template
.SettingsHTML(ctx
, this.options
, appCb
));
611 this.logger
.info(_scope
, 'finished', { ctx
});
616 * Submission to disable OTP.
619 * @param {AuthInfo} authData
621 async
_otpDisable(dbCtx
, ctx
, authData
) {
622 const _scope
= _fileScope('_otpDisable');
624 authData
.otpKey
= null;
625 await
this.db
.authenticationUpdateOTPKey(dbCtx
, ctx
.authenticationId
, null);
626 ctx
.notifications
.push('OTP removed!');
628 this.logger
.info(_scope
, 'otp disabled', { identifier: ctx
.authenticationId
});
630 this.logger
.error(_scope
, 'failed', { error: e
, ctx
});
631 ctx
.errors
.push('Failed to disable OTP!');
637 * Submission to enable OTP.
638 * @param {Object} ctx
640 async
_otpEnable(ctx
) {
641 const _scope
= _fileScope('_otpEnable');
643 ctx
.otpConfirmKey
= await TOTP
.createKey('sha1', 'base32');
644 ctx
.otpConfirmBox
= await
this.mysteryBox
.pack({
645 otpKey: ctx
.otpConfirmKey
,
647 otpInitiatedMs: Date
.now(),
650 delete ctx
.otpConfirmKey
;
651 delete ctx
.otpConfirmBox
;
652 this.logger
.error(_scope
, 'failed', { error: e
, ctx
});
653 ctx
.errors
.push('Failed to enable OTP!');
659 * Submission to confirm enabling OTP.
661 * @param {Object} ctx
663 async
_otpConfirm(dbCtx
, ctx
) {
664 const _scope
= _fileScope('_otpConfirm');
667 'otp-box': otpConfirmBox
,
668 'otp-token': otpToken
,
670 let otpKey
, otpAttempt
, otpInitiatedMs
;
672 ({ otpKey
, otpAttempt
, otpInitiatedMs
} = await
this.mysteryBox
.unpack(otpConfirmBox
));
674 this.logger
.debug(_scope
, 'failed to unpack otp box', { error: e
, ctx
});
675 ctx
.errors
.push('Problem with form data.');
679 // No token entered, just prompt again.
680 ctx
.otpConfirmKey
= otpKey
;
681 ctx
.otpConfirmBox
= otpConfirmBox
;
682 ctx
.notifications
.push('Please enter the OTP token to enable 2FA.');
686 const totp
= new TOTP({
688 keyEncoding: 'base32',
690 if (!totp
.validate(otpToken
)) {
691 // Bad token, prompt again.
692 ctx
.otpConfirmKey
= otpKey
;
693 ctx
.otpConfirmBox
= await
this.mysteryBox
.pack({
698 ctx
.errors
.push('Invalid token!');
703 await
this.db
.context(async (dbCtx
) => {
704 await
this.db
.authenticationUpdateOTPKey(dbCtx
, ctx
.authenticationId
, otpKey
);
706 ctx
.notifications
.push('OTP enabled!');
707 this.logger
.info(_scope
, 'otp enabled', { identifier: ctx
.authenticationId
, otpAttempt
, otpInitiatedMs
});
710 this.logger
.debug(_scope
, 'failed', { error: e
, ctx
});
711 ctx
.errors
.push('An error occurred, OTP was not enabled. Sorry this is not very helpful.');
717 * Submission to set new credential.
719 * @param {Object} ctx
720 * @param {AuthInfo} authData
722 async
_credentialUpdate(dbCtx
, ctx
, authData
) {
723 const _scope
= _fileScope('_credentialUpdate');
726 'credential-new': newCredential
,
727 'credential-new-2': newCredential2
,
728 'credential-current': oldCredential
,
730 if (newCredential
!== newCredential2
) {
731 ctx
.errors
.push('New password confirmation did not match!');
733 if (!newCredential
) {
734 ctx
.errors
.push('Password cannot be empty!');
736 if (! await
this.authenticator
._validateAuthDataCredential(authData
, oldCredential
)) {
737 ctx
.errors
.push('Invalid current password!');
739 if (ctx
.errors
.length
) {
743 await
this.authenticator
.updateCredential(dbCtx
, ctx
.authenticationId
, newCredential
);
744 ctx
.notifications
.push('Password updated!');
746 this.logger
.error(_scope
, 'failed', { error: e
, ctx
});
747 ctx
.errors
.push('Failed to update password!');
753 module
.exports
= SessionManager
;