@nodefony/security 10.0.0-beta.4 → 10.0.0-beta.6
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.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +60 -60
- package/dist/nodefony/config/config.js +13 -5
- package/dist/nodefony/contracts/IWebhookPayload.js +1 -0
- package/dist/nodefony/errors/index.js +4 -4
- package/dist/nodefony/keycloak/keycloakAdmin.js +3 -3
- package/dist/nodefony/keycloak/security-keycloak-realm.js +1 -1
- package/dist/nodefony/service/accessTokenVerifier.js +5 -5
- package/dist/nodefony/service/apiKeys.js +8 -4
- package/dist/nodefony/service/authFlow.js +3 -3
- package/dist/nodefony/service/authorization.js +1 -1
- package/dist/nodefony/service/firewall.js +233 -41
- 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 +8 -8
- package/dist/nodefony/service/totp.js +3 -3
- package/dist/nodefony/service/webAuthn.js +1 -1
- package/dist/nodefony/service/webhooks.js +11 -4
- package/dist/nodefony/src/admin/SecurityAdminApi.js +19 -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 +5 -2
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +1 -1
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +1 -1
- package/dist/nodefony/src/csp.js +265 -10
- package/dist/nodefony/src/cspReports.js +307 -0
- 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 +7 -6
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +8 -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 +3 -3
- package/dist/nodefony/testing/index.js +98 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/nodefony/config/config.d.ts +2 -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/service/apiKeys.d.ts +3 -0
- 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/webhooks.d.ts +7 -1
- package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +3 -0
- package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +11 -0
- 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/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 +2 -2
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +25 -17
- 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/testing/index.d.ts +57 -0
- package/docs/api-keys.md +3 -1
- 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/login-page.md +4 -3
- package/docs/totp.md +2 -2
- package/docs/webauthn.md +2 -2
- package/docs/webhooks.md +57 -11
- package/package.json +20 -14
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { MemoryRateLimitStore } from "@nodefony/http";
|
|
2
|
+
// nodefony/src/cspReports.ts
|
|
3
|
+
const MINUTE_MS = 6e4;
|
|
4
|
+
const MAX_URL_LENGTH = 512;
|
|
5
|
+
const MAX_USER_AGENT_LENGTH = 200;
|
|
6
|
+
/** Directive ou mot-clé de la norme : minuscules et tirets, court. */
|
|
7
|
+
const TOKEN = /^[a-z][a-z0-9-]{0,47}$/u;
|
|
8
|
+
/** Contrôles C0, DEL et C1 — jamais dans une ligne de journal ni un affichage. */
|
|
9
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/gu;
|
|
10
|
+
const MAX_INT = 1e7;
|
|
11
|
+
/**
|
|
12
|
+
* Reçoit, expurge, dédoublonne et garde EN MÉMOIRE les rapports de violation
|
|
13
|
+
* CSP (ADR-0024 §C7). La porte qui les livre est publique — le navigateur
|
|
14
|
+
* n'envoie aucun identifiant — donc tout ce qui entre est traité comme écrit
|
|
15
|
+
* par un attaquant :
|
|
16
|
+
*
|
|
17
|
+
* - **quota par adresse** (fenêtre d'une minute, le limiteur de `@nodefony/http`) ;
|
|
18
|
+
* - **deux types de contenu seulement** : `application/csp-report` (`report-uri`)
|
|
19
|
+
* et `application/reports+json` (`report-to`) ;
|
|
20
|
+
* - **URL expurgées** de leur requête, de leur fragment et de leurs identifiants
|
|
21
|
+
* (la norme ne retire pas la requête : elle peut porter un jeton) ; une URL
|
|
22
|
+
* qui n'est pas HTTP(S) se réduit à son schéma ;
|
|
23
|
+
* - **l'extrait de code n'est jamais gardé** — c'est une chaîne de l'attaquant ;
|
|
24
|
+
* - **mémoire bornée** : au plus `capacity` violations distinctes, la moins
|
|
25
|
+
* récemment vue évincée d'abord ; une répétition incrémente un compteur ;
|
|
26
|
+
* - **journal dédoublonné et borné** : une violation neuve y part une fois, et
|
|
27
|
+
* pas plus de `logPerMinute` lignes par minute.
|
|
28
|
+
*
|
|
29
|
+
* Rien n'est écrit sur disque : le journal centralisé voit déjà ce qui compte.
|
|
30
|
+
*/
|
|
31
|
+
var CspReportCollector = class {
|
|
32
|
+
/** URL que la politique désigne. */
|
|
33
|
+
endpoint;
|
|
34
|
+
/** Violations distinctes gardées au plus. */
|
|
35
|
+
capacity;
|
|
36
|
+
#violations = /* @__PURE__ */ new Map();
|
|
37
|
+
#limiter;
|
|
38
|
+
#log;
|
|
39
|
+
#now;
|
|
40
|
+
#logPerMinute;
|
|
41
|
+
#logWindowStart = 0;
|
|
42
|
+
#loggedInWindow = 0;
|
|
43
|
+
#silenced = 0;
|
|
44
|
+
#received = 0;
|
|
45
|
+
#rejected = 0;
|
|
46
|
+
/**
|
|
47
|
+
* @param options - URL désignée, bornes, journal, horloge.
|
|
48
|
+
*/
|
|
49
|
+
constructor(options) {
|
|
50
|
+
this.endpoint = options.endpoint;
|
|
51
|
+
this.capacity = options.capacity ?? 200;
|
|
52
|
+
this.#now = options.now ?? Date.now;
|
|
53
|
+
this.#log = options.log ?? null;
|
|
54
|
+
this.#logPerMinute = options.logPerMinute ?? 20;
|
|
55
|
+
this.#limiter = new MemoryRateLimitStore({
|
|
56
|
+
windowMs: MINUTE_MS,
|
|
57
|
+
max: options.perMinute ?? 120,
|
|
58
|
+
maxTracked: options.maxTrackedClients ?? 1e4
|
|
59
|
+
}, this.#now);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Traite un corps de rapport déjà lu (et déjà borné par l'appelant).
|
|
63
|
+
*
|
|
64
|
+
* @param contentType - type de contenu de la requête, paramètres compris ou non.
|
|
65
|
+
* @param body - le corps, octets ou texte.
|
|
66
|
+
* @param client - adresse cliente résolue (proxy de confiance compris) ;
|
|
67
|
+
* `null` = inconnue, non comptée (jamais une clé commune à tous).
|
|
68
|
+
* @param userAgent - en-tête `User-Agent`, gardé tronqué.
|
|
69
|
+
* @returns ce que le rapport est devenu — pour le journal et les tests ;
|
|
70
|
+
* l'émetteur reçoit 204 quoi qu'il arrive.
|
|
71
|
+
*/
|
|
72
|
+
receive(contentType, body, client, userAgent) {
|
|
73
|
+
this.#received++;
|
|
74
|
+
if (client !== null && this.#limiter.hit(client).limited) {
|
|
75
|
+
this.#rejected++;
|
|
76
|
+
return "limited";
|
|
77
|
+
}
|
|
78
|
+
const type = mediaTypeOf(contentType);
|
|
79
|
+
if (type !== "application/csp-report" && type !== "application/reports+json") {
|
|
80
|
+
this.#rejected++;
|
|
81
|
+
return "unsupported";
|
|
82
|
+
}
|
|
83
|
+
let payload;
|
|
84
|
+
try {
|
|
85
|
+
payload = JSON.parse(typeof body === "string" ? body : Buffer.from(body).toString("utf8"));
|
|
86
|
+
} catch {
|
|
87
|
+
this.#rejected++;
|
|
88
|
+
return "malformed";
|
|
89
|
+
}
|
|
90
|
+
const agent = cleanText(userAgent, MAX_USER_AGENT_LENGTH);
|
|
91
|
+
const violations = violationsOf(payload);
|
|
92
|
+
if (violations.length === 0) {
|
|
93
|
+
this.#rejected++;
|
|
94
|
+
return "malformed";
|
|
95
|
+
}
|
|
96
|
+
let outcome = "duplicate";
|
|
97
|
+
for (const violation of violations) if (this.#store(violation, agent)) outcome = "stored";
|
|
98
|
+
return outcome;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Photo des violations gardées, la plus récemment vue d'abord — ce que rend
|
|
102
|
+
* le plan d'administration.
|
|
103
|
+
*
|
|
104
|
+
* @returns une copie : la modifier ne touche pas le collecteur.
|
|
105
|
+
*/
|
|
106
|
+
snapshot() {
|
|
107
|
+
const items = [];
|
|
108
|
+
for (const v of this.#violations.values()) items.push({ ...v });
|
|
109
|
+
items.reverse();
|
|
110
|
+
return {
|
|
111
|
+
enabled: true,
|
|
112
|
+
endpoint: this.endpoint,
|
|
113
|
+
capacity: this.capacity,
|
|
114
|
+
received: this.#received,
|
|
115
|
+
rejected: this.#rejected,
|
|
116
|
+
items
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Range une violation ; `true` si elle est neuve. */
|
|
120
|
+
#store(violation, userAgent) {
|
|
121
|
+
const now = this.#now();
|
|
122
|
+
const key = [
|
|
123
|
+
violation.disposition,
|
|
124
|
+
violation.effectiveDirective,
|
|
125
|
+
violation.documentURL,
|
|
126
|
+
violation.blockedURL ?? "",
|
|
127
|
+
violation.sourceFile ?? "",
|
|
128
|
+
violation.lineNumber ?? "",
|
|
129
|
+
violation.columnNumber ?? ""
|
|
130
|
+
].join("\0");
|
|
131
|
+
const known = this.#violations.get(key);
|
|
132
|
+
if (known !== void 0) {
|
|
133
|
+
known.count++;
|
|
134
|
+
known.lastSeen = now;
|
|
135
|
+
if (userAgent !== null) known.userAgent = userAgent;
|
|
136
|
+
this.#violations.delete(key);
|
|
137
|
+
this.#violations.set(key, known);
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
if (this.#violations.size >= this.capacity) {
|
|
141
|
+
const oldest = this.#violations.keys().next();
|
|
142
|
+
if (oldest.done !== true) this.#violations.delete(oldest.value);
|
|
143
|
+
}
|
|
144
|
+
this.#violations.set(key, {
|
|
145
|
+
...violation,
|
|
146
|
+
userAgent,
|
|
147
|
+
count: 1,
|
|
148
|
+
firstSeen: now,
|
|
149
|
+
lastSeen: now
|
|
150
|
+
});
|
|
151
|
+
this.#announce(violation, now);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
/** Journalise une violation neuve, dans la limite du budget de la minute. */
|
|
155
|
+
#announce(v, now) {
|
|
156
|
+
if (this.#log === null) return;
|
|
157
|
+
if (now - this.#logWindowStart >= MINUTE_MS) {
|
|
158
|
+
if (this.#silenced > 0) this.#log(`CSP : ${this.#silenced} violation(s) neuve(s) non journalisée(s) la minute précédente — liste complète : GET /nodefony/security/api/csp-reports`, "NOTICE");
|
|
159
|
+
this.#logWindowStart = now;
|
|
160
|
+
this.#loggedInWindow = 0;
|
|
161
|
+
this.#silenced = 0;
|
|
162
|
+
}
|
|
163
|
+
if (this.#loggedInWindow >= this.#logPerMinute) {
|
|
164
|
+
this.#silenced++;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
this.#loggedInWindow++;
|
|
168
|
+
const where = v.sourceFile !== null ? ` (${v.sourceFile}${v.lineNumber !== null ? `:${v.lineNumber}` : ""})` : "";
|
|
169
|
+
this.#log(`CSP ${v.disposition === "report" ? "signalée" : "bloquée"} : ${v.effectiveDirective} refuse ${v.blockedURL ?? "?"} sur ${v.documentURL}${where}`, "WARNING");
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
/** Type de contenu sans paramètres, en minuscules. */
|
|
173
|
+
function mediaTypeOf(value) {
|
|
174
|
+
if (typeof value !== "string") return null;
|
|
175
|
+
const semi = value.indexOf(";");
|
|
176
|
+
return (semi === -1 ? value : value.slice(0, semi)).trim().toLowerCase();
|
|
177
|
+
}
|
|
178
|
+
/** Objet ordinaire, ou `null`. */
|
|
179
|
+
function recordOf(value) {
|
|
180
|
+
return isRecord(value) ? value : null;
|
|
181
|
+
}
|
|
182
|
+
function isRecord(value) {
|
|
183
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Lit un corps de rapport selon sa FORME, quel que soit celui des deux types de
|
|
187
|
+
* contenu admis qui l'annonce.
|
|
188
|
+
*
|
|
189
|
+
* Le type ne suffit pas à décider : WebKit (Safari) poste son `report-to` en
|
|
190
|
+
* `application/csp-report` avec UN rapport de la Reporting API, ni enveloppé
|
|
191
|
+
* dans `csp-report` ni mis en tableau (corps relevé au navigateur). Lu sur le
|
|
192
|
+
* seul type, ce rapport était écarté comme malformé — et toutes les violations
|
|
193
|
+
* de Safari avec lui.
|
|
194
|
+
*/
|
|
195
|
+
function violationsOf(payload) {
|
|
196
|
+
if (Array.isArray(payload)) return fromReportsJson(payload);
|
|
197
|
+
const record = recordOf(payload);
|
|
198
|
+
if (record === null) return [];
|
|
199
|
+
if ("csp-report" in record) return fromCspReport(record);
|
|
200
|
+
return fromReportsJson([record]);
|
|
201
|
+
}
|
|
202
|
+
/** Corps `{ "csp-report": { … } }` (CSP2, `report-uri`). */
|
|
203
|
+
function fromCspReport(payload) {
|
|
204
|
+
const report = recordOf(payload["csp-report"]);
|
|
205
|
+
if (report === null) return [];
|
|
206
|
+
const one = normalize({
|
|
207
|
+
documentURL: report["document-uri"],
|
|
208
|
+
blockedURL: report["blocked-uri"],
|
|
209
|
+
effectiveDirective: report["effective-directive"],
|
|
210
|
+
violatedDirective: report["violated-directive"],
|
|
211
|
+
disposition: report["disposition"],
|
|
212
|
+
sourceFile: report["source-file"],
|
|
213
|
+
lineNumber: report["line-number"],
|
|
214
|
+
columnNumber: report["column-number"],
|
|
215
|
+
statusCode: report["status-code"]
|
|
216
|
+
});
|
|
217
|
+
return one === null ? [] : [one];
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Rapports de la Reporting API (`report-to`), dont seuls ceux de type
|
|
221
|
+
* `csp-violation` sont lus.
|
|
222
|
+
*/
|
|
223
|
+
function fromReportsJson(payload) {
|
|
224
|
+
const out = [];
|
|
225
|
+
for (const item of payload) {
|
|
226
|
+
const report = recordOf(item);
|
|
227
|
+
if (report?.["type"] !== "csp-violation") continue;
|
|
228
|
+
const body = recordOf(report["body"]);
|
|
229
|
+
if (body === null) continue;
|
|
230
|
+
const one = normalize({
|
|
231
|
+
documentURL: body["documentURL"] ?? report["url"],
|
|
232
|
+
blockedURL: body["blockedURL"],
|
|
233
|
+
effectiveDirective: body["effectiveDirective"],
|
|
234
|
+
violatedDirective: void 0,
|
|
235
|
+
disposition: body["disposition"],
|
|
236
|
+
sourceFile: body["sourceFile"],
|
|
237
|
+
lineNumber: body["lineNumber"],
|
|
238
|
+
columnNumber: body["columnNumber"],
|
|
239
|
+
statusCode: body["statusCode"]
|
|
240
|
+
});
|
|
241
|
+
if (one !== null) out.push(one);
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Expurge une violation ; `null` si elle ne désigne ni un document ni une
|
|
247
|
+
* directive — rien d'exploitable, donc rien à garder.
|
|
248
|
+
*/
|
|
249
|
+
function normalize(raw) {
|
|
250
|
+
const documentURL = stripUrl(raw.documentURL);
|
|
251
|
+
if (documentURL === null) return null;
|
|
252
|
+
const effectiveDirective = tokenOf(raw.effectiveDirective) ?? firstTokenOf(raw.violatedDirective);
|
|
253
|
+
if (effectiveDirective === null) return null;
|
|
254
|
+
return {
|
|
255
|
+
documentURL,
|
|
256
|
+
blockedURL: stripUrl(raw.blockedURL),
|
|
257
|
+
effectiveDirective,
|
|
258
|
+
disposition: raw.disposition === "report" ? "report" : "enforce",
|
|
259
|
+
sourceFile: stripUrl(raw.sourceFile),
|
|
260
|
+
lineNumber: intOf(raw.lineNumber),
|
|
261
|
+
columnNumber: intOf(raw.columnNumber),
|
|
262
|
+
statusCode: intOf(raw.statusCode)
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Une URL de rapport, expurgée : sans identifiants, requête ni fragment ; le
|
|
267
|
+
* seul schéma si elle n'est pas HTTP(S) (`w3c-reporting.bs`, « Strip URL for
|
|
268
|
+
* use in reports », qui garde la requête — pas nous : elle peut porter un
|
|
269
|
+
* jeton). Un mot-clé de la norme (`inline`, `eval`…) passe tel quel.
|
|
270
|
+
*/
|
|
271
|
+
function stripUrl(value) {
|
|
272
|
+
if (typeof value !== "string" || value === "") return null;
|
|
273
|
+
if (TOKEN.test(value)) return value;
|
|
274
|
+
let url;
|
|
275
|
+
try {
|
|
276
|
+
url = new URL(value);
|
|
277
|
+
} catch {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return url.protocol.slice(0, -1);
|
|
281
|
+
url.username = "";
|
|
282
|
+
url.password = "";
|
|
283
|
+
url.search = "";
|
|
284
|
+
url.hash = "";
|
|
285
|
+
const href = url.href;
|
|
286
|
+
return href.length > MAX_URL_LENGTH ? href.slice(0, MAX_URL_LENGTH) : href;
|
|
287
|
+
}
|
|
288
|
+
function tokenOf(value) {
|
|
289
|
+
return typeof value === "string" && TOKEN.test(value) ? value : null;
|
|
290
|
+
}
|
|
291
|
+
/** `violated-directive` (CSP2) porte la directive suivie de ses sources. */
|
|
292
|
+
function firstTokenOf(value) {
|
|
293
|
+
if (typeof value !== "string") return null;
|
|
294
|
+
const space = value.indexOf(" ");
|
|
295
|
+
return tokenOf(space === -1 ? value : value.slice(0, space));
|
|
296
|
+
}
|
|
297
|
+
function intOf(value) {
|
|
298
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= MAX_INT ? value : null;
|
|
299
|
+
}
|
|
300
|
+
/** Texte libre de l'émetteur : sans contrôles, tronqué. */
|
|
301
|
+
function cleanText(value, max) {
|
|
302
|
+
if (typeof value !== "string") return null;
|
|
303
|
+
const text = value.replace(CONTROL, "").trim();
|
|
304
|
+
if (text === "") return null;
|
|
305
|
+
return text.length > max ? text.slice(0, max) : text;
|
|
306
|
+
}
|
|
307
|
+
export { CspReportCollector };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { postTokenRequest } from "./oauth2Client.js";
|
|
2
1
|
import { discoverAuthorizationServer } from "./metadata.js";
|
|
2
|
+
import { postTokenRequest } from "./oauth2Client.js";
|
|
3
3
|
// nodefony/src/oauth/clientCredentials.ts
|
|
4
4
|
/**
|
|
5
5
|
* Obtient un jeton d'accès pour un programme, par le flux `client_credentials`
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { describeTransportFailure, untrustedAuthorityHint } from "../net/transportFailure.js";
|
|
2
1
|
import { readJsonObjectBounded } from "./httpJson.js";
|
|
2
|
+
import { describeTransportFailure, untrustedAuthorityHint } from "../net/transportFailure.js";
|
|
3
3
|
import { canonicalIssuer, isBackChannelTransportAllowed, metadataCandidateUrls, validateIssuerMetadata } from "nodefony";
|
|
4
4
|
// nodefony/src/oauth/metadata.ts
|
|
5
5
|
/**
|
|
@@ -13,9 +13,22 @@ const factories = /* @__PURE__ */ new Map();
|
|
|
13
13
|
function registerOAuthProvider(name, factory, registration = {}) {
|
|
14
14
|
factories.set(name, {
|
|
15
15
|
factory,
|
|
16
|
-
requiresIssuer: registration.requiresIssuer === true
|
|
16
|
+
requiresIssuer: registration.requiresIssuer === true,
|
|
17
|
+
imageSources: registration.imageSources ?? []
|
|
17
18
|
});
|
|
18
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Sources d'images (avatars) des fournisseurs nommés, sans doublon — ce que
|
|
22
|
+
* `img-src` doit autoriser pour afficher leurs photos de profil.
|
|
23
|
+
*
|
|
24
|
+
* @param names - fournisseurs configurés ; un nom inconnu ne rend rien
|
|
25
|
+
* @returns les sources déclarées par leurs enregistrements
|
|
26
|
+
*/
|
|
27
|
+
function oauthProviderImageSources(names) {
|
|
28
|
+
const sources = /* @__PURE__ */ new Set();
|
|
29
|
+
for (const name of names) for (const source of factories.get(name)?.imageSources ?? []) sources.add(source);
|
|
30
|
+
return [...sources];
|
|
31
|
+
}
|
|
19
32
|
/** Fabrique d'un fournisseur par nom, ou `undefined` si inconnu. */
|
|
20
33
|
function getOAuthProviderFactory(name) {
|
|
21
34
|
return factories.get(name)?.factory;
|
|
@@ -51,8 +64,8 @@ function withKeycloakThemeHint(provider) {
|
|
|
51
64
|
};
|
|
52
65
|
}
|
|
53
66
|
const GOOGLE_ISSUER = "https://accounts.google.com";
|
|
54
|
-
registerOAuthProvider("google", (ctx) => createDiscoveredOidcProvider("google", ctx, { issuer: GOOGLE_ISSUER }));
|
|
67
|
+
registerOAuthProvider("google", (ctx) => createDiscoveredOidcProvider("google", ctx, { issuer: GOOGLE_ISSUER }), { imageSources: ["https://*.googleusercontent.com"] });
|
|
55
68
|
registerOAuthProvider("keycloak", async (ctx) => withKeycloakThemeHint(await createDiscoveredOidcProvider("keycloak", ctx)), { requiresIssuer: true });
|
|
56
69
|
registerOAuthProvider("oidc", (ctx) => createDiscoveredOidcProvider("oidc", ctx), { requiresIssuer: true });
|
|
57
|
-
registerOAuthProvider("github", createGithubProvider);
|
|
58
|
-
export { KEYCLOAK_THEME_PARAMETER, getOAuthProviderFactory, listOAuthProviders, oauthProviderRequiresIssuer, registerOAuthProvider, withKeycloakThemeHint };
|
|
70
|
+
registerOAuthProvider("github", createGithubProvider, { imageSources: ["https://avatars.githubusercontent.com"] });
|
|
71
|
+
export { KEYCLOAK_THEME_PARAMETER, getOAuthProviderFactory, listOAuthProviders, oauthProviderImageSources, oauthProviderRequiresIssuer, registerOAuthProvider, withKeycloakThemeHint };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { readJsonBounded, readJsonObjectBounded } from "../httpJson.js";
|
|
2
1
|
import { OAuth2Client, OAuth2RequestError } from "../oauth2Client.js";
|
|
2
|
+
import { readJsonBounded, readJsonObjectBounded } from "../httpJson.js";
|
|
3
3
|
import { isPlainObject } from "nodefony";
|
|
4
4
|
// nodefony/src/oauth/providers/github.ts
|
|
5
5
|
/** Scopes minimaux : profil public + emails (l'email primaire peut être privé). */
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// nodefony/src/realtime/UserRealtimeToken.ts
|
|
2
2
|
/** Aucun scope — figé et partagé (0 allocation pour le cas humain, le plus courant). */
|
|
3
3
|
const EMPTY_SCOPES = Object.freeze([]);
|
|
4
|
+
/** Une session ou une clé d'API : aucun émetteur, un compte local — figé et partagé. */
|
|
5
|
+
const LOCAL_ACCOUNT = Object.freeze({
|
|
6
|
+
issuer: null,
|
|
7
|
+
account: true
|
|
8
|
+
});
|
|
4
9
|
/**
|
|
5
10
|
* Adaptateur `IUser` → `IRealtimeToken` (jeton realtime).
|
|
6
11
|
*
|
|
@@ -35,6 +40,8 @@ var UserRealtimeToken = class {
|
|
|
35
40
|
* `null` = aucune re-validation câblée (best-effort).
|
|
36
41
|
*/
|
|
37
42
|
#revalidate;
|
|
43
|
+
/** Cause de l'état d'un jeton porteur ; `null` pour une session. */
|
|
44
|
+
#status;
|
|
38
45
|
/**
|
|
39
46
|
* Scopes délégués repris du jeton du firewall (clé API / JWT / OAuth). Vide
|
|
40
47
|
* pour une identité humaine, qui n'est pas downscopée.
|
|
@@ -46,15 +53,29 @@ var UserRealtimeToken = class {
|
|
|
46
53
|
* émet elle-même l'artefact. `null` = rien à confirmer.
|
|
47
54
|
*/
|
|
48
55
|
#confirmAccount;
|
|
56
|
+
/** Émetteur et compte, repris du jeton d'origine pour l'identité d'admission. */
|
|
57
|
+
#admission;
|
|
58
|
+
/**
|
|
59
|
+
* Identité d'admission, construite au PREMIER appel puis rendue telle quelle :
|
|
60
|
+
* `null` tant que personne ne l'a demandée (un jeton construit pour un test ou
|
|
61
|
+
* un pont qui ne la lit jamais ne la paie pas).
|
|
62
|
+
*/
|
|
63
|
+
#identity = null;
|
|
49
64
|
/**
|
|
50
65
|
* @param user - identité authentifiée résolue par le firewall.
|
|
51
66
|
* @param revalidate - preuve de vie de l'identité, adaptée à son mode.
|
|
52
67
|
* @param type - mode d'authentification réel (défaut `"session"`).
|
|
53
68
|
* @param scopes - scopes délégués du jeton d'origine (défaut aucun).
|
|
54
69
|
* @param confirmAccount - relecture du compte, payée sur `api.request` seul.
|
|
70
|
+
* @param status - la CAUSE de l'état, pour un jeton porteur (expiré ou révoqué) ;
|
|
71
|
+
* `null` pour une session, qui n'a qu'un verdict.
|
|
72
|
+
* @param admission - émetteur et compte local du jeton d'origine (défaut :
|
|
73
|
+
* aucun émetteur, compte local — une session).
|
|
55
74
|
*/
|
|
56
|
-
constructor(user, revalidate = null, type = "session", scopes = EMPTY_SCOPES, confirmAccount = null) {
|
|
75
|
+
constructor(user, revalidate = null, type = "session", scopes = EMPTY_SCOPES, confirmAccount = null, status = null, admission = LOCAL_ACCOUNT) {
|
|
76
|
+
this.#admission = admission;
|
|
57
77
|
this.#confirmAccount = confirmAccount;
|
|
78
|
+
this.#status = status;
|
|
58
79
|
this.#user = user;
|
|
59
80
|
this.#revalidate = revalidate;
|
|
60
81
|
this.type = type;
|
|
@@ -76,6 +97,17 @@ var UserRealtimeToken = class {
|
|
|
76
97
|
return this.#revalidate ? this.#revalidate(nowMs) : Promise.resolve(true);
|
|
77
98
|
}
|
|
78
99
|
/**
|
|
100
|
+
* La cause de l'état de l'identité — `expired` ferme la socket en 4006, `revoked`
|
|
101
|
+
* en 4001 (ADR-0021 §S11). Sans cause câblée (session), dérivée de
|
|
102
|
+
* {@link isValid} : `false` vaut `revoked`.
|
|
103
|
+
*
|
|
104
|
+
* @param nowMs - horloge injectable (epoch ms) ; par défaut l'heure courante.
|
|
105
|
+
*/
|
|
106
|
+
async status(nowMs) {
|
|
107
|
+
if (this.#status !== null) return this.#status(nowMs);
|
|
108
|
+
return await this.isValid(nowMs) ? "active" : "revoked";
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
79
111
|
* Le compte qui a ouvert la socket existe-t-il toujours, actif, et sous le
|
|
80
112
|
* même `id` immuable ? `true` si aucune confirmation n'est câblée.
|
|
81
113
|
*/
|
|
@@ -101,8 +133,33 @@ var UserRealtimeToken = class {
|
|
|
101
133
|
getScopes() {
|
|
102
134
|
return [...this.#scopes];
|
|
103
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Identité d'admission gelée de la connexion — la donnée plate que reçoit la règle `allow` d'un canal (ADR-0021 §S10).
|
|
138
|
+
*
|
|
139
|
+
* Construite une fois, au premier appel, puis rendue par RÉFÉRENCE : les rôles
|
|
140
|
+
* y sont copiés et gelés une seule fois (là où {@link getRoles} recopie à
|
|
141
|
+
* chaque appel), si bien que ni le `welcome` ni l'admission ne recopient
|
|
142
|
+
* plus rien. Gelée : une règle `allow` ne peut pas s'ajouter un rôle.
|
|
143
|
+
* `id` est l'identifiant immuable du compte, jamais son login.
|
|
144
|
+
*/
|
|
145
|
+
getIdentity() {
|
|
146
|
+
if (this.#identity !== null) return this.#identity;
|
|
147
|
+
const user = this.#user;
|
|
148
|
+
const scopes = this.#scopes;
|
|
149
|
+
this.#identity = Object.freeze({
|
|
150
|
+
type: this.type,
|
|
151
|
+
authenticated: true,
|
|
152
|
+
id: user.id,
|
|
153
|
+
userIdentifier: user.identifier,
|
|
154
|
+
roles: Object.freeze([...user.roles]),
|
|
155
|
+
scopes: Object.isFrozen(scopes) ? scopes : Object.freeze([...scopes]),
|
|
156
|
+
issuer: this.#admission.issuer,
|
|
157
|
+
account: this.#admission.account
|
|
158
|
+
});
|
|
159
|
+
return this.#identity;
|
|
160
|
+
}
|
|
104
161
|
getAttribute(key) {
|
|
105
162
|
return key === "user" ? this.#user : void 0;
|
|
106
163
|
}
|
|
107
164
|
};
|
|
108
|
-
export { UserRealtimeToken, UserRealtimeToken as default };
|
|
165
|
+
export { LOCAL_ACCOUNT, UserRealtimeToken, UserRealtimeToken as default };
|
|
@@ -224,7 +224,7 @@ function satisfies(policy, token, firewall) {
|
|
|
224
224
|
* data plane. Le verrou n'accorde donc que le plancher de ZONE, pas l'action.
|
|
225
225
|
*/
|
|
226
226
|
function authorizeApiRequest(firewall, params, token, onDeny) {
|
|
227
|
-
const path = params
|
|
227
|
+
const path = typeof params === "object" && params !== null && "path" in params ? params.path : void 0;
|
|
228
228
|
if (typeof path !== "string") return true;
|
|
229
229
|
const qi = path.indexOf("?");
|
|
230
230
|
const pathname = qi === -1 ? path : path.slice(0, qi);
|
|
@@ -254,7 +254,7 @@ function authorizeChannel(channel, token, firewall, resolver, systemRules, onDen
|
|
|
254
254
|
* (api.request) ou la politique du canal (subscribe/inbound).
|
|
255
255
|
*
|
|
256
256
|
* Trois surfaces gardées :
|
|
257
|
-
* - `api.request {path}` (pont
|
|
257
|
+
* - `api.request {path}` (pont socket) : re-match de zone HTTP → zone
|
|
258
258
|
* protégée + anonyme = refus (autorisation de ZONE, identique à `GET {path}` ;
|
|
259
259
|
* la méthode/action est gardée en aval par le router + `@IsGranted`).
|
|
260
260
|
* - `subscribe {channel}` : politique de canal (système plancher + déclaration
|
|
@@ -277,11 +277,12 @@ function buildFrameAuthorizer(firewall, options) {
|
|
|
277
277
|
const systemRules = options?.systemRules ?? DEFAULT_SYSTEM_RULES;
|
|
278
278
|
const onDeny = options?.onDeny;
|
|
279
279
|
return (frame, token) => {
|
|
280
|
-
|
|
281
|
-
const method =
|
|
282
|
-
if (method === "api.request") return authorizeApiRequest(firewall,
|
|
280
|
+
if (typeof frame !== "object" || frame === null || !("method" in frame)) return true;
|
|
281
|
+
const method = frame.method;
|
|
282
|
+
if (method === "api.request") return authorizeApiRequest(firewall, "params" in frame ? frame.params : void 0, token, onDeny);
|
|
283
283
|
if (method === "subscribe") {
|
|
284
|
-
const
|
|
284
|
+
const params = "params" in frame ? frame.params : void 0;
|
|
285
|
+
const channel = typeof params === "object" && params !== null && "channel" in params ? params.channel : void 0;
|
|
285
286
|
if (typeof channel !== "string") return true;
|
|
286
287
|
return authorizeChannel(channel, token, firewall, resolver, systemRules, onDeny);
|
|
287
288
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describeTransportFailure, untrustedAuthorityHint } from "../net/transportFailure.js";
|
|
2
|
+
import { REQUIRED_TIME_CLAIMS, isIssuedInFuture } from "./tokenTimeClaims.js";
|
|
2
3
|
import { canonicalIssuer, extractScopes, metadataCandidateUrls, parseDiscoveryUrl, validateIssuerMetadata } from "nodefony";
|
|
3
4
|
// nodefony/src/token/RemoteJwtVerifier.ts
|
|
4
5
|
/**
|
|
@@ -149,15 +150,20 @@ var RemoteJwtVerifier = class {
|
|
|
149
150
|
return null;
|
|
150
151
|
}
|
|
151
152
|
const { getKey } = await this.#resolve(trusted, jose);
|
|
153
|
+
const clockTolerance = this.#options.clockToleranceS ?? 5;
|
|
152
154
|
try {
|
|
153
155
|
const { payload } = await jose.jwtVerify(token, getKey, {
|
|
154
156
|
algorithms: [...trusted.algorithms],
|
|
155
157
|
issuer: trusted.issuer,
|
|
156
158
|
audience,
|
|
157
|
-
clockTolerance
|
|
159
|
+
clockTolerance,
|
|
158
160
|
...trusted.typ ? { typ: trusted.typ } : {},
|
|
159
|
-
|
|
161
|
+
requiredClaims: [...REQUIRED_TIME_CLAIMS, ...trusted.requiredClaims ?? []]
|
|
160
162
|
});
|
|
163
|
+
if (isIssuedInFuture(payload, Math.floor(Date.now() / 1e3), clockTolerance)) {
|
|
164
|
+
this.#audit(`jeton refusé (${trusted.issuer}) : iat dans le futur`);
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
161
167
|
return {
|
|
162
168
|
issuer: claimedIssuer,
|
|
163
169
|
payload
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// nodefony/src/token/tokenTimeClaims.ts
|
|
2
|
+
/**
|
|
3
|
+
* Bornes temporelles qu'un jeton d'accès JWT DOIT porter : `exp` et `iat`
|
|
4
|
+
* (RFC 9068 §2.2, l'un et l'autre REQUIRED).
|
|
5
|
+
*
|
|
6
|
+
* UNE règle pour les deux chemins de vérification — jeton de l'application
|
|
7
|
+
* (`JwtAuthenticator`) et jeton d'un émetteur tiers (`RemoteJwtVerifier`). Sans
|
|
8
|
+
* `exp`, une connexion ouverte avec le jeton n'a plus de borne propre : le
|
|
9
|
+
* revalidateur d'une socket ne sait alors la fermer que par le store de
|
|
10
|
+
* révocation, et une clé volée suffit à signer un accès éternel.
|
|
11
|
+
*/
|
|
12
|
+
const REQUIRED_TIME_CLAIMS = Object.freeze(["exp", "iat"]);
|
|
13
|
+
/**
|
|
14
|
+
* Tolérance d'horloge entre l'émetteur et ce serveur, en secondes : appliquée à
|
|
15
|
+
* `exp`/`nbf` par jose, et à `iat` par {@link isIssuedInFuture}.
|
|
16
|
+
*/
|
|
17
|
+
const CLOCK_TOLERANCE_S = 5;
|
|
18
|
+
/**
|
|
19
|
+
* Le jeton se dit-il émis dans le futur, au-delà de la tolérance d'horloge ?
|
|
20
|
+
*
|
|
21
|
+
* jose ne contrôle `iat` que lorsqu'un âge maximal est imposé (`maxTokenAge`),
|
|
22
|
+
* ce que Nodefony ne fait pas. Or un `iat` daté du futur rend inopérante la
|
|
23
|
+
* révocation en masse (`invalidBefore`), qui compare `iat` à un seuil passé :
|
|
24
|
+
* un jeton forgé à `iat = maintenant + 10 ans` ne serait jamais « émis avant ».
|
|
25
|
+
*
|
|
26
|
+
* @param payload - claims VÉRIFIÉS du jeton (signature déjà contrôlée)
|
|
27
|
+
* @param nowS - instant présent, en secondes epoch
|
|
28
|
+
* @param toleranceS - écart d'horloge admis, en secondes
|
|
29
|
+
* @returns `true` si `iat` dépasse `nowS + toleranceS`
|
|
30
|
+
*/
|
|
31
|
+
function isIssuedInFuture(payload, nowS, toleranceS) {
|
|
32
|
+
return typeof payload.iat === "number" && payload.iat > nowS + toleranceS;
|
|
33
|
+
}
|
|
34
|
+
export { CLOCK_TOLERANCE_S, REQUIRED_TIME_CLAIMS, isIssuedInFuture };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { decryptSecret, encryptSecret } from "../crypto/secretCipher.js";
|
|
2
|
-
import "./totpCipher.js";
|
|
3
2
|
import { base32Encode, buildOtpauthUri, generateRecoveryCodes, generateTotpSecret, hashRecoveryCode, matchRecoveryCode, verifyTotp } from "./totpCrypto.js";
|
|
3
|
+
import "./totpCipher.js";
|
|
4
4
|
// nodefony/src/totp/totpOperations.ts
|
|
5
5
|
/**
|
|
6
6
|
* Démarre l'enrôlement : génère un secret aléatoire, le **chiffre** au repos et
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { RoleHierarchyWalker } from "../RoleHierarchyWalker.js";
|
|
2
1
|
import "../../contracts/IAccessVoter.js";
|
|
2
|
+
import { RoleHierarchyWalker } from "../RoleHierarchyWalker.js";
|
|
3
3
|
// nodefony/src/voter/RoleVoter.ts
|
|
4
4
|
/**
|
|
5
5
|
* Voter built-in **niveau A** — résout les attributs `ROLE_*` via la hiérarchie
|