@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.
@@ -1,4 +1,4 @@
1
- // @ts-nocheck
1
+ // @ts-nocheck
2
2
  /* eslint-disable */
3
3
  /*!
4
4
  * Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file.
@@ -14,17 +14,13 @@ import querystring from 'querystring';
14
14
  import url from 'url';
15
15
  import {EventEmitter} from 'events';
16
16
 
17
- import {base64, oneFlight, whileInFlight} from '@webex/common';
17
+ import {decodeState, encodeState, oneFlight, whileInFlight} from '@webex/common';
18
18
  import {grantErrors, WebexPlugin} from '@webex/webex-core';
19
- import {cloneDeep, isEmpty, omit} from 'lodash';
19
+ import {cloneDeep, isEmpty, omit, isObject} from 'lodash';
20
20
  import uuid from 'uuid';
21
21
  import base64url from 'crypto-js/enc-base64url';
22
22
  import CryptoJS from 'crypto-js';
23
23
 
24
- // Necessary to require lodash this way in order to stub
25
- // methods in the unit test
26
- const lodash = require('lodash');
27
-
28
24
  const OAUTH2_CSRF_TOKEN = 'oauth2-csrf-token';
29
25
  const OAUTH2_CODE_VERIFIER = 'oauth2-code-verifier';
30
26
 
@@ -39,6 +35,18 @@ export const Events = {
39
35
  qRCodeLogin: 'qRCodeLogin',
40
36
  };
41
37
 
38
+ /**
39
+ * Terminal outcomes for the automatic authorization-code exchange performed
40
+ * during authorization plugin initialization.
41
+ *
42
+ * @enum {string}
43
+ */
44
+ export const InitialAuthorizationCodeGrantOutcomes = {
45
+ failure: 'failure',
46
+ notAttempted: 'not_attempted',
47
+ success: 'success',
48
+ };
49
+
42
50
  /**
43
51
  * Browser support for OAuth2 for first-party (Webex Web Client) usage.
44
52
  *
@@ -69,6 +77,37 @@ export const Events = {
69
77
  */
70
78
  const Authorization = WebexPlugin.extend({
71
79
  derived: {
80
+ /**
81
+ * Retains the terminal outcome of the automatic authorization-code exchange
82
+ * performed during this authorization plugin instance's initialization.
83
+ *
84
+ * This historical value does not represent current authorization state,
85
+ * credentials hydrated from storage, guest authentication, or
86
+ * authorization-code exchanges requested later on the same SDK instance. It
87
+ * is not reset by logout. OAuth redirect errors and CSRF validation failures
88
+ * occur before the exchange, so the value remains not_attempted; these errors
89
+ * can throw before ready becomes true.
90
+ *
91
+ * Calling logout({noRedirect: true}) does not cancel an initialization
92
+ * exchange already in flight. If that exchange later settles, it can still
93
+ * update credentials and this retained outcome.
94
+ *
95
+ * Interpret only after authorization readiness:
96
+ * - not_attempted: initialization did not invoke requestAuthorizationCodeGrant()
97
+ * - success: the initialization exchange fulfilled
98
+ * - failure: the initialization exchange threw or rejected
99
+ *
100
+ * @instance
101
+ * @memberof AuthorizationBrowserFirstParty
102
+ * @readonly
103
+ * @type {string}
104
+ */
105
+ initialAuthorizationCodeGrantOutcome: {
106
+ deps: ['_initialAuthorizationCodeGrantOutcome'],
107
+ fn() {
108
+ return this._initialAuthorizationCodeGrantOutcome;
109
+ },
110
+ },
72
111
  /**
73
112
  * Alias of {@link AuthorizationBrowserFirstParty#isAuthorizing}
74
113
  * @instance
@@ -94,6 +133,14 @@ const Authorization = WebexPlugin.extend({
94
133
  default: false,
95
134
  type: 'boolean',
96
135
  },
136
+ /**
137
+ * Internal backing state for the initial authorization-code grant outcome.
138
+ * @private
139
+ */
140
+ _initialAuthorizationCodeGrantOutcome: {
141
+ default: InitialAuthorizationCodeGrantOutcomes.notAttempted,
142
+ type: 'string',
143
+ },
97
144
  /**
98
145
  * Indicates that the plugin has finished any automatic startup
99
146
  * processing (e.g., exchanging a returned authorization code)
@@ -199,7 +246,7 @@ const Authorization = WebexPlugin.extend({
199
246
 
200
247
  // Decode and parse state object (if present)
201
248
  if (location.query.state) {
202
- location.query.state = JSON.parse(base64.decode(location.query.state));
249
+ location.query.state = decodeState(location.query.state);
203
250
  } else {
204
251
  location.query.state = {};
205
252
  }
@@ -233,7 +280,13 @@ const Authorization = WebexPlugin.extend({
233
280
  .collectPreauthCatalog(preauthCatalogParams)
234
281
  .catch(() => Promise.resolve()) // Non-fatal if catalog collection fails
235
282
  .then(() => this.requestAuthorizationCodeGrant({code, codeVerifier}))
283
+ .then(() => {
284
+ this._initialAuthorizationCodeGrantOutcome =
285
+ InitialAuthorizationCodeGrantOutcomes.success;
286
+ })
236
287
  .catch((error) => {
288
+ this._initialAuthorizationCodeGrantOutcome =
289
+ InitialAuthorizationCodeGrantOutcomes.failure;
237
290
  this.logger.warn('authorization: failed initial authorization code grant request', error);
238
291
  })
239
292
  .then(() => {
@@ -267,7 +320,7 @@ const Authorization = WebexPlugin.extend({
267
320
  eventType: 'initiateLogin',
268
321
  data: {
269
322
  hasEmail: !!options.email,
270
- hasState: !!options.state
323
+ hasState: !!options.state,
271
324
  },
272
325
  });
273
326
 
@@ -316,7 +369,7 @@ const Authorization = WebexPlugin.extend({
316
369
 
317
370
  this.eventEmitter.emit(Events.login, {
318
371
  eventType: 'redirectToLoginUrl',
319
- data: { loginUrl },
372
+ data: {loginUrl},
320
373
  });
321
374
 
322
375
  if (options?.separateWindow) {
@@ -343,6 +396,97 @@ const Authorization = WebexPlugin.extend({
343
396
  return Promise.resolve();
344
397
  },
345
398
 
399
+ /**
400
+ * Initiates third-party (social provider) login. Generates a CSRF token,
401
+ * embeds it in `options.state.csrf_token`, and delegates to
402
+ * `initiateThirdPartyLoginRedirect` for navigation.
403
+ *
404
+ * @instance
405
+ * @memberof AuthorizationBrowserFirstParty
406
+ * @param {Object} options
407
+ * @param {string} options.oauth2provider
408
+ * @param {string} options.returnURL
409
+ * @param {Object} [options.state] - Caller-supplied state object. Merged
410
+ * with the generated `csrf_token`.
411
+ * @returns {Promise<void>}
412
+ */
413
+ initiateThirdPartyLogin(options = {}) {
414
+ options = cloneDeep(options);
415
+ if (options.state !== undefined && !isObject(options.state)) {
416
+ throw new Error('if specified, `options.state` must be an object');
417
+ }
418
+ options.state = options.state || {};
419
+ options.state.csrf_token = this._generateSecurityToken();
420
+
421
+ return this.initiateThirdPartyLoginRedirect(options);
422
+ },
423
+
424
+ /**
425
+ * Performs the navigation step of the third-party login flow. Builds the
426
+ * IdBroker URL via `Credentials#buildThirdPartyLoginUrl` and assigns it
427
+ * to `getWindow().location`.
428
+ *
429
+ * Mirrors `initiateAuthorizationCodeGrant` for the `/authorize` flow.
430
+ * Consumers may override this method for custom navigation handling
431
+ * (e.g. postMessage in iframed contexts).
432
+ *
433
+ * @instance
434
+ * @memberof AuthorizationBrowserFirstParty
435
+ * @param {Object} options
436
+ * @param {string} options.oauth2provider
437
+ * @param {string} options.returnURL
438
+ * @returns {Promise<void>}
439
+ */
440
+ initiateThirdPartyLoginRedirect(options = {}) {
441
+ this.logger.info('authorization: initiating third-party login redirect');
442
+
443
+ try {
444
+ const url = this.webex.credentials.buildThirdPartyLoginUrl(options);
445
+
446
+ this.webex.getWindow().location = url;
447
+ } catch (err) {
448
+ return Promise.reject(err);
449
+ }
450
+
451
+ return Promise.resolve();
452
+ },
453
+
454
+ /**
455
+ * Handles the third-party (social provider) login callback. Reads the
456
+ * current `window.location`, decodes `state`, validates the CSRF token
457
+ * (`state.csrf_token`), scrubs sensitive parameters from the URL via
458
+ * `_cleanUrl`, and returns the parsed payload.
459
+ *
460
+ * Mirrors `initialize()` in always operating on the live
461
+ * `window.location`
462
+ *
463
+ * `idToken` is single-use: it is parsed out of the URL exactly once and
464
+ * the calling client is expected to exchange it (or discard it)
465
+ * immediately. The returned `state` has `csrf_token` removed.
466
+ *
467
+ * @instance
468
+ * @memberof AuthorizationBrowserFirstParty
469
+ * @returns {{idToken: string|undefined, email: string|undefined,
470
+ * error: string|undefined, state: Object}}
471
+ */
472
+ handleThirdPartyCallback() {
473
+ const location = url.parse(this.webex.getWindow().location.href, true);
474
+
475
+ location.query.state = decodeState(location.query.state || 'e30');
476
+
477
+ this._verifySecurityToken(location.query, {requireMatch: true});
478
+ this._cleanUrl(location);
479
+
480
+ const {
481
+ id_token: idToken,
482
+ email,
483
+ error,
484
+ state: {csrf_token, ...state},
485
+ } = location.query;
486
+
487
+ return {idToken, email, error, state};
488
+ },
489
+
346
490
  /**
347
491
  * Called by {@link WebexCore#logout()}.
348
492
  * Constructs logout URL and (unless suppressed) navigates away to ensure
@@ -497,10 +641,11 @@ const Authorization = WebexPlugin.extend({
497
641
  })
498
642
  .then((res) => {
499
643
  const {user_code, verification_uri, verification_uri_complete} = res.body;
500
- const verificationUriComplete = this._generateQRCodeVerificationUrl(verification_uri_complete);
644
+ const verificationUriComplete =
645
+ this._generateQRCodeVerificationUrl(verification_uri_complete);
501
646
  this.eventEmitter.emit(Events.qRCodeLogin, {
502
647
  eventType: 'getUserCodeSuccess',
503
- userData: {
648
+ userData: {
504
649
  userCode: user_code,
505
650
  verificationUri: verification_uri,
506
651
  verificationUriComplete,
@@ -591,7 +736,7 @@ const Authorization = WebexPlugin.extend({
591
736
  // If polling canceled (id changed), ignore this response
592
737
  if (this.currentPollingId !== this.pollingId) return;
593
738
 
594
- this.eventEmitter.emit(Events.qRCodeLogin, {
739
+ this.eventEmitter.emit(Events.qRCodeLogin, {
595
740
  eventType: 'authorizationSuccess',
596
741
  data: res.body,
597
742
  });
@@ -703,8 +848,9 @@ const Authorization = WebexPlugin.extend({
703
848
  * - HTTP referrer headers to third-party content
704
849
  *
705
850
  * Approach:
706
- * - Remove 'code'.
707
- * - Remove 'state' entirely if only contained csrf_token.
851
+ * - Remove 'code' (OAuth code-grant), 'id_token', and 'email'
852
+ * (third-party callback).
853
+ * - Remove 'state' entirely if it only contained csrf_token.
708
854
  * - Else, re-encode remaining state fields (minus csrf_token).
709
855
  * - Replace current history entry (no page reload).
710
856
  *
@@ -718,12 +864,12 @@ const Authorization = WebexPlugin.extend({
718
864
  location = cloneDeep(location);
719
865
  if (this.webex.getWindow().history && this.webex.getWindow().history.replaceState) {
720
866
  Reflect.deleteProperty(location.query, 'code');
867
+ Reflect.deleteProperty(location.query, 'id_token');
868
+ Reflect.deleteProperty(location.query, 'email');
721
869
  if (isEmpty(omit(location.query.state, 'csrf_token'))) {
722
870
  Reflect.deleteProperty(location.query, 'state');
723
871
  } else {
724
- location.query.state = base64.encode(
725
- JSON.stringify(omit(location.query.state, 'csrf_token'))
726
- );
872
+ location.query.state = encodeState(omit(location.query.state, 'csrf_token'));
727
873
  }
728
874
  location.search = querystring.stringify(location.query);
729
875
  Reflect.deleteProperty(location, 'query');
@@ -737,7 +883,8 @@ const Authorization = WebexPlugin.extend({
737
883
  * during authorization code exchange; removes it once consumed.
738
884
  *
739
885
  * Implementation details:
740
- * - Creates a 128 character string using base64url safe alphabet.
886
+ * - Creates a 128 character string using a cryptographically secure random
887
+ * source and the base64url safe alphabet.
741
888
  * - Computes SHA256 hash, encodes to base64url (no padding).
742
889
  *
743
890
  * @instance
@@ -750,10 +897,14 @@ const Authorization = WebexPlugin.extend({
750
897
 
751
898
  // eslint-disable-next-line no-underscore-dangle
752
899
  const safeCharacterMap = base64url._safe_map;
900
+ const randomValues = new Uint8Array(128);
753
901
 
754
- const codeVerifier = lodash
755
- .times(128, () => safeCharacterMap[lodash.random(0, safeCharacterMap.length - 1)])
756
- .join('');
902
+ this.webex.getWindow().crypto.getRandomValues(randomValues);
903
+
904
+ const codeVerifier = Array.from(
905
+ randomValues,
906
+ (randomValue) => safeCharacterMap[randomValue & (safeCharacterMap.length - 1)]
907
+ ).join('');
757
908
 
758
909
  const codeChallenge = CryptoJS.SHA256(codeVerifier).toString(base64url);
759
910
 
@@ -792,27 +943,32 @@ const Authorization = WebexPlugin.extend({
792
943
  * - Ensure state + state.csrf_token exist.
793
944
  * - Compare values; throw descriptive errors on mismatch / absence.
794
945
  *
795
- * If no stored token (e.g., user navigated directly), silently returns.
946
+ * If no stored token (e.g., user navigated directly), silently returns
947
+ * unless `options.requireMatch` is `true`, in which case absence of a
948
+ * stored token is treated as a CSRF failure.
796
949
  *
797
950
  * @instance
798
951
  * @memberof AuthorizationBrowserFirstParty
799
952
  * @param {Object} query - Parsed query (location.query)
953
+ * @param {Object} [options]
954
+ * @param {boolean} [options.requireMatch=false] - When true, throws if
955
+ * no stored sessionToken is present.
800
956
  * @private
801
957
  * @returns {void}
802
958
  */
803
- _verifySecurityToken(query) {
959
+ _verifySecurityToken(query, options = {}) {
804
960
  const sessionToken = this.webex.getWindow().sessionStorage.getItem(OAUTH2_CSRF_TOKEN);
805
961
 
806
962
  this.webex.getWindow().sessionStorage.removeItem(OAUTH2_CSRF_TOKEN);
807
963
  if (!sessionToken) {
808
- return;
809
- }
964
+ if (options.requireMatch) {
965
+ throw new Error('CSRF token missing from session storage');
966
+ }
810
967
 
811
- if (!query.state) {
812
- throw new Error(`Expected CSRF token ${sessionToken}, but not found in redirect query`);
968
+ return;
813
969
  }
814
970
 
815
- if (!query.state.csrf_token) {
971
+ if (!query.state?.csrf_token) {
816
972
  throw new Error(`Expected CSRF token ${sessionToken}, but not found in redirect query`);
817
973
  }
818
974
 
package/src/index.js CHANGED
@@ -14,5 +14,5 @@ registerPlugin('authorization', Authorization, {
14
14
  proxies,
15
15
  });
16
16
 
17
- export {default, Events} from './authorization';
17
+ export {default, Events, InitialAuthorizationCodeGrantOutcomes} from './authorization';
18
18
  export {default as config} from './config';