@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.
@@ -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.stub(webex.authorization, 'initiateAuthorizationCodeGrant').returns(Promise.resolve());
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: { hasEmail: false, hasState: false },
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.stub(webex.authorization, 'initiateAuthorizationCodeGrant').returns(Promise.resolve());
537
+ sinon
538
+ .stub(webex.authorization, 'initiateAuthorizationCodeGrant')
539
+ .returns(Promise.resolve());
448
540
 
449
- return webex.authorization.initiateLogin({ email: 'test@abc.xyz' }).then(() => {
541
+ return webex.authorization.initiateLogin({email: 'test@abc.xyz'}).then(() => {
450
542
  assert.calledOnceWithExactly(emitSpy, Events.login, {
451
543
  eventType: 'initiateLogin',
452
- data: { hasEmail: true, hasState: false },
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.stub(webex.authorization, 'initiateAuthorizationCodeGrant').returns(Promise.resolve());
553
+ sinon
554
+ .stub(webex.authorization, 'initiateAuthorizationCodeGrant')
555
+ .returns(Promise.resolve());
462
556
 
463
- return webex.authorization.initiateLogin({ state: {} }).then(() => {
557
+ return webex.authorization.initiateLogin({state: {}}).then(() => {
464
558
  assert.calledOnceWithExactly(emitSpy, Events.login, {
465
559
  eventType: 'initiateLogin',
466
- data: { hasEmail: false, hasState: true },
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
- const webex = makeWebex(undefined, undefined, {
475
- credentials: {
476
- clientType: 'confidential',
477
- },
478
- });
568
+ const webex = makeWebex(undefined, undefined, {
569
+ credentials: {
570
+ clientType: 'confidential',
571
+ },
572
+ });
479
573
 
480
- sinon.spy(webex.authorization, 'initiateAuthorizationCodeGrant');
574
+ sinon.spy(webex.authorization, 'initiateAuthorizationCodeGrant');
481
575
 
482
- return webex.authorization.initiateLogin().then(() => {
483
- assert.called(webex.authorization.initiateAuthorizationCodeGrant);
484
- assert.include(webex.getWindow().location, 'response_type=code');
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
- const webex = makeWebex();
583
+ const webex = makeWebex();
490
584
 
491
- return webex.authorization.initiateAuthorizationCodeGrant().then(() => {
492
- assert.isDefined(webex.getWindow().location);
493
- assert.isUndefined(webex.getWindow().open);
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
- const webex = makeWebex();
499
- webex.getWindow().open = sinon.spy();
500
-
501
- return webex.authorization.initiateAuthorizationCodeGrant({ separateWindow: true }).then(() => {
502
- assert.called(webex.getWindow().open);
503
- const openCall = webex.getWindow().open.getCall(0);
504
- assert.equal(openCall.args[1], '_blank');
505
- assert.equal(openCall.args[2], 'width=600,height=800');
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
- const webex = makeWebex();
511
- webex.getWindow().open = sinon.spy();
606
+ const webex = makeWebex();
607
+ webex.getWindow().open = sinon.spy();
512
608
 
513
- const customWindow = {
514
- width: 800,
515
- height: 600,
516
- menubar: 'no',
517
- toolbar: 'no'
518
- };
609
+ const customWindow = {
610
+ width: 800,
611
+ height: 600,
612
+ menubar: 'no',
613
+ toolbar: 'no',
614
+ };
519
615
 
520
- return webex.authorization.initiateAuthorizationCodeGrant({
521
- separateWindow: customWindow
522
- }).then(() => {
523
- assert.called(webex.getWindow().open);
524
- const openCall = webex.getWindow().open.getCall(0);
525
- assert.equal(openCall.args[1], '_blank');
526
- assert.equal(
527
- openCall.args[2],
528
- 'width=800,height=600,menubar=no,toolbar=no'
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
- const webex = makeWebex();
535
- webex.getWindow().open = sinon.spy();
536
-
537
- return webex.authorization.initiateAuthorizationCodeGrant({
538
- separateWindow: true,
539
- state: {}
540
- }).then(() => {
541
- assert.called(webex.getWindow().open);
542
- const url = webex.getWindow().open.getCall(0).args[0];
543
- assert.include(url, "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");
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 = "https://test.example.com";
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: { loginUrl: testLoginUrl },
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 = 'https://web.webex.com/deviceAuth?usercode=123456&oauthhelper=https%3A%2F%2Foauth-helper-a.wbx2.com%2Fhelperservice%2Fv1';
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
- const expectedVerifier = times(128, () => safeCharacterMap[0]).join('');
1315
+ function makeWebexWithRandomValues(fillRandomValues) {
1316
+ const getRandomValuesStub = sinon.stub().callsFake((randomValues) => {
1317
+ fillRandomValues(randomValues);
959
1318
 
960
- it('generates a challenge code and stores it in session storage', () => {
961
- const webex = makeWebex('http://example.com');
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
- const toStringStub = sinon.stub().returns(expectedCodeChallenge);
964
- const randomStub = sinon.stub(lodash, 'random').returns(0);
965
- const sha256Stub = sinon.stub(CryptoJS, 'SHA256').returns({
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.equal(codeChallenge, expectedCodeChallenge);
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
- assert.notInclude(href, 'csrf_token');
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