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 describe('createIdentifier', function () {
58 beforeEach(function () {
60 credential
= 'badpassword';
62 it('covers success', async
function () {
63 const otpKey
= '1234567890123456789012';
64 await authenticator
.createIdentifier(dbCtx
, identifier
, credential
, otpKey
);
65 assert(authenticator
.db
.authenticationUpsert
.called
);
67 it('covers failure', async
function () {
68 const expected
= new Error('blah');
69 await authenticator
.db
.authenticationUpsert
.rejects(expected
);
70 // assert.rejects was not happy to handle this for some reason
72 await authenticator
.createIdentifier(dbCtx
, identifier
, credential
);
73 assert
.fail('no expecte exception');
75 assert
.deepStrictEqual(e
, expected
);
76 assert(authenticator
.db
.authenticationUpsert
.called
);
77 assert(authenticator
.logger
.error
.called
);
80 }); // createIdentifier
82 describe('updateCredential', function () {
83 let dbCtx
, newCredential
;
84 beforeEach(function () {
86 newCredential
= 'newpassword';
88 it('covers success', async
function () {
89 await authenticator
.updateCredential(dbCtx
, identifier
, newCredential
);
90 assert(authenticator
.db
.authenticationUpdateCredential
.called
);
91 assert(authenticator
.logger
.info
.called
);
93 it('covers failure', async
function () {
94 const expected
= new Error('foo');
95 authenticator
.db
.authenticationUpdateCredential
.rejects(expected
);
97 await authenticator
.updateCredential(dbCtx
, identifier
, newCredential
);
98 assert
.fail('no expected exception');
100 assert
.deepStrictEqual(e
, expected
);
101 assert(authenticator
.logger
.error
.called
);
103 // assert.rejects was not happy to handle this for some reason
105 }); // updateCredential
107 describe('_secureCredential', function () {
108 beforeEach(function () {
109 credential
= 'badpassword';
111 it('covers plain', async
function () {
112 const result
= await authenticator
._secureCredential(credential
, 'plain');
113 assert
.strictEqual(result
, '$plain$' + credential
);
115 it('covers default (argon2)', async
function () {
116 const result
= await authenticator
._secureCredential(credential
);
117 assert(result
.startsWith('$argon2'));
119 it('covers invalid authn', async
function () {
120 const authn
= 'bogus';
121 assert
.rejects(async () => await authenticator
._secureCredential(credential
, authn
), RangeError
);
123 }); // _secureCredential
125 describe('_validateAuthDataCredential', function () {
127 beforeEach(function () {
128 credential
= 'badpassword';
131 it('fails if not provided a credential', async
function () {
132 const result
= await authenticator
._validateAuthDataCredential(authData
, credential
);
133 assert
.strictEqual(result
, false);
135 it('covers plain', async
function () {
136 authData
.credential
= '$plain$badpassword';
137 const result
= await authenticator
._validateAuthDataCredential(authData
, credential
);
138 assert
.strictEqual(result
, true);
140 }); // _validateAuthDataCredential
142 describe('isValidBasic', function () {
143 const b64
= (x
) => Buffer
.from(x
).toString('base64');
144 it('succeeds', async
function () {
145 _authMechanismRequired(authenticator
, 'argon2');
146 authenticator
.db
.authenticationGet
.resolves({
150 const authString
= b64(`${identifier}:${password}`);
151 const result
= await authenticator
.isValidBasic(authString
, ctx
);
152 assert
.strictEqual(result
, true);
153 assert
.strictEqual(ctx
.authenticationId
, identifier
);
155 it('fails', async
function () {
156 _authMechanismRequired(authenticator
, 'argon2');
157 authenticator
.db
.authenticationGet
.resolves({
161 const authString
= b64(`${identifier}:wrongPassword}`);
162 const result
= await authenticator
.isValidBasic(authString
, ctx
);
163 assert
.strictEqual(result
, false);
164 assert
.strictEqual(ctx
.authenticationId
, undefined);
166 it('covers no entry', async
function() {
167 authenticator
.db
.authenticationGet
.resolves();
168 const authString
= b64(`${identifier}:wrongPassword}`);
169 const result
= await authenticator
.isValidBasic(authString
, ctx
);
170 assert
.strictEqual(result
, false);
171 assert
.strictEqual(ctx
.authenticationId
, undefined);
173 it('covers unknown password hash', async
function () {
174 authenticator
.db
.authenticationGet
.resolves({
176 credential: '$other$kind_of_credential',
178 const authString
= b64(`${identifier}:wrongPassword}`);
179 const result
= await authenticator
.isValidBasic(authString
, ctx
);
180 assert
.strictEqual(result
, false);
181 assert
.strictEqual(ctx
.authenticationId
, undefined);
185 describe('isValidIdentifierCredential', function () {
186 it('succeeds', async
function () {
187 _authMechanismRequired(authenticator
, 'argon2');
188 authenticator
.db
.authenticationGet
.resolves({
192 const result
= await authenticator
.isValidIdentifierCredential(identifier
, password
, ctx
);
193 assert
.strictEqual(result
, true);
194 assert
.strictEqual(ctx
.authenticationId
, identifier
);
196 it('succeeds with OTP', async
function () {
197 const otpKey
= Buffer
.from('1234567890');
198 _authMechanismRequired(authenticator
, 'argon2');
199 authenticator
.db
.authenticationGet
.resolves({
204 const result
= await authenticator
.isValidIdentifierCredential(identifier
, password
, ctx
);
205 assert
.strictEqual(result
, true);
206 assert
.strictEqual(ctx
.authenticationId
, identifier
);
207 assert
.deepStrictEqual(ctx
.otpKey
, otpKey
);
209 it('fails', async
function () {
210 _authMechanismRequired(authenticator
, 'argon2');
211 authenticator
.db
.authenticationGet
.resolves({
215 const result
= await authenticator
.isValidIdentifierCredential(identifier
, 'wrongPassword', ctx
);
216 assert
.strictEqual(result
, false);
217 assert
.strictEqual(ctx
.authenticationId
, undefined);
219 it('covers no entry', async
function() {
220 authenticator
.db
.authenticationGet
.resolves();
221 const result
= await authenticator
.isValidIdentifierCredential(identifier
, 'wrongPassword', ctx
);
222 assert
.strictEqual(result
, false);
223 assert
.strictEqual(ctx
.authenticationId
, undefined);
225 it('covers non-string credential', async
function () {
226 credential
= '$argon2id$v=19$m=4096,t=3,p=1$SbAlHo5x2HM0PvMAWYHqww$gNn/o+B6+IWsnrVupPkTAiiK9tvwV+eM/HoXG41bnzM';
227 const result
= await authenticator
.isValidIdentifierCredential(identifier
, undefined, ctx
);
228 assert
.strictEqual(result
, false);
229 assert
.strictEqual(ctx
.authenticationId
, undefined);
231 it('covers unknown password hash', async
function () {
232 authenticator
.db
.authenticationGet
.resolves({
234 credential: '$other$kind_of_credential',
236 const result
= await authenticator
.isValidIdentifierCredential(identifier
, 'wrongPassword', ctx
);
237 assert
.strictEqual(result
, false);
238 assert
.strictEqual(ctx
.authenticationId
, undefined);
240 it('covers PAM', async
function () {
241 _authMechanismRequired(authenticator
, 'pam');
242 sinon
.stub(authenticator
, '_isValidPAMIdentifier').resolves(true);
243 authenticator
.db
.authenticationGet
.resolves({
247 const result
= await authenticator
.isValidIdentifierCredential(identifier
, password
, ctx
);
248 assert
.strictEqual(result
, true);
249 assert
.strictEqual(ctx
.authenticationId
, identifier
);
251 }); // isValidIdentifierCredential
253 describe('_isValidPAMIdentifier', function () {
255 beforeEach(function () {
256 _authMechanismRequired(authenticator
, 'pam');
257 sinon
.stub(authenticator
.authn
.pam
, 'pamAuthenticatePromise');
262 it('covers success', async
function () {
263 authenticator
.authn
.pam
.pamAuthenticatePromise
.resolves(true);
264 const result
= await authenticator
._isValidPAMIdentifier(authData
, credential
);
265 assert
.strictEqual(result
, true);
267 it('covers failure', async
function () {
268 _authMechanismRequired(authenticator
, 'pam');
269 authenticator
.authn
.pam
.pamAuthenticatePromise
.rejects(new authenticator
.authn
.pam
.PamError());
270 const result
= await authenticator
._isValidPAMIdentifier(authData
, credential
);
271 assert
.strictEqual(result
, false);
273 it('covers error', async
function () {
274 _authMechanismRequired(authenticator
, 'pam');
275 const expected
= new Error('blah');
276 authenticator
.authn
.pam
.pamAuthenticatePromise
.rejects(expected
);
277 assert
.rejects(() => authenticator
._isValidPAMIdentifier(authData
, credential
), expected
);
279 it('covers forbidden', async
function () {
280 authData
.identifier
= 'root';
281 const result
= await authenticator
._isValidPAMIdentifier(authData
, credential
);
282 assert
.strictEqual(result
, false);
284 }); // _isValidPAMIdentifier
286 describe('isValidAuthorization', function () {
287 it('handles basic', async
function () {
288 const expected
= true;
289 const authorizationHeader
= 'basic Zm9vOmJhcg==';
290 sinon
.stub(authenticator
, 'isValidBasic').resolves(expected
);
291 const result
= await authenticator
.isValidAuthorization(authorizationHeader
, ctx
);
292 assert
.strictEqual(result
, expected
);
294 it('handles other', async
function () {
295 const expected
= false;
296 const authorizationHeader
= 'bearer Zm9vOmJhcg==';
297 const result
= await authenticator
.isValidAuthorization(authorizationHeader
, ctx
);
298 assert
.strictEqual(result
, expected
);
300 }); // isValidAuthorization
302 describe('requestBasic', function () {
303 it('covers', function () {
307 assert
.throws(() => authenticator
.requestBasic(res
), {
308 name: 'ResponseError',
309 statusCode: Enum
.ErrorResponse
.Unauthorized
.statusCode
,
314 describe('isValidCookieAuth', function () {
315 beforeEach(function () {
316 sinon
.stub(authenticator
.mysteryBox
, 'unpack');
318 squeepSession: 'dummy',
322 it('covers identifier success', async
function () {
323 authenticator
.mysteryBox
.unpack
.resolves({
324 authenticatedIdentifier: 'identifier',
326 const result
= await authenticator
.isValidCookieAuth(ctx
);
327 assert
.strictEqual(result
, true);
329 it('covers profile success', async
function () {
330 authenticator
.mysteryBox
.unpack
.resolves({
331 authenticatedProfile: 'profile',
333 const result
= await authenticator
.isValidCookieAuth(ctx
);
334 assert
.strictEqual(result
, true);
336 it('covers missing cookie', async
function () {
337 delete ctx
.cookie
.squeepSession
;
338 const result
= await authenticator
.isValidCookieAuth(ctx
);
339 assert
.strictEqual(result
, false);
341 it('covers bad cookie', async
function () {
342 authenticator
.mysteryBox
.unpack
.rejects();
343 const result
= await authenticator
.isValidCookieAuth(ctx
);
344 assert
.strictEqual(result
, false);
346 it('covers broken session', async
function () {
347 authenticator
.mysteryBox
.unpack
.resolves({
350 const result
= await authenticator
.isValidCookieAuth(ctx
);
351 assert
.strictEqual(result
, false);
353 }); // isValidCookieAuth
355 describe('checkOTP', function () {
357 this.beforeEach(function () {
358 sinon
.stub(authenticator
.TOTP
.prototype, 'validate').returns(true);
360 key: '12345678901234567890123456789012',
366 it('covers valid OTP entry', function () {
367 const result
= authenticator
.checkOTP(state
, otp
);
368 assert
.strictEqual(result
, Enum
.OTPResult
.Valid
);
370 it('covers invalid OTP entry', function () {
371 authenticator
.TOTP
.prototype.validate
.returns(false);
372 const result
= authenticator
.checkOTP(state
, otp
);
373 assert
.strictEqual(result
, Enum
.OTPResult
.InvalidSoftFail
);
375 it('covers invalid OTP entry, too many failures', function () {
377 authenticator
.TOTP
.prototype.validate
.returns(false);
378 const result
= authenticator
.checkOTP(state
, otp
);
379 assert
.strictEqual(result
, Enum
.OTPResult
.InvalidHardFail
);
381 it('covers invalid OTP entry', function () {
383 authenticator
.TOTP
.prototype.validate
.returns(false);
384 const result
= authenticator
.checkOTP(state
, otp
);
385 assert
.strictEqual(result
, Enum
.OTPResult
.InvalidHardFail
);
389 describe('updateOTPKey', function () {
391 beforeEach(function () {
393 otpKey
= 'CDBGB3U3B2ILECQORMINGGSZN7LXY565';
395 it('covers success', async
function () {
396 await authenticator
.updateOTPKey(dbCtx
, identifier
, otpKey
);
397 assert(authenticator
.db
.authenticationUpdateOTPKey
.called
);
399 it('covers failure', async
function () {
400 authenticator
.db
.authenticationUpdateOTPKey
.rejects();
401 assert
.rejects(authenticator
.updateOTPKey(dbCtx
, identifier
, otpKey
));
405 describe('sessionCheck', function () {
406 let req
, res
, loginPath
, required
, profilesAllowed
;
407 beforeEach(function () {
408 ctx
.clientProtocol
= 'https';
410 squeepSession: 'squeep_session_blob',
413 getHeader: sinon
.stub(),
417 setHeader: sinon
.stub(),
418 appendHeader: sinon
.stub(),
420 loginPath
= '/admin/login';
422 profilesAllowed
= true;
424 it('covers valid cookie session', async
function () {
425 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(true);
427 authenticatedIdentifier: 'user',
429 const result
= await authenticator
.sessionCheck(req
, res
, ctx
, loginPath
, required
, profilesAllowed
);
430 assert
.strictEqual(result
, true);
432 it('covers valid insecure cookie session', async
function () {
433 authenticator
.secureAuthOnly
= false;
434 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(true);
436 authenticatedIdentifier: 'user',
438 const result
= await authenticator
.sessionCheck(req
, res
, ctx
, loginPath
, required
, profilesAllowed
);
439 assert
.strictEqual(result
, true);
441 it('rejects insecure connection', async
function () {
442 ctx
.clientProtocol
= 'http';
443 assert
.rejects(() => authenticator
.sessionCheck(req
, res
, ctx
, loginPath
, required
, profilesAllowed
), {
444 name: 'ResponseError',
445 sttausCode: Enum
.ErrorResponse
.Forbidden
.statusCode
,
448 it('ignores insecure connection if auth not required', async
function () {
449 ctx
.clientProtocol
= 'http';
451 const result
= await authenticator
.sessionCheck(req
, res
, ctx
, loginPath
, required
, profilesAllowed
);
452 assert
.strictEqual(result
, false);
454 it('redirects without any auth', async
function () {
455 await authenticator
.sessionCheck(req
, res
, ctx
, loginPath
, required
, profilesAllowed
);
456 assert(res
.end
.called
);
457 assert(res
.setHeader
.called
);
459 describe('convenience wrappers', function () {
460 describe('sessionRequiredLocal', function () {
461 it('accepts identifier', async
function () {
462 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(true);
464 authenticatedIdentifier: 'user',
466 const result
= await authenticator
.sessionRequiredLocal(req
, res
, ctx
, loginPath
);
467 assert
.strictEqual(result
, true);
469 it('redirects with profile', async
function () {
470 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(true);
472 authenticatedProfile: 'user',
474 const result
= await authenticator
.sessionRequiredLocal(req
, res
, ctx
, loginPath
);
475 assert
.strictEqual(result
, false);
476 assert(res
.end
.called
);
477 assert(res
.setHeader
.called
);
479 }); // sessionRequiredLocal
480 describe('sessionRequired', function () {
481 it('accepts identifier', async
function () {
482 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(true);
484 authenticatedIdentifier: 'user',
486 const result
= await authenticator
.sessionRequired(req
, res
, ctx
, loginPath
);
487 assert
.strictEqual(result
, true);
489 it('accepts profile', async
function () {
490 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(true);
492 authenticatedProfile: 'user',
494 const result
= await authenticator
.sessionRequired(req
, res
, ctx
, loginPath
);
495 assert
.strictEqual(result
, true);
497 it('rejects invalid', async
function () {
498 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(false);
499 const result
= await authenticator
.sessionRequired(req
, res
, ctx
, loginPath
);
500 assert
.strictEqual(result
, false);
501 assert(res
.end
.called
);
502 assert(res
.setHeader
.called
);
504 it('covers insecure allowed', async
function () {
505 authenticator
.options
.authenticator
.secureAuthOnly
= false;
506 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(false);
507 const result
= await authenticator
.sessionRequired(req
, res
, ctx
, loginPath
);
508 assert
.strictEqual(result
, false);
509 assert(res
.end
.called
);
510 assert(res
.setHeader
.called
);
512 }); // sessionRequired
513 describe('sessionOptionalLocal', function () {
514 it('rejects profile', async
function () {
515 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(true);
517 authenticatedProfile: 'user',
519 const result
= await authenticator
.sessionOptionalLocal(req
, res
, ctx
, loginPath
);
520 assert
.strictEqual(result
, false);
521 assert(!res
.end
.called
);
522 assert(!res
.setHeader
.called
);
524 }); // sessionOptionalLocal
525 describe('sessionOptional', function () {
526 it('rejects invalid', async
function () {
527 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(false);
528 const result
= await authenticator
.sessionOptional(req
, res
, ctx
, loginPath
);
529 assert
.strictEqual(result
, false);
530 assert(!res
.end
.called
);
531 assert(!res
.setHeader
.called
);
533 }); // sessionOptional
534 }); // convenience wrappers
537 describe('apiRequiredLocal', function () {
539 beforeEach(function () {
542 getHeader: sinon
.stub(),
546 setHeader: sinon
.stub(),
549 it('covers valid basic auth', async
function () {
550 req
.getHeader
.returns('Basic Zm9vOmJhcg==');
551 sinon
.stub(authenticator
, 'sessionCheck').resolves(false);
552 sinon
.stub(authenticator
, 'isValidAuthorization').resolves(true);
553 const result
= await authenticator
.apiRequiredLocal(req
, res
, ctx
);
554 assert
.strictEqual(result
, true);
555 assert(authenticator
.isValidAuthorization
.called
);
556 assert(!authenticator
.sessionCheck
.called
);
558 it('covers invalid basic auth', async
function () {
559 req
.getHeader
.returns('Basic Zm9vOmJhcg==');
560 sinon
.stub(authenticator
, 'sessionCheck').resolves(false);
561 sinon
.stub(authenticator
, 'isValidAuthorization').resolves(false);
562 assert
.rejects(() => authenticator
.apiRequiredLocal(req
, res
, ctx
), {
563 name: 'ResponseError',
566 assert(!authenticator
.sessionCheck
.called
);
567 assert(authenticator
.isValidAuthorization
.called
);
569 it('covers missing basic auth, valid session', async
function () {
570 req
.getHeader
.returns();
571 sinon
.stub(authenticator
, 'sessionCheck').resolves(true);
572 sinon
.stub(authenticator
, 'isValidAuthorization').resolves(false);
573 const result
= await authenticator
.apiRequiredLocal(req
, res
, ctx
);
574 assert
.strictEqual(result
, true);
575 assert(!authenticator
.isValidAuthorization
.called
);
576 assert(authenticator
.sessionCheck
.called
);
578 it('covers missing basic auth, ignores session', async
function () {
579 req
.getHeader
.returns();
580 sinon
.stub(authenticator
, 'isValidAuthorization').resolves(true);
581 assert
.rejects(() => authenticator
.apiRequiredLocal(req
, res
, ctx
, false), {
582 name: 'ResponseError',
585 assert(!authenticator
.sessionCheck
.called
);
586 assert(!authenticator
.isValidAuthorization
.called
);
587 assert(res
.setHeader
.called
);
589 it('covers errors', async
function () {
590 sinon
.stub(authenticator
, 'isValidAuthorization').rejects();
591 req
.getHeader
.returns('Basic Zm9vOmJhcg==');
592 assert
.rejects(() => authenticator
.apiRequiredLocal(req
, res
, ctx
));
594 }); // apiRequiredLocal