@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,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
|
+
}
|