@nodefony/security 10.0.0-alpha.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/LICENSE +544 -0
- package/README.md +182 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +151 -0
- package/dist/nodefony/command/security-secrets.js +158 -0
- package/dist/nodefony/command/security-token.js +335 -0
- package/dist/nodefony/command/security-user-add.js +131 -0
- package/dist/nodefony/command/security-user-delete.js +102 -0
- package/dist/nodefony/command/security-user-list.js +77 -0
- package/dist/nodefony/config/config.js +366 -0
- package/dist/nodefony/config/defineModuleConfig.js +35 -0
- package/dist/nodefony/contracts/IAccessVoter.js +13 -0
- package/dist/nodefony/contracts/IApiKey.js +1 -0
- package/dist/nodefony/contracts/IAuditEvent.js +1 -0
- package/dist/nodefony/contracts/IAuditStore.js +1 -0
- package/dist/nodefony/contracts/IAuthenticator.js +1 -0
- package/dist/nodefony/contracts/IAuthorizationService.js +1 -0
- package/dist/nodefony/contracts/IFirewall.js +1 -0
- package/dist/nodefony/contracts/IFirewallDescription.js +1 -0
- package/dist/nodefony/contracts/IJwtKeystore.js +1 -0
- package/dist/nodefony/contracts/IOAuthProvider.js +1 -0
- package/dist/nodefony/contracts/ISecuredArea.js +1 -0
- package/dist/nodefony/contracts/IToken.js +1 -0
- package/dist/nodefony/contracts/ITokenStore.js +1 -0
- package/dist/nodefony/contracts/ITotpSecret.js +1 -0
- package/dist/nodefony/contracts/ITotpSecretStore.js +1 -0
- package/dist/nodefony/contracts/IWebAuthnCredential.js +1 -0
- package/dist/nodefony/contracts/IWebAuthnCredentialStore.js +1 -0
- package/dist/nodefony/contracts/IWebhookEndpoint.js +1 -0
- package/dist/nodefony/contracts/IWebhookStore.js +1 -0
- package/dist/nodefony/contracts/index.js +2 -0
- package/dist/nodefony/errors/AccessDeniedError.js +14 -0
- package/dist/nodefony/errors/ApiKeyError.js +21 -0
- package/dist/nodefony/errors/AuthenticationError.js +14 -0
- package/dist/nodefony/errors/CsrfError.js +23 -0
- package/dist/nodefony/errors/InvalidTargetError.js +39 -0
- package/dist/nodefony/errors/SsrfError.js +17 -0
- package/dist/nodefony/errors/ThrottledError.js +21 -0
- package/dist/nodefony/errors/UnverifiableTokenError.js +42 -0
- package/dist/nodefony/errors/WebAuthnError.js +21 -0
- package/dist/nodefony/errors/index.js +9 -0
- package/dist/nodefony/service/accessTokenVerifier.js +77 -0
- package/dist/nodefony/service/apiKeys.js +310 -0
- package/dist/nodefony/service/auditService.js +145 -0
- package/dist/nodefony/service/authFlow.js +332 -0
- package/dist/nodefony/service/authorization.js +95 -0
- package/dist/nodefony/service/cors.js +81 -0
- package/dist/nodefony/service/csrf.js +97 -0
- package/dist/nodefony/service/firewall.js +699 -0
- package/dist/nodefony/service/oauth2.js +153 -0
- package/dist/nodefony/service/securityHeaders.js +80 -0
- package/dist/nodefony/service/tokenService.js +486 -0
- package/dist/nodefony/service/totp.js +209 -0
- package/dist/nodefony/service/webAuthn.js +343 -0
- package/dist/nodefony/service/webhooks.js +539 -0
- package/dist/nodefony/src/RoleHierarchyWalker.js +77 -0
- package/dist/nodefony/src/SecuredArea.js +51 -0
- package/dist/nodefony/src/admin/SecurityAdminApi.js +495 -0
- package/dist/nodefony/src/admin/WebhookAdminApi.js +378 -0
- package/dist/nodefony/src/admin/adminAudit.js +37 -0
- package/dist/nodefony/src/admin/userRevocationCascade.js +40 -0
- package/dist/nodefony/src/apikey/apiKeyFormat.js +107 -0
- package/dist/nodefony/src/audit/MemoryAuditStore.js +121 -0
- package/dist/nodefony/src/audit/auditBridge.js +82 -0
- package/dist/nodefony/src/audit/auditFilters.js +60 -0
- package/dist/nodefony/src/audit/auditStoreRegistry.js +25 -0
- package/dist/nodefony/src/audit/readAuditContext.js +24 -0
- package/dist/nodefony/src/audit/recordAudit.js +16 -0
- package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +36 -0
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +164 -0
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +224 -0
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +174 -0
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +176 -0
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +92 -0
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +95 -0
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +63 -0
- package/dist/nodefony/src/authenticator/bearer.js +2 -0
- package/dist/nodefony/src/authenticator/externalSubject.js +36 -0
- package/dist/nodefony/src/authenticator/peekIssuer.js +56 -0
- package/dist/nodefony/src/crypto/secretCipher.js +79 -0
- package/dist/nodefony/src/csp.js +54 -0
- package/dist/nodefony/src/csrfToken.js +65 -0
- package/dist/nodefony/src/net/ssrfGuard.js +130 -0
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +37 -0
- package/dist/nodefony/src/oauth/providers/github.js +65 -0
- package/dist/nodefony/src/oauth/providers/oidc.js +48 -0
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +94 -0
- package/dist/nodefony/src/realtime/frameAuthorizer.js +279 -0
- package/dist/nodefony/src/realtime/realtimeContracts.js +1 -0
- package/dist/nodefony/src/sessionIdentity.js +35 -0
- package/dist/nodefony/src/throttle/LoginThrottler.js +97 -0
- package/dist/nodefony/src/token/AnonymousToken.js +40 -0
- package/dist/nodefony/src/token/JwtKeystore.js +160 -0
- package/dist/nodefony/src/token/MemoryTokenStore.js +236 -0
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +231 -0
- package/dist/nodefony/src/token/UserToken.js +67 -0
- package/dist/nodefony/src/token/jwtRuntime.js +19 -0
- package/dist/nodefony/src/token/secretFile.js +134 -0
- package/dist/nodefony/src/token/tokenCriteria.js +35 -0
- package/dist/nodefony/src/token/tokenFilters.js +72 -0
- package/dist/nodefony/src/token/tokenSort.js +40 -0
- package/dist/nodefony/src/token/tokenStatus.js +35 -0
- package/dist/nodefony/src/token/tokenStoreRegistry.js +25 -0
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +97 -0
- package/dist/nodefony/src/totp/totpCipher.js +30 -0
- package/dist/nodefony/src/totp/totpCrypto.js +226 -0
- package/dist/nodefony/src/totp/totpOperations.js +129 -0
- package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +18 -0
- package/dist/nodefony/src/voter/RoleVoter.js +32 -0
- package/dist/nodefony/src/voter/ScopeVoter.js +52 -0
- package/dist/nodefony/src/voter/voterRegistry.js +20 -0
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +121 -0
- package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +18 -0
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +87 -0
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +208 -0
- package/dist/nodefony/src/webhook/webhookCipher.js +27 -0
- package/dist/nodefony/src/webhook/webhookDelivery.js +102 -0
- package/dist/nodefony/src/webhook/webhookFilters.js +56 -0
- package/dist/nodefony/src/webhook/webhookSignature.js +51 -0
- package/dist/nodefony/src/webhook/webhookSort.js +48 -0
- package/dist/nodefony/src/webhook/webhookStoreRegistry.js +18 -0
- package/dist/types/index.d.ts +157 -0
- package/dist/types/nodefony/command/security-secrets.d.ts +24 -0
- package/dist/types/nodefony/command/security-token.d.ts +44 -0
- package/dist/types/nodefony/command/security-user-add.d.ts +28 -0
- package/dist/types/nodefony/command/security-user-delete.d.ts +25 -0
- package/dist/types/nodefony/command/security-user-list.d.ts +28 -0
- package/dist/types/nodefony/config/config.d.ts +295 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
- package/dist/types/nodefony/contracts/IAccessVoter.d.ts +23 -0
- package/dist/types/nodefony/contracts/IApiKey.d.ts +75 -0
- package/dist/types/nodefony/contracts/IAuditEvent.d.ts +94 -0
- package/dist/types/nodefony/contracts/IAuditStore.d.ts +80 -0
- package/dist/types/nodefony/contracts/IAuthenticator.d.ts +66 -0
- package/dist/types/nodefony/contracts/IAuthorizationService.d.ts +28 -0
- package/dist/types/nodefony/contracts/IFirewall.d.ts +64 -0
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +120 -0
- package/dist/types/nodefony/contracts/IJwtKeystore.d.ts +40 -0
- package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +51 -0
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +57 -0
- package/dist/types/nodefony/contracts/IToken.d.ts +41 -0
- package/dist/types/nodefony/contracts/ITokenStore.d.ts +240 -0
- package/dist/types/nodefony/contracts/ITotpSecret.d.ts +41 -0
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +88 -0
- package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +56 -0
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +118 -0
- package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +82 -0
- package/dist/types/nodefony/contracts/IWebhookStore.d.ts +85 -0
- package/dist/types/nodefony/contracts/index.d.ts +9 -0
- package/dist/types/nodefony/errors/AccessDeniedError.d.ts +10 -0
- package/dist/types/nodefony/errors/ApiKeyError.d.ts +17 -0
- package/dist/types/nodefony/errors/AuthenticationError.d.ts +10 -0
- package/dist/types/nodefony/errors/CsrfError.d.ts +19 -0
- package/dist/types/nodefony/errors/InvalidTargetError.d.ts +34 -0
- package/dist/types/nodefony/errors/SsrfError.d.ts +13 -0
- package/dist/types/nodefony/errors/ThrottledError.d.ts +16 -0
- package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +37 -0
- package/dist/types/nodefony/errors/WebAuthnError.d.ts +17 -0
- package/dist/types/nodefony/errors/index.d.ts +8 -0
- package/dist/types/nodefony/service/accessTokenVerifier.d.ts +29 -0
- package/dist/types/nodefony/service/apiKeys.d.ts +103 -0
- package/dist/types/nodefony/service/auditService.d.ts +30 -0
- package/dist/types/nodefony/service/authFlow.d.ts +123 -0
- package/dist/types/nodefony/service/authorization.d.ts +33 -0
- package/dist/types/nodefony/service/cors.d.ts +48 -0
- package/dist/types/nodefony/service/csrf.d.ts +57 -0
- package/dist/types/nodefony/service/firewall.d.ts +148 -0
- package/dist/types/nodefony/service/oauth2.d.ts +66 -0
- package/dist/types/nodefony/service/securityHeaders.d.ts +66 -0
- package/dist/types/nodefony/service/tokenService.d.ts +103 -0
- package/dist/types/nodefony/service/totp.d.ts +58 -0
- package/dist/types/nodefony/service/webAuthn.d.ts +123 -0
- package/dist/types/nodefony/service/webhooks.d.ts +160 -0
- package/dist/types/nodefony/src/RoleHierarchyWalker.d.ts +21 -0
- package/dist/types/nodefony/src/SecuredArea.d.ts +31 -0
- package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +82 -0
- package/dist/types/nodefony/src/admin/WebhookAdminApi.d.ts +30 -0
- package/dist/types/nodefony/src/admin/adminAudit.d.ts +27 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +31 -0
- package/dist/types/nodefony/src/apikey/apiKeyFormat.d.ts +43 -0
- package/dist/types/nodefony/src/audit/MemoryAuditStore.d.ts +33 -0
- package/dist/types/nodefony/src/audit/auditBridge.d.ts +49 -0
- package/dist/types/nodefony/src/audit/auditFilters.d.ts +56 -0
- package/dist/types/nodefony/src/audit/auditStoreRegistry.d.ts +37 -0
- package/dist/types/nodefony/src/audit/readAuditContext.d.ts +17 -0
- package/dist/types/nodefony/src/audit/recordAudit.d.ts +13 -0
- package/dist/types/nodefony/src/authenticator/AnonymousAuthenticator.d.ts +26 -0
- package/dist/types/nodefony/src/authenticator/ApiKeyAuthenticator.d.ts +74 -0
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +132 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +78 -0
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +69 -0
- package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +70 -0
- package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +53 -0
- package/dist/types/nodefony/src/authenticator/authenticatorRegistry.d.ts +39 -0
- package/dist/types/nodefony/src/authenticator/bearer.d.ts +22 -0
- package/dist/types/nodefony/src/authenticator/externalSubject.d.ts +27 -0
- package/dist/types/nodefony/src/authenticator/peekIssuer.d.ts +31 -0
- package/dist/types/nodefony/src/crypto/secretCipher.d.ts +31 -0
- package/dist/types/nodefony/src/csp.d.ts +39 -0
- package/dist/types/nodefony/src/csrfToken.d.ts +36 -0
- package/dist/types/nodefony/src/net/ssrfGuard.d.ts +43 -0
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +45 -0
- package/dist/types/nodefony/src/oauth/providers/github.d.ts +9 -0
- package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +35 -0
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +62 -0
- package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +171 -0
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +139 -0
- package/dist/types/nodefony/src/sessionIdentity.d.ts +20 -0
- package/dist/types/nodefony/src/throttle/LoginThrottler.d.ts +68 -0
- package/dist/types/nodefony/src/token/AnonymousToken.d.ts +23 -0
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +43 -0
- package/dist/types/nodefony/src/token/MemoryTokenStore.d.ts +66 -0
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +149 -0
- package/dist/types/nodefony/src/token/UserToken.d.ts +41 -0
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +28 -0
- package/dist/types/nodefony/src/token/secretFile.d.ts +70 -0
- package/dist/types/nodefony/src/token/tokenCriteria.d.ts +20 -0
- package/dist/types/nodefony/src/token/tokenFilters.d.ts +76 -0
- package/dist/types/nodefony/src/token/tokenSort.d.ts +33 -0
- package/dist/types/nodefony/src/token/tokenStatus.d.ts +38 -0
- package/dist/types/nodefony/src/token/tokenStoreRegistry.d.ts +38 -0
- package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +43 -0
- package/dist/types/nodefony/src/totp/totpCipher.d.ts +9 -0
- package/dist/types/nodefony/src/totp/totpCrypto.d.ts +164 -0
- package/dist/types/nodefony/src/totp/totpOperations.d.ts +73 -0
- package/dist/types/nodefony/src/totp/totpSecretStoreRegistry.d.ts +27 -0
- package/dist/types/nodefony/src/voter/RoleVoter.d.ts +25 -0
- package/dist/types/nodefony/src/voter/ScopeVoter.d.ts +30 -0
- package/dist/types/nodefony/src/voter/voterRegistry.d.ts +33 -0
- package/dist/types/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.d.ts +39 -0
- package/dist/types/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.d.ts +26 -0
- package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +37 -0
- package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +69 -0
- package/dist/types/nodefony/src/webhook/webhookCipher.d.ts +8 -0
- package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +28 -0
- package/dist/types/nodefony/src/webhook/webhookFilters.d.ts +64 -0
- package/dist/types/nodefony/src/webhook/webhookSignature.d.ts +20 -0
- package/dist/types/nodefony/src/webhook/webhookSort.d.ts +39 -0
- package/dist/types/nodefony/src/webhook/webhookStoreRegistry.d.ts +31 -0
- package/docs/api-keys.md +691 -0
- package/docs/audit.md +751 -0
- package/docs/authenticators.md +487 -0
- package/docs/authorization.md +497 -0
- package/docs/cors.md +497 -0
- package/docs/csrf.md +392 -0
- package/docs/external-jwt.md +181 -0
- package/docs/firewall.md +546 -0
- package/docs/headers.md +616 -0
- package/docs/index.md +207 -0
- package/docs/lexique.md +190 -0
- package/docs/oauth2.md +575 -0
- package/docs/obtenir-un-jeton.md +225 -0
- package/docs/tokens.md +520 -0
- package/docs/totp.md +804 -0
- package/docs/webauthn.md +733 -0
- package/docs/webhooks.md +1016 -0
- package/package.json +83 -0
package/docs/api-keys.md
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Clés d'API — jetons opaques révocables pour les machines"
|
|
3
|
+
navTitle: Clés d'API
|
|
4
|
+
lang: fr
|
|
5
|
+
module: "@nodefony/security"
|
|
6
|
+
topic: api-keys
|
|
7
|
+
coverageModule: security
|
|
8
|
+
coverageFiles: "apiKey"
|
|
9
|
+
section: "Sécurité"
|
|
10
|
+
audience: [developer]
|
|
11
|
+
tags:
|
|
12
|
+
[
|
|
13
|
+
api-keys,
|
|
14
|
+
pat,
|
|
15
|
+
bearer,
|
|
16
|
+
revocation,
|
|
17
|
+
crc,
|
|
18
|
+
sha256,
|
|
19
|
+
scopes,
|
|
20
|
+
rfc6750,
|
|
21
|
+
owasp,
|
|
22
|
+
securite,
|
|
23
|
+
]
|
|
24
|
+
version: "doc"
|
|
25
|
+
status: stable
|
|
26
|
+
updated: 2026-07-19
|
|
27
|
+
source: "src/packages/@nodefony/security/docs/api-keys.md"
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
# Clés d'API — jetons opaques révocables pour les machines
|
|
31
|
+
|
|
32
|
+
> Une clé d'API Nodefony (PAT, _Personal Access Token_) est un **secret opaque**
|
|
33
|
+
> `nf_…` que tu remets à un script, un job CI ou un partenaire. Contrairement à un JWT, elle ne
|
|
34
|
+
> porte aucune information : sa vérité vit dans le store côté serveur — donc elle est **révocable
|
|
35
|
+
> à la seconde**. Elle est montrée **une seule fois** à l'émission, stockée **hachée**, et sa
|
|
36
|
+
> **forme est vérifiée hors-ligne** (checksum) avant que la base ne soit touchée. Ancré sur
|
|
37
|
+
> `src/packages/@nodefony/security/nodefony/service/apiKeys.ts`,
|
|
38
|
+
> `nodefony/src/apikey/apiKeyFormat.ts` et `nodefony/src/authenticator/ApiKeyAuthenticator.ts`.
|
|
39
|
+
|
|
40
|
+
📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **Clés d'API**
|
|
41
|
+
|
|
42
|
+
## 🧠 Le modèle mental — montrée une fois, filtrée hors-ligne, révoquée tout de suite
|
|
43
|
+
|
|
44
|
+
Trois moments dans la vie d'une clé, et ils ne coûtent pas le même prix. L'**émission** est rare et
|
|
45
|
+
chère (elle écrit). La **vérification** arrive à chaque requête : elle commence par un test
|
|
46
|
+
arithmétique local qui élimine les valeurs bidon **sans lire la base**. La **révocation** est un
|
|
47
|
+
simple champ posé — et elle prend effet à la requête suivante, partout.
|
|
48
|
+
|
|
49
|
+
```mermaid
|
|
50
|
+
flowchart TD
|
|
51
|
+
subgraph EM["Émission (rare, session BFF requise)"]
|
|
52
|
+
POST["POST /nodefony/security/api/keys<br/>{name, scopes?, expiresInDays?}"] --> GEN["generateApiKey()<br/>32 octets aléatoires"]
|
|
53
|
+
GEN --> ONCE["réponse 201 : token CLAIR<br/>montré 1× puis oublié"]
|
|
54
|
+
GEN --> HASH["sha256(token) → secretHash"]
|
|
55
|
+
HASH --> ST[("ITokenStore — kind:'pat'<br/>memory · drizzle · mongoose · redis")]
|
|
56
|
+
end
|
|
57
|
+
subgraph VE["Vérification (chaque requête)"]
|
|
58
|
+
REQ["Authorization: Bearer nf_…"] --> FORM{"parseApiKey()<br/>longueur + charset + CRC"}
|
|
59
|
+
FORM -->|"invalide"| K401["401 — la base n'est PAS touchée"]
|
|
60
|
+
FORM -->|"valide"| LOOK["findByHash(sha256)"]
|
|
61
|
+
LOOK --> ST
|
|
62
|
+
LOOK --> CHK{"révoquée ? expirée ?<br/>porteur banni ? compte actif ?"}
|
|
63
|
+
CHK -->|"non"| OK["token promu : scopes + apiKeyId"]
|
|
64
|
+
CHK -->|"oui"| K401
|
|
65
|
+
end
|
|
66
|
+
REV["DELETE /…/keys/{id}"] -->|"revokedAt"| ST
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## 📖 Lexique
|
|
70
|
+
|
|
71
|
+
| Terme | Sens |
|
|
72
|
+
| ---------------- | ----------------------------------------------------------------------------------------------------- |
|
|
73
|
+
| PAT | _Personal Access Token_ — clé d'API rattachée à un **porteur** (utilisateur ou compte de service). |
|
|
74
|
+
| Bearer | Schéma `Authorization: Bearer <valeur>` (RFC 6750) : « celui qui porte le jeton est cru ». |
|
|
75
|
+
| Opaque | Le jeton ne contient **aucune donnée lisible** : c'est un numéro de vestiaire, pas un passeport. |
|
|
76
|
+
| Auto-porté | À l'inverse : un JWT transporte ses propres affirmations signées, vérifiables **sans** état serveur. |
|
|
77
|
+
| CRC32 | Somme de contrôle (checksum) publique — détecte une clé tronquée ou inventée, **ne protège rien**. |
|
|
78
|
+
| `pubid` | Identifiant **public** de 8 caractères affiché dans la console (`nf_a1b2c3d4`) — jamais un secret. |
|
|
79
|
+
| `secretHash` | `sha256` du jeton entier : la **seule** trace stockée. Le clair n'est ni gardé ni re-dérivable. |
|
|
80
|
+
| Scope | Capacité accordée à la clé (`orders:read`) — axe distinct des rôles de l'humain. |
|
|
81
|
+
| `invalidBefore` | Seuil par porteur : tout jeton créé avant cet instant est rejeté (déconnexion globale, bannissement). |
|
|
82
|
+
| Anti-énumération | Répondre pareil quel que soit l'échec, pour ne pas révéler ce qui existe (404 plutôt que 403). |
|
|
83
|
+
| Shown once | Le secret n'est affiché qu'à la création — l'oublier impose d'en émettre un nouveau. |
|
|
84
|
+
| Store de jetons | Le `ITokenStore` partagé : il porte à la fois les PAT et les refresh tokens ([tokens](./tokens.md)). |
|
|
85
|
+
|
|
86
|
+
## Qu'est-ce qu'une clé d'API — et la faille qu'elle ferme
|
|
87
|
+
|
|
88
|
+
Ton back-office est protégé par un login. Mais un **script de déploiement** ne peut pas taper un mot
|
|
89
|
+
de passe, et un **partenaire** ne doit surtout pas recevoir le tien. Il faut un identifiant de
|
|
90
|
+
machine : long, aléatoire, limité, et surtout **jetable**.
|
|
91
|
+
|
|
92
|
+
La faille concrète que ça ferme : **le mot de passe partagé**. Sans clés d'API, l'équipe finit par
|
|
93
|
+
coller le compte `admin` dans un fichier de CI. Le jour où ce fichier fuite, l'attaquant a
|
|
94
|
+
l'intégralité du compte — et pour couper l'accès, il faut changer le mot de passe de tout le monde.
|
|
95
|
+
|
|
96
|
+
Une clé d'API découpe le problème en trois :
|
|
97
|
+
|
|
98
|
+
1. **Portée** — elle ne peut faire que ce que ses `scopes` autorisent, pas tout ce que son porteur peut faire.
|
|
99
|
+
2. **Traçabilité** — chaque clé a un nom (« CI deploy », « export nocturne ») et un dernier usage.
|
|
100
|
+
3. **Révocabilité** — on éteint **une** clé sans déranger personne d'autre.
|
|
101
|
+
|
|
102
|
+
> [!IMPORTANT]
|
|
103
|
+
> Une clé d'API n'est **pas** un mot de passe et ne s'utilise pas comme tel : elle a une entropie
|
|
104
|
+
> de 256 bits (`SECRET_BYTES` de 32 octets, `apiKeyFormat.ts:30`) — inutile de la « complexifier »,
|
|
105
|
+
> impossible de la deviner. Le vrai risque n'est pas le devinage, c'est la **fuite** : d'où le
|
|
106
|
+
> hachage au repos, l'expiration par défaut et la révocation immédiate.
|
|
107
|
+
|
|
108
|
+
## La vision Nodefony — opaque par choix, vérifiable sans toucher la base
|
|
109
|
+
|
|
110
|
+
Nodefony aurait pu émettre un JWT longue durée : zéro lecture de base à la vérification. Le
|
|
111
|
+
compromis a été tranché dans l'autre sens, et c'est **le** choix structurant de cette brique.
|
|
112
|
+
|
|
113
|
+
**Pourquoi opaque plutôt qu'auto-porté** : un JWT signé reste valide jusqu'à son expiration, quoi
|
|
114
|
+
qu'il arrive côté serveur — révoquer exige d'ajouter… un état serveur (denylist), donc de payer la
|
|
115
|
+
lecture qu'on voulait éviter. Une clé d'API vit **des mois** : un jeton auto-porté de six mois qui
|
|
116
|
+
fuite est une porte ouverte de six mois. Le PAT inverse le compromis : sa vérité est dans le store,
|
|
117
|
+
donc `revokedAt` posé = accès coupé à la requête suivante, sans attendre aucune expiration
|
|
118
|
+
(`ApiKeyAuthenticator.authenticate()`, `ApiKeyAuthenticator.ts:107-114`).
|
|
119
|
+
|
|
120
|
+
**Ce que Nodefony fait pour que ça reste bon marché** — trois décisions ancrées au code :
|
|
121
|
+
|
|
122
|
+
- **Un filtre hors-ligne avant la base.** La forme et le checksum sont validés en O(1) sans aucun
|
|
123
|
+
I/O — `parseApiKey()` (`apiKeyFormat.ts:131`). Une avalanche de chaînes au hasard ne devient
|
|
124
|
+
jamais une avalanche de requêtes SQL.
|
|
125
|
+
- **Le secret n'existe nulle part au repos.** Seul `sha256(token)` est persisté — `hashApiKey()`
|
|
126
|
+
(`apiKeyFormat.ts:70`). Une fuite de la base ne donne aucune clé utilisable.
|
|
127
|
+
- **Aucun store en propre.** Les clés vivent dans le **même** `ITokenStore` que les refresh tokens,
|
|
128
|
+
discriminées par `kind: "pat"` (`ITokenStore.ts:74`) — une brique de moins à configurer, à
|
|
129
|
+
purger et à superviser. Le propriétaire du store reste le `TokenService` ([tokens](./tokens.md)).
|
|
130
|
+
|
|
131
|
+
Et une décision assumée sur la crypto : `sha256` suffit **ici**, alors qu'un mot de passe humain
|
|
132
|
+
exige argon2. La raison est écrite dans le code (`apiKeyFormat.ts:22-25`) — un secret de 256 bits
|
|
133
|
+
tiré au hasard n'est ni brute-forçable ni exposé aux tables arc-en-ciel ; un hachage lent ne
|
|
134
|
+
protégerait que des secrets faibles, et coûterait sur le chemin chaud.
|
|
135
|
+
|
|
136
|
+
## 🚀 Démarrage rapide
|
|
137
|
+
|
|
138
|
+
### 1. Déclarer la zone machine et les règles d'émission
|
|
139
|
+
|
|
140
|
+
Dans une app générée par `nodefony create app`, tout se déclare dans `nodefony.config.ts`. Une zone
|
|
141
|
+
dont les authenticators contiennent `apikey` exige un `Authorization: Bearer nf_…` valide :
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
// nodefony.config.ts (extrait) — la zone machine + la politique d'émission
|
|
145
|
+
use("@nodefony/security", {
|
|
146
|
+
areas: {
|
|
147
|
+
// Zone protégée : sans clé valide → 401 AVANT ton controller (Zero Trust).
|
|
148
|
+
// `jwt` cohabite sans conflit — les deux se discriminent par la FORME du
|
|
149
|
+
// bearer (JWT = a.b.c, clé d'API = nf_…).
|
|
150
|
+
api: {
|
|
151
|
+
pattern: "^/api/v1",
|
|
152
|
+
authenticators: ["apikey", "jwt"],
|
|
153
|
+
mode: "first",
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
apiKeys: {
|
|
157
|
+
prefix: "acme", // marque de TES clés : acme_… (secret-scanning + support)
|
|
158
|
+
defaultExpiryDays: 90, // une clé émise sans durée meurt au bout de 90 jours
|
|
159
|
+
maxPerSubject: 20, // plafond de clés ACTIVES par porteur (au-delà : 409)
|
|
160
|
+
allowedScopes: ["orders:read", "orders:write"], // catalogue fermé
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### 2. Écrire le controller, borné par un scope
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
// nodefony/controllers/OrdersController.ts — complet, compile tel quel
|
|
169
|
+
import {
|
|
170
|
+
controller,
|
|
171
|
+
Controller,
|
|
172
|
+
Get,
|
|
173
|
+
RequireScope,
|
|
174
|
+
CurrentUser,
|
|
175
|
+
} from "@nodefony/framework";
|
|
176
|
+
import type { IUser } from "@nodefony/user";
|
|
177
|
+
|
|
178
|
+
@controller("/api/v1/orders")
|
|
179
|
+
class OrdersController extends Controller {
|
|
180
|
+
// Le firewall a déjà validé la clé (forme, CRC, store, révocation, expiration,
|
|
181
|
+
// compte actif) : `user` est le PORTEUR de la clé. @RequireScope borne ce que
|
|
182
|
+
// CETTE clé peut faire — une clé émise sans `orders:read` reçoit 403.
|
|
183
|
+
@RequireScope("orders:read")
|
|
184
|
+
@Get("/list")
|
|
185
|
+
async list(@CurrentUser() user: IUser) {
|
|
186
|
+
return this.renderJson({ owner: user.identifier, orders: [] });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export default OrdersController;
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### 3. Émettre la clé — par l'API, avec une session
|
|
194
|
+
|
|
195
|
+
L'émission est un **endpoint fourni**, monté seulement si le service `apiKeys` existe
|
|
196
|
+
(`mountApiKeyRoutes()`, `ApiKeyController.ts:191`). Il n'y a **pas** de commande CLI pour créer une
|
|
197
|
+
clé : la création exige une **session BFF** (les routes ne sont pas `bypassFirewall` —
|
|
198
|
+
`ApiKeyController.ts:50-54`), parce que le porteur est **toujours** l'utilisateur courant, jamais un
|
|
199
|
+
paramètre.
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
# 0) Un compte porteur (mot de passe demandé masqué, jamais en dur)
|
|
203
|
+
npx nodefony security:user:add ci-bot
|
|
204
|
+
|
|
205
|
+
# 1) Session BFF : c'est elle qui autorise la création
|
|
206
|
+
curl -si -c /tmp/jar -H 'Content-Type: application/json' \
|
|
207
|
+
-d "{\"username\":\"ci-bot\",\"password\":\"$NF_PASS\"}" \
|
|
208
|
+
https://localhost:5152/nodefony/security/api/auth/login | head -1
|
|
209
|
+
# HTTP/1.1 200 OK
|
|
210
|
+
|
|
211
|
+
# 2) Émission → 201, le token CLAIR n'apparaît QU'ICI
|
|
212
|
+
curl -s -b /tmp/jar -H 'Content-Type: application/json' \
|
|
213
|
+
-d '{"name":"CI deploy","scopes":["orders:read"],"expiresInDays":30}' \
|
|
214
|
+
https://localhost:5152/nodefony/security/api/keys
|
|
215
|
+
# {"id":"3f2a…","prefix":"acme_a1b2c3d4","name":"CI deploy",
|
|
216
|
+
# "scopes":["orders:read"],"expiresAt":1234567890000,
|
|
217
|
+
# "token":"acme_a1b2c3d4XXXX…z9z9z9"} ← à copier MAINTENANT
|
|
218
|
+
|
|
219
|
+
# 3) La clé authentifie le script — plus aucune session, plus aucun cookie
|
|
220
|
+
curl -s -H "Authorization: Bearer acme_a1b2c3d4XXXX…z9z9z9" \
|
|
221
|
+
https://localhost:5152/api/v1/orders/list
|
|
222
|
+
# {"owner":"ci-bot","orders":[]}
|
|
223
|
+
|
|
224
|
+
# 4) Sans clé (ou avec une clé bidon) → 401, message uniforme
|
|
225
|
+
curl -si https://localhost:5152/api/v1/orders/list | head -1
|
|
226
|
+
# HTTP/1.1 401 Unauthorized
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
> [!WARNING]
|
|
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:36`). Le listing ultérieur ne
|
|
232
|
+
> renvoie que le `prefix` public (`#toView()`, `apiKeys.ts:393`). Perdue = ré-émise.
|
|
233
|
+
|
|
234
|
+
## 🔐 Anatomie d'une clé — ce que chaque morceau paie
|
|
235
|
+
|
|
236
|
+
Format émis : `<prefix>_<pubid><secret><crc>` — **un seul** `_`, le reste est **positionnel**. La
|
|
237
|
+
raison est dans le code (`apiKeyFormat.ts:8-10`) : le charset base64url contient lui-même `-` et
|
|
238
|
+
`_`, donc un `split("_")` serait fragile ; on découpe par longueurs fixes.
|
|
239
|
+
|
|
240
|
+
| Morceau | Taille | Secret ? | À quoi ça sert |
|
|
241
|
+
| -------- | -------------------- | :------: | -------------------------------------------------------------------------------------------------- |
|
|
242
|
+
| `prefix` | ≤ 12 car. minuscules | non | Marque applicative — discrimine du JWT, aide le secret-scanning (`config.ts:730`) |
|
|
243
|
+
| `pubid` | 6 octets → 8 car. | non | Identifiant affichable dans la console (`nf_a1b2c3d4`) — `generateApiKey()` (`apiKeyFormat.ts:92`) |
|
|
244
|
+
| `secret` | 32 octets → 43 car. | **oui** | 256 bits d'entropie — `SECRET_BYTES` (`apiKeyFormat.ts:30`) |
|
|
245
|
+
| `crc` | 4 octets → 6 car. | non | CRC32 du `prefix_pubid+secret` — `crcChunk()` (`apiKeyFormat.ts:63`) |
|
|
246
|
+
|
|
247
|
+
Le corps total fait donc 57 caractères — `BODY_LEN` (`apiKeyFormat.ts:34`), longueur vérifiée
|
|
248
|
+
strictement au parsing.
|
|
249
|
+
|
|
250
|
+
### À quoi sert vraiment le CRC (et à quoi il ne sert pas)
|
|
251
|
+
|
|
252
|
+
Le checksum n'est **pas** une protection : il est public, recalculable par n'importe qui. Il achète
|
|
253
|
+
deux choses très concrètes :
|
|
254
|
+
|
|
255
|
+
1. **Rejeter une clé malformée en O(1), sans toucher le store.** `parseApiKey()` vérifie préfixe,
|
|
256
|
+
longueur, charset base64url puis CRC — et renvoie `null` avant tout I/O
|
|
257
|
+
(`apiKeyFormat.ts:136-142`). C'est une défense **anti-DoS** : un attaquant qui bombarde des
|
|
258
|
+
`nf_` aléatoires consomme du CPU, jamais des lectures de base.
|
|
259
|
+
2. **Le secret-scanning.** GitHub, GitGuardian & co. reconnaissent un motif `nf_…` **dont le
|
|
260
|
+
checksum tombe juste** avec un taux de faux positifs quasi nul. Une clé poussée par erreur dans
|
|
261
|
+
un dépôt est détectée par l'outillage de l'écosystème, pas seulement par toi.
|
|
262
|
+
|
|
263
|
+
La table CRC32 (IEEE 802.3) est précalculée **une fois** au chargement du module — `CRC_TABLE`
|
|
264
|
+
(`apiKeyFormat.ts:40`) — et l'implémentation est locale et déterministe, pour ne dépendre ni d'une
|
|
265
|
+
dépendance ni d'une variation de version Node (`crc32()`, `apiKeyFormat.ts:53`).
|
|
266
|
+
|
|
267
|
+
### Un test encore moins cher, pour l'aiguillage
|
|
268
|
+
|
|
269
|
+
Avant même de parser, le firewall doit savoir **quel** authenticator prend la main. C'est
|
|
270
|
+
`looksLikeApiKey()` (`apiKeyFormat.ts:117`) : un simple `startsWith("<prefix>_")`, appelé par
|
|
271
|
+
`ApiKeyAuthenticator.supports()` (`ApiKeyAuthenticator.ts:68`). C'est ce qui rend la cohabitation
|
|
272
|
+
`["apikey", "jwt"]` possible dans une même zone — un JWT a la structure `a.b.c`, il ne commence
|
|
273
|
+
jamais par le préfixe.
|
|
274
|
+
|
|
275
|
+
## 🏗️ Architecture interne — la vie d'une clé
|
|
276
|
+
|
|
277
|
+
### Émission — `ApiKeyService.createForSubject()`
|
|
278
|
+
|
|
279
|
+
`ApiKeyService.createForSubject()` (`apiKeys.ts:128`) est le seul chemin d'émission. Dans l'ordre :
|
|
280
|
+
|
|
281
|
+
1. **Validation du nom** — non vide, ≤ 100 caractères ; sinon `ApiKeyError` 400 (`#normalizeName()`,
|
|
282
|
+
`apiKeys.ts:337`).
|
|
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:292`).
|
|
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:370`).
|
|
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:386`) ; au-delà de `maxPerSubject` → 409 (`apiKeys.ts:113`).
|
|
289
|
+
5. **Génération** — 32 octets aléatoires, `publicPrefix` et `secretHash` dérivés
|
|
290
|
+
(`generateApiKey()`, `apiKeyFormat.ts:92`).
|
|
291
|
+
6. **Écriture** — le `record` de `kind:"pat"` posé au store par `store.put()` (`apiKeys.ts:147`).
|
|
292
|
+
7. **Audit** — `apikey.created`, catégorie `token`, avec l'**id public** et les scopes, **jamais le
|
|
293
|
+
secret** (`apiKeys.ts:153`).
|
|
294
|
+
|
|
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:268`) — indépendant de l'ordre de boot. Store
|
|
297
|
+
absent = **503 explicite**, jamais une 500 opaque.
|
|
298
|
+
|
|
299
|
+
### Vérification — `ApiKeyAuthenticator.authenticate()`
|
|
300
|
+
|
|
301
|
+
Le chemin chaud, dans l'ordre exact du code (`ApiKeyAuthenticator.ts:93`) — chaque étape est un
|
|
302
|
+
filtre qui coûte plus cher que la précédente :
|
|
303
|
+
|
|
304
|
+
| # | Contrôle | Coût | Ancrage |
|
|
305
|
+
| --- | --------------------------------------- | --------------- | ----------------------------------------------------- |
|
|
306
|
+
| 1 | Bearer présent + préfixe | regex | `readBearerHeader()` (`runtime/bearer.ts:68`) |
|
|
307
|
+
| 2 | Longueur, charset, **CRC** | CPU local | `parseApiKey` (`ApiKeyAuthenticator.ts:99`) |
|
|
308
|
+
| 3 | Lookup par `secretHash` | 1 lecture store | `findByHash` (`ApiKeyAuthenticator.ts:105`) |
|
|
309
|
+
| 4 | `kind:"pat"`, non révoquée, non expirée | en mémoire | `ApiKeyAuthenticator.ts:107-114` |
|
|
310
|
+
| 5 | Porteur banni ? (`invalidBefore`) | 1 lecture store | `getInvalidBefore` (`ApiKeyAuthenticator.ts:117`) |
|
|
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:133`) |
|
|
313
|
+
|
|
314
|
+
Deux points méritent d'être soulignés parce qu'ils décident du niveau de sécurité réel :
|
|
315
|
+
|
|
316
|
+
- **Le sujet est revérifié à CHAQUE requête** (étape 6). Une clé reste techniquement valide, mais si
|
|
317
|
+
le compte porteur est désactivé ou verrouillé, elle ne passe plus — les rôles sont **frais**, il
|
|
318
|
+
n'y a pas de cache d'identité. C'est ce qui fait qu'un départ de collaborateur coupe ses clés
|
|
319
|
+
sans avoir à les énumérer.
|
|
320
|
+
- **L'échec est toujours le même.** Malformée, inconnue, révoquée, expirée, porteur banni, compte
|
|
321
|
+
supprimé : un unique `"Invalid token"` (`INVALID_TOKEN`, `ApiKeyAuthenticator.ts:17`). Un
|
|
322
|
+
attaquant ne peut pas distinguer « cette clé n'existe pas » de « cette clé est révoquée » — c'est
|
|
323
|
+
l'**anti-énumération**, la cause fine part dans l'audit, jamais au client.
|
|
324
|
+
|
|
325
|
+
En cas de succès, le jeton est promu et porte trois attributs consommés en aval : `scopes`,
|
|
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:155`).
|
|
328
|
+
|
|
329
|
+
### Câblage — d'où viennent le préfixe et le throttle
|
|
330
|
+
|
|
331
|
+
L'authenticator n'est jamais instancié à la main : le firewall le construit depuis le registre, en
|
|
332
|
+
lui injectant la config effective — `registerAuthenticatorFactory("apikey")`
|
|
333
|
+
(`authenticatorRegistry.ts:117`), qui lit `prefix` et `lastUsedThrottleS`
|
|
334
|
+
(`authenticatorRegistry.ts:143`). Conséquence pratique : changer `apiKeys.prefix` change **à la
|
|
335
|
+
fois** l'émission et la reconnaissance — les anciennes clés ne sont plus reconnues.
|
|
336
|
+
|
|
337
|
+
## Quatre parcours vécus
|
|
338
|
+
|
|
339
|
+
### Donner un accès à un script CI (sans lui donner un compte)
|
|
340
|
+
|
|
341
|
+
**Le besoin** : ton pipeline doit lire les commandes une fois par nuit. Il ne doit jamais pouvoir
|
|
342
|
+
écrire, ni se connecter à la console.
|
|
343
|
+
|
|
344
|
+
**La config** : un porteur dédié + un catalogue de scopes fermé.
|
|
345
|
+
|
|
346
|
+
```typescript ignore
|
|
347
|
+
apiKeys: {
|
|
348
|
+
allowedScopes: ["orders:read"], // le catalogue REFUSE tout le reste à l'émission
|
|
349
|
+
defaultExpiryDays: 90,
|
|
350
|
+
}
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
**Ce qu'on observe** : `npx nodefony security:user:add ci-bot` (rôle `ROLE_USER` par défaut), login
|
|
354
|
+
en tant que `ci-bot`, puis émission avec `{"scopes":["orders:read"]}`. Demander
|
|
355
|
+
`{"scopes":["orders:write"]}` renvoie **400 `scope not allowed: orders:write`** — refusé à
|
|
356
|
+
l'émission, pas seulement à l'usage (`#normalizeScopes()`, `apiKeys.ts:292`).
|
|
357
|
+
|
|
358
|
+
> [!TIP]
|
|
359
|
+
> Le catalogue `allowedScopes` de la config est un **complément**, pas la source : la console
|
|
360
|
+
> propose aussi les scopes **découverts sur tes routes** (`@RequireScope`) par
|
|
361
|
+
> `collectDeclaredApiScopes()` (`scopeCatalog.ts:29`), agrégés dans l'endpoint `capabilities`
|
|
362
|
+
> (`ApiKeyController.ts:96`). Un formulaire de création qui ne ment pas.
|
|
363
|
+
|
|
364
|
+
### Faire tourner une clé sans coupure de service
|
|
365
|
+
|
|
366
|
+
**Le besoin** : la clé du CI arrive à expiration (ou tu appliques une rotation trimestrielle). Il ne
|
|
367
|
+
doit y avoir **aucune** fenêtre pendant laquelle le job échoue.
|
|
368
|
+
|
|
369
|
+
**Il n'y a pas de bouton « rotate »** — et c'est délibéré : une rotation atomique impliquerait
|
|
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:113`) :
|
|
372
|
+
|
|
373
|
+
1. Émettre une **seconde** clé (même porteur, mêmes scopes, nom `CI deploy v2`).
|
|
374
|
+
2. Déployer le nouveau secret dans le CI.
|
|
375
|
+
3. Vérifier le basculement : la colonne « dernier usage » de la v2 bouge dans Studio (`lastUsedAt`).
|
|
376
|
+
4. **Puis** révoquer la v1.
|
|
377
|
+
|
|
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:746`). Attends donc une minute
|
|
380
|
+
avant de conclure qu'une clé « ne sert plus ».
|
|
381
|
+
|
|
382
|
+
### Révoquer une clé qui a fuité
|
|
383
|
+
|
|
384
|
+
**Le besoin** : le secret est apparu dans un log public. Il faut couper **maintenant**, sans toucher
|
|
385
|
+
aux autres clés ni au compte.
|
|
386
|
+
|
|
387
|
+
Deux chemins, selon qui agit :
|
|
388
|
+
|
|
389
|
+
| Qui | Endpoint | Portée | Ancrage |
|
|
390
|
+
| ---------- | ------------------------------------------------- | ---------------------- | --------------------------------------- |
|
|
391
|
+
| Le porteur | `DELETE /nodefony/security/api/keys/{id}` | **ses** clés seulement | `revokeForSubject()` (`apiKeys.ts:247`) |
|
|
392
|
+
| Un admin | `POST /nodefony/security/api/apikeys/{id}/revoke` | n'importe quelle clé | `revokeAnyPat()` (`apiKeys.ts:201`) |
|
|
393
|
+
|
|
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:107-114`).
|
|
396
|
+
Le banc d'intégration le prouve bout en bout : 200 avant, 401 après
|
|
397
|
+
(`apikey-flow.test.ts:149-157`).
|
|
398
|
+
|
|
399
|
+
Une propriété de sécurité facile à manquer : si la clé n'existe pas **ou** appartient à quelqu'un
|
|
400
|
+
d'autre, le porteur reçoit un **404 indiscernable**, jamais un 403 (`ApiKeyController.ts:139-142`).
|
|
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:176`).
|
|
403
|
+
|
|
404
|
+
> [!WARNING]
|
|
405
|
+
> Si tu dois couper **toutes** les clés d'un porteur d'un coup (compte compromis, départ), ne les
|
|
406
|
+
> révoque pas une par une : pose le seuil `invalidBefore` du porteur
|
|
407
|
+
> (`revokeAllForSubject`, `ITokenStore.ts:261`). L'authenticator rejette alors toute clé créée avant
|
|
408
|
+
> ce seuil : `getInvalidBefore` est comparé au `createdAt` du record
|
|
409
|
+
> (`ApiKeyAuthenticator.ts:117-120`) — y compris pour les clés que tu aurais oubliées.
|
|
410
|
+
|
|
411
|
+
### Auditer qui a utilisé quoi
|
|
412
|
+
|
|
413
|
+
**Le besoin** : après un incident, savoir quelles clés existent, qui les porte, quand elles ont
|
|
414
|
+
servi et qui les a révoquées.
|
|
415
|
+
|
|
416
|
+
Trois sources, et il faut connaître les limites de chacune :
|
|
417
|
+
|
|
418
|
+
1. **L'état** — le listing d'administration paginé, tous porteurs confondus : `GET
|
|
419
|
+
/nodefony/security/api/apikeys` (`SecurityAdminApi.ts:394`), servi par `listPagePat()`
|
|
420
|
+
(`apiKeys.ts:208`). Filtres `subjectId`, `revoked`, fenêtre `limit`/`offset`/`cursor` et tri
|
|
421
|
+
`order=champ:ASC` (`parseTokenListQuery()`, `SecurityAdminApi.ts:126`), plafonnée à 200 entrées
|
|
422
|
+
(`KEYS_MAX_LIMIT`, `SecurityAdminApi.ts:109`).
|
|
423
|
+
|
|
424
|
+
Le tri n'est accepté que sur les champs que le backend branché **déclare** savoir trier
|
|
425
|
+
(`sortableFields()`, `apiKeys.ts:102` → `ITokenStore.sortableFields`) : `createdAt`, `name`,
|
|
426
|
+
`subjectId`, `id` sur mémoire/SQL/Mongo (`TOKEN_SORTABLE_FIELDS`, `tokenSort.ts:27`). Tout autre
|
|
427
|
+
champ est refusé en **400** — jamais accepté puis ignoré. Un backend Redis ne déclare rien (son
|
|
428
|
+
`SCAN` n'a pas d'ordre global) : tout `order` y est donc refusé, ce qui est la vérité de ce
|
|
429
|
+
store. Les champs _nullables_ (`lastUsedAt`, `expiresAt`, `revokedAt`) sont volontairement hors
|
|
430
|
+
du vocabulaire : le placement des valeurs absentes diffère d'un moteur à l'autre, et un tri dont
|
|
431
|
+
l'ordre dépend de la base configurée ne vaut pas mieux qu'un tri absent.
|
|
432
|
+
|
|
433
|
+
2. **Le journal** — les événements d'audit `apikey.created` (`apiKeys.ts:153`) et `apikey.revoked`
|
|
434
|
+
(`apiKeys.ts:212` côté admin, `apiKeys.ts:255` côté porteur), catégorie `token`. La révocation
|
|
435
|
+
admin trace **l'acteur ET le porteur cible** — voir [audit](./audit.md).
|
|
436
|
+
3. **Le dernier usage** — `lastUsedAt` sur chaque clé.
|
|
437
|
+
|
|
438
|
+
Ce que tu **n'auras pas** : un journal par requête. `markUsed` est appelé avec le seul horodatage
|
|
439
|
+
(`ApiKeyAuthenticator.ts:133`) ; les champs `lastUsedIp` et `lastUsedUserAgent` du record
|
|
440
|
+
(`ITokenStore.ts:135`) restent donc à `null` — ce sont des **emplacements réservés**, pas des
|
|
441
|
+
données remplies. Pour de la traçabilité par appel, c'est le journal d'audit applicatif qu'il faut
|
|
442
|
+
alimenter, pas le store de jetons.
|
|
443
|
+
|
|
444
|
+
## ⚙️ Configuration
|
|
445
|
+
|
|
446
|
+
Table dérivée du schéma Zod `apiKeysSchema` (`config.ts:727`), branché à la racine de la config du
|
|
447
|
+
module (`config.ts:727`). Toutes les valeurs ci-dessous sont les **défauts réels**.
|
|
448
|
+
|
|
449
|
+
| Option | Type | Défaut | Effet |
|
|
450
|
+
| ------------------- | ---------------- | ------ | -------------------------------------------------------------------------------------- |
|
|
451
|
+
| `enabled` | boolean | `true` | Coupe l'émission ET le listing (l'authenticator reste déclarable) (`config.ts:533`) |
|
|
452
|
+
| `prefix` | string ≤ 12 | `"nf"` | Marque des clés ; minuscules/chiffres — discrimine du JWT (`config.ts:730`) |
|
|
453
|
+
| `defaultExpiryDays` | number \| null | `90` | Expiration appliquée si l'appelant n'en donne pas ; `null` = jamais (`config.ts:739`) |
|
|
454
|
+
| `lastUsedThrottleS` | number (s) | `60` | Coalescence d'écriture de `lastUsedAt` ; `0` = à chaque usage (`config.ts:746`) |
|
|
455
|
+
| `maxPerSubject` | number > 0 | `100` | Plafond de clés **actives** par porteur ; au-delà → 409 (`config.ts:755`) |
|
|
456
|
+
| `allowedScopes` | string[] \| null | `null` | Catalogue fermé à la création ; `null` = tout scope non vide accepté (`config.ts:764`) |
|
|
457
|
+
|
|
458
|
+
Deux réglages méritent une décision consciente :
|
|
459
|
+
|
|
460
|
+
- **`prefix`** doit être **propre à ton application** (`acme`, `shop`…). C'est ce qui permet à un
|
|
461
|
+
outil de secret-scanning de reconnaître **tes** clés, et à ton support d'identifier un jeton d'un
|
|
462
|
+
coup d'œil. Le changer invalide la reconnaissance des clés déjà émises.
|
|
463
|
+
- **`defaultExpiryDays: null`** (clé éternelle) est un choix de confort qui se paie : plus rien
|
|
464
|
+
n'oblige à faire tourner le secret. Préfère une durée + le motif de recouvrement décrit plus haut.
|
|
465
|
+
|
|
466
|
+
## 🧰 API publique
|
|
467
|
+
|
|
468
|
+
### Les endpoints — deux portées, jamais mélangées
|
|
469
|
+
|
|
470
|
+
**Console « mes clés »** (le porteur gère les siennes) — montées par `mountApiKeyRoutes()`
|
|
471
|
+
(`ApiKeyController.ts:191`) **seulement si** le service `apiKeys` existe (`framework/index.ts:468`) ;
|
|
472
|
+
sinon 404, zéro surface. Aucune n'est `bypassFirewall` : la zone data plane exige la session BFF.
|
|
473
|
+
|
|
474
|
+
| Méthode | Chemin | Rôle | Ancrage |
|
|
475
|
+
| -------- | ------------------------------------------ | ------------------------------------------------- | ------------------------------------------- |
|
|
476
|
+
| `POST` | `/nodefony/security/api/keys` | Émission → **201** + `token` clair (1×) | `create()` (`ApiKeyController.ts:65`) |
|
|
477
|
+
| `GET` | `/nodefony/security/api/keys` | Mes clés, sans secret | `list()` (`ApiKeyController.ts:113`) |
|
|
478
|
+
| `GET` | `/nodefony/security/api/keys/capabilities` | Plafond, scopes proposés, préfixe, durée | `capabilities()` (`ApiKeyController.ts:96`) |
|
|
479
|
+
| `DELETE` | `/nodefony/security/api/keys/{id}` | Révoque **ma** clé ; 404 sinon (anti-énumération) | `revoke()` (`ApiKeyController.ts:126`) |
|
|
480
|
+
|
|
481
|
+
**Administration** (gouvernance, réponse à incident) — data plane `SecurityAdminApi`, RBAC
|
|
482
|
+
`ROLE_NODEFONY_ADMIN` :
|
|
483
|
+
|
|
484
|
+
| Méthode | Chemin | Rôle | Ancrage |
|
|
485
|
+
| ------- | -------------------------------------------- | ---------------------------------------- | ------------------------- |
|
|
486
|
+
| `GET` | `/nodefony/security/api/apikeys` | Toutes les clés, **paginé au store** | `SecurityAdminApi.ts:380` |
|
|
487
|
+
| `GET` | `/nodefony/security/api/apikeys/status` | « Où on écrit » : classe réelle + driver | `SecurityAdminApi.ts:416` |
|
|
488
|
+
| `POST` | `/nodefony/security/api/apikeys/{id}/revoke` | Révoque n'importe quelle clé, audité | `SecurityAdminApi.ts:440` |
|
|
489
|
+
|
|
490
|
+
Les deux espaces de chemins sont **disjoints** (`keys` vs `apikeys`) — aucune collision, et une
|
|
491
|
+
console d'admin ne peut pas atterrir par erreur sur l'endpoint personnel.
|
|
492
|
+
|
|
493
|
+
Codes d'erreur mappés par duck-typing sur `code` (`#renderApiKeyError()`, `ApiKeyController.ts:164`) :
|
|
494
|
+
**400** validation (nom, scope, durée), **409** plafond atteint, **503** clés indisponibles (store
|
|
495
|
+
absent ou `enabled:false`).
|
|
496
|
+
|
|
497
|
+
### Ce qu'on importe côté application
|
|
498
|
+
|
|
499
|
+
```typescript ignore
|
|
500
|
+
import {
|
|
501
|
+
ApiKeyService, // service (résolu du container : `this.get("apiKeys")`)
|
|
502
|
+
ApiKeyAuthenticator, // enregistré sous le nom "apikey"
|
|
503
|
+
generateApiKey, // helpers de FORMAT — purs, sans I/O
|
|
504
|
+
parseApiKey,
|
|
505
|
+
hashApiKey,
|
|
506
|
+
looksLikeApiKey,
|
|
507
|
+
} from "@nodefony/security";
|
|
508
|
+
import type {
|
|
509
|
+
IApiKeyView, // vue publique — sans secret ni hash
|
|
510
|
+
IApiKeyCreated, // vue publique + token clair (création seule)
|
|
511
|
+
IApiKeyCapabilities, // contraintes d'émission (formulaire honnête)
|
|
512
|
+
ICreateApiKeyOptions,
|
|
513
|
+
} from "@nodefony/security";
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Les contrats vivent dans `IApiKey.ts` : `IApiKeyView` (`IApiKey.ts:6`), `IApiKeyCreated`
|
|
517
|
+
(`IApiKey.ts:36`), `IApiKeyCapabilities` (`IApiKey.ts:47`), `ICreateApiKeyOptions`
|
|
518
|
+
(`IApiKey.ts:61`). Les signatures détaillées vivent dans le graphe TSDoc (`.ai/symbols.json`) —
|
|
519
|
+
cette page explique l'usage, elle ne recopie pas les prototypes.
|
|
520
|
+
|
|
521
|
+
## 🧑⚖️ Scopes — ce que la clé a le droit de faire
|
|
522
|
+
|
|
523
|
+
Deux axes se combinent, et les confondre est l'erreur la plus fréquente :
|
|
524
|
+
|
|
525
|
+
- **Les rôles** disent **qui tu es** — ils appartiennent au porteur (`ROLE_ADMIN`…).
|
|
526
|
+
- **Les scopes** disent **ce que cette clé-là peut faire** — ils appartiennent au jeton.
|
|
527
|
+
|
|
528
|
+
Une clé ne peut donc **jamais** dépasser son porteur : elle en est une restriction, pas une
|
|
529
|
+
extension. Concrètement, `@RequireScope("orders:read")` sur une action est tranché par le
|
|
530
|
+
`ScopeVoter`, dont la règle est asymétrique (`ScopeVoter.ts:44`) :
|
|
531
|
+
|
|
532
|
+
- un jeton **humain** (session, mot de passe, anonyme) n'est jamais bridé par un scope — la liste
|
|
533
|
+
`NON_SCOPABLE_TOKEN_TYPES` (`ScopeVoter.ts:17`) le fait passer ; ce sont ses **rôles** qui décident ;
|
|
534
|
+
- un jeton **machine délégué** (`apikey`, `jwt`, `oauth2`) doit porter le scope **exact**, sinon
|
|
535
|
+
refus par défaut du jury.
|
|
536
|
+
|
|
537
|
+
Le détail du jury (voters, veto, hiérarchie de rôles) est sur la page
|
|
538
|
+
[autorisation](./authorization.md) — la clé d'API n'y est qu'un porteur de scopes parmi d'autres.
|
|
539
|
+
|
|
540
|
+
## Persistance — un store partagé avec les jetons
|
|
541
|
+
|
|
542
|
+
Une clé d'API **n'a pas de table à elle**. Elle est un `IAccessTokenRecord` (`ITokenStore.ts:69`)
|
|
543
|
+
de `kind:"pat"` (`ITokenStore.ts:74`), dans la même table que les refresh tokens — les champs sans
|
|
544
|
+
objet pour un PAT (`family`, `replacedBy`, `audience`) valent `null`.
|
|
545
|
+
|
|
546
|
+
Les champs qui portent le sens **pour une clé d'API** :
|
|
547
|
+
|
|
548
|
+
| Champ | Rôle pour un PAT | Ancrage |
|
|
549
|
+
| ------------ | ------------------------------------------------------------- | -------------------- |
|
|
550
|
+
| `kind` | `"pat"` — discrimine du refresh dans la même table | `ITokenStore.ts:74` |
|
|
551
|
+
| `name` | Libellé humain (« CI deploy ») — ce qu'on lit dans la console | `ITokenStore.ts:76` |
|
|
552
|
+
| `prefix` | Préfixe public `nf_a1b2c3d4` (jamais le secret) | `ITokenStore.ts:78` |
|
|
553
|
+
| `subjectId` | Porteur — **référence logique**, pas une clé étrangère SQL | `ITokenStore.ts:95` |
|
|
554
|
+
| `scopes` | Capacités de la clé (lues par le `ScopeVoter`) | `ITokenStore.ts:103` |
|
|
555
|
+
| `secretHash` | `sha256` du token entier — clé de `findByHash` | `ITokenStore.ts:111` |
|
|
556
|
+
| `hashAlg` | `"sha256"` — agilité crypto pour une migration future | `ITokenStore.ts:113` |
|
|
557
|
+
| `expiresAt` | Expiration ou `null` (clé longue durée) | `ITokenStore.ts:131` |
|
|
558
|
+
| `lastUsedAt` | Dernier usage, écrit **throttlé** | `ITokenStore.ts:133` |
|
|
559
|
+
| `revokedAt` | Révocation — le contrôle n°1 de l'authenticator | `ITokenStore.ts:139` |
|
|
560
|
+
|
|
561
|
+
> [!NOTE]
|
|
562
|
+
> Les **colonnes et types par dialecte** ne sont pas dupliqués ici : le propriétaire du schéma est
|
|
563
|
+
> le `TokenService`, et la table est décrite une seule fois côté [tokens](./tokens.md) puis dans la
|
|
564
|
+
> doc de chaque adapter. Règle anti-triple-vérité — un seul endroit à corriger quand le schéma bouge.
|
|
565
|
+
|
|
566
|
+
### Bases prises en charge
|
|
567
|
+
|
|
568
|
+
**Quatre** backends portent les clés, exactement ceux du store de jetons — parce que c'est le
|
|
569
|
+
**même** store, résolu par le `TokenService` selon la doctrine `store:"auto"` :
|
|
570
|
+
|
|
571
|
+
| Backend | Durable | Listing admin | Pour… |
|
|
572
|
+
| ---------- | :-----: | --------------------------- | -------------------------------------------------------- |
|
|
573
|
+
| `memory` | non | offset + total | dev / tests mono-process |
|
|
574
|
+
| `drizzle` | oui | offset + total | SQL (PostgreSQL, MySQL/MariaDB, SQLite) — défaut durable |
|
|
575
|
+
| `mongoose` | oui | offset + total | MongoDB |
|
|
576
|
+
| `redis` | oui | **curseur**, `total` absent | flotte de pods, TTL natif |
|
|
577
|
+
|
|
578
|
+
Ce qui **n'existe pas** : aucun autre backend n'est enregistré, et il n'y a pas de store propre aux
|
|
579
|
+
clés d'API. En `memory` **en production**, la conséquence est directe et annoncée au boot : les clés
|
|
580
|
+
sont per-pod et volatiles — une clé émise sur un pod n'est pas reconnue par les autres, et une
|
|
581
|
+
révocation ne traverse pas. Le détail de la résolution, des avertissements et de la purge est sur
|
|
582
|
+
[tokens](./tokens.md).
|
|
583
|
+
|
|
584
|
+
## 📜 Normes appliquées
|
|
585
|
+
|
|
586
|
+
| Domaine | Norme | Ancrage |
|
|
587
|
+
| --------------------------------- | -------------------------------------- | ------------------------------------------------------ |
|
|
588
|
+
| Schéma `Bearer` (transport) | RFC 6750 §2.1 | `readBearerHeader()` (`runtime/bearer.ts:68`) |
|
|
589
|
+
| `invalid_token` → 401 + challenge | RFC 6750 §3.1 · RFC 7235 | `challenge()` (`ApiKeyAuthenticator.ts:205`) |
|
|
590
|
+
| 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:37`) |
|
|
592
|
+
| Anti-énumération des ressources | OWASP API1:2023 (BOLA/IDOR) | 404 indiscernable (`ApiKeyController.ts:139-142`) |
|
|
593
|
+
| 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:107-114`) |
|
|
595
|
+
| 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:113`) |
|
|
597
|
+
|
|
598
|
+
## ⚡ Performance & mémoire
|
|
599
|
+
|
|
600
|
+
Le coût par requête authentifiée par clé est **maîtrisé par construction**, dans cet ordre :
|
|
601
|
+
|
|
602
|
+
- **Le filtre le moins cher d'abord.** `supports()` ne fait qu'un `startsWith`
|
|
603
|
+
(`ApiKeyAuthenticator.ts:68`) ; le parsing complet (CRC inclus) est purement local
|
|
604
|
+
(`apiKeyFormat.ts:131`). Une valeur invalide ne coûte **aucun** I/O.
|
|
605
|
+
- **Table CRC précalculée une fois** au chargement du module, jamais par appel
|
|
606
|
+
(`CRC_TABLE`, `apiKeyFormat.ts:40`).
|
|
607
|
+
- **`lastUsedAt` throttlé** — sans cette coalescence, chaque requête d'API deviendrait une
|
|
608
|
+
**écriture** en base. Fenêtre par défaut 60 s (`ApiKeyAuthenticator.ts:127-134`) ; `0` rétablit
|
|
609
|
+
l'écriture systématique, à ne choisir qu'en connaissance de cause.
|
|
610
|
+
- **Dépendances résolues paresseusement** : store et fournisseur d'utilisateurs sont récupérés du
|
|
611
|
+
container au premier usage et mémoïsés (`ApiKeyAuthenticator.ts:173`, `apiKeys.ts:268`) — le boot
|
|
612
|
+
ne paie rien si aucune clé n'est jamais présentée.
|
|
613
|
+
- **Jamais N enregistrements en RAM** côté administration : le listing est paginé **au store**
|
|
614
|
+
(`listPagePat()`, `apiKeys.ts:216`), fenêtre plafonnée à 200 (`SecurityAdminApi.ts:107`).
|
|
615
|
+
|
|
616
|
+
Le point de vigilance restant : `createForSubject()` compte les clés actives via `findBySubject()`
|
|
617
|
+
(`ITokenStore.ts:211`), qui charge **toutes** les clés du porteur. C'est borné par `maxPerSubject`
|
|
618
|
+
(100 par défaut) et c'est un chemin froid (émission), pas le chemin chaud.
|
|
619
|
+
|
|
620
|
+
## 📡 Observabilité — Studio
|
|
621
|
+
|
|
622
|
+
L'écran **API Keys** (`/nodefony/api-keys`, `studio/frontend/src/routes/ApiKeys.tsx`) expose les
|
|
623
|
+
deux portées dans une seule page :
|
|
624
|
+
|
|
625
|
+
- **Mes clés** — création, listing et révocation via le data plane personnel
|
|
626
|
+
(`KEYS_ENDPOINT`, `studio/frontend/src/routes/apikeys/apiKeysModel.ts:83`). Le secret est affiché
|
|
627
|
+
dans la modale de création, une fois.
|
|
628
|
+
- **Administration** — toutes les clés du système, pagination serveur et révocation ciblée
|
|
629
|
+
(`ADMIN_KEYS_ENDPOINT`, `studio/frontend/src/routes/apikeys/apiKeysModel.ts:93`), réservé à
|
|
630
|
+
`ROLE_NODEFONY_ADMIN`.
|
|
631
|
+
- **Badge « où on écrit »** — la classe réelle du store et son driver, lu défensivement pour que la
|
|
632
|
+
console affiche toujours un état honnête (`API_KEYS_STATUS_ENDPOINT`,
|
|
633
|
+
`studio/frontend/src/routes/apikeys/apiKeysModel.ts:99` ; handler `IApiKeysStatus`,
|
|
634
|
+
`SecurityAdminApi.ts:54`).
|
|
635
|
+
|
|
636
|
+
Les types du front sont des **miroirs** du contrat serveur — le secret est exclu par construction,
|
|
637
|
+
pas masqué à l'affichage. Voir aussi l'écran **Audit** pour les événements `apikey.created` /
|
|
638
|
+
`apikey.revoked`.
|
|
639
|
+
|
|
640
|
+
## ⚠️ Pièges (symptôme → cause → correction)
|
|
641
|
+
|
|
642
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
643
|
+
| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
|
644
|
+
| 404 sur `/nodefony/security/api/keys` | Routes montées seulement si le service `apiKeys` existe (`framework/index.ts:385`) | Charger `@nodefony/security` + `apiKeys.enabled: true` |
|
|
645
|
+
| 503 « API keys unavailable » | Store non provisionné (`TokenService` absent/désactivé) (`apiKeys.ts:330`) | Vérifier `jwt`/`tokenStore` — le `TokenService` pose le store |
|
|
646
|
+
| 401 à la création de clé | Ces routes exigent une **session** (pas de `bypassFirewall`) | Se connecter d'abord (`/nodefony/security/api/auth/login`) |
|
|
647
|
+
| 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:113`) | Révoquer les clés inutilisées ou relever `maxPerSubject` |
|
|
649
|
+
| 400 « scope not allowed » | Scope hors du catalogue `allowedScopes` (`apiKeys.ts:292`) | Ajouter le scope au catalogue, ou corriger la demande |
|
|
650
|
+
| 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
|
+
| 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:117-120`) | Réactiver le compte, ou réémettre après le bannissement |
|
|
653
|
+
| 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
|
+
| `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
|
+
| Révocation sans effet entre pods | Store `memory` en production (per-pod) | Store durable partagé — voir [tokens](./tokens.md) |
|
|
657
|
+
| 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
|
+
|
|
659
|
+
## 🧪 Tests & couverture
|
|
660
|
+
|
|
661
|
+
Trois familles couvrent la brique — les **chiffres exacts vivent dans la carte de l'aperçu**
|
|
662
|
+
(régénérée par `gen-counters.mjs` depuis vitest, jamais figée ici) :
|
|
663
|
+
|
|
664
|
+
- **unit** : `apiKeyFormat` (génération, parsing, CRC invalide, charset, longueurs),
|
|
665
|
+
`apiKeyAuthenticator` (les 7 filtres : forme, hash, révocation, expiration, `invalidBefore`,
|
|
666
|
+
compte inactif, throttle `lastUsedAt`), `apiKeyService` (validation nom/scopes/durée, plafond,
|
|
667
|
+
anti-énumération de la révocation, vue publique sans secret) ;
|
|
668
|
+
- **intégration** : `apikey-flow` sur serveur HTTPS réel — le parcours complet login → émission →
|
|
669
|
+
usage → révocation, **plus une matrice d'attaques sur le fil** : absence de Bearer, clé forgée à
|
|
670
|
+
CRC invalide, clé révoquée, création anonyme, IDOR sur la clé d'autrui, secret jamais ré-exposé au
|
|
671
|
+
listing, cohabitation JWT + PAT dans la même zone ;
|
|
672
|
+
- **banc de contrat** : `tokenPaginationContract` — les invariants de `listPage`/`countTokens` que
|
|
673
|
+
**tous** les backends doivent tenir, donc ceux dont dépend le listing d'administration des clés.
|
|
674
|
+
|
|
675
|
+
**Ce qui manque, assumé** : pas de fichier `*.attack.test.ts` dédié aux clés d'API (les attaques
|
|
676
|
+
sont dans le banc d'intégration, sur le fil — c'est plus fort, mais elles ne tournent pas sans
|
|
677
|
+
serveur) ; pas de test de charge ni de mesure mémoire propre à la vérification de clé.
|
|
678
|
+
|
|
679
|
+
Les bancs sur serveur réel se **skippent sans leurs variables d'infra** — et un skip compte comme
|
|
680
|
+
vert : lire le bloc gates (`vitest.gates.ts`, affiché en fin de run) avant de conclure. Skills
|
|
681
|
+
utiles : `nodefony-security-review` (matrice d'attaque), `nodefony-load-test` (charge).
|
|
682
|
+
Couverture : `npm run coverage` dans `@nodefony/security`.
|
|
683
|
+
|
|
684
|
+
## 🔗 Pour aller plus loin
|
|
685
|
+
|
|
686
|
+
- ⬆️ **Retour au hub** : [Sécurité — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
|
|
687
|
+
- Le store partagé, son cycle de vie et ses backends → [tokens](./tokens.md)
|
|
688
|
+
- Ce que la clé a le droit de faire (scopes, voters, rôles) → [autorisation](./authorization.md)
|
|
689
|
+
- La zone qui exige la clé, et la cohabitation avec `jwt` → [firewall](./firewall.md)
|
|
690
|
+
- Le contrat commun à tous les authenticators → [authenticators](./authenticators.md)
|
|
691
|
+
- La trace des émissions et des révocations → [audit](./audit.md)
|