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