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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  3. package/dist/index.js +44 -10
  4. package/dist/nodefony/command/security-oauth-doctor.js +66 -0
  5. package/dist/nodefony/command/security-secrets.js +42 -25
  6. package/dist/nodefony/command/security-token.js +46 -15
  7. package/dist/nodefony/command/security-user-delete.js +5 -4
  8. package/dist/nodefony/command/security-user-password.js +4 -11
  9. package/dist/nodefony/config/config.js +80 -5
  10. package/dist/nodefony/contracts/index.js +1 -2
  11. package/dist/nodefony/errors/InsecureTransportError.js +24 -0
  12. package/dist/nodefony/errors/index.js +2 -1
  13. package/dist/nodefony/keycloak/keycloakAdmin.js +255 -0
  14. package/dist/nodefony/keycloak/keycloakRealm.js +260 -0
  15. package/dist/nodefony/keycloak/keycloakRealmInput.js +124 -0
  16. package/dist/nodefony/keycloak/scaffold.js +231 -0
  17. package/dist/nodefony/keycloak/security-keycloak-realm.js +128 -0
  18. package/dist/nodefony/scaffold/contribute.js +20 -0
  19. package/dist/nodefony/service/authFlow.js +111 -11
  20. package/dist/nodefony/service/firewall.js +111 -21
  21. package/dist/nodefony/service/keycloak.js +209 -0
  22. package/dist/nodefony/service/oauth2.js +379 -26
  23. package/dist/nodefony/service/tokenService.js +57 -6
  24. package/dist/nodefony/service/totp.js +51 -6
  25. package/dist/nodefony/service/webAuthn.js +20 -2
  26. package/dist/nodefony/src/SecuredArea.js +14 -3
  27. package/dist/nodefony/src/admin/SecurityAdminApi.js +70 -40
  28. package/dist/nodefony/src/admin/adminAudit.js +0 -1
  29. package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
  30. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +66 -6
  31. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +38 -15
  32. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +1 -0
  33. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +13 -3
  34. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +9 -2
  35. package/dist/nodefony/src/authenticator/bearer.js +1 -2
  36. package/dist/nodefony/src/boot/publicRoutesNotice.js +27 -0
  37. package/dist/nodefony/src/entryPoint.js +39 -0
  38. package/dist/nodefony/src/oauth/clientCredentials.js +40 -0
  39. package/dist/nodefony/src/oauth/metadata.js +36 -9
  40. package/dist/nodefony/src/oauth/oauth2Client.js +60 -33
  41. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +39 -6
  42. package/dist/nodefony/src/oauth/providerDiagnosis.js +283 -0
  43. package/dist/nodefony/src/oauth/providerRoles.js +79 -0
  44. package/dist/nodefony/src/oauth/providers/github.js +32 -6
  45. package/dist/nodefony/src/oauth/providers/oidc.js +82 -3
  46. package/dist/nodefony/src/openToSignup.js +47 -0
  47. package/dist/nodefony/src/sessionAuthentication.js +26 -0
  48. package/dist/nodefony/src/token/JwtKeystore.js +78 -46
  49. package/dist/nodefony/src/token/RemoteJwtVerifier.js +40 -13
  50. package/dist/nodefony/src/token/secretFile.js +59 -2
  51. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +51 -0
  52. package/dist/types/index.d.ts +26 -6
  53. package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
  54. package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
  55. package/dist/types/nodefony/command/security-token.d.ts +9 -0
  56. package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
  57. package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
  58. package/dist/types/nodefony/config/config.d.ts +63 -1
  59. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
  60. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
  61. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
  62. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
  63. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
  64. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
  65. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
  66. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
  67. package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
  68. package/dist/types/nodefony/errors/index.d.ts +1 -0
  69. package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
  70. package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
  71. package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
  72. package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
  73. package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
  74. package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
  75. package/dist/types/nodefony/service/authFlow.d.ts +35 -3
  76. package/dist/types/nodefony/service/firewall.d.ts +17 -3
  77. package/dist/types/nodefony/service/keycloak.d.ts +115 -0
  78. package/dist/types/nodefony/service/oauth2.d.ts +125 -9
  79. package/dist/types/nodefony/service/tokenService.d.ts +7 -1
  80. package/dist/types/nodefony/service/totp.d.ts +28 -4
  81. package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
  82. package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
  83. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +54 -12
  84. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +18 -0
  85. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +13 -0
  86. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
  87. package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
  88. package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
  89. package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
  90. package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
  91. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
  92. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
  93. package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
  94. package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
  95. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
  96. package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
  97. package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
  98. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
  99. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
  100. package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
  101. package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
  102. package/docs/api-keys.md +17 -17
  103. package/docs/audit.md +16 -16
  104. package/docs/authenticators.md +60 -27
  105. package/docs/authorization.md +16 -16
  106. package/docs/cors.md +29 -29
  107. package/docs/csrf.md +35 -33
  108. package/docs/external-jwt.md +66 -4
  109. package/docs/firewall.md +57 -24
  110. package/docs/headers.md +30 -30
  111. package/docs/index.md +12 -4
  112. package/docs/keycloak.md +818 -0
  113. package/docs/login-page.md +361 -0
  114. package/docs/oauth2.md +236 -122
  115. package/docs/obtenir-un-jeton.md +18 -11
  116. package/docs/tokens.md +136 -85
  117. package/docs/totp.md +105 -64
  118. package/docs/webauthn.md +90 -87
  119. package/docs/webhooks.md +3 -3
  120. package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
  121. package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
  122. package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
  123. package/keycloak/themes/nodefony/account/theme.properties +14 -0
  124. package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
  125. package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
  126. package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
  127. package/keycloak/themes/nodefony/admin/theme.properties +12 -0
  128. package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
  129. package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
  130. package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
  131. package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
  132. package/keycloak/themes/nodefony/email/theme.properties +8 -0
  133. package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
  134. package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
  135. package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
  136. package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
  137. package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
  138. package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
  139. package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
  140. package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
  141. package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
  142. package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
  143. package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
  144. package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
  145. package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
  146. package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
  147. package/keycloak/themes/nodefony/login/template.ftl +423 -0
  148. package/keycloak/themes/nodefony/login/theme.properties +17 -0
  149. package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
  150. package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
  151. package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
  152. package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
  153. package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
  154. package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
  155. package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
  156. package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
  157. package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
  158. package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
  159. package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
  160. package/package.json +17 -15
@@ -1,3 +1,6 @@
1
+ import { parseKeySet } from "../src/token/JwtKeystore.js";
2
+ import { PLATFORM_ROLE_PREFIX, isPlatformRole } from "../src/oauth/providerRoles.js";
3
+ import { DEFAULT_LOGIN_PAGE_SKIN, LOGIN_PAGE_LAYOUTS, LOGIN_PAGE_PATH, LOGIN_PAGE_SKINS, parseDiscoveryUrl, safeRedirectPath } from "nodefony";
1
4
  import { z } from "zod";
2
5
  //#region nodefony/config/config.ts
3
6
  /**
@@ -45,10 +48,18 @@ const areaSchema = z.strictObject({
45
48
  mode: z.enum(["first", "all"]).default("first").describe("Chaîne d'authenticators : 'first' = le premier qui reconnaît la requête authentifie (ex. cookie OU bearer) ; 'all' = tous doivent passer (ex. mtls+jwt zone admin)."),
46
49
  authenticators: z.array(z.string()).default([]).describe("Authenticators de la zone (sémantique selon `mode`). Validés au boot contre le registre."),
47
50
  roles: z.array(z.string()).default([]).describe("Rôle(s) exigés PAR DÉFAUT dans la zone, en OU (l'un suffit ; hiérarchie comprise). Vide (défaut) = la zone n'exige qu'une identité, et toute route qu'elle couvre est ouverte à n'importe quel compte authentifié — c'est un défaut OUVERT, qu'une surface d'administration ne doit pas garder. Renseigné, le rôle s'applique à toute route de la zone qui ne décide PAS elle-même de son autorisation : une route gardée (`@IsGranted`, `@RequireScope`) et le pont du plan d'administration (qui résout un rôle par point d'entrée) gardent la main — sans quoi une zone fermée à l'administrateur écraserait un point d'entrée voulu accessible à son propriétaire (`me`, `sessions/mine`). Un refus ici est un 403 (authentifié, pas autorisé), jamais un 401."),
48
- entryPoint: z.string().optional().describe("Route de login/redirect si non authentifié."),
51
+ entryPoint: z.string().refine((value) => safeRedirectPath(value, "") === value, { message: "entryPoint doit être un chemin de CETTE origine (`/login`), jamais une adresse externe" }).optional().describe("Page de connexion vers laquelle conduire un visiteur anonyme qui NAVIGUE (GET ou HEAD, `Accept: text/html`, `Sec-Fetch-Mode: navigate` s'il est présent) : 302 vers `<entryPoint>?from=<chemin demandé>`, et la page de connexion ramène au chemin demandé. Tout le reste — appel d'API, `fetch`, POST, WebSocket — garde son 401 et son `WWW-Authenticate`. Le refus reste journalisé (`auth.denied`). Absent (défaut) : 401 pour tous."),
49
52
  host: z.string().optional().describe("Domaine/vhost de la zone (ex. admin.exemple.com). Omis = tous domaines."),
50
53
  resource: z.string().optional().describe("URI canonique de la ressource protégée — l'AUDIENCE qu'un jeton doit porter pour être accepté ici (RFC 8707 §2). Obligatoire dès que la zone liste un authenticator de jetons TIERS (`external-jwt`), qui refuse de démarrer sans elle. C'est ce qui empêche un jeton valide, délivré au même porteur pour un AUTRE service, d'être rejoué sur celui-ci. Elle s'ÉCRIT et ne se dérive pas du `Host` : derrière un relais, ce que le processus croit être son adresse n'est pas ce que le client a demandé, et l'audience doit être exactement celle que le serveur d'autorisation a inscrite dans le jeton. Ex. « https://api.example.com/mcp »."),
51
- realtime: z.boolean().default(true).describe("Zone valable AUSSI pour les frames WebSocket (api.request + subscribe), pas seulement HTTP. Défaut `true` (Zero Trust : une zone protégée ferme TOUS ses transports — un opt-IN laisserait le WS anonyme par omission = fail-open). `false` = opt-out explicite pour une zone strictement HTTP. Le verrou WS consulte la MÊME zone que HTTP — invariant : `api.request {path}` n'accorde jamais plus que `GET {path}`.")
54
+ realtime: z.boolean().default(true).describe("Zone valable AUSSI pour les frames WebSocket (api.request + subscribe), pas seulement HTTP. Défaut `true` (Zero Trust : une zone protégée ferme TOUS ses transports — un opt-IN laisserait le WS anonyme par omission = fail-open). `false` = opt-out explicite pour une zone strictement HTTP. Le verrou WS consulte la MÊME zone que HTTP — invariant : `api.request {path}` n'accorde jamais plus que `GET {path}`."),
55
+ subjectPolicy: z.enum(["require", "ephemeral"]).optional().describe("Surcharge, pour CETTE zone, de `resourceServer.subjectPolicy` — comment le sujet d'un jeton TIERS devient un utilisateur. Omis (défaut) = la politique globale. Sert à faire cohabiter une zone d'appelants machine (« ephemeral » : aucun compte local exigé) et une zone de personnes (« require ») dans la même application ; une politique globale ne sait pas l'exprimer. N'a de sens que si la zone liste `external-jwt` : déclarée ailleurs, le boot la refuse."),
56
+ ephemeralRoles: z.array(z.string().min(1)).optional().describe("Rôles accordés à l'appelant accepté par CETTE zone en mode « ephemeral ». Exige `subjectPolicy: \"ephemeral\"` écrit dans la zone même : un pouvoir local ne s'hérite pas d'un réglage écrit ailleurs. Une zone qui déclare « ephemeral » sans ses rôles n'en accorde AUCUN — jamais ceux de `resourceServer.ephemeralRoles`, qui protègent une autre surface.")
57
+ }).refine((area) => area.subjectPolicy === void 0 || area.authenticators.includes("external-jwt"), {
58
+ message: "subjectPolicy ne s'applique qu'aux jetons tiers : la zone doit lister l'authenticator « external-jwt »",
59
+ path: ["subjectPolicy"]
60
+ }).refine((area) => area.ephemeralRoles === void 0 || area.subjectPolicy === "ephemeral", {
61
+ message: "ephemeralRoles exige subjectPolicy: \"ephemeral\" écrit dans la MÊME zone — un pouvoir local ne s'hérite pas",
62
+ path: ["ephemeralRoles"]
52
63
  });
53
64
  const corsSchema = z.strictObject({
54
65
  enabled: z.boolean().default(true).describe("Active la gestion CORS."),
@@ -152,7 +163,14 @@ const jwtSchema = z.strictObject({
152
163
  audiences: z.array(z.string()).default([]).describe("Audiences acceptées à la vérification ET **demandables à l'émission** (claim `aud`, RFC 8707) — une liste BLANCHE de ressources, pas une simple valeur par défaut. Un client nomme sa cible par le paramètre `resource` du grant ; absente de cette liste, la demande est REFUSÉE (`invalid_target`, RFC 8707 §2) au lieu d'être servie avec l'audience par défaut — sans quoi tout porteur d'un identifiant valide obtiendrait un jeton pour la ressource de son choix. La PREMIÈRE entrée est l'audience par défaut (jeton demandé sans `resource`). Vide = l'audience de l'app (= `issuer`). Déclarer ici l'URI canonique d'une ressource protégée (porte MCP, API interne) est ce qui la rend joignable avec un jeton émis par cette application. La validation d'audience est OBLIGATOIRE côté resource (RFC 9700)."),
153
164
  issuer: z.string().optional().describe("Émetteur (claim `iss`, RFC 7519). Omis = `\"nodefony\"` — suffisant tant que l'app émet ET vérifie ses propres jetons, mais PAS publiable (RFC 8414 §2 exige une URL https) : sans valeur explicite, aucune découverte n'est offerte. Ne se DEVINE pas — derrière un relais, `Host`/`X-Forwarded-*` viennent du client. STABLE : gravé dans chaque jeton déjà émis, le changer les invalide."),
154
165
  keystore: z.strictObject({
155
- keySetJson: z.string().optional().describe("JWK Set (clé(s) privée(s) Ed25519) injecté par l'app depuis son env — SECRET, jamais loggé. Présent = source `env` (prod cloud)."),
166
+ keySetJson: z.string().refine((json) => {
167
+ try {
168
+ parseKeySet(json);
169
+ return true;
170
+ } catch {
171
+ return false;
172
+ }
173
+ }, { message: "jeu de clés illisible — attendu le JSON produit par `npx nodefony security:secrets --jwt-keyset` ({ active, keys: [JWK privé avec kid] }), sur une ligne" }).optional().describe("JWK Set (clé(s) privée(s) Ed25519) PARTAGÉ par tous les process de l'app (pods, workers de cluster) — variable `NF_JWT_KEYSET` d'une application générée. SECRET, jamais loggé. Présent = source `env`, prioritaire sur `dir`."),
156
174
  dir: z.string().optional().describe("Dossier de persistance `keyset.json` (chmod 600), généré si absent — opt-in dev/VPS. Sans `keySetJson` ni `dir` = clé ÉPHÉMÈRE en mémoire + warning (refresh non durables).")
157
175
  }).default(() => ({})).describe("Source du matériel de signature JWT (priorité) : env (`keySetJson`) → fichier (`dir`) → mémoire+warn. SecretProvider (Vault/KMS) = P16.")
158
176
  }).describe("JWT — réservé API service↔service / agents (le web/navigateur utilise la session BFF).");
@@ -223,8 +241,17 @@ const remoteJwtAlgSchema = z.enum([
223
241
  "EdDSA",
224
242
  "Ed25519"
225
243
  ]);
244
+ const discoveryUrlSchema = z.string().refine((value) => {
245
+ try {
246
+ parseDiscoveryUrl(value);
247
+ return true;
248
+ } catch {
249
+ return false;
250
+ }
251
+ }, "adresse http(s) complète du document attendue, sans identifiants ni fragment").optional().describe("Adresse COMPLÈTE où lire le document de métadonnées, quand l'application ne joint pas l'émetteur par son adresse publique — en Kubernetes, le service interne : « http://keycloak.sso:8080/realms/app/.well-known/openid-configuration ». Seul le lieu de lecture change : le document doit toujours se déclarer de `issuer` (RFC 8414 §3.3), sinon refus. Les points appelés de serveur à serveur (jeton, clés) peuvent alors être en http sur l'origine exacte de cette adresse ; le point d'autorisation, où le navigateur est envoyé, reste en https. Keycloak publie ses points internes avec `--hostname-backchannel-dynamic=true`. Omis = découverte sur l'émetteur.");
226
252
  const trustedIssuerSchema = z.strictObject({
227
253
  issuer: z.string().min(1).describe("Identifiant de l'émetteur (claim `iss`), en https, sans requête ni fragment (RFC 8414 §2) — ex. « https://auth.example.com/realms/mon-royaume ». C'est une ALLOWLIST : un jeton dont l'`iss` n'est pas ici est refusé avant toute requête sortante."),
254
+ discoveryUrl: discoveryUrlSchema,
228
255
  jwksUri: z.string().optional().describe("Jeu de clés publiques de l'émetteur. Omis = découvert par les points bien connus RFC 8414/OpenID (3 URL essayées dans l'ordre normatif). Déclaré = aucune découverte (émetteur sans métadonnées, ou démarrage à froid sans requête)."),
229
256
  algorithms: z.array(remoteJwtAlgSchema).min(1).default([
230
257
  "RS256",
@@ -301,25 +328,71 @@ const studioSchema = z.strictObject({
301
328
  description: "INERTE — l'audit des mutations admin est posé point par point par `SecurityAdminApi` (certaines mutations émettent, pas toutes) ; ce drapeau ne pilote aucune couverture globale."
302
329
  })
303
330
  }).describe("Sécurité de la console Studio — durcissement exposition publique.");
331
+ const loginPageSchema = z.strictObject({
332
+ enabled: z.boolean().default(true).describe("Sert la page de connexion par défaut. false = l'application sert la sienne au même chemin (`useNodefonyLogin`) : les redirections continuent d'y mener."),
333
+ path: z.string().default(LOGIN_PAGE_PATH).refine((p) => p.length > 0 && safeRedirectPath(p, "") === p && !/[?#]/.test(p), "chemin local attendu, sans requête ni fragment (`/login`)").describe("Chemin de la page de connexion. La redirection d'échec d'un fournisseur et le retour après déconnexion fédérée le suivent, sauf s'ils sont réglés."),
334
+ title: z.string().min(1).optional().describe("Titre de la page. Omis = titre par défaut."),
335
+ logo: z.string().min(1).optional().describe("Adresse du logo affiché (chemin servi par l'application ou URL). Omis = logo Nodefony."),
336
+ template: z.string().min(1).optional().describe("Chemin d'un gabarit `.eta` de l'application qui REMPLACE celui du framework. Dernier recours : `stylesheet` (variables `--nf-login-*`), `layout` et les textes (`heading`, `subtitle`, `hero`) suffisent à l'habillage, et survivent aux évolutions du balisage — un gabarit copié, non."),
337
+ stylesheet: z.string().min(1).optional().describe("Adresse d'une feuille de l'application chargée APRÈS celle du framework : elle redéfinit les variables `--nf-login-*` (couleurs, rayons, `--nf-login-hero-image` pour la photo du panneau). Chemin servi par l'application de préférence — une URL d'un autre site doit être autorisée par la CSP (`style-src`)."),
338
+ heading: z.string().min(1).optional().describe("Titre de la carte et de l'onglet. Omis = « Se connecter ». Le nom de l'application, lui, vient de `title`."),
339
+ subtitle: z.string().min(1).optional().describe("Ligne sous le titre. Omis = phrase déduite des moyens proposés (identifiant local, compte de l'organisation, ou les deux)."),
340
+ providersFirst: z.boolean().default(true).describe("Les boutons des fournisseurs passent AVANT le formulaire identifiant et mot de passe. false = le formulaire d'abord. Sans fournisseur proposé, sans effet."),
341
+ hero: z.union([z.literal(false), z.strictObject({
342
+ heading: z.string().min(1).describe("Accroche du panneau."),
343
+ text: z.string().min(1).optional().describe("Phrase sous l'accroche.")
344
+ })]).optional().describe("Panneau d'illustration de la mise en page `split`. Omis = vitrine Nodefony. `{ heading, text }` = votre texte à la place. false = la marque seule, sur l'image du panneau. L'image se règle dans `stylesheet` (`--nf-login-hero-image`), jamais ici."),
345
+ footer: z.boolean().default(true).describe("Pied de page : protections de la session (cookie HttpOnly, anti-CSRF, CSP) et mention « Propulsé par Nodefony ». false = retiré."),
346
+ password: z.boolean().default(true).describe("Propose le formulaire identifiant et mot de passe. false = connexion par fournisseur seulement (SSO)."),
347
+ skin: z.enum(LOGIN_PAGE_SKINS).default(DEFAULT_LOGIN_PAGE_SKIN).describe("Habillage : `frontispiece` (défaut), `ledger`, `horizon`, `console`, `blueprint`, `dots`, `minimal`, `enterprise`, `photo-card`. Une feuille du framework chargée avant `stylesheet` ; aucune ne fournit de photo — elle vient de `stylesheet` (`--nf-login-hero-image`). En développement, `?skin=` et `?layout=` en donnent un aperçu."),
348
+ layout: z.enum(LOGIN_PAGE_LAYOUTS).optional().describe("Mise en page : `split` (illustration et formulaire côte à côte), `card` (panneau centré), `bare` (formulaire seul). Omis = celle pour laquelle l'habillage est dessiné."),
349
+ previewPicker: z.boolean().default(false).describe("En DÉVELOPPEMENT seulement : un sélecteur d'habillage et de mise en page sur la page, qui recharge avec `?skin=` et `?layout=`. Sans effet en production, où ces paramètres sont ignorés. Pour choisir son habillage à l'écran, pas pour le livrer."),
350
+ previewHook: z.string().regex(/^\/(?![/\\])/u, "chemin de l'application attendu (« /… »), jamais une autre origine").optional().describe("Avec `previewPicker`, en DÉVELOPPEMENT : chemin de l'application appelé 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 (`applyKeycloakLoginTheme`). Un échec n'empêche pas le rechargement.")
351
+ }).describe("Page de connexion servie par le framework, sans front (ADR-0015).");
304
352
  const oauthProviderSchema = z.strictObject({
305
353
  clientId: z.string().min(1).describe("Identifiant client OAuth délivré par le fournisseur."),
306
354
  clientSecret: z.string().min(1).describe("Secret client OAuth — SECRET, fourni par env, jamais loggé."),
307
355
  clientAuthMethod: z.enum(["client_secret_basic", "client_secret_post"]).optional().describe("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 et ce que la plupart des serveurs annoncent par défaut. Poser `client_secret_post` quand le serveur l'EXIGE — il le publie dans ses métadonnées (`token_endpoint_auth_methods_supported`). La méthode `none` (client public) n'est pas offerte ici : ce schéma exige un secret non vide."),
308
356
  redirectUri: z.string().min(1).describe("URL de callback EXACTE (RFC 9700 : exact string matching) enregistrée chez le fournisseur — doit pointer sur .../oauth2/<provider>/callback."),
309
357
  issuer: z.string().optional().describe("Émetteur OIDC self-hosted (Keycloak : URL du realm, ex. https://kc.example/realms/app). REQUIS pour keycloak ; ignoré par les fournisseurs à endpoints fixes (Google/GitHub)."),
358
+ discoveryUrl: discoveryUrlSchema,
310
359
  scopes: z.array(z.string()).default([]).describe("Scopes demandés. Vide = défauts du fournisseur (Google: openid/profile/email ; GitHub: read:user/user:email)."),
311
360
  successRedirect: z.string().optional().describe("Redirection succès — surcharge le global pour CE fournisseur."),
312
361
  failureRedirect: z.string().optional().describe("Redirection échec — surcharge le global pour CE fournisseur."),
362
+ postLogoutRedirectUri: z.url().optional().describe("Adresse ABSOLUE où le fournisseur renvoie le navigateur après la déconnexion (`post_logout_redirect_uri`, OpenID Connect RP-Initiated Logout) — doit être enregistrée chez lui. OMIS = l'écran d'échec (`failureRedirect`, la page de connexion) résolu contre `redirectUri`. Sans effet pour un fournisseur qui ne publie pas de point de déconnexion."),
313
363
  defaultRoles: z.array(z.string()).optional().describe("Rôles du Shadow User à la création — surcharge le global pour CE fournisseur."),
364
+ roleMapping: z.record(z.string().min(1), z.string().min(1)).optional().describe("Rôles du FOURNISSEUR traduits en rôles de l'application, recalculés à chaque connexion ET à chaque jeton présenté à l'API : `{ \"app-admin\": \"ROLE_ADMIN\" }`. Un rôle du fournisseur absent de la table est ignoré, jamais recopié. Les rôles donnés à la main sont conservés. OMIS = les rôles restent entièrement locaux (posés à la création, puis la base fait foi). Un rôle `ROLE_NODEFONY_*` refuse le démarrage, sauf `allowPlatformRoles`."),
365
+ audiences: z.array(z.string().min(1)).optional().describe("Ressources pour lesquelles CE fournisseur émet des jetons à l'application — la mention « valable pour » (`aud`) qu'il doit imprimer. Chaque entrée est la `resource` d'une zone `external-jwt`. Lue par `security:keycloak:realm`, qui en écrit les mappers d'audience ; sans effet sur la vérification, que la `resource` de la zone porte seule. OMIS = la `resource` de TOUTES les zones `external-jwt`. À écrire dès qu'une zone existe pour REFUSER les jetons de ce fournisseur : la déduire ferait imprimer son adresse."),
366
+ allowPlatformRoles: z.boolean().default(false).describe("Autorise `roleMapping` à accorder des rôles de PLATEFORME (`ROLE_NODEFONY_*` : console d'administration, génération de code, secrets). OMIS = refusé au démarrage. L'ouvrir fait de l'administrateur de l'annuaire un administrateur de l'instance, et d'un annuaire compromis une prise de contrôle complète — un avertissement le rappelle à chaque démarrage."),
367
+ rolesSource: z.array(z.enum([
368
+ "client",
369
+ "realm",
370
+ "groups"
371
+ ])).min(1).optional().describe("Où lire les rôles du fournisseur pour `roleMapping`. `client` (défaut) : les rôles du client de CETTE application (`resource_access.<clientId>.roles`, Keycloak). `realm` : les rôles du realm (`realm_access.roles`). `groups` : le claim `groups` (mapper de groupes à poser chez le fournisseur). Sans `roleMapping`, sans effet."),
314
372
  label: z.string().min(1).optional().describe("Libellé du bouton sur l'écran de connexion. OMIS = dérivé du nom du fournisseur (`keycloak` → « Keycloak », `oidc` → « OIDC », `mon-idp` → « Mon Idp »). À poser quand la marque ne se devine pas du nom de la clé (« Connexion agent », « Annuaire interne »)."),
373
+ icon: z.string().min(1).optional().describe("Image du bouton sur l'écran de connexion (chemin servi par l'application, ou URL autorisée par la CSP `img-src`). OMIS = icône du framework : la marque pour `github`, une clé sinon."),
374
+ admin: z.strictObject({
375
+ username: z.string().min(1).describe("Identifiant administrateur."),
376
+ password: z.string().min(1).describe("Mot de passe administrateur — SECRET, jamais loggé.")
377
+ }).optional().describe("DÉVELOPPEMENT seulement — compte d'administration du serveur Keycloak (realm `master`). Sert à poser le thème de connexion qui suit `loginPage.skin` sur un realm EXISTANT (`security:keycloak:realm --apply`, aperçu de `/login`), ce que le fichier d'import ne fait pas. Refusé hors développement : ne le passer qu'en `ctx.isDev`."),
315
378
  hidden: z.boolean().default(false).describe("Retire le bouton de l'écran de connexion SANS désactiver le fournisseur : le flux `/authorize` reste ouvert et fonctionnel. C'est la seule différence avec le fait de ne pas le configurer. Deux usages : une FIXTURE de développement qui pointe vers un serveur fictif (bouton mort), et un fournisseur réservé à un point d'entrée particulier (lien direct, sous-domaine) plutôt qu'offert à tout visiteur.")
379
+ }).superRefine((provider, ctx) => {
380
+ if (provider.allowPlatformRoles) return;
381
+ for (const [from, to] of Object.entries(provider.roleMapping ?? {})) {
382
+ if (!isPlatformRole(to)) continue;
383
+ ctx.addIssue({
384
+ code: "custom",
385
+ path: ["roleMapping", from],
386
+ message: `« ${to} » est un rôle de PLATEFORME (${PLATFORM_ROLE_PREFIX}*) : un annuaire externe ne l'accorde pas sans \`allowPlatformRoles: true\` écrit sur ce fournisseur. Sinon, le donner à la main (\`security:user:add --admin\`, console d'administration) — il survit alors à chaque connexion.`
387
+ });
388
+ }
316
389
  }).describe("Fournisseur OAuth/OIDC (secrets via env).");
317
390
  const oauth2Schema = z.strictObject({
318
391
  enabled: z.boolean().default(true).describe("Active le social login (les routes ne montent que si ≥1 provider)."),
319
- defaultRoles: z.array(z.string()).default(["ROLE_USER"]).describe("Rôles du Shadow User à la CRÉATION uniquement (OAuth = authentification, pas autorisation : les rôles ne sont jamais réécrits ensuite, la base fait foi)."),
392
+ defaultRoles: z.array(z.string()).default(["ROLE_USER"]).describe("Rôles du Shadow User à la CRÉATION uniquement. Les rôles ne sont jamais réécrits ensuite, la base fait foi — sauf ceux qu'un fournisseur gère par sa `roleMapping`."),
320
393
  allowSignup: z.boolean().default(true).describe("true = crée une ligne locale au 1er login (JIT, Shadow User). false = un compte préexistant lié est requis (fail-closed)."),
321
394
  successRedirect: z.string().default("/").describe("Redirection après login réussi."),
322
- failureRedirect: z.string().default("/login").describe("Redirection après échec (state invalide, refus utilisateur...)."),
395
+ failureRedirect: z.string().optional().describe("Redirection après échec (state invalide, refus utilisateur...). Omis = la page de connexion (`loginPage.path`)."),
323
396
  providers: z.record(z.string(), oauthProviderSchema).default({}).describe("Fournisseurs activés par nom (doit correspondre au registre : builtins google/keycloak/oidc/github ; tout autre serveur OpenID Connect en enregistrant une fabrique, décrit par son seul émetteur).")
324
397
  }).describe("Social login OAuth 2.0 — Authorization Code + PKCE, session BFF.");
325
398
  /**
@@ -352,6 +425,8 @@ const securityConfigSchema = z.strictObject({
352
425
  description: "RÉSERVÉ — Token Exchange RFC 8693 (délégation agents/MCP). Slot P12, aucun champ encore lu par le runtime."
353
426
  }),
354
427
  oauth2: oauth2Schema.default(() => oauth2Schema.parse({})),
428
+ loginPage: loginPageSchema.default(() => loginPageSchema.parse({})),
429
+ allowInsecureCredentials: z.boolean().default(false).describe("Laisse passer, en production, un mot de passe ou un code de second facteur reçu en clair (HTTP). false (défaut) : refus 403 AVANT toute vérification, sur toutes les portes — formulaire de session, second facteur, Basic, émission de jeton. Le refus se décide sur le scheme EFFECTIF : derrière un proxy qui termine TLS, déclarer ce proxy de confiance (`trustProxy` de @nodefony/http) suffit, et c'est la bonne réponse — pas cette clé. Ne la passer à true que pour un réseau où le clair est un choix assumé (maillage chiffré au niveau inférieur). Hors production, sans effet."),
355
430
  apiKeys: apiKeysSchema.default(() => apiKeysSchema.parse({})),
356
431
  webhooks: webhooksSchema.default(() => webhooksSchema.parse({})),
357
432
  audit: auditSchema.default(() => auditSchema.parse({})),
@@ -1,2 +1 @@
1
- import { VoterVote } from "./IAccessVoter.js";
2
- export { VoterVote };
1
+ export { VoterVote } from "./IAccessVoter.js";
@@ -0,0 +1,24 @@
1
+ import { INSECURE_TRANSPORT_MESSAGE, nodefonyError } from "nodefony";
2
+ //#region nodefony/errors/InsecureTransportError.ts
3
+ /**
4
+ * Secret refusé parce qu'il arrive en clair — `code = 403` (RFC 9110 §15.5.4 :
5
+ * le serveur a compris la requête mais refuse de l'honorer).
6
+ *
7
+ * Message constant ({@link INSECURE_TRANSPORT_MESSAGE}, cœur) : le navigateur
8
+ * le reconnaît, et il ne dit rien de la topologie.
9
+ *
10
+ * Levée AVANT toute vérification, quand un mot de passe ou un code de second
11
+ * facteur arrive en production sur une requête dont le scheme effectif n'est
12
+ * pas `https` (cf {@link CredentialTransportPolicy}). Ce n'est pas un 401 :
13
+ * aucune preuve n'a été jugée, et rien de ce que le client corrigera dans son
14
+ * secret ne changera la réponse — c'est le canal qu'il doit changer. Pas de
15
+ * `WWW-Authenticate` non plus : un défi Basic ferait rouvrir au navigateur la
16
+ * fenêtre qui renverrait le mot de passe par le même canal.
17
+ */
18
+ var InsecureTransportError = class extends nodefonyError {
19
+ constructor() {
20
+ super(INSECURE_TRANSPORT_MESSAGE, 403);
21
+ }
22
+ };
23
+ //#endregion
24
+ export { InsecureTransportError, InsecureTransportError as default };
@@ -1,3 +1,4 @@
1
+ import { InsecureTransportError } from "./InsecureTransportError.js";
1
2
  import { CsrfError } from "./CsrfError.js";
2
3
  import { AuthenticationError } from "./AuthenticationError.js";
3
4
  import { ThrottledError } from "./ThrottledError.js";
@@ -6,4 +7,4 @@ import { InvalidTargetError } from "./InvalidTargetError.js";
6
7
  import { WebAuthnError } from "./WebAuthnError.js";
7
8
  import { SsrfError } from "./SsrfError.js";
8
9
  import { AccessDeniedError } from "./AccessDeniedError.js";
9
- export { AccessDeniedError, AuthenticationError, CsrfError, InvalidTargetError, SsrfError, ThrottledError, UnverifiableTokenError, WebAuthnError };
10
+ export { AccessDeniedError, AuthenticationError, CsrfError, InsecureTransportError, InvalidTargetError, SsrfError, ThrottledError, UnverifiableTokenError, WebAuthnError };
@@ -0,0 +1,255 @@
1
+ import { isNodefonyKeycloakTheme, keycloakLoginTheme, realmNameFromIssuer } from "./keycloakRealmInput.js";
2
+ //#region nodefony/keycloak/keycloakAdmin.ts
3
+ /** Refus ou échec d'un appel ; le message nomme la cause et le geste suivant. */
4
+ var KeycloakAdminError = class extends Error {
5
+ status;
6
+ /**
7
+ * @param message - phrase destinée à l'opérateur
8
+ * @param status - statut HTTP de Keycloak, `null` s'il n'a pas répondu
9
+ */
10
+ constructor(message, status = null) {
11
+ super(message);
12
+ this.status = status;
13
+ this.name = "KeycloakAdminError";
14
+ }
15
+ };
16
+ const DEFAULT_TIMEOUT_MS = 1e4;
17
+ /**
18
+ * Marge retirée à la durée de vie du jeton : un jeton qui expire PENDANT un
19
+ * appel rend un 401 qu'on lirait comme un compte refusé.
20
+ */
21
+ const TOKEN_MARGIN_MS = 1e4;
22
+ /**
23
+ * Le realm et le compte d'administration d'un fournisseur, lus dans la
24
+ * configuration de sécurité VALIDÉE.
25
+ *
26
+ * @param config - configuration de sécurité (`defineSecurityConfig`)
27
+ * @param providerName - nom dans `oauth2.providers` (défaut `keycloak`)
28
+ * @returns l'émetteur et le compte
29
+ * @throws {KeycloakAdminError} Fournisseur absent, sans émetteur ou sans
30
+ * compte d'administration
31
+ */
32
+ function keycloakAdminTarget(config, providerName = "keycloak") {
33
+ const provider = Object.hasOwn(config.oauth2.providers, providerName) ? config.oauth2.providers[providerName] : void 0;
34
+ if (provider?.issuer === void 0) throw new KeycloakAdminError(`aucun fournisseur « ${providerName} » avec un issuer dans oauth2.providers — il n'est monté que si ses variables NF_KEYCLOAK_* sont posées`);
35
+ if (provider.admin === void 0) throw new KeycloakAdminError(`oauth2.providers.${providerName}.admin absent : l'API d'administration exige le compte administrateur de Keycloak (NF_KEYCLOAK_ADMIN_USERNAME / NF_KEYCLOAK_ADMIN_PASSWORD, passés en développement seulement)`);
36
+ return {
37
+ issuer: provider.issuer,
38
+ admin: provider.admin
39
+ };
40
+ }
41
+ /**
42
+ * Client de l'API d'administration d'UN realm Keycloak.
43
+ *
44
+ * Le jeton `admin-cli` est demandé au premier appel puis GARDÉ jusqu'à son
45
+ * expiration ; un 401 en cours de route le renouvelle une fois. Les gardes
46
+ * (développement seulement, https seulement) sont tenues au constructeur :
47
+ * un client qui existe a le droit de parler.
48
+ */
49
+ var KeycloakAdminClient = class {
50
+ /** Nom du realm administré. */
51
+ realm;
52
+ /** Origine du serveur (`https://hôte:port`). */
53
+ origin;
54
+ #admin;
55
+ #fetch;
56
+ #timeoutMs;
57
+ #token = null;
58
+ /**
59
+ * @param options - realm, compte, environnement, transport
60
+ * @throws {KeycloakAdminError} Hors développement, émetteur qui n'est pas un
61
+ * realm, ou émetteur en clair
62
+ */
63
+ constructor(options) {
64
+ if (options.environment !== "development") throw new KeycloakAdminError(`refusé en « ${options.environment} » : l'API d'administration exige le compte administrateur de Keycloak, réservé au développement — en production, passer par la console (Realm settings)`);
65
+ try {
66
+ this.realm = realmNameFromIssuer(options.issuer);
67
+ } catch (error) {
68
+ throw new KeycloakAdminError(error instanceof Error ? error.message : String(error));
69
+ }
70
+ const url = new URL(options.issuer);
71
+ if (url.protocol !== "https:") throw new KeycloakAdminError(`émetteur « ${options.issuer} » en clair : le mot de passe administrateur ne part qu'en https`);
72
+ this.origin = url.origin;
73
+ this.#admin = options.admin;
74
+ this.#fetch = options.fetch ?? globalThis.fetch;
75
+ this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
76
+ }
77
+ /**
78
+ * Un appel à l'API d'administration du realm, jeton joint.
79
+ *
80
+ * @param method - verbe HTTP
81
+ * @param path - chemin SOUS `/admin/realms/<realm>` (`""` = le realm, `/users?…`)
82
+ * @param body - corps JSON, ou rien
83
+ * @returns la réponse brute — au consommateur de lire son statut
84
+ * @throws {KeycloakAdminError} Serveur injoignable ou compte refusé
85
+ */
86
+ async request(method, path, body) {
87
+ const url = `${this.origin}/admin/realms/${encodeURIComponent(this.realm)}${path}`;
88
+ const send = async (token) => this.#call(url, {
89
+ method,
90
+ headers: {
91
+ authorization: `Bearer ${token}`,
92
+ accept: "application/json",
93
+ ...body === void 0 ? {} : { "content-type": "application/json" }
94
+ },
95
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
96
+ });
97
+ const response = await send(await this.#accessToken());
98
+ if (response.status !== 401) return response;
99
+ await response.body?.cancel();
100
+ this.#token = null;
101
+ return send(await this.#accessToken());
102
+ }
103
+ /**
104
+ * La représentation du realm.
105
+ *
106
+ * @returns l'objet JSON rendu par Keycloak
107
+ * @throws {KeycloakAdminError} Realm introuvable ou lecture refusée
108
+ */
109
+ async getRealm() {
110
+ const response = await this.request("GET", "");
111
+ if (!response.ok) {
112
+ await response.body?.cancel();
113
+ throw new KeycloakAdminError(response.status === 404 ? `realm « ${this.realm} » introuvable sur ${this.origin}` : `lecture du realm « ${this.realm} » refusée (HTTP ${response.status})`, response.status);
114
+ }
115
+ const body = await response.json();
116
+ if (typeof body !== "object" || body === null || Array.isArray(body)) throw new KeycloakAdminError(`Keycloak a rendu autre chose qu'un realm pour « ${this.realm} »`);
117
+ return Object.fromEntries(Object.entries(body));
118
+ }
119
+ /**
120
+ * Change des champs du realm — et eux seuls : Keycloak ne touche pas aux
121
+ * champs absents d'un `PUT` partiel.
122
+ *
123
+ * @param changes - les champs à écrire
124
+ * @throws {KeycloakAdminError} Écriture refusée
125
+ */
126
+ async updateRealm(changes) {
127
+ const response = await this.request("PUT", "", changes);
128
+ await response.body?.cancel();
129
+ if (!response.ok) throw new KeycloakAdminError(`écriture refusée sur le realm « ${this.realm} » (HTTP ${response.status})`, response.status);
130
+ }
131
+ /**
132
+ * Pose sur le realm le thème de connexion qui porte un habillage de `/login`.
133
+ *
134
+ * Un realm habillé par l'application (thème ni `nodefony` ni `nodefony-*`,
135
+ * ou aucun) n'est JAMAIS modifié : même règle que la dérivation du fichier
136
+ * d'import.
137
+ *
138
+ * @param skin - habillage (`loginPage.skin`, ou celui d'un aperçu)
139
+ * @returns le thème d'avant, celui demandé, et ce qui a été fait
140
+ * @throws {KeycloakAdminError} Keycloak injoignable, compte refusé, realm
141
+ * introuvable
142
+ */
143
+ async applyLoginTheme(skin) {
144
+ const theme = keycloakLoginTheme(skin);
145
+ const current = (await this.getRealm()).loginTheme;
146
+ const previous = typeof current === "string" && current.length > 0 ? current : null;
147
+ const realm = this.realm;
148
+ if (previous === null || !isNodefonyKeycloakTheme(previous)) return {
149
+ realm,
150
+ previous,
151
+ theme,
152
+ outcome: "unmanaged"
153
+ };
154
+ if (previous === theme) return {
155
+ realm,
156
+ previous,
157
+ theme,
158
+ outcome: "unchanged"
159
+ };
160
+ await this.updateRealm({ loginTheme: theme });
161
+ return {
162
+ realm,
163
+ previous,
164
+ theme,
165
+ outcome: "applied"
166
+ };
167
+ }
168
+ /** Le jeton `admin-cli`, gardé jusqu'à son expiration. */
169
+ async #accessToken() {
170
+ if (this.#token !== null && this.#token.expiresAt > Date.now()) return this.#token.value;
171
+ const response = await this.#call(`${this.origin}/realms/master/protocol/openid-connect/token`, {
172
+ method: "POST",
173
+ headers: { "content-type": "application/x-www-form-urlencoded" },
174
+ body: new URLSearchParams({
175
+ grant_type: "password",
176
+ client_id: "admin-cli",
177
+ username: this.#admin.username,
178
+ password: this.#admin.password
179
+ }).toString()
180
+ });
181
+ if (!response.ok) {
182
+ await response.body?.cancel();
183
+ throw new KeycloakAdminError(`compte administrateur refusé par Keycloak (HTTP ${response.status}, realm master) — vérifier NF_KEYCLOAK_ADMIN_USERNAME et NF_KEYCLOAK_ADMIN_PASSWORD`, response.status);
184
+ }
185
+ const payload = await response.json();
186
+ const field = (key) => typeof payload === "object" && payload !== null ? Object.getOwnPropertyDescriptor(payload, key)?.value : void 0;
187
+ const accessToken = field("access_token");
188
+ const expiresIn = field("expires_in");
189
+ if (typeof accessToken !== "string" || accessToken === "") throw new KeycloakAdminError("Keycloak n'a rendu aucun jeton d'administration");
190
+ const lifetimeMs = typeof expiresIn === "number" ? expiresIn * 1e3 : 0;
191
+ this.#token = {
192
+ value: accessToken,
193
+ expiresAt: Date.now() + lifetimeMs - TOKEN_MARGIN_MS
194
+ };
195
+ return accessToken;
196
+ }
197
+ /** Un appel réseau borné ; une panne devient une erreur qui nomme le serveur. */
198
+ async #call(url, init) {
199
+ try {
200
+ return await this.#fetch(url, {
201
+ ...init,
202
+ signal: AbortSignal.timeout(this.#timeoutMs)
203
+ });
204
+ } catch (error) {
205
+ const cause = error instanceof Error && error.cause instanceof Error ? ` (${error.cause.message})` : "";
206
+ throw new KeycloakAdminError(`Keycloak injoignable à ${this.origin} : ${error instanceof Error ? error.message : String(error)}${cause}`);
207
+ }
208
+ }
209
+ };
210
+ /**
211
+ * Le thème de connexion qu'une page servie par Keycloak charge.
212
+ *
213
+ * Lu sans aucun identifiant : la page d'autorisation publique charge ses
214
+ * fichiers sous `/resources/<version>/login/<thème>/…`.
215
+ *
216
+ * @param html - début de la page d'autorisation
217
+ * @returns le nom du thème, ou `null` si la page n'est pas celle de Keycloak
218
+ */
219
+ function readKeycloakLoginTheme(html) {
220
+ const match = /\/resources\/[^/"'\s]+\/login\/([^/"'\s]+)\//u.exec(html);
221
+ return match?.[1] === void 0 ? null : decodeURIComponent(match[1]);
222
+ }
223
+ /**
224
+ * La sonde « thème » du diagnostic d'un fournisseur : la page de Keycloak
225
+ * suit-elle l'habillage de `/login` ?
226
+ *
227
+ * @param skin - habillage configuré (`loginPage.skin`)
228
+ * @returns une fonction qui juge la page d'autorisation ; elle rend `null` —
229
+ * aucune sonde — sur une page qui n'est pas de Keycloak
230
+ */
231
+ function keycloakLoginThemeCheck(skin) {
232
+ const expected = keycloakLoginTheme(skin);
233
+ return (html) => {
234
+ const active = readKeycloakLoginTheme(html);
235
+ if (active === null) return null;
236
+ if (!isNodefonyKeycloakTheme(active)) return {
237
+ name: "theme",
238
+ status: "ok",
239
+ message: `thème « ${active} » propre à l'application, laissé tel quel`
240
+ };
241
+ if (active === expected) return {
242
+ name: "theme",
243
+ status: "ok",
244
+ message: `la page de Keycloak suit l'habillage « ${skin} » (${active})`
245
+ };
246
+ return {
247
+ name: "theme",
248
+ status: "failed",
249
+ kind: "login-theme-mismatch",
250
+ message: `Keycloak affiche le thème « ${active} », l'habillage « ${skin} » demande « ${expected} » — la connexion marche, mais ne ressemble pas à /login : nodefony security:keycloak:realm --apply (ou Realm settings › Themes)`
251
+ };
252
+ };
253
+ }
254
+ //#endregion
255
+ export { KeycloakAdminClient, KeycloakAdminError, keycloakAdminTarget, keycloakLoginThemeCheck, readKeycloakLoginTheme };