update dependencies and devDependencies, address lint issues
[squeep-indie-auther] / test / src / manager.js
1 /* eslint-disable sonarjs/no-duplicate-string */
2
3 'use strict';
4
5 const assert = require('assert');
6 const sinon = require('sinon');
7
8 const Manager = require('../../src/manager');
9 const Config = require('../../config');
10 const Enum = require('../../src/enum');
11 const { ResponseError } = require('../../src/errors');
12 const { UnexpectedResult } = require('../../src/db/errors');
13 const dns = require('dns');
14
15 const StubDatabase = require('../stub-db');
16 const StubLogger = require('../stub-logger');
17
18 const expectedException = new Error('oh no');
19 const noExpectedException = 'did not get expected exception';
20
21 describe('Manager', function () {
22 let manager, options, stubDb, logger;
23 let req, res, ctx;
24
25 beforeEach(function () {
26 logger = new StubLogger();
27 logger._reset();
28 stubDb = new StubDatabase();
29 stubDb._reset();
30 options = new Config('test');
31 req = {
32 getHeader : sinon.stub(),
33 };
34 res = {
35 end: sinon.stub(),
36 setHeader: sinon.stub(),
37 statusCode: 200,
38 };
39 ctx = {
40 params: {},
41 parsedBody: {},
42 queryParams: {},
43 session: {},
44 errors: [],
45 notifications: [],
46 };
47 manager = new Manager(logger, stubDb, options);
48 sinon.stub(manager.communication, 'fetchProfile');
49 sinon.stub(manager.communication, 'fetchClientIdentifier');
50 sinon.stub(manager.communication, 'deliverTicket');
51 sinon.stub(manager.communication, 'redeemTicket');
52 sinon.stub(dns.promises, 'lookup').resolves([{ family: 4, address: '10.11.12.13' }]);
53 sinon.stub(manager.queuePublisher, 'connect');
54 sinon.stub(manager.queuePublisher, 'establishAMQPPlumbing');
55 sinon.stub(manager.queuePublisher, 'publish');
56 sinon.stub(manager.queueConsumer, 'connect');
57 sinon.stub(manager.queueConsumer, 'establishAMQPPlumbing');
58 sinon.stub(manager.queueConsumer, 'consume');
59 });
60
61 afterEach(function () {
62 sinon.restore();
63 });
64
65 describe('constructor', function () {
66 it('instantiates', function () {
67 assert(manager);
68 });
69 it('covers no queuing', function () {
70 options.queues.amqp.url = undefined;
71 manager = new Manager(logger, stubDb, options);
72 assert(manager);
73 });
74 }); // constructor
75
76 describe('initialize', function () {
77 let spy;
78 beforeEach(function () {
79 spy = sinon.spy(manager, '_connectQueues');
80 });
81 it('covers', async function () {
82 await manager.initialize();
83 assert(spy.called);
84 });
85 it('covers no queue', async function () {
86 delete options.queues.amqp.url;
87 manager = new Manager(logger, stubDb, options);
88 await manager.initialize();
89 assert(spy.notCalled);
90 });
91 }); // initialize
92
93 describe('getRoot', function () {
94 it('normal response', async function () {
95 await manager.getRoot(res, ctx);
96 assert(res.end.called);
97 });
98 }); // getRoot
99
100 describe('getMeta', function () {
101 it('normal response', async function () {
102 await manager.getMeta(res, ctx);
103 assert(res.end.called);
104 JSON.parse(res.end.args[0][0]);
105 });
106 it('covers no ticket queue', async function () {
107 delete options.queues.amqp.url;
108 manager = new Manager(logger, stubDb, options);
109 await manager.getMeta(res, ctx);
110 assert(res.end.called);
111 });
112 }); // getMeta
113
114 describe('getHealthcheck', function () {
115 it('normal response', async function () {
116 await manager.getHealthcheck(res, ctx);
117 assert(res.end.called);
118 });
119 }); // getHealthcheck
120
121 describe('getAuthorization', function () {
122 it('covers missing redirect fields', async function () {
123 await manager.getAuthorization(res, ctx);
124 assert.strictEqual(res.statusCode, 400);
125 });
126 it('requires a configured profile', async function () {
127 manager.db.profilesScopesByIdentifier.resolves({
128 profileScopes: {
129 },
130 scopeIndex: {
131 'profile': {
132 description: '',
133 profiles: [],
134 },
135 'email': {
136 description: '',
137 profiles: [],
138 },
139 },
140 profiles: [],
141 });
142 manager.communication.fetchClientIdentifier.resolves({
143 items: [],
144 });
145 ctx.authenticationId = 'username';
146 Object.assign(ctx.queryParams, {
147 'client_id': 'https://client.example.com/',
148 'redirect_uri': 'https://client.example.com/action',
149 'response_type': 'code',
150 'state': '123456',
151 'code_challenge_method': 'S256',
152 'code_challenge': 'IZ9Jmupp0tvhT37e1KxfSZQXwcAGKHuVE51Z3xf5eog',
153 'scope': 'profile email',
154 });
155 await manager.getAuthorization(res, ctx);
156 assert.strictEqual(res.statusCode, 302);
157 assert(ctx.session.error);
158 assert(res.setHeader.called);
159 });
160 it('covers valid', async function () {
161 manager.db.profilesScopesByIdentifier.resolves({
162 profileScopes: {
163 'https://profile.example.com/': {
164 'create': {
165 description: '',
166 profiles: ['https://profile.example.com'],
167 },
168 },
169 },
170 scopeIndex: {
171 'profile': {
172 description: '',
173 profiles: [],
174 },
175 'email': {
176 description: '',
177 profiles: [],
178 },
179 'create': {
180 description: '',
181 profiles: ['https://profile.example.com/'],
182 },
183 },
184 profiles: ['https://profile.example.com/'],
185 });
186 manager.communication.fetchClientIdentifier.resolves({
187 items: [],
188 });
189 ctx.authenticationId = 'username';
190 Object.assign(ctx.queryParams, {
191 'client_id': 'https://client.example.com/',
192 'redirect_uri': 'https://client.example.com/action',
193 'response_type': 'code',
194 'state': '123456',
195 'code_challenge_method': 'S256',
196 'code_challenge': 'IZ9Jmupp0tvhT37e1KxfSZQXwcAGKHuVE51Z3xf5eog',
197 'scope': 'profile email',
198 'me': 'https://profile.example.com/',
199 });
200 await manager.getAuthorization(res, ctx);
201 assert.strictEqual(res.statusCode, 200);
202 assert.strictEqual(ctx.session.error, undefined);
203 assert.strictEqual(ctx.session.errorDescriptions.length, 0);
204 assert.strictEqual(ctx.notifications.length, 0);
205 });
206 it('succeeds with mismatched profile hint', async function () {
207 manager.db.profilesScopesByIdentifier.resolves({
208 profileScopes: {
209 'https://profile.example.com/': {
210 'create': {
211 description: '',
212 profiles: ['https://profile.example.com'],
213 },
214 },
215 },
216 scopeIndex: {
217 'profile': {
218 description: '',
219 profiles: [],
220 },
221 'email': {
222 description: '',
223 profiles: [],
224 },
225 'create': {
226 description: '',
227 profiles: ['https://profile.example.com/'],
228 },
229 },
230 profiles: ['https://profile.example.com/'],
231 });
232 manager.communication.fetchClientIdentifier.resolves({
233 items: [],
234 });
235 ctx.authenticationId = 'username';
236 Object.assign(ctx.queryParams, {
237 'client_id': 'https://client.example.com/',
238 'redirect_uri': 'https://client.example.com/action',
239 'response_type': 'code',
240 'state': '123456',
241 'code_challenge_method': 'S256',
242 'code_challenge': 'IZ9Jmupp0tvhT37e1KxfSZQXwcAGKHuVE51Z3xf5eog',
243 'scope': 'profile email',
244 'me': 'https://somethingelse.example.com/',
245 });
246 await manager.getAuthorization(res, ctx);
247 assert(!('me' in ctx.session));
248 assert.strictEqual(res.statusCode, 200);
249 assert.strictEqual(ctx.session.error, undefined);
250 assert.strictEqual(ctx.session.errorDescriptions.length, 0);
251 });
252 it('covers invalid redirect', async function () {
253 manager.db.profilesScopesByIdentifier.resolves({
254 profileScopes: {
255 'https://profile.example.com/': {
256 'create': {
257 description: '',
258 profiles: ['https://profile.example.com'],
259 },
260 },
261 },
262 scopeIndex: {
263 'profile': {
264 description: '',
265 profiles: [],
266 },
267 'email': {
268 description: '',
269 profiles: [],
270 },
271 'create': {
272 description: '',
273 profiles: ['https://profile.example.com/'],
274 },
275 },
276 profiles: ['https://profile.example.com/'],
277 });
278 manager.communication.fetchClientIdentifier.resolves({
279 items: [],
280 });
281 ctx.authenticationId = 'username';
282 Object.assign(ctx.queryParams, {
283 'client_id': 'https://client.example.com/',
284 'redirect_uri': 'https://client.example.com/action',
285 'response_type': 'blargl',
286 'state': '',
287 'code_challenge_method': 'S256',
288 'code_challenge': 'IZ9Jmupp0tvhT37e1KxfSZQXwcAGKHuVE51Z3xf5eog',
289 });
290 await manager.getAuthorization(res, ctx);
291 assert.strictEqual(res.statusCode, 302);
292 assert.strictEqual(ctx.session.error, 'invalid_request');
293 assert.strictEqual(ctx.session.errorDescriptions.length, 2);
294 });
295 it('covers legacy non-PKCE missing fields', async function () {
296 manager.db.profilesScopesByIdentifier.resolves({
297 profileScopes: {
298 'https://profile.example.com/': {
299 'create': {
300 description: '',
301 profiles: ['https://profile.example.com'],
302 },
303 },
304 },
305 scopeIndex: {
306 'profile': {
307 description: '',
308 profiles: [],
309 },
310 'email': {
311 description: '',
312 profiles: [],
313 },
314 'create': {
315 description: '',
316 profiles: ['https://profile.example.com/'],
317 },
318 },
319 profiles: ['https://profile.example.com/'],
320 });
321 manager.communication.fetchClientIdentifier.resolves({
322 items: [],
323 });
324 ctx.authenticationId = 'username';
325 Object.assign(ctx.queryParams, {
326 'client_id': 'https://client.example.com/',
327 'redirect_uri': 'https://client.example.com/action',
328 'response_type': 'code',
329 'state': '123456',
330 'scope': 'profile email',
331 'me': 'https://profile.example.com/',
332 });
333 manager.options.manager.allowLegacyNonPKCE = true;
334
335 await manager.getAuthorization(res, ctx);
336 assert.strictEqual(res.statusCode, 200);
337 assert.strictEqual(ctx.session.error, undefined);
338 assert.strictEqual(ctx.session.errorDescriptions.length, 0);
339 });
340 it('rejects legacy non-PKCE not missing all fields', async function () {
341 manager.db.profilesScopesByIdentifier.resolves({
342 profileScopes: {
343 'https://profile.example.com/': {
344 'create': {
345 description: '',
346 profiles: ['https://profile.example.com'],
347 },
348 },
349 },
350 scopeIndex: {
351 'profile': {
352 description: '',
353 profiles: [],
354 },
355 'email': {
356 description: '',
357 profiles: [],
358 },
359 'create': {
360 description: '',
361 profiles: ['https://profile.example.com/'],
362 },
363 },
364 profiles: ['https://profile.example.com/'],
365 });
366 manager.communication.fetchClientIdentifier.resolves({
367 items: [],
368 });
369 ctx.authenticationId = 'username';
370 Object.assign(ctx.queryParams, {
371 'client_id': 'https://client.example.com/',
372 'redirect_uri': 'https://client.example.com/action',
373 'response_type': 'code',
374 'code_challenge_method': 'S256',
375 'state': '123456',
376 'scope': 'profile email',
377 'me': 'https://profile.example.com/',
378 });
379 manager.options.manager.allowLegacyNonPKCE = true;
380
381 await manager.getAuthorization(res, ctx);
382 assert.strictEqual(res.statusCode, 302);
383 assert.strictEqual(ctx.session.error, 'invalid_request');
384 assert.strictEqual(ctx.session.errorDescriptions.length, 1);
385 });
386 it('rejects legacy non-PKCE not missing all fields', async function () {
387 manager.db.profilesScopesByIdentifier.resolves({
388 profileScopes: {
389 'https://profile.example.com/': {
390 'create': {
391 description: '',
392 profiles: ['https://profile.example.com'],
393 },
394 },
395 },
396 scopeIndex: {
397 'profile': {
398 description: '',
399 profiles: [],
400 },
401 'email': {
402 description: '',
403 profiles: [],
404 },
405 'create': {
406 description: '',
407 profiles: ['https://profile.example.com/'],
408 },
409 },
410 profiles: ['https://profile.example.com/'],
411 });
412 manager.communication.fetchClientIdentifier.resolves({
413 items: [],
414 });
415 ctx.authenticationId = 'username';
416 Object.assign(ctx.queryParams, {
417 'client_id': 'https://client.example.com/',
418 'redirect_uri': 'https://client.example.com/action',
419 'response_type': 'code',
420 'code_challenge': 'xxx',
421 'state': '123456',
422 'scope': 'profile email',
423 'me': 'https://profile.example.com/',
424 });
425 manager.options.manager.allowLegacyNonPKCE = true;
426
427 await manager.getAuthorization(res, ctx);
428 assert.strictEqual(res.statusCode, 302);
429 assert.strictEqual(ctx.session.error, 'invalid_request');
430 assert.strictEqual(ctx.session.errorDescriptions.length, 1);
431 });
432 }); // getAuthorization
433
434 describe('_setError', function () {
435 it('covers', function () {
436 const err = 'invalid_request';
437 const errDesc = 'something went wrong';
438 Manager._setError(ctx, err, errDesc);
439 });
440 it('covers bad error', function () {
441 const err = 'floopy';
442 const errDesc = 'something went wrong';
443 try {
444 Manager._setError(ctx, err, errDesc);
445 assert.fail(noExpectedException);
446 } catch (e) {
447 assert(e instanceof RangeError);
448 }
449 });
450 it('covers invalid error description', function () {
451 const err = 'invalid_scope';
452 const errDesc = 'something "went wrong"!';
453 try {
454 Manager._setError(ctx, err, errDesc);
455 assert.fail(noExpectedException);
456 } catch (e) {
457 assert(e instanceof RangeError);
458 }
459 });
460 }); // _setError
461
462 describe('_clientIdRequired', function () {
463 let clientIdentifier;
464 beforeEach(function () {
465 clientIdentifier = {
466 // h-card here
467 };
468 manager.communication.fetchClientIdentifier.resolves(clientIdentifier);
469 });
470 it('covers valid', async function () {
471 ctx.queryParams['client_id'] = 'https://client.example.com/';
472
473 await manager._clientIdRequired(ctx);
474
475 assert.deepStrictEqual(ctx.session.clientIdentifier, clientIdentifier);
476 assert.strictEqual(ctx.session.error, undefined);
477 assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
478 });
479 it('requires client_id', async function () {
480 ctx.queryParams['client_id'] = undefined;
481
482 await manager._clientIdRequired(ctx);
483
484 assert(ctx.session.error);
485 assert(ctx.session.errorDescriptions.length);
486 });
487 it('requires valid client_id', async function () {
488 ctx.queryParams['client_id'] = 'not a url';
489
490 await manager._clientIdRequired(ctx);
491
492 assert(ctx.session.error);
493 assert(ctx.session.errorDescriptions.length);
494 });
495 it('rejects strange schema', async function () {
496 ctx.queryParams['client_id'] = 'file:///etc/shadow';
497
498 await manager._clientIdRequired(ctx);
499
500 assert(ctx.session.error);
501 assert(ctx.session.errorDescriptions.length);
502 });
503 it('rejects un-allowed parts', async function () {
504 ctx.queryParams['client_id'] = 'https://user:pass@client.example.com/#here';
505
506 await manager._clientIdRequired(ctx);
507
508 assert(ctx.session.error);
509 assert(ctx.session.errorDescriptions.length);
510 });
511 it('rejects relative paths', async function () {
512 ctx.queryParams['client_id'] = 'https://client.example.com/x/../y/';
513
514 await manager._clientIdRequired(ctx);
515
516 assert(ctx.session.error);
517 assert(ctx.session.errorDescriptions.length);
518 });
519 it('rejects ipv6 hostname', async function () {
520 ctx.queryParams['client_id'] = 'https://[fd12:3456:789a:1::1]/';
521
522 await manager._clientIdRequired(ctx);
523
524 assert(ctx.session.error);
525 assert(ctx.session.errorDescriptions.length);
526 });
527 it('allows ipv6 loopback hostname', async function () {
528 ctx.queryParams['client_id'] = 'https://[::1]/';
529
530 await manager._clientIdRequired(ctx);
531
532 assert.deepStrictEqual(ctx.session.clientIdentifier, clientIdentifier);
533 assert.strictEqual(ctx.session.error, undefined);
534 assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
535 });
536 it('rejects ipv4 hostname', async function () {
537 ctx.queryParams['client_id'] = 'https://10.9.8.7/';
538
539 await manager._clientIdRequired(ctx);
540
541 assert(ctx.session.error);
542 assert(ctx.session.errorDescriptions.length);
543 });
544 it('allows ipv4 loopback hostname', async function () {
545 ctx.queryParams['client_id'] = 'https:/127.0.10.100/';
546
547 await manager._clientIdRequired(ctx);
548
549 assert.deepStrictEqual(ctx.session.clientIdentifier, clientIdentifier);
550 assert.strictEqual(ctx.session.error, undefined);
551 assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
552 });
553 it('requires response', async function () {
554 manager.communication.fetchClientIdentifier.restore();
555 sinon.stub(manager.communication, 'fetchClientIdentifier').resolves();
556 ctx.queryParams['client_id'] = 'https://client.example.com/';
557
558 await manager._clientIdRequired(ctx);
559
560 assert(ctx.session.error);
561 assert(ctx.session.errorDescriptions.length);
562 });
563 }); // _clientIdRequired
564
565 describe('_redirectURIRequired', function () {
566 beforeEach(function () {
567 ctx.session.clientId = new URL('https://client.example.com/');
568 ctx.session.clientIdentifier = {
569 rels: {
570 'redirect_uri': ['https://alternate.example.com/', 'https://other.example.com/'],
571 },
572 };
573 });
574 it('covers valid', function () {
575 ctx.queryParams['redirect_uri'] = 'https://client.example.com/return';
576
577 Manager._redirectURIRequired(ctx);
578
579 assert.strictEqual(ctx.session.error, undefined);
580 assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
581 });
582 it('requires redirect_uri', function () {
583 ctx.queryParams['redirect_uri'] = undefined;
584
585 Manager._redirectURIRequired(ctx);
586
587 assert(ctx.session.error);
588 assert(ctx.session.errorDescriptions.length);
589 });
590 it('requires valid redirect_uri', function () {
591 ctx.queryParams['redirect_uri'] = 'not a url';
592
593 Manager._redirectURIRequired(ctx);
594
595 assert(ctx.session.error);
596 assert(ctx.session.errorDescriptions.length);
597 });
598 it('rejects no matching alternate redirect_uri from client_id', function () {
599 ctx.queryParams['redirect_uri'] = 'https://unlisted.example.com/';
600
601 Manager._redirectURIRequired(ctx);
602
603 assert(ctx.session.error);
604 assert(ctx.session.errorDescriptions.length);
605 });
606 it('allows alternate redirect_uri from client_id', function () {
607 ctx.queryParams['redirect_uri'] = 'https://alternate.example.com/';
608
609 Manager._redirectURIRequired(ctx);
610
611 assert.strictEqual(ctx.session.error, undefined);
612 assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
613 });
614 }); // _redirectURIRequired
615
616 describe('_responseTypeRequired', function () {
617 it('covers valid', function () {
618 ctx.queryParams['response_type'] = 'code';
619
620 Manager._responseTypeRequired(ctx);
621
622 assert.strictEqual(ctx.session.error, undefined);
623 assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
624 });
625 it('requires response_type', function () {
626 ctx.queryParams['response_type'] = undefined;
627
628 Manager._responseTypeRequired(ctx);
629
630 assert(ctx.session.error);
631 assert(ctx.session.errorDescriptions.length);
632 });
633 it('rejects invalid', function () {
634 ctx.queryParams['response_type'] = 'flarp';
635
636 Manager._responseTypeRequired(ctx);
637
638 assert(ctx.session.error);
639 assert(ctx.session.errorDescriptions.length);
640 });
641 }); // _responseTypeRequired
642
643 describe('_stateRequired', function () {
644 it('covers valid', function () {
645 ctx.queryParams['state'] = 'StateStateState';
646
647 Manager._stateRequired(ctx);
648
649 assert.strictEqual(ctx.session.error, undefined);
650 assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
651 });
652 it('requires state', function () {
653 ctx.queryParams['state'] = undefined;
654
655 Manager._stateRequired(ctx);
656
657 assert(ctx.session.error);
658 assert(ctx.session.errorDescriptions.length);
659 });
660 }); // _stateRequired
661
662 describe('_codeChallengeMethodRequired', function () {
663 it('covers valid', function () {
664 ctx.queryParams['code_challenge_method'] = 'S256';
665
666 manager._codeChallengeMethodRequired(ctx);
667
668 assert.strictEqual(ctx.session.error, undefined);
669 assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
670 });
671 it('requires code_challenge_method', function () {
672 ctx.queryParams['code_challenge_method'] = undefined;
673
674 manager._codeChallengeMethodRequired(ctx);
675
676 assert(ctx.session.error);
677 assert(ctx.session.errorDescriptions.length);
678 });
679 it('rejects invalid', function () {
680 ctx.queryParams['code_challenge_method'] = 'MD5';
681
682 manager._codeChallengeMethodRequired(ctx);
683
684 assert(ctx.session.error);
685 assert(ctx.session.errorDescriptions.length);
686 });
687 it('covers legacy non-PKCE', function () {
688 ctx.queryParams['code_challenge_method'] = undefined;
689 manager.options.manager.allowLegacyNonPKCE = true;
690
691 manager._codeChallengeMethodRequired(ctx);
692
693 assert.strictEqual(ctx.session.error, undefined);
694 assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
695 });
696 }); // _codeChallengeMethodRequired
697
698 describe('_codeChallengeRequired', function () {
699 it('covers valid', function () {
700 ctx.queryParams['code_challenge'] = 'NBKNqs1TfjQFqpewPNOstmQ5MJnLoeTTbjqtQ9JbZOo';
701
702 manager._codeChallengeRequired(ctx);
703
704 assert.strictEqual(ctx.session.error, undefined);
705 assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
706 });
707 it('requires code_challenge', function () {
708 ctx.queryParams['code_challenge'] = undefined;
709
710 manager._codeChallengeRequired(ctx);
711
712 assert(ctx.session.error);
713 assert(ctx.session.errorDescriptions.length);
714 });
715 it('rejects invalid', function () {
716 ctx.queryParams['code_challenge'] = 'not base64/url encoded';
717
718 manager._codeChallengeRequired(ctx);
719
720 assert(ctx.session.error);
721 assert(ctx.session.errorDescriptions.length);
722 });
723 it('covers legacy non-PKCE', function () {
724 ctx.queryParams['code_challenge'] = undefined;
725 manager.options.manager.allowLegacyNonPKCE = true;
726
727 manager._codeChallengeRequired(ctx);
728
729 assert.strictEqual(ctx.session.error, undefined);
730 assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
731
732 });
733 }); // _codeChallengeRequired
734
735 describe('_redirectURIRequired', function () {
736 beforeEach(function () {
737 sinon.stub(Manager, '_setError');
738 ctx.queryParams['redirect_uri'] = 'https://example.com/redirect';
739 ctx.session.clientId = new URL('https://example.com/');
740 });
741 it('requires redirect_uri', function () {
742 delete ctx.queryParams['redirect_uri'];
743 Manager._redirectURIRequired(ctx);
744 assert(Manager._setError.called);
745 });
746 it('requires valid redirect_uri', function () {
747 ctx.queryParams['redirect_uri'] = 'not a uri';
748 Manager._redirectURIRequired(ctx);
749 assert(Manager._setError.called);
750 });
751 it('sets redirectUri if no clientId', function () {
752 delete ctx.session.clientId;
753 Manager._redirectURIRequired(ctx);
754 assert(Manager._setError.notCalled);
755 assert(ctx.session.redirectUri instanceof URL);
756 });
757 it('sets redirectUri if clientId matches', function () {
758 Manager._redirectURIRequired(ctx);
759 assert(Manager._setError.notCalled);
760 assert(ctx.session.redirectUri instanceof URL);
761 });
762 it('rejects mis-matched', function () {
763 ctx.queryParams['redirect_uri'] = 'https://example.com:8080/redirect';
764 Manager._redirectURIRequired(ctx);
765 assert(Manager._setError.called);
766 assert.strictEqual(ctx.session.redirectUri, undefined);
767 });
768 it('allows client-specified alternate redirect uri', function () {
769 ctx.session.clientIdentifier = {
770 rels: {
771 'redirect_uri': ['https://alternate.example.com/redirect'],
772 },
773 };
774 ctx.queryParams['redirect_uri'] = 'https://alternate.example.com/redirect';
775 Manager._redirectURIRequired(ctx);
776 assert(Manager._setError.notCalled);
777 assert(ctx.session.redirectUri instanceof URL);
778 });
779 }); // _redirectURIRequired
780
781 describe('_scopeOptional', function () {
782 it('covers valid', function () {
783 ctx.queryParams['scope'] = 'profile email';
784 manager._scopeOptional(ctx);
785 assert.strictEqual(ctx.session.error, undefined);
786 assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
787 assert.strictEqual(ctx.session.scope.length, 2);
788 });
789 it('allows empty', function () {
790 ctx.queryParams['scope'] = undefined;
791 manager._scopeOptional(ctx);
792 assert.strictEqual(ctx.session.error, undefined);
793 assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
794 assert.strictEqual(ctx.session.scope.length, 0);
795 });
796 it('rejects invalid scope combination', function () {
797 ctx.queryParams['scope'] = 'email';
798 manager._scopeOptional(ctx);
799 assert(ctx.session.error);
800 assert(ctx.session.errorDescriptions.length);
801 });
802 it('ignores invalid scope', function () {
803 ctx.queryParams['scope'] = 'profile email "funny_business"';
804 manager._scopeOptional(ctx);
805 assert.strictEqual(ctx.session.error, undefined);
806 assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
807 assert.strictEqual(ctx.session.scope.length, 2);
808 });
809 }); // _scopeOptional
810
811 describe('_meOptional', function () {
812 this.beforeEach(function () {
813 ctx.queryParams['me'] = 'https://profile.example.com/';
814 });
815 it('covers valid', async function () {
816 await manager._meOptional(ctx);
817
818 assert.strictEqual(ctx.session.me.href, ctx.queryParams['me']);
819 });
820 it('ignore invalid', async function () {
821 ctx.queryParams['me'] = 'not a url';
822
823 await manager._meOptional(ctx);
824
825 assert.strictEqual(ctx.session.me, undefined);
826 });
827 it('allows empty', async function () {
828 ctx.queryParams['me'] = undefined;
829
830 await manager._meOptional(ctx);
831
832 assert.strictEqual(ctx.session.me, undefined);
833 });
834 }); // _meOptional
835
836 describe('_profileValidForIdentifier', function () {
837 beforeEach(function () {
838 ctx.session = {
839 profiles: ['https://profile.example.com/', 'https://example.com/profile'],
840 me: new URL('https://example.com/profile'),
841 };
842 });
843 it('covers valid', async function () {
844
845 const result = await manager._profileValidForIdentifier(ctx);
846
847 assert.strictEqual(result, true);
848 });
849 it('covers missing me', async function () {
850 delete ctx.session.me;
851
852 const result = await manager._profileValidForIdentifier(ctx);
853
854 assert.strictEqual(result, false);
855 });
856 }); // _profileValidForIdentifier
857
858 describe('_parseLifespan', function () {
859 let field, customField;
860 beforeEach(function () {
861 field = 'lifespan';
862 customField = 'lifespan-seconds';
863 ctx.parsedBody['lifespan'] = undefined;
864 ctx.parsedBody['lifespan-seconds'] = undefined;
865 });
866 it('returns nothing without fields', function () {
867 const result = manager._parseLifespan(ctx, field, customField);
868 assert.strictEqual(result, undefined);
869 });
870 it('returns nothing for unrecognized field', function () {
871 ctx.parsedBody['lifespan'] = 'a while';
872 const result = manager._parseLifespan(ctx, field, customField);
873 assert.strictEqual(result, undefined);
874 });
875 it('returns recognized preset value', function () {
876 ctx.parsedBody['lifespan'] = '1d';
877 const result = manager._parseLifespan(ctx, field, customField);
878 assert.strictEqual(result, 86400);
879 });
880 it('returns valid custom value', function () {
881 ctx.parsedBody['lifespan'] = 'custom';
882 ctx.parsedBody['lifespan-seconds'] = '123';
883 const result = manager._parseLifespan(ctx, field, customField);
884 assert.strictEqual(result, 123);
885 });
886 it('returns nothing for invalid custom value', function () {
887 ctx.parsedBody['lifespan'] = 'custom';
888 ctx.parsedBody['lifespan-seconds'] = 'Not a number';
889 const result = manager._parseLifespan(ctx, field, customField);
890 assert.strictEqual(result, undefined);
891 });
892 it('returns nothing for invalid custom value', function () {
893 ctx.parsedBody['lifespan'] = 'custom';
894 ctx.parsedBody['lifespan-seconds'] = '-50';
895 const result = manager._parseLifespan(ctx, field, customField);
896 assert.strictEqual(result, undefined);
897 });
898 }); // _parseLifespan
899
900 describe('_parseConsentScopes', function () {
901 it('covers no scopes', function () {
902 const result = manager._parseConsentScopes(ctx);
903 assert.deepStrictEqual(result, []);
904 });
905 it('filters invalid scopes', function () {
906 ctx.parsedBody['accepted_scopes[]'] = ['read', 'email'];
907 ctx.parsedBody['ad_hoc_scopes'] = 'bad"scope create ';
908 const result = manager._parseConsentScopes(ctx);
909 assert.deepStrictEqual(result, ['read', 'create']);
910 });
911 }); // _parseConsentScopes
912
913 describe('_parseConsentMe', function () {
914 beforeEach(function () {
915 ctx.session.profiles = ['https://me.example.com/'];
916 });
917 it('covers valid', function () {
918 const expected = 'https://me.example.com/';
919 ctx.parsedBody['me'] = expected;
920 const result = manager._parseConsentMe(ctx);
921 assert(result);
922 assert.strictEqual(result.href, expected);
923 });
924 it('rejects unsupported', function () {
925 ctx.parsedBody['me'] = 'https://notme.example.com/';
926 const result = manager._parseConsentMe(ctx);
927 assert(!result);
928 assert(ctx.session.error);
929 });
930 it('rejects invalid', function () {
931 ctx.parsedBody['me'] = 'bagel';
932 const result = manager._parseConsentMe(ctx);
933 assert(!result);
934 assert(ctx.session.error);
935 });
936 }); // _parseConsentMe
937
938 describe('_fetchConsentProfileData', function () {
939 let profileResponse;
940 beforeEach(function () {
941 profileResponse = {
942 url: 'https://profile.example.com/',
943 };
944 manager.communication.fetchProfile.resolves(profileResponse);
945 });
946 it('covers success', async function () {
947 const expected = profileResponse;
948 const result = await manager._fetchConsentProfileData(ctx);
949 assert.deepStrictEqual(result, expected);
950 assert(!ctx.session.error);
951 });
952 it('covers empty response', async function () {
953 manager.communication.fetchProfile.resolves();
954 const result = await manager._fetchConsentProfileData(ctx);
955 assert.deepStrictEqual(result, undefined);
956 assert(ctx.session.error);
957 });
958 it('covers failure', async function () {
959 manager.communication.fetchProfile.rejects();
960 const result = await manager._fetchConsentProfileData(ctx);
961 assert.deepStrictEqual(result, undefined);
962 assert(ctx.session.error);
963 });
964 }); // _fetchConsentProfileData
965
966 describe('postConsent', function () {
967 let oldSession;
968 beforeEach(function () {
969 sinon.stub(manager.mysteryBox, 'unpack');
970 sinon.stub(manager.mysteryBox, 'pack');
971 manager.communication.fetchProfile.resolves({
972 url: 'https://profile.example.com/',
973 });
974 oldSession = {
975 clientId: 'https://example.com/',
976 redirectUri: 'https://example.com/_redirect',
977 profiles: ['https://profile.example.com/'],
978 };
979 manager.mysteryBox.unpack.resolves(oldSession);
980 ctx.parsedBody['me'] = 'https://profile.example.com/';
981 ctx.parsedBody['accept'] = 'true';
982 });
983 it('covers valid', async function () {
984 await manager.postConsent(res, ctx);
985 assert(!ctx.session.error, ctx.session.error);
986 assert.strictEqual(res.statusCode, 302);
987 });
988 it('covers valid with expiration and refresh', async function () {
989 ctx.parsedBody['expires'] = '1d';
990 ctx.parsedBody['refresh'] = '1w';
991 await manager.postConsent(res, ctx);
992 assert(!ctx.session.error, ctx.session.error);
993 assert.strictEqual(res.statusCode, 302);
994 });
995 it('covers denial', async function () {
996 ctx.parsedBody['accept'] = 'false';
997 await manager.postConsent(res, ctx);
998 assert(ctx.session.error);
999 assert.strictEqual(ctx.session.error, 'access_denied');
1000 assert.strictEqual(res.statusCode, 302);
1001 });
1002 it('covers profile fetch failure', async function () {
1003 manager.communication.fetchProfile.resolves();
1004 await manager.postConsent(res, ctx);
1005 assert.strictEqual(res.statusCode, 302);
1006 assert(ctx.session.error);
1007 });
1008 it('covers bad code', async function () {
1009 manager.mysteryBox.unpack.rejects();
1010 await manager.postConsent(res, ctx);
1011 assert.strictEqual(res.statusCode, 400);
1012 assert(ctx.session.error);
1013 });
1014 it('removes email scope without profile', async function () {
1015 ctx.parsedBody['accepted_scopes[]'] = ['email', 'create'];
1016 await manager.postConsent(res, ctx);
1017 assert(!ctx.session.acceptedScopes.includes('email'));
1018 });
1019 it('merges valid ad-hoc scopes', async function () {
1020 ctx.parsedBody['accepted_scopes[]'] = ['email', 'create'];
1021 ctx.parsedBody['ad_hoc_scopes'] = ' my:scope "badScope';
1022 await manager.postConsent(res, ctx);
1023 assert(ctx.session.acceptedScopes.includes('my:scope'));
1024 });
1025 it('covers invalid selected me profile', async function () {
1026 ctx.parsedBody['me'] = 'https://different.example.com/';
1027 await manager.postConsent(res, ctx);
1028 assert(ctx.session.error);
1029 });
1030 it('covers invalid me url', async function () {
1031 ctx.parsedBody['me'] = 'bagel';
1032 await manager.postConsent(res, ctx);
1033 assert(ctx.session.error);
1034 });
1035 it('covers profile fetch error', async function () {
1036 manager.communication.fetchProfile.rejects(expectedException);
1037 await manager.postConsent(res, ctx);
1038 assert.strictEqual(res.statusCode, 302);
1039 assert(ctx.session.error);
1040 });
1041 }); // postConsent
1042
1043 describe('postAuthorization', function () {
1044 let code, parsedBody;
1045 beforeEach(function () {
1046 sinon.stub(manager.mysteryBox, 'unpack');
1047 code = {
1048 codeId: 'cffe1558-35f0-11ec-98bc-0025905f714a',
1049 codeChallengeMethod: 'S256',
1050 codeChallenge: 'iMnq5o6zALKXGivsnlom_0F5_WYda32GHkxlV7mq7hQ',
1051 clientId: 'https://app.example.com/',
1052 redirectUri: 'https://app.example.com/_redirect',
1053 acceptedScopes: ['profile'],
1054 minted: Date.now(),
1055 me: 'https://client.example.com/',
1056 identifier: 'username',
1057 profile: {
1058 name: 'Firsty McLastname',
1059 email: 'f.mclastname@example.com',
1060 },
1061 };
1062 parsedBody = {
1063 code: 'codeCodeCode',
1064 'client_id': 'https://app.example.com/',
1065 'redirect_uri': 'https://app.example.com/_redirect',
1066 'grant_type': 'authorization_code',
1067 'code_verifier': 'verifier',
1068 };
1069 });
1070 it('covers valid', async function () {
1071 manager.db.redeemCode.resolves(true);
1072 manager.mysteryBox.unpack.resolves(code);
1073 Object.assign(ctx.parsedBody, parsedBody);
1074
1075 await manager.postAuthorization(res, ctx);
1076 assert(!ctx.session.error, ctx.session.error);
1077 assert(!res.end.firstCall.args[0].includes('email'));
1078 });
1079 it('includes email if accepted in scope', async function () {
1080 code.acceptedScopes = ['profile', 'email'];
1081 manager.db.redeemCode.resolves(true);
1082 manager.mysteryBox.unpack.resolves(code);
1083 Object.assign(ctx.parsedBody, parsedBody);
1084
1085 await manager.postAuthorization(res, ctx);
1086 assert(!ctx.session.error);
1087 assert(res.end.firstCall.args[0].includes('email'));
1088 });
1089 it('fails if already redeemed', async function () {
1090 manager.db.redeemCode.resolves(false);
1091 manager.mysteryBox.unpack.resolves(code);
1092 Object.assign(ctx.parsedBody, parsedBody);
1093
1094 await manager.postAuthorization(res, ctx);
1095 assert(ctx.session.error);
1096 });
1097 it('covers bad request', async function () {
1098 manager.mysteryBox.unpack.rejects(expectedException);
1099 Object.assign(ctx.parsedBody, parsedBody);
1100
1101 await manager.postAuthorization(res, ctx);
1102 assert(ctx.session.error);
1103 });
1104 }); // postAuthorization
1105
1106 describe('_ingestPostAuthorizationRequest', function () {
1107 beforeEach(function () {
1108 sinon.stub(manager, '_restoreSessionFromCode');
1109 sinon.stub(manager, '_checkSessionMatchingClientId');
1110 sinon.stub(manager, '_checkSessionMatchingRedirectUri');
1111 sinon.stub(manager, '_checkGrantType');
1112 sinon.stub(manager, '_checkSessionMatchingCodeVerifier');
1113 });
1114 it('covers valid', async function () {
1115 manager._restoreSessionFromCode.callsFake((ctx) => {
1116 ctx.session = {
1117 me: 'https://profile.example.com/',
1118 minted: Date.now(),
1119 };
1120 });
1121
1122 await manager._ingestPostAuthorizationRequest(ctx);
1123 assert(!ctx.session.error);
1124 });
1125 it('requires data', async function () {
1126 delete ctx.parsedBody;
1127 await manager._ingestPostAuthorizationRequest(ctx);
1128 assert(ctx.session.error);
1129 });
1130 it('requires me field', async function () {
1131 manager._restoreSessionFromCode.callsFake((ctx) => {
1132 ctx.session = {
1133 minted: Date.now(),
1134 };
1135 });
1136 await manager._ingestPostAuthorizationRequest(ctx);
1137 assert(ctx.session.error);
1138 });
1139 it('requires minted field', async function () {
1140 manager._restoreSessionFromCode.callsFake((ctx) => {
1141 ctx.session = {
1142 me: 'https://profile.example.com/',
1143 };
1144 });
1145 await manager._ingestPostAuthorizationRequest(ctx);
1146 assert(ctx.session.error);
1147 });
1148 it('rejects expired code', async function () {
1149 manager._restoreSessionFromCode.callsFake((ctx) => {
1150 ctx.session = {
1151 me: 'https://profile.example.com/',
1152 minted: Date.now() - 86400000,
1153 };
1154 });
1155
1156 await manager._ingestPostAuthorizationRequest(ctx);
1157 assert(ctx.session.error);
1158 });
1159 }); // _ingestPostAuthorizationRequest
1160
1161 describe('_restoreSessionFromCode', function () {
1162 let unpackedCode;
1163 beforeEach(function () {
1164 sinon.stub(manager.mysteryBox, 'unpack');
1165 unpackedCode = {
1166 codeId: 'cffe1558-35f0-11ec-98bc-0025905f714a',
1167 codeChallengeMethod: 'S256',
1168 codeChallenge: 'iMnq5o6zALKXGivsnlom_0F5_WYda32GHkxlV7mq7hQ',
1169 clientId: 'https://app.example.com/',
1170 redirectUri: 'https://app.example.com/_redirect',
1171 acceptedScopes: ['profile'],
1172 minted: Date.now(),
1173 me: 'https://client.example.com/',
1174 identifier: 'username',
1175 profile: {
1176 name: 'Firsty McLastname',
1177 email: 'f.mclastname@example.com',
1178 },
1179 };
1180 });
1181 it('covers valid', async function () {
1182 ctx.parsedBody['code'] = 'codeCodeCode';
1183 manager.mysteryBox.unpack.resolves(unpackedCode);
1184 const expected = Object.assign({}, ctx, {
1185 session: unpackedCode,
1186 });
1187 await manager._restoreSessionFromCode(ctx);
1188 assert.deepStrictEqual(ctx, expected);
1189 assert(!ctx.session.error);
1190 });
1191 it('requires code', async function () {
1192 ctx.parsedBody['code'] = '';
1193 manager.mysteryBox.unpack.resolves({
1194 me: 'https://example.com/me',
1195 });
1196 await manager._restoreSessionFromCode(ctx);
1197 assert(ctx.session.error);
1198 });
1199 it('covers invalid code', async function () {
1200 ctx.parsedBody['code'] = 'codeCodeCode';
1201 manager.mysteryBox.unpack.rejects();
1202 await manager._restoreSessionFromCode(ctx);
1203 assert(ctx.session.error);
1204 });
1205 it('covers missing code fields', async function () {
1206 ctx.parsedBody['code'] = 'codeCodeCode';
1207 delete unpackedCode.clientId;
1208 manager.mysteryBox.unpack.resolves(unpackedCode);
1209 await manager._restoreSessionFromCode(ctx);
1210 assert(ctx.session.error);
1211 });
1212 it('covers legacy non-PKCE missing fields', async function () {
1213 ctx.parsedBody['code'] = 'codeCodeCode';
1214 delete unpackedCode.codeChallengeMethod;
1215 delete unpackedCode.codeChallenge;
1216 manager.mysteryBox.unpack.resolves(unpackedCode);
1217 manager.options.manager.allowLegacyNonPKCE = true;
1218 const expected = Object.assign({}, ctx, {
1219 session: unpackedCode,
1220 });
1221 await manager._restoreSessionFromCode(ctx);
1222 assert.deepStrictEqual(ctx, expected);
1223 assert(!ctx.session.error);
1224 });
1225 }); // _restoreSessionFromCode
1226
1227 describe('_checkSessionMatchingClientId', function () {
1228 it('covers valid', async function () {
1229 ctx.session = {
1230 clientId: 'https://client.example.com/',
1231 };
1232 ctx.parsedBody['client_id'] = 'https://client.example.com/';
1233
1234 manager._checkSessionMatchingClientId(ctx);
1235 assert(!ctx.session.error);
1236 });
1237 it('covers missing', async function () {
1238 ctx.session = {
1239 clientId: 'https://client.example.com/',
1240 };
1241 ctx.parsedBody['client_id'] = undefined;
1242
1243 manager._checkSessionMatchingClientId(ctx);
1244 assert(ctx.session.error);
1245 });
1246 it('covers un-parsable', async function () {
1247 ctx.session = {
1248 clientId: 'https://client.example.com/',
1249 };
1250 ctx.parsedBody['client_id'] = 'not a url';
1251
1252 manager._checkSessionMatchingClientId(ctx);
1253 assert(ctx.session.error);
1254 });
1255 it('covers mismatch', async function () {
1256 ctx.session = {
1257 clientId: 'https://client.example.com/',
1258 };
1259 ctx.parsedBody['client_id'] = 'https://otherclient.example.com/';
1260
1261 manager._checkSessionMatchingClientId(ctx);
1262 assert(ctx.session.error);
1263 });
1264 }); // _checkSessionMatchingClientId
1265
1266 describe('_checkSessionMatchingRedirectUri', function () {
1267 it('covers valid', async function () {
1268 ctx.parsedBody['redirect_uri'] = 'https://client.example.com/_redirect';
1269 ctx.session.redirectUri = 'https://client.example.com/_redirect';
1270
1271 manager._checkSessionMatchingRedirectUri(ctx);
1272 assert(!ctx.session.error);
1273 });
1274 it('requires field', async function () {
1275 ctx.parsedBody['redirect_uri'] = undefined;
1276 ctx.session.redirectUri = 'https://client.example.com/_redirect';
1277
1278 manager._checkSessionMatchingRedirectUri(ctx);
1279 assert(ctx.session.error);
1280 });
1281 it('requires valid field', async function () {
1282 ctx.parsedBody['redirect_uri'] = 'not a url';
1283 ctx.session.redirectUri = 'https://client.example.com/_redirect';
1284
1285 manager._checkSessionMatchingRedirectUri(ctx);
1286 assert(ctx.session.error);
1287 });
1288 it('requires match', async function () {
1289 ctx.parsedBody['redirect_uri'] = 'https://client.example.com/other';
1290 ctx.session.redirectUri = 'https://client.example.com/_redirect';
1291
1292 manager._checkSessionMatchingRedirectUri(ctx);
1293 assert(ctx.session.error);
1294 });
1295 }); // _checkSessionMatchingRedirectUri
1296
1297 describe('_checkGrantType', function () {
1298 it('covers valid', async function () {
1299 ctx.parsedBody['grant_type'] = 'authorization_code';
1300
1301 manager._checkGrantType(ctx);
1302 assert(!ctx.session.error);
1303 });
1304 it('allows missing, because of one client', async function () {
1305 ctx.parsedBody['grant_type'] = undefined;
1306
1307 manager._checkGrantType(ctx);
1308 assert(!ctx.session.error);
1309 });
1310 it('rejects invalid', async function () {
1311 ctx.parsedBody['grant_type'] = 'pigeon_dance';
1312
1313 manager._checkGrantType(ctx);
1314 assert(ctx.session.error);
1315 });
1316 }); // _checkGrantType
1317
1318 describe('_checkSessionMatchingCodeVerifier', function () {
1319 it('covers valid', async function () {
1320 ctx.parsedBody['code_verifier'] = 'verifier';
1321 ctx.session.codeChallengeMethod = 'S256';
1322 ctx.session.codeChallenge = 'iMnq5o6zALKXGivsnlom_0F5_WYda32GHkxlV7mq7hQ';
1323
1324 manager._checkSessionMatchingCodeVerifier(ctx);
1325 assert(!ctx.session.error);
1326 });
1327 it('requires field', async function () {
1328 ctx.parsedBody['code_verifier'] = undefined;
1329 ctx.session.codeChallengeMethod = 'S256';
1330 ctx.session.codeChallenge = 'iMnq5o6zALKXGivsnlom_0F5_WYda32GHkxlV7mq7hQ';
1331
1332 manager._checkSessionMatchingCodeVerifier(ctx);
1333 assert(ctx.session.error);
1334 });
1335 it('requires match', async function () {
1336 ctx.parsedBody['code_verifier'] = 'wrongverifier';
1337 ctx.session.codeChallengeMethod = 'S256';
1338 ctx.session.codeChallenge = 'iMnq5o6zALKXGivsnlom_0F5_WYda32GHkxlV7mq7hQ';
1339
1340 manager._checkSessionMatchingCodeVerifier(ctx);
1341 assert(ctx.session.error);
1342 });
1343 it('covers legacy non-PKCE missing fields', async function () {
1344 ctx.parsedBody['code_verifier'] = undefined;
1345 ctx.session.codeChallengeMethod = undefined;
1346 ctx.session.codeChallenge = undefined;
1347 manager.options.manager.allowLegacyNonPKCE = true;
1348
1349 manager._checkSessionMatchingCodeVerifier(ctx);
1350 assert(!ctx.session.error);
1351 });
1352 }); // _checkSessionMatchingCodeVerifier
1353
1354 describe('postToken', function () {
1355 let unpackedCode;
1356 beforeEach(function () {
1357 ctx.session.acceptedScopes = [];
1358 unpackedCode = {
1359 codeId: 'cffe1558-35f0-11ec-98bc-0025905f714a',
1360 codeChallengeMethod: 'S256',
1361 codeChallenge: 'iMnq5o6zALKXGivsnlom_0F5_WYda32GHkxlV7mq7hQ',
1362 clientId: 'https://app.example.com/',
1363 redirectUri: 'https://app.example.com/return',
1364 acceptedScopes: ['profile', 'email', 'tricks'],
1365 minted: Date.now(),
1366 me: 'https://client.example.com/',
1367 identifier: 'username',
1368 profile: {
1369 name: 'Firsty McLastname',
1370 email: 'f.mclastname@example.com',
1371 url: 'https://example.com/',
1372 },
1373 };
1374 });
1375 describe('Revocation (legacy)', function () {
1376 beforeEach(function () {
1377 sinon.stub(manager, '_revokeToken');
1378 });
1379 it('covers revocation', async function () {
1380 manager._revokeToken.resolves();
1381 ctx.parsedBody = {
1382 action: 'revoke',
1383 token: 'XXX',
1384 };
1385 await manager.postToken(req, res, ctx);
1386 assert(manager._revokeToken.called);
1387 });
1388 }); // Revocation
1389 describe('Validation (legacy)', function () {
1390 beforeEach(function () {
1391 sinon.stub(manager, '_validateToken');
1392 req.getHeader.returns({ Authorization: 'Bearer XXX' });
1393 });
1394 it('covers validation', async function () {
1395 ctx.bearer = { isValid: true };
1396 await manager.postToken(req, res, ctx);
1397 assert(manager._validateToken.called);
1398 });
1399 }); // Validation
1400 describe('Refresh', function () {
1401 beforeEach(function () {
1402 sinon.stub(manager, '_refreshToken');
1403 });
1404 it('covers refresh', async function () {
1405 ctx.parsedBody['grant_type'] = 'refresh_token';
1406 await manager.postToken(req, res, ctx);
1407 assert(manager._refreshToken.called);
1408 });
1409 }); // Refresh
1410 describe('Ticket Redemption', function () {
1411 beforeEach(function () {
1412 sinon.stub(manager, '_ticketAuthToken');
1413 });
1414 it('covers ticket', async function () {
1415 ctx.parsedBody['grant_type'] = 'ticket';
1416 await manager.postToken(req, res, ctx);
1417 assert(manager._ticketAuthToken.called);
1418 });
1419 it('covers no ticket queue', async function () {
1420 delete options.queues.amqp.url;
1421 manager = new Manager(logger, stubDb, options);
1422 sinon.stub(manager.communication, 'fetchProfile');
1423 sinon.stub(manager.communication, 'fetchClientIdentifier');
1424 sinon.stub(manager.communication, 'deliverTicket');
1425
1426 ctx.parsedBody['grant_type'] = 'ticket';
1427 await assert.rejects(() => manager.postToken(req, res, ctx), ResponseError);
1428 });
1429 }); // Ticket Redemption
1430 describe('Code Redemption', function () {
1431 beforeEach(function () {
1432 sinon.stub(manager.mysteryBox, 'unpack');
1433 sinon.spy(manager.mysteryBox, 'pack');
1434 manager.mysteryBox.unpack.resolves(unpackedCode);
1435 ctx.parsedBody = {
1436 'redirect_uri': 'https://app.example.com/return',
1437 'code': 'xxx',
1438 };
1439 });
1440 it('covers invalid code', async function () {
1441 manager.mysteryBox.unpack.rejects(expectedException);
1442 try {
1443 await manager.postToken(req, res, ctx);
1444 assert.fail(noExpectedException);
1445 } catch (e) {
1446 assert(e instanceof ResponseError);
1447 }
1448 });
1449 it('covers mismatched redirect', async function () {
1450 ctx.parsedBody['redirect_uri'] = 'https://elsewhere.example.com/';
1451 try {
1452 await manager.postToken(req, res, ctx);
1453 assert.fail(noExpectedException);
1454 } catch (e) {
1455 assert(e instanceof ResponseError);
1456 }
1457 });
1458 it('covers success', async function () {
1459 manager.db.redeemCode.resolves(true);
1460 await manager.postToken(req, res, ctx);
1461 assert(res.end.called);
1462 assert.strictEqual(manager.mysteryBox.pack.callCount, 1);
1463 });
1464 it('covers success with refresh', async function () {
1465 manager.db.redeemCode.resolves(true);
1466 unpackedCode.refreshLifespan = 86400;
1467 unpackedCode.tokenLifespan = 86400;
1468 manager.mysteryBox.unpack.resolves(unpackedCode);
1469 await manager.postToken(req, res, ctx);
1470 assert(res.end.called);
1471 assert.strictEqual(manager.mysteryBox.pack.callCount, 2);
1472 });
1473 it('covers redemption failure', async function () {
1474 manager.db.redeemCode.resolves(false);
1475 try {
1476 await manager.postToken(req, res, ctx);
1477 assert.fail(noExpectedException);
1478 } catch (e) {
1479 assert(e instanceof ResponseError);
1480 }
1481 });
1482 it('removes email from profile if not in scope', async function () {
1483 manager.db.redeemCode.resolves(true);
1484 unpackedCode.acceptedScopes = ['profile', 'tricks'];
1485 manager.mysteryBox.unpack.resolves(unpackedCode);
1486 await manager.postToken(req, res, ctx);
1487 assert(res.end.called);
1488 const response = JSON.parse(res.end.args[0][0]);
1489 assert(!('email' in response.profile));
1490 });
1491
1492 }); // Code Redemption
1493 describe('Invalid grant_type', function () {
1494 it('throws response error', async function () {
1495 ctx.parsedBody['grant_type'] = 'bad';
1496 try {
1497 await manager.postToken(req, res, ctx);
1498 assert.fail(noExpectedException);
1499 } catch (e) {
1500 assert(e instanceof ResponseError);
1501 }
1502 });
1503 }); // Invalid grant_type
1504 }); // postToken
1505
1506 describe('_validateToken', function () {
1507 let dbCtx;
1508 beforeEach(function () {
1509 dbCtx = {};
1510 sinon.stub(manager, '_checkTokenValidationRequest');
1511 });
1512 it('covers valid token', async function () {
1513 ctx.bearer = {
1514 isValid: true,
1515 };
1516 ctx.token = {
1517 };
1518 await manager._validateToken(dbCtx, req, res, ctx);
1519 assert(res.end.called);
1520 });
1521 it('covers invalid token', async function () {
1522 ctx.bearer = {
1523 isValid: false,
1524 };
1525 await assert.rejects(manager._validateToken(dbCtx, req, res, ctx), ResponseError);
1526 });
1527 it('covers errors', async function () {
1528 ctx.bearer = {
1529 isValid: false,
1530 };
1531 ctx.session.error = 'error';
1532 ctx.session.errorDescriptions = ['error_description'];
1533 await assert.rejects(manager._validateToken(dbCtx, req, res, ctx), ResponseError);
1534 });
1535 }); // _validateToken
1536
1537 describe('_checkTokenValidationRequest', function () {
1538 let dbCtx;
1539 beforeEach(function () {
1540 dbCtx = {};
1541 sinon.stub(manager.mysteryBox, 'unpack');
1542 });
1543 it('does nothing with no auth header', async function () {
1544 await manager._checkTokenValidationRequest(dbCtx, req, ctx);
1545 });
1546 it('does nothing with unknown auth header', async function () {
1547 req.getHeader.returns('flarp authy woo');
1548 await manager._checkTokenValidationRequest(dbCtx, req, ctx);
1549 });
1550 it('requires a valid auth token', async function () {
1551 manager.mysteryBox.unpack.rejects(expectedException);
1552 req.getHeader.returns('Bearer XXX');
1553 await manager._checkTokenValidationRequest(dbCtx, req, ctx);
1554 assert(ctx.session.error);
1555 });
1556 it('requires valid auth token fields', async function () {
1557 manager.mysteryBox.unpack.resolves({});
1558 req.getHeader.returns('Bearer XXX');
1559 await manager._checkTokenValidationRequest(dbCtx, req, ctx);
1560 assert(ctx.session.error);
1561 });
1562 it('covers no token', async function () {
1563 manager.mysteryBox.unpack.resolves({ c: 'xxx' });
1564 req.getHeader.returns('Bearer XXX');
1565 await manager._checkTokenValidationRequest(dbCtx, req, ctx);
1566 assert(ctx.session.error);
1567 });
1568 it('covers db error', async function () {
1569 manager.mysteryBox.unpack.resolves({ c: 'xxx' });
1570 manager.db.tokenGetByCodeId.rejects(expectedException);
1571 req.getHeader.returns('Bearer XXX');
1572 await assert.rejects(manager._checkTokenValidationRequest(dbCtx, req, ctx), expectedException);
1573 });
1574 it('valid token', async function () {
1575 manager.mysteryBox.unpack.resolves({ c: 'xxx' });
1576 manager.db.tokenGetByCodeId.resolves({
1577 isRevoked: false,
1578 expires: new Date(Date.now() + 86400000),
1579 });
1580 req.getHeader.returns('Bearer XXX');
1581 await manager._checkTokenValidationRequest(dbCtx, req, ctx);
1582 assert.strictEqual(ctx.bearer.isValid, true);
1583 });
1584 it('revoked token', async function () {
1585 manager.mysteryBox.unpack.resolves({ c: 'xxx' });
1586 manager.db.tokenGetByCodeId.resolves({
1587 isRevoked: true,
1588 expires: new Date(Date.now() + 86400000),
1589 });
1590 req.getHeader.returns('Bearer XXX');
1591 await manager._checkTokenValidationRequest(dbCtx, req, ctx);
1592 assert.strictEqual(ctx.bearer.isValid, false);
1593 });
1594 it('expired token', async function () {
1595 manager.mysteryBox.unpack.resolves({ c: 'xxx' });
1596 manager.db.tokenGetByCodeId.resolves({
1597 isRevoked: false,
1598 expires: new Date(Date.now() - 86400000),
1599 });
1600 req.getHeader.returns('Bearer XXX');
1601 await manager._checkTokenValidationRequest(dbCtx, req, ctx);
1602 assert.strictEqual(ctx.bearer.isValid, false);
1603 });
1604 }); // _checkTokenValidationRequest
1605
1606 describe('postIntrospection', function () {
1607 let inactiveToken, activeToken, dbResponse;
1608 beforeEach(function () {
1609 dbResponse = {
1610 profile: 'https://profile.example.com/',
1611 clientId: 'https://client.example.com/',
1612 scopes: ['scope1', 'scope2'],
1613 created: new Date(),
1614 expires: Infinity,
1615 };
1616 inactiveToken = JSON.stringify({
1617 active: false,
1618 });
1619 activeToken = JSON.stringify({
1620 active: true,
1621 me: dbResponse.profile,
1622 'client_id': dbResponse.clientId,
1623 scope: dbResponse.scopes.join(' '),
1624 iat: Math.ceil(dbResponse.created.getTime() / 1000),
1625 });
1626 sinon.stub(manager.mysteryBox, 'unpack').resolves({ c: '7e9991dc-9cd5-11ec-85c4-0025905f714a' });
1627 manager.db.tokenGetByCodeId.resolves(dbResponse);
1628 });
1629 it('covers bad token', async function () {
1630 manager.mysteryBox.unpack.rejects();
1631 await manager.postIntrospection(res, ctx);
1632 assert(res.end.called);
1633 assert.strictEqual(res.end.args[0][0], inactiveToken);
1634 });
1635 it('covers token not in db', async function () {
1636 manager.db.tokenGetByCodeId.resolves();
1637 await manager.postIntrospection(res, ctx);
1638 assert(res.end.called);
1639 assert.strictEqual(res.end.args[0][0], inactiveToken);
1640 });
1641 it('covers valid token', async function () {
1642 await manager.postIntrospection(res, ctx);
1643 assert(res.end.called);
1644 assert.strictEqual(res.end.args[0][0], activeToken);
1645 });
1646 it('covers expired token', async function () {
1647 dbResponse.expires = new Date((new Date()).getTime() - 86400000);
1648 await manager.postIntrospection(res, ctx);
1649 assert(res.end.called);
1650 assert.strictEqual(res.end.args[0][0], inactiveToken);
1651 });
1652 it('covers expiring token', async function () {
1653 dbResponse.expires = new Date((new Date()).getTime() + 86400000);
1654 activeToken = JSON.stringify({
1655 active: true,
1656 me: dbResponse.profile,
1657 'client_id': dbResponse.clientId,
1658 scope: dbResponse.scopes.join(' '),
1659 iat: Math.ceil(dbResponse.created.getTime() / 1000),
1660 exp: Math.ceil(dbResponse.expires / 1000),
1661 });
1662 await manager.postIntrospection(res, ctx);
1663 assert(res.end.called);
1664 assert.strictEqual(res.end.args[0][0], activeToken);
1665 });
1666 it('covers ticket', async function () {
1667 ctx.parsedBody['token_hint_type'] = 'ticket';
1668 const nowEpoch = Math.ceil(Date.now() / 1000);
1669 manager.mysteryBox.unpack.resolves({
1670 c: '515172ae-5b0b-11ed-a6af-0025905f714a',
1671 iss: nowEpoch - 86400,
1672 exp: nowEpoch + 86400,
1673 sub: 'https://subject.exmaple.com/',
1674 res: 'https://profile.example.com/feed',
1675 scope: ['read', 'role:private'],
1676 ident: 'username',
1677 profile: 'https://profile.example.com/',
1678 });
1679 await manager.postIntrospection(res, ctx);
1680 assert(res.end.called);
1681 });
1682 }); // postIntrospection
1683
1684 describe('_revokeToken', function () {
1685 let dbCtx;
1686 beforeEach(function () {
1687 dbCtx = {};
1688 });
1689 it('requires token field', async function () {
1690 await manager._revokeToken(dbCtx, res, ctx);
1691 assert(res.end.called);
1692 assert.strictEqual(res.statusCode, 400);
1693 });
1694 it('requires parsable token', async function () {
1695 sinon.stub(manager.mysteryBox, 'unpack').resolves({ notC: 'foop' });
1696 ctx.parsedBody['token'] = 'invalid token';
1697 ctx.parsedBody['token_type_hint'] = 'access_token';
1698 await manager._revokeToken(dbCtx, res, ctx);
1699 assert(res.end.called);
1700 assert.strictEqual(res.statusCode, 400);
1701 });
1702 it('requires parsable token', async function () {
1703 sinon.stub(manager.mysteryBox, 'unpack').resolves();
1704 ctx.parsedBody['token'] = 'invalid token';
1705 ctx.parsedBody['token_type_hint'] = 'refresh_token';
1706 await manager._revokeToken(dbCtx, res, ctx);
1707 assert(res.end.called);
1708 assert.strictEqual(res.statusCode, 400);
1709 });
1710 it('succeeds', async function () {
1711 sinon.stub(manager.mysteryBox, 'unpack').resolves({ c: '8e4aed9e-fa3e-11ec-992e-0025905f714a' });
1712 ctx.parsedBody['token'] = 'valid token';
1713 await manager._revokeToken(dbCtx, res, ctx);
1714 assert(manager.db.tokenRevokeByCodeId.called);
1715 assert(res.end.called);
1716 });
1717 it('succeeds for refresh token', async function () {
1718 sinon.stub(manager.mysteryBox, 'unpack').resolves({ rc: '8e4aed9e-fa3e-11ec-992e-0025905f714a' });
1719 ctx.parsedBody['token'] = 'valid token';
1720 await manager._revokeToken(dbCtx, res, ctx);
1721 assert(manager.db.tokenRefreshRevokeByCodeId.called);
1722 assert(res.end.called);
1723 });
1724 it('covers non-revokable token', async function () {
1725 sinon.stub(manager.mysteryBox, 'unpack').resolves({ c: '8e4aed9e-fa3e-11ec-992e-0025905f714a' });
1726 manager.db.tokenRevokeByCodeId.rejects(new UnexpectedResult());
1727 ctx.parsedBody['token'] = 'valid token';
1728 await manager._revokeToken(dbCtx, res, ctx);
1729 assert.strictEqual(res.statusCode, 404);
1730 });
1731 it('covers failure', async function () {
1732 sinon.stub(manager.mysteryBox, 'unpack').resolves({ c: '8e4aed9e-fa3e-11ec-992e-0025905f714a' });
1733 manager.db.tokenRevokeByCodeId.rejects(expectedException);
1734 ctx.parsedBody['token'] = 'valid token';
1735 ctx.parsedBody['token_type_hint'] = 'ignores_bad_hint';
1736 await assert.rejects(manager._revokeToken(dbCtx, res, ctx), expectedException, noExpectedException);
1737 });
1738 }); // _revokeToken
1739
1740 describe('_scopeDifference', function () {
1741 let previousScopes, requestedScopes;
1742 beforeEach(function () {
1743 previousScopes = ['a', 'b', 'c'];
1744 requestedScopes = ['b', 'c', 'd'];
1745 });
1746 it('covers', function () {
1747 const expected = ['a'];
1748 const result = Manager._scopeDifference(previousScopes, requestedScopes);
1749 assert.deepStrictEqual(result, expected);
1750 });
1751 }); // _scopeDifference
1752
1753 describe('_refreshToken', function () {
1754 let dbCtx;
1755 beforeEach(function () {
1756 dbCtx = {};
1757 ctx.parsedBody['client_id'] = 'https://client.example.com/';
1758 const nowEpoch = Math.ceil(Date.now() / 1000);
1759 sinon.stub(manager.mysteryBox, 'unpack').resolves({
1760 rc: '03bb8ab0-1dc7-11ed-99f2-0025905f714a',
1761 ts: nowEpoch - 86400,
1762 exp: nowEpoch + 86400,
1763 });
1764 sinon.stub(manager.mysteryBox, 'pack').resolves('newToken');
1765 const futureDate = new Date(Date.now() + 86400000);
1766 manager.db.tokenGetByCodeId.resolves({
1767 refreshExpires: futureDate,
1768 duration: 86400,
1769 clientId: 'https://client.example.com/',
1770 scopes: ['profile', 'create'],
1771 });
1772 manager.db.refreshCode.resolves({
1773 expires: futureDate,
1774 refreshExpires: futureDate,
1775 });
1776 });
1777 it('requires a token', async function () {
1778 manager.mysteryBox.unpack.rejects();
1779 await assert.rejects(() => manager._refreshToken(dbCtx, req, res, ctx), ResponseError);
1780 });
1781 it('requires token to have refresh field', async function () {
1782 manager.mysteryBox.unpack.resolves();
1783 await assert.rejects(() => manager._refreshToken(dbCtx, req, res, ctx), ResponseError);
1784 });
1785 it('requires token to exist in db', async function () {
1786 manager.db.tokenGetByCodeId.resolves();
1787 await assert.rejects(() => manager._refreshToken(dbCtx, req, res, ctx), ResponseError);
1788 });
1789 it('requires token be refreshable', async function () {
1790 manager.db.tokenGetByCodeId.resolves({
1791 refreshExpires: undefined,
1792 });
1793 await assert.rejects(() => manager._refreshToken(dbCtx, req, res, ctx), ResponseError);
1794 });
1795 it('requires refresh of token not be expired', async function () {
1796 manager.db.tokenGetByCodeId.resolves({
1797 refreshExpires: 1000,
1798 });
1799 await assert.rejects(() => manager._refreshToken(dbCtx, req, res, ctx), ResponseError);
1800 });
1801 it('requires token not to have been already refreshed', async function () {
1802 const nowEpoch = Math.ceil(Date.now() / 1000);
1803 manager.mysteryBox.unpack.resolves({
1804 rc: '03bb8ab0-1dc7-11ed-99f2-0025905f714a',
1805 ts: nowEpoch - 864000,
1806 exp: nowEpoch - 86400,
1807 });
1808 await assert.rejects(() => manager._refreshToken(dbCtx, req, res, ctx), ResponseError);
1809 });
1810 it('requires client_id requesting refresh match', async function () {
1811 ctx.parsedBody['client_id'] = 'https://wrong.example.com/';
1812 await assert.rejects(() => manager._refreshToken(dbCtx, req, res, ctx), ResponseError);
1813 });
1814 it('succeeds', async function () {
1815 await manager._refreshToken(dbCtx, req, res, ctx);
1816 assert(res.end.called);
1817 });
1818 it('covers non-expiring', async function () {
1819 manager.db.tokenGetByCodeId.resolves({
1820 refreshExpires: new Date(Date.now() + 86400000),
1821 duration: 86400,
1822 clientId: 'https://client.example.com/',
1823 scopes: ['profile', 'create'],
1824 });
1825 await manager._refreshToken(dbCtx, req, res, ctx);
1826 assert(res.end.called);
1827 });
1828 it('covers profile and email', async function () {
1829 manager.db.tokenGetByCodeId.resolves({
1830 refreshExpires: new Date(Date.now() + 86400000),
1831 duration: 86400,
1832 clientId: 'https://client.example.com/',
1833 scopes: ['profile', 'email', 'create'],
1834 });
1835 await manager._refreshToken(dbCtx, req, res, ctx);
1836 assert(res.end.called);
1837 });
1838 it('succeeds with scope reduction', async function () {
1839 ctx.parsedBody['scope'] = 'profile fancy';
1840 manager.db.tokenGetByCodeId.resolves({
1841 refreshExpires: new Date(Date.now() + 86400000),
1842 clientId: 'https://client.example.com/',
1843 scopes: ['profile', 'create'],
1844 });
1845 await manager._refreshToken(dbCtx, req, res, ctx);
1846 assert(res.end.called);
1847 });
1848 it('covers refresh failed', async function () {
1849 manager.db.refreshCode.resolves();
1850 await assert.rejects(() => manager._refreshToken(dbCtx, req, res, ctx), ResponseError);
1851 });
1852 }); // _refreshToken
1853
1854 describe('_mintTicket', function () {
1855 let dbCtx, payload;
1856 beforeEach(function () {
1857 dbCtx = {};
1858 payload = {
1859 subject: 'https://third-party.example.com/',
1860 resource: 'https://private.example.com/feed',
1861 scopes: ['read'],
1862 identifier: 'account',
1863 profile: 'https://profile.example.com/',
1864 ticketLifespanSeconds: 86400,
1865 };
1866 });
1867 it('covers', async function () {
1868 const expected = 'xxx';
1869 sinon.stub(manager.mysteryBox, 'pack').resolves(expected);
1870 const result = await manager._mintTicket(dbCtx, payload);
1871 assert.strictEqual(result, expected);
1872 });
1873 }); // _mintTicket
1874
1875 describe('_ticketAuthToken', function () {
1876 let dbCtx, ticketPayload, nowEpoch;
1877 beforeEach(function () {
1878 dbCtx = {};
1879 nowEpoch = Math.ceil(Date.now() / 1000);
1880 ticketPayload = {
1881 c: '5ec17f78-5576-11ed-b444-0025905f714a',
1882 iss: nowEpoch - 86400,
1883 exp: nowEpoch + 86400,
1884 sub: 'https://third-party.example.com/',
1885 res: 'https://private.example.com/feed',
1886 scope: ['read', 'flap'],
1887 ident: 'account',
1888 profile: 'https://profile.example.com/',
1889 };
1890 sinon.stub(manager.mysteryBox, 'unpack').resolves(ticketPayload);
1891 sinon.stub(manager.mysteryBox, 'pack').resolves('ticket');
1892 });
1893 it('covers invalid ticket', async function () {
1894 manager.mysteryBox.unpack.resolves();
1895 await assert.rejects(() => manager._ticketAuthToken(dbCtx, req, res, ctx), ResponseError);
1896 });
1897 it('covers expired ticket', async function () {
1898 manager.mysteryBox.unpack.resolves({
1899 c: '5ec17f78-5576-11ed-b444-0025905f714a',
1900 iss: nowEpoch - 172800,
1901 exp: nowEpoch - 86400,
1902 sub: 'https://third-party.example.com/',
1903 res: 'https://private.example.com/feed',
1904 scope: ['read', 'flap'],
1905 ident: 'account',
1906 profile: 'https://profile.example.com/',
1907 });
1908 await assert.rejects(() => manager._ticketAuthToken(dbCtx, req, res, ctx), ResponseError);
1909 });
1910 it('covers success', async function () {
1911 manager.db.redeemCode.resolves(true);
1912 await manager._ticketAuthToken(dbCtx, req, res, ctx);
1913 assert(res.end.called);
1914 });
1915 it('covers invalid redeem', async function () {
1916 manager.db.redeemCode.resolves(false);
1917 await assert.rejects(() => manager._ticketAuthToken(dbCtx, req, res, ctx), ResponseError);
1918 });
1919 }); // _ticketAuthToken
1920
1921 describe('postRevocation', function () {
1922 beforeEach(function () {
1923 sinon.stub(manager, '_revokeToken');
1924 });
1925 it('covers success', async function () {
1926 manager._revokeToken.resolves();
1927 await manager.postRevocation(res, ctx);
1928 assert(manager._revokeToken.called);
1929 });
1930 it('covers failure', async function () {
1931 manager._revokeToken.rejects(expectedException);
1932 await assert.rejects(manager.postRevocation(res, ctx));
1933 });
1934 }); // postRevocation
1935
1936 describe('postUserInfo', function () {
1937 beforeEach(function () {
1938 ctx.parsedBody['token'] = 'XXX';
1939 sinon.stub(manager.mysteryBox, 'unpack');
1940 });
1941 it('requires a token', async function () {
1942 delete ctx.parsedBody.token;
1943 await manager.postUserInfo(res, ctx);
1944 assert(res.end.called);
1945 assert.strictEqual(res.statusCode, 400);
1946 });
1947 it('requires a valid token', async function () {
1948 manager.mysteryBox.unpack.rejects(expectedException);
1949 await manager.postUserInfo(res, ctx);
1950 assert(res.end.called);
1951 assert.strictEqual(res.statusCode, 401);
1952 });
1953 it('requires token to have profile scope', async function () {
1954 manager.mysteryBox.unpack.resolves({});
1955 manager.db.tokenGetByCodeId.resolves({
1956 scopes: [],
1957 });
1958 await manager.postUserInfo(res, ctx);
1959 assert(res.end.called);
1960 assert.strictEqual(res.statusCode, 403);
1961 });
1962 it('succeeds', async function () {
1963 manager.mysteryBox.unpack.resolves({});
1964 manager.db.tokenGetByCodeId.resolves({
1965 scopes: ['profile', 'email'],
1966 profile: {
1967 url: 'https://example.com/',
1968 email: 'user@example.com',
1969 },
1970 });
1971 await manager.postUserInfo(res, ctx);
1972 assert(res.end.called);
1973 assert.strictEqual(res.statusCode, 200);
1974 });
1975 it('succeeds, and does not include email without scope', async function () {
1976 manager.mysteryBox.unpack.resolves({});
1977 manager.db.tokenGetByCodeId.resolves({
1978 scopes: ['profile'],
1979 profile: {
1980 url: 'https://example.com/',
1981 email: 'user@example.com',
1982 },
1983 });
1984 await manager.postUserInfo(res, ctx);
1985 assert(res.end.called);
1986 assert.strictEqual(res.statusCode, 200);
1987 const response = JSON.parse(res.end.args[0][0]);
1988 assert(!('email' in response));
1989 });
1990 }); // postUserInfo
1991
1992 describe('getAdmin', function () {
1993 beforeEach(function () {
1994 manager.db.profilesScopesByIdentifier.resolves({
1995 profileScopes: {
1996 'https://profile.example.com/': {
1997 'scope': {
1998 'scope1': {
1999 description: 'a scope',
2000 profiles: ['https://profile.example.com/'],
2001 },
2002 },
2003 },
2004 },
2005 scopeIndex: {
2006 'scope1': {
2007 description: 'a scope',
2008 profiles: ['https://profile.example.com/'],
2009 },
2010 },
2011 profiles: ['https://profile.example.com/'],
2012 });
2013 manager.db.tokensGetByIdentifier.resolves();
2014 });
2015 it('covers', async function () {
2016 await manager.getAdmin(res, ctx);
2017 });
2018 }); // getAdmin
2019
2020 describe('postAdmin', function () {
2021 beforeEach(function () {
2022 manager.db.profilesScopesByIdentifier.resolves({
2023 profileScopes: {
2024 'https://profile.example.com/': {
2025 'scope': {
2026 'scope1': {
2027 description: 'a scope',
2028 profiles: ['https://profile.example.com/'],
2029 },
2030 },
2031 },
2032 },
2033 scopeIndex: {
2034 'scope1': {
2035 description: 'a scope',
2036 profiles: ['https://profile.example.com/'],
2037 },
2038 },
2039 profiles: ['https://profile.example.com/'],
2040 });
2041 manager.db.tokensGetByIdentifier.resolves([]);
2042 manager.db.tokenRevokeByCodeId.resolves();
2043 manager.db.profileIdentifierInsert.resolves();
2044 manager.db.profileScopesSetAll.resolves();
2045 manager.communication.fetchProfile.resolves({
2046 metadata: {
2047 authorizationEndpoint: manager.selfAuthorizationEndpoint,
2048 },
2049 });
2050 });
2051 describe('save-scopes action', function () {
2052 beforeEach(function () {
2053 ctx.parsedBody['action'] = 'save-scopes';
2054 ctx.parsedBody['scopes-https://profile/example.com/[]'] = ['scope1', 'scope2'];
2055 });
2056 it('covers saving scopes', async function () {
2057 await manager.postAdmin(res, ctx);
2058 assert(ctx.notifications.length);
2059 assert(manager.db.profileScopesSetAll.called);
2060 });
2061 it('covers saving scopes error', async function () {
2062 manager.db.profileScopesSetAll.rejects();
2063 await manager.postAdmin(res, ctx);
2064 assert(ctx.errors.length);
2065 });
2066 }); // save-scopes action
2067 describe('new-profile action', function () {
2068 beforeEach(function () {
2069 ctx.parsedBody['action'] = 'new-profile';
2070 });
2071 it('covers new profile', async function () {
2072 ctx.parsedBody['profile'] = 'https://profile.example.com/';
2073 await manager.postAdmin(res, ctx);
2074 assert(ctx.notifications.length);
2075 assert(manager.db.profileIdentifierInsert.called);
2076 assert(manager.db.profileScopesSetAll.called);
2077 });
2078 it('covers invalid profile', async function () {
2079 ctx.parsedBody['action'] = 'new-profile';
2080 ctx.parsedBody['profile'] = 'not a url';
2081 await manager.postAdmin(res, ctx);
2082 assert(ctx.errors.length);
2083 });
2084 it('covers other validation failure', async function () {
2085 sinon.stub(manager.communication, 'validateProfile').rejects(expectedException);
2086 ctx.parsedBody['action'] = 'new-profile';
2087 ctx.parsedBody['profile'] = 'not a url';
2088 await manager.postAdmin(res, ctx);
2089 assert(ctx.errors.length);
2090 });
2091 it('covers mismatched profile', async function () {
2092 ctx.parsedBody['action'] = 'new-profile';
2093 ctx.parsedBody['profile'] = 'https://profile.example.com/';
2094 manager.communication.fetchProfile.resolves({
2095 metadata: {
2096 authorizationEndpoint: 'https://other.example.com/auth',
2097 },
2098 });
2099 await manager.postAdmin(res, ctx);
2100 assert(ctx.errors.length);
2101 });
2102 it('covers new profile error', async function () {
2103 ctx.parsedBody['action'] = 'new-profile';
2104 ctx.parsedBody['profile'] = 'https://profile.example.com/';
2105 manager.db.profileIdentifierInsert.rejects();
2106 await manager.postAdmin(res, ctx);
2107 assert(ctx.errors.length);
2108 });
2109 }); // new-profile action
2110 describe('new-scope action', function () {
2111 beforeEach(function () {
2112 ctx.parsedBody['action'] = 'new-scope';
2113 });
2114 it('covers new scope', async function () {
2115 ctx.parsedBody['scope'] = 'newscope';
2116 await manager.postAdmin(res, ctx);
2117 assert(ctx.notifications.length);
2118 assert(manager.db.scopeUpsert.called);
2119 });
2120 it('covers bad scope', async function () {
2121 ctx.parsedBody['scope'] = 'bad scope';
2122 await manager.postAdmin(res, ctx);
2123 assert(ctx.errors.length);
2124 });
2125 it('covers new scope error', async function () {
2126 ctx.parsedBody['scope'] = 'newscope';
2127 manager.db.scopeUpsert.rejects();
2128 await manager.postAdmin(res, ctx);
2129 assert(ctx.errors.length);
2130 });
2131 it('covers empty scope', async function () {
2132 delete ctx.parsedBody.scope;
2133 await manager.postAdmin(res, ctx);
2134 assert(!ctx.errors.length);
2135 });
2136 }); // new-scope action
2137 describe('delete-scope-* action', function () {
2138 beforeEach(function () {
2139 ctx.parsedBody['action'] = 'delete-scope-food%3Ayum';
2140 });
2141 it('covers delete', async function () {
2142 manager.db.scopeDelete.resolves(true);
2143 await manager.postAdmin(res, ctx);
2144 assert(ctx.notifications.length);
2145 assert(manager.db.scopeDelete.called);
2146 });
2147 it('covers no delete', async function () {
2148 manager.db.scopeDelete.resolves(false);
2149 await manager.postAdmin(res, ctx);
2150 assert(ctx.notifications.length);
2151 assert(manager.db.scopeDelete.called);
2152 });
2153 it('covers delete error', async function () {
2154 manager.db.scopeDelete.rejects();
2155 await manager.postAdmin(res, ctx);
2156 assert(ctx.errors.length);
2157 assert(manager.db.scopeDelete.called);
2158 });
2159 it('ignores empty scope', async function () {
2160 ctx.parsedBody['action'] = 'delete-scope-';
2161 await manager.postAdmin(res, ctx);
2162 assert(manager.db.scopeDelete.notCalled);
2163 assert(!ctx.notifications.length);
2164 assert(!ctx.errors.length);
2165 });
2166 }); // delete-scope-* action
2167 describe('revoke-* action', function () {
2168 beforeEach(function () {
2169 ctx.parsedBody['action'] = 'revoke-b1591c00-9cb7-11ec-a05c-0025905f714a';
2170 });
2171 it('covers revocation', async function () {
2172 await manager.postAdmin(res, ctx);
2173 assert(ctx.notifications.length);
2174 assert(manager.db.tokenRevokeByCodeId.called);
2175 });
2176 it('covers revocation error', async function () {
2177 manager.db.tokenRevokeByCodeId.rejects();
2178 await manager.postAdmin(res, ctx);
2179 assert(ctx.errors.length);
2180 });
2181 it('covers no code', async function () {
2182 ctx.parsedBody['action'] = 'revoke-';
2183 await manager.postAdmin(res, ctx);
2184 assert(!ctx.notifications.length);
2185 assert(!ctx.errors.length);
2186 assert(manager.db.tokenRevokeByCodeId.notCalled);
2187 });
2188 }); // revoke-* action
2189 it('covers empty action', async function () {
2190 delete ctx.parsedBody.action;
2191 await manager.postAdmin(res, ctx);
2192 assert(!ctx.errors.length);
2193 });
2194 it('covers unknown action', async function () {
2195 ctx.parsedBody['action'] = 'unsupported-action';
2196 await manager.postAdmin(res, ctx);
2197 assert(ctx.errors.length);
2198 });
2199 }); // postAdmin
2200
2201 describe('getAdminTicket', function () {
2202 it('covers', async function () {
2203 manager.db.profilesScopesByIdentifier.resolves({ scopeIndex: {} });
2204 await manager.getAdminTicket(res, ctx);
2205 assert(res.end.called);
2206 });
2207 }); // getAdminTicket
2208
2209 describe('postAdminTicket', function () {
2210 beforeEach(function () {
2211 ctx.parsedBody['action'] = 'proffer-ticket';
2212 ctx.parsedBody['scopes[]'] = ['read', 'role:private'];
2213 ctx.parsedBody['adhoc'] = 'adhoc_scope';
2214 ctx.parsedBody['profile'] = 'https://profile.example.com/';
2215 ctx.parsedBody['resource'] = 'https://profile.example.com/feed';
2216 ctx.parsedBody['subject'] = 'https://subject.example.com/';
2217 manager.db.profilesScopesByIdentifier.resolves({ scopeIndex: {} });
2218 sinon.stub(manager.mysteryBox, 'pack').resolves('ticket');
2219 manager.communication.fetchProfile.resolves({
2220 metadata: {
2221 ticketEndpoint: 'https://example.com/ticket',
2222 },
2223 });
2224 });
2225 it('covers success', async function () {
2226 await manager.postAdminTicket(res, ctx);
2227 assert(res.end.called);
2228 assert.strictEqual(ctx.errors.length, 0);
2229 assert.strictEqual(ctx.notifications.length, 1);
2230 });
2231 it('requires params', async function () {
2232 delete ctx.parsedBody['adhoc'];
2233 ctx.parsedBody['profile'] = 'bad url';
2234 ctx.parsedBody['resource'] = 'bad url';
2235 ctx.parsedBody['subject'] = 'bad url';
2236 ctx.parsedBody['scopes[]'] = ['fl"hrgl', 'email'];
2237 await manager.postAdminTicket(res, ctx);
2238 assert(res.end.called);
2239 assert.strictEqual(ctx.errors.length, 5);
2240 assert.strictEqual(ctx.notifications.length, 0);
2241 });
2242 it('ignores unknown action', async function () {
2243 ctx.parsedBody['action'] = 'prove-dough';
2244 await manager.postAdminTicket(res, ctx);
2245 assert(res.end.called);
2246 });
2247 it('covers delivery failure', async function () {
2248 manager.communication.deliverTicket.rejects(expectedException);
2249 await manager.postAdminTicket(res, ctx);
2250 assert(res.end.called);
2251 assert.strictEqual(ctx.errors.length, 1);
2252 assert.strictEqual(ctx.notifications.length, 0);
2253 });
2254 it('covers no ticket endpoint', async function () {
2255 manager.communication.fetchProfile.resolves({
2256 metadata: {
2257 },
2258 });
2259 await manager.postAdminTicket(res, ctx);
2260 assert(res.end.called);
2261 assert.strictEqual(ctx.errors.length, 1);
2262 assert.strictEqual(ctx.notifications.length, 0);
2263 });
2264 it('covers bad ticket endpoint', async function () {
2265 manager.communication.fetchProfile.resolves({
2266 metadata: {
2267 ticketEndpoint: 'not a url',
2268 },
2269 });
2270 await manager.postAdminTicket(res, ctx);
2271 assert(res.end.called);
2272 assert.strictEqual(ctx.errors.length, 1);
2273 assert.strictEqual(ctx.notifications.length, 0);
2274 });
2275 }); // postAdminTicket
2276
2277 describe('postTicket', function () {
2278 beforeEach(function () {
2279 ctx.parsedBody = {
2280 ticket: 'ticket123',
2281 resource: 'https://blog.example.com/',
2282 subject: 'https://otheruser.example.com/',
2283 iss: 'https://ia.example.com/',
2284 };
2285 });
2286 it('accepts a ticket for a known profile', async function () {
2287 manager.db.profileIsValid.resolves(true);
2288 await manager.postTicket(req, res, ctx);
2289 assert(res.end.called);
2290 assert.strictEqual(res.statusCode, 202);
2291 });
2292 it('rejects invalid resource', async function () {
2293 ctx.parsedBody.resource = 'invalid url';
2294 await assert.rejects(() => manager.postTicket(req, res, ctx), ResponseError);
2295 });
2296 it('rejects invalid subject', async function () {
2297 manager.db.profileIsValid(false);
2298 await assert.rejects(() => manager.postTicket(req, res, ctx), ResponseError);
2299 });
2300 it('covers queue publish failure', async function () {
2301 manager.db.profileIsValid.resolves(true);
2302 manager.queuePublisher.publish.rejects(expectedException);
2303 await assert.rejects(() => manager.postTicket(req, res, ctx), expectedException);
2304 });
2305 it('covers no ticket queue', async function () {
2306 delete options.queues.amqp.url;
2307 manager = new Manager(logger, stubDb, options);
2308 await assert.rejects(() => manager.postTicket(req, res, ctx), ResponseError);
2309 });
2310 it('covers no issuer', async function () {
2311 delete ctx.parsedBody.iss;
2312 manager.db.profileIsValid.resolves(true);
2313 await manager.postTicket(req, res, ctx);
2314 assert(res.end.called);
2315 assert.strictEqual(res.statusCode, 202);
2316 });
2317 it('covers bad issuer', async function () {
2318 ctx.parsedBody.iss = 'not a url';
2319 manager.db.profileIsValid.resolves(true);
2320 await manager.postTicket(req, res, ctx);
2321 assert(res.end.called);
2322 assert.strictEqual(res.statusCode, 202);
2323 });
2324
2325 }); // postTicket
2326
2327 describe('queuedTicketProcessor', function () {
2328 let channel, content;
2329 const message = () => ({
2330 content: Buffer.from(JSON.stringify(content)),
2331 });
2332 beforeEach(function () {
2333 channel = {
2334 ack: sinon.stub(),
2335 };
2336 content = {
2337 ticket: 'XXXticketXXX',
2338 resource: 'https://blog.example.com/',
2339 subject: 'https://otheruser.exmaple.com/',
2340 iss: 'https://ia.example.com/',
2341 epochMs: Date.now(),
2342 };
2343 });
2344 it('redeems a ticket', async function () {
2345 await manager.queuedTicketProcessor(channel, message());
2346 assert(manager.queuePublisher.publish.called);
2347 assert(channel.ack.called);
2348 });
2349 it('redeems a ticket, missing issuer', async function () {
2350 delete content.iss;
2351 await manager.queuedTicketProcessor(channel, message());
2352 assert(manager.queuePublisher.publish.called);
2353 assert(channel.ack.called);
2354 });
2355 it('covers bad message', async function () {
2356 await manager.queuedTicketProcessor(channel, { content: 'diddly' });
2357 assert(channel.ack.called);
2358 });
2359 it('covers bad issuer', async function () {
2360 content.iss = 'not a url';
2361 await manager.queuedTicketProcessor(channel, message());
2362 assert(manager.queuePublisher.publish.called);
2363 });
2364 it('covers bad resource', async function () {
2365 content.resource = 'not a url';
2366 await manager.queuedTicketProcessor(channel, message());
2367 assert(manager.communication.redeemTicket.notCalled);
2368 assert(manager.queuePublisher.publish.notCalled);
2369 assert(channel.ack.called);
2370 });
2371 it('covers failed redemption', async function () {
2372 const expectedException = new Error('oh no');
2373 manager.communication.redeemTicket.rejects(expectedException);
2374 assert.rejects(() => manager.queuedTicketProcessor(channel, message()), expectedException);
2375 });
2376 it('covers failed publish', async function () {
2377 const expectedException = new Error('oh no');
2378 manager.queuePublisher.publish.rejects(expectedException);
2379 assert.rejects(() => manager.queuedTicketProcessor(channel, message()), expectedException);
2380 });
2381 }); // queuedTicketProcessor
2382
2383 describe('getAdminMaintenance', function () {
2384 it('covers information', async function () {
2385 await manager.getAdminMaintenance(res, ctx);
2386 assert(res.end.called);
2387 });
2388 it('covers tasks', async function () {
2389 ctx.queryParams = {
2390 [Enum.Chore.CleanTokens]: '',
2391 };
2392 await manager.getAdminMaintenance(res, ctx);
2393 assert(res.end.called);
2394 });
2395 }); // getAdminMaintenance
2396
2397 }); // Manager