@nodefony/security 10.0.0-alpha.2 → 10.0.0-alpha.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.
@@ -1,39 +1,56 @@
1
- import type * as Arctic from "arctic";
2
1
  import type { IOAuthProvider } from "../../contracts/IOAuthProvider.js";
2
+ import type { OAuth2ClientAuthMethod } from "./oauth2Client.js";
3
3
  /**
4
4
  * Registre de **fabriques de fournisseurs OAuth** — résout un nom configuré
5
5
  * (`oauth2.providers.<name>`) vers un {@link IOAuthProvider}, SANS coupler le
6
6
  * cœur à un fournisseur en dur.
7
7
  *
8
8
  * Convention-frère : `tokenStoreRegistry`, `authenticatorRegistry`,
9
- * `webAuthnCredentialStoreRegistry`. Les builtins (`google`, `github`) couvrent
10
- * les deux archétypes (OIDC+PKCE / OAuth simple) ; une application enregistre les
11
- * ~50 autres fournisseurs `arctic` (Microsoft, Apple, Discord...) ou un
12
- * fournisseur maison via {@link registerOAuthProvider}, sans éditer le core.
9
+ * `webAuthnCredentialStoreRegistry`.
13
10
  *
14
- * @remarks `arctic` n'est ici qu'un **type** : l'instance runtime, chargée
15
- * paresseusement par `OAuth2Service` au premier login, est passée à la fabrique
16
- * via {@link IOAuthProviderContext.arctic} zéro import runtime statique.
11
+ * @remarks Aucun fournisseur n'a de code propre : un serveur OpenID Connect publie
12
+ * ses points d'entrée (RFC 8414 / OpenID Connect Discovery), donc son seul émetteur
13
+ * suffit à le décrire. Enregistrer Microsoft Entra, Auth0, Okta ou Authentik tient
14
+ * en une ligne dans l'application :
15
+ *
16
+ * ```ts
17
+ * registerOAuthProvider("azure", (ctx) => createDiscoveredOidcProvider("azure", ctx));
18
+ * ```
17
19
  */
18
- /** Contexte de construction d'un fournisseur (lib arctic chargée + secrets de config). */
20
+ /** Contexte de construction d'un fournisseur (secrets et URL issus de la config). */
19
21
  export interface IOAuthProviderContext {
20
- /** Module `arctic` chargé paresseusement (les classes de fournisseurs). */
21
- readonly arctic: typeof Arctic;
22
22
  /** Identifiant client (config, issu de l'env de l'app). */
23
23
  readonly clientId: string;
24
- /** Secret client (config) — jamais loggé. */
24
+ /** Secret client (config) — jamais loggé ; vide pour un client public. */
25
25
  readonly clientSecret: string;
26
+ /**
27
+ * Comment le client s'authentifie au point de jeton, quand l'application le
28
+ * DÉCLARE ; `undefined` laisse le fournisseur poser son défaut.
29
+ *
30
+ * Elle remonte jusqu'ici parce qu'elle appartient au serveur d'autorisation,
31
+ * pas au code du fournisseur : un même Keycloak peut exiger `client_secret_post`
32
+ * là où un autre veut Basic, et ils l'annoncent dans leurs métadonnées. Sans ce
33
+ * champ, la forme serait ouverte dans le client et INATTEIGNABLE depuis une
34
+ * application — donc absente.
35
+ */
36
+ readonly clientAuthMethod?: OAuth2ClientAuthMethod;
26
37
  /** URL de callback exacte (RFC 9700). */
27
38
  readonly redirectUri: string;
28
39
  /**
29
- * Émetteur/realm des fournisseurs OIDC self-hosted (Keycloak : URL du realm,
30
- * ex. `https://kc.example/realms/app`) — `undefined` pour les fournisseurs à
31
- * endpoints fixes (Google, GitHub).
40
+ * Émetteur du fournisseur OIDC (Keycloak : URL du realm, ex.
41
+ * `https://kc.example/realms/app`) — `undefined` pour les fournisseurs dont
42
+ * l'émetteur est connu d'avance (Google) ou qui n'en publient pas (GitHub).
32
43
  */
33
44
  readonly issuer?: string;
34
45
  }
35
- /** Fabrique d'un fournisseur OAuth pour un nom donné. */
36
- export type OAuthProviderFactory = (ctx: IOAuthProviderContext) => IOAuthProvider;
46
+ /**
47
+ * Fabrique d'un fournisseur OAuth pour un nom donné.
48
+ *
49
+ * @remarks Elle peut être **asynchrone** : découvrir les points d'entrée d'un
50
+ * émetteur est une opération de construction, faite une seule fois par processus
51
+ * (le service mémoïse le fournisseur résolu).
52
+ */
53
+ export type OAuthProviderFactory = (ctx: IOAuthProviderContext) => IOAuthProvider | Promise<IOAuthProvider>;
37
54
  /**
38
55
  * Enregistre (ou remplace) la fabrique d'un fournisseur OAuth. Appelée par les
39
56
  * builtins au chargement, et par une application pour ses fournisseurs.
@@ -4,6 +4,6 @@ import type { IOAuthProviderContext } from "../oauthProviderRegistry.js";
4
4
  * Fournisseur **GitHub** (OAuth 2.0 simple, NON-OIDC). Pas de PKCE, pas d'ID
5
5
  * token : le profil est lu via l'API REST (`/user`), et l'email — souvent privé —
6
6
  * via `/user/emails` (scope `user:email`). GitHub n'émet pas de paramètre `iss`
7
- * (`expectedIssuer = null`) : la défense anti-CSRF repose sur le `state`.
7
+ * (`issuerPolicy = null`) : la défense anti-CSRF repose sur le `state`.
8
8
  */
9
9
  export declare function createGithubProvider(ctx: IOAuthProviderContext): IOAuthProvider;
@@ -1,35 +1,60 @@
1
- import type { OAuth2Tokens } from "arctic";
2
1
  import type { IOAuthProvider } from "../../../contracts/IOAuthProvider.js";
2
+ import type { IOAuthProviderContext } from "../oauthProviderRegistry.js";
3
+ import { type IAuthorizationRequest, type ITokenRequest, type OAuth2Tokens } from "../oauth2Client.js";
4
+ import { type IDiscoveryOptions } from "../metadata.js";
3
5
  /**
4
- * Client `arctic` minimal d'un fournisseur **OIDC avec PKCE** — surface
5
- * structurelle commune à `Google`, `MicrosoftEntraId`, `Auth0`, `Okta`,
6
- * `KeyCloak`... (une instance arctic de ces classes est assignable telle quelle).
6
+ * Client d'un fournisseur **OIDC avec PKCE** — surface structurelle que
7
+ * {@link OAuth2Client} remplit, et qu'un test peut remplacer par un double sans
8
+ * réseau.
7
9
  */
8
10
  export interface IOidcPkceClient {
9
- createAuthorizationURL(state: string, codeVerifier: string, scopes: string[]): URL;
10
- validateAuthorizationCode(code: string, codeVerifier: string): Promise<OAuth2Tokens>;
11
+ createAuthorizationURL(request: IAuthorizationRequest): URL;
12
+ validateAuthorizationCode(request: ITokenRequest): Promise<OAuth2Tokens>;
11
13
  }
12
14
  /** Paramètres d'un fournisseur OIDC générique. */
13
15
  export interface IOidcProviderOptions {
14
- /** Nom du fournisseur (`"google"`, `"microsoft"`...) — porté dans le profil. */
16
+ /** Nom du fournisseur (`"google"`, `"keycloak"`...) — porté dans le profil. */
15
17
  readonly name: string;
16
- /** Instance `arctic` (déjà construite avec les secrets). */
18
+ /** Client déjà construit sur les points d'entrée du fournisseur. */
17
19
  readonly client: IOidcPkceClient;
18
20
  /** Émetteur attendu (claim `iss`, anti-mix-up RFC 9207). */
19
21
  readonly issuer: string;
20
- /** `arctic.decodeIdToken` (injecté — arctic est chargé paresseusement). */
21
- readonly decodeIdToken: (idToken: string) => object;
22
+ /**
23
+ * `true` si l'émetteur ANNONCE le paramètre `iss` — son absence devient alors
24
+ * un refus. Défaut `false` : on ne peut pas exiger ce qui n'a pas été promis.
25
+ */
26
+ readonly issParameterSupported?: boolean;
27
+ /** Identifiant client — l'audience que l'ID token DOIT porter. */
28
+ readonly clientId: string;
29
+ /** Décodage des claims de l'ID token — synchrone ou non. */
30
+ readonly decodeIdToken: (idToken: string) => object | Promise<object>;
22
31
  /** Scopes par défaut si la config n'en précise aucun. */
23
32
  readonly defaultScopes?: string[];
24
33
  }
25
34
  /**
26
35
  * Fabrique un {@link IOAuthProvider} **générique OIDC** — couvre TOUT fournisseur
27
36
  * OpenID Connect sans code spécifique : le profil se lit toujours pareil (claims
28
- * standard `sub`/`email`/`email_verified`/`name` de l'ID token). Ajouter un
29
- * fournisseur OIDC = une entrée de quelques lignes (nom + classe arctic + issuer),
30
- * pas un fichier.
37
+ * standard `sub`/`email`/`email_verified`/`name` de l'ID token).
31
38
  *
32
- * PKCE S256 systématique (RFC 7636) ; le profil vient de l'ID token signé obtenu
33
- * du token endpoint via TLS (décodage suffisant en code flow, RFC 8725).
39
+ * PKCE S256 systématique (RFC 7636) ; le profil vient de l'ID token obtenu du
40
+ * point de jeton via TLS.
34
41
  */
35
42
  export declare function createOidcProvider(opts: IOidcProviderOptions): IOAuthProvider;
43
+ /** Réglages d'un fournisseur OIDC découvert. */
44
+ export interface IDiscoveredOidcOptions extends IDiscoveryOptions {
45
+ /** Émetteur ; à défaut, celui de la configuration de l'application. */
46
+ readonly issuer?: string;
47
+ }
48
+ /**
49
+ * Construit un fournisseur OIDC **en demandant ses points d'entrée à l'émetteur**
50
+ * (RFC 8414 / OpenID Connect Discovery) — aucune URL n'est écrite en dur.
51
+ *
52
+ * C'est ce qui permet d'enregistrer n'importe quel fournisseur OpenID Connect sans
53
+ * écrire une ligne de code : seul son émetteur le distingue.
54
+ *
55
+ * @param name - nom sous lequel le fournisseur est configuré.
56
+ * @param options - émetteur explicite, transport injectable, délai d'attente.
57
+ * @throws Error - émetteur absent, découverte impossible, ou serveur annonçant ne
58
+ * pas supporter PKCE S256 alors que ce fournisseur l'exige.
59
+ */
60
+ export declare function createDiscoveredOidcProvider(name: string, ctx: IOAuthProviderContext, options?: IDiscoveredOidcOptions): Promise<IOAuthProvider>;
package/docs/oauth2.md CHANGED
@@ -23,7 +23,7 @@ tags:
23
23
  ]
24
24
  version: "doc"
25
25
  status: stable
26
- updated: 2026-07-19
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
- | `arctic` | La bibliothèque OAuth utilisée (~50 fournisseurs), chargée **paresseusement** au premier login. |
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.** `arctic` est importé **paresseusement** au premier login
147
- (`OAuth2Service.#ensureLib()`, `oauth2.ts:234`) — jamais au boot, jamais par requête. Les
148
- fournisseurs sont instanciés une fois puis mémoïsés (`oauth2.ts:191-220`). Les routes ne sont montées
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:185`) monte trois routes sous
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:213`) — elles **sont** le mécanisme
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.
@@ -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:113`) travaille dans cet ordre, et l'ordre est la
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:175`), puis lecture du profil (`fetchProfile`,
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
- Trois sont livrés, résolus par nom via le registre `oauthProviderRegistry.ts:45`.
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:48`) : PKCE systématique
370
- (`usesPkce: true`, `oidc.ts:56`), émetteur figé `https://accounts.google.com`
371
- (`oauthProviderRegistry.ts:74`). Le profil se lit dans l'**ID token** claims standard `sub`,
372
- `email`, `email_verified`, `name` (`oidc.ts:72-89`). Un ID token sans `sub` est refusé : pas
373
- d'identifiant stable, pas d'identité (`oidc.ts:77-80`).
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 à construire le client et à valider
378
- l'`iss` (`oauthProviderRegistry.ts:86-105`). Il est donc **obligatoire** : sans lui, la fabrique lève
379
- au premier login avec un message explicite (`oauthProviderRegistry.ts:89-93`).
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`, `expectedIssuer: null`,
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
- `arctic` couvre une cinquantaine de fournisseurs (Microsoft, Apple, Discord, Auth0, Okta…). Ajouter
392
- l'un d'eux ou un IdP maison se fait par `registerOAuthProvider()`
393
- (`oauthProviderRegistry.ts:51`), au chargement de ton module (avant le `onBoot` du service) :
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 { registerOAuthProvider } from "@nodefony/security";
454
+ import {
455
+ registerOAuthProvider,
456
+ createDiscoveredOidcProvider,
457
+ } from "@nodefony/security";
397
458
 
398
- // Tout fournisseur OIDC : nom + classe arctic + issuer. Rien d'autre à écrire.
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
- createOidcProvider({
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:23`) : la lib `arctic` déjà
415
- chargée, plus les secrets issus de la config. Aucun import runtime d'`arctic` n'entre par ce chemin.
416
- Exemple réel et sans réseau dans le dépôt : `src/modules/test/nodefony/secure/oauthTestProvider.ts`.
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:175-176`) :
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:63`) — c'est là que ton implémentation les
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é ou absent | Comparaison stricte à `expectedIssuer` | `oauth2Service.test.ts:168` |
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:192` |
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:53`) |
496
- | PKCE | RFC 7636 | `usesPkce` (`IOAuthProvider.ts:26`) · `oidc.ts:49-54` |
497
- | Sécurité OAuth (BCP 2.1) | RFC 9700 | `OAuth2Service` (`oauth2.ts:40`) · `oauth2Schema` (`config.ts:1001`) |
498
- | Anti-mix-up (`iss`) | RFC 9207 | `expectedIssuer` (`IOAuthProvider.ts:34`) · `oauth2.ts:170-174` |
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:72-89`) |
501
- | ID token consommé en code flow | RFC 8725 | `createOidcProvider()` (`oidc.ts:46`) |
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
@@ -532,7 +594,7 @@ provisionné dans l'écran **Users**, avec ses rôles réels.
532
594
  | `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
595
  | 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
596
  | 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 ≠ `expectedIssuer` (`oauth2.ts:170-174`) | Corriger `issuer` (Keycloak : URL exacte du realm) |
597
+ | `OAuth issuer mismatch` | `iss` reçu ≠ l'émetteur attendu (`oauth2.ts:170-181`) | Corriger `issuer` (Keycloak : URL exacte du realm) |
536
598
  | Keycloak : erreur dès le premier login | `issuer` absent en config (`oauthProviderRegistry.ts:89-93`) | Renseigner l'URL du realm |
537
599
  | « provisioning indisponible » | `users` n'implémente pas la capability (`oauth2.ts:224-231`) | Implémenter `provisionOAuthUser()` sur le service `users` |
538
600
  | 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.2",
3
+ "version": "10.0.0-alpha.3",
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.2",
55
- "@nodefony/http": "^10.0.0-alpha.2",
56
- "@nodefony/user": "^10.0.0-alpha.2",
53
+ "@nodefony/framework": "^10.0.0-alpha.3",
54
+ "@nodefony/http": "^10.0.0-alpha.3",
55
+ "@nodefony/user": "^10.0.0-alpha.3",
57
56
  "@types/node": "26.4.1",
58
57
  "@vitest/coverage-v8": "5.0.0",
59
- "nodefony": "^10.0.0-alpha.2",
58
+ "nodefony": "^10.0.0-alpha.3",
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.2",
68
- "@nodefony/http": "^10.0.0-alpha.2",
69
- "@nodefony/user": "^10.0.0-alpha.2",
70
- "nodefony": "^10.0.0-alpha.2"
66
+ "@nodefony/framework": "^10.0.0-alpha.3",
67
+ "@nodefony/http": "^10.0.0-alpha.3",
68
+ "@nodefony/user": "^10.0.0-alpha.3",
69
+ "nodefony": "^10.0.0-alpha.3"
71
70
  },
72
71
  "files": [
73
72
  "dist",