@tstdl/base 0.92.158 → 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,53 +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>;
142
+ /**
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
+ */
140
148
  changeSecret(subject: string, currentSecret: string, newSecret: string): Promise<void>;
141
149
  /**
142
- * Initialize a secret reset
150
+ * Initialize a secret reset.
143
151
  * @param subject The subject to reset the secret for
144
152
  * @param data Additional data for secret reset
145
153
  */
146
154
  initResetSecret(subject: string, data: AdditionalInitSecretResetData): Promise<void>;
147
155
  /**
148
- * Reset a secret
156
+ * Reset a secret using a reset token.
149
157
  * @param token The secret reset token
150
158
  * @param newSecret The new secret
151
159
  */
152
160
  resetSecret(token: string, newSecret: string): Promise<void>;
153
161
  /**
154
- * Check a secret for requirements
162
+ * Check a secret for requirements.
155
163
  * @param secret The secret to check
156
164
  * @returns The result of the check
157
165
  */
158
166
  checkSecret(secret: string): Promise<SecretCheckResult>;
159
- private saveToken;
160
- private loadToken;
161
167
  private setNewToken;
162
168
  private refreshLoop;
163
169
  private refreshLoopIteration;
164
170
  private handleRefreshError;
171
+ private estimatedServerTimestampSeconds;
172
+ private syncClock;
173
+ private saveToken;
174
+ private loadToken;
175
+ private readFromStorage;
176
+ private writeToStorage;
165
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 });
@@ -278,11 +292,17 @@ let AuthenticationClientService = class AuthenticationClientService {
278
292
  }
279
293
  });
280
294
  }
295
+ /**
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
+ */
281
301
  async changeSecret(subject, currentSecret, newSecret) {
282
302
  await this.client.changeSecret({ subject, currentSecret, newSecret });
283
303
  }
284
304
  /**
285
- * Initialize a secret reset
305
+ * Initialize a secret reset.
286
306
  * @param subject The subject to reset the secret for
287
307
  * @param data Additional data for secret reset
288
308
  */
@@ -290,7 +310,7 @@ let AuthenticationClientService = class AuthenticationClientService {
290
310
  await this.client.initSecretReset({ subject, data });
291
311
  }
292
312
  /**
293
- * Reset a secret
313
+ * Reset a secret using a reset token.
294
314
  * @param token The secret reset token
295
315
  * @param newSecret The new secret
296
316
  */
@@ -298,39 +318,30 @@ let AuthenticationClientService = class AuthenticationClientService {
298
318
  await this.client.resetSecret({ token, newSecret });
299
319
  }
300
320
  /**
301
- * Check a secret for requirements
321
+ * Check a secret for requirements.
302
322
  * @param secret The secret to check
303
323
  * @returns The result of the check
304
324
  */
305
325
  async checkSecret(secret) {
306
326
  return await this.client.checkSecret({ secret });
307
327
  }
308
- saveToken(token) {
309
- if (isNullOrUndefined(token)) {
310
- localStorage?.removeItem(tokenStorageKey);
311
- }
312
- else {
313
- const serialized = JSON.stringify(token);
314
- localStorage?.setItem(tokenStorageKey, serialized);
315
- }
316
- }
317
- loadToken() {
318
- const existingSerializedToken = localStorage?.getItem(tokenStorageKey);
319
- const token = isString(existingSerializedToken)
320
- ? JSON.parse(existingSerializedToken)
321
- : undefined;
322
- this.token.set(token);
323
- }
324
328
  setNewToken(token) {
325
329
  this.saveToken(token);
326
330
  this.token.set(token);
327
331
  this.tokenUpdateBus.publishAndForget(token);
328
332
  }
329
333
  async refreshLoop() {
334
+ if (this.isLoggedIn()) {
335
+ await this.syncClock();
336
+ }
330
337
  while (this.disposeToken.isUnset) {
331
338
  try {
339
+ // Use a non-blocking lock to ensure only one tab/instance runs the refresh logic at a time.
332
340
  await this.lock.use(0, false, async () => await this.refreshLoopIteration());
333
- 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]));
334
345
  }
335
346
  catch {
336
347
  await firstValueFrom(race([timer(5000), this.disposeToken, this.forceRefreshToken]));
@@ -338,22 +349,71 @@ let AuthenticationClientService = class AuthenticationClientService {
338
349
  }
339
350
  }
340
351
  async refreshLoopIteration() {
352
+ // Wait for a token to be available or for the service to be disposed.
341
353
  const token = await firstValueFrom(race([this.definedToken$, this.disposeToken]));
342
354
  if (isUndefined(token)) {
343
355
  return;
344
356
  }
345
- if (this.forceRefreshToken.isSet || (currentTimestampSeconds() >= (token.exp - 60))) {
357
+ const needsRefresh = this.estimatedServerTimestampSeconds() >= (token.exp - refreshBufferSeconds);
358
+ if (this.forceRefreshToken.isSet || needsRefresh) {
346
359
  this.forceRefreshToken.unset();
347
- await this.refresh();
360
+ await this.refresh(); // Errors are caught by the outer loop
348
361
  }
349
362
  }
350
363
  async handleRefreshError(error) {
351
364
  this.logger.error(error);
352
365
  this.errorSubject.next(error);
353
- 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)) {
354
367
  await this.logout();
355
368
  }
356
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
+ }
357
417
  };
358
418
  AuthenticationClientService = __decorate([
359
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tstdl/base",
3
- "version": "0.92.158",
3
+ "version": "0.92.159",
4
4
  "author": "Patrick Hein",
5
5
  "publishConfig": {
6
6
  "access": "public"