@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,239 @@
1
+ /**
2
+ * Tous les messages d’erreur d’Express AuthKit, au même endroit.
3
+ *
4
+ * Les messages sont rédigés en français. Les applications ne doivent pas s’y
5
+ * fier pour leur logique : elles utilisent le `code` stable de chaque erreur.
6
+ * Centraliser les textes garantit un ton cohérent et facilitera une future
7
+ * traduction.
8
+ *
9
+ * @module core/messages
10
+ */
11
+
12
+ /** Commande proposée chaque fois qu’un secret JWT doit être (re)généré. */
13
+ const GENERATE_SECRET_HINT =
14
+ "Générez-en un avec : npx express-authkit secret " +
15
+ "(ou : node -e \"console.log(require('node:crypto').randomBytes(48).toString('base64url'))\").";
16
+
17
+ /**
18
+ * Messages publics : ils peuvent être renvoyés tels quels aux clients HTTP.
19
+ * Ils ne révèlent jamais d’information interne.
20
+ */
21
+ export const PUBLIC_MESSAGES = Object.freeze({
22
+ invalidCredentials: "Identifiants invalides.",
23
+ tokenMissing: "Authentification requise.",
24
+ tokenInvalid: "Jeton d’accès invalide.",
25
+ tokenExpired: "Jeton d’accès expiré.",
26
+ refreshTokenInvalid: "Jeton de rafraîchissement invalide ou expiré. Reconnectez-vous.",
27
+ accessDenied: "Accès refusé : vous n’avez pas les droits nécessaires.",
28
+ credentialsNotObject: "Les identifiants doivent être un objet contenant « identifier » et « password ».",
29
+ missingJsonBody:
30
+ "Le corps de la requête doit être un objet JSON : envoyez l’en-tête « Content-Type: application/json ».",
31
+ malformedJsonBody: "Le corps de la requête n’est pas un JSON valide.",
32
+ payloadTooLarge: "Le corps de la requête est trop volumineux.",
33
+ refreshTokenMalformed: "Le champ « refreshToken » est obligatoire et doit être un texte non vide.",
34
+ userIdInvalid: "L’identifiant d’utilisateur doit être un texte non vide, un entier ou un ObjectId MongoDB."
35
+ });
36
+
37
+ /**
38
+ * @param {string} field
39
+ * @returns {string}
40
+ */
41
+ export function fieldRequired(field) {
42
+ return `Le champ « ${field} » est obligatoire et doit être un texte non vide.`;
43
+ }
44
+
45
+ /**
46
+ * @param {string} field
47
+ * @param {number} maxLength
48
+ * @returns {string}
49
+ */
50
+ export function fieldTooLong(field, maxLength) {
51
+ return `Le champ « ${field} » ne doit pas dépasser ${maxLength} caractères.`;
52
+ }
53
+
54
+ /**
55
+ * Messages destinés aux développeurs (configuration, contrats, pannes).
56
+ * Ils expliquent toujours comment corriger le problème.
57
+ */
58
+ export const DEVELOPER_MESSAGES = Object.freeze({
59
+ // Mots de passe
60
+ unsupportedPasswordHash:
61
+ "Le hash de mot de passe enregistré n’est pas au format Argon2id. " +
62
+ "Vérifiez qu’il a été produit par auth.password.hash(), ou migrez les anciens hash (voir docs/providers-personnalises.md).",
63
+
64
+ // Options de createAuthKit()
65
+ usersRequired:
66
+ "L’option « users » est obligatoire : passez un objet qui possède les méthodes findByIdentifier() et findById().",
67
+ jwtOrTokenProvider:
68
+ "Utilisez soit l’option « jwt », soit « providers.token », mais pas les deux : l’une des deux serait ignorée.",
69
+ jwtRequired: "L’option « jwt » est obligatoire (secret, issuer, audience).",
70
+ passwordOrPasswordProvider:
71
+ "Utilisez soit l’option « password », soit « providers.password », mais pas les deux : l’une des deux serait ignorée.",
72
+
73
+ // Secret JWT
74
+ secretRequired: `L’option « jwt.secret » est obligatoire. ${GENERATE_SECRET_HINT}`,
75
+ secretWrongType: "L’option « jwt.secret » doit être un texte ou un Uint8Array.",
76
+ secretPlaceholder: `L’option « jwt.secret » ressemble à une valeur d’exemple. ${GENERATE_SECRET_HINT}`,
77
+ secretRepetitive: `L’option « jwt.secret » est trop répétitive pour être aléatoire. ${GENERATE_SECRET_HINT}`,
78
+
79
+ // Adaptateur Express
80
+ invalidRealm:
81
+ "L’option « realm » doit contenir de 1 à 64 caractères : lettres, chiffres, espaces, points, tirets ou soulignés.",
82
+ invalidIdentifierField:
83
+ "L’option « identifierField » doit être un simple nom de champ, par exemple \"email\" ou \"username\".",
84
+
85
+ // Contrats vérifiés pendant l’exécution
86
+ repositoryRecordNotObject:
87
+ "Le repository utilisateur doit renvoyer un objet, null ou undefined.",
88
+ repositoryRecordId:
89
+ "L’utilisateur renvoyé par le repository doit avoir un « id » : texte non vide, entier ou ObjectId MongoDB.",
90
+ repositoryRecordPasswordHash:
91
+ "L’utilisateur renvoyé par le repository doit avoir un « passwordHash » (texte non vide). " +
92
+ "Si votre requête ne sélectionne que certains champs, ajoutez celui du hash.",
93
+ repositoryRecordIsActive:
94
+ "L’utilisateur renvoyé par le repository doit avoir un « isActive » booléen. " +
95
+ "Si votre application ne permet pas de désactiver un compte, renvoyez « isActive: true ».",
96
+ repositoryIdMismatch:
97
+ "UserRepository.findById() a renvoyé un utilisateur dont l’« id » diffère de celui demandé.",
98
+ tokenClaimsWithoutSubject:
99
+ "TokenProvider.verify() doit renvoyer des informations contenant un « subject » non vide.",
100
+ refreshStoreConsumeInvalid:
101
+ "RefreshTokenStore.consume() doit renvoyer null, ou { userId, familyId, expiresAt, alreadyUsed } : " +
102
+ "userId et familyId sont des textes, expiresAt un nombre de secondes, alreadyUsed un booléen.",
103
+ refreshStoreRequired:
104
+ "L’option « refreshTokens.store » est obligatoire : passez votre stockage (save, consume, revokeFamily, " +
105
+ "revokeUser), ou createInMemoryRefreshTokenStore() pour le développement.",
106
+ refreshTokensDisabled:
107
+ "Les jetons de rafraîchissement ne sont pas activés. Ajoutez l’option « refreshTokens: { store } » " +
108
+ "à createAuthKit() (voir docs/refresh-tokens.md).",
109
+ authorizeRequirementInvalid:
110
+ "expressAuth.authorize() attend un rôle (\"admin\") ou un objet { anyRole, allPermissions, check } " +
111
+ "avec au moins une règle. anyRole et allPermissions sont des listes de textes non vides, check est une fonction.",
112
+ authorizationCheckNotBoolean:
113
+ "La fonction « check » de authorize() doit renvoyer true ou false (éventuellement via une promesse).",
114
+ issuedTokenInvalid:
115
+ "TokenProvider.issue() doit renvoyer { token, tokenId, issuedAt, expiresAt } : " +
116
+ "token et tokenId sont des textes, issuedAt et expiresAt des nombres de secondes."
117
+ });
118
+
119
+ /**
120
+ * @param {number} maxLength
121
+ * @returns {string}
122
+ */
123
+ export function subjectInvalid(maxLength) {
124
+ return `Le « subject » du jeton doit être un texte non vide d’au plus ${maxLength} caractères.`;
125
+ }
126
+
127
+ /**
128
+ * @param {string} name Nom de l’option, par exemple « jwt » ou « createAuthKit(options) ».
129
+ * @returns {string}
130
+ */
131
+ export function notPlainObject(name) {
132
+ return `« ${name} » doit être un objet simple, par exemple { … }.`;
133
+ }
134
+
135
+ /**
136
+ * @param {string} name
137
+ * @param {string[]} unknownKeys
138
+ * @param {readonly string[]} allowedKeys
139
+ * @returns {string}
140
+ */
141
+ export function unknownOptions(name, unknownKeys, allowedKeys) {
142
+ const label = unknownKeys.length > 1 ? "Options inconnues" : "Option inconnue";
143
+ return `${label} dans « ${name} » : ${unknownKeys.join(", ")}. Options acceptées : ${allowedKeys.join(", ")}.`;
144
+ }
145
+
146
+ /**
147
+ * @param {string} name
148
+ * @returns {string}
149
+ */
150
+ export function optionRequiredString(name) {
151
+ return `L’option « ${name} » doit être un texte non vide.`;
152
+ }
153
+
154
+ /**
155
+ * @param {string} name
156
+ * @param {number} maxLength
157
+ * @returns {string}
158
+ */
159
+ export function optionTooLong(name, maxLength) {
160
+ return `L’option « ${name} » ne doit pas dépasser ${maxLength} caractères.`;
161
+ }
162
+
163
+ /**
164
+ * @param {string} name
165
+ * @param {number} min
166
+ * @param {number} max
167
+ * @returns {string}
168
+ */
169
+ export function optionIntegerRange(name, min, max) {
170
+ return `L’option « ${name} » doit être un nombre entier compris entre ${min} et ${max}.`;
171
+ }
172
+
173
+ /**
174
+ * @param {readonly string[]} algorithms
175
+ * @returns {string}
176
+ */
177
+ export function unsupportedAlgorithm(algorithms) {
178
+ return `L’option « jwt.algorithm » doit valoir l’une de ces valeurs : ${algorithms.join(", ")}.`;
179
+ }
180
+
181
+ /**
182
+ * @param {number} minBytes
183
+ * @returns {string}
184
+ */
185
+ export function secretTooShort(minBytes) {
186
+ return `L’option « jwt.secret » doit contenir au moins ${minBytes} octets pour l’algorithme choisi. ${GENERATE_SECRET_HINT}`;
187
+ }
188
+
189
+ /**
190
+ * @param {string} contractName
191
+ * @returns {string}
192
+ */
193
+ export function contractNotObject(contractName) {
194
+ return `${contractName} doit être un objet.`;
195
+ }
196
+
197
+ /**
198
+ * @param {string} contractName
199
+ * @param {string[]} missingMethods
200
+ * @returns {string}
201
+ */
202
+ export function contractMissingMethods(contractName, missingMethods) {
203
+ const list = missingMethods.map((name) => `${name}()`).join(", ");
204
+ return missingMethods.length > 1
205
+ ? `${contractName} doit implémenter les méthodes suivantes : ${list}.`
206
+ : `${contractName} doit implémenter la méthode ${list}.`;
207
+ }
208
+
209
+ /**
210
+ * @param {string} dependency Par exemple « UserRepository.findById ».
211
+ * @returns {string}
212
+ */
213
+ export function dependencyFailed(dependency) {
214
+ return `L’appel à ${dependency}() a échoué. Consultez la propriété « cause » de cette erreur pour connaître l’erreur d’origine.`;
215
+ }
216
+
217
+ /**
218
+ * @param {string} method Par exemple « authenticate ».
219
+ * @returns {string}
220
+ */
221
+ export function middlewareFactoryMisuse(method) {
222
+ return (
223
+ `expressAuth.${method} doit être appelé avec des parenthèses : ` +
224
+ `écrivez expressAuth.${method}() et non expressAuth.${method}.`
225
+ );
226
+ }
227
+
228
+ /**
229
+ * @param {string} field « roles » ou « permissions ».
230
+ * @param {number} maxItems
231
+ * @param {number} maxLength
232
+ * @returns {string}
233
+ */
234
+ export function repositoryRecordAuthorities(field, maxItems, maxLength) {
235
+ return (
236
+ `Le champ « ${field} » renvoyé par le repository doit être une liste de textes non vides ` +
237
+ `(au plus ${maxItems} éléments de ${maxLength} caractères), ou être absent.`
238
+ );
239
+ }
@@ -0,0 +1,149 @@
1
+ import { normalizeConsumedRefreshToken } from "./contracts/refresh-token-store.js";
2
+ import { AUTH_FAILURE_REASONS, InvalidInputError, InvalidRefreshTokenError, callDependency } from "./errors.js";
3
+ import { MAX_REFRESH_TOKEN_LENGTH } from "./input-limits.js";
4
+ import { PUBLIC_MESSAGES } from "./messages.js";
5
+
6
+ /** 256 bits d’aléa : impossible à deviner, même avec beaucoup d’essais. */
7
+ const REFRESH_TOKEN_BYTES = 32;
8
+
9
+ /**
10
+ * @typedef {import("./contracts/refresh-token-store.js").RefreshTokenStore} RefreshTokenStore
11
+ * @typedef {import("./contracts/refresh-token-store.js").ConsumedRefreshToken} ConsumedRefreshToken
12
+ */
13
+
14
+ /**
15
+ * Gère le cycle de vie des jetons de rafraîchissement : émission, rotation,
16
+ * détection de réutilisation et révocation.
17
+ *
18
+ * Les jetons sont des valeurs aléatoires opaques (pas des JWT) : ils ne
19
+ * contiennent aucune information et ne valent que par leur présence dans le
20
+ * stockage, ce qui permet de les révoquer à tout moment.
21
+ *
22
+ * @param {object} options
23
+ * @param {RefreshTokenStore} options.store
24
+ * @param {number} options.expiresInSeconds
25
+ */
26
+ export function createRefreshTokenService({ store, expiresInSeconds }) {
27
+ /**
28
+ * Émet un nouveau jeton et l’enregistre (seulement son empreinte).
29
+ *
30
+ * @param {string} userId
31
+ * @param {string} [familyId] Famille existante en cas de rotation ; nouvelle sinon.
32
+ * @returns {Promise<{ token: string, expiresIn: number }>}
33
+ */
34
+ async function issue(userId, familyId = globalThis.crypto.randomUUID()) {
35
+ const token = generateToken();
36
+ const record = {
37
+ tokenHash: await hashToken(token),
38
+ userId,
39
+ familyId,
40
+ expiresAt: nowInSeconds() + expiresInSeconds
41
+ };
42
+ await callDependency("RefreshTokenStore.save", () => store.save(record));
43
+ return { token, expiresIn: expiresInSeconds };
44
+ }
45
+
46
+ /**
47
+ * Consomme un jeton pour une rotation. Un jeton déjà utilisé signale un vol
48
+ * probable : toute sa famille est alors révoquée.
49
+ *
50
+ * @param {unknown} token
51
+ * @returns {Promise<ConsumedRefreshToken>}
52
+ * @throws {InvalidInputError | InvalidRefreshTokenError}
53
+ */
54
+ async function consume(token) {
55
+ const consumed = await consumeRecord(assertRefreshTokenShape(token));
56
+ if (consumed === null) {
57
+ throw new InvalidRefreshTokenError(AUTH_FAILURE_REASONS.REFRESH_TOKEN_UNKNOWN);
58
+ }
59
+ if (consumed.alreadyUsed) {
60
+ await revokeFamily(consumed.familyId);
61
+ throw new InvalidRefreshTokenError(AUTH_FAILURE_REASONS.REFRESH_TOKEN_REUSED);
62
+ }
63
+ if (consumed.expiresAt <= nowInSeconds()) {
64
+ throw new InvalidRefreshTokenError(AUTH_FAILURE_REASONS.REFRESH_TOKEN_EXPIRED);
65
+ }
66
+ return consumed;
67
+ }
68
+
69
+ /**
70
+ * Révoque la session à laquelle appartient le jeton. Ne lève pas d’erreur si
71
+ * le jeton est inconnu, expiré ou déjà révoqué : une déconnexion répétée doit
72
+ * réussir.
73
+ *
74
+ * @param {unknown} token
75
+ * @returns {Promise<void>}
76
+ */
77
+ async function revokeSessionOf(token) {
78
+ const consumed = await consumeRecord(assertRefreshTokenShape(token));
79
+ if (consumed !== null) {
80
+ await revokeFamily(consumed.familyId);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * @param {string} familyId
86
+ * @returns {Promise<void>}
87
+ */
88
+ function revokeFamily(familyId) {
89
+ return callDependency("RefreshTokenStore.revokeFamily", () => store.revokeFamily(familyId));
90
+ }
91
+
92
+ /**
93
+ * @param {string} userId
94
+ * @returns {Promise<void>}
95
+ */
96
+ function revokeUser(userId) {
97
+ return callDependency("RefreshTokenStore.revokeUser", () => store.revokeUser(userId));
98
+ }
99
+
100
+ /**
101
+ * @param {string} token
102
+ * @returns {Promise<ConsumedRefreshToken | null>}
103
+ */
104
+ async function consumeRecord(token) {
105
+ const tokenHash = await hashToken(token);
106
+ return normalizeConsumedRefreshToken(
107
+ await callDependency("RefreshTokenStore.consume", () => store.consume(tokenHash))
108
+ );
109
+ }
110
+
111
+ return Object.freeze({ issue, consume, revokeSessionOf, revokeFamily, revokeUser });
112
+ }
113
+
114
+ /**
115
+ * @param {unknown} token
116
+ * @returns {string}
117
+ * @throws {InvalidInputError}
118
+ */
119
+ function assertRefreshTokenShape(token) {
120
+ if (typeof token !== "string" || token.length === 0 || token.length > MAX_REFRESH_TOKEN_LENGTH) {
121
+ throw new InvalidInputError(PUBLIC_MESSAGES.refreshTokenMalformed);
122
+ }
123
+ return token;
124
+ }
125
+
126
+ /**
127
+ * @returns {string} 32 octets aléatoires encodés en base64url (43 caractères).
128
+ */
129
+ function generateToken() {
130
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(REFRESH_TOKEN_BYTES));
131
+ return Buffer.from(bytes).toString("base64url");
132
+ }
133
+
134
+ /**
135
+ * Empreinte SHA-256 en hexadécimal. Un hachage rapide suffit ici : le jeton a
136
+ * 256 bits d’aléa, il ne peut pas être retrouvé par force brute (contrairement
137
+ * à un mot de passe, qui exige Argon2id).
138
+ *
139
+ * @param {string} token
140
+ * @returns {Promise<string>}
141
+ */
142
+ async function hashToken(token) {
143
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
144
+ return Buffer.from(digest).toString("hex");
145
+ }
146
+
147
+ function nowInSeconds() {
148
+ return Math.floor(Date.now() / 1000);
149
+ }
@@ -0,0 +1,90 @@
1
+ import { validateAuthKitOptions } from "../config/auth-kit-options.js";
2
+ import { resolveRefreshTokenOptions } from "../config/refresh-token-options.js";
3
+ import { createAuthenticationService } from "../core/authentication-service.js";
4
+ import { createRefreshTokenService } from "../core/refresh-token-service.js";
5
+ import { callDependency } from "../core/errors.js";
6
+ import { assertPasswordProvider } from "../core/contracts/password-provider.js";
7
+ import { assertTokenProvider } from "../core/contracts/token-provider.js";
8
+ import { assertUserRepository } from "../core/contracts/user-repository.js";
9
+ import { createArgon2PasswordProvider } from "../providers/argon2-password-provider.js";
10
+ import { createJwtTokenProvider } from "../providers/jwt-token-provider.js";
11
+
12
+ /**
13
+ * Fonctions liées aux mots de passe, exposées dans `auth.password`.
14
+ *
15
+ * @typedef {Readonly<{
16
+ * hash: (password: string) => Promise<string>,
17
+ * verify: (password: string, hash: string) => Promise<boolean>
18
+ * }>} PasswordApi
19
+ */
20
+
21
+ /**
22
+ * Moteur d’authentification renvoyé par {@link createAuthKit}.
23
+ *
24
+ * @typedef {Readonly<{
25
+ * login: (credentials: import("../core/contracts/authentication-service.js").LoginCredentials) => Promise<import("../core/contracts/authentication-service.js").LoginResult>,
26
+ * refresh: (refreshToken: string) => Promise<import("../core/contracts/authentication-service.js").LoginResult>,
27
+ * logout: (refreshToken: string) => Promise<void>,
28
+ * revokeAllSessions: (userId: import("../core/contracts/authentication-service.js").UserIdInput) => Promise<void>,
29
+ * verifyAccessToken: (token: string) => Promise<import("../core/contracts/auth-principal.js").AuthPrincipal>,
30
+ * password: PasswordApi
31
+ * }>} AuthKit
32
+ */
33
+
34
+ /**
35
+ * Crée et configure un moteur d’authentification.
36
+ *
37
+ * Toutes les options et dépendances injectées sont validées ici. Une mauvaise
38
+ * configuration arrête donc l’application au démarrage, et non à la première
39
+ * requête. L’objet renvoyé ne conserve aucun état par utilisateur et peut être
40
+ * partagé dans tout le processus.
41
+ *
42
+ * @example
43
+ * const auth = createAuthKit({
44
+ * users: userRepository,
45
+ * jwt: { secret: process.env.AUTH_SECRET, issuer: "my-api", audience: "my-client" }
46
+ * });
47
+ *
48
+ * @param {import("../config/auth-kit-options.js").AuthKitOptions} options
49
+ * @returns {AuthKit}
50
+ * @throws {import("../core/errors.js").ConfigurationError} Si les options manquent ou ne sont pas sûres.
51
+ * @throws {import("../core/errors.js").ContractViolationError} Si une dépendance injectée ne respecte pas son contrat.
52
+ */
53
+ export function createAuthKit(options) {
54
+ const { users, jwt, password, refreshTokens, providers = {} } = validateAuthKitOptions(options);
55
+
56
+ assertUserRepository(users);
57
+
58
+ const passwordProvider = providers.password ?? createArgon2PasswordProvider(password);
59
+ assertPasswordProvider(passwordProvider);
60
+
61
+ // validateAuthKitOptions() garantit que `jwt` est présent quand aucun provider de token n’est fourni.
62
+ const tokenProvider =
63
+ providers.token ?? createJwtTokenProvider(/** @type {import("../config/jwt-options.js").JwtOptions} */ (jwt));
64
+ assertTokenProvider(tokenProvider);
65
+
66
+ const refreshTokenService =
67
+ refreshTokens === undefined ? undefined : createRefreshTokenService(resolveRefreshTokenOptions(refreshTokens));
68
+
69
+ const service = createAuthenticationService({
70
+ users,
71
+ passwordProvider,
72
+ tokenProvider,
73
+ refreshTokens: refreshTokenService
74
+ });
75
+
76
+ return Object.freeze({
77
+ login: service.login,
78
+ refresh: service.refresh,
79
+ logout: service.logout,
80
+ revokeAllSessions: service.revokeAllSessions,
81
+ verifyAccessToken: service.verifyAccessToken,
82
+ password: Object.freeze({
83
+ /** @param {string} plainPassword */
84
+ hash: (plainPassword) => callDependency("PasswordProvider.hash", () => passwordProvider.hash(plainPassword)),
85
+ /** @param {string} plainPassword @param {string} passwordHash */
86
+ verify: (plainPassword, passwordHash) =>
87
+ callDependency("PasswordProvider.verify", () => passwordProvider.verify(plainPassword, passwordHash))
88
+ })
89
+ });
90
+ }
package/src/index.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Express AuthKit — point d’entrée indépendant du framework.
3
+ *
4
+ * API publique. Les modules non réexportés ici sont internes et peuvent
5
+ * évoluer sans préavis.
6
+ *
7
+ * @module @kourouma/express-authkit
8
+ */
9
+
10
+ export { createAuthKit } from "./factory/create-auth-kit.js";
11
+ export { isAuthorized } from "./core/authorization.js";
12
+
13
+ export { createArgon2PasswordProvider } from "./providers/argon2-password-provider.js";
14
+ export { createJwtTokenProvider } from "./providers/jwt-token-provider.js";
15
+ export { createInMemoryRefreshTokenStore } from "./providers/in-memory-refresh-token-store.js";
16
+
17
+ export { assertUserRepository } from "./core/contracts/user-repository.js";
18
+ export { assertPasswordProvider } from "./core/contracts/password-provider.js";
19
+ export { assertTokenProvider } from "./core/contracts/token-provider.js";
20
+ export { assertRefreshTokenStore } from "./core/contracts/refresh-token-store.js";
21
+
22
+ export {
23
+ AUTH_ERROR_CODES,
24
+ AUTH_FAILURE_REASONS,
25
+ AccessDeniedError,
26
+ AuthKitError,
27
+ ConfigurationError,
28
+ ContractViolationError,
29
+ DependencyFailureError,
30
+ InvalidCredentialsError,
31
+ InvalidInputError,
32
+ InvalidPasswordHashError,
33
+ InvalidRefreshTokenError,
34
+ InvalidTokenError,
35
+ TokenExpiredError,
36
+ TokenMissingError,
37
+ isAuthKitError
38
+ } from "./core/errors.js";
39
+
40
+ /**
41
+ * @typedef {import("./factory/create-auth-kit.js").AuthKit} AuthKit
42
+ * @typedef {import("./config/auth-kit-options.js").AuthKitOptions} AuthKitOptions
43
+ * @typedef {import("./config/jwt-options.js").JwtOptions} JwtOptions
44
+ * @typedef {import("./config/password-options.js").PasswordOptions} PasswordOptions
45
+ * @typedef {import("./core/contracts/user-repository.js").UserRepository} UserRepository
46
+ * @typedef {import("./core/contracts/user-repository.js").AuthUserRecord} AuthUserRecord
47
+ * @typedef {import("./core/contracts/password-provider.js").PasswordProvider} PasswordProvider
48
+ * @typedef {import("./core/contracts/token-provider.js").TokenProvider} TokenProvider
49
+ * @typedef {import("./core/contracts/token-provider.js").IssuedToken} IssuedToken
50
+ * @typedef {import("./core/contracts/token-provider.js").VerifiedTokenClaims} VerifiedTokenClaims
51
+ * @typedef {import("./core/contracts/authentication-service.js").AuthenticationService} AuthenticationService
52
+ * @typedef {import("./core/contracts/authentication-service.js").LoginCredentials} LoginCredentials
53
+ * @typedef {import("./core/contracts/authentication-service.js").LoginResult} LoginResult
54
+ * @typedef {import("./core/contracts/auth-principal.js").AuthPrincipal} AuthPrincipal
55
+ * @typedef {import("./core/contracts/authentication-service.js").UserIdInput} UserIdInput
56
+ * @typedef {import("./core/authorization.js").AuthorizationRequirement} AuthorizationRequirement
57
+ * @typedef {import("./config/refresh-token-options.js").RefreshTokenOptions} RefreshTokenOptions
58
+ * @typedef {import("./core/contracts/refresh-token-store.js").RefreshTokenStore} RefreshTokenStore
59
+ * @typedef {import("./core/contracts/refresh-token-store.js").RefreshTokenRecord} RefreshTokenRecord
60
+ * @typedef {import("./core/contracts/refresh-token-store.js").ConsumedRefreshToken} ConsumedRefreshToken
61
+ */
@@ -0,0 +1,68 @@
1
+ import { argon2id, hash as argon2Hash, verify as argon2Verify } from "argon2";
2
+
3
+ import { resolvePasswordOptions } from "../config/password-options.js";
4
+ import { InvalidInputError, InvalidPasswordHashError } from "../core/errors.js";
5
+ import { MAX_PASSWORD_LENGTH } from "../core/input-limits.js";
6
+ import { fieldRequired, fieldTooLong } from "../core/messages.js";
7
+
8
+ /** Seuls les hash Argon2id sont acceptés : Argon2i et Argon2d ne sont pas recommandés pour les mots de passe. */
9
+ const ARGON2ID_PREFIX = "$argon2id$";
10
+
11
+ /**
12
+ * Crée un {@link import("../core/contracts/password-provider.js").PasswordProvider}
13
+ * basé sur le paquet `argon2` (implémentation C de référence, variante Argon2id).
14
+ *
15
+ * La bibliothèque génère le sel avec un générateur aléatoire sécurisé et le
16
+ * conserve avec les paramètres dans le hash. Modifier les coûts plus tard ne
17
+ * rend donc pas les hash existants invalides.
18
+ *
19
+ * @param {import("../config/password-options.js").PasswordOptions} [options]
20
+ * @returns {import("../core/contracts/password-provider.js").PasswordProvider}
21
+ * @throws {import("../core/errors.js").ConfigurationError}
22
+ */
23
+ export function createArgon2PasswordProvider(options) {
24
+ const parameters = resolvePasswordOptions(options);
25
+
26
+ /**
27
+ * @param {string} password
28
+ * @returns {Promise<string>}
29
+ */
30
+ async function hash(password) {
31
+ assertPassword(password);
32
+ return argon2Hash(password, { type: argon2id, ...parameters });
33
+ }
34
+
35
+ /**
36
+ * @param {string} password
37
+ * @param {string} passwordHash
38
+ * @returns {Promise<boolean>}
39
+ */
40
+ async function verify(password, passwordHash) {
41
+ assertPassword(password);
42
+ if (typeof passwordHash !== "string" || !passwordHash.startsWith(ARGON2ID_PREFIX)) {
43
+ throw new InvalidPasswordHashError();
44
+ }
45
+ try {
46
+ // argon2.verify() recalcule le hash avec le sel et les paramètres
47
+ // stockés, puis compare les résultats en temps constant.
48
+ return await argon2Verify(passwordHash, password);
49
+ } catch (error) {
50
+ throw new InvalidPasswordHashError({ cause: error });
51
+ }
52
+ }
53
+
54
+ return Object.freeze({ hash, verify });
55
+ }
56
+
57
+ /**
58
+ * @param {unknown} password
59
+ * @returns {asserts password is string}
60
+ */
61
+ function assertPassword(password) {
62
+ if (typeof password !== "string" || password.length === 0) {
63
+ throw new InvalidInputError(fieldRequired("password"));
64
+ }
65
+ if (password.length > MAX_PASSWORD_LENGTH) {
66
+ throw new InvalidInputError(fieldTooLong("password", MAX_PASSWORD_LENGTH));
67
+ }
68
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Stockage des jetons de rafraîchissement en mémoire.
3
+ *
4
+ * ⚠️ Réservé au développement et aux tests : les jetons sont perdus au
5
+ * redémarrage et ne sont pas partagés entre plusieurs processus ou serveurs.
6
+ * En production, implémentez le contrat `RefreshTokenStore` avec votre base
7
+ * de données (voir docs/refresh-tokens.md).
8
+ *
9
+ * @returns {import("../core/contracts/refresh-token-store.js").RefreshTokenStore}
10
+ */
11
+ export function createInMemoryRefreshTokenStore() {
12
+ /** @type {Map<string, { userId: string, familyId: string, expiresAt: number, used: boolean }>} */
13
+ const records = new Map();
14
+
15
+ /** @param {(record: { userId: string, familyId: string, expiresAt: number, used: boolean }) => boolean} predicate */
16
+ function deleteWhere(predicate) {
17
+ for (const [tokenHash, record] of records) {
18
+ if (predicate(record)) {
19
+ records.delete(tokenHash);
20
+ }
21
+ }
22
+ }
23
+
24
+ return Object.freeze({
25
+ async save({ tokenHash, userId, familyId, expiresAt }) {
26
+ // Nettoyage au fil de l’eau : la mémoire ne grossit pas indéfiniment.
27
+ const now = Math.floor(Date.now() / 1000);
28
+ deleteWhere((record) => record.expiresAt <= now);
29
+ records.set(tokenHash, { userId, familyId, expiresAt, used: false });
30
+ },
31
+
32
+ async consume(tokenHash) {
33
+ const record = records.get(tokenHash);
34
+ if (record === undefined) {
35
+ return null;
36
+ }
37
+ // Lecture et marquage dans le même tour de boucle : atomique en JavaScript.
38
+ const alreadyUsed = record.used;
39
+ record.used = true;
40
+ return { userId: record.userId, familyId: record.familyId, expiresAt: record.expiresAt, alreadyUsed };
41
+ },
42
+
43
+ async revokeFamily(familyId) {
44
+ deleteWhere((record) => record.familyId === familyId);
45
+ },
46
+
47
+ async revokeUser(userId) {
48
+ deleteWhere((record) => record.userId === userId);
49
+ }
50
+ });
51
+ }