@nodefony/security 10.0.0-beta.5 → 10.0.0-beta.7
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 +24 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +63 -61
- package/dist/nodefony/command/security-mcp-token.js +43 -5
- package/dist/nodefony/config/config.js +30 -11
- package/dist/nodefony/config/defineModuleConfig.js +52 -1
- package/dist/nodefony/contracts/IWebhookPayload.js +1 -0
- package/dist/nodefony/errors/FetchPublicError.js +18 -0
- package/dist/nodefony/errors/SsrfError.js +9 -1
- package/dist/nodefony/errors/StaleSessionError.js +20 -0
- package/dist/nodefony/errors/index.js +7 -5
- package/dist/nodefony/keycloak/keycloakAdmin.js +3 -3
- package/dist/nodefony/keycloak/scaffold.js +31 -14
- package/dist/nodefony/keycloak/security-keycloak-realm.js +1 -1
- package/dist/nodefony/service/accessTokenVerifier.js +6 -6
- package/dist/nodefony/service/apiKeys.js +8 -4
- package/dist/nodefony/service/auditService.js +48 -3
- package/dist/nodefony/service/authFlow.js +9 -6
- package/dist/nodefony/service/authorization.js +1 -1
- package/dist/nodefony/service/firewall.js +281 -44
- package/dist/nodefony/service/keycloak.js +1 -1
- package/dist/nodefony/service/oauth2.js +8 -4
- package/dist/nodefony/service/securityHeaders.js +340 -47
- package/dist/nodefony/service/tokenService.js +16 -11
- package/dist/nodefony/service/totp.js +10 -5
- package/dist/nodefony/service/webAuthn.js +46 -7
- package/dist/nodefony/service/webhooks.js +11 -4
- package/dist/nodefony/src/admin/SecurityAdminApi.js +19 -0
- package/dist/nodefony/src/audit/AuditRefusalAggregator.js +139 -0
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +1 -1
- package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +31 -7
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +62 -20
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +29 -6
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +22 -9
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +2 -2
- package/dist/nodefony/src/csp.js +265 -10
- package/dist/nodefony/src/cspReports.js +307 -0
- package/dist/nodefony/src/net/fetchPublic.js +244 -0
- package/dist/nodefony/src/net/ssrfGuard.js +2 -2
- package/dist/nodefony/src/oauth/clientCredentials.js +1 -1
- package/dist/nodefony/src/oauth/metadata.js +1 -1
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +17 -4
- package/dist/nodefony/src/oauth/providers/github.js +1 -1
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +59 -2
- package/dist/nodefony/src/realtime/frameAuthorizer.js +136 -44
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +8 -2
- package/dist/nodefony/src/token/jwtRuntime.js +22 -2
- package/dist/nodefony/src/token/tokenTimeClaims.js +34 -0
- package/dist/nodefony/src/totp/totpOperations.js +1 -1
- package/dist/nodefony/src/voter/RoleVoter.js +1 -1
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +71 -17
- package/dist/nodefony/src/webhook/webhookDelivery.js +37 -86
- package/dist/nodefony/testing/index.js +26 -36
- package/dist/types/index.d.ts +5 -1
- package/dist/types/nodefony/command/security-mcp-token.d.ts +12 -0
- package/dist/types/nodefony/config/config.d.ts +8 -0
- package/dist/types/nodefony/contracts/IApiKey.d.ts +8 -2
- package/dist/types/nodefony/contracts/IFirewall.d.ts +15 -2
- package/dist/types/nodefony/contracts/ITokenStore.d.ts +7 -3
- package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +13 -3
- package/dist/types/nodefony/contracts/IWebhookPayload.d.ts +24 -0
- package/dist/types/nodefony/errors/FetchPublicError.d.ts +15 -0
- package/dist/types/nodefony/errors/SsrfError.d.ts +8 -1
- package/dist/types/nodefony/errors/StaleSessionError.d.ts +17 -0
- package/dist/types/nodefony/errors/index.d.ts +3 -0
- package/dist/types/nodefony/keycloak/scaffold.d.ts +12 -6
- package/dist/types/nodefony/service/apiKeys.d.ts +3 -0
- package/dist/types/nodefony/service/authFlow.d.ts +2 -2
- package/dist/types/nodefony/service/firewall.d.ts +28 -7
- package/dist/types/nodefony/service/securityHeaders.d.ts +82 -11
- package/dist/types/nodefony/service/webAuthn.d.ts +13 -0
- package/dist/types/nodefony/service/webhooks.d.ts +7 -1
- package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +3 -0
- package/dist/types/nodefony/src/audit/AuditRefusalAggregator.d.ts +55 -0
- package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +11 -0
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +17 -3
- package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +11 -4
- package/dist/types/nodefony/src/csp.d.ts +142 -0
- package/dist/types/nodefony/src/cspReports.d.ts +73 -0
- package/dist/types/nodefony/src/net/fetchPublic.d.ts +73 -0
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +15 -0
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +35 -1
- package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +10 -2
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +42 -18
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +21 -2
- package/dist/types/nodefony/src/token/tokenTimeClaims.d.ts +32 -0
- package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +36 -1
- package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +5 -2
- package/dist/types/nodefony/testing/index.d.ts +6 -0
- package/docs/api-keys.md +3 -1
- package/docs/audit.md +112 -28
- package/docs/authenticators.md +6 -4
- package/docs/csrf.md +3 -2
- package/docs/external-jwt.md +5 -1
- package/docs/firewall.md +3 -2
- package/docs/headers.md +427 -71
- package/docs/keycloak.md +4 -4
- package/docs/login-page.md +4 -3
- package/docs/obtenir-un-jeton.md +17 -10
- package/docs/tokens.md +1 -1
- package/docs/totp.md +2 -2
- package/docs/webauthn.md +2 -2
- package/docs/webhooks.md +63 -18
- package/package.json +17 -17
|
@@ -155,6 +155,9 @@ function toCredentialView(c) {
|
|
|
155
155
|
* - `GET /nodefony/security/api/firewall` — introspection du firewall (zones,
|
|
156
156
|
* authenticators montés, défenses) — état RUNTIME, secrets exclus.
|
|
157
157
|
* - `GET /nodefony/security/api/roleHierarchy` — hiérarchie de rôles + résolution.
|
|
158
|
+
* - `GET /nodefony/security/api/csp-reports` — violations CSP reçues (ADR-0024
|
|
159
|
+
* §C7), dédoublonnées, la plus récente d’abord ; champs écrits par le
|
|
160
|
+
* navigateur, donc à afficher comme du texte.
|
|
158
161
|
* - `GET /nodefony/security/api/apikeys` — toutes les clés API (gouvernance),
|
|
159
162
|
* `GET …/apikeys/status` — backend du token store (« où on écrit »),
|
|
160
163
|
* `POST …/apikeys/{id}/revoke` — révocation par id (réponse à incident).
|
|
@@ -222,6 +225,22 @@ function createSecurityAdminApi(container) {
|
|
|
222
225
|
};
|
|
223
226
|
}
|
|
224
227
|
},
|
|
228
|
+
{
|
|
229
|
+
path: "csp-reports",
|
|
230
|
+
method: "GET",
|
|
231
|
+
role: "ROLE_NODEFONY_ADMIN",
|
|
232
|
+
summary: "Violations de la politique CSP rapportées par les navigateurs (dédoublonnées, URL expurgées, extrait jamais gardé). Champs écrits par le navigateur — à afficher comme du texte.",
|
|
233
|
+
handler: () => {
|
|
234
|
+
return container.get("cspReports")?.snapshot() ?? {
|
|
235
|
+
enabled: false,
|
|
236
|
+
endpoint: null,
|
|
237
|
+
capacity: 0,
|
|
238
|
+
received: 0,
|
|
239
|
+
rejected: 0,
|
|
240
|
+
items: []
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
},
|
|
225
244
|
{
|
|
226
245
|
path: "roleHierarchy",
|
|
227
246
|
method: "GET",
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// nodefony/src/audit/AuditRefusalAggregator.ts
|
|
2
|
+
/** IP de la clé de repli, quand `maxKeys` est atteint. */
|
|
3
|
+
const OVERFLOW_IP = "*";
|
|
4
|
+
const SEP = "";
|
|
5
|
+
/**
|
|
6
|
+
* Une clé suivie : le gabarit de la ligne de synthèse et les compteurs de la
|
|
7
|
+
* fenêtre en cours. Champs déclarés au constructeur (forme stable).
|
|
8
|
+
*/
|
|
9
|
+
var RefusalBucket = class {
|
|
10
|
+
start;
|
|
11
|
+
count = 0;
|
|
12
|
+
suppressed = 0;
|
|
13
|
+
firstSuppressed = 0;
|
|
14
|
+
lastSuppressed = 0;
|
|
15
|
+
category;
|
|
16
|
+
action;
|
|
17
|
+
outcome;
|
|
18
|
+
resource;
|
|
19
|
+
reason;
|
|
20
|
+
ip;
|
|
21
|
+
constructor(draft, ip, now) {
|
|
22
|
+
this.start = now;
|
|
23
|
+
this.category = draft.category;
|
|
24
|
+
this.action = draft.action;
|
|
25
|
+
this.outcome = draft.outcome;
|
|
26
|
+
this.resource = draft.resource ?? null;
|
|
27
|
+
this.reason = draft.reason ?? null;
|
|
28
|
+
this.ip = ip;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Agrège les REFUS du journal d'audit par source, pour qu'un flot ne se paie pas
|
|
33
|
+
* d'une ligne par requête (OWASP Logging Cheat Sheet : la journalisation ne doit
|
|
34
|
+
* pas épuiser les ressources ni le journal de la base).
|
|
35
|
+
*
|
|
36
|
+
* Clé : catégorie, action, motif, ressource et IP — l'IP résolue par le contexte
|
|
37
|
+
* (`trustProxy`), donc non forgeable par le client. L'acteur n'entre PAS dans la
|
|
38
|
+
* clé : un bourrage d'identifiants change d'acteur à chaque essai, c'est sa
|
|
39
|
+
* source qui le trahit.
|
|
40
|
+
*
|
|
41
|
+
* Par clé et par fenêtre (ouverte au premier refus) : les `burst` premiers refus
|
|
42
|
+
* sont écrits tels quels — l'enquêteur voit les premiers essais, acteur compris ;
|
|
43
|
+
* les suivants sont seulement comptés, et la fenêtre close émet UNE ligne de
|
|
44
|
+
* synthèse (`metadata.aggregated`). Mémoire bornée : au-delà de `maxKeys` clés,
|
|
45
|
+
* les refus se regroupent sous l'IP `*`.
|
|
46
|
+
*
|
|
47
|
+
* Pur : l'horloge est passée à chaque appel, la synthèse part par `emit`. Les
|
|
48
|
+
* succès n'y passent jamais (l'appelant les écarte).
|
|
49
|
+
*/
|
|
50
|
+
var AuditRefusalAggregator = class {
|
|
51
|
+
#buckets = /* @__PURE__ */ new Map();
|
|
52
|
+
#options;
|
|
53
|
+
#emit;
|
|
54
|
+
/**
|
|
55
|
+
* @param options - fenêtre, rafale écrite telle quelle, borne de clés.
|
|
56
|
+
* @param emit - écrit une ligne de synthèse dans le journal.
|
|
57
|
+
*/
|
|
58
|
+
constructor(options, emit) {
|
|
59
|
+
this.#options = options;
|
|
60
|
+
this.#emit = emit;
|
|
61
|
+
}
|
|
62
|
+
/** Nombre de clés suivies (fenêtres ouvertes). */
|
|
63
|
+
get size() {
|
|
64
|
+
return this.#buckets.size;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Compte un refus.
|
|
68
|
+
*
|
|
69
|
+
* @param draft - l'événement de refus.
|
|
70
|
+
* @param now - horloge, en ms.
|
|
71
|
+
* @returns `true` : l'écrire tel quel ; `false` : il est compté dans la synthèse.
|
|
72
|
+
*/
|
|
73
|
+
admit(draft, now) {
|
|
74
|
+
const prefix = draft.category + SEP + draft.action + SEP + draft.outcome + SEP + (draft.reason ?? "") + SEP + (draft.resource ?? "") + SEP;
|
|
75
|
+
const ip = draft.ip ?? null;
|
|
76
|
+
let key = prefix + (ip ?? "");
|
|
77
|
+
let bucket = this.#buckets.get(key);
|
|
78
|
+
if (bucket === void 0) {
|
|
79
|
+
let bucketIp = ip;
|
|
80
|
+
if (this.#buckets.size >= this.#options.maxKeys) {
|
|
81
|
+
key = prefix + OVERFLOW_IP;
|
|
82
|
+
bucketIp = OVERFLOW_IP;
|
|
83
|
+
bucket = this.#buckets.get(key);
|
|
84
|
+
}
|
|
85
|
+
if (bucket === void 0) {
|
|
86
|
+
bucket = new RefusalBucket(draft, bucketIp, now);
|
|
87
|
+
this.#buckets.set(key, bucket);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (now - bucket.start >= this.#options.windowMs) {
|
|
91
|
+
this.#close(bucket);
|
|
92
|
+
bucket.start = now;
|
|
93
|
+
bucket.count = 0;
|
|
94
|
+
bucket.suppressed = 0;
|
|
95
|
+
}
|
|
96
|
+
bucket.count += 1;
|
|
97
|
+
if (bucket.count <= this.#options.burst) return true;
|
|
98
|
+
if (bucket.suppressed === 0) bucket.firstSuppressed = now;
|
|
99
|
+
bucket.suppressed += 1;
|
|
100
|
+
bucket.lastSuppressed = now;
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Ferme les fenêtres échues : émet leur synthèse et libère leurs clés.
|
|
105
|
+
*
|
|
106
|
+
* @param now - horloge, en ms.
|
|
107
|
+
*/
|
|
108
|
+
sweep(now) {
|
|
109
|
+
for (const [key, bucket] of this.#buckets) {
|
|
110
|
+
if (now - bucket.start < this.#options.windowMs) continue;
|
|
111
|
+
this.#close(bucket);
|
|
112
|
+
this.#buckets.delete(key);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Émet la synthèse de toutes les fenêtres ouvertes (arrêt) et vide la table. */
|
|
116
|
+
flush() {
|
|
117
|
+
for (const bucket of this.#buckets.values()) this.#close(bucket);
|
|
118
|
+
this.#buckets.clear();
|
|
119
|
+
}
|
|
120
|
+
#close(bucket) {
|
|
121
|
+
if (bucket.suppressed === 0) return;
|
|
122
|
+
this.#emit({
|
|
123
|
+
category: bucket.category,
|
|
124
|
+
action: bucket.action,
|
|
125
|
+
outcome: bucket.outcome,
|
|
126
|
+
actor: null,
|
|
127
|
+
resource: bucket.resource,
|
|
128
|
+
reason: bucket.reason,
|
|
129
|
+
ip: bucket.ip,
|
|
130
|
+
metadata: { aggregated: {
|
|
131
|
+
count: bucket.suppressed,
|
|
132
|
+
firstTs: bucket.firstSuppressed,
|
|
133
|
+
lastTs: bucket.lastSuppressed,
|
|
134
|
+
windowMs: this.#options.windowMs
|
|
135
|
+
} }
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
export { AuditRefusalAggregator };
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { AuthenticationError } from "../../errors/AuthenticationError.js";
|
|
2
2
|
import { isBoundToAccount } from "../accountBinding.js";
|
|
3
3
|
import { UserToken } from "../token/UserToken.js";
|
|
4
|
-
import { bearerToken } from "./bearer.js";
|
|
5
4
|
import { looksLikeApiKey, parseApiKey } from "../apikey/apiKeyFormat.js";
|
|
5
|
+
import { bearerToken } from "./bearer.js";
|
|
6
6
|
// nodefony/src/authenticator/ApiKeyAuthenticator.ts
|
|
7
7
|
const INVALID_TOKEN = "Invalid token";
|
|
8
8
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { compileProviderRoleMapping, mapProviderRoles } from "../oauth/providerRoles.js";
|
|
2
1
|
import { AuthenticationError } from "../../errors/AuthenticationError.js";
|
|
3
2
|
import { UnverifiableTokenError } from "../../errors/UnverifiableTokenError.js";
|
|
4
3
|
import { localIdentifierFor } from "./externalSubject.js";
|
|
4
|
+
import { compileProviderRoleMapping, mapProviderRoles } from "../oauth/providerRoles.js";
|
|
5
5
|
import { canonicalIssuer } from "nodefony";
|
|
6
6
|
import { BaseUser } from "@nodefony/user";
|
|
7
7
|
// nodefony/src/authenticator/ExternalSubjectResolver.ts
|
|
@@ -11,6 +11,25 @@ import { BaseUser } from "@nodefony/user";
|
|
|
11
11
|
*/
|
|
12
12
|
const INVALID_TOKEN = "Invalid token";
|
|
13
13
|
/**
|
|
14
|
+
* Les utilisateurs FABRIQUÉS en mode `ephemeral` — aucun compte local derrière.
|
|
15
|
+
* Faible : l'entrée meurt avec l'utilisateur, à la fin de sa requête ou de sa
|
|
16
|
+
* socket ; rien n'est à nettoyer.
|
|
17
|
+
*/
|
|
18
|
+
const EPHEMERAL_SUBJECTS = /* @__PURE__ */ new WeakSet();
|
|
19
|
+
/**
|
|
20
|
+
* Cet utilisateur a-t-il été fabriqué pour un appelant machine SANS compte local (mode `ephemeral`) ?
|
|
21
|
+
*
|
|
22
|
+
* L'identité d'admission temps réel en tire `account: false` : rien ne
|
|
23
|
+
* pourrait relire cet appelant par son `id` (`<émetteur>#<sujet>`), et un
|
|
24
|
+
* abonnement durable ne doit pas s'y adosser (ADR-0021 §S10 point 6). Lu sur
|
|
25
|
+
* le FAIT (l'objet vient du résolveur), jamais déduit de la forme de `id`.
|
|
26
|
+
*
|
|
27
|
+
* @param user - utilisateur résolu par le pare-feu.
|
|
28
|
+
*/
|
|
29
|
+
function isEphemeralSubject(user) {
|
|
30
|
+
return EPHEMERAL_SUBJECTS.has(user);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
14
33
|
* Les options de rattachement des sujets externes, lues dans la configuration
|
|
15
34
|
* de sécurité — UNE lecture, pour la zone `external-jwt` comme pour le
|
|
16
35
|
* résolveur de compte d'une ressource protégée.
|
|
@@ -105,12 +124,17 @@ var ExternalSubjectResolver = class {
|
|
|
105
124
|
async resolve(issuer, subject, claims, area) {
|
|
106
125
|
const mapping = this.#issuers.get(issuer);
|
|
107
126
|
if (mapping === void 0) throw new UnverifiableTokenError(`émetteur « ${issuer} » vérifié mais absent de la table de rattachement des sujets — configuration incohérente.`);
|
|
127
|
+
if ((area?.subjectPolicy ?? this.#policy) === "ephemeral") {
|
|
128
|
+
const identifier = localIdentifierFor(issuer, subject, "prefixed");
|
|
129
|
+
const ephemeral = new BaseUser({
|
|
130
|
+
id: identifier,
|
|
131
|
+
identifier,
|
|
132
|
+
roles: [...area?.ephemeralRoles ?? this.#ephemeralRoles]
|
|
133
|
+
});
|
|
134
|
+
EPHEMERAL_SUBJECTS.add(ephemeral);
|
|
135
|
+
return ephemeral;
|
|
136
|
+
}
|
|
108
137
|
const identifier = localIdentifierFor(issuer, subject, mapping);
|
|
109
|
-
if ((area?.subjectPolicy ?? this.#policy) === "ephemeral") return new BaseUser({
|
|
110
|
-
id: identifier,
|
|
111
|
-
identifier,
|
|
112
|
-
roles: [...area?.ephemeralRoles ?? this.#ephemeralRoles]
|
|
113
|
-
});
|
|
114
138
|
const provider = this.#resolveUserProvider();
|
|
115
139
|
let user = null;
|
|
116
140
|
const linked = this.#linkedProviders?.get(issuer);
|
|
@@ -177,4 +201,4 @@ function linkProviders(trusted, providers) {
|
|
|
177
201
|
for (const issuer of ambiguous) links.delete(issuer);
|
|
178
202
|
return links.size > 0 ? links : null;
|
|
179
203
|
}
|
|
180
|
-
export { ExternalSubjectResolver, externalSubjectOptions };
|
|
204
|
+
export { ExternalSubjectResolver, externalSubjectOptions, isEphemeralSubject };
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import { AuthenticationError } from "../../errors/AuthenticationError.js";
|
|
2
1
|
import { isBoundToAccount } from "../accountBinding.js";
|
|
3
|
-
import {
|
|
2
|
+
import { AuthenticationError } from "../../errors/AuthenticationError.js";
|
|
3
|
+
import { LOCAL_ACCOUNT, UserRealtimeToken } from "../realtime/UserRealtimeToken.js";
|
|
4
|
+
import { isEphemeralSubject } from "./ExternalSubjectResolver.js";
|
|
5
|
+
import { tokenStatusOf } from "../token/tokenStatus.js";
|
|
4
6
|
import { Nodefony, RequestContext } from "nodefony";
|
|
5
7
|
import { authenticatedUser } from "@nodefony/user";
|
|
6
8
|
// nodefony/src/authenticator/FirewallRealtimeAuthenticator.ts
|
|
9
|
+
/** Le verdict booléen d'un état : seul `active` fait vivre l'identité. */
|
|
10
|
+
function toValidity(status) {
|
|
11
|
+
return async (nowMs) => await status(nowMs) === "active";
|
|
12
|
+
}
|
|
7
13
|
/**
|
|
8
14
|
* Authenticator realtime des identités résolues par le **firewall** — équivalent
|
|
9
15
|
* WS de tout ce que le pipeline HTTP sait authentifier.
|
|
@@ -90,9 +96,10 @@ var FirewallRealtimeAuthenticator = class {
|
|
|
90
96
|
if (user === null) throw new AuthenticationError("Invalid realtime session");
|
|
91
97
|
const issued = readFirewallToken();
|
|
92
98
|
const type = issuedType(issued);
|
|
93
|
-
const
|
|
99
|
+
const status = type === "session" ? null : this.#buildBearerStatus(issued, user.identifier, type);
|
|
100
|
+
const revalidate = status === null ? buildSessionRevalidator(user.identifier) : toValidity(status);
|
|
94
101
|
const scopes = issued ? issued.getScopes() : void 0;
|
|
95
|
-
return new UserRealtimeToken(user, revalidate, type, scopes, this.#accountConfirmationFor(user, type));
|
|
102
|
+
return new UserRealtimeToken(user, revalidate, type, scopes, this.#accountConfirmationFor(user, type), status, admissionSourceOf(issued, user));
|
|
96
103
|
}
|
|
97
104
|
/**
|
|
98
105
|
* Le revalidateur de l'identité que le firewall a posée sur la requête
|
|
@@ -140,46 +147,58 @@ var FirewallRealtimeAuthenticator = class {
|
|
|
140
147
|
};
|
|
141
148
|
}
|
|
142
149
|
#revalidatorFor(user, issued, type) {
|
|
143
|
-
return type === "session" ? buildSessionRevalidator(user.identifier) : this.#
|
|
150
|
+
return type === "session" ? buildSessionRevalidator(user.identifier) : toValidity(this.#buildBearerStatus(issued, user.identifier, type));
|
|
144
151
|
}
|
|
145
152
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
153
|
+
* État d'une identité portée par un **jeton** (JWT, clé API, OAuth) : rejoue les
|
|
154
|
+
* trois conditions qui tuent un jeton, sans jamais parler de session, et en rend
|
|
155
|
+
* la CAUSE — `expired` ferme la socket en 4006, `revoked` en 4001 (ADR-0021 §S11).
|
|
156
|
+
*
|
|
157
|
+
* **Révoqué l'emporte sur expiré** : la liste de refus est interrogée AVANT de
|
|
158
|
+
* conclure à l'expiration, et l'ordre est celui de `tokenStatusOf`, appelé
|
|
159
|
+
* plutôt que recopié. Un jeton révoqué puis arrivé à `exp` ferme donc en 4001.
|
|
148
160
|
*
|
|
149
161
|
* `exp` est vérifié **sans personne** (la borne voyage dans le jeton) ; le store
|
|
150
162
|
* n'ajoute que la révocation *avant terme*. C'est pourquoi un store en panne ne
|
|
151
163
|
* coupe pas les sockets d'agents : ce serait une panne de disponibilité déguisée
|
|
152
164
|
* en mesure de sécurité, alors que la borne du jeton tient toujours.
|
|
153
165
|
*/
|
|
154
|
-
#
|
|
155
|
-
const claims = issued
|
|
166
|
+
#buildBearerStatus(issued, identifier, type) {
|
|
167
|
+
const claims = issued?.getAttribute("claims") ?? EMPTY_CLAIMS;
|
|
156
168
|
const expMs = secondsToMs(claims.exp);
|
|
157
169
|
const iatMs = secondsToMs(claims.iat);
|
|
158
|
-
const jti = issued
|
|
170
|
+
const jti = issued?.getAttribute("jti") ?? (typeof claims.jti === "string" ? claims.jti : null);
|
|
159
171
|
const resolveStore = this.#resolveStore;
|
|
160
172
|
if (expMs === null && (resolveStore === null || jti === null && iatMs === null)) {
|
|
161
173
|
Nodefony.getKernel()?.log(`Realtime ${type} token "${identifier}" carries neither an expiry nor any revocation handle ("jti"/"iat") — connection will be revoked (fail-closed). Ensure the authenticator forwards the token claims.`, "WARNING");
|
|
162
|
-
return () => Promise.resolve(
|
|
174
|
+
return () => Promise.resolve("revoked");
|
|
163
175
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (!resolveStore) return
|
|
176
|
+
/** `true` révoqué, `false` non, `null` = le store n'a pas pu répondre. */
|
|
177
|
+
const isRevoked = async () => {
|
|
178
|
+
if (!resolveStore) return false;
|
|
167
179
|
let store;
|
|
168
180
|
try {
|
|
169
181
|
store = resolveStore();
|
|
170
182
|
} catch {
|
|
171
|
-
return
|
|
183
|
+
return null;
|
|
172
184
|
}
|
|
173
|
-
if (!store) return
|
|
185
|
+
if (!store) return null;
|
|
174
186
|
try {
|
|
175
|
-
if (jti !== null && await store.isJtiDenied(jti)) return
|
|
187
|
+
if (jti !== null && await store.isJtiDenied(jti)) return true;
|
|
176
188
|
const invalidBefore = await store.getInvalidBefore(identifier);
|
|
177
|
-
|
|
178
|
-
return true;
|
|
189
|
+
return invalidBefore !== null && iatMs !== null && iatMs < invalidBefore;
|
|
179
190
|
} catch {
|
|
180
|
-
return
|
|
191
|
+
return null;
|
|
181
192
|
}
|
|
182
193
|
};
|
|
194
|
+
return async (nowMs = Date.now()) => {
|
|
195
|
+
const revoked = await isRevoked();
|
|
196
|
+
if (revoked === null && expMs === null) return "revoked";
|
|
197
|
+
return tokenStatusOf({
|
|
198
|
+
revokedAt: revoked === true ? nowMs : null,
|
|
199
|
+
expiresAt: expMs
|
|
200
|
+
}, nowMs);
|
|
201
|
+
};
|
|
183
202
|
}
|
|
184
203
|
};
|
|
185
204
|
const EMPTY_CLAIMS = Object.freeze({});
|
|
@@ -200,6 +219,29 @@ function secondsToMs(value) {
|
|
|
200
219
|
function issuedType(issued) {
|
|
201
220
|
return typeof issued?.type === "string" && issued.type ? issued.type : "session";
|
|
202
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* Émetteur et compte local de l'identité, pour l'identité d'admission temps réel (ADR-0021 §S10).
|
|
224
|
+
*
|
|
225
|
+
* L'émetteur est celui que l'authentificateur a VÉRIFIÉ et posé sur le jeton
|
|
226
|
+
* (attribut `issuer` : JWT de l'application, JWT tiers) ; `null` sans jeton
|
|
227
|
+
* porteur (session, clé d'API). Le compte est absent pour le seul appelant
|
|
228
|
+
* machine fabriqué en mode `ephemeral` — constaté sur l'objet, jamais déduit
|
|
229
|
+
* de la forme de son `id`.
|
|
230
|
+
*/
|
|
231
|
+
function admissionSourceOf(issued, user) {
|
|
232
|
+
const issuer = issued?.getAttribute("issuer");
|
|
233
|
+
const account = !isEphemeralSubject(user);
|
|
234
|
+
if (typeof issuer !== "string" || issuer === "") return account ? LOCAL_ACCOUNT : EPHEMERAL_WITHOUT_ISSUER;
|
|
235
|
+
return {
|
|
236
|
+
issuer,
|
|
237
|
+
account
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
/** Filet : un sujet éphémère sans émetteur posé (ne devrait pas exister). */
|
|
241
|
+
const EPHEMERAL_WITHOUT_ISSUER = Object.freeze({
|
|
242
|
+
issuer: null,
|
|
243
|
+
account: false
|
|
244
|
+
});
|
|
203
245
|
/** Le jeton posé dans l'ALS par `firewall.handleSecurity`, s'il y en a un. */
|
|
204
246
|
function readFirewallToken() {
|
|
205
247
|
return RequestContext.get()?.token;
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { AuthenticationError } from "../../errors/AuthenticationError.js";
|
|
2
|
-
import { loadBoundAccount } from "../accountBinding.js";
|
|
3
2
|
import { UserToken } from "../token/UserToken.js";
|
|
3
|
+
import { defaultAudience } from "../token/jwtRuntime.js";
|
|
4
|
+
import { REQUIRED_TIME_CLAIMS, isIssuedInFuture } from "../token/tokenTimeClaims.js";
|
|
4
5
|
import { bearerToken } from "./bearer.js";
|
|
5
6
|
import peekIssuer from "./peekIssuer.js";
|
|
7
|
+
import { loadBoundAccount } from "../accountBinding.js";
|
|
6
8
|
import { canonicalIssuer } from "nodefony";
|
|
7
9
|
// nodefony/src/authenticator/JwtAuthenticator.ts
|
|
8
10
|
const COMPACT_JWS = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$/;
|
|
9
11
|
const INVALID_TOKEN = "Invalid token";
|
|
12
|
+
const AUDIENCE = "audience";
|
|
10
13
|
/**
|
|
11
14
|
* Authentification par **JWT Bearer** (RFC 6750) — réservée API service↔service /
|
|
12
15
|
* agents (le web utilise la session BFF). Vérifie un access token EdDSA signé par
|
|
@@ -18,7 +21,8 @@ const INVALID_TOKEN = "Invalid token";
|
|
|
18
21
|
* - **clé par `kid` depuis le keyset LOCAL** (`createLocalJWKSet`) — jamais
|
|
19
22
|
* `jku`/`jwk` de l'en-tête (injection de clé / SSRF, §3.5).
|
|
20
23
|
* - **`aud` (§3.9) + `iss` (§3.8) obligatoires** + `typ:"at+jwt"` (§3.11, sépare
|
|
21
|
-
* access et refresh) + `exp`/`nbf` (jose).
|
|
24
|
+
* access et refresh) + `exp`/`nbf` (jose). L'audience est celle de la ZONE
|
|
25
|
+
* (`resource`, ou l'audience par défaut), jamais toute la liste `jwt.audiences`.
|
|
22
26
|
* - **révocation** : denylist `jti` + seuil `invalidBefore` par porteur (le JWT
|
|
23
27
|
* est auto-porté et non révocable sans état serveur).
|
|
24
28
|
* - **sujet revérifié** (§3.10) : `loadUserByIdentifier(sub)` → compte disparu,
|
|
@@ -67,15 +71,31 @@ var JwtAuthenticator = class {
|
|
|
67
71
|
const issuer = peekIssuer(token);
|
|
68
72
|
return issuer === null || issuer === this.#runtime.issuer;
|
|
69
73
|
}
|
|
70
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* Extrait le token brut (non vérifié) → porté par un `UserToken` type `"jwt"`,
|
|
76
|
+
* avec la ressource de la zone quand elle en déclare une.
|
|
77
|
+
*
|
|
78
|
+
* L'audience transite par le token parce que `authenticate()` ne reçoit pas
|
|
79
|
+
* le contexte (même patron que `ExternalJwtAuthenticator`). Rien n'est posé
|
|
80
|
+
* pour une zone sans ressource : l'audience par défaut s'y applique.
|
|
81
|
+
*/
|
|
71
82
|
createToken(context) {
|
|
72
83
|
const auth = context.request?.headers.authorization;
|
|
73
|
-
|
|
84
|
+
const token = new UserToken("jwt", bearerToken(auth) ?? "");
|
|
85
|
+
const zone = context.security;
|
|
86
|
+
if (zone && "resource" in zone && typeof zone.resource === "string" && zone.resource.length > 0) token.setAttribute(AUDIENCE, zone.resource);
|
|
87
|
+
return Promise.resolve(token);
|
|
74
88
|
}
|
|
75
89
|
/**
|
|
76
90
|
* Vérifie la signature + les claims du JWT, applique la révocation et résout le
|
|
77
91
|
* sujet — ou lève un 401 au message uniforme.
|
|
78
92
|
*
|
|
93
|
+
* L'audience exigée est celle de la ZONE, jamais la liste blanche d'émission
|
|
94
|
+
* entière (RFC 8707, RFC 9068 §4) : la `resource` de la zone si elle en
|
|
95
|
+
* déclare une, sinon l'audience par défaut — l'application elle-même. Un
|
|
96
|
+
* jeton émis pour la porte MCP, ou pour la ressource d'une autre zone, figure
|
|
97
|
+
* dans `jwt.audiences` sans pour autant ouvrir celle-ci.
|
|
98
|
+
*
|
|
79
99
|
* @throws AuthenticationError (401) — token absent/invalide/expiré/révoqué, ou
|
|
80
100
|
* sujet disparu/banni.
|
|
81
101
|
* @throws Error (câblage : keystore/store/users absents) — logguée ERROR par le
|
|
@@ -84,7 +104,8 @@ var JwtAuthenticator = class {
|
|
|
84
104
|
async authenticate(token) {
|
|
85
105
|
const raw = token.getCredentials();
|
|
86
106
|
if (typeof raw !== "string" || raw.length === 0) throw new AuthenticationError(INVALID_TOKEN);
|
|
87
|
-
const
|
|
107
|
+
const audience = token.getAttribute(AUDIENCE) ?? defaultAudience(this.#runtime);
|
|
108
|
+
const { user, payload } = await this.#verify(raw, audience);
|
|
88
109
|
return this.#promote(token, user, payload);
|
|
89
110
|
}
|
|
90
111
|
/**
|
|
@@ -143,11 +164,13 @@ var JwtAuthenticator = class {
|
|
|
143
164
|
algorithms: ["EdDSA"],
|
|
144
165
|
issuer: this.#runtime.issuer,
|
|
145
166
|
audience,
|
|
146
|
-
typ: "at+jwt"
|
|
167
|
+
typ: "at+jwt",
|
|
168
|
+
requiredClaims: [...REQUIRED_TIME_CLAIMS]
|
|
147
169
|
})).payload;
|
|
148
170
|
} catch {
|
|
149
171
|
throw new AuthenticationError(INVALID_TOKEN);
|
|
150
172
|
}
|
|
173
|
+
if (isIssuedInFuture(payload, Math.floor(Date.now() / 1e3), 5)) throw new AuthenticationError(INVALID_TOKEN);
|
|
151
174
|
const sub = payload.sub;
|
|
152
175
|
const jti = payload.jti;
|
|
153
176
|
if (typeof sub !== "string" || typeof jti !== "string") throw new AuthenticationError(INVALID_TOKEN);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AuthenticationError } from "../../errors/AuthenticationError.js";
|
|
2
|
-
import {
|
|
2
|
+
import { StaleSessionError } from "../../errors/StaleSessionError.js";
|
|
3
3
|
import { resolveBoundSessionIdentity } from "../sessionIdentity.js";
|
|
4
|
+
import { ACCOUNT_ID_KEY, CREDENTIALS_VERSION_KEY } from "../accountBinding.js";
|
|
4
5
|
import { UserToken } from "../token/UserToken.js";
|
|
5
6
|
// nodefony/src/authenticator/SessionAuthenticator.ts
|
|
6
7
|
/**
|
|
@@ -72,15 +73,22 @@ var SessionAuthenticator = class {
|
|
|
72
73
|
* token. Les contrôles d'état (existe, actif, non verrouillé) vivent dans
|
|
73
74
|
* {@link resolveSessionIdentity} — partagés avec `AuthFlow.me()`.
|
|
74
75
|
*
|
|
75
|
-
* @throws
|
|
76
|
-
* compte verrouillé ou désactiv
|
|
76
|
+
* @throws StaleSessionError (401, message uniforme) — session orpheline,
|
|
77
|
+
* compte verrouillé ou désactivé, liaison au compte rompue : un état à
|
|
78
|
+
* purger ({@link onFailure}). Une PANNE de la source d'identité remonte
|
|
79
|
+
* telle quelle, et ne purge rien.
|
|
77
80
|
*/
|
|
78
81
|
async authenticate(token) {
|
|
79
82
|
const credentials = token.getCredentials();
|
|
80
|
-
if (!credentials?.identifier) throw new
|
|
83
|
+
if (!credentials?.identifier) throw new StaleSessionError();
|
|
81
84
|
const provider = this.#provider ??= this.#resolveProvider();
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
try {
|
|
86
|
+
const user = await resolveBoundSessionIdentity(provider, credentials.identifier, credentials.accountId, credentials.credentialsVersion);
|
|
87
|
+
return token.promote(user);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (error instanceof AuthenticationError) throw new StaleSessionError(error.message);
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
84
92
|
}
|
|
85
93
|
/**
|
|
86
94
|
* Pose l'identifiant sur le contexte : la persistance de session du pipeline
|
|
@@ -90,9 +98,14 @@ var SessionAuthenticator = class {
|
|
|
90
98
|
context.user = token.getUser().identifier;
|
|
91
99
|
return Promise.resolve();
|
|
92
100
|
}
|
|
93
|
-
/**
|
|
94
|
-
|
|
95
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Une session périmée est DÉTRUITE, côté serveur (entrée du stockage) et
|
|
103
|
+
* côté navigateur (cookie expiré, RFC 6265 §5.3) — sans quoi le navigateur la
|
|
104
|
+
* renvoie à chaque requête et chaque page lui répond 401, sans issue pour
|
|
105
|
+
* l'utilisateur. Le firewall poursuit ensuite la chaîne de la zone.
|
|
106
|
+
*/
|
|
107
|
+
async onFailure(context, error) {
|
|
108
|
+
if (error instanceof StaleSessionError) await context.session?.destroy(true);
|
|
96
109
|
}
|
|
97
110
|
};
|
|
98
111
|
export { SessionAuthenticator, SessionAuthenticator as default };
|
|
@@ -3,8 +3,8 @@ import { SessionAuthenticator } from "./SessionAuthenticator.js";
|
|
|
3
3
|
import { UserPasswordAuthenticator } from "./UserPasswordAuthenticator.js";
|
|
4
4
|
import { JwtAuthenticator } from "./JwtAuthenticator.js";
|
|
5
5
|
import { ApiKeyAuthenticator } from "./ApiKeyAuthenticator.js";
|
|
6
|
-
import { externalSubjectOptions } from "./ExternalSubjectResolver.js";
|
|
7
6
|
import { ExternalJwtAuthenticator } from "./ExternalJwtAuthenticator.js";
|
|
7
|
+
import { externalSubjectOptions } from "./ExternalSubjectResolver.js";
|
|
8
8
|
import { resolveJwtRuntime } from "../token/jwtRuntime.js";
|
|
9
9
|
// nodefony/src/authenticator/authenticatorRegistry.ts
|
|
10
10
|
const factories = /* @__PURE__ */ new Map();
|
|
@@ -42,7 +42,7 @@ registerAuthenticatorFactory("session", ({ container }) => {
|
|
|
42
42
|
});
|
|
43
43
|
});
|
|
44
44
|
registerAuthenticatorFactory("jwt", ({ container, config }) => {
|
|
45
|
-
return new JwtAuthenticator(container, resolveJwtRuntime(config.jwt));
|
|
45
|
+
return new JwtAuthenticator(container, resolveJwtRuntime(config.jwt, config.areas));
|
|
46
46
|
});
|
|
47
47
|
registerAuthenticatorFactory("external-jwt", ({ container, config }) => {
|
|
48
48
|
return new ExternalJwtAuthenticator(container, externalSubjectOptions(config));
|