@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.
- package/CHANGELOG.md +78 -0
- package/LICENSE +21 -0
- package/README.md +218 -2
- package/SECURITY.md +30 -0
- package/bin/express-authkit.js +30 -0
- package/package.json +93 -4
- package/src/adapters/express/bearer-token.js +35 -0
- package/src/adapters/express/http-errors.js +89 -0
- package/src/adapters/express/index.js +405 -0
- package/src/config/assertions.js +80 -0
- package/src/config/auth-kit-options.js +61 -0
- package/src/config/defaults.js +66 -0
- package/src/config/jwt-options.js +86 -0
- package/src/config/password-options.js +38 -0
- package/src/config/refresh-token-options.js +49 -0
- package/src/config/secret-strength.js +112 -0
- package/src/core/authentication-service.js +278 -0
- package/src/core/authorization.js +156 -0
- package/src/core/contracts/assert-contract.js +26 -0
- package/src/core/contracts/auth-principal.js +36 -0
- package/src/core/contracts/authentication-service.js +44 -0
- package/src/core/contracts/password-provider.js +27 -0
- package/src/core/contracts/refresh-token-store.js +80 -0
- package/src/core/contracts/token-provider.js +45 -0
- package/src/core/contracts/user-repository.js +157 -0
- package/src/core/errors.js +258 -0
- package/src/core/input-limits.js +26 -0
- package/src/core/messages.js +239 -0
- package/src/core/refresh-token-service.js +149 -0
- package/src/factory/create-auth-kit.js +90 -0
- package/src/index.js +61 -0
- package/src/providers/argon2-password-provider.js +68 -0
- package/src/providers/in-memory-refresh-token-store.js +51 -0
- package/src/providers/jwt-token-provider.js +112 -0
- package/types/adapters/express/bearer-token.d.ts +11 -0
- package/types/adapters/express/http-errors.d.ts +30 -0
- package/types/adapters/express/index.d.ts +57 -0
- package/types/config/assertions.d.ts +40 -0
- package/types/config/auth-kit-options.d.ts +41 -0
- package/types/config/defaults.d.ts +55 -0
- package/types/config/jwt-options.d.ts +42 -0
- package/types/config/password-options.d.ts +23 -0
- package/types/config/refresh-token-options.d.ts +21 -0
- package/types/config/secret-strength.d.ts +7 -0
- package/types/core/authentication-service.d.ts +37 -0
- package/types/core/authorization.d.ts +40 -0
- package/types/core/contracts/assert-contract.d.ts +13 -0
- package/types/core/contracts/auth-principal.d.ts +38 -0
- package/types/core/contracts/authentication-service.d.ts +102 -0
- package/types/core/contracts/password-provider.d.ts +32 -0
- package/types/core/contracts/refresh-token-store.d.ts +105 -0
- package/types/core/contracts/token-provider.d.ts +79 -0
- package/types/core/contracts/user-repository.d.ts +112 -0
- package/types/core/errors.d.ts +213 -0
- package/types/core/input-limits.d.ts +20 -0
- package/types/core/messages.d.ts +141 -0
- package/types/core/refresh-token-service.d.ts +31 -0
- package/types/factory/create-auth-kit.d.ts +52 -0
- package/types/index.d.ts +60 -0
- package/types/providers/argon2-password-provider.d.ts +13 -0
- package/types/providers/in-memory-refresh-token-store.d.ts +11 -0
- 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
|
+
}
|