@xh/hoist 79.0.0-SNAPSHOT.1764355693370 → 79.0.0-SNAPSHOT.1764710211647

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,6 +1,16 @@
1
1
  # Changelog
2
2
 
3
- ## 79.0.0-SNAPSHOT - unreleased
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`
4
14
 
5
15
  ### 📚 Libraries
6
16
 
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "79.0.0-SNAPSHOT.1764355693370",
3
+ "version": "79.0.0-SNAPSHOT.1764710211647",
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",
@@ -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;