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