@xh/hoist 73.0.0-SNAPSHOT.1746837312368 → 73.0.0-SNAPSHOT.1747058765265

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
@@ -31,6 +31,10 @@
31
31
  expand vertically when used in a `Toolbar` or other space-constrained, single-line layout.
32
32
  * Updated `FormModel` to support `persistWith` for storing and recalling its values, including
33
33
  developer options to persist all or a provided subset of fields.
34
+ * `BaseOAuthClientConfig` now supports a new `reloginEnabled` property. Use this property to
35
+ allow the client to do a potentially interactive popup login during the session to re-establish
36
+ the login. This is especially useful to allow recovery from expired or invalidated refresh
37
+ tokens.
34
38
 
35
39
  ### 🐞 Bug Fixes
36
40
 
@@ -106,6 +106,16 @@ export class AppContainerModel extends HoistModel {
106
106
  */
107
107
  @bindable initializingLoadMaskMessage: ReactNode;
108
108
 
109
+ /**
110
+ * The last interactive login in the app. Hoist's security package will mark the last
111
+ * time spent during user interactive login.
112
+ *
113
+ * Used by `Promise.track`, to ensure this time is not counted in any elapsed time tracking
114
+ * for the app.
115
+ * @internal
116
+ */
117
+ lastRelogin: {started: number; completed: number} = null;
118
+
109
119
  /**
110
120
  * Main entry point. Initialize and render application code.
111
121
  */
@@ -46,6 +46,18 @@ export declare class AppContainerModel extends HoistModel {
46
46
  * Update within `AppModel.initAsync()` to relay app-specific initialization status.
47
47
  */
48
48
  initializingLoadMaskMessage: ReactNode;
49
+ /**
50
+ * The last interactive login in the app. Hoist's security package will mark the last
51
+ * time spent during user interactive login.
52
+ *
53
+ * Used by `Promise.track`, to ensure this time is not counted in any elapsed time tracking
54
+ * for the app.
55
+ * @internal
56
+ */
57
+ lastRelogin: {
58
+ started: number;
59
+ completed: number;
60
+ };
49
61
  /**
50
62
  * Main entry point. Initialize and render application code.
51
63
  */
@@ -15,5 +15,5 @@ export declare const AppState: Readonly<{
15
15
  export type AppState = (typeof AppState)[keyof typeof AppState];
16
16
  export interface AppSuspendData {
17
17
  message?: string;
18
- reason: 'IDLE' | 'SERVER_FORCE' | 'APP_UPDATE';
18
+ reason: 'IDLE' | 'SERVER_FORCE' | 'APP_UPDATE' | 'AUTH_EXPIRED';
19
19
  }
@@ -27,10 +27,6 @@ export interface NavigatorConfig {
27
27
  * See enum for description of supported modes.
28
28
  */
29
29
  refreshMode?: RefreshMode;
30
- /**
31
- * Base route name for this navigator, with the route for each page being "[route]/[page.id]".
32
- */
33
- route?: string;
34
30
  }
35
31
  /**
36
32
  * Model for handling stack-based navigation between pages.
@@ -38,8 +34,6 @@ export interface NavigatorConfig {
38
34
  */
39
35
  export declare class NavigatorModel extends HoistModel {
40
36
  disableAppRefreshButton: boolean;
41
- readonly route: string;
42
- readonly routePrefix: string;
43
37
  stack: PageModel[];
44
38
  pages: PageConfig[];
45
39
  track: boolean;
@@ -55,7 +49,7 @@ export declare class NavigatorModel extends HoistModel {
55
49
  get activePageIdx(): number;
56
50
  get allowSlideNext(): boolean;
57
51
  get allowSlidePrev(): boolean;
58
- constructor({ pages, route, track, pullDownToRefresh, transitionMs, renderMode, refreshMode }: NavigatorConfig);
52
+ constructor({ pages, track, pullDownToRefresh, transitionMs, renderMode, refreshMode }: NavigatorConfig);
59
53
  /**
60
54
  * @param callback - function to execute (once) after the next page transition.
61
55
  */
@@ -49,6 +49,22 @@ export interface BaseOAuthClientConfig<S extends AccessTokenSpec> {
49
49
  * other metadata required by the underlying provider.
50
50
  */
51
51
  accessTokens?: Record<string, S>;
52
+ /**
53
+ * True to allow this client to try to re-login interactively (via pop-up) if tokens begin
54
+ * failing to load due to specific provider exceptions indicating user interaction is required.
55
+ * This can happen, for example, if a token expires and the refresh token is expired or
56
+ * invalidated during the lifetime of the client. Default is false and retry will not be
57
+ * attempted.
58
+ */
59
+ reloginEnabled?: boolean;
60
+ /**
61
+ * Maximum time for (interactive) re-login.
62
+ *
63
+ * Set to a reasonably fixed amount of time, to allow user to type in password and complete
64
+ * MFA, but not so long as to allow a problematic build-up of application requests.
65
+ * Default 60 seconds;
66
+ */
67
+ reloginTimeoutSecs?: number;
52
68
  }
53
69
  /**
54
70
  * Implementations of this class coordinate OAuth-based login and token provision. Apps can use a
@@ -70,6 +86,8 @@ export declare abstract class BaseOAuthClient<C extends BaseOAuthClientConfig<S>
70
86
  private timer;
71
87
  private lastRefreshAttempt;
72
88
  private TIMER_INTERVAL;
89
+ private pendingRelogin;
90
+ private lastRelogin;
73
91
  constructor(config: C);
74
92
  /**
75
93
  * Main entry point for this object.
@@ -116,6 +134,7 @@ export declare abstract class BaseOAuthClient<C extends BaseOAuthClientConfig<S>
116
134
  protected abstract fetchIdTokenAsync(useCache: boolean): Promise<Token>;
117
135
  protected abstract fetchAccessTokenAsync(spec: S, useCache: boolean): Promise<Token>;
118
136
  protected abstract doLogoutAsync(): Promise<void>;
137
+ protected abstract interactiveLoginNeeded(exception: unknown): boolean;
119
138
  protected get redirectUrl(): string;
120
139
  protected get postLogoutRedirectUrl(): string;
121
140
  protected get loginMethod(): LoginMethod;
@@ -145,6 +164,9 @@ export declare abstract class BaseOAuthClient<C extends BaseOAuthClientConfig<S>
145
164
  protected getLocalStorage(key: string, defaultValue?: any): any;
146
165
  protected setLocalStorage(key: string, value: any): void;
147
166
  private fetchIdTokenSafeAsync;
167
+ private getWithRetry;
168
+ private rethrowWrapped;
169
+ private getLoginTask;
148
170
  private onTimerAsync;
149
171
  private logTokensDebug;
150
172
  }
@@ -49,6 +49,7 @@ export declare class AuthZeroClient extends BaseOAuthClient<AuthZeroClientConfig
49
49
  protected fetchIdTokenAsync(useCache?: boolean): Promise<Token>;
50
50
  protected fetchAccessTokenAsync(spec: AuthZeroTokenSpec, useCache?: boolean): Promise<Token>;
51
51
  protected doLogoutAsync(): Promise<void>;
52
+ protected interactiveLoginNeeded(exception: unknown): boolean;
52
53
  private createClient;
53
54
  private get loginScope();
54
55
  private returningFromRedirect;
@@ -77,10 +77,9 @@ export interface MsalTokenSpec extends AccessTokenSpec {
77
77
  * Also see this doc re. use of blankUrl as redirectUri for all "silent" token requests:
78
78
  * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/errors.md#issues-caused-by-the-redirecturi-page
79
79
  *
80
- * TODO: The handling of `ssoSilent` and `initRefreshTokenExpirationOffsetSecs` in this library
81
- * require 3rd party cookies to be enabled in the browser so that MSAL can load contact in a
82
- * hidden iFrame If its *not* enabled, we may be doing extra work. Consider checking 3rd party
83
- * cookie support and adding conditional behavior?
80
+ * Important note: The handling of `ssoSilent` and `initRefreshTokenExpirationOffsetSecs` in this
81
+ * library require 3rd party cookies to be enabled in the browser so that MSAL can load contact
82
+ * in a hidden iFrame.
84
83
  */
85
84
  export declare class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec> {
86
85
  private client;
@@ -96,6 +95,7 @@ export declare class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTo
96
95
  protected fetchIdTokenAsync(useCache?: boolean): Promise<Token>;
97
96
  protected fetchAccessTokenAsync(spec: MsalTokenSpec, useCache?: boolean): Promise<Token>;
98
97
  protected doLogoutAsync(): Promise<void>;
98
+ protected interactiveLoginNeeded(exception: unknown): boolean;
99
99
  getFormattedTelemetry(): PlainObject;
100
100
  enableTelemetry(): void;
101
101
  disableTelemetry(): void;
@@ -144,10 +144,10 @@ export class TabContainerModel extends HoistModel implements Persistable<{active
144
144
  this.refreshContextModel.xhImpl = xhImpl;
145
145
 
146
146
  if (route) {
147
- // if (XH.isMobileApp) {
148
- // this.logWarn('TabContainer routing is not supported for mobile applications.');
149
- // return;
150
- // }
147
+ if (XH.isMobileApp) {
148
+ this.logWarn('TabContainer routing is not supported for mobile applications.');
149
+ return;
150
+ }
151
151
 
152
152
  this.addReaction({
153
153
  track: () => XH.routerState,
@@ -28,5 +28,5 @@ export type AppState = (typeof AppState)[keyof typeof AppState];
28
28
 
29
29
  export interface AppSuspendData {
30
30
  message?: string;
31
- reason: 'IDLE' | 'SERVER_FORCE' | 'APP_UPDATE';
31
+ reason: 'IDLE'|'SERVER_FORCE'|'APP_UPDATE'|'AUTH_EXPIRED';
32
32
  }
@@ -44,11 +44,6 @@ export interface NavigatorConfig {
44
44
  * See enum for description of supported modes.
45
45
  */
46
46
  refreshMode?: RefreshMode;
47
-
48
- /**
49
- * Base route name for this navigator, with the route for each page being "[route]/[page.id]".
50
- */
51
- route?: string;
52
47
  }
53
48
 
54
49
  /**
@@ -58,9 +53,6 @@ export interface NavigatorConfig {
58
53
  export class NavigatorModel extends HoistModel {
59
54
  @bindable disableAppRefreshButton: boolean;
60
55
 
61
- readonly route: string;
62
- readonly routePrefix: string;
63
-
64
56
  @bindable.ref
65
57
  stack: PageModel[] = [];
66
58
 
@@ -97,7 +89,6 @@ export class NavigatorModel extends HoistModel {
97
89
 
98
90
  constructor({
99
91
  pages,
100
- route,
101
92
  track = false,
102
93
  pullDownToRefresh = true,
103
94
  transitionMs = 500,
@@ -110,9 +101,6 @@ export class NavigatorModel extends HoistModel {
110
101
  ensureNotEmpty(pages, 'NavigatorModel needs at least one page.');
111
102
  ensureUniqueBy(pages, 'id', 'Multiple NavigatorModel PageModels have the same id.');
112
103
 
113
- this.route = route ?? '';
114
- this.routePrefix = route ? route.substring(0, route.lastIndexOf('.') + 1) : '';
115
-
116
104
  this.pages = pages;
117
105
  this.track = track;
118
106
  this.pullDownToRefresh = pullDownToRefresh;
@@ -221,7 +209,7 @@ export class NavigatorModel extends HoistModel {
221
209
  this.stack = this.stack.slice(0, this._swiper.activeIndex + 1);
222
210
 
223
211
  // 2) Sync route to match the current page stack
224
- const newRouteName = this.routePrefix + this.stack.map(it => it.id).join('.'),
212
+ const newRouteName = this.stack.map(it => it.id).join('.'),
225
213
  newRouteParams = mergeDeep({}, ...this.stack.map(it => it.props));
226
214
 
227
215
  XH.navigate(newRouteName, newRouteParams);
@@ -238,17 +226,16 @@ export class NavigatorModel extends HoistModel {
238
226
  };
239
227
 
240
228
  private onRouteChange(init: boolean = false) {
241
- const {route: myRoute, routePrefix: myRoutePrefix, _swiper} = this;
242
- if (!XH.routerState || (myRoute && !XH.router.isActive(myRoute)) || !_swiper) return;
229
+ if (!this._swiper || !XH.routerState) return;
243
230
 
244
- // Break the current route name into parts, only looking at our part of it (myRoute and below).
245
- // Collect any params for each part. Use meta.params to determine which params are associated
246
- // with each route part. Save these params to use as props for the page.
231
+ // Break the current route name into parts, and collect any params for each part.
232
+ // Use meta.params to determine which params are associated with each route part.
233
+ // Save these params to use as props for the page.
247
234
  const {meta, name, params} = XH.routerState,
248
- parts = name.replace(myRoutePrefix, '').split('.');
235
+ parts = name.split('.');
249
236
 
250
237
  const routeParts = parts.map((id, idx) => {
251
- const metaKey = myRoutePrefix + parts.slice(0, idx + 1).join('.'),
238
+ const metaKey = parts.slice(0, idx + 1).join('.'),
252
239
  props = {};
253
240
 
254
241
  // Extract props for this part
@@ -275,7 +262,7 @@ export class NavigatorModel extends HoistModel {
275
262
  // we drop the rest of the route and redirect to the route so far
276
263
  if (init && pageModelCfg.disableDirectLink) {
277
264
  const completedRouteParts = routeParts.slice(0, i),
278
- newRouteName = myRoutePrefix + completedRouteParts.map(it => it.id).join('.'),
265
+ newRouteName = completedRouteParts.map(it => it.id).join('.'),
279
266
  newRouteParams = mergeDeep({}, ...completedRouteParts.map(it => it.props));
280
267
 
281
268
  XH.navigate(newRouteName, newRouteParams, {replace: true});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "73.0.0-SNAPSHOT.1746837312368",
3
+ "version": "73.0.0-SNAPSHOT.1747058765265",
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",
@@ -203,12 +203,23 @@ const enhancePromise = promisePrototype => {
203
203
 
204
204
  const startTime = Date.now(),
205
205
  doTrack = (isError: boolean) => {
206
- const opts: TrackOptions = isString(options)
207
- ? {message: options}
208
- : {...options};
209
-
206
+ const endTime = Date.now(),
207
+ opts: TrackOptions = isString(options) ? {message: options} : {...options};
210
208
  opts.timestamp = startTime;
211
- opts.elapsed = Date.now() - startTime;
209
+ opts.elapsed = endTime - startTime;
210
+
211
+ // Null out any time spent during "interactive" login, if it took longer than
212
+ // 2 seconds (e.g. user input required). This avoids stats being blown out.
213
+ // Could also try to correct, but this seems safer.
214
+ const login = XH.appContainerModel.lastRelogin;
215
+ if (
216
+ login &&
217
+ startTime <= login.completed &&
218
+ endTime >= login.completed &&
219
+ login.completed - login.started > 2 * SECONDS
220
+ ) {
221
+ opts.elapsed = null;
222
+ }
212
223
  if (isError) opts.severity = 'ERROR';
213
224
 
214
225
  XH.track(opts);
@@ -13,7 +13,7 @@ import {Token} from '@xh/hoist/security/Token';
13
13
  import {AccessTokenSpec, TokenMap} from './Types';
14
14
  import {Timer} from '@xh/hoist/utils/async';
15
15
  import {MINUTES, olderThan, ONE_MINUTE, SECONDS} from '@xh/hoist/utils/datetime';
16
- import {isJSON, throwIf} from '@xh/hoist/utils/js';
16
+ import {isJSON, logError, throwIf} from '@xh/hoist/utils/js';
17
17
  import {find, forEach, isEmpty, isObject, keys, map, pickBy, union} from 'lodash';
18
18
  import ShortUniqueId from 'short-unique-id';
19
19
 
@@ -73,6 +73,24 @@ export interface BaseOAuthClientConfig<S extends AccessTokenSpec> {
73
73
  * other metadata required by the underlying provider.
74
74
  */
75
75
  accessTokens?: Record<string, S>;
76
+
77
+ /**
78
+ * True to allow this client to try to re-login interactively (via pop-up) if tokens begin
79
+ * failing to load due to specific provider exceptions indicating user interaction is required.
80
+ * This can happen, for example, if a token expires and the refresh token is expired or
81
+ * invalidated during the lifetime of the client. Default is false and retry will not be
82
+ * attempted.
83
+ */
84
+ reloginEnabled?: boolean;
85
+
86
+ /**
87
+ * Maximum time for (interactive) re-login.
88
+ *
89
+ * Set to a reasonably fixed amount of time, to allow user to type in password and complete
90
+ * MFA, but not so long as to allow a problematic build-up of application requests.
91
+ * Default 60 seconds;
92
+ */
93
+ reloginTimeoutSecs?: number;
76
94
  }
77
95
 
78
96
  /**
@@ -101,6 +119,8 @@ export abstract class BaseOAuthClient<
101
119
  @managed private timer: Timer;
102
120
  private lastRefreshAttempt: number;
103
121
  private TIMER_INTERVAL = 2 * SECONDS;
122
+ private pendingRelogin: Promise<void> = null;
123
+ private lastRelogin: {started: number; completed: number};
104
124
 
105
125
  //------------------------
106
126
  // Public API
@@ -114,6 +134,8 @@ export abstract class BaseOAuthClient<
114
134
  redirectUrl: 'APP_BASE_URL',
115
135
  postLogoutRedirectUrl: 'APP_BASE_URL',
116
136
  autoRefreshSecs: -1,
137
+ reloginEnabled: false,
138
+ reloginTimeoutSecs: 60,
117
139
  ...config
118
140
  };
119
141
  throwIf(!config.clientId, 'Missing OAuth clientId. Please review your configuration.');
@@ -156,7 +178,7 @@ export abstract class BaseOAuthClient<
156
178
  * Get an ID token.
157
179
  */
158
180
  async getIdTokenAsync(): Promise<Token> {
159
- return this.fetchIdTokenSafeAsync(true);
181
+ return this.getWithRetry(() => this.fetchIdTokenSafeAsync(true));
160
182
  }
161
183
 
162
184
  /**
@@ -166,14 +188,14 @@ export abstract class BaseOAuthClient<
166
188
  const spec = this.accessSpecs[key];
167
189
  if (!spec) throw XH.exception(`No access token spec configured for key "${key}"`);
168
190
 
169
- return this.fetchAccessTokenAsync(spec, true);
191
+ return this.getWithRetry(() => this.fetchAccessTokenAsync(spec, true));
170
192
  }
171
193
 
172
194
  /**
173
195
  * Get all configured tokens.
174
196
  */
175
197
  async getAllTokensAsync(opts?: {eagerOnly?: boolean; useCache?: boolean}): Promise<TokenMap> {
176
- return this.fetchAllTokensAsync(opts);
198
+ return this.getWithRetry(() => this.fetchAllTokensAsync(opts));
177
199
  }
178
200
 
179
201
  /**
@@ -209,6 +231,8 @@ export abstract class BaseOAuthClient<
209
231
 
210
232
  protected abstract doLogoutAsync(): Promise<void>;
211
233
 
234
+ protected abstract interactiveLoginNeeded(exception: unknown): boolean;
235
+
212
236
  //---------------------------------------
213
237
  // Implementation
214
238
  //---------------------------------------
@@ -357,7 +381,7 @@ export abstract class BaseOAuthClient<
357
381
  // https://github.com/AzureAD/microsoft-authentication-library-for-js/issues/4206
358
382
  // Protect ourselves from this, without losing benefits of local cache.
359
383
  let ret = await this.fetchIdTokenAsync(useCache);
360
- if (useCache && ret.expiresWithin(ONE_MINUTE)) {
384
+ if (useCache && ret.expiresWithin(1 * MINUTES)) {
361
385
  this.logDebug('Stale ID Token loaded from the cache, reloading without cache.');
362
386
  ret = await this.fetchIdTokenAsync(false);
363
387
  }
@@ -367,6 +391,62 @@ export abstract class BaseOAuthClient<
367
391
  return ret;
368
392
  }
369
393
 
394
+ private async getWithRetry<V>(fn: () => Promise<V>): Promise<V> {
395
+ const {reloginEnabled} = this.config,
396
+ {lastRelogin, pendingRelogin} = this;
397
+
398
+ // 0) Simple case: either no relogin possible, OR we are already working on a relogin,
399
+ // and will take just a single shot when it completes.
400
+ if (!reloginEnabled || !olderThan(lastRelogin?.completed, 1 * MINUTES) || pendingRelogin) {
401
+ await pendingRelogin;
402
+ return fn().catch(e => this.rethrowWrapped(e));
403
+ }
404
+
405
+ // 1) Complex case: Take potentially two tries.
406
+ try {
407
+ return await fn();
408
+ } catch (e) {
409
+ if (!this.interactiveLoginNeeded(e)) throw e;
410
+ // Be sure to create and join on just a single shared loginTask.
411
+ this.pendingRelogin ??= this.getLoginTask().finally(() => (this.pendingRelogin = null));
412
+ await this.pendingRelogin;
413
+ return fn().catch(e => this.rethrowWrapped(e));
414
+ }
415
+ }
416
+
417
+ private rethrowWrapped(e: unknown): never {
418
+ if (!this.interactiveLoginNeeded(e)) throw e;
419
+
420
+ throw XH.exception({
421
+ name: 'Auth Expired',
422
+ message: 'Your authentication has expired. Please reload the app to login again.',
423
+ cause: e
424
+ });
425
+ }
426
+
427
+ // Return a highly managed task that will attempt to relogin the user, and never throw.
428
+ private getLoginTask(): Promise<void> {
429
+ let started: number = Date.now(),
430
+ completed: number;
431
+ return this.doLoginPopupAsync()
432
+ .timeout(this.config.reloginTimeoutSecs * SECONDS)
433
+ .finally(() => {
434
+ completed = Date.now();
435
+ this.lastRelogin = XH.appContainerModel.lastRelogin = {started, completed};
436
+ })
437
+ .then(() => {
438
+ XH.track({
439
+ category: 'App',
440
+ message: 'Interactive reauthentication succeeded',
441
+ elapsed: completed - started
442
+ });
443
+ })
444
+ .catch(e => {
445
+ // Should there be a non-auth requiring way to communicate this to server?
446
+ logError('Failed to reauthenticate', e);
447
+ });
448
+ }
449
+
370
450
  @action
371
451
  private async onTimerAsync(): Promise<void> {
372
452
  const {config, lastRefreshAttempt} = this,
@@ -178,6 +178,12 @@ export class AuthZeroClient extends BaseOAuthClient<AuthZeroClientConfig, AuthZe
178
178
  await wait(10 * SECONDS);
179
179
  }
180
180
 
181
+ protected override interactiveLoginNeeded(exception: unknown): boolean {
182
+ return ['login_required', 'interaction_required', 'consent_required'].includes(
183
+ exception['error']
184
+ );
185
+ }
186
+
181
187
  //------------------------
182
188
  // Private implementation
183
189
  //------------------------
@@ -9,6 +9,7 @@ import {
9
9
  AccountInfo,
10
10
  BrowserPerformanceClient,
11
11
  Configuration,
12
+ InteractionRequiredAuthError,
12
13
  IPublicClientApplication,
13
14
  LogLevel,
14
15
  PopupRequest,
@@ -103,10 +104,9 @@ export interface MsalTokenSpec extends AccessTokenSpec {
103
104
  * Also see this doc re. use of blankUrl as redirectUri for all "silent" token requests:
104
105
  * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/errors.md#issues-caused-by-the-redirecturi-page
105
106
  *
106
- * TODO: The handling of `ssoSilent` and `initRefreshTokenExpirationOffsetSecs` in this library
107
- * require 3rd party cookies to be enabled in the browser so that MSAL can load contact in a
108
- * hidden iFrame If its *not* enabled, we may be doing extra work. Consider checking 3rd party
109
- * cookie support and adding conditional behavior?
107
+ * Important note: The handling of `ssoSilent` and `initRefreshTokenExpirationOffsetSecs` in this
108
+ * library require 3rd party cookies to be enabled in the browser so that MSAL can load contact
109
+ * in a hidden iFrame.
110
110
  */
111
111
  export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec> {
112
112
  private client: IPublicClientApplication;
@@ -263,6 +263,10 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
263
263
  : await client.logoutPopup(opts);
264
264
  }
265
265
 
266
+ protected override interactiveLoginNeeded(exception: unknown): boolean {
267
+ return exception instanceof InteractionRequiredAuthError;
268
+ }
269
+
266
270
  //------------------------
267
271
  // Telemetry
268
272
  //------------------------