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