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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +7 -5
  2. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/index.js +44 -10
  5. package/dist/nodefony/command/security-oauth-doctor.js +66 -0
  6. package/dist/nodefony/command/security-secrets.js +42 -25
  7. package/dist/nodefony/command/security-token.js +46 -15
  8. package/dist/nodefony/command/security-user-delete.js +5 -4
  9. package/dist/nodefony/command/security-user-password.js +4 -11
  10. package/dist/nodefony/config/config.js +80 -5
  11. package/dist/nodefony/contracts/index.js +1 -2
  12. package/dist/nodefony/errors/InsecureTransportError.js +24 -0
  13. package/dist/nodefony/errors/index.js +2 -1
  14. package/dist/nodefony/keycloak/keycloakAdmin.js +255 -0
  15. package/dist/nodefony/keycloak/keycloakRealm.js +260 -0
  16. package/dist/nodefony/keycloak/keycloakRealmInput.js +124 -0
  17. package/dist/nodefony/keycloak/scaffold.js +231 -0
  18. package/dist/nodefony/keycloak/security-keycloak-realm.js +128 -0
  19. package/dist/nodefony/scaffold/contribute.js +20 -0
  20. package/dist/nodefony/service/authFlow.js +111 -11
  21. package/dist/nodefony/service/firewall.js +111 -21
  22. package/dist/nodefony/service/keycloak.js +209 -0
  23. package/dist/nodefony/service/oauth2.js +379 -26
  24. package/dist/nodefony/service/securityHeaders.js +12 -1
  25. package/dist/nodefony/service/tokenService.js +57 -6
  26. package/dist/nodefony/service/totp.js +51 -6
  27. package/dist/nodefony/service/webAuthn.js +20 -2
  28. package/dist/nodefony/src/SecuredArea.js +14 -3
  29. package/dist/nodefony/src/admin/SecurityAdminApi.js +70 -40
  30. package/dist/nodefony/src/admin/adminAudit.js +0 -1
  31. package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
  32. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +66 -6
  33. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +38 -15
  34. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +1 -0
  35. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +13 -3
  36. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +9 -2
  37. package/dist/nodefony/src/authenticator/bearer.js +1 -2
  38. package/dist/nodefony/src/boot/publicRoutesNotice.js +27 -0
  39. package/dist/nodefony/src/entryPoint.js +39 -0
  40. package/dist/nodefony/src/oauth/clientCredentials.js +40 -0
  41. package/dist/nodefony/src/oauth/metadata.js +36 -9
  42. package/dist/nodefony/src/oauth/oauth2Client.js +60 -33
  43. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +39 -6
  44. package/dist/nodefony/src/oauth/providerDiagnosis.js +283 -0
  45. package/dist/nodefony/src/oauth/providerRoles.js +79 -0
  46. package/dist/nodefony/src/oauth/providers/github.js +32 -6
  47. package/dist/nodefony/src/oauth/providers/oidc.js +82 -3
  48. package/dist/nodefony/src/openToSignup.js +47 -0
  49. package/dist/nodefony/src/sessionAuthentication.js +26 -0
  50. package/dist/nodefony/src/token/JwtKeystore.js +78 -46
  51. package/dist/nodefony/src/token/RemoteJwtVerifier.js +40 -13
  52. package/dist/nodefony/src/token/secretFile.js +59 -2
  53. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +51 -0
  54. package/dist/types/index.d.ts +26 -6
  55. package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
  56. package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
  57. package/dist/types/nodefony/command/security-token.d.ts +9 -0
  58. package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
  59. package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
  60. package/dist/types/nodefony/config/config.d.ts +63 -1
  61. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
  62. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
  63. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
  64. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
  65. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
  66. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
  67. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
  68. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
  69. package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
  70. package/dist/types/nodefony/errors/index.d.ts +1 -0
  71. package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
  72. package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
  73. package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
  74. package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
  75. package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
  76. package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
  77. package/dist/types/nodefony/service/authFlow.d.ts +35 -3
  78. package/dist/types/nodefony/service/firewall.d.ts +17 -3
  79. package/dist/types/nodefony/service/keycloak.d.ts +115 -0
  80. package/dist/types/nodefony/service/oauth2.d.ts +125 -9
  81. package/dist/types/nodefony/service/securityHeaders.d.ts +2 -1
  82. package/dist/types/nodefony/service/tokenService.d.ts +7 -1
  83. package/dist/types/nodefony/service/totp.d.ts +28 -4
  84. package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
  85. package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
  86. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +54 -12
  87. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +18 -0
  88. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +13 -0
  89. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
  90. package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
  91. package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
  92. package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
  93. package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
  94. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
  95. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
  96. package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
  97. package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
  98. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
  99. package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
  100. package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
  101. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
  102. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
  103. package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
  104. package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
  105. package/docs/api-keys.md +18 -18
  106. package/docs/audit.md +24 -22
  107. package/docs/authenticators.md +62 -29
  108. package/docs/authorization.md +16 -16
  109. package/docs/cors.md +30 -30
  110. package/docs/csrf.md +35 -33
  111. package/docs/external-jwt.md +68 -5
  112. package/docs/firewall.md +78 -45
  113. package/docs/headers.md +35 -35
  114. package/docs/index.md +15 -7
  115. package/docs/keycloak.md +818 -0
  116. package/docs/lexique.md +15 -15
  117. package/docs/login-page.md +361 -0
  118. package/docs/oauth2.md +242 -125
  119. package/docs/obtenir-un-jeton.md +18 -11
  120. package/docs/tokens.md +138 -86
  121. package/docs/totp.md +113 -71
  122. package/docs/webauthn.md +115 -115
  123. package/docs/webhooks.md +21 -21
  124. package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
  125. package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
  126. package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
  127. package/keycloak/themes/nodefony/account/theme.properties +14 -0
  128. package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
  129. package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
  130. package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
  131. package/keycloak/themes/nodefony/admin/theme.properties +12 -0
  132. package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
  133. package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
  134. package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
  135. package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
  136. package/keycloak/themes/nodefony/email/theme.properties +8 -0
  137. package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
  138. package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
  139. package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
  140. package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
  141. package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
  142. package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
  143. package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
  144. package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
  145. package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
  146. package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
  147. package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
  148. package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
  149. package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
  150. package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
  151. package/keycloak/themes/nodefony/login/template.ftl +423 -0
  152. package/keycloak/themes/nodefony/login/theme.properties +17 -0
  153. package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
  154. package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
  155. package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
  156. package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
  157. package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
  158. package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
  159. package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
  160. package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
  161. package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
  162. package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
  163. package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
  164. package/package.json +17 -15
@@ -1,7 +1,7 @@
1
1
  import { AuthenticationError } from "../../errors/AuthenticationError.js";
2
2
  import { UserRealtimeToken } from "../realtime/UserRealtimeToken.js";
3
3
  import { Nodefony, RequestContext } from "nodefony";
4
- import { anonymousUser } from "@nodefony/user";
4
+ import { authenticatedUser } from "@nodefony/user";
5
5
  //#region nodefony/src/authenticator/FirewallRealtimeAuthenticator.ts
6
6
  /**
7
7
  * Authenticator realtime des identités résolues par le **firewall** — équivalent
@@ -66,7 +66,7 @@ var FirewallRealtimeAuthenticator = class {
66
66
  }
67
67
  /** Une identité authentifiée a-t-elle été résolue (par le firewall) au handshake ? */
68
68
  supports(_handshake) {
69
- return isAuthenticatedUser(RequestContext.getUser());
69
+ return authenticatedUser() !== null;
70
70
  }
71
71
  /**
72
72
  * Promeut l'identité déjà résolue (ALS) en jeton realtime — 0 lecture base.
@@ -76,15 +76,40 @@ var FirewallRealtimeAuthenticator = class {
76
76
  * filet défensif fail-closed → le hub ferme la socket en 4001).
77
77
  */
78
78
  async authenticate(_handshake) {
79
- const user = RequestContext.getUser();
80
- if (!isAuthenticatedUser(user)) throw new AuthenticationError("Invalid realtime session");
79
+ const user = authenticatedUser();
80
+ if (user === null) throw new AuthenticationError("Invalid realtime session");
81
81
  const issued = readFirewallToken();
82
- const type = typeof issued?.type === "string" && issued.type ? issued.type : "session";
83
- const revalidate = type === "session" ? buildSessionRevalidator(user.identifier) : this.#buildBearerRevalidator(issued, user.identifier, type);
82
+ const type = issuedType(issued);
83
+ const revalidate = this.#revalidatorFor(user, issued, type);
84
84
  const scopes = issued ? issued.getScopes() : void 0;
85
85
  return new UserRealtimeToken(user, revalidate, type, scopes);
86
86
  }
87
87
  /**
88
+ * Le revalidateur de l'identité que le firewall a posée sur la requête
89
+ * COURANTE (ALS), pour toute connexion longue — socket WebSocket ou flux SSE.
90
+ *
91
+ * Une seule règle décide qu'une identité est morte, quel que soit le
92
+ * transport qui la porte : c'est celle-ci, et le hub comme le noyau HTTP
93
+ * l'appellent au lieu de la recopier.
94
+ *
95
+ * @returns `null` pour un anonyme (rien à révoquer), sinon la fonction qui
96
+ * dit si l'identité est TOUJOURS valable — fail-closed : `false` quand elle
97
+ * ne peut pas le prouver.
98
+ */
99
+ currentRevalidator() {
100
+ const user = authenticatedUser();
101
+ if (user === null) return null;
102
+ const issued = readFirewallToken();
103
+ return this.#revalidatorFor(user, issued, issuedType(issued));
104
+ }
105
+ /**
106
+ * Choisit la preuve de révocation selon le mode d'authentification : la
107
+ * session se relit, un jeton porteur se borne par `exp` et la denylist.
108
+ */
109
+ #revalidatorFor(user, issued, type) {
110
+ return type === "session" ? buildSessionRevalidator(user.identifier) : this.#buildBearerRevalidator(issued, user.identifier, type);
111
+ }
112
+ /**
88
113
  * Re-validation d'une identité portée par un **jeton** (JWT, clé API, OAuth) :
89
114
  * rejoue les trois conditions qui tuent un jeton, sans jamais parler de session.
90
115
  *
@@ -129,6 +154,13 @@ const EMPTY_CLAIMS = Object.freeze({});
129
154
  function secondsToMs(value) {
130
155
  return typeof value === "number" && Number.isFinite(value) ? value * 1e3 : null;
131
156
  }
157
+ /**
158
+ * Le mode d'authentification du jeton du firewall. Absent (zone historique) →
159
+ * le mode le plus strict, la session : un repli sûr, jamais permissif.
160
+ */
161
+ function issuedType(issued) {
162
+ return typeof issued?.type === "string" && issued.type ? issued.type : "session";
163
+ }
132
164
  /** Le jeton posé dans l'ALS par `firewall.handleSecurity`, s'il y en a un. */
133
165
  function readFirewallToken() {
134
166
  return RequestContext.get()?.token;
@@ -161,14 +193,5 @@ function buildSessionRevalidator(identifier) {
161
193
  }
162
194
  };
163
195
  }
164
- /**
165
- * Vrai si `user` est un utilisateur AUTHENTIFIÉ (≠ anonyme). Le firewall pose
166
- * `token.getUser()` dans l'ALS : soit l'`IUser` réel, soit `anonymousUser`
167
- * (singleton). Tout ce qui n'est pas l'anonyme et porte un `identifier` est
168
- * une identité valide.
169
- */
170
- function isAuthenticatedUser(user) {
171
- return !!user && user !== anonymousUser && typeof user.identifier === "string";
172
- }
173
196
  //#endregion
174
197
  export { FirewallRealtimeAuthenticator, FirewallRealtimeAuthenticator as default };
@@ -134,6 +134,7 @@ var JwtAuthenticator = class {
134
134
  ut.setAttribute("scopes", scopes);
135
135
  ut.setAttribute("jti", payload.jti);
136
136
  ut.setAttribute("claims", payload);
137
+ ut.setAttribute("issuer", payload.iss);
137
138
  return ut;
138
139
  }
139
140
  async #resolveUserOrReject(sub) {
@@ -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,7 +96,7 @@ 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
  }
@@ -1,3 +1,4 @@
1
+ import { compileProviderRoleMapping } from "../oauth/providerRoles.js";
1
2
  import { AnonymousAuthenticator } from "./AnonymousAuthenticator.js";
2
3
  import { SessionAuthenticator } from "./SessionAuthenticator.js";
3
4
  import { UserPasswordAuthenticator } from "./UserPasswordAuthenticator.js";
@@ -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(() => {
@@ -50,7 +52,12 @@ registerAuthenticatorFactory("external-jwt", ({ container, config }) => {
50
52
  subjectMapping: i.subjectMapping
51
53
  })),
52
54
  subjectPolicy: rs.subjectPolicy,
53
- ephemeralRoles: rs.ephemeralRoles
55
+ ephemeralRoles: rs.ephemeralRoles,
56
+ oauthProviders: Object.entries(config.oauth2.providers).flatMap(([name, p]) => p.issuer ? [{
57
+ name,
58
+ issuer: p.issuer,
59
+ roleMapping: compileProviderRoleMapping(p)
60
+ }] : [])
54
61
  });
55
62
  });
56
63
  registerAuthenticatorFactory("apikey", ({ container, config }) => {
@@ -1,2 +1 @@
1
- import { bearerToken } from "nodefony";
2
- export { bearerToken };
1
+ export { bearerToken } from "nodefony";
@@ -0,0 +1,27 @@
1
+ import { isFrameworkZone } from "nodefony";
2
+ //#region 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
+ //#endregion
27
+ export { publicRoutesNotice };
@@ -0,0 +1,39 @@
1
+ import { loginPagePath, safeRedirectPath } from "nodefony";
2
+ //#region 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
+ //#endregion
39
+ export { entryPointLocation, isBrowserNavigation };
@@ -0,0 +1,40 @@
1
+ import { postTokenRequest } from "./oauth2Client.js";
2
+ import { discoverAuthorizationServer } from "./metadata.js";
3
+ //#region 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
+ //#endregion
40
+ export { requestClientCredentialsToken };
@@ -1,5 +1,5 @@
1
1
  import { readJsonObjectBounded } from "./httpJson.js";
2
- import { canonicalIssuer, issuerMetadataUrls, validateIssuerMetadata } from "nodefony";
2
+ import { canonicalIssuer, isBackChannelTransportAllowed, metadataCandidateUrls, validateIssuerMetadata } from "nodefony";
3
3
  //#region nodefony/src/oauth/metadata.ts
4
4
  /**
5
5
  * **Découverte des métadonnées d'un serveur d'autorisation** (RFC 8414) — la face
@@ -22,6 +22,19 @@ import { canonicalIssuer, issuerMetadataUrls, validateIssuerMetadata } from "nod
22
22
  const DISCOVERY_TIMEOUT_MS = 1e4;
23
23
  /** Au-delà, le document n'est plus un document de métadonnées — on refuse de lire. */
24
24
  const MAX_METADATA_BYTES = 1048576;
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. Sans elle, « métadonnées introuvables »
29
+ * envoie chercher un émetteur absent quand c'est la confiance TLS qui manque.
30
+ */
31
+ function describeTransportFailure(error) {
32
+ if (!(error instanceof Error)) return String(error);
33
+ const cause = error.cause;
34
+ if (!(cause instanceof Error)) return error.message;
35
+ const code = cause.code;
36
+ return typeof code === "string" ? `${error.message} (${code} : ${cause.message})` : `${error.message} (${cause.message})`;
37
+ }
25
38
  async function fetchMetadataDocument(url, options) {
26
39
  const response = await (options.fetch ?? globalThis.fetch)(url, {
27
40
  headers: {
@@ -34,7 +47,7 @@ async function fetchMetadataDocument(url, options) {
34
47
  if (!response.ok) throw new Error(`${url} → HTTP ${response.status}`);
35
48
  return readJsonObjectBounded(response, MAX_METADATA_BYTES, url);
36
49
  }
37
- function requireEndpoint(document, field, issuer) {
50
+ function requireEndpoint(document, field, issuer, backChannelOf) {
38
51
  const value = document[field];
39
52
  if (typeof value !== "string" || value.length === 0) throw new Error(`métadonnées de « ${issuer} » : champ « ${field} » absent (RFC 8414 §2).`);
40
53
  let url;
@@ -43,10 +56,20 @@ function requireEndpoint(document, field, issuer) {
43
56
  } catch {
44
57
  throw new Error(`métadonnées de « ${issuer} » : « ${field} » n'est pas une URL.`);
45
58
  }
46
- if (url.protocol !== "https:") throw new Error(`métadonnées de « ${issuer} » : « ${field} » doit être en https.`);
59
+ 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
60
  return value;
48
61
  }
49
62
  /**
63
+ * Point d'entrée FACULTATIF : absent ⇒ `null` ; présent, il obéit aux mêmes
64
+ * règles qu'un point requis (URL https) — un serveur qui publie un point
65
+ * malformé se signale à la découverte, pas au premier clic de l'utilisateur.
66
+ */
67
+ function optionalEndpoint(document, field, issuer) {
68
+ const value = document[field];
69
+ if (value === void 0 || value === null) return null;
70
+ return requireEndpoint(document, field, issuer);
71
+ }
72
+ /**
50
73
  * Interroge un serveur d'autorisation et rend ses points d'entrée.
51
74
  *
52
75
  * Les URL candidates sont celles du cœur (`issuerMetadataUrls`, ordre normatif
@@ -56,30 +79,34 @@ function requireEndpoint(document, field, issuer) {
56
79
  * d'entrée — la même attaque que le paramètre `iss` couvre au retour (RFC 9207).
57
80
  *
58
81
  * @param rawIssuer - identifiant d'émetteur tel qu'écrit en configuration.
59
- * @param options - transport injectable et délai d'attente.
82
+ * @param options - transport injectable, délai d'attente, adresse de découverte.
60
83
  * @returns Les points d'entrée, prêts pour `OAuth2Client`.
61
84
  * @throws Error - émetteur mal formé, document introuvable, incomplet, ou `issuer` discordant.
62
85
  */
63
86
  async function discoverAuthorizationServer(rawIssuer, options = {}) {
64
87
  const issuer = canonicalIssuer(rawIssuer);
65
88
  const failures = [];
66
- for (const candidate of issuerMetadataUrls(issuer)) {
89
+ const discoveryUrl = options.discoveryUrl;
90
+ for (const candidate of metadataCandidateUrls(issuer, discoveryUrl)) {
67
91
  let document;
68
92
  try {
69
93
  document = await fetchMetadataDocument(candidate, options);
70
94
  } catch (error) {
71
- failures.push(error instanceof Error ? error.message : String(error));
95
+ failures.push(describeTransportFailure(error));
72
96
  continue;
73
97
  }
74
- const identity = validateIssuerMetadata(document, issuer);
98
+ const identity = validateIssuerMetadata(document, issuer, discoveryUrl);
75
99
  const methods = document.code_challenge_methods_supported;
100
+ const idTokenAlgs = document.id_token_signing_alg_values_supported;
76
101
  return {
77
102
  issuer: identity.issuer,
78
103
  jwksUri: identity.jwksUri,
79
104
  authorizationEndpoint: requireEndpoint(document, "authorization_endpoint", issuer),
80
- tokenEndpoint: requireEndpoint(document, "token_endpoint", issuer),
105
+ tokenEndpoint: requireEndpoint(document, "token_endpoint", issuer, discoveryUrl),
81
106
  codeChallengeMethodsSupported: Array.isArray(methods) ? methods.filter((m) => typeof m === "string") : null,
82
- issParameterSupported: document.authorization_response_iss_parameter_supported === true
107
+ issParameterSupported: document.authorization_response_iss_parameter_supported === true,
108
+ endSessionEndpoint: optionalEndpoint(document, "end_session_endpoint", issuer),
109
+ idTokenSigningAlgValuesSupported: Array.isArray(idTokenAlgs) ? idTokenAlgs.filter((a) => typeof a === "string") : null
83
110
  };
84
111
  }
85
112
  throw new Error(`métadonnées introuvables pour « ${issuer} » — ${failures.join(" ; ")}`);
@@ -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,55 @@ 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
  };
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
+ }
265
292
  //#endregion
266
- export { OAuth2Client, OAuth2RequestError, OAuth2Tokens, createCodeChallenge, generateCodeVerifier, generateState };
293
+ export { OAuth2Client, OAuth2RequestError, OAuth2Tokens, createCodeChallenge, generateCodeVerifier, generateState, postTokenRequest };
@@ -5,22 +5,55 @@ 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
58
  //#endregion
26
- export { getOAuthProviderFactory, listOAuthProviders, registerOAuthProvider };
59
+ export { KEYCLOAK_THEME_PARAMETER, getOAuthProviderFactory, listOAuthProviders, oauthProviderRequiresIssuer, registerOAuthProvider, withKeycloakThemeHint };