@webex/plugin-authorization-browser-first-party 3.11.0 → 3.12.0-llmrefactor.1
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 +170 -15
- 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 +176 -21
- package/src/index.js +1 -1
- package/test/unit/spec/authorization.js +447 -67
|
@@ -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');
|
|
@@ -104,6 +104,35 @@ describe('plugin-authorization-browser-first-party', () => {
|
|
|
104
104
|
sinon.restore();
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
+
it('exposes the initial authorization code grant outcome as readonly', () => {
|
|
108
|
+
const webex = makeWebex();
|
|
109
|
+
const changeSpy = sinon.spy();
|
|
110
|
+
|
|
111
|
+
webex.authorization.on('change:initialAuthorizationCodeGrantOutcome', changeSpy);
|
|
112
|
+
|
|
113
|
+
assert.equal(
|
|
114
|
+
webex.authorization.initialAuthorizationCodeGrantOutcome,
|
|
115
|
+
InitialAuthorizationCodeGrantOutcomes.notAttempted
|
|
116
|
+
);
|
|
117
|
+
assert.throws(() => {
|
|
118
|
+
webex.authorization.initialAuthorizationCodeGrantOutcome =
|
|
119
|
+
InitialAuthorizationCodeGrantOutcomes.success;
|
|
120
|
+
}, /derived property, it can't be set directly/);
|
|
121
|
+
|
|
122
|
+
webex.authorization._initialAuthorizationCodeGrantOutcome =
|
|
123
|
+
InitialAuthorizationCodeGrantOutcomes.success;
|
|
124
|
+
|
|
125
|
+
assert.equal(
|
|
126
|
+
webex.authorization.initialAuthorizationCodeGrantOutcome,
|
|
127
|
+
InitialAuthorizationCodeGrantOutcomes.success
|
|
128
|
+
);
|
|
129
|
+
assert.calledOnceWithExactly(
|
|
130
|
+
changeSpy,
|
|
131
|
+
webex.authorization,
|
|
132
|
+
InitialAuthorizationCodeGrantOutcomes.success
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
107
136
|
describe('#initialize()', () => {
|
|
108
137
|
describe('when there is a code in the url', () => {
|
|
109
138
|
it('exchanges it for an access token and sets ready', () => {
|
|
@@ -119,9 +148,25 @@ describe('plugin-authorization-browser-first-party', () => {
|
|
|
119
148
|
assert.calledTwice(webex.request);
|
|
120
149
|
assert.isTrue(webex.authorization.ready);
|
|
121
150
|
assert.isTrue(webex.credentials.canAuthorize);
|
|
151
|
+
assert.equal(
|
|
152
|
+
webex.authorization.initialAuthorizationCodeGrantOutcome,
|
|
153
|
+
InitialAuthorizationCodeGrantOutcomes.success
|
|
154
|
+
);
|
|
122
155
|
});
|
|
123
156
|
});
|
|
124
157
|
|
|
158
|
+
it('retains the initialization exchange outcome after logout', async () => {
|
|
159
|
+
const webex = makeWebex('http://example.com/?code=5');
|
|
160
|
+
|
|
161
|
+
await webex.authorization.when('change:ready');
|
|
162
|
+
webex.authorization.logout({noRedirect: true});
|
|
163
|
+
|
|
164
|
+
assert.equal(
|
|
165
|
+
webex.authorization.initialAuthorizationCodeGrantOutcome,
|
|
166
|
+
InitialAuthorizationCodeGrantOutcomes.success
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
125
170
|
it('validates the csrf token', () => {
|
|
126
171
|
const csrfToken = 'abcd';
|
|
127
172
|
|
|
@@ -264,8 +309,32 @@ describe('plugin-authorization-browser-first-party', () => {
|
|
|
264
309
|
'authorization: failed initial authorization code grant request',
|
|
265
310
|
error
|
|
266
311
|
);
|
|
312
|
+
assert.equal(
|
|
313
|
+
webex.authorization.initialAuthorizationCodeGrantOutcome,
|
|
314
|
+
InitialAuthorizationCodeGrantOutcomes.failure
|
|
315
|
+
);
|
|
267
316
|
});
|
|
268
317
|
});
|
|
318
|
+
|
|
319
|
+
it('retains failure when the automatic exchange promise rejects', async () => {
|
|
320
|
+
const error = new Error('exchange rejected');
|
|
321
|
+
|
|
322
|
+
sinon.stub(Authorization.prototype, 'requestAuthorizationCodeGrant').rejects(error);
|
|
323
|
+
|
|
324
|
+
const webex = makeWebex('http://example.com?code=5');
|
|
325
|
+
|
|
326
|
+
await webex.authorization.when('change:ready');
|
|
327
|
+
|
|
328
|
+
assert.equal(
|
|
329
|
+
webex.authorization.initialAuthorizationCodeGrantOutcome,
|
|
330
|
+
InitialAuthorizationCodeGrantOutcomes.failure
|
|
331
|
+
);
|
|
332
|
+
assert.calledOnceWithExactly(
|
|
333
|
+
webex.logger.warn,
|
|
334
|
+
'authorization: failed initial authorization code grant request',
|
|
335
|
+
error
|
|
336
|
+
);
|
|
337
|
+
});
|
|
269
338
|
});
|
|
270
339
|
describe('when the url contains an error', () => {
|
|
271
340
|
it('throws a grant error', () => {
|
|
@@ -287,6 +356,21 @@ describe('plugin-authorization-browser-first-party', () => {
|
|
|
287
356
|
|
|
288
357
|
assert.isTrue(webex.authorization.ready);
|
|
289
358
|
assert.isFalse(webex.credentials.canAuthorize);
|
|
359
|
+
assert.equal(
|
|
360
|
+
webex.authorization.initialAuthorizationCodeGrantOutcome,
|
|
361
|
+
InitialAuthorizationCodeGrantOutcomes.notAttempted
|
|
362
|
+
);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('does not treat a later authorization-code grant as the initialization exchange', async () => {
|
|
366
|
+
const webex = makeWebex('http://example.com');
|
|
367
|
+
|
|
368
|
+
await webex.authorization.requestAuthorizationCodeGrant({code: 'later-code'});
|
|
369
|
+
|
|
370
|
+
assert.equal(
|
|
371
|
+
webex.authorization.initialAuthorizationCodeGrantOutcome,
|
|
372
|
+
InitialAuthorizationCodeGrantOutcomes.notAttempted
|
|
373
|
+
);
|
|
290
374
|
});
|
|
291
375
|
});
|
|
292
376
|
|
|
@@ -430,12 +514,14 @@ describe('plugin-authorization-browser-first-party', () => {
|
|
|
430
514
|
const webex = makeWebex();
|
|
431
515
|
|
|
432
516
|
const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit');
|
|
433
|
-
sinon
|
|
517
|
+
sinon
|
|
518
|
+
.stub(webex.authorization, 'initiateAuthorizationCodeGrant')
|
|
519
|
+
.returns(Promise.resolve());
|
|
434
520
|
|
|
435
521
|
return webex.authorization.initiateLogin().then(() => {
|
|
436
522
|
assert.calledOnceWithExactly(emitSpy, Events.login, {
|
|
437
523
|
eventType: 'initiateLogin',
|
|
438
|
-
data: {
|
|
524
|
+
data: {hasEmail: false, hasState: false},
|
|
439
525
|
});
|
|
440
526
|
});
|
|
441
527
|
});
|
|
@@ -444,12 +530,14 @@ describe('plugin-authorization-browser-first-party', () => {
|
|
|
444
530
|
const webex = makeWebex();
|
|
445
531
|
|
|
446
532
|
const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit');
|
|
447
|
-
sinon
|
|
533
|
+
sinon
|
|
534
|
+
.stub(webex.authorization, 'initiateAuthorizationCodeGrant')
|
|
535
|
+
.returns(Promise.resolve());
|
|
448
536
|
|
|
449
|
-
return webex.authorization.initiateLogin({
|
|
537
|
+
return webex.authorization.initiateLogin({email: 'test@abc.xyz'}).then(() => {
|
|
450
538
|
assert.calledOnceWithExactly(emitSpy, Events.login, {
|
|
451
539
|
eventType: 'initiateLogin',
|
|
452
|
-
data: {
|
|
540
|
+
data: {hasEmail: true, hasState: false},
|
|
453
541
|
});
|
|
454
542
|
});
|
|
455
543
|
});
|
|
@@ -458,12 +546,14 @@ describe('plugin-authorization-browser-first-party', () => {
|
|
|
458
546
|
const webex = makeWebex();
|
|
459
547
|
|
|
460
548
|
const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit');
|
|
461
|
-
sinon
|
|
549
|
+
sinon
|
|
550
|
+
.stub(webex.authorization, 'initiateAuthorizationCodeGrant')
|
|
551
|
+
.returns(Promise.resolve());
|
|
462
552
|
|
|
463
|
-
return webex.authorization.initiateLogin({
|
|
553
|
+
return webex.authorization.initiateLogin({state: {}}).then(() => {
|
|
464
554
|
assert.calledOnceWithExactly(emitSpy, Events.login, {
|
|
465
555
|
eventType: 'initiateLogin',
|
|
466
|
-
data: {
|
|
556
|
+
data: {hasEmail: false, hasState: true},
|
|
467
557
|
});
|
|
468
558
|
});
|
|
469
559
|
});
|
|
@@ -471,81 +561,87 @@ describe('plugin-authorization-browser-first-party', () => {
|
|
|
471
561
|
|
|
472
562
|
describe('#initiateAuthorizationCodeGrant()', () => {
|
|
473
563
|
it('redirects to the login page with response_type=code', () => {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
564
|
+
const webex = makeWebex(undefined, undefined, {
|
|
565
|
+
credentials: {
|
|
566
|
+
clientType: 'confidential',
|
|
567
|
+
},
|
|
568
|
+
});
|
|
479
569
|
|
|
480
|
-
|
|
570
|
+
sinon.spy(webex.authorization, 'initiateAuthorizationCodeGrant');
|
|
481
571
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
572
|
+
return webex.authorization.initiateLogin().then(() => {
|
|
573
|
+
assert.called(webex.authorization.initiateAuthorizationCodeGrant);
|
|
574
|
+
assert.include(webex.getWindow().location, 'response_type=code');
|
|
575
|
+
});
|
|
486
576
|
});
|
|
487
577
|
|
|
488
578
|
it('redirects to the login page in the same window by default', () => {
|
|
489
|
-
|
|
579
|
+
const webex = makeWebex();
|
|
490
580
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
581
|
+
return webex.authorization.initiateAuthorizationCodeGrant().then(() => {
|
|
582
|
+
assert.isDefined(webex.getWindow().location);
|
|
583
|
+
assert.isUndefined(webex.getWindow().open);
|
|
584
|
+
});
|
|
495
585
|
});
|
|
496
586
|
|
|
497
587
|
it('opens login page in a new window when separateWindow is true', () => {
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
588
|
+
const webex = makeWebex();
|
|
589
|
+
webex.getWindow().open = sinon.spy();
|
|
590
|
+
|
|
591
|
+
return webex.authorization
|
|
592
|
+
.initiateAuthorizationCodeGrant({separateWindow: true})
|
|
593
|
+
.then(() => {
|
|
594
|
+
assert.called(webex.getWindow().open);
|
|
595
|
+
const openCall = webex.getWindow().open.getCall(0);
|
|
596
|
+
assert.equal(openCall.args[1], '_blank');
|
|
597
|
+
assert.equal(openCall.args[2], 'width=600,height=800');
|
|
598
|
+
});
|
|
507
599
|
});
|
|
508
600
|
|
|
509
601
|
it('opens login page in a new window with custom dimensions', () => {
|
|
510
|
-
|
|
511
|
-
|
|
602
|
+
const webex = makeWebex();
|
|
603
|
+
webex.getWindow().open = sinon.spy();
|
|
512
604
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
605
|
+
const customWindow = {
|
|
606
|
+
width: 800,
|
|
607
|
+
height: 600,
|
|
608
|
+
menubar: 'no',
|
|
609
|
+
toolbar: 'no',
|
|
610
|
+
};
|
|
519
611
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
});
|
|
612
|
+
return webex.authorization
|
|
613
|
+
.initiateAuthorizationCodeGrant({
|
|
614
|
+
separateWindow: customWindow,
|
|
615
|
+
})
|
|
616
|
+
.then(() => {
|
|
617
|
+
assert.called(webex.getWindow().open);
|
|
618
|
+
const openCall = webex.getWindow().open.getCall(0);
|
|
619
|
+
assert.equal(openCall.args[1], '_blank');
|
|
620
|
+
assert.equal(openCall.args[2], 'width=800,height=600,menubar=no,toolbar=no');
|
|
621
|
+
});
|
|
531
622
|
});
|
|
532
623
|
|
|
533
624
|
it('preserves other options when using separateWindow', () => {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
625
|
+
const webex = makeWebex();
|
|
626
|
+
webex.getWindow().open = sinon.spy();
|
|
627
|
+
|
|
628
|
+
return webex.authorization
|
|
629
|
+
.initiateAuthorizationCodeGrant({
|
|
630
|
+
separateWindow: true,
|
|
631
|
+
state: {},
|
|
632
|
+
})
|
|
633
|
+
.then(() => {
|
|
634
|
+
assert.called(webex.getWindow().open);
|
|
635
|
+
const url = webex.getWindow().open.getCall(0).args[0];
|
|
636
|
+
assert.include(
|
|
637
|
+
url,
|
|
638
|
+
'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'
|
|
639
|
+
);
|
|
640
|
+
});
|
|
545
641
|
});
|
|
546
642
|
|
|
547
643
|
it('Emits an event containing the login url', () => {
|
|
548
|
-
const testLoginUrl =
|
|
644
|
+
const testLoginUrl = 'https://test.example.com';
|
|
549
645
|
const webex = makeWebex();
|
|
550
646
|
|
|
551
647
|
sinon.stub(webex.credentials, 'buildLoginUrl').returns(testLoginUrl);
|
|
@@ -554,9 +650,266 @@ describe('plugin-authorization-browser-first-party', () => {
|
|
|
554
650
|
return webex.authorization.initiateAuthorizationCodeGrant().then(() => {
|
|
555
651
|
assert.calledOnceWithExactly(emitSpy, Events.login, {
|
|
556
652
|
eventType: 'redirectToLoginUrl',
|
|
557
|
-
data: {
|
|
653
|
+
data: {loginUrl: testLoginUrl},
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
describe('#initiateThirdPartyLogin()', () => {
|
|
660
|
+
it('generates a csrf_token, embeds it in state, and delegates to #initiateThirdPartyLoginRedirect', () => {
|
|
661
|
+
const webex = makeWebex();
|
|
662
|
+
const expected = Promise.resolve();
|
|
663
|
+
const stub = sinon
|
|
664
|
+
.stub(webex.authorization, 'initiateThirdPartyLoginRedirect')
|
|
665
|
+
.returns(expected);
|
|
666
|
+
sinon.stub(webex.authorization, '_generateSecurityToken').returns('csrf-1234');
|
|
667
|
+
const options = {
|
|
668
|
+
oauth2provider: 'google',
|
|
669
|
+
returnURL: 'https://web.webex.com',
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
const result = webex.authorization.initiateThirdPartyLogin(options);
|
|
673
|
+
|
|
674
|
+
assert.equal(result, expected);
|
|
675
|
+
assert.calledOnce(webex.authorization._generateSecurityToken);
|
|
676
|
+
assert.calledOnce(stub);
|
|
677
|
+
const passed = stub.getCall(0).args[0];
|
|
678
|
+
|
|
679
|
+
assert.deepEqual(passed, {
|
|
680
|
+
oauth2provider: 'google',
|
|
681
|
+
returnURL: 'https://web.webex.com',
|
|
682
|
+
state: {csrf_token: 'csrf-1234'},
|
|
683
|
+
});
|
|
684
|
+
// Caller's options object is not mutated
|
|
685
|
+
assert.notProperty(options, 'state');
|
|
686
|
+
|
|
687
|
+
return result;
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it('merges generated csrf_token into caller-supplied state without mutating the caller object', () => {
|
|
691
|
+
const webex = makeWebex();
|
|
692
|
+
sinon
|
|
693
|
+
.stub(webex.authorization, 'initiateThirdPartyLoginRedirect')
|
|
694
|
+
.returns(Promise.resolve());
|
|
695
|
+
sinon.stub(webex.authorization, '_generateSecurityToken').returns('csrf-1234');
|
|
696
|
+
const options = {
|
|
697
|
+
oauth2provider: 'google',
|
|
698
|
+
returnURL: 'https://web.webex.com',
|
|
699
|
+
state: {popUpSignIn: true, mode: 'meeting'},
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
return webex.authorization.initiateThirdPartyLogin(options).then(() => {
|
|
703
|
+
const passed = webex.authorization.initiateThirdPartyLoginRedirect.getCall(0).args[0];
|
|
704
|
+
|
|
705
|
+
assert.deepEqual(passed.state, {
|
|
706
|
+
popUpSignIn: true,
|
|
707
|
+
mode: 'meeting',
|
|
708
|
+
csrf_token: 'csrf-1234',
|
|
558
709
|
});
|
|
710
|
+
// Caller-supplied state is not mutated
|
|
711
|
+
assert.deepEqual(options.state, {popUpSignIn: true, mode: 'meeting'});
|
|
712
|
+
});
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it('throws (before generating a csrf_token) when state is supplied but is not an object', () => {
|
|
716
|
+
const webex = makeWebex();
|
|
717
|
+
const redirectStub = sinon.stub(webex.authorization, 'initiateThirdPartyLoginRedirect');
|
|
718
|
+
const tokenStub = sinon.stub(webex.authorization, '_generateSecurityToken');
|
|
719
|
+
|
|
720
|
+
assert.throws(
|
|
721
|
+
() =>
|
|
722
|
+
webex.authorization.initiateThirdPartyLogin({
|
|
723
|
+
oauth2provider: 'google',
|
|
724
|
+
returnURL: 'https://web.webex.com',
|
|
725
|
+
state: 'not-an-object',
|
|
726
|
+
}),
|
|
727
|
+
/`options.state` must be an object/
|
|
728
|
+
);
|
|
729
|
+
|
|
730
|
+
assert.notCalled(tokenStub);
|
|
731
|
+
assert.notCalled(redirectStub);
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
describe('#initiateThirdPartyLoginRedirect()', () => {
|
|
736
|
+
it('builds the third-party login URL and assigns it to getWindow().location', () => {
|
|
737
|
+
const webex = makeWebex();
|
|
738
|
+
const builtUrl =
|
|
739
|
+
'https://idbroker.webex.com/idb/ThirdPartyLogin?oauth2provider=google&returnURL=https%3A%2F%2Fweb.webex.com';
|
|
740
|
+
sinon.stub(webex.credentials, 'buildThirdPartyLoginUrl').returns(builtUrl);
|
|
741
|
+
|
|
742
|
+
return webex.authorization
|
|
743
|
+
.initiateThirdPartyLoginRedirect({
|
|
744
|
+
oauth2provider: 'google',
|
|
745
|
+
returnURL: 'https://web.webex.com',
|
|
746
|
+
})
|
|
747
|
+
.then(() => {
|
|
748
|
+
assert.calledOnceWithExactly(webex.credentials.buildThirdPartyLoginUrl, {
|
|
749
|
+
oauth2provider: 'google',
|
|
750
|
+
returnURL: 'https://web.webex.com',
|
|
751
|
+
});
|
|
752
|
+
assert.equal(webex.getWindow().location, builtUrl);
|
|
753
|
+
});
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
it('returns a rejected promise if buildThirdPartyLoginUrl throws', () => {
|
|
757
|
+
const webex = makeWebex();
|
|
758
|
+
sinon
|
|
759
|
+
.stub(webex.credentials, 'buildThirdPartyLoginUrl')
|
|
760
|
+
.throws(new Error('`options.oauth2provider` is required'));
|
|
761
|
+
|
|
762
|
+
return assert.isRejected(
|
|
763
|
+
webex.authorization.initiateThirdPartyLoginRedirect({}),
|
|
764
|
+
/`options.oauth2provider` is required/
|
|
765
|
+
);
|
|
766
|
+
});
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
describe('#_verifySecurityToken() requireMatch', () => {
|
|
770
|
+
it('silently returns undefined when no stored token and requireMatch is false', () => {
|
|
771
|
+
const webex = makeWebex();
|
|
772
|
+
webex.getWindow().sessionStorage.getItem = sinon.stub().returns(null);
|
|
773
|
+
|
|
774
|
+
const result = webex.authorization._verifySecurityToken({});
|
|
775
|
+
|
|
776
|
+
assert.isUndefined(result);
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it('throws when no stored token and requireMatch is true', () => {
|
|
780
|
+
const webex = makeWebex();
|
|
781
|
+
webex.getWindow().sessionStorage.getItem = sinon.stub().returns(null);
|
|
782
|
+
|
|
783
|
+
assert.throws(() => {
|
|
784
|
+
webex.authorization._verifySecurityToken({}, {requireMatch: true});
|
|
785
|
+
}, /CSRF token missing from session storage/);
|
|
786
|
+
});
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
describe('#handleThirdPartyCallback()', () => {
|
|
790
|
+
const buildState = (state) => base64.toBase64Url(JSON.stringify(state));
|
|
791
|
+
|
|
792
|
+
// Mirror the SDK behaviour: replaceState writes the cleaned URL to
|
|
793
|
+
// location.href, so we can re-parse it and assert against named query
|
|
794
|
+
// params instead of substring-matching the raw URL.
|
|
795
|
+
const parseLocationQuery = (webex) => url.parse(webex.getWindow().location.href, true).query;
|
|
796
|
+
|
|
797
|
+
it('CSRF round-trip succeeds, scrubs sensitive params, and resolves with payload', () => {
|
|
798
|
+
const storedToken = 'csrf-abc';
|
|
799
|
+
const state = {csrf_token: storedToken, popUpSignIn: true, mode: 'meeting'};
|
|
800
|
+
const search = `?id_token=id-1&email=user%40example.com&state=${buildState(state)}`;
|
|
801
|
+
const webex = makeWebex(`http://example.com/${search}`, storedToken);
|
|
802
|
+
webex.getWindow().sessionStorage.getItem = sinon.stub().returns(storedToken);
|
|
803
|
+
|
|
804
|
+
const result = webex.authorization.handleThirdPartyCallback();
|
|
805
|
+
|
|
806
|
+
assert.deepEqual(result, {
|
|
807
|
+
idToken: 'id-1',
|
|
808
|
+
email: 'user@example.com',
|
|
809
|
+
error: undefined,
|
|
810
|
+
state: {popUpSignIn: true, mode: 'meeting'},
|
|
811
|
+
});
|
|
812
|
+
// Stored token consumed
|
|
813
|
+
assert.calledWith(webex.getWindow().sessionStorage.removeItem, 'oauth2-csrf-token');
|
|
814
|
+
// URL scrubbed: no id_token/email/csrf_token left, residual state re-encoded.
|
|
815
|
+
assert.called(webex.getWindow().history.replaceState);
|
|
816
|
+
const cleanedQuery = parseLocationQuery(webex);
|
|
817
|
+
|
|
818
|
+
assert.notProperty(cleanedQuery, 'id_token');
|
|
819
|
+
assert.notProperty(cleanedQuery, 'email');
|
|
820
|
+
assert.deepEqual(JSON.parse(base64.decode(cleanedQuery.state)), {
|
|
821
|
+
popUpSignIn: true,
|
|
822
|
+
mode: 'meeting',
|
|
823
|
+
});
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it('throws when no stored CSRF token (treated as CSRF failure)', () => {
|
|
827
|
+
const search = `?id_token=id-1&state=${buildState({csrf_token: 'csrf-abc'})}`;
|
|
828
|
+
const webex = makeWebex(`http://example.com/${search}`);
|
|
829
|
+
webex.getWindow().sessionStorage.getItem = sinon.stub().returns(null);
|
|
830
|
+
|
|
831
|
+
assert.throws(
|
|
832
|
+
() => webex.authorization.handleThirdPartyCallback(),
|
|
833
|
+
/CSRF token missing from session storage/
|
|
834
|
+
);
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
it('throws when state.csrf_token does not match stored token', () => {
|
|
838
|
+
const search = `?id_token=id-1&state=${buildState({csrf_token: 'attacker'})}`;
|
|
839
|
+
const webex = makeWebex(`http://example.com/${search}`, 'real-token');
|
|
840
|
+
webex.getWindow().sessionStorage.getItem = sinon.stub().returns('real-token');
|
|
841
|
+
|
|
842
|
+
assert.throws(
|
|
843
|
+
() => webex.authorization.handleThirdPartyCallback(),
|
|
844
|
+
/CSRF token attacker does not match stored token real-token/
|
|
845
|
+
);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
it('throws the native decode error (not a wrapped message) when state is malformed', () => {
|
|
849
|
+
// base64('not json') decodes to 'not json' which is not valid JSON
|
|
850
|
+
const search = `?id_token=id-1&state=bm90IGpzb24%3D`;
|
|
851
|
+
const webex = makeWebex(`http://example.com/${search}`, 'csrf-abc');
|
|
852
|
+
webex.getWindow().sessionStorage.getItem = sinon.stub().returns('csrf-abc');
|
|
853
|
+
|
|
854
|
+
// _verifySecurityToken is never reached because decode throws first;
|
|
855
|
+
// surface the underlying SyntaxError untouched.
|
|
856
|
+
assert.throws(() => webex.authorization.handleThirdPartyCallback(), SyntaxError);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
it('throws on error responses that lack a valid state (still verifies CSRF)', () => {
|
|
860
|
+
// Social provider returned ?error=... without echoing state. We must
|
|
861
|
+
// not silently treat this as a successful callback.
|
|
862
|
+
const search = `?error=FailedToCallOAuthProvider`;
|
|
863
|
+
const webex = makeWebex('http://example.com', 'csrf-abc');
|
|
864
|
+
webex.getWindow().location.href = `http://example.com/${search}`;
|
|
865
|
+
webex.getWindow().sessionStorage.getItem = sinon.stub().returns('csrf-abc');
|
|
866
|
+
|
|
867
|
+
assert.throws(
|
|
868
|
+
() => webex.authorization.handleThirdPartyCallback(),
|
|
869
|
+
/Expected CSRF token csrf-abc, but not found in redirect query/
|
|
870
|
+
);
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
it('throws CSRF error when location has no query params at all', () => {
|
|
874
|
+
const webex = makeWebex('http://example.com/', 'csrf-abc');
|
|
875
|
+
webex.getWindow().sessionStorage.getItem = sinon.stub().returns('csrf-abc');
|
|
876
|
+
|
|
877
|
+
assert.throws(
|
|
878
|
+
() => webex.authorization.handleThirdPartyCallback(),
|
|
879
|
+
/Expected CSRF token csrf-abc, but not found in redirect query/
|
|
880
|
+
);
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
it('throws when state is absent even if no stored CSRF token exists (requireMatch)', () => {
|
|
884
|
+
const webex = makeWebex('http://example.com/?id_token=id-1');
|
|
885
|
+
webex.getWindow().sessionStorage.getItem = sinon.stub().returns(null);
|
|
886
|
+
|
|
887
|
+
assert.throws(
|
|
888
|
+
() => webex.authorization.handleThirdPartyCallback(),
|
|
889
|
+
/CSRF token missing from session storage/
|
|
890
|
+
);
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
it('returns error value in the result and preserves non-csrf state', () => {
|
|
894
|
+
const storedToken = 'csrf-abc';
|
|
895
|
+
const search = `?error=FailedToCallOAuthProvider&state=${buildState({
|
|
896
|
+
csrf_token: storedToken,
|
|
897
|
+
popUpSignIn: true,
|
|
898
|
+
})}`;
|
|
899
|
+
const webex = makeWebex('http://example.com', storedToken);
|
|
900
|
+
webex.getWindow().location.href = `http://example.com/${search}`;
|
|
901
|
+
webex.getWindow().sessionStorage.getItem = sinon.stub().returns(storedToken);
|
|
902
|
+
|
|
903
|
+
const result = webex.authorization.handleThirdPartyCallback();
|
|
904
|
+
|
|
905
|
+
assert.deepEqual(result, {
|
|
906
|
+
idToken: undefined,
|
|
907
|
+
email: undefined,
|
|
908
|
+
error: 'FailedToCallOAuthProvider',
|
|
909
|
+
state: {popUpSignIn: true},
|
|
559
910
|
});
|
|
911
|
+
// `error` is non-sensitive; not scrubbed from the URL.
|
|
912
|
+
assert.equal(parseLocationQuery(webex).error, 'FailedToCallOAuthProvider');
|
|
560
913
|
});
|
|
561
914
|
});
|
|
562
915
|
|
|
@@ -564,7 +917,8 @@ describe('plugin-authorization-browser-first-party', () => {
|
|
|
564
917
|
it('should generate a QR code URL when a userCode is present', () => {
|
|
565
918
|
const verificationUrl = 'https://example.com/verify?userCode=123456';
|
|
566
919
|
const oauthHelperUrl = 'https://oauth-helper-a.wbx2.com/helperservice/v1';
|
|
567
|
-
const expectedUrl =
|
|
920
|
+
const expectedUrl =
|
|
921
|
+
'https://web.webex.com/deviceAuth?usercode=123456&oauthhelper=https%3A%2F%2Foauth-helper-a.wbx2.com%2Fhelperservice%2Fv1';
|
|
568
922
|
|
|
569
923
|
const webex = makeWebex('http://example.com');
|
|
570
924
|
|
|
@@ -1027,7 +1381,33 @@ describe('plugin-authorization-browser-first-party', () => {
|
|
|
1027
1381
|
|
|
1028
1382
|
assert.isDefined(href);
|
|
1029
1383
|
assert.equal(href, `?state=${base64.encode(JSON.stringify({key: 'value'}))}`);
|
|
1030
|
-
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
it('strips id_token and email from the query string', () => {
|
|
1387
|
+
const webex = makeWebex(undefined, undefined, {
|
|
1388
|
+
credentials: {
|
|
1389
|
+
clientType: 'confidential',
|
|
1390
|
+
},
|
|
1391
|
+
});
|
|
1392
|
+
const location = {
|
|
1393
|
+
query: {
|
|
1394
|
+
code: 'code',
|
|
1395
|
+
id_token: 'id-token-value',
|
|
1396
|
+
email: 'user@example.com',
|
|
1397
|
+
state: {
|
|
1398
|
+
csrf_token: 'token',
|
|
1399
|
+
key: 'value',
|
|
1400
|
+
},
|
|
1401
|
+
},
|
|
1402
|
+
};
|
|
1403
|
+
|
|
1404
|
+
webex.authorization._cleanUrl(location);
|
|
1405
|
+
assert.called(webex.getWindow().history.replaceState);
|
|
1406
|
+
const {href} = webex.getWindow().location;
|
|
1407
|
+
|
|
1408
|
+
assert.notInclude(href, 'id_token');
|
|
1409
|
+
assert.notInclude(href, 'email');
|
|
1410
|
+
assert.notInclude(href, 'code');
|
|
1031
1411
|
});
|
|
1032
1412
|
});
|
|
1033
1413
|
|