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(logger
, options
);
56 this.cookieLifespan
= options
.authenticator
.inactiveSessionLifespanSeconds
|| 60 * 60 * 24 * 32;
61 * Check local auth entries.
62 * Sets ctx.authenticatedId if valid.
63 * @param {String} identifier
64 * @param {String} credential
67 async
isValidIdentifierCredential(identifier
, credential
, ctx
) {
68 const _scope
= _fileScope('isValidIdentifierCredential');
69 this.logger
.debug(_scope
, 'called', { identifier
, credential: '*'.repeat(credential
.length
), ctx
});
73 await
this.db
.context(async (dbCtx
) => {
74 const authData
= await
this.db
.authenticationGet(dbCtx
, identifier
);
76 this.logger
.debug(_scope
, 'failed, invalid identifier', { ctx
, identifier
});
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
);
85 this.logger
.error(_scope
, 'failed, unknown type of stored credential', { identifier
, ctx
});
89 if (this.authnEnabled
.includes('DEBUG_ANY')) {
94 ctx
.authenticationId
= identifier
;
95 await
this.db
.authenticationSuccess(dbCtx
, identifier
);
105 * @param {String} identifier
106 * @param {String} credential
109 async
_isValidPAMIdentifier(identifier
, credential
) {
110 const _scope
= _fileScope('_isValidPAMIdentifier');
112 if (this.forbiddenPAMIdentifiers
.includes(identifier
)) {
116 await
this.authn
.pam
.pamAuthenticatePromise({ username: identifier
, password: credential
});
119 this.logger
.debug(_scope
, 'failed', { error: e
});
120 if (!(e
instanceof this.authn
.pam
.PamError
)) {
129 * Check for valid Basic auth, updates ctx with identifier if valid.
130 * @param {String} credentials
131 * @param {Object} ctx
134 async
isValidBasic(credentials
, ctx
) {
135 const _scope
= _fileScope('isValidBasic');
136 this.logger
.debug(_scope
, 'called', { ctx
});
138 const [identifier
, credential
] = common
.splitFirst(credentials
, ':', '');
140 return this.isValidIdentifierCredential(identifier
, credential
, ctx
);
145 * Determine which Authorization header is available, and if it is valid.
146 * @param {String} authorizationHeader
147 * @param {Object} ctx
149 async
isValidAuthorization(authorizationHeader
, ctx
) {
150 const _scope
= _fileScope('isValidAuthorization');
151 this.logger
.debug(_scope
, 'called', { authorizationHeader: common
.obscureAuthorizationHeader(authorizationHeader
), ctx
});
153 const [authMethod
, authString
] = common
.splitFirst(authorizationHeader
, ' ', '').map((x
) => x
.trim());
154 // eslint-disable-next-line sonarjs/no-small-switch
155 switch (authMethod
.toLowerCase()) {
157 const credentials
= Buffer
.from(authString
, 'base64').toString('utf-8');
158 return this.isValidBasic(credentials
, ctx
);
162 this.logger
.debug(_scope
, 'unknown authorization scheme', { ctx
});
169 * Send a response requesting basic auth.
170 * @param {http.ServerResponse} res
173 res
.setHeader(Enum
.Header
.WWWAuthenticate
, `Basic realm="${this.basicRealm}", charset="UTF-8"`);
174 throw new Errors
.ResponseError(Enum
.ErrorResponse
.Unauthorized
);
180 * @param {String} cookieHeader
182 static _cookieParse(cookieHeader
) {
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
190 cookie
[name
] = value
; // eslint-disable-line security/detect-object-injection
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
206 async
isValidCookieAuth(ctx
, cookieHeader
) {
207 const _scope
= _fileScope('isValidCookieAuth');
208 this.logger
.debug(_scope
, 'called', { ctx
, cookieHeader
});
210 const cookie
= Authenticator
._cookieParse(cookieHeader
);
211 const cookieValue
= cookie
[Enum
.SessionCookie
];
217 ctx
.session
= await
this.mysteryBox
.unpack(cookieValue
);
218 this.logger
.debug(_scope
, 'unpacked cookie', { ctx
});
220 const hasIdentifier
= !!ctx
.session
.authenticatedIdentifier
;
221 const hasProfile
= !!ctx
.session
.authenticatedProfile
&& this.authnEnabled
.includes('indieAuth');
222 const isValid
= hasIdentifier
|| hasProfile
;
224 ctx
.authenticationId
= ctx
.session
.authenticatedIdentifier
|| ctx
.session
.authenticatedProfile
;
229 this.logger
.debug(_scope
, 'could not unpack cookie', { error:e
, ctx
});
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
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
});
249 if (this.secureAuthOnly
250 && ctx
.clientProtocol
.toLowerCase() !== 'https') {
251 this.logger
.debug(_scope
, 'insecure transport, no authentication is valid', { ctx
});
253 throw new Errors
.ResponseError(Enum
.ErrorResponse
.Forbidden
, 'authentication is required, but connection is insecure; cannot continue');
259 const sessionCookie
= req
.getHeader(Enum
.Header
.Cookie
);
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
= [
269 `Max-Age=${this.cookieLifespan}`,
271 `Path=${this.options.dingus.proxyPrefix}/`,
273 if (this.options
.authenticator
.secureAuthOnly
) {
274 cookieParts
.push('Secure');
276 res
.setHeader(Enum
.Header
.SetCookie
, cookieParts
.join('; '));
281 // Clear any existing invalid session
282 const cookieParts
= [
283 `${Enum.SessionCookie}=""`,
287 `Path=${this.options.dingus.proxyPrefix}/`,
289 if (this.options
.authenticator
.secureAuthOnly
) {
290 cookieParts
.push('Secure');
292 res
.setHeader(Enum
.Header
.SetCookie
, cookieParts
.join('; '));
294 res
.statusCode
= 302;
295 res
.setHeader(Enum
.Header
.Location
, `${loginPath}?r=${encodeURIComponent(req.url)}`);
304 * Requires a valid session with a local identifier. Redirects to loginPath if not.
305 * @param {http.ClientRequest} req
306 * @param {http.ServerResponse} res
307 * @param {Object} ctx
308 * @param {String} loginPath
311 async
sessionRequiredLocal(req
, res
, ctx
, loginPath
) {
312 return this.sessionCheck(req
, res
, ctx
, loginPath
, true, false);
317 * Requires a valid session with either a local identifier or a profile. Redirects to loginPath if not.
318 * @param {http.ClientRequest} req
319 * @param {http.ServerResponse} res
320 * @param {Object} ctx
321 * @param {String} loginPath
324 async
sessionRequired(req
, res
, ctx
, loginPath
) {
325 return this.sessionCheck(req
, res
, ctx
, loginPath
);
330 * Check for a valid session with a local identifier, but do nothing if not.
331 * @param {http.ClientRequest} req
332 * @param {http.ServerResponse} res
333 * @param {Object} ctx
334 * @param {String} loginPath
337 async
sessionOptionalLocal(req
, res
, ctx
) {
338 return this.sessionCheck(req
, res
, ctx
, undefined, false, false);
343 * Check for a valid session with either a local identifier or a profile, but do nothing if not.
344 * @param {http.ClientRequest} req
345 * @param {http.ServerResponse} res
346 * @param {Object} ctx
347 * @param {String} loginPath
350 async
sessionOptional(req
, res
, ctx
) {
351 return this.sessionCheck(req
, res
, ctx
, undefined, false);
356 * Require auth for an API endpoint.
357 * Check for valid local identifier in Authorization header; optionally
358 * fall back to session cookie if no header provided.
359 * Prompts for Basic auth if not valid.
360 * @param {http.ClientRequest} req
361 * @param {http.ServerResponse} res
362 * @param {Object} ctx
363 * @param {Boolean} sessionAlsoValid
365 async
apiRequiredLocal(req
, res
, ctx
, sessionAlsoValid
= true) {
366 const _scope
= _fileScope('apiRequiredLocal');
367 this.logger
.debug(_scope
, 'called', { ctx
, sessionAlsoValid
});
369 // If a Authorization header was provided, never consider session as a fallback.
370 const authorizationHeader
= req
.getHeader(Enum
.Header
.Authorization
);
371 if (authorizationHeader
) {
372 if (await
this.isValidAuthorization(authorizationHeader
, ctx
)) {
373 this.logger
.debug(_scope
, 'valid authorization', { ctx
, sessionAlsoValid
});
378 && await
this.sessionCheck(req
, res
, ctx
, undefined, false, false)) {
379 this.logger
.debug(_scope
, 'valid session', { ctx
, sessionAlsoValid
});
384 this.logger
.debug(_scope
, 'invalid authorization', { ctx
, sessionAlsoValid
});
385 this.requestBasic(res
);
390 module
.exports
= Authenticator
;