@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.
@@ -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;