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