@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 +4 -0
- package/appcontainer/AppContainerModel.ts +10 -0
- package/build/types/appcontainer/AppContainerModel.d.ts +12 -0
- package/build/types/core/types/AppState.d.ts +1 -1
- package/build/types/security/BaseOAuthClient.d.ts +22 -0
- package/build/types/security/authzero/AuthZeroClient.d.ts +1 -0
- package/build/types/security/msal/MsalClient.d.ts +4 -4
- package/core/types/AppState.ts +1 -1
- package/package.json +1 -1
- package/promise/Promise.ts +16 -5
- package/security/BaseOAuthClient.ts +85 -5
- package/security/authzero/AuthZeroClient.ts +6 -0
- package/security/msal/MsalClient.ts +8 -4
- package/tsconfig.tsbuildinfo +1 -1
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
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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;
|
package/core/types/AppState.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xh/hoist",
|
|
3
|
-
"version": "73.0.0-SNAPSHOT.
|
|
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",
|
package/promise/Promise.ts
CHANGED
|
@@ -203,12 +203,23 @@ const enhancePromise = promisePrototype => {
|
|
|
203
203
|
|
|
204
204
|
const startTime = Date.now(),
|
|
205
205
|
doTrack = (isError: boolean) => {
|
|
206
|
-
const
|
|
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 =
|
|
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(
|
|
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
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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
|
//------------------------
|