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 {String[]=} options.authenticator.loginBlurb
24 * @param {String[]=} options.authenticator.indieAuthBlurb
25 * @param {String[]=} options.authenticator.userBlurb
27 constructor(logger
, db
, options
) {
30 this.options
= options
;
31 this.basicRealm
= options
.authenticator
.basicRealm
|| packageName
;
32 this.secureAuthOnly
= options
.authenticator
.secureAuthOnly
;
39 this.authn
.argon2
= require('argon2');
42 this.authn
.pam
= require('node-linux-pam');
43 this.forbiddenPAMIdentifiers
= options
.authenticator
.forbiddenPAMIdentifiers
;
46 this.authnEnabled
= Object
.keys(this.authn
).filter((auth
) => options
.authenticator
.authnEnabled
.includes(auth
));
47 this.logger
.debug(_fileScope('constructor'), 'available mechanisms', { authn: this.authnEnabled
});
49 if (this.authnEnabled
.length
=== 0) {
50 throw new Error('no authentication mechanisms available');
53 this.mysteryBox
= new MysteryBox(logger
, options
);
58 * Check local auth entries.
59 * Sets ctx.authenticatedId if valid.
60 * @param {String} identifier
61 * @param {String} credential
64 async
isValidIdentifierCredential(identifier
, credential
, ctx
) {
65 const _scope
= _fileScope('isValidIdentifierCredential');
66 this.logger
.debug(_scope
, 'called', { identifier
, credential: '*'.repeat(credential
.length
), ctx
});
70 await
this.db
.context(async (dbCtx
) => {
71 const authData
= await
this.db
.authenticationGet(dbCtx
, identifier
);
73 this.logger
.debug(_scope
, 'failed, invalid identifier', { ctx
, identifier
});
75 if (authData
.credential
.startsWith('$argon2')
76 && this.authnEnabled
.includes('argon2')) {
77 isValid
= await
this.authn
.argon2
.verify(authData
.credential
, credential
);
78 } else if (authData
.credential
.startsWith('$PAM$')
79 && this.authnEnabled
.includes('pam')) {
80 isValid
= this._isValidPAMIdentifier(identifier
, credential
);
82 this.logger
.error(_scope
, 'failed, unknown type of stored credential', { identifier
, ctx
});
86 if (this.authnEnabled
.includes('DEBUG_ANY')) {
91 ctx
.authenticationId
= identifier
;
92 await
this.db
.authenticationSuccess(dbCtx
, identifier
);
102 * @param {String} identifier
103 * @param {String} credential
106 async
_isValidPAMIdentifier(identifier
, credential
) {
107 const _scope
= _fileScope('_isValidPAMIdentifier');
109 if (this.forbiddenPAMIdentifiers
.includes(identifier
)) {
113 await
this.authn
.pam
.pamAuthenticatePromise({ username: identifier
, password: credential
});
116 this.logger
.debug(_scope
, 'failed', { error: e
});
117 if (!(e
instanceof this.authn
.pam
.PamError
)) {
126 * Check for valid Basic auth, updates ctx with identifier if valid.
127 * @param {String} credentials
128 * @param {Object} ctx
131 async
isValidBasic(credentials
, ctx
) {
132 const _scope
= _fileScope('isValidBasic');
133 this.logger
.debug(_scope
, 'called', { ctx
});
135 const [identifier
, credential
] = common
.splitFirst(credentials
, ':', '');
137 return this.isValidIdentifierCredential(identifier
, credential
, ctx
);
142 * Determine which Authorization header is available, and if it is valid.
143 * @param {String} authorizationHeader
144 * @param {Object} ctx
146 async
isValidAuthorization(authorizationHeader
, ctx
) {
147 const _scope
= _fileScope('isValidAuthorization');
148 this.logger
.debug(_scope
, 'called', { authorizationHeader: common
.obscureAuthorizationHeader(authorizationHeader
), ctx
});
150 const [authMethod
, authString
] = common
.splitFirst(authorizationHeader
, ' ', '').map((x
) => x
.trim());
151 // eslint-disable-next-line sonarjs/no-small-switch
152 switch (authMethod
.toLowerCase()) {
154 const credentials
= Buffer
.from(authString
, 'base64').toString('utf-8');
155 return this.isValidBasic(credentials
, ctx
);
159 this.logger
.debug(_scope
, 'unknown authorization scheme', { ctx
});
166 * Send a response requesting basic auth.
167 * @param {http.ServerResponse} res
170 res
.setHeader(Enum
.Header
.WWWAuthenticate
, `Basic realm="${this.basicRealm}", charset="UTF-8"`);
171 throw new Errors
.ResponseError(Enum
.ErrorResponse
.Unauthorized
);
177 * @param {String} cookieHeader
179 static _cookieParse(cookieHeader
) {
181 (cookieHeader
|| '').split(/; */
).forEach((field
) => {
182 const [ name
, value
] = common
.splitFirst(field
, '=', null).map((x
) => x
&& decodeURIComponent(x
.trim()));
183 if (name
&& !(name
in cookie
)) {
184 if (value
&& value
.startsWith('"') && value
.endsWith('"')) {
185 cookie
[name
] = value
.slice(1, -1); // eslint-disable-line security/detect-object-injection
187 cookie
[name
] = value
; // eslint-disable-line security/detect-object-injection
196 * Attempt to parse a session cookie, and determine if it contains an
197 * authenticated user.
198 * Restores ctx.session from cookie data, sets ctx.authenticationId if identifier exists.
199 * @param {Object} ctx
200 * @param {String} cookieHeader
203 async
isValidCookieAuth(ctx
, cookieHeader
) {
204 const _scope
= _fileScope('isValidCookieAuth');
205 this.logger
.debug(_scope
, 'called', { ctx
, cookieHeader
});
207 const cookie
= Authenticator
._cookieParse(cookieHeader
);
208 const cookieValue
= cookie
[Enum
.SessionCookie
];
214 ctx
.session
= await
this.mysteryBox
.unpack(cookieValue
);
215 this.logger
.debug(_scope
, 'unpacked cookie', { ctx
});
217 const hasIdentifier
= !!ctx
.session
.authenticatedIdentifier
;
218 const hasProfile
= !!ctx
.session
.authenticatedProfile
&& this.authnEnabled
.includes('indieAuth');
219 const isValid
= hasIdentifier
|| hasProfile
;
221 ctx
.authenticationId
= ctx
.session
.authenticatedIdentifier
|| ctx
.session
.authenticatedProfile
;
226 this.logger
.debug(_scope
, 'could not unpack cookie', { error:e
, ctx
});
233 * Check for a valid session.
234 * @param {http.ClientRequest} req
235 * @param {http.ServerResponse} res
236 * @param {Object} ctx
237 * @param {String} loginPath
238 * @param {Boolean} required
239 * @param {Boolean} profilesAllowed
242 async
sessionCheck(req
, res
, ctx
, loginPath
, required
= true, profilesAllowed
= true) {
243 const _scope
= _fileScope('check');
244 this.logger
.debug(_scope
, 'called', { ctx
, loginPath
, required
, profilesAllowed
});
246 if (this.secureAuthOnly
247 && ctx
.clientProtocol
.toLowerCase() !== 'https') {
248 this.logger
.debug(_scope
, 'insecure transport, no authentication is valid', { ctx
});
250 throw new Errors
.ResponseError(Enum
.ErrorResponse
.Forbidden
, 'authentication is required, but connection is insecure; cannot continue');
256 const sessionCookie
= req
.getHeader(Enum
.Header
.Cookie
);
258 && await
this.isValidCookieAuth(ctx
, sessionCookie
)
259 && (ctx
.session
.authenticatedIdentifier
260 || (profilesAllowed
&& ctx
.session
.authenticatedProfile
))) {
261 this.logger
.debug(_scope
, 'valid session cookie', { ctx
});
266 // Clear any existing invalid session
267 const cookieParts
= [
268 `${Enum.SessionCookie}=""`,
271 `Path=${this.options.dingus.proxyPrefix}/`,
273 if (this.options
.authenticator
.secureAuthOnly
) {
274 cookieParts
.push('Secure');
276 res
.setHeader(Enum
.Header
.SetCookie
, cookieParts
.join('; '));
278 res
.statusCode
= 302;
279 res
.setHeader(Enum
.Header
.Location
, `${loginPath}?r=${encodeURIComponent(req.url)}`);
288 * Requires a valid session with a local identifier. Redirects to loginPath if not.
289 * @param {http.ClientRequest} req
290 * @param {http.ServerResponse} res
291 * @param {Object} ctx
292 * @param {String} loginPath
295 async
sessionRequiredLocal(req
, res
, ctx
, loginPath
) {
296 return this.sessionCheck(req
, res
, ctx
, loginPath
, true, false);
301 * Requires a valid session with either a local identifier or a profile. Redirects to loginPath if not.
302 * @param {http.ClientRequest} req
303 * @param {http.ServerResponse} res
304 * @param {Object} ctx
305 * @param {String} loginPath
308 async
sessionRequired(req
, res
, ctx
, loginPath
) {
309 return this.sessionCheck(req
, res
, ctx
, loginPath
);
314 * Check for a valid session with a local identifier, but do nothing if not.
315 * @param {http.ClientRequest} req
316 * @param {http.ServerResponse} res
317 * @param {Object} ctx
318 * @param {String} loginPath
321 async
sessionOptionalLocal(req
, res
, ctx
) {
322 return this.sessionCheck(req
, res
, ctx
, undefined, false, false);
327 * Check for a valid session with either a local identifier or a profile, but do nothing if not.
328 * @param {http.ClientRequest} req
329 * @param {http.ServerResponse} res
330 * @param {Object} ctx
331 * @param {String} loginPath
334 async
sessionOptional(req
, res
, ctx
) {
335 return this.sessionCheck(req
, res
, ctx
, undefined, false);
340 * Require auth for an API endpoint.
341 * Check for valid local identifier in Authorization header; optionally
342 * fall back to session cookie if no header provided.
343 * Prompts for Basic auth if not valid.
344 * @param {http.ClientRequest} req
345 * @param {http.ServerResponse} res
346 * @param {Object} ctx
347 * @param {Boolean} sessionAlsoValid
349 async
apiRequiredLocal(req
, res
, ctx
, sessionAlsoValid
= true) {
350 const _scope
= _fileScope('apiRequiredLocal');
351 this.logger
.debug(_scope
, 'called', { ctx
, sessionAlsoValid
});
353 // If a Authorization header was provided, never consider session as a fallback.
354 const authorizationHeader
= req
.getHeader(Enum
.Header
.Authorization
);
355 if (authorizationHeader
) {
356 if (await
this.isValidAuthorization(authorizationHeader
, ctx
)) {
357 this.logger
.debug(_scope
, 'valid authorization', { ctx
, sessionAlsoValid
});
362 && await
this.sessionCheck(req
, res
, ctx
, undefined, false, false)) {
363 this.logger
.debug(_scope
, 'valid session', { ctx
, sessionAlsoValid
});
368 this.logger
.debug(_scope
, 'invalid authorization', { ctx
, sessionAlsoValid
});
369 this.requestBasic(res
);
374 module
.exports
= Authenticator
;