rudimentary support for totp 2fa
[squeep-authentication-module] / test / lib / session-manager.js
1 /* eslint-env mocha */
2 /* eslint-disable capitalized-comments, sonarjs/no-duplicate-string, sonarjs/no-identical-functions */
3
4 'use strict';
5
6 const assert = require('assert');
7 const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
8
9 const SessionManager = require('../../lib/session-manager');
10 const Enum = require('../../lib/enum');
11 const Config = require('../stub-config');
12 const stubLogger = require('../stub-logger');
13
14 describe('SessionManager', function () {
15 let manager, options, stubAuthenticator;
16 let res, ctx;
17
18 beforeEach(function () {
19 options = new Config('test');
20 res = {
21 end: sinon.stub(),
22 setHeader: sinon.stub(),
23 };
24 ctx = {
25 cookie: '',
26 params: {},
27 queryParams: {},
28 parsedBody: {},
29 errors: [],
30 };
31 stubAuthenticator = {
32 isValidIdentifierCredential: sinon.stub(),
33 checkOTP: sinon.stub(),
34 };
35 manager = new SessionManager(stubLogger, stubAuthenticator, options);
36 sinon.stub(manager.indieAuthCommunication);
37 stubLogger._reset();
38 });
39 afterEach(function () {
40 sinon.restore();
41 });
42
43 describe('_sessionCookieSet', function () {
44 let session, maxAge;
45 beforeEach(function () {
46 session = {};
47 maxAge = 86400;
48 });
49 it('covers', async function () {
50 await manager._sessionCookieSet(res, session, maxAge);
51 assert(res.setHeader.called);
52 });
53 it('covers reset', async function () {
54 session = undefined;
55 maxAge = 0;
56 await manager._sessionCookieSet(res, session, maxAge);
57 assert(res.setHeader.called);
58 });
59 it('covers options', async function() {
60 options.authenticator.secureAuthOnly = false;
61 await manager._sessionCookieSet(res, session, undefined, '');
62 assert(res.setHeader.called);
63 });
64 }); // _sessionCookieSet
65
66 describe('getAdminLogin', function () {
67 it('covers no session', async function () {
68 await manager.getAdminLogin(res, ctx);
69 });
70 it('covers established session', async function () {
71 ctx.authenticationId = 'identifier';
72 ctx.queryParams['r'] = '/admin';
73 await manager.getAdminLogin(res, ctx);
74 assert.strictEqual(res.statusCode, 302);
75 assert(res.setHeader.called);
76 });
77 }); // getAdminLogin
78
79 describe('postAdminLogin', function () {
80 beforeEach(function () {
81 sinon.stub(manager, '_otpSubmission').resolves(false);
82 });
83 it('covers otp submission', async function () {
84 manager._otpSubmission.resolves(true);
85 await manager.postAdminLogin(res, ctx);
86 assert(res.end.notCalled);
87 });
88 it('covers valid local', async function () {
89 ctx.parsedBody.identifier = 'user';
90 ctx.parsedBody.credential = 'password';
91 manager.authenticator.isValidIdentifierCredential.resolves(true);
92 await manager.postAdminLogin(res, ctx);
93 assert.strictEqual(res.statusCode, 302);
94 });
95 it('covers invalid local', async function () {
96 ctx.parsedBody.identifier = 'user';
97 ctx.parsedBody.credential = 'password';
98 manager.authenticator.isValidIdentifierCredential.resolves(false);
99 await manager.postAdminLogin(res, ctx);
100 assert(!res.setHeader.called);
101 });
102 it('covers valid profile', async function () {
103 ctx.parsedBody.me = 'https://example.com/profile';
104 manager.indieAuthCommunication.fetchProfile.resolves({
105 metadata: {
106 authorizationEndpoint: 'https://example.com/auth',
107 },
108 });
109 await manager.postAdminLogin(res, ctx);
110 assert.strictEqual(res.statusCode, 302);
111 });
112 it('covers invalid profile', async function () {
113 ctx.parsedBody.me = 'not a profile';
114 manager.indieAuthCommunication.fetchProfile.resolves();
115 await manager.postAdminLogin(res, ctx);
116 assert(!res.setHeader.called);
117 });
118 it('covers invalid profile response', async function () {
119 ctx.parsedBody.me = 'https://example.com/profile';
120 manager.indieAuthCommunication.fetchProfile.resolves();
121 await manager.postAdminLogin(res, ctx);
122 assert(!res.setHeader.called);
123 });
124 it('covers invalid profile response endpoint', async function () {
125 ctx.parsedBody.me = 'https://example.com/profile';
126 manager.indieAuthCommunication.fetchProfile.resolves({
127 metadata: {
128 authorizationEndpoint: 'not an auth endpoint',
129 },
130 });
131 await manager.postAdminLogin(res, ctx);
132 assert(!res.setHeader.called);
133 });
134 it('covers profile scheme fallback', async function () {
135 ctx.parsedBody.me = 'https://example.com/profile';
136 ctx.parsedBody['me_auto_scheme'] = '1';
137 manager.indieAuthCommunication.fetchProfile
138 .onCall(0).resolves()
139 .onCall(1).resolves({
140 metadata: {
141 issuer: 'https://example.com/',
142 authorizationEndpoint: 'https://example.com/auth',
143 },
144 });
145 await manager.postAdminLogin(res, ctx);
146 assert.strictEqual(res.statusCode, 302);
147
148 });
149 describe('living-standard-20220212', function () {
150 it('covers valid profile', async function () {
151 ctx.parsedBody.me = 'https://example.com/profile';
152 manager.indieAuthCommunication.fetchProfile.resolves({
153 metadata: {
154 issuer: 'https://example.com/',
155 authorizationEndpoint: 'https://example.com/auth',
156 },
157 });
158 await manager.postAdminLogin(res, ctx);
159 assert.strictEqual(res.statusCode, 302);
160 });
161 it('covers bad issuer url', async function () {
162 ctx.parsedBody.me = 'https://example.com/profile';
163 manager.indieAuthCommunication.fetchProfile.resolves({
164 metadata: {
165 issuer: 'http://example.com/?bah#foo',
166 authorizationEndpoint: 'https://example.com/auth',
167 },
168 });
169 await manager.postAdminLogin(res, ctx);
170 assert(!res.setHeader.called);
171 });
172 it('covers unparsable issuer url', async function () {
173 ctx.parsedBody.me = 'https://example.com/profile';
174 manager.indieAuthCommunication.fetchProfile.resolves({
175 metadata: {
176 issuer: 'not a url',
177 authorizationEndpoint: 'https://example.com/auth',
178 },
179 });
180 await manager.postAdminLogin(res, ctx);
181 assert(!res.setHeader.called);
182 });
183 }); // living-standard-20220212
184 }); // postAdminLogin
185
186 describe('_otpSubmission', function () {
187 beforeEach(function () {
188 sinon.useFakeTimers(new Date());
189 sinon.stub(manager.mysteryBox, 'unpack').resolves({
190 authenticatedIdentifier: 'identifier',
191 attempt: 0,
192 epochMs: Date.now(),
193 });
194 manager.authenticator.checkOTP.resolves(Enum.OTPResult.Valid);
195 ctx.parsedBody.state = 'state_data';
196 ctx.parsedBody.otp = '123456';
197 });
198 it('returns false if no otp state', async function () {
199 delete ctx.parsedBody.state;
200 const result = await manager._otpSubmission(res, ctx);
201 assert(manager.mysteryBox.unpack.notCalled);
202 assert.strictEqual(result, false);
203 });
204 it('returns false when presented with invalid otp state', async function () {
205 manager.mysteryBox.unpack.rejects();
206 const result = await manager._otpSubmission(res, ctx);
207 assert(manager.mysteryBox.unpack.called);
208 assert.strictEqual(result, false);
209 });
210 it('returns true when submitted otp is invalid, but allowed to retry', async function () {
211 manager.authenticator.checkOTP.resolves(Enum.OTPResult.InvalidSoftFail);
212 const result = await manager._otpSubmission(res, ctx);
213 assert(manager.mysteryBox.unpack.called);
214 assert.strictEqual(result, true);
215 assert(res.end.called);
216 });
217 it('returns false when submitted otp is invalid and too many attempts', async function () {
218 manager.mysteryBox.unpack.resolves({
219 authenticatedIdentifier: 'identifier',
220 attempt: 10,
221 epochMs: Date.now(),
222 });
223 manager.authenticator.checkOTP.resolves(Enum.OTPResult.InvalidHardFail);
224 const result = await manager._otpSubmission(res, ctx);
225 assert(manager.mysteryBox.unpack.called);
226 assert.strictEqual(result, false);
227 });
228 it('returns false when submitted otp is invalid and too much time has passed', async function () {
229 manager.mysteryBox.unpack.resolves({
230 authenticatedIdentifier: 'identifier',
231 attempt: 0,
232 epochMs: Date.now() - 99999999,
233 });
234 manager.authenticator.checkOTP.resolves(Enum.OTPResult.InvalidHardFail);
235 const result = await manager._otpSubmission(res, ctx);
236 assert(manager.mysteryBox.unpack.called);
237 assert.strictEqual(result, false);
238 });
239 it('returns true when submitted otp is valid', async function () {
240 const result = await manager._otpSubmission(res, ctx);
241 assert(res.end.called);
242 assert.strictEqual(result, true);
243 });
244 it('covers unexpected otp response', async function () {
245 manager.authenticator.checkOTP.resolves('wrong');
246 assert.rejects(() => manager._otpSubmission(res, ctx), RangeError);
247 });
248 }); // _otpSubmission
249
250 describe('_localUserAuth', function () {
251 beforeEach(function () {
252 ctx.parsedBody.identifier = 'identifier';
253 ctx.parsedBody.credential = 'credential';
254 manager.authenticator.isValidIdentifierCredential.resolves(true);
255 sinon.stub(manager.mysteryBox, 'pack').resolves('box');
256 });
257 it('returns false if indieauth available', async function () {
258 ctx.parsedBody.me = 'https://example.com/';
259 const result = await manager._localUserAuth(res, ctx);
260 assert.strictEqual(result, false);
261 });
262 it('returns true if identifier is invalid', async function () {
263 manager.authenticator.isValidIdentifierCredential.resolves(false);
264 const result = await manager._localUserAuth(res, ctx);
265 assert.strictEqual(result, true);
266 assert(manager.authenticator.isValidIdentifierCredential.called);
267 assert(res.end.called);
268 });
269 it('returns true if valid identifier', async function () {
270 const result = await manager._localUserAuth(res, ctx);
271 assert.strictEqual(result, true);
272 assert(res.end.called);
273 });
274 it('returns true if valid identifier requires otp entry', async function () {
275 ctx.otpNeeded = true;
276 const result = await manager._localUserAuth(res, ctx);
277 assert.strictEqual(result, true);
278 assert(manager.mysteryBox.pack.called);
279 assert(res.end.called);
280 });
281 }); // _localUserAuth
282
283 describe('getAdminLogout', function () {
284 it('covers', async function () {
285 await manager.getAdminLogout(res, ctx);
286 });
287 }); // getAdminLogout
288
289 describe('getAdminIA', function () {
290 let state, me, authorizationEndpoint;
291 beforeEach(function () {
292 state = '4ea7e936-3427-11ec-9f4b-0025905f714a';
293 me = 'https://example.com/profile';
294 authorizationEndpoint = 'https://example.com/auth'
295 ctx.cookie = 'squeepSession=sessionCookie';
296 manager.indieAuthCommunication.redeemProfileCode.resolves({
297 me,
298 });
299 manager.indieAuthCommunication.fetchProfile.resolves({
300 metadata: {
301 authorizationEndpoint,
302 },
303 });
304 sinon.stub(manager.mysteryBox, 'unpack').resolves({
305 authorizationEndpoint,
306 state,
307 me,
308 });
309 });
310 it('covers valid', async function () {
311 ctx.queryParams['state'] = state;
312 ctx.queryParams['code'] = 'codeCodeCode';
313
314 await manager.getAdminIA(res, ctx);
315
316 assert.strictEqual(res.statusCode, 302);
317 });
318 it('covers missing cookie', async function () {
319 delete ctx.cookie;
320
321 await manager.getAdminIA(res, ctx);
322
323 assert(ctx.errors.length);
324 });
325 it('covers invalid cookie', async function () {
326 manager.mysteryBox.unpack.restore();
327 sinon.stub(manager.mysteryBox, 'unpack').rejects();
328
329 await manager.getAdminIA(res, ctx);
330
331 assert(ctx.errors.length);
332 });
333 it('covers mis-matched state', async function () {
334 ctx.queryParams['state'] = 'incorrect-state';
335 ctx.queryParams['code'] = 'codeCodeCode';
336
337 await manager.getAdminIA(res, ctx);
338
339 assert(ctx.errors.length);
340 });
341 it('relays auth endpoint errors', async function () {
342 ctx.queryParams['state'] = state;
343 ctx.queryParams['code'] = 'codeCodeCode';
344 ctx.queryParams['error'] = 'error_code';
345 ctx.queryParams['error_description'] = 'something went wrong';
346
347 await manager.getAdminIA(res, ctx);
348
349 assert(ctx.errors.length);
350 });
351 it('covers empty error_description', async function () {
352 ctx.queryParams['state'] = state;
353 ctx.queryParams['code'] = 'codeCodeCode';
354 ctx.queryParams['error'] = 'error_code';
355
356 await manager.getAdminIA(res, ctx);
357
358 assert(ctx.errors.length);
359 });
360 it('covers invalid restored session', async function () {
361 manager.mysteryBox.unpack.restore();
362 sinon.stub(manager.mysteryBox, 'unpack').resolves({
363 authorizationEndpoint: 'not a url',
364 state,
365 me,
366 });
367 ctx.queryParams['state'] = state;
368 ctx.queryParams['code'] = 'codeCodeCode';
369
370 await manager.getAdminIA(res, ctx);
371
372 assert(ctx.errors.length);
373 });
374 it('covers empty profile redemption response', async function () {
375 ctx.queryParams['state'] = state;
376 ctx.queryParams['code'] = 'codeCodeCode';
377 manager.indieAuthCommunication.redeemProfileCode.restore();
378 sinon.stub(manager.indieAuthCommunication, 'redeemProfileCode').resolves();
379
380 await manager.getAdminIA(res, ctx);
381
382 assert(ctx.errors.length);
383 });
384 it('covers missing profile in redemption response', async function () {
385 ctx.queryParams['state'] = state;
386 ctx.queryParams['code'] = 'codeCodeCode';
387 manager.indieAuthCommunication.redeemProfileCode.restore();
388 sinon.stub(manager.indieAuthCommunication, 'redeemProfileCode').resolves({
389 });
390
391 await manager.getAdminIA(res, ctx);
392
393 assert(ctx.errors.length);
394 });
395 it('covers different canonical profile response', async function () {
396 ctx.queryParams['state'] = state;
397 ctx.queryParams['code'] = 'codeCodeCode';
398 manager.indieAuthCommunication.redeemProfileCode.restore();
399 sinon.stub(manager.indieAuthCommunication, 'redeemProfileCode').resolves({
400 me: 'https://different.example.com/profile',
401 });
402
403 await manager.getAdminIA(res, ctx);
404
405 assert.strictEqual(res.statusCode, 302);
406 });
407 it('covers different canonical profile response mis-matched endpoint', async function () {
408 ctx.queryParams['state'] = state;
409 ctx.queryParams['code'] = 'codeCodeCode';
410 manager.indieAuthCommunication.redeemProfileCode.restore();
411 sinon.stub(manager.indieAuthCommunication, 'redeemProfileCode').resolves({
412 me: 'https://different.example.com/profile',
413 });
414 manager.indieAuthCommunication.fetchProfile.restore();
415 sinon.stub(manager.indieAuthCommunication, 'fetchProfile').resolves({
416 metadata: {
417 authorizationEndpoint: 'https://elsewhere.example.com/auth',
418 },
419 });
420
421 await manager.getAdminIA(res, ctx);
422
423 assert(ctx.errors.length);
424 });
425 describe('living-standard-20220212', function () {
426 beforeEach(function () {
427 manager.indieAuthCommunication.fetchProfile.resolves({
428 metadata: {
429 authorizationEndpoint,
430 issuer: 'https://example.com/',
431 },
432 });
433 manager.mysteryBox.unpack.resolves({
434 authorizationEndpoint,
435 issuer: 'https://example.com/',
436 state,
437 me,
438 });
439 });
440 it('covers valid', async function () {
441 ctx.queryParams['state'] = state;
442 ctx.queryParams['code'] = 'codeCodeCode';
443 ctx.queryParams['iss'] = 'https://example.com/';
444
445 await manager.getAdminIA(res, ctx);
446
447 assert.strictEqual(res.statusCode, 302);
448 });
449 it('covers mis-matched issuer', async function () {
450 ctx.queryParams['state'] = state;
451 ctx.queryParams['code'] = 'codeCodeCode';
452
453 await manager.getAdminIA(res, ctx);
454
455 assert(ctx.errors.length);
456 });
457 }); // living-standard-20220212
458 }); // getAdminIA
459
460 }); // SessionManager