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');
9 const _fileScope
= common
.fileScope(__filename
);
13 * @param {Console} logger
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
28 constructor(logger
, db
, options
) {
31 this.options
= options
;
32 this.basicRealm
= options
.authenticator
.basicRealm
|| packageName
;
33 this.secureAuthOnly
= options
.authenticator
.secureAuthOnly
;
40 this.authn
.argon2
= require('argon2');
43 this.authn
.pam
= require('node-linux-pam');
44 this.forbiddenPAMIdentifiers
= options
.authenticator
.forbiddenPAMIdentifiers
;
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
});
50 if (this.authnEnabled
.length
=== 0) {
51 throw new Error('no authentication mechanisms available');
54 this.mysteryBox
= new MysteryBox(options
);
55 this.mysteryBox
.on('statistics', common
.mysteryBoxLogger(logger
, _fileScope(this.constructor.name
)));
57 this.cookieLifespan
= options
.authenticator
.inactiveSessionLifespanSeconds
|| 60 * 60 * 24 * 32;
62 * Check local auth entries.
63 * Sets ctx.authenticatedId if valid.
64 * @param {String} identifier
65 * @param {String} credential
68 async
isValidIdentifierCredential(identifier
, credential
, ctx
) {
69 const _scope
= _fileScope('isValidIdentifierCredential');
70 this.logger
.debug(_scope
, 'called', { identifier
, credential: '*'.repeat((credential
|| '').length
), ctx
});
74 if (typeof credential
=== 'undefined') {
78 await
this.db
.context(async (dbCtx
) => {
79 const authData
= await
this.db
.authenticationGet(dbCtx
, identifier
);
81 this.logger
.debug(_scope
, 'failed, invalid identifier', { ctx
, identifier
});
83 if (authData
.credential
.startsWith('$argon2')
84 && this.authnEnabled
.includes('argon2')) {
85 isValid
= await
this.authn
.argon2
.verify(authData
.credential
, credential
);
86 } else if (authData
.credential
.startsWith('$PAM$')
87 && this.authnEnabled
.includes('pam')) {
88 isValid
= this._isValidPAMIdentifier(identifier
, credential
);
90 this.logger
.error(_scope
, 'failed, unknown or unsupported type of stored credential', { identifier
, ctx
});
94 if (this.authnEnabled
.includes('DEBUG_ANY')) {
99 ctx
.authenticationId
= identifier
;
100 await
this.db
.authenticationSuccess(dbCtx
, identifier
);
110 * @param {String} identifier
111 * @param {String} credential
114 async
_isValidPAMIdentifier(identifier
, credential
) {
115 const _scope
= _fileScope('_isValidPAMIdentifier');
117 if (this.forbiddenPAMIdentifiers
.includes(identifier
)) {
121 await
this.authn
.pam
.pamAuthenticatePromise({ username: identifier
, password: credential
});
124 this.logger
.debug(_scope
, 'failed', { error: e
});
125 if (!(e
instanceof this.authn
.pam
.PamError
)) {
134 * Check for valid Basic auth, updates ctx with identifier if valid.
135 * @param {String} credentials
136 * @param {Object} ctx
139 async
isValidBasic(credentials
, ctx
) {
140 const _scope
= _fileScope('isValidBasic');
141 this.logger
.debug(_scope
, 'called', { ctx
});
143 const [identifier
, credential
] = common
.splitFirst(credentials
, ':', '');
145 return this.isValidIdentifierCredential(identifier
, credential
, ctx
);
150 * Determine which Authorization header is available, and if it is valid.
151 * @param {String} authorizationHeader
152 * @param {Object} ctx
154 async
isValidAuthorization(authorizationHeader
, ctx
) {
155 const _scope
= _fileScope('isValidAuthorization');
156 this.logger
.debug(_scope
, 'called', { authorizationHeader: common
.obscureAuthorizationHeader(authorizationHeader
), ctx
});
158 const [authMethod
, authString
] = common
.splitFirst(authorizationHeader
, ' ', '').map((x
) => x
.trim());
159 // eslint-disable-next-line sonarjs/no-small-switch
160 switch (authMethod
.toLowerCase()) {
162 const credentials
= Buffer
.from(authString
, 'base64').toString('utf-8');
163 return this.isValidBasic(credentials
, ctx
);
167 this.logger
.debug(_scope
, 'unknown authorization scheme', { ctx
});
174 * Send a response requesting basic auth.
175 * @param {http.ServerResponse} res
178 res
.setHeader(Enum
.Header
.WWWAuthenticate
, `Basic realm="${this.basicRealm}", charset="UTF-8"`);
179 throw new Errors
.ResponseError(Enum
.ErrorResponse
.Unauthorized
);
185 * @param {String} cookieHeader
187 static _cookieParse(cookieHeader
) {
189 (cookieHeader
|| '').split(/; */
).forEach((field
) => {
190 const [ name
, value
] = common
.splitFirst(field
, '=', null).map((x
) => x
&& decodeURIComponent(x
.trim()));
191 if (name
&& !(name
in cookie
)) {
192 if (value
?.startsWith('"') && value
.endsWith('"')) {
193 cookie
[name
] = value
.slice(1, -1); // eslint-disable-line security/detect-object-injection
195 cookie
[name
] = value
; // eslint-disable-line security/detect-object-injection
204 * Attempt to parse a session cookie, and determine if it contains an
205 * authenticated user.
206 * Restores ctx.session from cookie data, sets ctx.authenticationId if identifier exists.
207 * @param {Object} ctx
208 * @param {String} cookieHeader
211 async
isValidCookieAuth(ctx
, cookieHeader
) {
212 const _scope
= _fileScope('isValidCookieAuth');
213 this.logger
.debug(_scope
, 'called', { ctx
, cookieHeader
});
215 const cookie
= Authenticator
._cookieParse(cookieHeader
);
216 const cookieValue
= cookie
[Enum
.SessionCookie
];
222 ctx
.session
= await
this.mysteryBox
.unpack(cookieValue
);
223 this.logger
.debug(_scope
, 'unpacked cookie', { ctx
});
225 const hasIdentifier
= !!ctx
.session
.authenticatedIdentifier
;
226 const hasProfile
= !!ctx
.session
.authenticatedProfile
&& this.authnEnabled
.includes('indieAuth');
227 const isValid
= hasIdentifier
|| hasProfile
;
229 ctx
.authenticationId
= ctx
.session
.authenticatedIdentifier
|| ctx
.session
.authenticatedProfile
;
234 this.logger
.debug(_scope
, 'could not unpack cookie', { error:e
, ctx
});
241 * Check for a valid session.
242 * @param {http.ClientRequest} req
243 * @param {http.ServerResponse} res
244 * @param {Object} ctx
245 * @param {String} loginPath
246 * @param {Boolean} required
247 * @param {Boolean} profilesAllowed
250 async
sessionCheck(req
, res
, ctx
, loginPath
, required
= true, profilesAllowed
= true) {
251 const _scope
= _fileScope('check');
252 this.logger
.debug(_scope
, 'called', { ctx
, loginPath
, required
, profilesAllowed
});
254 if (this.secureAuthOnly
255 && ctx
.clientProtocol
.toLowerCase() !== 'https') {
256 this.logger
.debug(_scope
, 'insecure transport, no authentication is valid', { ctx
});
258 throw new Errors
.ResponseError(Enum
.ErrorResponse
.Forbidden
, 'authentication is required, but connection is insecure; cannot continue');
264 const sessionCookie
= req
.getHeader(Enum
.Header
.Cookie
);
266 && await
this.isValidCookieAuth(ctx
, sessionCookie
)
267 && (ctx
.session
.authenticatedIdentifier
268 || (profilesAllowed
&& ctx
.session
.authenticatedProfile
))) {
269 this.logger
.debug(_scope
, 'valid session cookie', { ctx
});
270 // Refresh timeout on valid session.
271 const cookieParts
= [
274 `Max-Age=${this.cookieLifespan}`,
276 `Path=${this.options.dingus.proxyPrefix}/`,
278 if (this.secureAuthOnly
) {
279 cookieParts
.push('Secure');
281 res
.setHeader(Enum
.Header
.SetCookie
, cookieParts
.join('; '));
286 // Clear any existing invalid session
287 const cookieParts
= [
288 `${Enum.SessionCookie}=""`,
292 `Path=${this.options.dingus.proxyPrefix}/`,
294 if (this.options
.authenticator
.secureAuthOnly
) {
295 cookieParts
.push('Secure');
297 res
.setHeader(Enum
.Header
.SetCookie
, cookieParts
.join('; '));
299 res
.statusCode
= 302;
300 res
.setHeader(Enum
.Header
.Location
, `${loginPath}?r=${encodeURIComponent(req.url)}`);
309 * Requires a valid session with a local identifier. Redirects to loginPath if not.
310 * @param {http.ClientRequest} req
311 * @param {http.ServerResponse} res
312 * @param {Object} ctx
313 * @param {String} loginPath
316 async
sessionRequiredLocal(req
, res
, ctx
, loginPath
) {
317 return this.sessionCheck(req
, res
, ctx
, loginPath
, true, false);
322 * Requires a valid session with either a local identifier or a profile. Redirects to loginPath if not.
323 * @param {http.ClientRequest} req
324 * @param {http.ServerResponse} res
325 * @param {Object} ctx
326 * @param {String} loginPath
329 async
sessionRequired(req
, res
, ctx
, loginPath
) {
330 return this.sessionCheck(req
, res
, ctx
, loginPath
);
335 * Check for a valid session with a local identifier, but do nothing if not.
336 * @param {http.ClientRequest} req
337 * @param {http.ServerResponse} res
338 * @param {Object} ctx
339 * @param {String} loginPath
342 async
sessionOptionalLocal(req
, res
, ctx
) {
343 return this.sessionCheck(req
, res
, ctx
, undefined, false, false);
348 * Check for a valid session with either a local identifier or a profile, but do nothing if not.
349 * @param {http.ClientRequest} req
350 * @param {http.ServerResponse} res
351 * @param {Object} ctx
352 * @param {String} loginPath
355 async
sessionOptional(req
, res
, ctx
) {
356 return this.sessionCheck(req
, res
, ctx
, undefined, false);
361 * Require auth for an API endpoint.
362 * Check for valid local identifier in Authorization header; optionally
363 * fall back to session cookie if no header provided.
364 * Prompts for Basic auth if not valid.
365 * @param {http.ClientRequest} req
366 * @param {http.ServerResponse} res
367 * @param {Object} ctx
368 * @param {Boolean} sessionAlsoValid
370 async
apiRequiredLocal(req
, res
, ctx
, sessionAlsoValid
= true) {
371 const _scope
= _fileScope('apiRequiredLocal');
372 this.logger
.debug(_scope
, 'called', { ctx
, sessionAlsoValid
});
374 // If a Authorization header was provided, never consider session as a fallback.
375 const authorizationHeader
= req
.getHeader(Enum
.Header
.Authorization
);
376 if (authorizationHeader
) {
377 if (await
this.isValidAuthorization(authorizationHeader
, ctx
)) {
378 this.logger
.debug(_scope
, 'valid authorization', { ctx
, sessionAlsoValid
});
383 && await
this.sessionCheck(req
, res
, ctx
, undefined, false, false)) {
384 this.logger
.debug(_scope
, 'valid session', { ctx
, sessionAlsoValid
});
389 this.logger
.debug(_scope
, 'invalid authorization', { ctx
, sessionAlsoValid
});
390 this.requestBasic(res
);
395 module
.exports
= Authenticator
;