@xh/hoist 78.0.0 → 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 CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 78.1.0 - 2025-12-02
4
+
5
+ ### ⚙️ Technical
6
+ * New property `MsalClientConfig.enableSsoSilent` to govern use of MSAL SSO api.
7
+
8
+ * Existing property `MsalClientConfig.enableTelemetry` now defaults to `true`.
9
+
10
+ * Improved use of MSAL client API, to maximize effectiveness of SSO. Improved documentation
11
+ and logging. Iframe attempts will now time out by default after 3 seconds vs. 10 seconds.
12
+ This can be further modified by apps via the option
13
+ `MsalClientConfig.msalClientOptions.system.iFrameHashTimeout`
14
+
15
+ ### 📚 Libraries
16
+
17
+ * @auth0/auth0-spa-js `2.7 → 2.9`
18
+ * @azure/msal-browser `4.25 → 4.26`
19
+
3
20
  ## 78.0.0 - 2025-11-21
4
21
 
5
22
  ### 💥 Breaking Changes
@@ -33,6 +50,12 @@
33
50
 
34
51
  ## 77.1.1 - 2025-11-12
35
52
 
53
+ ### 💥 Breaking Changes (upgrade difficulty: 🟢 LOW)
54
+
55
+ * Apps that use and provide the `highcharts` library should be sure to update the version to v12.4.0.
56
+ Refer to `Bootstrap.js` in Toolbox for required import changes.
57
+ * Visit https://www.highcharts.com/blog/changelog/ for specific changes.
58
+
36
59
  ### 🎁 New Features
37
60
 
38
61
  * New method `StoreRecord.getModifiedValues()` to gather edited data from a store record.
@@ -64,7 +64,7 @@ const tbar = hoistCmp.factory<DifferModel>(({model}) => {
64
64
  placeholder: 'https://remote-host/',
65
65
  enableCreate: true,
66
66
  createMessageFn: identity,
67
- width: 250,
67
+ width: 350,
68
68
  options: model.remoteHosts
69
69
  }),
70
70
  button({
@@ -60,8 +60,14 @@ export class DifferModel extends HoistModel {
60
60
  recordsRequired: true
61
61
  };
62
62
 
63
+ /**
64
+ * All other configured appInstances URLs, excepting the current one.
65
+ * (Use of startsWith allows configs to end in trailing /)
66
+ */
63
67
  get remoteHosts(): string[] {
64
- return XH.getConf('xhAppInstances').filter(it => it !== window.location.origin);
68
+ return XH.getConf('xhAppInstances').filter(
69
+ (it: string) => !it.startsWith(window.location.origin)
70
+ );
65
71
  }
66
72
 
67
73
  constructor({
@@ -83,6 +89,9 @@ export class DifferModel extends HoistModel {
83
89
 
84
90
  this.url = entityName + 'DiffAdmin';
85
91
 
92
+ // Default to first available remote for comparison
93
+ this.remoteHost = this.remoteHosts[0];
94
+
86
95
  const rendererIsComplex = true;
87
96
  this.gridModel = new GridModel({
88
97
  store: {
@@ -24,6 +24,10 @@ export declare class DifferModel extends HoistModel {
24
24
  hasLoaded: boolean;
25
25
  get readonly(): boolean;
26
26
  applyRemoteAction: RecordActionSpec;
27
+ /**
28
+ * All other configured appInstances URLs, excepting the current one.
29
+ * (Use of startsWith allows configs to end in trailing /)
30
+ */
27
31
  get remoteHosts(): string[];
28
32
  constructor({ parentModel, entityName, displayName, columnFields, matchFields, valueRenderer }: Partial<DifferModel>);
29
33
  doLoadAsync(loadSpec: LoadSpec): Promise<void>;
@@ -326,39 +326,39 @@ export declare class GridModel extends HoistModel {
326
326
  localExport(filename: string, type: 'excel' | 'csv', params?: PlainObject): void;
327
327
  /**
328
328
  * Select records in the grid.
329
- *
330
329
  * @param records - one or more record(s) / ID(s) to select.
331
- * @param options - additional options containing the following keys:
332
- * ensureVisible - true to make selection visible if it is within a
333
- * collapsed node or outside of the visible scroll window. Default true.
334
- * clearSelection - true to clear previous selection (rather than
335
- * add to it). Default true.
330
+ * @param opts - additional post-selection options
336
331
  */
337
332
  selectAsync(records: Some<StoreRecordOrId>, opts?: {
333
+ /**
334
+ * True (default) to scroll the grid or expand nodes as needed to make selection
335
+ * visible if it is within a collapsed node or outside of the visible scroll window.
336
+ */
338
337
  ensureVisible?: boolean;
338
+ /** True (default) to clear previous selection (rather than add to it). */
339
339
  clearSelection?: boolean;
340
340
  }): Promise<void>;
341
341
  /**
342
342
  * Select the first row in the grid.
343
343
  *
344
- * See {@link preSelectFirstAsync} for a useful variant of this method. preSelectFirstAsync()
345
- * will not change the selection if there is already a selection, which is what applications
346
- * typically want to do when loading/reloading a grid.
347
- *
348
- * @param opts -
349
- * expandParentGroups - set to true to expand nodes to allow selection when the
350
- * first selectable node is in a collapsed group. Default true.
351
- * ensureVisible - set to to true to scroll to the selected row if it is outside of the
352
- * visible scroll window. Default true.
353
- *
344
+ * See {@link preSelectFirstAsync} for a useful variant of this method that will leave the
345
+ * any pre-existing selection unchanged, which is what apps typically want when reloading an
346
+ * already-populated grid.
354
347
  */
355
348
  selectFirstAsync(opts?: {
349
+ /**
350
+ * True (default) to expand nodes as needed to allow selection when the first selectable
351
+ * node is in a collapsed group.
352
+ */
356
353
  expandParentGroups?: boolean;
354
+ /**
355
+ * True (default) to scroll the grid or expand nodes as needed to make selection
356
+ * visible if it is outside of the visible scroll window.
357
+ */
357
358
  ensureVisible?: boolean;
358
359
  }): Promise<void>;
359
360
  /**
360
361
  * Select the first row in the grid, if no other selection present.
361
- *
362
362
  * This method delegates to {@link selectFirstAsync}.
363
363
  */
364
364
  preSelectFirstAsync(): Promise<void>;
@@ -19,7 +19,7 @@ export interface MsalClientConfig extends BaseOAuthClientConfig<MsalTokenSpec> {
19
19
  domainHint?: string;
20
20
  /**
21
21
  * True to enable support for built-in telemetry provided by this class's internal MSAL client.
22
- * Captured performance events will be summarized as {@link MsalClientTelemetry}.
22
+ * Captured performance events will be summarized as {@link MsalClientTelemetry}. Default true.
23
23
  */
24
24
  enableTelemetry?: boolean;
25
25
  /**
@@ -42,6 +42,17 @@ export interface MsalClientConfig extends BaseOAuthClientConfig<MsalTokenSpec> {
42
42
  * See https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/token-lifetimes.md
43
43
  */
44
44
  initRefreshTokenExpirationOffsetSecs?: number;
45
+ /**
46
+ * Enable the use of the MSAL ssoSilent() API, which will attempt to use credentials gained by
47
+ * another app or tab to start a new session for this app. Requires iFrames and 3rd party
48
+ * cookies to be enabled. Default true.
49
+ *
50
+ * In practice, and according to documentation, this operation is likely to fail for a
51
+ * number of reasons, and can often do so as timeout. Therefore, keeping the timeout limit
52
+ * value -- `system.iFrameHashTimeout` -- at a relatively low value is critical. Hoist
53
+ * defaults this value to 3000ms vs. the default 10000ms.
54
+ */
55
+ enableSsoSilent?: boolean;
45
56
  /** The log level of MSAL. Default is LogLevel.Warning. */
46
57
  msalLogLevel?: LogLevel;
47
58
  /**
@@ -105,14 +116,19 @@ export declare class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTo
105
116
  private get loginScopes();
106
117
  private get loginExtraScopesToConsent();
107
118
  private get refreshOffsetArgs();
108
- private noteUserAuthenticated;
119
+ private setAccount;
120
+ private noteAuthComplete;
121
+ private authRequestCore;
109
122
  }
123
+ type AuthMethod = 'acquireSilent' | 'ssoSilent' | 'loginPopup' | 'loginRedirect';
110
124
  /**
111
125
  * Telemetry produced by this client (if enabled) + included in {@link ClientHealthService}
112
126
  * reporting. Leverages MSAL's opt-in support for emitting performance events.
113
127
  * See https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/performance.md
114
128
  */
115
129
  interface MsalClientTelemetry {
130
+ /** Method of last authentication for this client. */
131
+ authMethod: AuthMethod;
116
132
  /** Stats across all events */
117
133
  summary: {
118
134
  successCount: number;
@@ -751,19 +751,22 @@ export class GridModel extends HoistModel {
751
751
 
752
752
  /**
753
753
  * Select records in the grid.
754
- *
755
754
  * @param records - one or more record(s) / ID(s) to select.
756
- * @param options - additional options containing the following keys:
757
- * ensureVisible - true to make selection visible if it is within a
758
- * collapsed node or outside of the visible scroll window. Default true.
759
- * clearSelection - true to clear previous selection (rather than
760
- * add to it). Default true.
755
+ * @param opts - additional post-selection options
761
756
  */
762
757
  async selectAsync(
763
758
  records: Some<StoreRecordOrId>,
764
- opts?: {ensureVisible?: boolean; clearSelection?: boolean}
759
+ opts: {
760
+ /**
761
+ * True (default) to scroll the grid or expand nodes as needed to make selection
762
+ * visible if it is within a collapsed node or outside of the visible scroll window.
763
+ */
764
+ ensureVisible?: boolean;
765
+ /** True (default) to clear previous selection (rather than add to it). */
766
+ clearSelection?: boolean;
767
+ } = {}
765
768
  ) {
766
- const {ensureVisible = true, clearSelection = true} = opts ?? {};
769
+ const {ensureVisible = true, clearSelection = true} = opts;
767
770
  this.selModel.select(records, clearSelection);
768
771
  if (ensureVisible) await this.ensureSelectionVisibleAsync();
769
772
  }
@@ -771,19 +774,25 @@ export class GridModel extends HoistModel {
771
774
  /**
772
775
  * Select the first row in the grid.
773
776
  *
774
- * See {@link preSelectFirstAsync} for a useful variant of this method. preSelectFirstAsync()
775
- * will not change the selection if there is already a selection, which is what applications
776
- * typically want to do when loading/reloading a grid.
777
- *
778
- * @param opts -
779
- * expandParentGroups - set to true to expand nodes to allow selection when the
780
- * first selectable node is in a collapsed group. Default true.
781
- * ensureVisible - set to to true to scroll to the selected row if it is outside of the
782
- * visible scroll window. Default true.
783
- *
777
+ * See {@link preSelectFirstAsync} for a useful variant of this method that will leave the
778
+ * any pre-existing selection unchanged, which is what apps typically want when reloading an
779
+ * already-populated grid.
784
780
  */
785
- async selectFirstAsync(opts?: {expandParentGroups?: boolean; ensureVisible?: boolean}) {
786
- const {expandParentGroups = true, ensureVisible = true} = opts ?? {};
781
+ async selectFirstAsync(
782
+ opts: {
783
+ /**
784
+ * True (default) to expand nodes as needed to allow selection when the first selectable
785
+ * node is in a collapsed group.
786
+ */
787
+ expandParentGroups?: boolean;
788
+ /**
789
+ * True (default) to scroll the grid or expand nodes as needed to make selection
790
+ * visible if it is outside of the visible scroll window.
791
+ */
792
+ ensureVisible?: boolean;
793
+ } = {}
794
+ ) {
795
+ const {expandParentGroups = true, ensureVisible = true} = opts;
787
796
  await this.whenReadyAsync();
788
797
  if (!this.isReady) return;
789
798
 
@@ -803,7 +812,6 @@ export class GridModel extends HoistModel {
803
812
 
804
813
  /**
805
814
  * Select the first row in the grid, if no other selection present.
806
- *
807
815
  * This method delegates to {@link selectFirstAsync}.
808
816
  */
809
817
  async preSelectFirstAsync() {
package/data/Store.ts CHANGED
@@ -763,7 +763,10 @@ export class Store extends HoistBase {
763
763
  /** True if the store has changes which need to be committed. */
764
764
  @computed
765
765
  get isDirty(): boolean {
766
- return this._current !== this._committed || this.summaryRecords?.some(it => it.isModified);
766
+ return (
767
+ this._current !== this._committed ||
768
+ (this.summaryRecords?.some(it => it.isModified) ?? false)
769
+ );
767
770
  }
768
771
 
769
772
  /** Alias for {@link Store.isDirty} */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "78.0.0",
3
+ "version": "78.1.0",
4
4
  "description": "Hoist add-on for building and deploying React Applications.",
5
5
  "repository": "github:xh/hoist-react",
6
6
  "homepage": "https://xh.io",
@@ -28,8 +28,8 @@
28
28
  ]
29
29
  },
30
30
  "dependencies": {
31
- "@auth0/auth0-spa-js": "~2.7.0",
32
- "@azure/msal-browser": "~4.25.0",
31
+ "@auth0/auth0-spa-js": "~2.9.1",
32
+ "@azure/msal-browser": "~4.26.2",
33
33
  "@blueprintjs/core": "^5.10.5",
34
34
  "@blueprintjs/datetime": "^5.3.7",
35
35
  "@blueprintjs/datetime2": "^2.3.7",
@@ -180,8 +180,8 @@ export abstract class BaseOAuthClient<
180
180
  * Request a full logout from the underlying OAuth provider.
181
181
  */
182
182
  async logoutAsync(): Promise<void> {
183
- await this.doLogoutAsync();
184
183
  this.setSelectedUsername(null);
184
+ await this.doLogoutAsync();
185
185
  }
186
186
 
187
187
  /**
@@ -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 {AppState, PlainObject, XH} from '@xh/hoist/core';
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; // Authenticated account
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
- if (this.config.enableTelemetry) {
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.noteUserAuthenticated(redirectResp.account);
157
+ this.setAccount(redirectResp.account);
143
158
  this.restoreRedirectState(redirectResp.state);
144
- return this.fetchAllTokensAsync({eagerOnly: true});
159
+ const ret = this.fetchAllTokensAsync({eagerOnly: true});
160
+ this.noteAuthComplete('loginRedirect');
161
+ return ret;
145
162
  }
146
163
 
147
- // 1) If we are logged in, try to just reload tokens silently. This is the happy path on
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('Authenticated accounts available', accounts);
153
- const account = accounts.length == 1 ? accounts[0] : null;
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.noteUserAuthenticated(account);
175
+ this.setAccount(account);
156
176
  try {
157
177
  this.initialTokenLoad = true;
158
178
  this.logDebug('Attempting silent token load.');
159
- return await this.fetchAllTokensAsync({eagerOnly: true});
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) Otherwise need to login.
169
- // 2a) Try MSALs `ssoSilent` API, to potentially reuse logged-in user on other apps in same
170
- // domain without interaction. This should never trigger popup/redirect, but will use an iFrame
171
- // if available (3rd party cookies required). Will work if MSAL can resolve a single
172
- // logged-in user with access to app and meeting all hint criteria.
173
- try {
174
- this.logDebug('Attempting SSO');
175
- await this.loginSsoAsync();
176
- } catch (e) {
177
- this.logDebug('SSO failed', e.message ?? e);
178
- }
179
-
180
- // 2b) Otherwise do full interactive login. This may or may not require user involvement
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) Return tokens
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(opts);
202
- this.noteUserAuthenticated(ret.account);
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
- state,
220
- loginHint: this.getSelectedUsername(),
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 {postLogoutRedirectUrl, client, account, loginMethod} = this,
259
- opts = {account, postLogoutRedirectUri: postLogoutRedirectUrl};
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
- loginMethod == 'REDIRECT'
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
- loginHint: this.getSelectedUsername(),
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 {client} = this;
422
+ const source = this.client.constructor.name;
417
423
  switch (level) {
418
424
  case msal.LogLevel.Info:
419
- return logInfo(message, client);
425
+ return logInfo(message, source);
420
426
  case msal.LogLevel.Warning:
421
- return logWarn(message, client);
427
+ return logWarn(message, source);
422
428
  case msal.LogLevel.Error:
423
- return logError(message, client);
429
+ return logError(message, source);
424
430
  default:
425
- return logDebug(message, client);
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 noteUserAuthenticated(account: AccountInfo) {
455
+ private setAccount(account: AccountInfo) {
450
456
  this.account = account;
451
457
  this.setSelectedUsername(account.username);
452
- this.logDebug('User Authenticated', account.username);
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;