@nodefony/security 10.0.0-alpha.8 → 10.0.0-beta.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/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +2 -2
- package/dist/nodefony/command/security-token.js +4 -4
- package/dist/nodefony/command/security-user-add.js +2 -18
- package/dist/nodefony/command/security-user-delete.js +2 -2
- package/dist/nodefony/command/security-user-list.js +1 -1
- package/dist/nodefony/command/security-user-password.js +2 -2
- package/dist/nodefony/config/config.js +2 -2
- package/dist/nodefony/service/apiKeys.js +2 -2
- package/dist/nodefony/service/auditService.js +6 -5
- package/dist/nodefony/service/authFlow.js +2 -2
- package/dist/nodefony/service/authorization.js +3 -4
- package/dist/nodefony/service/firewall.js +21 -9
- package/dist/nodefony/service/oauth2.js +5 -3
- package/dist/nodefony/service/securityHeaders.js +1 -1
- package/dist/nodefony/service/tokenService.js +30 -20
- package/dist/nodefony/service/totp.js +5 -3
- package/dist/nodefony/service/webAuthn.js +41 -33
- package/dist/nodefony/service/webhooks.js +55 -44
- package/dist/nodefony/src/admin/SecurityAdminApi.js +2 -1
- package/dist/nodefony/src/admin/WebhookAdminApi.js +1 -0
- package/dist/nodefony/src/admin/userRevocationCascade.js +1 -1
- package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -1
- package/dist/nodefony/src/audit/MemoryAuditStore.js +4 -8
- package/dist/nodefony/src/audit/auditBridge.js +9 -5
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -3
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +2 -2
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +2 -2
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +2 -2
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +5 -3
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +2 -2
- package/dist/nodefony/src/csp.js +4 -5
- package/dist/nodefony/src/oauth/providers/github.js +1 -1
- package/dist/nodefony/src/realtime/frameAuthorizer.js +6 -6
- package/dist/nodefony/src/token/JwtKeystore.js +7 -6
- package/dist/nodefony/src/token/MemoryTokenStore.js +19 -6
- package/dist/nodefony/src/token/jwtRuntime.js +13 -1
- package/dist/nodefony/src/token/tokenCriteria.js +1 -0
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +15 -3
- package/dist/nodefony/src/totp/totpCrypto.js +9 -9
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +24 -6
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +18 -10
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +4 -3
- package/dist/types/nodefony/command/security-user-add.d.ts +0 -16
- package/dist/types/nodefony/contracts/IAuditEvent.d.ts +8 -4
- package/dist/types/nodefony/contracts/IFirewall.d.ts +2 -2
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +5 -5
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +4 -4
- package/dist/types/nodefony/contracts/ITokenStore.d.ts +3 -3
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +4 -4
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +1 -1
- package/dist/types/nodefony/errors/InvalidTargetError.d.ts +1 -1
- package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +1 -1
- package/dist/types/nodefony/service/firewall.d.ts +3 -2
- package/dist/types/nodefony/service/securityHeaders.d.ts +5 -5
- package/dist/types/nodefony/src/SecuredArea.d.ts +2 -2
- package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +5 -3
- package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +2 -2
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +2 -2
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +3 -3
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +2 -2
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +2 -2
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +10 -0
- package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +0 -9
- package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +0 -8
- package/docs/api-keys.md +67 -65
- package/docs/audit.md +87 -82
- package/docs/authenticators.md +61 -49
- package/docs/authorization.md +21 -21
- package/docs/cors.md +30 -30
- package/docs/csrf.md +28 -28
- package/docs/external-jwt.md +21 -13
- package/docs/firewall.md +32 -32
- package/docs/headers.md +29 -29
- package/docs/index.md +3 -1
- package/docs/oauth2.md +61 -61
- package/docs/obtenir-un-jeton.md +2 -2
- package/docs/tokens.md +56 -56
- package/docs/totp.md +73 -72
- package/docs/webauthn.md +85 -85
- package/docs/webhooks.md +50 -47
- package/package.json +19 -16
|
@@ -28,7 +28,7 @@ export interface IWebAuthnCredentialSummary {
|
|
|
28
28
|
/** Compteur de signatures (anti-clone WebAuthn §6.1.1). */
|
|
29
29
|
readonly signCount: number;
|
|
30
30
|
/** Nom donné à l'appareil par l'utilisateur, si renseigné. */
|
|
31
|
-
readonly nickname?: string;
|
|
31
|
+
readonly nickname?: string | undefined;
|
|
32
32
|
/** Enrôlement (epoch ms). */
|
|
33
33
|
readonly createdAt: number;
|
|
34
34
|
/** Dernière authentification réussie (epoch ms), ou `null` = jamais servie. */
|
|
@@ -23,7 +23,7 @@ export declare class InvalidTargetError extends nodefonyError {
|
|
|
23
23
|
/** Code d'erreur OAuth à rendre au client (RFC 6749 §5.2 / RFC 8707 §2). */
|
|
24
24
|
readonly oauthError = "invalid_target";
|
|
25
25
|
/** Ce que le client a demandé, tel qu'il l'a écrit — pour le journal. */
|
|
26
|
-
readonly requested?: string;
|
|
26
|
+
readonly requested?: string | undefined;
|
|
27
27
|
/**
|
|
28
28
|
* @param description - raison, destinée à `error_description` (aucune fuite :
|
|
29
29
|
* elle qualifie la DEMANDE, jamais la configuration du serveur)
|
|
@@ -27,7 +27,7 @@ import { nodefonyError } from "nodefony";
|
|
|
27
27
|
*/
|
|
28
28
|
export declare class UnverifiableTokenError extends nodefonyError {
|
|
29
29
|
/** Cause technique, destinée au JOURNAL — jamais au client. */
|
|
30
|
-
readonly detail?: string;
|
|
30
|
+
readonly detail?: string | undefined;
|
|
31
31
|
/**
|
|
32
32
|
* @param detail - cause technique pour le journal ; n'apparaît jamais dans le
|
|
33
33
|
* message rendu au client
|
|
@@ -25,8 +25,9 @@ import type { IFirewallDescription, IRoleHierarchyDescription } from "../contrac
|
|
|
25
25
|
* d'identité down, câblage manquant) → log ERROR serveur + 401 générique
|
|
26
26
|
* (aucun détail ne fuite au client).
|
|
27
27
|
*
|
|
28
|
-
* Conformité : tout 401 porte un challenge `WWW-Authenticate` (RFC
|
|
29
|
-
*
|
|
28
|
+
* Conformité : tout 401 porte un challenge `WWW-Authenticate` (RFC 9110
|
|
29
|
+
* §15.5.2) — celui du premier authenticator de la zone qui en déclare un, sinon
|
|
30
|
+
* le défi de repli {@link FALLBACK_CHALLENGE}.
|
|
30
31
|
*
|
|
31
32
|
* CORS, CSRF et autorisation par décorateurs viennent se brancher en S4/S5.
|
|
32
33
|
* Toutes les structures sont **lazy** (perf : une app sans zone = zéro alloc).
|
|
@@ -13,11 +13,11 @@ export interface ISecurityHeadersOptions {
|
|
|
13
13
|
csp: string;
|
|
14
14
|
cspNonces: boolean;
|
|
15
15
|
referrerPolicy: string;
|
|
16
|
-
coop?: string;
|
|
17
|
-
coep?: string;
|
|
18
|
-
corp?: string;
|
|
19
|
-
originAgentCluster?: boolean;
|
|
20
|
-
permissionsPolicy?: string;
|
|
16
|
+
coop?: string | undefined;
|
|
17
|
+
coep?: string | undefined;
|
|
18
|
+
corp?: string | undefined;
|
|
19
|
+
originAgentCluster?: boolean | undefined;
|
|
20
|
+
permissionsPolicy?: string | undefined;
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
23
23
|
* En-têtes de sécurité **applicatifs** de Nodefony (couche `@nodefony/security`,
|
|
@@ -15,9 +15,9 @@ export declare class SecuredArea implements ISecuredArea {
|
|
|
15
15
|
readonly stateless: boolean;
|
|
16
16
|
readonly mode: "first" | "all";
|
|
17
17
|
readonly authenticators: readonly string[];
|
|
18
|
-
readonly host?: string;
|
|
18
|
+
readonly host?: string | undefined;
|
|
19
19
|
readonly realtime: boolean;
|
|
20
|
-
readonly resource?: string;
|
|
20
|
+
readonly resource?: string | undefined;
|
|
21
21
|
/**
|
|
22
22
|
* Rôles exigés par défaut dans la zone, ou `null` quand elle n'en exige
|
|
23
23
|
* aucun. `null` plutôt qu'un tableau vide : le hot path teste une référence,
|
|
@@ -14,9 +14,11 @@ import type { IToken } from "../../contracts/IToken.js";
|
|
|
14
14
|
* démarre jamais rien lui-même. L'identité est re-résolue à CHAQUE requête
|
|
15
15
|
* via {@link resolveSessionIdentity} (rôles frais, révocation immédiate).
|
|
16
16
|
*
|
|
17
|
-
* Pas de `challenge()` : une
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* Pas de `challenge()` : sur une zone à session seule, le firewall pose son
|
|
18
|
+
* défi de repli `Session realm="nodefony"` (RFC 9110 §15.5.2 : tout 401 en
|
|
19
|
+
* porte un) — schéma inconnu des navigateurs, donc jamais de popup Basic ; le
|
|
20
|
+
* client web redirige vers son écran de login. Si la zone liste aussi
|
|
21
|
+
* `userpassword`, le firewall pose SON challenge.
|
|
20
22
|
*/
|
|
21
23
|
export declare class SessionAuthenticator implements IAuthenticator {
|
|
22
24
|
#private;
|
|
@@ -137,9 +137,9 @@ export interface IOAuth2ClientOptions {
|
|
|
137
137
|
* Implémentation de `fetch` à employer — la voie pour éprouver l'échange sans
|
|
138
138
|
* réseau, plutôt que de remplacer le `fetch` global du processus.
|
|
139
139
|
*/
|
|
140
|
-
readonly fetch?: typeof globalThis.fetch;
|
|
140
|
+
readonly fetch?: typeof globalThis.fetch | undefined;
|
|
141
141
|
/** Délai d'attente de l'échange, en millisecondes. */
|
|
142
|
-
readonly timeoutMs?: number;
|
|
142
|
+
readonly timeoutMs?: number | undefined;
|
|
143
143
|
}
|
|
144
144
|
/**
|
|
145
145
|
* Client d'un serveur d'autorisation donné : construit l'URL d'autorisation puis
|
|
@@ -33,7 +33,7 @@ export interface IOAuthProviderContext {
|
|
|
33
33
|
* champ, la forme serait ouverte dans le client et INATTEIGNABLE depuis une
|
|
34
34
|
* application — donc absente.
|
|
35
35
|
*/
|
|
36
|
-
readonly clientAuthMethod?: OAuth2ClientAuthMethod;
|
|
36
|
+
readonly clientAuthMethod?: OAuth2ClientAuthMethod | undefined;
|
|
37
37
|
/** URL de callback exacte (RFC 9700). */
|
|
38
38
|
readonly redirectUri: string;
|
|
39
39
|
/**
|
|
@@ -41,7 +41,7 @@ export interface IOAuthProviderContext {
|
|
|
41
41
|
* `https://kc.example/realms/app`) — `undefined` pour les fournisseurs dont
|
|
42
42
|
* l'émetteur est connu d'avance (Google) ou qui n'en publient pas (GitHub).
|
|
43
43
|
*/
|
|
44
|
-
readonly issuer?: string;
|
|
44
|
+
readonly issuer?: string | undefined;
|
|
45
45
|
}
|
|
46
46
|
/**
|
|
47
47
|
* Fabrique d'un fournisseur OAuth pour un nom donné.
|
|
@@ -83,11 +83,11 @@ export type FrameAuthorizer = (frame: unknown, token: IRealtimeToken) => boolean
|
|
|
83
83
|
*/
|
|
84
84
|
export interface IChannelPolicy {
|
|
85
85
|
/** Exige une connexion authentifiée (token non anonyme). */
|
|
86
|
-
readonly authenticated?: boolean;
|
|
86
|
+
readonly authenticated?: boolean | undefined;
|
|
87
87
|
/** Un de ces rôles suffit (évalué AVEC la hiérarchie de rôles du firewall). */
|
|
88
|
-
readonly roles?: readonly string[];
|
|
88
|
+
readonly roles?: readonly string[] | undefined;
|
|
89
89
|
/** Un de ces scopes suffit (axe API : JWT/clé API ; session BFF n'en porte pas). */
|
|
90
|
-
readonly scopes?: readonly string[];
|
|
90
|
+
readonly scopes?: readonly string[] | undefined;
|
|
91
91
|
}
|
|
92
92
|
/**
|
|
93
93
|
* Surface consommée du `realtimeService` (miroir partiel de `RealtimeService`) —
|
|
@@ -5,9 +5,9 @@ type LogFn = (message: string, severity: string) => void;
|
|
|
5
5
|
/** Source de clé configurée (`config.jwt.keystore`). */
|
|
6
6
|
interface KeystoreSource {
|
|
7
7
|
/** JWK Set (clés privées) injecté depuis l'env — source `env` (prod). */
|
|
8
|
-
readonly keySetJson?: string;
|
|
8
|
+
readonly keySetJson?: string | undefined;
|
|
9
9
|
/** Dossier de persistance `keyset.json` — source `fichier` (opt-in dev/VPS). */
|
|
10
|
-
readonly dir?: string;
|
|
10
|
+
readonly dir?: string | undefined;
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
13
|
* Le trousseau va-t-il partir dans l'image de conteneur ?
|
|
@@ -20,7 +20,7 @@ export interface ITrustedIssuer {
|
|
|
20
20
|
* une requête au démarrage à froid. Déclaré, il fait autorité : rien n'est
|
|
21
21
|
* découvert.
|
|
22
22
|
*/
|
|
23
|
-
jwksUri?: string;
|
|
23
|
+
jwksUri?: string | undefined;
|
|
24
24
|
/**
|
|
25
25
|
* Jeu de clés fourni LOCALEMENT — aucune requête, aucune découverte.
|
|
26
26
|
*
|
|
@@ -58,7 +58,7 @@ export interface ITrustedIssuer {
|
|
|
58
58
|
* réglé finement. La séparation entre jetons est déjà assurée par l'audience,
|
|
59
59
|
* qui, elle, n'est pas facultative.
|
|
60
60
|
*/
|
|
61
|
-
typ?: string;
|
|
61
|
+
typ?: string | undefined;
|
|
62
62
|
/** Claims dont la PRÉSENCE est exigée, en plus de `iss`/`aud`/`sub`. */
|
|
63
63
|
requiredClaims?: readonly string[];
|
|
64
64
|
}
|
|
@@ -26,3 +26,13 @@ export interface IJwtRuntime {
|
|
|
26
26
|
* propre audience (`[issuer]`).
|
|
27
27
|
*/
|
|
28
28
|
export declare function resolveJwtRuntime(jwt: ISecurityConfig["jwt"]): IJwtRuntime;
|
|
29
|
+
/**
|
|
30
|
+
* Audience inscrite dans un jeton quand aucune ressource n'est demandée : la
|
|
31
|
+
* première de la liste blanche. La liste n'est jamais vide après
|
|
32
|
+
* {@link resolveJwtRuntime} ; un runtime construit à la main qui la laisserait
|
|
33
|
+
* vide retombe sur la même règle — l'application est sa propre audience.
|
|
34
|
+
*
|
|
35
|
+
* @param runtime - paramètres JWT résolus
|
|
36
|
+
* @returns l'audience par défaut
|
|
37
|
+
*/
|
|
38
|
+
export declare function defaultAudience(runtime: IJwtRuntime): string;
|
|
@@ -16,15 +16,6 @@ export declare function matchesTotpQuery(secret: ITotpSecret, query: ITotpListQu
|
|
|
16
16
|
export interface TotpStoreSnapshot {
|
|
17
17
|
secrets: ITotpSecret[];
|
|
18
18
|
}
|
|
19
|
-
/**
|
|
20
|
-
* Store de secrets TOTP **en mémoire** — implémentation de référence
|
|
21
|
-
* d'{@link ITotpSecretStore}. Clé = `userId` (un secret par utilisateur).
|
|
22
|
-
*
|
|
23
|
-
* 0 dépendance, idéale pour le développement mono-process et les **tests**. NON
|
|
24
|
-
* partagée entre process et **volatile** → en production multi-process, utiliser
|
|
25
|
-
* un adapter ORM ou Redis. Perf : la `Map` n'existe que si le store est instancié
|
|
26
|
-
* (2FA activé), jamais sur le hot path par requête.
|
|
27
|
-
*/
|
|
28
19
|
export declare class MemoryTotpSecretStore implements ITotpSecretStore {
|
|
29
20
|
#private;
|
|
30
21
|
findByUser(userId: string): Promise<ITotpSecret | null>;
|
|
@@ -10,14 +10,6 @@ import type { IWebhookEndpoint, WebhookEndpointUpdate } from "../../contracts/IW
|
|
|
10
10
|
* @returns `true` si l'endpoint appartient à la collection filtrée.
|
|
11
11
|
*/
|
|
12
12
|
export declare function matchesWebhookQuery(e: IWebhookEndpoint, query: IWebhookListQuery): boolean;
|
|
13
|
-
/**
|
|
14
|
-
* Store d'endpoints webhook **en mémoire** — défaut dev/test (non persistant :
|
|
15
|
-
* les endpoints sont perdus au redémarrage). En prod, utiliser `drizzle`/
|
|
16
|
-
* `mongoose` (config `webhooks.store`).
|
|
17
|
-
*
|
|
18
|
-
* Map indexée par id (O(1)). Les lectures renvoient une **copie défensive** : le
|
|
19
|
-
* store détient la vérité, un consommateur ne peut pas muter un record en place.
|
|
20
|
-
*/
|
|
21
13
|
export declare class MemoryWebhookStore implements IWebhookStore {
|
|
22
14
|
#private;
|
|
23
15
|
/**
|
package/docs/api-keys.md
CHANGED
|
@@ -115,7 +115,7 @@ qu'il arrive côté serveur — révoquer exige d'ajouter… un état serveur (d
|
|
|
115
115
|
lecture qu'on voulait éviter. Une clé d'API vit **des mois** : un jeton auto-porté de six mois qui
|
|
116
116
|
fuite est une porte ouverte de six mois. Le PAT inverse le compromis : sa vérité est dans le store,
|
|
117
117
|
donc `revokedAt` posé = accès coupé à la requête suivante, sans attendre aucune expiration
|
|
118
|
-
(`ApiKeyAuthenticator.authenticate()`, `ApiKeyAuthenticator.ts:
|
|
118
|
+
(`ApiKeyAuthenticator.authenticate()`, `ApiKeyAuthenticator.ts:97-104`).
|
|
119
119
|
|
|
120
120
|
**Ce que Nodefony fait pour que ça reste bon marché** — trois décisions ancrées au code :
|
|
121
121
|
|
|
@@ -193,7 +193,7 @@ export default OrdersController;
|
|
|
193
193
|
### 3. Émettre la clé — par l'API, avec une session
|
|
194
194
|
|
|
195
195
|
L'émission est un **endpoint fourni**, monté seulement si le service `apiKeys` existe
|
|
196
|
-
(`mountApiKeyRoutes()`, `ApiKeyController.ts:
|
|
196
|
+
(`mountApiKeyRoutes()`, `ApiKeyController.ts:192`). Il n'y a **pas** de commande CLI pour créer une
|
|
197
197
|
clé : la création exige une **session BFF** (les routes ne sont pas `bypassFirewall` —
|
|
198
198
|
`ApiKeyController.ts:50-54`), parce que le porteur est **toujours** l'utilisateur courant, jamais un
|
|
199
199
|
paramètre.
|
|
@@ -228,8 +228,8 @@ curl -si https://localhost:5152/api/v1/orders/list | head -1
|
|
|
228
228
|
|
|
229
229
|
> [!WARNING]
|
|
230
230
|
> Le champ `token` de la réponse 201 est la **seule** occasion de lire le secret : il n'est pas
|
|
231
|
-
> stocké, donc pas re-dérivable (`IApiKeyCreated`, `IApiKey.ts:
|
|
232
|
-
> renvoie que le `prefix` public (`#toView()`, `apiKeys.ts:
|
|
231
|
+
> stocké, donc pas re-dérivable (`IApiKeyCreated`, `IApiKey.ts:44`). Le listing ultérieur ne
|
|
232
|
+
> renvoie que le `prefix` public (`#toView()`, `apiKeys.ts:386`). Perdue = ré-émise.
|
|
233
233
|
|
|
234
234
|
## 🔐 Anatomie d'une clé — ce que chaque morceau paie
|
|
235
235
|
|
|
@@ -239,7 +239,7 @@ raison est dans le code (`apiKeyFormat.ts:8-10`) : le charset base64url contient
|
|
|
239
239
|
|
|
240
240
|
| Morceau | Taille | Secret ? | À quoi ça sert |
|
|
241
241
|
| -------- | -------------------- | :------: | -------------------------------------------------------------------------------------------------- |
|
|
242
|
-
| `prefix` | ≤ 12 car. minuscules | non | Marque applicative — discrimine du JWT, aide le secret-scanning (`config.ts:
|
|
242
|
+
| `prefix` | ≤ 12 car. minuscules | non | Marque applicative — discrimine du JWT, aide le secret-scanning (`config.ts:736`) |
|
|
243
243
|
| `pubid` | 6 octets → 8 car. | non | Identifiant affichable dans la console (`nf_a1b2c3d4`) — `generateApiKey()` (`apiKeyFormat.ts:92`) |
|
|
244
244
|
| `secret` | 32 octets → 43 car. | **oui** | 256 bits d'entropie — `SECRET_BYTES` (`apiKeyFormat.ts:30`) |
|
|
245
245
|
| `crc` | 4 octets → 6 car. | non | CRC32 du `prefix_pubid+secret` — `crcChunk()` (`apiKeyFormat.ts:63`) |
|
|
@@ -268,7 +268,7 @@ dépendance ni d'une variation de version Node (`crc32()`, `apiKeyFormat.ts:53`)
|
|
|
268
268
|
|
|
269
269
|
Avant même de parser, le firewall doit savoir **quel** authenticator prend la main. C'est
|
|
270
270
|
`looksLikeApiKey()` (`apiKeyFormat.ts:117`) : un simple `startsWith("<prefix>_")`, appelé par
|
|
271
|
-
`ApiKeyAuthenticator.supports()` (`ApiKeyAuthenticator.ts:
|
|
271
|
+
`ApiKeyAuthenticator.supports()` (`ApiKeyAuthenticator.ts:75`). C'est ce qui rend la cohabitation
|
|
272
272
|
`["apikey", "jwt"]` possible dans une même zone — un JWT a la structure `a.b.c`, il ne commence
|
|
273
273
|
jamais par le préfixe.
|
|
274
274
|
|
|
@@ -276,24 +276,24 @@ jamais par le préfixe.
|
|
|
276
276
|
|
|
277
277
|
### Émission — `ApiKeyService.createForSubject()`
|
|
278
278
|
|
|
279
|
-
`ApiKeyService.createForSubject()` (`apiKeys.ts:
|
|
279
|
+
`ApiKeyService.createForSubject()` (`apiKeys.ts:121`) est le seul chemin d'émission. Dans l'ordre :
|
|
280
280
|
|
|
281
281
|
1. **Validation du nom** — non vide, ≤ 100 caractères ; sinon `ApiKeyError` 400 (`#normalizeName()`,
|
|
282
|
-
`apiKeys.ts:
|
|
282
|
+
`apiKeys.ts:330`).
|
|
283
283
|
2. **Validation des scopes** — tableau de chaînes non vides, dédupliquées, et **⊆ catalogue** si
|
|
284
|
-
`allowedScopes` est défini ; sinon 400 (`#normalizeScopes()`, `apiKeys.ts:
|
|
284
|
+
`allowedScopes` est défini ; sinon 400 (`#normalizeScopes()`, `apiKeys.ts:341`).
|
|
285
285
|
3. **Résolution de l'expiration** — `expiresInDays` explicite, sinon le défaut de config, `null` =
|
|
286
|
-
sans expiration ; une valeur non positive lève un 400 (`#resolveExpiry()`, `apiKeys.ts:
|
|
286
|
+
sans expiration ; une valeur non positive lève un 400 (`#resolveExpiry()`, `apiKeys.ts:363`).
|
|
287
287
|
4. **Plafond anti-abus** — on ne compte que les clés **actives** (ni révoquées ni expirées) via
|
|
288
|
-
`#isActive()` (`apiKeys.ts:
|
|
288
|
+
`#isActive()` (`apiKeys.ts:379`) ; au-delà de `maxPerSubject` → 409 (`apiKeys.ts:48`).
|
|
289
289
|
5. **Génération** — 32 octets aléatoires, `publicPrefix` et `secretHash` dérivés
|
|
290
290
|
(`generateApiKey()`, `apiKeyFormat.ts:92`).
|
|
291
|
-
6. **Écriture** — le `record` de `kind:"pat"` posé au store par `store.put()` (`apiKeys.ts:
|
|
291
|
+
6. **Écriture** — le `record` de `kind:"pat"` posé au store par `store.put()` (`apiKeys.ts:177`).
|
|
292
292
|
7. **Audit** — `apikey.created`, catégorie `token`, avec l'**id public** et les scopes, **jamais le
|
|
293
|
-
secret** (`apiKeys.ts:
|
|
293
|
+
secret** (`apiKeys.ts:183-189`).
|
|
294
294
|
|
|
295
295
|
Le service ne connaît pas le store à la construction : il le résout **paresseusement** du container
|
|
296
|
-
au premier usage (`#resolveStore()`, `apiKeys.ts:
|
|
296
|
+
au premier usage (`#resolveStore()`, `apiKeys.ts:317`) — indépendant de l'ordre de boot. Store
|
|
297
297
|
absent = **503 explicite**, jamais une 500 opaque.
|
|
298
298
|
|
|
299
299
|
### Vérification — `ApiKeyAuthenticator.authenticate()`
|
|
@@ -304,12 +304,12 @@ filtre qui coûte plus cher que la précédente :
|
|
|
304
304
|
| # | Contrôle | Coût | Ancrage |
|
|
305
305
|
| --- | --------------------------------------- | --------------- | ----------------------------------------------------- |
|
|
306
306
|
| 1 | Bearer présent + préfixe | regex | `readBearerHeader()` (`runtime/bearer.ts:68`) |
|
|
307
|
-
| 2 | Longueur, charset, **CRC** | CPU local | `parseApiKey` (`ApiKeyAuthenticator.ts:
|
|
308
|
-
| 3 | Lookup par `secretHash` | 1 lecture store | `findByHash` (`ApiKeyAuthenticator.ts:
|
|
309
|
-
| 4 | `kind:"pat"`, non révoquée, non expirée | en mémoire | `ApiKeyAuthenticator.ts:
|
|
310
|
-
| 5 | Porteur banni ? (`invalidBefore`) | 1 lecture store | `getInvalidBefore` (`ApiKeyAuthenticator.ts:
|
|
307
|
+
| 2 | Longueur, charset, **CRC** | CPU local | `parseApiKey` (`ApiKeyAuthenticator.ts:103`) |
|
|
308
|
+
| 3 | Lookup par `secretHash` | 1 lecture store | `findByHash` (`ApiKeyAuthenticator.ts:109`) |
|
|
309
|
+
| 4 | `kind:"pat"`, non révoquée, non expirée | en mémoire | `ApiKeyAuthenticator.ts:111-118` |
|
|
310
|
+
| 5 | Porteur banni ? (`invalidBefore`) | 1 lecture store | `getInvalidBefore` (`ApiKeyAuthenticator.ts:121`) |
|
|
311
311
|
| 6 | Compte actif et non verrouillé | 1 lecture user | `#resolveUserOrReject` (`ApiKeyAuthenticator.ts:209`) |
|
|
312
|
-
| 7 | `lastUsedAt` (throttlé) | 0 ou 1 écriture | `markUsed` (`ApiKeyAuthenticator.ts:
|
|
312
|
+
| 7 | `lastUsedAt` (throttlé) | 0 ou 1 écriture | `markUsed` (`ApiKeyAuthenticator.ts:187`) |
|
|
313
313
|
|
|
314
314
|
Deux points méritent d'être soulignés parce qu'ils décident du niveau de sécurité réel :
|
|
315
315
|
|
|
@@ -324,13 +324,13 @@ Deux points méritent d'être soulignés parce qu'ils décident du niveau de sé
|
|
|
324
324
|
|
|
325
325
|
En cas de succès, le jeton est promu et porte trois attributs consommés en aval : `scopes`,
|
|
326
326
|
`apiKeyId` et `tenantId` (`ApiKeyAuthenticator.ts:138-140`). Le challenge renvoyé sur un 401 de la
|
|
327
|
-
zone est un simple `Bearer` (`challenge()`, `ApiKeyAuthenticator.ts:
|
|
327
|
+
zone est un simple `Bearer` (`challenge()`, `ApiKeyAuthenticator.ts:205`).
|
|
328
328
|
|
|
329
329
|
### Câblage — d'où viennent le préfixe et le throttle
|
|
330
330
|
|
|
331
331
|
L'authenticator n'est jamais instancié à la main : le firewall le construit depuis le registre, en
|
|
332
332
|
lui injectant la config effective — `registerAuthenticatorFactory("apikey")`
|
|
333
|
-
(`authenticatorRegistry.ts:
|
|
333
|
+
(`authenticatorRegistry.ts:137`), qui lit `prefix` et `lastUsedThrottleS`
|
|
334
334
|
(`authenticatorRegistry.ts:143`). Conséquence pratique : changer `apiKeys.prefix` change **à la
|
|
335
335
|
fois** l'émission et la reconnaissance — les anciennes clés ne sont plus reconnues.
|
|
336
336
|
|
|
@@ -353,13 +353,13 @@ apiKeys: {
|
|
|
353
353
|
**Ce qu'on observe** : `npx nodefony security:user:add ci-bot` (rôle `ROLE_USER` par défaut), login
|
|
354
354
|
en tant que `ci-bot`, puis émission avec `{"scopes":["orders:read"]}`. Demander
|
|
355
355
|
`{"scopes":["orders:write"]}` renvoie **400 `scope not allowed: orders:write`** — refusé à
|
|
356
|
-
l'émission, pas seulement à l'usage (`#normalizeScopes()`, `apiKeys.ts:
|
|
356
|
+
l'émission, pas seulement à l'usage (`#normalizeScopes()`, `apiKeys.ts:341`).
|
|
357
357
|
|
|
358
358
|
> [!TIP]
|
|
359
359
|
> Le catalogue `allowedScopes` de la config est un **complément**, pas la source : la console
|
|
360
360
|
> propose aussi les scopes **découverts sur tes routes** (`@RequireScope`) par
|
|
361
361
|
> `collectDeclaredApiScopes()` (`scopeCatalog.ts:29`), agrégés dans l'endpoint `capabilities`
|
|
362
|
-
> (`ApiKeyController.ts:
|
|
362
|
+
> (`ApiKeyController.ts:97`). Un formulaire de création qui ne ment pas.
|
|
363
363
|
|
|
364
364
|
### Faire tourner une clé sans coupure de service
|
|
365
365
|
|
|
@@ -368,7 +368,7 @@ doit y avoir **aucune** fenêtre pendant laquelle le job échoue.
|
|
|
368
368
|
|
|
369
369
|
**Il n'y a pas de bouton « rotate »** — et c'est délibéré : une rotation atomique impliquerait
|
|
370
370
|
soit deux secrets valides sous le même id (ambigu à auditer), soit une coupure. Le motif est le
|
|
371
|
-
**recouvrement**, rendu possible par le plafond `maxPerSubject` (`apiKeys.ts:
|
|
371
|
+
**recouvrement**, rendu possible par le plafond `maxPerSubject` (`apiKeys.ts:48`) :
|
|
372
372
|
|
|
373
373
|
1. Émettre une **seconde** clé (même porteur, mêmes scopes, nom `CI deploy v2`).
|
|
374
374
|
2. Déployer le nouveau secret dans le CI.
|
|
@@ -376,7 +376,7 @@ soit deux secrets valides sous le même id (ambigu à auditer), soit une coupure
|
|
|
376
376
|
4. **Puis** révoquer la v1.
|
|
377
377
|
|
|
378
378
|
Ce qui rend l'étape 3 fiable : `lastUsedAt` est écrit de façon **throttlée**, pas à chaque requête —
|
|
379
|
-
la fenêtre par défaut est de 60 s (`lastUsedThrottleS`, `config.ts:
|
|
379
|
+
la fenêtre par défaut est de 60 s (`lastUsedThrottleS`, `config.ts:752`). Attends donc une minute
|
|
380
380
|
avant de conclure qu'une clé « ne sert plus ».
|
|
381
381
|
|
|
382
382
|
### Révoquer une clé qui a fuité
|
|
@@ -388,25 +388,25 @@ Deux chemins, selon qui agit :
|
|
|
388
388
|
|
|
389
389
|
| Qui | Endpoint | Portée | Ancrage |
|
|
390
390
|
| ---------- | ------------------------------------------------- | ---------------------- | --------------------------------------- |
|
|
391
|
-
| Le porteur | `DELETE /nodefony/security/api/keys/{id}` | **ses** clés seulement | `revokeForSubject()` (`apiKeys.ts:
|
|
392
|
-
| Un admin | `POST /nodefony/security/api/apikeys/{id}/revoke` | n'importe quelle clé | `revokeAnyPat()` (`apiKeys.ts:
|
|
391
|
+
| Le porteur | `DELETE /nodefony/security/api/keys/{id}` | **ses** clés seulement | `revokeForSubject()` (`apiKeys.ts:296`) |
|
|
392
|
+
| Un admin | `POST /nodefony/security/api/apikeys/{id}/revoke` | n'importe quelle clé | `revokeAnyPat()` (`apiKeys.ts:250`) |
|
|
393
393
|
|
|
394
394
|
**Ce qu'on observe** : la révocation est **idempotente** et prend effet à la requête suivante —
|
|
395
|
-
l'authenticator lit `revokedAt` avant toute autre décision (`ApiKeyAuthenticator.ts:
|
|
395
|
+
l'authenticator lit `revokedAt` avant toute autre décision (`ApiKeyAuthenticator.ts:111-118`).
|
|
396
396
|
Le banc d'intégration le prouve bout en bout : 200 avant, 401 après
|
|
397
|
-
(`apikey-flow.test.ts:
|
|
397
|
+
(`apikey-flow.test.ts:182-191`).
|
|
398
398
|
|
|
399
399
|
Une propriété de sécurité facile à manquer : si la clé n'existe pas **ou** appartient à quelqu'un
|
|
400
400
|
d'autre, le porteur reçoit un **404 indiscernable**, jamais un 403 (`ApiKeyController.ts:139-142`).
|
|
401
401
|
Un 403 dirait « cette clé existe, mais pas à toi » — assez pour énumérer les identifiants des
|
|
402
|
-
autres. Le banc couvre explicitement cet IDOR (`apikey-flow.test.ts:
|
|
402
|
+
autres. Le banc couvre explicitement cet IDOR (`apikey-flow.test.ts:209`).
|
|
403
403
|
|
|
404
404
|
> [!WARNING]
|
|
405
405
|
> Si tu dois couper **toutes** les clés d'un porteur d'un coup (compte compromis, départ), ne les
|
|
406
406
|
> révoque pas une par une : pose le seuil `invalidBefore` du porteur
|
|
407
407
|
> (`revokeAllForSubject`, `ITokenStore.ts:261`). L'authenticator rejette alors toute clé créée avant
|
|
408
408
|
> ce seuil : `getInvalidBefore` est comparé au `createdAt` du record
|
|
409
|
-
> (`ApiKeyAuthenticator.ts:
|
|
409
|
+
> (`ApiKeyAuthenticator.ts:121-124`) — y compris pour les clés que tu aurais oubliées.
|
|
410
410
|
|
|
411
411
|
### Auditer qui a utilisé quoi
|
|
412
412
|
|
|
@@ -417,9 +417,9 @@ Trois sources, et il faut connaître les limites de chacune :
|
|
|
417
417
|
|
|
418
418
|
1. **L'état** — le listing d'administration paginé, tous porteurs confondus : `GET
|
|
419
419
|
/nodefony/security/api/apikeys` (`SecurityAdminApi.ts:394`), servi par `listPagePat()`
|
|
420
|
-
(`apiKeys.ts:
|
|
420
|
+
(`apiKeys.ts:209`). Filtres `subjectId`, `revoked`, fenêtre `limit`/`offset`/`cursor` et tri
|
|
421
421
|
`order=champ:ASC` (`parseTokenListQuery()`, `SecurityAdminApi.ts:126`), plafonnée à 200 entrées
|
|
422
|
-
(`KEYS_MAX_LIMIT`, `SecurityAdminApi.ts:
|
|
422
|
+
(`KEYS_MAX_LIMIT`, `SecurityAdminApi.ts:114`).
|
|
423
423
|
|
|
424
424
|
Le tri n'est accepté que sur les champs que le backend branché **déclare** savoir trier
|
|
425
425
|
(`sortableFields()`, `apiKeys.ts:102` → `ITokenStore.sortableFields`) : `createdAt`, `name`,
|
|
@@ -430,30 +430,33 @@ Trois sources, et il faut connaître les limites de chacune :
|
|
|
430
430
|
du vocabulaire : le placement des valeurs absentes diffère d'un moteur à l'autre, et un tri dont
|
|
431
431
|
l'ordre dépend de la base configurée ne vaut pas mieux qu'un tri absent.
|
|
432
432
|
|
|
433
|
-
2. **Le journal** — les événements d'audit `apikey.created` (`apiKeys.ts:
|
|
434
|
-
(`apiKeys.ts:
|
|
433
|
+
2. **Le journal** — les événements d'audit `apikey.created` (`apiKeys.ts:183-189`) et `apikey.revoked`
|
|
434
|
+
(`apiKeys.ts:268-271` côté admin, `apiKeys.ts:311-313` côté porteur), catégorie `token`. La révocation
|
|
435
435
|
admin trace **l'acteur ET le porteur cible** — voir [audit](./audit.md).
|
|
436
|
-
3. **Le dernier usage** — `lastUsedAt
|
|
436
|
+
3. **Le dernier usage** — `lastUsedAt`, avec l'IP et l'agent de ce dernier appel
|
|
437
|
+
(`lastUsedIp`, `lastUsedUserAgent`, `ITokenStore.ts:135`), écrits ensemble par `markUsed`
|
|
438
|
+
(`ApiKeyAuthenticator.ts:187`). L'IP est lue par l'accesseur du contexte, qui tient compte des
|
|
439
|
+
proxys de confiance ; si elle ne peut pas être lue, le champ reste vide sans faire échouer
|
|
440
|
+
l'authentification.
|
|
437
441
|
|
|
438
|
-
Ce que tu **n'auras pas** : un journal par requête.
|
|
439
|
-
(`
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
alimenter, pas le store de jetons.
|
|
442
|
+
Ce que tu **n'auras pas** : un journal par requête. Le dernier usage est **écrasé** à chaque
|
|
443
|
+
écriture, et cette écriture est throttlée (`lastUsedThrottleS`) : entre deux écritures, les appels
|
|
444
|
+
ne laissent aucune trace dans le store. Pour de la traçabilité par appel, c'est le journal d'audit
|
|
445
|
+
applicatif qu'il faut alimenter, pas le store de jetons.
|
|
443
446
|
|
|
444
447
|
## ⚙️ Configuration
|
|
445
448
|
|
|
446
|
-
Table dérivée du schéma Zod `apiKeysSchema` (`config.ts:
|
|
447
|
-
module (`config.ts:
|
|
449
|
+
Table dérivée du schéma Zod `apiKeysSchema` (`config.ts:733`), branché à la racine de la config du
|
|
450
|
+
module (`config.ts:733`). Toutes les valeurs ci-dessous sont les **défauts réels**.
|
|
448
451
|
|
|
449
452
|
| Option | Type | Défaut | Effet |
|
|
450
453
|
| ------------------- | ---------------- | ------ | -------------------------------------------------------------------------------------- |
|
|
451
|
-
| `enabled` | boolean | `true` | Coupe l'émission ET le listing (l'authenticator reste déclarable) (`config.ts:
|
|
452
|
-
| `prefix` | string ≤ 12 | `"nf"` | Marque des clés ; minuscules/chiffres — discrimine du JWT (`config.ts:
|
|
453
|
-
| `defaultExpiryDays` | number \| null | `90` | Expiration appliquée si l'appelant n'en donne pas ; `null` = jamais (`config.ts:
|
|
454
|
-
| `lastUsedThrottleS` | number (s) | `60` | Coalescence d'écriture de `lastUsedAt` ; `0` = à chaque usage (`config.ts:
|
|
455
|
-
| `maxPerSubject` | number > 0 | `100` | Plafond de clés **actives** par porteur ; au-delà → 409 (`config.ts:
|
|
456
|
-
| `allowedScopes` | string[] \| null | `null` | Catalogue fermé à la création ; `null` = tout scope non vide accepté (`config.ts:
|
|
454
|
+
| `enabled` | boolean | `true` | Coupe l'émission ET le listing (l'authenticator reste déclarable) (`config.ts:735`) |
|
|
455
|
+
| `prefix` | string ≤ 12 | `"nf"` | Marque des clés ; minuscules/chiffres — discrimine du JWT (`config.ts:736`) |
|
|
456
|
+
| `defaultExpiryDays` | number \| null | `90` | Expiration appliquée si l'appelant n'en donne pas ; `null` = jamais (`config.ts:745`) |
|
|
457
|
+
| `lastUsedThrottleS` | number (s) | `60` | Coalescence d'écriture de `lastUsedAt` ; `0` = à chaque usage (`config.ts:752`) |
|
|
458
|
+
| `maxPerSubject` | number > 0 | `100` | Plafond de clés **actives** par porteur ; au-delà → 409 (`config.ts:761`) |
|
|
459
|
+
| `allowedScopes` | string[] \| null | `null` | Catalogue fermé à la création ; `null` = tout scope non vide accepté (`config.ts:770`) |
|
|
457
460
|
|
|
458
461
|
Deux réglages méritent une décision consciente :
|
|
459
462
|
|
|
@@ -468,15 +471,15 @@ Deux réglages méritent une décision consciente :
|
|
|
468
471
|
### Les endpoints — deux portées, jamais mélangées
|
|
469
472
|
|
|
470
473
|
**Console « mes clés »** (le porteur gère les siennes) — montées par `mountApiKeyRoutes()`
|
|
471
|
-
(`ApiKeyController.ts:
|
|
474
|
+
(`ApiKeyController.ts:192`) **seulement si** le service `apiKeys` existe (`framework/index.ts:468`) ;
|
|
472
475
|
sinon 404, zéro surface. Aucune n'est `bypassFirewall` : la zone data plane exige la session BFF.
|
|
473
476
|
|
|
474
477
|
| Méthode | Chemin | Rôle | Ancrage |
|
|
475
478
|
| -------- | ------------------------------------------ | ------------------------------------------------- | ------------------------------------------- |
|
|
476
|
-
| `POST` | `/nodefony/security/api/keys` | Émission → **201** + `token` clair (1×) | `create()` (`ApiKeyController.ts:
|
|
477
|
-
| `GET` | `/nodefony/security/api/keys` | Mes clés, sans secret | `list()` (`ApiKeyController.ts:
|
|
478
|
-
| `GET` | `/nodefony/security/api/keys/capabilities` | Plafond, scopes proposés, préfixe, durée | `capabilities()` (`ApiKeyController.ts:
|
|
479
|
-
| `DELETE` | `/nodefony/security/api/keys/{id}` | Révoque **ma** clé ; 404 sinon (anti-énumération) | `revoke()` (`ApiKeyController.ts:
|
|
479
|
+
| `POST` | `/nodefony/security/api/keys` | Émission → **201** + `token` clair (1×) | `create()` (`ApiKeyController.ts:66`) |
|
|
480
|
+
| `GET` | `/nodefony/security/api/keys` | Mes clés, sans secret | `list()` (`ApiKeyController.ts:114`) |
|
|
481
|
+
| `GET` | `/nodefony/security/api/keys/capabilities` | Plafond, scopes proposés, préfixe, durée | `capabilities()` (`ApiKeyController.ts:97`) |
|
|
482
|
+
| `DELETE` | `/nodefony/security/api/keys/{id}` | Révoque **ma** clé ; 404 sinon (anti-énumération) | `revoke()` (`ApiKeyController.ts:127`) |
|
|
480
483
|
|
|
481
484
|
**Administration** (gouvernance, réponse à incident) — data plane `SecurityAdminApi`, RBAC
|
|
482
485
|
`ROLE_NODEFONY_ADMIN` :
|
|
@@ -490,7 +493,7 @@ sinon 404, zéro surface. Aucune n'est `bypassFirewall` : la zone data plane exi
|
|
|
490
493
|
Les deux espaces de chemins sont **disjoints** (`keys` vs `apikeys`) — aucune collision, et une
|
|
491
494
|
console d'admin ne peut pas atterrir par erreur sur l'endpoint personnel.
|
|
492
495
|
|
|
493
|
-
Codes d'erreur mappés par duck-typing sur `code` (`#renderApiKeyError()`, `ApiKeyController.ts:
|
|
496
|
+
Codes d'erreur mappés par duck-typing sur `code` (`#renderApiKeyError()`, `ApiKeyController.ts:165`) :
|
|
494
497
|
**400** validation (nom, scope, durée), **409** plafond atteint, **503** clés indisponibles (store
|
|
495
498
|
absent ou `enabled:false`).
|
|
496
499
|
|
|
@@ -514,8 +517,8 @@ import type {
|
|
|
514
517
|
```
|
|
515
518
|
|
|
516
519
|
Les contrats vivent dans `IApiKey.ts` : `IApiKeyView` (`IApiKey.ts:6`), `IApiKeyCreated`
|
|
517
|
-
(`IApiKey.ts:
|
|
518
|
-
(`IApiKey.ts:
|
|
520
|
+
(`IApiKey.ts:44`), `IApiKeyCapabilities` (`IApiKey.ts:55`), `ICreateApiKeyOptions`
|
|
521
|
+
(`IApiKey.ts:69`). Les signatures détaillées vivent dans le graphe TSDoc (`.ai/symbols.json`) —
|
|
519
522
|
cette page explique l'usage, elle ne recopie pas les prototypes.
|
|
520
523
|
|
|
521
524
|
## 🧑⚖️ Scopes — ce que la clé a le droit de faire
|
|
@@ -588,12 +591,12 @@ révocation ne traverse pas. Le détail de la résolution, des avertissements et
|
|
|
588
591
|
| Schéma `Bearer` (transport) | RFC 6750 §2.1 | `readBearerHeader()` (`runtime/bearer.ts:68`) |
|
|
589
592
|
| `invalid_token` → 401 + challenge | RFC 6750 §3.1 · RFC 7235 | `challenge()` (`ApiKeyAuthenticator.ts:205`) |
|
|
590
593
|
| Secret **jamais** stocké en clair | OWASP ASVS (secret storage) | `hashApiKey()` (`apiKeyFormat.ts:70`) |
|
|
591
|
-
| Secret montré une seule fois | Pratique « shown once » | `IApiKeyCreated.token` (`IApiKey.ts:
|
|
594
|
+
| Secret montré une seule fois | Pratique « shown once » | `IApiKeyCreated.token` (`IApiKey.ts:46`) |
|
|
592
595
|
| Anti-énumération des ressources | OWASP API1:2023 (BOLA/IDOR) | 404 indiscernable (`ApiKeyController.ts:139-142`) |
|
|
593
596
|
| Message d'échec uniforme | OWASP API2:2023 (Broken Auth) | `INVALID_TOKEN` (`ApiKeyAuthenticator.ts:17`) |
|
|
594
|
-
| Révocation immédiate côté serveur | OWASP API2:2023 | `revokedAt` vérifié (`ApiKeyAuthenticator.ts:
|
|
597
|
+
| Révocation immédiate côté serveur | OWASP API2:2023 | `revokedAt` vérifié (`ApiKeyAuthenticator.ts:111-118`) |
|
|
595
598
|
| Entropie du secret (≥ 128 bits) | NIST SP 800-63B | 32 octets aléatoires (`apiKeyFormat.ts:30`) |
|
|
596
|
-
| Plafond de ressources par acteur | OWASP API4:2023 (Resource Consumption) | `maxPerSubject` (`apiKeys.ts:
|
|
599
|
+
| Plafond de ressources par acteur | OWASP API4:2023 (Resource Consumption) | `maxPerSubject` (`apiKeys.ts:48`) |
|
|
597
600
|
|
|
598
601
|
## ⚡ Performance & mémoire
|
|
599
602
|
|
|
@@ -611,7 +614,7 @@ Le coût par requête authentifiée par clé est **maîtrisé par construction**
|
|
|
611
614
|
container au premier usage et mémoïsés (`ApiKeyAuthenticator.ts:173`, `apiKeys.ts:268`) — le boot
|
|
612
615
|
ne paie rien si aucune clé n'est jamais présentée.
|
|
613
616
|
- **Jamais N enregistrements en RAM** côté administration : le listing est paginé **au store**
|
|
614
|
-
(`listPagePat()`, `apiKeys.ts:
|
|
617
|
+
(`listPagePat()`, `apiKeys.ts:209`), fenêtre plafonnée à 200 (`SecurityAdminApi.ts:107`).
|
|
615
618
|
|
|
616
619
|
Le point de vigilance restant : `createForSubject()` compte les clés actives via `findBySubject()`
|
|
617
620
|
(`ITokenStore.ts:211`), qui charge **toutes** les clés du porteur. C'est borné par `maxPerSubject`
|
|
@@ -645,14 +648,13 @@ pas masqué à l'affichage. Voir aussi l'écran **Audit** pour les événements
|
|
|
645
648
|
| 503 « API keys unavailable » | Store non provisionné (`TokenService` absent/désactivé) (`apiKeys.ts:330`) | Vérifier `jwt`/`tokenStore` — le `TokenService` pose le store |
|
|
646
649
|
| 401 à la création de clé | Ces routes exigent une **session** (pas de `bypassFirewall`) | Se connecter d'abord (`/nodefony/security/api/auth/login`) |
|
|
647
650
|
| Le token clair est introuvable après coup | Seul `sha256` est stocké — non re-dérivable (`apiKeyFormat.ts:70`) | Émettre une nouvelle clé, révoquer l'ancienne |
|
|
648
|
-
| 409 « API key limit reached » | Plafond de clés **actives** atteint (`apiKeys.ts:
|
|
649
|
-
| 400 « scope not allowed » | Scope hors du catalogue `allowedScopes` (`apiKeys.ts:
|
|
651
|
+
| 409 « API key limit reached » | Plafond de clés **actives** atteint (`apiKeys.ts:144-147`) | Révoquer les clés inutilisées ou relever `maxPerSubject` |
|
|
652
|
+
| 400 « scope not allowed » | Scope hors du catalogue `allowedScopes` (`apiKeys.ts:284`) | Ajouter le scope au catalogue, ou corriger la demande |
|
|
650
653
|
| Toutes les clés rejetées après un changement de config | `prefix` modifié → les anciennes ne sont plus reconnues (`authenticatorRegistry.ts:142`) | Garder le `prefix` STABLE après la première émission |
|
|
651
654
|
| Clé valide mais 403 sur la route | Autorisation, pas authentification : scope manquant — `ScopeVoter.vote()` (`ScopeVoter.ts:50`) | Émettre une clé portant le scope exigé par `@RequireScope` |
|
|
652
|
-
| Clé rejetée alors qu'elle n'est ni expirée ni révoquée | Porteur désactivé/verrouillé, ou seuil `invalidBefore` (`ApiKeyAuthenticator.ts:
|
|
655
|
+
| Clé rejetée alors qu'elle n'est ni expirée ni révoquée | Porteur désactivé/verrouillé, ou seuil `invalidBefore` (`ApiKeyAuthenticator.ts:121-124`) | Réactiver le compte, ou réémettre après le bannissement |
|
|
653
656
|
| 404 en révoquant la clé d'un autre porteur | Anti-énumération volontaire, jamais 403 (`ApiKeyController.ts:139-142`) | Attendu — passer par l'endpoint d'administration |
|
|
654
657
|
| `lastUsedAt` qui ne bouge pas tout de suite | Écriture throttlée, 60 s par défaut (`ApiKeyAuthenticator.ts:127-134`) | Attendre la fenêtre, ou `lastUsedThrottleS: 0` (coût : 1 écriture/req) |
|
|
655
|
-
| `lastUsedIp` / `lastUsedUserAgent` toujours vides | `markUsed` n'envoie que l'horodatage (`ApiKeyAuthenticator.ts:133`) | Emplacements réservés — tracer par le journal d'audit applicatif |
|
|
656
658
|
| Révocation sans effet entre pods | Store `memory` en production (per-pod) | Store durable partagé — voir [tokens](./tokens.md) |
|
|
657
659
|
| Listing d'admin sans `total` sur Redis | Comptage exact refusé (O(N)) — pagination par curseur | Attendu : capacité réduite annoncée, paginer par `nextCursor` |
|
|
658
660
|
|