3 const assert
= require('node:assert');
4 const sinon
= require('sinon');
5 const Authenticator
= require('../../lib/authenticator');
6 const stubLogger
= require('../stub-logger');
7 const stubDb
= require('../stub-db');
8 const Enum
= require('../../lib/enum');
9 const Config
= require('../stub-config');
11 describe('Authenticator', function () {
12 let authenticator
, credential
, ctx
, identifier
, password
, options
;
13 function _authMechanismRequired(t
, a
, m
) {
14 if (!a
.authn
[m
]) { // eslint-disable-line security/detect-object-injection
19 beforeEach(function () {
20 options
= Config('test');
21 authenticator
= new Authenticator(stubLogger
, stubDb
, options
);
22 identifier
= 'username';
23 credential
= '$argon2id$v=19$m=4096,t=3,p=1$1a6zRlX4BI4$sZGcQ72BTpDOlxUI/j3DmE1PMcu+Cs5liZ/D6kk79Ew';
25 password
= 'badPassword';
29 afterEach(function () {
33 it('covers no auth mechanisms', function () {
34 options
.authenticator
.authnEnabled
= [];
35 assert
.throws(() => new Authenticator(stubLogger
, stubDb
, options
), {
36 message: 'no authentication mechanisms available',
40 it('covers empty realm', function () {
41 options
.authenticator
.basicRealm
= undefined;
42 authenticator
= new Authenticator(stubLogger
, stubDb
, options
);
45 it('covers option defaults', function () {
46 delete options
.authenticator
.secureAuthOnly
;
47 delete options
.dingus
?.proxyPrefix
;
48 delete options
.authenticator
.forbiddenPAMIdentifiers
;
49 options
.authenticator
.authnEnabled
.push('flarpyauth');
50 authenticator
= new Authenticator(stubLogger
, stubDb
, options
);
53 it('covers invalid sameSite', function () {
54 options
.authenticator
.sessionCookieSameSite
= 'Sometimes';
55 assert
.throws(() => new Authenticator(stubLogger
, stubDb
, options
), RangeError
);
58 describe('createIdentifier', function () {
60 beforeEach(function () {
62 credential
= 'badpassword';
64 it('covers success', async
function () {
65 const otpKey
= '1234567890123456789012';
66 await authenticator
.createIdentifier(dbCtx
, identifier
, credential
, otpKey
);
67 assert(authenticator
.db
.authenticationUpsert
.called
);
69 it('covers failure', async
function () {
70 const expected
= new Error('blah');
71 await authenticator
.db
.authenticationUpsert
.rejects(expected
);
72 // assert.rejects was not happy to handle this for some reason
74 await authenticator
.createIdentifier(dbCtx
, identifier
, credential
);
75 assert
.fail('no expecte exception');
77 assert
.deepStrictEqual(e
, expected
);
78 assert(authenticator
.db
.authenticationUpsert
.called
);
79 assert(authenticator
.logger
.error
.called
);
82 }); // createIdentifier
84 describe('updateCredential', function () {
85 let dbCtx
, newCredential
;
86 beforeEach(function () {
88 newCredential
= 'newpassword';
90 it('covers success', async
function () {
91 await authenticator
.updateCredential(dbCtx
, identifier
, newCredential
);
92 assert(authenticator
.db
.authenticationUpdateCredential
.called
);
93 assert(authenticator
.logger
.info
.called
);
95 it('covers failure', async
function () {
96 const expected
= new Error('foo');
97 authenticator
.db
.authenticationUpdateCredential
.rejects(expected
);
99 await authenticator
.updateCredential(dbCtx
, identifier
, newCredential
);
100 assert
.fail('no expected exception');
102 assert
.deepStrictEqual(e
, expected
);
103 assert(authenticator
.logger
.error
.called
);
105 // assert.rejects was not happy to handle this for some reason
107 }); // updateCredential
109 describe('_secureCredential', function () {
110 beforeEach(function () {
111 credential
= 'badpassword';
113 it('covers plain', async
function () {
114 const result
= await authenticator
._secureCredential(credential
, 'plain');
115 assert
.strictEqual(result
, '$plain$' + credential
);
117 it('covers default (argon2)', async
function () {
118 const result
= await authenticator
._secureCredential(credential
);
119 assert(result
.startsWith('$argon2'));
121 it('covers invalid authn', async
function () {
122 const authn
= 'bogus';
123 assert
.rejects(async () => await authenticator
._secureCredential(credential
, authn
), RangeError
);
125 }); // _secureCredential
127 describe('_validateAuthDataCredential', function () {
129 beforeEach(function () {
130 credential
= 'badpassword';
133 it('fails if not provided a credential', async
function () {
134 const result
= await authenticator
._validateAuthDataCredential(authData
, credential
);
135 assert
.strictEqual(result
, false);
137 it('covers plain', async
function () {
138 authData
.credential
= '$plain$badpassword';
139 const result
= await authenticator
._validateAuthDataCredential(authData
, credential
);
140 assert
.strictEqual(result
, true);
142 }); // _validateAuthDataCredential
144 describe('isValidBasic', function () {
145 const b64
= (x
) => Buffer
.from(x
).toString('base64');
146 it('succeeds', async
function () {
147 _authMechanismRequired(this, authenticator
, 'argon2');
148 authenticator
.db
.authenticationGet
.resolves({
152 const authString
= b64(`${identifier}:${password}`);
153 const result
= await authenticator
.isValidBasic(authString
, ctx
);
154 assert
.strictEqual(result
, true);
155 assert
.strictEqual(ctx
.authenticationId
, identifier
);
157 it('fails', async
function () {
158 _authMechanismRequired(this, authenticator
, 'argon2');
159 authenticator
.db
.authenticationGet
.resolves({
163 const authString
= b64(`${identifier}:wrongPassword}`);
164 const result
= await authenticator
.isValidBasic(authString
, ctx
);
165 assert
.strictEqual(result
, false);
166 assert
.strictEqual(ctx
.authenticationId
, undefined);
168 it('covers no entry', async
function() {
169 authenticator
.db
.authenticationGet
.resolves();
170 const authString
= b64(`${identifier}:wrongPassword}`);
171 const result
= await authenticator
.isValidBasic(authString
, ctx
);
172 assert
.strictEqual(result
, false);
173 assert
.strictEqual(ctx
.authenticationId
, undefined);
175 it('covers unknown password hash', async
function () {
176 authenticator
.db
.authenticationGet
.resolves({
178 credential: '$other$kind_of_credential',
180 const authString
= b64(`${identifier}:wrongPassword}`);
181 const result
= await authenticator
.isValidBasic(authString
, ctx
);
182 assert
.strictEqual(result
, false);
183 assert
.strictEqual(ctx
.authenticationId
, undefined);
187 describe('isValidIdentifierCredential', function () {
188 it('succeeds', async
function () {
189 _authMechanismRequired(this, authenticator
, 'argon2');
190 authenticator
.db
.authenticationGet
.resolves({
194 const result
= await authenticator
.isValidIdentifierCredential(identifier
, password
, ctx
);
195 assert
.strictEqual(result
, true);
196 assert
.strictEqual(ctx
.authenticationId
, identifier
);
198 it('succeeds with OTP', async
function () {
199 const otpKey
= Buffer
.from('1234567890');
200 _authMechanismRequired(this, authenticator
, 'argon2');
201 authenticator
.db
.authenticationGet
.resolves({
206 const result
= await authenticator
.isValidIdentifierCredential(identifier
, password
, ctx
);
207 assert
.strictEqual(result
, true);
208 assert
.strictEqual(ctx
.authenticationId
, identifier
);
209 assert
.deepStrictEqual(ctx
.otpKey
, otpKey
);
211 it('fails', async
function () {
212 _authMechanismRequired(this, authenticator
, 'argon2');
213 authenticator
.db
.authenticationGet
.resolves({
217 const result
= await authenticator
.isValidIdentifierCredential(identifier
, 'wrongPassword', ctx
);
218 assert
.strictEqual(result
, false);
219 assert
.strictEqual(ctx
.authenticationId
, undefined);
221 it('covers no entry', async
function() {
222 authenticator
.db
.authenticationGet
.resolves();
223 const result
= await authenticator
.isValidIdentifierCredential(identifier
, 'wrongPassword', ctx
);
224 assert
.strictEqual(result
, false);
225 assert
.strictEqual(ctx
.authenticationId
, undefined);
227 it('covers non-string credential', async
function () {
228 credential
= '$argon2id$v=19$m=4096,t=3,p=1$SbAlHo5x2HM0PvMAWYHqww$gNn/o+B6+IWsnrVupPkTAiiK9tvwV+eM/HoXG41bnzM';
229 const result
= await authenticator
.isValidIdentifierCredential(identifier
, undefined, ctx
);
230 assert
.strictEqual(result
, false);
231 assert
.strictEqual(ctx
.authenticationId
, undefined);
233 it('covers unknown password hash', async
function () {
234 authenticator
.db
.authenticationGet
.resolves({
236 credential: '$other$kind_of_credential',
238 const result
= await authenticator
.isValidIdentifierCredential(identifier
, 'wrongPassword', ctx
);
239 assert
.strictEqual(result
, false);
240 assert
.strictEqual(ctx
.authenticationId
, undefined);
242 it('covers PAM', async
function () {
243 _authMechanismRequired(this, authenticator
, 'pam');
244 sinon
.stub(authenticator
, '_isValidPAMIdentifier').resolves(true);
245 authenticator
.db
.authenticationGet
.resolves({
249 const result
= await authenticator
.isValidIdentifierCredential(identifier
, password
, ctx
);
250 assert
.strictEqual(result
, true);
251 assert
.strictEqual(ctx
.authenticationId
, identifier
);
253 }); // isValidIdentifierCredential
255 describe('_isValidPAMIdentifier', function () {
257 beforeEach(function () {
258 _authMechanismRequired(this, authenticator
, 'pam');
259 sinon
.stub(authenticator
.authn
.pam
, 'pamAuthenticatePromise');
264 it('covers success', async
function () {
265 authenticator
.authn
.pam
.pamAuthenticatePromise
.resolves(true);
266 const result
= await authenticator
._isValidPAMIdentifier(authData
, credential
);
267 assert
.strictEqual(result
, true);
269 it('covers failure', async
function () {
270 authenticator
.authn
.pam
.pamAuthenticatePromise
.rejects(new authenticator
.authn
.pam
.PamError());
271 const result
= await authenticator
._isValidPAMIdentifier(authData
, credential
);
272 assert
.strictEqual(result
, false);
274 it('covers error', async
function () {
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