@lenne.tech/nest-server 11.21.0 → 11.21.2
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/README.md +444 -100
- package/dist/core/common/helpers/input.helper.js +11 -8
- package/dist/core/common/helpers/input.helper.js.map +1 -1
- package/dist/core/common/interceptors/check-security.interceptor.js +5 -7
- package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +2 -0
- package/dist/core/common/services/email.service.d.ts +5 -1
- package/dist/core/common/services/email.service.js +16 -2
- package/dist/core/common/services/email.service.js.map +1 -1
- package/dist/core/common/services/request-context.service.js +6 -0
- package/dist/core/common/services/request-context.service.js.map +1 -1
- package/dist/core/modules/auth/tokens.decorator.d.ts +1 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +6 -0
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +52 -17
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.service.d.ts +3 -1
- package/dist/core/modules/better-auth/core-better-auth.service.js +14 -0
- package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +16 -2
- package/dist/core/modules/tenant/core-tenant.guard.js +168 -22
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.service.d.ts +3 -1
- package/dist/core/modules/tenant/core-tenant.service.js +14 -4
- package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
- package/dist/core/modules/user/core-user.service.js +12 -1
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +32 -25
- package/src/core/common/helpers/input.helper.ts +24 -9
- package/src/core/common/interceptors/check-security.interceptor.ts +10 -11
- package/src/core/common/interfaces/server-options.interface.ts +45 -0
- package/src/core/common/services/email.service.ts +26 -5
- package/src/core/common/services/request-context.service.ts +8 -0
- package/src/core/modules/better-auth/README.md +20 -1
- package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +86 -21
- package/src/core/modules/better-auth/core-better-auth.service.ts +27 -2
- package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +20 -1
- package/src/core/modules/tenant/README.md +71 -2
- package/src/core/modules/tenant/core-tenant.guard.ts +304 -27
- package/src/core/modules/tenant/core-tenant.service.ts +13 -4
- package/src/core/modules/user/core-user.service.ts +17 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.21.
|
|
3
|
+
"version": "11.21.2",
|
|
4
4
|
"description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node",
|
|
@@ -73,22 +73,22 @@
|
|
|
73
73
|
"node": ">= 20"
|
|
74
74
|
},
|
|
75
75
|
"dependencies": {
|
|
76
|
-
"@apollo/server": "5.
|
|
76
|
+
"@apollo/server": "5.5.0",
|
|
77
77
|
"@as-integrations/express5": "1.1.2",
|
|
78
78
|
"@better-auth/passkey": "1.5.5",
|
|
79
79
|
"@getbrevo/brevo": "3.0.1",
|
|
80
80
|
"@nestjs/apollo": "13.2.4",
|
|
81
|
-
"@nestjs/common": "11.1.
|
|
82
|
-
"@nestjs/core": "11.1.
|
|
81
|
+
"@nestjs/common": "11.1.17",
|
|
82
|
+
"@nestjs/core": "11.1.17",
|
|
83
83
|
"@nestjs/graphql": "13.2.4",
|
|
84
84
|
"@nestjs/jwt": "11.0.2",
|
|
85
85
|
"@nestjs/mongoose": "11.0.4",
|
|
86
86
|
"@nestjs/passport": "11.0.5",
|
|
87
|
-
"@nestjs/platform-express": "11.1.
|
|
87
|
+
"@nestjs/platform-express": "11.1.17",
|
|
88
88
|
"@nestjs/schedule": "6.1.1",
|
|
89
89
|
"@nestjs/swagger": "11.2.6",
|
|
90
90
|
"@nestjs/terminus": "11.1.1",
|
|
91
|
-
"@nestjs/websockets": "11.1.
|
|
91
|
+
"@nestjs/websockets": "11.1.17",
|
|
92
92
|
"@tus/file-store": "2.0.0",
|
|
93
93
|
"@tus/server": "2.3.0",
|
|
94
94
|
"bcrypt": "6.0.0",
|
|
@@ -97,21 +97,21 @@
|
|
|
97
97
|
"class-validator": "0.15.1",
|
|
98
98
|
"compression": "1.8.1",
|
|
99
99
|
"cookie-parser": "1.4.7",
|
|
100
|
-
"dotenv": "17.
|
|
100
|
+
"dotenv": "17.4.0",
|
|
101
101
|
"ejs": "5.0.1",
|
|
102
102
|
"express": "5.2.1",
|
|
103
|
-
"graphql": "16.13.
|
|
103
|
+
"graphql": "16.13.2",
|
|
104
104
|
"graphql-query-complexity": "1.1.0",
|
|
105
105
|
"graphql-subscriptions": "3.0.0",
|
|
106
106
|
"graphql-upload": "15.0.2",
|
|
107
107
|
"js-sha256": "0.11.1",
|
|
108
108
|
"json-to-graphql-query": "2.3.0",
|
|
109
|
-
"lodash": "4.
|
|
110
|
-
"mongodb": "7.1.
|
|
111
|
-
"mongoose": "9.3.
|
|
109
|
+
"lodash": "4.18.1",
|
|
110
|
+
"mongodb": "7.1.1",
|
|
111
|
+
"mongoose": "9.3.3",
|
|
112
112
|
"multer": "2.1.1",
|
|
113
113
|
"node-mailjet": "6.0.11",
|
|
114
|
-
"nodemailer": "8.0.
|
|
114
|
+
"nodemailer": "8.0.4",
|
|
115
115
|
"passport": "0.7.0",
|
|
116
116
|
"passport-jwt": "4.0.1",
|
|
117
117
|
"reflect-metadata": "0.2.2",
|
|
@@ -122,11 +122,11 @@
|
|
|
122
122
|
},
|
|
123
123
|
"devDependencies": {
|
|
124
124
|
"@compodoc/compodoc": "1.2.1",
|
|
125
|
-
"@nestjs/cli": "11.0.
|
|
126
|
-
"@nestjs/schematics": "11.0.
|
|
127
|
-
"@nestjs/testing": "11.1.
|
|
128
|
-
"@swc/cli": "0.8.
|
|
129
|
-
"@swc/core": "1.15.
|
|
125
|
+
"@nestjs/cli": "11.0.17",
|
|
126
|
+
"@nestjs/schematics": "11.0.10",
|
|
127
|
+
"@nestjs/testing": "11.1.17",
|
|
128
|
+
"@swc/cli": "0.8.1",
|
|
129
|
+
"@swc/core": "1.15.21",
|
|
130
130
|
"@types/compression": "1.8.1",
|
|
131
131
|
"@types/cookie-parser": "1.4.10",
|
|
132
132
|
"@types/ejs": "3.1.5",
|
|
@@ -137,16 +137,16 @@
|
|
|
137
137
|
"@types/nodemailer": "7.0.11",
|
|
138
138
|
"@types/passport": "1.0.17",
|
|
139
139
|
"@types/supertest": "7.2.0",
|
|
140
|
-
"@vitest/coverage-v8": "4.1.
|
|
141
|
-
"@vitest/ui": "4.1.
|
|
140
|
+
"@vitest/coverage-v8": "4.1.2",
|
|
141
|
+
"@vitest/ui": "4.1.2",
|
|
142
142
|
"ansi-colors": "4.1.3",
|
|
143
143
|
"find-file-up": "2.0.1",
|
|
144
144
|
"husky": "9.1.7",
|
|
145
145
|
"nodemon": "3.1.14",
|
|
146
146
|
"npm-watch": "0.13.0",
|
|
147
147
|
"otpauth": "9.5.0",
|
|
148
|
-
"oxfmt": "0.
|
|
149
|
-
"oxlint": "1.
|
|
148
|
+
"oxfmt": "0.43.0",
|
|
149
|
+
"oxlint": "1.58.0",
|
|
150
150
|
"rimraf": "6.1.3",
|
|
151
151
|
"supertest": "7.2.2",
|
|
152
152
|
"ts-node": "10.9.2",
|
|
@@ -157,7 +157,7 @@
|
|
|
157
157
|
"vite": "7.3.1",
|
|
158
158
|
"vite-plugin-node": "7.0.0",
|
|
159
159
|
"vite-tsconfig-paths": "6.1.1",
|
|
160
|
-
"vitest": "4.1.
|
|
160
|
+
"vitest": "4.1.2"
|
|
161
161
|
},
|
|
162
162
|
"main": "dist/index.js",
|
|
163
163
|
"types": "dist/index.d.ts",
|
|
@@ -179,12 +179,19 @@
|
|
|
179
179
|
"minimatch@<3.1.4": "3.1.4",
|
|
180
180
|
"minimatch@>=9.0.0 <9.0.7": "9.0.7",
|
|
181
181
|
"minimatch@>=10.0.0 <10.2.3": "10.2.4",
|
|
182
|
-
"rollup@>=4.0.0 <4.
|
|
182
|
+
"rollup@>=4.0.0 <4.60.1": "4.60.1",
|
|
183
183
|
"ajv@<6.14.0": "6.14.0",
|
|
184
184
|
"ajv@>=7.0.0-alpha.0 <8.18.0": "8.18.0",
|
|
185
|
-
"file-type@>=13.0.0 <21.3.2": "21.3.2",
|
|
186
185
|
"undici@>=7.0.0 <7.24.0": "7.24.3",
|
|
187
|
-
"
|
|
186
|
+
"srvx@<0.11.13": "0.11.13",
|
|
187
|
+
"handlebars@>=4.0.0 <4.7.9": "4.7.9",
|
|
188
|
+
"brace-expansion@<1.1.13": "1.1.13",
|
|
189
|
+
"brace-expansion@>=4.0.0 <5.0.5": "5.0.5",
|
|
190
|
+
"picomatch@<2.3.2": "2.3.2",
|
|
191
|
+
"picomatch@>=4.0.0 <4.0.4": "4.0.4",
|
|
192
|
+
"path-to-regexp@>=8.0.0 <8.4.0": "8.4.1",
|
|
193
|
+
"kysely@>=0.26.0 <0.28.15": "0.28.15",
|
|
194
|
+
"lodash@>=4.0.0 <4.18.0": "4.18.1"
|
|
188
195
|
},
|
|
189
196
|
"onlyBuiltDependencies": [
|
|
190
197
|
"bcrypt",
|
|
@@ -783,15 +783,30 @@ export function processDeep(
|
|
|
783
783
|
specialProperties?: string[];
|
|
784
784
|
},
|
|
785
785
|
): any {
|
|
786
|
-
// Set options
|
|
787
|
-
const
|
|
788
|
-
processedObjects: new WeakMap(),
|
|
789
|
-
specialClasses: [],
|
|
790
|
-
specialFunctions: [],
|
|
791
|
-
specialProperties: [],
|
|
792
|
-
...options,
|
|
786
|
+
// Set options once and reuse for all recursive calls (avoids creating new objects per property)
|
|
787
|
+
const resolvedOptions = {
|
|
788
|
+
processedObjects: options?.processedObjects ?? new WeakMap(),
|
|
789
|
+
specialClasses: options?.specialClasses ?? [],
|
|
790
|
+
specialFunctions: options?.specialFunctions ?? [],
|
|
791
|
+
specialProperties: options?.specialProperties ?? [],
|
|
793
792
|
};
|
|
794
793
|
|
|
794
|
+
return processDeepInternal(data, func, resolvedOptions);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** Internal recursive implementation that reuses the resolved options object */
|
|
798
|
+
function processDeepInternal(
|
|
799
|
+
data: any,
|
|
800
|
+
func: (data: any) => any,
|
|
801
|
+
options: {
|
|
802
|
+
processedObjects: WeakMap<any, boolean>;
|
|
803
|
+
specialClasses: ((new (args: any[]) => any) | string)[];
|
|
804
|
+
specialFunctions: string[];
|
|
805
|
+
specialProperties: string[];
|
|
806
|
+
},
|
|
807
|
+
): any {
|
|
808
|
+
const { processedObjects, specialClasses, specialFunctions, specialProperties } = options;
|
|
809
|
+
|
|
795
810
|
// Check for falsifiable values
|
|
796
811
|
if (!data) {
|
|
797
812
|
return func(data);
|
|
@@ -806,7 +821,7 @@ export function processDeep(
|
|
|
806
821
|
|
|
807
822
|
// Process array
|
|
808
823
|
if (Array.isArray(data)) {
|
|
809
|
-
return func(data.map((item) =>
|
|
824
|
+
return func(data.map((item) => processDeepInternal(item, func, options)));
|
|
810
825
|
}
|
|
811
826
|
|
|
812
827
|
// Process object
|
|
@@ -826,7 +841,7 @@ export function processDeep(
|
|
|
826
841
|
}
|
|
827
842
|
}
|
|
828
843
|
for (const [key, value] of Object.entries(data)) {
|
|
829
|
-
data[key] =
|
|
844
|
+
data[key] = processDeepInternal(value, func, options);
|
|
830
845
|
}
|
|
831
846
|
return func(data);
|
|
832
847
|
}
|
|
@@ -62,18 +62,17 @@ export class CheckSecurityInterceptor implements NestInterceptor {
|
|
|
62
62
|
|
|
63
63
|
// Check data
|
|
64
64
|
if (data && typeof data === 'object' && typeof data.securityCheck === 'function') {
|
|
65
|
-
|
|
65
|
+
// Only capture pre-check state when debug is active (JSON.stringify is expensive)
|
|
66
|
+
const dataJson = this.config.debug ? JSON.stringify(data) : undefined;
|
|
66
67
|
const response = data.securityCheck(user, force);
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
});
|
|
68
|
+
if (this.config.debug && dataJson !== JSON.stringify(response)) {
|
|
69
|
+
const id = getStringIds(data);
|
|
70
|
+
console.debug(
|
|
71
|
+
'CheckSecurityInterceptor: securityCheck changed data of type',
|
|
72
|
+
data.constructor.name,
|
|
73
|
+
id && !Array.isArray(id) ? `with ID: ${id}` : '',
|
|
74
|
+
);
|
|
75
|
+
}
|
|
77
76
|
if (response && !data._doNotCheckSecurityDeep) {
|
|
78
77
|
for (const key of Object.keys(response)) {
|
|
79
78
|
response[key] = check(response[key]);
|
|
@@ -895,6 +895,25 @@ export interface IMultiTenancy {
|
|
|
895
895
|
* ```
|
|
896
896
|
*/
|
|
897
897
|
roleHierarchy?: Record<string, number>;
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* TTL in milliseconds for the tenant guard's in-memory membership cache.
|
|
901
|
+
* The cache avoids repeated DB lookups when the same user accesses the same tenant.
|
|
902
|
+
* Set to 0 to disable caching (useful for testing or security-critical deployments).
|
|
903
|
+
*
|
|
904
|
+
* **Important:** This cache is process-local. In horizontally scaled deployments
|
|
905
|
+
* (multiple server instances), membership changes on one instance are not reflected
|
|
906
|
+
* on other instances until the TTL expires. For security-sensitive deployments,
|
|
907
|
+
* reduce the TTL or set to 0 to disable.
|
|
908
|
+
*
|
|
909
|
+
* Note: `CoreBetterAuthUserMapper` has an independent 15-second user cache for
|
|
910
|
+
* roles and verified status. Both caches affect revocation latency. To control both,
|
|
911
|
+
* set this to 0 and override `USER_CACHE_TTL_MS` in a custom mapper.
|
|
912
|
+
*
|
|
913
|
+
* @default 30000 (30 seconds)
|
|
914
|
+
* @since 11.21.1
|
|
915
|
+
*/
|
|
916
|
+
cacheTtlMs?: number;
|
|
898
917
|
}
|
|
899
918
|
|
|
900
919
|
/**
|
|
@@ -2382,6 +2401,32 @@ interface IBetterAuthBase {
|
|
|
2382
2401
|
*/
|
|
2383
2402
|
secret?: string;
|
|
2384
2403
|
|
|
2404
|
+
/**
|
|
2405
|
+
* Skip tenant validation on IAM endpoints.
|
|
2406
|
+
*
|
|
2407
|
+
* When true (default), IAM endpoints (sign-up, sign-in, sign-out, session, etc.)
|
|
2408
|
+
* skip the CoreTenantGuard tenant membership check. This is the correct default
|
|
2409
|
+
* because authentication typically happens BEFORE tenant context is established.
|
|
2410
|
+
*
|
|
2411
|
+
* Set to false for scenarios where tenant context is known at login time:
|
|
2412
|
+
* - Subdomain-based tenancy (tenant-a.crm.example.com)
|
|
2413
|
+
* - Invite links with embedded tenant (crm.example.com/invite/abc123)
|
|
2414
|
+
* - Tenant-specific login pages (crm.example.com/login?org=tenant-a)
|
|
2415
|
+
* - Tenant-specific auth policies (e.g., one tenant requires SSO)
|
|
2416
|
+
*
|
|
2417
|
+
* @default true
|
|
2418
|
+
*
|
|
2419
|
+
* @example
|
|
2420
|
+
* ```typescript
|
|
2421
|
+
* // Default: IAM endpoints skip tenant validation (correct for most cases)
|
|
2422
|
+
* betterAuth: { skipTenantCheck: true }
|
|
2423
|
+
*
|
|
2424
|
+
* // Tenant-aware authentication (subdomain-based tenancy, invite links, etc.)
|
|
2425
|
+
* betterAuth: { skipTenantCheck: false }
|
|
2426
|
+
* ```
|
|
2427
|
+
*/
|
|
2428
|
+
skipTenantCheck?: boolean;
|
|
2429
|
+
|
|
2385
2430
|
/**
|
|
2386
2431
|
* Sign-up checks configuration.
|
|
2387
2432
|
*
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type SMTPPool = require('nodemailer/lib/smtp-pool');
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
|
4
5
|
import nodemailer = require('nodemailer');
|
|
5
6
|
import { Attachment } from 'nodemailer/lib/mailer';
|
|
6
7
|
|
|
@@ -12,7 +13,14 @@ import { TemplateService } from './template.service';
|
|
|
12
13
|
* Email service
|
|
13
14
|
*/
|
|
14
15
|
@Injectable()
|
|
15
|
-
export class EmailService {
|
|
16
|
+
export class EmailService implements OnModuleDestroy {
|
|
17
|
+
/**
|
|
18
|
+
* Cached transporter to avoid creating new SMTP connections per email.
|
|
19
|
+
* Reused as long as the SMTP config hasn't changed.
|
|
20
|
+
*/
|
|
21
|
+
private cachedTransporter: nodemailer.Transporter | null = null;
|
|
22
|
+
private cachedSmtpConfig: string | null = null;
|
|
23
|
+
|
|
16
24
|
/**
|
|
17
25
|
* Inject services
|
|
18
26
|
*/
|
|
@@ -21,6 +29,14 @@ export class EmailService {
|
|
|
21
29
|
protected templateService: TemplateService,
|
|
22
30
|
) {}
|
|
23
31
|
|
|
32
|
+
onModuleDestroy(): void {
|
|
33
|
+
if (this.cachedTransporter) {
|
|
34
|
+
this.cachedTransporter.close();
|
|
35
|
+
this.cachedTransporter = null;
|
|
36
|
+
this.cachedSmtpConfig = null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
24
40
|
/**
|
|
25
41
|
* Send a mail
|
|
26
42
|
*/
|
|
@@ -74,11 +90,16 @@ export class EmailService {
|
|
|
74
90
|
isNonEmptyString(html);
|
|
75
91
|
}
|
|
76
92
|
|
|
77
|
-
//
|
|
78
|
-
|
|
93
|
+
// Reuse transporter if SMTP config hasn't changed (avoids creating new connections per email)
|
|
94
|
+
// Use hash instead of raw JSON to avoid keeping credentials as a string in memory
|
|
95
|
+
const smtpKey = createHash('sha256').update(JSON.stringify(smtp)).digest('hex');
|
|
96
|
+
if (!this.cachedTransporter || this.cachedSmtpConfig !== smtpKey) {
|
|
97
|
+
this.cachedTransporter = nodemailer.createTransport(smtp);
|
|
98
|
+
this.cachedSmtpConfig = smtpKey;
|
|
99
|
+
}
|
|
79
100
|
|
|
80
101
|
// Send mail
|
|
81
|
-
return
|
|
102
|
+
return this.cachedTransporter.sendMail({
|
|
82
103
|
attachments,
|
|
83
104
|
from: `"${senderName}" <${senderEmail}>`,
|
|
84
105
|
html,
|
|
@@ -70,6 +70,10 @@ export class RequestContext {
|
|
|
70
70
|
*/
|
|
71
71
|
static runWithBypassRoleGuard<T>(fn: () => T): T {
|
|
72
72
|
const currentStore = this.storage.getStore();
|
|
73
|
+
// Skip context creation if already bypassed (avoids redundant object spread)
|
|
74
|
+
if (currentStore?.bypassRoleGuard) {
|
|
75
|
+
return fn();
|
|
76
|
+
}
|
|
73
77
|
const context: IRequestContext = {
|
|
74
78
|
...currentStore,
|
|
75
79
|
bypassRoleGuard: true,
|
|
@@ -103,6 +107,10 @@ export class RequestContext {
|
|
|
103
107
|
*/
|
|
104
108
|
static runWithBypassTenantGuard<T>(fn: () => T): T {
|
|
105
109
|
const currentStore = this.storage.getStore();
|
|
110
|
+
// Skip context creation if already bypassed (avoids redundant object spread)
|
|
111
|
+
if (currentStore?.bypassTenantGuard) {
|
|
112
|
+
return fn();
|
|
113
|
+
}
|
|
106
114
|
const context: IRequestContext = {
|
|
107
115
|
...currentStore,
|
|
108
116
|
bypassTenantGuard: true,
|
|
@@ -2137,4 +2137,23 @@ For frontend integration with Better-Auth, see the **[Integration Checklist](./I
|
|
|
2137
2137
|
1. **Password Hashing**: Always hash passwords with SHA256 client-side before sending
|
|
2138
2138
|
2. **2FA Redirect**: Check for `twoFactorRedirect: true` in sign-in response
|
|
2139
2139
|
3. **Passkey Session**: Passkey auth returns session without user - call `validateSession()` to fetch user data
|
|
2140
|
-
4. **Credentials**: Use `credentials: 'include'` for cross-origin cookie handling
|
|
2140
|
+
4. **Credentials**: Use `credentials: 'include'` for cross-origin cookie handling
|
|
2141
|
+
|
|
2142
|
+
---
|
|
2143
|
+
|
|
2144
|
+
## Multi-Tenancy Integration
|
|
2145
|
+
|
|
2146
|
+
When both BetterAuth and Multi-Tenancy (`multiTenancy: {}`) are active, IAM endpoints
|
|
2147
|
+
automatically skip tenant validation by default (`skipTenantCheck: true`). This is the
|
|
2148
|
+
correct default — authentication happens before tenant context is established.
|
|
2149
|
+
|
|
2150
|
+
For tenant-aware authentication scenarios (subdomain-based tenancy, invite links, etc.),
|
|
2151
|
+
set `skipTenantCheck: false`:
|
|
2152
|
+
|
|
2153
|
+
```typescript
|
|
2154
|
+
betterAuth: {
|
|
2155
|
+
skipTenantCheck: false, // IAM endpoints require valid X-Tenant-Id header
|
|
2156
|
+
}
|
|
2157
|
+
```
|
|
2158
|
+
|
|
2159
|
+
See the [CoreTenantModule README](../tenant/README.md#betterauth-iam-integration) for details.
|
|
@@ -97,10 +97,32 @@ export interface SyncedUserDocument {
|
|
|
97
97
|
* - IAM → Legacy: Copies password from `accounts` to `users.password`
|
|
98
98
|
* - Legacy → IAM: Creates account entry in `accounts` from `users.password`
|
|
99
99
|
*/
|
|
100
|
+
/**
|
|
101
|
+
* Cached user data for mapSessionUser (lightweight: only the fields needed for MappedUser)
|
|
102
|
+
*/
|
|
103
|
+
interface CachedUserData {
|
|
104
|
+
expiresAt: number;
|
|
105
|
+
id: string;
|
|
106
|
+
roles: string[];
|
|
107
|
+
verified: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
100
110
|
@Injectable()
|
|
101
111
|
export class CoreBetterAuthUserMapper {
|
|
102
112
|
private readonly logger = new Logger(CoreBetterAuthUserMapper.name);
|
|
103
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Lightweight TTL cache for user DB lookups.
|
|
116
|
+
* Key: iamId (BetterAuth user ID), Value: minimal user data (id, roles, verified).
|
|
117
|
+
* Only caches ~100 bytes per entry (no full documents). Max 500 entries = ~50KB.
|
|
118
|
+
*/
|
|
119
|
+
private readonly userCache = new Map<string, CachedUserData>();
|
|
120
|
+
private static readonly USER_CACHE_MAX = 500;
|
|
121
|
+
|
|
122
|
+
/** Cache TTL: 15s in production, disabled in test environments */
|
|
123
|
+
private static readonly USER_CACHE_TTL_MS =
|
|
124
|
+
process.env.VITEST === 'true' || process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'e2e' ? 0 : 15_000;
|
|
125
|
+
|
|
104
126
|
constructor(@Optional() @InjectConnection() private readonly connection?: Connection) {}
|
|
105
127
|
|
|
106
128
|
/**
|
|
@@ -135,6 +157,23 @@ export class CoreBetterAuthUserMapper {
|
|
|
135
157
|
}
|
|
136
158
|
|
|
137
159
|
try {
|
|
160
|
+
// Check lightweight cache first (only stores id, roles, verified — ~100 bytes per entry)
|
|
161
|
+
const ttl = CoreBetterAuthUserMapper.USER_CACHE_TTL_MS;
|
|
162
|
+
const now = Date.now();
|
|
163
|
+
const cached = ttl > 0 ? this.userCache.get(sessionUser.id) : undefined;
|
|
164
|
+
if (cached && now < cached.expiresAt) {
|
|
165
|
+
return this.createMappedUser({
|
|
166
|
+
email: sessionUser.email,
|
|
167
|
+
emailVerified: sessionUser.emailVerified,
|
|
168
|
+
iamId: sessionUser.id,
|
|
169
|
+
id: cached.id,
|
|
170
|
+
image: sessionUser.image,
|
|
171
|
+
name: sessionUser.name,
|
|
172
|
+
roles: cached.roles,
|
|
173
|
+
verified: cached.verified,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
138
177
|
// Look up the user in our database by email OR iamId
|
|
139
178
|
// This ensures we find the user regardless of which system they signed up with
|
|
140
179
|
const userCollection = this.connection.collection('users');
|
|
@@ -148,6 +187,11 @@ export class CoreBetterAuthUserMapper {
|
|
|
148
187
|
// Use database verified status, fallback to Better-Auth emailVerified
|
|
149
188
|
const verified = dbUser.verified === true || sessionUser.emailVerified === true;
|
|
150
189
|
|
|
190
|
+
// Cache only the minimal data needed (not the full document)
|
|
191
|
+
if (CoreBetterAuthUserMapper.USER_CACHE_TTL_MS > 0) {
|
|
192
|
+
this.cacheUserData(sessionUser.id, { id: dbUser._id.toString(), roles, verified });
|
|
193
|
+
}
|
|
194
|
+
|
|
151
195
|
return this.createMappedUser({
|
|
152
196
|
email: sessionUser.email,
|
|
153
197
|
emailVerified: sessionUser.emailVerified,
|
|
@@ -190,32 +234,53 @@ export class CoreBetterAuthUserMapper {
|
|
|
190
234
|
return {
|
|
191
235
|
...userData,
|
|
192
236
|
_authenticatedViaBetterAuth: true,
|
|
193
|
-
|
|
194
|
-
|
|
237
|
+
// Bind to static method to avoid creating a new closure per request
|
|
238
|
+
hasRole: CoreBetterAuthUserMapper.createHasRole(roles, userData.verified === true),
|
|
239
|
+
roles,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
195
242
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
243
|
+
/**
|
|
244
|
+
* Creates a hasRole function. Uses a shared factory to minimize per-request closure size.
|
|
245
|
+
* The returned function only captures `roles` (string[]) and `verified` (boolean) — no large objects.
|
|
246
|
+
*/
|
|
247
|
+
private static createHasRole(roles: string[], verified: boolean): (checkRoles: string | string[]) => boolean {
|
|
248
|
+
return (checkRoles: string | string[]): boolean => {
|
|
249
|
+
const rolesToCheck = Array.isArray(checkRoles) ? checkRoles : [checkRoles];
|
|
200
250
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
251
|
+
if (rolesToCheck.includes(RoleEnum.S_EVERYONE)) return true;
|
|
252
|
+
if (rolesToCheck.includes(RoleEnum.S_USER)) return true;
|
|
253
|
+
if (rolesToCheck.includes(RoleEnum.S_NO_ONE)) return false;
|
|
254
|
+
if (rolesToCheck.includes(RoleEnum.S_VERIFIED)) return verified;
|
|
204
255
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
256
|
+
return rolesToCheck.some((role) => roles.includes(role));
|
|
257
|
+
};
|
|
258
|
+
}
|
|
208
259
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
260
|
+
/**
|
|
261
|
+
* Store minimal user data in the lightweight cache.
|
|
262
|
+
* Only stores id, roles, verified — no full documents or large objects.
|
|
263
|
+
*/
|
|
264
|
+
private cacheUserData(iamId: string, data: Omit<CachedUserData, 'expiresAt'>): void {
|
|
265
|
+
// Evict oldest if at capacity (simple FIFO)
|
|
266
|
+
if (this.userCache.size >= CoreBetterAuthUserMapper.USER_CACHE_MAX) {
|
|
267
|
+
const firstKey = this.userCache.keys().next().value;
|
|
268
|
+
if (firstKey) this.userCache.delete(firstKey);
|
|
269
|
+
}
|
|
270
|
+
this.userCache.set(iamId, {
|
|
271
|
+
...data,
|
|
272
|
+
expiresAt: Date.now() + CoreBetterAuthUserMapper.USER_CACHE_TTL_MS,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
213
275
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
276
|
+
/**
|
|
277
|
+
* Invalidate cached user data. Call when user roles or verified status change.
|
|
278
|
+
* Called automatically from `CoreUserService.setRoles()` and `CoreUserService.update()`.
|
|
279
|
+
*
|
|
280
|
+
* @param iamId - The BetterAuth user ID (from session/account, not MongoDB _id)
|
|
281
|
+
*/
|
|
282
|
+
invalidateUserCache(iamId: string): void {
|
|
283
|
+
this.userCache.delete(iamId);
|
|
219
284
|
}
|
|
220
285
|
|
|
221
286
|
// ===================================================================================================================
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BadRequestException, Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
|
1
|
+
import { BadRequestException, Inject, Injectable, Logger, OnModuleInit, Optional } from '@nestjs/common';
|
|
2
2
|
import { InjectConnection } from '@nestjs/mongoose';
|
|
3
3
|
import { Request } from 'express';
|
|
4
4
|
import { importJWK, jwtVerify } from 'jose';
|
|
@@ -61,7 +61,7 @@ export const BETTER_AUTH_CONFIG = 'BETTER_AUTH_CONFIG';
|
|
|
61
61
|
export const BETTER_AUTH_COOKIE_DOMAIN = 'BETTER_AUTH_COOKIE_DOMAIN';
|
|
62
62
|
|
|
63
63
|
@Injectable()
|
|
64
|
-
export class CoreBetterAuthService {
|
|
64
|
+
export class CoreBetterAuthService implements OnModuleInit {
|
|
65
65
|
private readonly logger = new Logger(CoreBetterAuthService.name);
|
|
66
66
|
private readonly config: IBetterAuth;
|
|
67
67
|
|
|
@@ -78,6 +78,31 @@ export class CoreBetterAuthService {
|
|
|
78
78
|
this.config = this.resolvedConfig || this.configService?.get<IBetterAuth>('betterAuth') || {};
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Ensure performance indices exist on session and users collections.
|
|
83
|
+
* Indices are idempotent — calling createIndex on an existing index is a no-op.
|
|
84
|
+
*/
|
|
85
|
+
async onModuleInit(): Promise<void> {
|
|
86
|
+
if (!this.isEnabled() || !this.connection?.db) return;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const db = this.connection.db;
|
|
90
|
+
|
|
91
|
+
// Session collection: token lookup (getSessionByToken) and user+expiry lookup (getActiveSessionForUser)
|
|
92
|
+
await db.collection('session').createIndex({ token: 1 });
|
|
93
|
+
await db.collection('session').createIndex({ userId: 1, expiresAt: 1 });
|
|
94
|
+
|
|
95
|
+
// Users collection: iamId lookup (mapSessionUser uses $or with email and iamId)
|
|
96
|
+
// email is typically already indexed by Mongoose schema, but iamId may not be
|
|
97
|
+
await db.collection('users').createIndex({ iamId: 1 }, { sparse: true });
|
|
98
|
+
|
|
99
|
+
this.logger.debug('Performance indices ensured on session and users collections');
|
|
100
|
+
} catch (error) {
|
|
101
|
+
// Non-fatal: indices improve performance but are not required for correctness
|
|
102
|
+
this.logger.warn(`Could not create performance indices: ${error instanceof Error ? error.message : 'unknown'}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
81
106
|
/**
|
|
82
107
|
* Checks if better-auth is enabled and initialized
|
|
83
108
|
* Returns true only if:
|
|
@@ -133,6 +133,23 @@ import { SkipTenantCheck, Roles, RoleEnum } from '@lenne.tech/nest-server';
|
|
|
133
133
|
async listMyTenants() { ... }
|
|
134
134
|
```
|
|
135
135
|
|
|
136
|
+
### 9. BetterAuth (IAM) Coexistence
|
|
137
|
+
|
|
138
|
+
When both `multiTenancy` and `betterAuth` are active, IAM endpoints (sign-in, sign-up, session, etc.)
|
|
139
|
+
automatically skip tenant validation when no `X-Tenant-Id` header is sent (`betterAuth.skipTenantCheck: true`, default).
|
|
140
|
+
If a header IS present, normal membership validation runs.
|
|
141
|
+
|
|
142
|
+
For tenant-aware authentication (subdomain-based, invite links, SSO per tenant), opt out:
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
// config.env.ts
|
|
146
|
+
betterAuth: {
|
|
147
|
+
skipTenantCheck: false, // IAM endpoints require valid X-Tenant-Id header
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
See [Tenant README — BetterAuth Integration](./README.md#betterauth-iam-integration) for details.
|
|
152
|
+
|
|
136
153
|
## Verification Checklist
|
|
137
154
|
|
|
138
155
|
- [ ] `pnpm run build` succeeds
|
|
@@ -143,6 +160,7 @@ async listMyTenants() { ... }
|
|
|
143
160
|
- [ ] Non-member gets 403 "Not a member of this tenant"
|
|
144
161
|
- [ ] Unauthenticated + header → 403 "Authentication required for tenant access"
|
|
145
162
|
- [ ] Public endpoint accessing tenantId-schema without context throws 403 (Safety Net)
|
|
163
|
+
- [ ] IAM endpoints work without `X-Tenant-Id` header when `skipTenantCheck: true` (default)
|
|
146
164
|
|
|
147
165
|
## Security
|
|
148
166
|
|
|
@@ -162,4 +180,5 @@ async listMyTenants() { ... }
|
|
|
162
180
|
| Querying membership without bypass | Empty results due to tenant filter | Use `RequestContext.runWithBypassTenantGuard()` |
|
|
163
181
|
| Public endpoint accessing tenantId-schema | 403 Safety Net exception | Use `@SkipTenantCheck()` + `RequestContext.runWithBypassTenantGuard()` |
|
|
164
182
|
| Passing user-supplied tenantId to create() | Cross-tenant write possible | Let plugin auto-set tenantId from context |
|
|
165
|
-
| Custom hierarchy doesn't match config | Roles fail unexpectedly | Ensure `createHierarchyRoles()` input matches `multiTenancy.roleHierarchy` |
|
|
183
|
+
| Custom hierarchy doesn't match config | Roles fail unexpectedly | Ensure `createHierarchyRoles()` input matches `multiTenancy.roleHierarchy` |
|
|
184
|
+
| `@SkipTenantCheck()` on BetterAuth handler | Redundant since v11.21.2 | Remove — auto-skip handles this via `betterAuth.skipTenantCheck` (default) |
|