@nodefony/user 10.0.0-alpha.1
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/LICENSE +544 -0
- package/README.md +120 -0
- package/dist/index.js +16 -0
- package/dist/nodefony/contracts/IOAuthUserProvisioner.js +1 -0
- package/dist/nodefony/contracts/IPasswordBlocklist.js +1 -0
- package/dist/nodefony/contracts/IPasswordEncoder.js +1 -0
- package/dist/nodefony/contracts/IPasswordVerifier.js +1 -0
- package/dist/nodefony/contracts/IUser.js +1 -0
- package/dist/nodefony/contracts/IUserProfile.js +1 -0
- package/dist/nodefony/contracts/IUserProvider.js +1 -0
- package/dist/nodefony/contracts/IUserRepository.js +1 -0
- package/dist/nodefony/contracts/index.js +1 -0
- package/dist/nodefony/errors/UserNotFoundError.js +19 -0
- package/dist/nodefony/errors/WeakPasswordError.js +16 -0
- package/dist/nodefony/service/UserService.js +280 -0
- package/dist/nodefony/src/AnonymousUser.js +37 -0
- package/dist/nodefony/src/BaseUser.js +121 -0
- package/dist/nodefony/src/InMemoryUserRepository.js +230 -0
- package/dist/nodefony/src/admin/UserAdminApi.js +588 -0
- package/dist/nodefony/src/encoders/Argon2idEncoder.js +107 -0
- package/dist/nodefony/src/encoders/BcryptEncoder.js +75 -0
- package/dist/nodefony/src/encoders/MigratingEncoder.js +88 -0
- package/dist/nodefony/src/encoders/encoderFromConfig.js +37 -0
- package/dist/nodefony/src/userContract.js +247 -0
- package/dist/nodefony/src/userFilters.js +66 -0
- package/dist/nodefony/src/userProfile.js +195 -0
- package/dist/nodefony/src/userSort.js +53 -0
- package/dist/nodefony/src/userStoreRegistry.js +41 -0
- package/dist/types/index.d.ts +40 -0
- package/dist/types/nodefony/contracts/IOAuthUserProvisioner.d.ts +69 -0
- package/dist/types/nodefony/contracts/IPasswordBlocklist.d.ts +20 -0
- package/dist/types/nodefony/contracts/IPasswordEncoder.d.ts +49 -0
- package/dist/types/nodefony/contracts/IPasswordVerifier.d.ts +26 -0
- package/dist/types/nodefony/contracts/IUser.d.ts +68 -0
- package/dist/types/nodefony/contracts/IUserProfile.d.ts +29 -0
- package/dist/types/nodefony/contracts/IUserProvider.d.ts +44 -0
- package/dist/types/nodefony/contracts/IUserRepository.d.ts +129 -0
- package/dist/types/nodefony/contracts/index.d.ts +7 -0
- package/dist/types/nodefony/errors/UserNotFoundError.d.ts +15 -0
- package/dist/types/nodefony/errors/WeakPasswordError.d.ts +12 -0
- package/dist/types/nodefony/service/UserService.d.ts +179 -0
- package/dist/types/nodefony/src/AnonymousUser.d.ts +27 -0
- package/dist/types/nodefony/src/BaseUser.d.ts +98 -0
- package/dist/types/nodefony/src/InMemoryUserRepository.d.ts +73 -0
- package/dist/types/nodefony/src/admin/UserAdminApi.d.ts +119 -0
- package/dist/types/nodefony/src/encoders/Argon2idEncoder.d.ts +83 -0
- package/dist/types/nodefony/src/encoders/BcryptEncoder.d.ts +55 -0
- package/dist/types/nodefony/src/encoders/MigratingEncoder.d.ts +68 -0
- package/dist/types/nodefony/src/encoders/encoderFromConfig.d.ts +36 -0
- package/dist/types/nodefony/src/userContract.d.ts +198 -0
- package/dist/types/nodefony/src/userFilters.d.ts +80 -0
- package/dist/types/nodefony/src/userProfile.d.ts +56 -0
- package/dist/types/nodefony/src/userSort.d.ts +41 -0
- package/dist/types/nodefony/src/userStoreRegistry.d.ts +15 -0
- package/docs/ajouter-des-champs.md +189 -0
- package/docs/index.md +1113 -0
- package/package.json +90 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
//#region nodefony/src/userProfile.ts
|
|
2
|
+
/**
|
|
3
|
+
* Logique PURE du profil utilisateur (claims OIDC stockés dans
|
|
4
|
+
* `metadata.profile`) — validation d'un patch client, projection en DTO par
|
|
5
|
+
* allowlist, fusion dans la `metadata` existante. Aucun I/O, aucun service :
|
|
6
|
+
* testable isolément (cœur de la garantie anti-fuite + anti-injection de clé).
|
|
7
|
+
*/
|
|
8
|
+
/** Clés de profil reconnues — allowlist STRICTE (toute autre clé est ignorée). */
|
|
9
|
+
const PROFILE_FIELDS = [
|
|
10
|
+
"givenName",
|
|
11
|
+
"familyName",
|
|
12
|
+
"displayName",
|
|
13
|
+
"email",
|
|
14
|
+
"locale",
|
|
15
|
+
"picture"
|
|
16
|
+
];
|
|
17
|
+
/** Bornes de longueur par champ (anti-abus + cohérence du stockage JSON). */
|
|
18
|
+
const MAX_LEN = {
|
|
19
|
+
givenName: 100,
|
|
20
|
+
familyName: 100,
|
|
21
|
+
displayName: 150,
|
|
22
|
+
email: 254,
|
|
23
|
+
locale: 35,
|
|
24
|
+
picture: 2048
|
|
25
|
+
};
|
|
26
|
+
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
27
|
+
const LOCALE_RE = /^[A-Za-z]{2,3}([-_][A-Za-z0-9]{2,8})*$/;
|
|
28
|
+
/**
|
|
29
|
+
* Borne dure d'un avatar embarqué en **data URL** (image recadrée + redimensionnée
|
|
30
|
+
* CÔTÉ CLIENT, ~256px WebP → ~20 KB). Le cap protège la row user (l'avatar vit
|
|
31
|
+
* dans la DB, choix cloud-native) et borne la surface : un avatar bien compressé
|
|
32
|
+
* reste très en dessous.
|
|
33
|
+
*/
|
|
34
|
+
const MAX_PICTURE_DATA_URL = 131072;
|
|
35
|
+
/**
|
|
36
|
+
* Data URL d'avatar ACCEPTÉE : base64 d'un raster `png`/`jpeg`/`webp` uniquement.
|
|
37
|
+
* **SVG exclu** par construction (un SVG embarque du script → XSS au rendu de
|
|
38
|
+
* l'avatar) ; `gif` exclu (animation/poids). Le base64 est validé (alphabet strict
|
|
39
|
+
* + padding) → ni `javascript:`, ni polyglotte.
|
|
40
|
+
*/
|
|
41
|
+
const DATA_IMAGE_RE = /^data:image\/(?:png|jpe?g|webp);base64,(?:[A-Za-z0-9+/]+={0,2})$/i;
|
|
42
|
+
/**
|
|
43
|
+
* Valide une valeur `picture` : soit une **URL http(s)** (claim OIDC, Gravatar,
|
|
44
|
+
* saisie), soit un **data URL image** raster (avatar uploadé + recadré côté
|
|
45
|
+
* client, stocké en DB). Retourne un message d'erreur, ou `null` si valide.
|
|
46
|
+
*/
|
|
47
|
+
function validatePictureValue(v) {
|
|
48
|
+
if (/^https?:\/\//i.test(v)) return v.length > MAX_LEN.picture ? `picture too long (max ${MAX_LEN.picture})` : null;
|
|
49
|
+
if (/^data:/i.test(v)) {
|
|
50
|
+
if (v.length > MAX_PICTURE_DATA_URL) return "picture data URL too large (max 128KB)";
|
|
51
|
+
return DATA_IMAGE_RE.test(v) ? null : "picture data URL must be base64 png/jpeg/webp (svg/gif rejected)";
|
|
52
|
+
}
|
|
53
|
+
return "picture must be an http(s) URL or a png/jpeg/webp data URL";
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Valide + normalise un patch de profil reçu d'un client. Allowlist stricte :
|
|
57
|
+
* seules les clés {@link PROFILE_FIELDS} sont lues (anti-injection d'une clé
|
|
58
|
+
* `metadata` arbitraire). Chaque valeur est `trim`ée ; une chaîne vide ou `null`
|
|
59
|
+
* = **effacement** du champ (matérialisé `""`, retiré au merge).
|
|
60
|
+
*
|
|
61
|
+
* @param input - corps client (`request.body.profile` admin, ou `request.body`
|
|
62
|
+
* self).
|
|
63
|
+
* @returns `{ ok: true, value }` (patch normalisé) ou `{ ok: false, error }` si
|
|
64
|
+
* une valeur est mal typée, trop longue, ou syntaxiquement invalide.
|
|
65
|
+
*/
|
|
66
|
+
function validateProfilePatch(input) {
|
|
67
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) return {
|
|
68
|
+
ok: false,
|
|
69
|
+
error: "profile must be an object"
|
|
70
|
+
};
|
|
71
|
+
const src = input;
|
|
72
|
+
const value = {};
|
|
73
|
+
for (const field of PROFILE_FIELDS) {
|
|
74
|
+
if (!(field in src)) continue;
|
|
75
|
+
const raw = src[field];
|
|
76
|
+
if (raw === null) {
|
|
77
|
+
value[field] = "";
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (typeof raw !== "string") return {
|
|
81
|
+
ok: false,
|
|
82
|
+
error: `${field} must be a string`
|
|
83
|
+
};
|
|
84
|
+
const v = raw.trim();
|
|
85
|
+
if (v.length === 0) {
|
|
86
|
+
value[field] = "";
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (field === "picture") {
|
|
90
|
+
const err = validatePictureValue(v);
|
|
91
|
+
if (err) return {
|
|
92
|
+
ok: false,
|
|
93
|
+
error: err
|
|
94
|
+
};
|
|
95
|
+
value[field] = v;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (v.length > MAX_LEN[field]) return {
|
|
99
|
+
ok: false,
|
|
100
|
+
error: `${field} too long (max ${MAX_LEN[field]})`
|
|
101
|
+
};
|
|
102
|
+
if (field === "email" && !EMAIL_RE.test(v)) return {
|
|
103
|
+
ok: false,
|
|
104
|
+
error: "email is not a valid address"
|
|
105
|
+
};
|
|
106
|
+
if (field === "locale" && !LOCALE_RE.test(v)) return {
|
|
107
|
+
ok: false,
|
|
108
|
+
error: "locale is not a valid BCP 47 tag"
|
|
109
|
+
};
|
|
110
|
+
value[field] = v;
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
ok: true,
|
|
114
|
+
value
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Projette la `metadata` brute d'une entité vers un {@link IUserProfile} — lecture
|
|
119
|
+
* par **allowlist** (seules les clés connues, typées string non vide). Garantit
|
|
120
|
+
* qu'aucune autre clé de `metadata` (applicative/sensible) n'entre dans le DTO.
|
|
121
|
+
*
|
|
122
|
+
* @param metadata - `entity.metadata` (inconnu : l'entité ORM, pas le contrat).
|
|
123
|
+
*/
|
|
124
|
+
function projectProfile(metadata) {
|
|
125
|
+
const profile = {};
|
|
126
|
+
if (!metadata || typeof metadata !== "object") return profile;
|
|
127
|
+
const raw = metadata.profile;
|
|
128
|
+
if (!raw || typeof raw !== "object") return profile;
|
|
129
|
+
const src = raw;
|
|
130
|
+
for (const field of PROFILE_FIELDS) {
|
|
131
|
+
const v = src[field];
|
|
132
|
+
if (typeof v === "string" && v.length > 0) profile[field] = v;
|
|
133
|
+
}
|
|
134
|
+
return profile;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Fusionne un patch validé dans la `metadata` existante en **préservant les autres
|
|
138
|
+
* clés**. Les champs de profil vidés (`""`) sont RETIRÉS (pas de clé fantôme).
|
|
139
|
+
* Rend la nouvelle `metadata` complète, prête pour `updateOne({ metadata })`.
|
|
140
|
+
*
|
|
141
|
+
* @param metadata - `metadata` actuelle de l'entité (préservée hors `profile`).
|
|
142
|
+
* @param patch - patch validé par {@link validateProfilePatch}.
|
|
143
|
+
*/
|
|
144
|
+
function mergeProfileIntoMetadata(metadata, patch) {
|
|
145
|
+
const base = metadata && typeof metadata === "object" ? { ...metadata } : {};
|
|
146
|
+
const current = base.profile && typeof base.profile === "object" ? base.profile : {};
|
|
147
|
+
const merged = {};
|
|
148
|
+
for (const [k, v] of Object.entries({
|
|
149
|
+
...current,
|
|
150
|
+
...patch
|
|
151
|
+
})) if (typeof v === "string" && v.length > 0) merged[k] = v;
|
|
152
|
+
base.profile = merged;
|
|
153
|
+
return base;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Pré-remplit un profil depuis des **claims OIDC/OAuth** (provisioning JIT d'un
|
|
157
|
+
* Shadow User social). Mappe les claims standard (`given_name`/`family_name`/
|
|
158
|
+
* `name`/`email`/`picture`/`locale`, + `avatar_url` GitHub) vers
|
|
159
|
+
* {@link IUserProfile}, **best-effort par champ** : chaque valeur est validée
|
|
160
|
+
* isolément (bornes/format) et un claim invalide est ignoré sans bloquer les
|
|
161
|
+
* autres ni le login. Les claims viennent d'un tiers → lecture défensive.
|
|
162
|
+
*
|
|
163
|
+
* @param claims - claims/payload du fournisseur (ex. `{...raw, name, email}`).
|
|
164
|
+
*/
|
|
165
|
+
function profileFromClaims(claims) {
|
|
166
|
+
const pick = (...keys) => {
|
|
167
|
+
for (const k of keys) {
|
|
168
|
+
const v = claims[k];
|
|
169
|
+
if (typeof v === "string" && v.trim().length > 0) return v;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
const candidate = {};
|
|
173
|
+
const givenName = pick("given_name", "givenName", "first_name");
|
|
174
|
+
const familyName = pick("family_name", "familyName", "last_name");
|
|
175
|
+
const displayName = pick("name", "displayName");
|
|
176
|
+
const email = pick("email");
|
|
177
|
+
const picture = pick("picture", "avatar_url");
|
|
178
|
+
const locale = pick("locale");
|
|
179
|
+
if (givenName) candidate.givenName = givenName;
|
|
180
|
+
if (familyName) candidate.familyName = familyName;
|
|
181
|
+
if (displayName) candidate.displayName = displayName;
|
|
182
|
+
if (email) candidate.email = email;
|
|
183
|
+
if (picture) candidate.picture = picture;
|
|
184
|
+
if (locale) candidate.locale = locale;
|
|
185
|
+
const result = {};
|
|
186
|
+
for (const field of PROFILE_FIELDS) {
|
|
187
|
+
const value = candidate[field];
|
|
188
|
+
if (value === void 0) continue;
|
|
189
|
+
const parsed = validateProfilePatch({ [field]: value });
|
|
190
|
+
if (parsed.ok && parsed.value[field]) result[field] = parsed.value[field];
|
|
191
|
+
}
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
//#endregion
|
|
195
|
+
export { PROFILE_FIELDS, mergeProfileIntoMetadata, profileFromClaims, projectProfile, validateProfilePatch };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
//#region nodefony/src/userSort.ts
|
|
2
|
+
/**
|
|
3
|
+
* **Le vocabulaire de tri des utilisateurs** — source unique, en noms publics.
|
|
4
|
+
*
|
|
5
|
+
* Il existe parce que cette liste était écrite DEUX fois, et qu'elle avait déjà
|
|
6
|
+
* divergé : l'adapter SQL autorisait `id`, l'adapter Mongo non. Un `?order=id`
|
|
7
|
+
* triait donc en base SQL et se faisait ignorer en base Mongo, sans erreur ni
|
|
8
|
+
* trace — le genre d'écart qui ne se voit qu'en production, sur l'installation
|
|
9
|
+
* d'un tiers.
|
|
10
|
+
*
|
|
11
|
+
* Chaque store la consomme au lieu de la recopier ; ceux qui **concatènent** le
|
|
12
|
+
* nom dans une requête (SQL) continuent de filtrer avec, en défense en
|
|
13
|
+
* profondeur — mais ils filtrent alors sur la même liste que tout le monde.
|
|
14
|
+
*/
|
|
15
|
+
const USER_SORTABLE_FIELDS = [
|
|
16
|
+
"identifier",
|
|
17
|
+
"enabled",
|
|
18
|
+
"createdAt",
|
|
19
|
+
"updatedAt",
|
|
20
|
+
"id"
|
|
21
|
+
];
|
|
22
|
+
/**
|
|
23
|
+
* Sous-ensemble réellement triable par l'annuaire **en mémoire**.
|
|
24
|
+
*
|
|
25
|
+
* `BaseUser` ne porte ni `createdAt` ni `updatedAt` — ce sont des colonnes des
|
|
26
|
+
* schémas persistants, pas des attributs du modèle. Déclarer le vocabulaire
|
|
27
|
+
* complet ici reviendrait à annoncer un tri que le store ne peut pas rendre :
|
|
28
|
+
* la page sortirait dans un ordre arbitraire, sans erreur, et personne ne le
|
|
29
|
+
* verrait. La capacité réduite est donc ANNONCÉE, pas simulée — même doctrine
|
|
30
|
+
* que le store de sessions Redis, qui ne déclare aucun tri du tout.
|
|
31
|
+
*
|
|
32
|
+
* Ce que cela donne à l'usage : `?order=createdAt` trie sur une base SQL ou
|
|
33
|
+
* Mongo, et rend **400** sur le backend mémoire. Un refus explicite vaut mieux
|
|
34
|
+
* qu'un ordre inventé.
|
|
35
|
+
*/
|
|
36
|
+
const USER_SORTABLE_FIELDS_IN_MEMORY = [
|
|
37
|
+
"identifier",
|
|
38
|
+
"enabled",
|
|
39
|
+
"id"
|
|
40
|
+
];
|
|
41
|
+
/**
|
|
42
|
+
* Socle garanti par **tous** les backends d'utilisateurs. C'est ce sur quoi une
|
|
43
|
+
* interface peut compter sans savoir quelle base est branchée.
|
|
44
|
+
*/
|
|
45
|
+
const USER_SORTABLE_FIELDS_COMMON = USER_SORTABLE_FIELDS_IN_MEMORY;
|
|
46
|
+
/**
|
|
47
|
+
* Ordre appliqué quand le client n'en demande aucun : par identifiant croissant
|
|
48
|
+
* — le seul champ toujours présent et unique, donc le seul qui rende une
|
|
49
|
+
* pagination offset déterministe sans départage supplémentaire.
|
|
50
|
+
*/
|
|
51
|
+
const USER_DEFAULT_ORDER = [["identifier", "ASC"]];
|
|
52
|
+
//#endregion
|
|
53
|
+
export { USER_DEFAULT_ORDER, USER_SORTABLE_FIELDS, USER_SORTABLE_FIELDS_COMMON, USER_SORTABLE_FIELDS_IN_MEMORY };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
//#region nodefony/src/userStoreRegistry.ts
|
|
2
|
+
/**
|
|
3
|
+
* Registre des BACKENDS de persistance utilisateur DISPONIBLES : `"memory"`
|
|
4
|
+
* (builtin de ce module) + les adapters ORM chargés (`@nodefony/drizzle`,
|
|
5
|
+
* `@nodefony/mongoose`).
|
|
6
|
+
*
|
|
7
|
+
* Différence avec les stores du framework (token/audit/idempotency/session…) : le
|
|
8
|
+
* dépôt utilisateur n'est PAS résolu par `resolveAutoStore` + un registre par
|
|
9
|
+
* brique — il est provisionné par l'APPLICATION (`provisionUsers`, piloté par
|
|
10
|
+
* `NF_USER_STORE`). Ce registre ne SÉLECTIONNE donc rien : il ÉNUMÈRE ce qui est
|
|
11
|
+
* branchable, pour que l'écran Studio « Stores » montre « résolu parmi disponibles »
|
|
12
|
+
* comme pour les 7 autres briques (l'affichage ne doit pas mentir avec `[resolved]`).
|
|
13
|
+
*
|
|
14
|
+
* Convention-frère de `SessionsService.registerStorage` : chaque adapter déclare son
|
|
15
|
+
* backend à son `onKernelRegister`. Lazy + process-wide (Set alloué au 1er ajout).
|
|
16
|
+
*/
|
|
17
|
+
let stores = null;
|
|
18
|
+
/**
|
|
19
|
+
* Déclare un backend de persistance utilisateur disponible (idempotent).
|
|
20
|
+
*
|
|
21
|
+
* @param name - nom court du backend (`"memory"`, `"drizzle"`, `"mongoose"`).
|
|
22
|
+
*/
|
|
23
|
+
function registerUserStore(name) {
|
|
24
|
+
(stores ??= /* @__PURE__ */ new Set()).add(name);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Liste des backends utilisateur disponibles — alimente le champ `available` du
|
|
28
|
+
* statut user (écran Studio « Stores »). `"memory"` est mis EN TÊTE (baseline
|
|
29
|
+
* builtin, toujours disponible = le repli qui marche partout), les adapters ORM
|
|
30
|
+
* suivent triés. L'ordre est celui affiché dans la colonne « Backends dispo ».
|
|
31
|
+
*
|
|
32
|
+
* @returns noms des backends enregistrés (vide si aucun — jamais `null`).
|
|
33
|
+
*/
|
|
34
|
+
function listUserStores() {
|
|
35
|
+
if (!stores) return [];
|
|
36
|
+
const rest = [...stores].filter((s) => s !== "memory").sort();
|
|
37
|
+
return stores.has("memory") ? ["memory", ...rest] : rest;
|
|
38
|
+
}
|
|
39
|
+
registerUserStore("memory");
|
|
40
|
+
//#endregion
|
|
41
|
+
export { listUserStores, registerUserStore };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nodefony/user` — socle utilisateur de Nodefony (User Core).
|
|
3
|
+
*
|
|
4
|
+
* Module **séparé** de `@nodefony/security` : il porte le contrat `IUser` et ses
|
|
5
|
+
* implémentations de base afin que tout consommateur (security, framework, orm-*,
|
|
6
|
+
* agent, llm, rag, realtime, studio) puisse manipuler un utilisateur **sans tirer
|
|
7
|
+
* toute la couche security** pour un simple type — l'identité est un concept plus
|
|
8
|
+
* large que l'authentification.
|
|
9
|
+
*
|
|
10
|
+
* Lib pure ORM-agnostique : les contrats sont effacés à la compilation, les classes
|
|
11
|
+
* de base (`BaseUser`, `AnonymousUser`, `BcryptEncoder`) et `UserService` sont
|
|
12
|
+
* consommés via DI. Les entités persistées (Mongoose/Drizzle) étendent
|
|
13
|
+
* `BaseUser` ou implémentent `IUser` dans chaque adapter.
|
|
14
|
+
*
|
|
15
|
+
* @remarks P5.5–5.9 livrés (contracts, base users, `UserService` + encoders,
|
|
16
|
+
* adapters Drizzle/Mongoose). `IRole`/`IPermission` différés à P6.8.
|
|
17
|
+
*/
|
|
18
|
+
export type { IUser, IPasswordAuthenticatedUser, ISocialProvider, IPasswordBlocklist, IPasswordEncoder, IPasswordVerifier, IUserProvider, IUserRepository, IUserListQuery, IOAuthProfile, IOAuthProvisionPolicy, IOAuthUserProvisioner, } from "./nodefony/contracts/index.js";
|
|
19
|
+
export { BaseUser } from "./nodefony/src/BaseUser.js";
|
|
20
|
+
export type { IBaseUserOptions } from "./nodefony/src/BaseUser.js";
|
|
21
|
+
export { AnonymousUser, anonymousUser, ROLE_ANONYMOUS, } from "./nodefony/src/AnonymousUser.js";
|
|
22
|
+
export { BcryptEncoder } from "./nodefony/src/encoders/BcryptEncoder.js";
|
|
23
|
+
export { Argon2idEncoder } from "./nodefony/src/encoders/Argon2idEncoder.js";
|
|
24
|
+
export type { Argon2idOptions } from "./nodefony/src/encoders/Argon2idEncoder.js";
|
|
25
|
+
export { MigratingEncoder } from "./nodefony/src/encoders/MigratingEncoder.js";
|
|
26
|
+
export { encoderFromConfig } from "./nodefony/src/encoders/encoderFromConfig.js";
|
|
27
|
+
export type { IEncoderSpec } from "./nodefony/src/encoders/encoderFromConfig.js";
|
|
28
|
+
export { InMemoryUserRepository } from "./nodefony/src/InMemoryUserRepository.js";
|
|
29
|
+
export { USER_SORTABLE_FIELDS, USER_SORTABLE_FIELDS_IN_MEMORY, USER_SORTABLE_FIELDS_COMMON, USER_DEFAULT_ORDER, } from "./nodefony/src/userSort.js";
|
|
30
|
+
export { USER_COLUMNS, USER_TABLE_NAME, attachExtraColumns, missingUserColumns, assertUserContract, } from "./nodefony/src/userContract.js";
|
|
31
|
+
export type { IUserColumn, IUserRow, UserColumnType, UserColumnOrigin, } from "./nodefony/src/userContract.js";
|
|
32
|
+
export { registerUserStore, listUserStores, } from "./nodefony/src/userStoreRegistry.js";
|
|
33
|
+
export { UserService } from "./nodefony/service/UserService.js";
|
|
34
|
+
export type { ICreateUserInput, AuthFailureReason, } from "./nodefony/service/UserService.js";
|
|
35
|
+
export { UserNotFoundError } from "./nodefony/errors/UserNotFoundError.js";
|
|
36
|
+
export { WeakPasswordError } from "./nodefony/errors/WeakPasswordError.js";
|
|
37
|
+
export { createUserAdminApi, registerUserAdminApi, toUserSummary, USER_REVOKED_EVENT, } from "./nodefony/src/admin/UserAdminApi.js";
|
|
38
|
+
export type { IUserSummary, IUserRevokedEvent, } from "./nodefony/src/admin/UserAdminApi.js";
|
|
39
|
+
export type { IUserProfile } from "./nodefony/contracts/IUserProfile.js";
|
|
40
|
+
export { validateProfilePatch, projectProfile, mergeProfileIntoMetadata, profileFromClaims, } from "./nodefony/src/userProfile.js";
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { IUser } from "./IUser.js";
|
|
2
|
+
/**
|
|
3
|
+
* Profil d'identité **normalisé** extrait d'un fournisseur OAuth/OIDC.
|
|
4
|
+
*
|
|
5
|
+
* Forme pivot, indépendante du fournisseur : `@nodefony/security` traduit la
|
|
6
|
+
* réponse propre à chaque provider (ID token OIDC de Google, API REST de
|
|
7
|
+
* GitHub...) vers cette structure unique, que le provisioner consomme sans rien
|
|
8
|
+
* savoir du protocole. Aucun jeton n'y figure (il ne sert qu'à l'authentification,
|
|
9
|
+
* jamais persisté côté Nodefony).
|
|
10
|
+
*/
|
|
11
|
+
export interface IOAuthProfile {
|
|
12
|
+
/** Fournisseur (`"google"`, `"github"`...). */
|
|
13
|
+
readonly provider: string;
|
|
14
|
+
/** Identifiant **stable** du compte chez le fournisseur (`sub` OIDC, `id` GitHub). */
|
|
15
|
+
readonly providerId: string;
|
|
16
|
+
/** Email retourné par le fournisseur, ou `null` (compte sans email exposé). */
|
|
17
|
+
readonly email: string | null;
|
|
18
|
+
/**
|
|
19
|
+
* `true` si le fournisseur **certifie** l'email vérifié. Jamais utilisé pour
|
|
20
|
+
* lier automatiquement un compte local existant (un email non vérifié =
|
|
21
|
+
* vecteur d'usurpation, OWASP) — réservé à un provisioner applicatif averti.
|
|
22
|
+
*/
|
|
23
|
+
readonly emailVerified: boolean;
|
|
24
|
+
/** Nom d'affichage, ou `null`. */
|
|
25
|
+
readonly name: string | null;
|
|
26
|
+
/** Charge utile brute du fournisseur (claims / payload) — pour un provisioner custom. */
|
|
27
|
+
readonly raw: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Politique de provisioning décidée par l'appelant (config `oauth2` de
|
|
31
|
+
* `@nodefony/security`) et transmise au provisioner — celui-ci ne lit aucune
|
|
32
|
+
* config, il applique ce qu'on lui passe (un provisioner applicatif peut ignorer
|
|
33
|
+
* ces valeurs et appliquer sa propre logique).
|
|
34
|
+
*/
|
|
35
|
+
export interface IOAuthProvisionPolicy {
|
|
36
|
+
/** Rôles plats accordés au Shadow User **à la création uniquement**. */
|
|
37
|
+
readonly defaultRoles: string[];
|
|
38
|
+
/**
|
|
39
|
+
* `true` : créer une ligne locale au premier login externe (JIT). `false` :
|
|
40
|
+
* un compte préexistant lié est requis, sinon échec (fail-closed).
|
|
41
|
+
*/
|
|
42
|
+
readonly allowSignup: boolean;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Capability de **provisioning d'un utilisateur OAuth** — pattern *Shadow User*
|
|
46
|
+
* Just-In-Time.
|
|
47
|
+
*
|
|
48
|
+
* Distincte d'{@link IUserProvider} (qui ne fait que **lire** : `loadUserByOAuth`
|
|
49
|
+
* lève si le lien est inconnu). Le provisioning **écrit** : il crée la ligne
|
|
50
|
+
* locale au premier login. Capability optionnelle (duck-typée par
|
|
51
|
+
* `@nodefony/security`) : le framework fournit le point d'extension, l'application
|
|
52
|
+
* branche sa politique (le défaut est `UserService.provisionOAuthUser`).
|
|
53
|
+
*
|
|
54
|
+
* @remarks OAuth = **authentification**, pas autorisation : les rôles sont fixés
|
|
55
|
+
* à la création (`policy.defaultRoles`) puis **jamais réécrits** par un re-login —
|
|
56
|
+
* la base locale reste la source de vérité des droits.
|
|
57
|
+
*/
|
|
58
|
+
export interface IOAuthUserProvisioner {
|
|
59
|
+
/**
|
|
60
|
+
* Retourne l'utilisateur lié au compte externe, en le **créant** si absent
|
|
61
|
+
* (selon `policy.allowSignup`).
|
|
62
|
+
*
|
|
63
|
+
* @param profile - profil normalisé issu du fournisseur.
|
|
64
|
+
* @param policy - rôles par défaut + autorisation de création (JIT).
|
|
65
|
+
* @returns l'utilisateur lié (existant ou nouvellement provisionné).
|
|
66
|
+
* @throws Si le lien est inconnu et que `allowSignup` est `false`.
|
|
67
|
+
*/
|
|
68
|
+
provisionOAuthUser(profile: IOAuthProfile, policy: IOAuthProvisionPolicy): Promise<IUser>;
|
|
69
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contrat de **liste de blocage de mots de passe compromis** (NIST SP 800-63B
|
|
3
|
+
* §5.1.1.2) — consulté à la CRÉATION et au CHANGEMENT de mot de passe, jamais
|
|
4
|
+
* au login (le hash stocké ne permet plus de juger le clair, et refuser un
|
|
5
|
+
* login existant verrouillerait l'utilisateur).
|
|
6
|
+
*
|
|
7
|
+
* Le cœur ne fournit QUE le point d'extension : la source de vérité (top-10k
|
|
8
|
+
* embarqué, fichier d'exploitation, API k-anonymity type HaveIBeenPwned) est un
|
|
9
|
+
* choix de déploiement, pas du ressort du framework. Brancher une implémentation
|
|
10
|
+
* via `UserService.passwordBlocklist`.
|
|
11
|
+
*/
|
|
12
|
+
export interface IPasswordBlocklist {
|
|
13
|
+
/**
|
|
14
|
+
* Le mot de passe en clair est-il connu-compromis / interdit ?
|
|
15
|
+
*
|
|
16
|
+
* @param plain - mot de passe candidat (jamais journalisé par l'implémentation).
|
|
17
|
+
* @returns `true` si le mot de passe doit être refusé.
|
|
18
|
+
*/
|
|
19
|
+
isBlocked(plain: string): Promise<boolean>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contrat d'un encodeur de mot de passe — abstraction de l'algorithme de hachage.
|
|
3
|
+
*
|
|
4
|
+
* Implémenté par `BcryptEncoder` (P5.6, rounds: 12 par défaut), interchangeable
|
|
5
|
+
* (argon2, scrypt...) sans toucher au reste de la chaîne d'authentification.
|
|
6
|
+
* Consommé par `@nodefony/security` (`UserPasswordAuthenticator`) et `UserService`.
|
|
7
|
+
*
|
|
8
|
+
* Toutes les opérations sont asynchrones : le hachage est volontairement coûteux
|
|
9
|
+
* en CPU et ne doit jamais bloquer la boucle d'événements.
|
|
10
|
+
*/
|
|
11
|
+
export interface IPasswordEncoder {
|
|
12
|
+
/**
|
|
13
|
+
* Indique si un hash stocké est au format de CET encodeur (parsing pur, sync).
|
|
14
|
+
*
|
|
15
|
+
* Permet à un composite ({@link MigratingEncoder}) de router la vérification
|
|
16
|
+
* vers le bon algorithme sans connaître les formats — chaque encodeur
|
|
17
|
+
* reconnaît son propre préfixe PHC (`$2b$…` bcrypt, `$argon2id$…` argon2).
|
|
18
|
+
*
|
|
19
|
+
* @param hash - hash stocké à inspecter.
|
|
20
|
+
* @returns `true` si ce hash a été produit par cet algorithme.
|
|
21
|
+
*/
|
|
22
|
+
supports(hash: string): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Hache un mot de passe en clair (sel inclus dans la sortie).
|
|
25
|
+
*
|
|
26
|
+
* @param plain - mot de passe en clair.
|
|
27
|
+
* @returns le hash à persister.
|
|
28
|
+
*/
|
|
29
|
+
hash(plain: string): Promise<string>;
|
|
30
|
+
/**
|
|
31
|
+
* Vérifie qu'un mot de passe en clair correspond à un hash stocké.
|
|
32
|
+
*
|
|
33
|
+
* Comparaison en temps constant déléguée à l'implémentation (anti-timing).
|
|
34
|
+
*
|
|
35
|
+
* @param plain - mot de passe en clair fourni à la connexion.
|
|
36
|
+
* @param hash - hash stocké pour l'utilisateur.
|
|
37
|
+
* @returns `true` si la correspondance est valide.
|
|
38
|
+
*/
|
|
39
|
+
verify(plain: string, hash: string): Promise<boolean>;
|
|
40
|
+
/**
|
|
41
|
+
* Indique si un hash devrait être recalculé (paramètres de coût obsolètes).
|
|
42
|
+
*
|
|
43
|
+
* Permet la migration transparente du coût (ex. rounds augmentés) au prochain login.
|
|
44
|
+
*
|
|
45
|
+
* @param hash - hash stocké à inspecter.
|
|
46
|
+
* @returns `true` si un re-hash est recommandé.
|
|
47
|
+
*/
|
|
48
|
+
needsRehash(hash: string): boolean;
|
|
49
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { IUser } from "./IUser.js";
|
|
2
|
+
/**
|
|
3
|
+
* Contrat de **vérification d'un credential mot de passe** — le guichet auquel
|
|
4
|
+
* `@nodefony/security` présente un couple identifiant/mot de passe et qui répond
|
|
5
|
+
* par un verdict, jamais par un hash.
|
|
6
|
+
*
|
|
7
|
+
* Complète {@link IUserProvider} (fourniture d'identité, lecture seule) : ici on
|
|
8
|
+
* VALIDE un credential. Le hash ne traverse jamais cette frontière — la
|
|
9
|
+
* comparaison (encoder, leurre anti-timing, re-hash transparent) reste du côté
|
|
10
|
+
* de l'implémentation ({@link UserService.authenticate} en est la référence).
|
|
11
|
+
* Une source custom (LDAP, SSO maison) s'authentifie par mot de passe en
|
|
12
|
+
* implémentant ce seul contrat, sans rien connaître du firewall.
|
|
13
|
+
*/
|
|
14
|
+
export interface IPasswordVerifier {
|
|
15
|
+
/**
|
|
16
|
+
* Vérifie un couple identifiant/mot de passe.
|
|
17
|
+
*
|
|
18
|
+
* @param identifier - identifiant fonctionnel saisi (email, login...).
|
|
19
|
+
* @param plain - mot de passe en clair saisi.
|
|
20
|
+
* @returns l'utilisateur authentifié, ou `null` si le credential est invalide
|
|
21
|
+
* (identifiant inconnu, compte inactif/verrouillé, mot de passe faux) — la
|
|
22
|
+
* raison fine n'est PAS exposée ici (anti-énumération) ; elle part dans les
|
|
23
|
+
* events d'audit de l'implémentation.
|
|
24
|
+
*/
|
|
25
|
+
authenticate(identifier: string, plain: string): Promise<IUser | null>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Référence à un compte d'un fournisseur d'identité externe (OAuth / OIDC).
|
|
3
|
+
*
|
|
4
|
+
* Stocké en **JSON** sur l'utilisateur (`BaseUser.socialProviders`) plutôt qu'en
|
|
5
|
+
* colonnes dédiées (`googleId`, `githubId`...) : ajouter un provider ne demande
|
|
6
|
+
* aucune migration de schéma. Support du pattern *Shadow User* (une ligne locale
|
|
7
|
+
* est créée même pour une authentification 100 % externe).
|
|
8
|
+
*/
|
|
9
|
+
export interface ISocialProvider {
|
|
10
|
+
/** Identifiant du fournisseur (`"google"`, `"github"`, `"microsoft"`...). */
|
|
11
|
+
readonly provider: string;
|
|
12
|
+
/** Identifiant du compte chez le fournisseur. */
|
|
13
|
+
readonly providerId: string;
|
|
14
|
+
/** Date de liaison du compte. */
|
|
15
|
+
readonly createdAt: Date;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Contrat **strict** d'un utilisateur Nodefony — surface minimale garantie au framework.
|
|
19
|
+
*
|
|
20
|
+
* C'est le type manipulé par la grande majorité des consommateurs (framework,
|
|
21
|
+
* adapters ORM, agent, llm, rag, realtime, studio) : il ne porte que l'identité
|
|
22
|
+
* et les rôles, jamais de credential ni de champ persistant. Les implémentations
|
|
23
|
+
* concrètes ({@link BaseUser}, entités ORM) l'enrichissent.
|
|
24
|
+
*
|
|
25
|
+
* Les rôles sont **plats** (`string[]`) : la résolution de hiérarchie
|
|
26
|
+
* (`roleHierarchy`) est du ressort de `@nodefony/security`, pas du modèle. Garder
|
|
27
|
+
* la liste plate sert la perf (lecture via ALS à chaque requête) et des logs
|
|
28
|
+
* structurés non ambigus.
|
|
29
|
+
*/
|
|
30
|
+
export interface IUser {
|
|
31
|
+
/** Identifiant interne — UUID (jamais `string | number`). */
|
|
32
|
+
readonly id: string;
|
|
33
|
+
/** Identifiant fonctionnel d'authentification (email, login...). Unique. */
|
|
34
|
+
readonly identifier: string;
|
|
35
|
+
/** Rôles **plats** accordés (sans hiérarchie résolue). */
|
|
36
|
+
readonly roles: string[];
|
|
37
|
+
/**
|
|
38
|
+
* Indique si l'utilisateur possède le rôle exact donné (sans hiérarchie).
|
|
39
|
+
*
|
|
40
|
+
* @param role - rôle recherché (ex. `"ROLE_ADMIN"`).
|
|
41
|
+
* @returns `true` si présent dans {@link roles}.
|
|
42
|
+
*/
|
|
43
|
+
hasRole(role: string): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Indique si le compte est actif (activé et non expiré).
|
|
46
|
+
*
|
|
47
|
+
* @returns `false` désactive l'authentification, indépendamment des credentials.
|
|
48
|
+
*/
|
|
49
|
+
isActive(): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Indique si le compte est verrouillé (ex. trop d'échecs de connexion).
|
|
52
|
+
*
|
|
53
|
+
* @returns `true` empêche l'authentification même avec des credentials valides.
|
|
54
|
+
*/
|
|
55
|
+
isLocked(): boolean;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Utilisateur **porteur d'un credential mot de passe local** — extension de {@link IUser}.
|
|
59
|
+
*
|
|
60
|
+
* Séparé de `IUser` pour garder le contrat de base pur : seuls `@nodefony/security`
|
|
61
|
+
* (`UserPasswordAuthenticator`) et un {@link IPasswordEncoder} ont besoin du hash —
|
|
62
|
+
* 90 % des consommateurs (affichage, autorisation) n'ont rien à faire d'un credential.
|
|
63
|
+
* Un compte 100 % OAuth a `password === null`.
|
|
64
|
+
*/
|
|
65
|
+
export interface IPasswordAuthenticatedUser extends IUser {
|
|
66
|
+
/** Hash du mot de passe stocké, ou `null` pour un compte sans mot de passe local. */
|
|
67
|
+
readonly password: string | null;
|
|
68
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profil **public** d'un utilisateur — sous-ensemble des *standard claims* OpenID
|
|
3
|
+
* Connect (OIDC §5.1) en camelCase (convention Nodefony). Stocké dans
|
|
4
|
+
* `BaseUser.metadata.profile`, PAS dans des colonnes dédiées : `IUser` reste
|
|
5
|
+
* identité + rôles (le profil = donnée d'affichage anti-migration). Exposé par
|
|
6
|
+
* **allowlist** dans le DTO admin (`IUserSummary.profile`) — les autres clés de
|
|
7
|
+
* `metadata` (applicatives, potentiellement sensibles) ne fuitent jamais.
|
|
8
|
+
*
|
|
9
|
+
* Générique par construction : ces claims sont fournis par tout fournisseur OAuth
|
|
10
|
+
* (le provisioning JIT pourra les pré-remplir). Les champs MÉTIER (société,
|
|
11
|
+
* service…) restent libres dans `metadata`, hors de cette allowlist typée.
|
|
12
|
+
*
|
|
13
|
+
* Tous les champs sont optionnels (un compte fraîchement créé n'a pas de profil).
|
|
14
|
+
*/
|
|
15
|
+
export interface IUserProfile {
|
|
16
|
+
/** Prénom — OIDC `given_name`. */
|
|
17
|
+
givenName?: string;
|
|
18
|
+
/** Nom de famille — OIDC `family_name`. */
|
|
19
|
+
familyName?: string;
|
|
20
|
+
/** Nom affiché — OIDC `name` (sinon dérivable « givenName familyName »). */
|
|
21
|
+
displayName?: string;
|
|
22
|
+
/** Adresse e-mail de contact — OIDC `email` (distincte de l'`identifier`). */
|
|
23
|
+
email?: string;
|
|
24
|
+
/** Préférence de langue — OIDC `locale` (BCP 47, ex. `fr-FR`). */
|
|
25
|
+
locale?: string;
|
|
26
|
+
/** URL d'avatar — OIDC `picture` (http/https). */
|
|
27
|
+
picture?: string;
|
|
28
|
+
}
|
|
29
|
+
export default IUserProfile;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { IUser } from "./IUser.js";
|
|
2
|
+
/**
|
|
3
|
+
* Contrat de **fourniture** d'utilisateurs — la source d'identité (DB, LDAP, SSO...).
|
|
4
|
+
*
|
|
5
|
+
* Consommé par les authenticators de `@nodefony/security` : il découple la
|
|
6
|
+
* stratégie d'authentification du stockage. Une implémentation typique s'appuie
|
|
7
|
+
* sur un {@link IUserRepository} ; un plugin externe (`@nodefony/auth-ldap`)
|
|
8
|
+
* implémente ce contrat sans dépendre du firewall.
|
|
9
|
+
*
|
|
10
|
+
* @remarks Les méthodes **lèvent** une erreur si l'utilisateur est introuvable
|
|
11
|
+
* (jamais `null`) — l'absence d'identité est un échec d'authentification explicite.
|
|
12
|
+
*/
|
|
13
|
+
export interface IUserProvider {
|
|
14
|
+
/**
|
|
15
|
+
* Charge un utilisateur par son identifiant fonctionnel (email, login...).
|
|
16
|
+
*
|
|
17
|
+
* @param identifier - identifiant unique recherché.
|
|
18
|
+
* @returns l'utilisateur correspondant.
|
|
19
|
+
* @throws Si aucun utilisateur ne correspond.
|
|
20
|
+
*/
|
|
21
|
+
loadUserByIdentifier(identifier: string): Promise<IUser>;
|
|
22
|
+
/**
|
|
23
|
+
* Charge un utilisateur lié à un compte d'un fournisseur OAuth/OIDC.
|
|
24
|
+
*
|
|
25
|
+
* Support du pattern *Shadow User* : l'implémentation peut créer une ligne
|
|
26
|
+
* locale au premier login externe.
|
|
27
|
+
*
|
|
28
|
+
* @param provider - fournisseur (`"google"`, `"github"`...).
|
|
29
|
+
* @param providerId - identifiant du compte chez le fournisseur.
|
|
30
|
+
* @returns l'utilisateur (existant ou nouvellement provisionné).
|
|
31
|
+
* @throws Si le lien est introuvable et qu'aucun provisionnement n'est possible.
|
|
32
|
+
*/
|
|
33
|
+
loadUserByOAuth(provider: string, providerId: string): Promise<IUser>;
|
|
34
|
+
/**
|
|
35
|
+
* Recharge un utilisateur depuis la source (rôles/état à jour) à chaque requête.
|
|
36
|
+
*
|
|
37
|
+
* Indispensable pour révoquer un accès sans attendre l'expiration du token.
|
|
38
|
+
*
|
|
39
|
+
* @param user - utilisateur (potentiellement périmé) à rafraîchir.
|
|
40
|
+
* @returns la version fraîche de l'utilisateur.
|
|
41
|
+
* @throws Si l'utilisateur n'existe plus (compte supprimé).
|
|
42
|
+
*/
|
|
43
|
+
refreshUser(user: IUser): Promise<IUser>;
|
|
44
|
+
}
|