@nodefony/security 10.0.0-beta.2 → 10.0.0-beta.4
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.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/index.js +48 -16
- package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +62 -24
- package/dist/nodefony/command/security-oauth-doctor.js +68 -0
- package/dist/nodefony/command/security-secrets.js +52 -29
- package/dist/nodefony/command/security-user-add.js +9 -3
- package/dist/nodefony/command/security-user-delete.js +10 -7
- package/dist/nodefony/command/security-user-list.js +5 -3
- package/dist/nodefony/command/security-user-password.js +9 -14
- package/dist/nodefony/config/config.js +99 -8
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/contracts/IAccessVoter.js +1 -2
- package/dist/nodefony/contracts/index.js +1 -2
- package/dist/nodefony/errors/AccessDeniedError.js +1 -2
- package/dist/nodefony/errors/ApiKeyError.js +3 -2
- package/dist/nodefony/errors/AuthenticationError.js +1 -2
- package/dist/nodefony/errors/CsrfError.js +1 -2
- package/dist/nodefony/errors/InsecureTransportError.js +23 -0
- package/dist/nodefony/errors/InvalidTargetError.js +1 -2
- package/dist/nodefony/errors/SsrfError.js +1 -2
- package/dist/nodefony/errors/ThrottledError.js +1 -2
- package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
- package/dist/nodefony/errors/WebAuthnError.js +1 -2
- package/dist/nodefony/errors/index.js +2 -1
- package/dist/nodefony/keycloak/keycloakAdmin.js +254 -0
- package/dist/nodefony/keycloak/keycloakRealm.js +259 -0
- package/dist/nodefony/keycloak/keycloakRealmInput.js +123 -0
- package/dist/nodefony/keycloak/scaffold.js +231 -0
- package/dist/nodefony/keycloak/security-keycloak-realm.js +130 -0
- package/dist/nodefony/scaffold/contribute.js +19 -0
- package/dist/nodefony/service/accessTokenVerifier.js +101 -34
- package/dist/nodefony/service/apiKeys.js +45 -5
- package/dist/nodefony/service/auditService.js +4 -5
- package/dist/nodefony/service/authFlow.js +118 -16
- package/dist/nodefony/service/authorization.js +1 -2
- package/dist/nodefony/service/cors.js +1 -2
- package/dist/nodefony/service/csrf.js +1 -2
- package/dist/nodefony/service/firewall.js +116 -25
- package/dist/nodefony/service/keycloak.js +208 -0
- package/dist/nodefony/service/oauth2.js +403 -28
- package/dist/nodefony/service/securityHeaders.js +1 -2
- package/dist/nodefony/service/tokenService.js +94 -27
- package/dist/nodefony/service/totp.js +55 -11
- package/dist/nodefony/service/webAuthn.js +24 -7
- package/dist/nodefony/service/webhooks.js +4 -5
- package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
- package/dist/nodefony/src/SecuredArea.js +15 -5
- package/dist/nodefony/src/accountBinding.js +96 -0
- package/dist/nodefony/src/admin/SecurityAdminApi.js +71 -42
- package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
- package/dist/nodefony/src/admin/adminAudit.js +1 -3
- package/dist/nodefony/src/admin/userRevocationCascade.js +148 -20
- package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
- package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
- package/dist/nodefony/src/audit/auditBridge.js +1 -2
- package/dist/nodefony/src/audit/auditFilters.js +1 -2
- package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
- package/dist/nodefony/src/audit/readAuditContext.js +1 -2
- package/dist/nodefony/src/audit/recordAudit.js +1 -2
- package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +14 -59
- package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +80 -19
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +92 -16
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +14 -5
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +5 -12
- package/dist/nodefony/src/authenticator/bearer.js +1 -2
- package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
- package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
- package/dist/nodefony/src/boot/publicRoutesNotice.js +26 -0
- package/dist/nodefony/src/crypto/secretCipher.js +1 -2
- package/dist/nodefony/src/csp.js +1 -2
- package/dist/nodefony/src/csrfToken.js +1 -2
- package/dist/nodefony/src/entryPoint.js +38 -0
- package/dist/nodefony/src/net/ssrfGuard.js +1 -2
- package/dist/nodefony/src/net/transportFailure.js +57 -0
- package/dist/nodefony/src/oauth/clientCredentials.js +39 -0
- package/dist/nodefony/src/oauth/httpJson.js +3 -3
- package/dist/nodefony/src/oauth/metadata.js +28 -12
- package/dist/nodefony/src/oauth/oauth2Client.js +61 -35
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +40 -8
- package/dist/nodefony/src/oauth/providerDiagnosis.js +282 -0
- package/dist/nodefony/src/oauth/providerRoles.js +78 -0
- package/dist/nodefony/src/oauth/providers/github.js +48 -12
- package/dist/nodefony/src/oauth/providers/oidc.js +90 -8
- package/dist/nodefony/src/openToSignup.js +46 -0
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
- package/dist/nodefony/src/realtime/frameAuthorizer.js +1 -2
- package/dist/nodefony/src/rolesWithin.js +57 -0
- package/dist/nodefony/src/sessionAuthentication.js +25 -0
- package/dist/nodefony/src/sessionIdentity.js +29 -3
- package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
- package/dist/nodefony/src/token/AnonymousToken.js +1 -2
- package/dist/nodefony/src/token/JwtKeystore.js +89 -48
- package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +46 -16
- package/dist/nodefony/src/token/UserToken.js +1 -2
- package/dist/nodefony/src/token/jwtRuntime.js +1 -2
- package/dist/nodefony/src/token/scopeGrants.js +109 -0
- package/dist/nodefony/src/token/secretFile.js +60 -4
- package/dist/nodefony/src/token/tokenCriteria.js +1 -2
- package/dist/nodefony/src/token/tokenFilters.js +1 -2
- package/dist/nodefony/src/token/tokenSort.js +1 -2
- package/dist/nodefony/src/token/tokenStatus.js +1 -2
- package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
- package/dist/nodefony/src/totp/totpCipher.js +1 -2
- package/dist/nodefony/src/totp/totpCrypto.js +1 -2
- package/dist/nodefony/src/totp/totpOperations.js +1 -2
- package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
- package/dist/nodefony/src/transport/CredentialTransportPolicy.js +50 -0
- package/dist/nodefony/src/voter/RoleVoter.js +1 -2
- package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
- package/dist/nodefony/src/voter/voterRegistry.js +1 -2
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
- package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
- package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
- package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
- package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
- package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
- package/dist/nodefony/src/webhook/webhookSort.js +1 -2
- package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
- package/dist/types/index.d.ts +26 -6
- package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +16 -6
- package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
- package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
- package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
- package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
- package/dist/types/nodefony/config/config.d.ts +64 -1
- package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
- package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
- package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
- package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
- package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
- package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
- package/dist/types/nodefony/errors/index.d.ts +1 -0
- package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
- package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
- package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
- package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
- package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
- package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
- package/dist/types/nodefony/service/accessTokenVerifier.d.ts +35 -14
- package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
- package/dist/types/nodefony/service/authFlow.d.ts +35 -3
- package/dist/types/nodefony/service/firewall.d.ts +17 -3
- package/dist/types/nodefony/service/keycloak.d.ts +115 -0
- package/dist/types/nodefony/service/oauth2.d.ts +145 -9
- package/dist/types/nodefony/service/tokenService.d.ts +7 -1
- package/dist/types/nodefony/service/totp.d.ts +28 -4
- package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
- package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
- package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +72 -12
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -26
- package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +17 -1
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
- package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
- package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
- package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
- package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
- package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
- package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
- package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
- package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
- package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
- package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
- package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
- package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
- package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
- package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
- package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -0
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
- package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
- package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
- package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
- package/docs/api-keys.md +182 -175
- package/docs/audit.md +141 -141
- package/docs/authenticators.md +129 -96
- package/docs/authorization.md +116 -69
- package/docs/cors.md +64 -64
- package/docs/csrf.md +77 -72
- package/docs/external-jwt.md +83 -11
- package/docs/firewall.md +119 -83
- package/docs/headers.md +82 -82
- package/docs/index.md +16 -8
- package/docs/keycloak.md +835 -0
- package/docs/login-page.md +361 -0
- package/docs/oauth2.md +291 -166
- package/docs/obtenir-un-jeton.md +131 -34
- package/docs/tokens.md +197 -140
- package/docs/totp.md +177 -136
- package/docs/webauthn.md +157 -154
- package/docs/webhooks.md +128 -125
- package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
- package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/account/theme.properties +14 -0
- package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
- package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/admin/theme.properties +12 -0
- package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
- package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
- package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
- package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/email/theme.properties +8 -0
- package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
- package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
- package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
- package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
- package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/login/template.ftl +423 -0
- package/keycloak/themes/nodefony/login/theme.properties +17 -0
- package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
- package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
- package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
- package/package.json +19 -17
- package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
package/docs/api-keys.md
CHANGED
|
@@ -101,7 +101,7 @@ Une clé d'API découpe le problème en trois :
|
|
|
101
101
|
|
|
102
102
|
> [!IMPORTANT]
|
|
103
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
|
|
104
|
+
> de 256 bits (`SECRET_BYTES` de 32 octets, `apiKeyFormat.ts#SECRET_BYTES`) — inutile de la « complexifier »,
|
|
105
105
|
> impossible de la deviner. Le vrai risque n'est pas le devinage, c'est la **fuite** : d'où le
|
|
106
106
|
> hachage au repos, l'expiration par défaut et la révocation immédiate.
|
|
107
107
|
|
|
@@ -115,21 +115,21 @@ 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#ApiKeyAuthenticator.authenticate`).
|
|
119
119
|
|
|
120
120
|
**Ce que Nodefony fait pour que ça reste bon marché** — trois décisions ancrées au code :
|
|
121
121
|
|
|
122
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
|
|
123
|
+
I/O — `parseApiKey()` (`apiKeyFormat.ts#parseApiKey`). Une avalanche de chaînes au hasard ne devient
|
|
124
124
|
jamais une avalanche de requêtes SQL.
|
|
125
125
|
- **Le secret n'existe nulle part au repos.** Seul `sha256(token)` est persisté — `hashApiKey()`
|
|
126
|
-
(`apiKeyFormat.ts
|
|
126
|
+
(`apiKeyFormat.ts#hashApiKey`). Une fuite de la base ne donne aucune clé utilisable.
|
|
127
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
|
|
128
|
+
discriminées par `kind: "pat"` (`ITokenStore.ts#IAccessTokenRecord.kind`) — une brique de moins à configurer, à
|
|
129
129
|
purger et à superviser. Le propriétaire du store reste le `TokenService` ([tokens](./tokens.md)).
|
|
130
130
|
|
|
131
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
|
|
132
|
+
exige argon2. La raison est écrite dans le code (`apiKeyFormat.ts#PUBID_BYTES`) — un secret de 256 bits
|
|
133
133
|
tiré au hasard n'est ni brute-forçable ni exposé aux tables arc-en-ciel ; un hachage lent ne
|
|
134
134
|
protégerait que des secrets faibles, et coûterait sur le chemin chaud.
|
|
135
135
|
|
|
@@ -141,25 +141,28 @@ Dans une app générée par `nodefony create app`, tout se déclare dans la conf
|
|
|
141
141
|
dont les authenticators contiennent `apikey` exige un `Authorization: Bearer nf_…` valide :
|
|
142
142
|
|
|
143
143
|
```typescript
|
|
144
|
-
// nodefony
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
144
|
+
// nodefony/config/security.ts — la zone machine + la politique d'émission
|
|
145
|
+
import type { ISecurityConfigInput } from "@nodefony/security";
|
|
146
|
+
|
|
147
|
+
export const securityConfig = () =>
|
|
148
|
+
({
|
|
149
|
+
areas: {
|
|
150
|
+
// Zone protégée : sans clé valide → 401 AVANT ton controller (Zero Trust).
|
|
151
|
+
// `jwt` cohabite sans conflit — les deux se discriminent par la FORME du
|
|
152
|
+
// bearer (JWT = a.b.c, clé d'API = nf_…).
|
|
153
|
+
api: {
|
|
154
|
+
pattern: "^/api/v1",
|
|
155
|
+
authenticators: ["apikey", "jwt"],
|
|
156
|
+
mode: "first",
|
|
157
|
+
},
|
|
154
158
|
},
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
});
|
|
159
|
+
apiKeys: {
|
|
160
|
+
prefix: "acme", // marque de TES clés : acme_… (secret-scanning + support)
|
|
161
|
+
defaultExpiryDays: 90, // une clé émise sans durée meurt au bout de 90 jours
|
|
162
|
+
maxPerSubject: 20, // plafond de clés ACTIVES par porteur (au-delà : 409)
|
|
163
|
+
allowedScopes: ["orders:read", "orders:write"], // catalogue fermé
|
|
164
|
+
},
|
|
165
|
+
}) satisfies ISecurityConfigInput;
|
|
163
166
|
```
|
|
164
167
|
|
|
165
168
|
### 2. Écrire le controller, borné par un scope
|
|
@@ -193,9 +196,9 @@ export default OrdersController;
|
|
|
193
196
|
### 3. Émettre la clé — par l'API, avec une session
|
|
194
197
|
|
|
195
198
|
L'émission est un **endpoint fourni**, monté seulement si le service `apiKeys` existe
|
|
196
|
-
(`mountApiKeyRoutes()`, `ApiKeyController.ts
|
|
199
|
+
(`mountApiKeyRoutes()`, `ApiKeyController.ts#mountApiKeyRoutes`). Il n'y a **pas** de commande CLI pour créer une
|
|
197
200
|
clé : la création exige une **session BFF** (les routes ne sont pas `bypassFirewall` —
|
|
198
|
-
`ApiKeyController.ts
|
|
201
|
+
`ApiKeyController.ts#ApiKeyController`), parce que le porteur est **toujours** l'utilisateur courant, jamais un
|
|
199
202
|
paramètre.
|
|
200
203
|
|
|
201
204
|
```bash
|
|
@@ -228,23 +231,23 @@ curl -si https://localhost:5152/api/v1/orders/list | head -1
|
|
|
228
231
|
|
|
229
232
|
> [!WARNING]
|
|
230
233
|
> 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
|
|
234
|
+
> stocké, donc pas re-dérivable (`IApiKeyCreated`, `IApiKey.ts#IApiKeyCreated`). Le listing ultérieur ne
|
|
235
|
+
> renvoie que le `prefix` public (`#toView()`, `apiKeys.ts#ApiKeyService.#toView`). Perdue = ré-émise.
|
|
233
236
|
|
|
234
237
|
## 🔐 Anatomie d'une clé — ce que chaque morceau paie
|
|
235
238
|
|
|
236
239
|
Format émis : `<prefix>_<pubid><secret><crc>` — **un seul** `_`, le reste est **positionnel**. La
|
|
237
|
-
raison est dans le code (`apiKeyFormat.ts
|
|
240
|
+
raison est dans le code (`apiKeyFormat.ts#PUBID_BYTES`) : le charset base64url contient lui-même `-` et
|
|
238
241
|
`_`, donc un `split("_")` serait fragile ; on découpe par longueurs fixes.
|
|
239
242
|
|
|
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
|
|
243
|
-
| `pubid` | 6 octets → 8 car. | non | Identifiant affichable dans la console (`nf_a1b2c3d4`) — `generateApiKey()` (`apiKeyFormat.ts
|
|
244
|
-
| `secret` | 32 octets → 43 car. | **oui** | 256 bits d'entropie — `SECRET_BYTES` (`apiKeyFormat.ts
|
|
245
|
-
| `crc` | 4 octets → 6 car. | non | CRC32 du `prefix_pubid+secret` — `crcChunk()` (`apiKeyFormat.ts
|
|
243
|
+
| Morceau | Taille | Secret ? | À quoi ça sert |
|
|
244
|
+
| -------- | -------------------- | :------: | -------------------------------------------------------------------------------------------------------------- |
|
|
245
|
+
| `prefix` | ≤ 12 car. minuscules | non | Marque applicative — discrimine du JWT, aide le secret-scanning (`config.ts#apiKeysSchema`) |
|
|
246
|
+
| `pubid` | 6 octets → 8 car. | non | Identifiant affichable dans la console (`nf_a1b2c3d4`) — `generateApiKey()` (`apiKeyFormat.ts#generateApiKey`) |
|
|
247
|
+
| `secret` | 32 octets → 43 car. | **oui** | 256 bits d'entropie — `SECRET_BYTES` (`apiKeyFormat.ts#SECRET_BYTES`) |
|
|
248
|
+
| `crc` | 4 octets → 6 car. | non | CRC32 du `prefix_pubid+secret` — `crcChunk()` (`apiKeyFormat.ts#crcChunk`) |
|
|
246
249
|
|
|
247
|
-
Le corps total fait donc 57 caractères — `BODY_LEN` (`apiKeyFormat.ts
|
|
250
|
+
Le corps total fait donc 57 caractères — `BODY_LEN` (`apiKeyFormat.ts#BODY_LEN`), longueur vérifiée
|
|
248
251
|
strictement au parsing.
|
|
249
252
|
|
|
250
253
|
### À quoi sert vraiment le CRC (et à quoi il ne sert pas)
|
|
@@ -254,21 +257,21 @@ deux choses très concrètes :
|
|
|
254
257
|
|
|
255
258
|
1. **Rejeter une clé malformée en O(1), sans toucher le store.** `parseApiKey()` vérifie préfixe,
|
|
256
259
|
longueur, charset base64url puis CRC — et renvoie `null` avant tout I/O
|
|
257
|
-
(`apiKeyFormat.ts
|
|
260
|
+
(`apiKeyFormat.ts#parseApiKey`). C'est une défense **anti-DoS** : un attaquant qui bombarde des
|
|
258
261
|
`nf_` aléatoires consomme du CPU, jamais des lectures de base.
|
|
259
262
|
2. **Le secret-scanning.** GitHub, GitGuardian & co. reconnaissent un motif `nf_…` **dont le
|
|
260
263
|
checksum tombe juste** avec un taux de faux positifs quasi nul. Une clé poussée par erreur dans
|
|
261
264
|
un dépôt est détectée par l'outillage de l'écosystème, pas seulement par toi.
|
|
262
265
|
|
|
263
266
|
La table CRC32 (IEEE 802.3) est précalculée **une fois** au chargement du module — `CRC_TABLE`
|
|
264
|
-
(`apiKeyFormat.ts
|
|
265
|
-
dépendance ni d'une variation de version Node (`crc32()`, `apiKeyFormat.ts
|
|
267
|
+
(`apiKeyFormat.ts#CRC_TABLE`) — et l'implémentation est locale et déterministe, pour ne dépendre ni d'une
|
|
268
|
+
dépendance ni d'une variation de version Node (`crc32()`, `apiKeyFormat.ts#crc32`).
|
|
266
269
|
|
|
267
270
|
### Un test encore moins cher, pour l'aiguillage
|
|
268
271
|
|
|
269
272
|
Avant même de parser, le firewall doit savoir **quel** authenticator prend la main. C'est
|
|
270
|
-
`looksLikeApiKey()` (`apiKeyFormat.ts
|
|
271
|
-
`ApiKeyAuthenticator.supports()` (`ApiKeyAuthenticator.ts
|
|
273
|
+
`looksLikeApiKey()` (`apiKeyFormat.ts#looksLikeApiKey`) : un simple `startsWith("<prefix>_")`, appelé par
|
|
274
|
+
`ApiKeyAuthenticator.supports()` (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.supports`). C'est ce qui rend la cohabitation
|
|
272
275
|
`["apikey", "jwt"]` possible dans une même zone — un JWT a la structure `a.b.c`, il ne commence
|
|
273
276
|
jamais par le préfixe.
|
|
274
277
|
|
|
@@ -276,40 +279,44 @@ jamais par le préfixe.
|
|
|
276
279
|
|
|
277
280
|
### Émission — `ApiKeyService.createForSubject()`
|
|
278
281
|
|
|
279
|
-
`ApiKeyService.createForSubject()` (`apiKeys.ts
|
|
282
|
+
`ApiKeyService.createForSubject()` (`apiKeys.ts#ApiKeyService.createForSubject`) est le seul chemin d'émission. Dans l'ordre :
|
|
280
283
|
|
|
281
284
|
1. **Validation du nom** — non vide, ≤ 100 caractères ; sinon `ApiKeyError` 400 (`#normalizeName()`,
|
|
282
|
-
`apiKeys.ts
|
|
285
|
+
`apiKeys.ts#ApiKeyService.#normalizeName`).
|
|
283
286
|
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
|
|
285
|
-
3. **
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
(
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
287
|
+
`allowedScopes` est défini ; sinon 400 (`#normalizeScopes()`, `apiKeys.ts#ApiKeyService.#normalizeScopes`).
|
|
288
|
+
3. **Droit d'obtenir chaque scope** — un scope réservé par `grantableScopes` à des rôles que le
|
|
289
|
+
porteur n'a pas, ou `admin:*` sans le rôle d'administration, refuse la création entière :
|
|
290
|
+
`ApiKeyError` **403** qui nomme le scope (`refusedScopes()`, `apiKeys.ts#ApiKeyService.createForSubject`). Même juge que
|
|
291
|
+
l'émission des jetons — voir [autorisation](./authorization.md).
|
|
292
|
+
4. **Résolution de l'expiration** — `expiresInDays` explicite, sinon le défaut de config, `null` =
|
|
293
|
+
sans expiration ; une valeur non positive lève un 400 (`#resolveExpiry()`, `apiKeys.ts#ApiKeyService.#resolveExpiry`).
|
|
294
|
+
5. **Plafond anti-abus** — on ne compte que les clés **actives** (ni révoquées ni expirées) via
|
|
295
|
+
`#isActive()` (`apiKeys.ts#ApiKeyService.#isActive`) ; au-delà de `maxPerSubject` → 409 (`apiKeys.ts#ApiKeyService.#maxPerSubject`).
|
|
296
|
+
6. **Génération** — 32 octets aléatoires, `publicPrefix` et `secretHash` dérivés
|
|
297
|
+
(`generateApiKey()`, `apiKeyFormat.ts#generateApiKey`).
|
|
298
|
+
7. **Écriture** — le `record` de `kind:"pat"` posé au store par `store.put()` (`apiKeys.ts#ApiKeyService.createForSubject`).
|
|
299
|
+
8. **Audit** — `apikey.created`, catégorie `token`, avec l'**id public** et les scopes, **jamais le
|
|
300
|
+
secret** (`apiKeys.ts#ApiKeyService.createForSubject`).
|
|
294
301
|
|
|
295
302
|
Le service ne connaît pas le store à la construction : il le résout **paresseusement** du container
|
|
296
|
-
au premier usage (`#resolveStore()`, `apiKeys.ts
|
|
303
|
+
au premier usage (`#resolveStore()`, `apiKeys.ts#ApiKeyService.#resolveStore`) — indépendant de l'ordre de boot. Store
|
|
297
304
|
absent = **503 explicite**, jamais une 500 opaque.
|
|
298
305
|
|
|
299
306
|
### Vérification — `ApiKeyAuthenticator.authenticate()`
|
|
300
307
|
|
|
301
|
-
Le chemin chaud, dans l'ordre exact du code (`ApiKeyAuthenticator.ts
|
|
308
|
+
Le chemin chaud, dans l'ordre exact du code (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate`) — chaque étape est un
|
|
302
309
|
filtre qui coûte plus cher que la précédente :
|
|
303
310
|
|
|
304
|
-
| # | Contrôle | Coût | Ancrage
|
|
305
|
-
| --- | --------------------------------------- | --------------- |
|
|
306
|
-
| 1 | Bearer présent + préfixe | regex | `readBearerHeader()` (`runtime/bearer.ts
|
|
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
|
|
311
|
-
| 6 | Compte actif et non verrouillé | 1 lecture user | `#resolveUserOrReject` (`ApiKeyAuthenticator.ts
|
|
312
|
-
| 7 | `lastUsedAt` (throttlé) | 0 ou 1 écriture | `markUsed` (`ApiKeyAuthenticator.ts
|
|
311
|
+
| # | Contrôle | Coût | Ancrage |
|
|
312
|
+
| --- | --------------------------------------- | --------------- | ------------------------------------------------------------------------------------------ |
|
|
313
|
+
| 1 | Bearer présent + préfixe | regex | `readBearerHeader()` (`runtime/bearer.ts#readBearerHeader`) |
|
|
314
|
+
| 2 | Longueur, charset, **CRC** | CPU local | `parseApiKey` (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate`) |
|
|
315
|
+
| 3 | Lookup par `secretHash` | 1 lecture store | `findByHash` (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate`) |
|
|
316
|
+
| 4 | `kind:"pat"`, non révoquée, non expirée | en mémoire | `ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate` |
|
|
317
|
+
| 5 | Porteur banni ? (`invalidBefore`) | 1 lecture store | `getInvalidBefore` (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate`) |
|
|
318
|
+
| 6 | Compte actif et non verrouillé | 1 lecture user | `#resolveUserOrReject` (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.#resolveUserOrReject`) |
|
|
319
|
+
| 7 | `lastUsedAt` (throttlé) | 0 ou 1 écriture | `markUsed` (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.onSuccess`) |
|
|
313
320
|
|
|
314
321
|
Deux points méritent d'être soulignés parce qu'ils décident du niveau de sécurité réel :
|
|
315
322
|
|
|
@@ -318,20 +325,20 @@ Deux points méritent d'être soulignés parce qu'ils décident du niveau de sé
|
|
|
318
325
|
n'y a pas de cache d'identité. C'est ce qui fait qu'un départ de collaborateur coupe ses clés
|
|
319
326
|
sans avoir à les énumérer.
|
|
320
327
|
- **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
|
|
328
|
+
supprimé : un unique `"Invalid token"` (`INVALID_TOKEN`, `ApiKeyAuthenticator.ts#INVALID_TOKEN`). Un
|
|
322
329
|
attaquant ne peut pas distinguer « cette clé n'existe pas » de « cette clé est révoquée » — c'est
|
|
323
330
|
l'**anti-énumération**, la cause fine part dans l'audit, jamais au client.
|
|
324
331
|
|
|
325
332
|
En cas de succès, le jeton est promu et porte trois attributs consommés en aval : `scopes`,
|
|
326
|
-
`apiKeyId` et `tenantId` (`ApiKeyAuthenticator.ts
|
|
327
|
-
zone est un simple `Bearer` (`challenge()`, `ApiKeyAuthenticator.ts
|
|
333
|
+
`apiKeyId` et `tenantId` (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate`). Le challenge renvoyé sur un 401 de la
|
|
334
|
+
zone est un simple `Bearer` (`challenge()`, `ApiKeyAuthenticator.ts#ApiKeyAuthenticator.challenge`).
|
|
328
335
|
|
|
329
336
|
### Câblage — d'où viennent le préfixe et le throttle
|
|
330
337
|
|
|
331
338
|
L'authenticator n'est jamais instancié à la main : le firewall le construit depuis le registre, en
|
|
332
339
|
lui injectant la config effective — `registerAuthenticatorFactory("apikey")`
|
|
333
|
-
(`authenticatorRegistry.ts
|
|
334
|
-
(`authenticatorRegistry.ts
|
|
340
|
+
(`authenticatorRegistry.ts#registerAuthenticatorFactory`), qui lit `prefix` et `lastUsedThrottleS`
|
|
341
|
+
(`authenticatorRegistry.ts`). Conséquence pratique : changer `apiKeys.prefix` change **à la
|
|
335
342
|
fois** l'émission et la reconnaissance — les anciennes clés ne sont plus reconnues.
|
|
336
343
|
|
|
337
344
|
## Quatre parcours vécus
|
|
@@ -353,13 +360,13 @@ apiKeys: {
|
|
|
353
360
|
**Ce qu'on observe** : `npx nodefony security:user:add ci-bot` (rôle `ROLE_USER` par défaut), login
|
|
354
361
|
en tant que `ci-bot`, puis émission avec `{"scopes":["orders:read"]}`. Demander
|
|
355
362
|
`{"scopes":["orders:write"]}` renvoie **400 `scope not allowed: orders:write`** — refusé à
|
|
356
|
-
l'émission, pas seulement à l'usage (`#normalizeScopes()`, `apiKeys.ts
|
|
363
|
+
l'émission, pas seulement à l'usage (`#normalizeScopes()`, `apiKeys.ts#ApiKeyService.#normalizeScopes`).
|
|
357
364
|
|
|
358
365
|
> [!TIP]
|
|
359
366
|
> Le catalogue `allowedScopes` de la config est un **complément**, pas la source : la console
|
|
360
367
|
> propose aussi les scopes **découverts sur tes routes** (`@RequireScope`) par
|
|
361
|
-
> `collectDeclaredApiScopes()` (`scopeCatalog.ts
|
|
362
|
-
> (`ApiKeyController.ts
|
|
368
|
+
> `collectDeclaredApiScopes()` (`scopeCatalog.ts#collectDeclaredApiScopes`), agrégés dans l'endpoint `capabilities`
|
|
369
|
+
> (`ApiKeyController.ts#ApiKeyController.capabilities`). Un formulaire de création qui ne ment pas.
|
|
363
370
|
|
|
364
371
|
### Faire tourner une clé sans coupure de service
|
|
365
372
|
|
|
@@ -368,7 +375,7 @@ doit y avoir **aucune** fenêtre pendant laquelle le job échoue.
|
|
|
368
375
|
|
|
369
376
|
**Il n'y a pas de bouton « rotate »** — et c'est délibéré : une rotation atomique impliquerait
|
|
370
377
|
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
|
|
378
|
+
**recouvrement**, rendu possible par le plafond `maxPerSubject` (`apiKeys.ts#ApiKeyService.#maxPerSubject`) :
|
|
372
379
|
|
|
373
380
|
1. Émettre une **seconde** clé (même porteur, mêmes scopes, nom `CI deploy v2`).
|
|
374
381
|
2. Déployer le nouveau secret dans le CI.
|
|
@@ -376,7 +383,7 @@ soit deux secrets valides sous le même id (ambigu à auditer), soit une coupure
|
|
|
376
383
|
4. **Puis** révoquer la v1.
|
|
377
384
|
|
|
378
385
|
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
|
|
386
|
+
la fenêtre par défaut est de 60 s (`lastUsedThrottleS`, `config.ts#apiKeysSchema`). Attends donc une minute
|
|
380
387
|
avant de conclure qu'une clé « ne sert plus ».
|
|
381
388
|
|
|
382
389
|
### Révoquer une clé qui a fuité
|
|
@@ -386,27 +393,27 @@ aux autres clés ni au compte.
|
|
|
386
393
|
|
|
387
394
|
Deux chemins, selon qui agit :
|
|
388
395
|
|
|
389
|
-
| Qui | Endpoint | Portée | Ancrage
|
|
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
|
|
396
|
+
| Qui | Endpoint | Portée | Ancrage |
|
|
397
|
+
| ---------- | ------------------------------------------------- | ---------------------- | ------------------------------------------------------------------ |
|
|
398
|
+
| Le porteur | `DELETE /nodefony/security/api/keys/{id}` | **ses** clés seulement | `revokeForSubject()` (`apiKeys.ts#ApiKeyService.revokeForSubject`) |
|
|
399
|
+
| Un admin | `POST /nodefony/security/api/apikeys/{id}/revoke` | n'importe quelle clé | `revokeAnyPat()` (`apiKeys.ts#ApiKeyService.revokeAnyPat`) |
|
|
393
400
|
|
|
394
401
|
**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
|
|
402
|
+
l'authenticator lit `revokedAt` avant toute autre décision (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate`).
|
|
396
403
|
Le banc d'intégration le prouve bout en bout : 200 avant, 401 après
|
|
397
|
-
(`apikey-flow.test.ts
|
|
404
|
+
(`apikey-flow.test.ts`).
|
|
398
405
|
|
|
399
406
|
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
|
|
407
|
+
d'autre, le porteur reçoit un **404 indiscernable**, jamais un 403 (`ApiKeyController.ts#ApiKeyController.revoke`).
|
|
401
408
|
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
|
|
409
|
+
autres. Le banc couvre explicitement cet IDOR (`apikey-flow.test.ts`).
|
|
403
410
|
|
|
404
411
|
> [!WARNING]
|
|
405
412
|
> Si tu dois couper **toutes** les clés d'un porteur d'un coup (compte compromis, départ), ne les
|
|
406
413
|
> révoque pas une par une : pose le seuil `invalidBefore` du porteur
|
|
407
|
-
> (`revokeAllForSubject`, `ITokenStore.ts
|
|
414
|
+
> (`revokeAllForSubject`, `ITokenStore.ts#ITokenStore.revokeAllForSubject`). L'authenticator rejette alors toute clé créée avant
|
|
408
415
|
> ce seuil : `getInvalidBefore` est comparé au `createdAt` du record
|
|
409
|
-
> (`ApiKeyAuthenticator.ts
|
|
416
|
+
> (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate`) — y compris pour les clés que tu aurais oubliées.
|
|
410
417
|
|
|
411
418
|
### Auditer qui a utilisé quoi
|
|
412
419
|
|
|
@@ -416,26 +423,26 @@ servi et qui les a révoquées.
|
|
|
416
423
|
Trois sources, et il faut connaître les limites de chacune :
|
|
417
424
|
|
|
418
425
|
1. **L'état** — le listing d'administration paginé, tous porteurs confondus : `GET
|
|
419
|
-
/nodefony/security/api/apikeys` (`SecurityAdminApi.ts
|
|
420
|
-
(`apiKeys.ts
|
|
421
|
-
`order=champ:ASC` (`parseTokenListQuery()`, `SecurityAdminApi.ts
|
|
422
|
-
(`KEYS_MAX_LIMIT`, `SecurityAdminApi.ts
|
|
426
|
+
/nodefony/security/api/apikeys` (`SecurityAdminApi.ts#createSecurityAdminApi`), servi par `listPagePat()`
|
|
427
|
+
(`apiKeys.ts#ApiKeyService.listPagePat`). Filtres `subjectId`, `revoked`, fenêtre `limit`/`offset`/`cursor` et tri
|
|
428
|
+
`order=champ:ASC` (`parseTokenListQuery()`, `SecurityAdminApi.ts#parseTokenListQuery`), plafonnée à 200 entrées
|
|
429
|
+
(`KEYS_MAX_LIMIT`, `SecurityAdminApi.ts#KEYS_MAX_LIMIT`).
|
|
423
430
|
|
|
424
431
|
Le tri n'est accepté que sur les champs que le backend branché **déclare** savoir trier
|
|
425
|
-
(`sortableFields()`, `apiKeys.ts
|
|
426
|
-
`subjectId`, `id` sur mémoire/SQL/Mongo (`TOKEN_SORTABLE_FIELDS`, `tokenSort.ts
|
|
432
|
+
(`sortableFields()`, `apiKeys.ts#ApiKeyService.sortableFields` → `ITokenStore.sortableFields`) : `createdAt`, `name`,
|
|
433
|
+
`subjectId`, `id` sur mémoire/SQL/Mongo (`TOKEN_SORTABLE_FIELDS`, `tokenSort.ts#TOKEN_SORTABLE_FIELDS`). Tout autre
|
|
427
434
|
champ est refusé en **400** — jamais accepté puis ignoré. Un backend Redis ne déclare rien (son
|
|
428
435
|
`SCAN` n'a pas d'ordre global) : tout `order` y est donc refusé, ce qui est la vérité de ce
|
|
429
436
|
store. Les champs _nullables_ (`lastUsedAt`, `expiresAt`, `revokedAt`) sont volontairement hors
|
|
430
437
|
du vocabulaire : le placement des valeurs absentes diffère d'un moteur à l'autre, et un tri dont
|
|
431
438
|
l'ordre dépend de la base configurée ne vaut pas mieux qu'un tri absent.
|
|
432
439
|
|
|
433
|
-
2. **Le journal** — les événements d'audit `apikey.created` (`apiKeys.ts
|
|
434
|
-
(`apiKeys.ts
|
|
440
|
+
2. **Le journal** — les événements d'audit `apikey.created` (`apiKeys.ts#ApiKeyService.createForSubject`) et `apikey.revoked`
|
|
441
|
+
(`apiKeys.ts#ApiKeyService.revokeAnyPat` côté admin, `apiKeys.ts#ApiKeyService.revokeForSubject` côté porteur), catégorie `token`. La révocation
|
|
435
442
|
admin trace **l'acteur ET le porteur cible** — voir [audit](./audit.md).
|
|
436
443
|
3. **Le dernier usage** — `lastUsedAt`, avec l'IP et l'agent de ce dernier appel
|
|
437
|
-
(`lastUsedIp`, `lastUsedUserAgent`, `ITokenStore.ts
|
|
438
|
-
(`ApiKeyAuthenticator.ts
|
|
444
|
+
(`lastUsedIp`, `lastUsedUserAgent`, `ITokenStore.ts#IAccessTokenRecord.lastUsedUserAgent`), écrits ensemble par `markUsed`
|
|
445
|
+
(`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.onSuccess`). L'IP est lue par l'accesseur du contexte, qui tient compte des
|
|
439
446
|
proxys de confiance ; si elle ne peut pas être lue, le champ reste vide sans faire échouer
|
|
440
447
|
l'authentification.
|
|
441
448
|
|
|
@@ -446,17 +453,17 @@ applicatif qu'il faut alimenter, pas le store de jetons.
|
|
|
446
453
|
|
|
447
454
|
## ⚙️ Configuration
|
|
448
455
|
|
|
449
|
-
Table dérivée du schéma Zod `apiKeysSchema` (`config.ts
|
|
450
|
-
module (`config.ts
|
|
456
|
+
Table dérivée du schéma Zod `apiKeysSchema` (`config.ts#apiKeysSchema`), branché à la racine de la config du
|
|
457
|
+
module (`config.ts#trustedIssuerSchema`). Toutes les valeurs ci-dessous sont les **défauts réels**.
|
|
451
458
|
|
|
452
|
-
| Option | Type | Défaut | Effet
|
|
453
|
-
| ------------------- | ---------------- | ------ |
|
|
454
|
-
| `enabled` | boolean | `true` | Coupe l'émission ET le listing (l'authenticator reste déclarable) (`config.ts
|
|
455
|
-
| `prefix` | string ≤ 12 | `"nf"` | Marque des clés ; minuscules/chiffres — discrimine du JWT (`config.ts
|
|
456
|
-
| `defaultExpiryDays` | number \| null | `90` | Expiration appliquée si l'appelant n'en donne pas ; `null` = jamais (`config.ts
|
|
457
|
-
| `lastUsedThrottleS` | number (s) | `60` | Coalescence d'écriture de `lastUsedAt` ; `0` = à chaque usage (`config.ts
|
|
458
|
-
| `maxPerSubject` | number > 0 | `100` | Plafond de clés **actives** par porteur ; au-delà → 409 (`config.ts
|
|
459
|
-
| `allowedScopes` | string[] \| null | `null` | Catalogue fermé à la création ; `null` = tout scope non vide accepté (`config.ts
|
|
459
|
+
| Option | Type | Défaut | Effet |
|
|
460
|
+
| ------------------- | ---------------- | ------ | --------------------------------------------------------------------------------------------------- |
|
|
461
|
+
| `enabled` | boolean | `true` | Coupe l'émission ET le listing (l'authenticator reste déclarable) (`config.ts#tokenExchangeSchema`) |
|
|
462
|
+
| `prefix` | string ≤ 12 | `"nf"` | Marque des clés ; minuscules/chiffres — discrimine du JWT (`config.ts#apiKeysSchema`) |
|
|
463
|
+
| `defaultExpiryDays` | number \| null | `90` | Expiration appliquée si l'appelant n'en donne pas ; `null` = jamais (`config.ts#apiKeysSchema`) |
|
|
464
|
+
| `lastUsedThrottleS` | number (s) | `60` | Coalescence d'écriture de `lastUsedAt` ; `0` = à chaque usage (`config.ts#apiKeysSchema`) |
|
|
465
|
+
| `maxPerSubject` | number > 0 | `100` | Plafond de clés **actives** par porteur ; au-delà → 409 (`config.ts#apiKeysSchema`) |
|
|
466
|
+
| `allowedScopes` | string[] \| null | `null` | Catalogue fermé à la création ; `null` = tout scope non vide accepté (`config.ts#apiKeysSchema`) |
|
|
460
467
|
|
|
461
468
|
Deux réglages méritent une décision consciente :
|
|
462
469
|
|
|
@@ -471,30 +478,30 @@ Deux réglages méritent une décision consciente :
|
|
|
471
478
|
### Les endpoints — deux portées, jamais mélangées
|
|
472
479
|
|
|
473
480
|
**Console « mes clés »** (le porteur gère les siennes) — montées par `mountApiKeyRoutes()`
|
|
474
|
-
(`ApiKeyController.ts
|
|
481
|
+
(`ApiKeyController.ts#mountApiKeyRoutes`) **seulement si** le service `apiKeys` existe (`framework/index.ts#Framework.onKernelReady`) ;
|
|
475
482
|
sinon 404, zéro surface. Aucune n'est `bypassFirewall` : la zone data plane exige la session BFF.
|
|
476
483
|
|
|
477
|
-
| Méthode | Chemin | Rôle | Ancrage
|
|
478
|
-
| -------- | ------------------------------------------ | ------------------------------------------------- |
|
|
479
|
-
| `POST` | `/nodefony/security/api/keys` | Émission → **201** + `token` clair (1×) | `create()` (`ApiKeyController.ts
|
|
480
|
-
| `GET` | `/nodefony/security/api/keys` | Mes clés, sans secret | `list()` (`ApiKeyController.ts
|
|
481
|
-
| `GET` | `/nodefony/security/api/keys/capabilities` | Plafond, scopes proposés, préfixe, durée | `capabilities()` (`ApiKeyController.ts
|
|
482
|
-
| `DELETE` | `/nodefony/security/api/keys/{id}` | Révoque **ma** clé ; 404 sinon (anti-énumération) | `revoke()` (`ApiKeyController.ts
|
|
484
|
+
| Méthode | Chemin | Rôle | Ancrage |
|
|
485
|
+
| -------- | ------------------------------------------ | ------------------------------------------------- | ---------------------------------------------------------------------- |
|
|
486
|
+
| `POST` | `/nodefony/security/api/keys` | Émission → **201** + `token` clair (1×) | `create()` (`ApiKeyController.ts#ApiKeyController.create`) |
|
|
487
|
+
| `GET` | `/nodefony/security/api/keys` | Mes clés, sans secret | `list()` (`ApiKeyController.ts#ApiKeyController.list`) |
|
|
488
|
+
| `GET` | `/nodefony/security/api/keys/capabilities` | Plafond, scopes proposés, préfixe, durée | `capabilities()` (`ApiKeyController.ts#ApiKeyController.capabilities`) |
|
|
489
|
+
| `DELETE` | `/nodefony/security/api/keys/{id}` | Révoque **ma** clé ; 404 sinon (anti-énumération) | `revoke()` (`ApiKeyController.ts#ApiKeyController.revoke`) |
|
|
483
490
|
|
|
484
491
|
**Administration** (gouvernance, réponse à incident) — data plane `SecurityAdminApi`, RBAC
|
|
485
492
|
`ROLE_NODEFONY_ADMIN` :
|
|
486
493
|
|
|
487
|
-
| Méthode | Chemin | Rôle | Ancrage
|
|
488
|
-
| ------- | -------------------------------------------- | ---------------------------------------- |
|
|
489
|
-
| `GET` | `/nodefony/security/api/apikeys` | Toutes les clés, **paginé au store** | `SecurityAdminApi.ts
|
|
490
|
-
| `GET` | `/nodefony/security/api/apikeys/status` | « Où on écrit » : classe réelle + driver | `SecurityAdminApi.ts
|
|
491
|
-
| `POST` | `/nodefony/security/api/apikeys/{id}/revoke` | Révoque n'importe quelle clé, audité | `SecurityAdminApi.ts
|
|
494
|
+
| Méthode | Chemin | Rôle | Ancrage |
|
|
495
|
+
| ------- | -------------------------------------------- | ---------------------------------------- | -------------------------------------------- |
|
|
496
|
+
| `GET` | `/nodefony/security/api/apikeys` | Toutes les clés, **paginé au store** | `SecurityAdminApi.ts#createSecurityAdminApi` |
|
|
497
|
+
| `GET` | `/nodefony/security/api/apikeys/status` | « Où on écrit » : classe réelle + driver | `SecurityAdminApi.ts#createSecurityAdminApi` |
|
|
498
|
+
| `POST` | `/nodefony/security/api/apikeys/{id}/revoke` | Révoque n'importe quelle clé, audité | `SecurityAdminApi.ts#createSecurityAdminApi` |
|
|
492
499
|
|
|
493
500
|
Les deux espaces de chemins sont **disjoints** (`keys` vs `apikeys`) — aucune collision, et une
|
|
494
501
|
console d'admin ne peut pas atterrir par erreur sur l'endpoint personnel.
|
|
495
502
|
|
|
496
|
-
Codes d'erreur mappés par duck-typing sur `code` (`#renderApiKeyError()`, `ApiKeyController.ts
|
|
497
|
-
**400** validation (nom, scope, durée), **409** plafond atteint, **503** clés indisponibles (store
|
|
503
|
+
Codes d'erreur mappés par duck-typing sur `code` (`#renderApiKeyError()`, `ApiKeyController.ts#ApiKeyController.#renderApiKeyError`) :
|
|
504
|
+
**400** validation (nom, scope, durée), **403** scope que le porteur ne peut pas obtenir, **409** plafond atteint, **503** clés indisponibles (store
|
|
498
505
|
absent ou `enabled:false`).
|
|
499
506
|
|
|
500
507
|
### Ce qu'on importe côté application
|
|
@@ -516,9 +523,9 @@ import type {
|
|
|
516
523
|
} from "@nodefony/security";
|
|
517
524
|
```
|
|
518
525
|
|
|
519
|
-
Les contrats vivent dans `IApiKey.ts` : `IApiKeyView` (`IApiKey.ts
|
|
520
|
-
(`IApiKey.ts
|
|
521
|
-
(`IApiKey.ts
|
|
526
|
+
Les contrats vivent dans `IApiKey.ts` : `IApiKeyView` (`IApiKey.ts#IApiKeyView`), `IApiKeyCreated`
|
|
527
|
+
(`IApiKey.ts#IApiKeyCreated`), `IApiKeyCapabilities` (`IApiKey.ts#IApiKeyCapabilities`), `ICreateApiKeyOptions`
|
|
528
|
+
(`IApiKey.ts#ICreateApiKeyOptions`). Les signatures détaillées vivent dans le graphe TSDoc (`.ai/symbols.json`) —
|
|
522
529
|
cette page explique l'usage, elle ne recopie pas les prototypes.
|
|
523
530
|
|
|
524
531
|
## 🧑⚖️ Scopes — ce que la clé a le droit de faire
|
|
@@ -530,10 +537,10 @@ Deux axes se combinent, et les confondre est l'erreur la plus fréquente :
|
|
|
530
537
|
|
|
531
538
|
Une clé ne peut donc **jamais** dépasser son porteur : elle en est une restriction, pas une
|
|
532
539
|
extension. Concrètement, `@RequireScope("orders:read")` sur une action est tranché par le
|
|
533
|
-
`ScopeVoter`, dont la règle est asymétrique (`ScopeVoter.ts
|
|
540
|
+
`ScopeVoter`, dont la règle est asymétrique (`ScopeVoter.ts#ScopeVoter`) :
|
|
534
541
|
|
|
535
542
|
- un jeton **humain** (session, mot de passe, anonyme) n'est jamais bridé par un scope — la liste
|
|
536
|
-
`NON_SCOPABLE_TOKEN_TYPES` (`ScopeVoter.ts
|
|
543
|
+
`NON_SCOPABLE_TOKEN_TYPES` (`ScopeVoter.ts#NON_SCOPABLE_TOKEN_TYPES`) le fait passer ; ce sont ses **rôles** qui décident ;
|
|
537
544
|
- un jeton **machine délégué** (`apikey`, `jwt`, `oauth2`) doit porter le scope **exact**, sinon
|
|
538
545
|
refus par défaut du jury.
|
|
539
546
|
|
|
@@ -542,24 +549,24 @@ Le détail du jury (voters, veto, hiérarchie de rôles) est sur la page
|
|
|
542
549
|
|
|
543
550
|
## Persistance — un store partagé avec les jetons
|
|
544
551
|
|
|
545
|
-
Une clé d'API **n'a pas de table à elle**. Elle est un `IAccessTokenRecord` (`ITokenStore.ts
|
|
546
|
-
de `kind:"pat"` (`ITokenStore.ts
|
|
552
|
+
Une clé d'API **n'a pas de table à elle**. Elle est un `IAccessTokenRecord` (`ITokenStore.ts#IAccessTokenRecord`)
|
|
553
|
+
de `kind:"pat"` (`ITokenStore.ts#IAccessTokenRecord.kind`), dans la même table que les refresh tokens — les champs sans
|
|
547
554
|
objet pour un PAT (`family`, `replacedBy`, `audience`) valent `null`.
|
|
548
555
|
|
|
549
556
|
Les champs qui portent le sens **pour une clé d'API** :
|
|
550
557
|
|
|
551
|
-
| Champ | Rôle pour un PAT | Ancrage
|
|
552
|
-
| ------------ | ------------------------------------------------------------- |
|
|
553
|
-
| `kind` | `"pat"` — discrimine du refresh dans la même table | `ITokenStore.ts
|
|
554
|
-
| `name` | Libellé humain (« CI deploy ») — ce qu'on lit dans la console | `ITokenStore.ts
|
|
555
|
-
| `prefix` | Préfixe public `nf_a1b2c3d4` (jamais le secret) | `ITokenStore.ts
|
|
556
|
-
| `subjectId` | Porteur — **référence logique**, pas une clé étrangère SQL | `ITokenStore.ts
|
|
557
|
-
| `scopes` | Capacités de la clé (lues par le `ScopeVoter`) | `ITokenStore.ts
|
|
558
|
-
| `secretHash` | `sha256` du token entier — clé de `findByHash` | `ITokenStore.ts
|
|
559
|
-
| `hashAlg` | `"sha256"` — agilité crypto pour une migration future | `ITokenStore.ts
|
|
560
|
-
| `expiresAt` | Expiration ou `null` (clé longue durée) | `ITokenStore.ts
|
|
561
|
-
| `lastUsedAt` | Dernier usage, écrit **throttlé** | `ITokenStore.ts
|
|
562
|
-
| `revokedAt` | Révocation — le contrôle n°1 de l'authenticator | `ITokenStore.ts
|
|
558
|
+
| Champ | Rôle pour un PAT | Ancrage |
|
|
559
|
+
| ------------ | ------------------------------------------------------------- | ---------------------------------------------- |
|
|
560
|
+
| `kind` | `"pat"` — discrimine du refresh dans la même table | `ITokenStore.ts#IAccessTokenRecord.kind` |
|
|
561
|
+
| `name` | Libellé humain (« CI deploy ») — ce qu'on lit dans la console | `ITokenStore.ts#IAccessTokenRecord.name` |
|
|
562
|
+
| `prefix` | Préfixe public `nf_a1b2c3d4` (jamais le secret) | `ITokenStore.ts#IAccessTokenRecord.prefix` |
|
|
563
|
+
| `subjectId` | Porteur — **référence logique**, pas une clé étrangère SQL | `ITokenStore.ts#IAccessTokenRecord.subjectId` |
|
|
564
|
+
| `scopes` | Capacités de la clé (lues par le `ScopeVoter`) | `ITokenStore.ts#IAccessTokenRecord.scopes` |
|
|
565
|
+
| `secretHash` | `sha256` du token entier — clé de `findByHash` | `ITokenStore.ts#ITokenStore.findByHash` |
|
|
566
|
+
| `hashAlg` | `"sha256"` — agilité crypto pour une migration future | `ITokenStore.ts#IAccessTokenRecord.hashAlg` |
|
|
567
|
+
| `expiresAt` | Expiration ou `null` (clé longue durée) | `ITokenStore.ts#IAccessTokenRecord.expiresAt` |
|
|
568
|
+
| `lastUsedAt` | Dernier usage, écrit **throttlé** | `ITokenStore.ts#IAccessTokenRecord.lastUsedAt` |
|
|
569
|
+
| `revokedAt` | Révocation — le contrôle n°1 de l'authenticator | `ITokenStore.ts#IAccessTokenRecord.revokedAt` |
|
|
563
570
|
|
|
564
571
|
> [!NOTE]
|
|
565
572
|
> Les **colonnes et types par dialecte** ne sont pas dupliqués ici : le propriétaire du schéma est
|
|
@@ -586,38 +593,38 @@ révocation ne traverse pas. Le détail de la résolution, des avertissements et
|
|
|
586
593
|
|
|
587
594
|
## 📜 Normes appliquées
|
|
588
595
|
|
|
589
|
-
| Domaine | Norme | Ancrage
|
|
590
|
-
| --------------------------------- | -------------------------------------- |
|
|
591
|
-
| Schéma `Bearer` (transport) | RFC 6750 §2.1 | `readBearerHeader()` (`runtime/bearer.ts
|
|
592
|
-
| `invalid_token` → 401 + challenge | RFC 6750 §3.1 · RFC
|
|
593
|
-
| Secret **jamais** stocké en clair | OWASP ASVS (secret storage) | `hashApiKey()` (`apiKeyFormat.ts
|
|
594
|
-
| Secret montré une seule fois | Pratique « shown once » | `IApiKeyCreated.token` (`IApiKey.ts
|
|
595
|
-
| Anti-énumération des ressources | OWASP API1:2023 (BOLA/IDOR) | 404 indiscernable (`ApiKeyController.ts
|
|
596
|
-
| Message d'échec uniforme | OWASP API2:2023 (Broken Auth) | `INVALID_TOKEN` (`ApiKeyAuthenticator.ts
|
|
597
|
-
| Révocation immédiate côté serveur | OWASP API2:2023 | `revokedAt` vérifié (`ApiKeyAuthenticator.ts
|
|
598
|
-
| Entropie du secret (≥ 128 bits) | NIST SP 800-63B | 32 octets aléatoires (`apiKeyFormat.ts
|
|
599
|
-
| Plafond de ressources par acteur | OWASP API4:2023 (Resource Consumption) | `maxPerSubject` (`apiKeys.ts
|
|
596
|
+
| Domaine | Norme | Ancrage |
|
|
597
|
+
| --------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------- |
|
|
598
|
+
| Schéma `Bearer` (transport) | RFC 6750 §2.1 | `readBearerHeader()` (`runtime/bearer.ts#readBearerHeader`) |
|
|
599
|
+
| `invalid_token` → 401 + challenge | RFC 6750 §3.1 · RFC 9110 §11 | `challenge()` (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.challenge`) |
|
|
600
|
+
| Secret **jamais** stocké en clair | OWASP ASVS (secret storage) | `hashApiKey()` (`apiKeyFormat.ts#hashApiKey`) |
|
|
601
|
+
| Secret montré une seule fois | Pratique « shown once » | `IApiKeyCreated.token` (`IApiKey.ts#IApiKeyCreated.token`) |
|
|
602
|
+
| Anti-énumération des ressources | OWASP API1:2023 (BOLA/IDOR) | 404 indiscernable (`ApiKeyController.ts#ApiKeyController.revoke`) |
|
|
603
|
+
| Message d'échec uniforme | OWASP API2:2023 (Broken Auth) | `INVALID_TOKEN` (`ApiKeyAuthenticator.ts#INVALID_TOKEN`) |
|
|
604
|
+
| Révocation immédiate côté serveur | OWASP API2:2023 | `revokedAt` vérifié (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate`) |
|
|
605
|
+
| Entropie du secret (≥ 128 bits) | NIST SP 800-63B | 32 octets aléatoires (`apiKeyFormat.ts#SECRET_BYTES`) |
|
|
606
|
+
| Plafond de ressources par acteur | OWASP API4:2023 (Resource Consumption) | `maxPerSubject` (`apiKeys.ts#ApiKeyService.#maxPerSubject`) |
|
|
600
607
|
|
|
601
608
|
## ⚡ Performance & mémoire
|
|
602
609
|
|
|
603
610
|
Le coût par requête authentifiée par clé est **maîtrisé par construction**, dans cet ordre :
|
|
604
611
|
|
|
605
612
|
- **Le filtre le moins cher d'abord.** `supports()` ne fait qu'un `startsWith`
|
|
606
|
-
(`ApiKeyAuthenticator.ts
|
|
607
|
-
(`apiKeyFormat.ts
|
|
613
|
+
(`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.constructor`) ; le parsing complet (CRC inclus) est purement local
|
|
614
|
+
(`apiKeyFormat.ts#parseApiKey`). Une valeur invalide ne coûte **aucun** I/O.
|
|
608
615
|
- **Table CRC précalculée une fois** au chargement du module, jamais par appel
|
|
609
|
-
(`CRC_TABLE`, `apiKeyFormat.ts
|
|
616
|
+
(`CRC_TABLE`, `apiKeyFormat.ts#CRC_TABLE`).
|
|
610
617
|
- **`lastUsedAt` throttlé** — sans cette coalescence, chaque requête d'API deviendrait une
|
|
611
|
-
**écriture** en base. Fenêtre par défaut 60 s (`ApiKeyAuthenticator.ts
|
|
618
|
+
**écriture** en base. Fenêtre par défaut 60 s (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate`) ; `0` rétablit
|
|
612
619
|
l'écriture systématique, à ne choisir qu'en connaissance de cause.
|
|
613
620
|
- **Dépendances résolues paresseusement** : store et fournisseur d'utilisateurs sont récupérés du
|
|
614
|
-
container au premier usage et mémoïsés (`ApiKeyAuthenticator.ts
|
|
621
|
+
container au premier usage et mémoïsés (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.onSuccess`, `apiKeys.ts#ApiKeyService.revokeAnyPat`) — le boot
|
|
615
622
|
ne paie rien si aucune clé n'est jamais présentée.
|
|
616
623
|
- **Jamais N enregistrements en RAM** côté administration : le listing est paginé **au store**
|
|
617
|
-
(`listPagePat()`, `apiKeys.ts
|
|
624
|
+
(`listPagePat()`, `apiKeys.ts#ApiKeyService.listPagePat`), fenêtre plafonnée à 200 (`SecurityAdminApi.ts#IApiKeyAdmin`).
|
|
618
625
|
|
|
619
626
|
Le point de vigilance restant : `createForSubject()` compte les clés actives via `findBySubject()`
|
|
620
|
-
(`ITokenStore.ts
|
|
627
|
+
(`ITokenStore.ts#ITokenStore.findBySubject`), qui charge **toutes** les clés du porteur. C'est borné par `maxPerSubject`
|
|
621
628
|
(100 par défaut) et c'est un chemin froid (émission), pas le chemin chaud.
|
|
622
629
|
|
|
623
630
|
## 📡 Observabilité — Studio
|
|
@@ -626,15 +633,15 @@ L'écran **API Keys** (`/nodefony/api-keys`, `studio/frontend/src/routes/ApiKeys
|
|
|
626
633
|
deux portées dans une seule page :
|
|
627
634
|
|
|
628
635
|
- **Mes clés** — création, listing et révocation via le data plane personnel
|
|
629
|
-
(`KEYS_ENDPOINT`, `studio/frontend/src/routes/apikeys/apiKeysModel.ts
|
|
636
|
+
(`KEYS_ENDPOINT`, `studio/frontend/src/routes/apikeys/apiKeysModel.ts#KEYS_ENDPOINT`). Le secret est affiché
|
|
630
637
|
dans la modale de création, une fois.
|
|
631
638
|
- **Administration** — toutes les clés du système, pagination serveur et révocation ciblée
|
|
632
|
-
(`ADMIN_KEYS_ENDPOINT`, `studio/frontend/src/routes/apikeys/apiKeysModel.ts
|
|
639
|
+
(`ADMIN_KEYS_ENDPOINT`, `studio/frontend/src/routes/apikeys/apiKeysModel.ts#ADMIN_KEYS_ENDPOINT`), réservé à
|
|
633
640
|
`ROLE_NODEFONY_ADMIN`.
|
|
634
641
|
- **Badge « où on écrit »** — la classe réelle du store et son driver, lu défensivement pour que la
|
|
635
642
|
console affiche toujours un état honnête (`API_KEYS_STATUS_ENDPOINT`,
|
|
636
|
-
`studio/frontend/src/routes/apikeys/apiKeysModel.ts
|
|
637
|
-
`SecurityAdminApi.ts
|
|
643
|
+
`studio/frontend/src/routes/apikeys/apiKeysModel.ts#API_KEYS_STATUS_ENDPOINT` ; handler `IApiKeysStatus`,
|
|
644
|
+
`SecurityAdminApi.ts#IApiKeysStatus`).
|
|
638
645
|
|
|
639
646
|
Les types du front sont des **miroirs** du contrat serveur — le secret est exclu par construction,
|
|
640
647
|
pas masqué à l'affichage. Voir aussi l'écran **Audit** pour les événements `apikey.created` /
|
|
@@ -642,21 +649,21 @@ pas masqué à l'affichage. Voir aussi l'écran **Audit** pour les événements
|
|
|
642
649
|
|
|
643
650
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
644
651
|
|
|
645
|
-
| Symptôme | Cause (dans le code)
|
|
646
|
-
| ------------------------------------------------------ |
|
|
647
|
-
| 404 sur `/nodefony/security/api/keys` | Routes montées seulement si le service `apiKeys` existe (`framework/index.ts
|
|
648
|
-
| 503 « API keys unavailable » | Store non provisionné (`TokenService` absent/désactivé) (`apiKeys.ts
|
|
649
|
-
| 401 à la création de clé | Ces routes exigent une **session** (pas de `bypassFirewall`)
|
|
650
|
-
| Le token clair est introuvable après coup | Seul `sha256` est stocké — non re-dérivable (`apiKeyFormat.ts
|
|
651
|
-
| 409 « API key limit reached » | Plafond de clés **actives** atteint (`apiKeys.ts
|
|
652
|
-
| 400 « scope not allowed » | Scope hors du catalogue `allowedScopes` (`apiKeys.ts
|
|
653
|
-
| Toutes les clés rejetées après un changement de config | `prefix` modifié → les anciennes ne sont plus reconnues (`authenticatorRegistry.ts
|
|
654
|
-
| Clé valide mais 403 sur la route | Autorisation, pas authentification : scope manquant — `ScopeVoter.vote()` (`ScopeVoter.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
|
|
656
|
-
| 404 en révoquant la clé d'un autre porteur | Anti-énumération volontaire, jamais 403 (`ApiKeyController.ts
|
|
657
|
-
| `lastUsedAt` qui ne bouge pas tout de suite | Écriture throttlée, 60 s par défaut (`ApiKeyAuthenticator.ts
|
|
658
|
-
| Révocation sans effet entre pods | Store `memory` en production (per-pod)
|
|
659
|
-
| Listing d'admin sans `total` sur Redis | Comptage exact refusé (O(N)) — pagination par curseur
|
|
652
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
653
|
+
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- |
|
|
654
|
+
| 404 sur `/nodefony/security/api/keys` | Routes montées seulement si le service `apiKeys` existe (`framework/index.ts#Framework.onKernelReady`) | Charger `@nodefony/security` + `apiKeys.enabled: true` |
|
|
655
|
+
| 503 « API keys unavailable » | Store non provisionné (`TokenService` absent/désactivé) (`apiKeys.ts#ApiKeyService.revokeForSubject`) | Vérifier `jwt`/`tokenStore` — le `TokenService` pose le store |
|
|
656
|
+
| 401 à la création de clé | Ces routes exigent une **session** (pas de `bypassFirewall`) | Se connecter d'abord (`/nodefony/security/api/auth/login`) |
|
|
657
|
+
| Le token clair est introuvable après coup | Seul `sha256` est stocké — non re-dérivable (`apiKeyFormat.ts#hashApiKey`) | Émettre une nouvelle clé, révoquer l'ancienne |
|
|
658
|
+
| 409 « API key limit reached » | Plafond de clés **actives** atteint (`apiKeys.ts#ApiKeyService.createForSubject`) | Révoquer les clés inutilisées ou relever `maxPerSubject` |
|
|
659
|
+
| 400 « scope not allowed » | Scope hors du catalogue `allowedScopes` (`apiKeys.ts#ApiKeyService.#allowedScopes`) | Ajouter le scope au catalogue, ou corriger la demande |
|
|
660
|
+
| Toutes les clés rejetées après un changement de config | `prefix` modifié → les anciennes ne sont plus reconnues (`authenticatorRegistry.ts`) | Garder le `prefix` STABLE après la première émission |
|
|
661
|
+
| Clé valide mais 403 sur la route | Autorisation, pas authentification : scope manquant — `ScopeVoter.vote()` (`ScopeVoter.ts#ScopeVoter.vote`) | Émettre une clé portant le scope exigé par `@RequireScope` |
|
|
662
|
+
| Clé rejetée alors qu'elle n'est ni expirée ni révoquée | Porteur désactivé/verrouillé, ou seuil `invalidBefore` (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate`) | Réactiver le compte, ou réémettre après le bannissement |
|
|
663
|
+
| 404 en révoquant la clé d'un autre porteur | Anti-énumération volontaire, jamais 403 (`ApiKeyController.ts#ApiKeyController.revoke`) | Attendu — passer par l'endpoint d'administration |
|
|
664
|
+
| `lastUsedAt` qui ne bouge pas tout de suite | Écriture throttlée, 60 s par défaut (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate`) | Attendre la fenêtre, ou `lastUsedThrottleS: 0` (coût : 1 écriture/req) |
|
|
665
|
+
| Révocation sans effet entre pods | Store `memory` en production (per-pod) | Store durable partagé — voir [tokens](./tokens.md) |
|
|
666
|
+
| Listing d'admin sans `total` sur Redis | Comptage exact refusé (O(N)) — pagination par curseur | Attendu : capacité réduite annoncée, paginer par `nextCursor` |
|
|
660
667
|
|
|
661
668
|
## 🧪 Tests & couverture
|
|
662
669
|
|
|
@@ -679,7 +686,7 @@ sont dans le banc d'intégration, sur le fil — c'est plus fort, mais elles ne
|
|
|
679
686
|
serveur) ; pas de test de charge ni de mesure mémoire propre à la vérification de clé.
|
|
680
687
|
|
|
681
688
|
Les bancs sur serveur réel se **skippent sans leurs variables d'infra** — et un skip compte comme
|
|
682
|
-
vert : lire le bloc gates (`vitest
|
|
689
|
+
vert : lire le bloc gates (`scripts/test/vitest/gates.ts`, affiché en fin de run) avant de conclure. Skills
|
|
683
690
|
utiles : `nodefony-security-review` (matrice d'attaque), `nodefony-load-test` (charge).
|
|
684
691
|
Couverture : `npm run coverage` dans `@nodefony/security`.
|
|
685
692
|
|