update changelog
[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(t, a, m) {
14 if (!a.authn[m]) { // eslint-disable-line security/detect-object-injection
15 t.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(this, 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(this, 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(this, 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(this, 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(this, 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(this, 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(this, 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 authenticator.authn.pam.pamAuthenticatePromise.rejects(new authenticator.authn.pam.PamError());
271 const result = await authenticator._isValidPAMIdentifier(authData, credential);
272 assert.strictEqual(result, false);
273 });
274 it('covers error', async function () {
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