update devDependencies, clean up lint issues
[squeep-authentication-module] / test / lib / authenticator.js
1 'use strict';
2
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');
10
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
15 this.skip();
16 }
17 }
18
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';
24 ctx = {};
25 password = 'badPassword';
26 stubDb._reset();
27 stubLogger._reset();
28 });
29 afterEach(function () {
30 sinon.restore();
31 });
32
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',
37 });
38 });
39
40 it('covers empty realm', function () {
41 options.authenticator.basicRealm = undefined;
42 authenticator = new Authenticator(stubLogger, stubDb, options);
43 });
44
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);
51 });
52
53 it('covers invalid sameSite', function () {
54 options.authenticator.sessionCookieSameSite = 'Sometimes';
55 assert.throws(() => new Authenticator(stubLogger, stubDb, options), RangeError);
56 });
57
58 describe('createIdentifier', function () {
59 let dbCtx;
60 beforeEach(function () {
61 dbCtx = {};
62 credential = 'badpassword';
63 });
64 it('covers success', async function () {
65 const otpKey = '1234567890123456789012';
66 await authenticator.createIdentifier(dbCtx, identifier, credential, otpKey);
67 assert(authenticator.db.authenticationUpsert.called);
68 });
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
73 try {
74 await authenticator.createIdentifier(dbCtx, identifier, credential);
75 assert.fail('no expecte exception');
76 } catch (e) {
77 assert.deepStrictEqual(e, expected);
78 assert(authenticator.db.authenticationUpsert.called);
79 assert(authenticator.logger.error.called);
80 }
81 });
82 }); // createIdentifier
83
84 describe('updateCredential', function () {
85 let dbCtx, newCredential;
86 beforeEach(function () {
87 dbCtx = {};
88 newCredential = 'newpassword';
89 });
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);
94 });
95 it('covers failure', async function () {
96 const expected = new Error('foo');
97 authenticator.db.authenticationUpdateCredential.rejects(expected);
98 try {
99 await authenticator.updateCredential(dbCtx, identifier, newCredential);
100 assert.fail('no expected exception');
101 } catch (e) {
102 assert.deepStrictEqual(e, expected);
103 assert(authenticator.logger.error.called);
104 }
105 // assert.rejects was not happy to handle this for some reason
106 });
107 }); // updateCredential
108
109 describe('_secureCredential', function () {
110 beforeEach(function () {
111 credential = 'badpassword';
112 });
113 it('covers plain', async function () {
114 const result = await authenticator._secureCredential(credential, 'plain');
115 assert.strictEqual(result, '$plain$' + credential);
116 });
117 it('covers default (argon2)', async function () {
118 const result = await authenticator._secureCredential(credential);
119 assert(result.startsWith('$argon2'));
120 });
121 it('covers invalid authn', async function () {
122 const authn = 'bogus';
123 assert.rejects(async () => await authenticator._secureCredential(credential, authn), RangeError);
124 });
125 }); // _secureCredential
126
127 describe('_validateAuthDataCredential', function () {
128 let authData;
129 beforeEach(function () {
130 credential = 'badpassword';
131 authData = {};
132 });
133 it('fails if not provided a credential', async function () {
134 const result = await authenticator._validateAuthDataCredential(authData, credential);
135 assert.strictEqual(result, false);
136 });
137 it('covers plain', async function () {
138 authData.credential = '$plain$badpassword';
139 const result = await authenticator._validateAuthDataCredential(authData, credential);
140 assert.strictEqual(result, true);
141 });
142 }); // _validateAuthDataCredential
143
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({
149 identifier,
150 credential,
151 });
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);
156 });
157 it('fails', async function () {
158 _authMechanismRequired(authenticator, 'argon2');
159 authenticator.db.authenticationGet.resolves({
160 identifier,
161 credential,
162 });
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);
167 });
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);
174 });
175 it('covers unknown password hash', async function () {
176 authenticator.db.authenticationGet.resolves({
177 identifier,
178 credential: '$other$kind_of_credential',
179 });
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);
184 });
185 }); // isValidBasic
186
187 describe('isValidIdentifierCredential', function () {
188 it('succeeds', async function () {
189 _authMechanismRequired(authenticator, 'argon2');
190 authenticator.db.authenticationGet.resolves({
191 identifier,
192 credential,
193 });
194 const result = await authenticator.isValidIdentifierCredential(identifier, password, ctx);
195 assert.strictEqual(result, true);
196 assert.strictEqual(ctx.authenticationId, identifier);
197 });
198 it('succeeds with OTP', async function () {
199 const otpKey = Buffer.from('1234567890');
200 _authMechanismRequired(authenticator, 'argon2');
201 authenticator.db.authenticationGet.resolves({
202 identifier,
203 credential,
204 otpKey,
205 });
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);
210 });
211 it('fails', async function () {
212 _authMechanismRequired(authenticator, 'argon2');
213 authenticator.db.authenticationGet.resolves({
214 identifier,
215 credential,
216 });
217 const result = await authenticator.isValidIdentifierCredential(identifier, 'wrongPassword', ctx);
218 assert.strictEqual(result, false);
219 assert.strictEqual(ctx.authenticationId, undefined);
220 });
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);
226 });
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);
232 });
233 it('covers unknown password hash', async function () {
234 authenticator.db.authenticationGet.resolves({
235 identifier,
236 credential: '$other$kind_of_credential',
237 });
238 const result = await authenticator.isValidIdentifierCredential(identifier, 'wrongPassword', ctx);
239 assert.strictEqual(result, false);
240 assert.strictEqual(ctx.authenticationId, undefined);
241 });
242 it('covers PAM', async function () {
243 _authMechanismRequired(authenticator, 'pam');
244 sinon.stub(authenticator, '_isValidPAMIdentifier').resolves(true);
245 authenticator.db.authenticationGet.resolves({
246 identifier,
247 credential: '$PAM$',
248 });
249 const result = await authenticator.isValidIdentifierCredential(identifier, password, ctx);
250 assert.strictEqual(result, true);
251 assert.strictEqual(ctx.authenticationId, identifier);
252 });
253 }); // isValidIdentifierCredential
254
255 describe('_isValidPAMIdentifier', function () {
256 let authData;
257 beforeEach(function () {
258 _authMechanismRequired(authenticator, 'pam');
259 sinon.stub(authenticator.authn.pam, 'pamAuthenticatePromise');
260 authData = {
261 identifier,
262 };
263 });
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);
268 });
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);
274 });
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);
280 });
281 it('covers forbidden', async function () {
282 authData.identifier = 'root';
283 const result = await authenticator._isValidPAMIdentifier(authData, credential);
284 assert.strictEqual(result, false);
285 });
286 }); // _isValidPAMIdentifier
287
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);
295 });
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);
301 });
302 }); // isValidAuthorization
303
304 describe('requestBasic', function () {
305 it('covers', function () {
306 const res = {
307 setHeader: () => {},
308 };
309 assert.throws(() => authenticator.requestBasic(res), {
310 name: 'ResponseError',
311 statusCode: Enum.ErrorResponse.Unauthorized.statusCode,
312 });
313 });
314 }); // requestBasic
315
316 describe('isValidCookieAuth', function () {
317 beforeEach(function () {
318 sinon.stub(authenticator.mysteryBox, 'unpack');
319 ctx.cookie = {
320 squeepSession: 'dummy',
321 otherCookie: 'foo',
322 };
323 });
324 it('covers identifier success', async function () {
325 authenticator.mysteryBox.unpack.resolves({
326 authenticatedIdentifier: 'identifier',
327 });
328 const result = await authenticator.isValidCookieAuth(ctx);
329 assert.strictEqual(result, true);
330 });
331 it('covers profile success', async function () {
332 authenticator.mysteryBox.unpack.resolves({
333 authenticatedProfile: 'profile',
334 });
335 const result = await authenticator.isValidCookieAuth(ctx);
336 assert.strictEqual(result, true);
337 });
338 it('covers missing cookie', async function () {
339 delete ctx.cookie.squeepSession;
340 const result = await authenticator.isValidCookieAuth(ctx);
341 assert.strictEqual(result, false);
342 });
343 it('covers bad cookie', async function () {
344 authenticator.mysteryBox.unpack.rejects();
345 const result = await authenticator.isValidCookieAuth(ctx);
346 assert.strictEqual(result, false);
347 });
348 it('covers broken session', async function () {
349 authenticator.mysteryBox.unpack.resolves({
350 randomData: 'foo',
351 });
352 const result = await authenticator.isValidCookieAuth(ctx);
353 assert.strictEqual(result, false);
354 });
355 }); // isValidCookieAuth
356
357 describe('checkOTP', function () {
358 let state, otp;
359 this.beforeEach(function () {
360 sinon.stub(authenticator.TOTP.prototype, 'validate').returns(true);
361 state = {
362 key: '12345678901234567890123456789012',
363 attempt: 0,
364 epochMs: Date.now(),
365 };
366 otp = '000000';
367 });
368 it('covers valid OTP entry', function () {
369 const result = authenticator.checkOTP(state, otp);
370 assert.strictEqual(result, Enum.OTPResult.Valid);
371 });
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);
376 });
377 it('covers invalid OTP entry, too many failures', function () {
378 state.attempt = 10;
379 authenticator.TOTP.prototype.validate.returns(false);
380 const result = authenticator.checkOTP(state, otp);
381 assert.strictEqual(result, Enum.OTPResult.InvalidHardFail);
382 });
383 it('covers invalid OTP entry', function () {
384 state.epochMs = 0;
385 authenticator.TOTP.prototype.validate.returns(false);
386 const result = authenticator.checkOTP(state, otp);
387 assert.strictEqual(result, Enum.OTPResult.InvalidHardFail);
388 });
389 }); // checkOTP
390
391 describe('updateOTPKey', function () {
392 let dbCtx, otpKey;
393 beforeEach(function () {
394 dbCtx = {};
395 otpKey = 'CDBGB3U3B2ILECQORMINGGSZN7LXY565';
396 });
397 it('covers success', async function () {
398 await authenticator.updateOTPKey(dbCtx, identifier, otpKey);
399 assert(authenticator.db.authenticationUpdateOTPKey.called);
400 });
401 it('covers failure', async function () {
402 authenticator.db.authenticationUpdateOTPKey.rejects();
403 assert.rejects(authenticator.updateOTPKey(dbCtx, identifier, otpKey));
404 });
405 }); // updateOTPKey
406
407 describe('sessionCheck', function () {
408 let req, res, loginPath, required, profilesAllowed;
409 beforeEach(function () {
410 ctx.clientProtocol = 'https';
411 ctx.cookie = {
412 squeepSession: 'squeep_session_blob',
413 };
414 req = {
415 getHeader: sinon.stub(),
416 };
417 res = {
418 end: sinon.stub(),
419 setHeader: sinon.stub(),
420 appendHeader: sinon.stub(),
421 };
422 loginPath = '/admin/login';
423 required = true;
424 profilesAllowed = true;
425 });
426 it('covers valid cookie session', async function () {
427 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
428 ctx.session = {
429 authenticatedIdentifier: 'user',
430 };
431 const result = await authenticator.sessionCheck(req, res, ctx, loginPath, required, profilesAllowed);
432 assert.strictEqual(result, true);
433 });
434 it('covers valid insecure cookie session', async function () {
435 authenticator.secureAuthOnly = false;
436 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
437 ctx.session = {
438 authenticatedIdentifier: 'user',
439 };
440 const result = await authenticator.sessionCheck(req, res, ctx, loginPath, required, profilesAllowed);
441 assert.strictEqual(result, true);
442 });
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,
448 });
449 });
450 it('ignores insecure connection if auth not required', async function () {
451 ctx.clientProtocol = 'http';
452 required = false;
453 const result = await authenticator.sessionCheck(req, res, ctx, loginPath, required, profilesAllowed);
454 assert.strictEqual(result, false);
455 });
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);
460 });
461 describe('convenience wrappers', function () {
462 describe('sessionRequiredLocal', function () {
463 it('accepts identifier', async function () {
464 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
465 ctx.session = {
466 authenticatedIdentifier: 'user',
467 };
468 const result = await authenticator.sessionRequiredLocal(req, res, ctx, loginPath);
469 assert.strictEqual(result, true);
470 });
471 it('redirects with profile', async function () {
472 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
473 ctx.session = {
474 authenticatedProfile: 'user',
475 };
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);
480 });
481 }); // sessionRequiredLocal
482 describe('sessionRequired', function () {
483 it('accepts identifier', async function () {
484 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
485 ctx.session = {
486 authenticatedIdentifier: 'user',
487 };
488 const result = await authenticator.sessionRequired(req, res, ctx, loginPath);
489 assert.strictEqual(result, true);
490 });
491 it('accepts profile', async function () {
492 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
493 ctx.session = {
494 authenticatedProfile: 'user',
495 };
496 const result = await authenticator.sessionRequired(req, res, ctx, loginPath);
497 assert.strictEqual(result, true);
498 });
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);
505 });
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);
513 });
514 }); // sessionRequired
515 describe('sessionOptionalLocal', function () {
516 it('rejects profile', async function () {
517 sinon.stub(authenticator, 'isValidCookieAuth').resolves(true);
518 ctx.session = {
519 authenticatedProfile: 'user',
520 };
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);
525 });
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);
534 });
535 }); // sessionOptional
536 }); // convenience wrappers
537 }); // sessionCheck
538
539 describe('apiRequiredLocal', function () {
540 let req, res;
541 beforeEach(function () {
542 ctx = {};
543 req = {
544 getHeader: sinon.stub(),
545 };
546 res = {
547 end: sinon.stub(),
548 setHeader: sinon.stub(),
549 };
550 });
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);
559 });
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',
566 statusCode: 401,
567 });
568 assert(!authenticator.sessionCheck.called);
569 assert(authenticator.isValidAuthorization.called);
570 });
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);
579 });
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',
585 statusCode: 401,
586 });
587 assert(!authenticator.sessionCheck.called);
588 assert(!authenticator.isValidAuthorization.called);
589 assert(res.setHeader.called);
590 });
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));
595 });
596 }); // apiRequiredLocal
597
598 }); // Authenticator