@webex/plugin-authorization-browser-first-party 3.12.0-next.5 → 3.12.0-next.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -7
- package/dist/authorization.js +177 -21
- package/dist/authorization.js.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/package.json +13 -13
- package/src/authorization.js +185 -29
- package/src/index.js +1 -1
- package/test/unit/spec/authorization.js +516 -82
package/src/authorization.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
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 {
|
|
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 =
|
|
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: {
|
|
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 =
|
|
644
|
+
const verificationUriComplete =
|
|
645
|
+
this._generateQRCodeVerificationUrl(verification_uri_complete);
|
|
501
646
|
this.eventEmitter.emit(Events.qRCodeLogin, {
|
|
502
647
|
eventType: 'getUserCodeSuccess',
|
|
503
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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 =
|
|
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
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
-
|
|
809
|
-
|
|
964
|
+
if (options.requireMatch) {
|
|
965
|
+
throw new Error('CSRF token missing from session storage');
|
|
966
|
+
}
|
|
810
967
|
|
|
811
|
-
|
|
812
|
-
throw new Error(`Expected CSRF token ${sessionToken}, but not found in redirect query`);
|
|
968
|
+
return;
|
|
813
969
|
}
|
|
814
970
|
|
|
815
|
-
if (!query.state
|
|
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