@nodefony/security 10.0.0-beta.1 → 10.0.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +7 -5
  2. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/index.js +44 -10
  5. package/dist/nodefony/command/security-oauth-doctor.js +66 -0
  6. package/dist/nodefony/command/security-secrets.js +42 -25
  7. package/dist/nodefony/command/security-token.js +46 -15
  8. package/dist/nodefony/command/security-user-delete.js +5 -4
  9. package/dist/nodefony/command/security-user-password.js +4 -11
  10. package/dist/nodefony/config/config.js +80 -5
  11. package/dist/nodefony/contracts/index.js +1 -2
  12. package/dist/nodefony/errors/InsecureTransportError.js +24 -0
  13. package/dist/nodefony/errors/index.js +2 -1
  14. package/dist/nodefony/keycloak/keycloakAdmin.js +255 -0
  15. package/dist/nodefony/keycloak/keycloakRealm.js +260 -0
  16. package/dist/nodefony/keycloak/keycloakRealmInput.js +124 -0
  17. package/dist/nodefony/keycloak/scaffold.js +231 -0
  18. package/dist/nodefony/keycloak/security-keycloak-realm.js +128 -0
  19. package/dist/nodefony/scaffold/contribute.js +20 -0
  20. package/dist/nodefony/service/authFlow.js +111 -11
  21. package/dist/nodefony/service/firewall.js +111 -21
  22. package/dist/nodefony/service/keycloak.js +209 -0
  23. package/dist/nodefony/service/oauth2.js +379 -26
  24. package/dist/nodefony/service/securityHeaders.js +12 -1
  25. package/dist/nodefony/service/tokenService.js +57 -6
  26. package/dist/nodefony/service/totp.js +51 -6
  27. package/dist/nodefony/service/webAuthn.js +20 -2
  28. package/dist/nodefony/src/SecuredArea.js +14 -3
  29. package/dist/nodefony/src/admin/SecurityAdminApi.js +70 -40
  30. package/dist/nodefony/src/admin/adminAudit.js +0 -1
  31. package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
  32. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +66 -6
  33. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +38 -15
  34. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +1 -0
  35. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +13 -3
  36. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +9 -2
  37. package/dist/nodefony/src/authenticator/bearer.js +1 -2
  38. package/dist/nodefony/src/boot/publicRoutesNotice.js +27 -0
  39. package/dist/nodefony/src/entryPoint.js +39 -0
  40. package/dist/nodefony/src/oauth/clientCredentials.js +40 -0
  41. package/dist/nodefony/src/oauth/metadata.js +36 -9
  42. package/dist/nodefony/src/oauth/oauth2Client.js +60 -33
  43. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +39 -6
  44. package/dist/nodefony/src/oauth/providerDiagnosis.js +283 -0
  45. package/dist/nodefony/src/oauth/providerRoles.js +79 -0
  46. package/dist/nodefony/src/oauth/providers/github.js +32 -6
  47. package/dist/nodefony/src/oauth/providers/oidc.js +82 -3
  48. package/dist/nodefony/src/openToSignup.js +47 -0
  49. package/dist/nodefony/src/sessionAuthentication.js +26 -0
  50. package/dist/nodefony/src/token/JwtKeystore.js +78 -46
  51. package/dist/nodefony/src/token/RemoteJwtVerifier.js +40 -13
  52. package/dist/nodefony/src/token/secretFile.js +59 -2
  53. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +51 -0
  54. package/dist/types/index.d.ts +26 -6
  55. package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
  56. package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
  57. package/dist/types/nodefony/command/security-token.d.ts +9 -0
  58. package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
  59. package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
  60. package/dist/types/nodefony/config/config.d.ts +63 -1
  61. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
  62. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
  63. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
  64. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
  65. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
  66. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
  67. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
  68. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
  69. package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
  70. package/dist/types/nodefony/errors/index.d.ts +1 -0
  71. package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
  72. package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
  73. package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
  74. package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
  75. package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
  76. package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
  77. package/dist/types/nodefony/service/authFlow.d.ts +35 -3
  78. package/dist/types/nodefony/service/firewall.d.ts +17 -3
  79. package/dist/types/nodefony/service/keycloak.d.ts +115 -0
  80. package/dist/types/nodefony/service/oauth2.d.ts +125 -9
  81. package/dist/types/nodefony/service/securityHeaders.d.ts +2 -1
  82. package/dist/types/nodefony/service/tokenService.d.ts +7 -1
  83. package/dist/types/nodefony/service/totp.d.ts +28 -4
  84. package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
  85. package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
  86. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +54 -12
  87. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +18 -0
  88. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +13 -0
  89. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
  90. package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
  91. package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
  92. package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
  93. package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
  94. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
  95. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
  96. package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
  97. package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
  98. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
  99. package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
  100. package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
  101. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
  102. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
  103. package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
  104. package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
  105. package/docs/api-keys.md +18 -18
  106. package/docs/audit.md +24 -22
  107. package/docs/authenticators.md +62 -29
  108. package/docs/authorization.md +16 -16
  109. package/docs/cors.md +30 -30
  110. package/docs/csrf.md +35 -33
  111. package/docs/external-jwt.md +68 -5
  112. package/docs/firewall.md +78 -45
  113. package/docs/headers.md +35 -35
  114. package/docs/index.md +15 -7
  115. package/docs/keycloak.md +818 -0
  116. package/docs/lexique.md +15 -15
  117. package/docs/login-page.md +361 -0
  118. package/docs/oauth2.md +242 -125
  119. package/docs/obtenir-un-jeton.md +18 -11
  120. package/docs/tokens.md +138 -86
  121. package/docs/totp.md +113 -71
  122. package/docs/webauthn.md +115 -115
  123. package/docs/webhooks.md +21 -21
  124. package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
  125. package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
  126. package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
  127. package/keycloak/themes/nodefony/account/theme.properties +14 -0
  128. package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
  129. package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
  130. package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
  131. package/keycloak/themes/nodefony/admin/theme.properties +12 -0
  132. package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
  133. package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
  134. package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
  135. package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
  136. package/keycloak/themes/nodefony/email/theme.properties +8 -0
  137. package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
  138. package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
  139. package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
  140. package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
  141. package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
  142. package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
  143. package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
  144. package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
  145. package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
  146. package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
  147. package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
  148. package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
  149. package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
  150. package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
  151. package/keycloak/themes/nodefony/login/template.ftl +423 -0
  152. package/keycloak/themes/nodefony/login/theme.properties +17 -0
  153. package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
  154. package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
  155. package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
  156. package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
  157. package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
  158. package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
  159. package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
  160. package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
  161. package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
  162. package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
  163. package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
  164. package/package.json +17 -15
package/docs/lexique.md CHANGED
@@ -126,21 +126,21 @@ source: "src/packages/@nodefony/security/docs/lexique.md"
126
126
  > L'**autorisation** (authz) répond « as-tu le DROIT de faire ça ? » (les voters, `@IsGranted`).
127
127
  > On peut être authentifié ET refusé (un `user` connecté sur une route `ROLE_ADMIN` → **403**, pas 401).
128
128
 
129
- | Sigle / terme | Développé | En clair |
130
- | ----------------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
131
- | **RBAC** | Role-Based Access Control | Droits par RÔLES (`ROLE_ADMIN` peut X). Simple, couvre 80 % des besoins. Niveau **A** chez Nodefony (`RoleVoter`). |
132
- | **ABAC** | Attribute-Based Access Control | Droits par ATTRIBUTS contextuels (le **propriétaire** du document peut le modifier) → les voters reçoivent le `subject`. Niveau **C**. |
133
- | **Voter** | « votant / juré » | Petit juge spécialisé : pour une décision donnée il rend **un** verdict parmi 3 (GRANT / DENY / ABSTAIN). On en empile autant qu'on veut (rôles, propriété, horaires…), chacun sur son domaine. |
134
- | **GRANT / DENY / ABSTAIN** | accorde / refuse / s'abstient | Les 3 verdicts d'un voter. **ABSTAIN** = « pas mon rayon » (ne compte pas). C'est la combinaison des votes qui tranche, pas un seul. |
135
- | **`AuthorizationService.decide()`** | — | Le **juge en chef** (`authorization.ts`, J6) : interroge tous les voters et applique la stratégie de vote → un seul booléen GRANT/DENY. |
136
- | **Stratégie affirmative** | — | « **un seul GRANT suffit** » (tant qu'aucun DENY) — la stratégie de `decide()`. Permissive sur l'accord, stricte sur le refus. |
137
- | **Veto (DENY)** | — | « **un seul DENY refuse tout** », même si d'autres votent GRANT. Le refus l'emporte toujours (sécurité d'abord). |
138
- | **Default DENY** | refus par défaut | Tous ABSTAIN, ou **zéro** voter, ou aucun GRANT → **refusé** (Zero Trust). Il faut un OUI explicite, le silence ne suffit pas. |
139
- | **Fail-closed (voter)** | « échoue fermé » | Si un voter **plante** (throw) → compté comme DENY + log ERROR, jamais une 500 qui laisserait passer. L'erreur ne doit jamais ouvrir la porte (cf lexique général `fail-closed`). |
140
- | **`voterRegistry`** | registre de voters | Fabrique pluggable (convention-frère de `authenticatorRegistry`) : on **ajoute** un voter sans toucher `decide()`. Le futur `PermissionVoter` (RBAC ORM, niveau B/J6b) s'y branchera. |
141
- | **`RoleVoter`** | — | Le voter niveau **A** : GRANT si le token porte le rôle demandé (via `RoleHierarchyWalker`), ABSTAIN sinon — **jamais de veto** (il ne bloque pas les attributs qu'il ne connaît pas). |
142
- | **Attribute** | attribut (de décision) | La chose demandée passée à `decide()` : un rôle (`ROLE_ADMIN`), une permission (`post:edit`)… Ne PAS confondre avec l'ABAC « attribut contextuel » (le `subject`). |
143
- | **Subject** | sujet / ressource ciblée | L'objet **sur lequel** porte l'action (le document à éditer), passé au voter pour les règles ABAC (« est-il le propriétaire ? »). Optionnel — souvent un param de route via `@IsGranted(..., { subject })`. |
129
+ | Sigle / terme | Développé | En clair |
130
+ | ---------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
131
+ | **RBAC** | Role-Based Access Control | Droits par RÔLES (`ROLE_ADMIN` peut X). Simple, couvre 80 % des besoins. Niveau **A** chez Nodefony (`RoleVoter`). |
132
+ | **ABAC** | Attribute-Based Access Control | Droits par ATTRIBUTS contextuels (le **propriétaire** du document peut le modifier) → les voters reçoivent le `subject`. Niveau **C**. |
133
+ | **Voter** | « votant / juré » | Petit juge spécialisé : pour une décision donnée il rend **un** verdict parmi 3 (GRANT / DENY / ABSTAIN). On en empile autant qu'on veut (rôles, propriété, horaires…), chacun sur son domaine. |
134
+ | **GRANT / DENY / ABSTAIN** | accorde / refuse / s'abstient | Les 3 verdicts d'un voter. **ABSTAIN** = « pas mon rayon » (ne compte pas). C'est la combinaison des votes qui tranche, pas un seul. |
135
+ | **`Authorization.decide()`** | — | Le **juge en chef** (`authorization.ts`, J6) : interroge tous les voters et applique la stratégie de vote → un seul booléen GRANT/DENY. |
136
+ | **Stratégie affirmative** | — | « **un seul GRANT suffit** » (tant qu'aucun DENY) — la stratégie de `decide()`. Permissive sur l'accord, stricte sur le refus. |
137
+ | **Veto (DENY)** | — | « **un seul DENY refuse tout** », même si d'autres votent GRANT. Le refus l'emporte toujours (sécurité d'abord). |
138
+ | **Default DENY** | refus par défaut | Tous ABSTAIN, ou **zéro** voter, ou aucun GRANT → **refusé** (Zero Trust). Il faut un OUI explicite, le silence ne suffit pas. |
139
+ | **Fail-closed (voter)** | « échoue fermé » | Si un voter **plante** (throw) → compté comme DENY + log ERROR, jamais une 500 qui laisserait passer. L'erreur ne doit jamais ouvrir la porte (cf lexique général `fail-closed`). |
140
+ | **`voterRegistry`** | registre de voters | Fabrique pluggable (convention-frère de `authenticatorRegistry`) : on **ajoute** un voter sans toucher `decide()`. Le futur `PermissionVoter` (RBAC ORM, niveau B/J6b) s'y branchera. |
141
+ | **`RoleVoter`** | — | Le voter niveau **A** : GRANT si le token porte le rôle demandé (via `RoleHierarchyWalker`), ABSTAIN sinon — **jamais de veto** (il ne bloque pas les attributs qu'il ne connaît pas). |
142
+ | **Attribute** | attribut (de décision) | La chose demandée passée à `decide()` : un rôle (`ROLE_ADMIN`), une permission (`post:edit`)… Ne PAS confondre avec l'ABAC « attribut contextuel » (le `subject`). |
143
+ | **Subject** | sujet / ressource ciblée | L'objet **sur lequel** porte l'action (le document à éditer), passé au voter pour les règles ABAC (« est-il le propriétaire ? »). Optionnel — souvent un param de route via `@IsGranted(..., { subject })`. |
144
144
 
145
145
  ### Décorateurs sécurité (panoplie J7)
146
146
 
@@ -0,0 +1,361 @@
1
+ ---
2
+ title: "Page de connexion — la page servie par le framework, et comment l'habiller"
3
+ navTitle: Page de connexion
4
+ lang: fr
5
+ module: "@nodefony/security"
6
+ topic: login-page
7
+ section: "Sécurité"
8
+ audience: [developer]
9
+ tags: [security, login, page, theme, branding, sso, csp]
10
+ version: "doc"
11
+ status: stable
12
+ updated: 2026-10-11
13
+ source: "src/packages/@nodefony/security/docs/login-page.md"
14
+ ---
15
+
16
+ # Page de connexion — la page servie, et comment l'habiller
17
+
18
+ > Une application qui protège ses routes a besoin d'une page où l'on se connecte. Nodefony en sert
19
+ > une, prête à l'emploi, à l'adresse `/login` : identifiant, mot de passe, second facteur, boutons
20
+ > des fournisseurs (Keycloak, GitHub…), thème clair et sombre. Vous l'**habillez** par la
21
+ > configuration (`security.loginPage`) et une feuille de style — sans copier son balisage. Ancré sur
22
+ > `src/packages/@nodefony/security/nodefony/config/config.ts` (la configuration),
23
+ > `src/packages/@nodefony/framework/nodefony/src/loginPage.ts` (le rendu) et
24
+ > `src/nodefony/src/client/login/mountLoginPage.ts` (le script de la page).
25
+
26
+ 📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **Page de connexion**
27
+
28
+ ## 🧠 Le modèle mental — la configuration dit QUOI, la feuille dit COMMENT
29
+
30
+ ```mermaid
31
+ flowchart LR
32
+ CFG["security.loginPage<br/>(textes, moyens, panneau)"] --> DESC["authFlow.describeLoginPage()"]
33
+ OAUTH["oauth2.providers<br/>(libellé, icône)"] --> DESC
34
+ DESC --> VIEW["buildLoginPageView()<br/>données filtrées"]
35
+ VIEW --> HTML["page HTML<br/>login.css + habillage + votre feuille"]
36
+ HTML --> JS["login.js<br/>déroulé de connexion"]
37
+ ```
38
+
39
+ La configuration décide de ce que la page **propose et dit** ; l'**habillage** choisi et votre
40
+ feuille de style décident de son **apparence** (couleurs, rayons, photo). Le balisage, lui, reste celui du framework :
41
+ c'est ce qui vous laisse profiter de ses corrections sans rien recopier.
42
+
43
+ ## 📖 Lexique
44
+
45
+ | Terme | Signification |
46
+ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------- |
47
+ | **SSO** | _Single Sign-On_ : se connecter avec le compte d'une organisation (Keycloak, Google…) plutôt qu'un mot de passe local. |
48
+ | **Fournisseur** | Un service d'identité configuré dans `oauth2.providers` ; il devient un bouton sur la page. |
49
+ | **Panneau (`hero`)** | La moitié illustrée de la mise en page `split`, à côté du formulaire. |
50
+ | **Vitrine** | Le contenu par défaut du panneau : la présentation de Nodefony. |
51
+ | **CSP** | _Content Security Policy_ : liste des sources que le navigateur accepte de charger pour la page. |
52
+ | **Redirection ouverte** | Faille où une adresse de retour fournie par l'attaquant renvoie la victime vers un autre site (CWE-601). |
53
+ | **`no-store`** | En-tête qui interdit toute mise en cache de la réponse, y compris par un proxy partagé. |
54
+
55
+ ## Qu'est-ce que c'est ?
56
+
57
+ C'est la porte d'entrée de votre application : l'écran que voit quiconque arrive sur une route
58
+ protégée sans session. L'écrire soi-même paraît simple, et c'est là que les failles se logent — une
59
+ adresse de retour `?from=` qui renvoie vers un autre site, une page affichable dans le cadre d'un
60
+ site tiers (détournement de clic), un mot de passe qui part en clair, un message d'erreur qui dit
61
+ si le compte existe. Nodefony sert une page qui ferme ces portes par construction, et vous laisse
62
+ la personnaliser là où c'est sans risque : les textes, les couleurs, le contenu du panneau.
63
+
64
+ ## La vision Nodefony
65
+
66
+ - **Configuration dans `@nodefony/security`, rendu dans `@nodefony/framework`** : le premier décrit
67
+ la page (`AuthFlow.describeLoginPage()`, `authFlow.ts:470`), le second la rend
68
+ (`buildLoginPageView()`, `loginPage.ts:227`) et la sert (`LoginPageController`,
69
+ `LoginPageController.ts:102`). Le contrat entre les deux, `ILoginPageDescription`, vit au cœur
70
+ (`authRoutes.ts:262`).
71
+ - **Tout passe par du texte échappé** : aucune valeur de la configuration ni de la requête n'entre
72
+ dans le balisage autrement que par `<%= %>`. L'image du panneau passe par votre feuille de style,
73
+ jamais par une adresse injectée dans le HTML.
74
+ - **Un seul déroulé** : le script de la page (`mountLoginPage()`, `mountLoginPage.ts:85`) s'appuie
75
+ sur `NodefonyLogin`, le même client que vos propres écrans peuvent utiliser.
76
+
77
+ ## 🚀 Démarrage rapide
78
+
79
+ ### Dans une app `nodefony create app`, la page est DÉJÀ servie
80
+
81
+ Rien à écrire : ouvrez `/login`. Le chemin se change par `loginPage.path`, et toutes les
82
+ redirections le suivent (échec d'un fournisseur, retour après déconnexion).
83
+
84
+ ### L'habiller : textes, panneau, feuille de style
85
+
86
+ Dans le fragment de configuration de la sécurité (`nodefony/config/security.ts`, importé par
87
+ `nodefony.config.ts` dans `use("@nodefony/security", …)`) :
88
+
89
+ ```typescript
90
+ // nodefony/config/security.ts — extrait, compile tel quel
91
+ import type { ISecurityConfigInput } from "@nodefony/security";
92
+
93
+ export const acmeLogin = {
94
+ loginPage: {
95
+ title: "Acme", // nom de l'application, en tête et dans l'onglet
96
+ heading: "Espace client", // titre de la carte
97
+ subtitle: "Votre compte Acme",
98
+ stylesheet: "/assets/acme-login.css", // chargée APRÈS celle du framework
99
+ hero: {
100
+ heading: "Bienvenue chez Acme",
101
+ text: "Vos commandes, vos factures, votre support.",
102
+ },
103
+ },
104
+ } satisfies ISecurityConfigInput;
105
+ ```
106
+
107
+ La feuille redéfinit les variables `--nf-login-*`. Les couleurs qui changent avec le thème se
108
+ redéfinissent pour les deux thèmes (voir « Pièges », plus bas) :
109
+
110
+ ```css
111
+ /* /assets/acme-login.css */
112
+ :root {
113
+ --nf-login-brand: #b4235a; /* même valeur dans les deux thèmes */
114
+ --nf-login-card-radius: 4px;
115
+ --nf-login-hero-image: url("/assets/acme-hero.jpg");
116
+ }
117
+ ```
118
+
119
+ ### Ce qu'on observe
120
+
121
+ - `GET /login` rend la page, avec `Cache-Control: no-store` et une CSP
122
+ `frame-ancestors 'none'` (`LoginPageController.ts:112`) ;
123
+ - le titre de la carte vaut « Espace client », le panneau affiche votre accroche à la place de la
124
+ vitrine Nodefony ;
125
+ - votre feuille arrive en second `<link rel="stylesheet">`, après `login.css` : vos variables
126
+ l'emportent.
127
+
128
+ ## ⚙️ Choisir sa page — quatre situations
129
+
130
+ ### Situation 1 — la page par défaut, à votre nom
131
+
132
+ `title` et `logo` suffisent. La vitrine Nodefony reste dans le panneau ; `hero: false` la retire
133
+ et ne laisse que votre marque sur l'image du panneau.
134
+
135
+ ### Situation 2 — connexion par l'organisation seulement (SSO)
136
+
137
+ `password: false` retire le formulaire : il ne reste que les boutons des fournisseurs. Chaque
138
+ bouton prend son image si vous la réglez (`oauth2.providers.<nom>.icon`, sinon la marque GitHub ou
139
+ une clé) — `listDisplayProviders()` (`oauth2.ts:422`).
140
+
141
+ Le libellé suit ce que l'utilisateur connaît. Une marque grand public (GitHub, Google, Microsoft…)
142
+ garde son nom. Un serveur d'identité d'organisation (Keycloak, un OIDC d'entreprise) se lit
143
+ **« Compte de l'organisation »**, son nom passant en petit dessous, `SSO · Keycloak` (votre
144
+ `oauth2.providers.<nom>.label` y figure) : personne ne sait ce qu'est « Keycloak » en arrivant sur
145
+ une page de connexion.
146
+
147
+ ### Situation 3 — le formulaire d'abord
148
+
149
+ Par défaut, quand des fournisseurs sont proposés, leurs boutons passent avant le formulaire : la
150
+ plupart des comptes d'une entreprise sont ceux de son annuaire. `providersFirst: false` inverse
151
+ l'ordre (`loginPage.ts:255`).
152
+
153
+ ### Situation 4 — votre propre page
154
+
155
+ `enabled: false` : le framework ne sert plus rien à ce chemin, et votre application y sert sa
156
+ page, construite sur `NodefonyLogin` (voir la doc du client du cœur). Les redirections continuent
157
+ d'y mener. `template` (un gabarit `.eta` qui remplace celui du framework) existe aussi, en dernier
158
+ recours : un gabarit copié ne suit plus les évolutions du balisage.
159
+
160
+ ### Le thème clair ou sombre
161
+
162
+ Le système décide par défaut (`prefers-color-scheme`). Un bouton en haut à droite bascule entre les
163
+ deux, et le choix est retenu dans le navigateur (`LOGIN_PAGE_THEME_KEY`), relu avant le premier
164
+ affichage — la page ne clignote pas. Un `?theme=light|dark` dans l'adresse l'emporte : c'est ainsi
165
+ que Studio passe son thème à la page.
166
+
167
+ ### Ce que la carte propose — tous les cas
168
+
169
+ La carte se compose de trois moyens, chacun décidé par un réglage différent : le formulaire
170
+ (`loginPage.password`), la passkey (`passkeys.enabled`, service WebAuthn prêt) et les
171
+ fournisseurs (ceux qui répondent). `describeLoginPage()` les relit à chaque affichage.
172
+
173
+ | Moyens proposés | La carte montre |
174
+ | -------------------------- | ---------------------------------------------------------------------------- |
175
+ | mot de passe | le formulaire |
176
+ | mot de passe + passkey | le formulaire, « ou », le bouton « Passkey / empreinte digitale » |
177
+ | mot de passe + fournisseur | le formulaire, « ou », les boutons des fournisseurs |
178
+ | passkey seule | le bouton passkey ; si le navigateur ne sait pas la faire, un message le dit |
179
+ | fournisseurs (± passkey) | les boutons, sans formulaire |
180
+ | **aucun** | un message « aucun moyen de connexion », et un WARNING au démarrage |
181
+
182
+ Le bouton passkey est rendu MASQUÉ : c'est le script de la page qui le montre, après avoir
183
+ constaté que le navigateur sait faire WebAuthn. Un écran de connexion à vous lit le même état par
184
+ `GET /nodefony/security/api/webauthn/status` (`WEBAUTHN_STATUS_PATH`, `{ enabled }`), pour ne
185
+ pas proposer un bouton dont la cérémonie répondrait `503`.
186
+
187
+ ## 🎨 Habillages — neuf apparences, un seul balisage
188
+
189
+ Le framework livre neuf habillages. Chacun est une feuille qui redéfinit les variables
190
+ `--nf-login-*` et, au plus, la disposition de sa mise en page : le balisage ne change jamais, donc
191
+ vos réglages (`heading`, `hero`, `providersFirst`…) et votre feuille valent pour les neuf.
192
+
193
+ ```typescript
194
+ // nodefony/config/security.ts — extrait, compile tel quel
195
+ import type { ISecurityConfigInput } from "@nodefony/security";
196
+
197
+ export const acmeSkin = {
198
+ loginPage: {
199
+ skin: "horizon", // un des neuf ; un nom inconnu arrête le démarrage
200
+ stylesheet: "/brand/login.css", // votre photo et vos couleurs, chargée en dernier
201
+ },
202
+ } satisfies ISecurityConfigInput;
203
+ ```
204
+
205
+ <!-- prettier-ignore -->
206
+ | `skin` | Mise en page | Fond de page | Pour quoi |
207
+ | --- | --- | --- | --- |
208
+ | `frontispiece` (défaut) | `split` | — | Intranet, application d'entreprise : panneau de marque à bord courbe, les comptes de l'organisation avant le mot de passe local. `login.css` seule. |
209
+ | `ledger` | `bare` | — | Outil interne, back-office : feuille en deux colonnes, étapes numérotées, formulaire à droite. |
210
+ | `horizon` | `card` | — | Grand public, SaaS : carte postale, la photo en bandeau dans la carte. |
211
+ | `console` | `card` | — | Outils, consoles : la signature de la barre de debug, en carte centrée. |
212
+ | `blueprint` | `card` | grille de plan | Produits techniques, API, données : fond bleu nuit. |
213
+ | `dots` | `card` | trame de points | Applications métier : discret, clair d'abord, ombre légère. |
214
+ | `minimal` | `bare` | — | Produit qui veut s'effacer : pas de carte, beaucoup d'air. |
215
+ | `enterprise` | `card` | courbes de niveau | Intranet derrière un fournisseur d'identité, palette sarcelle ; à marier avec `providersFirst: true`. |
216
+ | `photo-card` | `card` | photo voilée | La photo de l'application en fond de page, la carte reste pleine au-dessus. |
217
+
218
+ **Trois feuilles, dans cet ordre** : `login.css` (le socle), la feuille de l'habillage
219
+ (`/nodefony/security/login/skins/<skin>.css`, aucune pour `frontispiece`), puis la vôtre — vos
220
+ variables l'emportent toujours (`loginPage.ts:210`).
221
+
222
+ **La photo vient de vous.** Aucun habillage n'en publie : une photo par défaut alourdirait chaque
223
+ installation et finirait à l'identique dans toutes les applications. Une seule variable suffit,
224
+ chaque habillage la place où il veut — le panneau (`frontispiece`), le bandeau (`horizon`), le fond
225
+ de page (`photo-card`) — et changer d'habillage la conserve. Sans elle, ces emplacements prennent
226
+ une couleur pleine.
227
+
228
+ ```css
229
+ /* /brand/login.css — servie par votre application (public/brand/) */
230
+ :root {
231
+ --nf-login-hero-image: url("/brand/login-hero.webp");
232
+ }
233
+ ```
234
+
235
+ **La mise en page suit l'habillage** : sans `layout`, c'est celle pour laquelle il est dessiné ;
236
+ un `layout` écrit gagne (`authFlow.ts:495`).
237
+
238
+ **Keycloak suit le même habillage** : chaque habillage a son thème de connexion Keycloak
239
+ (`nodefony-<habillage>`), que `security:keycloak:realm --write` pose dans le realm d'après
240
+ `loginPage.skin` — voir [Keycloak](keycloak.md). Seuls `ledger` et `horizon` y perdent leur
241
+ structure propre (les formulaires de Keycloak n'ont pas nos classes) et n'y gardent que leurs
242
+ couleurs et leur mise en page.
243
+
244
+ **Comparer sans redémarrer** : en développement, `/login?skin=dots` sert la page sous un autre
245
+ habillage, et `&layout=card` force la mise en page — le temps d'une requête, sur vos vrais
246
+ réglages. En production ces paramètres sont ignorés (`loginPage.ts:207`) : la page servie est
247
+ celle de la configuration, et rien d'autre.
248
+
249
+ ## 🏗️ Architecture interne
250
+
251
+ ```mermaid
252
+ sequenceDiagram
253
+ participant N as Navigateur
254
+ participant C as LoginPageController
255
+ participant A as authFlow
256
+ N->>C: GET /login?from=/admin
257
+ C->>A: describeLoginPage()
258
+ A-->>C: description (null = page désactivée)
259
+ C->>C: buildLoginPageView() — from filtré, thème filtré
260
+ C-->>N: HTML (no-store, CSP + nonce)
261
+ N->>N: login.js — identifiant, mot de passe, second facteur
262
+ ```
263
+
264
+ - **La route est montée au démarrage** par `mountLoginPage()` du framework, hors firewall
265
+ (`bypassFirewall`, `LoginPageController.ts:156`) — sinon une application qui protège `^/`
266
+ exigerait d'être connecté pour se connecter.
267
+ - **Les fournisseurs sont relus à chaque requête** : l'un d'eux peut tomber ou revenir pendant que
268
+ le serveur tourne, et un bouton qui mène à une erreur est retiré de l'offre.
269
+ - **La destination `?from=`** repasse par `safeRedirectPath` (`loginPage.ts:231`) : une adresse
270
+ hors de l'origine retombe sur `/`.
271
+ - **Les boutons de fournisseur portent `from` et `back`** : `back` désigne cette page, si bien que
272
+ le « Retour » du fournisseur et un échec y ramènent, destination comprise, quel que soit
273
+ `failureRedirect` (cf `oauth2.md`, § Les retours).
274
+ - **Un retour sans connexion s'annonce** : `?reason=` autre qu'`access_denied` affiche un message
275
+ FIXE dans la zone de message ; une annulation n'en affiche aucun. La raison reçue n'est jamais
276
+ recopiée dans la page.
277
+
278
+ ## ⚙️ Configuration (schéma Zod `loginPageSchema`, `config.ts:1040`)
279
+
280
+ <!-- prettier-ignore -->
281
+ | Option | Type · défaut | Effet |
282
+ | --- | --- | --- |
283
+ | `enabled` | boolean · `true` | `false` = le framework ne sert pas de page ; la vôtre prend le chemin. |
284
+ | `path` | string · `/login` | Chemin local de la page, sans requête ni fragment. |
285
+ | `title` | string · nom de l'application | Nom affiché en tête et dans l'onglet. |
286
+ | `logo` | string · logo Nodefony | Adresse du logo (chemin servi, ou URL autorisée par la CSP). |
287
+ | `heading` | string · « Se connecter » | Titre de la carte et de l'onglet. |
288
+ | `subtitle` | string · déduit | Ligne sous le titre ; par défaut, déduite des moyens proposés. |
289
+ | `stylesheet` | string · — | Feuille chargée après `login.css` et l'habillage (`config.ts:1024`). |
290
+ | `skin` | un des neuf · `frontispiece` | Habillage (`config.ts:1133`) — voir « Habillages ». |
291
+ | `layout` | `split` · `card` · `bare` · celle de l'habillage | Mise en page : panneau et formulaire, carte centrée, formulaire seul (`config.ts:1139`). |
292
+ | `hero` | `{ heading, text? }` · `false` · vitrine | Contenu du panneau (`config.ts:1105`) ; une clé inconnue est refusée au démarrage. |
293
+ | `providersFirst` | boolean · `true` | Fournisseurs avant le formulaire (`config.ts:1099`). |
294
+ | `footer` | boolean · `true` | Protections de la session et « Propulsé par Nodefony » (`config.ts:1121`). |
295
+ | `password` | boolean · `true` | `false` = fournisseurs seulement. |
296
+ | `previewPicker` | boolean · `false` | En **développement** seulement : un sélecteur d'habillage et de mise en page posé dans un coin, qui recharge avec `?skin=` / `?layout=`. Sans effet en production. |
297
+ | `previewHook` | chemin · — | Avec `previewPicker`, en **développement** : chemin de l'application prévenu en `POST` (`{ skin, layout }`, même origine) AVANT chaque rechargement de l'aperçu — pour qu'un tiers suive l'habillage choisi (le thème d'un Keycloak de développement). Un échec n'empêche pas le rechargement ; une autre origine est refusée au démarrage. |
298
+ | `template` | string · — | Gabarit `.eta` de remplacement — dernier recours. |
299
+ | `oauth2.providers.<nom>.icon` | string · icône du framework | Image du bouton du fournisseur (`config.ts:1296`). |
300
+
301
+ Toute la section est en `z.strictObject` : une clé mal orthographiée interrompt le démarrage en la
302
+ nommant, au lieu d'être ignorée.
303
+
304
+ ## 🔒 Sécurité
305
+
306
+ - **Pas de cache partagé** (`no-store`) ni d'affichage dans un cadre (`frame-ancestors 'none'`) ;
307
+ le script porte le nonce CSP de la requête.
308
+ - **Mot de passe en clair refusé en production** : sur une requête qui n'arrive pas en HTTPS, la
309
+ connexion répond 403 avant toute vérification, et la page affiche « doit passer par HTTPS » au
310
+ lieu de « mot de passe incorrect » (`mountLoginPage.ts:150`). Derrière un proxy qui termine TLS,
311
+ déclarez-le dans `trustProxy` ([authenticators](authenticators.md#mot-de-passe-en-clair--refusé-en-production)).
312
+ - **Message d'échec uniforme** : un refus d'identifiants affiche un message fixe, jamais le texte du
313
+ serveur — il ne dit pas si le compte existe.
314
+ - **Vos adresses** (`logo`, `stylesheet`, `icon`) sont soumises à la CSP de la page : servez-les
315
+ depuis l'application, ou autorisez explicitement leur origine.
316
+
317
+ ## ⚡ Performance & mémoire
318
+
319
+ - Le gabarit est compilé **une fois par processus**, et un échec de chargement n'est pas mémorisé
320
+ (`keepSuccess()`, `loginPage.ts:128`) : la page ne reste pas en 500 après une correction.
321
+ - Feuille et script sont servis en statique, versionnés par leur empreinte (`?v=`) : le navigateur
322
+ les garde en cache jusqu'au prochain changement. Le script pèse 4 Ko.
323
+
324
+ ## ⚠️ Pièges (symptôme → cause → correction)
325
+
326
+ | Symptôme | Cause | Correction |
327
+ | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
328
+ | Ma couleur d'accent ne s'applique qu'en thème sombre | Les couleurs qui dépendent du thème (`--nf-login-accent`, `--nf-login-bg`…) sont redéfinies pour le thème clair, avec un sélecteur plus précis que `:root` | Les redéfinir aussi sous `@media (prefers-color-scheme: light) { :root:not([data-theme="dark"]) { … } }` et `:root[data-theme="light"]` |
329
+ | Ma feuille ou mon logo ne se charge pas | Adresse d'un autre site, refusée par la CSP | Servir le fichier depuis l'application |
330
+ | `hero: { image: … }` refuse de démarrer | Le panneau n'accepte que du texte | Poser l'image dans la feuille : `--nf-login-hero-image: url(…)` |
331
+ | Le panneau n'apparaît pas | `layout: "card"` ou `"bare"` : seul `split` a un panneau | `layout: "split"` |
332
+ | Toutes les connexions répondent 403 « doit passer par HTTPS » en production | Pod en HTTP derrière un proxy TLS non déclaré | Déclarer le proxy dans `trustProxy` |
333
+ | La page répond 404 | `enabled: false`, ou configuration de sécurité invalide (journal CRITIC au démarrage) | Réactiver, ou corriger la configuration |
334
+
335
+ ## 🧪 Tests & couverture
336
+
337
+ - **unit (framework)** : `tests/unit/loginPage.test.ts` — données du gabarit filtrées, gabarit
338
+ échappé (titres, sous-titre, accroche et adresses hostiles rendus en texte), chaque réglage de la
339
+ page, et le **vrai** script monté sur le HTML rendu jusqu'à l'ouverture de la session ;
340
+ - **unit (security)** : `tests/unit/loginPage.test.ts` — la section `loginPage` et sa lecture par
341
+ `describeLoginPage()` ; `oauth2Service.test.ts` — libellé et image des boutons ;
342
+ - **unit (cœur)** : `loginSkins.test.ts` — une feuille par habillage et aucune hors catalogue,
343
+ jeu clair écrit deux fois à l'identique, aucune variable lue sans définition ni posée sans
344
+ lecteur, aucune image publiée ; `clientLoginPage.test.ts` — le script de la page : déroulé complet, destination
345
+ hors origine refusée, message fixe, refus du clair reconnu ;
346
+ - **intégration live** : `tests/integration/login-page.test.ts` chez `@nodefony/framework` (serveur
347
+ réel : en-têtes, CSP, fichiers servis) ;
348
+ - **attaque** : `securedArea.attack.test.ts` — une zone protégée couvre la casse et la barre finale
349
+ que le routeur sert.
350
+
351
+ Ce qui manque : aucun test de charge dédié à la page (elle est servie hors du chemin chaud).
352
+ Couverture : `npm run coverage` dans chaque paquet.
353
+
354
+ ## 🔗 Pour aller plus loin
355
+
356
+ - ⬆️ **Retour au hub** : [Sécurité — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
357
+ - 🧭 **Pages sœurs** : [Authenticators](authenticators.md) · [OAuth2 et fournisseurs](oauth2.md) · [Keycloak](keycloak.md)
358
+
359
+ - La décision d'architecture (page servie par le framework) → ADR-0015 dans `docs/adr/`
360
+ - Le client de connexion sans interface (`NodefonyLogin`) → doc du client du cœur
361
+ - Les en-têtes de sécurité et la CSP → [headers](./headers.md)