@xh/hoist 73.0.0-SNAPSHOT.1746837763745 → 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
  }
@@ -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;
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "73.0.0-SNAPSHOT.1746837763745",
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
  //------------------------