@tstdl/base 0.93.150 → 0.93.152
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.
- package/authentication/authentication.api.d.ts +3 -0
- package/authentication/authentication.api.js +2 -1
- package/authentication/client/authentication.service.d.ts +12 -12
- package/authentication/client/authentication.service.js +39 -54
- package/authentication/models/token.model.d.ts +2 -0
- package/authentication/server/authentication.api-controller.d.ts +2 -1
- package/authentication/server/authentication.api-controller.js +7 -6
- package/authentication/server/authentication.audit.d.ts +2 -0
- package/authentication/server/authentication.service.d.ts +14 -2
- package/authentication/server/authentication.service.js +24 -11
- package/authentication/tests/remember.api.test.d.ts +1 -0
- package/authentication/tests/remember.api.test.js +109 -0
- package/authentication/tests/remember.service.test.d.ts +1 -0
- package/authentication/tests/remember.service.test.js +76 -0
- package/package.json +1 -1
- package/schema/converters/open-api-converter.js +10 -13
- package/testing/README.md +23 -12
|
@@ -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,
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { type Observable } from 'rxjs';
|
|
2
1
|
import type { AfterResolve } from '../../injector/index.js';
|
|
3
2
|
import { afterResolve } from '../../injector/index.js';
|
|
4
3
|
import type { Record } from '../../types/index.js';
|
|
@@ -36,7 +35,7 @@ export declare class AuthenticationClientService<AdditionalTokenPayload extends
|
|
|
36
35
|
* Observable for authentication errors.
|
|
37
36
|
* Emits when a refresh fails.
|
|
38
37
|
*/
|
|
39
|
-
readonly error$: Observable<Error>;
|
|
38
|
+
readonly error$: import("rxjs").Observable<Error>;
|
|
40
39
|
/** Current token */
|
|
41
40
|
readonly token: import("../../signals/api.js").WritableSignal<TokenPayload<AdditionalTokenPayload> | undefined>;
|
|
42
41
|
/** Current raw token */
|
|
@@ -58,23 +57,23 @@ export declare class AuthenticationClientService<AdditionalTokenPayload extends
|
|
|
58
57
|
/** Whether the user is impersonated */
|
|
59
58
|
readonly impersonated: import("../../signals/api.js").Signal<boolean>;
|
|
60
59
|
/** Current token */
|
|
61
|
-
readonly token$: Observable<TokenPayload<AdditionalTokenPayload> | undefined>;
|
|
60
|
+
readonly token$: import("rxjs").Observable<TokenPayload<AdditionalTokenPayload> | undefined>;
|
|
62
61
|
/** Emits when token is available (not undefined) */
|
|
63
|
-
readonly definedToken$: Observable<Exclude<TokenPayload<AdditionalTokenPayload>, void | undefined>>;
|
|
62
|
+
readonly definedToken$: import("rxjs").Observable<Exclude<TokenPayload<AdditionalTokenPayload>, void | undefined>>;
|
|
64
63
|
/** Emits when a valid token is available (not undefined and not expired) */
|
|
65
|
-
readonly validToken$: Observable<Exclude<TokenPayload<AdditionalTokenPayload>, void | undefined>>;
|
|
64
|
+
readonly validToken$: import("rxjs").Observable<Exclude<TokenPayload<AdditionalTokenPayload>, void | undefined>>;
|
|
66
65
|
/** Current subject */
|
|
67
|
-
readonly subjectId$: Observable<string | undefined>;
|
|
66
|
+
readonly subjectId$: import("rxjs").Observable<string | undefined>;
|
|
68
67
|
/** Emits when subject is available */
|
|
69
|
-
readonly definedSubjectId$: Observable<string>;
|
|
68
|
+
readonly definedSubjectId$: import("rxjs").Observable<string>;
|
|
70
69
|
/** Current session id */
|
|
71
|
-
readonly sessionId$: Observable<string | undefined>;
|
|
70
|
+
readonly sessionId$: import("rxjs").Observable<string | undefined>;
|
|
72
71
|
/** Emits when session id is available */
|
|
73
|
-
readonly definedSessionId$: Observable<string>;
|
|
72
|
+
readonly definedSessionId$: import("rxjs").Observable<string>;
|
|
74
73
|
/** Whether the user is logged in */
|
|
75
|
-
readonly isLoggedIn$: Observable<boolean>;
|
|
74
|
+
readonly isLoggedIn$: import("rxjs").Observable<boolean>;
|
|
76
75
|
/** Emits when the user logs out */
|
|
77
|
-
readonly loggedOut$: Observable<void>;
|
|
76
|
+
readonly loggedOut$: import("rxjs").Observable<void>;
|
|
78
77
|
private get authenticationData();
|
|
79
78
|
private set authenticationData(value);
|
|
80
79
|
private get impersonatorAuthenticationData();
|
|
@@ -128,8 +127,9 @@ export declare class AuthenticationClientService<AdditionalTokenPayload extends
|
|
|
128
127
|
* @param subjectInput The subject to login with
|
|
129
128
|
* @param secret The secret to login with
|
|
130
129
|
* @param data Additional authentication data
|
|
130
|
+
* @param remember Whether to remember the session
|
|
131
131
|
*/
|
|
132
|
-
login(subjectInput: SubjectInput, secret: string, data?: AuthenticationData): Promise<void>;
|
|
132
|
+
login(subjectInput: SubjectInput, secret: string, data?: AuthenticationData, remember?: boolean): Promise<void>;
|
|
133
133
|
/**
|
|
134
134
|
* Logout from the current session.
|
|
135
135
|
* This will attempt to end the session on the server and then clear local credentials.
|
|
@@ -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,
|
|
10
|
+
import { Subject, filter, firstValueFrom, from, race, skip, takeUntil, 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';
|
|
@@ -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);
|
|
@@ -398,76 +399,60 @@ let AuthenticationClientService = class AuthenticationClientService {
|
|
|
398
399
|
if (this.isLoggedIn()) {
|
|
399
400
|
await this.syncClock();
|
|
400
401
|
}
|
|
402
|
+
// Helper to sleep until the delay passes OR a vital state change occurs
|
|
403
|
+
const waitForNextAction = async (delayMs, referenceExp) => await firstValueFrom(race([
|
|
404
|
+
timer(Math.max(10, delayMs)),
|
|
405
|
+
from(this.disposeSignal),
|
|
406
|
+
this.token$.pipe(filter((t) => t?.exp !== referenceExp)),
|
|
407
|
+
this.forceRefreshRequested$.pipe(filter(Boolean),
|
|
408
|
+
// Skip the current value if already true to prevent infinite tight loops
|
|
409
|
+
skip(this.forceRefreshRequested() ? 1 : 0)),
|
|
410
|
+
]), { defaultValue: undefined });
|
|
401
411
|
while (this.disposeSignal.isUnset) {
|
|
402
|
-
const
|
|
412
|
+
const token = this.token();
|
|
413
|
+
// 1. Wait for login/token if none is present
|
|
414
|
+
if (isUndefined(token)) {
|
|
415
|
+
await firstValueFrom(race([this.definedToken$, from(this.disposeSignal)]), { defaultValue: undefined });
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
403
418
|
try {
|
|
404
|
-
const token = iterationToken;
|
|
405
|
-
if (isUndefined(token)) {
|
|
406
|
-
// Wait for login or dispose.
|
|
407
|
-
// We ignore forceRefreshToken here because we can't refresh without a token.
|
|
408
|
-
await firstValueFrom(race([this.definedToken$, from(this.disposeSignal)]), { defaultValue: undefined });
|
|
409
|
-
continue;
|
|
410
|
-
}
|
|
411
419
|
const now = this.estimatedServerTimestampSeconds();
|
|
412
|
-
const
|
|
413
|
-
const
|
|
414
|
-
|
|
420
|
+
const buffer = calculateRefreshBufferSeconds(token);
|
|
421
|
+
const needsRefresh = this.forceRefreshRequested() || (now >= token.exp - buffer);
|
|
422
|
+
// 2. Handle token refresh
|
|
415
423
|
if (needsRefresh) {
|
|
416
424
|
const lockResult = await this.lock.tryUse(undefined, async () => {
|
|
417
425
|
const currentToken = this.token();
|
|
426
|
+
if (isUndefined(currentToken)) {
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
// Passive Sync: Verify if refresh is still needed once lock is acquired
|
|
418
430
|
const currentNow = this.estimatedServerTimestampSeconds();
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
const stillNeedsRefresh = isDefined(currentToken) && (this.forceRefreshRequested() || (currentNow >= (currentToken.exp - currentRefreshBufferSeconds)));
|
|
431
|
+
const currentBuffer = calculateRefreshBufferSeconds(currentToken);
|
|
432
|
+
const stillNeedsRefresh = this.forceRefreshRequested() || (currentNow >= currentToken.exp - currentBuffer);
|
|
422
433
|
if (stillNeedsRefresh) {
|
|
423
434
|
await this.refresh();
|
|
424
435
|
}
|
|
425
|
-
|
|
426
|
-
this.forceRefreshRequested.set(false);
|
|
427
|
-
}
|
|
436
|
+
this.forceRefreshRequested.set(false);
|
|
428
437
|
});
|
|
438
|
+
// If lock is held by another instance/tab, wait briefly for it to finish (passive sync)
|
|
429
439
|
if (!lockResult.success) {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
this.token$.pipe(filter((t) => t?.jti != token.jti), map(() => 'token')),
|
|
434
|
-
from(this.disposeSignal),
|
|
435
|
-
]), { defaultValue: undefined });
|
|
436
|
-
if (changeReason == 'token') {
|
|
440
|
+
await waitForNextAction(5000, token.exp);
|
|
441
|
+
// If another tab successfully refreshed, the expiration will have changed
|
|
442
|
+
if (this.token()?.exp !== token.exp) {
|
|
437
443
|
this.forceRefreshRequested.set(false);
|
|
438
444
|
}
|
|
439
|
-
continue;
|
|
440
445
|
}
|
|
446
|
+
continue; // Re-evaluate the loop with the newly refreshed (or synced) token
|
|
441
447
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
const currentRefreshBufferSeconds = calculateRefreshBufferSeconds(currentToken);
|
|
447
|
-
const delay = clamp((currentToken.exp - this.estimatedServerTimestampSeconds() - currentRefreshBufferSeconds) * millisecondsPerSecond, minRefreshDelay, maxRefreshDelay);
|
|
448
|
-
const wakeUpSignals = [
|
|
449
|
-
from(this.disposeSignal),
|
|
450
|
-
this.token$.pipe(filter((t) => t?.jti != currentToken.jti)),
|
|
451
|
-
];
|
|
452
|
-
if (!forceRefresh) {
|
|
453
|
-
wakeUpSignals.push(this.forceRefreshRequested$.pipe(filter((requested) => requested)));
|
|
454
|
-
}
|
|
455
|
-
if (delay > 0) {
|
|
456
|
-
await firstValueFrom(race([timer(delay), ...wakeUpSignals]), { defaultValue: undefined });
|
|
457
|
-
}
|
|
458
|
-
else {
|
|
459
|
-
await firstValueFrom(race([timer(2500), ...wakeUpSignals]), { defaultValue: undefined });
|
|
460
|
-
}
|
|
448
|
+
// 3. Calculate delay and sleep until the next scheduled refresh window
|
|
449
|
+
const timeUntilRefreshMs = (token.exp - this.estimatedServerTimestampSeconds() - buffer) * millisecondsPerSecond;
|
|
450
|
+
const delay = clamp(timeUntilRefreshMs, minRefreshDelay, maxRefreshDelay);
|
|
451
|
+
await waitForNextAction(delay, token.exp);
|
|
461
452
|
}
|
|
462
453
|
catch (error) {
|
|
463
454
|
this.logger.error(error);
|
|
464
|
-
|
|
465
|
-
await firstValueFrom(race([
|
|
466
|
-
timer(2500),
|
|
467
|
-
from(this.disposeSignal),
|
|
468
|
-
this.token$.pipe(filter((t) => t?.jti != currentToken?.jti)),
|
|
469
|
-
this.forceRefreshRequested$.pipe(filter((requested) => requested), skip(this.forceRefreshRequested() ? 1 : 0)),
|
|
470
|
-
]), { defaultValue: undefined });
|
|
455
|
+
await waitForNextAction(2500, token.exp);
|
|
471
456
|
}
|
|
472
457
|
}
|
|
473
458
|
}
|
|
@@ -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 {
|
|
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:
|
|
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';
|
|
@@ -59,6 +60,12 @@ export declare class AuthenticationServiceOptions {
|
|
|
59
60
|
* @default 5 days
|
|
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);
|
|
@@ -57,6 +58,12 @@ export class AuthenticationServiceOptions {
|
|
|
57
58
|
* @default 5 days
|
|
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
|
*
|
|
@@ -120,6 +127,7 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
|
|
|
120
127
|
tokenVersion = this.#options.version ?? 1;
|
|
121
128
|
tokenTimeToLive = this.#options.tokenTimeToLive ?? (5 * millisecondsPerMinute);
|
|
122
129
|
refreshTokenTimeToLive = this.#options.refreshTokenTimeToLive ?? (5 * millisecondsPerDay);
|
|
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
|
|
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
|
|
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,7 +1,7 @@
|
|
|
1
1
|
import { NotSupportedError } from '../../errors/not-supported.error.js';
|
|
2
2
|
import { filterUndefinedObjectProperties, fromEntries, hasOwnProperty, objectEntries } from '../../utils/object/object.js';
|
|
3
3
|
import { isDefined, isNotNull, isNumber, isString } from '../../utils/type-guards.js';
|
|
4
|
-
import { ArraySchema, BooleanSchema, DateSchema, DefaultSchema, EnumerationSchema, LiteralSchema,
|
|
4
|
+
import { AnySchema, ArraySchema, BooleanSchema, DateSchema, DefaultSchema, EnumerationSchema, LiteralSchema, NullableSchema, NumberSchema, ObjectSchema, OptionalSchema, StringSchema, TransformSchema, Uint8ArraySchema, UnionSchema, UnknownSchema } from '../schemas/index.js';
|
|
5
5
|
import { schemaTestableToSchema } from '../testable.js';
|
|
6
6
|
export function convertToOpenApiSchema(testable) {
|
|
7
7
|
const schema = schemaTestableToSchema(testable);
|
|
@@ -17,7 +17,7 @@ export function convertToOpenApiSchema(testable) {
|
|
|
17
17
|
function convertToOpenApiSchemaBase(schema) {
|
|
18
18
|
if (schema instanceof ObjectSchema) {
|
|
19
19
|
const entries = objectEntries(schema.properties);
|
|
20
|
-
const convertedEntries = entries.map(([property, propertySchema]) => [property, convertToOpenApiSchema(
|
|
20
|
+
const convertedEntries = entries.map(([property, propertySchema]) => [property, convertToOpenApiSchema(propertySchema)]);
|
|
21
21
|
const required = entries
|
|
22
22
|
.filter(([, propertySchema]) => !(propertySchema instanceof OptionalSchema) && !((propertySchema instanceof NullableSchema) && (propertySchema.schema instanceof OptionalSchema)))
|
|
23
23
|
.map(([property]) => property);
|
|
@@ -29,13 +29,13 @@ function convertToOpenApiSchemaBase(schema) {
|
|
|
29
29
|
maxProperties: isNumber(schema.maximumPropertiesCount) ? schema.maximumPropertiesCount : undefined,
|
|
30
30
|
});
|
|
31
31
|
}
|
|
32
|
-
if (schema instanceof DefaultSchema) {
|
|
32
|
+
if (schema instanceof DefaultSchema) {
|
|
33
33
|
return {
|
|
34
34
|
...convertToOpenApiSchema(schema.schema),
|
|
35
35
|
default: schema.defaultValue,
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
|
-
if (schema instanceof TransformSchema) {
|
|
38
|
+
if (schema instanceof TransformSchema) {
|
|
39
39
|
return convertToOpenApiSchema(schema.schema);
|
|
40
40
|
}
|
|
41
41
|
if (schema instanceof StringSchema) {
|
|
@@ -114,19 +114,16 @@ function convertToOpenApiSchemaBase(schema) {
|
|
|
114
114
|
nullable: true,
|
|
115
115
|
};
|
|
116
116
|
}
|
|
117
|
+
if (schema instanceof OptionalSchema) {
|
|
118
|
+
return convertToOpenApiSchema(schema.schema);
|
|
119
|
+
}
|
|
117
120
|
if (schema instanceof UnionSchema) {
|
|
118
121
|
return {
|
|
119
122
|
anyOf: schema.schemas.map((innerSchema) => convertToOpenApiSchema(innerSchema)),
|
|
120
123
|
};
|
|
121
124
|
}
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
function stripOptional(schema) {
|
|
125
|
-
if ((schema instanceof OptionalSchema)) {
|
|
126
|
-
return schema.schema;
|
|
127
|
-
}
|
|
128
|
-
if ((schema instanceof NullableSchema) && (schema.schema instanceof OptionalSchema)) {
|
|
129
|
-
return nullable(schema.schema.schema);
|
|
125
|
+
if ((schema instanceof AnySchema) || (schema instanceof UnknownSchema)) {
|
|
126
|
+
return {};
|
|
130
127
|
}
|
|
131
|
-
|
|
128
|
+
throw new NotSupportedError(`Schema "${schema.name}" not supported.`);
|
|
132
129
|
}
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
const result = await
|
|
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
|
-
//
|
|
113
|
-
testInInjector('should
|
|
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
|
|