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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  3. package/dist/index.js +9 -5
  4. package/dist/nodefony/controller/AdminApiController.js +7 -4
  5. package/dist/nodefony/controller/ApiKeyController.js +3 -3
  6. package/dist/nodefony/controller/LoginPageController.js +124 -0
  7. package/dist/nodefony/controller/OAuth2Controller.js +131 -14
  8. package/dist/nodefony/controller/SessionAuthController.js +37 -9
  9. package/dist/nodefony/controller/TokenAuthController.js +11 -4
  10. package/dist/nodefony/controller/TotpController.js +5 -5
  11. package/dist/nodefony/controller/WebAuthnController.js +26 -9
  12. package/dist/nodefony/decorators/routerDecorators.js +114 -13
  13. package/dist/nodefony/service/AdminBroker.js +2 -2
  14. package/dist/nodefony/service/IdempotencyStore.js +2 -2
  15. package/dist/nodefony/service/router.js +51 -22
  16. package/dist/nodefony/src/Controller.js +49 -4
  17. package/dist/nodefony/src/FrameworkAdminApi.js +2 -1
  18. package/dist/nodefony/src/KernelAdminApi.js +76 -10
  19. package/dist/nodefony/src/Resolver.js +39 -2
  20. package/dist/nodefony/src/Route.js +52 -5
  21. package/dist/nodefony/src/SecurityApiController.js +24 -0
  22. package/dist/nodefony/src/docsReader.js +38 -21
  23. package/dist/nodefony/src/idempotency.js +13 -20
  24. package/dist/nodefony/src/loginPage.js +431 -0
  25. package/dist/nodefony/src/requireAuthentication.js +36 -0
  26. package/dist/types/index.d.ts +4 -3
  27. package/dist/types/nodefony/controller/ApiKeyController.d.ts +2 -2
  28. package/dist/types/nodefony/controller/LoginPageController.d.ts +32 -0
  29. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +67 -8
  30. package/dist/types/nodefony/controller/SessionAuthController.d.ts +39 -8
  31. package/dist/types/nodefony/controller/TokenAuthController.d.ts +6 -4
  32. package/dist/types/nodefony/controller/TotpController.d.ts +2 -2
  33. package/dist/types/nodefony/controller/WebAuthnController.d.ts +12 -4
  34. package/dist/types/nodefony/decorators/routerDecorators.d.ts +88 -3
  35. package/dist/types/nodefony/interfaces/IRoute.d.ts +1 -2
  36. package/dist/types/nodefony/service/router.d.ts +13 -0
  37. package/dist/types/nodefony/src/Controller.d.ts +40 -1
  38. package/dist/types/nodefony/src/Resolver.d.ts +19 -0
  39. package/dist/types/nodefony/src/Route.d.ts +18 -1
  40. package/dist/types/nodefony/src/SecurityApiController.d.ts +17 -0
  41. package/dist/types/nodefony/src/docsReader.d.ts +10 -6
  42. package/dist/types/nodefony/src/idempotency.d.ts +12 -10
  43. package/dist/types/nodefony/src/loginPage.d.ts +144 -0
  44. package/dist/types/nodefony/src/requireAuthentication.d.ts +29 -0
  45. package/docs/admin.md +28 -23
  46. package/docs/controller.md +76 -74
  47. package/docs/decorateurs.md +69 -67
  48. package/docs/idempotence.md +30 -28
  49. package/docs/routing.md +52 -52
  50. package/docs/templates.md +11 -11
  51. package/package.json +11 -11
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.152.0/helpers/esm/decorate.js
1
+ //#region \0@oxc-project+runtime@0.153.0/helpers/esm/decorate.js
2
2
  function __decorate(decorators, target, key, desc) {
3
3
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
4
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.152.0/helpers/esm/decorateMetadata.js
1
+ //#region \0@oxc-project+runtime@0.153.0/helpers/esm/decorateMetadata.js
2
2
  function __decorateMetadata(k, v) {
3
3
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
4
4
  }
package/dist/index.js CHANGED
@@ -5,16 +5,17 @@ import { RedisIdempotencyStore } from "./nodefony/src/RedisIdempotencyStore.js";
5
5
  import { scheduleIdempotencyGc } from "./nodefony/src/idempotencyGc.js";
6
6
  import Route from "./nodefony/src/Route.js";
7
7
  import Controller from "./nodefony/src/Controller.js";
8
- import { All, Anonymous, Body, BypassFirewall, Cookie, Csp, CsrfExempt, CsrfProtect, CurrentUser, Delete, Domain, Get, Head, Header, Headers, HttpCode, Idempotent, IsGranted, Options, Param, Patch, Post, Put, Query, Redirect, Req, RequireScope, Res, Scope, Session, UploadedFile, UploadedFiles, UseSession, controller, controllers, route, routeExpectsBodyStream } from "./nodefony/decorators/routerDecorators.js";
8
+ import { All, Anonymous, Body, BypassFirewall, Cookie, Csp, CsrfExempt, CsrfProtect, CurrentUser, Delete, Domain, Get, Head, Header, Headers, HttpCode, Idempotent, IsGranted, Options, Param, Patch, Post, Put, Query, Redirect, Req, RequireAuthentication, RequireScope, Res, Scope, Session, UploadedFile, UploadedFiles, UseSession, buildContextParamArgs, controller, controllers, getParamArgsMeta, route, routeExpectsBodyStream } from "./nodefony/decorators/routerDecorators.js";
9
9
  import Resolver from "./nodefony/src/Resolver.js";
10
- import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js";
11
- import __decorate from "./_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorate.js";
10
+ import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js";
11
+ import __decorate from "./_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.js";
12
12
  import router_default from "./nodefony/service/router.js";
13
13
  import ResourceController from "./nodefony/src/ResourceController.js";
14
14
  import AdminApiController from "./nodefony/controller/AdminApiController.js";
15
15
  import AdminBroker_default from "./nodefony/service/AdminBroker.js";
16
16
  import IdempotencyStore_default from "./nodefony/service/IdempotencyStore.js";
17
17
  import SessionAuthController, { mountSessionAuthRoutes } from "./nodefony/controller/SessionAuthController.js";
18
+ import LoginPageController, { mountLoginPage } from "./nodefony/controller/LoginPageController.js";
18
19
  import TokenAuthController, { mountTokenAuthRoutes } from "./nodefony/controller/TokenAuthController.js";
19
20
  import IssuerMetadataController, { mountIssuerMetadataRoutes } from "./nodefony/controller/IssuerMetadataController.js";
20
21
  import ProtectedResourceMetadataController, { collectProtectedResources, mountProtectedResourceRoutes, protectedResourceRoutePaths } from "./nodefony/controller/ProtectedResourceMetadataController.js";
@@ -176,7 +177,10 @@ let Framework = class Framework extends Module {
176
177
  }
177
178
  broker.mountAll();
178
179
  }
179
- if (this.kernel?.container?.get("authFlow")) mountSessionAuthRoutes(this);
180
+ if (this.kernel?.container?.get("authFlow")) {
181
+ mountSessionAuthRoutes(this);
182
+ mountLoginPage(this);
183
+ }
180
184
  if (this.kernel?.container?.get("tokenService")) {
181
185
  mountTokenAuthRoutes(this);
182
186
  const issuer = this.kernel.container.get("tokenService")?.publishedIssuer();
@@ -209,4 +213,4 @@ const graphql = {
209
213
  };
210
214
  var framework_default = Framework;
211
215
  //#endregion
212
- export { AdminApiController, AdminBroker_default as AdminBroker, All, Anonymous, ApiKeyController, BenchController, Body, BypassFirewall, Controller, Cookie, Csp, CsrfExempt, CsrfProtect, CurrentUser, Delete, Domain, Eta, Get, Head, Header, Headers, HttpCode, Idempotent, IsGranted, IssuerMetadataController, IdempotencyStore_default as MemoryIdempotencyStore, OAuth2Controller, Options, Param, Patch, Post, ProtectedResourceMetadataController, Put, Query, Redirect, Req, RequireScope, Res, Resolver, ResourceController, Route, router_default as Router, Scope, Session, SessionAuthController, TokenAuthController, TotpController, UploadedFile, UploadedFiles, UseSession, WebAuthnController, buildPlaygroundSnapshot, collectProtectedResources, controller, controllers, createFrameworkAdminApi, createKernelAdminApi, createSyslogAdminApi, framework_default as default, defineFrameworkConfig, frameworkConfigJsonSchema, frameworkConfigSchema, getIdempotencyStoreFactory, graphql, listIdempotencyStores, mountApiKeyRoutes, mountBenchRoutes, mountIssuerMetadataRoutes, mountOAuth2Routes, mountProtectedResourceRoutes, mountSessionAuthRoutes, mountTokenAuthRoutes, mountTotpRoutes, mountWebAuthnRoutes, protectedResourceRoutePaths, registerIdempotencyStore, route, routeExpectsBodyStream };
216
+ export { AdminApiController, AdminBroker_default as AdminBroker, All, Anonymous, ApiKeyController, BenchController, Body, BypassFirewall, Controller, Cookie, Csp, CsrfExempt, CsrfProtect, CurrentUser, Delete, Domain, Eta, Get, Head, Header, Headers, HttpCode, Idempotent, IsGranted, IssuerMetadataController, LoginPageController, IdempotencyStore_default as MemoryIdempotencyStore, OAuth2Controller, Options, Param, Patch, Post, ProtectedResourceMetadataController, Put, Query, Redirect, Req, RequireAuthentication, RequireScope, Res, Resolver, ResourceController, Route, router_default as Router, Scope, Session, SessionAuthController, TokenAuthController, TotpController, UploadedFile, UploadedFiles, UseSession, WebAuthnController, buildContextParamArgs, buildPlaygroundSnapshot, collectProtectedResources, controller, controllers, createFrameworkAdminApi, createKernelAdminApi, createSyslogAdminApi, framework_default as default, defineFrameworkConfig, frameworkConfigJsonSchema, frameworkConfigSchema, getIdempotencyStoreFactory, getParamArgsMeta, graphql, listIdempotencyStores, mountApiKeyRoutes, mountBenchRoutes, mountIssuerMetadataRoutes, mountLoginPage, mountOAuth2Routes, mountProtectedResourceRoutes, mountSessionAuthRoutes, mountTokenAuthRoutes, mountTotpRoutes, mountWebAuthnRoutes, protectedResourceRoutePaths, registerIdempotencyStore, route, routeExpectsBodyStream };
@@ -1,6 +1,6 @@
1
1
  import Controller from "../src/Controller.js";
2
2
  import { computeFingerprint, evaluateIdempotency, resolveIdempotencyKey, resolveIdentity } from "../src/idempotency.js";
3
- import { RequestContext, RpcError, executeAdminEndpoint } from "nodefony";
3
+ import { JsonRpcServerError, RequestContext, RpcError, executeAdminEndpoint } from "nodefony";
4
4
  //#region nodefony/controller/AdminApiController.ts
5
5
  /**
6
6
  * Controller pont unique du data plane admin (Studio).
@@ -46,7 +46,7 @@ var AdminApiController = class AdminApiController extends Controller {
46
46
  if (this.context?.type.startsWith("websocket")) {
47
47
  if (status >= 400) {
48
48
  const message = body?.error ?? "admin error";
49
- throw new RpcError(message, -32e3, {
49
+ throw new RpcError(message, JsonRpcServerError.DEFAULT, {
50
50
  status,
51
51
  body
52
52
  });
@@ -84,7 +84,10 @@ var AdminApiController = class AdminApiController extends Controller {
84
84
  request: this.buildRequest(args),
85
85
  requiredRole: adminRoute.role,
86
86
  gate: (request) => this.idempotencyGate(adminRoute, request),
87
- onServerError: (error) => this.log(error, "ERROR")
87
+ onServerError: (error) => this.log(error, "ERROR"),
88
+ assertCredentialTransport: () => {
89
+ this.get("credentialTransport")?.assert(this.context ?? {}, `admin ${name}`);
90
+ }
88
91
  });
89
92
  }
90
93
  /**
@@ -104,7 +107,7 @@ var AdminApiController = class AdminApiController extends Controller {
104
107
  const store = this.get("idempotencyStore");
105
108
  const verdict = await evaluateIdempotency({
106
109
  store,
107
- identity: resolveIdentity(request.user),
110
+ identity: resolveIdentity(),
108
111
  clientKey: request.idempotencyKey,
109
112
  fingerprint: computeFingerprint([
110
113
  adminRoute.name,
@@ -1,5 +1,5 @@
1
- import Controller from "../src/Controller.js";
2
1
  import router_default from "../service/router.js";
2
+ import SecurityApiController from "../src/SecurityApiController.js";
3
3
  import { collectDeclaredApiScopes } from "../src/scopeCatalog.js";
4
4
  //#region nodefony/controller/ApiKeyController.ts
5
5
  let mounted = false;
@@ -24,7 +24,7 @@ let mounted = false;
24
24
  * Erreurs mappées par DUCK-TYPING sur `code` (400/409/503) — framework ne peut pas
25
25
  * importer les classes d'erreur de security.
26
26
  */
27
- var ApiKeyController = class extends Controller {
27
+ var ApiKeyController = class extends SecurityApiController {
28
28
  constructor(context) {
29
29
  super("ApiKeyController", context);
30
30
  }
@@ -89,7 +89,7 @@ var ApiKeyController = class extends Controller {
89
89
  const flow = this.#flow();
90
90
  if (!flow) return null;
91
91
  const me = await flow.me(this.context);
92
- return me && typeof me.username === "string" ? me.username : null;
92
+ return me && typeof me.identifier === "string" ? me.identifier : null;
93
93
  }
94
94
  #renderApiKeyError(e) {
95
95
  const code = e.code;
@@ -0,0 +1,124 @@
1
+ import { isDevelopment } from "../src/singletonGuard.js";
2
+ import Controller from "../src/Controller.js";
3
+ import { Csp } from "../decorators/routerDecorators.js";
4
+ import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js";
5
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.js";
6
+ import router_default from "../service/router.js";
7
+ import { LOGIN_PAGE_ASSET_FILES, LOGIN_PAGE_TEMPLATE, buildLoginPageView, keepSuccess } from "../src/loginPage.js";
8
+ import path from "node:path";
9
+ import { LOGIN_PAGE_ASSETS_BASE, LOGIN_PAGE_SKINS, loginPageSkinFile } from "nodefony";
10
+ import { createHash } from "node:crypto";
11
+ import { readFile } from "node:fs/promises";
12
+ import { fileURLToPath } from "node:url";
13
+ import { Eta } from "eta";
14
+ //#region nodefony/controller/LoginPageController.ts
15
+ const NO_STORE = { "cache-control": "no-store" };
16
+ let mounted = false;
17
+ let engine = null;
18
+ let defaultTemplate = null;
19
+ let appTemplate = null;
20
+ /** Dossier des fichiers de la page (`dist/login/` du paquet `nodefony`). */
21
+ function assetsDir() {
22
+ return path.dirname(fileURLToPath(import.meta.resolve("nodefony/login.js")));
23
+ }
24
+ function eta() {
25
+ return engine ??= new Eta({
26
+ autoEscape: true,
27
+ useWith: false
28
+ });
29
+ }
30
+ function templateFor(page) {
31
+ if (page.template === null) return Promise.resolve(defaultTemplate ??= eta().compile(LOGIN_PAGE_TEMPLATE));
32
+ if (appTemplate?.file !== page.template) {
33
+ const file = page.template;
34
+ appTemplate = {
35
+ file,
36
+ template: keepSuccess(() => readFile(file, "utf8").then((source) => eta().compile(source)))
37
+ };
38
+ }
39
+ return appTemplate.template();
40
+ }
41
+ const version = keepSuccess(async () => {
42
+ const dir = assetsDir();
43
+ const hash = createHash("sha256");
44
+ const skins = LOGIN_PAGE_SKINS.map(loginPageSkinFile).filter((file) => file !== null);
45
+ for (const file of [
46
+ LOGIN_PAGE_ASSET_FILES.script,
47
+ LOGIN_PAGE_ASSET_FILES.style,
48
+ ...skins
49
+ ]) hash.update(await readFile(path.join(dir, file)));
50
+ return hash.digest("hex").slice(0, 12);
51
+ });
52
+ /**
53
+ * Page de connexion par défaut (ADR-0015) : gabarit F03 rendu côté serveur,
54
+ * feuille `nodefony/login.css`, script `nodefony/login.js`. Aucun front, aucun
55
+ * build côté application.
56
+ *
57
+ * Joignable sans session ; jamais mise en cache (`no-store`) ni affichable
58
+ * dans un cadre (`frame-ancestors 'none'`).
59
+ */
60
+ var LoginPageController = class extends Controller {
61
+ constructor(context) {
62
+ super("LoginPageController", context);
63
+ }
64
+ /**
65
+ * Rend la page de connexion.
66
+ *
67
+ * @returns la page HTML, ou 404 quand security ne la décrit plus
68
+ */
69
+ async page() {
70
+ const page = this.get("authFlow")?.describeLoginPage?.() ?? null;
71
+ if (page === null) return this.renderJson({ error: "Not Found" }, 404, NO_STORE);
72
+ const query = this.queryGet;
73
+ const view = buildLoginPageView(page, {
74
+ from: query?.from,
75
+ reason: query?.reason,
76
+ theme: query?.theme,
77
+ skin: query?.skin,
78
+ layout: query?.layout,
79
+ preview: isDevelopment(this.kernel?.environment),
80
+ nonce: this.context?.cspNonce ?? "",
81
+ projectName: this.kernel?.projectName ?? "Nodefony",
82
+ assetsVersion: await version()
83
+ });
84
+ const template = await templateFor(page);
85
+ return this.render(template.call(eta(), view), "utf8", 200, {
86
+ ...NO_STORE,
87
+ "content-type": "text/html; charset=utf-8"
88
+ });
89
+ }
90
+ };
91
+ __decorate([
92
+ Csp({ "frame-ancestors": ["'none'"] }),
93
+ __decorateMetadata("design:type", Function),
94
+ __decorateMetadata("design:paramtypes", []),
95
+ __decorateMetadata("design:returntype", Promise)
96
+ ], LoginPageController.prototype, "page", null);
97
+ /**
98
+ * Monte la page de connexion par défaut quand security la décrit : la route
99
+ * sur `loginPage.path`, et ses fichiers sous `LOGIN_PAGE_ASSETS_BASE`.
100
+ *
101
+ * La page est le chemin vers la session : elle échappe au pare-feu, comme les
102
+ * routes de connexion — sinon une application qui protège `^/` exigerait
103
+ * d'être connecté pour se connecter.
104
+ *
105
+ * @param frameworkModule - module framework, qui porte les contrôleurs
106
+ */
107
+ function mountLoginPage(frameworkModule) {
108
+ if (mounted) return;
109
+ const page = frameworkModule.get("authFlow")?.describeLoginPage?.() ?? null;
110
+ if (page === null) return;
111
+ if (!page.password && !page.passkey && page.providers.length === 0) frameworkModule.log(`${page.path} : aucun moyen de connexion — loginPage.password à false, passkeys coupées, aucun fournisseur opérationnel`, "WARNING");
112
+ router_default.createRoute("security.login.page", {
113
+ path: page.path,
114
+ constructor: LoginPageController,
115
+ classMethod: "page",
116
+ requirements: { methods: ["GET", "HEAD"] },
117
+ bypassFirewall: true
118
+ });
119
+ frameworkModule.get("server-static")?.addMount(LOGIN_PAGE_ASSETS_BASE, assetsDir());
120
+ if (!Object.prototype.hasOwnProperty.call(LoginPageController.prototype, "module")) router_default.setController(LoginPageController, frameworkModule);
121
+ mounted = true;
122
+ }
123
+ //#endregion
124
+ export { LoginPageController as default, mountLoginPage };
@@ -1,20 +1,80 @@
1
- import Controller from "../src/Controller.js";
2
1
  import router_default from "../service/router.js";
2
+ import SecurityApiController from "../src/SecurityApiController.js";
3
+ import { OAUTH2_API_BASE, OAUTH2_FAILED_REASON, safeRedirectPath } from "nodefony";
3
4
  //#region 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.queryPost?.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,6 +214,10 @@ 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
223
  const v = this.queryGet?.[key];
@@ -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, {
@@ -151,4 +268,4 @@ function mountOAuth2Routes(frameworkModule) {
151
268
  mounted = true;
152
269
  }
153
270
  //#endregion
154
- export { OAuth2Controller as default, mountOAuth2Routes };
271
+ export { OAuth2Controller as default, mountOAuth2Routes, oauth2FailureTarget, providerThemeOption };
@@ -1,5 +1,6 @@
1
- import Controller from "../src/Controller.js";
2
1
  import router_default from "../service/router.js";
2
+ import SecurityApiController from "../src/SecurityApiController.js";
3
+ import { AUTH_API_BASE } from "nodefony";
3
4
  //#region nodefony/controller/SessionAuthController.ts
4
5
  let mounted = false;
5
6
  /**
@@ -8,7 +9,8 @@ let mounted = false;
8
9
  *
9
10
  * - `POST /nodefony/security/api/auth/login` — body JSON `{username, password}`
10
11
  * - `POST /nodefony/security/api/auth/logout` — idempotent
11
- * - `GET /nodefony/security/api/auth/me` — identité de la session courante
12
+ * - `GET /nodefony/security/api/auth/me` — identité de la session courante :
13
+ * TOUJOURS `200`, `{ user }` ou `{ user: null }` sans session
12
14
  *
13
15
  * Montés par le module framework UNIQUEMENT si le service `authFlow` existe
14
16
  * (module security chargé) — sans lui, les routes n'existent pas (404, zéro
@@ -19,14 +21,20 @@ let mounted = false;
19
21
  * `Retry-After` (RFC 6585) posé par le throttler NIST ; tout le reste remonte
20
22
  * au pipeline d'erreurs standard (500, détail loggé jamais fuité).
21
23
  *
22
- * @remarks Dérogation RFC 7235 §3.1 DÉLIBÉRÉE : les 401 de `login`/`me` ne
24
+ * `me` est une SONDE d'état, pas une ressource protégée : « pas de session »
25
+ * est un état normal du navigateur, pas une faute — d'où `200` + `{ user:
26
+ * null }`, comme les bibliothèques qui livrent aussi le client de connexion
27
+ * (Auth.js, better-auth). Le `401` y écrivait une erreur réseau dans la console
28
+ * de chaque visiteur anonyme, sans rien protéger : la réponse ne révèle rien.
29
+ *
30
+ * @remarks Dérogation RFC 9110 §15.5.2 DÉLIBÉRÉE : les 401 de `login` ne
23
31
  * portent PAS de `WWW-Authenticate` — aucun scheme HTTP ne décrit un cookie de
24
32
  * session, et un challenge `Basic` mensonger déclencherait le popup natif du
25
33
  * navigateur (l'anti-pattern que le BFF élimine). Les zones du firewall, elles,
26
34
  * restent strictement conformes (challenge du premier authenticator qui en
27
35
  * déclare un).
28
36
  */
29
- var SessionAuthController = class extends Controller {
37
+ var SessionAuthController = class extends SecurityApiController {
30
38
  constructor(context) {
31
39
  super("SessionAuthController", context);
32
40
  }
@@ -62,19 +70,35 @@ var SessionAuthController = class extends Controller {
62
70
  return this.#renderAuthError(e);
63
71
  }
64
72
  }
65
- /** Détruit la session (storage + cookie). Toujours 200 (idempotent). */
73
+ /**
74
+ * Détruit la session (storage + cookie). Toujours 200 (idempotent). Quand la
75
+ * session venait d'un fournisseur qui publie un point de déconnexion, la
76
+ * réponse porte `logoutUrl` : le client y envoie le navigateur pour fermer
77
+ * aussi la session du fournisseur.
78
+ */
66
79
  async logout() {
67
80
  const flow = this.#flow();
68
81
  if (!flow) return this.renderJson({ error: "Authentication unavailable" }, 503);
69
- await flow.logout(this.context);
82
+ const context = this.context;
83
+ if (flow.logoutWithProvider) {
84
+ const { logoutUrl } = await flow.logoutWithProvider(context);
85
+ return this.renderJson(logoutUrl ? {
86
+ ok: true,
87
+ logoutUrl
88
+ } : { ok: true });
89
+ }
90
+ await flow.logout(context);
70
91
  return this.renderJson({ ok: true });
71
92
  }
72
- /** Identité de la session courante, re-résolue (rôles frais), ou 401. */
93
+ /**
94
+ * Identité de la session courante, re-résolue (rôles frais). Toujours `200` :
95
+ * `{ user: null }` quand il n'y a pas de session (ou qu'elle ne mène plus à
96
+ * un compte actif) — l'absence de session est un état, pas une erreur.
97
+ */
73
98
  async me() {
74
99
  const flow = this.#flow();
75
100
  if (!flow) return this.renderJson({ error: "Authentication unavailable" }, 503);
76
101
  const user = await flow.me(this.context);
77
- if (user === null) return this.renderJson({ error: "Unauthorized" }, 401);
78
102
  return this.renderJson({ user });
79
103
  }
80
104
  #flow() {
@@ -87,6 +111,10 @@ var SessionAuthController = class extends Controller {
87
111
  return this.renderJson({ error: "Too many attempts" }, 429, { "retry-after": String(typeof retry === "number" ? retry : 1) });
88
112
  }
89
113
  if (code === 401) return this.renderJson({ error: "Invalid credentials" }, 401);
114
+ if (code === 403) {
115
+ const message = e instanceof Error ? e.message : "Forbidden";
116
+ return this.renderJson({ error: message }, 403);
117
+ }
90
118
  throw e;
91
119
  }
92
120
  };
@@ -100,7 +128,7 @@ var SessionAuthController = class extends Controller {
100
128
  */
101
129
  function mountSessionAuthRoutes(frameworkModule) {
102
130
  if (mounted) return;
103
- const base = "/nodefony/security/api/auth";
131
+ const base = AUTH_API_BASE;
104
132
  const routes = [
105
133
  [
106
134
  "security.auth.login",
@@ -1,5 +1,5 @@
1
- import Controller from "../src/Controller.js";
2
1
  import router_default from "../service/router.js";
2
+ import SecurityApiController from "../src/SecurityApiController.js";
3
3
  //#region nodefony/controller/TokenAuthController.ts
4
4
  let mounted = false;
5
5
  /** Normalise le paramètre `scope` (string OAuth « a b c » ou tableau) → string[]. */
@@ -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
  }
@@ -38,7 +38,7 @@ var TokenAuthController = class extends Controller {
38
38
  if (!svc || !svc.isEnabled()) return this.renderJson({ error: "Token issuance unavailable" }, 503);
39
39
  const body = this.queryPost ?? {};
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);
@@ -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
  };
@@ -1,5 +1,5 @@
1
- import Controller from "../src/Controller.js";
2
1
  import router_default from "../service/router.js";
2
+ import SecurityApiController from "../src/SecurityApiController.js";
3
3
  //#region nodefony/controller/TotpController.ts
4
4
  let mounted = false;
5
5
  /**
@@ -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
  }
@@ -43,7 +43,7 @@ var TotpController = class extends Controller {
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
  /**