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