updates to support IndieAuth spec 20220212 metadata and issuer
[squeep-authentication-module] / lib / authenticator.js
1 'use strict';
2
3 const common = require('./common');
4 const Enum = require('./enum');
5 const Errors = require('./errors');
6 const { MysteryBox } = require('@squeep/mystery-box');
7 const { name: packageName } = require('../package');
8
9 const _fileScope = common.fileScope(__filename);
10
11 class Authenticator {
12 /**
13 * @param {Console} logger
14 * @param {*} db
15 * @param {(dbCtx: any, identifier: String) => Promise<AuthInfo> } db.authenticationGet
16 * @param {(dbCtx: any, identifier: String) => Promise<void>} db.authenticationSuccess
17 * @param {((dbCtx: any) => Promise<any>) => Promise<void>} db.context
18 * @param {Object} options
19 * @param {Object} options.authenticator
20 * @param {Boolean} options.authenticator.secureAuthOnly
21 * @param {String[]} options.authenticator.forbiddenPAMIdentifiers
22 * @param {String[]} options.authenticator.authnEnabled
23 * @param {Number=} options.authenticator.inactiveSessionLifespanSeconds
24 * @param {String[]=} options.authenticator.loginBlurb
25 * @param {String[]=} options.authenticator.indieAuthBlurb
26 * @param {String[]=} options.authenticator.userBlurb
27 */
28 constructor(logger, db, options) {
29 this.logger = logger;
30 this.db = db;
31 this.options = options;
32 this.basicRealm = options.authenticator.basicRealm || packageName;
33 this.secureAuthOnly = options.authenticator.secureAuthOnly;
34
35 this.authn = {
36 DEBUG_ANY: {},
37 indieAuth: {},
38 };
39 try {
40 this.authn.argon2 = require('argon2');
41 } catch (e) { /**/ }
42 try {
43 this.authn.pam = require('node-linux-pam');
44 this.forbiddenPAMIdentifiers = options.authenticator.forbiddenPAMIdentifiers;
45 } catch (e) { /**/ }
46
47 this.authnEnabled = Object.keys(this.authn).filter((auth) => options.authenticator.authnEnabled.includes(auth));
48 this.logger.debug(_fileScope('constructor'), 'available mechanisms', { authn: this.authnEnabled });
49
50 if (this.authnEnabled.length === 0) {
51 throw new Error('no authentication mechanisms available');
52 }
53
54 this.mysteryBox = new MysteryBox(logger, options);
55
56 this.cookieLifespan = options.authenticator.inactiveSessionLifespanSeconds || 60 * 60 * 24 * 32;
57 }
58
59
60 /**
61 * Check local auth entries.
62 * Sets ctx.authenticatedId if valid.
63 * @param {String} identifier
64 * @param {String} credential
65 * @param {Object} ctx
66 */
67 async isValidIdentifierCredential(identifier, credential, ctx) {
68 const _scope = _fileScope('isValidIdentifierCredential');
69 this.logger.debug(_scope, 'called', { identifier, credential: '*'.repeat(credential.length), ctx });
70
71 let isValid = false;
72
73 await this.db.context(async (dbCtx) => {
74 const authData = await this.db.authenticationGet(dbCtx, identifier);
75 if (!authData) {
76 this.logger.debug(_scope, 'failed, invalid identifier', { ctx, identifier });
77 } else {
78 if (authData.credential.startsWith('$argon2')
79 && this.authnEnabled.includes('argon2')) {
80 isValid = await this.authn.argon2.verify(authData.credential, credential);
81 } else if (authData.credential.startsWith('$PAM$')
82 && this.authnEnabled.includes('pam')) {
83 isValid = this._isValidPAMIdentifier(identifier, credential);
84 } else {
85 this.logger.error(_scope, 'failed, unknown type of stored credential', { identifier, ctx });
86 }
87 }
88
89 if (this.authnEnabled.includes('DEBUG_ANY')) {
90 isValid = true;
91 }
92
93 if (isValid) {
94 ctx.authenticationId = identifier;
95 await this.db.authenticationSuccess(dbCtx, identifier);
96 }
97 }); // dbCtx
98
99 return isValid;
100 }
101
102
103 /**
104 * Check system PAM.
105 * @param {String} identifier
106 * @param {String} credential
107 * @returns {Boolean}
108 */
109 async _isValidPAMIdentifier(identifier, credential) {
110 const _scope = _fileScope('_isValidPAMIdentifier');
111 let isValid = false;
112 if (this.forbiddenPAMIdentifiers.includes(identifier)) {
113 return false;
114 }
115 try {
116 await this.authn.pam.pamAuthenticatePromise({ username: identifier, password: credential });
117 isValid = true;
118 } catch (e) {
119 this.logger.debug(_scope, 'failed', { error: e });
120 if (!(e instanceof this.authn.pam.PamError)) {
121 throw e;
122 }
123 }
124 return isValid;
125 }
126
127
128 /**
129 * Check for valid Basic auth, updates ctx with identifier if valid.
130 * @param {String} credentials
131 * @param {Object} ctx
132 * @returns {Boolean}
133 */
134 async isValidBasic(credentials, ctx) {
135 const _scope = _fileScope('isValidBasic');
136 this.logger.debug(_scope, 'called', { ctx });
137
138 const [identifier, credential] = common.splitFirst(credentials, ':', '');
139
140 return this.isValidIdentifierCredential(identifier, credential, ctx);
141 }
142
143
144 /**
145 * Determine which Authorization header is available, and if it is valid.
146 * @param {String} authorizationHeader
147 * @param {Object} ctx
148 */
149 async isValidAuthorization(authorizationHeader, ctx) {
150 const _scope = _fileScope('isValidAuthorization');
151 this.logger.debug(_scope, 'called', { authorizationHeader: common.obscureAuthorizationHeader(authorizationHeader), ctx });
152
153 const [authMethod, authString] = common.splitFirst(authorizationHeader, ' ', '').map((x) => x.trim());
154 // eslint-disable-next-line sonarjs/no-small-switch
155 switch (authMethod.toLowerCase()) {
156 case 'basic': {
157 const credentials = Buffer.from(authString, 'base64').toString('utf-8');
158 return this.isValidBasic(credentials, ctx);
159 }
160
161 default:
162 this.logger.debug(_scope, 'unknown authorization scheme', { ctx });
163 return false;
164 }
165 }
166
167
168 /**
169 * Send a response requesting basic auth.
170 * @param {http.ServerResponse} res
171 */
172 requestBasic(res) {
173 res.setHeader(Enum.Header.WWWAuthenticate, `Basic realm="${this.basicRealm}", charset="UTF-8"`);
174 throw new Errors.ResponseError(Enum.ErrorResponse.Unauthorized);
175 }
176
177
178 /**
179 *
180 * @param {String} cookieHeader
181 */
182 static _cookieParse(cookieHeader) {
183 const cookie = {};
184 (cookieHeader || '').split(/; */).forEach((field) => {
185 const [ name, value ] = common.splitFirst(field, '=', null).map((x) => x && decodeURIComponent(x.trim()));
186 if (name && !(name in cookie)) {
187 if (value && value.startsWith('"') && value.endsWith('"')) {
188 cookie[name] = value.slice(1, -1); // eslint-disable-line security/detect-object-injection
189 } else {
190 cookie[name] = value; // eslint-disable-line security/detect-object-injection
191 }
192 }
193 });
194 return cookie;
195 }
196
197
198 /**
199 * Attempt to parse a session cookie, and determine if it contains an
200 * authenticated user.
201 * Restores ctx.session from cookie data, sets ctx.authenticationId if identifier exists.
202 * @param {Object} ctx
203 * @param {String} cookieHeader
204 * @returns {Boolean}
205 */
206 async isValidCookieAuth(ctx, cookieHeader) {
207 const _scope = _fileScope('isValidCookieAuth');
208 this.logger.debug(_scope, 'called', { ctx, cookieHeader });
209
210 const cookie = Authenticator._cookieParse(cookieHeader);
211 const cookieValue = cookie[Enum.SessionCookie];
212
213 if (!cookieValue) {
214 return false;
215 }
216 try {
217 ctx.session = await this.mysteryBox.unpack(cookieValue);
218 this.logger.debug(_scope, 'unpacked cookie', { ctx });
219
220 const hasIdentifier = !!ctx.session.authenticatedIdentifier;
221 const hasProfile = !!ctx.session.authenticatedProfile && this.authnEnabled.includes('indieAuth');
222 const isValid = hasIdentifier || hasProfile;
223 if (isValid) {
224 ctx.authenticationId = ctx.session.authenticatedIdentifier || ctx.session.authenticatedProfile;
225 }
226
227 return isValid;
228 } catch (e) {
229 this.logger.debug(_scope, 'could not unpack cookie', { error:e, ctx });
230 return false;
231 }
232 }
233
234
235 /**
236 * Check for a valid session.
237 * @param {http.ClientRequest} req
238 * @param {http.ServerResponse} res
239 * @param {Object} ctx
240 * @param {String} loginPath
241 * @param {Boolean} required
242 * @param {Boolean} profilesAllowed
243 * @returns {Boolean}
244 */
245 async sessionCheck(req, res, ctx, loginPath, required = true, profilesAllowed = true) {
246 const _scope = _fileScope('check');
247 this.logger.debug(_scope, 'called', { ctx, loginPath, required, profilesAllowed });
248
249 if (this.secureAuthOnly
250 && ctx.clientProtocol.toLowerCase() !== 'https') {
251 this.logger.debug(_scope, 'insecure transport, no authentication is valid', { ctx });
252 if (required) {
253 throw new Errors.ResponseError(Enum.ErrorResponse.Forbidden, 'authentication is required, but connection is insecure; cannot continue');
254 } else {
255 return false;
256 }
257 }
258
259 const sessionCookie = req.getHeader(Enum.Header.Cookie);
260 if (sessionCookie
261 && await this.isValidCookieAuth(ctx, sessionCookie)
262 && (ctx.session.authenticatedIdentifier
263 || (profilesAllowed && ctx.session.authenticatedProfile))) {
264 this.logger.debug(_scope, 'valid session cookie', { ctx });
265 // Refresh timeout on valid session.
266 const cookieParts = [
267 sessionCookie,
268 'HttpOnly',
269 `Path=${this.options.dingus.proxyPrefix}/`,
270 `Max-Age=${this.cookieLifespan}`,
271 ];
272 if (this.options.authenticator.secureAuthOnly) {
273 cookieParts.push('Secure');
274 }
275 res.setHeader(Enum.Header.SetCookie, cookieParts.join('; '));
276 return true;
277 }
278
279 if (required) {
280 // Clear any existing invalid session
281 const cookieParts = [
282 `${Enum.SessionCookie}=""`,
283 'HttpOnly',
284 'Max-Age=0',
285 `Path=${this.options.dingus.proxyPrefix}/`,
286 ];
287 if (this.options.authenticator.secureAuthOnly) {
288 cookieParts.push('Secure');
289 }
290 res.setHeader(Enum.Header.SetCookie, cookieParts.join('; '));
291
292 res.statusCode = 302;
293 res.setHeader(Enum.Header.Location, `${loginPath}?r=${encodeURIComponent(req.url)}`);
294 res.end();
295 }
296
297 return false;
298 }
299
300
301 /**
302 * Requires a valid session with a local identifier. Redirects to loginPath if not.
303 * @param {http.ClientRequest} req
304 * @param {http.ServerResponse} res
305 * @param {Object} ctx
306 * @param {String} loginPath
307 * @returns {Boolean}
308 */
309 async sessionRequiredLocal(req, res, ctx, loginPath) {
310 return this.sessionCheck(req, res, ctx, loginPath, true, false);
311 }
312
313
314 /**
315 * Requires a valid session with either a local identifier or a profile. Redirects to loginPath if not.
316 * @param {http.ClientRequest} req
317 * @param {http.ServerResponse} res
318 * @param {Object} ctx
319 * @param {String} loginPath
320 * @returns {Boolean}
321 */
322 async sessionRequired(req, res, ctx, loginPath) {
323 return this.sessionCheck(req, res, ctx, loginPath);
324 }
325
326
327 /**
328 * Check for a valid session with a local identifier, but do nothing if not.
329 * @param {http.ClientRequest} req
330 * @param {http.ServerResponse} res
331 * @param {Object} ctx
332 * @param {String} loginPath
333 * @returns {Boolean}
334 */
335 async sessionOptionalLocal(req, res, ctx) {
336 return this.sessionCheck(req, res, ctx, undefined, false, false);
337 }
338
339
340 /**
341 * Check for a valid session with either a local identifier or a profile, but do nothing if not.
342 * @param {http.ClientRequest} req
343 * @param {http.ServerResponse} res
344 * @param {Object} ctx
345 * @param {String} loginPath
346 * @returns {Boolean}
347 */
348 async sessionOptional(req, res, ctx) {
349 return this.sessionCheck(req, res, ctx, undefined, false);
350 }
351
352
353 /**
354 * Require auth for an API endpoint.
355 * Check for valid local identifier in Authorization header; optionally
356 * fall back to session cookie if no header provided.
357 * Prompts for Basic auth if not valid.
358 * @param {http.ClientRequest} req
359 * @param {http.ServerResponse} res
360 * @param {Object} ctx
361 * @param {Boolean} sessionAlsoValid
362 */
363 async apiRequiredLocal(req, res, ctx, sessionAlsoValid = true) {
364 const _scope = _fileScope('apiRequiredLocal');
365 this.logger.debug(_scope, 'called', { ctx, sessionAlsoValid });
366
367 // If a Authorization header was provided, never consider session as a fallback.
368 const authorizationHeader = req.getHeader(Enum.Header.Authorization);
369 if (authorizationHeader) {
370 if (await this.isValidAuthorization(authorizationHeader, ctx)) {
371 this.logger.debug(_scope, 'valid authorization', { ctx, sessionAlsoValid });
372 return true;
373 }
374 } else {
375 if (sessionAlsoValid
376 && await this.sessionCheck(req, res, ctx, undefined, false, false)) {
377 this.logger.debug(_scope, 'valid session', { ctx, sessionAlsoValid });
378 return true;
379 }
380 }
381
382 this.logger.debug(_scope, 'invalid authorization', { ctx, sessionAlsoValid });
383 this.requestBasic(res);
384 }
385
386 }
387
388 module.exports = Authenticator;