@nodefony/security 10.0.0-beta.2 → 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/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.152.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/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/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 +17 -17
- package/docs/audit.md +16 -16
- package/docs/authenticators.md +60 -27
- package/docs/authorization.md +16 -16
- package/docs/cors.md +29 -29
- package/docs/csrf.md +35 -33
- package/docs/external-jwt.md +66 -4
- package/docs/firewall.md +57 -24
- package/docs/headers.md +30 -30
- package/docs/index.md +12 -4
- package/docs/keycloak.md +818 -0
- package/docs/login-page.md +361 -0
- package/docs/oauth2.md +236 -122
- package/docs/obtenir-un-jeton.md +18 -11
- package/docs/tokens.md +136 -85
- package/docs/totp.md +105 -64
- package/docs/webauthn.md +90 -87
- package/docs/webhooks.md +3 -3
- 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,28 +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`, `oauth2Client.ts:
|
|
148
|
-
l'ID token — est importé **paresseusement**. Les fournisseurs sont construits
|
|
149
|
-
mémoïsés (`OAuth2Service.#resolveProvider()
|
|
150
|
-
|
|
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
|
|
151
161
|
que `@nodefony/security` est chargé (`framework/index.ts:460`) : sans social login configuré,
|
|
152
162
|
`…/providers` rend une liste vide, `authorize` rend `404 Unknown provider`, et `authorize`/`callback`
|
|
153
163
|
rendent `503 OAuth unavailable` quand `oauth2.enabled` vaut `false`.
|
|
154
164
|
|
|
155
165
|
Au boot, la config est validée et les fournisseurs configurés sont confrontés au registre : un nom
|
|
156
166
|
inconnu produit un **WARNING, pas un échec fatal** — `OAuth2Service.#build()` confronte les noms
|
|
157
|
-
configurés à `listOAuthProviders()` (`oauth2.ts:
|
|
167
|
+
configurés à `listOAuthProviders()` (`oauth2.ts:280`) et le
|
|
158
168
|
reste de l'application démarre, le bouton correspondant n'apparaît simplement pas.
|
|
159
169
|
|
|
160
170
|
## 🚀 Démarrage rapide
|
|
@@ -215,15 +225,16 @@ export default defineConfig<typeof env>((ctx) => ({
|
|
|
215
225
|
|
|
216
226
|
### Les routes sont FOURNIES — tu n'écris aucun controller
|
|
217
227
|
|
|
218
|
-
`mountOAuth2Routes()` (`OAuth2Controller.ts:
|
|
219
|
-
`/nodefony/security/api/oauth2` (`OAuth2Controller.ts:
|
|
228
|
+
`mountOAuth2Routes()` (`OAuth2Controller.ts:432`) monte quatre routes sous
|
|
229
|
+
`/nodefony/security/api/oauth2` (`OAuth2Controller.ts:373`) dès que le service `oauth2` est
|
|
220
230
|
enregistré, c'est-à-dire dès que `@nodefony/security` est chargé (`framework/index.ts:460`) :
|
|
221
231
|
|
|
222
|
-
| Route
|
|
223
|
-
|
|
|
224
|
-
| `GET …/providers`
|
|
225
|
-
| `GET …/{provider}/authorize`
|
|
226
|
-
| `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)). |
|
|
227
238
|
|
|
228
239
|
Ton écran de login n'a donc qu'un lien à poser :
|
|
229
240
|
|
|
@@ -234,10 +245,12 @@ Ton écran de login n'a donc qu'un lien à poser :
|
|
|
234
245
|
```
|
|
235
246
|
|
|
236
247
|
> [!WARNING]
|
|
237
|
-
> Ces routes portent `bypassFirewall: true` (`OAuth2Controller.ts:
|
|
248
|
+
> Ces routes portent `bypassFirewall: true` (`OAuth2Controller.ts:468`) — elles **sont** le mécanisme
|
|
238
249
|
> d'authentification : l'utilisateur est anonyme pendant tout l'aller-retour. Les protéger créerait
|
|
239
250
|
> un interblocage (il faudrait être connecté pour pouvoir se connecter). La session anonyme ne porte
|
|
240
|
-
> 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.
|
|
241
254
|
|
|
242
255
|
### Ce qu'on observe
|
|
243
256
|
|
|
@@ -256,7 +269,7 @@ curl -si -b /tmp/jar -c /tmp/jar \
|
|
|
256
269
|
|
|
257
270
|
# 3) L'identité est résolue comme après un login classique
|
|
258
271
|
curl -s -b /tmp/jar http://localhost:5151/nodefony/security/api/auth/me
|
|
259
|
-
# {"user":{"
|
|
272
|
+
# {"user":{"id":"…","identifier":"jane@example.com","roles":["ROLE_USER"]}}
|
|
260
273
|
|
|
261
274
|
# 4) Ce que l'UI de login interroge pour n'afficher que des boutons vivants
|
|
262
275
|
curl -s http://localhost:5151/nodefony/security/api/oauth2/providers
|
|
@@ -269,45 +282,67 @@ Séquence identique prouvée de bout en bout sur serveur réel par `oauth2-flow.
|
|
|
269
282
|
|
|
270
283
|
### Étape 1 — `createAuthorization(provider)`
|
|
271
284
|
|
|
272
|
-
`OAuth2Service.createAuthorization()` (`oauth2.ts:
|
|
285
|
+
`OAuth2Service.createAuthorization()` (`oauth2.ts:477`) fabrique trois choses :
|
|
273
286
|
|
|
274
287
|
1. un **`state`** aléatoire (anti-CSRF) ;
|
|
275
288
|
2. un **`code_verifier`** — **seulement si** le fournisseur pratique PKCE (`usesPkce`,
|
|
276
|
-
`oauth2.ts:
|
|
289
|
+
`oauth2.ts:458-460`) ; `null` sinon (GitHub) ;
|
|
277
290
|
3. l'**URL d'autorisation** construite par l'adaptateur du fournisseur, avec les scopes effectifs
|
|
278
|
-
(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`).
|
|
279
292
|
|
|
280
293
|
Le controller pose les trois valeurs en session, **persiste** (`session.save()` — pas seulement en
|
|
281
|
-
mémoire, `OAuth2Controller.ts:
|
|
294
|
+
mémoire, `OAuth2Controller.ts:64`), puis redirige en 302.
|
|
282
295
|
|
|
283
296
|
### Étape 2 — le retour, validé avant tout appel réseau
|
|
284
297
|
|
|
285
|
-
`OAuth2Controller.callback()` (`OAuth2Controller.ts:
|
|
298
|
+
`OAuth2Controller.callback()` (`OAuth2Controller.ts:258`) travaille dans cet ordre, et l'ordre est la
|
|
286
299
|
défense :
|
|
287
300
|
|
|
288
301
|
1. **lire l'état de session, puis l'invalider immédiatement** (`OAuth2Controller.ts:158-166`) — le
|
|
289
302
|
`state` est à **usage unique** : un rejeu du même retour échoue, même avec le bon cookie ;
|
|
290
303
|
2. **comparer** : `code` et `state` présents, `state` reçu ≡ `state` attendu, **et** fournisseur du
|
|
291
|
-
callback ≡ fournisseur démarré (`OAuth2Controller.ts:
|
|
292
|
-
`
|
|
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** ;
|
|
293
306
|
3. seulement ensuite, `exchangeAndProvision()`.
|
|
294
307
|
|
|
295
308
|
### Étape 3 — `exchangeAndProvision(provider, code, verifier, iss)`
|
|
296
309
|
|
|
297
|
-
`OAuth2Service.exchangeAndProvision()` (`oauth2.ts:
|
|
310
|
+
`OAuth2Service.exchangeAndProvision()` (`oauth2.ts:503`) enchaîne :
|
|
298
311
|
|
|
299
312
|
1. **anti-mix-up** — si le fournisseur annonce un émetteur attendu, l'`iss` reçu doit correspondre,
|
|
300
|
-
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`) ;
|
|
301
314
|
2. **échange** du `code` sur le canal serveur, avec le `code_verifier`
|
|
302
|
-
(`validateAuthorizationCode`, `oauth2.ts:
|
|
303
|
-
`oauth2.ts:
|
|
315
|
+
(`validateAuthorizationCode`, `oauth2.ts:479`), puis lecture du profil (`fetchProfile`,
|
|
316
|
+
`oauth2.ts:519`) ;
|
|
304
317
|
3. **provisionnement** du Shadow User avec la politique effective — rôles par défaut surchargeables
|
|
305
|
-
**par fournisseur** (`oauth2.ts:
|
|
318
|
+
**par fournisseur** (`oauth2.ts:417-418`), `allowSignup` global (`oauth2.ts:542`).
|
|
306
319
|
|
|
307
|
-
Toute erreur de cette étape est convertie en **échec uniforme** par le controller (`302
|
|
308
|
-
|
|
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
|
|
309
322
|
échange refusé ou d'un signup interdit.
|
|
310
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
|
+
|
|
311
346
|
## 🧑⚖️ Le Shadow User — l'identité locale, et pourquoi OAuth n'accorde aucun droit
|
|
312
347
|
|
|
313
348
|
Nodefony ne « connecte pas un compte Google ». Il crée et retrouve une **ligne locale** liée au
|
|
@@ -315,16 +350,25 @@ compte externe : le _Shadow User_. C'est cette ligne qui porte l'identifiant, le
|
|
|
315
350
|
actif/verrouillé — donc **tout** ce dont l'autorisation a besoin.
|
|
316
351
|
|
|
317
352
|
Le contrat s'appelle `IOAuthUserProvisioner` (`IOAuthUserProvisioner.ts:61`) ; l'implémentation par
|
|
318
|
-
défaut est `UserService.provisionOAuthUser()` (`UserService.ts:
|
|
353
|
+
défaut est `UserService.provisionOAuthUser()` (`UserService.ts:603`), en **find-or-create** :
|
|
319
354
|
|
|
320
355
|
| Situation au retour du fournisseur | Comportement |
|
|
321
356
|
| ---------------------------------------- | ------------------------------------------------------------------------------ |
|
|
322
357
|
| Lien social déjà connu | Le compte existant est rendu tel quel — rien n'est créé, rien n'est réécrit. |
|
|
323
358
|
| Lien inconnu, `allowSignup: true` | Création JIT : `password: null`, rôles = `defaultRoles`, lien social persisté. |
|
|
324
|
-
| 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é. |
|
|
325
360
|
| E-mail identique à un compte local | **Aucune liaison automatique** — un compte SÉPARÉ est créé. |
|
|
326
361
|
| Même `providerId` chez deux fournisseurs | Comptes séparés (le couple `provider` + `providerId` fait la clé). |
|
|
327
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
|
+
|
|
328
372
|
### Pourquoi l'e-mail ne lie jamais automatiquement un compte
|
|
329
373
|
|
|
330
374
|
C'est le point le plus contre-intuitif, et c'est une décision de sécurité. Si un compte externe dont
|
|
@@ -338,14 +382,14 @@ Conséquence assumée : l'utilisateur qui avait un mot de passe et clique « ave
|
|
|
338
382
|
faite **utilisateur déjà connecté** — jamais un effet de bord du login.
|
|
339
383
|
|
|
340
384
|
L'identifiant du compte créé dérive de l'e-mail si le fournisseur en donne un, sinon d'une clé
|
|
341
|
-
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`).
|
|
342
386
|
|
|
343
387
|
### Les rôles sont posés à la création, et plus jamais
|
|
344
388
|
|
|
345
|
-
`defaultRoles` s'applique **au moment du `create`** (`UserService.ts:
|
|
389
|
+
`defaultRoles` s'applique **au moment du `create`** (`UserService.ts:203`). Un second login
|
|
346
390
|
n'écrase rien : promouvoir quelqu'un dans ta base reste effectif, et modifier `defaultRoles` en
|
|
347
391
|
config ne repeint pas les comptes existants. C'est la traduction de la règle « OAuth =
|
|
348
|
-
authentification, pas autorisation » (`oauth2.ts:
|
|
392
|
+
authentification, pas autorisation » (`oauth2.ts:414-418`, `config.ts:1065-1070`).
|
|
349
393
|
|
|
350
394
|
> [!TIP]
|
|
351
395
|
> Un fournisseur social ne doit **jamais** figurer dans le chemin d'obtention d'un rôle privilégié.
|
|
@@ -355,7 +399,7 @@ authentification, pas autorisation » (`oauth2.ts:279-283`, `config.ts:1048-1053
|
|
|
355
399
|
### Brancher sa propre politique
|
|
356
400
|
|
|
357
401
|
Le provisioner est le service `users` **s'il implémente la capability**, détecté par duck-typing
|
|
358
|
-
(`OAuth2Service.#resolveProvisioner()`, `oauth2.ts:
|
|
402
|
+
(`OAuth2Service.#resolveProvisioner()`, `oauth2.ts:863-871`). S'il ne l'implémente pas, le login
|
|
359
403
|
**échoue** — jamais de création silencieuse par défaut. Une application qui veut sa propre politique
|
|
360
404
|
(quota d'inscriptions, allowlist de domaines e-mail, rattachement à un tenant) implémente
|
|
361
405
|
`provisionOAuthUser()` sur son service `users` : le profil normalisé `IOAuthProfile`
|
|
@@ -364,7 +408,7 @@ et la charge brute `raw`.
|
|
|
364
408
|
|
|
365
409
|
## 🧩 Fournisseurs — catalogue et extension
|
|
366
410
|
|
|
367
|
-
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
|
|
368
412
|
les divergences (PKCE ou non, profil par ID token ou par appel d'API) derrière un contrat unique.
|
|
369
413
|
Quatre sont livrés, résolus par nom via le registre `oauthProviderRegistry.ts:62`.
|
|
370
414
|
|
|
@@ -377,23 +421,25 @@ Quatre sont livrés, résolus par nom via le registre `oauthProviderRegistry.ts:
|
|
|
377
421
|
|
|
378
422
|
### `google` — OIDC, le cas nominal
|
|
379
423
|
|
|
380
|
-
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
|
|
381
425
|
(`usesPkce: true`, `oidc.ts:114`), émetteur figé `https://accounts.google.com`
|
|
382
|
-
(`oauthProviderRegistry.ts:
|
|
426
|
+
(`oauthProviderRegistry.ts:157`). Ses points d'entrée ne sont **pas** écrits en dur : ils sont
|
|
383
427
|
demandés à l'émetteur (RFC 8414, cf. « Découverte » plus bas). Le profil se lit dans l'**ID token** —
|
|
384
|
-
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
|
|
385
429
|
obligatoires d'OpenID Connect Core §3.1.3.7 : `iss`, `aud`, `exp`, et un `sub` non vide
|
|
386
|
-
(`assertIdTokenClaims()`, `oidc.ts:
|
|
430
|
+
(`assertIdTokenClaims()`, `oidc.ts:143`). Pas d'identifiant stable, pas d'identité.
|
|
387
431
|
|
|
388
432
|
### `keycloak` — OIDC self-hosted, l'émetteur vient de ta config
|
|
389
433
|
|
|
390
434
|
Même helper, mais l'**issuer** (URL du realm) sert à la fois à découvrir les points d'entrée et à
|
|
391
|
-
valider l'`iss
|
|
392
|
-
|
|
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.
|
|
393
439
|
|
|
394
440
|
### `oidc` — n'importe quel serveur OpenID Connect
|
|
395
441
|
|
|
396
|
-
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
|
|
397
443
|
l'émetteur de sa configuration et n'a besoin de rien d'autre. C'est elle qui rend inutile une classe
|
|
398
444
|
par fournisseur.
|
|
399
445
|
|
|
@@ -418,7 +464,7 @@ anti-mix-up **principale** est ailleurs — chaque fournisseur a son URL de redi
|
|
|
418
464
|
(`…/{provider}/callback`) et le flux vérifie que le fournisseur de retour est celui qui a démarré,
|
|
419
465
|
ce que la RFC 9700 §4.4.2.2 donne comme la protection de référence. `iss` est la seconde ceinture.
|
|
420
466
|
|
|
421
|
-
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
|
|
422
468
|
la découverte ; elle vaut `null` pour un fournisseur non-OIDC, qui ne relève pas de cette défense.
|
|
423
469
|
|
|
424
470
|
### Découverte des points d'entrée (RFC 8414)
|
|
@@ -430,7 +476,7 @@ points d'entrée sont demandés à l'émetteur, une seule fois par processus, au
|
|
|
430
476
|
bien connues (§3.1 : insertion oauth → insertion oidc → ajout oidc) et l'égalité stricte du §3.3
|
|
431
477
|
vivent dans le cœur (`nodefony` → `src/oauth/authorizationServer.ts`), qui s'en sert aussi pour
|
|
432
478
|
PUBLIER nos propres métadonnées. `metadata.ts` n'ajoute que le transport : requête bornée, sans
|
|
433
|
-
redirection suivie, avec un délai d'attente (`discoverAuthorizationServer()`, `metadata.ts:
|
|
479
|
+
redirection suivie, avec un délai d'attente (`discoverAuthorizationServer()`, `metadata.ts:201`).
|
|
434
480
|
|
|
435
481
|
Deux refus valent d'être connus. Un document dont l'`issuer` diffère de celui demandé est rejeté
|
|
436
482
|
**sans se rabattre** sur l'URL suivante — se rabattre masquerait un document hostile derrière un 404.
|
|
@@ -447,16 +493,16 @@ donnerait l'illusion de PKCE.
|
|
|
447
493
|
|
|
448
494
|
Pas de PKCE, pas d'ID token, pas d'`iss` (`usesPkce: false`, `issuerPolicy: null`,
|
|
449
495
|
`github.ts:68-69`) : ici, la défense anti-CSRF repose **entièrement** sur le `state`. Le profil vient
|
|
450
|
-
de l'API REST `/user` (`createGithubProvider()`, `github.ts:
|
|
496
|
+
de l'API REST `/user` (`createGithubProvider()`, `github.ts:78`). Subtilité GitHub : l'e-mail
|
|
451
497
|
primaire est souvent privé — l'adaptateur bascule alors sur `/user/emails` et n'accepte
|
|
452
|
-
`emailVerified` que si GitHub le certifie (`github.ts:
|
|
498
|
+
`emailVerified` que si GitHub le certifie (`github.ts:127-136`).
|
|
453
499
|
|
|
454
500
|
### Enregistrer le sien — sans éditer le cœur
|
|
455
501
|
|
|
456
502
|
**Tout serveur OpenID Connect conforme est déjà supporté** — Auth0, Okta, Authentik, Entra
|
|
457
503
|
mono-locataire… — sans une ligne de code propre. Le builtin `oidc` suffit quand il n'y en a qu'un ;
|
|
458
|
-
pour en nommer plusieurs, `registerOAuthProvider()`
|
|
459
|
-
|
|
504
|
+
pour en nommer plusieurs, `registerOAuthProvider()` au chargement de ton module (avant le
|
|
505
|
+
`onBoot` du service) :
|
|
460
506
|
|
|
461
507
|
```typescript ignore
|
|
462
508
|
import {
|
|
@@ -466,8 +512,12 @@ import {
|
|
|
466
512
|
|
|
467
513
|
// Le nom sert de clé de configuration ET de `provider` du Shadow User ;
|
|
468
514
|
// l'émetteur vient de la config (`oauth2.providers.microsoft.issuer`).
|
|
469
|
-
registerOAuthProvider(
|
|
470
|
-
|
|
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 },
|
|
471
521
|
);
|
|
472
522
|
```
|
|
473
523
|
|
|
@@ -480,35 +530,36 @@ protocole vient de `OAuth2Client`, la fabrique ne fait que lire le profil. C'est
|
|
|
480
530
|
lignes — `github.ts` en est le modèle.
|
|
481
531
|
|
|
482
532
|
Un fournisseur qui n'est pas OIDC (pas d'ID token, profil lu à son API) s'écrit comme GitHub
|
|
483
|
-
(`createGithubProvider()`, `github.ts:
|
|
533
|
+
(`createGithubProvider()`, `github.ts:78`) : `OAuth2Client` porte le protocole, la fabrique ne fait
|
|
484
534
|
que le mapping du profil. Exemple sans réseau dans le dépôt :
|
|
485
535
|
`src/modules/test/nodefony/secure/oauthTestProvider.ts`.
|
|
486
536
|
|
|
487
537
|
## ⚙️ Configuration
|
|
488
538
|
|
|
489
|
-
|
|
490
|
-
(`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.
|
|
491
541
|
|
|
492
|
-
| Option | Type | Défaut
|
|
493
|
-
| ----------------- | -------------------- |
|
|
494
|
-
| `enabled` | booléen | `true`
|
|
495
|
-
| `defaultRoles` | liste de rôles | `["ROLE_USER"]`
|
|
496
|
-
| `allowSignup` | booléen | `true`
|
|
497
|
-
| `successRedirect` | chemin | `/`
|
|
498
|
-
| `failureRedirect` | chemin | `/login`
|
|
499
|
-
| `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`). |
|
|
500
550
|
|
|
501
|
-
Par fournisseur (`oauthProviderSchema`, `config.ts:
|
|
551
|
+
Par fournisseur (`oauthProviderSchema`, `config.ts:1169`) :
|
|
502
552
|
|
|
503
553
|
<!-- prettier-ignore -->
|
|
504
554
|
| Option | Requis | Effet |
|
|
505
555
|
| --- | :---: | --- |
|
|
506
556
|
| `clientId` / `clientSecret` | ✅ | Identifiants délivrés par l'IdP. Secrets : par `env.ts`, jamais journalisés. |
|
|
507
|
-
| `redirectUri` | ✅ | URL de callback **exacte** (`config.ts:
|
|
557
|
+
| `redirectUri` | ✅ | URL de callback **exacte** (`config.ts:1190`). |
|
|
508
558
|
| `issuer` | OIDC self-hosted | Realm Keycloak ; ignoré par les IdP à endpoints fixes. |
|
|
509
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`. |
|
|
510
560
|
| `scopes` | | Vide = scopes par défaut du fournisseur. |
|
|
511
|
-
| `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. |
|
|
512
563
|
|
|
513
564
|
Les surcharges par fournisseur permettent la cohabitation : un IdP de recette garde ses redirections
|
|
514
565
|
et ses rôles pendant qu'un IdP de production pointe ailleurs.
|
|
@@ -518,7 +569,7 @@ et ses rôles pendant qu'un IdP de production pointe ailleurs.
|
|
|
518
569
|
### Les jetons du fournisseur ne sont pas conservés
|
|
519
570
|
|
|
520
571
|
C'est un choix, et il a des conséquences à connaître. Les jetons obtenus à l'échange vivent dans la
|
|
521
|
-
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`) :
|
|
522
573
|
ils ne sont ni retournés, ni mis en
|
|
523
574
|
session, ni persistés. Le profil normalisé qui traverse le système n'en contient aucun
|
|
524
575
|
(`IOAuthUserProvisioner.ts:8-10`).
|
|
@@ -529,47 +580,106 @@ session, ni persistés. Le profil normalisé qui traverse le système n'en conti
|
|
|
529
580
|
l'utilisateur plus tard (lire ses dépôts, envoyer un mail). Nodefony fait de l'**authentification**,
|
|
530
581
|
pas de la **délégation d'accès**.
|
|
531
582
|
- **Si tu as besoin de cette délégation** : le seul endroit où les jetons sont visibles est le
|
|
532
|
-
`fetchProfile()` de ton adaptateur (`IOAuthProvider.ts:
|
|
583
|
+
`fetchProfile()` de ton adaptateur (`IOAuthProvider.ts:106`) — c'est là que ton implémentation les
|
|
533
584
|
capture et les persiste, sous ta responsabilité (chiffrement au repos, rotation, révocation).
|
|
534
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
|
+
|
|
535
640
|
### Ce que « révoquer » veut dire ici
|
|
536
641
|
|
|
537
|
-
| Action
|
|
538
|
-
|
|
|
539
|
-
| Déconnexion (`
|
|
540
|
-
| Compte local désactivé/verrouillé
|
|
541
|
-
|
|
|
542
|
-
| `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. |
|
|
543
648
|
|
|
544
649
|
La troisième ligne est le piège courant : une fois la session BFF ouverte, ton application ne
|
|
545
|
-
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
|
|
546
651
|
ou détruire les sessions), pas chez le fournisseur.
|
|
547
652
|
|
|
548
653
|
### Attaques couvertes, prouvées par les tests
|
|
549
654
|
|
|
550
|
-
| Vecteur
|
|
551
|
-
|
|
|
552
|
-
| Rejeu du retour (même `code`, même `state`)
|
|
553
|
-
| `state` valide présenté au callback d'un autre IdP
|
|
554
|
-
| `iss` falsifié
|
|
555
|
-
| Prise de compte par e-mail collidant un admin
|
|
556
|
-
| Élévation de privilège par re-login
|
|
557
|
-
| Collision d'identifiants entre fournisseurs
|
|
558
|
-
| Interception du `code`
|
|
559
|
-
| 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` |
|
|
560
668
|
|
|
561
669
|
## 📜 Normes appliquées
|
|
562
670
|
|
|
563
|
-
| Domaine
|
|
564
|
-
|
|
|
565
|
-
| Flux Authorization Code
|
|
566
|
-
| PKCE
|
|
567
|
-
| Sécurité OAuth (BCP 2.1)
|
|
568
|
-
| Anti-mix-up (`iss`)
|
|
569
|
-
| Callback en correspondance exacte
|
|
570
|
-
| Claims d'identité OIDC
|
|
571
|
-
| ID token consommé en code flow
|
|
572
|
-
| 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`) |
|
|
573
683
|
|
|
574
684
|
Flux **exclus** par posture 2.1, et donc absents du code : `implicit` (jeton en fragment d'URL) et
|
|
575
685
|
`password` / ROPC (l'application verrait le mot de passe du fournisseur).
|
|
@@ -604,35 +714,38 @@ retirer de la configuration.
|
|
|
604
714
|
technique brut.
|
|
605
715
|
|
|
606
716
|
Côté suivi, chaque login réussi produit un événement d'audit `auth` / `login.success` via
|
|
607
|
-
`AuthFlow.establishSessionFor()` (`authFlow.ts:
|
|
717
|
+
`AuthFlow.establishSessionFor()` (`authFlow.ts:245-271`), consultable dans l'écran **Audit**. La
|
|
608
718
|
session ouverte apparaît dans l'écran **Sessions** (IP et agent capturés à l'ouverture) ; le compte
|
|
609
719
|
provisionné dans l'écran **Users**, avec ses rôles réels.
|
|
610
720
|
|
|
611
721
|
> [!NOTE]
|
|
612
722
|
> L'événement d'audit du login social porte la raison `oauth` : le controller la passe à
|
|
613
|
-
> `AuthFlow.establishSessionFor()` (`OAuth2Controller.ts:
|
|
723
|
+
> `AuthFlow.establishSessionFor()` (`OAuth2Controller.ts:74`), comme WebAuthn passe `webauthn`.
|
|
614
724
|
> `federated` n'est que la valeur par défaut d'un appelant qui n'a pas nommé son facteur
|
|
615
|
-
> (`authFlow.ts:
|
|
725
|
+
> (`authFlow.ts:248`).
|
|
616
726
|
|
|
617
727
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
618
728
|
|
|
619
|
-
| Symptôme
|
|
620
|
-
|
|
|
621
|
-
| `404` sur `…/oauth2/…`
|
|
622
|
-
| `503 OAuth unavailable`
|
|
623
|
-
| WARNING « inconnu du registre » au boot
|
|
624
|
-
| `404` « Unknown provider » sur `authorize`
|
|
625
|
-
| Bouton absent de l'écran de login
|
|
626
|
-
|
|
|
627
|
-
|
|
|
628
|
-
|
|
|
629
|
-
|
|
|
630
|
-
|
|
|
631
|
-
|
|
|
632
|
-
|
|
|
633
|
-
|
|
|
634
|
-
|
|
|
635
|
-
|
|
|
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_ |
|
|
636
749
|
|
|
637
750
|
## 🧪 Tests & couverture
|
|
638
751
|
|
|
@@ -645,13 +758,14 @@ Trois familles couvrent le social login — les chiffres exacts vivent dans la c
|
|
|
645
758
|
par e-mail) ;
|
|
646
759
|
- **intégration** — `oauth2-flow.test` : le flux complet sur **serveur réel**, du `302` d'`authorize`
|
|
647
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) ;
|
|
648
763
|
- **attaque** — `oauth2-attack.test` (rejeu du `state`, mix-up de fournisseur) et
|
|
649
764
|
`oauth.attack.test` (collision d'e-mail avec un admin, élévation par re-login, collision
|
|
650
765
|
d'identifiants entre fournisseurs).
|
|
651
766
|
|
|
652
767
|
Ce qui **manque** : aucun banc de charge dédié au social login (le flux est un chemin froid, deux
|
|
653
|
-
requêtes par connexion), et aucun test contre un
|
|
654
|
-
de test couvre la branche PKCE).
|
|
768
|
+
requêtes par connexion), et aucun test contre un fournisseur réel autre que Keycloak.
|
|
655
769
|
|
|
656
770
|
Couverture : `npm run coverage` dans `@nodefony/security`. Campagnes d'attaque : skill
|
|
657
771
|
`nodefony-security-review` (mode red/blue-team).
|