@tstdl/base 0.93.151 → 0.93.153

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.
@@ -30,6 +30,7 @@ export declare const authenticationApiDefinition: {
30
30
  readonly tenantId: string | undefined;
31
31
  readonly subject: string;
32
32
  readonly secret: string;
33
+ readonly remember: boolean;
33
34
  readonly data: undefined;
34
35
  }>;
35
36
  result: ObjectSchema<TokenPayload<import("type-fest").EmptyObject>>;
@@ -155,6 +156,7 @@ export declare function getAuthenticationApiDefinition<AdditionalTokenPayload ex
155
156
  readonly tenantId: string | undefined;
156
157
  readonly subject: string;
157
158
  readonly secret: string;
159
+ readonly remember: boolean;
158
160
  readonly data: AuthenticationData;
159
161
  }>;
160
162
  result: ObjectSchema<TokenPayload<AdditionalTokenPayload>>;
@@ -275,6 +277,7 @@ export declare function getAuthenticationApiEndpointsDefinition<AdditionalTokenP
275
277
  readonly tenantId: string | undefined;
276
278
  readonly subject: string;
277
279
  readonly secret: string;
280
+ readonly remember: boolean;
278
281
  readonly data: AuthenticationData;
279
282
  }>;
280
283
  result: ObjectSchema<TokenPayload<AdditionalTokenPayload>>;
@@ -1,5 +1,5 @@
1
1
  import { defineApi } from '../api/types.js';
2
- import { assign, emptyObjectSchema, explicitObject, literal, never, number, object, optional, string } from '../schema/index.js';
2
+ import { assign, boolean, defaulted, emptyObjectSchema, explicitObject, literal, never, number, object, optional, string } from '../schema/index.js';
3
3
  import { SecretCheckResult } from './models/secret-check-result.model.js';
4
4
  import { TokenPayloadBase } from './models/token-payload-base.model.js';
5
5
  /**
@@ -51,6 +51,7 @@ export function getAuthenticationApiEndpointsDefinition(additionalTokenPayloadSc
51
51
  tenantId: optional(string()),
52
52
  subject: string(),
53
53
  secret: string(),
54
+ remember: defaulted(boolean(), false),
54
55
  data: authenticationDataSchema,
55
56
  }),
56
57
  result: tokenResultSchema,
@@ -127,8 +127,9 @@ export declare class AuthenticationClientService<AdditionalTokenPayload extends
127
127
  * @param subjectInput The subject to login with
128
128
  * @param secret The secret to login with
129
129
  * @param data Additional authentication data
130
+ * @param remember Whether to remember the session
130
131
  */
131
- login(subjectInput: SubjectInput, secret: string, data?: AuthenticationData): Promise<void>;
132
+ login(subjectInput: SubjectInput, secret: string, data?: AuthenticationData, remember?: boolean): Promise<void>;
132
133
  /**
133
134
  * Logout from the current session.
134
135
  * This will attempt to end the session on the server and then clear local credentials.
@@ -220,13 +220,14 @@ let AuthenticationClientService = class AuthenticationClientService {
220
220
  * @param subjectInput The subject to login with
221
221
  * @param secret The secret to login with
222
222
  * @param data Additional authentication data
223
+ * @param remember Whether to remember the session
223
224
  */
224
- async login(subjectInput, secret, data) {
225
+ async login(subjectInput, secret, data, remember = false) {
225
226
  if (isDefined(data)) {
226
227
  this.setAdditionalData(data);
227
228
  }
228
229
  const [token] = await Promise.all([
229
- this.client.login({ tenantId: subjectInput.tenantId, subject: subjectInput.subject, secret, data: this.authenticationData }),
230
+ this.client.login({ tenantId: subjectInput.tenantId, subject: subjectInput.subject, secret, remember, data: this.authenticationData }),
230
231
  this.syncClock(),
231
232
  ]);
232
233
  this.setNewToken(token);
@@ -26,6 +26,8 @@ export type RefreshToken = JwtToken<{
26
26
  impersonator?: string;
27
27
  /** The id of the session. */
28
28
  session: string;
29
+ /** Whether to remember the session. */
30
+ remember: boolean;
29
31
  /** The secret to use for refreshing the token. */
30
32
  secret: string;
31
33
  }>;
@@ -1,3 +1,4 @@
1
+ /** biome-ignore-all lint/nursery/noExcessiveClassesPerFile: <explanation> */
1
2
  import type { ApiController, ApiRequestContext, ApiServerResult } from '../../api/types.js';
2
3
  import { HttpServerResponse } from '../../http/server/index.js';
3
4
  import type { ObjectSchemaOrType, SchemaTestable } from '../../schema/index.js';
@@ -71,7 +72,7 @@ export declare class AuthenticationApiController<AdditionalTokenPayload extends
71
72
  * @returns The current server timestamp in seconds.
72
73
  */
73
74
  timestamp(): ApiServerResult<AuthenticationApiDefinition<AdditionalTokenPayload, AuthenticationData, AdditionalInitSecretResetData>, 'timestamp'>;
74
- protected getTokenResponse({ token, jsonToken, refreshToken, omitImpersonatorRefreshToken, impersonatorRefreshToken, impersonatorRefreshTokenExpiration }: TokenResult<AdditionalTokenPayload>): HttpServerResponse;
75
+ protected getTokenResponse({ token, jsonToken, refreshToken, remember, omitImpersonatorRefreshToken, impersonatorRefreshToken, impersonatorRefreshTokenExpiration }: TokenResult<AdditionalTokenPayload>): HttpServerResponse;
75
76
  }
76
77
  /**
77
78
  * Get an authentication API controller.
@@ -1,3 +1,4 @@
1
+ /** biome-ignore-all lint/nursery/noExcessiveClassesPerFile: <explanation> */
1
2
  var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
3
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
4
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -8,7 +9,7 @@ import { apiController } from '../../api/server/index.js';
8
9
  import { HttpServerResponse } from '../../http/server/index.js';
9
10
  import { inject } from '../../injector/index.js';
10
11
  import { currentTimestampSeconds } from '../../utils/date-time.js';
11
- import { assertDefinedPass, isDefined } from '../../utils/type-guards.js';
12
+ import { isDefined } from '../../utils/type-guards.js';
12
13
  import { authenticationApiDefinition, getAuthenticationApiDefinition } from '../authentication.api.js';
13
14
  import { AuthenticationService } from './authentication.service.js';
14
15
  import { tryGetAuthorizationTokenStringFromRequest } from './helper.js';
@@ -29,7 +30,7 @@ let AuthenticationApiController = class AuthenticationApiController {
29
30
  * @returns The token result.
30
31
  */
31
32
  async login({ parameters, getAuditor }) {
32
- const result = await this.authenticationService.login({ tenantId: parameters.tenantId, subject: parameters.subject }, parameters.secret, parameters.data, await getAuditor());
33
+ const result = await this.authenticationService.login({ tenantId: parameters.tenantId, subject: parameters.subject }, parameters.secret, parameters.data, await getAuditor(), parameters.remember);
33
34
  return this.getTokenResponse(result);
34
35
  }
35
36
  /**
@@ -140,7 +141,7 @@ let AuthenticationApiController = class AuthenticationApiController {
140
141
  timestamp() {
141
142
  return currentTimestampSeconds();
142
143
  }
143
- getTokenResponse({ token, jsonToken, refreshToken, omitImpersonatorRefreshToken, impersonatorRefreshToken, impersonatorRefreshTokenExpiration }) {
144
+ getTokenResponse({ token, jsonToken, refreshToken, remember, omitImpersonatorRefreshToken, impersonatorRefreshToken, impersonatorRefreshTokenExpiration }) {
144
145
  const result = jsonToken.payload;
145
146
  const options = {
146
147
  headers: {
@@ -151,12 +152,12 @@ let AuthenticationApiController = class AuthenticationApiController {
151
152
  authorization: {
152
153
  value: `Bearer ${token}`,
153
154
  ...cookieBaseOptions,
154
- expires: jsonToken.payload.exp * 1000,
155
+ expires: remember ? (jsonToken.payload.exp * 1000) : undefined,
155
156
  },
156
157
  refreshToken: {
157
158
  value: `Bearer ${refreshToken}`,
158
159
  ...cookieBaseOptions,
159
- expires: jsonToken.payload.refreshTokenExp * 1000,
160
+ expires: remember ? (jsonToken.payload.refreshTokenExp * 1000) : undefined,
160
161
  },
161
162
  },
162
163
  body: {
@@ -168,7 +169,7 @@ let AuthenticationApiController = class AuthenticationApiController {
168
169
  options.cookies['impersonatorRefreshToken'] = {
169
170
  value: `Bearer ${impersonatorRefreshToken}`,
170
171
  ...cookieBaseOptions,
171
- expires: assertDefinedPass(impersonatorRefreshTokenExpiration) * 1000,
172
+ expires: (remember && isDefined(impersonatorRefreshTokenExpiration)) ? (impersonatorRefreshTokenExpiration * 1000) : undefined,
172
173
  };
173
174
  }
174
175
  if (omitImpersonatorRefreshToken == true) {
@@ -2,6 +2,7 @@ import type { SubjectInput } from '../types.js';
2
2
  export type AuthenticationAuditEvents = {
3
3
  'login-success': {
4
4
  sessionId: string;
5
+ remember: boolean;
5
6
  };
6
7
  'login-failure': {
7
8
  subjectInput: SubjectInput;
@@ -12,6 +13,7 @@ export type AuthenticationAuditEvents = {
12
13
  };
13
14
  'refresh-success': {
14
15
  sessionId: string;
16
+ remember: boolean;
15
17
  };
16
18
  'refresh-failure': {
17
19
  reason: string;
@@ -1,3 +1,4 @@
1
+ /** biome-ignore-all lint/nursery/noExcessiveClassesPerFile: <explanation> */
1
2
  import { Auditor } from '../../audit/index.js';
2
3
  import { afterResolve, type AfterResolve } from '../../injector/index.js';
3
4
  import type { BinaryData, Record } from '../../types/index.js';
@@ -56,9 +57,15 @@ export declare class AuthenticationServiceOptions {
56
57
  /**
57
58
  * How long a refresh token is valid in milliseconds. Implies session time to live.
58
59
  *
59
- * @default 5 days
60
+ * @default 1 hour
60
61
  */
61
62
  refreshTokenTimeToLive?: number;
63
+ /**
64
+ * How long a refresh token is valid in milliseconds if "remember" is checked.
65
+ *
66
+ * @default 30 days
67
+ */
68
+ rememberRefreshTokenTimeToLive?: number;
62
69
  /**
63
70
  * How long a secret reset token is valid in milliseconds.
64
71
  *
@@ -97,6 +104,7 @@ export type TokenResult<AdditionalTokenPayload extends Record> = {
97
104
  token: string;
98
105
  jsonToken: Token<AdditionalTokenPayload>;
99
106
  refreshToken: string;
107
+ remember: boolean;
100
108
  omitImpersonatorRefreshToken?: boolean;
101
109
  impersonatorRefreshToken?: string;
102
110
  impersonatorRefreshTokenExpiration?: number;
@@ -161,6 +169,7 @@ export declare class AuthenticationService<AdditionalTokenPayload extends Record
161
169
  private readonly tokenVersion;
162
170
  private readonly tokenTimeToLive;
163
171
  private readonly refreshTokenTimeToLive;
172
+ private readonly rememberRefreshTokenTimeToLive;
164
173
  private readonly secretResetTokenTimeToLive;
165
174
  private derivedTokenSigningSecret;
166
175
  private derivedRefreshTokenSigningSecret;
@@ -196,8 +205,9 @@ export declare class AuthenticationService<AdditionalTokenPayload extends Record
196
205
  * @param options Options for getting the token.
197
206
  * @returns The token result.
198
207
  */
199
- getToken(subject: Subject, authenticationData: AuthenticationData, { impersonator }?: {
208
+ getToken(subject: Subject, authenticationData: AuthenticationData, { impersonator, remember }?: {
200
209
  impersonator?: string;
210
+ remember?: boolean;
201
211
  }): Promise<TokenResult<AdditionalTokenPayload>>;
202
212
  /**
203
213
  * Logs in a subject.
@@ -205,9 +215,10 @@ export declare class AuthenticationService<AdditionalTokenPayload extends Record
205
215
  * @param secret The secret to log in with.
206
216
  * @param data Additional authentication data.
207
217
  * @param auditor Auditor for auditing.
218
+ * @param remember Whether to remember the session.
208
219
  * @returns Token
209
220
  */
210
- login(subjectInput: SubjectInput, secret: string, data: AuthenticationData, auditor: Auditor): Promise<TokenResult<AdditionalTokenPayload>>;
221
+ login(subjectInput: SubjectInput, secret: string, data: AuthenticationData, auditor: Auditor, remember?: boolean): Promise<TokenResult<AdditionalTokenPayload>>;
211
222
  /**
212
223
  * Ends a session.
213
224
  * @param sessionId The id of the session to end.
@@ -366,6 +377,7 @@ export declare class AuthenticationService<AdditionalTokenPayload extends Record
366
377
  */
367
378
  createRefreshToken(subject: Subject, sessionId: string, expirationTimestamp: number, options?: {
368
379
  impersonator?: string;
380
+ remember?: boolean;
369
381
  }): Promise<CreateRefreshTokenResult>;
370
382
  defaultResolveSubjects({ tenantId, subject }: {
371
383
  tenantId?: string;
@@ -1,3 +1,4 @@
1
+ /** biome-ignore-all lint/nursery/noExcessiveClassesPerFile: <explanation> */
1
2
  var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
3
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
4
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -27,7 +28,7 @@ import { createJwtTokenString } from '../../utils/jwt.js';
27
28
  import { isUuid } from '../../utils/patterns.js';
28
29
  import { getRandomBytes, getRandomString } from '../../utils/random.js';
29
30
  import { isBinaryData, isDefined, isString, isUndefined } from '../../utils/type-guards.js';
30
- import { millisecondsPerDay, millisecondsPerMinute } from '../../utils/units.js';
31
+ import { millisecondsPerDay, millisecondsPerHour, millisecondsPerMinute } from '../../utils/units.js';
31
32
  import { AuthenticationCredentials, AuthenticationSession, Subject, User } from '../models/index.js';
32
33
  import { AuthenticationAncillaryService, GetTokenPayloadContextAction } from './authentication-ancillary.service.js';
33
34
  import { AuthenticationSecretRequirementsValidator } from './authentication-secret-requirements.validator.js';
@@ -54,9 +55,15 @@ export class AuthenticationServiceOptions {
54
55
  /**
55
56
  * How long a refresh token is valid in milliseconds. Implies session time to live.
56
57
  *
57
- * @default 5 days
58
+ * @default 1 hour
58
59
  */
59
60
  refreshTokenTimeToLive;
61
+ /**
62
+ * How long a refresh token is valid in milliseconds if "remember" is checked.
63
+ *
64
+ * @default 30 days
65
+ */
66
+ rememberRefreshTokenTimeToLive;
60
67
  /**
61
68
  * How long a secret reset token is valid in milliseconds.
62
69
  *
@@ -119,7 +126,8 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
119
126
  };
120
127
  tokenVersion = this.#options.version ?? 1;
121
128
  tokenTimeToLive = this.#options.tokenTimeToLive ?? (5 * millisecondsPerMinute);
122
- refreshTokenTimeToLive = this.#options.refreshTokenTimeToLive ?? (5 * millisecondsPerDay);
129
+ refreshTokenTimeToLive = this.#options.refreshTokenTimeToLive ?? (1 * millisecondsPerHour);
130
+ rememberRefreshTokenTimeToLive = this.#options.rememberRefreshTokenTimeToLive ?? (30 * millisecondsPerDay);
123
131
  secretResetTokenTimeToLive = this.#options.secretResetTokenTimeToLive ?? (10 * millisecondsPerMinute);
124
132
  derivedTokenSigningSecret;
125
133
  derivedRefreshTokenSigningSecret;
@@ -201,9 +209,10 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
201
209
  * @param options Options for getting the token.
202
210
  * @returns The token result.
203
211
  */
204
- async getToken(subject, authenticationData, { impersonator } = {}) {
212
+ async getToken(subject, authenticationData, { impersonator, remember = false } = {}) {
205
213
  const now = currentTimestamp();
206
- const end = now + this.refreshTokenTimeToLive;
214
+ const ttl = remember ? this.rememberRefreshTokenTimeToLive : this.refreshTokenTimeToLive;
215
+ const end = now + ttl;
207
216
  return await this.#sessionRepository.transaction(async (tx) => {
208
217
  const session = await this.#sessionRepository.withTransaction(tx).insert({
209
218
  tenantId: subject.tenantId,
@@ -216,14 +225,14 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
216
225
  });
217
226
  const tokenPayload = await this.#authenticationAncillaryService?.getTokenPayload(subject, authenticationData, { action: GetTokenPayloadContextAction.GetToken });
218
227
  const { token, jsonToken } = await this.createToken({ additionalTokenPayload: tokenPayload, subject, impersonator, sessionId: session.id, refreshTokenExpiration: end, timestamp: now });
219
- const refreshToken = await this.createRefreshToken(subject, session.id, end, { impersonator });
228
+ const refreshToken = await this.createRefreshToken(subject, session.id, end, { impersonator, remember });
220
229
  await this.#sessionRepository.withTransaction(tx).update(session.id, {
221
230
  end,
222
231
  refreshTokenHashVersion: 1,
223
232
  refreshTokenSalt: refreshToken.salt,
224
233
  refreshTokenHash: refreshToken.hash,
225
234
  });
226
- return { token, jsonToken, refreshToken: refreshToken.token };
235
+ return { token, jsonToken, refreshToken: refreshToken.token, remember };
227
236
  });
228
237
  }
229
238
  /**
@@ -232,9 +241,10 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
232
241
  * @param secret The secret to log in with.
233
242
  * @param data Additional authentication data.
234
243
  * @param auditor Auditor for auditing.
244
+ * @param remember Whether to remember the session.
235
245
  * @returns Token
236
246
  */
237
- async login(subjectInput, secret, data, auditor) {
247
+ async login(subjectInput, secret, data, auditor, remember = false) {
238
248
  const authAuditor = auditor.fork(AuthenticationService_1.name);
239
249
  const authenticationResult = await this.authenticate(subjectInput, secret);
240
250
  if (!authenticationResult.success) {
@@ -249,7 +259,7 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
249
259
  throw new InvalidCredentialsError();
250
260
  }
251
261
  await this.hooks.beforeLogin.trigger({ subject: authenticationResult.subject });
252
- const token = await this.getToken(authenticationResult.subject, data);
262
+ const token = await this.getToken(authenticationResult.subject, data, { remember });
253
263
  await this.hooks.afterLogin.trigger({ subject: authenticationResult.subject });
254
264
  const sessionId = token.jsonToken.payload.session;
255
265
  await authAuditor.info('login-success', {
@@ -259,7 +269,7 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
259
269
  targetId: authenticationResult.subject.id,
260
270
  targetType: 'User',
261
271
  network: { sessionId },
262
- details: { sessionId },
272
+ details: { sessionId, remember },
263
273
  });
264
274
  return token;
265
275
  }
@@ -361,11 +371,13 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
361
371
  }
362
372
  const now = currentTimestamp();
363
373
  const impersonator = (options.omitImpersonator == true) ? undefined : validatedRefreshToken.payload.impersonator;
364
- const newEnd = now + this.refreshTokenTimeToLive;
374
+ const remember = validatedRefreshToken.payload.remember;
375
+ const ttl = remember ? this.rememberRefreshTokenTimeToLive : this.refreshTokenTimeToLive;
376
+ const newEnd = now + ttl;
365
377
  const subject = await this.#subjectRepository.loadByQuery({ tenantId: session.tenantId, id: session.subjectId });
366
378
  const tokenPayload = await this.#authenticationAncillaryService?.getTokenPayload(subject, authenticationData, { action: GetTokenPayloadContextAction.Refresh });
367
379
  const { token, jsonToken } = await this.createToken({ additionalTokenPayload: tokenPayload, subject, sessionId, refreshTokenExpiration: newEnd, impersonator, timestamp: now });
368
- const newRefreshToken = await this.createRefreshToken(subject, sessionId, newEnd, { impersonator });
380
+ const newRefreshToken = await this.createRefreshToken(subject, sessionId, newEnd, { impersonator, remember });
369
381
  await this.#sessionRepository.update(sessionId, {
370
382
  end: newEnd,
371
383
  refreshTokenHashVersion: 1,
@@ -378,9 +390,9 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
378
390
  actorType: ActorType.Subject,
379
391
  targetId: session.subjectId,
380
392
  targetType: 'User',
381
- details: { sessionId },
393
+ details: { sessionId, remember },
382
394
  });
383
- return { token, jsonToken, refreshToken: newRefreshToken.token, omitImpersonatorRefreshToken: options.omitImpersonator };
395
+ return { token, jsonToken, refreshToken: newRefreshToken.token, remember, omitImpersonatorRefreshToken: options.omitImpersonator };
384
396
  }
385
397
  catch (error) {
386
398
  await authAuditor.warn('refresh-failure', {
@@ -715,6 +727,7 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
715
727
  tenant: subject.tenantId,
716
728
  impersonator: options?.impersonator,
717
729
  session: sessionId,
730
+ remember: options?.remember ?? false,
718
731
  secret,
719
732
  },
720
733
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,109 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'vitest';
2
+ import { Auditor } from '../../audit/index.js';
3
+ import { HttpHeaders } from '../../http/index.js';
4
+ import { HttpServerResponse } from '../../http/server/index.js';
5
+ import { clearTenantData, setupIntegrationTest } from '../../testing/index.js';
6
+ import { toArray } from '../../utils/array/array.js';
7
+ import { AuthenticationApiController } from '../server/authentication.api-controller.js';
8
+ import { AuthenticationService } from '../server/authentication.service.js';
9
+ import { SubjectService } from '../server/subject.service.js';
10
+ import { DefaultAuthenticationAncillaryService } from './authentication.test-ancillary-service.js';
11
+ describe('AuthenticationApiController Remember Functionality', () => {
12
+ let injector;
13
+ let database;
14
+ let controller;
15
+ let authenticationService;
16
+ let subjectService;
17
+ let auditor;
18
+ const schema = 'authentication';
19
+ const tenantId = crypto.randomUUID();
20
+ beforeAll(async () => {
21
+ ({ injector, database } = await setupIntegrationTest({
22
+ modules: { authentication: true, audit: true, keyValueStore: true },
23
+ authenticationAncillaryService: DefaultAuthenticationAncillaryService,
24
+ }));
25
+ authenticationService = await injector.resolveAsync(AuthenticationService);
26
+ subjectService = await injector.resolveAsync(SubjectService);
27
+ auditor = injector.resolve(Auditor);
28
+ controller = injector.resolve(AuthenticationApiController);
29
+ });
30
+ afterAll(async () => {
31
+ await injector?.dispose();
32
+ });
33
+ beforeEach(async () => {
34
+ await clearTenantData(database, schema, ['credentials', 'session', 'user', 'service_account', 'system_account', 'subject'], tenantId);
35
+ });
36
+ test('login with remember: true should have Expires in cookies', async () => {
37
+ const user = await subjectService.createUser({ tenantId, email: 'api-rem@example.com', firstName: 'A', lastName: 'L' });
38
+ await authenticationService.setCredentials(user, 'Pass-R3m3mb3r-2026!');
39
+ const context = {
40
+ parameters: { tenantId, subject: user.id, secret: 'Pass-R3m3mb3r-2026!', remember: true, data: undefined },
41
+ getAuditor: async () => auditor,
42
+ };
43
+ const response = await controller.login(context);
44
+ expect(response).toBeInstanceOf(HttpServerResponse);
45
+ const setCookieHeaders = toArray(response.headers.tryGet('Set-Cookie') ?? []);
46
+ expect(setCookieHeaders).toHaveLength(2); // authorization and refreshToken
47
+ for (const cookie of setCookieHeaders) {
48
+ expect(cookie).toMatch(/Expires=/);
49
+ }
50
+ });
51
+ test('login with remember: false should NOT have Expires in cookies', async () => {
52
+ const user = await subjectService.createUser({ tenantId, email: 'api-no-rem@example.com', firstName: 'A', lastName: 'L' });
53
+ await authenticationService.setCredentials(user, 'Pass-R3m3mb3r-2026!');
54
+ const context = {
55
+ parameters: { tenantId, subject: user.id, secret: 'Pass-R3m3mb3r-2026!', remember: false, data: undefined },
56
+ getAuditor: async () => auditor,
57
+ };
58
+ const response = await controller.login(context);
59
+ const setCookieHeaders = toArray(response.headers.tryGet('Set-Cookie') ?? []);
60
+ for (const cookie of setCookieHeaders) {
61
+ expect(cookie).not.toMatch(/Expires=/);
62
+ expect(cookie).not.toMatch(/Max-Age=/);
63
+ }
64
+ });
65
+ test('refresh should propagate remember status to cookies', async () => {
66
+ const user = await subjectService.createUser({ tenantId, email: 'api-refresh-rem@example.com', firstName: 'A', lastName: 'L' });
67
+ await authenticationService.setCredentials(user, 'Pass-R3m3mb3r-2026!');
68
+ // 1. Login with remember: true
69
+ const loginResult = await authenticationService.login({ tenantId, subject: user.id }, 'Pass-R3m3mb3r-2026!', undefined, auditor, true);
70
+ // 2. Refresh
71
+ const context = {
72
+ request: {
73
+ headers: new HttpHeaders({
74
+ 'X-Refresh-Token': `Bearer ${loginResult.refreshToken}`
75
+ }),
76
+ cookies: {
77
+ tryGet: () => undefined
78
+ }
79
+ },
80
+ parameters: { data: undefined },
81
+ getAuditor: async () => auditor,
82
+ };
83
+ const response = await controller.refresh(context);
84
+ const setCookieHeaders = toArray(response.headers.tryGet('Set-Cookie') ?? []);
85
+ for (const cookie of setCookieHeaders) {
86
+ expect(cookie).toMatch(/Expires=/);
87
+ }
88
+ // 3. Login with remember: false
89
+ const loginResultNoRem = await authenticationService.login({ tenantId, subject: user.id }, 'Pass-R3m3mb3r-2026!', undefined, auditor, false);
90
+ // 4. Refresh
91
+ const contextNoRem = {
92
+ request: {
93
+ headers: new HttpHeaders({
94
+ 'X-Refresh-Token': `Bearer ${loginResultNoRem.refreshToken}`
95
+ }),
96
+ cookies: {
97
+ tryGet: () => undefined
98
+ }
99
+ },
100
+ parameters: { data: undefined },
101
+ getAuditor: async () => auditor,
102
+ };
103
+ const responseNoRem = await controller.refresh(contextNoRem);
104
+ const setCookieHeadersNoRem = toArray(responseNoRem.headers.tryGet('Set-Cookie') ?? []);
105
+ for (const cookie of setCookieHeadersNoRem) {
106
+ expect(cookie).not.toMatch(/Expires=/);
107
+ }
108
+ });
109
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,76 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'vitest';
2
+ import { Auditor } from '../../audit/index.js';
3
+ import { clearTenantData, setupIntegrationTest } from '../../testing/index.js';
4
+ import { AuthenticationService } from '../server/authentication.service.js';
5
+ import { getRefreshTokenFromString } from '../server/helper.js';
6
+ import { SubjectService } from '../server/subject.service.js';
7
+ import { DefaultAuthenticationAncillaryService } from './authentication.test-ancillary-service.js';
8
+ describe('AuthenticationService Remember Functionality', () => {
9
+ let injector;
10
+ let database;
11
+ let authenticationService;
12
+ let subjectService;
13
+ let auditor;
14
+ const schema = 'authentication';
15
+ const tenantId = crypto.randomUUID();
16
+ beforeAll(async () => {
17
+ ({ injector, database } = await setupIntegrationTest({
18
+ modules: { authentication: true },
19
+ authenticationAncillaryService: DefaultAuthenticationAncillaryService,
20
+ }));
21
+ authenticationService = await injector.resolveAsync(AuthenticationService);
22
+ subjectService = await injector.resolveAsync(SubjectService);
23
+ auditor = injector.resolve(Auditor);
24
+ });
25
+ afterAll(async () => {
26
+ await injector?.dispose();
27
+ });
28
+ beforeEach(async () => {
29
+ await clearTenantData(database, schema, ['credentials', 'session', 'user', 'service_account', 'system_account', 'subject'], tenantId);
30
+ });
31
+ test('RefreshToken should contain remember flag', async () => {
32
+ const user = await subjectService.createUser({
33
+ tenantId,
34
+ email: 'remember@example.com',
35
+ firstName: 'Rem',
36
+ lastName: 'Ember',
37
+ });
38
+ const tokenResult = await authenticationService.getToken(user, undefined, { remember: true });
39
+ const refreshToken = await getRefreshTokenFromString(tokenResult.refreshToken, authenticationService.derivedRefreshTokenSigningSecret);
40
+ expect(refreshToken.payload).toHaveProperty('remember', true);
41
+ expect(tokenResult.remember).toBe(true);
42
+ });
43
+ test('RefreshToken should respect remember flag for expiration', async () => {
44
+ const user = await subjectService.createUser({ tenantId, email: 'ttl@example.com', firstName: 'T', lastName: 'L' });
45
+ const normalResult = await authenticationService.getToken(user, undefined, { remember: false });
46
+ const rememberResult = await authenticationService.getToken(user, undefined, { remember: true });
47
+ const normalToken = await getRefreshTokenFromString(normalResult.refreshToken, authenticationService.derivedRefreshTokenSigningSecret);
48
+ const rememberToken = await getRefreshTokenFromString(rememberResult.refreshToken, authenticationService.derivedRefreshTokenSigningSecret);
49
+ expect(rememberToken.payload.exp).toBeGreaterThan(normalToken.payload.exp);
50
+ });
51
+ test('login should pass remember flag to getToken', async () => {
52
+ const user = await subjectService.createUser({ tenantId, email: 'login-rem@example.com', firstName: 'L', lastName: 'R' });
53
+ await authenticationService.setCredentials(user, 'Strong-Password-2026!');
54
+ const result = await authenticationService.login({ tenantId, subject: user.id }, 'Strong-Password-2026!', undefined, auditor, true);
55
+ expect(result.remember).toBe(true);
56
+ const refreshToken = await getRefreshTokenFromString(result.refreshToken, authenticationService.derivedRefreshTokenSigningSecret);
57
+ expect(refreshToken.payload.remember).toBe(true);
58
+ });
59
+ test('refresh should propagate remember flag', async () => {
60
+ const user = await subjectService.createUser({ tenantId, email: 'refresh-rem@example.com', firstName: 'R', lastName: 'R' });
61
+ await authenticationService.setCredentials(user, 'Strong-Password-2026!');
62
+ const loginResult = await authenticationService.login({ tenantId, subject: user.id }, 'Strong-Password-2026!', undefined, auditor, true);
63
+ expect(loginResult.remember).toBe(true);
64
+ const refreshResult = await authenticationService.refresh(loginResult.refreshToken, undefined, {}, auditor);
65
+ expect(refreshResult.remember).toBe(true);
66
+ const newRefreshToken = await getRefreshTokenFromString(refreshResult.refreshToken, authenticationService.derivedRefreshTokenSigningSecret);
67
+ expect(newRefreshToken.payload.remember).toBe(true);
68
+ // Verify it also works when not remembered
69
+ const loginResultNoRem = await authenticationService.login({ tenantId, subject: user.id }, 'Strong-Password-2026!', undefined, auditor, false);
70
+ expect(loginResultNoRem.remember).toBe(false);
71
+ const refreshResultNoRem = await authenticationService.refresh(loginResultNoRem.refreshToken, undefined, {}, auditor);
72
+ expect(refreshResultNoRem.remember).toBe(false);
73
+ const newRefreshTokenNoRem = await getRefreshTokenFromString(refreshResultNoRem.refreshToken, authenticationService.derivedRefreshTokenSigningSecret);
74
+ expect(newRefreshTokenNoRem.payload.remember).toBe(false);
75
+ });
76
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tstdl/base",
3
- "version": "0.93.151",
3
+ "version": "0.93.153",
4
4
  "author": "Patrick Hein",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -1,4 +1,6 @@
1
- type ErrorData = Pick<Error, 'name' | 'message' | 'stack'>;
1
+ type ErrorData = Pick<Error, 'name' | 'message' | 'stack'> & {
2
+ cause?: ErrorData;
3
+ };
2
4
  export declare function serializeError(error: Error): ErrorData;
3
5
  export declare function deserializeError(data: ErrorData): Error;
4
6
  export {};
@@ -1,9 +1,19 @@
1
+ import { formatError } from '../../errors/format.js';
2
+ import { isDefined, isError, isString } from '../../utils/type-guards.js';
1
3
  export function serializeError(error) {
2
- return { name: error.name, message: error.message, stack: error.stack };
4
+ const cause = isError(error.cause)
5
+ ? serializeError(error.cause)
6
+ : isString(error.cause)
7
+ ? { name: 'Error', message: error.cause, stack: undefined }
8
+ : isDefined(error.cause)
9
+ ? { name: 'Error', message: formatError(error.cause), stack: undefined }
10
+ : undefined;
11
+ return { name: error.name, message: error.message, stack: error.stack, cause };
3
12
  }
4
13
  export function deserializeError(data) {
5
14
  const error = new Error(data.message);
6
15
  error.name = data.name;
7
16
  error.stack = data.stack;
17
+ error.cause = isDefined(data.cause) ? deserializeError(data.cause) : undefined;
8
18
  return error;
9
19
  }
@@ -2,4 +2,5 @@ export * from './enqueue-batch.js';
2
2
  export * from './provider.js';
3
3
  export * from './task-context.js';
4
4
  export * from './task-queue.js';
5
+ export * from './task.error.js';
5
6
  export * from './types.js';
@@ -2,4 +2,5 @@ export * from './enqueue-batch.js';
2
2
  export * from './provider.js';
3
3
  export * from './task-context.js';
4
4
  export * from './task-queue.js';
5
+ export * from './task.error.js';
5
6
  export * from './types.js';
@@ -103,7 +103,7 @@ export declare class PostgresTaskQueue<Definitions extends TaskDefinitionMap = T
103
103
  results?: TasksResults<Tasks>;
104
104
  transaction?: Transaction;
105
105
  }): Promise<void>;
106
- fail(task: Task<Definitions>, error: unknown, options?: {
106
+ fail(task: Task<Definitions>, caughtError: unknown, options?: {
107
107
  fatal?: boolean;
108
108
  transaction?: Transaction;
109
109
  }): Promise<void>;
@@ -74,6 +74,7 @@ import { cancelableTimeout } from '../../utils/timing.js';
74
74
  import { isArray, isDefined, isNotNull, isNull, isNumber, isString, isUndefined } from '../../utils/type-guards.js';
75
75
  import { millisecondsPerMinute, millisecondsPerSecond } from '../../utils/units.js';
76
76
  import { defaultQueueConfig, queueableOrWaitableStatuses, queueableStatuses, TaskDependencyType, TaskQueue, TaskStatus, terminalStatuses } from '../task-queue.js';
77
+ import { ensureTaskError } from '../task.error.js';
77
78
  import { PostgresTaskQueueModuleConfig } from './module.js';
78
79
  import { taskArchive as taskArchiveTable, taskDependency as taskDependencyTable, taskDependencyType, taskStatus, task as taskTable } from './schemas.js';
79
80
  import { PostgresTask, PostgresTaskArchive } from './task.model.js';
@@ -849,7 +850,8 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
849
850
  }
850
851
  });
851
852
  }
852
- async fail(task, error, options) {
853
+ async fail(task, caughtError, options) {
854
+ const error = ensureTaskError(caughtError);
853
855
  const isRetryable = (options?.fatal != true) && (task.tries < this.maxTries);
854
856
  const nextStatus = isRetryable ? TaskStatus.Retrying : TaskStatus.Dead;
855
857
  const delay = isRetryable
@@ -883,7 +885,7 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
883
885
  }
884
886
  await this.#repository.useTransaction(options?.transaction, async (tx) => {
885
887
  const rows = tasks.map((task, index) => {
886
- const error = errors[index];
888
+ const error = ensureTaskError(errors[index]);
887
889
  const isRetryable = (task.tries < this.maxTries);
888
890
  const nextStatus = isRetryable ? TaskStatus.Retrying : TaskStatus.Dead;
889
891
  const delay = isRetryable
@@ -0,0 +1,7 @@
1
+ import { CustomError, type CustomErrorOptions } from '../errors/custom.error.js';
2
+ import type { TypedOmit } from '../types/types.js';
3
+ export declare class TaskError extends CustomError {
4
+ static readonly errorName = "TaskError";
5
+ constructor(message: string, options?: TypedOmit<CustomErrorOptions, 'message' | 'cause'>);
6
+ }
7
+ export declare function ensureTaskError(error: unknown): Error;
@@ -0,0 +1,18 @@
1
+ import { CustomError } from '../errors/custom.error.js';
2
+ import { formatError } from '../errors/format.js';
3
+ import { isString } from '../utils/type-guards.js';
4
+ export class TaskError extends CustomError {
5
+ static errorName = 'TaskError';
6
+ constructor(message, options) {
7
+ super({ message, ...options });
8
+ }
9
+ }
10
+ export function ensureTaskError(error) {
11
+ if (error instanceof Error) {
12
+ return error;
13
+ }
14
+ if (isString(error)) {
15
+ return new TaskError(error, undefined);
16
+ }
17
+ return new TaskError(`A task error occurred: ${formatError(error)}`);
18
+ }
package/testing/README.md CHANGED
@@ -80,37 +80,48 @@ The `setupIntegrationTest` utility (found in `source/testing/integration-setup.t
80
80
  4. **Schema Isolation**: Automatically creates and uses PostgreSQL schemas for isolation.
81
81
  5. **Modules**: Optional configuration for `taskQueue`, `authentication`, `objectStorage`, `notification`, etc.
82
82
 
83
- Integration tests typically require an injection context to resolve services and repositories. You can use the `testInInjector` (or `itInInjector`) helper to automatically wrap your test body.
83
+ ### Injection Context: `testInInjector`
84
+
85
+ Integration tests may require an injection context to resolve services and repositories. You can use the `testInInjector` (or `itInInjector`) helper to automatically wrap your test body.
84
86
 
85
87
  > [!IMPORTANT]
86
- > `testInInjector` is designed for test files where a **single injector** is shared across all tests (typically initialized in `beforeAll`). It accepts a `ValueOrProvider<Injector>`, allowing you to pass a getter function (e.g., `() => injector`) if the injector variable is initialized late.
88
+ > `testInInjector` is **only required** for code that uses `inject()` or `injectRepository()` outside of a class constructor/initializer (e.g. ad-hoc repository injection in a test body).
87
89
  >
88
- > If your tests require a fresh injector per test case (e.g. `setupIntegrationTest` in `beforeEach`), you can still use `testInInjector` by passing a provider.
90
+ > If you resolve your services in `beforeAll` or `beforeEach`, calling their methods usually does **not** require `testInInjector`.
91
+
92
+ > [!TIP]
93
+ > **Performance & Reusability**: For most integration tests, resolve services once in `beforeAll` and reuse the variables in your tests.
89
94
 
90
95
  ```typescript
91
- import { beforeAll, describe, expect } from 'vitest';
96
+ import { beforeAll, describe, expect, test } from 'vitest';
92
97
  import { setupIntegrationTest, testInInjector } from '#/testing/index.js';
93
98
  import { MyService } from '../my-service.js';
94
99
 
95
100
  describe('MyService Integration', () => {
96
- let injector;
101
+ let injector: Injector;
102
+ let myService: MyService;
97
103
 
98
104
  beforeAll(async () => {
99
- // Setup with database and specific modules
100
105
  ({ injector } = await setupIntegrationTest({
101
106
  modules: { taskQueue: true },
102
- orm: { schema: 'test_my_service' },
103
107
  }));
108
+
109
+ // Resolve reused services once
110
+ myService = injector.resolve(MyService);
104
111
  });
105
112
 
106
- testInInjector('should process data through the database', injector, async () => {
107
- const service = injector.resolve(MyService);
108
- const result = await service.process();
113
+ // Calling methods on resolved services does NOT require testInInjector
114
+ test('should process data', async () => {
115
+ const result = await myService.process();
109
116
  expect(result.success).toBe(true);
110
117
  });
111
118
 
112
- // Using a provider function to defer resolution of the 'injector' variable when using `beforeEach` for injector setup
113
- testInInjector('should process data through the database', () => injector, async () => { ... });
119
+ // testInInjector is ONLY needed if you use inject() directly in the test body
120
+ testInInjector('should work with ad-hoc injection', () => injector, async () => {
121
+ const repo = injectRepository(SomeEntity);
122
+ // ...
123
+ },
124
+ );
114
125
  });
115
126
  ```
116
127