@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
@@ -0,0 +1,128 @@
1
+ import { KeycloakRealmError } from "./keycloakRealmInput.js";
2
+ import { KeycloakAdminError } from "./keycloakAdmin.js";
3
+ import { Command } from "nodefony";
4
+ import path from "node:path";
5
+ //#region nodefony/keycloak/security-keycloak-realm.ts
6
+ const options = {
7
+ helpGroup: "COMPTES ET SECRETS",
8
+ showBanner: false,
9
+ kernelEvent: "onReady",
10
+ quietBoot: true
11
+ };
12
+ const GREEN = "\x1B[32m";
13
+ const YELLOW = "\x1B[33m";
14
+ const DIM = "\x1B[2m";
15
+ const RESET = "\x1B[0m";
16
+ /** Codes de sortie (sysexits). */
17
+ const EXIT_DIFFERS = 1;
18
+ const EXIT_CONFIG = 78;
19
+ /**
20
+ * `nodefony security:keycloak:realm` — le realm Keycloak que la configuration
21
+ * de l'application implique.
22
+ *
23
+ * Le realm d'import répète ce que la config dit déjà : identifiant du client,
24
+ * URL de retour, audience de chaque zone, rôles de `roleMapping`. Écrit à la
25
+ * main, il diverge — et le symptôme est un 401 ou une URL de retour refusée,
26
+ * jamais un message qui nomme la cause. Cette commande le DÉRIVE, par le
27
+ * service `keycloak` — qui porte toutes les actions Keycloak ; elle n'en
28
+ * garde que l'affichage et les codes de sortie.
29
+ *
30
+ * Elle FUSIONNE dans le fichier existant au lieu de l'écraser : comptes de
31
+ * démonstration, titres, secret machine, mappers posés dans la console
32
+ * survivent (`mergeKeycloakRealm`, cœur — le même constructeur que
33
+ * `create app`).
34
+ *
35
+ * - sans option : affiche le realm fusionné ;
36
+ * - `--write` : l'écrit (le fichier par défaut est l'unique `.json` de
37
+ * `docker/keycloak/import/`) ;
38
+ * - `--check` : sort en 1 si le fichier n'est plus à jour — la garde de CI ;
39
+ * - `--apply` : pose sur le realm EXISTANT le thème de connexion qui suit
40
+ * `loginPage.skin`, par l'API d'administration (service `keycloak`) —
41
+ * développement seulement, compte `oauth2.providers.<nom>.admin` requis.
42
+ *
43
+ * ⚠️ Keycloak n'importe un realm qu'à sa CRÉATION : un realm déjà présent
44
+ * n'est pas relu. Après `--write`, le porter par un import PARTIEL (clients en
45
+ * « Overwrite » : comptes et seconds facteurs survivent) ; retirer le volume
46
+ * ne se fait que sur un realm qui ne porte rien à garder.
47
+ */
48
+ var SecurityKeycloakRealm = class extends Command {
49
+ constructor(cli) {
50
+ super("security:keycloak:realm", "écrit le realm Keycloak déduit de la configuration", cli, options);
51
+ this.addOption("-w, --write [fichier]", "écrit le realm fusionné dans le fichier d'import");
52
+ this.addOption("-c, --check [fichier]", "sort en 1 si le fichier d'import n'est plus à jour");
53
+ this.addOption("-a, --apply", "pose le thème de connexion sur le realm existant (développement)");
54
+ this.addOption("-p, --provider <nom>", "fournisseur de oauth2.providers (défaut keycloak)");
55
+ this.addOption("-m, --machine <clientId>", "client machine dont l'audience suit la config");
56
+ this.addOption("--backchannel-origin <url>", "origine par laquelle Keycloak joint l'application (canal arrière)");
57
+ }
58
+ /** Le service `keycloak`, ou `null` (code de sortie posé). */
59
+ #service() {
60
+ const service = this.kernel?.container?.get("keycloak");
61
+ if (service) return service;
62
+ this.log("service keycloak absent — module security non chargé ?", "ERROR");
63
+ process.exitCode = EXIT_CONFIG;
64
+ return null;
65
+ }
66
+ async generate(opts) {
67
+ const service = this.#service();
68
+ if (service === null) return this;
69
+ if (opts.apply === true) {
70
+ if (!await this.#apply(service, opts.provider) || opts.write === void 0 && opts.check === void 0) return this;
71
+ }
72
+ let plan;
73
+ try {
74
+ plan = service.realmPlan({
75
+ provider: opts.provider,
76
+ machine: opts.machine,
77
+ backchannelOrigin: opts.backchannelOrigin,
78
+ file: typeof opts.write === "string" ? opts.write : typeof opts.check === "string" ? opts.check : void 0
79
+ });
80
+ } catch (error) {
81
+ if (!(error instanceof KeycloakRealmError)) throw error;
82
+ this.log(error.message, "ERROR");
83
+ process.exitCode = EXIT_CONFIG;
84
+ return this;
85
+ }
86
+ for (const warning of plan.warnings) process.stderr.write(`${YELLOW}⚠ ${warning}${RESET}\n`);
87
+ const relative = path.relative(this.kernel?.path ?? process.cwd(), plan.file);
88
+ if (opts.check !== void 0) {
89
+ if (plan.upToDate) {
90
+ process.stdout.write(`${GREEN}✓${RESET} ${relative} est à jour\n`);
91
+ return this;
92
+ }
93
+ process.stderr.write(`${relative} ne correspond plus à la configuration — nodefony security:keycloak:realm --write\n`);
94
+ process.exitCode = EXIT_DIFFERS;
95
+ return this;
96
+ }
97
+ if (opts.write !== void 0) {
98
+ if (!service.writeRealm(plan)) {
99
+ process.stdout.write(`${GREEN}✓${RESET} ${relative} déjà à jour, rien d'écrit\n`);
100
+ return this;
101
+ }
102
+ process.stdout.write(`${GREEN}✓${RESET} ${relative} écrit\n${DIM} Keycloak n'importe un realm qu'à sa CRÉATION — un realm déjà là ne relit pas ce fichier :\n · thème de connexion : nodefony security:keycloak:realm --apply ;\n · le reste : console › Realm settings › Action › Partial import, clients en « Overwrite » (comptes et seconds facteurs gardés) ;\n · ou recréer le realm (retirer le volume Keycloak) s'il ne porte rien à garder.${RESET}\n`);
103
+ return this;
104
+ }
105
+ process.stdout.write(plan.text);
106
+ return this;
107
+ }
108
+ /**
109
+ * `--apply` : le thème que l'habillage demande, posé sur le realm vivant.
110
+ *
111
+ * @returns `false` si le geste a échoué (code de sortie posé)
112
+ */
113
+ async #apply(service, provider) {
114
+ try {
115
+ const result = await service.applyLoginTheme(provider === void 0 ? {} : { provider });
116
+ const line = result.outcome === "applied" ? `${GREEN}✓${RESET} realm « ${result.realm} » : thème « ${result.previous ?? "—"} » → « ${result.theme} »` : result.outcome === "unchanged" ? `${GREEN}✓${RESET} realm « ${result.realm} » : déjà sur « ${result.theme} »` : `${YELLOW}⚠${RESET} realm « ${result.realm} » : thème « ${result.previous ?? "aucun"} » propre à l'application, laissé tel quel`;
117
+ process.stdout.write(`${line}\n`);
118
+ return true;
119
+ } catch (error) {
120
+ if (!(error instanceof KeycloakAdminError)) throw error;
121
+ this.log(error.message, "ERROR");
122
+ process.exitCode = EXIT_CONFIG;
123
+ return false;
124
+ }
125
+ }
126
+ };
127
+ //#endregion
128
+ export { SecurityKeycloakRealm as default };
@@ -0,0 +1,20 @@
1
+ import { contributeKeycloak } from "../keycloak/scaffold.js";
2
+ //#region nodefony/scaffold/contribute.ts
3
+ /**
4
+ * Le point de contribution de `@nodefony/security` : ce que le paquet livre à
5
+ * une application, déclaré dans son `package.json` (`nodefony.contribute`) et
6
+ * joué par le cœur, qui n'en connaît rien.
7
+ *
8
+ * @module
9
+ */
10
+ /**
11
+ * La contribution du paquet — aujourd'hui le seul décor Keycloak de
12
+ * développement (`nodefony/keycloak/`).
13
+ *
14
+ * @param context - contexte fourni par le cœur (`runAppContributions`)
15
+ */
16
+ function contribute(context) {
17
+ contributeKeycloak(context);
18
+ }
19
+ //#endregion
20
+ export { contribute };
@@ -1,10 +1,15 @@
1
+ import { securityConfigSchema } from "../config/config.js";
2
+ import { InsecureTransportError } from "../errors/InsecureTransportError.js";
1
3
  import { AuthenticationError } from "../errors/AuthenticationError.js";
2
4
  import { ThrottledError } from "../errors/ThrottledError.js";
3
5
  import { resolveSessionIdentity } from "../src/sessionIdentity.js";
4
6
  import { recordAudit } from "../src/audit/recordAudit.js";
5
7
  import { readAuditContext } from "../src/audit/readAuditContext.js";
6
- import { RequestContext, Service } from "nodefony";
8
+ import { amrForFactor } from "../src/sessionAuthentication.js";
9
+ import { LOGIN_PAGE_SKIN_SPECS, MFA_METHOD_TOTP, SESSION_AMR_KEY, SESSION_AUTH_AT_KEY, Service } from "nodefony";
10
+ import { requestUser } from "@nodefony/user";
7
11
  //#region nodefony/service/authFlow.ts
12
+ const MFA_METHODS = Object.freeze([MFA_METHOD_TOTP]);
8
13
  const serviceName = "authFlow";
9
14
  const INVALID_CREDENTIALS = "Invalid credentials";
10
15
  const PENDING_MFA_KEY = "mfa:pending";
@@ -34,8 +39,11 @@ var AuthFlow = class extends Service {
34
39
  #sessions = null;
35
40
  #throttler = null;
36
41
  #throttlerResolved = false;
42
+ #transport = null;
37
43
  #totp = null;
38
44
  #totpResolved = false;
45
+ #loginPage = null;
46
+ #loginPageResolved = false;
39
47
  constructor(module) {
40
48
  super(serviceName, module.container, module.notificationsCenter, module.options);
41
49
  this.module = module;
@@ -51,6 +59,8 @@ var AuthFlow = class extends Service {
51
59
  * @param identifier - identifiant saisi (body JSON, non typé à la frontière).
52
60
  * @param password - mot de passe saisi.
53
61
  * @returns `authenticated` (identité établie) ou `mfa_required` (2ᵉ facteur requis).
62
+ * @throws InsecureTransportError (403) — mot de passe reçu en clair en
63
+ * production (refusé AVANT le throttle et le verifier).
54
64
  * @throws ThrottledError (429 + `Retry-After`) — backoff actif.
55
65
  * @throws AuthenticationError (401, message uniforme) — credential absent ou
56
66
  * invalide.
@@ -58,6 +68,7 @@ var AuthFlow = class extends Service {
58
68
  async login(context, identifier, password) {
59
69
  const info = readAuditContext(context);
60
70
  const who = typeof identifier === "string" ? identifier : null;
71
+ this.#assertTransport(context, "login", who, info);
61
72
  if (typeof identifier !== "string" || identifier.length === 0 || typeof password !== "string" || password.length === 0) {
62
73
  recordAudit(this.container, {
63
74
  category: "auth",
@@ -113,12 +124,10 @@ var AuthFlow = class extends Service {
113
124
  });
114
125
  return {
115
126
  status: "mfa_required",
116
- methods: ["totp"]
127
+ methods: MFA_METHODS
117
128
  };
118
129
  }
119
- await this.#openSession(context, user.identifier);
120
- context.user = user.identifier;
121
- RequestContext.set("user", user);
130
+ await this.#openSession(context, user.identifier, "password");
122
131
  recordAudit(this.container, {
123
132
  category: "auth",
124
133
  action: "login.success",
@@ -148,9 +157,7 @@ var AuthFlow = class extends Service {
148
157
  async establishSessionFor(context, identifier, reason = "federated") {
149
158
  if (typeof identifier !== "string" || identifier.length === 0) throw new AuthenticationError(INVALID_CREDENTIALS);
150
159
  const user = await resolveSessionIdentity(this.#resolveUsers(), identifier);
151
- await this.#openSession(context, user.identifier);
152
- context.user = user.identifier;
153
- RequestContext.set("user", user);
160
+ await this.#openSession(context, user.identifier, reason);
154
161
  recordAudit(this.container, {
155
162
  category: "auth",
156
163
  action: "login.success",
@@ -171,6 +178,7 @@ var AuthFlow = class extends Service {
171
178
  * @param context - contexte HTTP (porte la session PENDING).
172
179
  * @param code - code présenté (TOTP ou code de récupération).
173
180
  * @returns la projection publique de l'utilisateur authentifié.
181
+ * @throws InsecureTransportError (403) — code reçu en clair en production.
174
182
  * @throws ThrottledError (429) — trop de tentatives.
175
183
  * @throws AuthenticationError (401, uniforme) — aucun défi en cours, code absent
176
184
  * ou invalide (la session N'est PAS ouverte ; le défi reste pour un retry).
@@ -178,6 +186,7 @@ var AuthFlow = class extends Service {
178
186
  async completeMfaLogin(context, code) {
179
187
  const info = readAuditContext(context);
180
188
  const pending = context.session?.get(PENDING_MFA_KEY);
189
+ this.#assertTransport(context, "login/totp", typeof pending === "string" && pending.length > 0 ? pending : null, info);
181
190
  if (typeof pending !== "string" || pending.length === 0) throw new AuthenticationError(INVALID_CREDENTIALS);
182
191
  const throttler = this.#resolveThrottler();
183
192
  if (throttler !== null) {
@@ -253,15 +262,35 @@ var AuthFlow = class extends Service {
253
262
  return true;
254
263
  }
255
264
  /**
265
+ * Déconnexion COMPLÈTE : détruit la session locale et rend, si la session
266
+ * vient d'un fournisseur qui publie un point de déconnexion (Keycloak…),
267
+ * l'adresse où envoyer le navigateur pour fermer AUSSI sa session à lui —
268
+ * sans quoi le clic suivant sur « Continuer avec … » reconnecte sans mot de
269
+ * passe (OpenID Connect RP-Initiated Logout 1.0).
270
+ *
271
+ * @returns `destroyed` comme {@link logout} ; `logoutUrl` à suivre, ou `null`.
272
+ */
273
+ async logoutWithProvider(context) {
274
+ const session = context.session;
275
+ const logoutUrl = session?.status === "active" ? await this.get("oauth2")?.logoutUrlFor(session) ?? null : null;
276
+ return {
277
+ destroyed: await this.logout(context),
278
+ logoutUrl
279
+ };
280
+ }
281
+ /**
256
282
  * Identité portée par la session courante, re-résolue auprès du provider
257
283
  * (mêmes contrôles que le `SessionAuthenticator` — source unique).
258
284
  *
259
285
  * @returns la projection publique, ou `null` (pas de session, session
260
- * orpheline, compte verrouillé/désactivé) — le handler répond 401.
286
+ * orpheline, compte verrouillé/désactivé) — le handler répond `200`
287
+ * `{ user: null }`.
261
288
  */
262
289
  async me(context) {
263
290
  const identifier = context.session?.user;
264
291
  if (typeof identifier !== "string" || identifier.length === 0) return null;
292
+ const current = requestUser(identifier);
293
+ if (current !== null) return toSafeUser(current);
265
294
  try {
266
295
  return toSafeUser(await resolveSessionIdentity(this.#resolveUsers(), identifier));
267
296
  } catch (error) {
@@ -269,7 +298,59 @@ var AuthFlow = class extends Service {
269
298
  throw error;
270
299
  }
271
300
  }
272
- async #openSession(context, identifier) {
301
+ /**
302
+ * Décrit la page de connexion par défaut : ce qu'elle propose et comment
303
+ * elle s'habille, depuis `security.loginPage` et les fournisseurs
304
+ * opérationnels.
305
+ *
306
+ * Lue par le contrôleur de `@nodefony/framework`, qui n'importe pas ce
307
+ * paquet. Les fournisseurs sont relus à chaque appel : l'un d'eux peut
308
+ * tomber ou revenir pendant que le serveur tourne.
309
+ *
310
+ * @returns la description, ou `null` quand la page par défaut est
311
+ * désactivée (`loginPage.enabled: false`) ou la configuration invalide —
312
+ * le contrôleur répond alors 404.
313
+ */
314
+ describeLoginPage() {
315
+ const page = this.#resolveLoginPage();
316
+ if (page === null || !page.enabled) return null;
317
+ const offered = this.get("oauth2")?.listDisplayProviders() ?? [];
318
+ return {
319
+ path: page.path,
320
+ title: page.title ?? null,
321
+ logo: page.logo ?? null,
322
+ template: page.template ?? null,
323
+ heading: page.heading ?? null,
324
+ subtitle: page.subtitle ?? null,
325
+ stylesheet: page.stylesheet ?? null,
326
+ providersFirst: page.providersFirst,
327
+ hero: page.hero === void 0 ? null : page.hero === false ? false : {
328
+ heading: page.hero.heading,
329
+ text: page.hero.text ?? null
330
+ },
331
+ footer: page.footer,
332
+ skin: page.skin,
333
+ layout: page.layout ?? LOGIN_PAGE_SKIN_SPECS[page.skin].layout,
334
+ previewPicker: page.previewPicker,
335
+ previewHook: page.previewHook ?? null,
336
+ password: page.password,
337
+ passkey: this.get("webauthn")?.isEnabled() ?? false,
338
+ providers: offered.map(({ name, label, icon }) => ({
339
+ name,
340
+ label,
341
+ icon: icon ?? null
342
+ }))
343
+ };
344
+ }
345
+ #resolveLoginPage() {
346
+ if (!this.#loginPageResolved) {
347
+ this.#loginPageResolved = true;
348
+ const parsed = securityConfigSchema.safeParse(this.options);
349
+ this.#loginPage = parsed.success ? parsed.data.loginPage : null;
350
+ }
351
+ return this.#loginPage;
352
+ }
353
+ async #openSession(context, identifier, factor) {
273
354
  const session = context.session ?? await this.#resolveSessions().start(context);
274
355
  if (session === null) {
275
356
  this.log("login: session service returned no session", "ERROR");
@@ -287,6 +368,8 @@ var AuthFlow = class extends Service {
287
368
  const ua = provenance.getUserAgent?.();
288
369
  if (ua) session.setMetaBag("ua", ua);
289
370
  } catch {}
371
+ session.setMetaBag(SESSION_AUTH_AT_KEY, Date.now());
372
+ session.setMetaBag(SESSION_AMR_KEY, amrForFactor(factor));
290
373
  await session.save(identifier);
291
374
  }
292
375
  #resolveSessions() {
@@ -305,6 +388,23 @@ var AuthFlow = class extends Service {
305
388
  }
306
389
  return this.#users;
307
390
  }
391
+ #assertTransport(context, door, actor, info) {
392
+ this.#transport ??= this.get("credentialTransport") ?? null;
393
+ if (this.#transport === null) return;
394
+ try {
395
+ this.#transport.assert(context, door);
396
+ } catch (error) {
397
+ if (error instanceof InsecureTransportError) recordAudit(this.container, {
398
+ category: "auth",
399
+ action: "login.failure",
400
+ outcome: "failure",
401
+ actor,
402
+ reason: "insecure_transport",
403
+ ...info
404
+ });
405
+ throw error;
406
+ }
407
+ }
308
408
  #resolveThrottler() {
309
409
  if (!this.#throttlerResolved) {
310
410
  this.#throttler = this.get("loginThrottler") ?? null;
@@ -324,7 +424,7 @@ var AuthFlow = class extends Service {
324
424
  function toSafeUser(user) {
325
425
  return {
326
426
  id: user.id,
327
- username: user.identifier,
427
+ identifier: user.identifier,
328
428
  roles: [...user.roles]
329
429
  };
330
430
  }
@@ -1,5 +1,9 @@
1
+ import { compileProviderRoleMapping, isPlatformRole } from "../src/oauth/providerRoles.js";
1
2
  import { SecuredArea } from "../src/SecuredArea.js";
3
+ import { entryPointLocation } from "../src/entryPoint.js";
2
4
  import { LoginThrottler } from "../src/throttle/LoginThrottler.js";
5
+ import { InsecureTransportError } from "../errors/InsecureTransportError.js";
6
+ import { CredentialTransportPolicy, shouldEnforceCredentialTransport } from "../src/transport/CredentialTransportPolicy.js";
3
7
  import { RoleHierarchyWalker } from "../src/RoleHierarchyWalker.js";
4
8
  import { mergeCspFragments } from "../src/csp.js";
5
9
  import { CsrfTokenManager } from "../src/csrfToken.js";
@@ -17,9 +21,10 @@ import { DEFAULT_SYSTEM_RULES, buildFrameAuthorizer, buildSystemRules } from "..
17
21
  import { recordAudit } from "../src/audit/recordAudit.js";
18
22
  import { readAuditContext } from "../src/audit/readAuditContext.js";
19
23
  import { SECURITY_AUDIT_CHANNEL, createAuditBridge } from "../src/audit/auditBridge.js";
24
+ import { findZonesOpenToSignup } from "../src/openToSignup.js";
20
25
  import { RequestContext, Service, logColor } from "nodefony";
21
- import { encoderFromConfig } from "@nodefony/user";
22
26
  import { randomBytes } from "node:crypto";
27
+ import { encoderFromConfig } from "@nodefony/user";
23
28
  //#region nodefony/service/firewall.ts
24
29
  const serviceName = "firewall";
25
30
  /**
@@ -87,6 +92,7 @@ var Firewall = class extends Service {
87
92
  #cspFragments = null;
88
93
  #config = null;
89
94
  #configError = null;
95
+ #longLivedRevalidation = null;
90
96
  constructor(module) {
91
97
  super(serviceName, module.container, module.notificationsCenter, module.options);
92
98
  this.module = module;
@@ -122,6 +128,7 @@ var Firewall = class extends Service {
122
128
  this.#areas = list;
123
129
  this.#instantiateAuthenticators(list);
124
130
  }
131
+ for (const z of findZonesOpenToSignup(this.#config)) this.log(`${z.message} → ${z.action}`, "WARNING");
125
132
  if (!this.#configError) {
126
133
  this.#wireRealtime();
127
134
  this.log(`Firewall ready — ${this.#areas?.length ?? 0} area(s), ${this.#authenticators?.size ?? 0} authenticator(s)`, "DEBUG");
@@ -172,6 +179,10 @@ var Firewall = class extends Service {
172
179
  this.log(wired ? "Realtime data plane locked — WS handshake authenticators (RBAC) + frame authorizer + audit channel wired" : "Realtime data plane locked — frame authorizer (system floor) wired without qualifying zone; system channels closed to anonymous", "DEBUG");
173
180
  }
174
181
  #provisionSharedServices(config) {
182
+ this.container?.set("credentialTransport", new CredentialTransportPolicy({
183
+ enforce: shouldEnforceCredentialTransport(this.kernel?.environment, config.allowInsecureCredentials),
184
+ log: (message) => this.log(message, "WARNING")
185
+ }));
175
186
  const specs = Object.values(config.encoders);
176
187
  if (specs.length > 0) this.container?.set("passwordEncoder", encoderFromConfig(specs));
177
188
  const rl = config.rateLimit;
@@ -263,17 +274,27 @@ var Firewall = class extends Service {
263
274
  describe() {
264
275
  const config = this.#config;
265
276
  const mounted = this.#authenticators;
266
- const zones = (this.#areas ?? []).map((a) => ({
267
- name: a.name,
268
- pattern: a.pattern.source,
269
- security: a.security,
270
- stateless: a.stateless,
271
- mode: a.mode,
272
- authenticators: [...a.authenticators],
273
- allowsAnonymous: a.authenticators.includes("anonymous"),
274
- host: a.host ?? null,
275
- realtime: a.realtime
276
- }));
277
+ const open = new Map((config ? findZonesOpenToSignup(config) : []).map((z) => [z.zone, z]));
278
+ const zones = (this.#areas ?? []).map((a) => {
279
+ const notice = open.get(a.name);
280
+ return {
281
+ name: a.name,
282
+ pattern: a.pattern.source,
283
+ security: a.security,
284
+ stateless: a.stateless,
285
+ mode: a.mode,
286
+ authenticators: [...a.authenticators],
287
+ allowsAnonymous: a.authenticators.includes("anonymous"),
288
+ host: a.host ?? null,
289
+ realtime: a.realtime,
290
+ roles: a.roles ? [...a.roles] : [],
291
+ openToSignup: notice !== void 0,
292
+ openToSignupNotice: notice ? {
293
+ message: notice.message,
294
+ action: notice.action
295
+ } : null
296
+ };
297
+ });
277
298
  const available = new Set(listAuthenticatorFactories());
278
299
  const names = new Set(available);
279
300
  if (mounted) for (const n of mounted.keys()) names.add(n);
@@ -340,12 +361,32 @@ var Firewall = class extends Service {
340
361
  const hierarchy = {};
341
362
  for (const [role, inherited] of Object.entries(raw)) hierarchy[role] = [...inherited];
342
363
  const walker = this.roleHierarchy;
364
+ const roles = Object.keys(hierarchy).map((role) => ({
365
+ role,
366
+ inherits: [...walker.reachableRoles([role])].filter((r) => r !== role).sort()
367
+ }));
368
+ const providerMappings = [];
369
+ for (const [provider, p] of Object.entries(this.#config?.oauth2.providers ?? {})) {
370
+ const mapping = compileProviderRoleMapping(p);
371
+ if (mapping === null) continue;
372
+ providerMappings.push({
373
+ provider,
374
+ sources: [...mapping.sources],
375
+ mappings: Object.keys(mapping.table).sort().map((from) => {
376
+ const to = mapping.table[from] ?? "";
377
+ return {
378
+ from,
379
+ to,
380
+ platform: isPlatformRole(to)
381
+ };
382
+ }),
383
+ allowPlatformRoles: p.allowPlatformRoles
384
+ });
385
+ }
343
386
  return {
344
387
  hierarchy,
345
- roles: Object.keys(hierarchy).map((role) => ({
346
- role,
347
- inherits: [...walker.reachableRoles([role])].filter((r) => r !== role).sort()
348
- }))
388
+ roles,
389
+ providerMappings
349
390
  };
350
391
  }
351
392
  /**
@@ -424,7 +465,7 @@ var Firewall = class extends Service {
424
465
  }
425
466
  /**
426
467
  * Pipeline complet de la zone : chaîne d'authenticators (selon `mode`) → ALS
427
- * → Zero Trust. Rejette (401, challenge RFC 7235 posé) ou résout.
468
+ * → Zero Trust. Rejette (401, challenge RFC 9110 §11 posé) ou résout.
428
469
  */
429
470
  async handleSecurity(context) {
430
471
  if (this.#configError) throw new AuthenticationError("Security configuration invalid");
@@ -449,17 +490,22 @@ var Firewall = class extends Service {
449
490
  this.#recordAuth(context, area, "auth.unverifiable", "failure", "verifier_unavailable", null);
450
491
  throw error;
451
492
  }
493
+ if (error instanceof InsecureTransportError) {
494
+ this.#recordAuth(context, area, "auth.failure", "failure", "insecure_transport", null);
495
+ throw error;
496
+ }
452
497
  this.#setChallenge(context, area);
453
498
  this.#recordAuth(context, area, "auth.failure", "failure", "invalid_credentials", null);
454
499
  throw error;
455
500
  }
456
501
  if (token === null) {
457
- this.#setChallenge(context, area);
502
+ if (!this.#redirectToEntryPoint(context, area)) this.#setChallenge(context, area);
458
503
  this.#recordAuth(context, area, "auth.denied", "denied", "no_credentials", null);
459
504
  throw new AuthenticationError(`Authentication required for area "${area.name}"`);
460
505
  }
461
506
  RequestContext.set("user", token.getUser());
462
507
  RequestContext.set("token", token);
508
+ if (token.isAuthenticated()) context.userIdentifier = token.getUserIdentifier();
463
509
  if (!token.isAuthenticated() && token.type !== "anonymous") {
464
510
  this.#setChallenge(context, area);
465
511
  this.#recordAuth(context, area, "auth.denied", "denied", "unauthenticated", token.getUserIdentifier());
@@ -520,8 +566,13 @@ var Firewall = class extends Service {
520
566
  * synchronizer token (`context.csrfToken`) — HttpContext pose ensuite le cookie
521
567
  * lisible `csrf-token`. Sinon, hot-path GET = retour immédiat (aucun en-tête lu).
522
568
  * - **Étape 1 (globale)** : sur une mutation, défense Fetch Metadata / Origin
523
- * (rejet cross-site même sur route publique). Skippée si `@CsrfExempt` (webhook,
524
- * auth par signature/clé) ou `bypassFirewall` (callbacks OAuth).
569
+ * (rejet cross-site même sur route publique). Skippée SEULEMENT par `@CsrfExempt`
570
+ * (webhook, auth par signature/clé). `bypassFirewall` ne l'en dispense PAS : il
571
+ * retire l'authentification, pas la provenance — les routes de connexion sont
572
+ * en bypass, et c'est précisément là qu'un formulaire tiers ouvrirait chez la
573
+ * victime une session sur le compte de l'attaquant (login CSRF, CWE-352). Un
574
+ * appel de serveur à serveur (canal arrière OIDC) ne porte ni `Sec-Fetch-Site`
575
+ * ni `Origin` : il passe toujours.
525
576
  * - **Étape 2 (opt-in)** : sur une mutation `@CsrfProtect`, exige EN PLUS le
526
577
  * synchronizer token (en-tête `x-csrf-token` ≡ cookie + HMAC valide).
527
578
  *
@@ -529,7 +580,6 @@ var Firewall = class extends Service {
529
580
  */
530
581
  enforceCsrf(context) {
531
582
  if (!this.#csrf) return;
532
- if (context.resolver?.bypassFirewall) return;
533
583
  const ctx = context;
534
584
  if (!Csrf.isStateChanging(context.method)) {
535
585
  if (ctx.csrfProtect && this.#csrfTokens && ctx.csrfToken == null) ctx.csrfToken = this.#csrfTokens.issue();
@@ -577,6 +627,18 @@ var Firewall = class extends Service {
577
627
  return isPreflight ? 204 : void 0;
578
628
  }
579
629
  /**
630
+ * Le revalidateur de l'identité de la requête courante, pour une connexion
631
+ * longue qui la gardera (flux SSE). Même règle, même store de jetons que les
632
+ * sockets WebSocket — un transport qui en aurait une autre serait celui
633
+ * qu'un attaquant prendrait pour survivre à sa révocation.
634
+ *
635
+ * @returns `null` pour un anonyme ; sinon la fonction qui re-valide.
636
+ */
637
+ currentRevalidator() {
638
+ this.#longLivedRevalidation ??= new FirewallRealtimeAuthenticator(() => this.container?.get("tokenStore") ?? null);
639
+ return this.#longLivedRevalidation.currentRevalidator();
640
+ }
641
+ /**
580
642
  * En-têtes de sécurité APPLICATIFS (P6 J5) — CSP, Referrer-Policy, isolation
581
643
  * cross-origin (COOP/COEP/CORP), Origin-Agent-Cluster, Permissions-Policy.
582
644
  * Posés sur toute réponse du pipeline (branché dans `handleHttp`). Complète le
@@ -690,6 +752,34 @@ var Firewall = class extends Service {
690
752
  }
691
753
  return token;
692
754
  }
755
+ /**
756
+ * Pose la redirection vers la page de connexion de la zone, quand la requête
757
+ * refusée est la navigation d'un navigateur. Cold path (refus seulement).
758
+ *
759
+ * La réponse est seulement MARQUÉE (`302` + `Location`) : le refus continue
760
+ * de remonter, et http rend une réponse déjà redirigée telle quelle au lieu
761
+ * d'un 401. Capability check : un contexte sans `redirect` (WebSocket) garde
762
+ * son refus.
763
+ *
764
+ * @returns `true` si la redirection est posée
765
+ */
766
+ #redirectToEntryPoint(context, area) {
767
+ if (area.entryPoint === void 0 || !("redirect" in context)) return false;
768
+ const request = context.request;
769
+ const header = (name) => {
770
+ const value = request.headers[name];
771
+ return typeof value === "string" ? value : void 0;
772
+ };
773
+ const location = entryPointLocation(area.entryPoint, {
774
+ method: request.method,
775
+ accept: header("accept"),
776
+ fetchMode: header("sec-fetch-mode"),
777
+ path: request.url.pathname + request.url.search
778
+ });
779
+ if (location === null) return false;
780
+ context.redirect(location, 302);
781
+ return true;
782
+ }
693
783
  #setChallenge(context, area) {
694
784
  const response = context.response;
695
785
  if (typeof response?.setHeader !== "function") return;