@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.
Files changed (57) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +120 -0
  3. package/dist/index.js +16 -0
  4. package/dist/nodefony/contracts/IOAuthUserProvisioner.js +1 -0
  5. package/dist/nodefony/contracts/IPasswordBlocklist.js +1 -0
  6. package/dist/nodefony/contracts/IPasswordEncoder.js +1 -0
  7. package/dist/nodefony/contracts/IPasswordVerifier.js +1 -0
  8. package/dist/nodefony/contracts/IUser.js +1 -0
  9. package/dist/nodefony/contracts/IUserProfile.js +1 -0
  10. package/dist/nodefony/contracts/IUserProvider.js +1 -0
  11. package/dist/nodefony/contracts/IUserRepository.js +1 -0
  12. package/dist/nodefony/contracts/index.js +1 -0
  13. package/dist/nodefony/errors/UserNotFoundError.js +19 -0
  14. package/dist/nodefony/errors/WeakPasswordError.js +16 -0
  15. package/dist/nodefony/service/UserService.js +280 -0
  16. package/dist/nodefony/src/AnonymousUser.js +37 -0
  17. package/dist/nodefony/src/BaseUser.js +121 -0
  18. package/dist/nodefony/src/InMemoryUserRepository.js +230 -0
  19. package/dist/nodefony/src/admin/UserAdminApi.js +588 -0
  20. package/dist/nodefony/src/encoders/Argon2idEncoder.js +107 -0
  21. package/dist/nodefony/src/encoders/BcryptEncoder.js +75 -0
  22. package/dist/nodefony/src/encoders/MigratingEncoder.js +88 -0
  23. package/dist/nodefony/src/encoders/encoderFromConfig.js +37 -0
  24. package/dist/nodefony/src/userContract.js +247 -0
  25. package/dist/nodefony/src/userFilters.js +66 -0
  26. package/dist/nodefony/src/userProfile.js +195 -0
  27. package/dist/nodefony/src/userSort.js +53 -0
  28. package/dist/nodefony/src/userStoreRegistry.js +41 -0
  29. package/dist/types/index.d.ts +40 -0
  30. package/dist/types/nodefony/contracts/IOAuthUserProvisioner.d.ts +69 -0
  31. package/dist/types/nodefony/contracts/IPasswordBlocklist.d.ts +20 -0
  32. package/dist/types/nodefony/contracts/IPasswordEncoder.d.ts +49 -0
  33. package/dist/types/nodefony/contracts/IPasswordVerifier.d.ts +26 -0
  34. package/dist/types/nodefony/contracts/IUser.d.ts +68 -0
  35. package/dist/types/nodefony/contracts/IUserProfile.d.ts +29 -0
  36. package/dist/types/nodefony/contracts/IUserProvider.d.ts +44 -0
  37. package/dist/types/nodefony/contracts/IUserRepository.d.ts +129 -0
  38. package/dist/types/nodefony/contracts/index.d.ts +7 -0
  39. package/dist/types/nodefony/errors/UserNotFoundError.d.ts +15 -0
  40. package/dist/types/nodefony/errors/WeakPasswordError.d.ts +12 -0
  41. package/dist/types/nodefony/service/UserService.d.ts +179 -0
  42. package/dist/types/nodefony/src/AnonymousUser.d.ts +27 -0
  43. package/dist/types/nodefony/src/BaseUser.d.ts +98 -0
  44. package/dist/types/nodefony/src/InMemoryUserRepository.d.ts +73 -0
  45. package/dist/types/nodefony/src/admin/UserAdminApi.d.ts +119 -0
  46. package/dist/types/nodefony/src/encoders/Argon2idEncoder.d.ts +83 -0
  47. package/dist/types/nodefony/src/encoders/BcryptEncoder.d.ts +55 -0
  48. package/dist/types/nodefony/src/encoders/MigratingEncoder.d.ts +68 -0
  49. package/dist/types/nodefony/src/encoders/encoderFromConfig.d.ts +36 -0
  50. package/dist/types/nodefony/src/userContract.d.ts +198 -0
  51. package/dist/types/nodefony/src/userFilters.d.ts +80 -0
  52. package/dist/types/nodefony/src/userProfile.d.ts +56 -0
  53. package/dist/types/nodefony/src/userSort.d.ts +41 -0
  54. package/dist/types/nodefony/src/userStoreRegistry.d.ts +15 -0
  55. package/docs/ajouter-des-champs.md +189 -0
  56. package/docs/index.md +1113 -0
  57. 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
+ }