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