@nodefony/security 10.0.0-beta.1 → 10.0.0-beta.3
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/README.md +7 -5
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +44 -10
- package/dist/nodefony/command/security-oauth-doctor.js +66 -0
- package/dist/nodefony/command/security-secrets.js +42 -25
- package/dist/nodefony/command/security-token.js +46 -15
- package/dist/nodefony/command/security-user-delete.js +5 -4
- package/dist/nodefony/command/security-user-password.js +4 -11
- package/dist/nodefony/config/config.js +80 -5
- package/dist/nodefony/contracts/index.js +1 -2
- package/dist/nodefony/errors/InsecureTransportError.js +24 -0
- package/dist/nodefony/errors/index.js +2 -1
- package/dist/nodefony/keycloak/keycloakAdmin.js +255 -0
- package/dist/nodefony/keycloak/keycloakRealm.js +260 -0
- package/dist/nodefony/keycloak/keycloakRealmInput.js +124 -0
- package/dist/nodefony/keycloak/scaffold.js +231 -0
- package/dist/nodefony/keycloak/security-keycloak-realm.js +128 -0
- package/dist/nodefony/scaffold/contribute.js +20 -0
- package/dist/nodefony/service/authFlow.js +111 -11
- package/dist/nodefony/service/firewall.js +111 -21
- package/dist/nodefony/service/keycloak.js +209 -0
- package/dist/nodefony/service/oauth2.js +379 -26
- package/dist/nodefony/service/securityHeaders.js +12 -1
- package/dist/nodefony/service/tokenService.js +57 -6
- package/dist/nodefony/service/totp.js +51 -6
- package/dist/nodefony/service/webAuthn.js +20 -2
- package/dist/nodefony/src/SecuredArea.js +14 -3
- package/dist/nodefony/src/admin/SecurityAdminApi.js +70 -40
- package/dist/nodefony/src/admin/adminAudit.js +0 -1
- package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +66 -6
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +38 -15
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +1 -0
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +13 -3
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +9 -2
- package/dist/nodefony/src/authenticator/bearer.js +1 -2
- package/dist/nodefony/src/boot/publicRoutesNotice.js +27 -0
- package/dist/nodefony/src/entryPoint.js +39 -0
- package/dist/nodefony/src/oauth/clientCredentials.js +40 -0
- package/dist/nodefony/src/oauth/metadata.js +36 -9
- package/dist/nodefony/src/oauth/oauth2Client.js +60 -33
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +39 -6
- package/dist/nodefony/src/oauth/providerDiagnosis.js +283 -0
- package/dist/nodefony/src/oauth/providerRoles.js +79 -0
- package/dist/nodefony/src/oauth/providers/github.js +32 -6
- package/dist/nodefony/src/oauth/providers/oidc.js +82 -3
- package/dist/nodefony/src/openToSignup.js +47 -0
- package/dist/nodefony/src/sessionAuthentication.js +26 -0
- package/dist/nodefony/src/token/JwtKeystore.js +78 -46
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +40 -13
- package/dist/nodefony/src/token/secretFile.js +59 -2
- package/dist/nodefony/src/transport/CredentialTransportPolicy.js +51 -0
- package/dist/types/index.d.ts +26 -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-token.d.ts +9 -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 +63 -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/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/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 +125 -9
- package/dist/types/nodefony/service/securityHeaders.d.ts +2 -1
- 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/admin/userRevocationCascade.d.ts +54 -12
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +18 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +13 -0
- 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/oauth/clientCredentials.d.ts +54 -0
- 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/sessionAuthentication.d.ts +14 -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/secretFile.d.ts +22 -0
- package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
- package/docs/api-keys.md +18 -18
- package/docs/audit.md +24 -22
- package/docs/authenticators.md +62 -29
- package/docs/authorization.md +16 -16
- package/docs/cors.md +30 -30
- package/docs/csrf.md +35 -33
- package/docs/external-jwt.md +68 -5
- package/docs/firewall.md +78 -45
- package/docs/headers.md +35 -35
- package/docs/index.md +15 -7
- package/docs/keycloak.md +818 -0
- package/docs/lexique.md +15 -15
- package/docs/login-page.md +361 -0
- package/docs/oauth2.md +242 -125
- package/docs/obtenir-un-jeton.md +18 -11
- package/docs/tokens.md +138 -86
- package/docs/totp.md +113 -71
- package/docs/webauthn.md +115 -115
- package/docs/webhooks.md +21 -21
- 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 +17 -15
package/docs/oauth2.md
CHANGED
|
@@ -33,7 +33,7 @@ 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:
|
|
36
|
+
> qu'un login par mot de passe. Ancré sur `OAuth2Service` (`oauth2.ts:280`) et le controller BFF
|
|
37
37
|
> `OAuth2Controller` (`OAuth2Controller.ts:89`).
|
|
38
38
|
|
|
39
39
|
📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **OAuth2**
|
|
@@ -114,12 +114,12 @@ qui les ferme ici :
|
|
|
114
114
|
de session opaque (`OAuth2Controller.ts:192`).
|
|
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:463-474`).
|
|
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:234-263`).
|
|
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:
|
|
122
|
+
fournisseur de confiance. _Fermé par la vérification de l'`iss`_ (`oauth2.ts:402-408`) **et** par
|
|
123
123
|
l'exigence « même fournisseur qu'à l'aller » côté controller (`OAuth2Controller.ts:170`).
|
|
124
124
|
|
|
125
125
|
> [!IMPORTANT]
|
|
@@ -133,27 +133,38 @@ 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:181`). 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:
|
|
140
|
+
`AuthFlow.establishSessionFor()` (`authFlow.ts:254`), qui re-résout l'identité, vérifie que le compte
|
|
141
141
|
est actif, **régénère l'ID de session** (anti-fixation, `session.regenerateId()`, `authFlow.ts:388`)
|
|
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.ts:
|
|
148
|
-
l'ID token — est importé **paresseusement**. Les fournisseurs sont construits
|
|
149
|
-
mémoïsés (`OAuth2Service.#resolveProvider()
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
147
|
+
dans le module (`OAuth2Client`, `oauth2Client.ts:345`), et `jose` — seul recours externe, pour lire les claims de
|
|
148
|
+
l'ID token — est importé **paresseusement**. Les fournisseurs sont construits une fois puis
|
|
149
|
+
mémoïsés (`OAuth2Service.#resolveProvider()`) : c'est là, une seule fois par processus, que les
|
|
150
|
+
points d'entrée d'un émetteur OIDC sont découverts. Un run qui sert (ports ouverts) les construit
|
|
151
|
+
dès le boot, sans l'attendre ; une commande console ne touche jamais le réseau.
|
|
152
|
+
|
|
153
|
+
**Une configuration fausse se montre au démarrage, pas au premier clic.** Au boot, l'`issuer` de
|
|
154
|
+
chaque fournisseur est confronté à ce que sa fabrique exige (`checkProviderIssuer()`,
|
|
155
|
+
`oauth2.ts`) : absent alors que la fabrique l'a déclaré requis, ou qui n'est pas une URL d'émetteur
|
|
156
|
+
valide (https, sans requête ni fragment — RFC 8414 §2), il **interrompt le démarrage** en nommant
|
|
157
|
+
la clé (`security.oauth2.providers.<nom>.issuer`). Un émetteur **injoignable**, lui, n'est pas une
|
|
158
|
+
faute de configuration : le boot continue, un WARNING le nomme, et son bouton quitte l'écran de
|
|
159
|
+
connexion — le flux `authorize` reste ouvert. Une nouvelle tentative part au plus toutes les 30 s
|
|
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:460`) : sans social login configuré,
|
|
162
|
+
`…/providers` rend une liste vide, `authorize` rend `404 Unknown provider`, et `authorize`/`callback`
|
|
163
|
+
rendent `503 OAuth unavailable` quand `oauth2.enabled` vaut `false`.
|
|
153
164
|
|
|
154
165
|
Au boot, la config est validée et les fournisseurs configurés sont confrontés au registre : un nom
|
|
155
166
|
inconnu produit un **WARNING, pas un échec fatal** — `OAuth2Service.#build()` confronte les noms
|
|
156
|
-
configurés à `listOAuthProviders()` (`oauth2.ts:
|
|
167
|
+
configurés à `listOAuthProviders()` (`oauth2.ts:280`) et le
|
|
157
168
|
reste de l'application démarre, le bouton correspondant n'apparaît simplement pas.
|
|
158
169
|
|
|
159
170
|
## 🚀 Démarrage rapide
|
|
@@ -214,15 +225,16 @@ export default defineConfig<typeof env>((ctx) => ({
|
|
|
214
225
|
|
|
215
226
|
### Les routes sont FOURNIES — tu n'écris aucun controller
|
|
216
227
|
|
|
217
|
-
`mountOAuth2Routes()` (`OAuth2Controller.ts:
|
|
218
|
-
`/nodefony/security/api/oauth2` (`OAuth2Controller.ts:
|
|
219
|
-
est
|
|
228
|
+
`mountOAuth2Routes()` (`OAuth2Controller.ts:432`) monte quatre routes sous
|
|
229
|
+
`/nodefony/security/api/oauth2` (`OAuth2Controller.ts:373`) dès que le service `oauth2` est
|
|
230
|
+
enregistré, c'est-à-dire dès que `@nodefony/security` est chargé (`framework/index.ts:460`) :
|
|
220
231
|
|
|
221
|
-
| Route
|
|
222
|
-
|
|
|
223
|
-
| `GET …/providers`
|
|
224
|
-
| `GET …/{provider}/authorize`
|
|
225
|
-
| `GET …/{provider}/callback`
|
|
232
|
+
| Route | Rôle |
|
|
233
|
+
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
234
|
+
| `GET …/providers` | Noms des fournisseurs **opérationnels** — l'UI n'affiche que ceux-là. |
|
|
235
|
+
| `GET …/{provider}/authorize` | Démarre le flux : pose l'état en session, `302` vers le fournisseur. |
|
|
236
|
+
| `GET …/{provider}/callback` | Valide, échange, provisionne, ouvre la session BFF, `302`. |
|
|
237
|
+
| `POST …/{provider}/backchannel-logout` | Canal arrière : le fournisseur demande de fermer des sessions (cf [§ canal arrière](#déconnexion-demandée-par-le-fournisseur--le-canal-arrière)). |
|
|
226
238
|
|
|
227
239
|
Ton écran de login n'a donc qu'un lien à poser :
|
|
228
240
|
|
|
@@ -233,10 +245,12 @@ Ton écran de login n'a donc qu'un lien à poser :
|
|
|
233
245
|
```
|
|
234
246
|
|
|
235
247
|
> [!WARNING]
|
|
236
|
-
> Ces routes portent `bypassFirewall: true` (`OAuth2Controller.ts:
|
|
248
|
+
> Ces routes portent `bypassFirewall: true` (`OAuth2Controller.ts:468`) — elles **sont** le mécanisme
|
|
237
249
|
> d'authentification : l'utilisateur est anonyme pendant tout l'aller-retour. Les protéger créerait
|
|
238
250
|
> un interblocage (il faudrait être connecté pour pouvoir se connecter). La session anonyme ne porte
|
|
239
|
-
> que `state`/`code_verifier`, et son ID est **régénéré** à la promotion.
|
|
251
|
+
> que `state`/`code_verifier`, et son ID est **régénéré** à la promotion. Le canal arrière, lui,
|
|
252
|
+
> est appelé **serveur à serveur** : ni cookie, ni session, ni jeton CSRF — la signature du jeton de
|
|
253
|
+
> déconnexion est sa seule authentification.
|
|
240
254
|
|
|
241
255
|
### Ce qu'on observe
|
|
242
256
|
|
|
@@ -255,7 +269,7 @@ curl -si -b /tmp/jar -c /tmp/jar \
|
|
|
255
269
|
|
|
256
270
|
# 3) L'identité est résolue comme après un login classique
|
|
257
271
|
curl -s -b /tmp/jar http://localhost:5151/nodefony/security/api/auth/me
|
|
258
|
-
# {"user":{"
|
|
272
|
+
# {"user":{"id":"…","identifier":"jane@example.com","roles":["ROLE_USER"]}}
|
|
259
273
|
|
|
260
274
|
# 4) Ce que l'UI de login interroge pour n'afficher que des boutons vivants
|
|
261
275
|
curl -s http://localhost:5151/nodefony/security/api/oauth2/providers
|
|
@@ -268,45 +282,67 @@ Séquence identique prouvée de bout en bout sur serveur réel par `oauth2-flow.
|
|
|
268
282
|
|
|
269
283
|
### Étape 1 — `createAuthorization(provider)`
|
|
270
284
|
|
|
271
|
-
`OAuth2Service.createAuthorization()` (`oauth2.ts:
|
|
285
|
+
`OAuth2Service.createAuthorization()` (`oauth2.ts:477`) fabrique trois choses :
|
|
272
286
|
|
|
273
287
|
1. un **`state`** aléatoire (anti-CSRF) ;
|
|
274
288
|
2. un **`code_verifier`** — **seulement si** le fournisseur pratique PKCE (`usesPkce`,
|
|
275
|
-
`oauth2.ts:
|
|
289
|
+
`oauth2.ts:458-460`) ; `null` sinon (GitHub) ;
|
|
276
290
|
3. l'**URL d'autorisation** construite par l'adaptateur du fournisseur, avec les scopes effectifs
|
|
277
|
-
(ceux de la config, sinon les scopes par défaut du fournisseur, `oauth2.ts:
|
|
291
|
+
(ceux de la config, sinon les scopes par défaut du fournisseur, `oauth2.ts:576`).
|
|
278
292
|
|
|
279
293
|
Le controller pose les trois valeurs en session, **persiste** (`session.save()` — pas seulement en
|
|
280
|
-
mémoire, `OAuth2Controller.ts:
|
|
294
|
+
mémoire, `OAuth2Controller.ts:64`), puis redirige en 302.
|
|
281
295
|
|
|
282
296
|
### Étape 2 — le retour, validé avant tout appel réseau
|
|
283
297
|
|
|
284
|
-
`OAuth2Controller.callback()` (`OAuth2Controller.ts:
|
|
298
|
+
`OAuth2Controller.callback()` (`OAuth2Controller.ts:258`) travaille dans cet ordre, et l'ordre est la
|
|
285
299
|
défense :
|
|
286
300
|
|
|
287
301
|
1. **lire l'état de session, puis l'invalider immédiatement** (`OAuth2Controller.ts:158-166`) — le
|
|
288
302
|
`state` est à **usage unique** : un rejeu du même retour échoue, même avec le bon cookie ;
|
|
289
303
|
2. **comparer** : `code` et `state` présents, `state` reçu ≡ `state` attendu, **et** fournisseur du
|
|
290
|
-
callback ≡ fournisseur démarré (`OAuth2Controller.ts:
|
|
291
|
-
`
|
|
304
|
+
callback ≡ fournisseur démarré (`OAuth2Controller.ts:231-241`). Un seul écart → `302` vers
|
|
305
|
+
l'écran d'échec avec `reason=failed`, **sans jamais contacter le fournisseur** ;
|
|
292
306
|
3. seulement ensuite, `exchangeAndProvision()`.
|
|
293
307
|
|
|
294
308
|
### Étape 3 — `exchangeAndProvision(provider, code, verifier, iss)`
|
|
295
309
|
|
|
296
|
-
`OAuth2Service.exchangeAndProvision()` (`oauth2.ts:
|
|
310
|
+
`OAuth2Service.exchangeAndProvision()` (`oauth2.ts:503`) enchaîne :
|
|
297
311
|
|
|
298
312
|
1. **anti-mix-up** — si le fournisseur annonce un émetteur attendu, l'`iss` reçu doit correspondre,
|
|
299
|
-
et un `iss` **absent** est un rejet, pas une tolérance (`oauth2.ts:
|
|
313
|
+
et un `iss` **absent** est un rejet, pas une tolérance (`oauth2.ts:402-408`) ;
|
|
300
314
|
2. **échange** du `code` sur le canal serveur, avec le `code_verifier`
|
|
301
|
-
(`validateAuthorizationCode`, `oauth2.ts:
|
|
302
|
-
`oauth2.ts:
|
|
315
|
+
(`validateAuthorizationCode`, `oauth2.ts:479`), puis lecture du profil (`fetchProfile`,
|
|
316
|
+
`oauth2.ts:519`) ;
|
|
303
317
|
3. **provisionnement** du Shadow User avec la politique effective — rôles par défaut surchargeables
|
|
304
|
-
**par fournisseur** (`oauth2.ts:
|
|
318
|
+
**par fournisseur** (`oauth2.ts:417-418`), `allowSignup` global (`oauth2.ts:542`).
|
|
305
319
|
|
|
306
|
-
Toute erreur de cette étape est convertie en **échec uniforme** par le controller (`302
|
|
307
|
-
|
|
320
|
+
Toute erreur de cette étape est convertie en **échec uniforme** par le controller (`302` vers
|
|
321
|
+
l'écran d'échec avec `reason=failed`) : le client ne distingue pas un `iss` invalide d'un
|
|
308
322
|
échange refusé ou d'un signup interdit.
|
|
309
323
|
|
|
324
|
+
### Les retours — `from` et `back`
|
|
325
|
+
|
|
326
|
+
La page qui lance le flux dit deux choses à `/authorize`, que le serveur revalide en chemins
|
|
327
|
+
locaux (`safeRedirectPath`, garde anti-redirection ouverte du cœur), garde en session avec le
|
|
328
|
+
`state` et purge au callback :
|
|
329
|
+
|
|
330
|
+
| Paramètre | Sens | Absent ou refusé |
|
|
331
|
+
| --------- | ---------------------------------------------------------------------------------------------- | ----------------- |
|
|
332
|
+
| `from` | page où aller une fois connecté | `successRedirect` |
|
|
333
|
+
| `back` | écran où revenir si la connexion n'aboutit pas — le bouton « Retour » du fournisseur, un échec | `failureRedirect` |
|
|
334
|
+
|
|
335
|
+
`back` l'emporte sur `failureRedirect` parce qu'une application peut avoir PLUSIEURS écrans de
|
|
336
|
+
connexion (la page par défaut, la console d'administration) : une seule adresse d'échec renverrait
|
|
337
|
+
l'un chez l'autre. La page par défaut et la console l'envoient d'elles-mêmes, et
|
|
338
|
+
`NodefonyLogin.startProvider()` pose la page courante.
|
|
339
|
+
|
|
340
|
+
L'écran d'échec reçoit `reason` et `from` (une `reason` ou un `from` déjà présents sont
|
|
341
|
+
remplacés) : `reason=access_denied` dit une ANNULATION — l'écran se tait, l'utilisateur est revenu
|
|
342
|
+
choisir une autre méthode —, toute autre valeur un échec à annoncer. Seuls les codes normalisés
|
|
343
|
+
d'OAuth 2.0 et d'OpenID Connect sont recopiés, et seulement sous un `state` valide ; tout le reste
|
|
344
|
+
devient `failed` (`OAUTH2_FAILED_REASON`, cœur).
|
|
345
|
+
|
|
310
346
|
## 🧑⚖️ Le Shadow User — l'identité locale, et pourquoi OAuth n'accorde aucun droit
|
|
311
347
|
|
|
312
348
|
Nodefony ne « connecte pas un compte Google ». Il crée et retrouve une **ligne locale** liée au
|
|
@@ -314,16 +350,25 @@ compte externe : le _Shadow User_. C'est cette ligne qui porte l'identifiant, le
|
|
|
314
350
|
actif/verrouillé — donc **tout** ce dont l'autorisation a besoin.
|
|
315
351
|
|
|
316
352
|
Le contrat s'appelle `IOAuthUserProvisioner` (`IOAuthUserProvisioner.ts:61`) ; l'implémentation par
|
|
317
|
-
défaut est `UserService.provisionOAuthUser()` (`UserService.ts:
|
|
353
|
+
défaut est `UserService.provisionOAuthUser()` (`UserService.ts:603`), en **find-or-create** :
|
|
318
354
|
|
|
319
355
|
| Situation au retour du fournisseur | Comportement |
|
|
320
356
|
| ---------------------------------------- | ------------------------------------------------------------------------------ |
|
|
321
357
|
| Lien social déjà connu | Le compte existant est rendu tel quel — rien n'est créé, rien n'est réécrit. |
|
|
322
358
|
| Lien inconnu, `allowSignup: true` | Création JIT : `password: null`, rôles = `defaultRoles`, lien social persisté. |
|
|
323
|
-
| Lien inconnu, `allowSignup: false` | **Échec fail-closed** (`UserService.ts:
|
|
359
|
+
| Lien inconnu, `allowSignup: false` | **Échec fail-closed** (`UserService.ts:599`) — un compte lié est exigé. |
|
|
324
360
|
| E-mail identique à un compte local | **Aucune liaison automatique** — un compte SÉPARÉ est créé. |
|
|
325
361
|
| Même `providerId` chez deux fournisseurs | Comptes séparés (le couple `provider` + `providerId` fait la clé). |
|
|
326
362
|
|
|
363
|
+
⚠️ **`allowSignup: true` (le défaut) + une zone sans `roles` = une zone ouverte à tout compte du
|
|
364
|
+
fournisseur.** Une zone qui n'exige aucun rôle laisse passer toute identité authentifiée ; avec la
|
|
365
|
+
création à la volée, cela veut dire quiconque obtient un compte chez Google ou GitHub. Le pare-feu le
|
|
366
|
+
signale au démarrage (`WARNING`), dans la console (`openToSignup` de la zone) et dans
|
|
367
|
+
`nodefony doctor --live` — c'est un constat, pas une faute (`findZonesOpenToSignup`,
|
|
368
|
+
`openToSignup.ts:40`). Deux gestes le soldent : `roles: ["ROLE_USER"]` sur la zone, qui ne change
|
|
369
|
+
rien au comportement mais **écrit** l'intention, ou `allowSignup: false`, qui exige un compte
|
|
370
|
+
préexistant.
|
|
371
|
+
|
|
327
372
|
### Pourquoi l'e-mail ne lie jamais automatiquement un compte
|
|
328
373
|
|
|
329
374
|
C'est le point le plus contre-intuitif, et c'est une décision de sécurité. Si un compte externe dont
|
|
@@ -337,14 +382,14 @@ Conséquence assumée : l'utilisateur qui avait un mot de passe et clique « ave
|
|
|
337
382
|
faite **utilisateur déjà connecté** — jamais un effet de bord du login.
|
|
338
383
|
|
|
339
384
|
L'identifiant du compte créé dérive de l'e-mail si le fournisseur en donne un, sinon d'une clé
|
|
340
|
-
préfixée `provider:providerId` — jamais de collision entre fournisseurs (`UserService.ts:
|
|
385
|
+
préfixée `provider:providerId` — jamais de collision entre fournisseurs (`UserService.ts:607-608`).
|
|
341
386
|
|
|
342
387
|
### Les rôles sont posés à la création, et plus jamais
|
|
343
388
|
|
|
344
|
-
`defaultRoles` s'applique **au moment du `create`** (`UserService.ts:
|
|
389
|
+
`defaultRoles` s'applique **au moment du `create`** (`UserService.ts:203`). Un second login
|
|
345
390
|
n'écrase rien : promouvoir quelqu'un dans ta base reste effectif, et modifier `defaultRoles` en
|
|
346
391
|
config ne repeint pas les comptes existants. C'est la traduction de la règle « OAuth =
|
|
347
|
-
authentification, pas autorisation » (`oauth2.ts:
|
|
392
|
+
authentification, pas autorisation » (`oauth2.ts:414-418`, `config.ts:1065-1070`).
|
|
348
393
|
|
|
349
394
|
> [!TIP]
|
|
350
395
|
> Un fournisseur social ne doit **jamais** figurer dans le chemin d'obtention d'un rôle privilégié.
|
|
@@ -354,7 +399,7 @@ authentification, pas autorisation » (`oauth2.ts:279-283`, `config.ts:1048-1053
|
|
|
354
399
|
### Brancher sa propre politique
|
|
355
400
|
|
|
356
401
|
Le provisioner est le service `users` **s'il implémente la capability**, détecté par duck-typing
|
|
357
|
-
(`OAuth2Service.#resolveProvisioner()`, `oauth2.ts:
|
|
402
|
+
(`OAuth2Service.#resolveProvisioner()`, `oauth2.ts:863-871`). S'il ne l'implémente pas, le login
|
|
358
403
|
**échoue** — jamais de création silencieuse par défaut. Une application qui veut sa propre politique
|
|
359
404
|
(quota d'inscriptions, allowlist de domaines e-mail, rattachement à un tenant) implémente
|
|
360
405
|
`provisionOAuthUser()` sur son service `users` : le profil normalisé `IOAuthProfile`
|
|
@@ -363,7 +408,7 @@ et la charge brute `raw`.
|
|
|
363
408
|
|
|
364
409
|
## 🧩 Fournisseurs — catalogue et extension
|
|
365
410
|
|
|
366
|
-
Un fournisseur est un adaptateur qui implémente `IOAuthProvider` (`IOAuthProvider.ts:
|
|
411
|
+
Un fournisseur est un adaptateur qui implémente `IOAuthProvider` (`IOAuthProvider.ts:67`) : il masque
|
|
367
412
|
les divergences (PKCE ou non, profil par ID token ou par appel d'API) derrière un contrat unique.
|
|
368
413
|
Quatre sont livrés, résolus par nom via le registre `oauthProviderRegistry.ts:62`.
|
|
369
414
|
|
|
@@ -376,23 +421,25 @@ Quatre sont livrés, résolus par nom via le registre `oauthProviderRegistry.ts:
|
|
|
376
421
|
|
|
377
422
|
### `google` — OIDC, le cas nominal
|
|
378
423
|
|
|
379
|
-
Construit par le helper générique `createOidcProvider()` (`oidc.ts:
|
|
424
|
+
Construit par le helper générique `createOidcProvider()` (`oidc.ts:177`) : PKCE systématique
|
|
380
425
|
(`usesPkce: true`, `oidc.ts:114`), émetteur figé `https://accounts.google.com`
|
|
381
|
-
(`oauthProviderRegistry.ts:
|
|
426
|
+
(`oauthProviderRegistry.ts:157`). Ses points d'entrée ne sont **pas** écrits en dur : ils sont
|
|
382
427
|
demandés à l'émetteur (RFC 8414, cf. « Découverte » plus bas). Le profil se lit dans l'**ID token** —
|
|
383
|
-
claims standard `sub`, `email`, `email_verified`, `name` (`oidc.ts:
|
|
428
|
+
claims standard `sub`, `email`, `email_verified`, `name` (`oidc.ts:217`), après les contrôles
|
|
384
429
|
obligatoires d'OpenID Connect Core §3.1.3.7 : `iss`, `aud`, `exp`, et un `sub` non vide
|
|
385
|
-
(`assertIdTokenClaims()`, `oidc.ts:
|
|
430
|
+
(`assertIdTokenClaims()`, `oidc.ts:143`). Pas d'identifiant stable, pas d'identité.
|
|
386
431
|
|
|
387
432
|
### `keycloak` — OIDC self-hosted, l'émetteur vient de ta config
|
|
388
433
|
|
|
389
434
|
Même helper, mais l'**issuer** (URL du realm) sert à la fois à découvrir les points d'entrée et à
|
|
390
|
-
valider l'`iss
|
|
391
|
-
|
|
435
|
+
valider l'`iss`. Il est donc **obligatoire**, et déclaré comme tel au registre
|
|
436
|
+
(`requiresIssuer: true`) : sans lui, le démarrage est refusé en nommant la clé.
|
|
437
|
+
Créer le realm et le client, lire l'émetteur, réussir le premier login : la page
|
|
438
|
+
[Keycloak](keycloak.md) fait le chemin entier.
|
|
392
439
|
|
|
393
440
|
### `oidc` — n'importe quel serveur OpenID Connect
|
|
394
441
|
|
|
395
|
-
La même mécanique, sans nom de marque : l'entrée `oidc` (`oauthProviderRegistry.ts:
|
|
442
|
+
La même mécanique, sans nom de marque : l'entrée `oidc` (`oauthProviderRegistry.ts:142`) prend
|
|
396
443
|
l'émetteur de sa configuration et n'a besoin de rien d'autre. C'est elle qui rend inutile une classe
|
|
397
444
|
par fournisseur.
|
|
398
445
|
|
|
@@ -417,7 +464,7 @@ anti-mix-up **principale** est ailleurs — chaque fournisseur a son URL de redi
|
|
|
417
464
|
(`…/{provider}/callback`) et le flux vérifie que le fournisseur de retour est celui qui a démarré,
|
|
418
465
|
ce que la RFC 9700 §4.4.2.2 donne comme la protection de référence. `iss` est la seconde ceinture.
|
|
419
466
|
|
|
420
|
-
La politique est portée par le fournisseur (`issuerPolicy`, `IOAuthProvider.ts:
|
|
467
|
+
La politique est portée par le fournisseur (`issuerPolicy`, `IOAuthProvider.ts:79`) et remplie par
|
|
421
468
|
la découverte ; elle vaut `null` pour un fournisseur non-OIDC, qui ne relève pas de cette défense.
|
|
422
469
|
|
|
423
470
|
### Découverte des points d'entrée (RFC 8414)
|
|
@@ -429,7 +476,7 @@ points d'entrée sont demandés à l'émetteur, une seule fois par processus, au
|
|
|
429
476
|
bien connues (§3.1 : insertion oauth → insertion oidc → ajout oidc) et l'égalité stricte du §3.3
|
|
430
477
|
vivent dans le cœur (`nodefony` → `src/oauth/authorizationServer.ts`), qui s'en sert aussi pour
|
|
431
478
|
PUBLIER nos propres métadonnées. `metadata.ts` n'ajoute que le transport : requête bornée, sans
|
|
432
|
-
redirection suivie, avec un délai d'attente (`discoverAuthorizationServer()`, `metadata.ts:
|
|
479
|
+
redirection suivie, avec un délai d'attente (`discoverAuthorizationServer()`, `metadata.ts:201`).
|
|
433
480
|
|
|
434
481
|
Deux refus valent d'être connus. Un document dont l'`issuer` diffère de celui demandé est rejeté
|
|
435
482
|
**sans se rabattre** sur l'URL suivante — se rabattre masquerait un document hostile derrière un 404.
|
|
@@ -446,16 +493,16 @@ donnerait l'illusion de PKCE.
|
|
|
446
493
|
|
|
447
494
|
Pas de PKCE, pas d'ID token, pas d'`iss` (`usesPkce: false`, `issuerPolicy: null`,
|
|
448
495
|
`github.ts:68-69`) : ici, la défense anti-CSRF repose **entièrement** sur le `state`. Le profil vient
|
|
449
|
-
de l'API REST `/user` (`createGithubProvider()`, `github.ts:
|
|
496
|
+
de l'API REST `/user` (`createGithubProvider()`, `github.ts:78`). Subtilité GitHub : l'e-mail
|
|
450
497
|
primaire est souvent privé — l'adaptateur bascule alors sur `/user/emails` et n'accepte
|
|
451
|
-
`emailVerified` que si GitHub le certifie (`github.ts:
|
|
498
|
+
`emailVerified` que si GitHub le certifie (`github.ts:127-136`).
|
|
452
499
|
|
|
453
500
|
### Enregistrer le sien — sans éditer le cœur
|
|
454
501
|
|
|
455
502
|
**Tout serveur OpenID Connect conforme est déjà supporté** — Auth0, Okta, Authentik, Entra
|
|
456
503
|
mono-locataire… — sans une ligne de code propre. Le builtin `oidc` suffit quand il n'y en a qu'un ;
|
|
457
|
-
pour en nommer plusieurs, `registerOAuthProvider()`
|
|
458
|
-
|
|
504
|
+
pour en nommer plusieurs, `registerOAuthProvider()` au chargement de ton module (avant le
|
|
505
|
+
`onBoot` du service) :
|
|
459
506
|
|
|
460
507
|
```typescript ignore
|
|
461
508
|
import {
|
|
@@ -465,8 +512,12 @@ import {
|
|
|
465
512
|
|
|
466
513
|
// Le nom sert de clé de configuration ET de `provider` du Shadow User ;
|
|
467
514
|
// l'émetteur vient de la config (`oauth2.providers.microsoft.issuer`).
|
|
468
|
-
registerOAuthProvider(
|
|
469
|
-
|
|
515
|
+
registerOAuthProvider(
|
|
516
|
+
"microsoft",
|
|
517
|
+
(ctx) => createDiscoveredOidcProvider("microsoft", ctx),
|
|
518
|
+
// Déclarer l'émetteur requis : son oubli refuse le démarrage au lieu
|
|
519
|
+
// d'échouer au premier clic.
|
|
520
|
+
{ requiresIssuer: true },
|
|
470
521
|
);
|
|
471
522
|
```
|
|
472
523
|
|
|
@@ -479,35 +530,36 @@ protocole vient de `OAuth2Client`, la fabrique ne fait que lire le profil. C'est
|
|
|
479
530
|
lignes — `github.ts` en est le modèle.
|
|
480
531
|
|
|
481
532
|
Un fournisseur qui n'est pas OIDC (pas d'ID token, profil lu à son API) s'écrit comme GitHub
|
|
482
|
-
(`createGithubProvider()`, `github.ts:
|
|
533
|
+
(`createGithubProvider()`, `github.ts:78`) : `OAuth2Client` porte le protocole, la fabrique ne fait
|
|
483
534
|
que le mapping du profil. Exemple sans réseau dans le dépôt :
|
|
484
535
|
`src/modules/test/nodefony/secure/oauthTestProvider.ts`.
|
|
485
536
|
|
|
486
537
|
## ⚙️ Configuration
|
|
487
538
|
|
|
488
|
-
|
|
489
|
-
(`config.ts:
|
|
539
|
+
Schéma Zod `oauth2Schema` (`config.ts:1357`), branché sur la section `oauth2` de la config du module
|
|
540
|
+
(`config.ts:1472`). Table dérivée du schéma — les défauts sont ceux du code.
|
|
490
541
|
|
|
491
|
-
| Option | Type | Défaut
|
|
492
|
-
| ----------------- | -------------------- |
|
|
493
|
-
| `enabled` | booléen | `true`
|
|
494
|
-
| `defaultRoles` | liste de rôles | `["ROLE_USER"]`
|
|
495
|
-
| `allowSignup` | booléen | `true`
|
|
496
|
-
| `successRedirect` | chemin | `/`
|
|
497
|
-
| `failureRedirect` | chemin | `/login`
|
|
498
|
-
| `providers` | dictionnaire par nom | `{}`
|
|
542
|
+
| Option | Type | Défaut | Effet |
|
|
543
|
+
| ----------------- | -------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
544
|
+
| `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:1233`). |
|
|
546
|
+
| `allowSignup` | booléen | `true` | `false` = compte préexistant lié exigé (`config.ts:1371`). |
|
|
547
|
+
| `successRedirect` | chemin | `/` | Où revient l'utilisateur après succès, quand la page de départ n'a pas donné `from`. |
|
|
548
|
+
| `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:1387`). |
|
|
499
550
|
|
|
500
|
-
Par fournisseur (`oauthProviderSchema`, `config.ts:
|
|
551
|
+
Par fournisseur (`oauthProviderSchema`, `config.ts:1169`) :
|
|
501
552
|
|
|
502
553
|
<!-- prettier-ignore -->
|
|
503
554
|
| Option | Requis | Effet |
|
|
504
555
|
| --- | :---: | --- |
|
|
505
556
|
| `clientId` / `clientSecret` | ✅ | Identifiants délivrés par l'IdP. Secrets : par `env.ts`, jamais journalisés. |
|
|
506
|
-
| `redirectUri` | ✅ | URL de callback **exacte** (`config.ts:
|
|
557
|
+
| `redirectUri` | ✅ | URL de callback **exacte** (`config.ts:1190`). |
|
|
507
558
|
| `issuer` | OIDC self-hosted | Realm Keycloak ; ignoré par les IdP à endpoints fixes. |
|
|
508
559
|
| `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`. |
|
|
509
560
|
| `scopes` | | Vide = scopes par défaut du fournisseur. |
|
|
510
|
-
| `successRedirect` / `failureRedirect` / `defaultRoles` | | Surchargent le global **pour ce fournisseur** (`oauth2.ts:
|
|
561
|
+
| `successRedirect` / `failureRedirect` / `defaultRoles` | | Surchargent le global **pour ce fournisseur** (`oauth2.ts:515-532`). |
|
|
562
|
+
| `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. |
|
|
511
563
|
|
|
512
564
|
Les surcharges par fournisseur permettent la cohabitation : un IdP de recette garde ses redirections
|
|
513
565
|
et ses rôles pendant qu'un IdP de production pointe ailleurs.
|
|
@@ -517,7 +569,7 @@ et ses rôles pendant qu'un IdP de production pointe ailleurs.
|
|
|
517
569
|
### Les jetons du fournisseur ne sont pas conservés
|
|
518
570
|
|
|
519
571
|
C'est un choix, et il a des conséquences à connaître. Les jetons obtenus à l'échange vivent dans la
|
|
520
|
-
portée locale de l'échange (`validateAuthorizationCode` puis `fetchProfile`, `oauth2.ts:
|
|
572
|
+
portée locale de l'échange (`validateAuthorizationCode` puis `fetchProfile`, `oauth2.ts:518-519`) :
|
|
521
573
|
ils ne sont ni retournés, ni mis en
|
|
522
574
|
session, ni persistés. Le profil normalisé qui traverse le système n'en contient aucun
|
|
523
575
|
(`IOAuthUserProvisioner.ts:8-10`).
|
|
@@ -528,47 +580,106 @@ session, ni persistés. Le profil normalisé qui traverse le système n'en conti
|
|
|
528
580
|
l'utilisateur plus tard (lire ses dépôts, envoyer un mail). Nodefony fait de l'**authentification**,
|
|
529
581
|
pas de la **délégation d'accès**.
|
|
530
582
|
- **Si tu as besoin de cette délégation** : le seul endroit où les jetons sont visibles est le
|
|
531
|
-
`fetchProfile()` de ton adaptateur (`IOAuthProvider.ts:
|
|
583
|
+
`fetchProfile()` de ton adaptateur (`IOAuthProvider.ts:106`) — c'est là que ton implémentation les
|
|
532
584
|
capture et les persiste, sous ta responsabilité (chiffrement au repos, rotation, révocation).
|
|
533
585
|
|
|
586
|
+
**Une exception, et une seule : l'ID token d'un fournisseur qui sait déconnecter.** Quand le
|
|
587
|
+
fournisseur publie un point de déconnexion (`end_session_endpoint`, lu par la découverte — Keycloak
|
|
588
|
+
le fait), la session BFF retient, **côté serveur**, le nom du fournisseur, l'ID token et le claim
|
|
589
|
+
`sid`. La console d'administration des sessions n'en montre rien. Le navigateur ne le voit qu'**une
|
|
590
|
+
fois**, à la déconnexion, dans l'adresse `logoutUrl` (`id_token_hint`) : RP-Initiated Logout passe
|
|
591
|
+
par une redirection du navigateur, la norme ne laisse pas d'autre chemin — la session locale est
|
|
592
|
+
alors déjà détruite. Pourquoi : à la déconnexion, l'ID token est rejoué en `id_token_hint` — il désigne la session à
|
|
593
|
+
fermer chez le fournisseur, qui sans lui demanderait une confirmation à chaque fois. Il n'ouvre
|
|
594
|
+
**aucun** accès : ni l'API du fournisseur, ni un rafraîchissement. Le jeton d'accès et le jeton de
|
|
595
|
+
rafraîchissement restent jetés. GitHub, ou un serveur OIDC sans point de déconnexion : rien n'est
|
|
596
|
+
retenu.
|
|
597
|
+
|
|
598
|
+
Pour que le fournisseur puisse, à l'inverse, fermer lui-même ces sessions, le login retient aussi
|
|
599
|
+
un **index** — fournisseur et `sid`, **sans** l'ID token — dans les métadonnées de la session
|
|
600
|
+
(`oauth2.ts:558`). Il est là parce que l'énumération des sessions efface les attributs, dans tous
|
|
601
|
+
les stores : c'est la seule partie que le canal arrière peut lire en parcourant le parc. L'écran
|
|
602
|
+
**Sessions** ne l'affiche pas (résumé construit par liste blanche).
|
|
603
|
+
|
|
604
|
+
### Déconnexion demandée par le fournisseur — le canal arrière
|
|
605
|
+
|
|
606
|
+
Quand l'utilisateur se déconnecte **ailleurs** — la console d'administration de Keycloak, une autre
|
|
607
|
+
application du même SSO —, le fournisseur appelle l'application : `POST
|
|
608
|
+
…/oauth2/{provider}/backchannel-logout`, avec un paramètre `logout_token` (OpenID Connect
|
|
609
|
+
Back-Channel Logout 1.0). Le contrôleur (`OAuth2Controller.ts:314`) délègue au service
|
|
610
|
+
(`OAuth2Service.backchannelLogout()`, `oauth2.ts:638`) :
|
|
611
|
+
|
|
612
|
+
1. **La signature d'abord.** Le jeton est vérifié par `RemoteJwtVerifier` (`verifyClaims`,
|
|
613
|
+
`RemoteJwtVerifier.ts:251`), avec les clés publiques du fournisseur découvertes à son `jwks_uri` :
|
|
614
|
+
émetteur exact, audience = le `clientId`, `iat`, `exp` et `jti` exigés
|
|
615
|
+
(`oidc.ts:303`). Les algorithmes sont ceux des ID tokens annoncés par le fournisseur, **sans**
|
|
616
|
+
ceux à secret partagé (`oidc.ts:293`) — Keycloak annonce `HS256`, qui ferait d'une clé publique un
|
|
617
|
+
secret de signature.
|
|
618
|
+
2. **Le sens ensuite** (`readLogoutTokenClaims()`, `oidc.ts:93`) : `sub` ou `sid` présent ; `events`
|
|
619
|
+
qui le déclare jeton de déconnexion ; **aucun** `nonce`. C'est ce qui empêche un ID token, signé
|
|
620
|
+
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:704`).
|
|
622
|
+
4. **Les sessions désignées** sont détruites par `SessionsService.destroyWhere()`
|
|
623
|
+
(`sessions-service.ts:788`) : celles ouvertes par **ce** fournisseur et qui portent le `sid` du
|
|
624
|
+
jeton ; sans `sid`, toutes celles du compte lié au `sub`. Une session ouverte par mot de passe ou
|
|
625
|
+
par un autre fournisseur n'est **jamais** touchée : le fournisseur ne ferme que ce qu'il a ouvert.
|
|
626
|
+
|
|
627
|
+
Réponses (norme §2.8) : `200` quand la déconnexion a eu lieu — ou n'avait plus lieu d'être ; `400`
|
|
628
|
+
`invalid_request` pour un jeton refusé **comme** pour une déconnexion impossible (jeu de clés
|
|
629
|
+
injoignable), la cause n'allant qu'au journal ; `404` fournisseur inconnu ; `501` fournisseur qui
|
|
630
|
+
n'émet pas de jeton de déconnexion (GitHub). Toujours `Cache-Control: no-store`.
|
|
631
|
+
|
|
632
|
+
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:113`), code `4001`.
|
|
634
|
+
|
|
635
|
+
> [!NOTE]
|
|
636
|
+
> L'anti-rejeu vit **dans le processus**. Plusieurs exemplaires : un jeton rejoué sur un autre
|
|
637
|
+
> exemplaire passe — et ne ferme que des sessions que le fournisseur a déjà déclarées closes. La
|
|
638
|
+
> borne est la durée de vie du jeton, que la norme recommande courte (§4).
|
|
639
|
+
|
|
534
640
|
### Ce que « révoquer » veut dire ici
|
|
535
641
|
|
|
536
|
-
| Action
|
|
537
|
-
|
|
|
538
|
-
| Déconnexion (`
|
|
539
|
-
| Compte local désactivé/verrouillé
|
|
540
|
-
|
|
|
541
|
-
| `allowSignup: false` après coup
|
|
642
|
+
| Action | Effet sur ton application |
|
|
643
|
+
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
644
|
+
| Déconnexion (`POST …/auth/logout`) | Session détruite côté serveur + cookie effacé — immédiat. Si la session vient d'un fournisseur qui publie un point de déconnexion, la réponse porte `logoutUrl` : le client y envoie le navigateur, qui ferme **aussi** la session du fournisseur. Sans ce détour, le clic suivant sur « Continuer avec Keycloak » reconnecte **sans mot de passe**. |
|
|
645
|
+
| Compte local désactivé/verrouillé | Rejet à la requête suivante : l'identité est **re-résolue** à chaque requête. |
|
|
646
|
+
| Session fermée **chez le fournisseur** | Fournisseur OpenID Connect qui appelle le canal arrière (Keycloak, client configuré) : les sessions qu'il a ouvertes sont détruites. Sinon (GitHub, fournisseur non configuré pour ce canal) : **aucun effet** — la session locale reste valide jusqu'à son terme. |
|
|
647
|
+
| `allowSignup: false` après coup | Bloque les nouveaux comptes, pas les liens existants. |
|
|
542
648
|
|
|
543
649
|
La troisième ligne est le piège courant : une fois la session BFF ouverte, ton application ne
|
|
544
|
-
redemande plus rien à GitHub. Pour couper l'accès, il faut agir **localement** (désactiver le compte
|
|
650
|
+
redemande plus rien à GitHub — et Keycloak ne prévient que s'il connaît l'adresse du canal arrière. Pour couper l'accès, il faut agir **localement** (désactiver le compte
|
|
545
651
|
ou détruire les sessions), pas chez le fournisseur.
|
|
546
652
|
|
|
547
653
|
### Attaques couvertes, prouvées par les tests
|
|
548
654
|
|
|
549
|
-
| Vecteur
|
|
550
|
-
|
|
|
551
|
-
| Rejeu du retour (même `code`, même `state`)
|
|
552
|
-
| `state` valide présenté au callback d'un autre IdP
|
|
553
|
-
| `iss` falsifié
|
|
554
|
-
| Prise de compte par e-mail collidant un admin
|
|
555
|
-
| Élévation de privilège par re-login
|
|
556
|
-
| Collision d'identifiants entre fournisseurs
|
|
557
|
-
| Interception du `code`
|
|
558
|
-
| Création de compte non voulue
|
|
655
|
+
| Vecteur | Défense | Preuve |
|
|
656
|
+
| ----------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------- |
|
|
657
|
+
| Rejeu du retour (même `code`, même `state`) | `state` consommé + session régénérée à la promotion | `oauth2-attack.test.ts:89` (S5) |
|
|
658
|
+
| `state` valide présenté au callback d'un autre IdP | Fournisseur attendu conservé en session et comparé | `oauth2-attack.test.ts:115` (S6) |
|
|
659
|
+
| `iss` falsifié | Comparaison stricte à l'émetteur de la politique | `oauth2Service.test.ts:218` |
|
|
660
|
+
| Prise de compte par e-mail collidant un admin | Aucune liaison auto : compte séparé, admin intact | `oauth.attack.test.ts:71` (A1) |
|
|
661
|
+
| Élévation de privilège par re-login | Rôles posés à la création, jamais réécrits | `oauth.attack.test.ts:123` (A2) |
|
|
662
|
+
| Collision d'identifiants entre fournisseurs | Clé = `provider` + `providerId` | `oauth.attack.test.ts:155` (A3) |
|
|
663
|
+
| Interception du `code` | PKCE : `code_verifier` exigé, refus si absent | `oauthProviders.test.ts:79` |
|
|
664
|
+
| Création de compte non voulue | Provisioner absent (`provisionOAuthUser`) → fail-closed | `oauth2Service.test.ts:70` |
|
|
665
|
+
| 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
|
+
| ID token présenté comme jeton de déconnexion | `events` exigé, `nonce` interdit | `oauthProviders.test.ts` |
|
|
667
|
+
| Rejeu d'un jeton de déconnexion | `jti` déjà vu → refusé | `oauth2Service.test.ts` |
|
|
559
668
|
|
|
560
669
|
## 📜 Normes appliquées
|
|
561
670
|
|
|
562
|
-
| Domaine
|
|
563
|
-
|
|
|
564
|
-
| Flux Authorization Code
|
|
565
|
-
| PKCE
|
|
566
|
-
| Sécurité OAuth (BCP 2.1)
|
|
567
|
-
| Anti-mix-up (`iss`)
|
|
568
|
-
| Callback en correspondance exacte
|
|
569
|
-
| Claims d'identité OIDC
|
|
570
|
-
| ID token consommé en code flow
|
|
571
|
-
| Anti-fixation de session
|
|
671
|
+
| Domaine | Norme | Ancrage |
|
|
672
|
+
| ------------------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
673
|
+
| Flux Authorization Code | RFC 6749 | `IOAuthProvider.validateAuthorizationCode()` (`IOAuthProvider.ts:99`) |
|
|
674
|
+
| PKCE | RFC 7636 | `usesPkce` (`IOAuthProvider.ts:72`) · `oidc.ts:104-111` |
|
|
675
|
+
| Sécurité OAuth (BCP 2.1) | RFC 9700 | `OAuth2Service` (`oauth2.ts:280`) · `oauth2Schema` (`config.ts:1357`) |
|
|
676
|
+
| Anti-mix-up (`iss`) | RFC 9207 | `issuerPolicy` (`IOAuthProvider.ts:79`) · `oauth2.ts:402-408` |
|
|
677
|
+
| Callback en correspondance exacte | RFC 9700 §4 | `redirectUri` (`config.ts:1190`) |
|
|
678
|
+
| Claims d'identité OIDC | OpenID Connect Core | `fetchProfile()` du helper OIDC (`oidc.ts:205-220`) |
|
|
679
|
+
| ID token consommé en code flow | OIDC Core §3.1.3.7 | `assertIdTokenClaims()` (`oidc.ts:143`) |
|
|
680
|
+
| Anti-fixation de session | OWASP Session Management | `session.regenerateId()` au login (`authFlow.ts:388`) |
|
|
681
|
+
| Déconnexion initiée par l'application | OpenID Connect RP-Initiated Logout 1.0 | `createLogoutURL` (`oidc.ts:224`) · `end_session_endpoint` découvert (`metadata.ts:242`) |
|
|
682
|
+
| Déconnexion par le fournisseur | OpenID Connect Back-Channel Logout 1.0 | `verifyLogoutToken` (`oidc.ts:240`) · `backchannelLogout()` (`oauth2.ts:638`) |
|
|
572
683
|
|
|
573
684
|
Flux **exclus** par posture 2.1, et donc absents du code : `implicit` (jeton en fragment d'URL) et
|
|
574
685
|
`password` / ROPC (l'application verrait le mot de passe du fournisseur).
|
|
@@ -603,33 +714,38 @@ retirer de la configuration.
|
|
|
603
714
|
technique brut.
|
|
604
715
|
|
|
605
716
|
Côté suivi, chaque login réussi produit un événement d'audit `auth` / `login.success` via
|
|
606
|
-
`AuthFlow.establishSessionFor()` (`authFlow.ts:
|
|
717
|
+
`AuthFlow.establishSessionFor()` (`authFlow.ts:245-271`), consultable dans l'écran **Audit**. La
|
|
607
718
|
session ouverte apparaît dans l'écran **Sessions** (IP et agent capturés à l'ouverture) ; le compte
|
|
608
719
|
provisionné dans l'écran **Users**, avec ses rôles réels.
|
|
609
720
|
|
|
610
721
|
> [!NOTE]
|
|
611
|
-
> L'événement d'audit du login social porte la raison
|
|
612
|
-
> (`
|
|
613
|
-
>
|
|
722
|
+
> L'événement d'audit du login social porte la raison `oauth` : le controller la passe à
|
|
723
|
+
> `AuthFlow.establishSessionFor()` (`OAuth2Controller.ts:74`), comme WebAuthn passe `webauthn`.
|
|
724
|
+
> `federated` n'est que la valeur par défaut d'un appelant qui n'a pas nommé son facteur
|
|
725
|
+
> (`authFlow.ts:248`).
|
|
614
726
|
|
|
615
727
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
616
728
|
|
|
617
|
-
| Symptôme
|
|
618
|
-
|
|
|
619
|
-
| `404` sur `…/oauth2/…`
|
|
620
|
-
|
|
|
621
|
-
|
|
|
622
|
-
|
|
|
623
|
-
|
|
|
624
|
-
|
|
|
625
|
-
|
|
|
626
|
-
|
|
|
627
|
-
|
|
|
628
|
-
|
|
|
629
|
-
|
|
|
630
|
-
|
|
|
631
|
-
|
|
|
632
|
-
|
|
|
729
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
730
|
+
| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
|
731
|
+
| `404` sur `…/oauth2/…` | Module `@nodefony/security` non chargé, ou nom de fournisseur inconnu | Charger `@nodefony/security` ; vérifier le nom du fournisseur |
|
|
732
|
+
| `503 OAuth unavailable` | `oauth2.enabled: false`, ou boot du service échoué | Activer `oauth2` ; lire le WARNING de boot |
|
|
733
|
+
| WARNING « inconnu du registre » au boot | Nom configuré sans fabrique (`OAuth2Service.#build()`) | `registerOAuthProvider()` au chargement du module, ou builtin |
|
|
734
|
+
| `404` « Unknown provider » sur `authorize` | Le nom n'est pas dans `listProviders()` (`OAuth2Controller.ts:19`) | Vérifier le nom exact **et** la présence des secrets |
|
|
735
|
+
| Bouton absent de l'écran de login | Secrets manquants → fournisseur non monté (spread conditionnel) | Renseigner `clientId`/`clientSecret` dans l'env |
|
|
736
|
+
| Bouton absent + WARNING « indisponible » au boot | Émetteur injoignable à la construction (`OAuth2Service.#resolveProvider()`) | Démarrer le serveur d'autorisation ; le bouton revient sous 30 s |
|
|
737
|
+
| `redirect_uri_mismatch` chez le fournisseur | `redirectUri` ≠ URL enregistrée, au caractère près (`config.ts:1170`) | Aligner schéma, hôte, port et chemin `/…/{provider}/callback` |
|
|
738
|
+
| 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 |
|
|
739
|
+
| Callback échoue au **deuxième** essai | `state` à usage unique, consommé (`OAuth2Controller.ts:163-165`) | Refaire le flux depuis `authorize` — comportement attendu |
|
|
740
|
+
| `OAuth issuer mismatch` | `iss` reçu ≠ l'émetteur attendu (`oauth2.ts:402-408`) | Corriger `issuer` (Keycloak : URL exacte du realm) |
|
|
741
|
+
| Boot refusé : `….providers.<nom>.issuer` | Émetteur absent (fabrique `requiresIssuer`) ou mal formé (`checkProviderIssuer()`) | Renseigner l'URL https du realm / de l'émetteur |
|
|
742
|
+
| « provisioning indisponible » | `users` n'implémente pas la capability (`oauth2.ts:863-871`) | Implémenter `provisionOAuthUser()` sur le service `users` |
|
|
743
|
+
| Profil connu refusé | `allowSignup: false` sans lien préexistant (`UserService.ts:599`) | Activer `allowSignup` ou lier le compte au préalable |
|
|
744
|
+
| Doublon de compte pour un utilisateur existant | Aucune liaison auto par e-mail (choix de sécurité) | Rattacher explicitement, utilisateur connecté |
|
|
745
|
+
| Rôle attendu absent après re-login | Rôles posés à la **création** seulement (`provisionOAuthUser`, `UserService.ts:603`), sauf `roleMapping` | Modifier les rôles en base, ou déclarer `roleMapping` ([Keycloak](keycloak.md)) |
|
|
746
|
+
| Jeton du fournisseur introuvable côté application | `IOAuthProfile` n'en porte aucun, par choix (`IOAuthUserProvisioner.ts:8-10`) | Le capturer dans son propre `fetchProfile()` et le stocker soi-même |
|
|
747
|
+
| 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 |
|
|
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 | Se reconnecter ; vérifier _Backchannel logout session required_ |
|
|
633
749
|
|
|
634
750
|
## 🧪 Tests & couverture
|
|
635
751
|
|
|
@@ -642,13 +758,14 @@ Trois familles couvrent le social login — les chiffres exacts vivent dans la c
|
|
|
642
758
|
par e-mail) ;
|
|
643
759
|
- **intégration** — `oauth2-flow.test` : le flux complet sur **serveur réel**, du `302` d'`authorize`
|
|
644
760
|
à l'identité résolue par `/me`, avec un fournisseur de test déterministe et sans réseau ;
|
|
761
|
+
`oauth2-keycloak.test` : le même flux contre un **vrai** Keycloak, déconnexions comprises (les
|
|
762
|
+
deux sens) ;
|
|
645
763
|
- **attaque** — `oauth2-attack.test` (rejeu du `state`, mix-up de fournisseur) et
|
|
646
764
|
`oauth.attack.test` (collision d'e-mail avec un admin, élévation par re-login, collision
|
|
647
765
|
d'identifiants entre fournisseurs).
|
|
648
766
|
|
|
649
767
|
Ce qui **manque** : aucun banc de charge dédié au social login (le flux est un chemin froid, deux
|
|
650
|
-
requêtes par connexion), et aucun test contre un
|
|
651
|
-
de test couvre la branche PKCE).
|
|
768
|
+
requêtes par connexion), et aucun test contre un fournisseur réel autre que Keycloak.
|
|
652
769
|
|
|
653
770
|
Couverture : `npm run coverage` dans `@nodefony/security`. Campagnes d'attaque : skill
|
|
654
771
|
`nodefony-security-review` (mode red/blue-team).
|