@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,405 @@
1
+ /**
2
+ * Express AuthKit — adaptateur Express.js (`@kourouma/express-authkit/express`).
3
+ *
4
+ * Ce module n’importe pas Express : il produit uniquement des middlewares
5
+ * standard `(req, res, next)`, compatibles avec Express 4 et 5.
6
+ *
7
+ * @module @kourouma/express-authkit/express
8
+ */
9
+
10
+ import { assertImplements } from "../../core/contracts/assert-contract.js";
11
+ import { normalizeAuthorizationRequirement, isAuthorized } from "../../core/authorization.js";
12
+ import { AccessDeniedError, ConfigurationError, InvalidInputError } from "../../core/errors.js";
13
+ import { MAX_IDENTIFIER_LENGTH } from "../../core/input-limits.js";
14
+ import {
15
+ DEVELOPER_MESSAGES,
16
+ PUBLIC_MESSAGES,
17
+ fieldRequired,
18
+ fieldTooLong,
19
+ middlewareFactoryMisuse
20
+ } from "../../core/messages.js";
21
+ import { assertKnownKeys, assertPlainObject } from "../../config/assertions.js";
22
+ import { extractBearerToken } from "./bearer-token.js";
23
+ import { getClientErrorStatus, resolveClientError, sendClientError } from "./http-errors.js";
24
+
25
+ /**
26
+ * @typedef {object} ExpressAuthOptions
27
+ * @property {string} [realm="api"] Domaine annoncé dans l’en-tête `WWW-Authenticate`.
28
+ */
29
+
30
+ /**
31
+ * Règle d’accès pour `expressAuth.authorize()`. Un texte désigne un rôle.
32
+ * `check` reçoit l’utilisateur connecté et la requête Express.
33
+ *
34
+ * @typedef {string | {
35
+ * anyRole?: string[],
36
+ * allPermissions?: string[],
37
+ * check?: (user: import("../../core/contracts/auth-principal.js").AuthPrincipal, req: import("express").Request) => boolean | Promise<boolean>
38
+ * }} ExpressAuthorizationRequirement
39
+ */
40
+
41
+ /**
42
+ * @typedef {object} LoginHandlerOptions
43
+ * @property {string} [identifierField="identifier"] Champ du corps JSON contenant l’identifiant (par exemple `"email"`).
44
+ */
45
+
46
+ /**
47
+ * @typedef {Readonly<{
48
+ * authenticate: () => import("express").RequestHandler,
49
+ * authorize: (requirement: ExpressAuthorizationRequirement) => import("express").RequestHandler,
50
+ * login: (options?: LoginHandlerOptions) => import("express").RequestHandler,
51
+ * refresh: () => import("express").RequestHandler,
52
+ * logout: () => import("express").RequestHandler,
53
+ * errorHandler: () => import("express").ErrorRequestHandler
54
+ * }>} ExpressAuth
55
+ */
56
+
57
+ const DEFAULT_REALM = "api";
58
+ /** Jeu de caractères limité : le domaine est écrit dans un en-tête de réponse. */
59
+ const REALM_PATTERN = /^[A-Za-z0-9 ._-]{1,64}$/;
60
+ const DEFAULT_IDENTIFIER_FIELD = "identifier";
61
+ const FIELD_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
62
+
63
+ /**
64
+ * Crée l’intégration Express pour un moteur construit avec `createAuthKit()`.
65
+ *
66
+ * @example
67
+ * const expressAuth = createExpressAuth(auth);
68
+ * app.post("/login", express.json(), expressAuth.login());
69
+ * app.get("/profile", expressAuth.authenticate(), (req, res) => {
70
+ * res.json({ userId: req.auth.userId });
71
+ * });
72
+ *
73
+ * @param {import("../../factory/create-auth-kit.js").AuthKit} auth
74
+ * @param {ExpressAuthOptions} [options]
75
+ * @returns {ExpressAuth}
76
+ */
77
+ export function createExpressAuth(auth, options = {}) {
78
+ assertImplements(auth, "AuthKit", ["login", "verifyAccessToken"]);
79
+ assertPlainObject(options, "createExpressAuth(auth, options)");
80
+ assertKnownKeys(options, ["realm"], "createExpressAuth(auth, options)");
81
+
82
+ const realm = options.realm ?? DEFAULT_REALM;
83
+
84
+ /**
85
+ * Requêtes authentifiées par cette instance. `authorize()` ne fait confiance
86
+ * qu’à elles : une valeur de `req.auth` posée par un autre middleware est
87
+ * ignorée et ne peut pas usurper une identité.
88
+ *
89
+ * @type {WeakMap<object, import("../../core/contracts/auth-principal.js").AuthPrincipal>}
90
+ */
91
+ const authenticatedRequests = new WeakMap();
92
+ if (typeof realm !== "string" || !REALM_PATTERN.test(realm)) {
93
+ throw new ConfigurationError(DEVELOPER_MESSAGES.invalidRealm);
94
+ }
95
+
96
+ /**
97
+ * Middleware exigeant un en-tête `Authorization: Bearer <token>` valide.
98
+ * En cas de succès, le principal figé est disponible dans `req.auth`.
99
+ * Les échecs d’authentification reçoivent une réponse JSON 401 ; les erreurs
100
+ * inattendues sont transmises à `next(error)`.
101
+ *
102
+ * @returns {import("express").RequestHandler}
103
+ * @param {...unknown} unexpectedArguments Doit rester vide (détecte l’oubli des parenthèses).
104
+ */
105
+ function authenticate(...unexpectedArguments) {
106
+ assertCalledAsFactory("authenticate", unexpectedArguments);
107
+ return async function authenticateRequest(req, res, next) {
108
+ let principal;
109
+ try {
110
+ const token = extractBearerToken(req.headers.authorization);
111
+ principal = await auth.verifyAccessToken(token);
112
+ } catch (error) {
113
+ const status = getClientErrorStatus(error);
114
+ if (status !== undefined) {
115
+ sendClientError(res, /** @type {import("../../core/errors.js").AuthKitError} */ (error), status, realm);
116
+ return;
117
+ }
118
+ next(error);
119
+ return;
120
+ }
121
+ setPrincipal(req, principal);
122
+ next();
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Protège une route par rôle, permission ou règle personnalisée. Authentifie
128
+ * aussi la requête si `authenticate()` n’a pas été appelé avant : une seule
129
+ * ligne suffit.
130
+ *
131
+ * Réponses : 401 si l’utilisateur n’est pas connecté, 403 (`ACCESS_DENIED`)
132
+ * s’il est connecté mais n’a pas les droits.
133
+ *
134
+ * @example
135
+ * app.get("/admin", expressAuth.authorize("admin"), handler);
136
+ * app.post("/orders", expressAuth.authorize({ allPermissions: ["orders:write"] }), handler);
137
+ * app.put("/users/:id", expressAuth.authorize({ check: (user, req) => user.userId === req.params.id }), handler);
138
+ *
139
+ * @param {ExpressAuthorizationRequirement} requirement
140
+ * @param {...unknown} unexpectedArguments Doit rester vide (détecte l’oubli des parenthèses).
141
+ * @returns {import("express").RequestHandler}
142
+ */
143
+ function authorize(requirement, ...unexpectedArguments) {
144
+ assertCalledAsFactory("authorize", unexpectedArguments);
145
+ // Validée une fois, au démarrage : une règle invalide arrête l’application.
146
+ const rules = normalizeAuthorizationRequirement(requirement);
147
+
148
+ return async function authorizeRequest(req, res, next) {
149
+ try {
150
+ const principal = await resolvePrincipal(req);
151
+ if (!(await isAuthorized(principal, rules, req))) {
152
+ throw new AccessDeniedError();
153
+ }
154
+ } catch (error) {
155
+ const status = getClientErrorStatus(error);
156
+ if (status !== undefined) {
157
+ sendClientError(res, /** @type {import("../../core/errors.js").AuthKitError} */ (error), status, realm);
158
+ return;
159
+ }
160
+ next(error);
161
+ return;
162
+ }
163
+ next();
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Réutilise l’utilisateur déjà authentifié par `authenticate()` sur cette
169
+ * requête, ou authentifie la requête maintenant.
170
+ *
171
+ * @param {import("express").Request} req
172
+ * @returns {Promise<import("../../core/contracts/auth-principal.js").AuthPrincipal>}
173
+ */
174
+ async function resolvePrincipal(req) {
175
+ const existing = authenticatedRequests.get(req);
176
+ if (existing !== undefined) {
177
+ return existing;
178
+ }
179
+ const principal = await auth.verifyAccessToken(extractBearerToken(req.headers.authorization));
180
+ setPrincipal(req, principal);
181
+ return principal;
182
+ }
183
+
184
+ /**
185
+ * @param {import("express").Request} req
186
+ * @param {import("../../core/contracts/auth-principal.js").AuthPrincipal} principal
187
+ */
188
+ function setPrincipal(req, principal) {
189
+ authenticatedRequests.set(req, principal);
190
+ /** @type {import("express").Request & { auth?: import("../../core/contracts/auth-principal.js").AuthPrincipal }} */ (
191
+ req
192
+ ).auth = principal;
193
+ }
194
+
195
+ /**
196
+ * Gestionnaire de connexion prêt à l’emploi. Ce n’est pas une route
197
+ * automatique : l’application choisit l’URL sur laquelle elle le monte.
198
+ *
199
+ * Lit `password` et l’identifiant (champ `identifier` par défaut) dans le
200
+ * corps JSON, puis répond `{ accessToken, tokenType, expiresIn }`.
201
+ * Les échecs reçoivent une réponse JSON 400/401 ; les erreurs inattendues
202
+ * sont transmises à `next(error)`.
203
+ *
204
+ * @example
205
+ * app.post("/login", express.json(), expressAuth.login());
206
+ * app.post("/login", express.json(), expressAuth.login({ identifierField: "email" }));
207
+ *
208
+ * @param {LoginHandlerOptions} [loginOptions]
209
+ * @returns {import("express").RequestHandler}
210
+ * @param {...unknown} unexpectedArguments Doit rester vide (détecte l’oubli des parenthèses).
211
+ */
212
+ function login(loginOptions = {}, ...unexpectedArguments) {
213
+ assertCalledAsFactory("login", unexpectedArguments);
214
+ const identifierField = resolveIdentifierField(loginOptions);
215
+
216
+ return async function loginRequest(req, res, next) {
217
+ let result;
218
+ try {
219
+ const body = readJsonBody(req);
220
+ // Object.hasOwn évite de lire une propriété héritée du prototype.
221
+ const identifier = Object.hasOwn(body, identifierField) ? body[identifierField] : undefined;
222
+ const password = Object.hasOwn(body, "password") ? body.password : undefined;
223
+ // Vérifié ici pour que le message cite le vrai nom du champ (« email »…).
224
+ assertIdentifierField(identifier, identifierField);
225
+ // Le type exact du mot de passe est vérifié par auth.login().
226
+ result = await auth.login({ identifier, password: /** @type {string} */ (password) });
227
+ } catch (error) {
228
+ const status = getClientErrorStatus(error);
229
+ if (status !== undefined) {
230
+ sendClientError(res, /** @type {import("../../core/errors.js").AuthKitError} */ (error), status, realm);
231
+ return;
232
+ }
233
+ next(error);
234
+ return;
235
+ }
236
+ sendSession(res, result);
237
+ };
238
+ }
239
+
240
+ /**
241
+ * Gestionnaire de rafraîchissement : lit `refreshToken` dans le corps JSON
242
+ * et répond avec une nouvelle paire de jetons (l’ancien jeton est invalidé).
243
+ * Nécessite l’option `refreshTokens` de `createAuthKit()`.
244
+ *
245
+ * @example
246
+ * app.post("/auth/refresh", express.json(), expressAuth.refresh());
247
+ *
248
+ * @returns {import("express").RequestHandler}
249
+ * @param {...unknown} unexpectedArguments Doit rester vide (détecte l’oubli des parenthèses).
250
+ */
251
+ function refresh(...unexpectedArguments) {
252
+ assertCalledAsFactory("refresh", unexpectedArguments);
253
+ return handleJson(async (body, res) => {
254
+ sendSession(res, await auth.refresh(readRefreshToken(body)));
255
+ });
256
+ }
257
+
258
+ /**
259
+ * Gestionnaire de déconnexion : lit `refreshToken` dans le corps JSON,
260
+ * révoque la session et répond 204 (sans contenu). Répond 204 aussi si la
261
+ * session était déjà révoquée : se déconnecter deux fois n’est pas une erreur.
262
+ *
263
+ * @example
264
+ * app.post("/auth/logout", express.json(), expressAuth.logout());
265
+ *
266
+ * @returns {import("express").RequestHandler}
267
+ * @param {...unknown} unexpectedArguments Doit rester vide (détecte l’oubli des parenthèses).
268
+ */
269
+ function logout(...unexpectedArguments) {
270
+ assertCalledAsFactory("logout", unexpectedArguments);
271
+ return handleJson(async (body, res) => {
272
+ await auth.logout(readRefreshToken(body));
273
+ res.set("Cache-Control", "no-store");
274
+ res.status(204).end();
275
+ });
276
+ }
277
+
278
+ /**
279
+ * Enveloppe commune des gestionnaires qui lisent un corps JSON : les
280
+ * erreurs client reçoivent une réponse JSON, les autres vont à `next()`.
281
+ *
282
+ * @param {(body: Record<string, unknown>, res: import("express").Response) => Promise<void>} handle
283
+ * @returns {import("express").RequestHandler}
284
+ */
285
+ function handleJson(handle) {
286
+ return async function jsonRequest(req, res, next) {
287
+ try {
288
+ await handle(readJsonBody(req), res);
289
+ } catch (error) {
290
+ const status = getClientErrorStatus(error);
291
+ if (status !== undefined && !res.headersSent) {
292
+ sendClientError(res, /** @type {import("../../core/errors.js").AuthKitError} */ (error), status, realm);
293
+ return;
294
+ }
295
+ next(error);
296
+ }
297
+ };
298
+ }
299
+
300
+ /**
301
+ * Middleware d’erreur qui convertit les erreurs d’authentification client,
302
+ * par exemple celles de `auth.login()`, en réponses JSON. Les autres erreurs
303
+ * sont transmises sans modification au gestionnaire suivant.
304
+ *
305
+ * @returns {import("express").ErrorRequestHandler}
306
+ * @param {...unknown} unexpectedArguments Doit rester vide (détecte l’oubli des parenthèses).
307
+ */
308
+ function errorHandler(...unexpectedArguments) {
309
+ assertCalledAsFactory("errorHandler", unexpectedArguments);
310
+ return function authErrorHandler(error, req, res, next) {
311
+ const clientError = resolveClientError(error);
312
+ if (clientError === undefined || res.headersSent) {
313
+ next(error);
314
+ return;
315
+ }
316
+ sendClientError(res, clientError.error, clientError.status, realm);
317
+ };
318
+ }
319
+
320
+ return Object.freeze({ authenticate, authorize, login, refresh, logout, errorHandler });
321
+ }
322
+
323
+ /**
324
+ * @param {unknown} loginOptions
325
+ * @returns {string}
326
+ */
327
+ function resolveIdentifierField(loginOptions) {
328
+ assertPlainObject(loginOptions, "expressAuth.login(options)");
329
+ assertKnownKeys(loginOptions, ["identifierField"], "expressAuth.login(options)");
330
+ const field = loginOptions.identifierField ?? DEFAULT_IDENTIFIER_FIELD;
331
+ if (typeof field !== "string" || !FIELD_NAME_PATTERN.test(field)) {
332
+ throw new ConfigurationError(DEVELOPER_MESSAGES.invalidIdentifierField);
333
+ }
334
+ return field;
335
+ }
336
+
337
+ /**
338
+ * Détecte l’oubli des parenthèses (`expressAuth.authenticate` au lieu de
339
+ * `expressAuth.authenticate()`). Sans ce contrôle, Express appellerait la
340
+ * fabrique comme un middleware et la requête resterait bloquée sans réponse.
341
+ *
342
+ * @param {string} method
343
+ * @param {unknown[]} unexpectedArguments
344
+ */
345
+ function assertCalledAsFactory(method, unexpectedArguments) {
346
+ if (unexpectedArguments.length > 0) {
347
+ throw new ConfigurationError(middlewareFactoryMisuse(method));
348
+ }
349
+ }
350
+
351
+ /**
352
+ * @param {unknown} identifier
353
+ * @param {string} field
354
+ * @returns {asserts identifier is string}
355
+ */
356
+ function assertIdentifierField(identifier, field) {
357
+ if (typeof identifier !== "string" || identifier.length === 0) {
358
+ throw new InvalidInputError(fieldRequired(field));
359
+ }
360
+ if (identifier.length > MAX_IDENTIFIER_LENGTH) {
361
+ throw new InvalidInputError(fieldTooLong(field, MAX_IDENTIFIER_LENGTH));
362
+ }
363
+ }
364
+
365
+ /**
366
+ * @param {import("express").Request} req
367
+ * @returns {Record<string, unknown>}
368
+ * @throws {InvalidInputError}
369
+ */
370
+ function readJsonBody(req) {
371
+ const body = req.body;
372
+ if (body === undefined || body === null || typeof body !== "object") {
373
+ throw new InvalidInputError(PUBLIC_MESSAGES.missingJsonBody);
374
+ }
375
+ return body;
376
+ }
377
+
378
+ /**
379
+ * Lit le champ sans le valider : auth.refresh() et auth.logout() vérifient
380
+ * eux-mêmes que c’est un texte non vide.
381
+ *
382
+ * @param {Record<string, unknown>} body
383
+ * @returns {string} Type annoncé ; la valeur réelle est validée par le Core.
384
+ */
385
+ function readRefreshToken(body) {
386
+ const value = Object.hasOwn(body, "refreshToken") ? body.refreshToken : undefined;
387
+ return /** @type {string} */ (value);
388
+ }
389
+
390
+ /**
391
+ * Envoie une paire de jetons. Le principal interne n’est pas inclus.
392
+ *
393
+ * @param {import("express").Response} res
394
+ * @param {import("../../core/contracts/authentication-service.js").LoginResult} result
395
+ */
396
+ function sendSession(res, result) {
397
+ /** @type {{ accessToken: string, tokenType: string, expiresIn: number, refreshToken?: string, refreshExpiresIn?: number }} */
398
+ const body = { accessToken: result.accessToken, tokenType: result.tokenType, expiresIn: result.expiresIn };
399
+ if (result.refreshToken !== undefined) {
400
+ body.refreshToken = result.refreshToken;
401
+ body.refreshExpiresIn = result.refreshExpiresIn;
402
+ }
403
+ res.set("Cache-Control", "no-store");
404
+ res.json(body);
405
+ }
@@ -0,0 +1,80 @@
1
+ import { ConfigurationError } from "../core/errors.js";
2
+ import {
3
+ notPlainObject,
4
+ optionIntegerRange,
5
+ optionRequiredString,
6
+ optionTooLong,
7
+ unknownOptions
8
+ } from "../core/messages.js";
9
+
10
+ /**
11
+ * Petites fonctions de vérification partagées par les validateurs de configuration.
12
+ *
13
+ * @module config/assertions
14
+ */
15
+
16
+ /**
17
+ * @param {unknown} value
18
+ * @returns {value is Record<string, unknown>}
19
+ */
20
+ export function isPlainObject(value) {
21
+ if (value === null || typeof value !== "object") {
22
+ return false;
23
+ }
24
+ const prototype = Object.getPrototypeOf(value);
25
+ return prototype === Object.prototype || prototype === null;
26
+ }
27
+
28
+ /**
29
+ * @param {unknown} value
30
+ * @param {string} name
31
+ * @returns {asserts value is Record<string, unknown>}
32
+ */
33
+ export function assertPlainObject(value, name) {
34
+ if (!isPlainObject(value)) {
35
+ throw new ConfigurationError(notPlainObject(name));
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Refuse les clés inconnues afin qu’une faute de frappe (par exemple
41
+ * `expiresIn` au lieu de `expiresInSeconds`) ne passe pas inaperçue.
42
+ *
43
+ * @param {Record<string, unknown>} object
44
+ * @param {readonly string[]} allowedKeys
45
+ * @param {string} name
46
+ */
47
+ export function assertKnownKeys(object, allowedKeys, name) {
48
+ const unknown = Object.keys(object).filter((key) => !allowedKeys.includes(key));
49
+ if (unknown.length > 0) {
50
+ throw new ConfigurationError(unknownOptions(name, unknown, allowedKeys));
51
+ }
52
+ }
53
+
54
+ /**
55
+ * @param {unknown} value
56
+ * @param {string} name
57
+ * @param {number} maxLength
58
+ * @returns {asserts value is string}
59
+ */
60
+ export function assertNonEmptyString(value, name, maxLength) {
61
+ if (typeof value !== "string" || value.trim().length === 0) {
62
+ throw new ConfigurationError(optionRequiredString(name));
63
+ }
64
+ if (value.length > maxLength) {
65
+ throw new ConfigurationError(optionTooLong(name, maxLength));
66
+ }
67
+ }
68
+
69
+ /**
70
+ * @param {unknown} value
71
+ * @param {string} name
72
+ * @param {number} min
73
+ * @param {number} max
74
+ * @returns {asserts value is number}
75
+ */
76
+ export function assertIntegerInRange(value, name, min, max) {
77
+ if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) {
78
+ throw new ConfigurationError(optionIntegerRange(name, min, max));
79
+ }
80
+ }
@@ -0,0 +1,61 @@
1
+ import { ConfigurationError } from "../core/errors.js";
2
+ import { DEVELOPER_MESSAGES } from "../core/messages.js";
3
+ import { assertKnownKeys, assertPlainObject } from "./assertions.js";
4
+
5
+ /**
6
+ * Fournisseurs qui remplacent les implémentations intégrées.
7
+ *
8
+ * @typedef {object} ProviderOverrides
9
+ * @property {import("../core/contracts/password-provider.js").PasswordProvider} [password]
10
+ * Remplace le fournisseur Argon2id. Les options `password` doivent alors être absentes.
11
+ * @property {import("../core/contracts/token-provider.js").TokenProvider} [token]
12
+ * Remplace le fournisseur JWT. Les options `jwt` doivent alors être absentes.
13
+ */
14
+
15
+ /**
16
+ * Options acceptées par `createAuthKit()`.
17
+ *
18
+ * @typedef {object} AuthKitOptions
19
+ * @property {import("../core/contracts/user-repository.js").UserRepository} users Dépôt d’utilisateurs de l’application.
20
+ * @property {import("./jwt-options.js").JwtOptions} [jwt] Obligatoire sans `providers.token`.
21
+ * @property {import("./password-options.js").PasswordOptions} [password] Paramètres de coût Argon2id.
22
+ * @property {import("./refresh-token-options.js").RefreshTokenOptions} [refreshTokens]
23
+ * Active les jetons de rafraîchissement (désactivés par défaut).
24
+ * @property {ProviderOverrides} [providers] Implémentations personnalisées des fournisseurs.
25
+ */
26
+
27
+ const ALLOWED_KEYS = Object.freeze(["users", "jwt", "password", "refreshTokens", "providers"]);
28
+ const ALLOWED_PROVIDER_KEYS = Object.freeze(["password", "token"]);
29
+
30
+ /**
31
+ * Valide la structure principale des options. Les valeurs imbriquées sont
32
+ * validées par leurs résolveurs et leurs vérifications de contrat dédiés.
33
+ *
34
+ * @param {unknown} options
35
+ * @returns {AuthKitOptions}
36
+ * @throws {ConfigurationError}
37
+ */
38
+ export function validateAuthKitOptions(options) {
39
+ assertPlainObject(options, "createAuthKit(options)");
40
+ assertKnownKeys(options, ALLOWED_KEYS, "createAuthKit(options)");
41
+
42
+ if (options.users === undefined || options.users === null) {
43
+ throw new ConfigurationError(DEVELOPER_MESSAGES.usersRequired);
44
+ }
45
+
46
+ const providers = options.providers ?? {};
47
+ assertPlainObject(providers, "providers");
48
+ assertKnownKeys(providers, ALLOWED_PROVIDER_KEYS, "providers");
49
+
50
+ if (providers.token !== undefined && options.jwt !== undefined) {
51
+ throw new ConfigurationError(DEVELOPER_MESSAGES.jwtOrTokenProvider);
52
+ }
53
+ if (providers.token === undefined && options.jwt === undefined) {
54
+ throw new ConfigurationError(DEVELOPER_MESSAGES.jwtRequired);
55
+ }
56
+ if (providers.password !== undefined && options.password !== undefined) {
57
+ throw new ConfigurationError(DEVELOPER_MESSAGES.passwordOrPasswordProvider);
58
+ }
59
+
60
+ return /** @type {AuthKitOptions} */ (options);
61
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Valeurs sûres par défaut et limites acceptées.
3
+ *
4
+ * @module config/defaults
5
+ */
6
+
7
+ /**
8
+ * Algorithmes JWT acceptés pour une signature par secret partagé, avec la taille
9
+ * minimale de clé exigée par la RFC 7518 §3.2.
10
+ *
11
+ * @type {Readonly<Record<string, number>>}
12
+ */
13
+ export const JWT_ALGORITHM_MIN_KEY_BYTES = Object.freeze({
14
+ HS256: 32,
15
+ HS384: 48,
16
+ HS512: 64
17
+ });
18
+
19
+ export const JWT_DEFAULTS = Object.freeze({
20
+ algorithm: "HS256",
21
+ /** 15 minutes : une courte durée réduit l’impact d’un jeton divulgué. */
22
+ expiresInSeconds: 900,
23
+ clockToleranceSeconds: 0
24
+ });
25
+
26
+ export const JWT_LIMITS = Object.freeze({
27
+ minExpiresInSeconds: 30,
28
+ /** Les jetons d’accès ne sont pas révocables : une durée de plus d’un jour est refusée. */
29
+ maxExpiresInSeconds: 86_400,
30
+ maxClockToleranceSeconds: 60,
31
+ maxClaimLength: 256
32
+ });
33
+
34
+ /**
35
+ * Valeurs Argon2id par défaut : « deuxième option recommandée » de la RFC 9106
36
+ * (64 Mio, t=3, p=4), plus forte que le minimum OWASP.
37
+ */
38
+ export const ARGON2_DEFAULTS = Object.freeze({
39
+ memoryCost: 65_536,
40
+ timeCost: 3,
41
+ parallelism: 4
42
+ });
43
+
44
+ /**
45
+ * Seuils en dessous desquels Argon2id est refusé : au moins 19 Mio de mémoire
46
+ * et 2 itérations, selon OWASP.
47
+ */
48
+ export const ARGON2_LIMITS = Object.freeze({
49
+ minMemoryCost: 19_456,
50
+ maxMemoryCost: 4_194_304,
51
+ minTimeCost: 2,
52
+ maxTimeCost: 20,
53
+ minParallelism: 1,
54
+ maxParallelism: 16
55
+ });
56
+
57
+ export const REFRESH_TOKEN_DEFAULTS = Object.freeze({
58
+ /** 14 jours : l’utilisateur reste connecté s’il revient au moins toutes les deux semaines. */
59
+ expiresInSeconds: 1_209_600
60
+ });
61
+
62
+ export const REFRESH_TOKEN_LIMITS = Object.freeze({
63
+ minExpiresInSeconds: 300,
64
+ /** 90 jours au plus : au-delà, une session oubliée reste exploitable trop longtemps. */
65
+ maxExpiresInSeconds: 7_776_000
66
+ });
@@ -0,0 +1,86 @@
1
+ import { ConfigurationError } from "../core/errors.js";
2
+ import { unsupportedAlgorithm } from "../core/messages.js";
3
+ import {
4
+ assertIntegerInRange,
5
+ assertKnownKeys,
6
+ assertNonEmptyString,
7
+ assertPlainObject
8
+ } from "./assertions.js";
9
+ import { JWT_ALGORITHM_MIN_KEY_BYTES, JWT_DEFAULTS, JWT_LIMITS } from "./defaults.js";
10
+ import { assertStrongSecret } from "./secret-strength.js";
11
+
12
+ /**
13
+ * Options acceptées dans `createAuthKit({ jwt })`.
14
+ *
15
+ * @typedef {object} JwtOptions
16
+ * @property {string | Uint8Array | undefined} secret Clé de signature partagée produite par un générateur sûr (≥ 32 octets pour HS256). Une valeur absente (par exemple une variable d’environnement non définie) est acceptée par les types mais arrête createAuthKit() avec un message clair.
17
+ * @property {string} issuer Valeur de la revendication `iss`, contrôlée à la vérification.
18
+ * @property {string} audience Valeur de la revendication `aud`, contrôlée à la vérification.
19
+ * @property {number} [expiresInSeconds=900] Durée de vie du jeton (30 à 86 400 secondes).
20
+ * @property {"HS256" | "HS384" | "HS512"} [algorithm="HS256"] Seul algorithme accepté à la vérification.
21
+ * @property {number} [clockToleranceSeconds=0] Décalage d’horloge accepté entre instances (0 à 60 secondes).
22
+ */
23
+
24
+ /**
25
+ * Paramètres JWT validés. Le secret est converti une seule fois en octets.
26
+ *
27
+ * @typedef {Readonly<{
28
+ * key: Uint8Array,
29
+ * issuer: string,
30
+ * audience: string,
31
+ * expiresInSeconds: number,
32
+ * algorithm: string,
33
+ * clockToleranceSeconds: number
34
+ * }>} ResolvedJwtOptions
35
+ */
36
+
37
+ const ALLOWED_KEYS = Object.freeze([
38
+ "secret",
39
+ "issuer",
40
+ "audience",
41
+ "expiresInSeconds",
42
+ "algorithm",
43
+ "clockToleranceSeconds"
44
+ ]);
45
+
46
+ /**
47
+ * Valide les options JWT, applique des valeurs sûres et refuse les valeurs dangereuses.
48
+ *
49
+ * @param {unknown} options
50
+ * @returns {ResolvedJwtOptions}
51
+ * @throws {ConfigurationError}
52
+ */
53
+ export function resolveJwtOptions(options) {
54
+ assertPlainObject(options, "jwt");
55
+ assertKnownKeys(options, ALLOWED_KEYS, "jwt");
56
+
57
+ const algorithm = options.algorithm ?? JWT_DEFAULTS.algorithm;
58
+ if (typeof algorithm !== "string" || !Object.hasOwn(JWT_ALGORITHM_MIN_KEY_BYTES, algorithm)) {
59
+ throw new ConfigurationError(unsupportedAlgorithm(Object.keys(JWT_ALGORITHM_MIN_KEY_BYTES)));
60
+ }
61
+
62
+ const key = assertStrongSecret(options.secret, JWT_ALGORITHM_MIN_KEY_BYTES[algorithm]);
63
+
64
+ assertNonEmptyString(options.issuer, "jwt.issuer", JWT_LIMITS.maxClaimLength);
65
+ assertNonEmptyString(options.audience, "jwt.audience", JWT_LIMITS.maxClaimLength);
66
+
67
+ const expiresInSeconds = options.expiresInSeconds ?? JWT_DEFAULTS.expiresInSeconds;
68
+ assertIntegerInRange(
69
+ expiresInSeconds,
70
+ "jwt.expiresInSeconds",
71
+ JWT_LIMITS.minExpiresInSeconds,
72
+ JWT_LIMITS.maxExpiresInSeconds
73
+ );
74
+
75
+ const clockToleranceSeconds = options.clockToleranceSeconds ?? JWT_DEFAULTS.clockToleranceSeconds;
76
+ assertIntegerInRange(clockToleranceSeconds, "jwt.clockToleranceSeconds", 0, JWT_LIMITS.maxClockToleranceSeconds);
77
+
78
+ return Object.freeze({
79
+ key,
80
+ issuer: options.issuer,
81
+ audience: options.audience,
82
+ expiresInSeconds,
83
+ algorithm,
84
+ clockToleranceSeconds
85
+ });
86
+ }