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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  2. package/dist/index.js +48 -16
  3. package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +62 -24
  4. package/dist/nodefony/command/security-oauth-doctor.js +68 -0
  5. package/dist/nodefony/command/security-secrets.js +52 -29
  6. package/dist/nodefony/command/security-user-add.js +9 -3
  7. package/dist/nodefony/command/security-user-delete.js +10 -7
  8. package/dist/nodefony/command/security-user-list.js +5 -3
  9. package/dist/nodefony/command/security-user-password.js +9 -14
  10. package/dist/nodefony/config/config.js +99 -8
  11. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  12. package/dist/nodefony/contracts/IAccessVoter.js +1 -2
  13. package/dist/nodefony/contracts/index.js +1 -2
  14. package/dist/nodefony/errors/AccessDeniedError.js +1 -2
  15. package/dist/nodefony/errors/ApiKeyError.js +3 -2
  16. package/dist/nodefony/errors/AuthenticationError.js +1 -2
  17. package/dist/nodefony/errors/CsrfError.js +1 -2
  18. package/dist/nodefony/errors/InsecureTransportError.js +23 -0
  19. package/dist/nodefony/errors/InvalidTargetError.js +1 -2
  20. package/dist/nodefony/errors/SsrfError.js +1 -2
  21. package/dist/nodefony/errors/ThrottledError.js +1 -2
  22. package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
  23. package/dist/nodefony/errors/WebAuthnError.js +1 -2
  24. package/dist/nodefony/errors/index.js +2 -1
  25. package/dist/nodefony/keycloak/keycloakAdmin.js +254 -0
  26. package/dist/nodefony/keycloak/keycloakRealm.js +259 -0
  27. package/dist/nodefony/keycloak/keycloakRealmInput.js +123 -0
  28. package/dist/nodefony/keycloak/scaffold.js +231 -0
  29. package/dist/nodefony/keycloak/security-keycloak-realm.js +130 -0
  30. package/dist/nodefony/scaffold/contribute.js +19 -0
  31. package/dist/nodefony/service/accessTokenVerifier.js +101 -34
  32. package/dist/nodefony/service/apiKeys.js +45 -5
  33. package/dist/nodefony/service/auditService.js +4 -5
  34. package/dist/nodefony/service/authFlow.js +118 -16
  35. package/dist/nodefony/service/authorization.js +1 -2
  36. package/dist/nodefony/service/cors.js +1 -2
  37. package/dist/nodefony/service/csrf.js +1 -2
  38. package/dist/nodefony/service/firewall.js +116 -25
  39. package/dist/nodefony/service/keycloak.js +208 -0
  40. package/dist/nodefony/service/oauth2.js +403 -28
  41. package/dist/nodefony/service/securityHeaders.js +1 -2
  42. package/dist/nodefony/service/tokenService.js +94 -27
  43. package/dist/nodefony/service/totp.js +55 -11
  44. package/dist/nodefony/service/webAuthn.js +24 -7
  45. package/dist/nodefony/service/webhooks.js +4 -5
  46. package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
  47. package/dist/nodefony/src/SecuredArea.js +15 -5
  48. package/dist/nodefony/src/accountBinding.js +96 -0
  49. package/dist/nodefony/src/admin/SecurityAdminApi.js +71 -42
  50. package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
  51. package/dist/nodefony/src/admin/adminAudit.js +1 -3
  52. package/dist/nodefony/src/admin/userRevocationCascade.js +148 -20
  53. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
  54. package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
  55. package/dist/nodefony/src/audit/auditBridge.js +1 -2
  56. package/dist/nodefony/src/audit/auditFilters.js +1 -2
  57. package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
  58. package/dist/nodefony/src/audit/readAuditContext.js +1 -2
  59. package/dist/nodefony/src/audit/recordAudit.js +1 -2
  60. package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
  61. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
  62. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +14 -59
  63. package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
  64. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +80 -19
  65. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +92 -16
  66. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
  67. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +14 -5
  68. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +5 -12
  69. package/dist/nodefony/src/authenticator/bearer.js +1 -2
  70. package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
  71. package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
  72. package/dist/nodefony/src/boot/publicRoutesNotice.js +26 -0
  73. package/dist/nodefony/src/crypto/secretCipher.js +1 -2
  74. package/dist/nodefony/src/csp.js +1 -2
  75. package/dist/nodefony/src/csrfToken.js +1 -2
  76. package/dist/nodefony/src/entryPoint.js +38 -0
  77. package/dist/nodefony/src/net/ssrfGuard.js +1 -2
  78. package/dist/nodefony/src/net/transportFailure.js +57 -0
  79. package/dist/nodefony/src/oauth/clientCredentials.js +39 -0
  80. package/dist/nodefony/src/oauth/httpJson.js +3 -3
  81. package/dist/nodefony/src/oauth/metadata.js +28 -12
  82. package/dist/nodefony/src/oauth/oauth2Client.js +61 -35
  83. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +40 -8
  84. package/dist/nodefony/src/oauth/providerDiagnosis.js +282 -0
  85. package/dist/nodefony/src/oauth/providerRoles.js +78 -0
  86. package/dist/nodefony/src/oauth/providers/github.js +48 -12
  87. package/dist/nodefony/src/oauth/providers/oidc.js +90 -8
  88. package/dist/nodefony/src/openToSignup.js +46 -0
  89. package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
  90. package/dist/nodefony/src/realtime/frameAuthorizer.js +1 -2
  91. package/dist/nodefony/src/rolesWithin.js +57 -0
  92. package/dist/nodefony/src/sessionAuthentication.js +25 -0
  93. package/dist/nodefony/src/sessionIdentity.js +29 -3
  94. package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
  95. package/dist/nodefony/src/token/AnonymousToken.js +1 -2
  96. package/dist/nodefony/src/token/JwtKeystore.js +89 -48
  97. package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
  98. package/dist/nodefony/src/token/RemoteJwtVerifier.js +46 -16
  99. package/dist/nodefony/src/token/UserToken.js +1 -2
  100. package/dist/nodefony/src/token/jwtRuntime.js +1 -2
  101. package/dist/nodefony/src/token/scopeGrants.js +109 -0
  102. package/dist/nodefony/src/token/secretFile.js +60 -4
  103. package/dist/nodefony/src/token/tokenCriteria.js +1 -2
  104. package/dist/nodefony/src/token/tokenFilters.js +1 -2
  105. package/dist/nodefony/src/token/tokenSort.js +1 -2
  106. package/dist/nodefony/src/token/tokenStatus.js +1 -2
  107. package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
  108. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
  109. package/dist/nodefony/src/totp/totpCipher.js +1 -2
  110. package/dist/nodefony/src/totp/totpCrypto.js +1 -2
  111. package/dist/nodefony/src/totp/totpOperations.js +1 -2
  112. package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
  113. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +50 -0
  114. package/dist/nodefony/src/voter/RoleVoter.js +1 -2
  115. package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
  116. package/dist/nodefony/src/voter/voterRegistry.js +1 -2
  117. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
  118. package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
  119. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
  120. package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
  121. package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
  122. package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
  123. package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
  124. package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
  125. package/dist/nodefony/src/webhook/webhookSort.js +1 -2
  126. package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
  127. package/dist/types/index.d.ts +26 -6
  128. package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +16 -6
  129. package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
  130. package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
  131. package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
  132. package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
  133. package/dist/types/nodefony/config/config.d.ts +64 -1
  134. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
  135. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
  136. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
  137. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
  138. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
  139. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
  140. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
  141. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
  142. package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
  143. package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
  144. package/dist/types/nodefony/errors/index.d.ts +1 -0
  145. package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
  146. package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
  147. package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
  148. package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
  149. package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
  150. package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
  151. package/dist/types/nodefony/service/accessTokenVerifier.d.ts +35 -14
  152. package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
  153. package/dist/types/nodefony/service/authFlow.d.ts +35 -3
  154. package/dist/types/nodefony/service/firewall.d.ts +17 -3
  155. package/dist/types/nodefony/service/keycloak.d.ts +115 -0
  156. package/dist/types/nodefony/service/oauth2.d.ts +145 -9
  157. package/dist/types/nodefony/service/tokenService.d.ts +7 -1
  158. package/dist/types/nodefony/service/totp.d.ts +28 -4
  159. package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
  160. package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
  161. package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
  162. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +72 -12
  163. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -26
  164. package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
  165. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +17 -1
  166. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
  167. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
  168. package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
  169. package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
  170. package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
  171. package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
  172. package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
  173. package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
  174. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
  175. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
  176. package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
  177. package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
  178. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
  179. package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
  180. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
  181. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
  182. package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
  183. package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
  184. package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -0
  185. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
  186. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
  187. package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
  188. package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
  189. package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
  190. package/docs/api-keys.md +182 -175
  191. package/docs/audit.md +141 -141
  192. package/docs/authenticators.md +129 -96
  193. package/docs/authorization.md +116 -69
  194. package/docs/cors.md +64 -64
  195. package/docs/csrf.md +77 -72
  196. package/docs/external-jwt.md +83 -11
  197. package/docs/firewall.md +119 -83
  198. package/docs/headers.md +82 -82
  199. package/docs/index.md +16 -8
  200. package/docs/keycloak.md +835 -0
  201. package/docs/login-page.md +361 -0
  202. package/docs/oauth2.md +291 -166
  203. package/docs/obtenir-un-jeton.md +131 -34
  204. package/docs/tokens.md +197 -140
  205. package/docs/totp.md +177 -136
  206. package/docs/webauthn.md +157 -154
  207. package/docs/webhooks.md +128 -125
  208. package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
  209. package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
  210. package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
  211. package/keycloak/themes/nodefony/account/theme.properties +14 -0
  212. package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
  213. package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
  214. package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
  215. package/keycloak/themes/nodefony/admin/theme.properties +12 -0
  216. package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
  217. package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
  218. package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
  219. package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
  220. package/keycloak/themes/nodefony/email/theme.properties +8 -0
  221. package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
  222. package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
  223. package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
  224. package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
  225. package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
  226. package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
  227. package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
  228. package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
  229. package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
  230. package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
  231. package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
  232. package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
  233. package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
  234. package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
  235. package/keycloak/themes/nodefony/login/template.ftl +423 -0
  236. package/keycloak/themes/nodefony/login/theme.properties +17 -0
  237. package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
  238. package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
  239. package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
  240. package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
  241. package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
  242. package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
  243. package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
  244. package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
  245. package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
  246. package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
  247. package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
  248. package/package.json +19 -17
  249. package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
@@ -1,7 +1,7 @@
1
1
  import { AuthenticationError } from "../../errors/AuthenticationError.js";
2
2
  import { ThrottledError } from "../../errors/ThrottledError.js";
3
3
  import { UserToken } from "../token/UserToken.js";
4
- //#region nodefony/src/authenticator/UserPasswordAuthenticator.ts
4
+ // nodefony/src/authenticator/UserPasswordAuthenticator.ts
5
5
  const BASIC_SCHEME = /^basic\s+/i;
6
6
  const INVALID_CREDENTIALS = "Invalid credentials";
7
7
  /**
@@ -24,22 +24,32 @@ var UserPasswordAuthenticator = class {
24
24
  #verifier = null;
25
25
  #resolveVerifier;
26
26
  #throttler;
27
+ #transport;
27
28
  /**
28
29
  * @param resolveVerifier - résolution lazy de la source de vérification
29
30
  * (typiquement `container.get("users")`) — appelée au premier login.
30
31
  * @param throttler - limiteur de tentatives (backoff NIST), `null` = désactivé.
32
+ * @param transport - politique du transport des secrets, `null` = aucune.
31
33
  */
32
- constructor(resolveVerifier, throttler = null) {
34
+ constructor(resolveVerifier, throttler = null, transport = null) {
33
35
  this.#resolveVerifier = resolveVerifier;
34
36
  this.#throttler = throttler;
37
+ this.#transport = transport;
35
38
  }
36
39
  /** La requête porte-t-elle un en-tête `Authorization: Basic ...` ? */
37
40
  supports(context) {
38
41
  const auth = context.request?.headers.authorization;
39
42
  return typeof auth === "string" && BASIC_SCHEME.test(auth);
40
43
  }
41
- /** Décode l'enveloppe Basic — un contenu malformé donne un credential vide (échec uniforme). */
44
+ /**
45
+ * Décode l'enveloppe Basic — un contenu malformé donne un credential vide
46
+ * (échec uniforme).
47
+ *
48
+ * @throws InsecureTransportError (403) — en production, en-tête reçu hors
49
+ * TLS : refusé avant même d'être décodé.
50
+ */
42
51
  createToken(context) {
52
+ this.#transport?.assert(context, "basic");
43
53
  const auth = context.request?.headers.authorization;
44
54
  const decoded = Buffer.from(auth.replace(BASIC_SCHEME, ""), "base64").toString("utf8");
45
55
  const sep = decoded.indexOf(":");
@@ -86,10 +96,9 @@ var UserPasswordAuthenticator = class {
86
96
  onFailure(_context, _error) {
87
97
  return Promise.resolve();
88
98
  }
89
- /** Challenge RFC 7235 posé par le firewall sur les 401 de la zone. */
99
+ /** Challenge RFC 9110 §11 posé par le firewall sur les 401 de la zone. */
90
100
  challenge() {
91
101
  return "Basic realm=\"nodefony\", charset=\"UTF-8\"";
92
102
  }
93
103
  };
94
- //#endregion
95
104
  export { UserPasswordAuthenticator, UserPasswordAuthenticator as default };
@@ -3,9 +3,10 @@ import { SessionAuthenticator } from "./SessionAuthenticator.js";
3
3
  import { UserPasswordAuthenticator } from "./UserPasswordAuthenticator.js";
4
4
  import { JwtAuthenticator } from "./JwtAuthenticator.js";
5
5
  import { ApiKeyAuthenticator } from "./ApiKeyAuthenticator.js";
6
+ import { externalSubjectOptions } from "./ExternalSubjectResolver.js";
6
7
  import { ExternalJwtAuthenticator } from "./ExternalJwtAuthenticator.js";
7
8
  import { resolveJwtRuntime } from "../token/jwtRuntime.js";
8
- //#region nodefony/src/authenticator/authenticatorRegistry.ts
9
+ // nodefony/src/authenticator/authenticatorRegistry.ts
9
10
  const factories = /* @__PURE__ */ new Map();
10
11
  /**
11
12
  * Enregistre (ou remplace) la fabrique d'un authenticator. Appelé par les
@@ -26,11 +27,12 @@ function listAuthenticatorFactories() {
26
27
  registerAuthenticatorFactory("anonymous", () => new AnonymousAuthenticator());
27
28
  registerAuthenticatorFactory("userpassword", ({ container }) => {
28
29
  const throttler = container.get("loginThrottler") ?? null;
30
+ const transport = container.get("credentialTransport") ?? null;
29
31
  return new UserPasswordAuthenticator(() => {
30
32
  const verifier = container.get("users");
31
33
  if (!verifier) throw new Error("UserPasswordAuthenticator: aucun service \"users\" (IPasswordVerifier) dans le container — enregistrer un UserService au boot de l'application.");
32
34
  return verifier;
33
- }, throttler);
35
+ }, throttler, transport);
34
36
  });
35
37
  registerAuthenticatorFactory("session", ({ container }) => {
36
38
  return new SessionAuthenticator(() => {
@@ -43,15 +45,7 @@ registerAuthenticatorFactory("jwt", ({ container, config }) => {
43
45
  return new JwtAuthenticator(container, resolveJwtRuntime(config.jwt));
44
46
  });
45
47
  registerAuthenticatorFactory("external-jwt", ({ container, config }) => {
46
- const rs = config.resourceServer;
47
- return new ExternalJwtAuthenticator(container, {
48
- issuers: rs.issuers.map((i) => ({
49
- issuer: i.issuer,
50
- subjectMapping: i.subjectMapping
51
- })),
52
- subjectPolicy: rs.subjectPolicy,
53
- ephemeralRoles: rs.ephemeralRoles
54
- });
48
+ return new ExternalJwtAuthenticator(container, externalSubjectOptions(config));
55
49
  });
56
50
  registerAuthenticatorFactory("apikey", ({ container, config }) => {
57
51
  return new ApiKeyAuthenticator(container, {
@@ -59,5 +53,4 @@ registerAuthenticatorFactory("apikey", ({ container, config }) => {
59
53
  lastUsedThrottleS: config.apiKeys.lastUsedThrottleS
60
54
  });
61
55
  });
62
- //#endregion
63
56
  export { getAuthenticatorFactory, listAuthenticatorFactories, registerAuthenticatorFactory };
@@ -1,2 +1 @@
1
- import { bearerToken } from "nodefony";
2
- export { bearerToken };
1
+ export { bearerToken } from "nodefony";
@@ -1,4 +1,4 @@
1
- //#region nodefony/src/authenticator/externalSubject.ts
1
+ // nodefony/src/authenticator/externalSubject.ts
2
2
  /**
3
3
  * Séparateur entre l'émetteur et le sujet.
4
4
  *
@@ -32,5 +32,4 @@ function localIdentifierFor(issuer, subject, mapping) {
32
32
  if (mapping === "subject") return subject;
33
33
  return `${issuer}${SEPARATOR}${subject}`;
34
34
  }
35
- //#endregion
36
35
  export { localIdentifierFor };
@@ -1,4 +1,4 @@
1
- //#region nodefony/src/authenticator/peekIssuer.ts
1
+ // nodefony/src/authenticator/peekIssuer.ts
2
2
  /**
3
3
  * Lecture NON VÉRIFIÉE de l'émetteur d'un JWS compact.
4
4
  *
@@ -52,5 +52,4 @@ function peekIssuer(raw) {
52
52
  return null;
53
53
  }
54
54
  }
55
- //#endregion
56
55
  export { peekIssuer as default, peekIssuer };
@@ -0,0 +1,26 @@
1
+ import { isFrameworkZone } from "nodefony";
2
+ // nodefony/src/boot/publicRoutesNotice.ts
3
+ /**
4
+ * Le point d'attention « routes métier publiques » du bilan de démarrage.
5
+ *
6
+ * Hors d'une zone du pare-feu, une requête n'est jamais authentifiée : son
7
+ * identité n'est pas résolue, et une route métier qu'on croyait protégée
8
+ * répond à n'importe qui. Les seules zones présentes sont alors les aires du
9
+ * framework (`/nodefony/*`) ; on le DIT au démarrage, avec la recette.
10
+ *
11
+ * La classification « applicative ou framework » n'est pas recopiée : elle
12
+ * vient du cœur (`isFrameworkZone`), qui l'applique aussi au tableau des zones.
13
+ *
14
+ * @param zones - zones montées (`firewall.describe().zones`).
15
+ * @returns le point à déclarer, ou `null` si une zone applicative existe.
16
+ */
17
+ function publicRoutesNotice(zones) {
18
+ if (zones.some((z) => !isFrameworkZone(z))) return null;
19
+ return {
20
+ code: "FIREWALL_PUBLIC_ROUTES",
21
+ level: "warning",
22
+ message: "Tes routes métier sont PUBLIQUES — aucune zone de pare-feu ne les couvre",
23
+ fix: "use(\"@nodefony/security\", { areas: { main: { pattern: \"^/api\", authenticators: [\"session\"] } } })"
24
+ };
25
+ }
26
+ export { publicRoutesNotice };
@@ -1,6 +1,6 @@
1
1
  import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto";
2
2
  import { Buffer } from "node:buffer";
3
- //#region nodefony/src/crypto/secretCipher.ts
3
+ // nodefony/src/crypto/secretCipher.ts
4
4
  /**
5
5
  * Chiffrement **réversible** de secrets applicatifs au repos — AES-256-GCM
6
6
  * (authentifié). Brique générique partagée par tous les domaines qui doivent
@@ -75,5 +75,4 @@ function decryptSecret(blob, key) {
75
75
  decipher.setAuthTag(tag);
76
76
  return Buffer.concat([decipher.update(ct), decipher.final()]);
77
77
  }
78
- //#endregion
79
78
  export { decryptSecret, deriveKey, encryptSecret, generateEphemeralKey };
@@ -1,4 +1,4 @@
1
- //#region nodefony/src/csp.ts
1
+ // nodefony/src/csp.ts
2
2
  /**
3
3
  * Parse une chaîne CSP en directives ordonnées `[nom, sources[]]`. L'ordre des
4
4
  * directives et des sources est préservé (déterminisme : header stable, tests
@@ -49,5 +49,4 @@ function mergeCspFragments(base, fragments) {
49
49
  }
50
50
  return serializeCsp(directives);
51
51
  }
52
- //#endregion
53
52
  export { mergeCspFragments, parseCsp, serializeCsp };
@@ -1,5 +1,5 @@
1
1
  import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
- //#region nodefony/src/csrfToken.ts
2
+ // nodefony/src/csrfToken.ts
3
3
  /**
4
4
  * Jeton synchronizer CSRF — modèle **double-submit signé** (OWASP CSRF Prevention
5
5
  * Cheat Sheet, « Signed Double-Submit Cookie »). Le token est
@@ -61,5 +61,4 @@ var CsrfTokenManager = class CsrfTokenManager {
61
61
  return timingSafeEqual(ba, bb);
62
62
  }
63
63
  };
64
- //#endregion
65
64
  export { CsrfTokenManager, CsrfTokenManager as default };
@@ -0,0 +1,38 @@
1
+ import { loginPagePath, safeRedirectPath } from "nodefony";
2
+ // nodefony/src/entryPoint.ts
3
+ /**
4
+ * Dit si une requête refusée est la NAVIGATION d'un navigateur — le seul cas
5
+ * où conduire à la page de connexion aide, au lieu de casser un client.
6
+ *
7
+ * `GET` ou `HEAD`, `Accept` qui demande du HTML, et `Sec-Fetch-Mode: navigate`
8
+ * quand l'en-tête est présent : un `fetch()` d'une page porte `cors` ou
9
+ * `same-origin` même s'il accepte du HTML, et recevrait une page de connexion
10
+ * à la place de ses données.
11
+ *
12
+ * @param request - la requête refusée
13
+ * @returns `true` pour une navigation
14
+ */
15
+ function isBrowserNavigation(request) {
16
+ const method = request.method?.toUpperCase();
17
+ if (method !== "GET" && method !== "HEAD") return false;
18
+ if (request.accept?.toLowerCase().includes("text/html") !== true) return false;
19
+ return request.fetchMode === void 0 || request.fetchMode.toLowerCase() === "navigate";
20
+ }
21
+ /**
22
+ * L'adresse de la page de connexion pour un visiteur refusé :
23
+ * `<entryPoint>?from=<chemin demandé>`, composée par `loginPagePath` (cœur).
24
+ * Le chemin demandé repasse par `safeRedirectPath` — la seule garde
25
+ * anti-redirection ouverte du framework, que la page de connexion appliquera
26
+ * de nouveau au retour.
27
+ *
28
+ * @param entryPoint - chemin de la page de connexion (vérifié au boot)
29
+ * @param request - la requête refusée
30
+ * @returns l'adresse du `Location`, ou `null` quand ce n'est pas une
31
+ * navigation, ou quand la page demandée EST la page de connexion
32
+ */
33
+ function entryPointLocation(entryPoint, request) {
34
+ if (!isBrowserNavigation(request)) return null;
35
+ if (request.path.split("?", 1)[0] === entryPoint.split("?", 1)[0]) return null;
36
+ return loginPagePath(safeRedirectPath(request.path), entryPoint);
37
+ }
38
+ export { entryPointLocation, isBrowserNavigation };
@@ -1,7 +1,7 @@
1
1
  import { SsrfError } from "../../errors/SsrfError.js";
2
2
  import { BlockList, isIP } from "node:net";
3
3
  import { lookup } from "node:dns/promises";
4
- //#region nodefony/src/net/ssrfGuard.ts
4
+ // nodefony/src/net/ssrfGuard.ts
5
5
  /**
6
6
  * Protection **SSRF** (Server-Side Request Forgery) générique pour les URL
7
7
  * sortantes — endpoints webhook, fetch applicatif, futurs agents/RAG.
@@ -126,5 +126,4 @@ async function assertPublicUrl(rawUrl, options = {}) {
126
126
  addresses
127
127
  };
128
128
  }
129
- //#endregion
130
129
  export { assertPublicUrl, isBlockedAddress };
@@ -0,0 +1,57 @@
1
+ // nodefony/src/net/transportFailure.ts
2
+ /**
3
+ * Codes TLS d'une chaîne de certificats qu'aucune autorité CONNUE ne signe.
4
+ *
5
+ * Leur remède est le même : faire reconnaître l'autorité. Un certificat expiré
6
+ * (`CERT_HAS_EXPIRED`) ou émis pour un autre nom (`ERR_TLS_CERT_ALTNAME_INVALID`)
7
+ * n'en fait pas partie : ajouter une autorité n'y changerait rien.
8
+ */
9
+ const UNTRUSTED_AUTHORITY_CODES = /* @__PURE__ */ new Set([
10
+ "SELF_SIGNED_CERT_IN_CHAIN",
11
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
12
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
13
+ "UNABLE_TO_GET_ISSUER_CERT",
14
+ "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
15
+ "CERT_UNTRUSTED"
16
+ ]);
17
+ /** Le code porté par la `cause` d'un échec de `fetch`, s'il y en a un. */
18
+ function causeCode(error) {
19
+ if (!(error instanceof Error)) return null;
20
+ const cause = error.cause;
21
+ if (!(cause instanceof Error)) return null;
22
+ const code = cause.code;
23
+ return typeof code === "string" ? code : null;
24
+ }
25
+ /**
26
+ * Rend un échec de transport LISIBLE : `fetch` (undici) ne dit que
27
+ * `fetch failed` et range la vraie raison dans `cause` — certificat refusé,
28
+ * connexion refusée, nom introuvable.
29
+ *
30
+ * @param error - ce que `fetch` (ou une bibliothèque qui l'appelle) a levé
31
+ * @returns `message (CODE : raison)`, ou le message seul faute de cause
32
+ */
33
+ function describeTransportFailure(error) {
34
+ if (!(error instanceof Error)) return String(error);
35
+ const cause = error.cause;
36
+ if (!(cause instanceof Error)) return error.message;
37
+ const code = causeCode(error);
38
+ return code !== null ? `${error.message} (${code} : ${cause.message})` : `${error.message} (${cause.message})`;
39
+ }
40
+ /**
41
+ * Le remède d'un certificat signé par une autorité que le processus ne
42
+ * reconnaît pas — `null` pour tout autre échec.
43
+ *
44
+ * En développement, Nodefony reconnaît d'office l'autorité qu'il a générée ; en
45
+ * production jamais, et c'est voulu. Sans ce remède, le journal ne montrait que
46
+ * `SELF_SIGNED_CERT_IN_CHAIN` : la variable qui le règle n'était écrite que dans
47
+ * un commentaire du `.env`, fichier qu'un serveur de production n'a pas.
48
+ *
49
+ * @param error - ce que `fetch` (ou une bibliothèque qui l'appelle) a levé
50
+ * @returns une phrase d'action, ou `null`
51
+ */
52
+ function untrustedAuthorityHint(error) {
53
+ const code = causeCode(error);
54
+ if (code === null || !UNTRUSTED_AUTHORITY_CODES.has(code)) return null;
55
+ return "le certificat de ce serveur est signé par une autorité que l'application ne reconnaît pas : déclare-la dans NF_TLS_CA_FILE (fichier PEM, `http.certificates.extraCaFiles`)";
56
+ }
57
+ export { describeTransportFailure, untrustedAuthorityHint };
@@ -0,0 +1,39 @@
1
+ import { postTokenRequest } from "./oauth2Client.js";
2
+ import { discoverAuthorizationServer } from "./metadata.js";
3
+ // nodefony/src/oauth/clientCredentials.ts
4
+ /**
5
+ * Obtient un jeton d'accès pour un programme, par le flux `client_credentials`
6
+ * (RFC 6749 §4.4), auprès d'un émetteur découvert (RFC 8414).
7
+ *
8
+ * C'est la voie de l'appelant MACHINE : il s'authentifie avec son propre
9
+ * secret, et le jeton rendu le désigne lui, pas un utilisateur. La même
10
+ * authentification de client que l'échange de code ({@link postTokenRequest}) —
11
+ * une seule implémentation, pour qu'aucune règle ne diverge d'un flux à l'autre.
12
+ *
13
+ * @param request - émetteur, identité du client, portées et ressource.
14
+ * @returns Les jetons rendus ; `accessToken()` est celui à présenter en `Bearer`.
15
+ * @throws Error - secret vide, émetteur invalide ou injoignable, métadonnées discordantes.
16
+ * @throws OAuth2RequestError - le serveur a refusé (`invalid_client` : secret
17
+ * faux ; `unauthorized_client` : le client n'a pas droit à ce flux).
18
+ */
19
+ async function requestClientCredentialsToken(request) {
20
+ if (request.clientSecret.length === 0) throw new Error(`client « ${request.clientId} » : secret vide — le flux client_credentials n'existe que pour un client confidentiel (RFC 6749 §4.4).`);
21
+ const server = await discoverAuthorizationServer(request.issuer, {
22
+ discoveryUrl: request.discoveryUrl,
23
+ ...request.fetch ? { fetch: request.fetch } : {},
24
+ ...request.timeoutMs !== void 0 ? { timeoutMs: request.timeoutMs } : {}
25
+ });
26
+ const body = new URLSearchParams();
27
+ body.set("grant_type", "client_credentials");
28
+ if (request.scopes !== void 0 && request.scopes.length > 0) body.set("scope", request.scopes.join(" "));
29
+ if (request.resource !== void 0) body.set("resource", request.resource);
30
+ return postTokenRequest({
31
+ tokenEndpoint: server.tokenEndpoint,
32
+ clientId: request.clientId,
33
+ clientSecret: request.clientSecret,
34
+ clientAuthMethod: request.clientAuthMethod ?? "client_secret_basic",
35
+ fetch: request.fetch,
36
+ timeoutMs: request.timeoutMs
37
+ }, body);
38
+ }
39
+ export { requestClientCredentialsToken };
@@ -1,4 +1,5 @@
1
- //#region nodefony/src/oauth/httpJson.ts
1
+ import { isPlainObject } from "nodefony";
2
+ // nodefony/src/oauth/httpJson.ts
2
3
  /**
3
4
  * Lecture BORNÉE d'un corps JSON — la brique de transport commune aux trois
4
5
  * appels sortants du social login (découverte, point de jeton, API d'un
@@ -57,8 +58,7 @@ async function readJsonBounded(response, maxBytes, subject) {
57
58
  */
58
59
  async function readJsonObjectBounded(response, maxBytes, subject) {
59
60
  const payload = await readJsonBounded(response, maxBytes, subject);
60
- if (payload === null || typeof payload !== "object" || Array.isArray(payload)) throw new Error(`${subject} → corps inattendu (objet JSON attendu)`);
61
+ if (!isPlainObject(payload)) throw new Error(`${subject} → corps inattendu (objet JSON attendu)`);
61
62
  return payload;
62
63
  }
63
- //#endregion
64
64
  export { readJsonBounded, readJsonObjectBounded };
@@ -1,6 +1,7 @@
1
+ import { describeTransportFailure, untrustedAuthorityHint } from "../net/transportFailure.js";
1
2
  import { readJsonObjectBounded } from "./httpJson.js";
2
- import { canonicalIssuer, issuerMetadataUrls, validateIssuerMetadata } from "nodefony";
3
- //#region nodefony/src/oauth/metadata.ts
3
+ import { canonicalIssuer, isBackChannelTransportAllowed, metadataCandidateUrls, validateIssuerMetadata } from "nodefony";
4
+ // nodefony/src/oauth/metadata.ts
4
5
  /**
5
6
  * **Découverte des métadonnées d'un serveur d'autorisation** (RFC 8414) — la face
6
7
  * CLIENTE de la règle que le cœur porte déjà.
@@ -34,7 +35,7 @@ async function fetchMetadataDocument(url, options) {
34
35
  if (!response.ok) throw new Error(`${url} → HTTP ${response.status}`);
35
36
  return readJsonObjectBounded(response, MAX_METADATA_BYTES, url);
36
37
  }
37
- function requireEndpoint(document, field, issuer) {
38
+ function requireEndpoint(document, field, issuer, backChannelOf) {
38
39
  const value = document[field];
39
40
  if (typeof value !== "string" || value.length === 0) throw new Error(`métadonnées de « ${issuer} » : champ « ${field} » absent (RFC 8414 §2).`);
40
41
  let url;
@@ -43,10 +44,20 @@ function requireEndpoint(document, field, issuer) {
43
44
  } catch {
44
45
  throw new Error(`métadonnées de « ${issuer} » : « ${field} » n'est pas une URL.`);
45
46
  }
46
- if (url.protocol !== "https:") throw new Error(`métadonnées de « ${issuer} » : « ${field} » doit être en https.`);
47
+ if (!(backChannelOf === void 0 ? url.protocol === "https:" : isBackChannelTransportAllowed(url, backChannelOf))) throw new Error(`métadonnées de « ${issuer} » : « ${field} » doit être en https` + (backChannelOf === void 0 ? "." : ` (ou en http sur l'origine de discoveryUrl « ${backChannelOf} »).`));
47
48
  return value;
48
49
  }
49
50
  /**
51
+ * Point d'entrée FACULTATIF : absent ⇒ `null` ; présent, il obéit aux mêmes
52
+ * règles qu'un point requis (URL https) — un serveur qui publie un point
53
+ * malformé se signale à la découverte, pas au premier clic de l'utilisateur.
54
+ */
55
+ function optionalEndpoint(document, field, issuer) {
56
+ const value = document[field];
57
+ if (value === void 0 || value === null) return null;
58
+ return requireEndpoint(document, field, issuer);
59
+ }
60
+ /**
50
61
  * Interroge un serveur d'autorisation et rend ses points d'entrée.
51
62
  *
52
63
  * Les URL candidates sont celles du cœur (`issuerMetadataUrls`, ordre normatif
@@ -56,33 +67,38 @@ function requireEndpoint(document, field, issuer) {
56
67
  * d'entrée — la même attaque que le paramètre `iss` couvre au retour (RFC 9207).
57
68
  *
58
69
  * @param rawIssuer - identifiant d'émetteur tel qu'écrit en configuration.
59
- * @param options - transport injectable et délai d'attente.
70
+ * @param options - transport injectable, délai d'attente, adresse de découverte.
60
71
  * @returns Les points d'entrée, prêts pour `OAuth2Client`.
61
72
  * @throws Error - émetteur mal formé, document introuvable, incomplet, ou `issuer` discordant.
62
73
  */
63
74
  async function discoverAuthorizationServer(rawIssuer, options = {}) {
64
75
  const issuer = canonicalIssuer(rawIssuer);
65
76
  const failures = [];
66
- for (const candidate of issuerMetadataUrls(issuer)) {
77
+ let hint = null;
78
+ const discoveryUrl = options.discoveryUrl;
79
+ for (const candidate of metadataCandidateUrls(issuer, discoveryUrl)) {
67
80
  let document;
68
81
  try {
69
82
  document = await fetchMetadataDocument(candidate, options);
70
83
  } catch (error) {
71
- failures.push(error instanceof Error ? error.message : String(error));
84
+ failures.push(describeTransportFailure(error));
85
+ hint ??= untrustedAuthorityHint(error);
72
86
  continue;
73
87
  }
74
- const identity = validateIssuerMetadata(document, issuer);
88
+ const identity = validateIssuerMetadata(document, issuer, discoveryUrl);
75
89
  const methods = document.code_challenge_methods_supported;
90
+ const idTokenAlgs = document.id_token_signing_alg_values_supported;
76
91
  return {
77
92
  issuer: identity.issuer,
78
93
  jwksUri: identity.jwksUri,
79
94
  authorizationEndpoint: requireEndpoint(document, "authorization_endpoint", issuer),
80
- tokenEndpoint: requireEndpoint(document, "token_endpoint", issuer),
95
+ tokenEndpoint: requireEndpoint(document, "token_endpoint", issuer, discoveryUrl),
81
96
  codeChallengeMethodsSupported: Array.isArray(methods) ? methods.filter((m) => typeof m === "string") : null,
82
- issParameterSupported: document.authorization_response_iss_parameter_supported === true
97
+ issParameterSupported: document.authorization_response_iss_parameter_supported === true,
98
+ endSessionEndpoint: optionalEndpoint(document, "end_session_endpoint", issuer),
99
+ idTokenSigningAlgValuesSupported: Array.isArray(idTokenAlgs) ? idTokenAlgs.filter((a) => typeof a === "string") : null
83
100
  };
84
101
  }
85
- throw new Error(`métadonnées introuvables pour « ${issuer} » — ${failures.join(" ; ")}`);
102
+ throw new Error(`métadonnées introuvables pour « ${issuer} » — ${failures.join(" ; ")}` + (hint === null ? "" : ` — ${hint}`));
86
103
  }
87
- //#endregion
88
104
  export { discoverAuthorizationServer };
@@ -1,6 +1,6 @@
1
1
  import { readJsonObjectBounded } from "./httpJson.js";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- //#region nodefony/src/oauth/oauth2Client.ts
3
+ // nodefony/src/oauth/oauth2Client.ts
4
4
  /**
5
5
  * Client **OAuth 2.0 / Authorization Code** minimal — la face cliente du protocole
6
6
  * dont Nodefony écrit déjà la face serveur (émetteur de jetons, métadonnées,
@@ -79,11 +79,21 @@ var OAuth2RequestError = class extends Error {
79
79
  code;
80
80
  /** Description lisible fournie par le serveur, ou `null`. */
81
81
  description;
82
- constructor(code, description) {
82
+ /**
83
+ * Statut HTTP de la réponse, ou `null` s'il n'est pas connu.
84
+ *
85
+ * Le code `error` ne suffit pas toujours à dire QUI est refusé : la RFC 6749
86
+ * §5.2 réserve `401` à l'échec d'authentification du client, et Keycloak
87
+ * répond `unauthorized_client` en `401` sur un secret faux — le même code
88
+ * qu'en `400` pour un flux que le client n'a pas le droit d'employer.
89
+ */
90
+ status;
91
+ constructor(code, description, status = null) {
83
92
  super(description === null ? code : `${code}: ${description}`);
84
93
  this.name = "OAuth2RequestError";
85
94
  this.code = code;
86
95
  this.description = description;
96
+ this.status = status;
87
97
  }
88
98
  };
89
99
  /**
@@ -229,38 +239,54 @@ var OAuth2Client = class {
229
239
  body.set("code", request.code);
230
240
  body.set("redirect_uri", this.#options.redirectUri);
231
241
  if (request.codeVerifier !== null) body.set("code_verifier", request.codeVerifier);
232
- const headers = {
233
- "Content-Type": "application/x-www-form-urlencoded",
234
- Accept: "application/json",
235
- "User-Agent": "nodefony"
236
- };
237
- switch (this.#options.clientAuthMethod) {
238
- case "client_secret_basic":
239
- headers.Authorization = `Basic ${this.#basicCredentials()}`;
240
- break;
241
- case "client_secret_post":
242
- body.set("client_id", this.#options.clientId);
243
- body.set("client_secret", this.#options.clientSecret);
244
- break;
245
- case "none": body.set("client_id", this.#options.clientId);
246
- }
247
- const response = await (this.#options.fetch ?? globalThis.fetch)(this.#options.tokenEndpoint, {
248
- method: "POST",
249
- headers,
250
- body: body.toString(),
251
- redirect: "error",
252
- signal: AbortSignal.timeout(this.#options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS)
253
- });
254
- const data = await readJsonObjectBounded(response, MAX_TOKEN_RESPONSE_BYTES, `point de jeton (HTTP ${response.status})`);
255
- if (typeof data.error === "string") throw new OAuth2RequestError(data.error, typeof data.error_description === "string" ? data.error_description : null);
256
- if (!response.ok) throw new Error(`Point de jeton en échec (HTTP ${response.status}).`);
257
- return new OAuth2Tokens(data);
258
- }
259
- #basicCredentials() {
260
- const id = formUrlencode(this.#options.clientId);
261
- const secret = formUrlencode(this.#options.clientSecret);
262
- return Buffer.from(`${id}:${secret}`, "utf8").toString("base64");
242
+ return postTokenRequest(this.#options, body);
263
243
  }
264
244
  };
265
- //#endregion
266
- export { OAuth2Client, OAuth2RequestError, OAuth2Tokens, createCodeChallenge, generateCodeVerifier, generateState };
245
+ /**
246
+ * Présente une requête au point de jeton et rend les jetons (RFC 6749 §5.1).
247
+ *
248
+ * SEULE implémentation de l'authentification du client et de la lecture de la
249
+ * réponse : l'échange de code et le flux `client_credentials` y passent tous
250
+ * deux, pour qu'une règle (méthode déclarée, redirection refusée, réponse
251
+ * bornée, refus lu sur `error`) ne diverge jamais entre deux flux.
252
+ *
253
+ * @param client - point de jeton et identité du client.
254
+ * @param body - corps de la requête, `grant_type` compris ; l'identité du
255
+ * client y est ajoutée selon la méthode déclarée.
256
+ * @throws OAuth2RequestError - le serveur a refusé, en nommant la cause (RFC 6749 §5.2).
257
+ * @throws Error - réponse inintelligible, hors gabarit, ou serveur injoignable.
258
+ */
259
+ async function postTokenRequest(client, body) {
260
+ const headers = {
261
+ "Content-Type": "application/x-www-form-urlencoded",
262
+ Accept: "application/json",
263
+ "User-Agent": "nodefony"
264
+ };
265
+ switch (client.clientAuthMethod) {
266
+ case "client_secret_basic":
267
+ headers.Authorization = `Basic ${basicCredentials(client)}`;
268
+ break;
269
+ case "client_secret_post":
270
+ body.set("client_id", client.clientId);
271
+ body.set("client_secret", client.clientSecret);
272
+ break;
273
+ case "none": body.set("client_id", client.clientId);
274
+ }
275
+ const response = await (client.fetch ?? globalThis.fetch)(client.tokenEndpoint, {
276
+ method: "POST",
277
+ headers,
278
+ body: body.toString(),
279
+ redirect: "error",
280
+ signal: AbortSignal.timeout(client.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS)
281
+ });
282
+ const data = await readJsonObjectBounded(response, MAX_TOKEN_RESPONSE_BYTES, `point de jeton (HTTP ${response.status})`);
283
+ if (typeof data.error === "string") throw new OAuth2RequestError(data.error, typeof data.error_description === "string" ? data.error_description : null, response.status);
284
+ if (!response.ok) throw new Error(`Point de jeton en échec (HTTP ${response.status}).`);
285
+ return new OAuth2Tokens(data);
286
+ }
287
+ function basicCredentials(client) {
288
+ const id = formUrlencode(client.clientId);
289
+ const secret = formUrlencode(client.clientSecret);
290
+ return Buffer.from(`${id}:${secret}`, "utf8").toString("base64");
291
+ }
292
+ export { OAuth2Client, OAuth2RequestError, OAuth2Tokens, createCodeChallenge, generateCodeVerifier, generateState, postTokenRequest };
@@ -1,26 +1,58 @@
1
1
  import { createDiscoveredOidcProvider } from "./providers/oidc.js";
2
2
  import { createGithubProvider } from "./providers/github.js";
3
- //#region nodefony/src/oauth/oauthProviderRegistry.ts
3
+ // nodefony/src/oauth/oauthProviderRegistry.ts
4
4
  const factories = /* @__PURE__ */ new Map();
5
5
  /**
6
6
  * Enregistre (ou remplace) la fabrique d'un fournisseur OAuth. Appelée par les
7
7
  * builtins au chargement, et par une application pour ses fournisseurs.
8
+ *
9
+ * @param name - nom sous lequel le fournisseur se configure (`oauth2.providers.<name>`).
10
+ * @param factory - fabrique du fournisseur.
11
+ * @param registration - exigences de configuration, vérifiées au démarrage.
8
12
  */
9
- function registerOAuthProvider(name, factory) {
10
- factories.set(name, factory);
13
+ function registerOAuthProvider(name, factory, registration = {}) {
14
+ factories.set(name, {
15
+ factory,
16
+ requiresIssuer: registration.requiresIssuer === true
17
+ });
11
18
  }
12
19
  /** Fabrique d'un fournisseur par nom, ou `undefined` si inconnu. */
13
20
  function getOAuthProviderFactory(name) {
14
- return factories.get(name);
21
+ return factories.get(name)?.factory;
22
+ }
23
+ /** `true` si le fournisseur nommé a déclaré exiger un émetteur en configuration. */
24
+ function oauthProviderRequiresIssuer(name) {
25
+ return factories.get(name)?.requiresIssuer === true;
15
26
  }
16
27
  /** Noms enregistrés (validation boot, introspection Studio, tests). */
17
28
  function listOAuthProviders() {
18
29
  return [...factories.keys()];
19
30
  }
31
+ /**
32
+ * Paramètre d'autorisation que lit le thème Keycloak livré (`template.ftl`)
33
+ * pour afficher l'écran dans le thème de l'application (`light` | `dark`).
34
+ */
35
+ const KEYCLOAK_THEME_PARAMETER = "nf_theme";
36
+ /**
37
+ * Ajoute le thème demandé ({@link IAuthorizationRequest.theme}) à l'URL
38
+ * d'autorisation d'un fournisseur Keycloak, sous {@link KEYCLOAK_THEME_PARAMETER}.
39
+ *
40
+ * @param provider - fournisseur OIDC découvert, inchangé pour tout le reste
41
+ * @returns le même fournisseur, dont l'URL d'autorisation porte le thème
42
+ */
43
+ function withKeycloakThemeHint(provider) {
44
+ return {
45
+ ...provider,
46
+ createAuthorizationURL(request) {
47
+ const url = provider.createAuthorizationURL(request);
48
+ if (request.theme === "light" || request.theme === "dark") url.searchParams.set(KEYCLOAK_THEME_PARAMETER, request.theme);
49
+ return url;
50
+ }
51
+ };
52
+ }
20
53
  const GOOGLE_ISSUER = "https://accounts.google.com";
21
54
  registerOAuthProvider("google", (ctx) => createDiscoveredOidcProvider("google", ctx, { issuer: GOOGLE_ISSUER }));
22
- registerOAuthProvider("keycloak", (ctx) => createDiscoveredOidcProvider("keycloak", ctx));
23
- registerOAuthProvider("oidc", (ctx) => createDiscoveredOidcProvider("oidc", ctx));
55
+ registerOAuthProvider("keycloak", async (ctx) => withKeycloakThemeHint(await createDiscoveredOidcProvider("keycloak", ctx)), { requiresIssuer: true });
56
+ registerOAuthProvider("oidc", (ctx) => createDiscoveredOidcProvider("oidc", ctx), { requiresIssuer: true });
24
57
  registerOAuthProvider("github", createGithubProvider);
25
- //#endregion
26
- export { getOAuthProviderFactory, listOAuthProviders, registerOAuthProvider };
58
+ export { KEYCLOAK_THEME_PARAMETER, getOAuthProviderFactory, listOAuthProviders, oauthProviderRequiresIssuer, registerOAuthProvider, withKeycloakThemeHint };