@webex/plugin-authorization-browser-first-party 3.12.0-next.5 → 3.12.0-next.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -7
- package/dist/authorization.js +177 -21
- package/dist/authorization.js.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/package.json +13 -13
- package/src/authorization.js +185 -29
- package/src/index.js +1 -1
- package/test/unit/spec/authorization.js +516 -82
|
@@ -14,7 +14,7 @@ import {base64, patterns} from '@webex/common';
|
|
|
14
14
|
import {merge, times} from 'lodash';
|
|
15
15
|
import CryptoJS from 'crypto-js';
|
|
16
16
|
import Authorization from '@webex/plugin-authorization-browser-first-party';
|
|
17
|
-
import {Events} from '../../../src';
|
|
17
|
+
import {Events, InitialAuthorizationCodeGrantOutcomes} from '../../../src';
|
|
18
18
|
|
|
19
19
|
// Necessary to require lodash this way in order to stub the method
|
|
20
20
|
const lodash = require('lodash');
|
|
@@ -25,9 +25,13 @@ describe('plugin-authorization-browser-first-party', () => {
|
|
|
25
25
|
href = 'https://example.com',
|
|
26
26
|
csrfToken = undefined,
|
|
27
27
|
pkceVerifier = undefined,
|
|
28
|
-
config = {}
|
|
28
|
+
config = {},
|
|
29
|
+
getRandomValues = sinon.stub().callsFake((randomValues) => randomValues.fill(0))
|
|
29
30
|
) {
|
|
30
31
|
const mockWindow = {
|
|
32
|
+
crypto: {
|
|
33
|
+
getRandomValues,
|
|
34
|
+
},
|
|
31
35
|
history: {
|
|
32
36
|
replaceState(a, b, location) {
|
|
33
37
|
mockWindow.location.href = location;
|
|
@@ -104,6 +108,35 @@ describe('plugin-authorization-browser-first-party', () => {
|
|
|
104
108
|
sinon.restore();
|
|
105
109
|
});
|
|
106
110
|
|
|
111
|
+
it('exposes the initial authorization code grant outcome as readonly', () => {
|
|
112
|
+
const webex = makeWebex();
|
|
113
|
+
const changeSpy = sinon.spy();
|
|
114
|
+
|
|
115
|
+
webex.authorization.on('change:initialAuthorizationCodeGrantOutcome', changeSpy);
|
|
116
|
+
|
|
117
|
+
assert.equal(
|
|
118
|
+
webex.authorization.initialAuthorizationCodeGrantOutcome,
|
|
119
|
+
InitialAuthorizationCodeGrantOutcomes.notAttempted
|
|
120
|
+
);
|
|
121
|
+
assert.throws(() => {
|
|
122
|
+
webex.authorization.initialAuthorizationCodeGrantOutcome =
|
|
123
|
+
InitialAuthorizationCodeGrantOutcomes.success;
|
|
124
|
+
}, /derived property, it can't be set directly/);
|
|
125
|
+
|
|
126
|
+
webex.authorization._initialAuthorizationCodeGrantOutcome =
|
|
127
|
+
InitialAuthorizationCodeGrantOutcomes.success;
|
|
128
|
+
|
|
129
|
+
assert.equal(
|
|
130
|
+
webex.authorization.initialAuthorizationCodeGrantOutcome,
|
|
131
|
+
InitialAuthorizationCodeGrantOutcomes.success
|
|
132
|
+
);
|
|
133
|
+
assert.calledOnceWithExactly(
|
|
134
|
+
changeSpy,
|
|
135
|
+
webex.authorization,
|
|
136
|
+
InitialAuthorizationCodeGrantOutcomes.success
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
107
140
|
describe('#initialize()', () => {
|
|
108
141
|
describe('when there is a code in the url', () => {
|
|
109
142
|
it('exchanges it for an access token and sets ready', () => {
|
|
@@ -119,9 +152,25 @@ describe('plugin-authorization-browser-first-party', () => {
|
|
|
119
152
|
assert.calledTwice(webex.request);
|
|
120
153
|
assert.isTrue(webex.authorization.ready);
|
|
121
154
|
assert.isTrue(webex.credentials.canAuthorize);
|
|
155
|
+
assert.equal(
|
|
156
|
+
webex.authorization.initialAuthorizationCodeGrantOutcome,
|
|
157
|
+
InitialAuthorizationCodeGrantOutcomes.success
|
|
158
|
+
);
|
|
122
159
|
});
|
|
123
160
|
});
|
|
124
161
|
|
|
162
|
+
it('retains the initialization exchange outcome after logout', async () => {
|
|
163
|
+
const webex = makeWebex('http://example.com/?code=5');
|
|
164
|
+
|
|
165
|
+
await webex.authorization.when('change:ready');
|
|
166
|
+
webex.authorization.logout({noRedirect: true});
|
|
167
|
+
|
|
168
|
+
assert.equal(
|
|
169
|
+
webex.authorization.initialAuthorizationCodeGrantOutcome,
|
|
170
|
+
InitialAuthorizationCodeGrantOutcomes.success
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
125
174
|
it('validates the csrf token', () => {
|
|
126
175
|
const csrfToken = 'abcd';
|
|
127
176
|
|
|
@@ -264,8 +313,32 @@ describe('plugin-authorization-browser-first-party', () => {
|
|
|
264
313
|
'authorization: failed initial authorization code grant request',
|
|
265
314
|
error
|
|
266
315
|
);
|
|
316
|
+
assert.equal(
|
|
317
|
+
webex.authorization.initialAuthorizationCodeGrantOutcome,
|
|
318
|
+
InitialAuthorizationCodeGrantOutcomes.failure
|
|
319
|
+
);
|
|
267
320
|
});
|
|
268
321
|
});
|
|
322
|
+
|
|
323
|
+
it('retains failure when the automatic exchange promise rejects', async () => {
|
|
324
|
+
const error = new Error('exchange rejected');
|
|
325
|
+
|
|
326
|
+
sinon.stub(Authorization.prototype, 'requestAuthorizationCodeGrant').rejects(error);
|
|
327
|
+
|
|
328
|
+
const webex = makeWebex('http://example.com?code=5');
|
|
329
|
+
|
|
330
|
+
await webex.authorization.when('change:ready');
|
|
331
|
+
|
|
332
|
+
assert.equal(
|
|
333
|
+
webex.authorization.initialAuthorizationCodeGrantOutcome,
|
|
334
|
+
InitialAuthorizationCodeGrantOutcomes.failure
|
|
335
|
+
);
|
|
336
|
+
assert.calledOnceWithExactly(
|
|
337
|
+
webex.logger.warn,
|
|
338
|
+
'authorization: failed initial authorization code grant request',
|
|
339
|
+
error
|
|
340
|
+
);
|
|
341
|
+
});
|
|
269
342
|
});
|
|
270
343
|
describe('when the url contains an error', () => {
|
|
271
344
|
it('throws a grant error', () => {
|
|
@@ -287,6 +360,21 @@ describe('plugin-authorization-browser-first-party', () => {
|
|
|
287
360
|
|
|
288
361
|
assert.isTrue(webex.authorization.ready);
|
|
289
362
|
assert.isFalse(webex.credentials.canAuthorize);
|
|
363
|
+
assert.equal(
|
|
364
|
+
webex.authorization.initialAuthorizationCodeGrantOutcome,
|
|
365
|
+
InitialAuthorizationCodeGrantOutcomes.notAttempted
|
|
366
|
+
);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('does not treat a later authorization-code grant as the initialization exchange', async () => {
|
|
370
|
+
const webex = makeWebex('http://example.com');
|
|
371
|
+
|
|
372
|
+
await webex.authorization.requestAuthorizationCodeGrant({code: 'later-code'});
|
|
373
|
+
|
|
374
|
+
assert.equal(
|
|
375
|
+
webex.authorization.initialAuthorizationCodeGrantOutcome,
|
|
376
|
+
InitialAuthorizationCodeGrantOutcomes.notAttempted
|
|
377
|
+
);
|
|
290
378
|
});
|
|
291
379
|
});
|
|
292
380
|
|
|
@@ -430,12 +518,14 @@ describe('plugin-authorization-browser-first-party', () => {
|
|
|
430
518
|
const webex = makeWebex();
|
|
431
519
|
|
|
432
520
|
const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit');
|
|
433
|
-
sinon
|
|
521
|
+
sinon
|
|
522
|
+
.stub(webex.authorization, 'initiateAuthorizationCodeGrant')
|
|
523
|
+
.returns(Promise.resolve());
|
|
434
524
|
|
|
435
525
|
return webex.authorization.initiateLogin().then(() => {
|
|
436
526
|
assert.calledOnceWithExactly(emitSpy, Events.login, {
|
|
437
527
|
eventType: 'initiateLogin',
|
|
438
|
-
data: {
|
|
528
|
+
data: {hasEmail: false, hasState: false},
|
|
439
529
|
});
|
|
440
530
|
});
|
|
441
531
|
});
|
|
@@ -444,12 +534,14 @@ describe('plugin-authorization-browser-first-party', () => {
|
|
|
444
534
|
const webex = makeWebex();
|
|
445
535
|
|
|
446
536
|
const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit');
|
|
447
|
-
sinon
|
|
537
|
+
sinon
|
|
538
|
+
.stub(webex.authorization, 'initiateAuthorizationCodeGrant')
|
|
539
|
+
.returns(Promise.resolve());
|
|
448
540
|
|
|
449
|
-
return webex.authorization.initiateLogin({
|
|
541
|
+
return webex.authorization.initiateLogin({email: 'test@abc.xyz'}).then(() => {
|
|
450
542
|
assert.calledOnceWithExactly(emitSpy, Events.login, {
|
|
451
543
|
eventType: 'initiateLogin',
|
|
452
|
-
data: {
|
|
544
|
+
data: {hasEmail: true, hasState: false},
|
|
453
545
|
});
|
|
454
546
|
});
|
|
455
547
|
});
|
|
@@ -458,12 +550,14 @@ describe('plugin-authorization-browser-first-party', () => {
|
|
|
458
550
|
const webex = makeWebex();
|
|
459
551
|
|
|
460
552
|
const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit');
|
|
461
|
-
sinon
|
|
553
|
+
sinon
|
|
554
|
+
.stub(webex.authorization, 'initiateAuthorizationCodeGrant')
|
|
555
|
+
.returns(Promise.resolve());
|
|
462
556
|
|
|
463
|
-
return webex.authorization.initiateLogin({
|
|
557
|
+
return webex.authorization.initiateLogin({state: {}}).then(() => {
|
|
464
558
|
assert.calledOnceWithExactly(emitSpy, Events.login, {
|
|
465
559
|
eventType: 'initiateLogin',
|
|
466
|
-
data: {
|
|
560
|
+
data: {hasEmail: false, hasState: true},
|
|
467
561
|
});
|
|
468
562
|
});
|
|
469
563
|
});
|
|
@@ -471,81 +565,87 @@ describe('plugin-authorization-browser-first-party', () => {
|
|
|
471
565
|
|
|
472
566
|
describe('#initiateAuthorizationCodeGrant()', () => {
|
|
473
567
|
it('redirects to the login page with response_type=code', () => {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
568
|
+
const webex = makeWebex(undefined, undefined, {
|
|
569
|
+
credentials: {
|
|
570
|
+
clientType: 'confidential',
|
|
571
|
+
},
|
|
572
|
+
});
|
|
479
573
|
|
|
480
|
-
|
|
574
|
+
sinon.spy(webex.authorization, 'initiateAuthorizationCodeGrant');
|
|
481
575
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
576
|
+
return webex.authorization.initiateLogin().then(() => {
|
|
577
|
+
assert.called(webex.authorization.initiateAuthorizationCodeGrant);
|
|
578
|
+
assert.include(webex.getWindow().location, 'response_type=code');
|
|
579
|
+
});
|
|
486
580
|
});
|
|
487
581
|
|
|
488
582
|
it('redirects to the login page in the same window by default', () => {
|
|
489
|
-
|
|
583
|
+
const webex = makeWebex();
|
|
490
584
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
585
|
+
return webex.authorization.initiateAuthorizationCodeGrant().then(() => {
|
|
586
|
+
assert.isDefined(webex.getWindow().location);
|
|
587
|
+
assert.isUndefined(webex.getWindow().open);
|
|
588
|
+
});
|
|
495
589
|
});
|
|
496
590
|
|
|
497
591
|
it('opens login page in a new window when separateWindow is true', () => {
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
592
|
+
const webex = makeWebex();
|
|
593
|
+
webex.getWindow().open = sinon.spy();
|
|
594
|
+
|
|
595
|
+
return webex.authorization
|
|
596
|
+
.initiateAuthorizationCodeGrant({separateWindow: true})
|
|
597
|
+
.then(() => {
|
|
598
|
+
assert.called(webex.getWindow().open);
|
|
599
|
+
const openCall = webex.getWindow().open.getCall(0);
|
|
600
|
+
assert.equal(openCall.args[1], '_blank');
|
|
601
|
+
assert.equal(openCall.args[2], 'width=600,height=800');
|
|
602
|
+
});
|
|
507
603
|
});
|
|
508
604
|
|
|
509
605
|
it('opens login page in a new window with custom dimensions', () => {
|
|
510
|
-
|
|
511
|
-
|
|
606
|
+
const webex = makeWebex();
|
|
607
|
+
webex.getWindow().open = sinon.spy();
|
|
512
608
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
609
|
+
const customWindow = {
|
|
610
|
+
width: 800,
|
|
611
|
+
height: 600,
|
|
612
|
+
menubar: 'no',
|
|
613
|
+
toolbar: 'no',
|
|
614
|
+
};
|
|
519
615
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
});
|
|
616
|
+
return webex.authorization
|
|
617
|
+
.initiateAuthorizationCodeGrant({
|
|
618
|
+
separateWindow: customWindow,
|
|
619
|
+
})
|
|
620
|
+
.then(() => {
|
|
621
|
+
assert.called(webex.getWindow().open);
|
|
622
|
+
const openCall = webex.getWindow().open.getCall(0);
|
|
623
|
+
assert.equal(openCall.args[1], '_blank');
|
|
624
|
+
assert.equal(openCall.args[2], 'width=800,height=600,menubar=no,toolbar=no');
|
|
625
|
+
});
|
|
531
626
|
});
|
|
532
627
|
|
|
533
628
|
it('preserves other options when using separateWindow', () => {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
629
|
+
const webex = makeWebex();
|
|
630
|
+
webex.getWindow().open = sinon.spy();
|
|
631
|
+
|
|
632
|
+
return webex.authorization
|
|
633
|
+
.initiateAuthorizationCodeGrant({
|
|
634
|
+
separateWindow: true,
|
|
635
|
+
state: {},
|
|
636
|
+
})
|
|
637
|
+
.then(() => {
|
|
638
|
+
assert.called(webex.getWindow().open);
|
|
639
|
+
const url = webex.getWindow().open.getCall(0).args[0];
|
|
640
|
+
assert.include(
|
|
641
|
+
url,
|
|
642
|
+
'https://idbrokerbts.webex.com/idb/oauth2/v1/authorize?response_type=code&separateWindow=true&client_id=fake&redirect_uri=http%3A%2F%2Fexample.com&scope=scope%3Aone'
|
|
643
|
+
);
|
|
644
|
+
});
|
|
545
645
|
});
|
|
546
646
|
|
|
547
647
|
it('Emits an event containing the login url', () => {
|
|
548
|
-
const testLoginUrl =
|
|
648
|
+
const testLoginUrl = 'https://test.example.com';
|
|
549
649
|
const webex = makeWebex();
|
|
550
650
|
|
|
551
651
|
sinon.stub(webex.credentials, 'buildLoginUrl').returns(testLoginUrl);
|
|
@@ -554,17 +654,275 @@ describe('plugin-authorization-browser-first-party', () => {
|
|
|
554
654
|
return webex.authorization.initiateAuthorizationCodeGrant().then(() => {
|
|
555
655
|
assert.calledOnceWithExactly(emitSpy, Events.login, {
|
|
556
656
|
eventType: 'redirectToLoginUrl',
|
|
557
|
-
data: {
|
|
657
|
+
data: {loginUrl: testLoginUrl},
|
|
558
658
|
});
|
|
559
659
|
});
|
|
560
660
|
});
|
|
561
661
|
});
|
|
562
662
|
|
|
663
|
+
describe('#initiateThirdPartyLogin()', () => {
|
|
664
|
+
it('generates a csrf_token, embeds it in state, and delegates to #initiateThirdPartyLoginRedirect', () => {
|
|
665
|
+
const webex = makeWebex();
|
|
666
|
+
const expected = Promise.resolve();
|
|
667
|
+
const stub = sinon
|
|
668
|
+
.stub(webex.authorization, 'initiateThirdPartyLoginRedirect')
|
|
669
|
+
.returns(expected);
|
|
670
|
+
sinon.stub(webex.authorization, '_generateSecurityToken').returns('csrf-1234');
|
|
671
|
+
const options = {
|
|
672
|
+
oauth2provider: 'google',
|
|
673
|
+
returnURL: 'https://web.webex.com',
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
const result = webex.authorization.initiateThirdPartyLogin(options);
|
|
677
|
+
|
|
678
|
+
assert.equal(result, expected);
|
|
679
|
+
assert.calledOnce(webex.authorization._generateSecurityToken);
|
|
680
|
+
assert.calledOnce(stub);
|
|
681
|
+
const passed = stub.getCall(0).args[0];
|
|
682
|
+
|
|
683
|
+
assert.deepEqual(passed, {
|
|
684
|
+
oauth2provider: 'google',
|
|
685
|
+
returnURL: 'https://web.webex.com',
|
|
686
|
+
state: {csrf_token: 'csrf-1234'},
|
|
687
|
+
});
|
|
688
|
+
// Caller's options object is not mutated
|
|
689
|
+
assert.notProperty(options, 'state');
|
|
690
|
+
|
|
691
|
+
return result;
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it('merges generated csrf_token into caller-supplied state without mutating the caller object', () => {
|
|
695
|
+
const webex = makeWebex();
|
|
696
|
+
sinon
|
|
697
|
+
.stub(webex.authorization, 'initiateThirdPartyLoginRedirect')
|
|
698
|
+
.returns(Promise.resolve());
|
|
699
|
+
sinon.stub(webex.authorization, '_generateSecurityToken').returns('csrf-1234');
|
|
700
|
+
const options = {
|
|
701
|
+
oauth2provider: 'google',
|
|
702
|
+
returnURL: 'https://web.webex.com',
|
|
703
|
+
state: {popUpSignIn: true, mode: 'meeting'},
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
return webex.authorization.initiateThirdPartyLogin(options).then(() => {
|
|
707
|
+
const passed = webex.authorization.initiateThirdPartyLoginRedirect.getCall(0).args[0];
|
|
708
|
+
|
|
709
|
+
assert.deepEqual(passed.state, {
|
|
710
|
+
popUpSignIn: true,
|
|
711
|
+
mode: 'meeting',
|
|
712
|
+
csrf_token: 'csrf-1234',
|
|
713
|
+
});
|
|
714
|
+
// Caller-supplied state is not mutated
|
|
715
|
+
assert.deepEqual(options.state, {popUpSignIn: true, mode: 'meeting'});
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it('throws (before generating a csrf_token) when state is supplied but is not an object', () => {
|
|
720
|
+
const webex = makeWebex();
|
|
721
|
+
const redirectStub = sinon.stub(webex.authorization, 'initiateThirdPartyLoginRedirect');
|
|
722
|
+
const tokenStub = sinon.stub(webex.authorization, '_generateSecurityToken');
|
|
723
|
+
|
|
724
|
+
assert.throws(
|
|
725
|
+
() =>
|
|
726
|
+
webex.authorization.initiateThirdPartyLogin({
|
|
727
|
+
oauth2provider: 'google',
|
|
728
|
+
returnURL: 'https://web.webex.com',
|
|
729
|
+
state: 'not-an-object',
|
|
730
|
+
}),
|
|
731
|
+
/`options.state` must be an object/
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
assert.notCalled(tokenStub);
|
|
735
|
+
assert.notCalled(redirectStub);
|
|
736
|
+
});
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
describe('#initiateThirdPartyLoginRedirect()', () => {
|
|
740
|
+
it('builds the third-party login URL and assigns it to getWindow().location', () => {
|
|
741
|
+
const webex = makeWebex();
|
|
742
|
+
const builtUrl =
|
|
743
|
+
'https://idbroker.webex.com/idb/ThirdPartyLogin?oauth2provider=google&returnURL=https%3A%2F%2Fweb.webex.com';
|
|
744
|
+
sinon.stub(webex.credentials, 'buildThirdPartyLoginUrl').returns(builtUrl);
|
|
745
|
+
|
|
746
|
+
return webex.authorization
|
|
747
|
+
.initiateThirdPartyLoginRedirect({
|
|
748
|
+
oauth2provider: 'google',
|
|
749
|
+
returnURL: 'https://web.webex.com',
|
|
750
|
+
})
|
|
751
|
+
.then(() => {
|
|
752
|
+
assert.calledOnceWithExactly(webex.credentials.buildThirdPartyLoginUrl, {
|
|
753
|
+
oauth2provider: 'google',
|
|
754
|
+
returnURL: 'https://web.webex.com',
|
|
755
|
+
});
|
|
756
|
+
assert.equal(webex.getWindow().location, builtUrl);
|
|
757
|
+
});
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
it('returns a rejected promise if buildThirdPartyLoginUrl throws', () => {
|
|
761
|
+
const webex = makeWebex();
|
|
762
|
+
sinon
|
|
763
|
+
.stub(webex.credentials, 'buildThirdPartyLoginUrl')
|
|
764
|
+
.throws(new Error('`options.oauth2provider` is required'));
|
|
765
|
+
|
|
766
|
+
return assert.isRejected(
|
|
767
|
+
webex.authorization.initiateThirdPartyLoginRedirect({}),
|
|
768
|
+
/`options.oauth2provider` is required/
|
|
769
|
+
);
|
|
770
|
+
});
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
describe('#_verifySecurityToken() requireMatch', () => {
|
|
774
|
+
it('silently returns undefined when no stored token and requireMatch is false', () => {
|
|
775
|
+
const webex = makeWebex();
|
|
776
|
+
webex.getWindow().sessionStorage.getItem = sinon.stub().returns(null);
|
|
777
|
+
|
|
778
|
+
const result = webex.authorization._verifySecurityToken({});
|
|
779
|
+
|
|
780
|
+
assert.isUndefined(result);
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it('throws when no stored token and requireMatch is true', () => {
|
|
784
|
+
const webex = makeWebex();
|
|
785
|
+
webex.getWindow().sessionStorage.getItem = sinon.stub().returns(null);
|
|
786
|
+
|
|
787
|
+
assert.throws(() => {
|
|
788
|
+
webex.authorization._verifySecurityToken({}, {requireMatch: true});
|
|
789
|
+
}, /CSRF token missing from session storage/);
|
|
790
|
+
});
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
describe('#handleThirdPartyCallback()', () => {
|
|
794
|
+
const buildState = (state) => base64.toBase64Url(JSON.stringify(state));
|
|
795
|
+
|
|
796
|
+
// Mirror the SDK behaviour: replaceState writes the cleaned URL to
|
|
797
|
+
// location.href, so we can re-parse it and assert against named query
|
|
798
|
+
// params instead of substring-matching the raw URL.
|
|
799
|
+
const parseLocationQuery = (webex) => url.parse(webex.getWindow().location.href, true).query;
|
|
800
|
+
|
|
801
|
+
it('CSRF round-trip succeeds, scrubs sensitive params, and resolves with payload', () => {
|
|
802
|
+
const storedToken = 'csrf-abc';
|
|
803
|
+
const state = {csrf_token: storedToken, popUpSignIn: true, mode: 'meeting'};
|
|
804
|
+
const search = `?id_token=id-1&email=user%40example.com&state=${buildState(state)}`;
|
|
805
|
+
const webex = makeWebex(`http://example.com/${search}`, storedToken);
|
|
806
|
+
webex.getWindow().sessionStorage.getItem = sinon.stub().returns(storedToken);
|
|
807
|
+
|
|
808
|
+
const result = webex.authorization.handleThirdPartyCallback();
|
|
809
|
+
|
|
810
|
+
assert.deepEqual(result, {
|
|
811
|
+
idToken: 'id-1',
|
|
812
|
+
email: 'user@example.com',
|
|
813
|
+
error: undefined,
|
|
814
|
+
state: {popUpSignIn: true, mode: 'meeting'},
|
|
815
|
+
});
|
|
816
|
+
// Stored token consumed
|
|
817
|
+
assert.calledWith(webex.getWindow().sessionStorage.removeItem, 'oauth2-csrf-token');
|
|
818
|
+
// URL scrubbed: no id_token/email/csrf_token left, residual state re-encoded.
|
|
819
|
+
assert.called(webex.getWindow().history.replaceState);
|
|
820
|
+
const cleanedQuery = parseLocationQuery(webex);
|
|
821
|
+
|
|
822
|
+
assert.notProperty(cleanedQuery, 'id_token');
|
|
823
|
+
assert.notProperty(cleanedQuery, 'email');
|
|
824
|
+
assert.deepEqual(JSON.parse(base64.decode(cleanedQuery.state)), {
|
|
825
|
+
popUpSignIn: true,
|
|
826
|
+
mode: 'meeting',
|
|
827
|
+
});
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it('throws when no stored CSRF token (treated as CSRF failure)', () => {
|
|
831
|
+
const search = `?id_token=id-1&state=${buildState({csrf_token: 'csrf-abc'})}`;
|
|
832
|
+
const webex = makeWebex(`http://example.com/${search}`);
|
|
833
|
+
webex.getWindow().sessionStorage.getItem = sinon.stub().returns(null);
|
|
834
|
+
|
|
835
|
+
assert.throws(
|
|
836
|
+
() => webex.authorization.handleThirdPartyCallback(),
|
|
837
|
+
/CSRF token missing from session storage/
|
|
838
|
+
);
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
it('throws when state.csrf_token does not match stored token', () => {
|
|
842
|
+
const search = `?id_token=id-1&state=${buildState({csrf_token: 'attacker'})}`;
|
|
843
|
+
const webex = makeWebex(`http://example.com/${search}`, 'real-token');
|
|
844
|
+
webex.getWindow().sessionStorage.getItem = sinon.stub().returns('real-token');
|
|
845
|
+
|
|
846
|
+
assert.throws(
|
|
847
|
+
() => webex.authorization.handleThirdPartyCallback(),
|
|
848
|
+
/CSRF token attacker does not match stored token real-token/
|
|
849
|
+
);
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
it('throws the native decode error (not a wrapped message) when state is malformed', () => {
|
|
853
|
+
// base64('not json') decodes to 'not json' which is not valid JSON
|
|
854
|
+
const search = `?id_token=id-1&state=bm90IGpzb24%3D`;
|
|
855
|
+
const webex = makeWebex(`http://example.com/${search}`, 'csrf-abc');
|
|
856
|
+
webex.getWindow().sessionStorage.getItem = sinon.stub().returns('csrf-abc');
|
|
857
|
+
|
|
858
|
+
// _verifySecurityToken is never reached because decode throws first;
|
|
859
|
+
// surface the underlying SyntaxError untouched.
|
|
860
|
+
assert.throws(() => webex.authorization.handleThirdPartyCallback(), SyntaxError);
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
it('throws on error responses that lack a valid state (still verifies CSRF)', () => {
|
|
864
|
+
// Social provider returned ?error=... without echoing state. We must
|
|
865
|
+
// not silently treat this as a successful callback.
|
|
866
|
+
const search = `?error=FailedToCallOAuthProvider`;
|
|
867
|
+
const webex = makeWebex('http://example.com', 'csrf-abc');
|
|
868
|
+
webex.getWindow().location.href = `http://example.com/${search}`;
|
|
869
|
+
webex.getWindow().sessionStorage.getItem = sinon.stub().returns('csrf-abc');
|
|
870
|
+
|
|
871
|
+
assert.throws(
|
|
872
|
+
() => webex.authorization.handleThirdPartyCallback(),
|
|
873
|
+
/Expected CSRF token csrf-abc, but not found in redirect query/
|
|
874
|
+
);
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
it('throws CSRF error when location has no query params at all', () => {
|
|
878
|
+
const webex = makeWebex('http://example.com/', 'csrf-abc');
|
|
879
|
+
webex.getWindow().sessionStorage.getItem = sinon.stub().returns('csrf-abc');
|
|
880
|
+
|
|
881
|
+
assert.throws(
|
|
882
|
+
() => webex.authorization.handleThirdPartyCallback(),
|
|
883
|
+
/Expected CSRF token csrf-abc, but not found in redirect query/
|
|
884
|
+
);
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
it('throws when state is absent even if no stored CSRF token exists (requireMatch)', () => {
|
|
888
|
+
const webex = makeWebex('http://example.com/?id_token=id-1');
|
|
889
|
+
webex.getWindow().sessionStorage.getItem = sinon.stub().returns(null);
|
|
890
|
+
|
|
891
|
+
assert.throws(
|
|
892
|
+
() => webex.authorization.handleThirdPartyCallback(),
|
|
893
|
+
/CSRF token missing from session storage/
|
|
894
|
+
);
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
it('returns error value in the result and preserves non-csrf state', () => {
|
|
898
|
+
const storedToken = 'csrf-abc';
|
|
899
|
+
const search = `?error=FailedToCallOAuthProvider&state=${buildState({
|
|
900
|
+
csrf_token: storedToken,
|
|
901
|
+
popUpSignIn: true,
|
|
902
|
+
})}`;
|
|
903
|
+
const webex = makeWebex('http://example.com', storedToken);
|
|
904
|
+
webex.getWindow().location.href = `http://example.com/${search}`;
|
|
905
|
+
webex.getWindow().sessionStorage.getItem = sinon.stub().returns(storedToken);
|
|
906
|
+
|
|
907
|
+
const result = webex.authorization.handleThirdPartyCallback();
|
|
908
|
+
|
|
909
|
+
assert.deepEqual(result, {
|
|
910
|
+
idToken: undefined,
|
|
911
|
+
email: undefined,
|
|
912
|
+
error: 'FailedToCallOAuthProvider',
|
|
913
|
+
state: {popUpSignIn: true},
|
|
914
|
+
});
|
|
915
|
+
// `error` is non-sensitive; not scrubbed from the URL.
|
|
916
|
+
assert.equal(parseLocationQuery(webex).error, 'FailedToCallOAuthProvider');
|
|
917
|
+
});
|
|
918
|
+
});
|
|
919
|
+
|
|
563
920
|
describe('#_generateQRCodeVerificationUrl()', () => {
|
|
564
921
|
it('should generate a QR code URL when a userCode is present', () => {
|
|
565
922
|
const verificationUrl = 'https://example.com/verify?userCode=123456';
|
|
566
923
|
const oauthHelperUrl = 'https://oauth-helper-a.wbx2.com/helperservice/v1';
|
|
567
|
-
const expectedUrl =
|
|
924
|
+
const expectedUrl =
|
|
925
|
+
'https://web.webex.com/deviceAuth?usercode=123456&oauthhelper=https%3A%2F%2Foauth-helper-a.wbx2.com%2Fhelperservice%2Fv1';
|
|
568
926
|
|
|
569
927
|
const webex = makeWebex('http://example.com');
|
|
570
928
|
|
|
@@ -951,34 +1309,84 @@ describe('plugin-authorization-browser-first-party', () => {
|
|
|
951
1309
|
});
|
|
952
1310
|
|
|
953
1311
|
describe('#_generateCodeChallenge', () => {
|
|
954
|
-
const expectedCodeChallenge = 'code challenge';
|
|
955
1312
|
// eslint-disable-next-line no-underscore-dangle
|
|
956
1313
|
const safeCharacterMap = CryptoJS.enc.Base64url._safe_map;
|
|
957
1314
|
|
|
958
|
-
|
|
1315
|
+
function makeWebexWithRandomValues(fillRandomValues) {
|
|
1316
|
+
const getRandomValuesStub = sinon.stub().callsFake((randomValues) => {
|
|
1317
|
+
fillRandomValues(randomValues);
|
|
959
1318
|
|
|
960
|
-
|
|
961
|
-
|
|
1319
|
+
return randomValues;
|
|
1320
|
+
});
|
|
1321
|
+
const webex = makeWebex(
|
|
1322
|
+
'http://example.com',
|
|
1323
|
+
undefined,
|
|
1324
|
+
undefined,
|
|
1325
|
+
{},
|
|
1326
|
+
getRandomValuesStub
|
|
1327
|
+
);
|
|
1328
|
+
|
|
1329
|
+
getRandomValuesStub.resetHistory();
|
|
1330
|
+
webex.getWindow().sessionStorage.setItem.resetHistory();
|
|
1331
|
+
|
|
1332
|
+
return {getRandomValuesStub, webex};
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
it('uses a 128-byte CSPRNG and does not use insecure random generators', () => {
|
|
1336
|
+
const {getRandomValuesStub, webex} = makeWebexWithRandomValues((randomValues) => {
|
|
1337
|
+
randomValues.fill(0);
|
|
1338
|
+
});
|
|
1339
|
+
const mathRandomStub = sinon.stub(Math, 'random');
|
|
1340
|
+
const lodashRandomStub = sinon.stub(lodash, 'random');
|
|
1341
|
+
|
|
1342
|
+
// eslint-disable-next-line no-underscore-dangle
|
|
1343
|
+
webex.authorization._generateCodeChallenge();
|
|
1344
|
+
|
|
1345
|
+
const generatedRandomValues = getRandomValuesStub.firstCall.args[0];
|
|
1346
|
+
|
|
1347
|
+
assert.calledOnceWithExactly(getRandomValuesStub, generatedRandomValues);
|
|
1348
|
+
assert.instanceOf(generatedRandomValues, Uint8Array);
|
|
1349
|
+
assert.lengthOf(generatedRandomValues, 128);
|
|
1350
|
+
assert.notCalled(mathRandomStub);
|
|
1351
|
+
assert.notCalled(lodashRandomStub);
|
|
1352
|
+
});
|
|
962
1353
|
|
|
963
|
-
|
|
964
|
-
const
|
|
965
|
-
|
|
966
|
-
toString: toStringStub,
|
|
1354
|
+
it('generates a 128-character verifier from the base64url-safe alphabet', () => {
|
|
1355
|
+
const {webex} = makeWebexWithRandomValues((randomValues) => {
|
|
1356
|
+
randomValues.set(times(128, (index) => index));
|
|
967
1357
|
});
|
|
1358
|
+
const expectedVerifier = times(
|
|
1359
|
+
128,
|
|
1360
|
+
(index) => safeCharacterMap[index & (safeCharacterMap.length - 1)]
|
|
1361
|
+
).join('');
|
|
1362
|
+
|
|
1363
|
+
// eslint-disable-next-line no-underscore-dangle
|
|
1364
|
+
webex.authorization._generateCodeChallenge();
|
|
1365
|
+
|
|
1366
|
+
const storedVerifier = webex.getWindow().sessionStorage.setItem.firstCall.args[1];
|
|
1367
|
+
|
|
1368
|
+
assert.match(storedVerifier, /^[A-Za-z0-9_-]{128}$/);
|
|
1369
|
+
assert.equal(storedVerifier, expectedVerifier);
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
it('stores the verifier and returns its SHA-256 base64url challenge', () => {
|
|
1373
|
+
const {webex} = makeWebexWithRandomValues((randomValues) => {
|
|
1374
|
+
randomValues.fill(42);
|
|
1375
|
+
});
|
|
1376
|
+
const expectedVerifier = safeCharacterMap[42 & (safeCharacterMap.length - 1)].repeat(128);
|
|
1377
|
+
const expectedChallenge = CryptoJS.SHA256(expectedVerifier).toString(
|
|
1378
|
+
CryptoJS.enc.Base64url
|
|
1379
|
+
);
|
|
968
1380
|
|
|
969
1381
|
// eslint-disable-next-line no-underscore-dangle
|
|
970
1382
|
const codeChallenge = webex.authorization._generateCodeChallenge();
|
|
971
1383
|
|
|
972
|
-
assert.
|
|
973
|
-
assert.calledWith(sha256Stub, expectedVerifier);
|
|
974
|
-
assert.calledWith(toStringStub, CryptoJS.enc.Base64url);
|
|
975
|
-
assert.callCount(randomStub, 128);
|
|
976
|
-
assert.calledWith(randomStub, 0, safeCharacterMap.length - 1);
|
|
977
|
-
assert.calledWith(
|
|
1384
|
+
assert.calledOnceWithExactly(
|
|
978
1385
|
webex.getWindow().sessionStorage.setItem,
|
|
979
1386
|
'oauth2-code-verifier',
|
|
980
1387
|
expectedVerifier
|
|
981
1388
|
);
|
|
1389
|
+
assert.equal(codeChallenge, expectedChallenge);
|
|
982
1390
|
});
|
|
983
1391
|
});
|
|
984
1392
|
|
|
@@ -1027,7 +1435,33 @@ describe('plugin-authorization-browser-first-party', () => {
|
|
|
1027
1435
|
|
|
1028
1436
|
assert.isDefined(href);
|
|
1029
1437
|
assert.equal(href, `?state=${base64.encode(JSON.stringify({key: 'value'}))}`);
|
|
1030
|
-
|
|
1438
|
+
});
|
|
1439
|
+
|
|
1440
|
+
it('strips id_token and email from the query string', () => {
|
|
1441
|
+
const webex = makeWebex(undefined, undefined, {
|
|
1442
|
+
credentials: {
|
|
1443
|
+
clientType: 'confidential',
|
|
1444
|
+
},
|
|
1445
|
+
});
|
|
1446
|
+
const location = {
|
|
1447
|
+
query: {
|
|
1448
|
+
code: 'code',
|
|
1449
|
+
id_token: 'id-token-value',
|
|
1450
|
+
email: 'user@example.com',
|
|
1451
|
+
state: {
|
|
1452
|
+
csrf_token: 'token',
|
|
1453
|
+
key: 'value',
|
|
1454
|
+
},
|
|
1455
|
+
},
|
|
1456
|
+
};
|
|
1457
|
+
|
|
1458
|
+
webex.authorization._cleanUrl(location);
|
|
1459
|
+
assert.called(webex.getWindow().history.replaceState);
|
|
1460
|
+
const {href} = webex.getWindow().location;
|
|
1461
|
+
|
|
1462
|
+
assert.notInclude(href, 'id_token');
|
|
1463
|
+
assert.notInclude(href, 'email');
|
|
1464
|
+
assert.notInclude(href, 'code');
|
|
1031
1465
|
});
|
|
1032
1466
|
});
|
|
1033
1467
|
|