@lenne.tech/nest-server 11.28.0 → 11.29.0
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/.claude/rules/better-auth.md +36 -0
- package/.claude/rules/configurable-features.md +1 -0
- package/.claude/rules/testing.md +4 -2
- package/FRAMEWORK-API.md +9 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
- package/dist/core/modules/better-auth/better-auth.config.js +19 -0
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
- package/dist/core/modules/user/core-user.service.js +7 -1
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +12 -0
- package/migration-guides/11.28.0-to-11.28.1.md +155 -0
- package/migration-guides/11.28.1-to-11.29.0.md +231 -0
- package/package.json +31 -26
- package/src/core/common/interfaces/server-options.interface.ts +12 -0
- package/src/core/modules/better-auth/README.md +22 -0
- package/src/core/modules/better-auth/better-auth.config.ts +72 -1
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +1 -1
- package/src/core/modules/user/core-user.service.ts +14 -1
|
@@ -275,6 +275,14 @@ interface SocialProviderConfig {
|
|
|
275
275
|
interface UserFieldConfig {
|
|
276
276
|
defaultValue?: unknown;
|
|
277
277
|
fieldName?: string;
|
|
278
|
+
/**
|
|
279
|
+
* Whether a client may supply this field's value via Better-Auth's native input parsing
|
|
280
|
+
* (sign-up create / update-user). When `false`, Better-Auth rejects client-supplied values
|
|
281
|
+
* (throws FIELD_NOT_ALLOWED on update, silently substitutes the server default on create).
|
|
282
|
+
* Defaults to `true` in Better-Auth when omitted. Used to lock server-managed fields
|
|
283
|
+
* (e.g. `roles`) so authenticated users cannot self-set them.
|
|
284
|
+
*/
|
|
285
|
+
input?: boolean;
|
|
278
286
|
required?: boolean;
|
|
279
287
|
type: BetterAuthFieldType;
|
|
280
288
|
}
|
|
@@ -808,6 +816,28 @@ export function buildTrustedOrigins(
|
|
|
808
816
|
return undefined;
|
|
809
817
|
}
|
|
810
818
|
|
|
819
|
+
/**
|
|
820
|
+
* Server-managed, security-critical user fields whose `input: false` MUST be re-asserted after
|
|
821
|
+
* merging project-supplied `additionalUserFields`, so a project override cannot silently re-open
|
|
822
|
+
* a protected key. Each of these, if client-settable, is a concrete vulnerability:
|
|
823
|
+
*
|
|
824
|
+
* - `roles` → vertical privilege escalation (self-granting `admin`)
|
|
825
|
+
* - `verified` → email-verification bypass
|
|
826
|
+
* - `verifiedAt` → email-verification bypass
|
|
827
|
+
* - `twoFactorEnabled` → self-toggling the 2FA state
|
|
828
|
+
* - `iamId` → identity / account-linking hijack
|
|
829
|
+
*
|
|
830
|
+
* Note: `termsAndPrivacyAcceptedAt` is server-managed by default (input:false above) but is
|
|
831
|
+
* intentionally NOT hard-locked here — a project may legitimately choose to accept a client-set
|
|
832
|
+
* consent timestamp; it is not a privilege boundary.
|
|
833
|
+
*
|
|
834
|
+
* These protections only concern Better-Auth's native input parsing (sign-up / update-user).
|
|
835
|
+
* nest-server's own role assignment (UserService/CrudService `checkRoles` + Mongoose writes,
|
|
836
|
+
* `setRoles`, and the user mapper's native `$set` writes) does NOT use Better-Auth input parsing
|
|
837
|
+
* and is therefore unaffected by `input: false`.
|
|
838
|
+
*/
|
|
839
|
+
const PROTECTED_INPUT_FALSE_KEYS = ['iamId', 'roles', 'twoFactorEnabled', 'verified', 'verifiedAt'] as const;
|
|
840
|
+
|
|
811
841
|
/**
|
|
812
842
|
* Builds the user additional fields configuration.
|
|
813
843
|
* Merges core fields (firstName, lastName, etc.) with custom fields from config.
|
|
@@ -824,6 +854,9 @@ function buildUserFields(config: IBetterAuth): Record<string, UserFieldConfig> {
|
|
|
824
854
|
iamId: {
|
|
825
855
|
defaultValue: null,
|
|
826
856
|
fieldName: 'iamId',
|
|
857
|
+
// Server-managed: linked to the Better-Auth identity by the user mapper (native DB write),
|
|
858
|
+
// never from client input. input:false blocks identity/account-linking hijack.
|
|
859
|
+
input: false,
|
|
827
860
|
type: 'string',
|
|
828
861
|
},
|
|
829
862
|
lastName: {
|
|
@@ -834,45 +867,83 @@ function buildUserFields(config: IBetterAuth): Record<string, UserFieldConfig> {
|
|
|
834
867
|
roles: {
|
|
835
868
|
defaultValue: [],
|
|
836
869
|
fieldName: 'roles',
|
|
870
|
+
// Server-managed: assigned via UserService/CrudService (checkRoles guard) + setRoles,
|
|
871
|
+
// never from client input. input:false blocks vertical privilege escalation
|
|
872
|
+
// (e.g. a self-registered user POSTing {"roles":["admin"]} to /iam/update-user).
|
|
873
|
+
input: false,
|
|
837
874
|
type: 'string[]',
|
|
838
875
|
},
|
|
839
876
|
// Track when terms and privacy policy were accepted (for sign-up checks)
|
|
840
877
|
termsAndPrivacyAcceptedAt: {
|
|
841
878
|
defaultValue: null,
|
|
842
879
|
fieldName: 'termsAndPrivacyAcceptedAt',
|
|
880
|
+
// Server-managed consent timestamp: set by the sign-up flow, not client input.
|
|
881
|
+
input: false,
|
|
843
882
|
type: 'date',
|
|
844
883
|
},
|
|
845
884
|
twoFactorEnabled: {
|
|
846
885
|
defaultValue: false,
|
|
847
886
|
fieldName: 'twoFactorEnabled',
|
|
887
|
+
// Server-managed: toggled by the 2FA enable/disable flow, never from client input.
|
|
888
|
+
// input:false blocks self-toggling the 2FA state.
|
|
889
|
+
input: false,
|
|
848
890
|
type: 'boolean',
|
|
849
891
|
},
|
|
850
892
|
verified: {
|
|
851
893
|
defaultValue: false,
|
|
852
894
|
fieldName: 'verified',
|
|
895
|
+
// Server-managed: synced from Better-Auth email verification, never from client input.
|
|
896
|
+
// input:false blocks email-verification bypass.
|
|
897
|
+
input: false,
|
|
853
898
|
type: 'boolean',
|
|
854
899
|
},
|
|
855
900
|
// Track when email was verified (synced from Better-Auth)
|
|
856
901
|
verifiedAt: {
|
|
857
902
|
defaultValue: null,
|
|
858
903
|
fieldName: 'verifiedAt',
|
|
904
|
+
// Server-managed: synced from Better-Auth email verification, never from client input.
|
|
905
|
+
// input:false blocks email-verification bypass.
|
|
906
|
+
input: false,
|
|
859
907
|
type: 'date',
|
|
860
908
|
},
|
|
861
909
|
};
|
|
862
910
|
|
|
863
911
|
// Merge with custom additional fields from configuration
|
|
864
|
-
// Custom fields can override core fields or add new ones
|
|
912
|
+
// Custom fields can override core fields or add new ones.
|
|
913
|
+
// The `input` flag is carried through so projects can mark their own fields as server-managed.
|
|
865
914
|
if (config.additionalUserFields) {
|
|
866
915
|
for (const [key, field] of Object.entries(config.additionalUserFields)) {
|
|
867
916
|
coreFields[key] = {
|
|
868
917
|
defaultValue: field.defaultValue,
|
|
869
918
|
fieldName: field.fieldName || key,
|
|
919
|
+
input: field.input,
|
|
870
920
|
required: field.required,
|
|
871
921
|
type: field.type,
|
|
872
922
|
};
|
|
873
923
|
}
|
|
874
924
|
}
|
|
875
925
|
|
|
926
|
+
// Security: re-assert input:false on server-managed, security-critical fields AFTER the merge,
|
|
927
|
+
// so a project-supplied additionalUserFields override cannot silently re-open a protected key
|
|
928
|
+
// (privilege escalation, email-verification bypass, 2FA bypass, identity hijack).
|
|
929
|
+
for (const key of PROTECTED_INPUT_FALSE_KEYS) {
|
|
930
|
+
if (coreFields[key]) {
|
|
931
|
+
coreFields[key].input = false;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Security (defense-in-depth): the loop above keys on the well-known object key. A project could
|
|
936
|
+
// still reopen a protected COLUMN by registering a shadow field under a different key that maps to
|
|
937
|
+
// it, e.g. `additionalUserFields: { customRoles: { fieldName: 'roles', input: true } }`. Lock any
|
|
938
|
+
// such field too, so no additionalUserFields entry — regardless of its key — can feed client input
|
|
939
|
+
// into a protected column.
|
|
940
|
+
const protectedColumns = new Set<string>(PROTECTED_INPUT_FALSE_KEYS);
|
|
941
|
+
for (const [key, field] of Object.entries(coreFields)) {
|
|
942
|
+
if (!protectedColumns.has(key) && field.fieldName && protectedColumns.has(field.fieldName)) {
|
|
943
|
+
field.input = false;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
876
947
|
return coreFields;
|
|
877
948
|
}
|
|
878
949
|
|
|
@@ -53,6 +53,19 @@ export abstract class CoreUserService<
|
|
|
53
53
|
serviceOptions = prepareServiceOptionsForCreate(serviceOptions);
|
|
54
54
|
return this.process(
|
|
55
55
|
async (data) => {
|
|
56
|
+
// Application-level email-uniqueness check. The unique index alone is NOT
|
|
57
|
+
// sufficient: on a FRESH database Mongoose builds indexes asynchronously
|
|
58
|
+
// (autoIndex), so an early duplicate sign-up can slip through before the
|
|
59
|
+
// index exists — observed as a load-dependent e2e flake, and the same
|
|
60
|
+
// window exists on a freshly deployed production database. The index
|
|
61
|
+
// remains the backstop for truly concurrent duplicate requests.
|
|
62
|
+
if (data.input?.email) {
|
|
63
|
+
const existing = await this.mainDbModel.findOne({ email: data.input.email }).lean().exec();
|
|
64
|
+
if (existing) {
|
|
65
|
+
throw new BadRequestException('Email address already in use');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
56
69
|
// Create user with verification token
|
|
57
70
|
const currentUserId = serviceOptions?.currentUser?._id;
|
|
58
71
|
const createdUser = new this.mainDbModel({
|
|
@@ -66,7 +79,7 @@ export abstract class CoreUserService<
|
|
|
66
79
|
try {
|
|
67
80
|
await createdUser.save();
|
|
68
81
|
} catch (error) {
|
|
69
|
-
if (error?.errors?.email?.kind === 'unique') {
|
|
82
|
+
if (error?.errors?.email?.kind === 'unique' || error?.code === 11000) {
|
|
70
83
|
throw new BadRequestException('Email address already in use');
|
|
71
84
|
} else {
|
|
72
85
|
throw new UnprocessableEntityException();
|