bump package version to 1.5.0
[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('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');
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 const b64 = (x) => Buffer.from(x).toString('base64');
144 it('succeeds', async function () {
145 _authMechanismRequired(authenticator, 'argon2');
146 authenticator.db.authenticationGet.resolves({
147 identifier,
148 credential,
149 });
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);
154 });
155 it('fails', async function () {
156 _authMechanismRequired(authenticator, 'argon2');
157 authenticator.db.authenticationGet.resolves({
158 identifier,
159 credential,
160 });
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);
165 });
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);
172 });
173 it('covers unknown password hash', async function () {
174 authenticator.db.authenticationGet.resolves({
175 identifier,
176 credential: '$other$kind_of_credential',
177 });
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);
182 });
183 }); // isValidBasic
184
185 describe('isValidIdentifierCredential', function () {
186 it('succeeds', async function () {
187 _authMechanismRequired(authenticator, 'argon2');
188 authenticator.db.authenticationGet.resolves({
189 identifier,
190 credential,
191 });
192 const result = await authenticator.isValidIdentifierCredential(identifier, password, ctx);
193 assert.strictEqual(result, true);
194 assert.strictEqual(ctx.authenticationId, identifier);
195 });
196 it('succeeds with OTP', async function () {
197 const otpKey = Buffer.from('1234567890');
198 _authMechanismRequired(authenticator, 'argon2');
199 authenticator.db.authenticationGet.resolves({
200 identifier,
201 credential,
202 otpKey,
203 });
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);
208 });
209 it('fails', async function () {
210 _authMechanismRequired(authenticator, 'argon2');
211 authenticator.db.authenticationGet.resolves({
212 identifier,
213 credential,
214 });
215 const result = await authenticator.isValidIdentifierCredential(identifier, 'wrongPassword', ctx);
216 assert.strictEqual(result, false);
217 assert.strictEqual(ctx.authenticationId, undefined);
218 });
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);
224 });
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);
230 });
231 it('covers unknown password hash', async function () {
232 authenticator.db.authenticationGet.resolves({
233 identifier,
234 credential: '$other$kind_of_credential',
235 });
236 const result = await authenticator.isValidIdentifierCredential(identifier, 'wrongPassword', ctx);
237 assert.strictEqual(result, false);
238 assert.strictEqual(ctx.authenticationId, undefined);
239 });
240 it('covers PAM', async function () {
241 _authMechanismRequired(authenticator, 'pam');
242 sinon.stub(authenticator, '_isValidPAMIdentifier').resolves(true);
243 authenticator.db.authenticationGet.resolves({
244 identifier,
245 credential: '$PAM$',
246 });
247 const result = await authenticator.isValidIdentifierCredential(identifier, password, ctx);
248 assert.strictEqual(result, true);
249 assert.strictEqual(ctx.authenticationId, identifier);
250 });
251 }); // isValidIdentifierCredential
252
253 describe('_isValidPAMIdentifier', function () {
254 let authData;
255 beforeEach(function () {
256 _authMechanismRequired(authenticator, 'pam');
257 sinon.stub(authenticator.authn.pam, 'pamAuthenticatePromise');
258 authData = {
259 identifier,
260 };
261 });
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);
266 });
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);
272 });
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);
278 });
279 it('covers forbidden', async function () {
280 authData.identifier = 'root';
281 const result = await authenticator._isValidPAMIdentifier(authData, credential);
282 assert.strictEqual(result, false);
283 });
284 }); // _isValidPAMIdentifier
285
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);
293 });
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);
299 });
300 }); // isValidAuthorization
301
302 describe('requestBasic', function () {
303 it('covers', function () {
304 const res = {
305 setHeader: () => {},
306 };
307 assert.throws(() => authenticator.requestBasic(res), {
308 name: 'ResponseError',
309 statusCode: Enum.ErrorResponse.Unauthorized.statusCode,
310 });
311 });
312 }); // requestBasic
313
314 describe('isValidCookieAuth', function () {
315 beforeEach(function () {
316 sinon.stub(authenticator.mysteryBox, 'unpack');
317 ctx.cookie = {
318 squeepSession: 'dummy',
319 otherCookie: 'foo',
320 };
321 });
322 it('covers identifier success', async function () {
323 authenticator.mysteryBox.unpack.resolves({
324 authenticatedIdentifier: 'identifier',
325 });
326 const result = await authenticator.isValidCookieAuth(ctx);
327 assert.strictEqual(result, true);
328 });
329 it('covers profile success', async function () {
330 authenticator.mysteryBox.unpack.resolves({
331 authenticatedProfile: 'profile',
332 });
333 const result = await authenticator.isValidCookieAuth(ctx);
334 assert.strictEqual(result, true);
335 });
336 it('covers missing cookie', async function () {
337 delete ctx.cookie.squeepSession;
338 const result = await authenticator.isValidCookieAuth(ctx);
339 assert.strictEqual(result, false);
340 });
341 it('covers bad cookie', async function () {
342 authenticator.mysteryBox.unpack.rejects();
343 const result = await authenticator.isValidCookieAuth(ctx);
344 assert.strictEqual(result, false);
345 });
346 it('covers broken session', async function () {
347 authenticator.mysteryBox.unpack.resolves({
348 randomData: 'foo',
349 });
350 const result = await authenticator.isValidCookieAuth(ctx);
351 assert.strictEqual(result, false);
352 });
353 }); // isValidCookieAuth
354
355 describe('checkOTP', function () {
356 let state, otp;
357 this.beforeEach(function () {
358 sinon.stub(authenticator.TOTP.prototype, 'validate').returns(true);
359 state = {
360 key: '12345678901234567890123456789012',
361 attempt: 0,
362 epochMs: Date.now(),
363 };
364 otp = '000000';
365 });
366 it('covers valid OTP entry', function () {
367 const result = authenticator.checkOTP(state, otp);
368 assert.strictEqual(result, Enum.OTPResult.Valid);
369 });
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);
374 });
375 it('covers invalid OTP entry, too many failures', function () {
376 state.attempt = 10;
377 authenticator.TOTP.prototype.validate.returns(false);
378 const result = authenticator.checkOTP(state, otp);
379 assert.strictEqual(result, Enum.OTPResult.InvalidHardFail);
380 });
381 it('covers invalid OTP entry', function () {
382 state.epochMs = 0;
383 authenticator.TOTP.prototype.validate.returns(false);
384 const result = authenticator.checkOTP(state, otp);
385 assert.strictEqual(result, Enum.OTPResult.InvalidHardFail);
386 });
387 }); // checkOTP
388
389 describe('updateOTPKey', function () {
390 let dbCtx, otpKey;
391 beforeEach(function () {
392 dbCtx = {};
393 otpKey = 'CDBGB3U3B2ILECQORMINGGSZN7LXY565';
394 });
395 it('covers success', async function () {
396 await authenticator.updateOTPKey(dbCtx, identifier, otpKey);
397 assert(authenticator.db.authenticationUpdateOTPKey.called);
398 });
399 it('covers failure', async function () {
400 authenticator.db.authenticationUpdateOTPKey.rejects();
401 assert.rejects(authenticator.updateOTPKey(dbCtx, identifier, otpKey));
402 });
403 }); // updateOTPKey
404
405 describe('sessionCheck', function () {
406 let req, res, loginPath, required, profilesAllowed;
407 beforeEach(function () {
408 ctx.clientProtocol = 'https';
409 ctx.cookie = {
410 squeepSession: 'squeep_session_blob',
411 };
412 req = {
413 getHeader: sinon.stub(),
414 };
415 res = {
416 end: sinon.stub(),
417 setHeader: sinon.stub(),
418 appendHeader: sinon.stub(),
419 };
420 loginPath = '/admin/login';
421 required = true;
422 profilesAllowed = true;
423 });
424 it('covers valid cookie session', async function () {
425 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
426 ctx.session = {
427 authenticatedIdentifier: 'user',
428 };
429 const result = await authenticator.sessionCheck(req, res, ctx, loginPath, required, profilesAllowed);
430 assert.strictEqual(result, true);
431 });
432 it('covers valid insecure cookie session', async function () {
433 authenticator.secureAuthOnly = false;
434 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
435 ctx.session = {
436 authenticatedIdentifier: 'user',
437 };
438 const result = await authenticator.sessionCheck(req, res, ctx, loginPath, required, profilesAllowed);
439 assert.strictEqual(result, true);
440 });
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,
446 });
447 });
448 it('ignores insecure connection if auth not required', async function () {
449 ctx.clientProtocol = 'http';
450 required = false;
451 const result = await authenticator.sessionCheck(req, res, ctx, loginPath, required, profilesAllowed);
452 assert.strictEqual(result, false);
453 });
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);
458 });
459 describe('convenience wrappers', function () {
460 describe('sessionRequiredLocal', function () {
461 it('accepts identifier', async function () {
462 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
463 ctx.session = {
464 authenticatedIdentifier: 'user',
465 };
466 const result = await authenticator.sessionRequiredLocal(req, res, ctx, loginPath);
467 assert.strictEqual(result, true);
468 });
469 it('redirects with profile', async function () {
470 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
471 ctx.session = {
472 authenticatedProfile: 'user',
473 };
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);
478 });
479 }); // sessionRequiredLocal
480 describe('sessionRequired', function () {
481 it('accepts identifier', async function () {
482 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
483 ctx.session = {
484 authenticatedIdentifier: 'user',
485 };
486 const result = await authenticator.sessionRequired(req, res, ctx, loginPath);
487 assert.strictEqual(result, true);
488 });
489 it('accepts profile', async function () {
490 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
491 ctx.session = {
492 authenticatedProfile: 'user',
493 };
494 const result = await authenticator.sessionRequired(req, res, ctx, loginPath);
495 assert.strictEqual(result, true);
496 });
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);
503 });
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);
511 });
512 }); // sessionRequired
513 describe('sessionOptionalLocal', function () {
514 it('rejects profile', async function () {
515 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
516 ctx.session = {
517 authenticatedProfile: 'user',
518 };
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);
523 });
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);
532 });
533 }); // sessionOptional
534 }); // convenience wrappers
535 }); // sessionCheck
536
537 describe('apiRequiredLocal', function () {
538 let req, res;
539 beforeEach(function () {
540 ctx = {};
541 req = {
542 getHeader: sinon.stub(),
543 };
544 res = {
545 end: sinon.stub(),
546 setHeader: sinon.stub(),
547 };
548 });
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);
557 });
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',
564 statusCode: 401,
565 });
566 assert(!authenticator.sessionCheck.called);
567 assert(authenticator.isValidAuthorization.called);
568 });
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);
577 });
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',
583 statusCode: 401,
584 });
585 assert(!authenticator.sessionCheck.called);
586 assert(!authenticator.isValidAuthorization.called);
587 assert(res.setHeader.called);
588 });
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));
593 });
594 }); // apiRequiredLocal
595
596 }); // Authenticator