@kourouma/express-authkit 0.0.0-stage → 0.1.0-rc.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 (62) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/LICENSE +21 -0
  3. package/README.md +218 -2
  4. package/SECURITY.md +30 -0
  5. package/bin/express-authkit.js +30 -0
  6. package/package.json +93 -4
  7. package/src/adapters/express/bearer-token.js +35 -0
  8. package/src/adapters/express/http-errors.js +89 -0
  9. package/src/adapters/express/index.js +405 -0
  10. package/src/config/assertions.js +80 -0
  11. package/src/config/auth-kit-options.js +61 -0
  12. package/src/config/defaults.js +66 -0
  13. package/src/config/jwt-options.js +86 -0
  14. package/src/config/password-options.js +38 -0
  15. package/src/config/refresh-token-options.js +49 -0
  16. package/src/config/secret-strength.js +112 -0
  17. package/src/core/authentication-service.js +278 -0
  18. package/src/core/authorization.js +156 -0
  19. package/src/core/contracts/assert-contract.js +26 -0
  20. package/src/core/contracts/auth-principal.js +36 -0
  21. package/src/core/contracts/authentication-service.js +44 -0
  22. package/src/core/contracts/password-provider.js +27 -0
  23. package/src/core/contracts/refresh-token-store.js +80 -0
  24. package/src/core/contracts/token-provider.js +45 -0
  25. package/src/core/contracts/user-repository.js +157 -0
  26. package/src/core/errors.js +258 -0
  27. package/src/core/input-limits.js +26 -0
  28. package/src/core/messages.js +239 -0
  29. package/src/core/refresh-token-service.js +149 -0
  30. package/src/factory/create-auth-kit.js +90 -0
  31. package/src/index.js +61 -0
  32. package/src/providers/argon2-password-provider.js +68 -0
  33. package/src/providers/in-memory-refresh-token-store.js +51 -0
  34. package/src/providers/jwt-token-provider.js +112 -0
  35. package/types/adapters/express/bearer-token.d.ts +11 -0
  36. package/types/adapters/express/http-errors.d.ts +30 -0
  37. package/types/adapters/express/index.d.ts +57 -0
  38. package/types/config/assertions.d.ts +40 -0
  39. package/types/config/auth-kit-options.d.ts +41 -0
  40. package/types/config/defaults.d.ts +55 -0
  41. package/types/config/jwt-options.d.ts +42 -0
  42. package/types/config/password-options.d.ts +23 -0
  43. package/types/config/refresh-token-options.d.ts +21 -0
  44. package/types/config/secret-strength.d.ts +7 -0
  45. package/types/core/authentication-service.d.ts +37 -0
  46. package/types/core/authorization.d.ts +40 -0
  47. package/types/core/contracts/assert-contract.d.ts +13 -0
  48. package/types/core/contracts/auth-principal.d.ts +38 -0
  49. package/types/core/contracts/authentication-service.d.ts +102 -0
  50. package/types/core/contracts/password-provider.d.ts +32 -0
  51. package/types/core/contracts/refresh-token-store.d.ts +105 -0
  52. package/types/core/contracts/token-provider.d.ts +79 -0
  53. package/types/core/contracts/user-repository.d.ts +112 -0
  54. package/types/core/errors.d.ts +213 -0
  55. package/types/core/input-limits.d.ts +20 -0
  56. package/types/core/messages.d.ts +141 -0
  57. package/types/core/refresh-token-service.d.ts +31 -0
  58. package/types/factory/create-auth-kit.d.ts +52 -0
  59. package/types/index.d.ts +60 -0
  60. package/types/providers/argon2-password-provider.d.ts +13 -0
  61. package/types/providers/in-memory-refresh-token-store.d.ts +11 -0
  62. package/types/providers/jwt-token-provider.d.ts +13 -0
@@ -0,0 +1,38 @@
1
+ import { assertIntegerInRange, assertKnownKeys, assertPlainObject } from "./assertions.js";
2
+ import { ARGON2_DEFAULTS, ARGON2_LIMITS } from "./defaults.js";
3
+
4
+ /**
5
+ * Paramètres de coût Argon2id acceptés dans `createAuthKit({ password })`.
6
+ *
7
+ * @typedef {object} PasswordOptions
8
+ * @property {number} [memoryCost=65536] Mémoire en Kio (minimum 19 456, soit 19 Mio).
9
+ * @property {number} [timeCost=3] Nombre d’itérations (minimum 2).
10
+ * @property {number} [parallelism=4] Niveau de parallélisme (1 à 16).
11
+ */
12
+
13
+ /**
14
+ * @typedef {Readonly<Required<PasswordOptions>>} ResolvedPasswordOptions
15
+ */
16
+
17
+ const ALLOWED_KEYS = Object.freeze(["memoryCost", "timeCost", "parallelism"]);
18
+
19
+ /**
20
+ * Valide les options Argon2id et refuse les paramètres sous le seuil OWASP.
21
+ *
22
+ * @param {unknown} [options]
23
+ * @returns {ResolvedPasswordOptions}
24
+ * @throws {import("../core/errors.js").ConfigurationError}
25
+ */
26
+ export function resolvePasswordOptions(options = {}) {
27
+ assertPlainObject(options, "password");
28
+ assertKnownKeys(options, ALLOWED_KEYS, "password");
29
+
30
+ const memoryCost = options.memoryCost ?? ARGON2_DEFAULTS.memoryCost;
31
+ const timeCost = options.timeCost ?? ARGON2_DEFAULTS.timeCost;
32
+ const parallelism = options.parallelism ?? ARGON2_DEFAULTS.parallelism;
33
+ assertIntegerInRange(memoryCost, "password.memoryCost", ARGON2_LIMITS.minMemoryCost, ARGON2_LIMITS.maxMemoryCost);
34
+ assertIntegerInRange(timeCost, "password.timeCost", ARGON2_LIMITS.minTimeCost, ARGON2_LIMITS.maxTimeCost);
35
+ assertIntegerInRange(parallelism, "password.parallelism", ARGON2_LIMITS.minParallelism, ARGON2_LIMITS.maxParallelism);
36
+
37
+ return Object.freeze({ memoryCost, timeCost, parallelism });
38
+ }
@@ -0,0 +1,49 @@
1
+ import { assertRefreshTokenStore } from "../core/contracts/refresh-token-store.js";
2
+ import { ConfigurationError } from "../core/errors.js";
3
+ import { DEVELOPER_MESSAGES } from "../core/messages.js";
4
+ import { assertIntegerInRange, assertKnownKeys, assertPlainObject } from "./assertions.js";
5
+ import { REFRESH_TOKEN_DEFAULTS, REFRESH_TOKEN_LIMITS } from "./defaults.js";
6
+
7
+ /**
8
+ * Options acceptées dans `createAuthKit({ refreshTokens })`.
9
+ *
10
+ * @typedef {object} RefreshTokenOptions
11
+ * @property {import("../core/contracts/refresh-token-store.js").RefreshTokenStore} store
12
+ * Stockage des jetons (votre base de données, ou `createInMemoryRefreshTokenStore()` en développement).
13
+ * @property {number} [expiresInSeconds=1209600] Durée de vie d’un jeton de rafraîchissement
14
+ * (14 jours par défaut, de 5 minutes à 90 jours). Chaque rotation repart de zéro.
15
+ */
16
+
17
+ /**
18
+ * @typedef {Readonly<{
19
+ * store: import("../core/contracts/refresh-token-store.js").RefreshTokenStore,
20
+ * expiresInSeconds: number
21
+ * }>} ResolvedRefreshTokenOptions
22
+ */
23
+
24
+ const ALLOWED_KEYS = Object.freeze(["store", "expiresInSeconds"]);
25
+
26
+ /**
27
+ * @param {unknown} options
28
+ * @returns {ResolvedRefreshTokenOptions}
29
+ * @throws {ConfigurationError | import("../core/errors.js").ContractViolationError}
30
+ */
31
+ export function resolveRefreshTokenOptions(options) {
32
+ assertPlainObject(options, "refreshTokens");
33
+ assertKnownKeys(options, ALLOWED_KEYS, "refreshTokens");
34
+
35
+ if (options.store === undefined || options.store === null) {
36
+ throw new ConfigurationError(DEVELOPER_MESSAGES.refreshStoreRequired);
37
+ }
38
+ assertRefreshTokenStore(options.store);
39
+
40
+ const expiresInSeconds = options.expiresInSeconds ?? REFRESH_TOKEN_DEFAULTS.expiresInSeconds;
41
+ assertIntegerInRange(
42
+ expiresInSeconds,
43
+ "refreshTokens.expiresInSeconds",
44
+ REFRESH_TOKEN_LIMITS.minExpiresInSeconds,
45
+ REFRESH_TOKEN_LIMITS.maxExpiresInSeconds
46
+ );
47
+
48
+ return Object.freeze({ store: options.store, expiresInSeconds });
49
+ }
@@ -0,0 +1,112 @@
1
+ import { ConfigurationError } from "../core/errors.js";
2
+ import { DEVELOPER_MESSAGES, secretTooShort } from "../core/messages.js";
3
+
4
+ /**
5
+ * Détection heuristique des secrets JWT manifestement faibles.
6
+ *
7
+ * Ce contrôle ne peut pas prouver qu’un secret est aléatoire : il rejette
8
+ * seulement les erreurs fréquentes (valeurs d’exemple, courtes ou répétées).
9
+ * Générez toujours les secrets avec un générateur aléatoire sûr, par exemple
10
+ * `npx express-authkit secret`.
11
+ *
12
+ * @module config/secret-strength
13
+ */
14
+
15
+ /** Fragments qui signalent une valeur d’exemple copiée d’un tutoriel ou d’un `.env.example`. */
16
+ const PLACEHOLDER_FRAGMENTS = Object.freeze([
17
+ "changeme",
18
+ "change-me",
19
+ "change_me",
20
+ "replace-me",
21
+ "replace_me",
22
+ "your-secret",
23
+ "your_secret",
24
+ "yoursecret",
25
+ "mysecret",
26
+ "my-secret",
27
+ "my_secret",
28
+ "supersecret",
29
+ "super-secret",
30
+ "super_secret",
31
+ "jwt-secret",
32
+ "jwt_secret",
33
+ "secretkey",
34
+ "secret-key",
35
+ "secret_key",
36
+ "placeholder",
37
+ "example",
38
+ "password",
39
+ "xxxxxxxx",
40
+ "12345678"
41
+ ]);
42
+
43
+ const MIN_DISTINCT_CHARACTERS = 10;
44
+ const MAX_REPEATED_UNIT_LENGTH = 16;
45
+
46
+ /**
47
+ * @param {unknown} secret
48
+ * @param {number} minBytes
49
+ * @returns {Uint8Array} Secret encodé en octets, prêt pour le fournisseur JWT.
50
+ * @throws {ConfigurationError}
51
+ */
52
+ export function assertStrongSecret(secret, minBytes) {
53
+ if (secret === undefined || secret === null || secret === "") {
54
+ throw new ConfigurationError(DEVELOPER_MESSAGES.secretRequired);
55
+ }
56
+
57
+ if (typeof secret === "string") {
58
+ assertStrongStringSecret(secret);
59
+ return checkLength(new TextEncoder().encode(secret), minBytes);
60
+ }
61
+
62
+ if (secret instanceof Uint8Array) {
63
+ if (new Set(secret).size < MIN_DISTINCT_CHARACTERS) {
64
+ throw new ConfigurationError(DEVELOPER_MESSAGES.secretRepetitive);
65
+ }
66
+ // Copie le tampon pour empêcher une modification ultérieure de changer la clé.
67
+ return checkLength(Uint8Array.from(secret), minBytes);
68
+ }
69
+
70
+ throw new ConfigurationError(DEVELOPER_MESSAGES.secretWrongType);
71
+ }
72
+
73
+ /**
74
+ * @param {Uint8Array} bytes
75
+ * @param {number} minBytes
76
+ * @returns {Uint8Array}
77
+ */
78
+ function checkLength(bytes, minBytes) {
79
+ if (bytes.length < minBytes) {
80
+ throw new ConfigurationError(secretTooShort(minBytes));
81
+ }
82
+ return bytes;
83
+ }
84
+
85
+ /**
86
+ * @param {string} secret
87
+ */
88
+ function assertStrongStringSecret(secret) {
89
+ const lowered = secret.toLowerCase();
90
+ if (PLACEHOLDER_FRAGMENTS.some((fragment) => lowered.includes(fragment))) {
91
+ throw new ConfigurationError(DEVELOPER_MESSAGES.secretPlaceholder);
92
+ }
93
+ if (new Set(secret).size < MIN_DISTINCT_CHARACTERS || isRepeatedPattern(secret)) {
94
+ throw new ConfigurationError(DEVELOPER_MESSAGES.secretRepetitive);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Détecte les valeurs telles que « abcdefghij » répétées de nombreuses fois.
100
+ *
101
+ * @param {string} value
102
+ * @returns {boolean}
103
+ */
104
+ function isRepeatedPattern(value) {
105
+ for (let size = 1; size <= MAX_REPEATED_UNIT_LENGTH && size < value.length; size += 1) {
106
+ const unit = value.slice(0, size);
107
+ if (unit.repeat(Math.ceil(value.length / size)).slice(0, value.length) === value) {
108
+ return true;
109
+ }
110
+ }
111
+ return false;
112
+ }
@@ -0,0 +1,278 @@
1
+ import { createAuthPrincipal } from "./contracts/auth-principal.js";
2
+ import { normalizeUserRecord, toUserIdString } from "./contracts/user-repository.js";
3
+ import {
4
+ AUTH_FAILURE_REASONS,
5
+ ConfigurationError,
6
+ ContractViolationError,
7
+ InvalidCredentialsError,
8
+ InvalidInputError,
9
+ InvalidRefreshTokenError,
10
+ InvalidTokenError,
11
+ TokenMissingError,
12
+ callDependency
13
+ } from "./errors.js";
14
+ import { MAX_IDENTIFIER_LENGTH, MAX_PASSWORD_LENGTH, MAX_TOKEN_LENGTH } from "./input-limits.js";
15
+ import { DEVELOPER_MESSAGES, PUBLIC_MESSAGES, fieldRequired, fieldTooLong } from "./messages.js";
16
+
17
+ /**
18
+ * @typedef {import("./contracts/user-repository.js").UserRepository} UserRepository
19
+ * @typedef {import("./contracts/password-provider.js").PasswordProvider} PasswordProvider
20
+ * @typedef {import("./contracts/token-provider.js").TokenProvider} TokenProvider
21
+ * @typedef {import("./contracts/authentication-service.js").AuthenticationService} AuthenticationService
22
+ * @typedef {import("./contracts/authentication-service.js").LoginCredentials} LoginCredentials
23
+ * @typedef {import("./contracts/authentication-service.js").LoginResult} LoginResult
24
+ * @typedef {import("./contracts/auth-principal.js").AuthPrincipal} AuthPrincipal
25
+ */
26
+
27
+ /**
28
+ * Crée le service d’authentification indépendant du framework.
29
+ *
30
+ * La fabrique doit avoir validé les dépendances au préalable. Ce service ne
31
+ * conserve aucun état utilisateur : il peut être partagé entre les requêtes
32
+ * et entre plusieurs processus derrière un répartiteur de charge.
33
+ *
34
+ * @param {object} dependencies
35
+ * @param {UserRepository} dependencies.users
36
+ * @param {PasswordProvider} dependencies.passwordProvider
37
+ * @param {TokenProvider} dependencies.tokenProvider
38
+ * @param {ReturnType<typeof import("./refresh-token-service.js").createRefreshTokenService>} [dependencies.refreshTokens]
39
+ * Service des jetons de rafraîchissement ; absent si la fonctionnalité n’est pas activée.
40
+ * @returns {AuthenticationService}
41
+ */
42
+ export function createAuthenticationService({ users, passwordProvider, tokenProvider, refreshTokens }) {
43
+ const getDummyHash = createDummyHashSource(passwordProvider);
44
+
45
+ // Chaque appel à une dépendance injectée est enveloppé : une panne (base de
46
+ // données indisponible, bug d’un provider) devient une DependencyFailureError
47
+ // avec un code stable, l’erreur d’origine restant disponible dans `cause`.
48
+ /** @param {string} identifier */
49
+ const findUserByIdentifier = (identifier) =>
50
+ callDependency("UserRepository.findByIdentifier", () => users.findByIdentifier(identifier));
51
+ /** @param {string} id */
52
+ const findUserById = (id) => callDependency("UserRepository.findById", () => users.findById(id));
53
+ /** @param {string} password @param {string} hash */
54
+ const verifyPassword = (password, hash) =>
55
+ callDependency("PasswordProvider.verify", () => passwordProvider.verify(password, hash));
56
+ /** @param {string} subject */
57
+ const issueToken = (subject) => callDependency("TokenProvider.issue", () => tokenProvider.issue({ subject }));
58
+ /** @param {string} token */
59
+ const verifyToken = (token) => callDependency("TokenProvider.verify", () => tokenProvider.verify(token));
60
+
61
+ /**
62
+ * @param {LoginCredentials} credentials
63
+ * @returns {Promise<LoginResult>}
64
+ */
65
+ async function login(credentials) {
66
+ const { identifier, password } = validateCredentials(credentials);
67
+
68
+ const user = normalizeUserRecord(await findUserByIdentifier(identifier));
69
+
70
+ if (user === null) {
71
+ // Réduit les fuites de temps : vérifie aussi un faux hash pour que les
72
+ // comptes inconnus coûtent autant qu’un compte existant.
73
+ await verifyPassword(password, await getDummyHash());
74
+ throw new InvalidCredentialsError(AUTH_FAILURE_REASONS.USER_NOT_FOUND);
75
+ }
76
+
77
+ // Vérifie volontairement le mot de passe avant le statut du compte :
78
+ // un compte désactivé prend alors le même temps qu’un compte actif.
79
+ const passwordMatches = await verifyPassword(password, user.passwordHash);
80
+ if (passwordMatches !== true) {
81
+ throw new InvalidCredentialsError(AUTH_FAILURE_REASONS.INVALID_PASSWORD);
82
+ }
83
+ if (user.isActive !== true) {
84
+ throw new InvalidCredentialsError(AUTH_FAILURE_REASONS.ACCOUNT_DISABLED);
85
+ }
86
+
87
+ return createSession(user);
88
+ }
89
+
90
+ /**
91
+ * Échange un jeton de rafraîchissement contre une nouvelle paire de jetons.
92
+ * L’ancien jeton devient inutilisable (rotation).
93
+ *
94
+ * @param {string} refreshToken
95
+ * @returns {Promise<LoginResult>}
96
+ */
97
+ async function refresh(refreshToken) {
98
+ const service = requireRefreshTokens();
99
+ const consumed = await service.consume(refreshToken);
100
+
101
+ // Le compte a pu être supprimé ou désactivé depuis la connexion.
102
+ const user = normalizeUserRecord(await findUserById(consumed.userId));
103
+ if (user === null || user.id !== consumed.userId || user.isActive !== true) {
104
+ await service.revokeFamily(consumed.familyId);
105
+ throw new InvalidRefreshTokenError(
106
+ user === null ? AUTH_FAILURE_REASONS.USER_NOT_FOUND : AUTH_FAILURE_REASONS.ACCOUNT_DISABLED
107
+ );
108
+ }
109
+
110
+ return createSession(user, consumed.familyId);
111
+ }
112
+
113
+ /**
114
+ * Déconnecte la session liée à ce jeton de rafraîchissement. Réussit même si
115
+ * le jeton est déjà révoqué ou expiré.
116
+ *
117
+ * @param {string} refreshToken
118
+ * @returns {Promise<void>}
119
+ */
120
+ async function logout(refreshToken) {
121
+ await requireRefreshTokens().revokeSessionOf(refreshToken);
122
+ }
123
+
124
+ /**
125
+ * Déconnecte toutes les sessions d’un utilisateur (par exemple après un
126
+ * changement de mot de passe).
127
+ *
128
+ * @param {string | number | bigint | { toHexString(): string }} userId
129
+ * @returns {Promise<void>}
130
+ */
131
+ async function revokeAllSessions(userId) {
132
+ const service = requireRefreshTokens();
133
+ const id = toUserIdString(userId);
134
+ if (id === null) {
135
+ throw new InvalidInputError(PUBLIC_MESSAGES.userIdInvalid);
136
+ }
137
+ await service.revokeUser(id);
138
+ }
139
+
140
+ /**
141
+ * Émet un jeton d’accès et, si la fonctionnalité est activée, un jeton de
142
+ * rafraîchissement (dans la famille donnée, ou une nouvelle).
143
+ *
144
+ * @param {import("./contracts/user-repository.js").NormalizedUserRecord} user
145
+ * @param {string} [familyId]
146
+ * @returns {Promise<LoginResult>}
147
+ */
148
+ async function createSession(user, familyId) {
149
+ const issued = assertIssuedToken(await issueToken(user.id));
150
+ const result = {
151
+ accessToken: issued.token,
152
+ tokenType: /** @type {const} */ ("Bearer"),
153
+ expiresIn: issued.expiresAt - issued.issuedAt,
154
+ principal: createAuthPrincipal(issued, user)
155
+ };
156
+ if (refreshTokens === undefined) {
157
+ return result;
158
+ }
159
+ const refreshed = await refreshTokens.issue(user.id, familyId);
160
+ return { ...result, refreshToken: refreshed.token, refreshExpiresIn: refreshed.expiresIn };
161
+ }
162
+
163
+ function requireRefreshTokens() {
164
+ if (refreshTokens === undefined) {
165
+ throw new ConfigurationError(DEVELOPER_MESSAGES.refreshTokensDisabled);
166
+ }
167
+ return refreshTokens;
168
+ }
169
+
170
+ /**
171
+ * @param {string} token
172
+ * @returns {Promise<AuthPrincipal>}
173
+ */
174
+ async function verifyAccessToken(token) {
175
+ if (token === undefined || token === null || token === "") {
176
+ throw new TokenMissingError();
177
+ }
178
+ if (typeof token !== "string" || token.length > MAX_TOKEN_LENGTH) {
179
+ throw new InvalidTokenError();
180
+ }
181
+
182
+ const claims = await verifyToken(token);
183
+ if (typeof claims?.subject !== "string" || claims.subject.length === 0) {
184
+ throw new ContractViolationError(DEVELOPER_MESSAGES.tokenClaimsWithoutSubject);
185
+ }
186
+
187
+ // Vérifier l’utilisateur à chaque requête bloque immédiatement les
188
+ // comptes supprimés ou désactivés, ce qu’un JWT seul ne peut pas faire.
189
+ const user = normalizeUserRecord(await findUserById(claims.subject));
190
+ if (user === null) {
191
+ throw new InvalidTokenError({ reason: AUTH_FAILURE_REASONS.USER_NOT_FOUND });
192
+ }
193
+ if (user.id !== claims.subject) {
194
+ throw new ContractViolationError(DEVELOPER_MESSAGES.repositoryIdMismatch);
195
+ }
196
+ if (user.isActive !== true) {
197
+ throw new InvalidTokenError({ reason: AUTH_FAILURE_REASONS.ACCOUNT_DISABLED });
198
+ }
199
+
200
+ return createAuthPrincipal(claims, user);
201
+ }
202
+
203
+ return Object.freeze({ login, refresh, logout, revokeAllSessions, verifyAccessToken });
204
+ }
205
+
206
+ /**
207
+ * Vérifie la forme du jeton renvoyé par un TokenProvider (utile surtout pour
208
+ * les providers personnalisés).
209
+ *
210
+ * @param {unknown} value
211
+ * @returns {import("./contracts/token-provider.js").IssuedToken}
212
+ * @throws {ContractViolationError}
213
+ */
214
+ function assertIssuedToken(value) {
215
+ const issued = /** @type {Partial<import("./contracts/token-provider.js").IssuedToken> | null | undefined} */ (value);
216
+ const isValid =
217
+ typeof issued?.token === "string" &&
218
+ issued.token.length > 0 &&
219
+ typeof issued.tokenId === "string" &&
220
+ Number.isFinite(issued.issuedAt) &&
221
+ Number.isFinite(issued.expiresAt);
222
+ if (!isValid) {
223
+ throw new ContractViolationError(DEVELOPER_MESSAGES.issuedTokenInvalid);
224
+ }
225
+ return /** @type {import("./contracts/token-provider.js").IssuedToken} */ (issued);
226
+ }
227
+
228
+ /**
229
+ * @param {unknown} credentials
230
+ * @returns {LoginCredentials}
231
+ * @throws {InvalidInputError}
232
+ */
233
+ function validateCredentials(credentials) {
234
+ if (credentials === null || typeof credentials !== "object") {
235
+ throw new InvalidInputError(PUBLIC_MESSAGES.credentialsNotObject);
236
+ }
237
+ const { identifier, password } = /** @type {Record<string, unknown>} */ (credentials);
238
+ assertBoundedString(identifier, "identifier", MAX_IDENTIFIER_LENGTH);
239
+ assertBoundedString(password, "password", MAX_PASSWORD_LENGTH);
240
+ return { identifier, password };
241
+ }
242
+
243
+ /**
244
+ * @param {unknown} value
245
+ * @param {string} field
246
+ * @param {number} maxLength
247
+ * @returns {asserts value is string}
248
+ */
249
+ function assertBoundedString(value, field, maxLength) {
250
+ if (typeof value !== "string" || value.length === 0) {
251
+ throw new InvalidInputError(fieldRequired(field));
252
+ }
253
+ if (value.length > maxLength) {
254
+ throw new InvalidInputError(fieldTooLong(field, maxLength));
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Calcule puis met en cache, à la demande, le hash d’une valeur aléatoire avec
260
+ * le fournisseur configuré. Son coût correspond ainsi à celui d’un vrai hash.
261
+ * Un essai en erreur n’est pas mis en cache.
262
+ *
263
+ * @param {PasswordProvider} passwordProvider
264
+ * @returns {() => Promise<string>}
265
+ */
266
+ function createDummyHashSource(passwordProvider) {
267
+ /** @type {Promise<string> | undefined} */
268
+ let pending;
269
+ return () => {
270
+ pending ??= callDependency("PasswordProvider.hash", () =>
271
+ passwordProvider.hash(globalThis.crypto.randomUUID())
272
+ ).catch((error) => {
273
+ pending = undefined;
274
+ throw error;
275
+ });
276
+ return pending;
277
+ };
278
+ }
@@ -0,0 +1,156 @@
1
+ import { ConfigurationError, ContractViolationError } from "./errors.js";
2
+ import { MAX_AUTHORITY_LENGTH } from "./input-limits.js";
3
+ import { DEVELOPER_MESSAGES } from "./messages.js";
4
+
5
+ /**
6
+ * Règle d’accès.
7
+ *
8
+ * Un simple texte désigne un rôle : `"admin"` équivaut à `{ anyRole: ["admin"] }`.
9
+ * Quand plusieurs règles sont données, **toutes** doivent être respectées.
10
+ *
11
+ * @typedef {string | {
12
+ * anyRole?: string[],
13
+ * allPermissions?: string[],
14
+ * check?: (principal: import("./contracts/auth-principal.js").AuthPrincipal, context?: unknown) => boolean | Promise<boolean>
15
+ * }} AuthorizationRequirement
16
+ */
17
+
18
+ /**
19
+ * Règle validée, prête à être évaluée.
20
+ *
21
+ * @typedef {Readonly<{
22
+ * anyRole: readonly string[],
23
+ * allPermissions: readonly string[],
24
+ * check: ((principal: import("./contracts/auth-principal.js").AuthPrincipal, context?: unknown) => boolean | Promise<boolean>) | undefined
25
+ * }>} NormalizedAuthorizationRequirement
26
+ */
27
+
28
+ const ALLOWED_KEYS = Object.freeze(["anyRole", "allPermissions", "check"]);
29
+
30
+ /**
31
+ * Règles produites par normalizeAuthorizationRequirement(). Un registre
32
+ * interne, plutôt qu’un test sur la forme de l’objet : un objet fabriqué à la
33
+ * main (par exemple avec une liste de rôles vide) ne peut pas se faire passer
34
+ * pour une règle validée et contourner la validation.
35
+ *
36
+ * @type {WeakSet<object>}
37
+ */
38
+ const normalizedRequirements = new WeakSet();
39
+
40
+ /**
41
+ * Valide une règle d’accès au démarrage, pour qu’une faute (rôle vide, clé
42
+ * mal orthographiée…) soit signalée avant la première requête.
43
+ *
44
+ * @param {unknown} requirement
45
+ * @returns {NormalizedAuthorizationRequirement}
46
+ * @throws {ConfigurationError}
47
+ */
48
+ export function normalizeAuthorizationRequirement(requirement) {
49
+ if (typeof requirement === "string") {
50
+ return register({ anyRole: Object.freeze([assertAuthorityName(requirement)]), allPermissions: Object.freeze([]), check: undefined });
51
+ }
52
+ if (requirement === null || typeof requirement !== "object" || Array.isArray(requirement)) {
53
+ throw new ConfigurationError(DEVELOPER_MESSAGES.authorizeRequirementInvalid);
54
+ }
55
+
56
+ const rules = /** @type {Record<string, unknown>} */ (requirement);
57
+ const hasUnknownKey = Object.keys(rules).some((key) => !ALLOWED_KEYS.includes(key));
58
+ const hasAtLeastOneRule = ALLOWED_KEYS.some((key) => rules[key] !== undefined);
59
+ if (hasUnknownKey || !hasAtLeastOneRule) {
60
+ throw new ConfigurationError(DEVELOPER_MESSAGES.authorizeRequirementInvalid);
61
+ }
62
+ if (rules.check !== undefined && typeof rules.check !== "function") {
63
+ throw new ConfigurationError(DEVELOPER_MESSAGES.authorizeRequirementInvalid);
64
+ }
65
+
66
+ return register({
67
+ anyRole: normalizeNameList(rules.anyRole),
68
+ allPermissions: normalizeNameList(rules.allPermissions),
69
+ check: /** @type {NormalizedAuthorizationRequirement["check"]} */ (rules.check)
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Indique si un utilisateur authentifié respecte une règle d’accès.
75
+ *
76
+ * - `anyRole` : l’utilisateur doit avoir **au moins un** de ces rôles ;
77
+ * - `allPermissions` : il doit avoir **toutes** ces permissions ;
78
+ * - `check` : fonction personnalisée, appelée en dernier, avec `context`
79
+ * (la requête HTTP avec l’adaptateur Express).
80
+ *
81
+ * @example
82
+ * await isAuthorized(principal, "admin");
83
+ * await isAuthorized(principal, { allPermissions: ["orders:write"] });
84
+ * await isAuthorized(principal, { check: (user, req) => user.userId === req.params.id }, req);
85
+ *
86
+ * @param {import("./contracts/auth-principal.js").AuthPrincipal} principal
87
+ * @param {AuthorizationRequirement | NormalizedAuthorizationRequirement} requirement
88
+ * @param {unknown} [context] Information transmise à `check` (par exemple la requête).
89
+ * @returns {Promise<boolean>}
90
+ * @throws {ConfigurationError} Si la règle est invalide.
91
+ * @throws {ContractViolationError} Si `check` ne renvoie pas un booléen.
92
+ */
93
+ export async function isAuthorized(principal, requirement, context) {
94
+ const rules = isNormalized(requirement) ? requirement : normalizeAuthorizationRequirement(requirement);
95
+
96
+ if (rules.anyRole.length > 0 && !rules.anyRole.some((role) => principal.roles.includes(role))) {
97
+ return false;
98
+ }
99
+ if (!rules.allPermissions.every((permission) => principal.permissions.includes(permission))) {
100
+ return false;
101
+ }
102
+ if (rules.check === undefined) {
103
+ return true;
104
+ }
105
+
106
+ const allowed = await rules.check(principal, context);
107
+ if (typeof allowed !== "boolean") {
108
+ // Refuser silencieusement masquerait un bug ; accepter une valeur « vraie »
109
+ // ouvrirait l’accès par erreur. On signale donc le problème.
110
+ throw new ContractViolationError(DEVELOPER_MESSAGES.authorizationCheckNotBoolean);
111
+ }
112
+ return allowed;
113
+ }
114
+
115
+ /**
116
+ * @param {NormalizedAuthorizationRequirement} rules
117
+ * @returns {NormalizedAuthorizationRequirement}
118
+ */
119
+ function register(rules) {
120
+ const frozen = Object.freeze(rules);
121
+ normalizedRequirements.add(frozen);
122
+ return frozen;
123
+ }
124
+
125
+ /**
126
+ * @param {unknown} requirement
127
+ * @returns {requirement is NormalizedAuthorizationRequirement}
128
+ */
129
+ function isNormalized(requirement) {
130
+ return typeof requirement === "object" && requirement !== null && normalizedRequirements.has(requirement);
131
+ }
132
+
133
+ /**
134
+ * @param {unknown} value
135
+ * @returns {readonly string[]}
136
+ */
137
+ function normalizeNameList(value) {
138
+ if (value === undefined) {
139
+ return Object.freeze([]);
140
+ }
141
+ if (!Array.isArray(value) || value.length === 0) {
142
+ throw new ConfigurationError(DEVELOPER_MESSAGES.authorizeRequirementInvalid);
143
+ }
144
+ return Object.freeze(value.map(assertAuthorityName));
145
+ }
146
+
147
+ /**
148
+ * @param {unknown} name
149
+ * @returns {string}
150
+ */
151
+ function assertAuthorityName(name) {
152
+ if (typeof name !== "string" || name.trim().length === 0 || name.length > MAX_AUTHORITY_LENGTH) {
153
+ throw new ConfigurationError(DEVELOPER_MESSAGES.authorizeRequirementInvalid);
154
+ }
155
+ return name;
156
+ }
@@ -0,0 +1,26 @@
1
+ import { ContractViolationError } from "../errors.js";
2
+ import { contractMissingMethods, contractNotObject } from "../messages.js";
3
+
4
+ /**
5
+ * Vérifie que `candidate` expose toutes les méthodes de `methodNames`.
6
+ *
7
+ * JavaScript n’a pas d’interfaces : les contrats sont vérifiés par leur forme
8
+ * au démarrage afin de signaler clairement une dépendance mal configurée.
9
+ *
10
+ * @param {unknown} candidate Dépendance injectée.
11
+ * @param {string} contractName Nom utilisé dans les messages d’erreur.
12
+ * @param {readonly string[]} methodNames Méthodes exigées par le contrat.
13
+ * @returns {void}
14
+ * @throws {ContractViolationError}
15
+ */
16
+ export function assertImplements(candidate, contractName, methodNames) {
17
+ if (candidate === null || (typeof candidate !== "object" && typeof candidate !== "function")) {
18
+ throw new ContractViolationError(contractNotObject(contractName));
19
+ }
20
+
21
+ const members = /** @type {Record<string, unknown>} */ (candidate);
22
+ const missing = methodNames.filter((name) => typeof members[name] !== "function");
23
+ if (missing.length > 0) {
24
+ throw new ContractViolationError(contractMissingMethods(contractName, missing));
25
+ }
26
+ }