@xh/hoist 78.0.0-SNAPSHOT.1763737348746 → 78.1.0
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/CHANGELOG.md +25 -3
- package/admin/differ/Differ.ts +1 -1
- package/admin/differ/DifferModel.ts +10 -1
- package/build/types/admin/differ/DifferModel.d.ts +4 -0
- package/build/types/cmp/grid/GridModel.d.ts +17 -17
- package/build/types/core/types/Types.d.ts +2 -0
- package/build/types/data/cube/BucketSpec.d.ts +4 -9
- package/build/types/data/cube/Cube.d.ts +3 -3
- package/build/types/data/cube/Query.d.ts +1 -1
- package/build/types/security/msal/MsalClient.d.ts +18 -2
- package/cmp/grid/GridModel.ts +29 -21
- package/core/types/Types.ts +3 -0
- package/data/Store.ts +4 -1
- package/data/cube/BucketSpec.ts +7 -10
- package/data/cube/Cube.ts +3 -3
- package/data/cube/Query.ts +1 -1
- package/data/cube/View.ts +2 -7
- package/package.json +3 -3
- package/security/BaseOAuthClient.ts +1 -1
- package/security/msal/MsalClient.ts +112 -74
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -12,10 +12,10 @@ import {
|
|
|
12
12
|
InteractionRequiredAuthError,
|
|
13
13
|
IPublicClientApplication,
|
|
14
14
|
LogLevel,
|
|
15
|
-
PopupRequest,
|
|
16
15
|
SilentRequest
|
|
17
16
|
} from '@azure/msal-browser';
|
|
18
|
-
import {
|
|
17
|
+
import {CommonAuthorizationUrlRequest} from '@azure/msal-common';
|
|
18
|
+
import {AppState, PlainObject, ReactionSpec, XH} from '@xh/hoist/core';
|
|
19
19
|
import {Token} from '@xh/hoist/security/Token';
|
|
20
20
|
import {logDebug, logError, logInfo, logWarn, mergeDeep, throwIf} from '@xh/hoist/utils/js';
|
|
21
21
|
import {withFormattedTimestamps} from '@xh/hoist/format';
|
|
@@ -40,7 +40,7 @@ export interface MsalClientConfig extends BaseOAuthClientConfig<MsalTokenSpec> {
|
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* True to enable support for built-in telemetry provided by this class's internal MSAL client.
|
|
43
|
-
* Captured performance events will be summarized as {@link MsalClientTelemetry}.
|
|
43
|
+
* Captured performance events will be summarized as {@link MsalClientTelemetry}. Default true.
|
|
44
44
|
*/
|
|
45
45
|
enableTelemetry?: boolean;
|
|
46
46
|
|
|
@@ -65,6 +65,18 @@ export interface MsalClientConfig extends BaseOAuthClientConfig<MsalTokenSpec> {
|
|
|
65
65
|
*/
|
|
66
66
|
initRefreshTokenExpirationOffsetSecs?: number;
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Enable the use of the MSAL ssoSilent() API, which will attempt to use credentials gained by
|
|
70
|
+
* another app or tab to start a new session for this app. Requires iFrames and 3rd party
|
|
71
|
+
* cookies to be enabled. Default true.
|
|
72
|
+
*
|
|
73
|
+
* In practice, and according to documentation, this operation is likely to fail for a
|
|
74
|
+
* number of reasons, and can often do so as timeout. Therefore, keeping the timeout limit
|
|
75
|
+
* value -- `system.iFrameHashTimeout` -- at a relatively low value is critical. Hoist
|
|
76
|
+
* defaults this value to 3000ms vs. the default 10000ms.
|
|
77
|
+
*/
|
|
78
|
+
enableSsoSilent?: boolean;
|
|
79
|
+
|
|
68
80
|
/** The log level of MSAL. Default is LogLevel.Warning. */
|
|
69
81
|
msalLogLevel?: LogLevel;
|
|
70
82
|
|
|
@@ -110,7 +122,7 @@ export interface MsalTokenSpec extends AccessTokenSpec {
|
|
|
110
122
|
*/
|
|
111
123
|
export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec> {
|
|
112
124
|
private client: IPublicClientApplication;
|
|
113
|
-
private account: AccountInfo; //
|
|
125
|
+
private account: AccountInfo; // target account, may or may not be authenticated yet
|
|
114
126
|
private initialTokenLoad: boolean;
|
|
115
127
|
|
|
116
128
|
/** Enable telemetry via `enableTelemetry` ctor config, or via {@link enableTelemetry}. */
|
|
@@ -122,6 +134,8 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
|
|
|
122
134
|
initRefreshTokenExpirationOffsetSecs: -1,
|
|
123
135
|
msalLogLevel: LogLevel.Warning,
|
|
124
136
|
domainHint: null,
|
|
137
|
+
enableTelemetry: true,
|
|
138
|
+
enableSsoSilent: true,
|
|
125
139
|
...config
|
|
126
140
|
});
|
|
127
141
|
}
|
|
@@ -130,8 +144,9 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
|
|
|
130
144
|
// Implementations of core lifecycle methods
|
|
131
145
|
//-------------------------------------------
|
|
132
146
|
protected override async doInitAsync(): Promise<TokenMap> {
|
|
133
|
-
const client = (this.client = await this.createClientAsync())
|
|
134
|
-
|
|
147
|
+
const client = (this.client = await this.createClientAsync()),
|
|
148
|
+
{enableTelemetry, enableSsoSilent} = this.config;
|
|
149
|
+
if (enableTelemetry) {
|
|
135
150
|
this.enableTelemetry();
|
|
136
151
|
}
|
|
137
152
|
|
|
@@ -139,67 +154,65 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
|
|
|
139
154
|
const redirectResp = await client.handleRedirectPromise();
|
|
140
155
|
if (redirectResp) {
|
|
141
156
|
this.logDebug('Completing Redirect login');
|
|
142
|
-
this.
|
|
157
|
+
this.setAccount(redirectResp.account);
|
|
143
158
|
this.restoreRedirectState(redirectResp.state);
|
|
144
|
-
|
|
159
|
+
const ret = this.fetchAllTokensAsync({eagerOnly: true});
|
|
160
|
+
this.noteAuthComplete('loginRedirect');
|
|
161
|
+
return ret;
|
|
145
162
|
}
|
|
146
163
|
|
|
147
|
-
// 1) If we
|
|
148
|
-
// recent refresh. This should never trigger popup/redirect, but if
|
|
164
|
+
// 1) If we can identify the "selected" account, try to just reload tokens silently.
|
|
165
|
+
// This is the happy path on recent refresh. This should never trigger popup/redirect, but if
|
|
149
166
|
// 'initRefreshTokenExpirationOffsetSecs' is set, this may trigger a hidden iframe redirect
|
|
150
167
|
// to gain a new refreshToken (3rd party cookies required).
|
|
151
168
|
const accounts = client.getAllAccounts();
|
|
152
|
-
this.logDebug('
|
|
153
|
-
const account =
|
|
169
|
+
this.logDebug('Accounts available', accounts);
|
|
170
|
+
const account =
|
|
171
|
+
accounts.length == 1
|
|
172
|
+
? accounts[0]
|
|
173
|
+
: accounts.find(a => (a.username = this.getSelectedUsername()));
|
|
154
174
|
if (account) {
|
|
155
|
-
this.
|
|
175
|
+
this.setAccount(account);
|
|
156
176
|
try {
|
|
157
177
|
this.initialTokenLoad = true;
|
|
158
178
|
this.logDebug('Attempting silent token load.');
|
|
159
|
-
|
|
179
|
+
const ret = await this.fetchAllTokensAsync({eagerOnly: true});
|
|
180
|
+
this.noteAuthComplete('acquireSilent');
|
|
181
|
+
return ret;
|
|
160
182
|
} catch (e) {
|
|
161
|
-
this.account = null;
|
|
162
183
|
this.logDebug('Failed to load tokens on init, fall back to login', e.message ?? e);
|
|
163
184
|
} finally {
|
|
164
185
|
this.initialTokenLoad = false;
|
|
165
186
|
}
|
|
166
187
|
}
|
|
167
188
|
|
|
168
|
-
// 2)
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
// but will require at the very least a redirect or cursory auto-closing popup.
|
|
182
|
-
if (!this.account) {
|
|
183
|
-
this.logDebug('Logging in');
|
|
184
|
-
await this.loginAsync();
|
|
189
|
+
// 2) Try `ssoSilent` API to potentially reuse logged-in user on other apps
|
|
190
|
+
// in same domain without interaction. This should never trigger popup/redirect, and will
|
|
191
|
+
// use an iFrame (3rd party cookies required). Must fail gently.
|
|
192
|
+
if (enableSsoSilent) {
|
|
193
|
+
try {
|
|
194
|
+
this.logDebug('Attempting SSO');
|
|
195
|
+
await this.loginSsoAsync();
|
|
196
|
+
const ret = await this.fetchAllTokensAsync({eagerOnly: true});
|
|
197
|
+
this.noteAuthComplete('ssoSilent');
|
|
198
|
+
return ret;
|
|
199
|
+
} catch (e) {
|
|
200
|
+
this.logDebug('SSO failed', e.message ?? e);
|
|
201
|
+
}
|
|
185
202
|
}
|
|
186
203
|
|
|
187
|
-
// 3)
|
|
204
|
+
// 3) If none of above succeeded, must do "interactive" login. This may or may not require
|
|
205
|
+
// user involvement but will require at least a redirect or cursory auto-closing popup.
|
|
206
|
+
this.logDebug('Attempting Login');
|
|
207
|
+
await this.loginAsync();
|
|
188
208
|
return this.fetchAllTokensAsync({eagerOnly: true});
|
|
189
209
|
}
|
|
190
210
|
|
|
191
211
|
protected override async doLoginPopupAsync(): Promise<void> {
|
|
192
|
-
const {client} = this,
|
|
193
|
-
opts: PopupRequest = {
|
|
194
|
-
loginHint: this.getSelectedUsername(),
|
|
195
|
-
domainHint: this.config.domainHint,
|
|
196
|
-
scopes: this.loginScopes,
|
|
197
|
-
extraScopesToConsent: this.loginExtraScopesToConsent,
|
|
198
|
-
redirectUri: this.blankUrl
|
|
199
|
-
};
|
|
200
212
|
try {
|
|
201
|
-
const ret = await client.acquireTokenPopup(
|
|
202
|
-
this.
|
|
213
|
+
const ret = await this.client.acquireTokenPopup(this.authRequestCore());
|
|
214
|
+
this.setAccount(ret.account);
|
|
215
|
+
this.noteAuthComplete('loginPopup');
|
|
203
216
|
} catch (e) {
|
|
204
217
|
if (e.message?.toLowerCase().includes('popup window')) {
|
|
205
218
|
throw XH.exception({
|
|
@@ -213,14 +226,9 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
|
|
|
213
226
|
}
|
|
214
227
|
|
|
215
228
|
protected override async doLoginRedirectAsync(): Promise<void> {
|
|
216
|
-
const state = this.captureRedirectState();
|
|
217
|
-
|
|
218
229
|
await this.client.acquireTokenRedirect({
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
domainHint: this.config.domainHint,
|
|
222
|
-
scopes: this.loginScopes,
|
|
223
|
-
extraScopesToConsent: this.loginExtraScopesToConsent,
|
|
230
|
+
...this.authRequestCore(),
|
|
231
|
+
state: this.captureRedirectState(),
|
|
224
232
|
redirectUri: this.redirectUrl
|
|
225
233
|
});
|
|
226
234
|
|
|
@@ -255,12 +263,15 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
|
|
|
255
263
|
}
|
|
256
264
|
|
|
257
265
|
protected override async doLogoutAsync(): Promise<void> {
|
|
258
|
-
const {
|
|
259
|
-
|
|
266
|
+
const {client, account, loginMethod, postLogoutRedirectUrl} = this,
|
|
267
|
+
isRedirect = loginMethod == 'REDIRECT',
|
|
268
|
+
opts = {
|
|
269
|
+
account,
|
|
270
|
+
postLogoutRedirectUri: isRedirect ? postLogoutRedirectUrl : this.blankUrl,
|
|
271
|
+
mainWindowRedirectUri: isRedirect ? undefined : postLogoutRedirectUrl
|
|
272
|
+
};
|
|
260
273
|
|
|
261
|
-
|
|
262
|
-
? await client.logoutRedirect(opts)
|
|
263
|
-
: await client.logoutPopup(opts);
|
|
274
|
+
isRedirect ? await client.logoutRedirect(opts) : await client.logoutPopup(opts);
|
|
264
275
|
}
|
|
265
276
|
|
|
266
277
|
protected override interactiveLoginNeeded(exception: unknown): boolean {
|
|
@@ -281,6 +292,7 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
|
|
|
281
292
|
}
|
|
282
293
|
|
|
283
294
|
this.telemetry = {
|
|
295
|
+
authMethod: null,
|
|
284
296
|
summary: {
|
|
285
297
|
successCount: 0,
|
|
286
298
|
failureCount: 0,
|
|
@@ -297,7 +309,7 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
|
|
|
297
309
|
{name, startTimeMs, durationMs, success, errorName, errorCode} = e,
|
|
298
310
|
eTime = startTimeMs ?? Date.now();
|
|
299
311
|
|
|
300
|
-
const eResult = (events[name] ??= {
|
|
312
|
+
const eResult: PlainObject = (events[name] ??= {
|
|
301
313
|
firstTime: eTime,
|
|
302
314
|
lastTime: eTime,
|
|
303
315
|
successCount: 0,
|
|
@@ -343,7 +355,7 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
|
|
|
343
355
|
this.addReaction({
|
|
344
356
|
when: () => XH.appState === AppState.INITIALIZING_APP,
|
|
345
357
|
run: () => XH.clientHealthService.addSource('msalClient', () => this.telemetry)
|
|
346
|
-
});
|
|
358
|
+
} as ReactionSpec);
|
|
347
359
|
|
|
348
360
|
this.logDebug('Telemetry enabled');
|
|
349
361
|
}
|
|
@@ -365,15 +377,8 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
|
|
|
365
377
|
// Private implementation
|
|
366
378
|
//------------------------
|
|
367
379
|
private async loginSsoAsync(): Promise<void> {
|
|
368
|
-
const result = await this.client.ssoSilent(
|
|
369
|
-
|
|
370
|
-
domainHint: this.config.domainHint,
|
|
371
|
-
redirectUri: this.blankUrl,
|
|
372
|
-
scopes: this.loginScopes,
|
|
373
|
-
extraScopesToConsent: this.loginExtraScopesToConsent,
|
|
374
|
-
prompt: 'none'
|
|
375
|
-
});
|
|
376
|
-
this.noteUserAuthenticated(result.account);
|
|
380
|
+
const result = await this.client.ssoSilent(this.authRequestCore());
|
|
381
|
+
this.setAccount(result.account);
|
|
377
382
|
}
|
|
378
383
|
|
|
379
384
|
private async createClientAsync(): Promise<IPublicClientApplication> {
|
|
@@ -391,7 +396,8 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
|
|
|
391
396
|
loggerOptions: {
|
|
392
397
|
loggerCallback: this.logFromMsal,
|
|
393
398
|
logLevel: msalLogLevel
|
|
394
|
-
}
|
|
399
|
+
},
|
|
400
|
+
iFrameHashTimeout: 3000 // Prevent long pauses for sso failures.
|
|
395
401
|
},
|
|
396
402
|
cache: {
|
|
397
403
|
cacheLocation: 'localStorage' // allows sharing auth info across tabs.
|
|
@@ -413,16 +419,16 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
|
|
|
413
419
|
}
|
|
414
420
|
|
|
415
421
|
private logFromMsal(level: LogLevel, message: string) {
|
|
416
|
-
const
|
|
422
|
+
const source = this.client.constructor.name;
|
|
417
423
|
switch (level) {
|
|
418
424
|
case msal.LogLevel.Info:
|
|
419
|
-
return logInfo(message,
|
|
425
|
+
return logInfo(message, source);
|
|
420
426
|
case msal.LogLevel.Warning:
|
|
421
|
-
return logWarn(message,
|
|
427
|
+
return logWarn(message, source);
|
|
422
428
|
case msal.LogLevel.Error:
|
|
423
|
-
return logError(message,
|
|
429
|
+
return logError(message, source);
|
|
424
430
|
default:
|
|
425
|
-
return logDebug(message,
|
|
431
|
+
return logDebug(message, source);
|
|
426
432
|
}
|
|
427
433
|
}
|
|
428
434
|
|
|
@@ -446,19 +452,51 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
|
|
|
446
452
|
: {};
|
|
447
453
|
}
|
|
448
454
|
|
|
449
|
-
private
|
|
455
|
+
private setAccount(account: AccountInfo) {
|
|
450
456
|
this.account = account;
|
|
451
457
|
this.setSelectedUsername(account.username);
|
|
452
|
-
this.logDebug('
|
|
458
|
+
this.logDebug('Target account identified:', account.username);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private noteAuthComplete(authMethod: AuthMethod) {
|
|
462
|
+
if (this.telemetry) this.telemetry.authMethod = authMethod;
|
|
463
|
+
this.logInfo(`Authenticated user ${this.account.username} via ${authMethod}`);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private authRequestCore(): AuthRequestCore {
|
|
467
|
+
const ret: AuthRequestCore = {
|
|
468
|
+
domainHint: this.config.domainHint,
|
|
469
|
+
scopes: this.loginScopes,
|
|
470
|
+
extraScopesToConsent: this.loginExtraScopesToConsent,
|
|
471
|
+
redirectUri: this.blankUrl
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
// Only send these critical hints if we have them. Prioritize account, as that has
|
|
475
|
+
// more info, and MSAL source appears to let loginHint override that.
|
|
476
|
+
if (this.account) {
|
|
477
|
+
ret.account = this.account;
|
|
478
|
+
} else if (this.getSelectedUsername()) {
|
|
479
|
+
ret.loginHint = this.getSelectedUsername();
|
|
480
|
+
}
|
|
481
|
+
return ret;
|
|
453
482
|
}
|
|
454
483
|
}
|
|
455
484
|
|
|
485
|
+
type AuthRequestCore = Pick<
|
|
486
|
+
CommonAuthorizationUrlRequest,
|
|
487
|
+
'domainHint' | 'scopes' | 'extraScopesToConsent' | 'account' | 'loginHint' | 'redirectUri'
|
|
488
|
+
>;
|
|
489
|
+
type AuthMethod = 'acquireSilent' | 'ssoSilent' | 'loginPopup' | 'loginRedirect';
|
|
490
|
+
|
|
456
491
|
/**
|
|
457
492
|
* Telemetry produced by this client (if enabled) + included in {@link ClientHealthService}
|
|
458
493
|
* reporting. Leverages MSAL's opt-in support for emitting performance events.
|
|
459
494
|
* See https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/performance.md
|
|
460
495
|
*/
|
|
461
496
|
interface MsalClientTelemetry {
|
|
497
|
+
/** Method of last authentication for this client. */
|
|
498
|
+
authMethod: AuthMethod;
|
|
499
|
+
|
|
462
500
|
/** Stats across all events */
|
|
463
501
|
summary: {
|
|
464
502
|
successCount: number;
|