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