@nodefony/framework 10.0.0-alpha.1

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 (96) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +50 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +211 -0
  6. package/dist/nodefony/config/config.js +61 -0
  7. package/dist/nodefony/config/defineModuleConfig.js +36 -0
  8. package/dist/nodefony/controller/AdminApiController.js +163 -0
  9. package/dist/nodefony/controller/ApiKeyController.js +151 -0
  10. package/dist/nodefony/controller/BenchController.js +132 -0
  11. package/dist/nodefony/controller/IssuerMetadataController.js +148 -0
  12. package/dist/nodefony/controller/OAuth2Controller.js +133 -0
  13. package/dist/nodefony/controller/ProtectedResourceMetadataController.js +221 -0
  14. package/dist/nodefony/controller/SessionAuthController.js +141 -0
  15. package/dist/nodefony/controller/TokenAuthController.js +113 -0
  16. package/dist/nodefony/controller/TotpController.js +129 -0
  17. package/dist/nodefony/controller/WebAuthnController.js +242 -0
  18. package/dist/nodefony/controller/oauthAuthority.js +74 -0
  19. package/dist/nodefony/decorators/routerDecorators.js +967 -0
  20. package/dist/nodefony/interfaces/IAdminBroker.js +1 -0
  21. package/dist/nodefony/interfaces/IController.js +1 -0
  22. package/dist/nodefony/interfaces/IIdempotencyStore.js +1 -0
  23. package/dist/nodefony/interfaces/IResolver.js +1 -0
  24. package/dist/nodefony/interfaces/IRoute.js +1 -0
  25. package/dist/nodefony/interfaces/index.js +1 -0
  26. package/dist/nodefony/service/AdminBroker.js +106 -0
  27. package/dist/nodefony/service/Eta.js +68 -0
  28. package/dist/nodefony/service/IdempotencyStore.js +136 -0
  29. package/dist/nodefony/service/router.js +243 -0
  30. package/dist/nodefony/src/Controller.js +515 -0
  31. package/dist/nodefony/src/FrameworkAdminApi.js +268 -0
  32. package/dist/nodefony/src/KernelAdminApi.js +1243 -0
  33. package/dist/nodefony/src/PlaygroundAdminApi.js +97 -0
  34. package/dist/nodefony/src/RedisIdempotencyStore.js +254 -0
  35. package/dist/nodefony/src/Resolver.js +416 -0
  36. package/dist/nodefony/src/ResourceController.js +148 -0
  37. package/dist/nodefony/src/Route.js +476 -0
  38. package/dist/nodefony/src/SyslogAdminApi.js +466 -0
  39. package/dist/nodefony/src/Template.js +15 -0
  40. package/dist/nodefony/src/configMutation.js +186 -0
  41. package/dist/nodefony/src/docsReader.js +929 -0
  42. package/dist/nodefony/src/idempotency.js +137 -0
  43. package/dist/nodefony/src/idempotencyGc.js +32 -0
  44. package/dist/nodefony/src/idempotencyStoreRegistry.js +36 -0
  45. package/dist/nodefony/src/scopeCatalog.js +40 -0
  46. package/dist/nodefony/src/syslogFilters.js +51 -0
  47. package/dist/types/index.d.ts +96 -0
  48. package/dist/types/nodefony/config/config.d.ts +41 -0
  49. package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
  50. package/dist/types/nodefony/controller/AdminApiController.d.ts +70 -0
  51. package/dist/types/nodefony/controller/ApiKeyController.d.ts +49 -0
  52. package/dist/types/nodefony/controller/BenchController.d.ts +45 -0
  53. package/dist/types/nodefony/controller/IssuerMetadataController.d.ts +86 -0
  54. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +81 -0
  55. package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +147 -0
  56. package/dist/types/nodefony/controller/SessionAuthController.d.ts +74 -0
  57. package/dist/types/nodefony/controller/TokenAuthController.d.ts +56 -0
  58. package/dist/types/nodefony/controller/TotpController.d.ts +42 -0
  59. package/dist/types/nodefony/controller/WebAuthnController.d.ts +123 -0
  60. package/dist/types/nodefony/controller/oauthAuthority.d.ts +54 -0
  61. package/dist/types/nodefony/decorators/routerDecorators.d.ts +632 -0
  62. package/dist/types/nodefony/interfaces/IAdminBroker.d.ts +79 -0
  63. package/dist/types/nodefony/interfaces/IController.d.ts +38 -0
  64. package/dist/types/nodefony/interfaces/IIdempotencyStore.d.ts +1 -0
  65. package/dist/types/nodefony/interfaces/IResolver.d.ts +25 -0
  66. package/dist/types/nodefony/interfaces/IRoute.d.ts +31 -0
  67. package/dist/types/nodefony/interfaces/index.d.ts +5 -0
  68. package/dist/types/nodefony/service/AdminBroker.d.ts +37 -0
  69. package/dist/types/nodefony/service/Eta.d.ts +25 -0
  70. package/dist/types/nodefony/service/IdempotencyStore.d.ts +45 -0
  71. package/dist/types/nodefony/service/router.d.ts +53 -0
  72. package/dist/types/nodefony/src/Controller.d.ts +193 -0
  73. package/dist/types/nodefony/src/FrameworkAdminApi.d.ts +35 -0
  74. package/dist/types/nodefony/src/KernelAdminApi.d.ts +71 -0
  75. package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +98 -0
  76. package/dist/types/nodefony/src/RedisIdempotencyStore.d.ts +104 -0
  77. package/dist/types/nodefony/src/Resolver.d.ts +165 -0
  78. package/dist/types/nodefony/src/ResourceController.d.ts +171 -0
  79. package/dist/types/nodefony/src/Route.d.ts +192 -0
  80. package/dist/types/nodefony/src/SyslogAdminApi.d.ts +38 -0
  81. package/dist/types/nodefony/src/Template.d.ts +8 -0
  82. package/dist/types/nodefony/src/configMutation.d.ts +109 -0
  83. package/dist/types/nodefony/src/docsReader.d.ts +369 -0
  84. package/dist/types/nodefony/src/idempotency.d.ts +96 -0
  85. package/dist/types/nodefony/src/idempotencyGc.d.ts +30 -0
  86. package/dist/types/nodefony/src/idempotencyStoreRegistry.d.ts +59 -0
  87. package/dist/types/nodefony/src/scopeCatalog.d.ts +26 -0
  88. package/dist/types/nodefony/src/syslogFilters.d.ts +52 -0
  89. package/docs/admin.md +451 -0
  90. package/docs/controller.md +645 -0
  91. package/docs/decorateurs.md +845 -0
  92. package/docs/idempotence.md +741 -0
  93. package/docs/index.md +151 -0
  94. package/docs/routing.md +648 -0
  95. package/docs/templates.md +380 -0
  96. package/package.json +83 -0
@@ -0,0 +1,132 @@
1
+ import Controller from "../src/Controller.js";
2
+ import router_default from "../service/router.js";
3
+ //#region nodefony/controller/BenchController.ts
4
+ /**
5
+ * Corps servi par la cible de banc — **gelé et partagé** : ce qu'on chronomètre
6
+ * est le pipeline, pas la construction d'un objet. Le figer évite qu'une
7
+ * allocation par requête ne s'ajoute au coût mesuré.
8
+ *
9
+ * Sa forme reprend celle du banc comparatif inter-frameworks
10
+ * (`.claude/skills/nodefony-load-test/bench-frameworks/payload.mjs`), pour que
11
+ * Nodefony, bare, Express et Fastify sérialisent **exactement le même corps**.
12
+ * Comparer deux corps différents, c'est comparer deux travaux différents.
13
+ */
14
+ const BENCH_PAYLOAD = Object.freeze({
15
+ byContext: {},
16
+ lastHookRequestId: null,
17
+ hookUser: null,
18
+ lateHookRequestId: null,
19
+ wsHookRequestId: null,
20
+ wsHookHandshakeId: null,
21
+ wsHookFireCount: 0,
22
+ hookCount: 0
23
+ });
24
+ let mounted = false;
25
+ /**
26
+ * Cible de mesure du **pipeline applicatif** — un controller ordinaire qui rend un
27
+ * corps figé, et rien d'autre.
28
+ *
29
+ * **Pourquoi un controller et pas un endpoint du data plane admin** : une route
30
+ * `/nodefony/<ns>/api/*` traverse, en plus du pipeline, la résolution de zone du
31
+ * firewall, un authenticator et le broker d'administration. La mesurer et la
32
+ * comparer à un handler Express nu revient à chronométrer deux choses
33
+ * différentes — et à imputer au framework le coût de son étage d'administration.
34
+ * Ce controller emprunte le chemin d'une route applicative normale : routing,
35
+ * contexte, sérialisation, réponse.
36
+ *
37
+ * **Chemin hors aire admin** : `/nodefony/kernel/bench` (deux segments, sans
38
+ * `/api/`) échappe au pattern `^/nodefony/[^/]+/api(/|$)` de la zone
39
+ * `nodefony-admin`, donc aucune zone ne s'y applique — pas de 401 à mesurer, et
40
+ * pas besoin d'ouvrir une zone dédiée. Il évite aussi le repli SPA mono-segment
41
+ * de Studio (`/nodefony/{page}`).
42
+ *
43
+ * **N'existe que sous `NF_BENCH_ROUTE=1`** : zéro surface en production par
44
+ * défaut. C'est un drapeau d'OUTILLAGE (banc), pas une option applicative — d'où
45
+ * une variable d'environnement plutôt qu'une clé de configuration.
46
+ */
47
+ var BenchController = class extends Controller {
48
+ constructor(context) {
49
+ super("BenchController", context);
50
+ }
51
+ /** Rend le corps figé. Aucune lecture de kernel, aucun I/O, aucune allocation. */
52
+ index() {
53
+ return this.renderJson(BENCH_PAYLOAD);
54
+ }
55
+ /**
56
+ * Dump de la sonde perf in-situ du http-kernel (`NF_PERF_PROBE=1`) : µs
57
+ * moyens par requête des postes enterScope / new HttpContext / leaveScope.
58
+ * Vit ICI (et pas dans `@nodefony/test`, `policy:"dev"`) parce que le décor
59
+ * de mesure est le mono `production` du banc — où le module test n'existe
60
+ * pas. `?reset=1` remet les compteurs à zéro (à faire après le warmup, pour
61
+ * ne pas diluer la mesure avec le code froid).
62
+ */
63
+ probe() {
64
+ const probe = globalThis.__nfPerfProbe;
65
+ if (!probe) return this.renderJson({ enabled: false });
66
+ const url = this.context?.request?.url;
67
+ const reset = url instanceof URL ? url.searchParams.get("reset") === "1" : false;
68
+ const n = probe.count || 1;
69
+ const num = (v) => typeof v === "number" ? v : 0;
70
+ const out = {
71
+ enabled: true,
72
+ count: probe.count,
73
+ avgUs: {
74
+ enterScope: probe.enterScopeNs / n / 1e3,
75
+ ctx: probe.ctxNs / n / 1e3,
76
+ leaveScope: probe.leaveScopeNs / n / 1e3,
77
+ total: (probe.enterScopeNs + probe.ctxNs + probe.leaveScopeNs) / n / 1e3
78
+ },
79
+ ctxSlicesUs: {
80
+ svc: probe.svcNs / n / 1e3,
81
+ ctxTail: (probe.ctxBaseNs - probe.svcNs) / n / 1e3,
82
+ upload: (probe.uploadNs - probe.ctxBaseNs) / n / 1e3,
83
+ reqRes: (probe.reqResNs - probe.uploadNs) / n / 1e3,
84
+ httpTail: (probe.ctxNs - probe.reqResNs) / n / 1e3
85
+ },
86
+ svcSlicesUs: {
87
+ entry: num(probe.svcStartNs) / n / 1e3,
88
+ opts: (num(probe.svcOptsNs) - num(probe.svcStartNs)) / n / 1e3,
89
+ lookups: (num(probe.svcLookupsNs) - num(probe.svcOptsNs)) / n / 1e3,
90
+ event: (num(probe.svcEventNs) - num(probe.svcLookupsNs)) / n / 1e3,
91
+ tail: (probe.svcNs - num(probe.svcEventNs)) / n / 1e3
92
+ },
93
+ reqSlicesUs: {
94
+ entry: (num(probe.reqStartNs) - probe.uploadNs) / n / 1e3,
95
+ inits: (num(probe.reqProxyNs) - num(probe.reqStartNs)) / n / 1e3,
96
+ url: (num(probe.reqUrlNs) - num(probe.reqProxyNs)) / n / 1e3,
97
+ query: (num(probe.reqQueryNs) - num(probe.reqUrlNs)) / n / 1e3,
98
+ meta: (num(probe.reqMetaNs) - num(probe.reqQueryNs)) / n / 1e3,
99
+ accept: (num(probe.reqAcceptNs) - num(probe.reqMetaNs)) / n / 1e3,
100
+ resp: (probe.reqResNs - num(probe.reqAcceptNs)) / n / 1e3
101
+ }
102
+ };
103
+ if (reset) for (const k of Object.keys(probe)) {
104
+ if (k === "t0") continue;
105
+ if (typeof probe[k] === "number") probe[k] = 0;
106
+ }
107
+ return this.renderJson(out);
108
+ }
109
+ };
110
+ /**
111
+ * Monte la route de banc — appelée par le module framework, uniquement si
112
+ * `NF_BENCH_ROUTE=1`.
113
+ */
114
+ function mountBenchRoutes(frameworkModule) {
115
+ if (mounted) return;
116
+ router_default.createRoute("framework.bench", {
117
+ path: "/nodefony/kernel/bench",
118
+ constructor: BenchController,
119
+ classMethod: "index",
120
+ requirements: { methods: ["GET"] }
121
+ });
122
+ router_default.createRoute("framework.bench.probe", {
123
+ path: "/nodefony/kernel/bench/probe",
124
+ constructor: BenchController,
125
+ classMethod: "probe",
126
+ requirements: { methods: ["GET"] }
127
+ });
128
+ if (!Object.prototype.hasOwnProperty.call(BenchController.prototype, "module")) router_default.setController(BenchController, frameworkModule);
129
+ mounted = true;
130
+ }
131
+ //#endregion
132
+ export { BenchController as default, mountBenchRoutes };
@@ -0,0 +1,148 @@
1
+ import Controller from "../src/Controller.js";
2
+ import router_default from "../service/router.js";
3
+ import { askedAuthority, onDeclaredAuthority } from "./oauthAuthority.js";
4
+ import { JWKS_PATH, authorizationServerMetadataPath, buildAuthorizationServerMetadata } from "nodefony";
5
+ //#region nodefony/controller/IssuerMetadataController.ts
6
+ let mounted = false;
7
+ /**
8
+ * Les deux documents qui rendent une application Nodefony **découvrable** comme
9
+ * émetteur de jetons — RFC 8414 :
10
+ *
11
+ * - `GET /.well-known/oauth-authorization-server` — métadonnées d'émetteur
12
+ * (`issuer`, `jwks_uri`) ; c'est le seul document qu'un tiers sait trouver,
13
+ * puisqu'il ne le lit pas mais le CONSTRUIT depuis l'identifiant d'émetteur
14
+ * par insertion de chemin (§3.1).
15
+ * - `GET /.well-known/jwks.json` — le jeu de clés publiques lui-même.
16
+ *
17
+ * ## Pourquoi ces routes existent
18
+ *
19
+ * Sans elles, `getPublicJWKS()` n'a qu'un usage interne : Nodefony vérifie ses
20
+ * propres jetons en mémoire. Un tiers — une autre application Nodefony, un
21
+ * agent, un service — ne peut donc PAS valider une signature émise ici, même en
22
+ * connaissant l'URL des clés : il n'y en a pas. C'est le symétrique exact du
23
+ * rôle serveur de ressource : là, on refusait en disant où aller ; ici, on
24
+ * permet à quelqu'un d'autre de vérifier ce qu'on a signé.
25
+ *
26
+ * ## Ce qui les monte, et ce qui les retient
27
+ *
28
+ * Montées par le module framework UNIQUEMENT si `tokenService.publishedIssuer()`
29
+ * rend une valeur — c'est-à-dire si le JWT est actif, `security.jwt.jwks` vrai,
30
+ * et l'émetteur écrit sous forme d'URL https. Sinon les routes **n'existent
31
+ * pas** (`404`, zéro surface) : un document creux apprendrait à un client qu'il
32
+ * y a quelque chose à découvrir sans lui donner de quoi le faire.
33
+ *
34
+ * Le montage ne suffit pas : servir encore exige que la requête entre par
35
+ * **l'autorité de l'émetteur** ({@link IssuerMetadataController.metadata}). Un
36
+ * serveur écoute plusieurs adresses ; l'émetteur n'en désigne qu'une.
37
+ *
38
+ * `bypassFirewall: true` : ces documents sont publics par construction. Un JWKS
39
+ * derrière une authentification serait un non-sens — il sert précisément à
40
+ * vérifier les jetons de ceux qui ne sont pas encore authentifiés. Ils ne
41
+ * révèlent rien de secret : des clés PUBLIQUES et un identifiant, jamais `d`.
42
+ *
43
+ * @remarks Le chemin des métadonnées est **dérivé** de l'émetteur
44
+ * (`authorizationServerMetadataPath`) et non écrit en dur : un émetteur porteur
45
+ * d'un chemin se publie SOUS ce chemin (RFC 8414 §3.1), et c'est la même
46
+ * fonction qui sert au lecteur, dans `@nodefony/security`.
47
+ */
48
+ var IssuerMetadataController = class extends Controller {
49
+ constructor(context) {
50
+ super("IssuerMetadataController", context);
51
+ }
52
+ /** Résout le service d'émission — absent = capacité éteinte. */
53
+ #publisher() {
54
+ return this.get("tokenService") ?? null;
55
+ }
56
+ /**
57
+ * 🔴 Ces documents ne sont servis QUE sur l'autorité de l'émetteur.
58
+ *
59
+ * Un serveur écoute presque toujours plusieurs autorités — deux ports en
60
+ * développement, plusieurs hôtes virtuels en production. Servir le document
61
+ * sur toutes revient à répondre « le serveur d'autorisation, c'est ici » à un
62
+ * client qui interroge une adresse dont l'émetteur ne se réclame pas : il
63
+ * DOIT alors rejeter le document (RFC 8414 §3.3 exige l'égalité stricte de
64
+ * `issuer`), et un client réel s'arrête là — il ne cherche pas ailleurs.
65
+ * Vécu : un client MCP sondant `http://localhost:5151` recevait le document
66
+ * de `https://localhost:5152` et déclarait la connexion en échec, alors que
67
+ * `404` l'aurait simplement fait continuer sans authentification.
68
+ *
69
+ * La comparaison porte sur l'**autorité demandée** (hôte + port, tels que le
70
+ * client les a écrits), jamais sur le schéma : derrière un relais qui termine
71
+ * TLS, le processus voit `http` pour une requête que le client a faite en
72
+ * `https`, et refuser là-dessus fermerait le document en production. Le port
73
+ * par défaut est normalisé par `URL` — `app.example:443` et `app.example`
74
+ * désignent le même serveur et doivent se valoir.
75
+ *
76
+ * @param issuer - émetteur canonique publié
77
+ * @returns `true` si la requête entre par l'autorité de l'émetteur
78
+ */
79
+ #onIssuerAuthority(issuer) {
80
+ return onDeclaredAuthority(askedAuthority(this.request?.headers), issuer);
81
+ }
82
+ /**
83
+ * `GET /.well-known/oauth-authorization-server` — métadonnées d'émetteur.
84
+ *
85
+ * @returns le document RFC 8414, ou `404` si la publication a été coupée
86
+ * après le montage des routes
87
+ */
88
+ async metadata() {
89
+ const issuer = this.#publisher()?.publishedIssuer() ?? null;
90
+ if (!issuer || !this.#onIssuerAuthority(issuer)) return this.renderJson({ error: "not_found" }, 404);
91
+ let document;
92
+ try {
93
+ document = buildAuthorizationServerMetadata({ issuer });
94
+ } catch (error) {
95
+ this.log(`métadonnées d'émetteur impubliables : ${error.message}`, "CRITIC");
96
+ return this.renderJson({ error: "server_error" }, 500);
97
+ }
98
+ return this.renderJson(document, 200, { "Cache-Control": "public, max-age=3600" });
99
+ }
100
+ /**
101
+ * `GET /.well-known/jwks.json` — clés publiques de signature.
102
+ *
103
+ * @returns le JWK Set (paramètres publics seuls)
104
+ */
105
+ async jwks() {
106
+ const publisher = this.#publisher();
107
+ const issuer = publisher?.publishedIssuer() ?? null;
108
+ if (!publisher || !issuer || !this.#onIssuerAuthority(issuer)) return this.renderJson({ error: "not_found" }, 404);
109
+ let keys;
110
+ try {
111
+ keys = await publisher.getPublicJWKS();
112
+ } catch (error) {
113
+ this.log(`JWKS illisible : ${error.message}`, "CRITIC");
114
+ return this.renderJson({ error: "server_error" }, 500);
115
+ }
116
+ return this.renderJson(keys, 200, { "Cache-Control": "public, max-age=300" });
117
+ }
118
+ };
119
+ /**
120
+ * Monte les deux documents d'émetteur — appelé par le module framework à
121
+ * `onKernelReady`, seulement si `tokenService.publishedIssuer()` répond.
122
+ *
123
+ * @param frameworkModule - module porteur des routes
124
+ * @param issuer - émetteur canonique, qui DÉTERMINE le chemin des métadonnées
125
+ */
126
+ function mountIssuerMetadataRoutes(frameworkModule, issuer) {
127
+ if (mounted) return;
128
+ const routes = [[
129
+ "security.issuer.metadata",
130
+ authorizationServerMetadataPath(issuer),
131
+ "metadata"
132
+ ], [
133
+ "security.issuer.jwks",
134
+ JWKS_PATH,
135
+ "jwks"
136
+ ]];
137
+ for (const [name, path, classMethod] of routes) router_default.createRoute(name, {
138
+ path,
139
+ constructor: IssuerMetadataController,
140
+ classMethod,
141
+ requirements: { methods: ["GET"] },
142
+ bypassFirewall: true
143
+ });
144
+ if (!Object.prototype.hasOwnProperty.call(IssuerMetadataController.prototype, "module")) router_default.setController(IssuerMetadataController, frameworkModule);
145
+ mounted = true;
146
+ }
147
+ //#endregion
148
+ export { IssuerMetadataController as default, mountIssuerMetadataRoutes };
@@ -0,0 +1,133 @@
1
+ import Controller from "../src/Controller.js";
2
+ import router_default from "../service/router.js";
3
+ //#region nodefony/controller/OAuth2Controller.ts
4
+ const STATE_KEY = "oauth2:state";
5
+ const VERIFIER_KEY = "oauth2:verifier";
6
+ const PROVIDER_KEY = "oauth2:provider";
7
+ let mounted = false;
8
+ /**
9
+ * Endpoints HTTP du **social login OAuth 2.0** (P6 J9) — adaptateurs MINCES
10
+ * au-dessus du service `oauth2` (`@nodefony/security`) :
11
+ *
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.
15
+ * - `GET /nodefony/security/api/oauth2/{provider}/callback` — valide le `state`
16
+ * (anti-CSRF), échange le `code`, provisionne le Shadow User et OUVRE la
17
+ * session BFF (302 vers `successRedirect`).
18
+ *
19
+ * Montés UNIQUEMENT si le service `oauth2` existe (social login activé) — 404
20
+ * sinon, zéro surface.
21
+ *
22
+ * @remarks `bypassFirewall` : ces routes SONT (ou précèdent) le mécanisme d'auth
23
+ * (l'utilisateur est anonyme pendant tout l'aller-retour). Le firewall, sur l'aire
24
+ * data plane, déclencherait un deadlock identique au login BFF / WebAuthn login.
25
+ * La session anonyme ne porte que `state`/`verifier` ; `establishSessionFor`
26
+ * régénère l'ID (anti-fixation) à la promotion.
27
+ */
28
+ var OAuth2Controller = class extends Controller {
29
+ constructor(context) {
30
+ super("OAuth2Controller", context);
31
+ }
32
+ /**
33
+ * Liste PUBLIQUE des fournisseurs activés (configurés ET connus du registre).
34
+ * Consommé par l'UI de login pour n'afficher QUE les boutons opérationnels —
35
+ * jamais de bouton mort. Aucun secret n'est exposé (uniquement les noms).
36
+ */
37
+ providers() {
38
+ const svc = this.#service();
39
+ return this.renderJson({ providers: svc ? svc.listProviders() : [] });
40
+ }
41
+ /** Démarre le flux : URL d'autorisation + état anti-replay en session, 302. */
42
+ async authorize(provider) {
43
+ const svc = this.#service();
44
+ const flow = this.#flow();
45
+ if (!svc || !flow) return this.renderJson({ error: "OAuth unavailable" }, 503);
46
+ if (!svc.listProviders().includes(provider)) return this.renderJson({ error: "Unknown provider" }, 404);
47
+ const auth = await svc.createAuthorization(provider);
48
+ const session = await flow.ensureSession(this.context);
49
+ if (!session) return this.renderJson({ error: "Session unavailable" }, 503);
50
+ session.set(STATE_KEY, auth.state);
51
+ session.set(VERIFIER_KEY, auth.codeVerifier);
52
+ session.set(PROVIDER_KEY, provider);
53
+ await session.save();
54
+ return this.redirect(auth.url, 302);
55
+ }
56
+ /** Valide `state`, échange le `code`, ouvre la session BFF (302). */
57
+ async callback(provider) {
58
+ const svc = this.#service();
59
+ const flow = this.#flow();
60
+ if (!svc || !flow) return this.renderJson({ error: "OAuth unavailable" }, 503);
61
+ const { success, failure } = svc.getRedirects(provider);
62
+ const session = this.context.session;
63
+ const expectedState = session?.get(STATE_KEY);
64
+ const storedVerifier = session?.get(VERIFIER_KEY);
65
+ const expectedProvider = session?.get(PROVIDER_KEY);
66
+ session?.set(STATE_KEY, null);
67
+ session?.set(VERIFIER_KEY, null);
68
+ session?.set(PROVIDER_KEY, null);
69
+ await session?.save();
70
+ const code = this.#queryString("code");
71
+ const returnedState = this.#queryString("state");
72
+ const returnedIss = this.#queryString("iss");
73
+ if (code === null || returnedState === null || typeof expectedState !== "string" || returnedState !== expectedState || expectedProvider !== provider) return this.redirect(failure, 302);
74
+ try {
75
+ const { identifier } = await svc.exchangeAndProvision(provider, code, typeof storedVerifier === "string" ? storedVerifier : null, returnedIss);
76
+ await flow.establishSessionFor(this.context, identifier, "oauth");
77
+ return this.redirect(success, 302);
78
+ } catch {
79
+ return this.redirect(failure, 302);
80
+ }
81
+ }
82
+ #service() {
83
+ const svc = this.get("oauth2");
84
+ return svc && svc.isEnabled() ? svc : null;
85
+ }
86
+ #flow() {
87
+ return this.get("authFlow") ?? null;
88
+ }
89
+ /** Lit un paramètre de query string (GET), ou `null`. */
90
+ #queryString(key) {
91
+ const v = (this.queryGet ?? {})[key];
92
+ return typeof v === "string" && v.length > 0 ? v : null;
93
+ }
94
+ };
95
+ /**
96
+ * Monte les routes du social login OAuth — appelé par le module framework à
97
+ * `onKernelReady`, seulement si le service `oauth2` est présent.
98
+ */
99
+ function mountOAuth2Routes(frameworkModule) {
100
+ if (mounted) return;
101
+ const base = "/nodefony/security/api/oauth2";
102
+ const routes = [
103
+ [
104
+ "security.oauth2.providers",
105
+ `${base}/providers`,
106
+ "GET",
107
+ "providers"
108
+ ],
109
+ [
110
+ "security.oauth2.authorize",
111
+ `${base}/{provider}/authorize`,
112
+ "GET",
113
+ "authorize"
114
+ ],
115
+ [
116
+ "security.oauth2.callback",
117
+ `${base}/{provider}/callback`,
118
+ "GET",
119
+ "callback"
120
+ ]
121
+ ];
122
+ for (const [name, path, method, classMethod] of routes) router_default.createRoute(name, {
123
+ path,
124
+ constructor: OAuth2Controller,
125
+ classMethod,
126
+ requirements: { methods: [method] },
127
+ bypassFirewall: true
128
+ });
129
+ if (!Object.prototype.hasOwnProperty.call(OAuth2Controller.prototype, "module")) router_default.setController(OAuth2Controller, frameworkModule);
130
+ mounted = true;
131
+ }
132
+ //#endregion
133
+ export { OAuth2Controller as default, mountOAuth2Routes };
@@ -0,0 +1,221 @@
1
+ import Controller from "../src/Controller.js";
2
+ import router_default from "../service/router.js";
3
+ import { askedAuthority, onDeclaredAuthority } from "./oauthAuthority.js";
4
+ import { buildProtectedResourceMetadata, canonicalResourceUri, protectedResourceMetadataPath } from "nodefony";
5
+ //#region nodefony/controller/ProtectedResourceMetadataController.ts
6
+ let mounted = false;
7
+ /**
8
+ * Ce qui a été PUBLIÉ au montage — la même liste qui a décidé des chemins.
9
+ *
10
+ * Le controller la relit plutôt que de réinterroger les services à chaque
11
+ * requête, pour deux raisons. La première est la justesse : les routes sont
12
+ * montées une fois, à partir de cette liste ; servir un document composé
13
+ * d'autre chose ferait répondre une ressource à un chemin dérivé d'une autre.
14
+ * La seconde est le coût : balayer le conteneur sur le chemin de la requête
15
+ * pour un document qui ne change qu'au redéploiement est exactement la dépense
16
+ * que la règle de perf proscrit.
17
+ */
18
+ let published = Object.freeze([]);
19
+ /**
20
+ * Le document qui rend un refus APPRENABLE — RFC 9728.
21
+ *
22
+ * ## Le trou que ce controller ferme
23
+ *
24
+ * Un `401` d'une zone qui déclare sa ressource porte désormais un défi complet :
25
+ *
26
+ * ```
27
+ * WWW-Authenticate: Bearer resource_metadata="https://app/.well-known/oauth-protected-resource/api"
28
+ * ```
29
+ *
30
+ * Sans ce controller, cette URL rendait **404** : un pointeur syntaxiquement
31
+ * conforme qui ne mène nulle part. Le client le lit, le suit, trouve une erreur,
32
+ * et conclut qu'il n'y a pas d'autorisation ici — c'est-à-dire exactement
33
+ * l'inverse de ce que le refus voulait lui apprendre. Seul `@nodefony/devkit`
34
+ * publiait un document, et uniquement pour SA porte MCP.
35
+ *
36
+ * ## Une route par CHEMIN, un document par AUTORITÉ
37
+ *
38
+ * La RFC 9728 §3.1 **insère** le chemin de la ressource dans l'URL bien connue
39
+ * (« Using path components enables supporting multiple resources per host ») :
40
+ * deux ressources de chemins distincts ont donc deux URL distinctes, et l'on
41
+ * monte une route par chemin. Deux ressources qui partagent le chemin mais pas
42
+ * l'autorité (`https://a.example/api` et `https://b.example/api`) partagent en
43
+ * revanche la même route : c'est alors l'autorité demandée qui départage, à la
44
+ * requête.
45
+ *
46
+ * ## Ce qui retient la publication
47
+ *
48
+ * Rien n'est monté si le service `firewall` est absent, ou s'il ne déclare
49
+ * aucune ressource : **`404`, zéro surface**. Un document creux apprendrait à un
50
+ * client qu'il y a quelque chose à découvrir sans lui donner de quoi le faire —
51
+ * et la spécification MCP l'interdit explicitement (« MUST include […] at least
52
+ * one authorization server »).
53
+ *
54
+ * `bypassFirewall: true` : ce document est public par construction. Le placer
55
+ * derrière l'authentification serait circulaire — il sert précisément à
56
+ * expliquer comment s'authentifier à qui ne l'est pas encore. Il ne révèle rien
57
+ * de secret : une URI publique, des émetteurs, des scopes.
58
+ *
59
+ * @see references/rfc/ietf/rfc9728.txt — métadonnées de la ressource protégée
60
+ * @see references/rfc/ietf/rfc8707.txt — l'audience, qui LIE un jeton à CE service
61
+ */
62
+ var ProtectedResourceMetadataController = class extends Controller {
63
+ constructor(context) {
64
+ super("ProtectedResourceMetadataController", context);
65
+ }
66
+ /** Chemin bien connu demandé, tel que le Router l'a matché. */
67
+ #askedPath() {
68
+ const req = this.request;
69
+ const p = req?.pathname;
70
+ if (typeof p === "string") return p;
71
+ const url = req?.url;
72
+ if (url instanceof URL) return url.pathname;
73
+ if (typeof url === "string") try {
74
+ return new URL(url, "http://localhost").pathname;
75
+ } catch {
76
+ return null;
77
+ }
78
+ return null;
79
+ }
80
+ /**
81
+ * `GET /.well-known/oauth-protected-resource/<chemin>` — RFC 9728 §3.
82
+ *
83
+ * 🔴 Le document n'est servi que sur l'autorité de SA ressource. Un client
84
+ * conforme rejette un document dont la `resource` ne correspond pas à l'URI
85
+ * qu'il interrogeait (§3.3) — et un client réel s'arrête là au lieu de
86
+ * continuer sans authentification. C'est la faille déjà vécue sur le document
87
+ * d'émetteur, transposée : la règle est partagée (`onDeclaredAuthority`).
88
+ *
89
+ * @returns le document JSON, ou `404` si aucune ressource déclarée ne
90
+ * correspond au chemin ET à l'autorité demandés
91
+ */
92
+ async metadata() {
93
+ const declared = published;
94
+ const path = this.#askedPath();
95
+ const asked = askedAuthority(this.request?.headers);
96
+ if (!path || declared.length === 0) return this.renderJson({ error: "not_found" }, 404);
97
+ let match = null;
98
+ for (const entry of declared) {
99
+ let canonical;
100
+ try {
101
+ canonical = canonicalResourceUri(entry.resource);
102
+ } catch {
103
+ continue;
104
+ }
105
+ if (protectedResourceMetadataPath(canonical) !== path) continue;
106
+ if (!onDeclaredAuthority(asked, canonical)) continue;
107
+ match = entry;
108
+ break;
109
+ }
110
+ if (!match) return this.renderJson({ error: "not_found" }, 404);
111
+ let document;
112
+ try {
113
+ document = buildProtectedResourceMetadata(match);
114
+ } catch (error) {
115
+ this.log(`métadonnées de ressource protégée impubliables : ${error.message}`, "CRITIC");
116
+ return this.renderJson({ error: "server_error" }, 500);
117
+ }
118
+ return this.renderJson(document, 200, { "Cache-Control": "public, max-age=3600" });
119
+ }
120
+ };
121
+ /**
122
+ * Rassemble ce que TOUS les services du conteneur déclarent protéger.
123
+ *
124
+ * ⭐ **Pourquoi balayer plutôt que demander à un service nommé.** Les ressources
125
+ * protégées d'une application n'ont pas une source unique : le pare-feu en
126
+ * déclare (une zone qui exige un jeton tiers), et un module peut en déclarer une
127
+ * de son propre chef — la porte MCP de `@nodefony/devkit` en est le premier cas.
128
+ * Interroger le seul `firewall` aurait laissé chaque autre module monter SON
129
+ * document, c'est-à-dire recopier cette règle autant de fois qu'il y a de
130
+ * sources, avec la collision de chemin en prime : `Router.createRoute` empile
131
+ * sans vérifier, et la seconde route ne serait jamais atteinte.
132
+ *
133
+ * Un **registre** aurait fait le même travail, au prix d'une dépendance d'ordre :
134
+ * un module qui s'enregistre après le `onKernelReady` du framework ne serait
135
+ * jamais publié, et rien ne le dirait. Le balayage n'a pas ce défaut — les
136
+ * services sont tous en place bien avant, et la question se pose au moment où
137
+ * l'on monte.
138
+ *
139
+ * Le contrat est **structurel** : porter la méthode suffit, aucun import, aucune
140
+ * interface à implémenter formellement — le même couplage que `tokenService` ou
141
+ * `adminBroker`.
142
+ *
143
+ * @param container - conteneur de services de l'application
144
+ * @returns les ressources déclarées, dans l'ordre des services
145
+ */
146
+ function collectProtectedResources(container) {
147
+ const collected = [];
148
+ for (const name of container.keys()) {
149
+ const service = container.get(name);
150
+ if (typeof service?.publishedProtectedResources !== "function") continue;
151
+ let declared;
152
+ try {
153
+ declared = service.publishedProtectedResources();
154
+ } catch {
155
+ continue;
156
+ }
157
+ for (const entry of declared) collected.push(entry);
158
+ }
159
+ return collected;
160
+ }
161
+ /**
162
+ * Chemins bien connus à monter pour un jeu de ressources déclarées.
163
+ *
164
+ * Fonction **pure**, exportée pour être éprouvée sans serveur : c'est elle qui
165
+ * porte les deux décisions qui font la différence entre un document servi et un
166
+ * `404` — la dérivation du chemin (insertion RFC 9728 §3.1, jamais une
167
+ * concaténation) et la déduplication.
168
+ *
169
+ * Deux zones peuvent parfaitement déclarer la même ressource (typiquement une
170
+ * zone HTTP et son pendant WebSocket) ; deux hôtes virtuels peuvent en déclarer
171
+ * deux différentes sous le même chemin. Dans les deux cas il ne faut monter
172
+ * qu'**une** route : `Router.createRoute` empile sans vérifier, et la seconde
173
+ * ne serait jamais atteinte — une collision parfaitement silencieuse.
174
+ *
175
+ * @param resources - ressources déclarées par le publieur
176
+ * @returns les chemins distincts à servir, dans l'ordre de déclaration
177
+ */
178
+ function protectedResourceRoutePaths(resources) {
179
+ const paths = [];
180
+ const seen = /* @__PURE__ */ new Set();
181
+ for (const entry of resources) {
182
+ let canonical;
183
+ try {
184
+ canonical = canonicalResourceUri(entry.resource);
185
+ } catch {
186
+ continue;
187
+ }
188
+ const path = protectedResourceMetadataPath(canonical);
189
+ if (seen.has(path)) continue;
190
+ seen.add(path);
191
+ paths.push(path);
192
+ }
193
+ return paths;
194
+ }
195
+ /**
196
+ * Monte les documents de ressource protégée — appelé par le module framework à
197
+ * `onKernelReady`, seulement si un publieur déclare au moins une ressource.
198
+ *
199
+ * @param frameworkModule - module porteur des routes
200
+ * @param resources - ressources déclarées, qui DÉTERMINENT les chemins montés
201
+ * @returns le nombre de routes montées
202
+ */
203
+ function mountProtectedResourceRoutes(frameworkModule, resources) {
204
+ if (mounted) return 0;
205
+ const paths = protectedResourceRoutePaths(resources);
206
+ if (paths.length === 0) return 0;
207
+ published = resources;
208
+ let index = 0;
209
+ for (const path of paths) router_default.createRoute(`security.resource.metadata.${index++}`, {
210
+ path,
211
+ constructor: ProtectedResourceMetadataController,
212
+ classMethod: "metadata",
213
+ requirements: { methods: ["GET"] },
214
+ bypassFirewall: true
215
+ });
216
+ if (!Object.prototype.hasOwnProperty.call(ProtectedResourceMetadataController.prototype, "module")) router_default.setController(ProtectedResourceMetadataController, frameworkModule);
217
+ mounted = true;
218
+ return paths.length;
219
+ }
220
+ //#endregion
221
+ export { collectProtectedResources, ProtectedResourceMetadataController as default, mountProtectedResourceRoutes, protectedResourceRoutePaths };