@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.
- package/CHANGELOG.md +17 -1
- package/appcontainer/AppContainerModel.ts +2 -2
- package/build/types/core/types/Types.d.ts +1 -0
- package/build/types/security/BaseOAuthClient.d.ts +63 -46
- package/build/types/security/{TokenInfo.d.ts → Token.d.ts} +5 -4
- package/build/types/security/authzero/AuthZeroClient.d.ts +12 -7
- package/build/types/security/authzero/index.d.ts +1 -0
- package/build/types/security/msal/MsalClient.d.ts +63 -10
- package/build/types/security/msal/index.d.ts +1 -0
- package/build/types/svc/FetchService.d.ts +5 -5
- package/core/types/Types.ts +1 -0
- package/kit/blueprint/styles.scss +31 -18
- package/package.json +1 -1
- package/security/BaseOAuthClient.ts +148 -165
- package/security/{TokenInfo.ts → Token.ts} +12 -9
- package/security/authzero/AuthZeroClient.ts +92 -80
- package/security/authzero/index.ts +7 -0
- package/security/msal/MsalClient.ts +204 -94
- package/security/msal/index.ts +7 -0
- package/svc/FetchService.ts +14 -11
- package/svc/IdentityService.ts +1 -1
- package/svc/LocalStorageService.ts +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -4,26 +4,16 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Copyright © 2024 Extremely Heavy Industries Inc.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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
|
|
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?:
|
|
36
|
+
loginMethodDesktop?: LoginMethod;
|
|
47
37
|
|
|
48
38
|
/** The method used for logging in on mobile. Default is 'REDIRECT'. */
|
|
49
|
-
loginMethodMobile?:
|
|
39
|
+
loginMethodMobile?: LoginMethod;
|
|
50
40
|
|
|
51
41
|
/**
|
|
52
|
-
* Governs
|
|
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
|
|
55
|
-
* activity. However, if
|
|
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 `
|
|
50
|
+
* returned by the underlying API and `autoRefreshSkipCacheSecs`.
|
|
51
|
+
*
|
|
52
|
+
* Default is -1, disabling this behavior.
|
|
61
53
|
*/
|
|
62
|
-
|
|
54
|
+
autoRefreshSecs?: number;
|
|
63
55
|
|
|
64
56
|
/**
|
|
65
|
-
*
|
|
66
|
-
* local cache and go directly to the underlying provider for
|
|
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
|
-
|
|
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
|
|
98
|
-
*
|
|
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
|
-
|
|
114
|
-
|
|
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 =
|
|
115
|
+
this.config = {
|
|
143
116
|
loginMethodDesktop: 'REDIRECT',
|
|
144
117
|
loginMethodMobile: 'REDIRECT',
|
|
145
118
|
redirectUrl: 'APP_BASE_URL',
|
|
146
119
|
postLogoutRedirectUrl: 'APP_BASE_URL',
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
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.
|
|
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.
|
|
164
|
-
|
|
165
|
-
|
|
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<
|
|
202
|
+
protected abstract doInitAsync(): Promise<TokenMap>;
|
|
180
203
|
|
|
181
|
-
protected abstract
|
|
204
|
+
protected abstract doLoginPopupAsync(): Promise<void>;
|
|
205
|
+
|
|
206
|
+
protected abstract doLoginRedirectAsync(): Promise<void>;
|
|
182
207
|
|
|
183
|
-
protected abstract
|
|
208
|
+
protected abstract fetchIdTokenAsync(useCache: boolean): Promise<Token>;
|
|
184
209
|
|
|
185
|
-
protected abstract
|
|
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():
|
|
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 =
|
|
238
|
-
.
|
|
239
|
-
|
|
260
|
+
const recs = this.getLocalStorage('xhOAuthState', []).filter(
|
|
261
|
+
r => !olderThan(r.timestamp, 5 * MINUTES)
|
|
262
|
+
);
|
|
240
263
|
|
|
241
264
|
recs.push(state);
|
|
242
|
-
|
|
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(
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
this._idToken = idToken;
|
|
282
|
-
this.logDebug('Installed new Id Token', idToken.formattedExpiry, idToken.forLog);
|
|
283
|
-
}
|
|
292
|
+
return ret;
|
|
293
|
+
}
|
|
284
294
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
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.
|
|
307
|
-
if (useCache && ret.expiresWithin(
|
|
308
|
-
|
|
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 {
|
|
319
|
-
refreshSecs = config.
|
|
320
|
-
skipCacheSecs = config.
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
15
|
-
|
|
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(
|
|
20
|
-
this.
|
|
21
|
-
this.decoded = jwtDecode(
|
|
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
|
-
|
|
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:
|
|
43
|
-
return this.
|
|
45
|
+
equals(other: Token) {
|
|
46
|
+
return this.value == other?.value;
|
|
44
47
|
}
|
|
45
48
|
}
|