@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.
@@ -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.stub(webex.authorization, 'initiateAuthorizationCodeGrant').returns(Promise.resolve());
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: { hasEmail: false, hasState: false },
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.stub(webex.authorization, 'initiateAuthorizationCodeGrant').returns(Promise.resolve());
533
+ sinon
534
+ .stub(webex.authorization, 'initiateAuthorizationCodeGrant')
535
+ .returns(Promise.resolve());
448
536
 
449
- return webex.authorization.initiateLogin({ email: 'test@abc.xyz' }).then(() => {
537
+ return webex.authorization.initiateLogin({email: 'test@abc.xyz'}).then(() => {
450
538
  assert.calledOnceWithExactly(emitSpy, Events.login, {
451
539
  eventType: 'initiateLogin',
452
- data: { hasEmail: true, hasState: false },
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.stub(webex.authorization, 'initiateAuthorizationCodeGrant').returns(Promise.resolve());
549
+ sinon
550
+ .stub(webex.authorization, 'initiateAuthorizationCodeGrant')
551
+ .returns(Promise.resolve());
462
552
 
463
- return webex.authorization.initiateLogin({ state: {} }).then(() => {
553
+ return webex.authorization.initiateLogin({state: {}}).then(() => {
464
554
  assert.calledOnceWithExactly(emitSpy, Events.login, {
465
555
  eventType: 'initiateLogin',
466
- data: { hasEmail: false, hasState: true },
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
- const webex = makeWebex(undefined, undefined, {
475
- credentials: {
476
- clientType: 'confidential',
477
- },
478
- });
564
+ const webex = makeWebex(undefined, undefined, {
565
+ credentials: {
566
+ clientType: 'confidential',
567
+ },
568
+ });
479
569
 
480
- sinon.spy(webex.authorization, 'initiateAuthorizationCodeGrant');
570
+ sinon.spy(webex.authorization, 'initiateAuthorizationCodeGrant');
481
571
 
482
- return webex.authorization.initiateLogin().then(() => {
483
- assert.called(webex.authorization.initiateAuthorizationCodeGrant);
484
- assert.include(webex.getWindow().location, 'response_type=code');
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
- const webex = makeWebex();
579
+ const webex = makeWebex();
490
580
 
491
- return webex.authorization.initiateAuthorizationCodeGrant().then(() => {
492
- assert.isDefined(webex.getWindow().location);
493
- assert.isUndefined(webex.getWindow().open);
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
- 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
- });
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
- const webex = makeWebex();
511
- webex.getWindow().open = sinon.spy();
602
+ const webex = makeWebex();
603
+ webex.getWindow().open = sinon.spy();
512
604
 
513
- const customWindow = {
514
- width: 800,
515
- height: 600,
516
- menubar: 'no',
517
- toolbar: 'no'
518
- };
605
+ const customWindow = {
606
+ width: 800,
607
+ height: 600,
608
+ menubar: 'no',
609
+ toolbar: 'no',
610
+ };
519
611
 
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
- });
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
- 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
- });
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 = "https://test.example.com";
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: { loginUrl: testLoginUrl },
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 = 'https://web.webex.com/deviceAuth?usercode=123456&oauthhelper=https%3A%2F%2Foauth-helper-a.wbx2.com%2Fhelperservice%2Fv1';
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
- assert.notInclude(href, 'csrf_token');
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