9428203a9619243355f35fb0950f826b7b7db118
[squeep-authentication-module] / lib / session-manager.js
1 'use strict';
2
3 /**
4 * Here we process activities which support login sessions.
5 */
6
7 const { Communication: IndieAuthCommunication } = require('@squeep/indieauth-helper');
8 const { MysteryBox } = require('@squeep/mystery-box');
9 const common = require('./common');
10 const Enum = require('./enum');
11 const Template = require('./template');
12
13 const _fileScope = common.fileScope(__filename);
14
15 class SessionManager {
16 /**
17 * @param {Console} logger
18 * @param {Authenticator} authenticator
19 * @param {Object} options
20 * @param {Object} options.authenticator
21 * @param {String[]} options.authenticator.authnEnabled
22 * @param {Number=} options.authenticator.inactiveSessionLifespanSeconds
23 * @param {Boolean} options.authenticator.secureAuthOnly
24 * @param {Object} options.dingus
25 * @param {Object} options.dingus.proxyPrefix
26 * @param {Object} options.dingus.selfBaseUrl
27 */
28 constructor(logger, authenticator, options) {
29 this.logger = logger;
30 this.authenticator = authenticator;
31 this.options = options;
32 this.indieAuthCommunication = new IndieAuthCommunication(logger, options);
33 this.mysteryBox = new MysteryBox(options);
34 this.mysteryBox.on('statistics', common.mysteryBoxLogger(logger, _fileScope(this.constructor.name)));
35
36 this.cookieLifespan = options.authenticator.inactiveSessionLifespanSeconds || 60 * 60 * 24 * 32;
37 }
38
39
40 /**
41 * Set or update our session cookie.
42 * @param {http.ServerResponse} res
43 * @param {Object} session
44 * @param {Number} maxAge
45 * @param {String} path
46 */
47 async _sessionCookieSet(res, session, maxAge, path = '/') {
48 const _scope = _fileScope('_sessionCookieSet');
49
50 const cookieName = Enum.SessionCookie;
51 const secureSession = session && await this.mysteryBox.pack(session) || '';
52 const cookieParts = [
53 `${cookieName}=${secureSession}`,
54 'HttpOnly',
55 'SameSite=Lax',
56 ];
57 if (this.options.authenticator.secureAuthOnly) {
58 cookieParts.push('Secure');
59 }
60 if (typeof(maxAge) === 'number') {
61 cookieParts.push(`Max-Age=${maxAge}`);
62 }
63 if (path) {
64 cookieParts.push(`Path=${this.options.dingus.proxyPrefix}${path}`);
65 }
66 const cookie = cookieParts.join('; ');
67 this.logger.debug(_scope, 'session cookie', { cookie, session })
68 res.setHeader(Enum.Header.SetCookie, cookie);
69 }
70
71
72 /**
73 * GET request for establishing admin session.
74 * @param {http.ServerResponse} res
75 * @param {Object} ctx
76 */
77 async getAdminLogin(res, ctx) {
78 const _scope = _fileScope('getAdminLogin');
79 this.logger.debug(_scope, 'called', { ctx });
80
81 res.end(Template.LoginHTML(ctx, this.options));
82 this.logger.info(_scope, 'finished', { ctx })
83 }
84
85
86 /**
87 * POST request for taking form data to establish admin session.
88 * @param {http.ServerResponse} res
89 * @param {Object} ctx
90 */
91 async postAdminLogin(res, ctx) {
92 const _scope = _fileScope('postAdminLogin');
93 this.logger.debug(_scope, 'called', { ctx });
94
95 ctx.errors = [];
96
97 const redirect = ctx.queryParams['r'] || './';
98
99 // Only attempt user login if no IndieAuth profile is set
100 if (!this.options.authenticator.authnEnabled.includes('indieAuth') || !ctx.parsedBody['me']) {
101
102 const identifier = ctx.parsedBody['identifier'];
103 const credential = ctx.parsedBody['credential']; // N.B. Logger must specifically mask this field from ctx.
104
105 const isValidLocalIdentifier = await this.authenticator.isValidIdentifierCredential(identifier, credential, ctx);
106 if (!isValidLocalIdentifier) {
107 ctx.errors.push('Invalid username or password');
108 }
109
110 if (ctx.errors.length) {
111 res.end(Template.LoginHTML(ctx, this.options));
112 return;
113 }
114
115 // Valid auth, persist the authenticated session
116 ctx.session = {
117 authenticatedIdentifier: ctx.authenticationId,
118 };
119 await this._sessionCookieSet(res, ctx.session, this.cookieLifespan);
120 res.statusCode = 302;
121 res.setHeader(Enum.Header.Location, redirect);
122 res.end();
123 this.logger.info(_scope, 'finished local', { ctx });
124 return;
125 }
126
127 // Otherwise, carry on with IndieAuth handshake.
128 let me, meAutoScheme, session, authorizationEndpoint;
129 try {
130 me = new URL(ctx.parsedBody['me']);
131 meAutoScheme = !!ctx.parsedBody['me_auto_scheme'];
132
133 } catch (e) {
134 this.logger.debug(_scope, 'failed to parse supplied profile url', { ctx });
135 ctx.errors.push(`Unable to understand '${ctx.parsedBody['me']}' as a profile URL.`);
136 }
137
138 if (this.options.authenticator.authnEnabled.includes('indieAuth')
139 && me) {
140 let profile;
141 profile = await this.indieAuthCommunication.fetchProfile(me);
142 if ((!profile || !profile.metadata)
143 && meAutoScheme) {
144 this.logger.debug(_scope, 'trying http fallback', { ctx });
145 me.protocol = 'http';
146 profile = await this.indieAuthCommunication.fetchProfile(me);
147 }
148 if (!profile || !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}'.`);
151 } else {
152 // fetch and parse me for 'authorization_endpoint' relation links
153 try {
154 authorizationEndpoint = new URL(profile.metadata.authorizationEndpoint);
155 } catch (e) {
156 ctx.errors.push(`Unable to understand the authorization endpoint ('${profile.metadata.authorizationEndpoint}') indicated by that profile ('${me}') as a URL.`);
157 }
158
159 if (profile.metadata.issuer) {
160 // Validate issuer
161 try {
162 const issuer = new URL(profile.metadata.issuer);
163 if (issuer.hash
164 || issuer.search
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.');
168 }
169 } catch (e) {
170 this.logger.debug(_scope, 'failed to parse supplied issuer url', { ctx });
171 ctx.errors.push('Authorization server provided an unparsable issuer field.');
172 }
173 } else {
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.');
177 }
178 }
179
180 if (authorizationEndpoint) {
181 const pkce = await IndieAuthCommunication.generatePKCE();
182
183 session = {
184 authorizationEndpoint: authorizationEndpoint.href,
185 state: ctx.requestId,
186 codeVerifier: pkce.codeVerifier,
187 me,
188 redirect,
189 issuer: profile.metadata.issuer,
190 };
191
192 // Update auth endpoint parameters
193 Object.entries({
194 'response_type': 'code',
195 'client_id': this.options.dingus.selfBaseUrl,
196 'redirect_uri': `${this.options.dingus.selfBaseUrl}admin/_ia`,
197 'state': session.state,
198 'code_challenge': pkce.codeChallenge,
199 'code_challenge_method': pkce.codeChallengeMethod,
200 'me': me,
201 }).forEach(([name, value]) => authorizationEndpoint.searchParams.set(name, value));
202 }
203 }
204
205 if (ctx.errors.length) {
206 res.end(Template.LoginHTML(ctx, this.options));
207 return;
208 }
209
210 await this._sessionCookieSet(res, session, this.cookieLifespan);
211 res.setHeader(Enum.Header.Location, authorizationEndpoint.href);
212 res.statusCode = 302; // Found
213 res.end();
214
215 this.logger.info(_scope, 'finished indieauth', { ctx })
216 }
217
218
219 /**
220 * GET request to remove current credentials.
221 * @param {http.ServerResponse} res
222 * @param {Object} ctx
223 */
224 async getAdminLogout(res, ctx) {
225 const _scope = _fileScope('getAdminLogout');
226 this.logger.debug(_scope, 'called', { ctx });
227
228 this._sessionCookieSet(res, '', 0);
229
230 const redirect = ctx.queryParams['r'] || './';
231
232 res.statusCode = 302;
233 res.setHeader(Enum.Header.Location, redirect);
234 res.end();
235
236 this.logger.info(_scope, 'finished', { ctx });
237 }
238
239
240 /**
241 * GET request for returning IndieAuth redirect.
242 * This currently only redeems a scope-less profile.
243 * @param {http.ServerResponse} res
244 * @param {Object} ctx
245 */
246 async getAdminIA(res, ctx) {
247 const _scope = _fileScope('getAdminIA');
248 this.logger.debug(_scope, 'called', { ctx });
249
250 ctx.errors = [];
251 ctx.session = {};
252
253 // Unpack cookie to restore session data
254
255 const [ cookieName, cookieValue ] = common.splitFirst((ctx.cookie || ''), '=', '');
256 if (cookieName !== Enum.SessionCookie) {
257 this.logger.debug(_scope, 'no cookie', { ctx });
258 ctx.errors.push('missing required cookie');
259 } else {
260 try {
261 ctx.session = await this.mysteryBox.unpack(cookieValue);
262 this.logger.debug(_scope, 'restored session from cookie', { ctx });
263 } catch (e) {
264 this.logger.debug(_scope, 'could not unpack cookie');
265 ctx.errors.push('invalid cookie');
266 }
267 }
268
269 // Validate unpacked session values
270 // ...
271
272 // Add any auth errors
273 if (ctx.queryParams['error']) {
274 ctx.errors.push(ctx.queryParams['error']);
275 if (ctx.queryParams['error_description']) {
276 ctx.errors.push(ctx.queryParams['error_description']);
277 }
278 }
279
280 // check stuff
281 if (ctx.queryParams['state'] !== ctx.session.state) {
282 this.logger.debug(_scope, 'state mismatch', { ctx });
283 ctx.errors.push('invalid state');
284 }
285
286 const code = ctx.queryParams['code'];
287 if (!code) {
288 this.logger.debug(_scope, 'missing code', { ctx });
289 ctx.errors.push('invalid code');
290 }
291
292 // check issuer
293 if (ctx.session.issuer) {
294 if (ctx.queryParams['iss'] !== ctx.session.issuer) {
295 this.logger.debug(_scope, 'issuer mismatch', { ctx });
296 ctx.errors.push('invalid issuer');
297 }
298 } else {
299 this.logger.debug(_scope, 'no issuer in metadata, assuming legacy mode', { ctx });
300 // Strict 20220212 compliance would error here. (Also earlier.)
301 // ctx.errors.push('invalid issuer');
302 }
303
304 let redeemProfileUrl;
305 try {
306 redeemProfileUrl = new URL(ctx.session.authorizationEndpoint);
307 } catch (e) {
308 this.logger.debug(_scope, 'failed to parse restored session authorization endpoint as url', { ctx });
309 ctx.errors.push('invalid cookie');
310 }
311 let profile;
312 if (redeemProfileUrl) {
313 profile = await this.indieAuthCommunication.redeemProfileCode(redeemProfileUrl, code, ctx.session.codeVerifier, this.options.dingus.selfBaseUrl, `${this.options.dingus.selfBaseUrl}admin/_ia`);
314 if (!profile) {
315 this.logger.debug(_scope, 'no profile from code redemption', { ctx });
316 ctx.errors.push('did not get a profile response from authorization endpoint code redemption');
317 } else if (!profile.me) {
318 this.logger.debug(_scope, 'no profile me identifier from code redemption', { ctx });
319 ctx.errors.push('did not get \'me\' value from authorization endpoint code redemption');
320 } else if (profile.me !== ctx.session.me) {
321 this.logger.debug(_scope, 'mis-matched canonical me from redeemed profile', { ctx, profile });
322 const newProfileUrl = new URL(profile.me);
323 // Rediscover auth endpoint for the new returned profile.
324 const newProfile = await this.indieAuthCommunication.fetchProfile(newProfileUrl);
325 if (newProfile.metadata.authorizationEndpoint !== ctx.session.authorizationEndpoint) {
326 this.logger.debug(_scope, 'mis-matched auth endpoints between provided me and canonical me', { ctx, profile, newProfile });
327 ctx.errors.push('canonical profile url provided by authorization endpoint is not handled by that endpoint, cannot continue');
328 } else {
329 // The endpoints match, all is okay, update our records.
330 ctx.session.me = profile.me;
331 }
332 }
333 }
334
335 if (ctx.errors.length) {
336 await this._sessionCookieSet(res, '', 0);
337 res.end(Template.IAHTML(ctx, this.options));
338 return;
339 }
340
341 const redirect = ctx.session.redirect || './';
342
343 // Set cookie as auth valid, redirect to original location.
344 ctx.session = {
345 authenticatedProfile: ctx.session.me,
346 };
347
348 await this._sessionCookieSet(res, ctx.session, this.cookieLifespan);
349 res.statusCode = 302;
350 res.setHeader(Enum.Header.Location, redirect);
351 res.end();
352
353 this.logger.info(_scope, 'finished', { ctx })
354 }
355
356
357 }
358
359 module.exports = SessionManager;