@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.
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +9 -5
- package/dist/nodefony/controller/AdminApiController.js +7 -4
- package/dist/nodefony/controller/ApiKeyController.js +3 -3
- package/dist/nodefony/controller/LoginPageController.js +124 -0
- package/dist/nodefony/controller/OAuth2Controller.js +131 -14
- package/dist/nodefony/controller/SessionAuthController.js +37 -9
- package/dist/nodefony/controller/TokenAuthController.js +11 -4
- package/dist/nodefony/controller/TotpController.js +5 -5
- package/dist/nodefony/controller/WebAuthnController.js +26 -9
- package/dist/nodefony/decorators/routerDecorators.js +114 -13
- package/dist/nodefony/service/AdminBroker.js +2 -2
- package/dist/nodefony/service/IdempotencyStore.js +2 -2
- package/dist/nodefony/service/router.js +51 -22
- package/dist/nodefony/src/Controller.js +49 -4
- package/dist/nodefony/src/FrameworkAdminApi.js +2 -1
- package/dist/nodefony/src/KernelAdminApi.js +76 -10
- package/dist/nodefony/src/Resolver.js +39 -2
- package/dist/nodefony/src/Route.js +52 -5
- package/dist/nodefony/src/SecurityApiController.js +24 -0
- package/dist/nodefony/src/docsReader.js +38 -21
- package/dist/nodefony/src/idempotency.js +13 -20
- package/dist/nodefony/src/loginPage.js +431 -0
- package/dist/nodefony/src/requireAuthentication.js +36 -0
- package/dist/types/index.d.ts +4 -3
- 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 +39 -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 +88 -3
- package/dist/types/nodefony/interfaces/IRoute.d.ts +1 -2
- package/dist/types/nodefony/service/router.d.ts +13 -0
- package/dist/types/nodefony/src/Controller.d.ts +40 -1
- package/dist/types/nodefony/src/Resolver.d.ts +19 -0
- package/dist/types/nodefony/src/Route.d.ts +18 -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/docs/admin.md +28 -23
- package/docs/controller.md +76 -74
- package/docs/decorateurs.md +69 -67
- package/docs/idempotence.md +30 -28
- package/docs/routing.md +52 -52
- package/docs/templates.md +11 -11
- package/package.json +11 -11
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Module } from "nodefony";
|
|
2
2
|
import type { ContextType } from "@nodefony/http";
|
|
3
|
-
import
|
|
3
|
+
import SecurityApiController from "../src/SecurityApiController.js";
|
|
4
4
|
/**
|
|
5
5
|
* Vue MINIMALE du service `oauth2` (`@nodefony/security`) — couplage par NOM
|
|
6
6
|
* (framework ne dépend pas de security). Contrat structurel imposé par
|
|
@@ -24,13 +24,31 @@ export interface IOAuth2Service {
|
|
|
24
24
|
success: string;
|
|
25
25
|
failure: string;
|
|
26
26
|
};
|
|
27
|
-
|
|
27
|
+
/** `options` : une version de security qui ne le lit pas l'ignore. */
|
|
28
|
+
createAuthorization(provider: string, options?: {
|
|
29
|
+
theme?: "light" | "dark";
|
|
30
|
+
}): Promise<{
|
|
28
31
|
url: string;
|
|
29
32
|
state: string;
|
|
30
33
|
codeVerifier: string | null;
|
|
31
34
|
}>;
|
|
32
35
|
exchangeAndProvision(provider: string, code: string, codeVerifier: string | null, returnedIss: string | null): Promise<{
|
|
33
36
|
identifier: string;
|
|
37
|
+
logoutHint?: unknown;
|
|
38
|
+
}>;
|
|
39
|
+
/**
|
|
40
|
+
* Retient en session de quoi fermer plus tard la session du fournisseur
|
|
41
|
+
* (RP-Initiated Logout). Optionnel : la clé et la forme appartiennent au
|
|
42
|
+
* service, ce contrôleur ne fait que transmettre.
|
|
43
|
+
*/
|
|
44
|
+
rememberLogout?(session: IOAuth2Session, hint: unknown): void;
|
|
45
|
+
/**
|
|
46
|
+
* Déconnexion demandée par le fournisseur sur le canal arrière (OpenID
|
|
47
|
+
* Connect Back-Channel Logout). Optionnel, même raison que
|
|
48
|
+
* {@link listDisplayProviders} : absent, la route répond 501.
|
|
49
|
+
*/
|
|
50
|
+
backchannelLogout?(provider: string, logoutToken: string): Promise<{
|
|
51
|
+
outcome: "unsupported" | "refused" | "done";
|
|
34
52
|
}>;
|
|
35
53
|
}
|
|
36
54
|
/** Vue minimale d'une session — porte l'état du flux OAuth (anti-CSRF/anti-replay). */
|
|
@@ -50,16 +68,45 @@ export interface IOAuth2BffFlow {
|
|
|
50
68
|
/** Garantit une session (anonyme) pour porter `state`/`code_verifier`. */
|
|
51
69
|
ensureSession(context: ContextType): Promise<IOAuth2Session | null>;
|
|
52
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Thème que la page appelante a rendu (`?theme=`), à transmettre à l'écran du
|
|
73
|
+
* fournisseur. Liste FERMÉE : la chaîne vient de l'URL, toute autre valeur est
|
|
74
|
+
* ignorée sans un mot — rien d'arbitraire n'atteint la requête d'autorisation.
|
|
75
|
+
*
|
|
76
|
+
* @param value - paramètre `theme` de la requête, ou `null`
|
|
77
|
+
* @returns l'option à passer à `createAuthorization`, vide hors liste
|
|
78
|
+
*/
|
|
79
|
+
export declare function providerThemeOption(value: string | null): {
|
|
80
|
+
theme?: "light" | "dark";
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Compose l'adresse de l'écran d'échec : la raison de l'échec, et la page où
|
|
84
|
+
* aller une fois connecté, pour que l'écran la garde à la tentative suivante.
|
|
85
|
+
*
|
|
86
|
+
* `reason` vaut le code rendu par le fournisseur s'il appartient à la liste
|
|
87
|
+
* normalisée (`access_denied` = l'utilisateur a choisi de revenir), sinon
|
|
88
|
+
* {@link OAUTH2_FAILED_REASON} — jamais la chaîne reçue, qui vient de l'URL.
|
|
89
|
+
* Une `reason` ou un `from` déjà présents sont REMPLACÉS (l'écran qui a lancé
|
|
90
|
+
* le flux peut porter ceux d'une tentative précédente) ; le reste de la
|
|
91
|
+
* requête et le fragment sont conservés, et une adresse relative reste relative.
|
|
92
|
+
*
|
|
93
|
+
* @param failure - l'écran d'échec : `back` revalidé, ou `failureRedirect`
|
|
94
|
+
* @param error - le paramètre `error` reçu au callback, ou `null`
|
|
95
|
+
* @param from - la page où aller une fois connecté (revalidée), ou `null`
|
|
96
|
+
* @returns l'adresse de redirection
|
|
97
|
+
*/
|
|
98
|
+
export declare function oauth2FailureTarget(failure: string, error: string | null, from: string | null): string;
|
|
53
99
|
/**
|
|
54
100
|
* Endpoints HTTP du **social login OAuth 2.0** (P6 J9) — adaptateurs MINCES
|
|
55
101
|
* au-dessus du service `oauth2` (`@nodefony/security`) :
|
|
56
102
|
*
|
|
57
|
-
* - `GET /nodefony/security/api/oauth2/{provider}/authorize
|
|
58
|
-
* pose `state`+`code_verifier` en session (anonyme),
|
|
59
|
-
* fournisseur.
|
|
103
|
+
* - `GET /nodefony/security/api/oauth2/{provider}/authorize?from=&back=` —
|
|
104
|
+
* démarre le flux : pose `state`+`code_verifier` en session (anonyme), avec
|
|
105
|
+
* les retours revalidés en chemins locaux, redirige (302) vers le fournisseur.
|
|
60
106
|
* - `GET /nodefony/security/api/oauth2/{provider}/callback` — valide le `state`
|
|
61
107
|
* (anti-CSRF), échange le `code`, provisionne le Shadow User et OUVRE la
|
|
62
|
-
* session BFF (302 vers `successRedirect`).
|
|
108
|
+
* session BFF (302 vers `from`, sinon `successRedirect`). Un échec mène à
|
|
109
|
+
* `back`, sinon à `failureRedirect`, avec `reason` et `from`.
|
|
63
110
|
*
|
|
64
111
|
* Montés UNIQUEMENT si le service `oauth2` existe (social login activé) — 404
|
|
65
112
|
* sinon, zéro surface.
|
|
@@ -70,7 +117,7 @@ export interface IOAuth2BffFlow {
|
|
|
70
117
|
* La session anonyme ne porte que `state`/`verifier` ; `establishSessionFor`
|
|
71
118
|
* régénère l'ID (anti-fixation) à la promotion.
|
|
72
119
|
*/
|
|
73
|
-
declare class OAuth2Controller extends
|
|
120
|
+
declare class OAuth2Controller extends SecurityApiController {
|
|
74
121
|
#private;
|
|
75
122
|
constructor(context: ContextType);
|
|
76
123
|
/**
|
|
@@ -87,6 +134,18 @@ declare class OAuth2Controller extends Controller {
|
|
|
87
134
|
authorize(provider: string): Promise<import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse | undefined>;
|
|
88
135
|
/** Valide `state`, échange le `code`, ouvre la session BFF (302). */
|
|
89
136
|
callback(provider: string): Promise<import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse | undefined>;
|
|
137
|
+
/**
|
|
138
|
+
* Canal arrière : le fournisseur demande de fermer les sessions qu'il
|
|
139
|
+
* désigne (OpenID Connect Back-Channel Logout 1.0 §2.5-2.8).
|
|
140
|
+
*
|
|
141
|
+
* Appelé serveur à serveur — ni cookie, ni session, ni jeton CSRF : la
|
|
142
|
+
* SIGNATURE du `logout_token` est la seule authentification. Réponses de la
|
|
143
|
+
* norme (§2.8) : 200 si la déconnexion a eu lieu (ou n'avait plus lieu
|
|
144
|
+
* d'être), 400 sinon — jeton refusé (`invalid_request`) comme déconnexion
|
|
145
|
+
* impossible (la cause part au journal, jamais au fournisseur). Toujours
|
|
146
|
+
* `Cache-Control: no-store`.
|
|
147
|
+
*/
|
|
148
|
+
backchannelLogout(provider: string): Promise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
|
|
90
149
|
}
|
|
91
150
|
/**
|
|
92
151
|
* Monte les routes du social login OAuth — appelé par le module framework à
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ILoginPageDescription, type Module } from "nodefony";
|
|
2
2
|
import type { ContextType } from "@nodefony/http";
|
|
3
|
-
import
|
|
3
|
+
import SecurityApiController from "../src/SecurityApiController.js";
|
|
4
4
|
/**
|
|
5
5
|
* Vue MINIMALE du flux de session BFF que consomme ce controller — le service
|
|
6
6
|
* `authFlow` est posé au container par `@nodefony/security` (P6 J3). Contrat
|
|
@@ -14,14 +14,29 @@ type ILoginOutcome = {
|
|
|
14
14
|
user: unknown;
|
|
15
15
|
} | {
|
|
16
16
|
status: "mfa_required";
|
|
17
|
-
methods: string[];
|
|
17
|
+
methods: readonly string[];
|
|
18
18
|
};
|
|
19
19
|
export interface ISessionAuthFlow {
|
|
20
20
|
login(context: ContextType, identifier: unknown, password: unknown): Promise<ILoginOutcome>;
|
|
21
21
|
completeMfaLogin(context: ContextType, code: unknown): Promise<unknown>;
|
|
22
22
|
logout(context: ContextType): Promise<boolean>;
|
|
23
|
+
/**
|
|
24
|
+
* Déconnexion qui ferme AUSSI la session du fournisseur d'identité. Optionnelle :
|
|
25
|
+
* une version de `@nodefony/security` qui ne l'expose pas encore dégrade sur
|
|
26
|
+
* {@link logout}.
|
|
27
|
+
*/
|
|
28
|
+
logoutWithProvider?(context: ContextType): Promise<{
|
|
29
|
+
destroyed: boolean;
|
|
30
|
+
logoutUrl: string | null;
|
|
31
|
+
}>;
|
|
23
32
|
/** Identité courante, ou `null` sans session authentifiée. */
|
|
24
33
|
me(context: ContextType): Promise<unknown>;
|
|
34
|
+
/**
|
|
35
|
+
* Ce que la page de connexion par défaut doit afficher, ou `null` quand
|
|
36
|
+
* elle est désactivée. Optionnelle : une version de `@nodefony/security`
|
|
37
|
+
* qui ne l'expose pas encore laisse la page absente (404).
|
|
38
|
+
*/
|
|
39
|
+
describeLoginPage?(): ILoginPageDescription | null;
|
|
25
40
|
}
|
|
26
41
|
/**
|
|
27
42
|
* Endpoints HTTP du flux de session BFF — adaptateurs MINCES au-dessus du
|
|
@@ -29,7 +44,8 @@ export interface ISessionAuthFlow {
|
|
|
29
44
|
*
|
|
30
45
|
* - `POST /nodefony/security/api/auth/login` — body JSON `{username, password}`
|
|
31
46
|
* - `POST /nodefony/security/api/auth/logout` — idempotent
|
|
32
|
-
* - `GET /nodefony/security/api/auth/me` — identité de la session courante
|
|
47
|
+
* - `GET /nodefony/security/api/auth/me` — identité de la session courante :
|
|
48
|
+
* TOUJOURS `200`, `{ user }` ou `{ user: null }` sans session
|
|
33
49
|
*
|
|
34
50
|
* Montés par le module framework UNIQUEMENT si le service `authFlow` existe
|
|
35
51
|
* (module security chargé) — sans lui, les routes n'existent pas (404, zéro
|
|
@@ -40,14 +56,20 @@ export interface ISessionAuthFlow {
|
|
|
40
56
|
* `Retry-After` (RFC 6585) posé par le throttler NIST ; tout le reste remonte
|
|
41
57
|
* au pipeline d'erreurs standard (500, détail loggé jamais fuité).
|
|
42
58
|
*
|
|
43
|
-
*
|
|
59
|
+
* `me` est une SONDE d'état, pas une ressource protégée : « pas de session »
|
|
60
|
+
* est un état normal du navigateur, pas une faute — d'où `200` + `{ user:
|
|
61
|
+
* null }`, comme les bibliothèques qui livrent aussi le client de connexion
|
|
62
|
+
* (Auth.js, better-auth). Le `401` y écrivait une erreur réseau dans la console
|
|
63
|
+
* de chaque visiteur anonyme, sans rien protéger : la réponse ne révèle rien.
|
|
64
|
+
*
|
|
65
|
+
* @remarks Dérogation RFC 9110 §15.5.2 DÉLIBÉRÉE : les 401 de `login` ne
|
|
44
66
|
* portent PAS de `WWW-Authenticate` — aucun scheme HTTP ne décrit un cookie de
|
|
45
67
|
* session, et un challenge `Basic` mensonger déclencherait le popup natif du
|
|
46
68
|
* navigateur (l'anti-pattern que le BFF élimine). Les zones du firewall, elles,
|
|
47
69
|
* restent strictement conformes (challenge du premier authenticator qui en
|
|
48
70
|
* déclare un).
|
|
49
71
|
*/
|
|
50
|
-
declare class SessionAuthController extends
|
|
72
|
+
declare class SessionAuthController extends SecurityApiController {
|
|
51
73
|
#private;
|
|
52
74
|
constructor(context: ContextType);
|
|
53
75
|
/** Login BFF : credential JSON présenté UNE fois → cookie de session opaque. */
|
|
@@ -58,9 +80,18 @@ declare class SessionAuthController extends Controller {
|
|
|
58
80
|
* absent/invalide → 401 (uniforme) ; trop de tentatives → 429 (Retry-After).
|
|
59
81
|
*/
|
|
60
82
|
loginTotp(): Promise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
|
|
61
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Détruit la session (storage + cookie). Toujours 200 (idempotent). Quand la
|
|
85
|
+
* session venait d'un fournisseur qui publie un point de déconnexion, la
|
|
86
|
+
* réponse porte `logoutUrl` : le client y envoie le navigateur pour fermer
|
|
87
|
+
* aussi la session du fournisseur.
|
|
88
|
+
*/
|
|
62
89
|
logout(): Promise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
|
|
63
|
-
/**
|
|
90
|
+
/**
|
|
91
|
+
* Identité de la session courante, re-résolue (rôles frais). Toujours `200` :
|
|
92
|
+
* `{ user: null }` quand il n'y a pas de session (ou qu'elle ne mène plus à
|
|
93
|
+
* un compte actif) — l'absence de session est un état, pas une erreur.
|
|
94
|
+
*/
|
|
64
95
|
me(): Promise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
|
|
65
96
|
}
|
|
66
97
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Module } from "nodefony";
|
|
2
2
|
import type { ContextType } from "@nodefony/http";
|
|
3
|
-
import
|
|
3
|
+
import SecurityApiController from "../src/SecurityApiController.js";
|
|
4
4
|
/**
|
|
5
5
|
* Vue MINIMALE du service d'émission de jetons (`tokenService`, posé au container
|
|
6
6
|
* par `@nodefony/security` P6 J4). Contrat structurel local : framework ne dépend
|
|
@@ -10,7 +10,9 @@ export interface ITokenIssuer {
|
|
|
10
10
|
/** `true` si l'émission JWT est opérationnelle (JWT activé + store prêt). */
|
|
11
11
|
isEnabled(): boolean;
|
|
12
12
|
/** Émet access+refresh après vérification d'un credential (grant M2M/CLI). */
|
|
13
|
-
issueForCredentials(identifier: unknown, password: unknown, scopes?: string[], resource?: unknown
|
|
13
|
+
issueForCredentials(identifier: unknown, password: unknown, scopes?: string[], resource?: unknown,
|
|
14
|
+
/** Requête porteuse — soumet le credential au refus du clair en production. */
|
|
15
|
+
context?: ContextType): Promise<unknown>;
|
|
14
16
|
/** Rotation d'un refresh token (nouveau couple, ancien révoqué). */
|
|
15
17
|
refresh(rawRefresh: unknown, resource?: unknown): Promise<unknown>;
|
|
16
18
|
}
|
|
@@ -31,11 +33,11 @@ export interface ITokenIssuer {
|
|
|
31
33
|
* framework ne peut pas importer les classes d'erreur de security ; un 429
|
|
32
34
|
* reporte le `Retry-After` (RFC 6585) du throttler NIST.
|
|
33
35
|
*
|
|
34
|
-
* @remarks Dérogation RFC
|
|
36
|
+
* @remarks Dérogation RFC 9110 §15.5.2 (comme `SessionAuthController`) : ces 401 ne
|
|
35
37
|
* portent pas de `WWW-Authenticate` — l'endpoint d'émission n'est pas une
|
|
36
38
|
* ressource protégée par Bearer, il DÉLIVRE le Bearer.
|
|
37
39
|
*/
|
|
38
|
-
declare class TokenAuthController extends
|
|
40
|
+
declare class TokenAuthController extends SecurityApiController {
|
|
39
41
|
#private;
|
|
40
42
|
constructor(context: ContextType);
|
|
41
43
|
/** Émission : credential présenté → couple access/refresh. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Module } from "nodefony";
|
|
2
2
|
import type { ContextType } from "@nodefony/http";
|
|
3
|
-
import
|
|
3
|
+
import SecurityApiController from "../src/SecurityApiController.js";
|
|
4
4
|
/**
|
|
5
5
|
* Endpoints HTTP **self-service 2FA TOTP (P6.17)** — console « ma sécurité »,
|
|
6
6
|
* adaptateurs MINCES au-dessus du service `totp` (`@nodefony/security`) :
|
|
@@ -19,7 +19,7 @@ import Controller from "../src/Controller.js";
|
|
|
19
19
|
* `totp` existe (security chargé) — sans security, 404 et zéro surface ; 2FA
|
|
20
20
|
* désactivé, les routes répondent 503.
|
|
21
21
|
*/
|
|
22
|
-
declare class TotpController extends
|
|
22
|
+
declare class TotpController extends SecurityApiController {
|
|
23
23
|
#private;
|
|
24
24
|
constructor(context: ContextType);
|
|
25
25
|
/** Démarre l'enrôlement : secret + URI otpauth affichés une seule fois. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Module } from "nodefony";
|
|
2
2
|
import type { ContextType } from "@nodefony/http";
|
|
3
|
-
import
|
|
3
|
+
import SecurityApiController from "../src/SecurityApiController.js";
|
|
4
4
|
/**
|
|
5
5
|
* Options de cérémonie renvoyées au navigateur (forme JSON WebAuthn) — seul le
|
|
6
6
|
* `challenge` nous intéresse côté serveur (stocké en session, anti-replay) ; le
|
|
@@ -20,6 +20,7 @@ export interface IWebAuthnService {
|
|
|
20
20
|
id: string;
|
|
21
21
|
name: string;
|
|
22
22
|
displayName?: string;
|
|
23
|
+
handle?: string;
|
|
23
24
|
}): Promise<CeremonyOptions>;
|
|
24
25
|
verifyRegistration(response: unknown, expectedChallenge: string, userId: string, requestOrigin?: string): Promise<{
|
|
25
26
|
id: string;
|
|
@@ -46,7 +47,8 @@ export interface IWebAuthnSession {
|
|
|
46
47
|
/** Vue minimale du flux de session BFF (`authFlow`) consommée ici. */
|
|
47
48
|
export interface IWebAuthnBffFlow {
|
|
48
49
|
me(context: ContextType): Promise<{
|
|
49
|
-
|
|
50
|
+
id: string;
|
|
51
|
+
identifier: string;
|
|
50
52
|
} | null>;
|
|
51
53
|
/**
|
|
52
54
|
* @param reason - facteur d'authentification journalisé par l'audit
|
|
@@ -84,9 +86,15 @@ export interface IWebAuthnBffFlow {
|
|
|
84
86
|
* firewall (qui, sur l'aire data plane, déclencherait un deadlock identique au
|
|
85
87
|
* login BFF).
|
|
86
88
|
*/
|
|
87
|
-
declare class WebAuthnController extends
|
|
89
|
+
declare class WebAuthnController extends SecurityApiController {
|
|
88
90
|
#private;
|
|
89
91
|
constructor(context: ContextType);
|
|
92
|
+
/**
|
|
93
|
+
* Dit si la connexion par passkey répondra : `{ enabled }`, toujours `200`.
|
|
94
|
+
* Un écran de connexion le lit pour ne pas proposer un bouton dont la
|
|
95
|
+
* cérémonie répondrait `503` (passkeys coupées).
|
|
96
|
+
*/
|
|
97
|
+
status(): import("nodefony").MaybePromise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
|
|
90
98
|
/** Défi d'enregistrement — l'utilisateur connecté ajoute un passkey. */
|
|
91
99
|
registerOptions(): Promise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
|
|
92
100
|
/** Vérifie la réponse d'enregistrement et persiste le credential. */
|
|
@@ -562,7 +562,49 @@ declare function resolveParamArg(meta: ParamMeta, ctx: IParamArgContext): unknow
|
|
|
562
562
|
* @param ctx - contexte de requête (forme structurelle minimale)
|
|
563
563
|
* @returns arguments positionnels à spread dans l'action
|
|
564
564
|
*/
|
|
565
|
-
declare function buildParamArgs(metas: ParamMeta[], ctx: IParamArgContext): unknown[];
|
|
565
|
+
declare function buildParamArgs(metas: readonly ParamMeta[], ctx: IParamArgContext): unknown[];
|
|
566
|
+
/**
|
|
567
|
+
* Ce qu'un `Context` — HTTP comme WebSocket — offre à la résolution des
|
|
568
|
+
* paramètres décorés. Forme structurelle : le framework n'importe pas les
|
|
569
|
+
* classes de contexte pour la lire.
|
|
570
|
+
*/
|
|
571
|
+
export interface IParamArgSource {
|
|
572
|
+
/** La requête du contexte (HTTP, ou l'upgrade d'une connexion WS). */
|
|
573
|
+
readonly request?: unknown;
|
|
574
|
+
/** La réponse du contexte. */
|
|
575
|
+
readonly response?: unknown;
|
|
576
|
+
/** La session, quand le contexte en porte une. */
|
|
577
|
+
readonly session?: unknown;
|
|
578
|
+
/** Lit un cookie de la requête, ou tous sans argument. */
|
|
579
|
+
getRequestCookies(name?: string): unknown;
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Résout les paramètres décorés d'une action depuis un `Context` — la mise en
|
|
583
|
+
* forme UNIQUE, appelée par le Resolver (routes HTTP et WS) comme par les
|
|
584
|
+
* actions `@RealtimeAction`, pour qu'un décorateur ne puisse pas prendre deux
|
|
585
|
+
* sens selon le transport.
|
|
586
|
+
*
|
|
587
|
+
* Le corps d'un appel WebSocket voyage dans l'ALS (`RequestContext` clé
|
|
588
|
+
* `body`) et `@CurrentUser` y lit l'utilisateur : l'appelant pose la bulle,
|
|
589
|
+
* cette fonction n'en crée aucune.
|
|
590
|
+
*
|
|
591
|
+
* @param metas - paramètres décorés de l'action ({@link getParamArgsMeta})
|
|
592
|
+
* @param context - le contexte de la requête ou de la connexion
|
|
593
|
+
* @param paramsMap - variables de route nommées (vide hors route)
|
|
594
|
+
* @param queryOverride - query de l'invocation quand elle ne vient pas de l'URL
|
|
595
|
+
* @returns arguments positionnels à passer à l'action
|
|
596
|
+
*/
|
|
597
|
+
export declare function buildContextParamArgs(metas: readonly ParamMeta[], context: IParamArgSource, paramsMap: Record<string, unknown>, queryOverride?: Record<string, unknown>): unknown[];
|
|
598
|
+
/**
|
|
599
|
+
* Les paramètres décorés d'une méthode (`@Body`, `@CurrentUser`…), ou `null`
|
|
600
|
+
* si elle n'en porte aucun. Lecture `Reflect` : à faire une fois, hors du
|
|
601
|
+
* chemin de chaque requête.
|
|
602
|
+
*
|
|
603
|
+
* @param proto - le prototype de la classe
|
|
604
|
+
* @param method - le nom de la méthode
|
|
605
|
+
* @returns les métadonnées, ou `null` — une action sans décorateur ne paie rien
|
|
606
|
+
*/
|
|
607
|
+
export declare function getParamArgsMeta(proto: object, method: string | symbol): readonly ParamMeta[] | null;
|
|
566
608
|
/**
|
|
567
609
|
* P2.9 — Indique si l'action d'une route attend le **flux brut** du body
|
|
568
610
|
* (un paramètre `@Body({ stream:true })`). Le résultat est **mémoïsé** sur
|
|
@@ -588,7 +630,7 @@ declare function routeExpectsBodyStream(routeDef: {
|
|
|
588
630
|
*/
|
|
589
631
|
export interface RouteActionMeta {
|
|
590
632
|
/** Paramètres décorés (`@Param`/`@Body`/`@Query`…) — `null` si aucun. */
|
|
591
|
-
paramsMeta: ParamMeta[] | null;
|
|
633
|
+
paramsMeta: readonly ParamMeta[] | null;
|
|
592
634
|
/** `@Redirect` de l'action — `null` si absent. */
|
|
593
635
|
redirectMeta: RedirectMeta | null;
|
|
594
636
|
/** `@HttpCode` de l'action — `null` si absent. */
|
|
@@ -603,6 +645,8 @@ export interface RouteActionMeta {
|
|
|
603
645
|
* path (ni résolution de service, ni `decide`, ni await). Frozen, partagé.
|
|
604
646
|
*/
|
|
605
647
|
security: SecurityRequirement | null;
|
|
648
|
+
/** `@RequireAuthentication` (fusion au plus strict) — `null` = 0 coût, frozen. */
|
|
649
|
+
authentication: IAuthenticationRequirement | null;
|
|
606
650
|
/**
|
|
607
651
|
* Directives CSP additionnelles (`@Csp`, fusion classe+méthode) — `null` si
|
|
608
652
|
* l'action n'en déclare pas (cas courant → 0 composition CSP). Frozen, partagé.
|
|
@@ -649,4 +693,45 @@ declare function resolveActionMeta(routeDef: {
|
|
|
649
693
|
classMethod?: string | undefined;
|
|
650
694
|
actionMeta?: RouteActionMeta | undefined;
|
|
651
695
|
}): RouteActionMeta;
|
|
652
|
-
export { route, controller, controllers, Get, Post, Put, Delete, Patch, Options, Head, All, Domain, BypassFirewall, IsGranted, RequireScope, Anonymous, Csp, CsrfProtect, CsrfExempt, Idempotent, CurrentUser, Scope, UseSession, resolveSessionIntent, HttpCode, Header, Redirect, Param, Body, Query, Headers, Cookie, Session, Req, Res, UploadedFile, UploadedFiles, resolveParamArg, buildParamArgs, routeExpectsBodyStream, computeActionMeta, resolveActionMeta, extractActionScopes, };
|
|
696
|
+
export { route, controller, controllers, Get, Post, Put, Delete, Patch, Options, Head, All, Domain, BypassFirewall, IsGranted, RequireScope, RequireAuthentication, Anonymous, Csp, CsrfProtect, CsrfExempt, Idempotent, CurrentUser, Scope, UseSession, resolveSessionIntent, HttpCode, Header, Redirect, Param, Body, Query, Headers, Cookie, Session, Req, Res, UploadedFile, UploadedFiles, resolveParamArg, buildParamArgs, routeExpectsBodyStream, computeActionMeta, resolveActionMeta, extractActionScopes, };
|
|
697
|
+
/** Options de {@link RequireAuthentication}. */
|
|
698
|
+
export interface IRequireAuthenticationOptions {
|
|
699
|
+
/** Âge maximal de la connexion, en secondes. Omis = aucune limite d'âge. */
|
|
700
|
+
readonly maxAge?: number;
|
|
701
|
+
/**
|
|
702
|
+
* Moyens d'authentification EXIGÉS, tous présents (RFC 8176) : `"pwd"` mot
|
|
703
|
+
* de passe, `"otp"` code à usage unique, `"pop"` clé d'accès. Omis = aucun.
|
|
704
|
+
*/
|
|
705
|
+
readonly amr?: readonly string[];
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Exigence d'authentification **figée** d'une action — fusion classe + méthode
|
|
709
|
+
* au plus strict (âge le plus court, moyens réunis), calculée UNE fois par
|
|
710
|
+
* route. Objet partagé entre requêtes : ne jamais muter.
|
|
711
|
+
*/
|
|
712
|
+
export interface IAuthenticationRequirement {
|
|
713
|
+
/** Âge maximal en secondes, `null` = sans limite. */
|
|
714
|
+
readonly maxAge: number | null;
|
|
715
|
+
/** Moyens exigés, tous présents. */
|
|
716
|
+
readonly amr: readonly string[];
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Exige une connexion RÉCENTE et/ou faite avec certains moyens pour l'action
|
|
720
|
+
* (décorateur de méthode) ou tout le contrôleur (décorateur de classe).
|
|
721
|
+
*
|
|
722
|
+
* - `@RequireAuthentication({ maxAge: 300 })` — connexion de moins de 5 minutes.
|
|
723
|
+
* - `@RequireAuthentication({ amr: ["otp"] })` — second facteur exigé.
|
|
724
|
+
* - les deux se combinent ; classe + méthode fusionnent au plus strict.
|
|
725
|
+
*
|
|
726
|
+
* Lit ce que la session a retenu à son ouverture (`readSessionAuthentication`,
|
|
727
|
+
* ADR-0015) et s'évalue dans le `Resolver` APRÈS `@IsGranted` : un rôle absent
|
|
728
|
+
* reste un 403 définitif, une connexion trop ancienne ou trop faible devient un
|
|
729
|
+
* **401** dont le `WWW-Authenticate` porte
|
|
730
|
+
* `error="insufficient_user_authentication"` (RFC 9470) — le client sait qu'il
|
|
731
|
+
* doit redemander une connexion, pas que l'accès lui est fermé. Une session qui
|
|
732
|
+
* ne porte pas ces métadonnées est refusée, jamais acceptée.
|
|
733
|
+
*
|
|
734
|
+
* @param options - âge maximal et moyens exigés.
|
|
735
|
+
* @throws TypeError si `maxAge` n'est pas un entier positif ou si un moyen est vide.
|
|
736
|
+
*/
|
|
737
|
+
declare function RequireAuthentication(options?: IRequireAuthenticationOptions): (target: object, propertyKey?: string, descriptor?: PropertyDescriptor) => any;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { HTTPMethod,
|
|
1
|
+
import type { HTTPMethod, ContextType, IResolvedRoute } from "@nodefony/http";
|
|
2
2
|
import type { ControllerConstructor, RouteRequirements } from "../src/Route.js";
|
|
3
3
|
export interface IRoute extends IResolvedRoute {
|
|
4
4
|
name: string;
|
|
@@ -7,7 +7,6 @@ export interface IRoute extends IResolvedRoute {
|
|
|
7
7
|
classMethod?: string | undefined;
|
|
8
8
|
prefix?: string | undefined;
|
|
9
9
|
method?: HTTPMethod | undefined;
|
|
10
|
-
schemes?: SchemeType | undefined;
|
|
11
10
|
pattern?: RegExp | undefined;
|
|
12
11
|
variables: unknown[];
|
|
13
12
|
defaults: Partial<Record<string, unknown>>;
|
|
@@ -30,6 +30,19 @@ declare class Router extends Service implements IRequestRouter {
|
|
|
30
30
|
* cache, la requête suivante recrée.
|
|
31
31
|
*/
|
|
32
32
|
getSingletonController<T extends object>(ctor: new (...args: never[]) => T, create: () => MaybePromise<T>): MaybePromise<T>;
|
|
33
|
+
/**
|
|
34
|
+
* Dit si au moins une route déclare le transport `WEBSOCKET` pour ce chemin
|
|
35
|
+
* — contrôle d'avant `101` du pipeline WebSocket (cf `IRequestRouter`).
|
|
36
|
+
*
|
|
37
|
+
* Parcours de la table entière, sans l'index de `resolve` : il est lu une
|
|
38
|
+
* fois par CONNEXION (pas par message), et une route dont les méthodes
|
|
39
|
+
* changent après coup (`addRequirement`) ne laisse ainsi aucun index périmé.
|
|
40
|
+
* Même normalisation que `resolve` (`stripTrailingSlashes`), même motif.
|
|
41
|
+
*
|
|
42
|
+
* @param pathname - chemin de l'URL d'upgrade
|
|
43
|
+
* @returns `true` si une route WebSocket a un motif qui couvre ce chemin
|
|
44
|
+
*/
|
|
45
|
+
servesWebsocket(pathname: string): boolean;
|
|
33
46
|
/**
|
|
34
47
|
* Résout une route pour un contexte donné.
|
|
35
48
|
*
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Service, Module, FileClass } from "nodefony";
|
|
2
|
-
import type { MaybePromise } from "nodefony";
|
|
2
|
+
import type { IRequestUser, MaybePromise } from "nodefony";
|
|
3
3
|
import type { IController } from "../interfaces/index.js";
|
|
4
4
|
import Route from "./Route.js";
|
|
5
5
|
import { contextRequest, HTTPMethod, HttpResponse, Session, ContextType, Http2Response, WebsocketResponse } from "@nodefony/http";
|
|
6
|
+
import type { ISseStreamOptions, SseStream } from "@nodefony/http";
|
|
6
7
|
import { OutgoingHttpHeaders } from "node:http";
|
|
7
8
|
import { ReadStream } from "node:fs";
|
|
8
9
|
import Eta from "../service/Eta.js";
|
|
@@ -94,6 +95,13 @@ declare class Controller extends Service implements IController {
|
|
|
94
95
|
set request(request: contextRequest);
|
|
95
96
|
get response(): HttpResponse | Http2Response | WebsocketResponse | null;
|
|
96
97
|
set response(response: HttpResponse | Http2Response | WebsocketResponse | null);
|
|
98
|
+
/**
|
|
99
|
+
* L'utilisateur CONNECTÉ, ou `null` — anonyme, ou route hors de toute zone
|
|
100
|
+
* du pare-feu. C'est l'identité que le pare-feu a établie pour cette
|
|
101
|
+
* requête : aucun appel de plus. Une page publique qui veut savoir qui la
|
|
102
|
+
* visite se place sous une zone `authenticators: ["session", "anonymous"]`.
|
|
103
|
+
*/
|
|
104
|
+
get user(): IRequestUser | null;
|
|
97
105
|
get method(): HTTPMethod | undefined;
|
|
98
106
|
set method(method: HTTPMethod | undefined);
|
|
99
107
|
/**
|
|
@@ -177,6 +185,37 @@ declare class Controller extends Service implements IController {
|
|
|
177
185
|
* @throws de façon SYNCHRONE ce que lève `JSON.stringify` (cycle, BigInt).
|
|
178
186
|
*/
|
|
179
187
|
renderJson(obj: unknown, status?: string | number, headers?: OutgoingHttpHeaders): MaybePromise<Http2Response | HttpResponse | WebsocketResponse>;
|
|
188
|
+
/**
|
|
189
|
+
* Le client demande-t-il un flux d'événements (`Accept: text/event-stream`) ?
|
|
190
|
+
*
|
|
191
|
+
* La route ne change pas de méthode pour autant : un même `POST` répond en
|
|
192
|
+
* JSON ou en flux selon cette réponse. Toujours `false` sur WebSocket.
|
|
193
|
+
*
|
|
194
|
+
* @returns `true` quand `text/event-stream` est accepté avec un poids non nul.
|
|
195
|
+
*/
|
|
196
|
+
acceptsSse(): boolean;
|
|
197
|
+
/**
|
|
198
|
+
* Répond par un flux d'événements serveur, en HTTP/1.1 comme en HTTP/2.
|
|
199
|
+
*
|
|
200
|
+
* L'action garde la main : elle `send()` à son rythme et `close()` quand elle
|
|
201
|
+
* a fini ; le départ du client ferme le flux (`sse.closed`, `sse.onClose`).
|
|
202
|
+
* Rendre le flux depuis l'action est sans effet — la réponse est déjà prise.
|
|
203
|
+
*
|
|
204
|
+
* @example
|
|
205
|
+
* ```ts
|
|
206
|
+
* @Get("/progress")
|
|
207
|
+
* async progress() {
|
|
208
|
+
* const sse = await this.renderSse();
|
|
209
|
+
* for (const step of steps) await sse.send(step, { event: "step" });
|
|
210
|
+
* return sse.close();
|
|
211
|
+
* }
|
|
212
|
+
* ```
|
|
213
|
+
*
|
|
214
|
+
* @param options - battement de cœur, délai `retry:` initial.
|
|
215
|
+
* @returns le flux, ou sa promesse quand une session est à sauver d'abord.
|
|
216
|
+
* @throws HttpError 500 hors HTTP (WebSocket) ou quand la réponse est partie.
|
|
217
|
+
*/
|
|
218
|
+
renderSse(options?: ISseStreamOptions): SseStream | Promise<SseStream>;
|
|
180
219
|
/**
|
|
181
220
|
* Pose la route de la requête courante sur l'instance (per-request).
|
|
182
221
|
*
|
|
@@ -115,6 +115,12 @@ declare class Resolver implements IResolver {
|
|
|
115
115
|
* `instanceof` au message suivant — le hub était RÉINSTANCIÉ (DI +
|
|
116
116
|
* `initialize()`) à chaque frame qui suivait un `api.request`.
|
|
117
117
|
*
|
|
118
|
+
* Ni pour un `forward` sur une connexion WebSocket : son résolveur est neuf,
|
|
119
|
+
* pas une invocation par message, et réécrivait le pointeur que la poignée
|
|
120
|
+
* de main a posé — même réinstanciation à la frame suivante (#574). Une
|
|
121
|
+
* connexion garde le contrôleur qui l'a ouverte. Chemin froid : seulement
|
|
122
|
+
* quand un contrôleur est créé.
|
|
123
|
+
*
|
|
118
124
|
* @returns le controller reçu, pour enchaîner.
|
|
119
125
|
*/
|
|
120
126
|
private _pinController;
|
|
@@ -339,5 +345,18 @@ declare class Resolver implements IResolver {
|
|
|
339
345
|
returnController(result: unknown): MaybePromise<unknown>;
|
|
340
346
|
/** Statut courant de la réponse (0 si le transport n'en porte pas). */
|
|
341
347
|
private getResponseStatus;
|
|
348
|
+
/**
|
|
349
|
+
* `@RequireAuthentication` — les gardes d'autorisation d'abord (un rôle
|
|
350
|
+
* absent reste un 403 définitif : inutile de faire redemander une connexion à
|
|
351
|
+
* qui n'aura pas le droit), PUIS l'âge et les moyens de la connexion.
|
|
352
|
+
*
|
|
353
|
+
* Le refus est un 401 « step-up » (RFC 9470) : le `WWW-Authenticate` porte
|
|
354
|
+
* `error="insufficient_user_authentication"` et, quand la route fixe un âge,
|
|
355
|
+
* `max_age` — le client redemande une connexion au lieu de conclure à un
|
|
356
|
+
* accès fermé. Cold path : seules les routes décorées y passent.
|
|
357
|
+
*
|
|
358
|
+
* @throws nodefonyError 401 si la connexion est trop ancienne ou trop faible.
|
|
359
|
+
*/
|
|
360
|
+
private _enforceStepUp;
|
|
342
361
|
}
|
|
343
362
|
export default Resolver;
|
|
@@ -62,9 +62,25 @@ export interface RouteOptions {
|
|
|
62
62
|
* exception qui se justifie. Défaut `false`.
|
|
63
63
|
*/
|
|
64
64
|
areaRoleExempt?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Route de REPLI : elle ne sert un chemin que si aucune autre route ne le
|
|
67
|
+
* sert, quel que soit l'ordre de déclaration.
|
|
68
|
+
*
|
|
69
|
+
* Le routeur retient la première route déclarée qui correspond ; un repli à
|
|
70
|
+
* variable (`/nodefony/{page}`, la page d'une application monopage) déclaré
|
|
71
|
+
* tôt capturerait donc les routes de même forme des modules chargés après
|
|
72
|
+
* lui — sans erreur ni avertissement. Les replis sont rangés après toutes les
|
|
73
|
+
* autres routes, dans leur ordre relatif, à la construction de l'index :
|
|
74
|
+
* aucun coût par requête. Défaut `false`.
|
|
75
|
+
*/
|
|
76
|
+
fallback?: boolean;
|
|
65
77
|
}
|
|
66
78
|
export interface RouteRequirements {
|
|
67
79
|
domain?: string | string[] | undefined;
|
|
80
|
+
/**
|
|
81
|
+
* Transport exigé : `https`/`wss` refuse un canal clair, `http`/`ws` un
|
|
82
|
+
* canal chiffré — 403 sinon. Proto transmis par un proxy de confiance compris.
|
|
83
|
+
*/
|
|
68
84
|
scheme?: SchemeType | undefined;
|
|
69
85
|
methods?: HTTPMethod[] | HTTPMethod | undefined;
|
|
70
86
|
protocol?: string | undefined;
|
|
@@ -77,7 +93,6 @@ declare class Route implements IRoute {
|
|
|
77
93
|
classMethod?: string | undefined;
|
|
78
94
|
prefix?: string | undefined;
|
|
79
95
|
method?: HTTPMethod | undefined;
|
|
80
|
-
schemes?: SchemeType | undefined;
|
|
81
96
|
pattern?: RegExp | undefined;
|
|
82
97
|
variables: string[];
|
|
83
98
|
/**
|
|
@@ -115,6 +130,8 @@ declare class Route implements IRoute {
|
|
|
115
130
|
* pas son rôle par défaut. Cf {@link RouteOptions.areaRoleExempt}.
|
|
116
131
|
*/
|
|
117
132
|
areaRoleExempt: boolean;
|
|
133
|
+
/** Route de repli, résolue après toutes les autres. Cf {@link RouteOptions.fallback}. */
|
|
134
|
+
fallback: boolean;
|
|
118
135
|
/**
|
|
119
136
|
* P2.9 — Cache mémoïsé : l'action attend-elle le **flux brut** du body
|
|
120
137
|
* (`@Body({ stream:true })`) ? `undefined` = pas encore calculé (résolu au 1er
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { OutgoingHttpHeaders } from "node:http";
|
|
2
|
+
import Controller from "./Controller.js";
|
|
3
|
+
/**
|
|
4
|
+
* Base des contrôleurs d'authentification (`/nodefony/security/api/*`) : toute
|
|
5
|
+
* réponse JSON part en `Cache-Control: no-store`.
|
|
6
|
+
*
|
|
7
|
+
* Ces réponses portent une identité, un jeton, un secret affiché une seule
|
|
8
|
+
* fois (clé API, secret TOTP, codes de récupération) ou leur refus. Un cookie
|
|
9
|
+
* ou un en-tête dans la requête n'empêche pas un cache partagé de STOCKER la
|
|
10
|
+
* réponse (RFC 9111 §3) ; RFC 6749 §5.1 exige `no-store` sur un jeton. La règle
|
|
11
|
+
* vit ici, une fois : une action ajoutée à un contrôleur héritier ne peut pas
|
|
12
|
+
* l'oublier. Un en-tête passé par l'action garde la priorité.
|
|
13
|
+
*/
|
|
14
|
+
declare abstract class SecurityApiController extends Controller {
|
|
15
|
+
renderJson(obj: unknown, status?: string | number, headers?: OutgoingHttpHeaders): ReturnType<Controller["renderJson"]>;
|
|
16
|
+
}
|
|
17
|
+
export default SecurityApiController;
|