@xh/hoist 64.0.1 → 64.0.3

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.
@@ -4,26 +4,16 @@
4
4
  *
5
5
  * Copyright © 2024 Extremely Heavy Industries Inc.
6
6
  */
7
- import {BannerSpec, HoistBase, managed, XH} from '@xh/hoist/core';
8
- import {Icon} from '@xh/hoist/icon';
9
- import {TokenInfo} from '@xh/hoist/security/TokenInfo';
7
+ import {HoistBase, managed, XH} from '@xh/hoist/core';
8
+ import {Token, TokenMap} from '@xh/hoist/security/Token';
10
9
  import {Timer} from '@xh/hoist/utils/async';
11
10
  import {MINUTES, olderThan, SECONDS} from '@xh/hoist/utils/datetime';
12
- import {throwIf} from '@xh/hoist/utils/js';
13
- import {
14
- defaultsDeep,
15
- every,
16
- find,
17
- forEach,
18
- isNil,
19
- isObject,
20
- keys,
21
- mapValues,
22
- some,
23
- union
24
- } from 'lodash';
11
+ import {isJSON, throwIf} from '@xh/hoist/utils/js';
12
+ import {find, forEach, isEmpty, isObject, keys, pickBy, union} from 'lodash';
25
13
  import {v4 as uuid} from 'uuid';
26
- import {action, makeObservable, observable, runInAction} from '@xh/hoist/mobx';
14
+ import {action, makeObservable} from '@xh/hoist/mobx';
15
+
16
+ export type LoginMethod = 'REDIRECT' | 'POPUP';
27
17
 
28
18
  export interface BaseOAuthClientConfig<S> {
29
19
  /** Client ID (GUID) of your app registered with your Oauth provider. */
@@ -43,29 +33,34 @@ export interface BaseOAuthClientConfig<S> {
43
33
  postLogoutRedirectUrl?: 'APP_BASE_URL' | string;
44
34
 
45
35
  /** The method used for logging in on desktop. Default is 'REDIRECT'. */
46
- loginMethodDesktop?: 'REDIRECT' | 'POPUP';
36
+ loginMethodDesktop?: LoginMethod;
47
37
 
48
38
  /** The method used for logging in on mobile. Default is 'REDIRECT'. */
49
- loginMethodMobile?: 'REDIRECT' | 'POPUP';
39
+ loginMethodMobile?: LoginMethod;
50
40
 
51
41
  /**
52
- * Governs how frequently we attempt to refresh tokens with the API.
42
+ * Governs an optional refresh timer that will work to keep the tokens fresh.
53
43
  *
54
- * A typical refresh will use the underlying provider cache, and should not result in network
55
- * activity. However, if the token lifetime falls below`tokenSkipCacheSecs`, this client
56
- * will force a call to the underlying provider to get the token.
44
+ * A typical refresh will use the underlying provider cache, and should not result in
45
+ * network activity. However, if any token lifetime falls below`autoRefreshSkipCacheSecs`,
46
+ * this client will force a call to the underlying provider to get the token.
57
47
  *
58
48
  * In order to allow aging tokens to be replaced in a timely manner, this value should be
59
49
  * significantly shorter than both the minimum token lifetime that will be
60
- * returned by the underlying API and `tokenSkipCacheSecs`. Default is 30 secs.
50
+ * returned by the underlying API and `autoRefreshSkipCacheSecs`.
51
+ *
52
+ * Default is -1, disabling this behavior.
61
53
  */
62
- tokenRefreshSecs?: number;
54
+ autoRefreshSecs?: number;
63
55
 
64
56
  /**
65
- * When the remaining token lifetime is below this threshold, force the provider to skip the
66
- * local cache and go directly to the underlying provider for a new token. Default is 180 secs.
57
+ * During auto-refresh, if the remaining lifetime for any token is below this threshold,
58
+ * force the provider to skip the local cache and go directly to the underlying provider for
59
+ * new tokens and refresh tokens.
60
+ *
61
+ * Default is -1, disabling this behavior.
67
62
  */
68
- tokenSkipCacheSecs?: number;
63
+ autoRefreshSkipCacheSecs?: number;
69
64
 
70
65
  /**
71
66
  * Scopes to request - if any - beyond the core `['openid', 'email']` scopes, which
@@ -84,18 +79,12 @@ export interface BaseOAuthClientConfig<S> {
84
79
  * Use this map to gain targeted access tokens for different back-end resources.
85
80
  */
86
81
  accessTokens?: Record<string, S>;
87
-
88
- /**
89
- * True to display a warning banner to the user if tokens expire. May be specified as a boolean
90
- * or a partial banner spec. Defaults to false.
91
- */
92
- expiryWarning?: boolean | Partial<BannerSpec>;
93
82
  }
94
83
 
95
84
  /**
96
85
  * Implementations of this class coordinate OAuth-based login and token provision. Apps can use a
97
- * suitable concrete implementation to power a client-side OauthService - see Toolbox for an
98
- * example using `Auth0Client`.
86
+ * suitable concrete implementation to power a client-side OauthService. See `MsalClient` and
87
+ * `AuthZeroClient`
99
88
  *
100
89
  * Initialize such a service and this client within the `preAuthInitAsync()` lifecycle method of
101
90
  * `AppModel` to use the tokens it acquires to authenticate with the Hoist server. (Note this
@@ -107,82 +96,120 @@ export abstract class BaseOAuthClient<C extends BaseOAuthClientConfig<S>, S> ext
107
96
  /** Config loaded from UI server + init method. */
108
97
  protected config: C;
109
98
 
110
- /** Scopes */
99
+ /** Id Scopes */
111
100
  protected idScopes: string[];
112
101
 
113
- @observable.ref protected _idToken: TokenInfo;
114
- @observable protected _accessTokens: Record<string, TokenInfo>;
102
+ /** Specification for Access Tokens **/
103
+ protected accessSpecs: Record<string, S>;
115
104
 
116
105
  @managed private timer: Timer;
117
- private expiryWarningDisplayed: boolean;
118
106
  private lastRefreshAttempt: number;
119
-
120
107
  private TIMER_INTERVAL = 2 * SECONDS;
121
108
 
122
109
  //------------------------
123
110
  // Public API
124
111
  //------------------------
125
- /**
126
- * ID token in JWT format. Observable.
127
- */
128
- get idToken(): string {
129
- return this._idToken?.token;
130
- }
131
-
132
- /**
133
- * Get a configured Access token in JWT format. Observable.
134
- */
135
- getAccessToken(key: string): string {
136
- return this._accessTokens[key]?.token;
137
- }
138
-
139
112
  constructor(config: C) {
140
113
  super();
141
114
  makeObservable(this);
142
- this.config = defaultsDeep(config, {
115
+ this.config = {
143
116
  loginMethodDesktop: 'REDIRECT',
144
117
  loginMethodMobile: 'REDIRECT',
145
118
  redirectUrl: 'APP_BASE_URL',
146
119
  postLogoutRedirectUrl: 'APP_BASE_URL',
147
- expiryWarning: false,
148
- tokenRefreshSecs: 30,
149
- tokenSkipCacheSecs: 180
150
- } as C);
120
+ autoRefreshSecs: -1,
121
+ autoRefreshSkipCacheSecs: -1,
122
+ ...config
123
+ };
151
124
  throwIf(!config.clientId, 'Missing OAuth clientId. Please review your configuration.');
152
125
 
153
126
  this.idScopes = union(['openid', 'email'], config.idScopes);
154
- this._idToken = null;
155
- this._accessTokens = mapValues(config.accessTokens, () => null);
127
+ this.accessSpecs = this.config.accessTokens;
156
128
  }
157
129
 
158
130
  /**
159
131
  * Main entry point for this object.
160
132
  */
161
133
  async initAsync(): Promise<void> {
162
- await this.doInitAsync();
163
- this.timer = Timer.create({
164
- runFn: async () => this.onTimerAsync(),
165
- interval: this.TIMER_INTERVAL
166
- });
134
+ const tokens = await this.doInitAsync();
135
+ this.logDebug('Successfully initialized with following tokens:');
136
+ this.logTokensDebug(tokens);
137
+ if (this.config.autoRefreshSecs > 0) {
138
+ this.timer = Timer.create({
139
+ runFn: async () => this.onTimerAsync(),
140
+ interval: this.TIMER_INTERVAL
141
+ });
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Request an interactive login with the underlying OAuth provider.
147
+ */
148
+ async loginAsync(method: LoginMethod = this.loginMethod): Promise<void> {
149
+ return method == 'REDIRECT' ? this.doLoginRedirectAsync() : this.doLoginPopupAsync();
167
150
  }
168
151
 
169
152
  /**
170
153
  * Request a full logout from the underlying OAuth provider.
171
154
  */
172
155
  async logoutAsync(): Promise<void> {
156
+ this.setSelectedUsername(null);
173
157
  await this.doLogoutAsync();
174
158
  }
175
159
 
160
+ /**
161
+ * Get an ID token.
162
+ */
163
+ async getIdTokenAsync(): Promise<Token> {
164
+ return this.fetchIdTokenSafeAsync(true);
165
+ }
166
+
167
+ /**
168
+ * Get a Access token.
169
+ */
170
+ async getAccessTokenAsync(key: string): Promise<Token> {
171
+ return this.fetchAccessTokenAsync(this.accessSpecs[key], true);
172
+ }
173
+
174
+ /**
175
+ * Get all available tokens.
176
+ */
177
+ async getAllTokensAsync(): Promise<TokenMap> {
178
+ return this.fetchAllTokensAsync(true);
179
+ }
180
+
181
+ /**
182
+ * The last authenticated OAuth username.
183
+ *
184
+ * Provided to facilitate more efficient re-login via SSO or otherwise. Cleared on logout.
185
+ * Note: not necessarily a currently authenticated user, and not necessarily the Hoist username.
186
+ */
187
+ getSelectedUsername(): string {
188
+ return this.getLocalStorage('xhOAuthSelectedUsername');
189
+ }
190
+
191
+ /**
192
+ * Set the last authenticated OAuth username.
193
+ * See `getSelectedUsername()`.
194
+ */
195
+ setSelectedUsername(username: string): void {
196
+ this.setLocalStorage('xhOAuthSelectedUsername', username);
197
+ }
198
+
176
199
  //------------------------------------
177
200
  // Template methods
178
201
  //-----------------------------------
179
- protected abstract doInitAsync(): Promise<void>;
202
+ protected abstract doInitAsync(): Promise<TokenMap>;
180
203
 
181
- protected abstract doLogoutAsync(): Promise<void>;
204
+ protected abstract doLoginPopupAsync(): Promise<void>;
205
+
206
+ protected abstract doLoginRedirectAsync(): Promise<void>;
182
207
 
183
- protected abstract getIdTokenAsync(useCache: boolean): Promise<TokenInfo>;
208
+ protected abstract fetchIdTokenAsync(useCache: boolean): Promise<Token>;
184
209
 
185
- protected abstract getAccessTokenAsync(spec: S, useCache: boolean): Promise<TokenInfo>;
210
+ protected abstract fetchAccessTokenAsync(spec: S, useCache: boolean): Promise<Token>;
211
+
212
+ protected abstract doLogoutAsync(): Promise<void>;
186
213
 
187
214
  //---------------------------------------
188
215
  // Implementation
@@ -197,14 +224,10 @@ export abstract class BaseOAuthClient<C extends BaseOAuthClientConfig<S>, S> ext
197
224
  return url === 'APP_BASE_URL' ? this.baseUrl : url;
198
225
  }
199
226
 
200
- protected get loginMethod(): 'REDIRECT' | 'POPUP' {
227
+ protected get loginMethod(): LoginMethod {
201
228
  return XH.isDesktop ? this.config.loginMethodDesktop : this.config.loginMethodMobile;
202
229
  }
203
230
 
204
- protected get usesRedirect(): boolean {
205
- return this.loginMethod == 'REDIRECT';
206
- }
207
-
208
231
  protected get baseUrl() {
209
232
  return `${window.location.origin}/${XH.clientAppCode}/`;
210
233
  }
@@ -234,12 +257,12 @@ export abstract class BaseOAuthClient<C extends BaseOAuthClientConfig<S>, S> ext
234
257
  search
235
258
  };
236
259
 
237
- const recs = XH.localStorageService
238
- .get('xhOAuthState', [])
239
- .filter(r => !olderThan(r.timestamp, 5 * MINUTES));
260
+ const recs = this.getLocalStorage('xhOAuthState', []).filter(
261
+ r => !olderThan(r.timestamp, 5 * MINUTES)
262
+ );
240
263
 
241
264
  recs.push(state);
242
- XH.localStorageService.set('xhOAuthState', recs);
265
+ this.setLocalStorage('xhOAuthState', recs);
243
266
  return state.key;
244
267
  }
245
268
 
@@ -249,7 +272,7 @@ export abstract class BaseOAuthClient<C extends BaseOAuthClientConfig<S>, S> ext
249
272
  * @param key - key for re-accessing this state, as round-tripped with redirect.
250
273
  */
251
274
  protected restoreRedirectState(key: string) {
252
- const state = find(XH.localStorageService.get('xhOAuthState', []), {key});
275
+ const state = find(this.getLocalStorage('xhOAuthState', []), {key});
253
276
  throwIf(!state, 'Failure in OAuth, no redirect state located.');
254
277
 
255
278
  this.logDebug('Restoring Redirect State', state);
@@ -257,55 +280,42 @@ export abstract class BaseOAuthClient<C extends BaseOAuthClientConfig<S>, S> ext
257
280
  window.history.replaceState(null, '', pathname + search);
258
281
  }
259
282
 
260
- /**
261
- * Load tokens from provider.
262
- *
263
- * @param useCache - true (default) to use local cache if available, or false to force a
264
- * network request to fetch a fresh token.
265
- */
266
- protected async loadTokensAsync(useCache: boolean = true): Promise<void> {
267
- this.logDebug('Loading tokens from provider', `useCache=${useCache}`);
268
-
269
- const {_idToken, _accessTokens, config} = this,
270
- idToken = await this.getIdTokenSafeAsync(useCache),
271
- accessTokens: Record<string, TokenInfo> = {},
272
- accessTasks = mapValues(config.accessTokens, spec =>
273
- this.getAccessTokenAsync(spec, useCache)
274
- );
275
- for (const key of keys(accessTasks)) {
276
- accessTokens[key] = await accessTasks[key];
283
+ protected async fetchAllTokensAsync(useCache = true): Promise<TokenMap> {
284
+ const ret: TokenMap = {},
285
+ {accessSpecs} = this;
286
+ for (const key of keys(accessSpecs)) {
287
+ ret[key] = await this.fetchAccessTokenAsync(accessSpecs[key], useCache);
277
288
  }
289
+ // Do this after getting any access tokens --which can also populate the idToken cache!
290
+ ret.id = await this.fetchIdTokenSafeAsync(useCache);
278
291
 
279
- runInAction(() => {
280
- if (!_idToken?.equals(idToken)) {
281
- this._idToken = idToken;
282
- this.logDebug('Installed new Id Token', idToken.formattedExpiry, idToken.forLog);
283
- }
292
+ return ret;
293
+ }
284
294
 
285
- forEach(accessTokens, (token, k) => {
286
- if (!_accessTokens[k]?.equals(token)) {
287
- _accessTokens[k] = token;
288
- this.logDebug(
289
- `Installed new Access Token '${k}'`,
290
- token.formattedExpiry,
291
- token.forLog
292
- );
293
- }
294
- });
295
- });
295
+ protected getLocalStorage(key: string, defaultValue: any = null): any {
296
+ const val = window.localStorage.getItem(key);
297
+ if (!val) return defaultValue;
298
+ return isJSON(val) ? JSON.parse(val) : val;
299
+ }
300
+
301
+ protected setLocalStorage(key: string, value: any) {
302
+ if (value == null) window.localStorage.removeItem(value);
303
+ if (isObject(value)) value = JSON.stringify(value);
304
+ window.localStorage.setItem(key, value);
296
305
  }
297
306
 
298
307
  //-------------------
299
308
  // Implementation
300
309
  //-------------------
301
- private async getIdTokenSafeAsync(useCache: boolean): Promise<TokenInfo> {
310
+ private async fetchIdTokenSafeAsync(useCache: boolean): Promise<Token> {
302
311
  // Client libraries can apparently return expired idIokens when using local cache.
303
312
  // See: https://github.com/auth0/auth0-spa-js/issues/1089 and
304
313
  // https://github.com/AzureAD/microsoft-authentication-library-for-js/issues/4206
305
314
  // Protect ourselves from this, without losing benefits of local cache.
306
- let ret = await this.getIdTokenAsync(useCache);
307
- if (useCache && ret.expiresWithin(this.config.tokenSkipCacheSecs)) {
308
- ret = await this.getIdTokenAsync(false);
315
+ let ret = await this.fetchIdTokenAsync(useCache);
316
+ if (useCache && ret.expiresWithin(1 * MINUTES)) {
317
+ this.logDebug('Stale Id Token loaded from the cache, reloading without cache.');
318
+ ret = await this.fetchIdTokenAsync(false);
309
319
  }
310
320
 
311
321
  // Paranoia -- we don't expect this after workaround above to skip cache
@@ -315,62 +325,35 @@ export abstract class BaseOAuthClient<C extends BaseOAuthClientConfig<S>, S> ext
315
325
 
316
326
  @action
317
327
  private async onTimerAsync(): Promise<void> {
318
- const {_idToken, _accessTokens, config, lastRefreshAttempt, TIMER_INTERVAL} = this,
319
- refreshSecs = config.tokenRefreshSecs * SECONDS,
320
- skipCacheSecs = config.tokenSkipCacheSecs * SECONDS;
328
+ const {config, lastRefreshAttempt} = this,
329
+ refreshSecs = config.autoRefreshSecs * SECONDS,
330
+ skipCacheSecs = config.autoRefreshSkipCacheSecs * SECONDS;
321
331
 
322
- // 1) Periodically Refresh if we are missing a token, or a token is too close to expiry
323
- // NOTE -- we do this for all tokens at once, could be more selective.
324
332
  if (olderThan(lastRefreshAttempt, refreshSecs)) {
325
333
  this.lastRefreshAttempt = Date.now();
326
334
  try {
327
- const useCache =
328
- _idToken &&
329
- !_idToken.expiresWithin(skipCacheSecs) &&
330
- every(_accessTokens, t => t && !t.expiresWithin(skipCacheSecs));
331
- await this.loadTokensAsync(useCache);
335
+ this.logDebug('Refreshing all tokens:');
336
+ let tokens = await this.fetchAllTokensAsync(),
337
+ aging = pickBy(
338
+ tokens,
339
+ v => skipCacheSecs > 0 && v.expiresWithin(skipCacheSecs)
340
+ );
341
+ if (!isEmpty(aging)) {
342
+ this.logDebug(
343
+ `Tokens [${keys(aging).join(', ')}] have < ${skipCacheSecs}s remaining, reloading without cache.`
344
+ );
345
+ tokens = await this.fetchAllTokensAsync(false);
346
+ }
347
+ this.logTokensDebug(tokens);
332
348
  } catch (e) {
333
349
  XH.handleException(e, {showAlert: false, logOnServer: false});
334
350
  }
335
- } else {
336
- // 2) Otherwise, if a token will expire before next check, clear it out.
337
- // Note that we don't expect to have to do this, if refresh above working fine.
338
- // This is the unhappy path, and will trigger warning, if configured.
339
- if (_idToken?.expiresWithin(TIMER_INTERVAL)) this._idToken = null;
340
- forEach(_accessTokens, (tkn, k) => {
341
- if (tkn?.expiresWithin(TIMER_INTERVAL)) _accessTokens[k] = null;
342
- });
343
351
  }
344
-
345
- // 3) Always update the warning state.
346
- this.updateWarning();
347
352
  }
348
353
 
349
- private updateWarning() {
350
- const {expiryWarning} = this.config;
351
- if (!expiryWarning) return;
352
-
353
- const expired = !this._idToken || some(this._accessTokens, isNil);
354
- if (this.expiryWarningDisplayed != expired) {
355
- this.expiryWarningDisplayed = expired;
356
- if (expired) {
357
- const onClick = () => XH.reloadApp();
358
- let spec: BannerSpec = {
359
- category: 'xhOAuth',
360
- message: 'Authentication expired. Reload required',
361
- icon: Icon.warning(),
362
- intent: 'warning',
363
- enableClose: false,
364
- actionButtonProps: {text: 'Reload Now', onClick},
365
- onClick
366
- };
367
- if (isObject(expiryWarning)) {
368
- spec = {...spec, ...expiryWarning};
369
- }
370
- XH.showBanner(spec);
371
- } else {
372
- XH.hideBanner('xhOAuth');
373
- }
374
- }
354
+ private logTokensDebug(tokens: TokenMap) {
355
+ forEach(tokens, (token, key) => {
356
+ this.logDebug(`Token '${key}'`, token.formattedExpiry);
357
+ });
375
358
  }
376
359
  }
@@ -4,21 +4,23 @@
4
4
  *
5
5
  * Copyright © 2024 Extremely Heavy Industries Inc.
6
6
  */
7
-
8
7
  import {PlainObject} from '@xh/hoist/core';
8
+ import {fmtCompactDate} from '@xh/hoist/format';
9
9
  import {olderThan, SECONDS} from '@xh/hoist/utils/datetime';
10
10
  import {jwtDecode} from 'jwt-decode';
11
11
  import {getRelativeTimestamp} from '@xh/hoist/cmp/relativetimestamp';
12
12
  import {isNil} from 'lodash';
13
13
 
14
- export class TokenInfo {
15
- readonly token: string;
14
+ export type TokenMap = Record<string, Token>;
15
+
16
+ export class Token {
17
+ readonly value: string;
16
18
  readonly decoded: PlainObject;
17
19
  readonly expiry: number;
18
20
 
19
- constructor(token: string) {
20
- this.token = token;
21
- this.decoded = jwtDecode(token);
21
+ constructor(value: string) {
22
+ this.value = value;
23
+ this.decoded = jwtDecode(value);
22
24
  this.expiry = this.decoded.exp * SECONDS;
23
25
  }
24
26
 
@@ -27,7 +29,8 @@ export class TokenInfo {
27
29
  }
28
30
 
29
31
  get formattedExpiry() {
30
- return getRelativeTimestamp(this.expiry, {allowFuture: true, prefix: 'expires'});
32
+ const rel = getRelativeTimestamp(this.expiry, {allowFuture: true});
33
+ return `expires ${fmtCompactDate(this.expiry)} (${rel})`;
31
34
  }
32
35
 
33
36
  get forLog(): PlainObject {
@@ -39,7 +42,7 @@ export class TokenInfo {
39
42
  return ret;
40
43
  }
41
44
 
42
- equals(other: TokenInfo) {
43
- return this.token == other?.token;
45
+ equals(other: Token) {
46
+ return this.value == other?.value;
44
47
  }
45
48
  }