4 * Here we process activities which support login sessions.
7 const { Communication: IndieAuthCommunication
} = require('@squeep/indieauth-helper');
8 const { MysteryBox
} = require('@squeep/mystery-box');
9 const { randomUUID
} = require('crypto');
10 const common
= require('./common');
11 const Enum
= require('./enum');
12 const Template
= require('./template');
14 const _fileScope
= common
.fileScope(__filename
);
16 class SessionManager
{
18 * @param {Console} logger
19 * @param {Authenticator} authenticator
20 * @param {Object} options
21 * @param {Object} options.authenticator
22 * @param {String[]} options.authenticator.authnEnabled
23 * @param {Number=} options.authenticator.inactiveSessionLifespanSeconds
24 * @param {Boolean} options.authenticator.secureAuthOnly
25 * @param {Object} options.dingus
26 * @param {String} options.dingus.proxyPrefix
27 * @param {String} options.dingus.selfBaseUrl
29 constructor(logger
, authenticator
, options
) {
31 this.authenticator
= authenticator
;
32 this.options
= options
;
33 this.indieAuthCommunication
= new IndieAuthCommunication(logger
, options
);
34 this.mysteryBox
= new MysteryBox(options
);
35 this.mysteryBox
.on('statistics', common
.mysteryBoxLogger(logger
, _fileScope(this.constructor.name
)));
37 this.cookieLifespan
= options
.authenticator
.inactiveSessionLifespanSeconds
|| 60 * 60 * 24 * 32;
42 * Set or update our session cookie.
43 * @param {http.ServerResponse} res
44 * @param {Object} session
45 * @param {Number} maxAge
46 * @param {String} path
48 async
_sessionCookieSet(res
, session
, maxAge
, path
= '/') {
49 const _scope
= _fileScope('_sessionCookieSet');
51 const cookieName
= Enum
.SessionCookie
;
52 const secureSession
= session
&& await
this.mysteryBox
.pack(session
) || '';
54 `${cookieName}=${secureSession}`,
58 if (this.options
.authenticator
.secureAuthOnly
) {
59 cookieParts
.push('Secure');
61 if (typeof maxAge
=== 'number') {
62 cookieParts
.push(`Max-Age=${maxAge}`);
65 cookieParts
.push(`Path=${this.options.dingus.proxyPrefix}${path}`);
67 const cookie
= cookieParts
.join('; ');
68 this.logger
.debug(_scope
, 'session cookie', { cookie
, session
})
69 res
.setHeader(Enum
.Header
.SetCookie
, cookie
);
74 * GET request for establishing admin session.
75 * @param {http.ServerResponse} res
78 async
getAdminLogin(res
, ctx
) {
79 const _scope
= _fileScope('getAdminLogin');
80 this.logger
.debug(_scope
, 'called', { ctx
});
82 // If there is already a valid session established, and if a
83 // redirect is present, follow it. Why? Maybe someone had
84 // multiple tabs open, their session expired, and their browser
85 // restarted, reloading all of them, and they have relogged in
86 // on one and just want to hit reload on the others to go back
87 // to where they were.
88 const redirect
= ctx
.queryParams
['r'];
89 if (ctx
.authenticationId
&& redirect
) {
91 res
.setHeader(Enum
.Header
.Location
, redirect
);
94 res
.end(Template
.LoginHTML(ctx
, this.options
));
97 this.logger
.info(_scope
, 'finished', { ctx
})
102 * POST request for taking form data to establish admin session.
103 * @param {http.ServerResponse} res
104 * @param {Object} ctx
106 async
postAdminLogin(res
, ctx
) {
107 const _scope
= _fileScope('postAdminLogin');
108 this.logger
.debug(_scope
, 'called', { ctx
});
112 // Check if this was an OTP entry attempt.
113 if (await
this._otpSubmission(res
, ctx
)) {
114 // OTP path was taken, either successful entry and session creation, or re-prompting for otp.
118 if (await
this._localUserAuth(res
, ctx
)) {
119 // Local auth path was taken.
123 // Otherwise, carry on with IndieAuth handshake.
125 // Is profile a reasonable url?
126 let me
, meAutoScheme
, session
, authorizationEndpoint
;
128 me
= new URL(ctx
.parsedBody
['me']);
129 meAutoScheme
= !!ctx
.parsedBody
['me_auto_scheme'];
131 this.logger
.debug(_scope
, 'failed to parse supplied profile url', { ctx
});
132 ctx
.errors
.push(`Unable to understand '${ctx.parsedBody['me']}' as a profile URL.`);
135 if (ctx
.errors
.length
) {
136 res
.end(Template
.LoginHTML(ctx
, this.options
));
141 profile
= await
this.indieAuthCommunication
.fetchProfile(me
);
142 if ((!profile
?.metadata
)
144 this.logger
.debug(_scope
, 'trying http fallback', { ctx
});
145 me
.protocol
= 'http';
146 profile
= await
this.indieAuthCommunication
.fetchProfile(me
);
148 if (!profile
?.metadata
) {
149 this.logger
.debug(_scope
, 'failed to find any profile information at url', { ctx
});
150 ctx
.errors
.push(`No profile information was found at '${me}'.`);
152 // fetch and parse me for 'authorization_endpoint' relation links
154 authorizationEndpoint
= new URL(profile
.metadata
.authorizationEndpoint
);
156 ctx
.errors
.push(`Unable to understand the authorization endpoint ('${profile.metadata.authorizationEndpoint}') indicated by that profile ('${me}') as a URL.`);
159 if (profile
.metadata
.issuer
) {
162 const issuer
= new URL(profile
.metadata
.issuer
);
165 || issuer
.protocol
.toLowerCase() !== 'https:') { // stupid URL trailing colon thing
166 this.logger
.debug(_scope
, 'supplied issuer url invalid', { ctx
});
167 ctx
.errors
.push('Authorization server provided an invalid issuer field.');
170 this.logger
.debug(_scope
, 'failed to parse supplied issuer url', { ctx
});
171 ctx
.errors
.push('Authorization server provided an unparsable issuer field.');
174 this.logger
.debug(_scope
, 'no issuer in metadata, assuming legacy mode', { ctx
});
175 // Strict 20220212 compliance would error here.
176 // ctx.errors.push('Authorization server did not provide issuer field, as required by current specification.');
180 if (authorizationEndpoint
) {
181 const pkce
= await IndieAuthCommunication
.generatePKCE();
183 const state
= randomUUID();
184 const redirect
= ctx
.queryParams
['r'] || './';
186 authorizationEndpoint: authorizationEndpoint
.href
,
188 codeVerifier: pkce
.codeVerifier
,
191 issuer: profile
.metadata
.issuer
,
194 // Update auth endpoint parameters
196 'response_type': 'code',
197 'client_id': this.options
.dingus
.selfBaseUrl
,
198 'redirect_uri': `${this.options.dingus.selfBaseUrl}admin/_ia`,
199 'state': session
.state
,
200 'code_challenge': pkce
.codeChallenge
,
201 'code_challenge_method': pkce
.codeChallengeMethod
,
203 }).forEach(([name
, value
]) => authorizationEndpoint
.searchParams
.set(name
, value
));
206 if (ctx
.errors
.length
) {
207 res
.end(Template
.LoginHTML(ctx
, this.options
));
211 await
this._sessionCookieSet(res
, session
, this.cookieLifespan
);
212 res
.setHeader(Enum
.Header
.Location
, authorizationEndpoint
.href
);
213 res
.statusCode
= 302; // Found
216 this.logger
.info(_scope
, 'finished indieauth', { ctx
})
221 * Check if processing an OTP entry attempt. If not, resume login flow.
222 * If so, validate otp and establish session, else reprompt for OTP, or
223 * return to login entry after too many failures.
226 * @returns {Boolean} true if otp was handled, otherwise false indicates further login processing needed
228 async
_otpSubmission(res
, ctx
) {
229 const _scope
= _fileScope('_otpSubmission');
231 // Are we processing an OTP entry attempt?
232 const { otp
, state: stateBox
} = ctx
.parsedBody
;
236 state
= await
this.mysteryBox
.unpack(stateBox
);
239 this.logger
.debug(_scope
, 'failed to unpack otp state', { error: e
, ctx
});
240 // Ignore and continue back to main login
244 const OTPResult
= await
this.authenticator
.checkOTP(state
, otp
);
246 case Enum
.OTPResult
.Valid:
247 // successful otp entry
248 // Valid auth, persist the authenticated session
250 authenticatedIdentifier: state
.authenticationId
,
252 await
this._sessionCookieSet(res
, ctx
.session
, this.cookieLifespan
);
253 res
.statusCode
= 302;
254 res
.setHeader(Enum
.Header
.Location
, state
.redirect
);
256 this.logger
.info(_scope
, 'finished otp', { ctx
});
259 case Enum
.OTPResult
.InvalidSoftFail:
261 ctx
.otpNeeded
= true;
262 ctx
.errors
.push('Invalid OTP token.');
263 ctx
.otpState
= await
this.mysteryBox
.pack({
265 attempt: state
.attempt
+ 1,
267 res
.end(Template
.OTPHTML(ctx
, this.options
));
268 this.logger
.info(_scope
, 'finished otp, invalid, request again', { ctx
});
271 case Enum
.OTPResult
.InvalidHardFail:
272 // return to initial login
273 this.logger
.debug(_scope
, 'too many otp failures', { ctx
, state
});
274 ctx
.errors
.push('Invalid OTP token, and too many failures. Try again.');
278 throw new RangeError('Unexpected OTPResult');
287 * @param {http.ServerResponse} res
288 * @param {Object} ctx
290 async
_localUserAuth(res
, ctx
) {
291 const _scope
= _fileScope('_localUserAuth');
293 // If Indiauth enabled and profile was submitted, defer to that.
294 if (this.options
.authenticator
.authnEnabled
.includes('indieAuth')
295 && ctx
.parsedBody
['me']) {
299 const redirect
= ctx
.queryParams
['r'] || './';
300 const identifier
= ctx
.parsedBody
['identifier'];
301 const credential
= ctx
.parsedBody
['credential']; // N.B. Logger must specifically mask this field from ctx.
303 // N.B. validity check also sets autenticationId and otpNeeded on ctx
304 const isValidLocalIdentifier
= await
this.authenticator
.isValidIdentifierCredential(identifier
, credential
, ctx
);
305 if (!isValidLocalIdentifier
) {
306 ctx
.errors
.push('Invalid username or password');
309 if (ctx
.errors
.length
) {
310 res
.end(Template
.LoginHTML(ctx
, this.options
));
314 // If OTP exists for valid identifier, follow that flow.
316 ctx
.otpState
= await
this.mysteryBox
.pack({
317 authenticatedIdentifier: ctx
.authenticationId
,
322 res
.end(Template
.OTPHTML(ctx
, this.options
));
323 this.logger
.info(_scope
, 'finished local, otp required', { ctx
});
327 // Valid auth, persist the authenticated session
329 authenticatedIdentifier: ctx
.authenticationId
,
331 await
this._sessionCookieSet(res
, ctx
.session
, this.cookieLifespan
);
332 res
.statusCode
= 302;
333 res
.setHeader(Enum
.Header
.Location
, redirect
);
335 this.logger
.info(_scope
, 'finished local, no otp', { ctx
});
341 * GET request to remove current credentials.
342 * @param {http.ServerResponse} res
343 * @param {Object} ctx
345 async
getAdminLogout(res
, ctx
) {
346 const _scope
= _fileScope('getAdminLogout');
347 this.logger
.debug(_scope
, 'called', { ctx
});
349 await
this._sessionCookieSet(res
, '', 0);
351 const redirect
= ctx
.queryParams
['r'] || './';
353 res
.statusCode
= 302;
354 res
.setHeader(Enum
.Header
.Location
, redirect
);
357 this.logger
.info(_scope
, 'finished', { ctx
});
362 * GET request for returning IndieAuth redirect.
363 * This currently only redeems a scope-less profile.
364 * @param {http.ServerResponse} res
365 * @param {Object} ctx
367 async
getAdminIA(res
, ctx
) {
368 const _scope
= _fileScope('getAdminIA');
369 this.logger
.debug(_scope
, 'called', { ctx
});
374 // Unpack cookie to restore session data
376 const [cookieName
, cookieValue
] = common
.splitFirst((ctx
.cookie
|| ''), '=', '');
377 if (cookieName
!== Enum
.SessionCookie
) {
378 this.logger
.debug(_scope
, 'no cookie', { ctx
});
379 ctx
.errors
.push('missing required cookie');
382 ctx
.session
= await
this.mysteryBox
.unpack(cookieValue
);
383 this.logger
.debug(_scope
, 'restored session from cookie', { ctx
});
385 this.logger
.debug(_scope
, 'could not unpack cookie');
386 ctx
.errors
.push('invalid cookie');
390 // Validate unpacked session values
393 // Add any auth errors
394 if (ctx
.queryParams
['error']) {
395 ctx
.errors
.push(ctx
.queryParams
['error']);
396 if (ctx
.queryParams
['error_description']) {
397 ctx
.errors
.push(ctx
.queryParams
['error_description']);
402 if (ctx
.queryParams
['state'] !== ctx
.session
.state
) {
403 this.logger
.debug(_scope
, 'state mismatch', { ctx
});
404 ctx
.errors
.push('invalid state');
407 const code
= ctx
.queryParams
['code'];
409 this.logger
.debug(_scope
, 'missing code', { ctx
});
410 ctx
.errors
.push('invalid code');
414 if (ctx
.session
.issuer
) {
415 if (ctx
.queryParams
['iss'] !== ctx
.session
.issuer
) {
416 this.logger
.debug(_scope
, 'issuer mismatch', { ctx
});
417 ctx
.errors
.push('invalid issuer');
420 this.logger
.debug(_scope
, 'no issuer in metadata, assuming legacy mode', { ctx
});
421 // Strict 20220212 compliance would error here. (Also earlier.)
422 // ctx.errors.push('invalid issuer');
425 let redeemProfileUrl
;
427 redeemProfileUrl
= new URL(ctx
.session
.authorizationEndpoint
);
429 this.logger
.debug(_scope
, 'failed to parse restored session authorization endpoint as url', { ctx
});
430 ctx
.errors
.push('invalid cookie');
433 if (redeemProfileUrl
) {
434 profile
= await
this.indieAuthCommunication
.redeemProfileCode(redeemProfileUrl
, code
, ctx
.session
.codeVerifier
, this.options
.dingus
.selfBaseUrl
, `${this.options.dingus.selfBaseUrl}admin/_ia`);
436 this.logger
.debug(_scope
, 'no profile from code redemption', { ctx
});
437 ctx
.errors
.push('did not get a profile response from authorization endpoint code redemption');
438 } else if (!profile
.me
) {
439 this.logger
.debug(_scope
, 'no profile me identifier from code redemption', { ctx
});
440 ctx
.errors
.push('did not get \'me\' value from authorization endpoint code redemption');
441 } else if (profile
.me
!== ctx
.session
.me
) {
442 this.logger
.debug(_scope
, 'mis-matched canonical me from redeemed profile', { ctx
, profile
});
443 const newProfileUrl
= new URL(profile
.me
);
444 // Rediscover auth endpoint for the new returned profile.
445 const newProfile
= await
this.indieAuthCommunication
.fetchProfile(newProfileUrl
);
446 if (newProfile
.metadata
.authorizationEndpoint
!== ctx
.session
.authorizationEndpoint
) {
447 this.logger
.debug(_scope
, 'mis-matched auth endpoints between provided me and canonical me', { ctx
, profile
, newProfile
});
448 ctx
.errors
.push('canonical profile url provided by authorization endpoint is not handled by that endpoint, cannot continue');
450 // The endpoints match, all is okay, update our records.
451 ctx
.session
.me
= profile
.me
;
456 if (ctx
.errors
.length
) {
457 await
this._sessionCookieSet(res
, '', 0);
458 res
.end(Template
.IAHTML(ctx
, this.options
));
462 const redirect
= ctx
.session
.redirect
|| './';
464 // Set cookie as auth valid, redirect to original location.
466 authenticatedProfile: ctx
.session
.me
,
469 await
this._sessionCookieSet(res
, ctx
.session
, this.cookieLifespan
);
470 res
.statusCode
= 302;
471 res
.setHeader(Enum
.Header
.Location
, redirect
);
474 this.logger
.info(_scope
, 'finished', { ctx
})
480 module
.exports
= SessionManager
;