2 /* eslint-disable sonarjs/no-duplicate-string */
3 /* eslint-disable jsdoc/require-jsdoc */
6 const assert
= require('node:assert');
7 const sinon
= require('sinon');
8 const Authenticator
= require('../../lib/authenticator');
9 const stubLogger
= require('../stub-logger');
10 const stubDb
= require('../stub-db');
11 const Enum
= require('../../lib/enum');
12 const Config
= require('../stub-config');
14 describe('Authenticator', function () {
15 let authenticator
, credential
, ctx
, identifier
, password
, options
;
16 function _authMechanismRequired(a
, m
) {
17 if (!a
.authn
[m
]) { // eslint-disable-line security/detect-object-injection
22 beforeEach(function () {
23 options
= Config('test');
24 authenticator
= new Authenticator(stubLogger
, stubDb
, options
);
25 identifier
= 'username';
26 credential
= '$argon2id$v=19$m=4096,t=3,p=1$1a6zRlX4BI4$sZGcQ72BTpDOlxUI/j3DmE1PMcu+Cs5liZ/D6kk79Ew';
28 password
= 'badPassword';
32 afterEach(function () {
36 it('covers no auth mechanisms', function () {
37 options
.authenticator
.authnEnabled
= [];
38 assert
.throws(() => new Authenticator(stubLogger
, stubDb
, options
), {
39 message: 'no authentication mechanisms available',
43 it('covers empty realm', function () {
44 options
.authenticator
.basicRealm
= undefined;
45 authenticator
= new Authenticator(stubLogger
, stubDb
, options
);
48 it('covers option defaults', function () {
49 delete options
.authenticator
.secureAuthOnly
;
50 delete options
.dingus
?.proxyPrefix
;
51 delete options
.authenticator
.forbiddenPAMIdentifiers
;
52 options
.authenticator
.authnEnabled
.push('flarpyauth');
53 authenticator
= new Authenticator(stubLogger
, stubDb
, options
);
56 it('covers invalid sameSite', function () {
57 options
.authenticator
.sessionCookieSameSite
= 'Sometimes';
58 assert
.throws(() => new Authenticator(stubLogger
, stubDb
, options
), RangeError
);
61 describe('createIdentifier', function () {
63 beforeEach(function () {
65 credential
= 'badpassword';
67 it('covers success', async
function () {
68 const otpKey
= '1234567890123456789012';
69 await authenticator
.createIdentifier(dbCtx
, identifier
, credential
, otpKey
);
70 assert(authenticator
.db
.authenticationUpsert
.called
);
72 it('covers failure', async
function () {
73 const expected
= new Error('blah');
74 await authenticator
.db
.authenticationUpsert
.rejects(expected
);
75 // assert.rejects was not happy to handle this for some reason
77 await authenticator
.createIdentifier(dbCtx
, identifier
, credential
);
78 assert
.fail('no expecte exception');
80 assert
.deepStrictEqual(e
, expected
);
81 assert(authenticator
.db
.authenticationUpsert
.called
);
82 assert(authenticator
.logger
.error
.called
);
85 }); // createIdentifier
87 describe('updateCredential', function () {
88 let dbCtx
, newCredential
;
89 beforeEach(function () {
91 newCredential
= 'newpassword';
93 it('covers success', async
function () {
94 await authenticator
.updateCredential(dbCtx
, identifier
, newCredential
);
95 assert(authenticator
.db
.authenticationUpdateCredential
.called
);
96 assert(authenticator
.logger
.info
.called
);
98 it('covers failure', async
function () {
99 const expected
= new Error('foo');
100 authenticator
.db
.authenticationUpdateCredential
.rejects(expected
);
102 await authenticator
.updateCredential(dbCtx
, identifier
, newCredential
);
103 assert
.fail('no expected exception');
105 assert
.deepStrictEqual(e
, expected
);
106 assert(authenticator
.logger
.error
.called
);
108 // assert.rejects was not happy to handle this for some reason
110 }); // updateCredential
112 describe('_secureCredential', function () {
113 beforeEach(function () {
114 credential
= 'badpassword';
116 it('covers plain', async
function () {
117 const result
= await authenticator
._secureCredential(credential
, 'plain');
118 assert
.strictEqual(result
, '$plain$' + credential
);
120 it('covers default (argon2)', async
function () {
121 const result
= await authenticator
._secureCredential(credential
);
122 assert(result
.startsWith('$argon2'));
124 it('covers invalid authn', async
function () {
125 const authn
= 'bogus';
126 assert
.rejects(async () => await authenticator
._secureCredential(credential
, authn
), RangeError
);
128 }); // _secureCredential
130 describe('_validateAuthDataCredential', function () {
132 beforeEach(function () {
133 credential
= 'badpassword';
136 it('fails if not provided a credential', async
function () {
137 const result
= await authenticator
._validateAuthDataCredential(authData
, credential
);
138 assert
.strictEqual(result
, false);
140 it('covers plain', async
function () {
141 authData
.credential
= '$plain$badpassword';
142 const result
= await authenticator
._validateAuthDataCredential(authData
, credential
);
143 assert
.strictEqual(result
, true);
145 }); // _validateAuthDataCredential
147 describe('isValidBasic', function () {
148 const b64
= (x
) => Buffer
.from(x
).toString('base64');
149 it('succeeds', async
function () {
150 _authMechanismRequired(authenticator
, 'argon2');
151 authenticator
.db
.authenticationGet
.resolves({
155 const authString
= b64(`${identifier}:${password}`);
156 const result
= await authenticator
.isValidBasic(authString
, ctx
);
157 assert
.strictEqual(result
, true);
158 assert
.strictEqual(ctx
.authenticationId
, identifier
);
160 it('fails', async
function () {
161 _authMechanismRequired(authenticator
, 'argon2');
162 authenticator
.db
.authenticationGet
.resolves({
166 const authString
= b64(`${identifier}:wrongPassword}`);
167 const result
= await authenticator
.isValidBasic(authString
, ctx
);
168 assert
.strictEqual(result
, false);
169 assert
.strictEqual(ctx
.authenticationId
, undefined);
171 it('covers no entry', async
function() {
172 authenticator
.db
.authenticationGet
.resolves();
173 const authString
= b64(`${identifier}:wrongPassword}`);
174 const result
= await authenticator
.isValidBasic(authString
, ctx
);
175 assert
.strictEqual(result
, false);
176 assert
.strictEqual(ctx
.authenticationId
, undefined);
178 it('covers unknown password hash', async
function () {
179 authenticator
.db
.authenticationGet
.resolves({
181 credential: '$other$kind_of_credential',
183 const authString
= b64(`${identifier}:wrongPassword}`);
184 const result
= await authenticator
.isValidBasic(authString
, ctx
);
185 assert
.strictEqual(result
, false);
186 assert
.strictEqual(ctx
.authenticationId
, undefined);
190 describe('isValidIdentifierCredential', function () {
191 it('succeeds', async
function () {
192 _authMechanismRequired(authenticator
, 'argon2');
193 authenticator
.db
.authenticationGet
.resolves({
197 const result
= await authenticator
.isValidIdentifierCredential(identifier
, password
, ctx
);
198 assert
.strictEqual(result
, true);
199 assert
.strictEqual(ctx
.authenticationId
, identifier
);
201 it('succeeds with OTP', async
function () {
202 const otpKey
= Buffer
.from('1234567890');
203 _authMechanismRequired(authenticator
, 'argon2');
204 authenticator
.db
.authenticationGet
.resolves({
209 const result
= await authenticator
.isValidIdentifierCredential(identifier
, password
, ctx
);
210 assert
.strictEqual(result
, true);
211 assert
.strictEqual(ctx
.authenticationId
, identifier
);
212 assert
.deepStrictEqual(ctx
.otpKey
, otpKey
);
214 it('fails', async
function () {
215 _authMechanismRequired(authenticator
, 'argon2');
216 authenticator
.db
.authenticationGet
.resolves({
220 const result
= await authenticator
.isValidIdentifierCredential(identifier
, 'wrongPassword', ctx
);
221 assert
.strictEqual(result
, false);
222 assert
.strictEqual(ctx
.authenticationId
, undefined);
224 it('covers no entry', async
function() {
225 authenticator
.db
.authenticationGet
.resolves();
226 const result
= await authenticator
.isValidIdentifierCredential(identifier
, 'wrongPassword', ctx
);
227 assert
.strictEqual(result
, false);
228 assert
.strictEqual(ctx
.authenticationId
, undefined);
230 it('covers non-string credential', async
function () {
231 credential
= '$argon2id$v=19$m=4096,t=3,p=1$SbAlHo5x2HM0PvMAWYHqww$gNn/o+B6+IWsnrVupPkTAiiK9tvwV+eM/HoXG41bnzM';
232 const result
= await authenticator
.isValidIdentifierCredential(identifier
, undefined, ctx
);
233 assert
.strictEqual(result
, false);
234 assert
.strictEqual(ctx
.authenticationId
, undefined);
236 it('covers unknown password hash', async
function () {
237 authenticator
.db
.authenticationGet
.resolves({
239 credential: '$other$kind_of_credential',
241 const result
= await authenticator
.isValidIdentifierCredential(identifier
, 'wrongPassword', ctx
);
242 assert
.strictEqual(result
, false);
243 assert
.strictEqual(ctx
.authenticationId
, undefined);
245 it('covers PAM', async
function () {
246 _authMechanismRequired(authenticator
, 'pam');
247 sinon
.stub(authenticator
, '_isValidPAMIdentifier').resolves(true);
248 authenticator
.db
.authenticationGet
.resolves({
252 const result
= await authenticator
.isValidIdentifierCredential(identifier
, password
, ctx
);
253 assert
.strictEqual(result
, true);
254 assert
.strictEqual(ctx
.authenticationId
, identifier
);
256 }); // isValidIdentifierCredential
258 describe('_isValidPAMIdentifier', function () {
260 beforeEach(function () {
261 _authMechanismRequired(authenticator
, 'pam');
262 sinon
.stub(authenticator
.authn
.pam
, 'pamAuthenticatePromise');
267 it('covers success', async
function () {
268 authenticator
.authn
.pam
.pamAuthenticatePromise
.resolves(true);
269 const result
= await authenticator
._isValidPAMIdentifier(authData
, credential
);
270 assert
.strictEqual(result
, true);
272 it('covers failure', async
function () {
273 _authMechanismRequired(authenticator
, 'pam');
274 authenticator
.authn
.pam
.pamAuthenticatePromise
.rejects(new authenticator
.authn
.pam
.PamError());
275 const result
= await authenticator
._isValidPAMIdentifier(authData
, credential
);
276 assert
.strictEqual(result
, false);
278 it('covers error', async
function () {
279 _authMechanismRequired(authenticator
, 'pam');
280 const expected
= new Error('blah');
281 authenticator
.authn
.pam
.pamAuthenticatePromise
.rejects(expected
);
282 assert
.rejects(() => authenticator
._isValidPAMIdentifier(authData
, credential
), expected
);
284 it('covers forbidden', async
function () {
285 authData
.identifier
= 'root';
286 const result
= await authenticator
._isValidPAMIdentifier(authData
, credential
);
287 assert
.strictEqual(result
, false);
289 }); // _isValidPAMIdentifier
291 describe('isValidAuthorization', function () {
292 it('handles basic', async
function () {
293 const expected
= true;
294 const authorizationHeader
= 'basic Zm9vOmJhcg==';
295 sinon
.stub(authenticator
, 'isValidBasic').resolves(expected
);
296 const result
= await authenticator
.isValidAuthorization(authorizationHeader
, ctx
);
297 assert
.strictEqual(result
, expected
);
299 it('handles other', async
function () {
300 const expected
= false;
301 const authorizationHeader
= 'bearer Zm9vOmJhcg==';
302 const result
= await authenticator
.isValidAuthorization(authorizationHeader
, ctx
);
303 assert
.strictEqual(result
, expected
);
305 }); // isValidAuthorization
307 describe('requestBasic', function () {
308 it('covers', function () {
312 assert
.throws(() => authenticator
.requestBasic(res
), {
313 name: 'ResponseError',
314 statusCode: Enum
.ErrorResponse
.Unauthorized
.statusCode
,
319 describe('isValidCookieAuth', function () {
320 beforeEach(function () {
321 sinon
.stub(authenticator
.mysteryBox
, 'unpack');
323 squeepSession: 'dummy',
327 it('covers identifier success', async
function () {
328 authenticator
.mysteryBox
.unpack
.resolves({
329 authenticatedIdentifier: 'identifier',
331 const result
= await authenticator
.isValidCookieAuth(ctx
);
332 assert
.strictEqual(result
, true);
334 it('covers profile success', async
function () {
335 authenticator
.mysteryBox
.unpack
.resolves({
336 authenticatedProfile: 'profile',
338 const result
= await authenticator
.isValidCookieAuth(ctx
);
339 assert
.strictEqual(result
, true);
341 it('covers missing cookie', async
function () {
342 delete ctx
.cookie
.squeepSession
;
343 const result
= await authenticator
.isValidCookieAuth(ctx
);
344 assert
.strictEqual(result
, false);
346 it('covers bad cookie', async
function () {
347 authenticator
.mysteryBox
.unpack
.rejects();
348 const result
= await authenticator
.isValidCookieAuth(ctx
);
349 assert
.strictEqual(result
, false);
351 it('covers broken session', async
function () {
352 authenticator
.mysteryBox
.unpack
.resolves({
355 const result
= await authenticator
.isValidCookieAuth(ctx
);
356 assert
.strictEqual(result
, false);
358 }); // isValidCookieAuth
360 describe('checkOTP', function () {
362 this.beforeEach(function () {
363 sinon
.stub(authenticator
.TOTP
.prototype, 'validate').returns(true);
365 key: '12345678901234567890123456789012',
371 it('covers valid OTP entry', function () {
372 const result
= authenticator
.checkOTP(state
, otp
);
373 assert
.strictEqual(result
, Enum
.OTPResult
.Valid
);
375 it('covers invalid OTP entry', function () {
376 authenticator
.TOTP
.prototype.validate
.returns(false);
377 const result
= authenticator
.checkOTP(state
, otp
);
378 assert
.strictEqual(result
, Enum
.OTPResult
.InvalidSoftFail
);
380 it('covers invalid OTP entry, too many failures', function () {
382 authenticator
.TOTP
.prototype.validate
.returns(false);
383 const result
= authenticator
.checkOTP(state
, otp
);
384 assert
.strictEqual(result
, Enum
.OTPResult
.InvalidHardFail
);
386 it('covers invalid OTP entry', function () {
388 authenticator
.TOTP
.prototype.validate
.returns(false);
389 const result
= authenticator
.checkOTP(state
, otp
);
390 assert
.strictEqual(result
, Enum
.OTPResult
.InvalidHardFail
);
394 describe('updateOTPKey', function () {
396 beforeEach(function () {
398 otpKey
= 'CDBGB3U3B2ILECQORMINGGSZN7LXY565';
400 it('covers success', async
function () {
401 await authenticator
.updateOTPKey(dbCtx
, identifier
, otpKey
);
402 assert(authenticator
.db
.authenticationUpdateOTPKey
.called
);
404 it('covers failure', async
function () {
405 authenticator
.db
.authenticationUpdateOTPKey
.rejects();
406 assert
.rejects(authenticator
.updateOTPKey(dbCtx
, identifier
, otpKey
));
410 describe('sessionCheck', function () {
411 let req
, res
, loginPath
, required
, profilesAllowed
;
412 beforeEach(function () {
413 ctx
.clientProtocol
= 'https';
415 squeepSession: 'squeep_session_blob',
418 getHeader: sinon
.stub(),
422 setHeader: sinon
.stub(),
423 appendHeader: sinon
.stub(),
425 loginPath
= '/admin/login';
427 profilesAllowed
= true;
429 it('covers valid cookie session', async
function () {
430 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(true);
432 authenticatedIdentifier: 'user',
434 const result
= await authenticator
.sessionCheck(req
, res
, ctx
, loginPath
, required
, profilesAllowed
);
435 assert
.strictEqual(result
, true);
437 it('covers valid insecure cookie session', async
function () {
438 authenticator
.secureAuthOnly
= false;
439 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(true);
441 authenticatedIdentifier: 'user',
443 const result
= await authenticator
.sessionCheck(req
, res
, ctx
, loginPath
, required
, profilesAllowed
);
444 assert
.strictEqual(result
, true);
446 it('rejects insecure connection', async
function () {
447 ctx
.clientProtocol
= 'http';
448 assert
.rejects(() => authenticator
.sessionCheck(req
, res
, ctx
, loginPath
, required
, profilesAllowed
), {
449 name: 'ResponseError',
450 sttausCode: Enum
.ErrorResponse
.Forbidden
.statusCode
,
453 it('ignores insecure connection if auth not required', async
function () {
454 ctx
.clientProtocol
= 'http';
456 const result
= await authenticator
.sessionCheck(req
, res
, ctx
, loginPath
, required
, profilesAllowed
);
457 assert
.strictEqual(result
, false);
459 it('redirects without any auth', async
function () {
460 await authenticator
.sessionCheck(req
, res
, ctx
, loginPath
, required
, profilesAllowed
);
461 assert(res
.end
.called
);
462 assert(res
.setHeader
.called
);
464 describe('convenience wrappers', function () {
465 describe('sessionRequiredLocal', function () {
466 it('accepts identifier', async
function () {
467 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(true);
469 authenticatedIdentifier: 'user',
471 const result
= await authenticator
.sessionRequiredLocal(req
, res
, ctx
, loginPath
);
472 assert
.strictEqual(result
, true);
474 it('redirects with profile', async
function () {
475 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(true);
477 authenticatedProfile: 'user',
479 const result
= await authenticator
.sessionRequiredLocal(req
, res
, ctx
, loginPath
);
480 assert
.strictEqual(result
, false);
481 assert(res
.end
.called
);
482 assert(res
.setHeader
.called
);
484 }); // sessionRequiredLocal
485 describe('sessionRequired', function () {
486 it('accepts identifier', async
function () {
487 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(true);
489 authenticatedIdentifier: 'user',
491 const result
= await authenticator
.sessionRequired(req
, res
, ctx
, loginPath
);
492 assert
.strictEqual(result
, true);
494 it('accepts profile', async
function () {
495 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(true);
497 authenticatedProfile: 'user',
499 const result
= await authenticator
.sessionRequired(req
, res
, ctx
, loginPath
);
500 assert
.strictEqual(result
, true);
502 it('rejects invalid', async
function () {
503 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(false);
504 const result
= await authenticator
.sessionRequired(req
, res
, ctx
, loginPath
);
505 assert
.strictEqual(result
, false);
506 assert(res
.end
.called
);
507 assert(res
.setHeader
.called
);
509 it('covers insecure allowed', async
function () {
510 authenticator
.options
.authenticator
.secureAuthOnly
= false;
511 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(false);
512 const result
= await authenticator
.sessionRequired(req
, res
, ctx
, loginPath
);
513 assert
.strictEqual(result
, false);
514 assert(res
.end
.called
);
515 assert(res
.setHeader
.called
);
517 }); // sessionRequired
518 describe('sessionOptionalLocal', function () {
519 it('rejects profile', async
function () {
520 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(true);
522 authenticatedProfile: 'user',
524 const result
= await authenticator
.sessionOptionalLocal(req
, res
, ctx
, loginPath
);
525 assert
.strictEqual(result
, false);
526 assert(!res
.end
.called
);
527 assert(!res
.setHeader
.called
);
529 }); // sessionOptionalLocal
530 describe('sessionOptional', function () {
531 it('rejects invalid', async
function () {
532 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(false);
533 const result
= await authenticator
.sessionOptional(req
, res
, ctx
, loginPath
);
534 assert
.strictEqual(result
, false);
535 assert(!res
.end
.called
);
536 assert(!res
.setHeader
.called
);
538 }); // sessionOptional
539 }); // convenience wrappers
542 describe('apiRequiredLocal', function () {
544 beforeEach(function () {
547 getHeader: sinon
.stub(),
551 setHeader: sinon
.stub(),
554 it('covers valid basic auth', async
function () {
555 req
.getHeader
.returns('Basic Zm9vOmJhcg==');
556 sinon
.stub(authenticator
, 'sessionCheck').resolves(false);
557 sinon
.stub(authenticator
, 'isValidAuthorization').resolves(true);
558 const result
= await authenticator
.apiRequiredLocal(req
, res
, ctx
);
559 assert
.strictEqual(result
, true);
560 assert(authenticator
.isValidAuthorization
.called
);
561 assert(!authenticator
.sessionCheck
.called
);
563 it('covers invalid basic auth', async
function () {
564 req
.getHeader
.returns('Basic Zm9vOmJhcg==');
565 sinon
.stub(authenticator
, 'sessionCheck').resolves(false);
566 sinon
.stub(authenticator
, 'isValidAuthorization').resolves(false);
567 assert
.rejects(() => authenticator
.apiRequiredLocal(req
, res
, ctx
), {
568 name: 'ResponseError',
571 assert(!authenticator
.sessionCheck
.called
);
572 assert(authenticator
.isValidAuthorization
.called
);
574 it('covers missing basic auth, valid session', async
function () {
575 req
.getHeader
.returns();
576 sinon
.stub(authenticator
, 'sessionCheck').resolves(true);
577 sinon
.stub(authenticator
, 'isValidAuthorization').resolves(false);
578 const result
= await authenticator
.apiRequiredLocal(req
, res
, ctx
);
579 assert
.strictEqual(result
, true);
580 assert(!authenticator
.isValidAuthorization
.called
);
581 assert(authenticator
.sessionCheck
.called
);
583 it('covers missing basic auth, ignores session', async
function () {
584 req
.getHeader
.returns();
585 sinon
.stub(authenticator
, 'isValidAuthorization').resolves(true);
586 assert
.rejects(() => authenticator
.apiRequiredLocal(req
, res
, ctx
, false), {
587 name: 'ResponseError',
590 assert(!authenticator
.sessionCheck
.called
);
591 assert(!authenticator
.isValidAuthorization
.called
);
592 assert(res
.setHeader
.called
);
594 it('covers errors', async
function () {
595 sinon
.stub(authenticator
, 'isValidAuthorization').rejects();
596 req
.getHeader
.returns('Basic Zm9vOmJhcg==');
597 assert
.rejects(() => authenticator
.apiRequiredLocal(req
, res
, ctx
));
599 }); // apiRequiredLocal