@nodefony/security 10.0.0-beta.1 → 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/README.md +7 -5
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +44 -10
- package/dist/nodefony/command/security-oauth-doctor.js +66 -0
- package/dist/nodefony/command/security-secrets.js +42 -25
- package/dist/nodefony/command/security-token.js +46 -15
- package/dist/nodefony/command/security-user-delete.js +5 -4
- package/dist/nodefony/command/security-user-password.js +4 -11
- package/dist/nodefony/config/config.js +80 -5
- package/dist/nodefony/contracts/index.js +1 -2
- package/dist/nodefony/errors/InsecureTransportError.js +24 -0
- package/dist/nodefony/errors/index.js +2 -1
- package/dist/nodefony/keycloak/keycloakAdmin.js +255 -0
- package/dist/nodefony/keycloak/keycloakRealm.js +260 -0
- package/dist/nodefony/keycloak/keycloakRealmInput.js +124 -0
- package/dist/nodefony/keycloak/scaffold.js +231 -0
- package/dist/nodefony/keycloak/security-keycloak-realm.js +128 -0
- package/dist/nodefony/scaffold/contribute.js +20 -0
- package/dist/nodefony/service/authFlow.js +111 -11
- package/dist/nodefony/service/firewall.js +111 -21
- package/dist/nodefony/service/keycloak.js +209 -0
- package/dist/nodefony/service/oauth2.js +379 -26
- package/dist/nodefony/service/securityHeaders.js +12 -1
- package/dist/nodefony/service/tokenService.js +57 -6
- package/dist/nodefony/service/totp.js +51 -6
- package/dist/nodefony/service/webAuthn.js +20 -2
- package/dist/nodefony/src/SecuredArea.js +14 -3
- package/dist/nodefony/src/admin/SecurityAdminApi.js +70 -40
- package/dist/nodefony/src/admin/adminAudit.js +0 -1
- package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +66 -6
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +38 -15
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +1 -0
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +13 -3
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +9 -2
- package/dist/nodefony/src/authenticator/bearer.js +1 -2
- package/dist/nodefony/src/boot/publicRoutesNotice.js +27 -0
- package/dist/nodefony/src/entryPoint.js +39 -0
- package/dist/nodefony/src/oauth/clientCredentials.js +40 -0
- package/dist/nodefony/src/oauth/metadata.js +36 -9
- package/dist/nodefony/src/oauth/oauth2Client.js +60 -33
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +39 -6
- package/dist/nodefony/src/oauth/providerDiagnosis.js +283 -0
- package/dist/nodefony/src/oauth/providerRoles.js +79 -0
- package/dist/nodefony/src/oauth/providers/github.js +32 -6
- package/dist/nodefony/src/oauth/providers/oidc.js +82 -3
- package/dist/nodefony/src/openToSignup.js +47 -0
- package/dist/nodefony/src/sessionAuthentication.js +26 -0
- package/dist/nodefony/src/token/JwtKeystore.js +78 -46
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +40 -13
- package/dist/nodefony/src/token/secretFile.js +59 -2
- package/dist/nodefony/src/transport/CredentialTransportPolicy.js +51 -0
- package/dist/types/index.d.ts +26 -6
- package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
- package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
- package/dist/types/nodefony/command/security-token.d.ts +9 -0
- package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
- package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
- package/dist/types/nodefony/config/config.d.ts +63 -1
- package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
- package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
- package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
- package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
- package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
- package/dist/types/nodefony/errors/index.d.ts +1 -0
- package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
- package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
- package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
- package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
- package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
- package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
- package/dist/types/nodefony/service/authFlow.d.ts +35 -3
- package/dist/types/nodefony/service/firewall.d.ts +17 -3
- package/dist/types/nodefony/service/keycloak.d.ts +115 -0
- package/dist/types/nodefony/service/oauth2.d.ts +125 -9
- package/dist/types/nodefony/service/securityHeaders.d.ts +2 -1
- package/dist/types/nodefony/service/tokenService.d.ts +7 -1
- package/dist/types/nodefony/service/totp.d.ts +28 -4
- package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
- package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +54 -12
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +18 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +13 -0
- package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
- package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
- package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
- package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
- package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
- package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
- package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
- package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
- package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
- package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
- package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
- package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
- package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
- package/docs/api-keys.md +18 -18
- package/docs/audit.md +24 -22
- package/docs/authenticators.md +62 -29
- package/docs/authorization.md +16 -16
- package/docs/cors.md +30 -30
- package/docs/csrf.md +35 -33
- package/docs/external-jwt.md +68 -5
- package/docs/firewall.md +78 -45
- package/docs/headers.md +35 -35
- package/docs/index.md +15 -7
- package/docs/keycloak.md +818 -0
- package/docs/lexique.md +15 -15
- package/docs/login-page.md +361 -0
- package/docs/oauth2.md +242 -125
- package/docs/obtenir-un-jeton.md +18 -11
- package/docs/tokens.md +138 -86
- package/docs/totp.md +113 -71
- package/docs/webauthn.md +115 -115
- package/docs/webhooks.md +21 -21
- package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
- package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/account/theme.properties +14 -0
- package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
- package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/admin/theme.properties +12 -0
- package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
- package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
- package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
- package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/email/theme.properties +8 -0
- package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
- package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
- package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
- package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
- package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/login/template.ftl +423 -0
- package/keycloak/themes/nodefony/login/theme.properties +17 -0
- package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
- package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
- package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
- package/package.json +17 -15
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { KeycloakRealmError } from "./keycloakRealmInput.js";
|
|
2
|
+
import { KeycloakAdminError } from "./keycloakAdmin.js";
|
|
3
|
+
import { Command } from "nodefony";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
//#region nodefony/keycloak/security-keycloak-realm.ts
|
|
6
|
+
const options = {
|
|
7
|
+
helpGroup: "COMPTES ET SECRETS",
|
|
8
|
+
showBanner: false,
|
|
9
|
+
kernelEvent: "onReady",
|
|
10
|
+
quietBoot: true
|
|
11
|
+
};
|
|
12
|
+
const GREEN = "\x1B[32m";
|
|
13
|
+
const YELLOW = "\x1B[33m";
|
|
14
|
+
const DIM = "\x1B[2m";
|
|
15
|
+
const RESET = "\x1B[0m";
|
|
16
|
+
/** Codes de sortie (sysexits). */
|
|
17
|
+
const EXIT_DIFFERS = 1;
|
|
18
|
+
const EXIT_CONFIG = 78;
|
|
19
|
+
/**
|
|
20
|
+
* `nodefony security:keycloak:realm` — le realm Keycloak que la configuration
|
|
21
|
+
* de l'application implique.
|
|
22
|
+
*
|
|
23
|
+
* Le realm d'import répète ce que la config dit déjà : identifiant du client,
|
|
24
|
+
* URL de retour, audience de chaque zone, rôles de `roleMapping`. Écrit à la
|
|
25
|
+
* main, il diverge — et le symptôme est un 401 ou une URL de retour refusée,
|
|
26
|
+
* jamais un message qui nomme la cause. Cette commande le DÉRIVE, par le
|
|
27
|
+
* service `keycloak` — qui porte toutes les actions Keycloak ; elle n'en
|
|
28
|
+
* garde que l'affichage et les codes de sortie.
|
|
29
|
+
*
|
|
30
|
+
* Elle FUSIONNE dans le fichier existant au lieu de l'écraser : comptes de
|
|
31
|
+
* démonstration, titres, secret machine, mappers posés dans la console
|
|
32
|
+
* survivent (`mergeKeycloakRealm`, cœur — le même constructeur que
|
|
33
|
+
* `create app`).
|
|
34
|
+
*
|
|
35
|
+
* - sans option : affiche le realm fusionné ;
|
|
36
|
+
* - `--write` : l'écrit (le fichier par défaut est l'unique `.json` de
|
|
37
|
+
* `docker/keycloak/import/`) ;
|
|
38
|
+
* - `--check` : sort en 1 si le fichier n'est plus à jour — la garde de CI ;
|
|
39
|
+
* - `--apply` : pose sur le realm EXISTANT le thème de connexion qui suit
|
|
40
|
+
* `loginPage.skin`, par l'API d'administration (service `keycloak`) —
|
|
41
|
+
* développement seulement, compte `oauth2.providers.<nom>.admin` requis.
|
|
42
|
+
*
|
|
43
|
+
* ⚠️ Keycloak n'importe un realm qu'à sa CRÉATION : un realm déjà présent
|
|
44
|
+
* n'est pas relu. Après `--write`, le porter par un import PARTIEL (clients en
|
|
45
|
+
* « Overwrite » : comptes et seconds facteurs survivent) ; retirer le volume
|
|
46
|
+
* ne se fait que sur un realm qui ne porte rien à garder.
|
|
47
|
+
*/
|
|
48
|
+
var SecurityKeycloakRealm = class extends Command {
|
|
49
|
+
constructor(cli) {
|
|
50
|
+
super("security:keycloak:realm", "écrit le realm Keycloak déduit de la configuration", cli, options);
|
|
51
|
+
this.addOption("-w, --write [fichier]", "écrit le realm fusionné dans le fichier d'import");
|
|
52
|
+
this.addOption("-c, --check [fichier]", "sort en 1 si le fichier d'import n'est plus à jour");
|
|
53
|
+
this.addOption("-a, --apply", "pose le thème de connexion sur le realm existant (développement)");
|
|
54
|
+
this.addOption("-p, --provider <nom>", "fournisseur de oauth2.providers (défaut keycloak)");
|
|
55
|
+
this.addOption("-m, --machine <clientId>", "client machine dont l'audience suit la config");
|
|
56
|
+
this.addOption("--backchannel-origin <url>", "origine par laquelle Keycloak joint l'application (canal arrière)");
|
|
57
|
+
}
|
|
58
|
+
/** Le service `keycloak`, ou `null` (code de sortie posé). */
|
|
59
|
+
#service() {
|
|
60
|
+
const service = this.kernel?.container?.get("keycloak");
|
|
61
|
+
if (service) return service;
|
|
62
|
+
this.log("service keycloak absent — module security non chargé ?", "ERROR");
|
|
63
|
+
process.exitCode = EXIT_CONFIG;
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
async generate(opts) {
|
|
67
|
+
const service = this.#service();
|
|
68
|
+
if (service === null) return this;
|
|
69
|
+
if (opts.apply === true) {
|
|
70
|
+
if (!await this.#apply(service, opts.provider) || opts.write === void 0 && opts.check === void 0) return this;
|
|
71
|
+
}
|
|
72
|
+
let plan;
|
|
73
|
+
try {
|
|
74
|
+
plan = service.realmPlan({
|
|
75
|
+
provider: opts.provider,
|
|
76
|
+
machine: opts.machine,
|
|
77
|
+
backchannelOrigin: opts.backchannelOrigin,
|
|
78
|
+
file: typeof opts.write === "string" ? opts.write : typeof opts.check === "string" ? opts.check : void 0
|
|
79
|
+
});
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (!(error instanceof KeycloakRealmError)) throw error;
|
|
82
|
+
this.log(error.message, "ERROR");
|
|
83
|
+
process.exitCode = EXIT_CONFIG;
|
|
84
|
+
return this;
|
|
85
|
+
}
|
|
86
|
+
for (const warning of plan.warnings) process.stderr.write(`${YELLOW}⚠ ${warning}${RESET}\n`);
|
|
87
|
+
const relative = path.relative(this.kernel?.path ?? process.cwd(), plan.file);
|
|
88
|
+
if (opts.check !== void 0) {
|
|
89
|
+
if (plan.upToDate) {
|
|
90
|
+
process.stdout.write(`${GREEN}✓${RESET} ${relative} est à jour\n`);
|
|
91
|
+
return this;
|
|
92
|
+
}
|
|
93
|
+
process.stderr.write(`${relative} ne correspond plus à la configuration — nodefony security:keycloak:realm --write\n`);
|
|
94
|
+
process.exitCode = EXIT_DIFFERS;
|
|
95
|
+
return this;
|
|
96
|
+
}
|
|
97
|
+
if (opts.write !== void 0) {
|
|
98
|
+
if (!service.writeRealm(plan)) {
|
|
99
|
+
process.stdout.write(`${GREEN}✓${RESET} ${relative} déjà à jour, rien d'écrit\n`);
|
|
100
|
+
return this;
|
|
101
|
+
}
|
|
102
|
+
process.stdout.write(`${GREEN}✓${RESET} ${relative} écrit\n${DIM} Keycloak n'importe un realm qu'à sa CRÉATION — un realm déjà là ne relit pas ce fichier :\n · thème de connexion : nodefony security:keycloak:realm --apply ;\n · le reste : console › Realm settings › Action › Partial import, clients en « Overwrite » (comptes et seconds facteurs gardés) ;\n · ou recréer le realm (retirer le volume Keycloak) s'il ne porte rien à garder.${RESET}\n`);
|
|
103
|
+
return this;
|
|
104
|
+
}
|
|
105
|
+
process.stdout.write(plan.text);
|
|
106
|
+
return this;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* `--apply` : le thème que l'habillage demande, posé sur le realm vivant.
|
|
110
|
+
*
|
|
111
|
+
* @returns `false` si le geste a échoué (code de sortie posé)
|
|
112
|
+
*/
|
|
113
|
+
async #apply(service, provider) {
|
|
114
|
+
try {
|
|
115
|
+
const result = await service.applyLoginTheme(provider === void 0 ? {} : { provider });
|
|
116
|
+
const line = result.outcome === "applied" ? `${GREEN}✓${RESET} realm « ${result.realm} » : thème « ${result.previous ?? "—"} » → « ${result.theme} »` : result.outcome === "unchanged" ? `${GREEN}✓${RESET} realm « ${result.realm} » : déjà sur « ${result.theme} »` : `${YELLOW}⚠${RESET} realm « ${result.realm} » : thème « ${result.previous ?? "aucun"} » propre à l'application, laissé tel quel`;
|
|
117
|
+
process.stdout.write(`${line}\n`);
|
|
118
|
+
return true;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (!(error instanceof KeycloakAdminError)) throw error;
|
|
121
|
+
this.log(error.message, "ERROR");
|
|
122
|
+
process.exitCode = EXIT_CONFIG;
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
//#endregion
|
|
128
|
+
export { SecurityKeycloakRealm as default };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { contributeKeycloak } from "../keycloak/scaffold.js";
|
|
2
|
+
//#region nodefony/scaffold/contribute.ts
|
|
3
|
+
/**
|
|
4
|
+
* Le point de contribution de `@nodefony/security` : ce que le paquet livre à
|
|
5
|
+
* une application, déclaré dans son `package.json` (`nodefony.contribute`) et
|
|
6
|
+
* joué par le cœur, qui n'en connaît rien.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* La contribution du paquet — aujourd'hui le seul décor Keycloak de
|
|
12
|
+
* développement (`nodefony/keycloak/`).
|
|
13
|
+
*
|
|
14
|
+
* @param context - contexte fourni par le cœur (`runAppContributions`)
|
|
15
|
+
*/
|
|
16
|
+
function contribute(context) {
|
|
17
|
+
contributeKeycloak(context);
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
export { contribute };
|
|
@@ -1,10 +1,15 @@
|
|
|
1
|
+
import { securityConfigSchema } from "../config/config.js";
|
|
2
|
+
import { InsecureTransportError } from "../errors/InsecureTransportError.js";
|
|
1
3
|
import { AuthenticationError } from "../errors/AuthenticationError.js";
|
|
2
4
|
import { ThrottledError } from "../errors/ThrottledError.js";
|
|
3
5
|
import { resolveSessionIdentity } from "../src/sessionIdentity.js";
|
|
4
6
|
import { recordAudit } from "../src/audit/recordAudit.js";
|
|
5
7
|
import { readAuditContext } from "../src/audit/readAuditContext.js";
|
|
6
|
-
import {
|
|
8
|
+
import { amrForFactor } from "../src/sessionAuthentication.js";
|
|
9
|
+
import { LOGIN_PAGE_SKIN_SPECS, MFA_METHOD_TOTP, SESSION_AMR_KEY, SESSION_AUTH_AT_KEY, Service } from "nodefony";
|
|
10
|
+
import { requestUser } from "@nodefony/user";
|
|
7
11
|
//#region nodefony/service/authFlow.ts
|
|
12
|
+
const MFA_METHODS = Object.freeze([MFA_METHOD_TOTP]);
|
|
8
13
|
const serviceName = "authFlow";
|
|
9
14
|
const INVALID_CREDENTIALS = "Invalid credentials";
|
|
10
15
|
const PENDING_MFA_KEY = "mfa:pending";
|
|
@@ -34,8 +39,11 @@ var AuthFlow = class extends Service {
|
|
|
34
39
|
#sessions = null;
|
|
35
40
|
#throttler = null;
|
|
36
41
|
#throttlerResolved = false;
|
|
42
|
+
#transport = null;
|
|
37
43
|
#totp = null;
|
|
38
44
|
#totpResolved = false;
|
|
45
|
+
#loginPage = null;
|
|
46
|
+
#loginPageResolved = false;
|
|
39
47
|
constructor(module) {
|
|
40
48
|
super(serviceName, module.container, module.notificationsCenter, module.options);
|
|
41
49
|
this.module = module;
|
|
@@ -51,6 +59,8 @@ var AuthFlow = class extends Service {
|
|
|
51
59
|
* @param identifier - identifiant saisi (body JSON, non typé à la frontière).
|
|
52
60
|
* @param password - mot de passe saisi.
|
|
53
61
|
* @returns `authenticated` (identité établie) ou `mfa_required` (2ᵉ facteur requis).
|
|
62
|
+
* @throws InsecureTransportError (403) — mot de passe reçu en clair en
|
|
63
|
+
* production (refusé AVANT le throttle et le verifier).
|
|
54
64
|
* @throws ThrottledError (429 + `Retry-After`) — backoff actif.
|
|
55
65
|
* @throws AuthenticationError (401, message uniforme) — credential absent ou
|
|
56
66
|
* invalide.
|
|
@@ -58,6 +68,7 @@ var AuthFlow = class extends Service {
|
|
|
58
68
|
async login(context, identifier, password) {
|
|
59
69
|
const info = readAuditContext(context);
|
|
60
70
|
const who = typeof identifier === "string" ? identifier : null;
|
|
71
|
+
this.#assertTransport(context, "login", who, info);
|
|
61
72
|
if (typeof identifier !== "string" || identifier.length === 0 || typeof password !== "string" || password.length === 0) {
|
|
62
73
|
recordAudit(this.container, {
|
|
63
74
|
category: "auth",
|
|
@@ -113,12 +124,10 @@ var AuthFlow = class extends Service {
|
|
|
113
124
|
});
|
|
114
125
|
return {
|
|
115
126
|
status: "mfa_required",
|
|
116
|
-
methods:
|
|
127
|
+
methods: MFA_METHODS
|
|
117
128
|
};
|
|
118
129
|
}
|
|
119
|
-
await this.#openSession(context, user.identifier);
|
|
120
|
-
context.user = user.identifier;
|
|
121
|
-
RequestContext.set("user", user);
|
|
130
|
+
await this.#openSession(context, user.identifier, "password");
|
|
122
131
|
recordAudit(this.container, {
|
|
123
132
|
category: "auth",
|
|
124
133
|
action: "login.success",
|
|
@@ -148,9 +157,7 @@ var AuthFlow = class extends Service {
|
|
|
148
157
|
async establishSessionFor(context, identifier, reason = "federated") {
|
|
149
158
|
if (typeof identifier !== "string" || identifier.length === 0) throw new AuthenticationError(INVALID_CREDENTIALS);
|
|
150
159
|
const user = await resolveSessionIdentity(this.#resolveUsers(), identifier);
|
|
151
|
-
await this.#openSession(context, user.identifier);
|
|
152
|
-
context.user = user.identifier;
|
|
153
|
-
RequestContext.set("user", user);
|
|
160
|
+
await this.#openSession(context, user.identifier, reason);
|
|
154
161
|
recordAudit(this.container, {
|
|
155
162
|
category: "auth",
|
|
156
163
|
action: "login.success",
|
|
@@ -171,6 +178,7 @@ var AuthFlow = class extends Service {
|
|
|
171
178
|
* @param context - contexte HTTP (porte la session PENDING).
|
|
172
179
|
* @param code - code présenté (TOTP ou code de récupération).
|
|
173
180
|
* @returns la projection publique de l'utilisateur authentifié.
|
|
181
|
+
* @throws InsecureTransportError (403) — code reçu en clair en production.
|
|
174
182
|
* @throws ThrottledError (429) — trop de tentatives.
|
|
175
183
|
* @throws AuthenticationError (401, uniforme) — aucun défi en cours, code absent
|
|
176
184
|
* ou invalide (la session N'est PAS ouverte ; le défi reste pour un retry).
|
|
@@ -178,6 +186,7 @@ var AuthFlow = class extends Service {
|
|
|
178
186
|
async completeMfaLogin(context, code) {
|
|
179
187
|
const info = readAuditContext(context);
|
|
180
188
|
const pending = context.session?.get(PENDING_MFA_KEY);
|
|
189
|
+
this.#assertTransport(context, "login/totp", typeof pending === "string" && pending.length > 0 ? pending : null, info);
|
|
181
190
|
if (typeof pending !== "string" || pending.length === 0) throw new AuthenticationError(INVALID_CREDENTIALS);
|
|
182
191
|
const throttler = this.#resolveThrottler();
|
|
183
192
|
if (throttler !== null) {
|
|
@@ -253,15 +262,35 @@ var AuthFlow = class extends Service {
|
|
|
253
262
|
return true;
|
|
254
263
|
}
|
|
255
264
|
/**
|
|
265
|
+
* Déconnexion COMPLÈTE : détruit la session locale et rend, si la session
|
|
266
|
+
* vient d'un fournisseur qui publie un point de déconnexion (Keycloak…),
|
|
267
|
+
* l'adresse où envoyer le navigateur pour fermer AUSSI sa session à lui —
|
|
268
|
+
* sans quoi le clic suivant sur « Continuer avec … » reconnecte sans mot de
|
|
269
|
+
* passe (OpenID Connect RP-Initiated Logout 1.0).
|
|
270
|
+
*
|
|
271
|
+
* @returns `destroyed` comme {@link logout} ; `logoutUrl` à suivre, ou `null`.
|
|
272
|
+
*/
|
|
273
|
+
async logoutWithProvider(context) {
|
|
274
|
+
const session = context.session;
|
|
275
|
+
const logoutUrl = session?.status === "active" ? await this.get("oauth2")?.logoutUrlFor(session) ?? null : null;
|
|
276
|
+
return {
|
|
277
|
+
destroyed: await this.logout(context),
|
|
278
|
+
logoutUrl
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
256
282
|
* Identité portée par la session courante, re-résolue auprès du provider
|
|
257
283
|
* (mêmes contrôles que le `SessionAuthenticator` — source unique).
|
|
258
284
|
*
|
|
259
285
|
* @returns la projection publique, ou `null` (pas de session, session
|
|
260
|
-
* orpheline, compte verrouillé/désactivé) — le handler répond
|
|
286
|
+
* orpheline, compte verrouillé/désactivé) — le handler répond `200`
|
|
287
|
+
* `{ user: null }`.
|
|
261
288
|
*/
|
|
262
289
|
async me(context) {
|
|
263
290
|
const identifier = context.session?.user;
|
|
264
291
|
if (typeof identifier !== "string" || identifier.length === 0) return null;
|
|
292
|
+
const current = requestUser(identifier);
|
|
293
|
+
if (current !== null) return toSafeUser(current);
|
|
265
294
|
try {
|
|
266
295
|
return toSafeUser(await resolveSessionIdentity(this.#resolveUsers(), identifier));
|
|
267
296
|
} catch (error) {
|
|
@@ -269,7 +298,59 @@ var AuthFlow = class extends Service {
|
|
|
269
298
|
throw error;
|
|
270
299
|
}
|
|
271
300
|
}
|
|
272
|
-
|
|
301
|
+
/**
|
|
302
|
+
* Décrit la page de connexion par défaut : ce qu'elle propose et comment
|
|
303
|
+
* elle s'habille, depuis `security.loginPage` et les fournisseurs
|
|
304
|
+
* opérationnels.
|
|
305
|
+
*
|
|
306
|
+
* Lue par le contrôleur de `@nodefony/framework`, qui n'importe pas ce
|
|
307
|
+
* paquet. Les fournisseurs sont relus à chaque appel : l'un d'eux peut
|
|
308
|
+
* tomber ou revenir pendant que le serveur tourne.
|
|
309
|
+
*
|
|
310
|
+
* @returns la description, ou `null` quand la page par défaut est
|
|
311
|
+
* désactivée (`loginPage.enabled: false`) ou la configuration invalide —
|
|
312
|
+
* le contrôleur répond alors 404.
|
|
313
|
+
*/
|
|
314
|
+
describeLoginPage() {
|
|
315
|
+
const page = this.#resolveLoginPage();
|
|
316
|
+
if (page === null || !page.enabled) return null;
|
|
317
|
+
const offered = this.get("oauth2")?.listDisplayProviders() ?? [];
|
|
318
|
+
return {
|
|
319
|
+
path: page.path,
|
|
320
|
+
title: page.title ?? null,
|
|
321
|
+
logo: page.logo ?? null,
|
|
322
|
+
template: page.template ?? null,
|
|
323
|
+
heading: page.heading ?? null,
|
|
324
|
+
subtitle: page.subtitle ?? null,
|
|
325
|
+
stylesheet: page.stylesheet ?? null,
|
|
326
|
+
providersFirst: page.providersFirst,
|
|
327
|
+
hero: page.hero === void 0 ? null : page.hero === false ? false : {
|
|
328
|
+
heading: page.hero.heading,
|
|
329
|
+
text: page.hero.text ?? null
|
|
330
|
+
},
|
|
331
|
+
footer: page.footer,
|
|
332
|
+
skin: page.skin,
|
|
333
|
+
layout: page.layout ?? LOGIN_PAGE_SKIN_SPECS[page.skin].layout,
|
|
334
|
+
previewPicker: page.previewPicker,
|
|
335
|
+
previewHook: page.previewHook ?? null,
|
|
336
|
+
password: page.password,
|
|
337
|
+
passkey: this.get("webauthn")?.isEnabled() ?? false,
|
|
338
|
+
providers: offered.map(({ name, label, icon }) => ({
|
|
339
|
+
name,
|
|
340
|
+
label,
|
|
341
|
+
icon: icon ?? null
|
|
342
|
+
}))
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
#resolveLoginPage() {
|
|
346
|
+
if (!this.#loginPageResolved) {
|
|
347
|
+
this.#loginPageResolved = true;
|
|
348
|
+
const parsed = securityConfigSchema.safeParse(this.options);
|
|
349
|
+
this.#loginPage = parsed.success ? parsed.data.loginPage : null;
|
|
350
|
+
}
|
|
351
|
+
return this.#loginPage;
|
|
352
|
+
}
|
|
353
|
+
async #openSession(context, identifier, factor) {
|
|
273
354
|
const session = context.session ?? await this.#resolveSessions().start(context);
|
|
274
355
|
if (session === null) {
|
|
275
356
|
this.log("login: session service returned no session", "ERROR");
|
|
@@ -287,6 +368,8 @@ var AuthFlow = class extends Service {
|
|
|
287
368
|
const ua = provenance.getUserAgent?.();
|
|
288
369
|
if (ua) session.setMetaBag("ua", ua);
|
|
289
370
|
} catch {}
|
|
371
|
+
session.setMetaBag(SESSION_AUTH_AT_KEY, Date.now());
|
|
372
|
+
session.setMetaBag(SESSION_AMR_KEY, amrForFactor(factor));
|
|
290
373
|
await session.save(identifier);
|
|
291
374
|
}
|
|
292
375
|
#resolveSessions() {
|
|
@@ -305,6 +388,23 @@ var AuthFlow = class extends Service {
|
|
|
305
388
|
}
|
|
306
389
|
return this.#users;
|
|
307
390
|
}
|
|
391
|
+
#assertTransport(context, door, actor, info) {
|
|
392
|
+
this.#transport ??= this.get("credentialTransport") ?? null;
|
|
393
|
+
if (this.#transport === null) return;
|
|
394
|
+
try {
|
|
395
|
+
this.#transport.assert(context, door);
|
|
396
|
+
} catch (error) {
|
|
397
|
+
if (error instanceof InsecureTransportError) recordAudit(this.container, {
|
|
398
|
+
category: "auth",
|
|
399
|
+
action: "login.failure",
|
|
400
|
+
outcome: "failure",
|
|
401
|
+
actor,
|
|
402
|
+
reason: "insecure_transport",
|
|
403
|
+
...info
|
|
404
|
+
});
|
|
405
|
+
throw error;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
308
408
|
#resolveThrottler() {
|
|
309
409
|
if (!this.#throttlerResolved) {
|
|
310
410
|
this.#throttler = this.get("loginThrottler") ?? null;
|
|
@@ -324,7 +424,7 @@ var AuthFlow = class extends Service {
|
|
|
324
424
|
function toSafeUser(user) {
|
|
325
425
|
return {
|
|
326
426
|
id: user.id,
|
|
327
|
-
|
|
427
|
+
identifier: user.identifier,
|
|
328
428
|
roles: [...user.roles]
|
|
329
429
|
};
|
|
330
430
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { compileProviderRoleMapping, isPlatformRole } from "../src/oauth/providerRoles.js";
|
|
1
2
|
import { SecuredArea } from "../src/SecuredArea.js";
|
|
3
|
+
import { entryPointLocation } from "../src/entryPoint.js";
|
|
2
4
|
import { LoginThrottler } from "../src/throttle/LoginThrottler.js";
|
|
5
|
+
import { InsecureTransportError } from "../errors/InsecureTransportError.js";
|
|
6
|
+
import { CredentialTransportPolicy, shouldEnforceCredentialTransport } from "../src/transport/CredentialTransportPolicy.js";
|
|
3
7
|
import { RoleHierarchyWalker } from "../src/RoleHierarchyWalker.js";
|
|
4
8
|
import { mergeCspFragments } from "../src/csp.js";
|
|
5
9
|
import { CsrfTokenManager } from "../src/csrfToken.js";
|
|
@@ -17,9 +21,10 @@ import { DEFAULT_SYSTEM_RULES, buildFrameAuthorizer, buildSystemRules } from "..
|
|
|
17
21
|
import { recordAudit } from "../src/audit/recordAudit.js";
|
|
18
22
|
import { readAuditContext } from "../src/audit/readAuditContext.js";
|
|
19
23
|
import { SECURITY_AUDIT_CHANNEL, createAuditBridge } from "../src/audit/auditBridge.js";
|
|
24
|
+
import { findZonesOpenToSignup } from "../src/openToSignup.js";
|
|
20
25
|
import { RequestContext, Service, logColor } from "nodefony";
|
|
21
|
-
import { encoderFromConfig } from "@nodefony/user";
|
|
22
26
|
import { randomBytes } from "node:crypto";
|
|
27
|
+
import { encoderFromConfig } from "@nodefony/user";
|
|
23
28
|
//#region nodefony/service/firewall.ts
|
|
24
29
|
const serviceName = "firewall";
|
|
25
30
|
/**
|
|
@@ -87,6 +92,7 @@ var Firewall = class extends Service {
|
|
|
87
92
|
#cspFragments = null;
|
|
88
93
|
#config = null;
|
|
89
94
|
#configError = null;
|
|
95
|
+
#longLivedRevalidation = null;
|
|
90
96
|
constructor(module) {
|
|
91
97
|
super(serviceName, module.container, module.notificationsCenter, module.options);
|
|
92
98
|
this.module = module;
|
|
@@ -122,6 +128,7 @@ var Firewall = class extends Service {
|
|
|
122
128
|
this.#areas = list;
|
|
123
129
|
this.#instantiateAuthenticators(list);
|
|
124
130
|
}
|
|
131
|
+
for (const z of findZonesOpenToSignup(this.#config)) this.log(`${z.message} → ${z.action}`, "WARNING");
|
|
125
132
|
if (!this.#configError) {
|
|
126
133
|
this.#wireRealtime();
|
|
127
134
|
this.log(`Firewall ready — ${this.#areas?.length ?? 0} area(s), ${this.#authenticators?.size ?? 0} authenticator(s)`, "DEBUG");
|
|
@@ -172,6 +179,10 @@ var Firewall = class extends Service {
|
|
|
172
179
|
this.log(wired ? "Realtime data plane locked — WS handshake authenticators (RBAC) + frame authorizer + audit channel wired" : "Realtime data plane locked — frame authorizer (system floor) wired without qualifying zone; system channels closed to anonymous", "DEBUG");
|
|
173
180
|
}
|
|
174
181
|
#provisionSharedServices(config) {
|
|
182
|
+
this.container?.set("credentialTransport", new CredentialTransportPolicy({
|
|
183
|
+
enforce: shouldEnforceCredentialTransport(this.kernel?.environment, config.allowInsecureCredentials),
|
|
184
|
+
log: (message) => this.log(message, "WARNING")
|
|
185
|
+
}));
|
|
175
186
|
const specs = Object.values(config.encoders);
|
|
176
187
|
if (specs.length > 0) this.container?.set("passwordEncoder", encoderFromConfig(specs));
|
|
177
188
|
const rl = config.rateLimit;
|
|
@@ -263,17 +274,27 @@ var Firewall = class extends Service {
|
|
|
263
274
|
describe() {
|
|
264
275
|
const config = this.#config;
|
|
265
276
|
const mounted = this.#authenticators;
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
+
const open = new Map((config ? findZonesOpenToSignup(config) : []).map((z) => [z.zone, z]));
|
|
278
|
+
const zones = (this.#areas ?? []).map((a) => {
|
|
279
|
+
const notice = open.get(a.name);
|
|
280
|
+
return {
|
|
281
|
+
name: a.name,
|
|
282
|
+
pattern: a.pattern.source,
|
|
283
|
+
security: a.security,
|
|
284
|
+
stateless: a.stateless,
|
|
285
|
+
mode: a.mode,
|
|
286
|
+
authenticators: [...a.authenticators],
|
|
287
|
+
allowsAnonymous: a.authenticators.includes("anonymous"),
|
|
288
|
+
host: a.host ?? null,
|
|
289
|
+
realtime: a.realtime,
|
|
290
|
+
roles: a.roles ? [...a.roles] : [],
|
|
291
|
+
openToSignup: notice !== void 0,
|
|
292
|
+
openToSignupNotice: notice ? {
|
|
293
|
+
message: notice.message,
|
|
294
|
+
action: notice.action
|
|
295
|
+
} : null
|
|
296
|
+
};
|
|
297
|
+
});
|
|
277
298
|
const available = new Set(listAuthenticatorFactories());
|
|
278
299
|
const names = new Set(available);
|
|
279
300
|
if (mounted) for (const n of mounted.keys()) names.add(n);
|
|
@@ -340,12 +361,32 @@ var Firewall = class extends Service {
|
|
|
340
361
|
const hierarchy = {};
|
|
341
362
|
for (const [role, inherited] of Object.entries(raw)) hierarchy[role] = [...inherited];
|
|
342
363
|
const walker = this.roleHierarchy;
|
|
364
|
+
const roles = Object.keys(hierarchy).map((role) => ({
|
|
365
|
+
role,
|
|
366
|
+
inherits: [...walker.reachableRoles([role])].filter((r) => r !== role).sort()
|
|
367
|
+
}));
|
|
368
|
+
const providerMappings = [];
|
|
369
|
+
for (const [provider, p] of Object.entries(this.#config?.oauth2.providers ?? {})) {
|
|
370
|
+
const mapping = compileProviderRoleMapping(p);
|
|
371
|
+
if (mapping === null) continue;
|
|
372
|
+
providerMappings.push({
|
|
373
|
+
provider,
|
|
374
|
+
sources: [...mapping.sources],
|
|
375
|
+
mappings: Object.keys(mapping.table).sort().map((from) => {
|
|
376
|
+
const to = mapping.table[from] ?? "";
|
|
377
|
+
return {
|
|
378
|
+
from,
|
|
379
|
+
to,
|
|
380
|
+
platform: isPlatformRole(to)
|
|
381
|
+
};
|
|
382
|
+
}),
|
|
383
|
+
allowPlatformRoles: p.allowPlatformRoles
|
|
384
|
+
});
|
|
385
|
+
}
|
|
343
386
|
return {
|
|
344
387
|
hierarchy,
|
|
345
|
-
roles
|
|
346
|
-
|
|
347
|
-
inherits: [...walker.reachableRoles([role])].filter((r) => r !== role).sort()
|
|
348
|
-
}))
|
|
388
|
+
roles,
|
|
389
|
+
providerMappings
|
|
349
390
|
};
|
|
350
391
|
}
|
|
351
392
|
/**
|
|
@@ -424,7 +465,7 @@ var Firewall = class extends Service {
|
|
|
424
465
|
}
|
|
425
466
|
/**
|
|
426
467
|
* Pipeline complet de la zone : chaîne d'authenticators (selon `mode`) → ALS
|
|
427
|
-
* → Zero Trust. Rejette (401, challenge RFC
|
|
468
|
+
* → Zero Trust. Rejette (401, challenge RFC 9110 §11 posé) ou résout.
|
|
428
469
|
*/
|
|
429
470
|
async handleSecurity(context) {
|
|
430
471
|
if (this.#configError) throw new AuthenticationError("Security configuration invalid");
|
|
@@ -449,17 +490,22 @@ var Firewall = class extends Service {
|
|
|
449
490
|
this.#recordAuth(context, area, "auth.unverifiable", "failure", "verifier_unavailable", null);
|
|
450
491
|
throw error;
|
|
451
492
|
}
|
|
493
|
+
if (error instanceof InsecureTransportError) {
|
|
494
|
+
this.#recordAuth(context, area, "auth.failure", "failure", "insecure_transport", null);
|
|
495
|
+
throw error;
|
|
496
|
+
}
|
|
452
497
|
this.#setChallenge(context, area);
|
|
453
498
|
this.#recordAuth(context, area, "auth.failure", "failure", "invalid_credentials", null);
|
|
454
499
|
throw error;
|
|
455
500
|
}
|
|
456
501
|
if (token === null) {
|
|
457
|
-
this.#setChallenge(context, area);
|
|
502
|
+
if (!this.#redirectToEntryPoint(context, area)) this.#setChallenge(context, area);
|
|
458
503
|
this.#recordAuth(context, area, "auth.denied", "denied", "no_credentials", null);
|
|
459
504
|
throw new AuthenticationError(`Authentication required for area "${area.name}"`);
|
|
460
505
|
}
|
|
461
506
|
RequestContext.set("user", token.getUser());
|
|
462
507
|
RequestContext.set("token", token);
|
|
508
|
+
if (token.isAuthenticated()) context.userIdentifier = token.getUserIdentifier();
|
|
463
509
|
if (!token.isAuthenticated() && token.type !== "anonymous") {
|
|
464
510
|
this.#setChallenge(context, area);
|
|
465
511
|
this.#recordAuth(context, area, "auth.denied", "denied", "unauthenticated", token.getUserIdentifier());
|
|
@@ -520,8 +566,13 @@ var Firewall = class extends Service {
|
|
|
520
566
|
* synchronizer token (`context.csrfToken`) — HttpContext pose ensuite le cookie
|
|
521
567
|
* lisible `csrf-token`. Sinon, hot-path GET = retour immédiat (aucun en-tête lu).
|
|
522
568
|
* - **Étape 1 (globale)** : sur une mutation, défense Fetch Metadata / Origin
|
|
523
|
-
* (rejet cross-site même sur route publique). Skippée
|
|
524
|
-
* auth par signature/clé)
|
|
569
|
+
* (rejet cross-site même sur route publique). Skippée SEULEMENT par `@CsrfExempt`
|
|
570
|
+
* (webhook, auth par signature/clé). `bypassFirewall` ne l'en dispense PAS : il
|
|
571
|
+
* retire l'authentification, pas la provenance — les routes de connexion sont
|
|
572
|
+
* en bypass, et c'est précisément là qu'un formulaire tiers ouvrirait chez la
|
|
573
|
+
* victime une session sur le compte de l'attaquant (login CSRF, CWE-352). Un
|
|
574
|
+
* appel de serveur à serveur (canal arrière OIDC) ne porte ni `Sec-Fetch-Site`
|
|
575
|
+
* ni `Origin` : il passe toujours.
|
|
525
576
|
* - **Étape 2 (opt-in)** : sur une mutation `@CsrfProtect`, exige EN PLUS le
|
|
526
577
|
* synchronizer token (en-tête `x-csrf-token` ≡ cookie + HMAC valide).
|
|
527
578
|
*
|
|
@@ -529,7 +580,6 @@ var Firewall = class extends Service {
|
|
|
529
580
|
*/
|
|
530
581
|
enforceCsrf(context) {
|
|
531
582
|
if (!this.#csrf) return;
|
|
532
|
-
if (context.resolver?.bypassFirewall) return;
|
|
533
583
|
const ctx = context;
|
|
534
584
|
if (!Csrf.isStateChanging(context.method)) {
|
|
535
585
|
if (ctx.csrfProtect && this.#csrfTokens && ctx.csrfToken == null) ctx.csrfToken = this.#csrfTokens.issue();
|
|
@@ -577,6 +627,18 @@ var Firewall = class extends Service {
|
|
|
577
627
|
return isPreflight ? 204 : void 0;
|
|
578
628
|
}
|
|
579
629
|
/**
|
|
630
|
+
* Le revalidateur de l'identité de la requête courante, pour une connexion
|
|
631
|
+
* longue qui la gardera (flux SSE). Même règle, même store de jetons que les
|
|
632
|
+
* sockets WebSocket — un transport qui en aurait une autre serait celui
|
|
633
|
+
* qu'un attaquant prendrait pour survivre à sa révocation.
|
|
634
|
+
*
|
|
635
|
+
* @returns `null` pour un anonyme ; sinon la fonction qui re-valide.
|
|
636
|
+
*/
|
|
637
|
+
currentRevalidator() {
|
|
638
|
+
this.#longLivedRevalidation ??= new FirewallRealtimeAuthenticator(() => this.container?.get("tokenStore") ?? null);
|
|
639
|
+
return this.#longLivedRevalidation.currentRevalidator();
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
580
642
|
* En-têtes de sécurité APPLICATIFS (P6 J5) — CSP, Referrer-Policy, isolation
|
|
581
643
|
* cross-origin (COOP/COEP/CORP), Origin-Agent-Cluster, Permissions-Policy.
|
|
582
644
|
* Posés sur toute réponse du pipeline (branché dans `handleHttp`). Complète le
|
|
@@ -690,6 +752,34 @@ var Firewall = class extends Service {
|
|
|
690
752
|
}
|
|
691
753
|
return token;
|
|
692
754
|
}
|
|
755
|
+
/**
|
|
756
|
+
* Pose la redirection vers la page de connexion de la zone, quand la requête
|
|
757
|
+
* refusée est la navigation d'un navigateur. Cold path (refus seulement).
|
|
758
|
+
*
|
|
759
|
+
* La réponse est seulement MARQUÉE (`302` + `Location`) : le refus continue
|
|
760
|
+
* de remonter, et http rend une réponse déjà redirigée telle quelle au lieu
|
|
761
|
+
* d'un 401. Capability check : un contexte sans `redirect` (WebSocket) garde
|
|
762
|
+
* son refus.
|
|
763
|
+
*
|
|
764
|
+
* @returns `true` si la redirection est posée
|
|
765
|
+
*/
|
|
766
|
+
#redirectToEntryPoint(context, area) {
|
|
767
|
+
if (area.entryPoint === void 0 || !("redirect" in context)) return false;
|
|
768
|
+
const request = context.request;
|
|
769
|
+
const header = (name) => {
|
|
770
|
+
const value = request.headers[name];
|
|
771
|
+
return typeof value === "string" ? value : void 0;
|
|
772
|
+
};
|
|
773
|
+
const location = entryPointLocation(area.entryPoint, {
|
|
774
|
+
method: request.method,
|
|
775
|
+
accept: header("accept"),
|
|
776
|
+
fetchMode: header("sec-fetch-mode"),
|
|
777
|
+
path: request.url.pathname + request.url.search
|
|
778
|
+
});
|
|
779
|
+
if (location === null) return false;
|
|
780
|
+
context.redirect(location, 302);
|
|
781
|
+
return true;
|
|
782
|
+
}
|
|
693
783
|
#setChallenge(context, area) {
|
|
694
784
|
const response = context.response;
|
|
695
785
|
if (typeof response?.setHeader !== "function") return;
|