@nodefony/framework 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 (76) hide show
  1. package/README.md +4 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/index.js +15 -20
  4. package/dist/nodefony/config/config.js +1 -2
  5. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  6. package/dist/nodefony/controller/AdminApiController.js +45 -11
  7. package/dist/nodefony/controller/ApiKeyController.js +18 -10
  8. package/dist/nodefony/controller/BenchController.js +1 -2
  9. package/dist/nodefony/controller/IssuerMetadataController.js +1 -2
  10. package/dist/nodefony/controller/LoginPageController.js +117 -0
  11. package/dist/nodefony/controller/OAuth2Controller.js +133 -17
  12. package/dist/nodefony/controller/ProtectedResourceMetadataController.js +1 -2
  13. package/dist/nodefony/controller/SessionAuthController.js +50 -13
  14. package/dist/nodefony/controller/TokenAuthController.js +14 -8
  15. package/dist/nodefony/controller/TotpController.js +7 -8
  16. package/dist/nodefony/controller/WebAuthnController.js +28 -12
  17. package/dist/nodefony/controller/oauthAuthority.js +1 -2
  18. package/dist/nodefony/decorators/routerDecorators.js +268 -65
  19. package/dist/nodefony/service/AdminBroker.js +4 -6
  20. package/dist/nodefony/service/Eta.js +1 -2
  21. package/dist/nodefony/service/IdempotencyStore.js +4 -6
  22. package/dist/nodefony/service/router.js +97 -34
  23. package/dist/nodefony/src/Controller.js +71 -43
  24. package/dist/nodefony/src/FrameworkAdminApi.js +33 -11
  25. package/dist/nodefony/src/KernelAdminApi.js +82 -18
  26. package/dist/nodefony/src/PlaygroundAdminApi.js +72 -3
  27. package/dist/nodefony/src/RedisIdempotencyStore.js +46 -4
  28. package/dist/nodefony/src/Resolver.js +105 -36
  29. package/dist/nodefony/src/ResourceController.js +2 -3
  30. package/dist/nodefony/src/Route.js +54 -8
  31. package/dist/nodefony/src/SecurityApiController.js +23 -0
  32. package/dist/nodefony/src/SyslogAdminApi.js +1 -2
  33. package/dist/nodefony/src/Template.js +1 -2
  34. package/dist/nodefony/src/configMutation.js +1 -2
  35. package/dist/nodefony/src/docsReader.js +39 -23
  36. package/dist/nodefony/src/idempotency.js +14 -22
  37. package/dist/nodefony/src/idempotencyGc.js +1 -2
  38. package/dist/nodefony/src/idempotencyStoreRegistry.js +1 -2
  39. package/dist/nodefony/src/loginPage.js +430 -0
  40. package/dist/nodefony/src/requireAuthentication.js +35 -0
  41. package/dist/nodefony/src/routeSchema.js +81 -0
  42. package/dist/nodefony/src/scopeCatalog.js +1 -2
  43. package/dist/nodefony/src/singletonGuard.js +1 -2
  44. package/dist/nodefony/src/syslogFilters.js +1 -2
  45. package/dist/types/index.d.ts +8 -12
  46. package/dist/types/nodefony/controller/AdminApiController.d.ts +12 -0
  47. package/dist/types/nodefony/controller/ApiKeyController.d.ts +2 -2
  48. package/dist/types/nodefony/controller/LoginPageController.d.ts +32 -0
  49. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +67 -8
  50. package/dist/types/nodefony/controller/SessionAuthController.d.ts +46 -8
  51. package/dist/types/nodefony/controller/TokenAuthController.d.ts +6 -4
  52. package/dist/types/nodefony/controller/TotpController.d.ts +2 -2
  53. package/dist/types/nodefony/controller/WebAuthnController.d.ts +12 -4
  54. package/dist/types/nodefony/decorators/routerDecorators.d.ts +221 -35
  55. package/dist/types/nodefony/interfaces/IController.d.ts +2 -3
  56. package/dist/types/nodefony/interfaces/IRoute.d.ts +1 -2
  57. package/dist/types/nodefony/service/router.d.ts +40 -1
  58. package/dist/types/nodefony/src/Controller.d.ts +54 -17
  59. package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +12 -0
  60. package/dist/types/nodefony/src/Resolver.d.ts +44 -1
  61. package/dist/types/nodefony/src/ResourceController.d.ts +1 -1
  62. package/dist/types/nodefony/src/Route.d.ts +21 -1
  63. package/dist/types/nodefony/src/SecurityApiController.d.ts +17 -0
  64. package/dist/types/nodefony/src/docsReader.d.ts +10 -6
  65. package/dist/types/nodefony/src/idempotency.d.ts +12 -10
  66. package/dist/types/nodefony/src/loginPage.d.ts +144 -0
  67. package/dist/types/nodefony/src/requireAuthentication.d.ts +29 -0
  68. package/dist/types/nodefony/src/routeSchema.d.ts +112 -0
  69. package/docs/admin.md +83 -78
  70. package/docs/controller.md +112 -111
  71. package/docs/decorateurs.md +212 -163
  72. package/docs/idempotence.md +137 -135
  73. package/docs/routing.md +96 -95
  74. package/docs/templates.md +55 -55
  75. package/package.json +10 -13
  76. package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
@@ -1,20 +1,80 @@
1
- import Controller from "../src/Controller.js";
2
1
  import router_default from "../service/router.js";
3
- //#region nodefony/controller/OAuth2Controller.ts
2
+ import SecurityApiController from "../src/SecurityApiController.js";
3
+ import { OAUTH2_API_BASE, OAUTH2_FAILED_REASON, safeRedirectPath } from "nodefony";
4
+ // nodefony/controller/OAuth2Controller.ts
4
5
  const STATE_KEY = "oauth2:state";
5
6
  const VERIFIER_KEY = "oauth2:verifier";
6
7
  const PROVIDER_KEY = "oauth2:provider";
8
+ const FROM_KEY = "oauth2:from";
9
+ const BACK_KEY = "oauth2:back";
7
10
  let mounted = false;
8
11
  /**
12
+ * Codes d'erreur qu'un serveur d'autorisation peut renvoyer au callback, et
13
+ * SEULS codes que l'écran d'arrivée reçoit : OAuth 2.0 (RFC 6749 §4.1.2.1) et
14
+ * OpenID Connect Core (§3.1.2.6). Une liste fermée — jamais la chaîne reçue
15
+ * telle quelle : le paramètre `error` vient de l'URL, donc de n'importe qui.
16
+ */
17
+ const AUTHORIZATION_ERRORS = /* @__PURE__ */ new Set([
18
+ "invalid_request",
19
+ "unauthorized_client",
20
+ "access_denied",
21
+ "unsupported_response_type",
22
+ "invalid_scope",
23
+ "server_error",
24
+ "temporarily_unavailable",
25
+ "interaction_required",
26
+ "login_required",
27
+ "account_selection_required",
28
+ "consent_required"
29
+ ]);
30
+ /**
31
+ * Thème que la page appelante a rendu (`?theme=`), à transmettre à l'écran du
32
+ * fournisseur. Liste FERMÉE : la chaîne vient de l'URL, toute autre valeur est
33
+ * ignorée sans un mot — rien d'arbitraire n'atteint la requête d'autorisation.
34
+ *
35
+ * @param value - paramètre `theme` de la requête, ou `null`
36
+ * @returns l'option à passer à `createAuthorization`, vide hors liste
37
+ */
38
+ function providerThemeOption(value) {
39
+ return value === "light" || value === "dark" ? { theme: value } : {};
40
+ }
41
+ const PROBE_ORIGIN = "http://redirect.invalid";
42
+ /**
43
+ * Compose l'adresse de l'écran d'échec : la raison de l'échec, et la page où
44
+ * aller une fois connecté, pour que l'écran la garde à la tentative suivante.
45
+ *
46
+ * `reason` vaut le code rendu par le fournisseur s'il appartient à la liste
47
+ * normalisée (`access_denied` = l'utilisateur a choisi de revenir), sinon
48
+ * {@link OAUTH2_FAILED_REASON} — jamais la chaîne reçue, qui vient de l'URL.
49
+ * Une `reason` ou un `from` déjà présents sont REMPLACÉS (l'écran qui a lancé
50
+ * le flux peut porter ceux d'une tentative précédente) ; le reste de la
51
+ * requête et le fragment sont conservés, et une adresse relative reste relative.
52
+ *
53
+ * @param failure - l'écran d'échec : `back` revalidé, ou `failureRedirect`
54
+ * @param error - le paramètre `error` reçu au callback, ou `null`
55
+ * @param from - la page où aller une fois connecté (revalidée), ou `null`
56
+ * @returns l'adresse de redirection
57
+ */
58
+ function oauth2FailureTarget(failure, error, from) {
59
+ const reason = error !== null && AUTHORIZATION_ERRORS.has(error) ? error : OAUTH2_FAILED_REASON;
60
+ const absolute = /^[a-z][a-z0-9+.-]*:/i.test(failure);
61
+ const url = new URL(failure, PROBE_ORIGIN);
62
+ url.searchParams.set("reason", reason);
63
+ if (from === null) url.searchParams.delete("from");
64
+ else url.searchParams.set("from", from);
65
+ return absolute ? url.href : `${url.pathname}${url.search}${url.hash}`;
66
+ }
67
+ /**
9
68
  * Endpoints HTTP du **social login OAuth 2.0** (P6 J9) — adaptateurs MINCES
10
69
  * au-dessus du service `oauth2` (`@nodefony/security`) :
11
70
  *
12
- * - `GET /nodefony/security/api/oauth2/{provider}/authorize` — démarre le flux :
13
- * pose `state`+`code_verifier` en session (anonyme), redirige (302) vers le
14
- * fournisseur.
71
+ * - `GET /nodefony/security/api/oauth2/{provider}/authorize?from=&back=` —
72
+ * démarre le flux : pose `state`+`code_verifier` en session (anonyme), avec
73
+ * les retours revalidés en chemins locaux, redirige (302) vers le fournisseur.
15
74
  * - `GET /nodefony/security/api/oauth2/{provider}/callback` — valide le `state`
16
75
  * (anti-CSRF), échange le `code`, provisionne le Shadow User et OUVRE la
17
- * session BFF (302 vers `successRedirect`).
76
+ * session BFF (302 vers `from`, sinon `successRedirect`). Un échec mène à
77
+ * `back`, sinon à `failureRedirect`, avec `reason` et `from`.
18
78
  *
19
79
  * Montés UNIQUEMENT si le service `oauth2` existe (social login activé) — 404
20
80
  * sinon, zéro surface.
@@ -25,7 +85,7 @@ let mounted = false;
25
85
  * La session anonyme ne porte que `state`/`verifier` ; `establishSessionFor`
26
86
  * régénère l'ID (anti-fixation) à la promotion.
27
87
  */
28
- var OAuth2Controller = class extends Controller {
88
+ var OAuth2Controller = class extends SecurityApiController {
29
89
  constructor(context) {
30
90
  super("OAuth2Controller", context);
31
91
  }
@@ -55,7 +115,7 @@ var OAuth2Controller = class extends Controller {
55
115
  if (!svc.listProviders().includes(provider)) return this.renderJson({ error: "Unknown provider" }, 404);
56
116
  let auth;
57
117
  try {
58
- auth = await svc.createAuthorization(provider);
118
+ auth = await svc.createAuthorization(provider, providerThemeOption(this.#queryString("theme")));
59
119
  } catch (error) {
60
120
  this.log(`oauth2 authorize "${provider}" : ${error.message}`, "ERROR");
61
121
  return this.renderJson({ error: "OAuth provider unavailable" }, 503);
@@ -65,6 +125,8 @@ var OAuth2Controller = class extends Controller {
65
125
  session.set(STATE_KEY, auth.state);
66
126
  session.set(VERIFIER_KEY, auth.codeVerifier);
67
127
  session.set(PROVIDER_KEY, provider);
128
+ session.set(FROM_KEY, this.#localPath("from"));
129
+ session.set(BACK_KEY, this.#localPath("back"));
68
130
  await session.save();
69
131
  this.redirect(auth.url, 302);
70
132
  }
@@ -78,28 +140,73 @@ var OAuth2Controller = class extends Controller {
78
140
  const expectedState = session?.get(STATE_KEY);
79
141
  const storedVerifier = session?.get(VERIFIER_KEY);
80
142
  const expectedProvider = session?.get(PROVIDER_KEY);
143
+ const from = safeRedirectPath(session?.get(FROM_KEY), "") || null;
144
+ const back = safeRedirectPath(session?.get(BACK_KEY), "") || null;
81
145
  session?.set(STATE_KEY, null);
82
146
  session?.set(VERIFIER_KEY, null);
83
147
  session?.set(PROVIDER_KEY, null);
148
+ session?.set(FROM_KEY, null);
149
+ session?.set(BACK_KEY, null);
84
150
  await session?.save();
151
+ const failedAt = back ?? failure;
85
152
  const code = this.#queryString("code");
86
153
  const returnedState = this.#queryString("state");
87
154
  const returnedIss = this.#queryString("iss");
88
- if (code === null || returnedState === null || typeof expectedState !== "string" || returnedState !== expectedState || expectedProvider !== provider) {
89
- this.redirect(failure, 302);
155
+ const stateIsValid = returnedState !== null && typeof expectedState === "string" && returnedState === expectedState && expectedProvider === provider;
156
+ if (code === null || !stateIsValid) {
157
+ const returnedError = code === null && stateIsValid ? this.#queryString("error") : null;
158
+ if (returnedError !== null) {
159
+ const logged = AUTHORIZATION_ERRORS.has(returnedError) ? returnedError : "code non normalisé";
160
+ this.log(`oauth2 callback "${provider}" : le fournisseur a répondu « ${logged} »`, "INFO");
161
+ }
162
+ this.redirect(oauth2FailureTarget(failedAt, returnedError, from), 302);
90
163
  return;
91
164
  }
92
165
  try {
93
- const { identifier } = await svc.exchangeAndProvision(provider, code, typeof storedVerifier === "string" ? storedVerifier : null, returnedIss);
166
+ const { identifier, logoutHint } = await svc.exchangeAndProvision(provider, code, typeof storedVerifier === "string" ? storedVerifier : null, returnedIss);
94
167
  await flow.establishSessionFor(this.context, identifier, "oauth");
95
- this.redirect(success, 302);
168
+ const opened = this.context.session;
169
+ if (opened && logoutHint !== void 0 && svc.rememberLogout) svc.rememberLogout(opened, logoutHint);
170
+ this.redirect(from ?? success, 302);
96
171
  return;
97
172
  } catch (error) {
98
173
  this.log(`oauth2 callback "${provider}" : ${error.message.slice(0, 200)}`, "WARNING");
99
- this.redirect(failure, 302);
174
+ this.redirect(oauth2FailureTarget(failedAt, null, from), 302);
100
175
  return;
101
176
  }
102
177
  }
178
+ /**
179
+ * Canal arrière : le fournisseur demande de fermer les sessions qu'il
180
+ * désigne (OpenID Connect Back-Channel Logout 1.0 §2.5-2.8).
181
+ *
182
+ * Appelé serveur à serveur — ni cookie, ni session, ni jeton CSRF : la
183
+ * SIGNATURE du `logout_token` est la seule authentification. Réponses de la
184
+ * norme (§2.8) : 200 si la déconnexion a eu lieu (ou n'avait plus lieu
185
+ * d'être), 400 sinon — jeton refusé (`invalid_request`) comme déconnexion
186
+ * impossible (la cause part au journal, jamais au fournisseur). Toujours
187
+ * `Cache-Control: no-store`.
188
+ */
189
+ async backchannelLogout(provider) {
190
+ const noStore = { "cache-control": "no-store" };
191
+ const svc = this.#service();
192
+ if (!svc || !svc.listProviders().includes(provider)) return this.renderJson({ error: "Unknown provider" }, 404, noStore);
193
+ if (!svc.backchannelLogout) return this.renderJson({ error: "Not implemented" }, 501, noStore);
194
+ const token = this.body?.logout_token;
195
+ if (typeof token !== "string" || token.length === 0) return this.renderJson({ error: "invalid_request" }, 400, noStore);
196
+ let outcome;
197
+ try {
198
+ ({outcome} = await svc.backchannelLogout(provider, token));
199
+ } catch (error) {
200
+ this.log(`oauth2 back-channel logout "${provider}" : ${error.message.slice(0, 200)}`, "ERROR");
201
+ return this.renderJson({ error: "invalid_request" }, 400, noStore);
202
+ }
203
+ if (outcome === "unsupported") return this.renderJson({ error: "Not implemented" }, 501, noStore);
204
+ if (outcome === "refused") {
205
+ this.log(`oauth2 back-channel logout "${provider}" : jeton de déconnexion refusé`, "WARNING");
206
+ return this.renderJson({ error: "invalid_request" }, 400, noStore);
207
+ }
208
+ return this.renderResponse("", "utf8", 200, noStore);
209
+ }
103
210
  #service() {
104
211
  const svc = this.get("oauth2");
105
212
  return svc?.isEnabled() ? svc : null;
@@ -107,9 +214,13 @@ var OAuth2Controller = class extends Controller {
107
214
  #flow() {
108
215
  return this.get("authFlow") ?? null;
109
216
  }
217
+ /** Paramètre de query string revalidé en chemin local, ou `null`. */
218
+ #localPath(key) {
219
+ return safeRedirectPath(this.#queryString(key), "") || null;
220
+ }
110
221
  /** Lit un paramètre de query string (GET), ou `null`. */
111
222
  #queryString(key) {
112
- const v = this.queryGet?.[key];
223
+ const v = this.query?.[key];
113
224
  return typeof v === "string" && v.length > 0 ? v : null;
114
225
  }
115
226
  };
@@ -119,7 +230,7 @@ var OAuth2Controller = class extends Controller {
119
230
  */
120
231
  function mountOAuth2Routes(frameworkModule) {
121
232
  if (mounted) return;
122
- const base = "/nodefony/security/api/oauth2";
233
+ const base = OAUTH2_API_BASE;
123
234
  const routes = [
124
235
  [
125
236
  "security.oauth2.providers",
@@ -138,6 +249,12 @@ function mountOAuth2Routes(frameworkModule) {
138
249
  `${base}/{provider}/callback`,
139
250
  "GET",
140
251
  "callback"
252
+ ],
253
+ [
254
+ "security.oauth2.backchannelLogout",
255
+ `${base}/{provider}/backchannel-logout`,
256
+ "POST",
257
+ "backchannelLogout"
141
258
  ]
142
259
  ];
143
260
  for (const [name, path, method, classMethod] of routes) router_default.createRoute(name, {
@@ -150,5 +267,4 @@ function mountOAuth2Routes(frameworkModule) {
150
267
  if (!Object.prototype.hasOwnProperty.call(OAuth2Controller.prototype, "module")) router_default.setController(OAuth2Controller, frameworkModule);
151
268
  mounted = true;
152
269
  }
153
- //#endregion
154
- export { OAuth2Controller as default, mountOAuth2Routes };
270
+ export { OAuth2Controller as default, mountOAuth2Routes, oauth2FailureTarget, providerThemeOption };
@@ -2,7 +2,7 @@ import Controller from "../src/Controller.js";
2
2
  import router_default from "../service/router.js";
3
3
  import { askedAuthority, onDeclaredAuthority } from "./oauthAuthority.js";
4
4
  import { buildProtectedResourceMetadata, canonicalResourceUri, protectedResourceMetadataPath } from "nodefony";
5
- //#region nodefony/controller/ProtectedResourceMetadataController.ts
5
+ // nodefony/controller/ProtectedResourceMetadataController.ts
6
6
  let mounted = false;
7
7
  /**
8
8
  * Ce qui a été PUBLIÉ au montage — la même liste qui a décidé des chemins.
@@ -217,5 +217,4 @@ function mountProtectedResourceRoutes(frameworkModule, resources) {
217
217
  mounted = true;
218
218
  return paths.length;
219
219
  }
220
- //#endregion
221
220
  export { collectProtectedResources, ProtectedResourceMetadataController as default, mountProtectedResourceRoutes, protectedResourceRoutePaths };
@@ -1,6 +1,9 @@
1
- import Controller from "../src/Controller.js";
1
+ import { CsrfProtect } from "../decorators/routerDecorators.js";
2
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.155.0/helpers/esm/decorate.js";
2
3
  import router_default from "../service/router.js";
3
- //#region nodefony/controller/SessionAuthController.ts
4
+ import SecurityApiController from "../src/SecurityApiController.js";
5
+ import { AUTH_API_BASE } from "nodefony";
6
+ // nodefony/controller/SessionAuthController.ts
4
7
  let mounted = false;
5
8
  /**
6
9
  * Endpoints HTTP du flux de session BFF — adaptateurs MINCES au-dessus du
@@ -8,7 +11,8 @@ let mounted = false;
8
11
  *
9
12
  * - `POST /nodefony/security/api/auth/login` — body JSON `{username, password}`
10
13
  * - `POST /nodefony/security/api/auth/logout` — idempotent
11
- * - `GET /nodefony/security/api/auth/me` — identité de la session courante
14
+ * - `GET /nodefony/security/api/auth/me` — identité de la session courante :
15
+ * TOUJOURS `200`, `{ user }` ou `{ user: null }` sans session
12
16
  *
13
17
  * Montés par le module framework UNIQUEMENT si le service `authFlow` existe
14
18
  * (module security chargé) — sans lui, les routes n'existent pas (404, zéro
@@ -19,14 +23,20 @@ let mounted = false;
19
23
  * `Retry-After` (RFC 6585) posé par le throttler NIST ; tout le reste remonte
20
24
  * au pipeline d'erreurs standard (500, détail loggé jamais fuité).
21
25
  *
22
- * @remarks Dérogation RFC 7235 §3.1 DÉLIBÉRÉE : les 401 de `login`/`me` ne
26
+ * `me` est une SONDE d'état, pas une ressource protégée : « pas de session »
27
+ * est un état normal du navigateur, pas une faute — d'où `200` + `{ user:
28
+ * null }`, comme les bibliothèques qui livrent aussi le client de connexion
29
+ * (Auth.js, better-auth). Le `401` y écrivait une erreur réseau dans la console
30
+ * de chaque visiteur anonyme, sans rien protéger : la réponse ne révèle rien.
31
+ *
32
+ * @remarks Dérogation RFC 9110 §15.5.2 DÉLIBÉRÉE : les 401 de `login` ne
23
33
  * portent PAS de `WWW-Authenticate` — aucun scheme HTTP ne décrit un cookie de
24
34
  * session, et un challenge `Basic` mensonger déclencherait le popup natif du
25
35
  * navigateur (l'anti-pattern que le BFF élimine). Les zones du firewall, elles,
26
36
  * restent strictement conformes (challenge du premier authenticator qui en
27
37
  * déclare un).
28
38
  */
29
- var SessionAuthController = class extends Controller {
39
+ var SessionAuthController = class extends SecurityApiController {
30
40
  constructor(context) {
31
41
  super("SessionAuthController", context);
32
42
  }
@@ -34,7 +44,7 @@ var SessionAuthController = class extends Controller {
34
44
  async login() {
35
45
  const flow = this.#flow();
36
46
  if (!flow) return this.renderJson({ error: "Authentication unavailable" }, 503);
37
- const body = this.queryPost ?? {};
47
+ const body = this.body ?? {};
38
48
  try {
39
49
  const outcome = await flow.login(this.context, body.username, body.password);
40
50
  if (outcome.status === "mfa_required") return await this.renderJson({
@@ -54,7 +64,7 @@ var SessionAuthController = class extends Controller {
54
64
  async loginTotp() {
55
65
  const flow = this.#flow();
56
66
  if (!flow) return this.renderJson({ error: "Authentication unavailable" }, 503);
57
- const body = this.queryPost ?? {};
67
+ const body = this.body ?? {};
58
68
  try {
59
69
  const user = await flow.completeMfaLogin(this.context, body.code);
60
70
  return await this.renderJson({ user });
@@ -62,19 +72,42 @@ var SessionAuthController = class extends Controller {
62
72
  return this.#renderAuthError(e);
63
73
  }
64
74
  }
65
- /** Détruit la session (storage + cookie). Toujours 200 (idempotent). */
75
+ /**
76
+ * Détruit la session (storage + cookie). Toujours 200 (idempotent). Quand la
77
+ * session venait d'un fournisseur qui publie un point de déconnexion, la
78
+ * réponse porte `logoutUrl` : le client y envoie le navigateur pour fermer
79
+ * aussi la session du fournisseur.
80
+ */
66
81
  async logout() {
67
82
  const flow = this.#flow();
68
83
  if (!flow) return this.renderJson({ error: "Authentication unavailable" }, 503);
69
- await flow.logout(this.context);
84
+ const context = this.context;
85
+ if (flow.logoutWithProvider) {
86
+ const { logoutUrl } = await flow.logoutWithProvider(context);
87
+ return this.renderJson(logoutUrl ? {
88
+ ok: true,
89
+ logoutUrl
90
+ } : { ok: true });
91
+ }
92
+ await flow.logout(context);
70
93
  return this.renderJson({ ok: true });
71
94
  }
72
- /** Identité de la session courante, re-résolue (rôles frais), ou 401. */
95
+ /**
96
+ * Identité de la session courante, re-résolue (rôles frais). Toujours `200` :
97
+ * `{ user: null }` quand il n'y a pas de session (ou qu'elle ne mène plus à
98
+ * un compte actif) — l'absence de session est un état, pas une erreur.
99
+ *
100
+ * **Sème aussi le jeton anti-CSRF** (cookie lisible `csrf-token`) : c'est la
101
+ * seule requête sûre que TOUT client connaît d'avance. Sans elle, une mutation
102
+ * `@CsrfProtect` n'a de jeton à rejouer que si son auteur a AUSSI décoré une
103
+ * lecture — protéger les seules écritures, le geste naturel, rendait alors 403
104
+ * sans issue, au navigateur comme aux tests générés. Le jeton est sans état
105
+ * (HMAC) : celui-ci vaut pour toute route protégée.
106
+ */
73
107
  async me() {
74
108
  const flow = this.#flow();
75
109
  if (!flow) return this.renderJson({ error: "Authentication unavailable" }, 503);
76
110
  const user = await flow.me(this.context);
77
- if (user === null) return this.renderJson({ error: "Unauthorized" }, 401);
78
111
  return this.renderJson({ user });
79
112
  }
80
113
  #flow() {
@@ -87,9 +120,14 @@ var SessionAuthController = class extends Controller {
87
120
  return this.renderJson({ error: "Too many attempts" }, 429, { "retry-after": String(typeof retry === "number" ? retry : 1) });
88
121
  }
89
122
  if (code === 401) return this.renderJson({ error: "Invalid credentials" }, 401);
123
+ if (code === 403) {
124
+ const message = e instanceof Error ? e.message : "Forbidden";
125
+ return this.renderJson({ error: message }, 403);
126
+ }
90
127
  throw e;
91
128
  }
92
129
  };
130
+ __decorate([CsrfProtect()], SessionAuthController.prototype, "me", null);
93
131
  /**
94
132
  * Monte les routes du flux de session BFF — appelé par le module framework à
95
133
  * `onKernelReady`, seulement si le service `authFlow` est présent.
@@ -100,7 +138,7 @@ var SessionAuthController = class extends Controller {
100
138
  */
101
139
  function mountSessionAuthRoutes(frameworkModule) {
102
140
  if (mounted) return;
103
- const base = "/nodefony/security/api/auth";
141
+ const base = AUTH_API_BASE;
104
142
  const routes = [
105
143
  [
106
144
  "security.auth.login",
@@ -137,5 +175,4 @@ function mountSessionAuthRoutes(frameworkModule) {
137
175
  if (!Object.prototype.hasOwnProperty.call(SessionAuthController.prototype, "module")) router_default.setController(SessionAuthController, frameworkModule);
138
176
  mounted = true;
139
177
  }
140
- //#endregion
141
178
  export { SessionAuthController as default, mountSessionAuthRoutes };
@@ -1,6 +1,6 @@
1
- import Controller from "../src/Controller.js";
2
1
  import router_default from "../service/router.js";
3
- //#region nodefony/controller/TokenAuthController.ts
2
+ import SecurityApiController from "../src/SecurityApiController.js";
3
+ // nodefony/controller/TokenAuthController.ts
4
4
  let mounted = false;
5
5
  /** Normalise le paramètre `scope` (string OAuth « a b c » ou tableau) → string[]. */
6
6
  function parseScope(scope) {
@@ -24,11 +24,11 @@ function parseScope(scope) {
24
24
  * framework ne peut pas importer les classes d'erreur de security ; un 429
25
25
  * reporte le `Retry-After` (RFC 6585) du throttler NIST.
26
26
  *
27
- * @remarks Dérogation RFC 7235 §3.1 (comme `SessionAuthController`) : ces 401 ne
27
+ * @remarks Dérogation RFC 9110 §15.5.2 (comme `SessionAuthController`) : ces 401 ne
28
28
  * portent pas de `WWW-Authenticate` — l'endpoint d'émission n'est pas une
29
29
  * ressource protégée par Bearer, il DÉLIVRE le Bearer.
30
30
  */
31
- var TokenAuthController = class extends Controller {
31
+ var TokenAuthController = class extends SecurityApiController {
32
32
  constructor(context) {
33
33
  super("TokenAuthController", context);
34
34
  }
@@ -36,9 +36,9 @@ var TokenAuthController = class extends Controller {
36
36
  async token() {
37
37
  const svc = this.#issuer();
38
38
  if (!svc || !svc.isEnabled()) return this.renderJson({ error: "Token issuance unavailable" }, 503);
39
- const body = this.queryPost ?? {};
39
+ const body = this.body ?? {};
40
40
  try {
41
- const tokens = await svc.issueForCredentials(body.username, body.password, parseScope(body.scope), body.resource);
41
+ const tokens = await svc.issueForCredentials(body.username, body.password, parseScope(body.scope), body.resource, this.context);
42
42
  return await this.renderJson(tokens);
43
43
  } catch (e) {
44
44
  return this.#renderAuthError(e);
@@ -48,7 +48,7 @@ var TokenAuthController = class extends Controller {
48
48
  async refresh() {
49
49
  const svc = this.#issuer();
50
50
  if (!svc || !svc.isEnabled()) return this.renderJson({ error: "Token issuance unavailable" }, 503);
51
- const body = this.queryPost ?? {};
51
+ const body = this.body ?? {};
52
52
  try {
53
53
  const tokens = await svc.refresh(body.refresh_token, body.resource);
54
54
  return await this.renderJson(tokens);
@@ -73,6 +73,13 @@ var TokenAuthController = class extends Controller {
73
73
  return this.renderJson({ error: "Too many attempts" }, 429, { "retry-after": String(typeof retry === "number" ? retry : 1) });
74
74
  }
75
75
  if (code === 401) return this.renderJson({ error: "invalid_grant" }, 401);
76
+ if (code === 403) {
77
+ const description = e instanceof Error ? e.message : "Forbidden";
78
+ return this.renderJson({
79
+ error: "invalid_request",
80
+ error_description: description
81
+ }, 403);
82
+ }
76
83
  throw e;
77
84
  }
78
85
  };
@@ -109,5 +116,4 @@ function mountTokenAuthRoutes(frameworkModule) {
109
116
  if (!Object.prototype.hasOwnProperty.call(TokenAuthController.prototype, "module")) router_default.setController(TokenAuthController, frameworkModule);
110
117
  mounted = true;
111
118
  }
112
- //#endregion
113
119
  export { TokenAuthController as default, mountTokenAuthRoutes };
@@ -1,6 +1,6 @@
1
- import Controller from "../src/Controller.js";
2
1
  import router_default from "../service/router.js";
3
- //#region nodefony/controller/TotpController.ts
2
+ import SecurityApiController from "../src/SecurityApiController.js";
3
+ // nodefony/controller/TotpController.ts
4
4
  let mounted = false;
5
5
  /**
6
6
  * Endpoints HTTP **self-service 2FA TOTP (P6.17)** — console « ma sécurité »,
@@ -20,7 +20,7 @@ let mounted = false;
20
20
  * `totp` existe (security chargé) — sans security, 404 et zéro surface ; 2FA
21
21
  * désactivé, les routes répondent 503.
22
22
  */
23
- var TotpController = class extends Controller {
23
+ var TotpController = class extends SecurityApiController {
24
24
  constructor(context) {
25
25
  super("TotpController", context);
26
26
  }
@@ -39,11 +39,11 @@ var TotpController = class extends Controller {
39
39
  if (!svc) return this.renderJson({ error: "2FA unavailable" }, 503);
40
40
  const subject = await this.#currentSubject();
41
41
  if (subject === null) return this.renderJson({ error: "Unauthorized" }, 401);
42
- const code = this.queryPost?.code;
42
+ const code = this.body?.code;
43
43
  if (typeof code !== "string" || code.length === 0) return this.renderJson({ error: "Invalid code" }, 400);
44
44
  let activation;
45
45
  try {
46
- activation = await svc.confirmEnrollment(subject, code);
46
+ activation = await svc.confirmEnrollment(subject, code, this.context);
47
47
  } catch {
48
48
  return this.renderJson({ error: "Invalid or expired code" }, 400);
49
49
  }
@@ -55,7 +55,7 @@ var TotpController = class extends Controller {
55
55
  if (!svc) return this.renderJson({ error: "2FA unavailable" }, 503);
56
56
  const subject = await this.#currentSubject();
57
57
  if (subject === null) return this.renderJson({ error: "Unauthorized" }, 401);
58
- await svc.disable(subject);
58
+ await svc.disable(subject, this.context);
59
59
  return this.renderJson({ ok: true });
60
60
  }
61
61
  /** État 2FA du porteur courant (alimente la console « ma sécurité »). */
@@ -78,7 +78,7 @@ var TotpController = class extends Controller {
78
78
  const flow = this.#flow();
79
79
  if (!flow) return null;
80
80
  const me = await flow.me(this.context);
81
- return me && typeof me.username === "string" ? me.username : null;
81
+ return me && typeof me.identifier === "string" ? me.identifier : null;
82
82
  }
83
83
  };
84
84
  /**
@@ -128,5 +128,4 @@ function mountTotpRoutes(frameworkModule) {
128
128
  if (!Object.prototype.hasOwnProperty.call(TotpController.prototype, "module")) router_default.setController(TotpController, frameworkModule);
129
129
  mounted = true;
130
130
  }
131
- //#endregion
132
131
  export { TotpController as default, mountTotpRoutes };
@@ -1,6 +1,7 @@
1
- import Controller from "../src/Controller.js";
2
1
  import router_default from "../service/router.js";
3
- //#region nodefony/controller/WebAuthnController.ts
2
+ import SecurityApiController from "../src/SecurityApiController.js";
3
+ import { WEBAUTHN_API_BASE, WEBAUTHN_STATUS_PATH } from "nodefony";
4
+ // nodefony/controller/WebAuthnController.ts
4
5
  const REG_CHALLENGE = "webauthn:reg:challenge";
5
6
  const AUTH_CHALLENGE = "webauthn:auth:challenge";
6
7
  let mounted = false;
@@ -31,10 +32,18 @@ let mounted = false;
31
32
  * firewall (qui, sur l'aire data plane, déclencherait un deadlock identique au
32
33
  * login BFF).
33
34
  */
34
- var WebAuthnController = class extends Controller {
35
+ var WebAuthnController = class extends SecurityApiController {
35
36
  constructor(context) {
36
37
  super("WebAuthnController", context);
37
38
  }
39
+ /**
40
+ * Dit si la connexion par passkey répondra : `{ enabled }`, toujours `200`.
41
+ * Un écran de connexion le lit pour ne pas proposer un bouton dont la
42
+ * cérémonie répondrait `503` (passkeys coupées).
43
+ */
44
+ status() {
45
+ return this.renderJson({ enabled: this.#service() !== null });
46
+ }
38
47
  /** Défi d'enregistrement — l'utilisateur connecté ajoute un passkey. */
39
48
  async registerOptions() {
40
49
  const svc = this.#service();
@@ -43,8 +52,9 @@ var WebAuthnController = class extends Controller {
43
52
  const me = await flow.me(this.context);
44
53
  if (!me) return this.renderJson({ error: "Unauthorized" }, 401);
45
54
  const options = await svc.generateRegistrationOptions({
46
- id: me.username,
47
- name: me.username
55
+ id: me.identifier,
56
+ name: me.identifier,
57
+ handle: me.id
48
58
  });
49
59
  const session = await flow.ensureSession(this.context);
50
60
  session?.set(REG_CHALLENGE, options.challenge);
@@ -60,7 +70,7 @@ var WebAuthnController = class extends Controller {
60
70
  const challenge = await this.#takeChallenge(REG_CHALLENGE);
61
71
  if (!challenge) return this.renderJson({ error: "No challenge" }, 400);
62
72
  try {
63
- const credential = await svc.verifyRegistration(this.#body().response, challenge, me.username, this.#origin());
73
+ const credential = await svc.verifyRegistration(this.#body().response, challenge, me.identifier, this.#origin());
64
74
  return await this.renderJson({
65
75
  verified: true,
66
76
  credentialId: credential.id
@@ -90,7 +100,7 @@ var WebAuthnController = class extends Controller {
90
100
  const flow = this.#flow();
91
101
  if (!svc || !flow) return this.renderJson({ error: "WebAuthn unavailable" }, 503);
92
102
  const me = await flow.me(this.context);
93
- const options = await svc.generateAuthenticationOptions(me?.username);
103
+ const options = await svc.generateAuthenticationOptions(me?.identifier);
94
104
  const session = await flow.ensureSession(this.context);
95
105
  session?.set(AUTH_CHALLENGE, options.challenge);
96
106
  await session?.save();
@@ -121,7 +131,7 @@ var WebAuthnController = class extends Controller {
121
131
  if (!svc || !flow) return this.renderJson({ error: "WebAuthn unavailable" }, 503);
122
132
  const me = await flow.me(this.context);
123
133
  if (!me) return this.renderJson({ error: "Unauthorized" }, 401);
124
- const creds = await svc.listUserCredentials(me.username);
134
+ const creds = await svc.listUserCredentials(me.identifier);
125
135
  return this.renderJson({ credentials: creds.map((c) => ({
126
136
  id: c.id,
127
137
  transports: c.transports,
@@ -138,7 +148,7 @@ var WebAuthnController = class extends Controller {
138
148
  const me = await flow.me(this.context);
139
149
  if (!me) return this.renderJson({ error: "Unauthorized" }, 401);
140
150
  if (typeof id !== "string" || id.length === 0) return this.renderJson({ error: "Not found" }, 404);
141
- if (!await svc.removeUserCredential(me.username, id)) return this.renderJson({ error: "Not found" }, 404);
151
+ if (!await svc.removeUserCredential(me.identifier, id)) return this.renderJson({ error: "Not found" }, 404);
142
152
  return this.renderJson({ ok: true });
143
153
  }
144
154
  #service() {
@@ -154,7 +164,7 @@ var WebAuthnController = class extends Controller {
154
164
  * cérémonie est découvrable (cf {@link WebAuthnController.loginOptions}).
155
165
  */
156
166
  #body() {
157
- return this.queryPost ?? {};
167
+ return this.body ?? {};
158
168
  }
159
169
  /** Origine HTTP de la requête (validée par le service contre le rpID). */
160
170
  #origin() {
@@ -183,7 +193,7 @@ var WebAuthnController = class extends Controller {
183
193
  */
184
194
  function mountWebAuthnRoutes(frameworkModule) {
185
195
  if (mounted) return;
186
- const base = "/nodefony/security/api/webauthn";
196
+ const base = WEBAUTHN_API_BASE;
187
197
  const routes = [
188
198
  [
189
199
  "security.webauthn.register.options",
@@ -199,6 +209,13 @@ function mountWebAuthnRoutes(frameworkModule) {
199
209
  "registerVerify",
200
210
  true
201
211
  ],
212
+ [
213
+ "security.webauthn.status",
214
+ WEBAUTHN_STATUS_PATH,
215
+ "GET",
216
+ "status",
217
+ true
218
+ ],
202
219
  [
203
220
  "security.webauthn.login.options",
204
221
  `${base}/login/options`,
@@ -238,5 +255,4 @@ function mountWebAuthnRoutes(frameworkModule) {
238
255
  if (!Object.prototype.hasOwnProperty.call(WebAuthnController.prototype, "module")) router_default.setController(WebAuthnController, frameworkModule);
239
256
  mounted = true;
240
257
  }
241
- //#endregion
242
258
  export { WebAuthnController as default, mountWebAuthnRoutes };
@@ -1,4 +1,4 @@
1
- //#region nodefony/controller/oauthAuthority.ts
1
+ // nodefony/controller/oauthAuthority.ts
2
2
  /**
3
3
  * Une seule règle : un document bien connu ne se sert QUE sur l'autorité dont
4
4
  * il se réclame.
@@ -70,5 +70,4 @@ function askedAuthority(headers) {
70
70
  const asked = Array.isArray(raw) ? raw[0] : raw;
71
71
  return typeof asked === "string" && asked.length > 0 ? asked : void 0;
72
72
  }
73
- //#endregion
74
73
  export { askedAuthority, onDeclaredAuthority };