@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.
- package/README.md +4 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/index.js +15 -20
- package/dist/nodefony/config/config.js +1 -2
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/controller/AdminApiController.js +45 -11
- package/dist/nodefony/controller/ApiKeyController.js +18 -10
- package/dist/nodefony/controller/BenchController.js +1 -2
- package/dist/nodefony/controller/IssuerMetadataController.js +1 -2
- package/dist/nodefony/controller/LoginPageController.js +117 -0
- package/dist/nodefony/controller/OAuth2Controller.js +133 -17
- package/dist/nodefony/controller/ProtectedResourceMetadataController.js +1 -2
- package/dist/nodefony/controller/SessionAuthController.js +50 -13
- package/dist/nodefony/controller/TokenAuthController.js +14 -8
- package/dist/nodefony/controller/TotpController.js +7 -8
- package/dist/nodefony/controller/WebAuthnController.js +28 -12
- package/dist/nodefony/controller/oauthAuthority.js +1 -2
- package/dist/nodefony/decorators/routerDecorators.js +268 -65
- package/dist/nodefony/service/AdminBroker.js +4 -6
- package/dist/nodefony/service/Eta.js +1 -2
- package/dist/nodefony/service/IdempotencyStore.js +4 -6
- package/dist/nodefony/service/router.js +97 -34
- package/dist/nodefony/src/Controller.js +71 -43
- package/dist/nodefony/src/FrameworkAdminApi.js +33 -11
- package/dist/nodefony/src/KernelAdminApi.js +82 -18
- package/dist/nodefony/src/PlaygroundAdminApi.js +72 -3
- package/dist/nodefony/src/RedisIdempotencyStore.js +46 -4
- package/dist/nodefony/src/Resolver.js +105 -36
- package/dist/nodefony/src/ResourceController.js +2 -3
- package/dist/nodefony/src/Route.js +54 -8
- package/dist/nodefony/src/SecurityApiController.js +23 -0
- package/dist/nodefony/src/SyslogAdminApi.js +1 -2
- package/dist/nodefony/src/Template.js +1 -2
- package/dist/nodefony/src/configMutation.js +1 -2
- package/dist/nodefony/src/docsReader.js +39 -23
- package/dist/nodefony/src/idempotency.js +14 -22
- package/dist/nodefony/src/idempotencyGc.js +1 -2
- package/dist/nodefony/src/idempotencyStoreRegistry.js +1 -2
- package/dist/nodefony/src/loginPage.js +430 -0
- package/dist/nodefony/src/requireAuthentication.js +35 -0
- package/dist/nodefony/src/routeSchema.js +81 -0
- package/dist/nodefony/src/scopeCatalog.js +1 -2
- package/dist/nodefony/src/singletonGuard.js +1 -2
- package/dist/nodefony/src/syslogFilters.js +1 -2
- package/dist/types/index.d.ts +8 -12
- package/dist/types/nodefony/controller/AdminApiController.d.ts +12 -0
- package/dist/types/nodefony/controller/ApiKeyController.d.ts +2 -2
- package/dist/types/nodefony/controller/LoginPageController.d.ts +32 -0
- package/dist/types/nodefony/controller/OAuth2Controller.d.ts +67 -8
- package/dist/types/nodefony/controller/SessionAuthController.d.ts +46 -8
- package/dist/types/nodefony/controller/TokenAuthController.d.ts +6 -4
- package/dist/types/nodefony/controller/TotpController.d.ts +2 -2
- package/dist/types/nodefony/controller/WebAuthnController.d.ts +12 -4
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +221 -35
- package/dist/types/nodefony/interfaces/IController.d.ts +2 -3
- package/dist/types/nodefony/interfaces/IRoute.d.ts +1 -2
- package/dist/types/nodefony/service/router.d.ts +40 -1
- package/dist/types/nodefony/src/Controller.d.ts +54 -17
- package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +12 -0
- package/dist/types/nodefony/src/Resolver.d.ts +44 -1
- package/dist/types/nodefony/src/ResourceController.d.ts +1 -1
- package/dist/types/nodefony/src/Route.d.ts +21 -1
- package/dist/types/nodefony/src/SecurityApiController.d.ts +17 -0
- package/dist/types/nodefony/src/docsReader.d.ts +10 -6
- package/dist/types/nodefony/src/idempotency.d.ts +12 -10
- package/dist/types/nodefony/src/loginPage.d.ts +144 -0
- package/dist/types/nodefony/src/requireAuthentication.d.ts +29 -0
- package/dist/types/nodefony/src/routeSchema.d.ts +112 -0
- package/docs/admin.md +83 -78
- package/docs/controller.md +112 -111
- package/docs/decorateurs.md +212 -163
- package/docs/idempotence.md +137 -135
- package/docs/routing.md +96 -95
- package/docs/templates.md +55 -55
- package/package.json +10 -13
- 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
|
-
|
|
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
|
|
13
|
-
* pose `state`+`code_verifier` en session (anonyme),
|
|
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
|
|
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
|
-
|
|
89
|
-
|
|
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.
|
|
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(
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
47
|
-
name: me.
|
|
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.
|
|
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?.
|
|
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.
|
|
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.
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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 };
|