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