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