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(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(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(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(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(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(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(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(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 _authMechanismRequired(authenticator
, 'pam');
271 authenticator
.authn
.pam
.pamAuthenticatePromise
.rejects(new authenticator
.authn
.pam
.PamError());
272 const result
= await authenticator
._isValidPAMIdentifier(authData
, credential
);
273 assert
.strictEqual(result
, false);
275 it('covers error', async
function () {
276 _authMechanismRequired(authenticator
, 'pam');
277 const expected
= new Error('blah');
278 authenticator
.authn
.pam
.pamAuthenticatePromise
.rejects(expected
);
279 assert
.rejects(() => authenticator
._isValidPAMIdentifier(authData
, credential
), expected
);
281 it('covers forbidden', async
function () {
282 authData
.identifier
= 'root';
283 const result
= await authenticator
._isValidPAMIdentifier(authData
, credential
);
284 assert
.strictEqual(result
, false);
286 }); // _isValidPAMIdentifier
288 describe('isValidAuthorization', function () {
289 it('handles basic', async
function () {
290 const expected
= true;
291 const authorizationHeader
= 'basic Zm9vOmJhcg==';
292 sinon
.stub(authenticator
, 'isValidBasic').resolves(expected
);
293 const result
= await authenticator
.isValidAuthorization(authorizationHeader
, ctx
);
294 assert
.strictEqual(result
, expected
);
296 it('handles other', async
function () {
297 const expected
= false;
298 const authorizationHeader
= 'bearer Zm9vOmJhcg==';
299 const result
= await authenticator
.isValidAuthorization(authorizationHeader
, ctx
);
300 assert
.strictEqual(result
, expected
);
302 }); // isValidAuthorization
304 describe('requestBasic', function () {
305 it('covers', function () {
309 assert
.throws(() => authenticator
.requestBasic(res
), {
310 name: 'ResponseError',
311 statusCode: Enum
.ErrorResponse
.Unauthorized
.statusCode
,
316 describe('isValidCookieAuth', function () {
317 beforeEach(function () {
318 sinon
.stub(authenticator
.mysteryBox
, 'unpack');
320 squeepSession: 'dummy',
324 it('covers identifier success', async
function () {
325 authenticator
.mysteryBox
.unpack
.resolves({
326 authenticatedIdentifier: 'identifier',
328 const result
= await authenticator
.isValidCookieAuth(ctx
);
329 assert
.strictEqual(result
, true);
331 it('covers profile success', async
function () {
332 authenticator
.mysteryBox
.unpack
.resolves({
333 authenticatedProfile: 'profile',
335 const result
= await authenticator
.isValidCookieAuth(ctx
);
336 assert
.strictEqual(result
, true);
338 it('covers missing cookie', async
function () {
339 delete ctx
.cookie
.squeepSession
;
340 const result
= await authenticator
.isValidCookieAuth(ctx
);
341 assert
.strictEqual(result
, false);
343 it('covers bad cookie', async
function () {
344 authenticator
.mysteryBox
.unpack
.rejects();
345 const result
= await authenticator
.isValidCookieAuth(ctx
);
346 assert
.strictEqual(result
, false);
348 it('covers broken session', async
function () {
349 authenticator
.mysteryBox
.unpack
.resolves({
352 const result
= await authenticator
.isValidCookieAuth(ctx
);
353 assert
.strictEqual(result
, false);
355 }); // isValidCookieAuth
357 describe('checkOTP', function () {
359 this.beforeEach(function () {
360 sinon
.stub(authenticator
.TOTP
.prototype, 'validate').returns(true);
362 key: '12345678901234567890123456789012',
368 it('covers valid OTP entry', function () {
369 const result
= authenticator
.checkOTP(state
, otp
);
370 assert
.strictEqual(result
, Enum
.OTPResult
.Valid
);
372 it('covers invalid OTP entry', function () {
373 authenticator
.TOTP
.prototype.validate
.returns(false);
374 const result
= authenticator
.checkOTP(state
, otp
);
375 assert
.strictEqual(result
, Enum
.OTPResult
.InvalidSoftFail
);
377 it('covers invalid OTP entry, too many failures', function () {
379 authenticator
.TOTP
.prototype.validate
.returns(false);
380 const result
= authenticator
.checkOTP(state
, otp
);
381 assert
.strictEqual(result
, Enum
.OTPResult
.InvalidHardFail
);
383 it('covers invalid OTP entry', function () {
385 authenticator
.TOTP
.prototype.validate
.returns(false);
386 const result
= authenticator
.checkOTP(state
, otp
);
387 assert
.strictEqual(result
, Enum
.OTPResult
.InvalidHardFail
);
391 describe('updateOTPKey', function () {
393 beforeEach(function () {
395 otpKey
= 'CDBGB3U3B2ILECQORMINGGSZN7LXY565';
397 it('covers success', async
function () {
398 await authenticator
.updateOTPKey(dbCtx
, identifier
, otpKey
);
399 assert(authenticator
.db
.authenticationUpdateOTPKey
.called
);
401 it('covers failure', async
function () {
402 authenticator
.db
.authenticationUpdateOTPKey
.rejects();
403 assert
.rejects(authenticator
.updateOTPKey(dbCtx
, identifier
, otpKey
));
407 describe('sessionCheck', function () {
408 let req
, res
, loginPath
, required
, profilesAllowed
;
409 beforeEach(function () {
410 ctx
.clientProtocol
= 'https';
412 squeepSession: 'squeep_session_blob',
415 getHeader: sinon
.stub(),
419 setHeader: sinon
.stub(),
420 appendHeader: sinon
.stub(),
422 loginPath
= '/admin/login';
424 profilesAllowed
= true;
426 it('covers valid cookie session', async
function () {
427 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(true);
429 authenticatedIdentifier: 'user',
431 const result
= await authenticator
.sessionCheck(req
, res
, ctx
, loginPath
, required
, profilesAllowed
);
432 assert
.strictEqual(result
, true);
434 it('covers valid insecure cookie session', async
function () {
435 authenticator
.secureAuthOnly
= false;
436 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(true);
438 authenticatedIdentifier: 'user',
440 const result
= await authenticator
.sessionCheck(req
, res
, ctx
, loginPath
, required
, profilesAllowed
);
441 assert
.strictEqual(result
, true);
443 it('rejects insecure connection', async
function () {
444 ctx
.clientProtocol
= 'http';
445 assert
.rejects(() => authenticator
.sessionCheck(req
, res
, ctx
, loginPath
, required
, profilesAllowed
), {
446 name: 'ResponseError',
447 sttausCode: Enum
.ErrorResponse
.Forbidden
.statusCode
,
450 it('ignores insecure connection if auth not required', async
function () {
451 ctx
.clientProtocol
= 'http';
453 const result
= await authenticator
.sessionCheck(req
, res
, ctx
, loginPath
, required
, profilesAllowed
);
454 assert
.strictEqual(result
, false);
456 it('redirects without any auth', async
function () {
457 await authenticator
.sessionCheck(req
, res
, ctx
, loginPath
, required
, profilesAllowed
);
458 assert(res
.end
.called
);
459 assert(res
.setHeader
.called
);
461 describe('convenience wrappers', function () {
462 describe('sessionRequiredLocal', function () {
463 it('accepts identifier', async
function () {
464 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(true);
466 authenticatedIdentifier: 'user',
468 const result
= await authenticator
.sessionRequiredLocal(req
, res
, ctx
, loginPath
);
469 assert
.strictEqual(result
, true);
471 it('redirects with profile', async
function () {
472 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(true);
474 authenticatedProfile: 'user',
476 const result
= await authenticator
.sessionRequiredLocal(req
, res
, ctx
, loginPath
);
477 assert
.strictEqual(result
, false);
478 assert(res
.end
.called
);
479 assert(res
.setHeader
.called
);
481 }); // sessionRequiredLocal
482 describe('sessionRequired', function () {
483 it('accepts identifier', async
function () {
484 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(true);
486 authenticatedIdentifier: 'user',
488 const result
= await authenticator
.sessionRequired(req
, res
, ctx
, loginPath
);
489 assert
.strictEqual(result
, true);
491 it('accepts profile', async
function () {
492 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(true);
494 authenticatedProfile: 'user',
496 const result
= await authenticator
.sessionRequired(req
, res
, ctx
, loginPath
);
497 assert
.strictEqual(result
, true);
499 it('rejects invalid', async
function () {
500 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(false);
501 const result
= await authenticator
.sessionRequired(req
, res
, ctx
, loginPath
);
502 assert
.strictEqual(result
, false);
503 assert(res
.end
.called
);
504 assert(res
.setHeader
.called
);
506 it('covers insecure allowed', async
function () {
507 authenticator
.options
.authenticator
.secureAuthOnly
= false;
508 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(false);
509 const result
= await authenticator
.sessionRequired(req
, res
, ctx
, loginPath
);
510 assert
.strictEqual(result
, false);
511 assert(res
.end
.called
);
512 assert(res
.setHeader
.called
);
514 }); // sessionRequired
515 describe('sessionOptionalLocal', function () {
516 it('rejects profile', async
function () {
517 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(true);
519 authenticatedProfile: 'user',
521 const result
= await authenticator
.sessionOptionalLocal(req
, res
, ctx
, loginPath
);
522 assert
.strictEqual(result
, false);
523 assert(!res
.end
.called
);
524 assert(!res
.setHeader
.called
);
526 }); // sessionOptionalLocal
527 describe('sessionOptional', function () {
528 it('rejects invalid', async
function () {
529 sinon
.stub(authenticator
, 'isValidCookieAuth').resolves(false);
530 const result
= await authenticator
.sessionOptional(req
, res
, ctx
, loginPath
);
531 assert
.strictEqual(result
, false);
532 assert(!res
.end
.called
);
533 assert(!res
.setHeader
.called
);
535 }); // sessionOptional
536 }); // convenience wrappers
539 describe('apiRequiredLocal', function () {
541 beforeEach(function () {
544 getHeader: sinon
.stub(),
548 setHeader: sinon
.stub(),
551 it('covers valid basic auth', async
function () {
552 req
.getHeader
.returns('Basic Zm9vOmJhcg==');
553 sinon
.stub(authenticator
, 'sessionCheck').resolves(false);
554 sinon
.stub(authenticator
, 'isValidAuthorization').resolves(true);
555 const result
= await authenticator
.apiRequiredLocal(req
, res
, ctx
);
556 assert
.strictEqual(result
, true);
557 assert(authenticator
.isValidAuthorization
.called
);
558 assert(!authenticator
.sessionCheck
.called
);
560 it('covers invalid basic auth', async
function () {
561 req
.getHeader
.returns('Basic Zm9vOmJhcg==');
562 sinon
.stub(authenticator
, 'sessionCheck').resolves(false);
563 sinon
.stub(authenticator
, 'isValidAuthorization').resolves(false);
564 assert
.rejects(() => authenticator
.apiRequiredLocal(req
, res
, ctx
), {
565 name: 'ResponseError',
568 assert(!authenticator
.sessionCheck
.called
);
569 assert(authenticator
.isValidAuthorization
.called
);
571 it('covers missing basic auth, valid session', async
function () {
572 req
.getHeader
.returns();
573 sinon
.stub(authenticator
, 'sessionCheck').resolves(true);
574 sinon
.stub(authenticator
, 'isValidAuthorization').resolves(false);
575 const result
= await authenticator
.apiRequiredLocal(req
, res
, ctx
);
576 assert
.strictEqual(result
, true);
577 assert(!authenticator
.isValidAuthorization
.called
);
578 assert(authenticator
.sessionCheck
.called
);
580 it('covers missing basic auth, ignores session', async
function () {
581 req
.getHeader
.returns();
582 sinon
.stub(authenticator
, 'isValidAuthorization').resolves(true);
583 assert
.rejects(() => authenticator
.apiRequiredLocal(req
, res
, ctx
, false), {
584 name: 'ResponseError',
587 assert(!authenticator
.sessionCheck
.called
);
588 assert(!authenticator
.isValidAuthorization
.called
);
589 assert(res
.setHeader
.called
);
591 it('covers errors', async
function () {
592 sinon
.stub(authenticator
, 'isValidAuthorization').rejects();
593 req
.getHeader
.returns('Basic Zm9vOmJhcg==');
594 assert
.rejects(() => authenticator
.apiRequiredLocal(req
, res
, ctx
));
596 }); // apiRequiredLocal