@nodefony/security 10.0.0-beta.3 → 10.0.0-beta.5
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.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/index.js +6 -8
- package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +18 -11
- package/dist/nodefony/command/security-oauth-doctor.js +5 -3
- package/dist/nodefony/command/security-secrets.js +10 -4
- package/dist/nodefony/command/security-user-add.js +9 -3
- package/dist/nodefony/command/security-user-delete.js +5 -3
- package/dist/nodefony/command/security-user-list.js +5 -3
- package/dist/nodefony/command/security-user-password.js +5 -3
- package/dist/nodefony/config/config.js +20 -4
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/contracts/IAccessVoter.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 +1 -2
- 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/keycloak/keycloakAdmin.js +1 -2
- package/dist/nodefony/keycloak/keycloakRealm.js +1 -2
- package/dist/nodefony/keycloak/keycloakRealmInput.js +1 -2
- package/dist/nodefony/keycloak/scaffold.js +3 -3
- package/dist/nodefony/keycloak/security-keycloak-realm.js +5 -3
- package/dist/nodefony/scaffold/contribute.js +1 -2
- 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 +10 -8
- 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 +5 -4
- package/dist/nodefony/service/keycloak.js +1 -2
- package/dist/nodefony/service/oauth2.js +29 -7
- package/dist/nodefony/service/securityHeaders.js +1 -2
- package/dist/nodefony/service/tokenService.js +38 -22
- package/dist/nodefony/service/totp.js +4 -5
- package/dist/nodefony/service/webAuthn.js +4 -5
- package/dist/nodefony/service/webhooks.js +4 -5
- package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
- package/dist/nodefony/src/SecuredArea.js +1 -2
- package/dist/nodefony/src/accountBinding.js +96 -0
- package/dist/nodefony/src/admin/SecurityAdminApi.js +1 -2
- package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
- package/dist/nodefony/src/admin/adminAudit.js +1 -2
- package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
- 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 +8 -113
- package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +42 -4
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +91 -16
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +1 -2
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +3 -17
- 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 +1 -2
- 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 +1 -2
- 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 +1 -2
- package/dist/nodefony/src/oauth/httpJson.js +3 -3
- package/dist/nodefony/src/oauth/metadata.js +5 -16
- package/dist/nodefony/src/oauth/oauth2Client.js +1 -2
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +1 -2
- package/dist/nodefony/src/oauth/providerDiagnosis.js +5 -6
- package/dist/nodefony/src/oauth/providerRoles.js +1 -2
- package/dist/nodefony/src/oauth/providers/github.js +16 -6
- package/dist/nodefony/src/oauth/providers/oidc.js +9 -6
- package/dist/nodefony/src/openToSignup.js +1 -2
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
- package/dist/nodefony/src/realtime/frameAuthorizer.js +2 -3
- package/dist/nodefony/src/rolesWithin.js +57 -0
- package/dist/nodefony/src/sessionAuthentication.js +1 -2
- 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 +14 -5
- package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +6 -3
- 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 +1 -2
- 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 +1 -2
- 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/nodefony/testing/index.js +108 -0
- package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +7 -6
- package/dist/types/nodefony/config/config.d.ts +1 -0
- package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
- 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/oauth2.d.ts +20 -0
- package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +23 -5
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -44
- package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +4 -1
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
- package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
- package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
- package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +2 -2
- 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/sessionIdentity.d.ts +22 -0
- package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
- package/dist/types/nodefony/testing/index.d.ts +51 -0
- package/docs/api-keys.md +181 -174
- package/docs/audit.md +140 -140
- package/docs/authenticators.md +101 -101
- package/docs/authorization.md +116 -69
- package/docs/cors.md +64 -64
- package/docs/csrf.md +74 -71
- package/docs/external-jwt.md +22 -12
- package/docs/firewall.md +85 -82
- package/docs/headers.md +81 -81
- package/docs/index.md +4 -4
- package/docs/keycloak.md +95 -78
- package/docs/login-page.md +23 -23
- package/docs/oauth2.md +157 -146
- package/docs/obtenir-un-jeton.md +116 -26
- package/docs/tokens.md +128 -122
- package/docs/totp.md +136 -136
- package/docs/webauthn.md +153 -153
- package/docs/webhooks.md +136 -124
- package/package.json +19 -13
- package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
package/docs/oauth2.md
CHANGED
|
@@ -33,8 +33,8 @@ source: "src/packages/@nodefony/security/docs/oauth2.md"
|
|
|
33
33
|
> connecté à **ton** application. Nodefony orchestre ce voyage avec la posture **OAuth 2.1**
|
|
34
34
|
> (RFC 9700) : Authorization Code, PKCE, `state` anti-CSRF, `iss` anti-mix-up. Point clé :
|
|
35
35
|
> **aucun jeton n'atteint le navigateur** — le retour produit une **session BFF**, exactement la même
|
|
36
|
-
> qu'un login par mot de passe. Ancré sur `OAuth2Service` (`oauth2.ts
|
|
37
|
-
> `OAuth2Controller` (`OAuth2Controller.ts
|
|
36
|
+
> qu'un login par mot de passe. Ancré sur `OAuth2Service` (`oauth2.ts#OAuth2Service`) et le controller BFF
|
|
37
|
+
> `OAuth2Controller` (`OAuth2Controller.ts#OAuth2Controller`).
|
|
38
38
|
|
|
39
39
|
📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **OAuth2**
|
|
40
40
|
|
|
@@ -111,16 +111,16 @@ qui les ferme ici :
|
|
|
111
111
|
|
|
112
112
|
- **Vol de jeton via XSS** — si un `access_token` transite par le navigateur, tout script injecté
|
|
113
113
|
peut le lire. _Fermé par construction_ : aucun jeton ne sort du serveur, le résultat est un cookie
|
|
114
|
-
de session opaque (`OAuth2Controller.ts
|
|
114
|
+
de session opaque (`OAuth2Controller.ts#OAuth2Controller.constructor`).
|
|
115
115
|
- **Interception du `code`** — un `code` capté (log de proxy, historique, redirection ouverte) est
|
|
116
116
|
échangeable par l'attaquant. _Fermé par **PKCE**_ : l'échange exige le `code_verifier` resté en
|
|
117
|
-
session (`OAuth2Service.createAuthorization()`, `oauth2.ts
|
|
117
|
+
session (`OAuth2Service.createAuthorization()`, `oauth2.ts#OAuth2Service.createAuthorization`).
|
|
118
118
|
- **CSRF de login** — un tiers force ta victime à terminer **son** flux à lui : elle se retrouve
|
|
119
119
|
connectée sur le compte de l'attaquant, qui lit ensuite ce qu'elle y dépose. _Fermé par le `state`_
|
|
120
|
-
comparé au retour (`OAuth2Controller.callback()`, `OAuth2Controller.ts
|
|
120
|
+
comparé au retour (`OAuth2Controller.callback()`, `OAuth2Controller.ts#OAuth2Controller.callback`).
|
|
121
121
|
- **Mix-up d'IdP** — un `code` obtenu chez un fournisseur malveillant est présenté au callback d'un
|
|
122
|
-
fournisseur de confiance. _Fermé par la vérification de l'`iss`_ (`oauth2.ts
|
|
123
|
-
l'exigence « même fournisseur qu'à l'aller » côté controller (`OAuth2Controller.ts
|
|
122
|
+
fournisseur de confiance. _Fermé par la vérification de l'`iss`_ (`oauth2.ts#OAuth2Service.listProviders`) **et** par
|
|
123
|
+
l'exigence « même fournisseur qu'à l'aller » côté controller (`OAuth2Controller.ts#OAuth2Controller`).
|
|
124
124
|
|
|
125
125
|
> [!IMPORTANT]
|
|
126
126
|
> Le fournisseur social te dit **qui** est la personne. Il ne te dit **rien** de ses droits.
|
|
@@ -133,18 +133,18 @@ Trois partis pris, tous vérifiables au code.
|
|
|
133
133
|
|
|
134
134
|
**Le service ne touche ni HTTP ni session.** `OAuth2Service` rend à l'appelant les éléments à
|
|
135
135
|
persister (`url`, `state`, `codeVerifier`) et un simple `{ identifier }` en sortie
|
|
136
|
-
(`IOAuthAuthorization`, `oauth2.ts
|
|
136
|
+
(`IOAuthAuthorization`, `oauth2.ts#IOAuthAuthorization`). Conséquence pratique : la logique OAuth se teste **sans
|
|
137
137
|
serveur**, comme `AuthFlow`. Le transport (cookies, redirections 302) vit dans le controller BFF.
|
|
138
138
|
|
|
139
139
|
**Le login social finit exactement comme un login classique.** Le callback appelle
|
|
140
|
-
`AuthFlow.establishSessionFor()` (`authFlow.ts
|
|
141
|
-
est actif, **régénère l'ID de session** (anti-fixation, `session.regenerateId()`, `authFlow.ts
|
|
140
|
+
`AuthFlow.establishSessionFor()` (`authFlow.ts#AuthFlow.establishSessionFor`), qui re-résout l'identité, vérifie que le compte
|
|
141
|
+
est actif, **régénère l'ID de session** (anti-fixation, `session.regenerateId()`, `authFlow.ts#AuthFlow.logout`)
|
|
142
142
|
et journalise l'événement
|
|
143
143
|
d'audit. Il n'existe **aucun** authenticator `oauth2` dans la chaîne du firewall : après le retour,
|
|
144
144
|
c'est l'authenticator `session` qui identifie chaque requête, comme après un mot de passe.
|
|
145
145
|
|
|
146
146
|
**Coût nul quand on ne s'en sert pas.** Aucune dépendance tierce : le client OAuth 2.0 est écrit
|
|
147
|
-
dans le module (`OAuth2Client`, `oauth2Client.ts
|
|
147
|
+
dans le module (`OAuth2Client`, `oauth2Client.ts#OAuth2Client`), et `jose` — seul recours externe, pour lire les claims de
|
|
148
148
|
l'ID token — est importé **paresseusement**. Les fournisseurs sont construits une fois puis
|
|
149
149
|
mémoïsés (`OAuth2Service.#resolveProvider()`) : c'est là, une seule fois par processus, que les
|
|
150
150
|
points d'entrée d'un émetteur OIDC sont découverts. Un run qui sert (ports ouverts) les construit
|
|
@@ -158,13 +158,13 @@ la clé (`security.oauth2.providers.<nom>.issuer`). Un émetteur **injoignable**
|
|
|
158
158
|
faute de configuration : le boot continue, un WARNING le nomme, et son bouton quitte l'écran de
|
|
159
159
|
connexion — le flux `authorize` reste ouvert. Une nouvelle tentative part au plus toutes les 30 s
|
|
160
160
|
pendant que l'écran est affiché ; le bouton revient dès qu'elle réussit. Les routes sont montées dès
|
|
161
|
-
que `@nodefony/security` est chargé (`framework/index.ts
|
|
161
|
+
que `@nodefony/security` est chargé (`framework/index.ts#Framework.onKernelReady`) : sans social login configuré,
|
|
162
162
|
`…/providers` rend une liste vide, `authorize` rend `404 Unknown provider`, et `authorize`/`callback`
|
|
163
163
|
rendent `503 OAuth unavailable` quand `oauth2.enabled` vaut `false`.
|
|
164
164
|
|
|
165
165
|
Au boot, la config est validée et les fournisseurs configurés sont confrontés au registre : un nom
|
|
166
166
|
inconnu produit un **WARNING, pas un échec fatal** — `OAuth2Service.#build()` confronte les noms
|
|
167
|
-
configurés à `listOAuthProviders()` (`oauth2.ts
|
|
167
|
+
configurés à `listOAuthProviders()` (`oauth2.ts#OAuth2Service`) et le
|
|
168
168
|
reste de l'application démarre, le bouton correspondant n'apparaît simplement pas.
|
|
169
169
|
|
|
170
170
|
## 🚀 Démarrage rapide
|
|
@@ -181,10 +181,41 @@ de login quand la variable manque.
|
|
|
181
181
|
> valeur sous un second nom, qui finirait par diverger. La base des callbacks, elle, n'est émise par
|
|
182
182
|
> personne : c'est un réglage que votre application se donne, donc `NF_OAUTH_REDIRECT_BASE`.
|
|
183
183
|
|
|
184
|
-
```
|
|
184
|
+
```ts ignore
|
|
185
|
+
// nodefony/config/security.ts — le fragment du module, importé par le manifeste
|
|
186
|
+
import type { ConfigContext } from "nodefony";
|
|
187
|
+
import type { ISecurityConfigInput } from "@nodefony/security";
|
|
188
|
+
import type { env } from "../../env";
|
|
189
|
+
|
|
190
|
+
export const securityConfig = (ctx: ConfigContext<typeof env>) =>
|
|
191
|
+
({
|
|
192
|
+
oauth2: {
|
|
193
|
+
// Rôles posés à la CRÉATION du compte local, jamais réécrits ensuite.
|
|
194
|
+
defaultRoles: ["ROLE_USER"],
|
|
195
|
+
allowSignup: true, // false = un compte local déjà lié est exigé
|
|
196
|
+
successRedirect: "/",
|
|
197
|
+
failureRedirect: "/login?error=oauth",
|
|
198
|
+
providers: {
|
|
199
|
+
// Secrets absents → fournisseur non monté, bouton non affiché.
|
|
200
|
+
...(ctx.env.GITHUB_CLIENT_ID && ctx.env.GITHUB_CLIENT_SECRET
|
|
201
|
+
? {
|
|
202
|
+
github: {
|
|
203
|
+
clientId: ctx.env.GITHUB_CLIENT_ID,
|
|
204
|
+
clientSecret: ctx.env.GITHUB_CLIENT_SECRET,
|
|
205
|
+
redirectUri: `${ctx.env.NF_OAUTH_REDIRECT_BASE}/nodefony/security/api/oauth2/github/callback`,
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
: {}),
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
}) satisfies ISecurityConfigInput;
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
```typescript ignore
|
|
185
215
|
// env.ts — SEUL lecteur de process.env (catalogue typé, validé au boot).
|
|
186
216
|
// nodefony.config.ts — `ctx.env` EST ce catalogue (typé par le paramètre générique).
|
|
187
217
|
import { defineConfig, defineEnv, envString, use } from "nodefony";
|
|
218
|
+
import { securityConfig } from "./nodefony/config/security";
|
|
188
219
|
|
|
189
220
|
export const env = defineEnv({
|
|
190
221
|
GITHUB_CLIENT_ID: envString({ optional: true }),
|
|
@@ -198,36 +229,16 @@ export default defineConfig<typeof env>((ctx) => ({
|
|
|
198
229
|
modules: [
|
|
199
230
|
"@nodefony/http",
|
|
200
231
|
"@nodefony/framework",
|
|
201
|
-
use("@nodefony/security",
|
|
202
|
-
oauth2: {
|
|
203
|
-
// Rôles posés à la CRÉATION du compte local, jamais réécrits ensuite.
|
|
204
|
-
defaultRoles: ["ROLE_USER"],
|
|
205
|
-
allowSignup: true, // false = un compte local déjà lié est exigé
|
|
206
|
-
successRedirect: "/",
|
|
207
|
-
failureRedirect: "/login?error=oauth",
|
|
208
|
-
providers: {
|
|
209
|
-
// Secrets absents → fournisseur non monté, bouton non affiché.
|
|
210
|
-
...(ctx.env.GITHUB_CLIENT_ID && ctx.env.GITHUB_CLIENT_SECRET
|
|
211
|
-
? {
|
|
212
|
-
github: {
|
|
213
|
-
clientId: ctx.env.GITHUB_CLIENT_ID,
|
|
214
|
-
clientSecret: ctx.env.GITHUB_CLIENT_SECRET,
|
|
215
|
-
redirectUri: `${ctx.env.NF_OAUTH_REDIRECT_BASE}/nodefony/security/api/oauth2/github/callback`,
|
|
216
|
-
},
|
|
217
|
-
}
|
|
218
|
-
: {}),
|
|
219
|
-
},
|
|
220
|
-
},
|
|
221
|
-
}),
|
|
232
|
+
use("@nodefony/security", securityConfig(ctx)),
|
|
222
233
|
],
|
|
223
234
|
}));
|
|
224
235
|
```
|
|
225
236
|
|
|
226
237
|
### Les routes sont FOURNIES — tu n'écris aucun controller
|
|
227
238
|
|
|
228
|
-
`mountOAuth2Routes()` (`OAuth2Controller.ts
|
|
229
|
-
`/nodefony/security/api/oauth2` (`OAuth2Controller.ts
|
|
230
|
-
enregistré, c'est-à-dire dès que `@nodefony/security` est chargé (`framework/index.ts
|
|
239
|
+
`mountOAuth2Routes()` (`OAuth2Controller.ts#mountOAuth2Routes`) monte quatre routes sous
|
|
240
|
+
`/nodefony/security/api/oauth2` (`OAuth2Controller.ts#OAuth2Controller.backchannelLogout`) dès que le service `oauth2` est
|
|
241
|
+
enregistré, c'est-à-dire dès que `@nodefony/security` est chargé (`framework/index.ts#Framework.onKernelReady`) :
|
|
231
242
|
|
|
232
243
|
| Route | Rôle |
|
|
233
244
|
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -245,7 +256,7 @@ Ton écran de login n'a donc qu'un lien à poser :
|
|
|
245
256
|
```
|
|
246
257
|
|
|
247
258
|
> [!WARNING]
|
|
248
|
-
> Ces routes portent `bypassFirewall: true` (`OAuth2Controller.ts
|
|
259
|
+
> Ces routes portent `bypassFirewall: true` (`OAuth2Controller.ts#mountOAuth2Routes`) — elles **sont** le mécanisme
|
|
249
260
|
> d'authentification : l'utilisateur est anonyme pendant tout l'aller-retour. Les protéger créerait
|
|
250
261
|
> un interblocage (il faudrait être connecté pour pouvoir se connecter). La session anonyme ne porte
|
|
251
262
|
> que `state`/`code_verifier`, et son ID est **régénéré** à la promotion. Le canal arrière, lui,
|
|
@@ -282,40 +293,40 @@ Séquence identique prouvée de bout en bout sur serveur réel par `oauth2-flow.
|
|
|
282
293
|
|
|
283
294
|
### Étape 1 — `createAuthorization(provider)`
|
|
284
295
|
|
|
285
|
-
`OAuth2Service.createAuthorization()` (`oauth2.ts
|
|
296
|
+
`OAuth2Service.createAuthorization()` (`oauth2.ts#OAuth2Service.createAuthorization`) fabrique trois choses :
|
|
286
297
|
|
|
287
298
|
1. un **`state`** aléatoire (anti-CSRF) ;
|
|
288
299
|
2. un **`code_verifier`** — **seulement si** le fournisseur pratique PKCE (`usesPkce`,
|
|
289
|
-
`oauth2.ts
|
|
300
|
+
`oauth2.ts#OAuth2Service.getRedirects`) ; `null` sinon (GitHub) ;
|
|
290
301
|
3. l'**URL d'autorisation** construite par l'adaptateur du fournisseur, avec les scopes effectifs
|
|
291
|
-
(ceux de la config, sinon les scopes par défaut du fournisseur, `oauth2.ts
|
|
302
|
+
(ceux de la config, sinon les scopes par défaut du fournisseur, `oauth2.ts#OAuth2Service.rememberLogout`).
|
|
292
303
|
|
|
293
304
|
Le controller pose les trois valeurs en session, **persiste** (`session.save()` — pas seulement en
|
|
294
|
-
mémoire, `OAuth2Controller.ts
|
|
305
|
+
mémoire, `OAuth2Controller.ts#IOAuth2Session.save`), puis redirige en 302.
|
|
295
306
|
|
|
296
307
|
### Étape 2 — le retour, validé avant tout appel réseau
|
|
297
308
|
|
|
298
|
-
`OAuth2Controller.callback()` (`OAuth2Controller.ts
|
|
309
|
+
`OAuth2Controller.callback()` (`OAuth2Controller.ts#OAuth2Controller.callback`) travaille dans cet ordre, et l'ordre est la
|
|
299
310
|
défense :
|
|
300
311
|
|
|
301
|
-
1. **lire l'état de session, puis l'invalider immédiatement** (`OAuth2Controller.ts
|
|
312
|
+
1. **lire l'état de session, puis l'invalider immédiatement** (`OAuth2Controller.ts#oauth2FailureTarget`) — le
|
|
302
313
|
`state` est à **usage unique** : un rejeu du même retour échoue, même avec le bon cookie ;
|
|
303
314
|
2. **comparer** : `code` et `state` présents, `state` reçu ≡ `state` attendu, **et** fournisseur du
|
|
304
|
-
callback ≡ fournisseur démarré (`OAuth2Controller.ts
|
|
315
|
+
callback ≡ fournisseur démarré (`OAuth2Controller.ts#OAuth2Controller.authorize`). Un seul écart → `302` vers
|
|
305
316
|
l'écran d'échec avec `reason=failed`, **sans jamais contacter le fournisseur** ;
|
|
306
317
|
3. seulement ensuite, `exchangeAndProvision()`.
|
|
307
318
|
|
|
308
319
|
### Étape 3 — `exchangeAndProvision(provider, code, verifier, iss)`
|
|
309
320
|
|
|
310
|
-
`OAuth2Service.exchangeAndProvision()` (`oauth2.ts
|
|
321
|
+
`OAuth2Service.exchangeAndProvision()` (`oauth2.ts#OAuth2Service.exchangeAndProvision`) enchaîne :
|
|
311
322
|
|
|
312
323
|
1. **anti-mix-up** — si le fournisseur annonce un émetteur attendu, l'`iss` reçu doit correspondre,
|
|
313
|
-
et un `iss` **absent** est un rejet, pas une tolérance (`oauth2.ts
|
|
324
|
+
et un `iss` **absent** est un rejet, pas une tolérance (`oauth2.ts#OAuth2Service.listProviders`) ;
|
|
314
325
|
2. **échange** du `code` sur le canal serveur, avec le `code_verifier`
|
|
315
|
-
(`validateAuthorizationCode`, `oauth2.ts
|
|
316
|
-
`oauth2.ts
|
|
326
|
+
(`validateAuthorizationCode`, `oauth2.ts#OAuth2Service.createAuthorization`), puis lecture du profil (`fetchProfile`,
|
|
327
|
+
`oauth2.ts#OAuth2Service.exchangeAndProvision`) ;
|
|
317
328
|
3. **provisionnement** du Shadow User avec la politique effective — rôles par défaut surchargeables
|
|
318
|
-
**par fournisseur** (`oauth2.ts
|
|
329
|
+
**par fournisseur** (`oauth2.ts#OAuth2Service.listDisplayProviders`), `allowSignup` global (`oauth2.ts#OAuth2Service.exchangeAndProvision`).
|
|
319
330
|
|
|
320
331
|
Toute erreur de cette étape est convertie en **échec uniforme** par le controller (`302` vers
|
|
321
332
|
l'écran d'échec avec `reason=failed`) : le client ne distingue pas un `iss` invalide d'un
|
|
@@ -349,23 +360,23 @@ Nodefony ne « connecte pas un compte Google ». Il crée et retrouve une **lign
|
|
|
349
360
|
compte externe : le _Shadow User_. C'est cette ligne qui porte l'identifiant, les rôles, l'état
|
|
350
361
|
actif/verrouillé — donc **tout** ce dont l'autorisation a besoin.
|
|
351
362
|
|
|
352
|
-
Le contrat s'appelle `IOAuthUserProvisioner` (`IOAuthUserProvisioner.ts
|
|
353
|
-
défaut est `UserService.provisionOAuthUser()` (`UserService.ts
|
|
363
|
+
Le contrat s'appelle `IOAuthUserProvisioner` (`IOAuthUserProvisioner.ts#IOAuthUserProvisioner`) ; l'implémentation par
|
|
364
|
+
défaut est `UserService.provisionOAuthUser()` (`UserService.ts#UserService.provisionOAuthUser`), en **find-or-create** :
|
|
354
365
|
|
|
355
|
-
| Situation au retour du fournisseur | Comportement
|
|
356
|
-
| ---------------------------------------- |
|
|
357
|
-
| Lien social déjà connu | Le compte existant est rendu tel quel — rien n'est créé, rien n'est réécrit.
|
|
358
|
-
| Lien inconnu, `allowSignup: true` | Création JIT : `password: null`, rôles = `defaultRoles`, lien social persisté.
|
|
359
|
-
| Lien inconnu, `allowSignup: false` | **Échec fail-closed** (`UserService.ts
|
|
360
|
-
| E-mail identique à un compte local | **Aucune liaison automatique** — un compte SÉPARÉ est créé.
|
|
361
|
-
| Même `providerId` chez deux fournisseurs | Comptes séparés (le couple `provider` + `providerId` fait la clé).
|
|
366
|
+
| Situation au retour du fournisseur | Comportement |
|
|
367
|
+
| ---------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
368
|
+
| Lien social déjà connu | Le compte existant est rendu tel quel — rien n'est créé, rien n'est réécrit. |
|
|
369
|
+
| Lien inconnu, `allowSignup: true` | Création JIT : `password: null`, rôles = `defaultRoles`, lien social persisté. |
|
|
370
|
+
| Lien inconnu, `allowSignup: false` | **Échec fail-closed** (`UserService.ts#UserService.provisionOAuthUser`) — un compte lié est exigé. |
|
|
371
|
+
| E-mail identique à un compte local | **Aucune liaison automatique** — un compte SÉPARÉ est créé. |
|
|
372
|
+
| Même `providerId` chez deux fournisseurs | Comptes séparés (le couple `provider` + `providerId` fait la clé). |
|
|
362
373
|
|
|
363
374
|
⚠️ **`allowSignup: true` (le défaut) + une zone sans `roles` = une zone ouverte à tout compte du
|
|
364
375
|
fournisseur.** Une zone qui n'exige aucun rôle laisse passer toute identité authentifiée ; avec la
|
|
365
376
|
création à la volée, cela veut dire quiconque obtient un compte chez Google ou GitHub. Le pare-feu le
|
|
366
377
|
signale au démarrage (`WARNING`), dans la console (`openToSignup` de la zone) et dans
|
|
367
378
|
`nodefony doctor --live` — c'est un constat, pas une faute (`findZonesOpenToSignup`,
|
|
368
|
-
`openToSignup.ts
|
|
379
|
+
`openToSignup.ts#findZonesOpenToSignup`). Deux gestes le soldent : `roles: ["ROLE_USER"]` sur la zone, qui ne change
|
|
369
380
|
rien au comportement mais **écrit** l'intention, ou `allowSignup: false`, qui exige un compte
|
|
370
381
|
préexistant.
|
|
371
382
|
|
|
@@ -375,21 +386,21 @@ C'est le point le plus contre-intuitif, et c'est une décision de sécurité. Si
|
|
|
375
386
|
l'e-mail vaut `admin@ton-domaine.fr` liait automatiquement l'administrateur local, il suffirait de
|
|
376
387
|
créer un compte chez un fournisseur laxiste avec cette adresse pour **prendre le compte admin**. La
|
|
377
388
|
liaison par e-mail est donc refusée y compris quand le fournisseur certifie l'adresse
|
|
378
|
-
(`emailVerified` reste informatif, `IOAuthUserProvisioner.ts
|
|
389
|
+
(`emailVerified` reste informatif, `IOAuthUserProvisioner.ts#IOAuthProfile.emailVerified`).
|
|
379
390
|
|
|
380
391
|
Conséquence assumée : l'utilisateur qui avait un mot de passe et clique « avec GitHub » obtient un
|
|
381
392
|
**second** compte. Le rattachement d'un compte externe à un compte existant est une action explicite,
|
|
382
393
|
faite **utilisateur déjà connecté** — jamais un effet de bord du login.
|
|
383
394
|
|
|
384
395
|
L'identifiant du compte créé dérive de l'e-mail si le fournisseur en donne un, sinon d'une clé
|
|
385
|
-
préfixée `provider:providerId` — jamais de collision entre fournisseurs (`UserService.ts
|
|
396
|
+
préfixée `provider:providerId` — jamais de collision entre fournisseurs (`UserService.ts#UserService.provisionOAuthUser`).
|
|
386
397
|
|
|
387
398
|
### Les rôles sont posés à la création, et plus jamais
|
|
388
399
|
|
|
389
|
-
`defaultRoles` s'applique **au moment du `create`** (`UserService.ts
|
|
400
|
+
`defaultRoles` s'applique **au moment du `create`** (`UserService.ts#UserService.create`). Un second login
|
|
390
401
|
n'écrase rien : promouvoir quelqu'un dans ta base reste effectif, et modifier `defaultRoles` en
|
|
391
402
|
config ne repeint pas les comptes existants. C'est la traduction de la règle « OAuth =
|
|
392
|
-
authentification, pas autorisation » (`oauth2.ts
|
|
403
|
+
authentification, pas autorisation » (`oauth2.ts#OAuth2Service.listDisplayProviders`, `config.ts#loginPageSchema`).
|
|
393
404
|
|
|
394
405
|
> [!TIP]
|
|
395
406
|
> Un fournisseur social ne doit **jamais** figurer dans le chemin d'obtention d'un rôle privilégié.
|
|
@@ -399,18 +410,18 @@ authentification, pas autorisation » (`oauth2.ts:414-418`, `config.ts:1065-1070
|
|
|
399
410
|
### Brancher sa propre politique
|
|
400
411
|
|
|
401
412
|
Le provisioner est le service `users` **s'il implémente la capability**, détecté par duck-typing
|
|
402
|
-
(`OAuth2Service.#resolveProvisioner()`, `oauth2.ts
|
|
413
|
+
(`OAuth2Service.#resolveProvisioner()`, `oauth2.ts#OAuth2Service.#resolveProvisioner`). S'il ne l'implémente pas, le login
|
|
403
414
|
**échoue** — jamais de création silencieuse par défaut. Une application qui veut sa propre politique
|
|
404
415
|
(quota d'inscriptions, allowlist de domaines e-mail, rattachement à un tenant) implémente
|
|
405
416
|
`provisionOAuthUser()` sur son service `users` : le profil normalisé `IOAuthProfile`
|
|
406
|
-
(`IOAuthUserProvisioner.ts
|
|
417
|
+
(`IOAuthUserProvisioner.ts#IOAuthProfile`) lui donne `provider`, `providerId`, `email`, `emailVerified`, `name`
|
|
407
418
|
et la charge brute `raw`.
|
|
408
419
|
|
|
409
420
|
## 🧩 Fournisseurs — catalogue et extension
|
|
410
421
|
|
|
411
|
-
Un fournisseur est un adaptateur qui implémente `IOAuthProvider` (`IOAuthProvider.ts
|
|
422
|
+
Un fournisseur est un adaptateur qui implémente `IOAuthProvider` (`IOAuthProvider.ts#IOAuthProvider`) : il masque
|
|
412
423
|
les divergences (PKCE ou non, profil par ID token ou par appel d'API) derrière un contrat unique.
|
|
413
|
-
Quatre sont livrés, résolus par nom via le registre `oauthProviderRegistry.ts
|
|
424
|
+
Quatre sont livrés, résolus par nom via le registre `oauthProviderRegistry.ts#OAuthProviderFactory`.
|
|
414
425
|
|
|
415
426
|
| Nom | Famille | PKCE | `iss` vérifié | Profil lu depuis | Scopes par défaut |
|
|
416
427
|
| ---------- | ---------------- | :--: | --------------------- | ----------------- | ---------------------------- |
|
|
@@ -421,13 +432,13 @@ Quatre sont livrés, résolus par nom via le registre `oauthProviderRegistry.ts:
|
|
|
421
432
|
|
|
422
433
|
### `google` — OIDC, le cas nominal
|
|
423
434
|
|
|
424
|
-
Construit par le helper générique `createOidcProvider()` (`oidc.ts
|
|
425
|
-
(`usesPkce: true`, `oidc.ts
|
|
426
|
-
(`oauthProviderRegistry.ts
|
|
435
|
+
Construit par le helper générique `createOidcProvider()` (`oidc.ts#createOidcProvider`) : PKCE systématique
|
|
436
|
+
(`usesPkce: true`, `oidc.ts#readLogoutTokenClaims`), émetteur figé `https://accounts.google.com`
|
|
437
|
+
(`oauthProviderRegistry.ts#withKeycloakThemeHint`). Ses points d'entrée ne sont **pas** écrits en dur : ils sont
|
|
427
438
|
demandés à l'émetteur (RFC 8414, cf. « Découverte » plus bas). Le profil se lit dans l'**ID token** —
|
|
428
|
-
claims standard `sub`, `email`, `email_verified`, `name` (`oidc.ts
|
|
439
|
+
claims standard `sub`, `email`, `email_verified`, `name` (`oidc.ts#IOidcProviderOptions.name`), après les contrôles
|
|
429
440
|
obligatoires d'OpenID Connect Core §3.1.3.7 : `iss`, `aud`, `exp`, et un `sub` non vide
|
|
430
|
-
(`assertIdTokenClaims()`, `oidc.ts
|
|
441
|
+
(`assertIdTokenClaims()`, `oidc.ts#assertIdTokenClaims`). Pas d'identifiant stable, pas d'identité.
|
|
431
442
|
|
|
432
443
|
### `keycloak` — OIDC self-hosted, l'émetteur vient de ta config
|
|
433
444
|
|
|
@@ -439,7 +450,7 @@ Créer le realm et le client, lire l'émetteur, réussir le premier login : la p
|
|
|
439
450
|
|
|
440
451
|
### `oidc` — n'importe quel serveur OpenID Connect
|
|
441
452
|
|
|
442
|
-
La même mécanique, sans nom de marque : l'entrée `oidc` (`oauthProviderRegistry.ts
|
|
453
|
+
La même mécanique, sans nom de marque : l'entrée `oidc` (`oauthProviderRegistry.ts#withKeycloakThemeHint`) prend
|
|
443
454
|
l'émetteur de sa configuration et n'a besoin de rien d'autre. C'est elle qui rend inutile une classe
|
|
444
455
|
par fournisseur.
|
|
445
456
|
|
|
@@ -464,7 +475,7 @@ anti-mix-up **principale** est ailleurs — chaque fournisseur a son URL de redi
|
|
|
464
475
|
(`…/{provider}/callback`) et le flux vérifie que le fournisseur de retour est celui qui a démarré,
|
|
465
476
|
ce que la RFC 9700 §4.4.2.2 donne comme la protection de référence. `iss` est la seconde ceinture.
|
|
466
477
|
|
|
467
|
-
La politique est portée par le fournisseur (`issuerPolicy`, `IOAuthProvider.ts
|
|
478
|
+
La politique est portée par le fournisseur (`issuerPolicy`, `IOAuthProvider.ts#IOAuthProvider.issuerPolicy`) et remplie par
|
|
468
479
|
la découverte ; elle vaut `null` pour un fournisseur non-OIDC, qui ne relève pas de cette défense.
|
|
469
480
|
|
|
470
481
|
### Découverte des points d'entrée (RFC 8414)
|
|
@@ -476,7 +487,7 @@ points d'entrée sont demandés à l'émetteur, une seule fois par processus, au
|
|
|
476
487
|
bien connues (§3.1 : insertion oauth → insertion oidc → ajout oidc) et l'égalité stricte du §3.3
|
|
477
488
|
vivent dans le cœur (`nodefony` → `src/oauth/authorizationServer.ts`), qui s'en sert aussi pour
|
|
478
489
|
PUBLIER nos propres métadonnées. `metadata.ts` n'ajoute que le transport : requête bornée, sans
|
|
479
|
-
redirection suivie, avec un délai d'attente (`discoverAuthorizationServer()`, `metadata.ts
|
|
490
|
+
redirection suivie, avec un délai d'attente (`discoverAuthorizationServer()`, `metadata.ts#discoverAuthorizationServer`).
|
|
480
491
|
|
|
481
492
|
Deux refus valent d'être connus. Un document dont l'`issuer` diffère de celui demandé est rejeté
|
|
482
493
|
**sans se rabattre** sur l'URL suivante — se rabattre masquerait un document hostile derrière un 404.
|
|
@@ -492,10 +503,10 @@ donnerait l'illusion de PKCE.
|
|
|
492
503
|
### `github` — OAuth simple, l'archétype non-OIDC
|
|
493
504
|
|
|
494
505
|
Pas de PKCE, pas d'ID token, pas d'`iss` (`usesPkce: false`, `issuerPolicy: null`,
|
|
495
|
-
`github.ts
|
|
496
|
-
de l'API REST `/user` (`createGithubProvider()`, `github.ts
|
|
506
|
+
`github.ts#normalizeGithubError`) : ici, la défense anti-CSRF repose **entièrement** sur le `state`. Le profil vient
|
|
507
|
+
de l'API REST `/user` (`createGithubProvider()`, `github.ts#createGithubProvider`). Subtilité GitHub : l'e-mail
|
|
497
508
|
primaire est souvent privé — l'adaptateur bascule alors sur `/user/emails` et n'accepte
|
|
498
|
-
`emailVerified` que si GitHub le certifie (`github.ts
|
|
509
|
+
`emailVerified` que si GitHub le certifie (`github.ts#createGithubProvider`).
|
|
499
510
|
|
|
500
511
|
### Enregistrer le sien — sans éditer le cœur
|
|
501
512
|
|
|
@@ -521,7 +532,7 @@ registerOAuthProvider(
|
|
|
521
532
|
);
|
|
522
533
|
```
|
|
523
534
|
|
|
524
|
-
La fabrique reçoit `IOAuthProviderContext` (`oauthProviderRegistry.ts
|
|
535
|
+
La fabrique reçoit `IOAuthProviderContext` (`oauthProviderRegistry.ts#IOAuthProviderContext`) : les secrets et l'URL de
|
|
525
536
|
callback issus de la config, rien d'autre. Elle peut être **asynchrone** — découvrir un émetteur est
|
|
526
537
|
une opération de construction, faite une fois par processus.
|
|
527
538
|
|
|
@@ -530,35 +541,35 @@ protocole vient de `OAuth2Client`, la fabrique ne fait que lire le profil. C'est
|
|
|
530
541
|
lignes — `github.ts` en est le modèle.
|
|
531
542
|
|
|
532
543
|
Un fournisseur qui n'est pas OIDC (pas d'ID token, profil lu à son API) s'écrit comme GitHub
|
|
533
|
-
(`createGithubProvider()`, `github.ts
|
|
544
|
+
(`createGithubProvider()`, `github.ts#createGithubProvider`) : `OAuth2Client` porte le protocole, la fabrique ne fait
|
|
534
545
|
que le mapping du profil. Exemple sans réseau dans le dépôt :
|
|
535
546
|
`src/modules/test/nodefony/secure/oauthTestProvider.ts`.
|
|
536
547
|
|
|
537
548
|
## ⚙️ Configuration
|
|
538
549
|
|
|
539
|
-
Schéma Zod `oauth2Schema` (`config.ts
|
|
540
|
-
(`config.ts
|
|
550
|
+
Schéma Zod `oauth2Schema` (`config.ts#oauth2Schema`), branché sur la section `oauth2` de la config du module
|
|
551
|
+
(`config.ts#securityConfigSchema`). Table dérivée du schéma — les défauts sont ceux du code.
|
|
541
552
|
|
|
542
553
|
| Option | Type | Défaut | Effet |
|
|
543
554
|
| ----------------- | -------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
544
555
|
| `enabled` | booléen | `true` | Coupe le social login : `authorize`/`callback` rendent 503. |
|
|
545
|
-
| `defaultRoles` | liste de rôles | `["ROLE_USER"]` | Rôles du Shadow User **à la création** (`config.ts
|
|
546
|
-
| `allowSignup` | booléen | `true` | `false` = compte préexistant lié exigé (`config.ts
|
|
556
|
+
| `defaultRoles` | liste de rôles | `["ROLE_USER"]` | Rôles du Shadow User **à la création** (`config.ts#oauthProviderSchema`). |
|
|
557
|
+
| `allowSignup` | booléen | `true` | `false` = compte préexistant lié exigé (`config.ts#oauth2Schema`). |
|
|
547
558
|
| `successRedirect` | chemin | `/` | Où revient l'utilisateur après succès, quand la page de départ n'a pas donné `from`. |
|
|
548
559
|
| `failureRedirect` | chemin | `loginPage.path` (`/login`) | Où il revient après échec, quand la page de départ n'a pas donné `back`. Omis = la page de connexion, qu'il suit si elle change de chemin. |
|
|
549
|
-
| `providers` | dictionnaire par nom | `{}` | Fournisseurs activés (`config.ts
|
|
560
|
+
| `providers` | dictionnaire par nom | `{}` | Fournisseurs activés (`config.ts#oauth2Schema`). |
|
|
550
561
|
|
|
551
|
-
Par fournisseur (`oauthProviderSchema`, `config.ts
|
|
562
|
+
Par fournisseur (`oauthProviderSchema`, `config.ts#oauthProviderSchema`) :
|
|
552
563
|
|
|
553
564
|
<!-- prettier-ignore -->
|
|
554
565
|
| Option | Requis | Effet |
|
|
555
566
|
| --- | :---: | --- |
|
|
556
567
|
| `clientId` / `clientSecret` | ✅ | Identifiants délivrés par l'IdP. Secrets : par `env.ts`, jamais journalisés. |
|
|
557
|
-
| `redirectUri` | ✅ | URL de callback **exacte** (`config.ts
|
|
568
|
+
| `redirectUri` | ✅ | URL de callback **exacte** (`config.ts#oauthProviderSchema`). |
|
|
558
569
|
| `issuer` | OIDC self-hosted | Realm Keycloak ; ignoré par les IdP à endpoints fixes. |
|
|
559
570
|
| `clientAuthMethod` | | Comment le client s'authentifie au point de jeton (RFC 6749 §2.3). Omis = `client_secret_basic`, ce que la RFC demande de préférer. Poser `client_secret_post` quand le serveur l'EXIGE — il le publie dans `token_endpoint_auth_methods_supported`. |
|
|
560
571
|
| `scopes` | | Vide = scopes par défaut du fournisseur. |
|
|
561
|
-
| `successRedirect` / `failureRedirect` / `defaultRoles` | | Surchargent le global **pour ce fournisseur** (`oauth2.ts
|
|
572
|
+
| `successRedirect` / `failureRedirect` / `defaultRoles` | | Surchargent le global **pour ce fournisseur** (`oauth2.ts#OAuth2Service.exchangeAndProvision`). |
|
|
562
573
|
| `postLogoutRedirectUri` | | Adresse **absolue** où le fournisseur renvoie le navigateur après la déconnexion, enregistrée chez lui. Omis = la page de connexion (`failureRedirect` sans sa query) résolue contre `redirectUri`. Sans effet pour un fournisseur qui ne publie pas de point de déconnexion. |
|
|
563
574
|
|
|
564
575
|
Les surcharges par fournisseur permettent la cohabitation : un IdP de recette garde ses redirections
|
|
@@ -569,10 +580,10 @@ et ses rôles pendant qu'un IdP de production pointe ailleurs.
|
|
|
569
580
|
### Les jetons du fournisseur ne sont pas conservés
|
|
570
581
|
|
|
571
582
|
C'est un choix, et il a des conséquences à connaître. Les jetons obtenus à l'échange vivent dans la
|
|
572
|
-
portée locale de l'échange (`validateAuthorizationCode` puis `fetchProfile`, `oauth2.ts
|
|
583
|
+
portée locale de l'échange (`validateAuthorizationCode` puis `fetchProfile`, `oauth2.ts#OAuth2Service.exchangeAndProvision`) :
|
|
573
584
|
ils ne sont ni retournés, ni mis en
|
|
574
585
|
session, ni persistés. Le profil normalisé qui traverse le système n'en contient aucun
|
|
575
|
-
(`IOAuthUserProvisioner.ts
|
|
586
|
+
(`IOAuthUserProvisioner.ts#IOAuthProfile`).
|
|
576
587
|
|
|
577
588
|
- **Conséquence 1** — la surface d'exposition est minimale : pas de coffre de jetons à protéger, pas
|
|
578
589
|
de fuite possible par la base ni par la session.
|
|
@@ -580,7 +591,7 @@ session, ni persistés. Le profil normalisé qui traverse le système n'en conti
|
|
|
580
591
|
l'utilisateur plus tard (lire ses dépôts, envoyer un mail). Nodefony fait de l'**authentification**,
|
|
581
592
|
pas de la **délégation d'accès**.
|
|
582
593
|
- **Si tu as besoin de cette délégation** : le seul endroit où les jetons sont visibles est le
|
|
583
|
-
`fetchProfile()` de ton adaptateur (`IOAuthProvider.ts
|
|
594
|
+
`fetchProfile()` de ton adaptateur (`IOAuthProvider.ts#IOAuthProvider.fetchProfile`) — c'est là que ton implémentation les
|
|
584
595
|
capture et les persiste, sous ta responsabilité (chiffrement au repos, rotation, révocation).
|
|
585
596
|
|
|
586
597
|
**Une exception, et une seule : l'ID token d'un fournisseur qui sait déconnecter.** Quand le
|
|
@@ -597,7 +608,7 @@ retenu.
|
|
|
597
608
|
|
|
598
609
|
Pour que le fournisseur puisse, à l'inverse, fermer lui-même ces sessions, le login retient aussi
|
|
599
610
|
un **index** — fournisseur et `sid`, **sans** l'ID token — dans les métadonnées de la session
|
|
600
|
-
(`oauth2.ts
|
|
611
|
+
(`oauth2.ts#IFederatedLogoutHint.sid`). Il est là parce que l'énumération des sessions efface les attributs, dans tous
|
|
601
612
|
les stores : c'est la seule partie que le canal arrière peut lire en parcourant le parc. L'écran
|
|
602
613
|
**Sessions** ne l'affiche pas (résumé construit par liste blanche).
|
|
603
614
|
|
|
@@ -606,21 +617,21 @@ les stores : c'est la seule partie que le canal arrière peut lire en parcourant
|
|
|
606
617
|
Quand l'utilisateur se déconnecte **ailleurs** — la console d'administration de Keycloak, une autre
|
|
607
618
|
application du même SSO —, le fournisseur appelle l'application : `POST
|
|
608
619
|
…/oauth2/{provider}/backchannel-logout`, avec un paramètre `logout_token` (OpenID Connect
|
|
609
|
-
Back-Channel Logout 1.0). Le contrôleur (`OAuth2Controller.ts
|
|
610
|
-
(`OAuth2Service.backchannelLogout()`, `oauth2.ts
|
|
620
|
+
Back-Channel Logout 1.0). Le contrôleur (`OAuth2Controller.ts#OAuth2Controller.callback`) délègue au service
|
|
621
|
+
(`OAuth2Service.backchannelLogout()`, `oauth2.ts#OAuth2Service.backchannelLogout`) :
|
|
611
622
|
|
|
612
623
|
1. **La signature d'abord.** Le jeton est vérifié par `RemoteJwtVerifier` (`verifyClaims`,
|
|
613
|
-
`RemoteJwtVerifier.ts
|
|
624
|
+
`RemoteJwtVerifier.ts#RemoteJwtVerifier.verifyClaims`), avec les clés publiques du fournisseur découvertes à son `jwks_uri` :
|
|
614
625
|
émetteur exact, audience = le `clientId`, `iat`, `exp` et `jti` exigés
|
|
615
|
-
(`oidc.ts
|
|
616
|
-
ceux à secret partagé (`oidc.ts
|
|
626
|
+
(`oidc.ts#createDiscoveredOidcProvider`). Les algorithmes sont ceux des ID tokens annoncés par le fournisseur, **sans**
|
|
627
|
+
ceux à secret partagé (`oidc.ts#createDiscoveredOidcProvider`) — Keycloak annonce `HS256`, qui ferait d'une clé publique un
|
|
617
628
|
secret de signature.
|
|
618
|
-
2. **Le sens ensuite** (`readLogoutTokenClaims()`, `oidc.ts
|
|
629
|
+
2. **Le sens ensuite** (`readLogoutTokenClaims()`, `oidc.ts#readLogoutTokenClaims`) : `sub` ou `sid` présent ; `events`
|
|
619
630
|
qui le déclare jeton de déconnexion ; **aucun** `nonce`. C'est ce qui empêche un ID token, signé
|
|
620
631
|
par la même clé, de passer pour un jeton de déconnexion.
|
|
621
|
-
3. **Pas de rejeu** : un `jti` déjà vu est refusé (`oauth2.ts
|
|
632
|
+
3. **Pas de rejeu** : un `jti` déjà vu est refusé (`oauth2.ts#OAuth2Service.backchannelLogout`).
|
|
622
633
|
4. **Les sessions désignées** sont détruites par `SessionsService.destroyWhere()`
|
|
623
|
-
(`sessions-service.ts
|
|
634
|
+
(`sessions-service.ts#SessionsService.destroyWhere`) : celles ouvertes par **ce** fournisseur et qui portent le `sid` du
|
|
624
635
|
jeton ; sans `sid`, toutes celles du compte lié au `sub`. Une session ouverte par mot de passe ou
|
|
625
636
|
par un autre fournisseur n'est **jamais** touchée : le fournisseur ne ferme que ce qu'il a ouvert.
|
|
626
637
|
|
|
@@ -630,7 +641,7 @@ injoignable), la cause n'allant qu'au journal ; `404` fournisseur inconnu ; `501
|
|
|
630
641
|
n'émet pas de jeton de déconnexion (GitHub). Toujours `Cache-Control: no-store`.
|
|
631
642
|
|
|
632
643
|
Les **WebSockets** portées par une session détruite se ferment au tick de revalidation du hub temps
|
|
633
|
-
réel, au plus 30 secondes plus tard (`RealtimeHub.ts
|
|
644
|
+
réel, au plus 30 secondes plus tard (`RealtimeHub.ts#channelFamily`), code `4001`.
|
|
634
645
|
|
|
635
646
|
> [!NOTE]
|
|
636
647
|
> L'anti-rejeu vit **dans le processus**. Plusieurs exemplaires : un jeton rejoué sur un autre
|
|
@@ -654,32 +665,32 @@ ou détruire les sessions), pas chez le fournisseur.
|
|
|
654
665
|
|
|
655
666
|
| Vecteur | Défense | Preuve |
|
|
656
667
|
| ----------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------- |
|
|
657
|
-
| Rejeu du retour (même `code`, même `state`) | `state` consommé + session régénérée à la promotion | `oauth2-attack.test.ts
|
|
658
|
-
| `state` valide présenté au callback d'un autre IdP | Fournisseur attendu conservé en session et comparé | `oauth2-attack.test.ts
|
|
659
|
-
| `iss` falsifié | Comparaison stricte à l'émetteur de la politique | `oauth2Service.test.ts
|
|
660
|
-
| Prise de compte par e-mail collidant un admin | Aucune liaison auto : compte séparé, admin intact | `oauth.attack.test.ts
|
|
661
|
-
| Élévation de privilège par re-login | Rôles posés à la création, jamais réécrits | `oauth.attack.test.ts
|
|
662
|
-
| Collision d'identifiants entre fournisseurs | Clé = `provider` + `providerId` | `oauth.attack.test.ts
|
|
663
|
-
| Interception du `code` | PKCE : `code_verifier` exigé, refus si absent | `oauthProviders.test.ts
|
|
664
|
-
| Création de compte non voulue | Provisioner absent (`provisionOAuthUser`) → fail-closed | `oauth2Service.test.ts
|
|
668
|
+
| Rejeu du retour (même `code`, même `state`) | `state` consommé + session régénérée à la promotion | `oauth2-attack.test.ts` (S5) |
|
|
669
|
+
| `state` valide présenté au callback d'un autre IdP | Fournisseur attendu conservé en session et comparé | `oauth2-attack.test.ts` (S6) |
|
|
670
|
+
| `iss` falsifié | Comparaison stricte à l'émetteur de la politique | `oauth2Service.test.ts` |
|
|
671
|
+
| Prise de compte par e-mail collidant un admin | Aucune liaison auto : compte séparé, admin intact | `oauth.attack.test.ts` (A1) |
|
|
672
|
+
| Élévation de privilège par re-login | Rôles posés à la création, jamais réécrits | `oauth.attack.test.ts` (A2) |
|
|
673
|
+
| Collision d'identifiants entre fournisseurs | Clé = `provider` + `providerId` | `oauth.attack.test.ts` (A3) |
|
|
674
|
+
| Interception du `code` | PKCE : `code_verifier` exigé, refus si absent | `oauthProviders.test.ts` |
|
|
675
|
+
| Création de compte non voulue | Provisioner absent (`provisionOAuthUser`) → fail-closed | `oauth2Service.test.ts#makeUsers` |
|
|
665
676
|
| Jeton de déconnexion forgé ou signé par une autre clé | Signature vérifiée sur le jeu de clés de l'émetteur | `oauthProviders.test.ts` · `oauth2-keycloak.test.ts` |
|
|
666
677
|
| ID token présenté comme jeton de déconnexion | `events` exigé, `nonce` interdit | `oauthProviders.test.ts` |
|
|
667
678
|
| Rejeu d'un jeton de déconnexion | `jti` déjà vu → refusé | `oauth2Service.test.ts` |
|
|
668
679
|
|
|
669
680
|
## 📜 Normes appliquées
|
|
670
681
|
|
|
671
|
-
| Domaine | Norme | Ancrage
|
|
672
|
-
| ------------------------------------- | -------------------------------------- |
|
|
673
|
-
| Flux Authorization Code | RFC 6749 | `IOAuthProvider.validateAuthorizationCode()` (`IOAuthProvider.ts
|
|
674
|
-
| PKCE | RFC 7636 | `usesPkce` (`IOAuthProvider.ts
|
|
675
|
-
| Sécurité OAuth (BCP 2.1) | RFC 9700 | `OAuth2Service` (`oauth2.ts
|
|
676
|
-
| Anti-mix-up (`iss`) | RFC 9207 | `issuerPolicy` (`IOAuthProvider.ts
|
|
677
|
-
| Callback en correspondance exacte | RFC 9700 §4 | `redirectUri` (`config.ts
|
|
678
|
-
| Claims d'identité OIDC | OpenID Connect Core | `fetchProfile()` du helper OIDC (`oidc.ts
|
|
679
|
-
| ID token consommé en code flow | OIDC Core §3.1.3.7 | `assertIdTokenClaims()` (`oidc.ts
|
|
680
|
-
| Anti-fixation de session | OWASP Session Management | `session.regenerateId()` au login (`authFlow.ts
|
|
681
|
-
| Déconnexion initiée par l'application | OpenID Connect RP-Initiated Logout 1.0 | `createLogoutURL` (`oidc.ts
|
|
682
|
-
| Déconnexion par le fournisseur | OpenID Connect Back-Channel Logout 1.0 | `verifyLogoutToken` (`oidc.ts
|
|
682
|
+
| Domaine | Norme | Ancrage |
|
|
683
|
+
| ------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
684
|
+
| Flux Authorization Code | RFC 6749 | `IOAuthProvider.validateAuthorizationCode()` (`IOAuthProvider.ts#IOAuthProvider.validateAuthorizationCode`) |
|
|
685
|
+
| PKCE | RFC 7636 | `usesPkce` (`IOAuthProvider.ts#IOAuthProvider.usesPkce`) · `oidc.ts#readLogoutTokenClaims` |
|
|
686
|
+
| Sécurité OAuth (BCP 2.1) | RFC 9700 | `OAuth2Service` (`oauth2.ts#OAuth2Service`) · `oauth2Schema` (`config.ts#oauth2Schema`) |
|
|
687
|
+
| Anti-mix-up (`iss`) | RFC 9207 | `issuerPolicy` (`IOAuthProvider.ts#IOAuthProvider.issuerPolicy`) · `oauth2.ts#OAuth2Service.listProviders` |
|
|
688
|
+
| Callback en correspondance exacte | RFC 9700 §4 | `redirectUri` (`config.ts#oauthProviderSchema`) |
|
|
689
|
+
| Claims d'identité OIDC | OpenID Connect Core | `fetchProfile()` du helper OIDC (`oidc.ts#createOidcProvider`) |
|
|
690
|
+
| ID token consommé en code flow | OIDC Core §3.1.3.7 | `assertIdTokenClaims()` (`oidc.ts#assertIdTokenClaims`) |
|
|
691
|
+
| Anti-fixation de session | OWASP Session Management | `session.regenerateId()` au login (`authFlow.ts#AuthFlow.logout`) |
|
|
692
|
+
| Déconnexion initiée par l'application | OpenID Connect RP-Initiated Logout 1.0 | `createLogoutURL` (`oidc.ts#createOidcProvider`) · `end_session_endpoint` découvert (`metadata.ts#discoverAuthorizationServer`) |
|
|
693
|
+
| Déconnexion par le fournisseur | OpenID Connect Back-Channel Logout 1.0 | `verifyLogoutToken` (`oidc.ts#createOidcProvider`) · `backchannelLogout()` (`oauth2.ts#OAuth2Service.backchannelLogout`) |
|
|
683
694
|
|
|
684
695
|
Flux **exclus** par posture 2.1, et donc absents du code : `implicit` (jeton en fragment d'URL) et
|
|
685
696
|
`password` / ROPC (l'application verrait le mot de passe du fournisseur).
|
|
@@ -714,38 +725,38 @@ retirer de la configuration.
|
|
|
714
725
|
technique brut.
|
|
715
726
|
|
|
716
727
|
Côté suivi, chaque login réussi produit un événement d'audit `auth` / `login.success` via
|
|
717
|
-
`AuthFlow.establishSessionFor()` (`authFlow.ts
|
|
728
|
+
`AuthFlow.establishSessionFor()` (`authFlow.ts#AuthFlow.establishSessionFor`), consultable dans l'écran **Audit**. La
|
|
718
729
|
session ouverte apparaît dans l'écran **Sessions** (IP et agent capturés à l'ouverture) ; le compte
|
|
719
730
|
provisionné dans l'écran **Users**, avec ses rôles réels.
|
|
720
731
|
|
|
721
732
|
> [!NOTE]
|
|
722
733
|
> L'événement d'audit du login social porte la raison `oauth` : le controller la passe à
|
|
723
|
-
> `AuthFlow.establishSessionFor()` (`OAuth2Controller.ts
|
|
734
|
+
> `AuthFlow.establishSessionFor()` (`OAuth2Controller.ts#IOAuth2BffFlow.establishSessionFor`), comme WebAuthn passe `webauthn`.
|
|
724
735
|
> `federated` n'est que la valeur par défaut d'un appelant qui n'a pas nommé son facteur
|
|
725
|
-
> (`authFlow.ts
|
|
736
|
+
> (`authFlow.ts#AuthFlow.establishSessionFor`).
|
|
726
737
|
|
|
727
738
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
728
739
|
|
|
729
|
-
| Symptôme | Cause (dans le code)
|
|
730
|
-
| ---------------------------------------------------- |
|
|
731
|
-
| `404` sur `…/oauth2/…` | Module `@nodefony/security` non chargé, ou nom de fournisseur inconnu
|
|
732
|
-
| `503 OAuth unavailable` | `oauth2.enabled: false`, ou boot du service échoué
|
|
733
|
-
| WARNING « inconnu du registre » au boot | Nom configuré sans fabrique (`OAuth2Service.#build()`)
|
|
734
|
-
| `404` « Unknown provider » sur `authorize` | Le nom n'est pas dans `listProviders()` (`OAuth2Controller.ts
|
|
735
|
-
| Bouton absent de l'écran de login | Secrets manquants → fournisseur non monté (spread conditionnel)
|
|
736
|
-
| Bouton absent + WARNING « indisponible » au boot | Émetteur injoignable à la construction (`OAuth2Service.#resolveProvider()`)
|
|
737
|
-
| `redirect_uri_mismatch` chez le fournisseur | `redirectUri` ≠ URL enregistrée, au caractère près (`config.ts
|
|
738
|
-
| Retour systématique sur `failureRedirect` | `state`/`verifier` absents (cookie perdu entre les deux requêtes)
|
|
739
|
-
| Callback échoue au **deuxième** essai | `state` à usage unique, consommé (`OAuth2Controller.ts
|
|
740
|
-
| `OAuth issuer mismatch` | `iss` reçu ≠ l'émetteur attendu (`oauth2.ts
|
|
741
|
-
| Boot refusé : `….providers.<nom>.issuer` | Émetteur absent (fabrique `requiresIssuer`) ou mal formé (`checkProviderIssuer()`)
|
|
742
|
-
| « provisioning indisponible » | `users` n'implémente pas la capability (`oauth2.ts
|
|
743
|
-
| Profil connu refusé | `allowSignup: false` sans lien préexistant (`UserService.ts
|
|
744
|
-
| Doublon de compte pour un utilisateur existant | Aucune liaison auto par e-mail (choix de sécurité)
|
|
745
|
-
| Rôle attendu absent après re-login | Rôles posés à la **création** seulement (`provisionOAuthUser`, `UserService.ts
|
|
746
|
-
| Jeton du fournisseur introuvable côté application | `IOAuthProfile` n'en porte aucun, par choix (`IOAuthUserProvisioner.ts
|
|
747
|
-
| Keycloak : « Some clients have not been logged out » | Canal arrière injoignable, ou client en _Front channel logout_ ON
|
|
748
|
-
| Canal arrière : `0 session(s) fermée(s)` au journal | Session ouverte avant la mise en place de l'index fédéré, ou `sid` différent
|
|
740
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
741
|
+
| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
|
742
|
+
| `404` sur `…/oauth2/…` | Module `@nodefony/security` non chargé, ou nom de fournisseur inconnu | Charger `@nodefony/security` ; vérifier le nom du fournisseur |
|
|
743
|
+
| `503 OAuth unavailable` | `oauth2.enabled: false`, ou boot du service échoué | Activer `oauth2` ; lire le WARNING de boot |
|
|
744
|
+
| WARNING « inconnu du registre » au boot | Nom configuré sans fabrique (`OAuth2Service.#build()`) | `registerOAuthProvider()` au chargement du module, ou builtin |
|
|
745
|
+
| `404` « Unknown provider » sur `authorize` | Le nom n'est pas dans `listProviders()` (`OAuth2Controller.ts#IOAuth2Service.listProviders`) | Vérifier le nom exact **et** la présence des secrets |
|
|
746
|
+
| Bouton absent de l'écran de login | Secrets manquants → fournisseur non monté (spread conditionnel) | Renseigner `clientId`/`clientSecret` dans l'env |
|
|
747
|
+
| Bouton absent + WARNING « indisponible » au boot | Émetteur injoignable à la construction (`OAuth2Service.#resolveProvider()`) | Démarrer le serveur d'autorisation ; le bouton revient sous 30 s |
|
|
748
|
+
| `redirect_uri_mismatch` chez le fournisseur | `redirectUri` ≠ URL enregistrée, au caractère près (`config.ts#oauthProviderSchema`) | Aligner schéma, hôte, port et chemin `/…/{provider}/callback` |
|
|
749
|
+
| Retour systématique sur `failureRedirect` | `state`/`verifier` absents (cookie perdu entre les deux requêtes) | Vérifier `SameSite`/domaine du cookie ; un seul hôte en dev |
|
|
750
|
+
| Callback échoue au **deuxième** essai | `state` à usage unique, consommé (`OAuth2Controller.ts#oauth2FailureTarget`) | Refaire le flux depuis `authorize` — comportement attendu |
|
|
751
|
+
| `OAuth issuer mismatch` | `iss` reçu ≠ l'émetteur attendu (`oauth2.ts#OAuth2Service.listProviders`) | Corriger `issuer` (Keycloak : URL exacte du realm) |
|
|
752
|
+
| Boot refusé : `….providers.<nom>.issuer` | Émetteur absent (fabrique `requiresIssuer`) ou mal formé (`checkProviderIssuer()`) | Renseigner l'URL https du realm / de l'émetteur |
|
|
753
|
+
| « provisioning indisponible » | `users` n'implémente pas la capability (`oauth2.ts#OAuth2Service`) | Implémenter `provisionOAuthUser()` sur le service `users` |
|
|
754
|
+
| Profil connu refusé | `allowSignup: false` sans lien préexistant (`UserService.ts#UserService.provisionOAuthUser`) | Activer `allowSignup` ou lier le compte au préalable |
|
|
755
|
+
| Doublon de compte pour un utilisateur existant | Aucune liaison auto par e-mail (choix de sécurité) | Rattacher explicitement, utilisateur connecté |
|
|
756
|
+
| Rôle attendu absent après re-login | Rôles posés à la **création** seulement (`provisionOAuthUser`, `UserService.ts#UserService.provisionOAuthUser`), sauf `roleMapping` | Modifier les rôles en base, ou déclarer `roleMapping` ([Keycloak](keycloak.md)) |
|
|
757
|
+
| Jeton du fournisseur introuvable côté application | `IOAuthProfile` n'en porte aucun, par choix (`IOAuthUserProvisioner.ts#IOAuthProfile`) | Le capturer dans son propre `fetchProfile()` et le stocker soi-même |
|
|
758
|
+
| Keycloak : « Some clients have not been logged out » | Canal arrière injoignable, ou client en _Front channel logout_ ON | Adresse joignable par Keycloak ; _Front channel logout_ OFF |
|
|
759
|
+
| Canal arrière : `0 session(s) fermée(s)` au journal | Session ouverte avant la mise en place de l'index fédéré, ou `sid` différent | Se reconnecter ; vérifier _Backchannel logout session required_ |
|
|
749
760
|
|
|
750
761
|
## 🧪 Tests & couverture
|
|
751
762
|
|