@nodefony/security 10.0.0-alpha.2 → 10.0.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_virtual/{_@oxc-project_runtime@0.148.0 → _@oxc-project_runtime@0.149.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.148.0 → _@oxc-project_runtime@0.149.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +7 -3
- package/dist/nodefony/command/security-secrets.js +7 -3
- package/dist/nodefony/command/security-token.js +7 -6
- package/dist/nodefony/command/security-user-add.js +5 -4
- package/dist/nodefony/command/security-user-delete.js +2 -1
- package/dist/nodefony/command/security-user-list.js +2 -1
- package/dist/nodefony/config/config.js +6 -3
- package/dist/nodefony/service/auditService.js +3 -3
- package/dist/nodefony/service/oauth2.js +111 -25
- package/dist/nodefony/service/tokenService.js +3 -3
- package/dist/nodefony/service/totp.js +3 -3
- package/dist/nodefony/service/webAuthn.js +3 -3
- package/dist/nodefony/service/webhooks.js +3 -3
- package/dist/nodefony/src/oauth/httpJson.js +64 -0
- package/dist/nodefony/src/oauth/metadata.js +88 -0
- package/dist/nodefony/src/oauth/oauth2Client.js +266 -0
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +5 -16
- package/dist/nodefony/src/oauth/providers/github.js +34 -9
- package/dist/nodefony/src/oauth/providers/oidc.js +87 -13
- package/dist/types/index.d.ts +8 -1
- package/dist/types/nodefony/config/config.d.ts +6 -0
- package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +45 -14
- package/dist/types/nodefony/contracts/ITokenStore.d.ts +1 -1
- package/dist/types/nodefony/service/oauth2.d.ts +47 -7
- package/dist/types/nodefony/src/oauth/httpJson.d.ts +28 -0
- package/dist/types/nodefony/src/oauth/metadata.d.ts +54 -0
- package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +174 -0
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +34 -17
- package/dist/types/nodefony/src/oauth/providers/github.d.ts +1 -1
- package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +40 -15
- package/docs/oauth2.md +154 -70
- package/package.json +9 -10
package/docs/oauth2.md
CHANGED
|
@@ -23,7 +23,7 @@ tags:
|
|
|
23
23
|
]
|
|
24
24
|
version: "doc"
|
|
25
25
|
status: stable
|
|
26
|
-
updated: 2026-07
|
|
26
|
+
updated: 2026-09-07
|
|
27
27
|
source: "src/packages/@nodefony/security/docs/oauth2.md"
|
|
28
28
|
---
|
|
29
29
|
|
|
@@ -82,24 +82,24 @@ cookie de session opaque, révocable côté serveur.
|
|
|
82
82
|
|
|
83
83
|
## 📖 Lexique
|
|
84
84
|
|
|
85
|
-
| Terme | Sens
|
|
86
|
-
| ------------------ |
|
|
87
|
-
| OAuth 2.0 | Protocole de **délégation d'accès** (RFC 6749). Ici détourné pour prouver une identité.
|
|
88
|
-
| OIDC | _OpenID Connect_ : couche d'**identité** au-dessus d'OAuth ; ajoute l'**ID token** signé.
|
|
89
|
-
| IdP | _Identity Provider_ — le fournisseur qui authentifie (Google, GitHub, Keycloak…).
|
|
90
|
-
| Authorization Code | Le flux où le serveur échange un `code` à usage unique contre des jetons. Jamais côté client.
|
|
91
|
-
| PKCE | _Proof Key for Code Exchange_ (RFC 7636) : lie la demande et l'échange (anti-interception du `code`).
|
|
92
|
-
| `code_verifier` | Le secret aléatoire gardé en session ; son empreinte (`code_challenge`) part avec la demande.
|
|
93
|
-
| `state` | Jeton anti-CSRF porté à l'aller et au retour, comparé côté serveur (RFC 9700).
|
|
94
|
-
| `iss` | Émetteur renvoyé au callback ; doit correspondre à celui attendu (anti-mix-up, RFC 9207).
|
|
95
|
-
| Mix-up | Attaque où un `code` émis par un IdP est présenté au callback d'un **autre** IdP.
|
|
96
|
-
| ID token | JWT signé par l'IdP portant les _claims_ d'identité (`sub`, `email`, `name`…).
|
|
97
|
-
| `sub` | _Subject_ : identifiant **stable** du compte chez le fournisseur (jamais l'e-mail).
|
|
98
|
-
| Claim | Une donnée d'identité attestée par l'IdP (couple clé/valeur dans l'ID token).
|
|
99
|
-
| BFF | _Backend For Frontend_ : l'identité vit en **session serveur**, pas en jeton exposé au JS.
|
|
100
|
-
| Shadow User | La ligne **locale** créée à l'image du compte externe — c'est elle qui porte les rôles.
|
|
101
|
-
| JIT | _Just In Time_ : le Shadow User est créé **au premier login**, pas par un import préalable.
|
|
102
|
-
|
|
|
85
|
+
| Terme | Sens |
|
|
86
|
+
| ------------------ | ------------------------------------------------------------------------------------------------------- |
|
|
87
|
+
| OAuth 2.0 | Protocole de **délégation d'accès** (RFC 6749). Ici détourné pour prouver une identité. |
|
|
88
|
+
| OIDC | _OpenID Connect_ : couche d'**identité** au-dessus d'OAuth ; ajoute l'**ID token** signé. |
|
|
89
|
+
| IdP | _Identity Provider_ — le fournisseur qui authentifie (Google, GitHub, Keycloak…). |
|
|
90
|
+
| Authorization Code | Le flux où le serveur échange un `code` à usage unique contre des jetons. Jamais côté client. |
|
|
91
|
+
| PKCE | _Proof Key for Code Exchange_ (RFC 7636) : lie la demande et l'échange (anti-interception du `code`). |
|
|
92
|
+
| `code_verifier` | Le secret aléatoire gardé en session ; son empreinte (`code_challenge`) part avec la demande. |
|
|
93
|
+
| `state` | Jeton anti-CSRF porté à l'aller et au retour, comparé côté serveur (RFC 9700). |
|
|
94
|
+
| `iss` | Émetteur renvoyé au callback ; doit correspondre à celui attendu (anti-mix-up, RFC 9207). |
|
|
95
|
+
| Mix-up | Attaque où un `code` émis par un IdP est présenté au callback d'un **autre** IdP. |
|
|
96
|
+
| ID token | JWT signé par l'IdP portant les _claims_ d'identité (`sub`, `email`, `name`…). |
|
|
97
|
+
| `sub` | _Subject_ : identifiant **stable** du compte chez le fournisseur (jamais l'e-mail). |
|
|
98
|
+
| Claim | Une donnée d'identité attestée par l'IdP (couple clé/valeur dans l'ID token). |
|
|
99
|
+
| BFF | _Backend For Frontend_ : l'identité vit en **session serveur**, pas en jeton exposé au JS. |
|
|
100
|
+
| Shadow User | La ligne **locale** créée à l'image du compte externe — c'est elle qui porte les rôles. |
|
|
101
|
+
| JIT | _Just In Time_ : le Shadow User est créé **au premier login**, pas par un import préalable. |
|
|
102
|
+
| Découverte | L'IdP publie ses points d'entrée (RFC 8414) : son seul émetteur suffit à le décrire, aucune URL en dur. |
|
|
103
103
|
|
|
104
104
|
## Qu'est-ce que c'est ? — et quelles failles ça ferme
|
|
105
105
|
|
|
@@ -143,9 +143,11 @@ 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
|
-
**Coût nul quand on ne s'en sert pas.**
|
|
147
|
-
(`
|
|
148
|
-
|
|
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:207`), et `jose` — seul recours externe, pour lire les claims de
|
|
148
|
+
l'ID token — est importé **paresseusement**. Les fournisseurs sont construits au premier login puis
|
|
149
|
+
mémoïsés (`OAuth2Service.#resolveProvider()`, `oauth2.ts:190`) : c'est là, une seule fois par
|
|
150
|
+
processus, que les points d'entrée d'un émetteur OIDC sont découverts. Les routes ne sont montées
|
|
149
151
|
que si le service existe (`framework/index.ts:379`) : sans social login configuré, la surface HTTP
|
|
150
152
|
est **404**, pas « désactivée ».
|
|
151
153
|
|
|
@@ -205,7 +207,7 @@ export default defineConfig<typeof env>((ctx) => ({
|
|
|
205
207
|
|
|
206
208
|
### Les routes sont FOURNIES — tu n'écris aucun controller
|
|
207
209
|
|
|
208
|
-
`mountOAuth2Routes()` (`OAuth2Controller.ts:
|
|
210
|
+
`mountOAuth2Routes()` (`OAuth2Controller.ts:208`) monte trois routes sous
|
|
209
211
|
`/nodefony/security/api/oauth2` (`OAuth2Controller.ts:187`), et **seulement si** le service `oauth2`
|
|
210
212
|
est présent (`framework/index.ts:379`) :
|
|
211
213
|
|
|
@@ -224,7 +226,7 @@ Ton écran de login n'a donc qu'un lien à poser :
|
|
|
224
226
|
```
|
|
225
227
|
|
|
226
228
|
> [!WARNING]
|
|
227
|
-
> Ces routes portent `bypassFirewall: true` (`OAuth2Controller.ts:
|
|
229
|
+
> Ces routes portent `bypassFirewall: true` (`OAuth2Controller.ts:236`) — elles **sont** le mécanisme
|
|
228
230
|
> d'authentification : l'utilisateur est anonyme pendant tout l'aller-retour. Les protéger créerait
|
|
229
231
|
> un interblocage (il faudrait être connecté pour pouvoir se connecter). La session anonyme ne porte
|
|
230
232
|
> que `state`/`code_verifier`, et son ID est **régénéré** à la promotion.
|
|
@@ -250,7 +252,7 @@ curl -s -b /tmp/jar http://localhost:5151/nodefony/security/api/auth/me
|
|
|
250
252
|
|
|
251
253
|
# 4) Ce que l'UI de login interroge pour n'afficher que des boutons vivants
|
|
252
254
|
curl -s http://localhost:5151/nodefony/security/api/oauth2/providers
|
|
253
|
-
# {"providers":["github"]}
|
|
255
|
+
# {"providers":[{"name":"github","label":"GitHub"}]}
|
|
254
256
|
```
|
|
255
257
|
|
|
256
258
|
Séquence identique prouvée de bout en bout sur serveur réel par `oauth2-flow.test.ts` (6 cas).
|
|
@@ -272,7 +274,7 @@ mémoire, `OAuth2Controller.ts:105-108`), puis redirige en 302.
|
|
|
272
274
|
|
|
273
275
|
### Étape 2 — le retour, validé avant tout appel réseau
|
|
274
276
|
|
|
275
|
-
`OAuth2Controller.callback()` (`OAuth2Controller.ts:
|
|
277
|
+
`OAuth2Controller.callback()` (`OAuth2Controller.ts:132`) travaille dans cet ordre, et l'ordre est la
|
|
276
278
|
défense :
|
|
277
279
|
|
|
278
280
|
1. **lire l'état de session, puis l'invalider immédiatement** (`OAuth2Controller.ts:126-129`) — le
|
|
@@ -289,7 +291,7 @@ défense :
|
|
|
289
291
|
1. **anti-mix-up** — si le fournisseur annonce un émetteur attendu, l'`iss` reçu doit correspondre,
|
|
290
292
|
et un `iss` **absent** est un rejet, pas une tolérance (`oauth2.ts:170-174`) ;
|
|
291
293
|
2. **échange** du `code` sur le canal serveur, avec le `code_verifier`
|
|
292
|
-
(`validateAuthorizationCode`, `oauth2.ts:
|
|
294
|
+
(`validateAuthorizationCode`, `oauth2.ts:181`), puis lecture du profil (`fetchProfile`,
|
|
293
295
|
`oauth2.ts:176`) ;
|
|
294
296
|
3. **provisionnement** du Shadow User avec la politique effective — rôles par défaut surchargeables
|
|
295
297
|
**par fournisseur** (`oauth2.ts:180-181`), `allowSignup` global (`oauth2.ts:182-185`).
|
|
@@ -356,31 +358,86 @@ et la charge brute `raw`.
|
|
|
356
358
|
|
|
357
359
|
Un fournisseur est un adaptateur qui implémente `IOAuthProvider` (`IOAuthProvider.ts:21`) : il masque
|
|
358
360
|
les divergences (PKCE ou non, profil par ID token ou par appel d'API) derrière un contrat unique.
|
|
359
|
-
|
|
361
|
+
Quatre sont livrés, résolus par nom via le registre `oauthProviderRegistry.ts:50`.
|
|
360
362
|
|
|
361
363
|
| Nom | Famille | PKCE | `iss` vérifié | Profil lu depuis | Scopes par défaut |
|
|
362
364
|
| ---------- | ---------------- | :--: | --------------------- | ----------------- | ---------------------------- |
|
|
363
365
|
| `google` | OIDC | ✅ | `accounts.google.com` | ID token (claims) | `openid`, `profile`, `email` |
|
|
364
366
|
| `keycloak` | OIDC self-hosted | ✅ | URL du realm (config) | ID token (claims) | `openid`, `profile`, `email` |
|
|
367
|
+
| `oidc` | OIDC générique | ✅ | émetteur (config) | ID token (claims) | `openid`, `profile`, `email` |
|
|
365
368
|
| `github` | OAuth simple | ❌ | — (non émis) | API REST `/user` | `read:user`, `user:email` |
|
|
366
369
|
|
|
367
370
|
### `google` — OIDC, le cas nominal
|
|
368
371
|
|
|
369
|
-
Construit par le helper générique `createOidcProvider()` (`oidc.ts:
|
|
370
|
-
(`usesPkce: true`, `oidc.ts:
|
|
371
|
-
(`oauthProviderRegistry.ts:
|
|
372
|
-
|
|
373
|
-
|
|
372
|
+
Construit par le helper générique `createOidcProvider()` (`oidc.ts:103`) : PKCE systématique
|
|
373
|
+
(`usesPkce: true`, `oidc.ts:111`), émetteur figé `https://accounts.google.com`
|
|
374
|
+
(`oauthProviderRegistry.ts:80`). Ses points d'entrée ne sont **pas** écrits en dur : ils sont
|
|
375
|
+
demandés à l'émetteur (RFC 8414, cf. « Découverte » plus bas). Le profil se lit dans l'**ID token** —
|
|
376
|
+
claims standard `sub`, `email`, `email_verified`, `name` (`oidc.ts:134`), après les contrôles
|
|
377
|
+
obligatoires d'OpenID Connect Core §3.1.3.7 : `iss`, `aud`, `exp`, et un `sub` non vide
|
|
378
|
+
(`assertIdTokenClaims()`, `oidc.ts:132`). Pas d'identifiant stable, pas d'identité.
|
|
374
379
|
|
|
375
380
|
### `keycloak` — OIDC self-hosted, l'émetteur vient de ta config
|
|
376
381
|
|
|
377
|
-
Même helper, mais l'**issuer** (URL du realm) sert à la fois à
|
|
378
|
-
l'`iss` (`oauthProviderRegistry.ts:
|
|
379
|
-
au premier login avec un message explicite
|
|
382
|
+
Même helper, mais l'**issuer** (URL du realm) sert à la fois à découvrir les points d'entrée et à
|
|
383
|
+
valider l'`iss` (`oauthProviderRegistry.ts:85`). Il est donc **obligatoire** : sans lui, la fabrique
|
|
384
|
+
lève au premier login avec un message explicite.
|
|
385
|
+
|
|
386
|
+
### `oidc` — n'importe quel serveur OpenID Connect
|
|
387
|
+
|
|
388
|
+
La même mécanique, sans nom de marque : l'entrée `oidc` (`oauthProviderRegistry.ts:89`) prend
|
|
389
|
+
l'émetteur de sa configuration et n'a besoin de rien d'autre. C'est elle qui rend inutile une classe
|
|
390
|
+
par fournisseur.
|
|
391
|
+
|
|
392
|
+
### Le paramètre `iss` — une règle à TROIS états, pas deux
|
|
393
|
+
|
|
394
|
+
La RFC 9207 ajoute un paramètre `iss` à la réponse d'autorisation, pour qu'un client branché sur
|
|
395
|
+
plusieurs fournisseurs ne confonde pas leurs réponses. Mais elle ne l'impose pas à tous : son §2.4
|
|
396
|
+
demande au client d'extraire `iss` **« if the parameter is present »**, et son §2.3 fait ANNONCER ce
|
|
397
|
+
support par les métadonnées de l'émetteur (`authorization_response_iss_parameter_supported`).
|
|
398
|
+
|
|
399
|
+
D'où trois cas, et non deux :
|
|
400
|
+
|
|
401
|
+
| Le serveur l'annonce | `iss` reçu | Verdict |
|
|
402
|
+
| :------------------: | --------------------- | ---------------------------------------- |
|
|
403
|
+
| oui | absent | **refus** — il a promis, il n'a pas tenu |
|
|
404
|
+
| oui ou non | présent et discordant | **refus** |
|
|
405
|
+
| non | absent | on continue — le serveur est conforme |
|
|
406
|
+
|
|
407
|
+
Exiger `iss` d'un serveur qui n'a jamais promis de l'émettre reviendrait à refuser un serveur
|
|
408
|
+
conforme (Microsoft Entra n'annonce pas ce support). Ce n'est pas un relâchement : la défense
|
|
409
|
+
anti-mix-up **principale** est ailleurs — chaque fournisseur a son URL de redirection propre
|
|
410
|
+
(`…/{provider}/callback`) et le flux vérifie que le fournisseur de retour est celui qui a démarré,
|
|
411
|
+
ce que la RFC 9700 §4.4.2.2 donne comme la protection de référence. `iss` est la seconde ceinture.
|
|
412
|
+
|
|
413
|
+
La politique est portée par le fournisseur (`issuerPolicy`, `IOAuthProvider.ts:61`) et remplie par
|
|
414
|
+
la découverte ; elle vaut `null` pour un fournisseur non-OIDC, qui ne relève pas de cette défense.
|
|
415
|
+
|
|
416
|
+
### Découverte des points d'entrée (RFC 8414)
|
|
417
|
+
|
|
418
|
+
Aucune URL de fournisseur n'est écrite en dur — sauf GitHub, qui ne publie pas de métadonnées. Les
|
|
419
|
+
points d'entrée sont demandés à l'émetteur, une seule fois par processus, au premier login.
|
|
420
|
+
|
|
421
|
+
**Cette règle n'est pas réécrite ici** : la normalisation de l'émetteur, l'ordre normatif des URL
|
|
422
|
+
bien connues (§3.1 : insertion oauth → insertion oidc → ajout oidc) et l'égalité stricte du §3.3
|
|
423
|
+
vivent dans le cœur (`nodefony` → `src/oauth/authorizationServer.ts`), qui s'en sert aussi pour
|
|
424
|
+
PUBLIER nos propres métadonnées. `metadata.ts` n'ajoute que le transport : requête bornée, sans
|
|
425
|
+
redirection suivie, avec un délai d'attente (`discoverAuthorizationServer()`, `metadata.ts:126`).
|
|
426
|
+
|
|
427
|
+
Deux refus valent d'être connus. Un document dont l'`issuer` diffère de celui demandé est rejeté
|
|
428
|
+
**sans se rabattre** sur l'URL suivante — se rabattre masquerait un document hostile derrière un 404.
|
|
429
|
+
Et un émetteur qui annonce ses méthodes PKCE sans y mettre `S256` est refusé : lui envoyer un défi
|
|
430
|
+
donnerait l'illusion de PKCE.
|
|
431
|
+
|
|
432
|
+
> [!NOTE]
|
|
433
|
+
> **Microsoft Entra** : un locataire nommé (`…/{tenant-id}/v2.0`) se découvre normalement. Les
|
|
434
|
+
> points d'entrée **`common`** et **`organizations`**, eux, publient un `issuer` contenant le
|
|
435
|
+
> gabarit littéral `{tenantid}` — l'égalité du §3.3 le refuse, à raison. Le multi-locataire demande
|
|
436
|
+
> donc un adaptateur dédié, pas le builtin.
|
|
380
437
|
|
|
381
438
|
### `github` — OAuth simple, l'archétype non-OIDC
|
|
382
439
|
|
|
383
|
-
Pas de PKCE, pas d'ID token, pas d'`iss` (`usesPkce: false`, `
|
|
440
|
+
Pas de PKCE, pas d'ID token, pas d'`iss` (`usesPkce: false`, `issuerPolicy: null`,
|
|
384
441
|
`github.ts:43-44`) : ici, la défense anti-CSRF repose **entièrement** sur le `state`. Le profil vient
|
|
385
442
|
de l'API REST `/user` (`createGithubProvider()`, `github.ts:34`). Subtilité GitHub : l'e-mail
|
|
386
443
|
primaire est souvent privé — l'adaptateur bascule alors sur `/user/emails` et n'accepte
|
|
@@ -388,32 +445,36 @@ primaire est souvent privé — l'adaptateur bascule alors sur `/user/emails` et
|
|
|
388
445
|
|
|
389
446
|
### Enregistrer le sien — sans éditer le cœur
|
|
390
447
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
(`oauthProviderRegistry.ts:
|
|
448
|
+
**Tout serveur OpenID Connect conforme est déjà supporté** — Auth0, Okta, Authentik, Entra
|
|
449
|
+
mono-locataire… — sans une ligne de code propre. Le builtin `oidc` suffit quand il n'y en a qu'un ;
|
|
450
|
+
pour en nommer plusieurs, `registerOAuthProvider()` (`oauthProviderRegistry.ts:56`) au chargement de
|
|
451
|
+
ton module (avant le `onBoot` du service) :
|
|
394
452
|
|
|
395
453
|
```typescript ignore
|
|
396
|
-
import {
|
|
454
|
+
import {
|
|
455
|
+
registerOAuthProvider,
|
|
456
|
+
createDiscoveredOidcProvider,
|
|
457
|
+
} from "@nodefony/security";
|
|
397
458
|
|
|
398
|
-
//
|
|
459
|
+
// Le nom sert de clé de configuration ET de `provider` du Shadow User ;
|
|
460
|
+
// l'émetteur vient de la config (`oauth2.providers.microsoft.issuer`).
|
|
399
461
|
registerOAuthProvider("microsoft", (ctx) =>
|
|
400
|
-
|
|
401
|
-
name: "microsoft",
|
|
402
|
-
client: new ctx.arctic.MicrosoftEntraId(
|
|
403
|
-
tenantId,
|
|
404
|
-
ctx.clientId,
|
|
405
|
-
ctx.clientSecret,
|
|
406
|
-
ctx.redirectUri,
|
|
407
|
-
),
|
|
408
|
-
issuer: `https://login.microsoftonline.com/${tenantId}/v2.0`,
|
|
409
|
-
decodeIdToken: ctx.arctic.decodeIdToken,
|
|
410
|
-
}),
|
|
462
|
+
createDiscoveredOidcProvider("microsoft", ctx),
|
|
411
463
|
);
|
|
412
464
|
```
|
|
413
465
|
|
|
414
|
-
La fabrique reçoit `IOAuthProviderContext` (`oauthProviderRegistry.ts:
|
|
415
|
-
|
|
416
|
-
|
|
466
|
+
La fabrique reçoit `IOAuthProviderContext` (`oauthProviderRegistry.ts:24`) : les secrets et l'URL de
|
|
467
|
+
callback issus de la config, rien d'autre. Elle peut être **asynchrone** — découvrir un émetteur est
|
|
468
|
+
une opération de construction, faite une fois par processus.
|
|
469
|
+
|
|
470
|
+
Un fournisseur qui n'est **pas** OIDC (pas de métadonnées, pas d'ID token) demande un adaptateur : le
|
|
471
|
+
protocole vient de `OAuth2Client`, la fabrique ne fait que lire le profil. C'est une quarantaine de
|
|
472
|
+
lignes — `github.ts` en est le modèle.
|
|
473
|
+
|
|
474
|
+
Un fournisseur qui n'est pas OIDC (pas d'ID token, profil lu à son API) s'écrit comme GitHub
|
|
475
|
+
(`createGithubProvider()`, `github.ts:40`) : `OAuth2Client` porte le protocole, la fabrique ne fait
|
|
476
|
+
que le mapping du profil. Exemple sans réseau dans le dépôt :
|
|
477
|
+
`src/modules/test/nodefony/secure/oauthTestProvider.ts`.
|
|
417
478
|
|
|
418
479
|
## ⚙️ Configuration
|
|
419
480
|
|
|
@@ -437,6 +498,7 @@ Par fournisseur (`oauthProviderSchema`, `config.ts:948`) :
|
|
|
437
498
|
| `clientId` / `clientSecret` | ✅ | Identifiants délivrés par l'IdP. Secrets : par `env.ts`, jamais journalisés. |
|
|
438
499
|
| `redirectUri` | ✅ | URL de callback **exacte** (`config.ts:958`). |
|
|
439
500
|
| `issuer` | OIDC self-hosted | Realm Keycloak ; ignoré par les IdP à endpoints fixes. |
|
|
501
|
+
| `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`. |
|
|
440
502
|
| `scopes` | | Vide = scopes par défaut du fournisseur. |
|
|
441
503
|
| `successRedirect` / `failureRedirect` / `defaultRoles` | | Surchargent le global **pour ce fournisseur** (`oauth2.ts:124-131`). |
|
|
442
504
|
|
|
@@ -448,7 +510,7 @@ et ses rôles pendant qu'un IdP de production pointe ailleurs.
|
|
|
448
510
|
### Les jetons du fournisseur ne sont pas conservés
|
|
449
511
|
|
|
450
512
|
C'est un choix, et il a des conséquences à connaître. Les jetons obtenus à l'échange vivent dans la
|
|
451
|
-
portée locale de l'échange (`validateAuthorizationCode` puis `fetchProfile`, `oauth2.ts:
|
|
513
|
+
portée locale de l'échange (`validateAuthorizationCode` puis `fetchProfile`, `oauth2.ts:181-182`) :
|
|
452
514
|
ils ne sont ni retournés, ni mis en
|
|
453
515
|
session, ni persistés. Le profil normalisé qui traverse le système n'en contient aucun
|
|
454
516
|
(`IOAuthUserProvisioner.ts:8-10`).
|
|
@@ -459,7 +521,7 @@ session, ni persistés. Le profil normalisé qui traverse le système n'en conti
|
|
|
459
521
|
l'utilisateur plus tard (lire ses dépôts, envoyer un mail). Nodefony fait de l'**authentification**,
|
|
460
522
|
pas de la **délégation d'accès**.
|
|
461
523
|
- **Si tu as besoin de cette délégation** : le seul endroit où les jetons sont visibles est le
|
|
462
|
-
`fetchProfile()` de ton adaptateur (`IOAuthProvider.ts:
|
|
524
|
+
`fetchProfile()` de ton adaptateur (`IOAuthProvider.ts:90`) — c'est là que ton implémentation les
|
|
463
525
|
capture et les persiste, sous ta responsabilité (chiffrement au repos, rotation, révocation).
|
|
464
526
|
|
|
465
527
|
### Ce que « révoquer » veut dire ici
|
|
@@ -481,24 +543,24 @@ ou détruire les sessions), pas chez le fournisseur.
|
|
|
481
543
|
| -------------------------------------------------- | ------------------------------------------------------- | -------------------------------- |
|
|
482
544
|
| 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) |
|
|
483
545
|
| `state` valide présenté au callback d'un autre IdP | Fournisseur attendu conservé en session et comparé | `oauth2-attack.test.ts:115` (S6) |
|
|
484
|
-
| `iss` falsifié
|
|
546
|
+
| `iss` falsifié | Comparaison stricte à l'émetteur de la politique | `oauth2Service.test.ts:167` |
|
|
485
547
|
| Prise de compte par e-mail collidant un admin | Aucune liaison auto : compte séparé, admin intact | `oauth.attack.test.ts:71` (A1) |
|
|
486
548
|
| Élévation de privilège par re-login | Rôles posés à la création, jamais réécrits | `oauth.attack.test.ts:123` (A2) |
|
|
487
549
|
| Collision d'identifiants entre fournisseurs | Clé = `provider` + `providerId` | `oauth.attack.test.ts:155` (A3) |
|
|
488
550
|
| Interception du `code` | PKCE : `code_verifier` exigé, refus si absent | `oauthProviders.test.ts:67` |
|
|
489
|
-
| Création de compte non voulue | Provisioner absent (`provisionOAuthUser`) → fail-closed | `oauth2Service.test.ts:
|
|
551
|
+
| Création de compte non voulue | Provisioner absent (`provisionOAuthUser`) → fail-closed | `oauth2Service.test.ts:52` |
|
|
490
552
|
|
|
491
553
|
## 📜 Normes appliquées
|
|
492
554
|
|
|
493
555
|
| Domaine | Norme | Ancrage |
|
|
494
556
|
| --------------------------------- | ------------------------ | --------------------------------------------------------------------- |
|
|
495
|
-
| Flux Authorization Code | RFC 6749 | `IOAuthProvider.validateAuthorizationCode()` (`IOAuthProvider.ts:
|
|
496
|
-
| PKCE | RFC 7636 | `usesPkce` (`IOAuthProvider.ts:
|
|
497
|
-
| Sécurité OAuth (BCP 2.1) | RFC 9700 | `OAuth2Service` (`oauth2.ts:
|
|
498
|
-
| Anti-mix-up (`iss`) | RFC 9207 | `
|
|
557
|
+
| Flux Authorization Code | RFC 6749 | `IOAuthProvider.validateAuthorizationCode()` (`IOAuthProvider.ts:85`) |
|
|
558
|
+
| PKCE | RFC 7636 | `usesPkce` (`IOAuthProvider.ts:58`) · `oidc.ts:104-111` |
|
|
559
|
+
| Sécurité OAuth (BCP 2.1) | RFC 9700 | `OAuth2Service` (`oauth2.ts:56`) · `oauth2Schema` (`config.ts:1001`) |
|
|
560
|
+
| Anti-mix-up (`iss`) | RFC 9207 | `issuerPolicy` (`IOAuthProvider.ts:61`) · `oauth2.ts:170-181` |
|
|
499
561
|
| Callback en correspondance exacte | RFC 9700 §4 | `redirectUri` (`config.ts:958`) |
|
|
500
|
-
| Claims d'identité OIDC | OpenID Connect Core | `fetchProfile()` du helper OIDC (`oidc.ts:
|
|
501
|
-
| ID token consommé en code flow |
|
|
562
|
+
| Claims d'identité OIDC | OpenID Connect Core | `fetchProfile()` du helper OIDC (`oidc.ts:127-145`) |
|
|
563
|
+
| ID token consommé en code flow | OIDC Core §3.1.3.7 | `assertIdTokenClaims()` (`oidc.ts:132`) |
|
|
502
564
|
| Anti-fixation de session | OWASP Session Management | `session.regenerateId()` au login (`authFlow.ts:388`) |
|
|
503
565
|
|
|
504
566
|
Flux **exclus** par posture 2.1, et donc absents du code : `implicit` (jeton en fragment d'URL) et
|
|
@@ -507,9 +569,31 @@ Flux **exclus** par posture 2.1, et donc absents du code : `implicit` (jeton en
|
|
|
507
569
|
## 📡 Observabilité — Studio
|
|
508
570
|
|
|
509
571
|
L'écran de connexion de Studio consomme directement le data plane : il interroge
|
|
510
|
-
`/nodefony/security/api/oauth2/providers`
|
|
511
|
-
|
|
512
|
-
|
|
572
|
+
`/nodefony/security/api/oauth2/providers` et affiche **tout** ce que cette route lui rend — zéro
|
|
573
|
+
bouton mort, et zéro fournisseur légitime masqué. Le clic déclenche la redirection vers `authorize`.
|
|
574
|
+
|
|
575
|
+
C'est le SERVEUR qui décide de la liste et des libellés, parce qu'il est le seul à lire la
|
|
576
|
+
configuration. L'écran ne connaît que des icônes de marque, pour l'esthétique : un fournisseur
|
|
577
|
+
qu'il ne reconnaît pas reçoit une icône neutre et reste affiché. Filtrer côté écran sur une table
|
|
578
|
+
de marques masquerait précisément les fournisseurs qu'une application enregistre elle-même —
|
|
579
|
+
Keycloak, ou un OIDC d'entreprise.
|
|
580
|
+
|
|
581
|
+
**Retirer un bouton sans fermer le flux** — `hidden: true` sur un fournisseur :
|
|
582
|
+
|
|
583
|
+
```ts
|
|
584
|
+
providers: {
|
|
585
|
+
"test-oidc": { /* … */ hidden: true }, // absent de l'écran…
|
|
586
|
+
}
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
…mais `/authorize` continue de répondre `302` : **masquer n'est pas désactiver**. Les deux usages
|
|
590
|
+
sont une fixture de développement qui pointe vers un serveur fictif (le bouton serait mort), et un
|
|
591
|
+
fournisseur réservé à un point d'entrée particulier. Pour le désactiver vraiment, il faut le
|
|
592
|
+
retirer de la configuration.
|
|
593
|
+
|
|
594
|
+
**Le libellé** vient de `label`, sinon il est dérivé du nom de la clé (`keycloak` → « Keycloak »,
|
|
595
|
+
`oidc` → « OIDC », `mon-idp` → « Mon Idp ») : un écran de connexion ne montre jamais un identifiant
|
|
596
|
+
technique brut.
|
|
513
597
|
|
|
514
598
|
Côté suivi, chaque login réussi produit un événement d'audit `auth` / `login.success` via
|
|
515
599
|
`AuthFlow.establishSessionFor()` (`authFlow.ts:229-236`), consultable dans l'écran **Audit**. La
|
|
@@ -532,7 +616,7 @@ provisionné dans l'écran **Users**, avec ses rôles réels.
|
|
|
532
616
|
| `redirect_uri_mismatch` chez le fournisseur | `redirectUri` ≠ URL enregistrée, au caractère près (`config.ts:958`) | Aligner schéma, hôte, port et chemin `/…/{provider}/callback` |
|
|
533
617
|
| 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 |
|
|
534
618
|
| Callback échoue au **deuxième** essai | `state` à usage unique, consommé (`OAuth2Controller.ts:126-129`) | Refaire le flux depuis `authorize` — comportement attendu |
|
|
535
|
-
| `OAuth issuer mismatch` | `iss` reçu ≠
|
|
619
|
+
| `OAuth issuer mismatch` | `iss` reçu ≠ l'émetteur attendu (`oauth2.ts:170-181`) | Corriger `issuer` (Keycloak : URL exacte du realm) |
|
|
536
620
|
| Keycloak : erreur dès le premier login | `issuer` absent en config (`oauthProviderRegistry.ts:89-93`) | Renseigner l'URL du realm |
|
|
537
621
|
| « provisioning indisponible » | `users` n'implémente pas la capability (`oauth2.ts:224-231`) | Implémenter `provisionOAuthUser()` sur le service `users` |
|
|
538
622
|
| Profil connu refusé | `allowSignup: false` sans lien préexistant (`UserService.ts:363`) | Activer `allowSignup` ou lier le compte au préalable |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nodefony/security",
|
|
3
|
-
"version": "10.0.0-alpha.
|
|
3
|
+
"version": "10.0.0-alpha.4",
|
|
4
4
|
"description": "Pare-feu applicatif par zones pour Nodefony : authentification, autorisation par rôles, protection CSRF, journal d'audit",
|
|
5
5
|
"author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -45,18 +45,17 @@
|
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@simplewebauthn/server": "14.0.1",
|
|
48
|
-
"arctic": "^3.7.0",
|
|
49
48
|
"jose": "6.2.12",
|
|
50
49
|
"tslib": "2.8.1",
|
|
51
50
|
"zod": "^4.4.3"
|
|
52
51
|
},
|
|
53
52
|
"devDependencies": {
|
|
54
|
-
"@nodefony/framework": "^10.0.0-alpha.
|
|
55
|
-
"@nodefony/http": "^10.0.0-alpha.
|
|
56
|
-
"@nodefony/user": "^10.0.0-alpha.
|
|
53
|
+
"@nodefony/framework": "^10.0.0-alpha.4",
|
|
54
|
+
"@nodefony/http": "^10.0.0-alpha.4",
|
|
55
|
+
"@nodefony/user": "^10.0.0-alpha.4",
|
|
57
56
|
"@types/node": "26.4.1",
|
|
58
57
|
"@vitest/coverage-v8": "5.0.0",
|
|
59
|
-
"nodefony": "^10.0.0-alpha.
|
|
58
|
+
"nodefony": "^10.0.0-alpha.4",
|
|
60
59
|
"rimraf": "6.1.3",
|
|
61
60
|
"vitest": "5.0.0"
|
|
62
61
|
},
|
|
@@ -64,10 +63,10 @@
|
|
|
64
63
|
"readmeFilename": "README.md",
|
|
65
64
|
"contributors": [],
|
|
66
65
|
"peerDependencies": {
|
|
67
|
-
"@nodefony/framework": "^10.0.0-alpha.
|
|
68
|
-
"@nodefony/http": "^10.0.0-alpha.
|
|
69
|
-
"@nodefony/user": "^10.0.0-alpha.
|
|
70
|
-
"nodefony": "^10.0.0-alpha.
|
|
66
|
+
"@nodefony/framework": "^10.0.0-alpha.4",
|
|
67
|
+
"@nodefony/http": "^10.0.0-alpha.4",
|
|
68
|
+
"@nodefony/user": "^10.0.0-alpha.4",
|
|
69
|
+
"nodefony": "^10.0.0-alpha.4"
|
|
71
70
|
},
|
|
72
71
|
"files": [
|
|
73
72
|
"dist",
|