@tstdl/base 0.92.157 → 0.92.159

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.
@@ -27,6 +27,7 @@ export declare class AuthenticationClientService<AdditionalTokenPayload extends
27
27
  private readonly lock;
28
28
  private readonly logger;
29
29
  private readonly disposeToken;
30
+ private clockOffset;
30
31
  /**
31
32
  * Observable for authentication errors.
32
33
  * Emits when a refresh fails.
@@ -113,52 +114,64 @@ export declare class AuthenticationClientService<AdditionalTokenPayload extends
113
114
  */
114
115
  login(subject: string, secret: string, data?: AuthenticationData): Promise<void>;
115
116
  /**
116
- * Logout
117
+ * Logout from the current session.
118
+ * This will attempt to end the session on the server and then clear local credentials.
117
119
  */
118
120
  logout(): Promise<void>;
119
121
  /**
120
- * Force a refresh of the token
122
+ * Force an immediate refresh of the token.
121
123
  * @param data Additional authentication data
122
124
  */
123
125
  requestRefresh(data?: AuthenticationData): void;
124
126
  /**
125
- * Refresh the token
127
+ * Refresh the token.
126
128
  * @param data Additional authentication data
127
129
  */
128
130
  refresh(data?: AuthenticationData): Promise<void>;
129
131
  /**
130
- * Impersonate a subject
132
+ * Impersonate a subject.
131
133
  * @param subject The subject to impersonate
132
- * @param data Additional authentication data
134
+ * @param data Additional authentication data for the impersonated session
133
135
  */
134
136
  impersonate(subject: string, data?: AuthenticationData): Promise<void>;
135
137
  /**
136
- * Unimpersonate
138
+ * End impersonation and return to the original user session.
137
139
  * @param data Additional authentication data. If not provided, the data from before impersonation is used.
138
140
  */
139
141
  unimpersonate(data?: AuthenticationData): Promise<void>;
140
142
  /**
141
- * Initialize a secret reset
143
+ * Change the secret for a subject.
144
+ * @param subject The subject to change the secret for
145
+ * @param currentSecret The current secret
146
+ * @param newSecret The new secret
147
+ */
148
+ changeSecret(subject: string, currentSecret: string, newSecret: string): Promise<void>;
149
+ /**
150
+ * Initialize a secret reset.
142
151
  * @param subject The subject to reset the secret for
143
152
  * @param data Additional data for secret reset
144
153
  */
145
154
  initResetSecret(subject: string, data: AdditionalInitSecretResetData): Promise<void>;
146
155
  /**
147
- * Reset a secret
156
+ * Reset a secret using a reset token.
148
157
  * @param token The secret reset token
149
158
  * @param newSecret The new secret
150
159
  */
151
160
  resetSecret(token: string, newSecret: string): Promise<void>;
152
161
  /**
153
- * Check a secret for requirements
162
+ * Check a secret for requirements.
154
163
  * @param secret The secret to check
155
164
  * @returns The result of the check
156
165
  */
157
166
  checkSecret(secret: string): Promise<SecretCheckResult>;
158
- private saveToken;
159
- private loadToken;
160
167
  private setNewToken;
161
168
  private refreshLoop;
162
169
  private refreshLoopIteration;
163
170
  private handleRefreshError;
171
+ private estimatedServerTimestampSeconds;
172
+ private syncClock;
173
+ private saveToken;
174
+ private loadToken;
175
+ private readFromStorage;
176
+ private writeToStorage;
164
177
  }
@@ -25,8 +25,10 @@ import { Logger } from '../../logger/index.js';
25
25
  import { MessageBus } from '../../message-bus/index.js';
26
26
  import { computed, signal, toObservable } from '../../signals/api.js';
27
27
  import { currentTimestampSeconds } from '../../utils/date-time.js';
28
+ import { formatError } from '../../utils/format-error.js';
28
29
  import { timeout } from '../../utils/timing.js';
29
- import { assertDefinedPass, isDefined, isNullOrUndefined, isString, isUndefined } from '../../utils/type-guards.js';
30
+ import { assertDefinedPass, isDefined, isNullOrUndefined, isUndefined } from '../../utils/type-guards.js';
31
+ import { millisecondsPerSecond } from '../../utils/units.js';
30
32
  import { AUTHENTICATION_API_CLIENT, INITIAL_AUTHENTICATION_DATA } from './tokens.js';
31
33
  const tokenStorageKey = 'AuthenticationService:token';
32
34
  const authenticationDataStorageKey = 'AuthenticationService:authentication-data';
@@ -35,6 +37,17 @@ const tokenUpdateBusName = 'AuthenticationService:tokenUpdate';
35
37
  const loggedOutBusName = 'AuthenticationService:loggedOut';
36
38
  const refreshLockResource = 'AuthenticationService:refresh';
37
39
  const localStorage = globalThis.localStorage;
40
+ const refreshBufferSeconds = 15;
41
+ const lockTimeout = 10000;
42
+ const logoutTimeout = 150;
43
+ const unrecoverableErrors = [
44
+ InvalidTokenError,
45
+ NotFoundError,
46
+ BadRequestError,
47
+ ForbiddenError,
48
+ NotSupportedError,
49
+ UnauthorizedError,
50
+ ];
38
51
  /**
39
52
  * Handles authentication on client side.
40
53
  *
@@ -58,6 +71,7 @@ let AuthenticationClientService = class AuthenticationClientService {
58
71
  lock = inject(Lock, refreshLockResource);
59
72
  logger = inject(Logger, 'AuthenticationService');
60
73
  disposeToken = new CancellationToken();
74
+ clockOffset = 0;
61
75
  /**
62
76
  * Observable for authentication errors.
63
77
  * Emits when a refresh fails.
@@ -80,7 +94,7 @@ let AuthenticationClientService = class AuthenticationClientService {
80
94
  /** Emits when token is available (not undefined) */
81
95
  definedToken$ = this.token$.pipe(filter(isDefined));
82
96
  /** Emits when a valid token is available (not undefined and not expired) */
83
- validToken$ = this.definedToken$.pipe(filter((token) => token.exp > currentTimestampSeconds()));
97
+ validToken$ = this.definedToken$.pipe(filter((token) => token.exp > this.estimatedServerTimestampSeconds()));
84
98
  /** Current subject */
85
99
  subject$ = toObservable(this.subject);
86
100
  /** Emits when subject is available */
@@ -94,30 +108,16 @@ let AuthenticationClientService = class AuthenticationClientService {
94
108
  /** Emits when the user logs out */
95
109
  loggedOut$ = this.loggedOutBus.allMessages$;
96
110
  get authenticationData() {
97
- const data = localStorage?.getItem(authenticationDataStorageKey);
98
- return isNullOrUndefined(data) ? undefined : JSON.parse(data);
111
+ return this.readFromStorage(authenticationDataStorageKey);
99
112
  }
100
113
  set authenticationData(data) {
101
- if (isUndefined(data)) {
102
- localStorage?.removeItem(authenticationDataStorageKey);
103
- }
104
- else {
105
- const json = JSON.stringify(data);
106
- localStorage?.setItem(authenticationDataStorageKey, json);
107
- }
114
+ this.writeToStorage(authenticationDataStorageKey, data);
108
115
  }
109
116
  get impersonatorAuthenticationData() {
110
- const data = localStorage?.getItem(impersonatorAuthenticationDataStorageKey);
111
- return isNullOrUndefined(data) ? undefined : JSON.parse(data);
117
+ return this.readFromStorage(impersonatorAuthenticationDataStorageKey);
112
118
  }
113
119
  set impersonatorAuthenticationData(data) {
114
- if (isUndefined(data)) {
115
- localStorage?.removeItem(impersonatorAuthenticationDataStorageKey);
116
- }
117
- else {
118
- const json = JSON.stringify(data);
119
- localStorage?.setItem(impersonatorAuthenticationDataStorageKey, json);
120
- }
120
+ this.writeToStorage(impersonatorAuthenticationDataStorageKey, data);
121
121
  }
122
122
  /**
123
123
  * Get current token or throw if not available
@@ -142,7 +142,7 @@ let AuthenticationClientService = class AuthenticationClientService {
142
142
  }
143
143
  /** Whether a valid token is available (not undefined and not expired) */
144
144
  get hasValidToken() {
145
- return (this.token()?.exp ?? 0) > currentTimestampSeconds();
145
+ return (this.token()?.exp ?? 0) > this.estimatedServerTimestampSeconds();
146
146
  }
147
147
  constructor(initialAuthenticationData) {
148
148
  if (isUndefined(this.authenticationData)) {
@@ -195,26 +195,31 @@ let AuthenticationClientService = class AuthenticationClientService {
195
195
  if (isDefined(data)) {
196
196
  this.setAdditionalData(data);
197
197
  }
198
- const token = await this.client.login({ subject, secret, data: this.authenticationData });
198
+ const [token] = await Promise.all([
199
+ this.client.login({ subject, secret, data: this.authenticationData }),
200
+ this.syncClock(),
201
+ ]);
199
202
  this.setNewToken(token);
200
203
  }
201
204
  /**
202
- * Logout
205
+ * Logout from the current session.
206
+ * This will attempt to end the session on the server and then clear local credentials.
203
207
  */
204
208
  async logout() {
205
209
  try {
206
210
  await Promise.race([
207
211
  this.client.endSession(),
208
- timeout(150),
212
+ timeout(logoutTimeout),
209
213
  ]).catch((error) => this.logger.error(error));
210
214
  }
211
215
  finally {
216
+ // Always clear the local token, even if the server call fails.
212
217
  this.setNewToken(undefined);
213
218
  this.loggedOutBus.publishAndForget();
214
219
  }
215
220
  }
216
221
  /**
217
- * Force a refresh of the token
222
+ * Force an immediate refresh of the token.
218
223
  * @param data Additional authentication data
219
224
  */
220
225
  requestRefresh(data) {
@@ -224,7 +229,7 @@ let AuthenticationClientService = class AuthenticationClientService {
224
229
  this.forceRefreshToken.set();
225
230
  }
226
231
  /**
227
- * Refresh the token
232
+ * Refresh the token.
228
233
  * @param data Additional authentication data
229
234
  */
230
235
  async refresh(data) {
@@ -232,7 +237,10 @@ let AuthenticationClientService = class AuthenticationClientService {
232
237
  this.setAdditionalData(data);
233
238
  }
234
239
  try {
235
- const token = await this.client.refresh({ data: this.authenticationData });
240
+ const [token] = await Promise.all([
241
+ this.client.refresh({ data: this.authenticationData }),
242
+ this.syncClock(),
243
+ ]);
236
244
  this.setNewToken(token);
237
245
  }
238
246
  catch (error) {
@@ -241,12 +249,15 @@ let AuthenticationClientService = class AuthenticationClientService {
241
249
  }
242
250
  }
243
251
  /**
244
- * Impersonate a subject
252
+ * Impersonate a subject.
245
253
  * @param subject The subject to impersonate
246
- * @param data Additional authentication data
254
+ * @param data Additional authentication data for the impersonated session
247
255
  */
248
256
  async impersonate(subject, data) {
249
- await this.lock.use(10000, true, async () => {
257
+ if (this.impersonated()) {
258
+ throw new Error('Already impersonating. Please unimpersonate first.');
259
+ }
260
+ await this.lock.use(lockTimeout, true, async () => {
250
261
  this.impersonatorAuthenticationData = this.authenticationData;
251
262
  this.authenticationData = data;
252
263
  try {
@@ -254,17 +265,20 @@ let AuthenticationClientService = class AuthenticationClientService {
254
265
  this.setNewToken(token);
255
266
  }
256
267
  catch (error) {
268
+ // Rollback authentication data on failure
269
+ this.authenticationData = this.impersonatorAuthenticationData;
270
+ this.impersonatorAuthenticationData = undefined;
257
271
  await this.handleRefreshError(error);
258
272
  throw error;
259
273
  }
260
274
  });
261
275
  }
262
276
  /**
263
- * Unimpersonate
277
+ * End impersonation and return to the original user session.
264
278
  * @param data Additional authentication data. If not provided, the data from before impersonation is used.
265
279
  */
266
280
  async unimpersonate(data) {
267
- await this.lock.use(10000, true, async () => {
281
+ await this.lock.use(lockTimeout, true, async () => {
268
282
  const newData = data ?? this.impersonatorAuthenticationData;
269
283
  try {
270
284
  const token = await this.client.unimpersonate({ data: newData });
@@ -279,7 +293,16 @@ let AuthenticationClientService = class AuthenticationClientService {
279
293
  });
280
294
  }
281
295
  /**
282
- * Initialize a secret reset
296
+ * Change the secret for a subject.
297
+ * @param subject The subject to change the secret for
298
+ * @param currentSecret The current secret
299
+ * @param newSecret The new secret
300
+ */
301
+ async changeSecret(subject, currentSecret, newSecret) {
302
+ await this.client.changeSecret({ subject, currentSecret, newSecret });
303
+ }
304
+ /**
305
+ * Initialize a secret reset.
283
306
  * @param subject The subject to reset the secret for
284
307
  * @param data Additional data for secret reset
285
308
  */
@@ -287,7 +310,7 @@ let AuthenticationClientService = class AuthenticationClientService {
287
310
  await this.client.initSecretReset({ subject, data });
288
311
  }
289
312
  /**
290
- * Reset a secret
313
+ * Reset a secret using a reset token.
291
314
  * @param token The secret reset token
292
315
  * @param newSecret The new secret
293
316
  */
@@ -295,39 +318,30 @@ let AuthenticationClientService = class AuthenticationClientService {
295
318
  await this.client.resetSecret({ token, newSecret });
296
319
  }
297
320
  /**
298
- * Check a secret for requirements
321
+ * Check a secret for requirements.
299
322
  * @param secret The secret to check
300
323
  * @returns The result of the check
301
324
  */
302
325
  async checkSecret(secret) {
303
326
  return await this.client.checkSecret({ secret });
304
327
  }
305
- saveToken(token) {
306
- if (isNullOrUndefined(token)) {
307
- localStorage?.removeItem(tokenStorageKey);
308
- }
309
- else {
310
- const serialized = JSON.stringify(token);
311
- localStorage?.setItem(tokenStorageKey, serialized);
312
- }
313
- }
314
- loadToken() {
315
- const existingSerializedToken = localStorage?.getItem(tokenStorageKey);
316
- const token = isString(existingSerializedToken)
317
- ? JSON.parse(existingSerializedToken)
318
- : undefined;
319
- this.token.set(token);
320
- }
321
328
  setNewToken(token) {
322
329
  this.saveToken(token);
323
330
  this.token.set(token);
324
331
  this.tokenUpdateBus.publishAndForget(token);
325
332
  }
326
333
  async refreshLoop() {
334
+ if (this.isLoggedIn()) {
335
+ await this.syncClock();
336
+ }
327
337
  while (this.disposeToken.isUnset) {
328
338
  try {
339
+ // Use a non-blocking lock to ensure only one tab/instance runs the refresh logic at a time.
329
340
  await this.lock.use(0, false, async () => await this.refreshLoopIteration());
330
- await firstValueFrom(race([timer(2500), this.disposeToken, this.forceRefreshToken]));
341
+ // Calculate delay until the next refresh check.
342
+ // The buffer ensures we refresh *before* the token actually expires.
343
+ const delay = ((this.token()?.exp ?? 0) - this.estimatedServerTimestampSeconds() - refreshBufferSeconds) * millisecondsPerSecond;
344
+ await firstValueFrom(race([timer(delay), this.disposeToken, this.forceRefreshToken]));
331
345
  }
332
346
  catch {
333
347
  await firstValueFrom(race([timer(5000), this.disposeToken, this.forceRefreshToken]));
@@ -335,22 +349,71 @@ let AuthenticationClientService = class AuthenticationClientService {
335
349
  }
336
350
  }
337
351
  async refreshLoopIteration() {
352
+ // Wait for a token to be available or for the service to be disposed.
338
353
  const token = await firstValueFrom(race([this.definedToken$, this.disposeToken]));
339
354
  if (isUndefined(token)) {
340
355
  return;
341
356
  }
342
- if (this.forceRefreshToken.isSet || (currentTimestampSeconds() >= (token.exp - 60))) {
357
+ const needsRefresh = this.estimatedServerTimestampSeconds() >= (token.exp - refreshBufferSeconds);
358
+ if (this.forceRefreshToken.isSet || needsRefresh) {
343
359
  this.forceRefreshToken.unset();
344
- await this.refresh();
360
+ await this.refresh(); // Errors are caught by the outer loop
345
361
  }
346
362
  }
347
363
  async handleRefreshError(error) {
348
364
  this.logger.error(error);
349
365
  this.errorSubject.next(error);
350
- if ((error instanceof InvalidTokenError) || (error instanceof NotFoundError) || (error instanceof BadRequestError) || (error instanceof ForbiddenError) || (error instanceof NotSupportedError) || (error instanceof UnauthorizedError)) {
366
+ if (unrecoverableErrors.some((errorType) => error instanceof errorType)) {
351
367
  await this.logout();
352
368
  }
353
369
  }
370
+ estimatedServerTimestampSeconds() {
371
+ return currentTimestampSeconds() + this.clockOffset;
372
+ }
373
+ async syncClock() {
374
+ try {
375
+ const serverTimestamp = await this.client.timestamp();
376
+ this.clockOffset = serverTimestamp - currentTimestampSeconds();
377
+ }
378
+ catch (error) {
379
+ this.logger.warn(`Failed to synchronize clock with server: ${formatError(error)}`);
380
+ this.clockOffset = 0;
381
+ }
382
+ }
383
+ saveToken(token) {
384
+ this.writeToStorage(tokenStorageKey, token);
385
+ }
386
+ loadToken() {
387
+ const token = this.readFromStorage(tokenStorageKey);
388
+ this.token.set(token);
389
+ }
390
+ readFromStorage(key) {
391
+ try {
392
+ const serialized = localStorage?.getItem(key);
393
+ if (isNullOrUndefined(serialized)) {
394
+ return undefined;
395
+ }
396
+ return JSON.parse(serialized);
397
+ }
398
+ catch (error) {
399
+ this.logger.warn(`Failed to read and parse from localStorage key "${key}": ${formatError(error)}`);
400
+ return undefined;
401
+ }
402
+ }
403
+ writeToStorage(key, value) {
404
+ try {
405
+ if (isUndefined(value)) {
406
+ localStorage?.removeItem(key);
407
+ }
408
+ else {
409
+ const serialized = JSON.stringify(value);
410
+ localStorage?.setItem(key, serialized);
411
+ }
412
+ }
413
+ catch (error) {
414
+ this.logger.warn(`Failed to write to localStorage key "${key}": ${formatError(error)}`);
415
+ }
416
+ }
354
417
  };
355
418
  AuthenticationClientService = __decorate([
356
419
  Singleton(),
@@ -69,7 +69,7 @@ export declare class AuthenticationApiController<AdditionalTokenPayload extends
69
69
  checkSecret({ parameters }: ApiRequestContext<AuthenticationApiDefinition<AdditionalTokenPayload, AuthenticationData, AdditionalInitSecretResetData>, 'checkSecret'>): Promise<ApiServerResult<AuthenticationApiDefinition<AdditionalTokenPayload, AuthenticationData, AdditionalInitSecretResetData>, 'checkSecret'>>;
70
70
  /**
71
71
  * Get the current server timestamp.
72
- * @returns The current server timestamp.
72
+ * @returns The current server timestamp in seconds.
73
73
  */
74
74
  timestamp(): ApiServerResult<AuthenticationApiDefinition<AdditionalTokenPayload, AuthenticationData, AdditionalInitSecretResetData>, 'timestamp'>;
75
75
  protected getTokenResponse({ token, jsonToken, refreshToken, omitImpersonatorRefreshToken, impersonatorRefreshToken, impersonatorRefreshTokenExpiration }: TokenResult<AdditionalTokenPayload>): HttpServerResponse;
@@ -9,7 +9,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
9
9
  };
10
10
  import { apiController } from '../../api/server/index.js';
11
11
  import { HttpServerResponse } from '../../http/server/index.js';
12
- import { currentTimestamp } from '../../utils/date-time.js';
12
+ import { currentTimestampSeconds } from '../../utils/date-time.js';
13
13
  import { assertDefinedPass, isDefined } from '../../utils/type-guards.js';
14
14
  import { authenticationApiDefinition, getAuthenticationApiDefinition } from '../authentication.api.js';
15
15
  import { AuthenticationService } from './authentication.service.js';
@@ -138,10 +138,10 @@ let AuthenticationApiController = class AuthenticationApiController {
138
138
  }
139
139
  /**
140
140
  * Get the current server timestamp.
141
- * @returns The current server timestamp.
141
+ * @returns The current server timestamp in seconds.
142
142
  */
143
143
  timestamp() {
144
- return currentTimestamp();
144
+ return currentTimestampSeconds();
145
145
  }
146
146
  getTokenResponse({ token, jsonToken, refreshToken, omitImpersonatorRefreshToken, impersonatorRefreshToken, impersonatorRefreshTokenExpiration }) {
147
147
  const result = jsonToken.payload;
package/core.js CHANGED
@@ -5,7 +5,7 @@ import { LogLevel, Logger } from './logger/index.js';
5
5
  import { LOG_LEVEL } from './logger/tokens.js';
6
6
  import { initializeSignals, setProcessShutdownLogger } from './process-shutdown.js';
7
7
  import { timeout } from './utils/timing.js';
8
- import { assertDefinedPass, isDefined, isUndefined } from './utils/type-guards.js';
8
+ import { assertDefinedPass, isDefined } from './utils/type-guards.js';
9
9
  if (globalThis.tstdlLoaded == true) {
10
10
  console.error(new Error('tstdl seems to be loaded multiple times. This is likely an error as some modules won\'t work as intended this way.'));
11
11
  }
@@ -17,10 +17,7 @@ let _isDevMode = true;
17
17
  * @deprecated Usage of `getGlobalInjector` should be avoided. Use `Application` scoped injector instead.
18
18
  */
19
19
  export function getGlobalInjector() {
20
- if (isUndefined(globalInjector)) {
21
- globalInjector = new Injector('GlobalInjector');
22
- }
23
- return globalInjector;
20
+ return globalInjector ??= new Injector('GlobalInjector');
24
21
  }
25
22
  export function isDevMode() {
26
23
  return _isDevMode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tstdl/base",
3
- "version": "0.92.157",
3
+ "version": "0.92.159",
4
4
  "author": "Patrick Hein",
5
5
  "publishConfig": {
6
6
  "access": "public"