update devDependencies, fix lint issues
[squeep-authentication-module] / test / lib / authenticator.js
1 /* eslint-env mocha */
2 /* eslint-disable sonarjs/no-duplicate-string */
3 /* eslint-disable jsdoc/require-jsdoc */
4 'use strict';
5
6 const assert = require('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');
13
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
18 this.skip();
19 }
20 }
21
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';
27 ctx = {};
28 password = 'badPassword';
29 stubDb._reset();
30 stubLogger._reset();
31 });
32 afterEach(function () {
33 sinon.restore();
34 });
35
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',
40 });
41 });
42
43 it('covers empty realm', function () {
44 options.authenticator.basicRealm = undefined;
45 authenticator = new Authenticator(stubLogger, stubDb, options);
46 });
47
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);
54 });
55
56 describe('createIdentifier', function () {
57 let dbCtx;
58 beforeEach(function () {
59 dbCtx = {};
60 credential = 'badpassword';
61 });
62 it('covers success', async function () {
63 const otpKey = '1234567890123456789012';
64 await authenticator.createIdentifier(dbCtx, identifier, credential, otpKey);
65 assert(authenticator.db.authenticationUpsert.called);
66 });
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
71 try {
72 await authenticator.createIdentifier(dbCtx, identifier, credential);
73 assert.fail('no expecte exception');
74 } catch (e) {
75 assert.deepStrictEqual(e, expected);
76 assert(authenticator.db.authenticationUpsert.called);
77 assert(authenticator.logger.error.called);
78 }
79 });
80 }); // createIdentifier
81
82 describe('updateCredential', function () {
83 let dbCtx, newCredential;
84 beforeEach(function () {
85 dbCtx = {};
86 newCredential = 'newpassword';
87 });
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);
92 });
93 it('covers failure', async function () {
94 const expected = new Error('foo');
95 authenticator.db.authenticationUpdateCredential.rejects(expected);
96 try {
97 await authenticator.updateCredential(dbCtx, identifier, newCredential);
98 assert.fail('no expected exception');
99 } catch (e) {
100 assert.deepStrictEqual(e, expected);
101 assert(authenticator.logger.error.called);
102 }
103 // assert.rejects was not happy to handle this for some reason
104 });
105 }); // updateCredential
106
107 describe('_secureCredential', function () {
108 beforeEach(function () {
109 credential = 'badpassword';
110 });
111 it('covers plain', async function () {
112 const result = await authenticator._secureCredential(credential, 'plain');
113 assert.strictEqual(result, '$plain$' + credential);
114 });
115 it('covers default (argon2)', async function () {
116 const result = await authenticator._secureCredential(credential);
117 assert(result.startsWith('$argon2'));
118 });
119 it('covers invalid authn', async function () {
120 const authn = 'bogus';
121 assert.rejects(async () => await authenticator._secureCredential(credential, authn), RangeError);
122 });
123 }); // _secureCredential
124
125 describe('_validateAuthDataCredential', function () {
126 let authData;
127 beforeEach(function () {
128 credential = 'badpassword';
129 authData = {};
130 });
131 it('fails if not provided a credential', async function () {
132 const result = await authenticator._validateAuthDataCredential(authData, credential);
133 assert.strictEqual(result, false);
134 });
135 it('covers plain', async function () {
136 authData.credential = '$plain$badpassword';
137 const result = await authenticator._validateAuthDataCredential(authData, credential);
138 assert.strictEqual(result, true);
139 });
140 }); // _validateAuthDataCredential
141
142 describe('isValidBasic', function () {
143 it('succeeds', async function () {
144 _authMechanismRequired(authenticator, 'argon2');
145 authenticator.db.authenticationGet.resolves({
146 identifier,
147 credential,
148 });
149 const authString = `${identifier}:${password}`;
150 const result = await authenticator.isValidBasic(authString, ctx);
151 assert.strictEqual(result, true);
152 assert.strictEqual(ctx.authenticationId, identifier);
153 });
154 it('fails', async function () {
155 _authMechanismRequired(authenticator, 'argon2');
156 authenticator.db.authenticationGet.resolves({
157 identifier,
158 credential,
159 });
160 const authString = `${identifier}:wrongPassword}`;
161 const result = await authenticator.isValidBasic(authString, ctx);
162 assert.strictEqual(result, false);
163 assert.strictEqual(ctx.authenticationId, undefined);
164 });
165 it('covers no entry', async function() {
166 authenticator.db.authenticationGet.resolves();
167 const authString = `${identifier}:wrongPassword}`;
168 const result = await authenticator.isValidBasic(authString, ctx);
169 assert.strictEqual(result, false);
170 assert.strictEqual(ctx.authenticationId, undefined);
171 });
172 it('covers unknown password hash', async function () {
173 authenticator.db.authenticationGet.resolves({
174 identifier,
175 credential: '$other$kind_of_credential',
176 });
177 const authString = `${identifier}:wrongPassword}`;
178 const result = await authenticator.isValidBasic(authString, ctx);
179 assert.strictEqual(result, false);
180 assert.strictEqual(ctx.authenticationId, undefined);
181 });
182 }); // isValidBasic
183
184 describe('isValidIdentifierCredential', function () {
185 it('succeeds', async function () {
186 _authMechanismRequired(authenticator, 'argon2');
187 authenticator.db.authenticationGet.resolves({
188 identifier,
189 credential,
190 });
191 const result = await authenticator.isValidIdentifierCredential(identifier, password, ctx);
192 assert.strictEqual(result, true);
193 assert.strictEqual(ctx.authenticationId, identifier);
194 });
195 it('succeeds with OTP', async function () {
196 const otpKey = Buffer.from('1234567890');
197 _authMechanismRequired(authenticator, 'argon2');
198 authenticator.db.authenticationGet.resolves({
199 identifier,
200 credential,
201 otpKey,
202 });
203 const result = await authenticator.isValidIdentifierCredential(identifier, password, ctx);
204 assert.strictEqual(result, true);
205 assert.strictEqual(ctx.authenticationId, identifier);
206 assert.deepStrictEqual(ctx.otpKey, otpKey);
207 });
208 it('fails', async function () {
209 _authMechanismRequired(authenticator, 'argon2');
210 authenticator.db.authenticationGet.resolves({
211 identifier,
212 credential,
213 });
214 const result = await authenticator.isValidIdentifierCredential(identifier, 'wrongPassword', ctx);
215 assert.strictEqual(result, false);
216 assert.strictEqual(ctx.authenticationId, undefined);
217 });
218 it('covers no entry', async function() {
219 authenticator.db.authenticationGet.resolves();
220 const result = await authenticator.isValidIdentifierCredential(identifier, 'wrongPassword', ctx);
221 assert.strictEqual(result, false);
222 assert.strictEqual(ctx.authenticationId, undefined);
223 });
224 it('covers non-string credential', async function () {
225 credential = '$argon2id$v=19$m=4096,t=3,p=1$SbAlHo5x2HM0PvMAWYHqww$gNn/o+B6+IWsnrVupPkTAiiK9tvwV+eM/HoXG41bnzM';
226 const result = await authenticator.isValidIdentifierCredential(identifier, undefined, ctx);
227 assert.strictEqual(result, false);
228 assert.strictEqual(ctx.authenticationId, undefined);
229 });
230 it('covers unknown password hash', async function () {
231 authenticator.db.authenticationGet.resolves({
232 identifier,
233 credential: '$other$kind_of_credential',
234 });
235 const result = await authenticator.isValidIdentifierCredential(identifier, 'wrongPassword', ctx);
236 assert.strictEqual(result, false);
237 assert.strictEqual(ctx.authenticationId, undefined);
238 });
239 it('covers PAM', async function () {
240 _authMechanismRequired(authenticator, 'pam');
241 sinon.stub(authenticator, '_isValidPAMIdentifier').resolves(true);
242 authenticator.db.authenticationGet.resolves({
243 identifier,
244 credential: '$PAM$',
245 });
246 const result = await authenticator.isValidIdentifierCredential(identifier, password, ctx);
247 assert.strictEqual(result, true);
248 assert.strictEqual(ctx.authenticationId, identifier);
249 });
250 }); // isValidIdentifierCredential
251
252 describe('_isValidPAMIdentifier', function () {
253 let authData;
254 beforeEach(function () {
255 _authMechanismRequired(authenticator, 'pam');
256 sinon.stub(authenticator.authn.pam, 'pamAuthenticatePromise');
257 authData = {
258 identifier,
259 };
260 });
261 it('covers success', async function () {
262 authenticator.authn.pam.pamAuthenticatePromise.resolves(true);
263 const result = await authenticator._isValidPAMIdentifier(authData, credential);
264 assert.strictEqual(result, true);
265 });
266 it('covers failure', async function () {
267 _authMechanismRequired(authenticator, 'pam');
268 authenticator.authn.pam.pamAuthenticatePromise.rejects(new authenticator.authn.pam.PamError());
269 const result = await authenticator._isValidPAMIdentifier(authData, credential);
270 assert.strictEqual(result, false);
271 });
272 it('covers error', async function () {
273 _authMechanismRequired(authenticator, 'pam');
274 const expected = new Error('blah');
275 authenticator.authn.pam.pamAuthenticatePromise.rejects(expected);
276 assert.rejects(() => authenticator._isValidPAMIdentifier(authData, credential), expected);
277 });
278 it('covers forbidden', async function () {
279 authData.identifier = 'root';
280 const result = await authenticator._isValidPAMIdentifier(authData, credential);
281 assert.strictEqual(result, false);
282 });
283 }); // _isValidPAMIdentifier
284
285 describe('isValidAuthorization', function () {
286 it('handles basic', async function () {
287 const expected = true;
288 const authorizationHeader = 'basic Zm9vOmJhcg==';
289 sinon.stub(authenticator, 'isValidBasic').resolves(expected);
290 const result = await authenticator.isValidAuthorization(authorizationHeader, ctx);
291 assert.strictEqual(result, expected);
292 });
293 it('handles other', async function () {
294 const expected = false;
295 const authorizationHeader = 'bearer Zm9vOmJhcg==';
296 const result = await authenticator.isValidAuthorization(authorizationHeader, ctx);
297 assert.strictEqual(result, expected);
298 });
299 }); // isValidAuthorization
300
301 describe('requestBasic', function () {
302 it('covers', function () {
303 const res = {
304 setHeader: () => {},
305 };
306 assert.throws(() => authenticator.requestBasic(res), {
307 name: 'ResponseError',
308 statusCode: Enum.ErrorResponse.Unauthorized.statusCode,
309 });
310 });
311 }); // requestBasic
312
313 describe('isValidCookieAuth', function () {
314 beforeEach(function () {
315 sinon.stub(authenticator.mysteryBox, 'unpack');
316 ctx.cookie = {
317 squeepSession: 'dummy',
318 otherCookie: 'foo',
319 };
320 });
321 it('covers identifier success', async function () {
322 authenticator.mysteryBox.unpack.resolves({
323 authenticatedIdentifier: 'identifier',
324 });
325 const result = await authenticator.isValidCookieAuth(ctx);
326 assert.strictEqual(result, true);
327 });
328 it('covers profile success', async function () {
329 authenticator.mysteryBox.unpack.resolves({
330 authenticatedProfile: 'profile',
331 });
332 const result = await authenticator.isValidCookieAuth(ctx);
333 assert.strictEqual(result, true);
334 });
335 it('covers missing cookie', async function () {
336 delete ctx.cookie.squeepSession;
337 const result = await authenticator.isValidCookieAuth(ctx);
338 assert.strictEqual(result, false);
339 });
340 it('covers bad cookie', async function () {
341 authenticator.mysteryBox.unpack.rejects();
342 const result = await authenticator.isValidCookieAuth(ctx);
343 assert.strictEqual(result, false);
344 });
345 it('covers broken session', async function () {
346 authenticator.mysteryBox.unpack.resolves({
347 randomData: 'foo',
348 });
349 const result = await authenticator.isValidCookieAuth(ctx);
350 assert.strictEqual(result, false);
351 });
352 }); // isValidCookieAuth
353
354 describe('checkOTP', function () {
355 let state, otp;
356 this.beforeEach(function () {
357 sinon.stub(authenticator.TOTP.prototype, 'validate').returns(true);
358 state = {
359 key: '12345678901234567890123456789012',
360 attempt: 0,
361 epochMs: Date.now(),
362 };
363 otp = '000000';
364 });
365 it('covers valid OTP entry', function () {
366 const result = authenticator.checkOTP(state, otp);
367 assert.strictEqual(result, Enum.OTPResult.Valid);
368 });
369 it('covers invalid OTP entry', function () {
370 authenticator.TOTP.prototype.validate.returns(false);
371 const result = authenticator.checkOTP(state, otp);
372 assert.strictEqual(result, Enum.OTPResult.InvalidSoftFail);
373 });
374 it('covers invalid OTP entry, too many failures', function () {
375 state.attempt = 10;
376 authenticator.TOTP.prototype.validate.returns(false);
377 const result = authenticator.checkOTP(state, otp);
378 assert.strictEqual(result, Enum.OTPResult.InvalidHardFail);
379 });
380 it('covers invalid OTP entry', function () {
381 state.epochMs = 0;
382 authenticator.TOTP.prototype.validate.returns(false);
383 const result = authenticator.checkOTP(state, otp);
384 assert.strictEqual(result, Enum.OTPResult.InvalidHardFail);
385 });
386 }); // checkOTP
387
388 describe('updateOTPKey', function () {
389 let dbCtx, otpKey;
390 beforeEach(function () {
391 dbCtx = {};
392 otpKey = 'CDBGB3U3B2ILECQORMINGGSZN7LXY565';
393 });
394 it('covers success', async function () {
395 await authenticator.updateOTPKey(dbCtx, identifier, otpKey);
396 assert(authenticator.db.authenticationUpdateOTPKey.called);
397 });
398 it('covers failure', async function () {
399 authenticator.db.authenticationUpdateOTPKey.rejects();
400 assert.rejects(authenticator.updateOTPKey(dbCtx, identifier, otpKey));
401 });
402 }); // updateOTPKey
403
404 describe('sessionCheck', function () {
405 let req, res, loginPath, required, profilesAllowed;
406 beforeEach(function () {
407 ctx.clientProtocol = 'https';
408 ctx.cookie = {
409 squeepSession: 'squeep_session_blob',
410 };
411 req = {
412 getHeader: sinon.stub(),
413 };
414 res = {
415 end: sinon.stub(),
416 setHeader: sinon.stub(),
417 appendHeader: sinon.stub(),
418 };
419 loginPath = '/admin/login';
420 required = true;
421 profilesAllowed = true;
422 });
423 it('covers valid cookie session', async function () {
424 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
425 ctx.session = {
426 authenticatedIdentifier: 'user',
427 };
428 const result = await authenticator.sessionCheck(req, res, ctx, loginPath, required, profilesAllowed);
429 assert.strictEqual(result, true);
430 });
431 it('covers valid insecure cookie session', async function () {
432 authenticator.secureAuthOnly = false;
433 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
434 ctx.session = {
435 authenticatedIdentifier: 'user',
436 };
437 const result = await authenticator.sessionCheck(req, res, ctx, loginPath, required, profilesAllowed);
438 assert.strictEqual(result, true);
439 });
440 it('rejects insecure connection', async function () {
441 ctx.clientProtocol = 'http';
442 assert.rejects(() => authenticator.sessionCheck(req, res, ctx, loginPath, required, profilesAllowed), {
443 name: 'ResponseError',
444 sttausCode: Enum.ErrorResponse.Forbidden.statusCode,
445 });
446 });
447 it('ignores insecure connection if auth not required', async function () {
448 ctx.clientProtocol = 'http';
449 required = false;
450 const result = await authenticator.sessionCheck(req, res, ctx, loginPath, required, profilesAllowed);
451 assert.strictEqual(result, false);
452 });
453 it('redirects without any auth', async function () {
454 await authenticator.sessionCheck(req, res, ctx, loginPath, required, profilesAllowed);
455 assert(res.end.called);
456 assert(res.setHeader.called);
457 });
458 describe('convenience wrappers', function () {
459 describe('sessionRequiredLocal', function () {
460 it('accepts identifier', async function () {
461 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
462 ctx.session = {
463 authenticatedIdentifier: 'user',
464 };
465 const result = await authenticator.sessionRequiredLocal(req, res, ctx, loginPath);
466 assert.strictEqual(result, true);
467 });
468 it('redirects with profile', async function () {
469 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
470 ctx.session = {
471 authenticatedProfile: 'user',
472 };
473 const result = await authenticator.sessionRequiredLocal(req, res, ctx, loginPath);
474 assert.strictEqual(result, false);
475 assert(res.end.called);
476 assert(res.setHeader.called);
477 });
478 }); // sessionRequiredLocal
479 describe('sessionRequired', function () {
480 it('accepts identifier', async function () {
481 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
482 ctx.session = {
483 authenticatedIdentifier: 'user',
484 };
485 const result = await authenticator.sessionRequired(req, res, ctx, loginPath);
486 assert.strictEqual(result, true);
487 });
488 it('accepts profile', async function () {
489 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
490 ctx.session = {
491 authenticatedProfile: 'user',
492 };
493 const result = await authenticator.sessionRequired(req, res, ctx, loginPath);
494 assert.strictEqual(result, true);
495 });
496 it('rejects invalid', async function () {
497 sinon.stub(authenticator, 'isValidCookieAuth').resolves(false);
498 const result = await authenticator.sessionRequired(req, res, ctx, loginPath);
499 assert.strictEqual(result, false);
500 assert(res.end.called);
501 assert(res.setHeader.called);
502 });
503 it('covers insecure allowed', async function () {
504 authenticator.options.authenticator.secureAuthOnly = false;
505 sinon.stub(authenticator, 'isValidCookieAuth').resolves(false);
506 const result = await authenticator.sessionRequired(req, res, ctx, loginPath);
507 assert.strictEqual(result, false);
508 assert(res.end.called);
509 assert(res.setHeader.called);
510 });
511 }); // sessionRequired
512 describe('sessionOptionalLocal', function () {
513 it('rejects profile', async function () {
514 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
515 ctx.session = {
516 authenticatedProfile: 'user',
517 };
518 const result = await authenticator.sessionOptionalLocal(req, res, ctx, loginPath);
519 assert.strictEqual(result, false);
520 assert(!res.end.called);
521 assert(!res.setHeader.called);
522 });
523 }); // sessionOptionalLocal
524 describe('sessionOptional', function () {
525 it('rejects invalid', async function () {
526 sinon.stub(authenticator, 'isValidCookieAuth').resolves(false);
527 const result = await authenticator.sessionOptional(req, res, ctx, loginPath);
528 assert.strictEqual(result, false);
529 assert(!res.end.called);
530 assert(!res.setHeader.called);
531 });
532 }); // sessionOptional
533 }); // convenience wrappers
534 }); // sessionCheck
535
536 describe('apiRequiredLocal', function () {
537 let req, res;
538 beforeEach(function () {
539 ctx = {};
540 req = {
541 getHeader: sinon.stub(),
542 };
543 res = {
544 end: sinon.stub(),
545 setHeader: sinon.stub(),
546 };
547 });
548 it('covers valid basic auth', async function () {
549 req.getHeader.returns('Basic Zm9vOmJhcg==');
550 sinon.stub(authenticator, 'sessionCheck').resolves(false);
551 sinon.stub(authenticator, 'isValidAuthorization').resolves(true);
552 const result = await authenticator.apiRequiredLocal(req, res, ctx);
553 assert.strictEqual(result, true);
554 assert(authenticator.isValidAuthorization.called);
555 assert(!authenticator.sessionCheck.called);
556 });
557 it('covers invalid basic auth', async function () {
558 req.getHeader.returns('Basic Zm9vOmJhcg==');
559 sinon.stub(authenticator, 'sessionCheck').resolves(false);
560 sinon.stub(authenticator, 'isValidAuthorization').resolves(false);
561 assert.rejects(() => authenticator.apiRequiredLocal(req, res, ctx), {
562 name: 'ResponseError',
563 statusCode: 401,
564 });
565 assert(!authenticator.sessionCheck.called);
566 assert(authenticator.isValidAuthorization.called);
567 });
568 it('covers missing basic auth, valid session', async function () {
569 req.getHeader.returns();
570 sinon.stub(authenticator, 'sessionCheck').resolves(true);
571 sinon.stub(authenticator, 'isValidAuthorization').resolves(false);
572 const result = await authenticator.apiRequiredLocal(req, res, ctx);
573 assert.strictEqual(result, true);
574 assert(!authenticator.isValidAuthorization.called);
575 assert(authenticator.sessionCheck.called);
576 });
577 it('covers missing basic auth, ignores session', async function () {
578 req.getHeader.returns();
579 sinon.stub(authenticator, 'isValidAuthorization').resolves(true);
580 assert.rejects(authenticator.apiRequiredLocal(req, res, ctx, false), {
581 name: 'ResponseError',
582 statusCode: 401,
583 });
584 assert(!authenticator.sessionCheck.called);
585 assert(!authenticator.isValidAuthorization.called);
586 assert(res.setHeader.called);
587 });
588 }); // apiRequiredLocal
589
590 }); // Authenticator