@tstdl/base 0.93.186 → 0.93.188

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.
Files changed (39) hide show
  1. package/ai/genkit/multi-region.plugin.js +14 -11
  2. package/ai/genkit/types.d.ts +1 -1
  3. package/ai/genkit/types.js +1 -1
  4. package/authentication/client/authentication.service.js +40 -15
  5. package/authentication/models/authentication-totp-recovery-code.model.js +3 -2
  6. package/authentication/server/authentication.api-controller.d.ts +1 -0
  7. package/authentication/server/authentication.api-controller.js +9 -2
  8. package/authentication/server/authentication.service.js +108 -91
  9. package/authentication/server/drizzle/{0000_odd_echo.sql → 0000_dry_stepford_cuckoos.sql} +2 -1
  10. package/authentication/server/drizzle/meta/0000_snapshot.json +24 -2
  11. package/authentication/server/drizzle/meta/_journal.json +2 -2
  12. package/circuit-breaker/circuit-breaker.d.ts +22 -10
  13. package/circuit-breaker/postgres/circuit-breaker.d.ts +5 -4
  14. package/circuit-breaker/postgres/circuit-breaker.js +21 -19
  15. package/circuit-breaker/postgres/drizzle/{0000_same_captain_cross.sql → 0000_dapper_hercules.sql} +3 -2
  16. package/circuit-breaker/postgres/drizzle/meta/0000_snapshot.json +13 -6
  17. package/circuit-breaker/postgres/drizzle/meta/_journal.json +2 -2
  18. package/circuit-breaker/postgres/model.d.ts +2 -1
  19. package/circuit-breaker/postgres/model.js +9 -4
  20. package/circuit-breaker/postgres/provider.d.ts +1 -1
  21. package/circuit-breaker/postgres/provider.js +2 -2
  22. package/circuit-breaker/provider.d.ts +6 -1
  23. package/orm/sqls/sqls.d.ts +5 -3
  24. package/orm/sqls/sqls.js +5 -3
  25. package/package.json +4 -4
  26. package/rate-limit/postgres/drizzle/{0000_serious_sauron.sql → 0000_previous_zeigeist.sql} +2 -1
  27. package/rate-limit/postgres/drizzle/meta/0000_snapshot.json +10 -3
  28. package/rate-limit/postgres/drizzle/meta/_journal.json +2 -2
  29. package/rate-limit/postgres/postgres-rate-limiter.d.ts +1 -0
  30. package/rate-limit/postgres/postgres-rate-limiter.js +3 -2
  31. package/rate-limit/postgres/rate-limit.model.d.ts +1 -0
  32. package/rate-limit/postgres/rate-limit.model.js +6 -1
  33. package/rate-limit/postgres/rate-limiter.provider.d.ts +1 -1
  34. package/rate-limit/postgres/rate-limiter.provider.js +2 -2
  35. package/rate-limit/provider.d.ts +3 -3
  36. package/rate-limit/rate-limiter.d.ts +2 -1
  37. package/signals/operators/derive-async.js +11 -6
  38. package/task-queue/postgres/task-queue.js +8 -8
  39. package/testing/integration-setup.js +4 -0
@@ -14,16 +14,19 @@ export function vertexAiMultiLocation(options) {
14
14
  }
15
15
  const tokenLimitThreshold = options.tokenLimitThreshold ?? defaultTokenLimitThreshold;
16
16
  const locationConfigs = options.locations.map((location) => {
17
- const circuitBreakerKey = `genkit:vertex-ai:location:${location}`;
18
- const tokenLimitCircuitBreakerKey = `${circuitBreakerKey}:token-limit`;
17
+ const circuitBreakerNamespace = `genkit:vertex-ai:location`;
18
+ const resource = location;
19
+ const tokenLimitResource = `${location}:token-limit`;
19
20
  return {
20
21
  location,
21
- circuitBreaker: options.circuitBreakerProvider.provide(circuitBreakerKey, {
22
+ resource,
23
+ tokenLimitResource,
24
+ circuitBreaker: options.circuitBreakerProvider.provide(circuitBreakerNamespace, {
22
25
  threshold: 1,
23
26
  resetTimeout: 30 * millisecondsPerSecond,
24
27
  ...options.circuitBreakerConfig,
25
28
  }),
26
- tokenLimitCircuitBreaker: options.circuitBreakerProvider.provide(tokenLimitCircuitBreakerKey, {
29
+ tokenLimitCircuitBreaker: options.circuitBreakerProvider.provide(circuitBreakerNamespace, {
27
30
  threshold: 1,
28
31
  resetTimeout: 15 * millisecondsPerMinute,
29
32
  ...options.tokenLimitCircuitBreakerConfig,
@@ -48,15 +51,15 @@ export function vertexAiMultiLocation(options) {
48
51
  let lastError;
49
52
  let isLargeRequest = false;
50
53
  const skippedLocations = [];
51
- for (const { location, circuitBreaker, tokenLimitCircuitBreaker } of shuffledConfigs) {
52
- const check = await circuitBreaker.check();
54
+ for (const { location, resource, tokenLimitResource, circuitBreaker, tokenLimitCircuitBreaker } of shuffledConfigs) {
55
+ const check = await circuitBreaker.check(resource);
53
56
  if (!check.allowed) {
54
57
  options.logger.warn(`Location ${location} is currently unhealthy. Skipping...`);
55
58
  skippedLocations.push({ location, reason: 'unhealthy' });
56
59
  continue;
57
60
  }
58
61
  if (isLargeRequest) {
59
- const tokenCheck = await tokenLimitCircuitBreaker.check();
62
+ const tokenCheck = await tokenLimitCircuitBreaker.check(tokenLimitResource);
60
63
  if (!tokenCheck.allowed) {
61
64
  options.logger.warn(`Location ${location} is known to have a low token limit. Skipping for this large request...`);
62
65
  skippedLocations.push({ location, reason: 'known to have low token limits' });
@@ -73,10 +76,10 @@ export function vertexAiMultiLocation(options) {
73
76
  }, {
74
77
  onChunk: streamingCallback,
75
78
  });
76
- await circuitBreaker.recordSuccess();
79
+ await circuitBreaker.recordSuccess(resource);
77
80
  const isLargeSuccess = isLargeRequest || ((result.usage?.inputTokens ?? 0) > tokenLimitThreshold);
78
81
  if (isLargeSuccess) {
79
- await tokenLimitCircuitBreaker.recordSuccess();
82
+ await tokenLimitCircuitBreaker.recordSuccess(tokenLimitResource);
80
83
  }
81
84
  return result;
82
85
  }
@@ -93,11 +96,11 @@ export function vertexAiMultiLocation(options) {
93
96
  if (isTokenLimitError) {
94
97
  options.logger.warn(`Location ${location} responded with token limit error. Trying next location...`);
95
98
  isLargeRequest = true;
96
- await tokenLimitCircuitBreaker.recordFailure();
99
+ await tokenLimitCircuitBreaker.recordFailure(tokenLimitResource);
97
100
  }
98
101
  else {
99
102
  options.logger.warn(`Location ${location} responded with ${error.status}. Tripping circuit breaker and trying next location...`);
100
- await circuitBreaker.recordFailure();
103
+ await circuitBreaker.recordFailure(resource);
101
104
  }
102
105
  }
103
106
  }
@@ -430,7 +430,7 @@ export declare abstract class VertexAiMultiLocationOptions {
430
430
  /**
431
431
  * Threshold of input tokens after which a request is considered large.
432
432
  * A successful request with more tokens than this threshold will record a success for the token limit circuit breaker.
433
- * Defaults to 131,072.
433
+ * Defaults to 131_072.
434
434
  */
435
435
  tokenLimitThreshold?: number;
436
436
  }
@@ -16,7 +16,7 @@ export class VertexAiMultiLocationOptions {
16
16
  /**
17
17
  * Threshold of input tokens after which a request is considered large.
18
18
  * A successful request with more tokens than this threshold will record a success for the token limit circuit breaker.
19
- * Defaults to 131,072.
19
+ * Defaults to 131_072.
20
20
  */
21
21
  tokenLimitThreshold;
22
22
  }
@@ -7,7 +7,7 @@ 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
- import { Subject, filter, firstValueFrom, from, race, takeUntil, timer } from 'rxjs';
10
+ import { Subject, filter, firstValueFrom, from, race, takeUntil, tap, timer } from 'rxjs';
11
11
  import { CancellationSignal } from '../../cancellation/token.js';
12
12
  import { BadRequestError } from '../../errors/bad-request.error.js';
13
13
  import { ForbiddenError } from '../../errors/forbidden.error.js';
@@ -204,7 +204,7 @@ let AuthenticationClientService = class AuthenticationClientService {
204
204
  this.loadToken();
205
205
  this.tokenUpdateBus.messages$
206
206
  .pipe(takeUntil(this.disposeSignal))
207
- .subscribe((token) => this.token.set(token));
207
+ .subscribe(() => this.loadToken());
208
208
  this.refreshLoopPromise ??= this.refreshLoop();
209
209
  }
210
210
  /** @internal */
@@ -500,33 +500,46 @@ let AuthenticationClientService = class AuthenticationClientService {
500
500
  this.tokenUpdateBus.publishAndForget(token);
501
501
  }
502
502
  async refreshLoop() {
503
+ this.logger.trace('[refreshLoop] Starting refresh loop...');
503
504
  if (this.isLoggedIn()) {
505
+ this.logger.trace('[refreshLoop] User is logged in, syncing clock...');
504
506
  await this.syncClock();
505
507
  }
506
508
  // Helper to sleep until the delay passes OR a vital state change occurs
507
- const waitForNextAction = async (delayMs, referenceExp) => await firstValueFrom(race([
508
- timer(Math.max(10, delayMs)),
509
- from(this.disposeSignal),
510
- this.token$.pipe(filter((t) => t?.exp !== referenceExp)),
511
- this.forceRefreshSubject,
512
- ]), { defaultValue: undefined });
509
+ const waitForNextAction = async (delayMs) => {
510
+ this.logger.trace(`[waitForNextAction] Waiting ${delayMs}ms for next action...`);
511
+ return await firstValueFrom(race([
512
+ timer(Math.max(10, delayMs)).pipe(tap(() => this.logger.trace('[waitForNextAction] Timer expired'))),
513
+ from(this.disposeSignal).pipe(tap(() => this.logger.trace('[waitForNextAction] Dispose signal received'))),
514
+ this.tokenUpdateBus.allMessages$.pipe(tap(() => this.logger.trace('[waitForNextAction] Token update message received'))),
515
+ this.forceRefreshSubject.pipe(tap(() => this.logger.trace('[waitForNextAction] Force refresh requested'))),
516
+ ]), { defaultValue: undefined });
517
+ };
513
518
  while (this.disposeSignal.isUnset) {
514
519
  const token = this.token();
515
520
  // 1. Wait for login/token if none is present
516
521
  if (isUndefined(token)) {
517
- await firstValueFrom(race([this.definedToken$, from(this.disposeSignal)]), { defaultValue: undefined });
522
+ this.logger.trace('[refreshLoop] No token found, waiting for login...');
523
+ await firstValueFrom(race([
524
+ this.definedToken$.pipe(tap(() => this.logger.trace('[refreshLoop] Token became defined'))),
525
+ from(this.disposeSignal).pipe(tap(() => this.logger.trace('[refreshLoop] Dispose signal received'))),
526
+ ]), { defaultValue: undefined });
518
527
  continue;
519
528
  }
520
529
  try {
521
530
  const now = this.estimatedServerTimestampSeconds();
522
531
  const buffer = calculateRefreshBufferSeconds(token);
523
532
  const needsRefresh = this.forceRefreshRequested() || (now >= token.exp - buffer);
533
+ this.logger.trace(`[refreshLoop] Checking token refresh status: now=${now}, exp=${token.exp}, buffer=${buffer}, needsRefresh=${needsRefresh}, force=${this.forceRefreshRequested()}`);
524
534
  // 2. Handle token refresh
525
535
  if (needsRefresh) {
536
+ this.logger.trace('[refreshLoop] Attempting to acquire refresh lock...');
526
537
  const lockResult = await this.lock.tryUse(undefined, async () => {
538
+ this.logger.trace('[refreshLoop] Refresh lock acquired, evaluating if refresh is still needed...');
527
539
  this.loadToken();
528
540
  const currentToken = this.token();
529
541
  if (isUndefined(currentToken)) {
542
+ this.logger.trace('[refreshLoop] Token became undefined while waiting for lock, skipping refresh.');
530
543
  return;
531
544
  }
532
545
  // Passive Sync: Verify if refresh is still needed once lock is acquired
@@ -534,15 +547,22 @@ let AuthenticationClientService = class AuthenticationClientService {
534
547
  const currentBuffer = calculateRefreshBufferSeconds(currentToken);
535
548
  const stillNeedsRefresh = this.forceRefreshRequested() || (currentNow >= currentToken.exp - currentBuffer);
536
549
  if (stillNeedsRefresh) {
550
+ this.logger.trace('[refreshLoop] Refresh is still needed, performing refresh...');
537
551
  await this.refresh();
538
552
  this.forceRefreshRequested.set(false);
553
+ this.logger.trace('[refreshLoop] Refresh completed successfully.');
554
+ }
555
+ else {
556
+ this.logger.trace('[refreshLoop] Token already refreshed by another instance, skipping.');
539
557
  }
540
558
  });
541
559
  // If lock is held by another instance/tab, wait briefly for it to finish (passive sync)
542
560
  if (!lockResult.success) {
543
- await waitForNextAction(5000, token.exp);
561
+ this.logger.trace('[refreshLoop] Refresh lock held by another instance, waiting 5000ms for passive sync...');
562
+ await waitForNextAction(5000);
544
563
  // If another tab successfully refreshed, the expiration will have changed
545
564
  if (this.token()?.exp !== token.exp) {
565
+ this.logger.trace('[refreshLoop] Token refreshed by another instance (expiration changed), clearing force refresh flag.');
546
566
  this.forceRefreshRequested.set(false);
547
567
  }
548
568
  }
@@ -553,26 +573,31 @@ let AuthenticationClientService = class AuthenticationClientService {
553
573
  const newBuffer = calculateRefreshBufferSeconds(newToken);
554
574
  const stillNeedsRefresh = this.forceRefreshRequested() || (newNow >= newToken.exp - newBuffer);
555
575
  if (stillNeedsRefresh) {
556
- this.logger.warn('Token still needs refresh after attempt. Waiting briefly to avoid tight loop.');
557
- await waitForNextAction(refreshLoopTightLoopDelay, newToken.exp);
576
+ this.logger.warn('[refreshLoop] Token still needs refresh after attempt. Waiting briefly to avoid tight loop.');
577
+ await waitForNextAction(refreshLoopTightLoopDelay);
558
578
  }
559
579
  }
560
- await waitForNextAction(100, newToken?.exp);
580
+ this.logger.trace('[refreshLoop] Completing refresh evaluation cycle, waiting 100ms...');
581
+ await waitForNextAction(100);
561
582
  continue; // Re-evaluate the loop with the newly refreshed (or synced) token
562
583
  }
563
584
  // 3. Calculate delay and sleep until the next scheduled refresh window
564
585
  const timeUntilRefreshMs = (token.exp - this.estimatedServerTimestampSeconds() - buffer) * millisecondsPerSecond;
565
586
  let delay = clamp(timeUntilRefreshMs, minRefreshDelay, maxRefreshDelay);
566
587
  if (Number.isNaN(delay)) {
588
+ this.logger.trace(`[refreshLoop] Calculated delay is NaN, using minRefreshDelay (${minRefreshDelay}ms)`);
567
589
  delay = minRefreshDelay;
568
590
  }
569
- await waitForNextAction(delay, token.exp);
591
+ this.logger.trace(`[refreshLoop] Scheduling next refresh in ${delay}ms...`);
592
+ await waitForNextAction(delay);
570
593
  }
571
594
  catch (error) {
572
595
  this.logger.error(error);
573
- await waitForNextAction(refreshLoopTightLoopDelay, token.exp);
596
+ this.logger.trace(`[refreshLoop] Refresh loop encountered error, waiting ${refreshLoopTightLoopDelay}ms before retry...`);
597
+ await waitForNextAction(refreshLoopTightLoopDelay);
574
598
  }
575
599
  }
600
+ this.logger.trace('[refreshLoop] Refresh loop exited (dispose signal set).');
576
601
  }
577
602
  async handleRefreshError(error) {
578
603
  this.logger.error(error);
@@ -7,7 +7,7 @@ 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
- import { Table, TenantEntity, TenantReference, TimestampProperty, UuidProperty } from '../../orm/index.js';
10
+ import { Index, Table, TenantEntity, TenantReference, TimestampProperty, UuidProperty } from '../../orm/index.js';
11
11
  import { Uint8ArrayProperty } from '../../schema/index.js';
12
12
  import { AuthenticationTotp } from './authentication-totp.model.js';
13
13
  let AuthenticationTotpRecoveryCode = class AuthenticationTotpRecoveryCode extends TenantEntity {
@@ -29,6 +29,7 @@ __decorate([
29
29
  __metadata("design:type", Object)
30
30
  ], AuthenticationTotpRecoveryCode.prototype, "usedTimestamp", void 0);
31
31
  AuthenticationTotpRecoveryCode = __decorate([
32
- Table('totp_recovery_code', { schema: 'authentication' })
32
+ Table('totp_recovery_code', { schema: 'authentication' }),
33
+ Index(['tenantId', 'totpId'])
33
34
  ], AuthenticationTotpRecoveryCode);
34
35
  export { AuthenticationTotpRecoveryCode };
@@ -99,6 +99,7 @@ export declare class AuthenticationApiController<AdditionalTokenPayload extends
99
99
  */
100
100
  invalidateAllOtherSessions({ getToken, getAuditor }: ApiRequestContext<AuthenticationApiDefinition<AdditionalTokenPayload, AuthenticationData, AdditionalInitPasswordResetData>, 'invalidateAllOtherSessions'>): Promise<ApiServerResult<AuthenticationApiDefinition<AdditionalTokenPayload, AuthenticationData, AdditionalInitPasswordResetData>, 'invalidateAllOtherSessions'>>;
101
101
  protected enforceRateLimit(ip: string, subjectResource: string, auditor: Auditor, targetId: string, action: string): Promise<void>;
102
+ protected refundRateLimit(ip: string, subjectResource: string): Promise<void>;
102
103
  protected getTokenResponse<T>(result: TokenResult<AdditionalTokenPayload>, body: NoInfer<T>): HttpServerResponse;
103
104
  protected getLoginResponse(result: LoginResult<AdditionalTokenPayload>): ApiServerResult<AuthenticationApiDefinition<AdditionalTokenPayload, AuthenticationData, AdditionalInitPasswordResetData>, 'login'>;
104
105
  }
@@ -35,12 +35,12 @@ let AuthenticationApiController = AuthenticationApiController_1 = class Authenti
35
35
  authenticationService = inject((AuthenticationService));
36
36
  options = inject(AuthenticationServiceOptions, undefined, { optional: true }) ?? {};
37
37
  subjectRateLimiter = inject(RateLimiter, {
38
- resource: 'authentication:subject',
38
+ namespace: 'authentication:subject',
39
39
  burstCapacity: this.options.bruteForceProtection?.subjectBurstCapacity ?? 10,
40
40
  refillInterval: this.options.bruteForceProtection?.subjectRefillInterval ?? (30 * millisecondsPerMinute),
41
41
  });
42
42
  ipRateLimiter = inject(RateLimiter, {
43
- resource: 'authentication:ip',
43
+ namespace: 'authentication:ip',
44
44
  burstCapacity: this.options.bruteForceProtection?.ipBurstCapacity ?? 20,
45
45
  refillInterval: this.options.bruteForceProtection?.ipRefillInterval ?? (5 * millisecondsPerMinute),
46
46
  });
@@ -154,6 +154,7 @@ let AuthenticationApiController = AuthenticationApiController_1 = class Authenti
154
154
  const subjectResource = `${validatedToken.payload.tenant}:${validatedToken.payload.subject}`;
155
155
  await this.enforceRateLimit(request.ip, subjectResource, auditor, validatedToken.payload.subject, 'refresh');
156
156
  const result = await this.authenticationService.refreshAlreadyValidatedToken(validatedToken, parameters.data, undefined, auditor);
157
+ await this.refundRateLimit(request.ip, subjectResource);
157
158
  return this.getTokenResponse(result, result.jsonToken.payload);
158
159
  }
159
160
  /**
@@ -332,6 +333,12 @@ let AuthenticationApiController = AuthenticationApiController_1 = class Authenti
332
333
  throw new TooManyRequestsError();
333
334
  }
334
335
  }
336
+ async refundRateLimit(ip, subjectResource) {
337
+ await Promise.all([
338
+ this.ipRateLimiter.refund(ip),
339
+ this.subjectRateLimiter.refund(subjectResource),
340
+ ]);
341
+ }
335
342
  getTokenResponse(result, body) {
336
343
  const { token, jsonToken, refreshToken, remember, omitImpersonatorRefreshToken, impersonatorRefreshToken, impersonatorRefreshTokenExpiration } = result;
337
344
  const options = {
@@ -294,14 +294,15 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
294
294
  const authAuditor = auditor.fork(AuthenticationService_1.name);
295
295
  await this.#endSession(sessionId, authAuditor);
296
296
  }
297
- async #endSession(sessionId, authAuditor) {
298
- const session = await this.#sessionRepository.tryLoad(sessionId);
297
+ async #endSession(sessionId, authAuditor, tx) {
298
+ const sessionRepository = this.#sessionRepository.withOptionalTransaction(tx);
299
+ const session = await sessionRepository.tryLoad(sessionId);
299
300
  if (isUndefined(session)) {
300
301
  this.#logger.warn(`Session "${sessionId}" not found for logout.`);
301
302
  return;
302
303
  }
303
304
  const now = currentTimestamp();
304
- await this.#sessionRepository.update(sessionId, { end: now });
305
+ await sessionRepository.update(sessionId, { end: now });
305
306
  await authAuditor.info('logout', {
306
307
  tenantId: session.tenantId,
307
308
  actor: session.subjectId,
@@ -411,53 +412,55 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
411
412
  let session;
412
413
  try {
413
414
  const sessionId = validatedRefreshToken.payload.session;
414
- session = await this.#sessionRepository.load(sessionId);
415
- const hash = await this.getHash(validatedRefreshToken.payload.secret, session.refreshTokenSalt);
416
- if (session.end <= currentTimestamp()) {
417
- throw new InvalidTokenError('Session is expired.');
418
- }
419
- if (!timingSafeBinaryEquals(hash, session.refreshTokenHash)) {
420
- await this.#endSession(sessionId, authAuditor);
421
- await authAuditor.warn('refresh-failure', {
422
- actorType: ActorType.Anonymous,
415
+ return await this.#sessionRepository.transaction(async (tx) => {
416
+ session = await this.#sessionRepository.withTransaction(tx).load(sessionId);
417
+ const hash = await this.getHash(validatedRefreshToken.payload.secret, session.refreshTokenSalt);
418
+ if (session.end <= currentTimestamp()) {
419
+ throw new InvalidTokenError('Session is expired.');
420
+ }
421
+ if (!timingSafeBinaryEquals(hash, session.refreshTokenHash)) {
422
+ await this.#endSession(sessionId, authAuditor, tx);
423
+ await authAuditor.warn('refresh-failure', {
424
+ actorType: ActorType.Anonymous,
425
+ tenantId: session.tenantId,
426
+ targetId: session.subjectId,
427
+ targetType: 'Subject',
428
+ details: { sessionId, reason: 'Token reuse detected. Session revoked.' },
429
+ });
430
+ throw new InvalidTokenError('Invalid refresh token.');
431
+ }
432
+ const now = currentTimestamp();
433
+ const impersonator = (options.omitImpersonator == true) ? undefined : validatedRefreshToken.payload.impersonator;
434
+ const remember = validatedRefreshToken.payload.remember;
435
+ const ttl = remember ? this.rememberRefreshTokenTimeToLive : this.refreshTokenTimeToLive;
436
+ const newEnd = now + ttl;
437
+ const subject = await this.#subjectRepository.loadByQuery({ tenantId: session.tenantId, id: session.subjectId });
438
+ this.ensureNotSuspended(subject);
439
+ const tokenPayload = await this.#authenticationAncillaryService?.getTokenPayload(subject, authenticationData, { action: GetTokenPayloadContextAction.Refresh });
440
+ const { token, jsonToken } = await this.createToken({ additionalTokenPayload: tokenPayload, subject, sessionId, refreshTokenExpiration: newEnd, impersonator, timestamp: now });
441
+ const newRefreshToken = await this.createRefreshToken(subject, sessionId, newEnd, { impersonator, remember });
442
+ await this.#sessionRepository.withTransaction(tx).update(sessionId, {
443
+ end: newEnd,
444
+ refreshTokenSalt: newRefreshToken.salt,
445
+ refreshTokenHash: newRefreshToken.hash,
446
+ });
447
+ await authAuditor.info('refresh-success', {
423
448
  tenantId: session.tenantId,
449
+ actor: session.subjectId,
450
+ actorType: ActorType.Subject,
424
451
  targetId: session.subjectId,
425
452
  targetType: 'Subject',
426
- details: { sessionId, reason: 'Token reuse detected. Session revoked.' },
453
+ details: { sessionId, remember },
427
454
  });
428
- throw new InvalidTokenError('Invalid refresh token.');
429
- }
430
- const now = currentTimestamp();
431
- const impersonator = (options.omitImpersonator == true) ? undefined : validatedRefreshToken.payload.impersonator;
432
- const remember = validatedRefreshToken.payload.remember;
433
- const ttl = remember ? this.rememberRefreshTokenTimeToLive : this.refreshTokenTimeToLive;
434
- const newEnd = now + ttl;
435
- const subject = await this.#subjectRepository.loadByQuery({ tenantId: session.tenantId, id: session.subjectId });
436
- this.ensureNotSuspended(subject);
437
- const tokenPayload = await this.#authenticationAncillaryService?.getTokenPayload(subject, authenticationData, { action: GetTokenPayloadContextAction.Refresh });
438
- const { token, jsonToken } = await this.createToken({ additionalTokenPayload: tokenPayload, subject, sessionId, refreshTokenExpiration: newEnd, impersonator, timestamp: now });
439
- const newRefreshToken = await this.createRefreshToken(subject, sessionId, newEnd, { impersonator, remember });
440
- await this.#sessionRepository.update(sessionId, {
441
- end: newEnd,
442
- refreshTokenSalt: newRefreshToken.salt,
443
- refreshTokenHash: newRefreshToken.hash,
444
- });
445
- await authAuditor.info('refresh-success', {
446
- tenantId: session.tenantId,
447
- actor: session.subjectId,
448
- actorType: ActorType.Subject,
449
- targetId: session.subjectId,
450
- targetType: 'Subject',
451
- details: { sessionId, remember },
455
+ return { token, jsonToken, refreshToken: newRefreshToken.token, remember, omitImpersonatorRefreshToken: options.omitImpersonator };
452
456
  });
453
- return { token, jsonToken, refreshToken: newRefreshToken.token, remember, omitImpersonatorRefreshToken: options.omitImpersonator };
454
457
  }
455
458
  catch (error) {
456
459
  await authAuditor.warn('refresh-failure', {
457
460
  actorType: ActorType.Anonymous,
458
461
  targetId: session?.subjectId ?? NIL_UUID,
459
462
  targetType: 'Subject',
460
- details: { sessionId: null, reason: error.message },
463
+ details: { sessionId: session?.id ?? null, reason: error.message },
461
464
  });
462
465
  throw error;
463
466
  }
@@ -995,7 +998,7 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
995
998
  }));
996
999
  await this.#totpRepository.transaction(async (transaction) => {
997
1000
  const totpRecoveryCodeRepository = this.#totpRecoveryCodeRepository.withTransaction(transaction);
998
- await totpRecoveryCodeRepository.deleteByQuery({ tenantId, totpId: totp.id });
1001
+ await totpRecoveryCodeRepository.hardDeleteManyByQuery({ tenantId, totpId: totp.id });
999
1002
  await totpRecoveryCodeRepository.insertMany(hashedRecoveryCodes);
1000
1003
  if (options?.invalidateOtherSessions == true) {
1001
1004
  await this.#invalidateAllOtherSessions(tenantId, subjectId, NIL_UUID, authAuditor);
@@ -1028,7 +1031,10 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
1028
1031
  });
1029
1032
  throw new ForbiddenError('Invalid TOTP token');
1030
1033
  }
1031
- await this.#totpRepository.deleteByQuery({ tenantId, id: totp.id });
1034
+ await this.#totpRepository.transaction(async (tx) => {
1035
+ await this.#totpRecoveryCodeRepository.withTransaction(tx).hardDeleteManyByQuery({ tenantId, totpId: totp.id });
1036
+ await this.#totpRepository.withTransaction(tx).hardDeleteByQuery({ tenantId, id: totp.id });
1037
+ });
1032
1038
  await authAuditor.info('totp-disable-success', {
1033
1039
  tenantId,
1034
1040
  actor: subjectId,
@@ -1054,7 +1060,10 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
1054
1060
  });
1055
1061
  throw new ForbiddenError('Invalid recovery code');
1056
1062
  }
1057
- await this.#totpRepository.deleteByQuery({ tenantId, id: totp.id });
1063
+ await this.#totpRepository.transaction(async (tx) => {
1064
+ await this.#totpRecoveryCodeRepository.withTransaction(tx).hardDeleteManyByQuery({ tenantId, totpId: totp.id });
1065
+ await this.#totpRepository.withTransaction(tx).hardDeleteByQuery({ tenantId, id: totp.id });
1066
+ });
1058
1067
  await authAuditor.info('totp-disable-success', {
1059
1068
  tenantId,
1060
1069
  actor: subjectId,
@@ -1075,58 +1084,63 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
1075
1084
  const challengeToken = await this.validateTotpChallengeToken(challengeTokenString);
1076
1085
  const { tenant, subject: subjectId, remember, data } = challengeToken.payload;
1077
1086
  const totp = await this.#totpRepository.loadByQuery({ tenantId: tenant, subjectId });
1078
- const isRecoveryCodeValid = await this.verifyAndUseRecoveryCode(totp, recoveryCode);
1079
- if (!isRecoveryCodeValid) {
1080
- await authAuditor.warn('recovery-login-failure', {
1081
- actorType: ActorType.Anonymous,
1082
- tenantId: tenant,
1083
- targetId: subjectId,
1084
- targetType: 'Subject',
1085
- details: {
1086
- subjectInput: { tenantId: tenant, subject: subjectId },
1087
- resolvedSubjectId: subjectId,
1088
- },
1089
- });
1090
- throw new ForbiddenError('Invalid recovery code');
1091
- }
1092
- const subject = await this.#subjectRepository.loadByQuery({ tenantId: tenant, id: subjectId });
1093
- const loginResult = await this.#loginAlreadyValidatedSubject(subject, data, authAuditor, remember);
1094
- const unusedRecoveryCodesCount = await this.#totpRecoveryCodeRepository.countByQuery({ tenantId: tenant, totpId: totp.id, usedTimestamp: null });
1087
+ const result = await this.#totpRepository.transaction(async (tx) => {
1088
+ const isRecoveryCodeValid = await this.verifyAndUseRecoveryCode(totp, recoveryCode, tx);
1089
+ if (!isRecoveryCodeValid) {
1090
+ await authAuditor.warn('recovery-login-failure', {
1091
+ actorType: ActorType.Anonymous,
1092
+ tenantId: tenant,
1093
+ targetId: subjectId,
1094
+ targetType: 'Subject',
1095
+ details: {
1096
+ subjectInput: { tenantId: tenant, subject: subjectId },
1097
+ resolvedSubjectId: subjectId,
1098
+ },
1099
+ });
1100
+ throw new ForbiddenError('Invalid recovery code');
1101
+ }
1102
+ const subject = await this.#subjectRepository.loadByQuery({ tenantId: tenant, id: subjectId });
1103
+ const loginResult = await this.#loginAlreadyValidatedSubject(subject, data, authAuditor, remember);
1104
+ const unusedRecoveryCodesCount = await this.#totpRecoveryCodeRepository.withTransaction(tx).countByQuery({ tenantId: tenant, totpId: totp.id, usedTimestamp: null });
1105
+ return { loginResult, unusedRecoveryCodesCount };
1106
+ });
1095
1107
  await authAuditor.info('recovery-login-success', {
1096
1108
  tenantId: tenant,
1097
1109
  actor: subjectId,
1098
1110
  actorType: ActorType.Subject,
1099
1111
  targetId: subjectId,
1100
1112
  targetType: 'Subject',
1101
- network: { sessionId: loginResult.result.jsonToken.payload.session },
1113
+ network: { sessionId: result.loginResult.result.jsonToken.payload.session },
1102
1114
  details: {
1103
- sessionId: loginResult.result.jsonToken.payload.session,
1115
+ sessionId: result.loginResult.result.jsonToken.payload.session,
1104
1116
  remember,
1105
1117
  },
1106
1118
  });
1107
1119
  return {
1108
- ...loginResult,
1109
- lowRecoveryCodesWarning: unusedRecoveryCodesCount <= 3,
1120
+ ...result.loginResult,
1121
+ lowRecoveryCodesWarning: result.unusedRecoveryCodesCount <= 3,
1110
1122
  };
1111
1123
  }
1112
1124
  async #loginVerifyTotp(challengeTokenString, token, authAuditor) {
1113
1125
  const challengeToken = await this.validateTotpChallengeToken(challengeTokenString);
1114
1126
  const { tenant, subject: subjectId, remember, data } = challengeToken.payload;
1115
- const totp = await this.#totpRepository.loadByQuery({ tenantId: tenant, subjectId });
1116
- const isValid = await this.verifyAndRecordTotpToken(totp, token, authAuditor);
1117
- if (!isValid) {
1118
- await authAuditor.warn('totp-verify-failure', {
1119
- tenantId: tenant,
1120
- actor: subjectId,
1121
- actorType: ActorType.Subject,
1122
- targetId: subjectId,
1123
- targetType: 'Subject',
1124
- details: { reason: 'Invalid token' },
1125
- });
1126
- throw new ForbiddenError('Invalid TOTP token');
1127
- }
1128
- const subject = await this.#subjectRepository.loadByQuery({ tenantId: tenant, id: subjectId });
1129
- return await this.#loginAlreadyValidatedSubject(subject, data, authAuditor, remember);
1127
+ return await this.#totpRepository.transaction(async (tx) => {
1128
+ const totp = await this.#totpRepository.withTransaction(tx).loadByQuery({ tenantId: tenant, subjectId });
1129
+ const isValid = await this.verifyAndRecordTotpToken(totp, token, authAuditor, tx);
1130
+ if (!isValid) {
1131
+ await authAuditor.warn('totp-verify-failure', {
1132
+ tenantId: tenant,
1133
+ actor: subjectId,
1134
+ actorType: ActorType.Subject,
1135
+ targetId: subjectId,
1136
+ targetType: 'Subject',
1137
+ details: { reason: 'Invalid token' },
1138
+ });
1139
+ throw new ForbiddenError('Invalid TOTP token');
1140
+ }
1141
+ const subject = await this.#subjectRepository.loadByQuery({ tenantId: tenant, id: subjectId });
1142
+ return await this.#loginAlreadyValidatedSubject(subject, data, authAuditor, remember);
1143
+ });
1130
1144
  }
1131
1145
  async validateTotpChallengeToken(tokenString) {
1132
1146
  const validatedToken = await parseAndValidateJwtTokenString(tokenString, 'KMAC256', await this.#totpChallengeSigningKey.getKey());
@@ -1155,26 +1169,29 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
1155
1169
  };
1156
1170
  return await createJwtTokenString(jsonToken, await this.#totpChallengeSigningKey.getKey());
1157
1171
  }
1158
- async verifyAndUseRecoveryCode(totp, code) {
1159
- const recoveryCodes = await this.#totpRecoveryCodeRepository.loadManyByQuery({ tenantId: totp.tenantId, totpId: totp.id, usedTimestamp: null });
1160
- const hashingOptions = this.#getTotpRecoveryCodeHashingOptions(totp.recoveryCodeSalt);
1161
- const hash = await hashTotpRecoveryCode(code, hashingOptions);
1162
- for (const recoveryCode of recoveryCodes) {
1163
- const isValid = await verifyTotpRecoveryCode(hash, recoveryCode.code, hashingOptions);
1164
- if (isValid) {
1165
- await this.#totpRecoveryCodeRepository.updateByQuery({ tenantId: recoveryCode.tenantId, id: recoveryCode.id }, { usedTimestamp: TRANSACTION_TIMESTAMP });
1166
- return true;
1172
+ async verifyAndUseRecoveryCode(totp, code, tx) {
1173
+ return await this.#totpRecoveryCodeRepository.useTransaction(tx, async (tx) => {
1174
+ const totpRecoveryCodeRepository = this.#totpRecoveryCodeRepository.withTransaction(tx);
1175
+ const recoveryCodes = await totpRecoveryCodeRepository.loadManyByQuery({ tenantId: totp.tenantId, totpId: totp.id, usedTimestamp: null });
1176
+ const hashingOptions = this.#getTotpRecoveryCodeHashingOptions(totp.recoveryCodeSalt);
1177
+ const hash = await hashTotpRecoveryCode(code, hashingOptions);
1178
+ for (const recoveryCode of recoveryCodes) {
1179
+ const isValid = await verifyTotpRecoveryCode(hash, recoveryCode.code, hashingOptions);
1180
+ if (isValid) {
1181
+ await totpRecoveryCodeRepository.updateByQuery({ tenantId: recoveryCode.tenantId, id: recoveryCode.id, usedTimestamp: null }, { usedTimestamp: TRANSACTION_TIMESTAMP });
1182
+ return true;
1183
+ }
1167
1184
  }
1168
- }
1169
- return false;
1185
+ return false;
1186
+ });
1170
1187
  }
1171
- async verifyAndRecordTotpToken(totp, token, authAuditor) {
1188
+ async verifyAndRecordTotpToken(totp, token, authAuditor, tx) {
1172
1189
  const secret = await importHmacKey('raw-secret', this.totpOptions.codeHashAlgorithm, totp.secret, false);
1173
1190
  const isValid = await verifyTotpToken(secret, token, this.#getTotpOptions(totp.recoveryCodeSalt));
1174
1191
  if (!isValid) {
1175
1192
  return false;
1176
1193
  }
1177
- const inserted = await this.#usedTotpTokenRepository.tryInsert({
1194
+ const inserted = await this.#usedTotpTokenRepository.withOptionalTransaction(tx).tryInsert({
1178
1195
  tenantId: totp.tenantId,
1179
1196
  subjectId: totp.subjectId,
1180
1197
  token,
@@ -140,4 +140,5 @@ ALTER TABLE "authentication"."used_totp_tokens" ADD CONSTRAINT "used_totp_tokens
140
140
  ALTER TABLE "authentication"."service_account" ADD CONSTRAINT "service_account_tenantId_type_id_subject_fkey" FOREIGN KEY ("tenant_id","type","id") REFERENCES "authentication"."subject"("tenant_id","type","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
141
141
  ALTER TABLE "authentication"."service_account" ADD CONSTRAINT "service_account_id_subject_fkey" FOREIGN KEY ("tenant_id","parent") REFERENCES "authentication"."subject"("tenant_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
142
142
  ALTER TABLE "authentication"."system_account" ADD CONSTRAINT "system_account_tenantId_type_id_subject_fkey" FOREIGN KEY ("tenant_id","type","id") REFERENCES "authentication"."subject"("tenant_id","type","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
143
- ALTER TABLE "authentication"."user" ADD CONSTRAINT "user_tenantId_type_id_subject_fkey" FOREIGN KEY ("tenant_id","type","id") REFERENCES "authentication"."subject"("tenant_id","type","id") ON DELETE cascade ON UPDATE no action;
143
+ ALTER TABLE "authentication"."user" ADD CONSTRAINT "user_tenantId_type_id_subject_fkey" FOREIGN KEY ("tenant_id","type","id") REFERENCES "authentication"."subject"("tenant_id","type","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
144
+ CREATE INDEX "totp_recovery_code_tenant_id_totp_id_idx" ON "authentication"."totp_recovery_code" USING btree ("tenant_id","totp_id");
@@ -1,5 +1,5 @@
1
1
  {
2
- "id": "98e3b751-2fd9-4c4a-a432-c397baa359c0",
2
+ "id": "06619eff-dfa3-4a91-b5f4-c3cfe7a78951",
3
3
  "prevId": "00000000-0000-0000-0000-000000000000",
4
4
  "version": "7",
5
5
  "dialect": "postgresql",
@@ -408,7 +408,29 @@
408
408
  "default": "'{}'::jsonb"
409
409
  }
410
410
  },
411
- "indexes": {},
411
+ "indexes": {
412
+ "totp_recovery_code_tenant_id_totp_id_idx": {
413
+ "name": "totp_recovery_code_tenant_id_totp_id_idx",
414
+ "columns": [
415
+ {
416
+ "expression": "tenant_id",
417
+ "isExpression": false,
418
+ "asc": true,
419
+ "nulls": "last"
420
+ },
421
+ {
422
+ "expression": "totp_id",
423
+ "isExpression": false,
424
+ "asc": true,
425
+ "nulls": "last"
426
+ }
427
+ ],
428
+ "isUnique": false,
429
+ "concurrently": false,
430
+ "method": "btree",
431
+ "with": {}
432
+ }
433
+ },
412
434
  "foreignKeys": {
413
435
  "totp_recovery_code_id_totp_fkey": {
414
436
  "name": "totp_recovery_code_id_totp_fkey",