@webex/webex-core 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 +5 -2
- package/dist/config.js +38 -0
- package/dist/config.js.map +1 -1
- package/dist/credentials-config.js +12 -0
- package/dist/credentials-config.js.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/interceptors/catalog-url.js +87 -0
- package/dist/interceptors/catalog-url.js.map +1 -0
- package/dist/interceptors/payload-transformer.js +25 -4
- package/dist/interceptors/payload-transformer.js.map +1 -1
- package/dist/lib/batcher.js +23 -7
- package/dist/lib/batcher.js.map +1 -1
- package/dist/lib/credentials/credentials.js +103 -4
- package/dist/lib/credentials/credentials.js.map +1 -1
- package/dist/lib/credentials/token.js +1 -1
- package/dist/lib/domains.js +90 -0
- package/dist/lib/domains.js.map +1 -0
- package/dist/lib/services/service-catalog.js +108 -18
- package/dist/lib/services/service-catalog.js.map +1 -1
- package/dist/lib/services/services.js +232 -34
- package/dist/lib/services/services.js.map +1 -1
- package/dist/lib/services-v2/service-catalog.js +14 -13
- package/dist/lib/services-v2/service-catalog.js.map +1 -1
- package/dist/lib/services-v2/services-v2.js +241 -37
- package/dist/lib/services-v2/services-v2.js.map +1 -1
- package/dist/plugins/logger.js +1 -1
- package/dist/webex-core.js +11 -3
- package/dist/webex-core.js.map +1 -1
- package/package.json +13 -13
- package/src/config.js +42 -0
- package/src/credentials-config.js +13 -0
- package/src/index.js +1 -0
- package/src/interceptors/catalog-url.js +66 -0
- package/src/interceptors/payload-transformer.js +23 -1
- package/src/lib/batcher.js +25 -10
- package/src/lib/credentials/credentials.js +93 -3
- package/src/lib/domains.ts +94 -0
- package/src/lib/services/service-catalog.js +112 -16
- package/src/lib/services/services.js +208 -20
- package/src/lib/services-v2/service-catalog.ts +16 -12
- package/src/lib/services-v2/services-v2.ts +218 -21
- package/src/webex-core.js +8 -0
- package/test/fixtures/activation-email.ts +22 -0
- package/test/integration/spec/services/services.js +183 -112
- package/test/integration/spec/services-v2/services-v2.js +169 -98
- package/test/unit/spec/credentials/credentials.js +247 -3
- package/test/unit/spec/interceptors/auth.js +56 -0
- package/test/unit/spec/interceptors/catalog-url.js +224 -0
- package/test/unit/spec/interceptors/payload-transformer.js +119 -1
- package/test/unit/spec/lib/batcher.js +56 -0
- package/test/unit/spec/services/service-catalog.js +368 -11
- package/test/unit/spec/services/services.js +440 -2
- package/test/unit/spec/services-v2/service-catalog.ts +134 -11
- package/test/unit/spec/services-v2/services-v2.ts +467 -0
- package/test/unit/spec/webex-core.js +77 -1
|
@@ -6,7 +6,7 @@ import querystring from 'querystring';
|
|
|
6
6
|
import url from 'url';
|
|
7
7
|
|
|
8
8
|
import jwt from 'jsonwebtoken';
|
|
9
|
-
import {base64, makeStateDataType, oneFlight, tap, whileInFlight} from '@webex/common';
|
|
9
|
+
import {base64, encodeState, makeStateDataType, oneFlight, tap, whileInFlight} from '@webex/common';
|
|
10
10
|
import {safeSetTimeout} from '@webex/common-timers';
|
|
11
11
|
import {clone, cloneDeep, isObject, isEmpty} from 'lodash';
|
|
12
12
|
|
|
@@ -109,7 +109,7 @@ const Credentials = WebexPlugin.extend({
|
|
|
109
109
|
*/
|
|
110
110
|
buildLoginUrl(options = {clientType: 'public'}) {
|
|
111
111
|
/* eslint-disable camelcase */
|
|
112
|
-
if (options.state && !isObject(options.state)) {
|
|
112
|
+
if (options.state !== undefined && !isObject(options.state)) {
|
|
113
113
|
throw new Error('if specified, `options.state` must be an object');
|
|
114
114
|
}
|
|
115
115
|
|
|
@@ -126,7 +126,7 @@ const Credentials = WebexPlugin.extend({
|
|
|
126
126
|
|
|
127
127
|
if (options.state) {
|
|
128
128
|
if (!isEmpty(options.state)) {
|
|
129
|
-
options.state =
|
|
129
|
+
options.state = encodeState(options.state);
|
|
130
130
|
} else {
|
|
131
131
|
delete options.state;
|
|
132
132
|
}
|
|
@@ -212,6 +212,96 @@ const Credentials = WebexPlugin.extend({
|
|
|
212
212
|
return fields[2];
|
|
213
213
|
},
|
|
214
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Extract the CI user ID [cis_uuid] from a provided token.
|
|
217
|
+
*
|
|
218
|
+
* @private
|
|
219
|
+
* @param {string} token - The access token to extract the user ID from.
|
|
220
|
+
* @throws {Error} - If the token cannot be parsed or does not contain a user ID.
|
|
221
|
+
* @returns {string} - The CI user ID.
|
|
222
|
+
*/
|
|
223
|
+
extractUserIdFromToken(token = '') {
|
|
224
|
+
// User tokens are JWT-like; the middle section holds a base64-encoded JSON payload.
|
|
225
|
+
const payload = JSON.parse(base64.decode(token.split('.')[1]));
|
|
226
|
+
|
|
227
|
+
if (!payload.cis_uuid) {
|
|
228
|
+
throw new Error('the provided token does not contain a user ID');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return payload.cis_uuid;
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Get the CI user ID [cis_uuid] of the currently authenticated user.
|
|
236
|
+
*
|
|
237
|
+
* Checks the supertoken first, then falls back to any stored user tokens.
|
|
238
|
+
*
|
|
239
|
+
* @throws {Error} - If the user ID could not be determined from any token.
|
|
240
|
+
* @returns {string} - The CI user ID.
|
|
241
|
+
*/
|
|
242
|
+
getUserId() {
|
|
243
|
+
const tokens = [this.supertoken, ...this.userTokens.models];
|
|
244
|
+
|
|
245
|
+
for (const token of tokens) {
|
|
246
|
+
if (token && token.access_token) {
|
|
247
|
+
try {
|
|
248
|
+
return this.extractUserIdFromToken(token.access_token);
|
|
249
|
+
} catch {
|
|
250
|
+
// token wasn't parseable or lacked a user ID; try the next one
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
throw new Error('could not extract the user ID from any available token');
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Generates a Third-Party Login URL pointing at IdBroker's
|
|
260
|
+
* `/idb/ThirdPartyLogin` endpoint. Used by the social-provider sign-in
|
|
261
|
+
* flow (Google / Microsoft / Apple / ...).
|
|
262
|
+
*
|
|
263
|
+
* Mirrors `buildLoginUrl` / `buildLogoutUrl` — pure URL construction,
|
|
264
|
+
* no navigation side effects. Reads from `this.config.thirdPartyLoginUrl`,
|
|
265
|
+
* which is derived from `idbroker.url` in `credentials-config.js`.
|
|
266
|
+
*
|
|
267
|
+
* @instance
|
|
268
|
+
* @memberof Credentials
|
|
269
|
+
* @param {Object} options
|
|
270
|
+
* @param {string} options.oauth2provider - Provider name (`google`,
|
|
271
|
+
* `microsoft`, `apple`, ...). Required.
|
|
272
|
+
* @param {string} options.returnURL - URL IdBroker should send the user
|
|
273
|
+
* back to after the third-party hand-off. Required.
|
|
274
|
+
* @param {Object} [options.state] - Optional state object. When non-empty
|
|
275
|
+
* it is JSON-stringified and base64url-encoded, then emitted as the
|
|
276
|
+
* top-level `state` query param so IdBroker can echo it back unchanged
|
|
277
|
+
* on the callback (mirrors `buildLoginUrl`).
|
|
278
|
+
* @returns {string}
|
|
279
|
+
*/
|
|
280
|
+
buildThirdPartyLoginUrl(options = {}) {
|
|
281
|
+
const {oauth2provider, returnURL, state} = options;
|
|
282
|
+
|
|
283
|
+
if (!oauth2provider) {
|
|
284
|
+
throw new Error('`options.oauth2provider` is required');
|
|
285
|
+
}
|
|
286
|
+
if (!returnURL) {
|
|
287
|
+
throw new Error('`options.returnURL` is required');
|
|
288
|
+
}
|
|
289
|
+
if (state !== undefined && !isObject(state)) {
|
|
290
|
+
throw new Error('if specified, `options.state` must be an object');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const query = {
|
|
294
|
+
oauth2provider,
|
|
295
|
+
returnURL,
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
if (state && !isEmpty(state)) {
|
|
299
|
+
query.state = encodeState(state);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return `${this.config.thirdPartyLoginUrl}?${querystring.stringify(query)}`;
|
|
303
|
+
},
|
|
304
|
+
|
|
215
305
|
/**
|
|
216
306
|
* Generates a Logout URL
|
|
217
307
|
* @instance
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import Url from 'url';
|
|
2
|
+
|
|
3
|
+
import {uniq} from 'lodash';
|
|
4
|
+
|
|
5
|
+
// Canonicalise a hostname for comparison: lowercase, drop the brackets around
|
|
6
|
+
// an IPv6 literal, and drop leading/trailing dots. DNS treats `Example.com`,
|
|
7
|
+
// `example.com.` and `example.com` as the same name.
|
|
8
|
+
//
|
|
9
|
+
// Node's `url.domainToASCII` looks like the standard way to do this, but the
|
|
10
|
+
// `url` polyfill this package bundles for the browser does not implement it,
|
|
11
|
+
// so it cannot be used here. It also leaves trailing dots in place.
|
|
12
|
+
const normalizeHostname = (value: string): string =>
|
|
13
|
+
typeof value === 'string'
|
|
14
|
+
? value
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(/^\[|\]$/g, '')
|
|
17
|
+
.replace(/^\.+/, '')
|
|
18
|
+
.replace(/\.+$/, '')
|
|
19
|
+
: '';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Canonicalise a list of configured allowed domains, discarding any entry that
|
|
23
|
+
* is not a usable hostname. Callers normalise on the way in so the stored list
|
|
24
|
+
* is already canonical, rather than re-deriving it on every request.
|
|
25
|
+
*
|
|
26
|
+
* @param {Array<string>} allowedDomains - The configured allowed domains.
|
|
27
|
+
* @returns {Array<string>} - Normalized, de-duplicated, non-empty entries.
|
|
28
|
+
*/
|
|
29
|
+
export const normalizeAllowedDomains = (allowedDomains: Array<string>): Array<string> =>
|
|
30
|
+
uniq(
|
|
31
|
+
(Array.isArray(allowedDomains) ? allowedDomains : []).map(normalizeHostname).filter(Boolean)
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Determine if a hostname is covered by an allowed domain, matching only on DNS
|
|
36
|
+
* label boundaries, so that a hostname is allowed only when it is the domain
|
|
37
|
+
* itself or a subdomain of it. Matching on a substring instead would treat
|
|
38
|
+
* unrelated hostnames that merely contain the domain as allowed.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} hostname - Hostname to test. Must not include a port.
|
|
41
|
+
* @param {string} allowedDomain - The configured allowed domain.
|
|
42
|
+
* @returns {boolean} - True when the hostname is the domain or a subdomain of it.
|
|
43
|
+
*/
|
|
44
|
+
const hostnameMatchesDomain = (hostname: string, allowedDomain: string): boolean => {
|
|
45
|
+
// The stored list is normalized on write, but `allowedDomains` is a public
|
|
46
|
+
// property, so normalize again here rather than trust it.
|
|
47
|
+
const host = normalizeHostname(hostname);
|
|
48
|
+
const domain = normalizeHostname(allowedDomain);
|
|
49
|
+
|
|
50
|
+
return !!host && !!domain && (host === domain || host.endsWith(`.${domain}`));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Find the allowed domain covering a url, or `undefined` if there is none.
|
|
55
|
+
*
|
|
56
|
+
* Parsing lives here rather than in the callers, and deliberately uses both url
|
|
57
|
+
* parsers, because this check gates an `Authorization` header. The two
|
|
58
|
+
* transports behind `@webex/http-core` do not use the same url parser: the
|
|
59
|
+
* browser transport parses per WHATWG, the node transport uses Node's legacy
|
|
60
|
+
* `Url.parse`, and for some inputs the two resolve different hosts.
|
|
61
|
+
*
|
|
62
|
+
* Rather than picking one, require both to agree and fail closed when they do
|
|
63
|
+
* not, so this check can never authorize a host that differs from the one a
|
|
64
|
+
* transport would actually connect to. Do not narrow this to a single parser.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} url - The url to match the allowed domains against.
|
|
67
|
+
* @param {Array<string>} allowedDomains - The configured allowed domains.
|
|
68
|
+
* @returns {string} - The matching allowed domain, or undefined if there is none.
|
|
69
|
+
*/
|
|
70
|
+
export const matchAllowedDomain = (
|
|
71
|
+
url: string,
|
|
72
|
+
allowedDomains: Array<string>
|
|
73
|
+
): string | undefined => {
|
|
74
|
+
let hostname: string;
|
|
75
|
+
let legacyHostname: string;
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
({hostname} = new URL(url));
|
|
79
|
+
({hostname: legacyHostname} = Url.parse(url));
|
|
80
|
+
} catch {
|
|
81
|
+
// Not a parsable absolute url, so it cannot belong to an allowed domain.
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (normalizeHostname(hostname) !== normalizeHostname(legacyHostname)) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (allowedDomains || []).find((allowedDomain) =>
|
|
90
|
+
hostnameMatchesDomain(hostname, allowedDomain)
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export default matchAllowedDomain;
|
|
@@ -4,6 +4,100 @@ import AmpState from 'ampersand-state';
|
|
|
4
4
|
|
|
5
5
|
import {union} from 'lodash';
|
|
6
6
|
import ServiceUrl from './service-url';
|
|
7
|
+
import {matchAllowedDomain, normalizeAllowedDomains} from '../domains';
|
|
8
|
+
|
|
9
|
+
// Catalog base URLs are a small, stable set, so their parsed origin/path is
|
|
10
|
+
// memoized to avoid repeated `new URL()` work when scanning the catalog on the
|
|
11
|
+
// request hot path.
|
|
12
|
+
const catalogUrlCache = new Map();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse a catalog URL into the origin and normalized path used for matching,
|
|
16
|
+
* memoizing the result. Returns null when the URL is unparsable.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} catalogUrlString - The catalog URL to parse
|
|
19
|
+
* @returns {{origin: string, path: string} | null} - Parsed catalog URL, or null
|
|
20
|
+
*/
|
|
21
|
+
export function parseCatalogUrl(catalogUrlString) {
|
|
22
|
+
if (catalogUrlCache.has(catalogUrlString)) {
|
|
23
|
+
return catalogUrlCache.get(catalogUrlString);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let parsed = null;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const catalogUrl = new URL(catalogUrlString);
|
|
30
|
+
|
|
31
|
+
// Normalize paths by removing trailing slashes (except root "/")
|
|
32
|
+
parsed = {origin: catalogUrl.origin, path: catalogUrl.pathname.replace(/\/$/, '') || '/'};
|
|
33
|
+
} catch {
|
|
34
|
+
parsed = null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
catalogUrlCache.set(catalogUrlString, parsed);
|
|
38
|
+
|
|
39
|
+
return parsed;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check if an already-parsed candidate URL matches a parsed catalog URL with
|
|
44
|
+
* proper origin validation. Allocates nothing, so it is safe to call in a tight
|
|
45
|
+
* loop over the whole catalog.
|
|
46
|
+
*
|
|
47
|
+
* @param {URL} candidateUrl - The parsed candidate URL to validate
|
|
48
|
+
* @param {{origin: string, path: string} | null} parsedCatalogUrl - The parsed catalog URL
|
|
49
|
+
* @returns {boolean} - True if the candidate URL is under the catalog URL's origin and path
|
|
50
|
+
*/
|
|
51
|
+
export function matchesParsedCatalogUrl(candidateUrl, parsedCatalogUrl) {
|
|
52
|
+
if (!candidateUrl || !parsedCatalogUrl) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Origins must match exactly (scheme + host + port)
|
|
57
|
+
if (candidateUrl.origin !== parsedCatalogUrl.origin) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const catalogPath = parsedCatalogUrl.path;
|
|
62
|
+
|
|
63
|
+
if (catalogPath === '/') {
|
|
64
|
+
// Root path matches everything under this origin
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const candidatePath = candidateUrl.pathname;
|
|
69
|
+
|
|
70
|
+
if (candidatePath.startsWith(catalogPath)) {
|
|
71
|
+
// Ensure we're at a path boundary, not mid-segment
|
|
72
|
+
// e.g., /api/v1 should match /api/v1/foo but not /api/v1extra
|
|
73
|
+
// nextChar is undefined for exact match, '/' for valid extension
|
|
74
|
+
const nextChar = candidatePath[catalogPath.length];
|
|
75
|
+
|
|
76
|
+
return nextChar === '/' || nextChar === undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check if a candidate URL matches a catalog URL with proper origin validation.
|
|
84
|
+
* This prevents bypasses like https://trusted.example.attacker.com matching https://trusted.example
|
|
85
|
+
*
|
|
86
|
+
* @param {string} candidateUrlString - The URL to validate
|
|
87
|
+
* @param {string} catalogUrlString - The catalog URL to compare against
|
|
88
|
+
* @returns {boolean} - True if the candidate URL is under the catalog URL's origin and path
|
|
89
|
+
*/
|
|
90
|
+
export function matchesCatalogUrl(candidateUrlString, catalogUrlString) {
|
|
91
|
+
let candidateUrl;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
candidateUrl = new URL(candidateUrlString);
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return matchesParsedCatalogUrl(candidateUrl, parseCatalogUrl(catalogUrlString));
|
|
100
|
+
}
|
|
7
101
|
|
|
8
102
|
/* eslint-disable no-underscore-dangle */
|
|
9
103
|
/**
|
|
@@ -245,20 +339,27 @@ const ServiceCatalog = AmpState.extend({
|
|
|
245
339
|
...this.serviceGroups.override,
|
|
246
340
|
];
|
|
247
341
|
|
|
342
|
+
// Invalid URLs cannot match any service
|
|
343
|
+
let candidateUrl;
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
candidateUrl = new URL(url);
|
|
347
|
+
} catch {
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
|
|
248
351
|
return serviceUrls.find((serviceUrl) => {
|
|
249
|
-
// Check
|
|
250
|
-
if (
|
|
352
|
+
// Check if the URL matches the default URL with proper origin validation
|
|
353
|
+
if (matchesParsedCatalogUrl(candidateUrl, parseCatalogUrl(serviceUrl.defaultUrl))) {
|
|
251
354
|
return true;
|
|
252
355
|
}
|
|
253
356
|
|
|
254
|
-
//
|
|
255
|
-
// These are made by swapping the host of the default URL
|
|
256
|
-
// with that of an alternate host
|
|
357
|
+
// Check alternate URLs (built by swapping host with alternate hosts)
|
|
257
358
|
for (const host of serviceUrl.hosts) {
|
|
258
359
|
const alternateUrl = new URL(serviceUrl.defaultUrl);
|
|
259
360
|
alternateUrl.host = host.host;
|
|
260
361
|
|
|
261
|
-
if (
|
|
362
|
+
if (matchesParsedCatalogUrl(candidateUrl, parseCatalogUrl(alternateUrl.toString()))) {
|
|
262
363
|
return true;
|
|
263
364
|
}
|
|
264
365
|
}
|
|
@@ -268,19 +369,14 @@ const ServiceCatalog = AmpState.extend({
|
|
|
268
369
|
},
|
|
269
370
|
|
|
270
371
|
/**
|
|
271
|
-
* Finds an allowed domain that matches a specific url.
|
|
372
|
+
* Finds an allowed domain that matches a specific url. The url's hostname
|
|
373
|
+
* must be the allowed domain itself or a subdomain of it.
|
|
272
374
|
*
|
|
273
375
|
* @param {string} url - The url to match the allowed domains against.
|
|
274
376
|
* @returns {string} - The matching allowed domain.
|
|
275
377
|
*/
|
|
276
378
|
findAllowedDomain(url) {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
if (!urlObj.host) {
|
|
280
|
-
return undefined;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
return this.allowedDomains.find((allowedDomain) => urlObj.host.includes(allowedDomain));
|
|
379
|
+
return matchAllowedDomain(url, this.allowedDomains);
|
|
284
380
|
},
|
|
285
381
|
|
|
286
382
|
/**
|
|
@@ -367,7 +463,7 @@ const ServiceCatalog = AmpState.extend({
|
|
|
367
463
|
* @returns {void}
|
|
368
464
|
*/
|
|
369
465
|
setAllowedDomains(allowedDomains) {
|
|
370
|
-
this.allowedDomains =
|
|
466
|
+
this.allowedDomains = normalizeAllowedDomains(allowedDomains);
|
|
371
467
|
},
|
|
372
468
|
|
|
373
469
|
/**
|
|
@@ -376,7 +472,7 @@ const ServiceCatalog = AmpState.extend({
|
|
|
376
472
|
* @returns {void}
|
|
377
473
|
*/
|
|
378
474
|
addAllowedDomains(newAllowedDomains) {
|
|
379
|
-
this.allowedDomains = union(this.allowedDomains, newAllowedDomains);
|
|
475
|
+
this.allowedDomains = union(this.allowedDomains, normalizeAllowedDomains(newAllowedDomains));
|
|
380
476
|
},
|
|
381
477
|
|
|
382
478
|
/**
|
|
@@ -57,6 +57,22 @@ const Services = WebexPlugin.extend({
|
|
|
57
57
|
initFailed: ['boolean', false, false],
|
|
58
58
|
},
|
|
59
59
|
|
|
60
|
+
session: {
|
|
61
|
+
/**
|
|
62
|
+
* Becomes `true` once the initial catalog collection has completed
|
|
63
|
+
* (successfully or otherwise) and any in-flight credentials refresh has
|
|
64
|
+
* settled. Blocks `webex.ready` so consumers can rely on `webex.ready`
|
|
65
|
+
* implying "catalogs populated AND credential state stable".
|
|
66
|
+
* @instance
|
|
67
|
+
* @memberof Services
|
|
68
|
+
* @type {boolean}
|
|
69
|
+
*/
|
|
70
|
+
ready: {
|
|
71
|
+
default: false,
|
|
72
|
+
type: 'boolean',
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
|
|
60
76
|
_catalogs: new WeakMap(),
|
|
61
77
|
|
|
62
78
|
_serviceUrls: null,
|
|
@@ -1087,9 +1103,16 @@ const Services = WebexPlugin.extend({
|
|
|
1087
1103
|
requestObject.headers = {authorization: token};
|
|
1088
1104
|
}
|
|
1089
1105
|
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1106
|
+
const sendRequest = () => this.request(requestObject);
|
|
1107
|
+
|
|
1108
|
+
const responsePromise = this.webex.internal.newMetrics
|
|
1109
|
+
? this.webex.internal.newMetrics.callDiagnosticLatencies.measureLatency(
|
|
1110
|
+
sendRequest,
|
|
1111
|
+
'internal.get.u2c.time'
|
|
1112
|
+
)
|
|
1113
|
+
: sendRequest();
|
|
1114
|
+
|
|
1115
|
+
return responsePromise.then(({body}) => body);
|
|
1093
1116
|
},
|
|
1094
1117
|
|
|
1095
1118
|
/**
|
|
@@ -1346,6 +1369,7 @@ const Services = WebexPlugin.extend({
|
|
|
1346
1369
|
|
|
1347
1370
|
// Destructure the credentials plugin.
|
|
1348
1371
|
const {credentials} = this.webex;
|
|
1372
|
+
const catalog = this._getCatalog();
|
|
1349
1373
|
|
|
1350
1374
|
// Init a promise chain. Must be done as a Promise.resolve() to allow
|
|
1351
1375
|
// credentials#getOrgId() to properly throw.
|
|
@@ -1358,12 +1382,18 @@ const Services = WebexPlugin.extend({
|
|
|
1358
1382
|
.then(() => {
|
|
1359
1383
|
// Validate if the token is authorized.
|
|
1360
1384
|
if (credentials.canAuthorize) {
|
|
1361
|
-
// Attempt to collect the postauth catalog
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1385
|
+
// Attempt to collect the postauth catalog, then mark the catalog
|
|
1386
|
+
// ready. Setting `isReady` here - rather than only in the init
|
|
1387
|
+
// callers - means a slow postauth fetch that loses the gated-init
|
|
1388
|
+
// timeout race still marks the catalog ready once it completes.
|
|
1389
|
+
return this.updateServices()
|
|
1390
|
+
.then(() => {
|
|
1391
|
+
catalog.isReady = true;
|
|
1392
|
+
})
|
|
1393
|
+
.catch(() => {
|
|
1394
|
+
this.initFailed = true;
|
|
1395
|
+
this.logger.warn('services: cannot retrieve postauth catalog');
|
|
1396
|
+
});
|
|
1367
1397
|
}
|
|
1368
1398
|
|
|
1369
1399
|
// Return a resolved promise for consistent return value.
|
|
@@ -1372,6 +1402,51 @@ const Services = WebexPlugin.extend({
|
|
|
1372
1402
|
);
|
|
1373
1403
|
},
|
|
1374
1404
|
|
|
1405
|
+
/**
|
|
1406
|
+
* Await any in-flight credentials refresh, then flip `services.ready` so
|
|
1407
|
+
* `webex.ready` can fire. Closes the parallel-refresh window: if a credential
|
|
1408
|
+
* refresh is in flight when initial catalog collection settles, we must not
|
|
1409
|
+
* signal ready until the refresh has resolved - otherwise downstream
|
|
1410
|
+
* consumers may observe `canAuthorize`/token state that is about to change
|
|
1411
|
+
* under them.
|
|
1412
|
+
*
|
|
1413
|
+
* @private
|
|
1414
|
+
* @returns {Promise<void>}
|
|
1415
|
+
*/
|
|
1416
|
+
async _finalizeReady() {
|
|
1417
|
+
const {credentials} = this.webex;
|
|
1418
|
+
|
|
1419
|
+
if (credentials && credentials.isRefreshing) {
|
|
1420
|
+
await new Promise((resolve) => {
|
|
1421
|
+
credentials.once('change:isRefreshing', resolve);
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
this.ready = true;
|
|
1426
|
+
},
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* Build a promise that rejects once the catalog init timeout elapses. Race
|
|
1430
|
+
* this against catalog collection so a hung request never leaves
|
|
1431
|
+
* `services.ready` false forever - that would stall `webex.ready` and leave
|
|
1432
|
+
* consumers waiting on it indefinitely. Timeout is configurable via
|
|
1433
|
+
* `config.services.catalogInitTimeout` (defaults to 15s in config). Created
|
|
1434
|
+
* lazily so paths that skip catalog collection never schedule a stray timer.
|
|
1435
|
+
*
|
|
1436
|
+
* @private
|
|
1437
|
+
* @returns {Promise<never>}
|
|
1438
|
+
*/
|
|
1439
|
+
_makeInitTimeout() {
|
|
1440
|
+
const initTimeoutMs = this.webex.config?.services?.catalogInitTimeout;
|
|
1441
|
+
|
|
1442
|
+
return new Promise((_, reject) => {
|
|
1443
|
+
setTimeout(
|
|
1444
|
+
() => reject(new Error(`services: init timed out after ${initTimeoutMs}ms`)),
|
|
1445
|
+
initTimeoutMs
|
|
1446
|
+
);
|
|
1447
|
+
});
|
|
1448
|
+
},
|
|
1449
|
+
|
|
1375
1450
|
/**
|
|
1376
1451
|
* Initializer
|
|
1377
1452
|
*
|
|
@@ -1388,11 +1463,39 @@ const Services = WebexPlugin.extend({
|
|
|
1388
1463
|
this.registries.set(this.webex, registry);
|
|
1389
1464
|
this.states.set(this.webex, state);
|
|
1390
1465
|
|
|
1391
|
-
// Listen for configuration changes once.
|
|
1466
|
+
// Listen for configuration changes once. The config is not populated on the
|
|
1467
|
+
// webex instance until the `change:config` event fires, so any decision that
|
|
1468
|
+
// depends on config values (such as the gated-vs-ungated init below) must be
|
|
1469
|
+
// made from within this handler rather than synchronously in `initialize()`.
|
|
1392
1470
|
this.listenToOnce(this.webex, 'change:config', () => {
|
|
1393
1471
|
this.initConfig();
|
|
1472
|
+
|
|
1473
|
+
// Feature flag: when enabled, `webex.ready` is blocked until the initial
|
|
1474
|
+
// catalog collection has settled AND any in-flight credentials refresh has
|
|
1475
|
+
// completed. When disabled (the default), preserves the pre-existing
|
|
1476
|
+
// behavior where `webex.ready` fires as soon as `webex.loaded` does and
|
|
1477
|
+
// the catalog is collected out-of-band.
|
|
1478
|
+
const waitForCatalogInit = this.webex.config?.services?.waitForCatalogInit === true;
|
|
1479
|
+
|
|
1480
|
+
if (waitForCatalogInit) {
|
|
1481
|
+
this._initializeCatalogsGated(catalog);
|
|
1482
|
+
} else {
|
|
1483
|
+
// Not gating - immediately mark ready so we do not block webex.ready.
|
|
1484
|
+
this.ready = true;
|
|
1485
|
+
this._initializeCatalogsUngated(catalog);
|
|
1486
|
+
}
|
|
1394
1487
|
});
|
|
1488
|
+
},
|
|
1395
1489
|
|
|
1490
|
+
/**
|
|
1491
|
+
* Original (pre-verified-ready) initialization path. Runs on `webex.ready`
|
|
1492
|
+
* and collects catalogs opportunistically without blocking anything.
|
|
1493
|
+
*
|
|
1494
|
+
* @private
|
|
1495
|
+
* @param {ServiceCatalog} catalog
|
|
1496
|
+
* @returns {void}
|
|
1497
|
+
*/
|
|
1498
|
+
_initializeCatalogsUngated(catalog) {
|
|
1396
1499
|
// wait for webex instance to be ready before attempting
|
|
1397
1500
|
// to update the service catalogs
|
|
1398
1501
|
// this can cause a race condition because credentials may
|
|
@@ -1407,19 +1510,25 @@ const Services = WebexPlugin.extend({
|
|
|
1407
1510
|
const {supertoken} = this.webex.credentials;
|
|
1408
1511
|
// Validate if the supertoken exists.
|
|
1409
1512
|
if (supertoken && supertoken.access_token) {
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
.
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
);
|
|
1419
|
-
});
|
|
1513
|
+
// `initServiceCatalogs` marks the catalog ready internally once the
|
|
1514
|
+
// postauth catalog is collected.
|
|
1515
|
+
this.initServiceCatalogs().catch((error) => {
|
|
1516
|
+
this.initFailed = true;
|
|
1517
|
+
this.logger.error(
|
|
1518
|
+
`services: failed to init initial services when credentials available, ${error?.message}`
|
|
1519
|
+
);
|
|
1520
|
+
});
|
|
1420
1521
|
} else {
|
|
1421
1522
|
const {email} = this.webex.config;
|
|
1422
1523
|
|
|
1524
|
+
if (this.webex.config?.services?.skipPreauthCatalogOnUnauthenticated === true) {
|
|
1525
|
+
this.logger.info(
|
|
1526
|
+
'services: skipping preauth catalog collection while unauthenticated as per the config'
|
|
1527
|
+
);
|
|
1528
|
+
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1423
1532
|
this.collectPreauthCatalog(email ? {email} : undefined).catch((error) => {
|
|
1424
1533
|
this.initFailed = true;
|
|
1425
1534
|
this.logger.error(
|
|
@@ -1429,6 +1538,85 @@ const Services = WebexPlugin.extend({
|
|
|
1429
1538
|
}
|
|
1430
1539
|
});
|
|
1431
1540
|
},
|
|
1541
|
+
|
|
1542
|
+
/**
|
|
1543
|
+
* Verified-ready initialization path. Blocks `webex.ready` until the initial
|
|
1544
|
+
* catalog fetch has settled (or timed out) AND any in-flight credentials
|
|
1545
|
+
* refresh has completed. Also handles the fresh-login case where OAuth
|
|
1546
|
+
* completes after `loaded` fires.
|
|
1547
|
+
*
|
|
1548
|
+
* @private
|
|
1549
|
+
* @param {ServiceCatalog} catalog
|
|
1550
|
+
* @returns {void}
|
|
1551
|
+
*/
|
|
1552
|
+
_initializeCatalogsGated(catalog) {
|
|
1553
|
+
// Wait for storage to be loaded before attempting to update the service
|
|
1554
|
+
// catalogs. We listen for 'loaded' instead of 'ready' because `services.ready`
|
|
1555
|
+
// now blocks `webex.ready` - listening to 'ready' would deadlock.
|
|
1556
|
+
this.listenToOnce(this.webex, 'loaded', async () => {
|
|
1557
|
+
const cachedCatalog = await this._loadCatalogFromCache();
|
|
1558
|
+
if (cachedCatalog) {
|
|
1559
|
+
catalog.isReady = true;
|
|
1560
|
+
await this._finalizeReady();
|
|
1561
|
+
|
|
1562
|
+
return; // skip initServiceCatalogs() on reload when cache exists
|
|
1563
|
+
}
|
|
1564
|
+
const {supertoken} = this.webex.credentials;
|
|
1565
|
+
|
|
1566
|
+
// Validate if the supertoken exists.
|
|
1567
|
+
if (supertoken && supertoken.access_token) {
|
|
1568
|
+
// `initServiceCatalogs` marks the catalog ready internally once the
|
|
1569
|
+
// postauth catalog is collected - even if it loses the timeout race
|
|
1570
|
+
// below, so a slow fetch still eventually flips `catalog.isReady`.
|
|
1571
|
+
Promise.race([this.initServiceCatalogs(), this._makeInitTimeout()])
|
|
1572
|
+
.catch((error) => {
|
|
1573
|
+
this.initFailed = true;
|
|
1574
|
+
this.logger.error(
|
|
1575
|
+
`services: failed to init initial services when credentials available, ${error?.message}`
|
|
1576
|
+
);
|
|
1577
|
+
})
|
|
1578
|
+
.finally(() => this._finalizeReady());
|
|
1579
|
+
} else {
|
|
1580
|
+
const {email} = this.webex.config;
|
|
1581
|
+
|
|
1582
|
+
// Handle fresh login: 'loaded' fires before OAuth completes, so listen
|
|
1583
|
+
// for `canAuthorize` flipping true and then collect the postauth catalog.
|
|
1584
|
+
this.listenToOnce(this.webex, 'change:canAuthorize', () => {
|
|
1585
|
+
if (this.webex.canAuthorize && !catalog.status.postauth.ready) {
|
|
1586
|
+
// `initServiceCatalogs` marks the catalog ready internally.
|
|
1587
|
+
this.initServiceCatalogs().catch((error) => {
|
|
1588
|
+
this.logger.error(
|
|
1589
|
+
`services: failed to init service catalogs after auth, ${error?.message}`
|
|
1590
|
+
);
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1595
|
+
if (this.webex.config?.services?.skipPreauthCatalogOnUnauthenticated === true) {
|
|
1596
|
+
// Skip the preauth catalog fetch (it will be collected manually
|
|
1597
|
+
// later), but still finalize `services.ready` so `webex.ready` is not
|
|
1598
|
+
// stalled while unauthenticated. No timeout is created here so there
|
|
1599
|
+
// is no stray timer or unhandled rejection.
|
|
1600
|
+
this.logger.info(
|
|
1601
|
+
'services: skipping preauth catalog collection while unauthenticated as per the config'
|
|
1602
|
+
);
|
|
1603
|
+
this._finalizeReady();
|
|
1604
|
+
} else {
|
|
1605
|
+
Promise.race([
|
|
1606
|
+
this.collectPreauthCatalog(email ? {email} : undefined),
|
|
1607
|
+
this._makeInitTimeout(),
|
|
1608
|
+
])
|
|
1609
|
+
.catch((error) => {
|
|
1610
|
+
this.initFailed = true;
|
|
1611
|
+
this.logger.error(
|
|
1612
|
+
`services: failed to init initial services when no credentials available, ${error?.message}`
|
|
1613
|
+
);
|
|
1614
|
+
})
|
|
1615
|
+
.finally(() => this._finalizeReady());
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
});
|
|
1619
|
+
},
|
|
1432
1620
|
});
|
|
1433
1621
|
/* eslint-enable no-underscore-dangle */
|
|
1434
1622
|
|