@tstdl/base 0.92.158 → 0.92.160

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.
@@ -83,7 +84,7 @@ export declare class AuthenticationClientService<AdditionalTokenPayload extends
83
84
  get definedSessionId(): string;
84
85
  /** Whether a valid token is available (not undefined and not expired) */
85
86
  get hasValidToken(): boolean;
86
- constructor(initialAuthenticationData: AuthenticationData | undefined);
87
+ constructor();
87
88
  /** @internal */
88
89
  [afterResolve](): void;
89
90
  /**
@@ -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
  }
@@ -7,9 +7,6 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
7
7
  var __metadata = (this && this.__metadata) || function (k, v) {
8
8
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
9
  };
10
- var __param = (this && this.__param) || function (paramIndex, decorator) {
11
- return function (target, key) { decorator(target, key, paramIndex); }
12
- };
13
10
  import { Subject, filter, firstValueFrom, race, timer } from 'rxjs';
14
11
  import { CancellationToken } from '../../cancellation/token.js';
15
12
  import { disposeAsync } from '../../disposable/index.js';
@@ -19,14 +16,16 @@ import { InvalidTokenError } from '../../errors/invalid-token.error.js';
19
16
  import { NotFoundError } from '../../errors/not-found.error.js';
20
17
  import { NotSupportedError } from '../../errors/not-supported.error.js';
21
18
  import { UnauthorizedError } from '../../errors/unauthorized.error.js';
22
- import { Inject, Optional, Singleton, afterResolve, inject } from '../../injector/index.js';
19
+ import { Singleton, afterResolve, inject } from '../../injector/index.js';
23
20
  import { Lock } from '../../lock/index.js';
24
21
  import { Logger } from '../../logger/index.js';
25
22
  import { MessageBus } from '../../message-bus/index.js';
26
23
  import { computed, signal, toObservable } from '../../signals/api.js';
27
24
  import { currentTimestampSeconds } from '../../utils/date-time.js';
25
+ import { formatError } from '../../utils/format-error.js';
28
26
  import { timeout } from '../../utils/timing.js';
29
- import { assertDefinedPass, isDefined, isNullOrUndefined, isString, isUndefined } from '../../utils/type-guards.js';
27
+ import { assertDefinedPass, isDefined, isNullOrUndefined, isUndefined } from '../../utils/type-guards.js';
28
+ import { millisecondsPerSecond } from '../../utils/units.js';
30
29
  import { AUTHENTICATION_API_CLIENT, INITIAL_AUTHENTICATION_DATA } from './tokens.js';
31
30
  const tokenStorageKey = 'AuthenticationService:token';
32
31
  const authenticationDataStorageKey = 'AuthenticationService:authentication-data';
@@ -35,6 +34,17 @@ const tokenUpdateBusName = 'AuthenticationService:tokenUpdate';
35
34
  const loggedOutBusName = 'AuthenticationService:loggedOut';
36
35
  const refreshLockResource = 'AuthenticationService:refresh';
37
36
  const localStorage = globalThis.localStorage;
37
+ const refreshBufferSeconds = 15;
38
+ const lockTimeout = 10000;
39
+ const logoutTimeout = 150;
40
+ const unrecoverableErrors = [
41
+ InvalidTokenError,
42
+ NotFoundError,
43
+ BadRequestError,
44
+ ForbiddenError,
45
+ NotSupportedError,
46
+ UnauthorizedError,
47
+ ];
38
48
  /**
39
49
  * Handles authentication on client side.
40
50
  *
@@ -58,6 +68,7 @@ let AuthenticationClientService = class AuthenticationClientService {
58
68
  lock = inject(Lock, refreshLockResource);
59
69
  logger = inject(Logger, 'AuthenticationService');
60
70
  disposeToken = new CancellationToken();
71
+ clockOffset = 0;
61
72
  /**
62
73
  * Observable for authentication errors.
63
74
  * Emits when a refresh fails.
@@ -80,7 +91,7 @@ let AuthenticationClientService = class AuthenticationClientService {
80
91
  /** Emits when token is available (not undefined) */
81
92
  definedToken$ = this.token$.pipe(filter(isDefined));
82
93
  /** Emits when a valid token is available (not undefined and not expired) */
83
- validToken$ = this.definedToken$.pipe(filter((token) => token.exp > currentTimestampSeconds()));
94
+ validToken$ = this.definedToken$.pipe(filter((token) => token.exp > this.estimatedServerTimestampSeconds()));
84
95
  /** Current subject */
85
96
  subject$ = toObservable(this.subject);
86
97
  /** Emits when subject is available */
@@ -94,30 +105,16 @@ let AuthenticationClientService = class AuthenticationClientService {
94
105
  /** Emits when the user logs out */
95
106
  loggedOut$ = this.loggedOutBus.allMessages$;
96
107
  get authenticationData() {
97
- const data = localStorage?.getItem(authenticationDataStorageKey);
98
- return isNullOrUndefined(data) ? undefined : JSON.parse(data);
108
+ return this.readFromStorage(authenticationDataStorageKey);
99
109
  }
100
110
  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
- }
111
+ this.writeToStorage(authenticationDataStorageKey, data);
108
112
  }
109
113
  get impersonatorAuthenticationData() {
110
- const data = localStorage?.getItem(impersonatorAuthenticationDataStorageKey);
111
- return isNullOrUndefined(data) ? undefined : JSON.parse(data);
114
+ return this.readFromStorage(impersonatorAuthenticationDataStorageKey);
112
115
  }
113
116
  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
- }
117
+ this.writeToStorage(impersonatorAuthenticationDataStorageKey, data);
121
118
  }
122
119
  /**
123
120
  * Get current token or throw if not available
@@ -142,11 +139,11 @@ let AuthenticationClientService = class AuthenticationClientService {
142
139
  }
143
140
  /** Whether a valid token is available (not undefined and not expired) */
144
141
  get hasValidToken() {
145
- return (this.token()?.exp ?? 0) > currentTimestampSeconds();
142
+ return (this.token()?.exp ?? 0) > this.estimatedServerTimestampSeconds();
146
143
  }
147
- constructor(initialAuthenticationData) {
144
+ constructor() {
148
145
  if (isUndefined(this.authenticationData)) {
149
- this.authenticationData = initialAuthenticationData;
146
+ this.authenticationData = inject(INITIAL_AUTHENTICATION_DATA, undefined, { optional: true });
150
147
  }
151
148
  }
152
149
  /** @internal */
@@ -195,26 +192,31 @@ let AuthenticationClientService = class AuthenticationClientService {
195
192
  if (isDefined(data)) {
196
193
  this.setAdditionalData(data);
197
194
  }
198
- const token = await this.client.login({ subject, secret, data: this.authenticationData });
195
+ const [token] = await Promise.all([
196
+ this.client.login({ subject, secret, data: this.authenticationData }),
197
+ this.syncClock(),
198
+ ]);
199
199
  this.setNewToken(token);
200
200
  }
201
201
  /**
202
- * Logout
202
+ * Logout from the current session.
203
+ * This will attempt to end the session on the server and then clear local credentials.
203
204
  */
204
205
  async logout() {
205
206
  try {
206
207
  await Promise.race([
207
208
  this.client.endSession(),
208
- timeout(150),
209
+ timeout(logoutTimeout),
209
210
  ]).catch((error) => this.logger.error(error));
210
211
  }
211
212
  finally {
213
+ // Always clear the local token, even if the server call fails.
212
214
  this.setNewToken(undefined);
213
215
  this.loggedOutBus.publishAndForget();
214
216
  }
215
217
  }
216
218
  /**
217
- * Force a refresh of the token
219
+ * Force an immediate refresh of the token.
218
220
  * @param data Additional authentication data
219
221
  */
220
222
  requestRefresh(data) {
@@ -224,7 +226,7 @@ let AuthenticationClientService = class AuthenticationClientService {
224
226
  this.forceRefreshToken.set();
225
227
  }
226
228
  /**
227
- * Refresh the token
229
+ * Refresh the token.
228
230
  * @param data Additional authentication data
229
231
  */
230
232
  async refresh(data) {
@@ -232,7 +234,10 @@ let AuthenticationClientService = class AuthenticationClientService {
232
234
  this.setAdditionalData(data);
233
235
  }
234
236
  try {
235
- const token = await this.client.refresh({ data: this.authenticationData });
237
+ const [token] = await Promise.all([
238
+ this.client.refresh({ data: this.authenticationData }),
239
+ this.syncClock(),
240
+ ]);
236
241
  this.setNewToken(token);
237
242
  }
238
243
  catch (error) {
@@ -241,12 +246,15 @@ let AuthenticationClientService = class AuthenticationClientService {
241
246
  }
242
247
  }
243
248
  /**
244
- * Impersonate a subject
249
+ * Impersonate a subject.
245
250
  * @param subject The subject to impersonate
246
- * @param data Additional authentication data
251
+ * @param data Additional authentication data for the impersonated session
247
252
  */
248
253
  async impersonate(subject, data) {
249
- await this.lock.use(10000, true, async () => {
254
+ if (this.impersonated()) {
255
+ throw new Error('Already impersonating. Please unimpersonate first.');
256
+ }
257
+ await this.lock.use(lockTimeout, true, async () => {
250
258
  this.impersonatorAuthenticationData = this.authenticationData;
251
259
  this.authenticationData = data;
252
260
  try {
@@ -254,17 +262,20 @@ let AuthenticationClientService = class AuthenticationClientService {
254
262
  this.setNewToken(token);
255
263
  }
256
264
  catch (error) {
265
+ // Rollback authentication data on failure
266
+ this.authenticationData = this.impersonatorAuthenticationData;
267
+ this.impersonatorAuthenticationData = undefined;
257
268
  await this.handleRefreshError(error);
258
269
  throw error;
259
270
  }
260
271
  });
261
272
  }
262
273
  /**
263
- * Unimpersonate
274
+ * End impersonation and return to the original user session.
264
275
  * @param data Additional authentication data. If not provided, the data from before impersonation is used.
265
276
  */
266
277
  async unimpersonate(data) {
267
- await this.lock.use(10000, true, async () => {
278
+ await this.lock.use(lockTimeout, true, async () => {
268
279
  const newData = data ?? this.impersonatorAuthenticationData;
269
280
  try {
270
281
  const token = await this.client.unimpersonate({ data: newData });
@@ -278,11 +289,17 @@ let AuthenticationClientService = class AuthenticationClientService {
278
289
  }
279
290
  });
280
291
  }
292
+ /**
293
+ * Change the secret for a subject.
294
+ * @param subject The subject to change the secret for
295
+ * @param currentSecret The current secret
296
+ * @param newSecret The new secret
297
+ */
281
298
  async changeSecret(subject, currentSecret, newSecret) {
282
299
  await this.client.changeSecret({ subject, currentSecret, newSecret });
283
300
  }
284
301
  /**
285
- * Initialize a secret reset
302
+ * Initialize a secret reset.
286
303
  * @param subject The subject to reset the secret for
287
304
  * @param data Additional data for secret reset
288
305
  */
@@ -290,7 +307,7 @@ let AuthenticationClientService = class AuthenticationClientService {
290
307
  await this.client.initSecretReset({ subject, data });
291
308
  }
292
309
  /**
293
- * Reset a secret
310
+ * Reset a secret using a reset token.
294
311
  * @param token The secret reset token
295
312
  * @param newSecret The new secret
296
313
  */
@@ -298,39 +315,30 @@ let AuthenticationClientService = class AuthenticationClientService {
298
315
  await this.client.resetSecret({ token, newSecret });
299
316
  }
300
317
  /**
301
- * Check a secret for requirements
318
+ * Check a secret for requirements.
302
319
  * @param secret The secret to check
303
320
  * @returns The result of the check
304
321
  */
305
322
  async checkSecret(secret) {
306
323
  return await this.client.checkSecret({ secret });
307
324
  }
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
325
  setNewToken(token) {
325
326
  this.saveToken(token);
326
327
  this.token.set(token);
327
328
  this.tokenUpdateBus.publishAndForget(token);
328
329
  }
329
330
  async refreshLoop() {
331
+ if (this.isLoggedIn()) {
332
+ await this.syncClock();
333
+ }
330
334
  while (this.disposeToken.isUnset) {
331
335
  try {
336
+ // Use a non-blocking lock to ensure only one tab/instance runs the refresh logic at a time.
332
337
  await this.lock.use(0, false, async () => await this.refreshLoopIteration());
333
- await firstValueFrom(race([timer(2500), this.disposeToken, this.forceRefreshToken]));
338
+ // Calculate delay until the next refresh check.
339
+ // The buffer ensures we refresh *before* the token actually expires.
340
+ const delay = ((this.token()?.exp ?? 0) - this.estimatedServerTimestampSeconds() - refreshBufferSeconds) * millisecondsPerSecond;
341
+ await firstValueFrom(race([timer(delay), this.disposeToken, this.forceRefreshToken]));
334
342
  }
335
343
  catch {
336
344
  await firstValueFrom(race([timer(5000), this.disposeToken, this.forceRefreshToken]));
@@ -338,27 +346,74 @@ let AuthenticationClientService = class AuthenticationClientService {
338
346
  }
339
347
  }
340
348
  async refreshLoopIteration() {
349
+ // Wait for a token to be available or for the service to be disposed.
341
350
  const token = await firstValueFrom(race([this.definedToken$, this.disposeToken]));
342
351
  if (isUndefined(token)) {
343
352
  return;
344
353
  }
345
- if (this.forceRefreshToken.isSet || (currentTimestampSeconds() >= (token.exp - 60))) {
354
+ const needsRefresh = this.estimatedServerTimestampSeconds() >= (token.exp - refreshBufferSeconds);
355
+ if (this.forceRefreshToken.isSet || needsRefresh) {
346
356
  this.forceRefreshToken.unset();
347
- await this.refresh();
357
+ await this.refresh(); // Errors are caught by the outer loop
348
358
  }
349
359
  }
350
360
  async handleRefreshError(error) {
351
361
  this.logger.error(error);
352
362
  this.errorSubject.next(error);
353
- if ((error instanceof InvalidTokenError) || (error instanceof NotFoundError) || (error instanceof BadRequestError) || (error instanceof ForbiddenError) || (error instanceof NotSupportedError) || (error instanceof UnauthorizedError)) {
363
+ if (unrecoverableErrors.some((errorType) => error instanceof errorType)) {
354
364
  await this.logout();
355
365
  }
356
366
  }
367
+ estimatedServerTimestampSeconds() {
368
+ return currentTimestampSeconds() + this.clockOffset;
369
+ }
370
+ async syncClock() {
371
+ try {
372
+ const serverTimestamp = await this.client.timestamp();
373
+ this.clockOffset = serverTimestamp - currentTimestampSeconds();
374
+ }
375
+ catch (error) {
376
+ this.logger.warn(`Failed to synchronize clock with server: ${formatError(error)}`);
377
+ this.clockOffset = 0;
378
+ }
379
+ }
380
+ saveToken(token) {
381
+ this.writeToStorage(tokenStorageKey, token);
382
+ }
383
+ loadToken() {
384
+ const token = this.readFromStorage(tokenStorageKey);
385
+ this.token.set(token);
386
+ }
387
+ readFromStorage(key) {
388
+ try {
389
+ const serialized = localStorage?.getItem(key);
390
+ if (isNullOrUndefined(serialized)) {
391
+ return undefined;
392
+ }
393
+ return JSON.parse(serialized);
394
+ }
395
+ catch (error) {
396
+ this.logger.warn(`Failed to read and parse from localStorage key "${key}": ${formatError(error)}`);
397
+ return undefined;
398
+ }
399
+ }
400
+ writeToStorage(key, value) {
401
+ try {
402
+ if (isUndefined(value)) {
403
+ localStorage?.removeItem(key);
404
+ }
405
+ else {
406
+ const serialized = JSON.stringify(value);
407
+ localStorage?.setItem(key, serialized);
408
+ }
409
+ }
410
+ catch (error) {
411
+ this.logger.warn(`Failed to write to localStorage key "${key}": ${formatError(error)}`);
412
+ }
413
+ }
357
414
  };
358
415
  AuthenticationClientService = __decorate([
359
416
  Singleton(),
360
- __param(0, Inject(INITIAL_AUTHENTICATION_DATA)),
361
- __param(0, Optional()),
362
- __metadata("design:paramtypes", [Object])
417
+ __metadata("design:paramtypes", [])
363
418
  ], AuthenticationClientService);
364
419
  export { AuthenticationClientService };
@@ -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.160",
4
4
  "author": "Patrick Hein",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -80,6 +80,7 @@
80
80
  "./jsx": "./jsx/index.js",
81
81
  "./key-value-store": "./key-value-store/index.js",
82
82
  "./key-value-store/mongo": "./key-value-store/mongo/index.js",
83
+ "./key-value-store/postgres": "./key-value-store/postgres/index.js",
83
84
  "./lock": "./lock/index.js",
84
85
  "./lock/mongo": "./lock/mongo/index.js",
85
86
  "./lock/web": "./lock/web/index.js",