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 { timingSafeEqual
} = require('node:crypto');
9 const { name: packageName
} = require('../package');
11 const _fileScope
= common
.fileScope(__filename
);
14 * Wrangles the fiddly bits of authentication.
15 * Handles checking of credentials and otp codes, creating and validating
16 * cookie-based sessions, Basic auth.
17 * Interacts with the authentication database interface.
21 * @typedef {import('node:http')} http
26 * @typedef {object} AuthInfo
27 * @property {string} identifier identifier
28 * @property {string} credential hashed credential
29 * @property {string=} otpKey optional otp key
32 * @callback DBContextExec
33 * @param {object} dbCtx
34 * @returns {Promise<any>}
37 * @typedef {object} AuthDBInterface
38 * @property {(DBContextExec) => Promise<any>} context db context runner
39 * @property {(dbCtx: any, identifier: string) => Promise<AuthInfo>} authenticationGet get an authentication entry
40 * @property {(dbCtx: any, identifier: string) => Promise<void>} authenticationSuccess store a successful authentication event
41 * @property {(dbCtx: any, identifier: string, credential: string, otpKey: string=) => Promise<void>} authenticationUpsert update an authentication entry
42 * @property {(dbCtx: any, identifier: string, otpKey: string) => Promise<void>} authenticationUpdateOTPKey update otp key
43 * @property {(dbCtx: any, identifier: string, credential: AuthInfo) => Promise<void>} authenticationUpdateCredential update credential
46 * @typedef {object} ConsoleLike
47 * @property {Function} debug log debug
48 * @property {Function} error log error
49 * @property {Function} info log info
52 * @param {ConsoleLike} logger logger instance
53 * @param {AuthDBInterface} db db instance
54 * @param {object} options options
55 * @param {string | string[]} options.encryptionSecret encryption secret
56 * @param {object} options.authenticator authenticator options
57 * @param {boolean=} options.authenticator.secureAuthOnly disable auth over non-https
58 * @param {string=} options.authenticator.sessionCookieSameSite sameSite setting for session cookie, default Lax
59 * @param {string[]=} options.authenticator.forbiddenPAMIdentifiers reject these identifiers for PAM auth
60 * @param {string[]=} options.authenticator.authnEnabled in order of preference for storing new credentials
61 * @param {number=} options.authenticator.inactiveSessionLifespanSeconds session timeout
62 * @param {string[]=} options.authenticator.loginBlurb text for login page
63 * @param {string[]=} options.authenticator.indieAuthBlurb text for indieauth login section
64 * @param {string[]=} options.authenticator.userBlurb text for local user login section
65 * @param {string[]=} options.authenticator.otpBlurb text for otp entry
66 * @param {object=} options.dingus dingus options
67 * @param {string=} options.dingus.proxyPrefix base url prefix
69 constructor(logger
, db
, options
) {
72 this.options
= options
;
73 this.basicRealm
= options
.authenticator
.basicRealm
|| packageName
;
74 this.secureAuthOnly
= options
.authenticator
.secureAuthOnly
?? true;
75 this.sameSite
= options
.authenticator
.sessionCookieSameSite
|| 'Lax';
76 this.proxyPrefix
= options
.dingus
?.proxyPrefix
?? '';
78 if (!['None', 'Lax', 'Strict'].includes(this.sameSite
)) {
79 throw new RangeError(`invalid sameSite value "${this.sameSite}"`);
82 // First construct map of all available code-supported auth mechanisms.
88 this.authn
.argon2
= require('argon2');
89 } catch (e
) { /**/ } // eslint-disable-line no-unused-vars
91 this.authn
.pam
= require('node-linux-pam');
92 this.forbiddenPAMIdentifiers
= options
.authenticator
.forbiddenPAMIdentifiers
?? ['root'];
93 } catch (e
) { /**/ } // eslint-disable-line no-unused-vars
95 // Track which authn methods we can change credentials et cetera.
96 const authnUpdatable
= ['plain', 'argon2'];
98 // Filter configured mechanisms from available, note the first as which to use for setting new credentials.
99 this.authnEnabled
= new Set();
100 this.authnPreferred
= undefined; // For updating credentials
101 options
.authenticator
.authnEnabled
.forEach((authn
) => {
102 if (authn
in this.authn
) {
103 this.authnEnabled
.add(authn
);
104 if (!this.authnPreferred
&& authnUpdatable
.includes(authn
)) {
105 this.authnPreferred
= authn
;
109 this.logger
.debug(_fileScope('constructor'), 'available mechanisms', { authn: Array
.from(this.authnEnabled
), preferred: this.authnPreferred
});
111 if (this.authnEnabled
.size
=== 0) {
112 throw new Error('no authentication mechanisms available');
115 this.mysteryBox
= new MysteryBox(options
);
116 this.mysteryBox
.on('statistics', common
.mysteryBoxLogger(logger
, _fileScope(this.constructor.name
)));
118 this.cookieLifespan
= options
.authenticator
.inactiveSessionLifespanSeconds
|| 60 * 60 * 24 * 32;
120 this.TOTP
= TOTP
; // For ease of test stubbing.
125 * Populate the authentication database with a new identifier, the
126 * secured credential, and optionally an OTP key.
127 * @param {*} dbCtx db context
128 * @param {string} identifier identifier
129 * @param {string} credential plaintext
130 * @param {string=} otpKey otp key
131 * @returns {Promise<void>}
133 async
createIdentifier(dbCtx
, identifier
, credential
, otpKey
= null) {
134 const _scope
= _fileScope('createIdentifier');
136 const secureCredential
= await
this._secureCredential(credential
);
137 await
this.db
.authenticationUpsert(dbCtx
, identifier
, secureCredential
, otpKey
);
139 this.logger
.error(_scope
, 'failed', { error: e
, identifier
});
146 * Update the authentication database with a new secured credential
147 * for an indentifier.
148 * @param {*} dbCtx dbCtx
149 * @param {string} identifier identifier
150 * @param {string} credential plaintext
151 * @returns {Promise<void>}
153 async
updateCredential(dbCtx
, identifier
, credential
) {
154 const _scope
= _fileScope('updateCredential');
156 const secureCredential
= await
this._secureCredential(credential
);
157 await
this.db
.authenticationUpdateCredential(dbCtx
, identifier
, secureCredential
);
158 this.logger
.info(_scope
, 'success', { identifier
});
160 this.logger
.error(_scope
, 'failed', { error: e
, identifier
});
167 * Encode a plaintext credential in the preferred way to store in database.
168 * @param {string} credential plaintext
169 * @param {string=} authn authentication mechanism
170 * @returns {Promise<string>} encoded credential
172 async
_secureCredential(credential
, authn
= this.authnPreferred
) {
173 const _scope
= _fileScope('_secureCredential');
177 return '$plain$' + credential
;
180 return await
this.authn
.argon2
.hash(credential
, { type: this.authn
.argon2
.argon2id
});
183 throw new RangeError('Unknown authn');
186 this.logger
.error(_scope
, 'failed', { error: e
, authn
});
193 * Checks a supplied credential against supplied data.
194 * @param {AuthInfo} authData from database
195 * @param {string} credential plaintext
196 * @returns {Promise<boolean>} is valid
198 async
_validateAuthDataCredential(authData
, credential
) {
199 const _scope
= _fileScope('_validateAuthDataCredential');
201 if (!authData
?.credential
) {
202 this.logger
.debug(_scope
, 'failed, no authInfo');
205 if (authData
.credential
.startsWith('$argon2')
206 && this.authnEnabled
.has('argon2')) {
207 return await
this._isValidArgon2Identifier(authData
, credential
);
209 if (authData
.credential
.startsWith('$PAM$')
210 && this.authnEnabled
.has('pam')) {
211 return await
this._isValidPAMIdentifier(authData
, credential
);
213 if (authData
.credential
.startsWith('$plain$')
214 && this.authnEnabled
.has('plain')) {
215 return this.constructor._isValidPlainIdentifier(authData
, credential
);
217 this.logger
.error(_scope
, 'failed, unknown or unsupported type of stored credential', { authData
});
224 * @param {AuthInfo} authData auth entry
225 * @param {string} credential to check
226 * @returns {Promise<boolean>} is valid
228 async
_isValidArgon2Identifier(authData
, credential
) {
229 return await
this.authn
.argon2
.verify(authData
.credential
, credential
);
235 * @param {AuthInfo} authData auth entry
236 * @param {string} credential to check
237 * @returns {Promise<boolean>} is valid
239 static _isValidPlainIdentifier(authData
, credential
) {
240 const authBuf
= Buffer
.from(authData
.credential
.substring('$plain$'.length
));
241 const credBuf
= Buffer
.from(credential
);
242 return (authBuf
.length
=== credBuf
.length
) && timingSafeEqual(authBuf
, credBuf
);
248 * @param {AuthInfo} authData auth entry
249 * @param {string} credential to check
250 * @returns {Promise<boolean>} is valid
252 async
_isValidPAMIdentifier(authData
, credential
) {
253 const _scope
= _fileScope('_isValidPAMIdentifier');
254 const { identifier
} = authData
;
256 if (this.forbiddenPAMIdentifiers
.includes(identifier
)) {
260 await
this.authn
.pam
.pamAuthenticatePromise({ username: identifier
, password: credential
});
263 this.logger
.debug(_scope
, 'failed', { error: e
});
264 if (!(e
instanceof this.authn
.pam
.PamError
)) {
273 * Check local auth entries.
274 * Sets ctx.authenticatedId if valid.
275 * Sets ctx.otpKey if account has otpKey.
276 * @param {string} identifier identifier
277 * @param {string} credential to check
278 * @param {object} ctx context
279 * @returns {Promise<boolean>} is valid
281 async
isValidIdentifierCredential(identifier
, credential
, ctx
) {
282 const _scope
= _fileScope('isValidIdentifierCredential');
283 this.logger
.debug(_scope
, 'called', { identifier
, credential: '*'.repeat((credential
|| '').length
), ctx
});
287 if (typeof credential
=== 'undefined') {
291 await
this.db
.context(async (dbCtx
) => {
292 const authData
= await
this.db
.authenticationGet(dbCtx
, identifier
);
294 this.logger
.debug(_scope
, 'failed, invalid identifier', { ctx
, identifier
});
298 isValid
= await
this._validateAuthDataCredential(authData
, credential
);
301 ctx
.authenticationId
= identifier
;
302 if (authData
.otpKey
) {
303 ctx
.otpKey
= authData
.otpKey
;
305 await
this.db
.authenticationSuccess(dbCtx
, identifier
);
314 * @typedef {object} OTPState
315 * @property {string} key otp key
316 * @property {number} attempt count of attempts
317 * @property {number} epochMs when entry was initiated
320 * Validate if an entered otp token matches the key.
321 * @param {OTPState} state otp state
322 * @param {string} otp to check
323 * @returns {Enum.OTPResult} result
325 checkOTP(state
, otp
) {
326 const totp
= new this.TOTP({
327 keyEncoding: 'base32',
328 ...this.options
?.authenticator
?.otpOptions
,
331 const isValid
= totp
.validate(otp
);
333 return Enum
.OTPResult
.Valid
;
335 const otpAttemptsRemain
= state
.attempt
<= (this.options
?.authenticator
?.otpAttemptsAllowed
?? 3);
336 const otpWithinWindow
= state
.epochMs
+ (this.options
?.authenticator
?.otpWindowMs
?? 600000) > Date
.now();
337 if (otpAttemptsRemain
&& otpWithinWindow
) {
338 return Enum
.OTPResult
.InvalidSoftFail
;
340 return Enum
.OTPResult
.InvalidHardFail
;
345 * Update the authentication database with a new otp key.
346 * @param {*} dbCtx db context
347 * @param {string} identifier identifier
348 * @param {string=} otpKey otp key
350 async
updateOTPKey(dbCtx
, identifier
, otpKey
) {
351 const _scope
= _fileScope('updateOTPKey');
353 await
this.db
.authenticationUpdateOTPKey(dbCtx
, identifier
, otpKey
);
354 this.logger
.info(_scope
, 'otp key updated');
356 this.logger
.error(_scope
, 'failed', { error: e
, identifier
});
362 * Check for valid Basic auth, updates ctx with identifier if valid.
363 * @param {string} authValue basic auth value (base64)
364 * @param {object} ctx context
365 * @returns {Promise<boolean>} is valid
367 async
isValidBasic(authValue
, ctx
) {
368 const _scope
= _fileScope('isValidBasic');
369 this.logger
.debug(_scope
, 'called', { ctx
});
371 const credentials
= Buffer
.from(authValue
, 'base64').toString('utf-8');
372 const [identifier
, credential
] = common
.splitFirst(credentials
, ':', '');
374 return this.isValidIdentifierCredential(identifier
, credential
, ctx
);
379 * Determine which Authorization header is available, and if it is valid.
380 * @param {string} authorizationHeader request header
381 * @param {object} ctx context
382 * @returns {Promise<boolean>} is valid
384 async
isValidAuthorization(authorizationHeader
, ctx
) {
385 const _scope
= _fileScope('isValidAuthorization');
386 this.logger
.debug(_scope
, 'called', { authorizationHeader: common
.obscureAuthorizationHeader(authorizationHeader
), ctx
});
388 const [authMethod
, authString
] = common
.splitFirst(authorizationHeader
, ' ', '').map((x
) => x
.trim());
389 // eslint-disable-next-line sonarjs/no-small-switch
390 switch (authMethod
.toLowerCase()) {
392 return this.isValidBasic(authString
, ctx
);
396 this.logger
.debug(_scope
, 'unknown authorization scheme', { ctx
});
403 * Send a response requesting basic auth.
404 * @param {http.ServerResponse} res response
407 res
.setHeader(Enum
.Header
.WWWAuthenticate
, `Basic realm="${this.basicRealm}", charset="UTF-8"`);
408 throw new Errors
.ResponseError(Enum
.ErrorResponse
.Unauthorized
);
413 * Attempt to parse a session cookie, and determine if it contains an
414 * authenticated user.
415 * Restores ctx.session from cookie data, sets ctx.authenticationId to
416 * identifier or profile for session.
417 * @param {object} ctx context
418 * @param {object} ctx.cookie cookies object
419 * @returns {Promise<boolean>} is valid
421 async
isValidCookieAuth(ctx
) {
422 const _scope
= _fileScope('isValidCookieAuth');
423 this.logger
.debug(_scope
, 'called', { ctx
});
425 const cookieValue
= ctx
.cookie
?.[Enum
.SessionCookie
];
431 ctx
.session
= await
this.mysteryBox
.unpack(cookieValue
);
432 this.logger
.debug(_scope
, 'unpacked cookie', { ctx
});
434 const hasIdentifier
= !!ctx
.session
.authenticatedIdentifier
;
435 const hasProfile
= !!ctx
.session
.authenticatedProfile
&& this.authnEnabled
.has('indieAuth');
436 const isValid
= hasIdentifier
|| hasProfile
;
438 ctx
.authenticationId
= ctx
.session
.authenticatedIdentifier
|| ctx
.session
.authenticatedProfile
;
443 this.logger
.debug(_scope
, 'could not unpack cookie', { error: e
, ctx
});
450 * Check for a valid session.
451 * If a valid session cookie is present, refreshes the timeout for it.
452 * If not, and one is required, redirects to the login url.
453 * Convenience wrappers for option combinations:
454 * @see sessionRequired
455 * @see sessionRequiredLocal
456 * @see sessionOptional
457 * @see sessionOptionalLocal
458 * @param {http.ClientRequest} req request
459 * @param {http.ServerResponse} res response
460 * @param {object} ctx context
461 * @param {string} loginPath url path to redirect to when login is required
462 * @param {boolean} required redirect to login url if no valid session
463 * @param {boolean} profilesAllowed if true, an indieauth session is valid, otherwise only identifier/credential
464 * @returns {Promise<boolean>} is valid session
466 async
sessionCheck(req
, res
, ctx
, loginPath
, required
= true, profilesAllowed
= true) {
467 const _scope
= _fileScope('sessionCheck');
468 this.logger
.debug(_scope
, 'called', { ctx
, loginPath
, required
, profilesAllowed
});
470 if (this.secureAuthOnly
471 && ctx
.clientProtocol
.toLowerCase() !== 'https') {
472 this.logger
.debug(_scope
, 'insecure transport, no authentication is valid', { ctx
});
474 throw new Errors
.ResponseError(Enum
.ErrorResponse
.Forbidden
, 'authentication is required, but connection is insecure; cannot continue');
480 if (await
this.isValidCookieAuth(ctx
)
481 && (ctx
.session
.authenticatedIdentifier
482 || (profilesAllowed
&& ctx
.session
.authenticatedProfile
))) {
483 this.logger
.debug(_scope
, 'valid session cookie', { ctx
});
484 // Refresh timeout on valid session.
485 common
.addCookie(res
, Enum
.SessionCookie
, ctx
.cookie
[Enum
.SessionCookie
], {
487 maxAge: this.cookieLifespan
,
488 sameSite: this.sameSite
,
489 path: `${this.proxyPrefix}/`,
490 secure: this.secureAuthOnly
,
492 res
.setHeader(Enum
.Header
.SetLogin
, Enum
.LoginState
.LoggedIn
);
497 // Clear any existing invalid session
498 common
.addCookie(res
, Enum
.SessionCookie
, '""', {
501 sameSite: this.sameSite
,
502 path: `${this.proxyPrefix}/`,
503 secure: this.secureAuthOnly
,
505 res
.setHeader(Enum
.Header
.SetLogin
, Enum
.LoginState
.LoggedOut
);
507 res
.statusCode
= 302;
508 res
.setHeader(Enum
.Header
.Location
, `${loginPath}?r=${encodeURIComponent(req.url)}`);
517 * Requires a valid session with a local identifier. Redirects to loginPath if not.
518 * @param {http.ClientRequest} req request
519 * @param {http.ServerResponse} res response
520 * @param {object} ctx context
521 * @param {string} loginPath url path to redirect to when login is needed
522 * @returns {Promise<boolean>} is valid session
524 async
sessionRequiredLocal(req
, res
, ctx
, loginPath
) {
525 return this.sessionCheck(req
, res
, ctx
, loginPath
, true, false);
530 * Requires a valid session with either a local identifier or a profile. Redirects to loginPath if not.
531 * @param {http.ClientRequest} req request
532 * @param {http.ServerResponse} res response
533 * @param {object} ctx context
534 * @param {string} loginPath url path to redirect to when login is needed
535 * @returns {Promise<boolean>} is valid session
537 async
sessionRequired(req
, res
, ctx
, loginPath
) {
538 return this.sessionCheck(req
, res
, ctx
, loginPath
);
543 * Check for a valid session with a local identifier, but do nothing if not.
544 * @param {http.ClientRequest} req request
545 * @param {http.ServerResponse} res response
546 * @param {object} ctx context
547 * @returns {Promise<boolean>} is valid session
549 async
sessionOptionalLocal(req
, res
, ctx
) {
550 return this.sessionCheck(req
, res
, ctx
, undefined, false, false);
555 * Check for a valid session with either a local identifier or a profile, but do nothing if not.
556 * @param {http.ClientRequest} req request
557 * @param {http.ServerResponse} res response
558 * @param {object} ctx context
559 * @returns {Promise<boolean>} is valid session
561 async
sessionOptional(req
, res
, ctx
) {
562 return this.sessionCheck(req
, res
, ctx
, undefined, false);
567 * Require auth for an API endpoint.
568 * Check for valid local identifier in Authorization header;
569 * optionally fall back to session cookie if no header provided.
570 * Prompts for Basic auth if not valid.
571 * @param {http.ClientRequest} req request
572 * @param {http.ServerResponse} res response
573 * @param {object} ctx context
574 * @param {boolean} sessionAlsoValid fall back to session if no authz header
575 * @returns {Promise<boolean>} is valid
577 async
apiRequiredLocal(req
, res
, ctx
, sessionAlsoValid
= true) {
578 const _scope
= _fileScope('apiRequiredLocal');
579 this.logger
.debug(_scope
, 'called', { ctx
, sessionAlsoValid
});
582 // If a Authorization header was provided, never consider session as a fallback.
583 const authorizationHeader
= req
.getHeader(Enum
.Header
.Authorization
);
584 if (authorizationHeader
) {
585 if (await
this.isValidAuthorization(authorizationHeader
, ctx
)) {
586 this.logger
.debug(_scope
, 'valid authorization', { ctx
, sessionAlsoValid
});
589 } else if (sessionAlsoValid
590 && await
this.sessionCheck(req
, res
, ctx
, undefined, false, false)) {
591 this.logger
.debug(_scope
, 'valid session', { ctx
, sessionAlsoValid
});
595 this.logger
.error(_scope
, 'failed', { error: e
});
599 this.logger
.debug(_scope
, 'invalid authorization', { ctx
, sessionAlsoValid
});
600 this.requestBasic(res
);
606 module
.exports
= Authenticator
;