3 const common
= require('./common');
4 const Enum
= require('./enum');
5 const Errors
= require('./errors');
6 const { MysteryBox
} = require('@squeep/mystery-box');
7 const { TOTP
} = require('@squeep/totp');
8 const { name: packageName
} = require('../package');
10 const _fileScope
= common
.fileScope(__filename
);
13 * Wrangles the fiddly bits of authentication.
14 * Handles checking of credentials and otp codes, creating and validating
15 * cookie-based sessions, Basic auth.
16 * Interacts with the authentication database interface.
20 * @typedef {import('node:http')} http
25 * @typedef {object} AuthInfo
26 * @property {string} identifier identifier
27 * @property {string} credential hashed credential
28 * @property {string=} otpKey optional otp key
31 * @callback DBContextExec
32 * @param {object} dbCtx
33 * @returns {Promise<any>}
36 * @typedef {object} AuthDBInterface
37 * @property {(DBContextExec) => Promise<any>} context db context runner
38 * @property {(dbCtx: any, identifier: string) => Promise<AuthInfo>} authenticationGet get an authentication entry
39 * @property {(dbCtx: any, identifier: string) => Promise<void>} authenticationSuccess store a successful authentication event
40 * @property {(dbCtx: any, identifier: string, credential: string, otpKey: string=) => Promise<void>} authenticationUpsert update an authentication entry
41 * @property {(dbCtx: any, identifier: string, otpKey: string) => Promise<void>} authenticationUpdateOTPKey update otp key
42 * @property {(dbCtx: any, identifier: string, credential: AuthInfo) => Promise<void>} authenticationUpdateCredential update credential
45 * @typedef {object} ConsoleLike
46 * @property {Function} debug log debug
47 * @property {Function} error log error
48 * @property {Function} info log info
51 * @param {ConsoleLike} logger logger instance
52 * @param {AuthDBInterface} db db instance
53 * @param {object} options options
54 * @param {string | string[]} options.encryptionSecret encryption secret
55 * @param {object} options.authenticator authenticator options
56 * @param {boolean=} options.authenticator.secureAuthOnly disable auth over non-https
57 * @param {string=} options.authenticator.sessionCookieSameSite sameSite setting for session cookie, default Lax
58 * @param {string[]=} options.authenticator.forbiddenPAMIdentifiers reject these identifiers for PAM auth
59 * @param {string[]=} options.authenticator.authnEnabled in order of preference for storing new credentials
60 * @param {number=} options.authenticator.inactiveSessionLifespanSeconds session timeout
61 * @param {string[]=} options.authenticator.loginBlurb text for login page
62 * @param {string[]=} options.authenticator.indieAuthBlurb text for indieauth login section
63 * @param {string[]=} options.authenticator.userBlurb text for local user login section
64 * @param {string[]=} options.authenticator.otpBlurb text for otp entry
65 * @param {object=} options.dingus dingus options
66 * @param {string=} options.dingus.proxyPrefix base url prefix
68 constructor(logger
, db
, options
) {
71 this.options
= options
;
72 this.basicRealm
= options
.authenticator
.basicRealm
|| packageName
;
73 this.secureAuthOnly
= options
.authenticator
.secureAuthOnly
?? true;
74 this.sameSite
= options
.authenticator
.sessionCookieSameSite
|| 'Lax';
75 this.proxyPrefix
= options
.dingus
?.proxyPrefix
?? '';
77 if (!['None', 'Lax', 'Strict'].includes(this.sameSite
)) {
78 throw new RangeError(`invalid sameSite value "${this.sameSite}"`);
81 // First construct map of all available code-supported auth mechanisms.
87 this.authn
.argon2
= require('argon2');
88 } catch (e
) { /**/ } // eslint-disable-line no-unused-vars
90 this.authn
.pam
= require('node-linux-pam');
91 this.forbiddenPAMIdentifiers
= options
.authenticator
.forbiddenPAMIdentifiers
?? ['root'];
92 } catch (e
) { /**/ } // eslint-disable-line no-unused-vars
94 // Track which authn methods we can change credentials et cetera.
95 const authnUpdatable
= ['plain', 'argon2'];
97 // Filter configured mechanisms from available, note the first as which to use for setting new credentials.
98 this.authnEnabled
= new Set();
99 this.authnPreferred
= undefined; // For updating credentials
100 options
.authenticator
.authnEnabled
.forEach((authn
) => {
101 if (authn
in this.authn
) {
102 this.authnEnabled
.add(authn
);
103 if (!this.authnPreferred
&& authnUpdatable
.includes(authn
)) {
104 this.authnPreferred
= authn
;
108 this.logger
.debug(_fileScope('constructor'), 'available mechanisms', { authn: Array
.from(this.authnEnabled
), preferred: this.authnPreferred
});
110 if (this.authnEnabled
.size
=== 0) {
111 throw new Error('no authentication mechanisms available');
114 this.mysteryBox
= new MysteryBox(options
);
115 this.mysteryBox
.on('statistics', common
.mysteryBoxLogger(logger
, _fileScope(this.constructor.name
)));
117 this.cookieLifespan
= options
.authenticator
.inactiveSessionLifespanSeconds
|| 60 * 60 * 24 * 32;
119 this.TOTP
= TOTP
; // For ease of test stubbing.
124 * Populate the authentication database with a new identifier, the
125 * secured credential, and optionally an OTP key.
126 * @param {*} dbCtx db context
127 * @param {string} identifier identifier
128 * @param {string} credential plaintext
129 * @param {string=} otpKey otp key
130 * @returns {Promise<void>}
132 async
createIdentifier(dbCtx
, identifier
, credential
, otpKey
= null) {
133 const _scope
= _fileScope('createIdentifier');
135 const secureCredential
= await
this._secureCredential(credential
);
136 await
this.db
.authenticationUpsert(dbCtx
, identifier
, secureCredential
, otpKey
);
138 this.logger
.error(_scope
, 'failed', { error: e
, identifier
});
145 * Update the authentication database with a new secured credential
146 * for an indentifier.
147 * @param {*} dbCtx dbCtx
148 * @param {string} identifier identifier
149 * @param {string} credential plaintext
150 * @returns {Promise<void>}
152 async
updateCredential(dbCtx
, identifier
, credential
) {
153 const _scope
= _fileScope('updateCredential');
155 const secureCredential
= await
this._secureCredential(credential
);
156 await
this.db
.authenticationUpdateCredential(dbCtx
, identifier
, secureCredential
);
157 this.logger
.info(_scope
, 'success', { identifier
});
159 this.logger
.error(_scope
, 'failed', { error: e
, identifier
});
166 * Encode a plaintext credential in the preferred way to store in database.
167 * @param {string} credential plaintext
168 * @param {string=} authn authentication mechanism
169 * @returns {Promise<string>} encoded credential
171 async
_secureCredential(credential
, authn
= this.authnPreferred
) {
172 const _scope
= _fileScope('_secureCredential');
176 return '$plain$' + credential
;
179 return await
this.authn
.argon2
.hash(credential
, { type: this.authn
.argon2
.argon2id
});
182 throw new RangeError('Unknown authn');
185 this.logger
.error(_scope
, 'failed', { error: e
, authn
});
192 * Checks a supplied credential against supplied data.
193 * @param {AuthInfo} authData from database
194 * @param {string} credential plaintext
195 * @returns {Promise<boolean>} is valid
197 async
_validateAuthDataCredential(authData
, credential
) {
198 const _scope
= _fileScope('_validateAuthDataCredential');
200 if (!authData
?.credential
) {
201 this.logger
.debug(_scope
, 'failed, no authInfo');
204 if (authData
.credential
.startsWith('$argon2')
205 && this.authnEnabled
.has('argon2')) {
206 return await
this._isValidArgon2Identifier(authData
, credential
);
208 if (authData
.credential
.startsWith('$PAM$')
209 && this.authnEnabled
.has('pam')) {
210 return await
this._isValidPAMIdentifier(authData
, credential
);
212 if (authData
.credential
.startsWith('$plain$')
213 && this.authnEnabled
.has('plain')) {
214 return this.constructor._isValidPlainIdentifier(authData
, credential
);
216 this.logger
.error(_scope
, 'failed, unknown or unsupported type of stored credential', { authData
});
223 * @param {AuthInfo} authData auth entry
224 * @param {string} credential to check
225 * @returns {Promise<boolean>} is valid
227 async
_isValidArgon2Identifier(authData
, credential
) {
228 return await
this.authn
.argon2
.verify(authData
.credential
, credential
);
234 * @param {AuthInfo} authData auth entry
235 * @param {string} credential to check
236 * @returns {Promise<boolean>} is valid
238 static _isValidPlainIdentifier(authData
, credential
) {
239 return authData
.credential
.substring('$plain$'.length
) === credential
;
245 * @param {AuthInfo} authData auth entry
246 * @param {string} credential to check
247 * @returns {Promise<boolean>} is valid
249 async
_isValidPAMIdentifier(authData
, credential
) {
250 const _scope
= _fileScope('_isValidPAMIdentifier');
251 const { identifier
} = authData
;
253 if (this.forbiddenPAMIdentifiers
.includes(identifier
)) {
257 await
this.authn
.pam
.pamAuthenticatePromise({ username: identifier
, password: credential
});
260 this.logger
.debug(_scope
, 'failed', { error: e
});
261 if (!(e
instanceof this.authn
.pam
.PamError
)) {
270 * Check local auth entries.
271 * Sets ctx.authenticatedId if valid.
272 * Sets ctx.otpKey if account has otpKey.
273 * @param {string} identifier identifier
274 * @param {string} credential to check
275 * @param {object} ctx context
276 * @returns {Promise<boolean>} is valid
278 async
isValidIdentifierCredential(identifier
, credential
, ctx
) {
279 const _scope
= _fileScope('isValidIdentifierCredential');
280 this.logger
.debug(_scope
, 'called', { identifier
, credential: '*'.repeat((credential
|| '').length
), ctx
});
284 if (typeof credential
=== 'undefined') {
288 await
this.db
.context(async (dbCtx
) => {
289 const authData
= await
this.db
.authenticationGet(dbCtx
, identifier
);
291 this.logger
.debug(_scope
, 'failed, invalid identifier', { ctx
, identifier
});
295 isValid
= await
this._validateAuthDataCredential(authData
, credential
);
298 ctx
.authenticationId
= identifier
;
299 if (authData
.otpKey
) {
300 ctx
.otpKey
= authData
.otpKey
;
302 await
this.db
.authenticationSuccess(dbCtx
, identifier
);
311 * @typedef {object} OTPState
312 * @property {string} key otp key
313 * @property {number} attempt count of attempts
314 * @property {number} epochMs when entry was initiated
317 * Validate if an entered otp token matches the key.
318 * @param {OTPState} state otp state
319 * @param {string} otp to check
320 * @returns {Enum.OTPResult} result
322 checkOTP(state
, otp
) {
323 const totp
= new this.TOTP({
324 keyEncoding: 'base32',
325 ...this.options
?.authenticator
?.otpOptions
,
328 const isValid
= totp
.validate(otp
);
330 return Enum
.OTPResult
.Valid
;
332 const otpAttemptsRemain
= state
.attempt
<= (this.options
?.authenticator
?.otpAttemptsAllowed
?? 3);
333 const otpWithinWindow
= state
.epochMs
+ (this.options
?.authenticator
?.otpWindowMs
?? 600000) > Date
.now();
334 if (otpAttemptsRemain
&& otpWithinWindow
) {
335 return Enum
.OTPResult
.InvalidSoftFail
;
337 return Enum
.OTPResult
.InvalidHardFail
;
342 * Update the authentication database with a new otp key.
343 * @param {*} dbCtx db context
344 * @param {string} identifier identifier
345 * @param {string=} otpKey otp key
347 async
updateOTPKey(dbCtx
, identifier
, otpKey
) {
348 const _scope
= _fileScope('updateOTPKey');
350 await
this.db
.authenticationUpdateOTPKey(dbCtx
, identifier
, otpKey
);
351 this.logger
.info(_scope
, 'otp key updated');
353 this.logger
.error(_scope
, 'failed', { error: e
, identifier
});
359 * Check for valid Basic auth, updates ctx with identifier if valid.
360 * @param {string} authValue basic auth value (base64)
361 * @param {object} ctx context
362 * @returns {Promise<boolean>} is valid
364 async
isValidBasic(authValue
, ctx
) {
365 const _scope
= _fileScope('isValidBasic');
366 this.logger
.debug(_scope
, 'called', { ctx
});
368 const credentials
= Buffer
.from(authValue
, 'base64').toString('utf-8');
369 const [identifier
, credential
] = common
.splitFirst(credentials
, ':', '');
371 return this.isValidIdentifierCredential(identifier
, credential
, ctx
);
376 * Determine which Authorization header is available, and if it is valid.
377 * @param {string} authorizationHeader request header
378 * @param {object} ctx context
379 * @returns {Promise<boolean>} is valid
381 async
isValidAuthorization(authorizationHeader
, ctx
) {
382 const _scope
= _fileScope('isValidAuthorization');
383 this.logger
.debug(_scope
, 'called', { authorizationHeader: common
.obscureAuthorizationHeader(authorizationHeader
), ctx
});
385 const [authMethod
, authString
] = common
.splitFirst(authorizationHeader
, ' ', '').map((x
) => x
.trim());
386 // eslint-disable-next-line sonarjs/no-small-switch
387 switch (authMethod
.toLowerCase()) {
389 return this.isValidBasic(authString
, ctx
);
393 this.logger
.debug(_scope
, 'unknown authorization scheme', { ctx
});
400 * Send a response requesting basic auth.
401 * @param {http.ServerResponse} res response
404 res
.setHeader(Enum
.Header
.WWWAuthenticate
, `Basic realm="${this.basicRealm}", charset="UTF-8"`);
405 throw new Errors
.ResponseError(Enum
.ErrorResponse
.Unauthorized
);
410 * Attempt to parse a session cookie, and determine if it contains an
411 * authenticated user.
412 * Restores ctx.session from cookie data, sets ctx.authenticationId to
413 * identifier or profile for session.
414 * @param {object} ctx context
415 * @param {object} ctx.cookie cookies object
416 * @returns {Promise<boolean>} is valid
418 async
isValidCookieAuth(ctx
) {
419 const _scope
= _fileScope('isValidCookieAuth');
420 this.logger
.debug(_scope
, 'called', { ctx
});
422 const cookieValue
= ctx
.cookie
?.[Enum
.SessionCookie
];
428 ctx
.session
= await
this.mysteryBox
.unpack(cookieValue
);
429 this.logger
.debug(_scope
, 'unpacked cookie', { ctx
});
431 const hasIdentifier
= !!ctx
.session
.authenticatedIdentifier
;
432 const hasProfile
= !!ctx
.session
.authenticatedProfile
&& this.authnEnabled
.has('indieAuth');
433 const isValid
= hasIdentifier
|| hasProfile
;
435 ctx
.authenticationId
= ctx
.session
.authenticatedIdentifier
|| ctx
.session
.authenticatedProfile
;
440 this.logger
.debug(_scope
, 'could not unpack cookie', { error: e
, ctx
});
447 * Check for a valid session.
448 * If a valid session cookie is present, refreshes the timeout for it.
449 * If not, and one is required, redirects to the login url.
450 * Convenience wrappers for option combinations:
451 * @see sessionRequired
452 * @see sessionRequiredLocal
453 * @see sessionOptional
454 * @see sessionOptionalLocal
455 * @param {http.ClientRequest} req request
456 * @param {http.ServerResponse} res response
457 * @param {object} ctx context
458 * @param {string} loginPath url path to redirect to when login is required
459 * @param {boolean} required redirect to login url if no valid session
460 * @param {boolean} profilesAllowed if true, an indieauth session is valid, otherwise only identifier/credential
461 * @returns {Promise<boolean>} is valid session
463 async
sessionCheck(req
, res
, ctx
, loginPath
, required
= true, profilesAllowed
= true) {
464 const _scope
= _fileScope('sessionCheck');
465 this.logger
.debug(_scope
, 'called', { ctx
, loginPath
, required
, profilesAllowed
});
467 if (this.secureAuthOnly
468 && ctx
.clientProtocol
.toLowerCase() !== 'https') {
469 this.logger
.debug(_scope
, 'insecure transport, no authentication is valid', { ctx
});
471 throw new Errors
.ResponseError(Enum
.ErrorResponse
.Forbidden
, 'authentication is required, but connection is insecure; cannot continue');
477 if (await
this.isValidCookieAuth(ctx
)
478 && (ctx
.session
.authenticatedIdentifier
479 || (profilesAllowed
&& ctx
.session
.authenticatedProfile
))) {
480 this.logger
.debug(_scope
, 'valid session cookie', { ctx
});
481 // Refresh timeout on valid session.
482 common
.addCookie(res
, Enum
.SessionCookie
, ctx
.cookie
[Enum
.SessionCookie
], {
484 maxAge: this.cookieLifespan
,
485 sameSite: this.sameSite
,
486 path: `${this.proxyPrefix}/`,
487 secure: this.secureAuthOnly
,
489 res
.setHeader(Enum
.Header
.SetLogin
, Enum
.LoginState
.LoggedIn
);
494 // Clear any existing invalid session
495 common
.addCookie(res
, Enum
.SessionCookie
, '""', {
498 sameSite: this.sameSite
,
499 path: `${this.proxyPrefix}/`,
500 secure: this.secureAuthOnly
,
502 res
.setHeader(Enum
.Header
.SetLogin
, Enum
.LoginState
.LoggedOut
);
504 res
.statusCode
= 302;
505 res
.setHeader(Enum
.Header
.Location
, `${loginPath}?r=${encodeURIComponent(req.url)}`);
514 * Requires a valid session with a local identifier. Redirects to loginPath if not.
515 * @param {http.ClientRequest} req request
516 * @param {http.ServerResponse} res response
517 * @param {object} ctx context
518 * @param {string} loginPath url path to redirect to when login is needed
519 * @returns {Promise<boolean>} is valid session
521 async
sessionRequiredLocal(req
, res
, ctx
, loginPath
) {
522 return this.sessionCheck(req
, res
, ctx
, loginPath
, true, false);
527 * Requires a valid session with either a local identifier or a profile. Redirects to loginPath if not.
528 * @param {http.ClientRequest} req request
529 * @param {http.ServerResponse} res response
530 * @param {object} ctx context
531 * @param {string} loginPath url path to redirect to when login is needed
532 * @returns {Promise<boolean>} is valid session
534 async
sessionRequired(req
, res
, ctx
, loginPath
) {
535 return this.sessionCheck(req
, res
, ctx
, loginPath
);
540 * Check for a valid session with a local identifier, but do nothing if not.
541 * @param {http.ClientRequest} req request
542 * @param {http.ServerResponse} res response
543 * @param {object} ctx context
544 * @returns {Promise<boolean>} is valid session
546 async
sessionOptionalLocal(req
, res
, ctx
) {
547 return this.sessionCheck(req
, res
, ctx
, undefined, false, false);
552 * Check for a valid session with either a local identifier or a profile, but do nothing if not.
553 * @param {http.ClientRequest} req request
554 * @param {http.ServerResponse} res response
555 * @param {object} ctx context
556 * @returns {Promise<boolean>} is valid session
558 async
sessionOptional(req
, res
, ctx
) {
559 return this.sessionCheck(req
, res
, ctx
, undefined, false);
564 * Require auth for an API endpoint.
565 * Check for valid local identifier in Authorization header;
566 * optionally fall back to session cookie if no header provided.
567 * Prompts for Basic auth if not valid.
568 * @param {http.ClientRequest} req request
569 * @param {http.ServerResponse} res response
570 * @param {object} ctx context
571 * @param {boolean} sessionAlsoValid fall back to session if no authz header
572 * @returns {Promise<boolean>} is valid
574 async
apiRequiredLocal(req
, res
, ctx
, sessionAlsoValid
= true) {
575 const _scope
= _fileScope('apiRequiredLocal');
576 this.logger
.debug(_scope
, 'called', { ctx
, sessionAlsoValid
});
579 // If a Authorization header was provided, never consider session as a fallback.
580 const authorizationHeader
= req
.getHeader(Enum
.Header
.Authorization
);
581 if (authorizationHeader
) {
582 if (await
this.isValidAuthorization(authorizationHeader
, ctx
)) {
583 this.logger
.debug(_scope
, 'valid authorization', { ctx
, sessionAlsoValid
});
586 } else if (sessionAlsoValid
587 && await
this.sessionCheck(req
, res
, ctx
, undefined, false, false)) {
588 this.logger
.debug(_scope
, 'valid session', { ctx
, sessionAlsoValid
});
592 this.logger
.error(_scope
, 'failed', { error: e
});
596 this.logger
.debug(_scope
, 'invalid authorization', { ctx
, sessionAlsoValid
});
597 this.requestBasic(res
);
603 module
.exports
= Authenticator
;