@nodefony/security 10.0.0-alpha.9 → 10.0.0-beta.1
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/nodefony/command/security-token.js +4 -4
- package/dist/nodefony/command/security-user-delete.js +2 -2
- package/dist/nodefony/command/security-user-list.js +1 -1
- package/dist/nodefony/service/apiKeys.js +2 -2
- package/dist/nodefony/service/auditService.js +3 -3
- package/dist/nodefony/service/authFlow.js +2 -2
- package/dist/nodefony/service/authorization.js +3 -4
- package/dist/nodefony/service/firewall.js +21 -9
- package/dist/nodefony/service/oauth2.js +5 -3
- package/dist/nodefony/service/securityHeaders.js +1 -1
- package/dist/nodefony/service/tokenService.js +27 -18
- package/dist/nodefony/service/webAuthn.js +38 -31
- package/dist/nodefony/service/webhooks.js +50 -41
- package/dist/nodefony/src/admin/SecurityAdminApi.js +2 -1
- package/dist/nodefony/src/admin/WebhookAdminApi.js +1 -0
- package/dist/nodefony/src/admin/userRevocationCascade.js +1 -1
- package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -1
- package/dist/nodefony/src/audit/MemoryAuditStore.js +4 -8
- package/dist/nodefony/src/audit/auditBridge.js +9 -5
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -3
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +2 -2
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +2 -2
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +2 -2
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +5 -3
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +2 -2
- package/dist/nodefony/src/csp.js +4 -5
- package/dist/nodefony/src/oauth/providers/github.js +1 -1
- package/dist/nodefony/src/realtime/frameAuthorizer.js +6 -6
- package/dist/nodefony/src/token/JwtKeystore.js +7 -6
- package/dist/nodefony/src/token/MemoryTokenStore.js +1 -1
- package/dist/nodefony/src/token/jwtRuntime.js +13 -1
- package/dist/nodefony/src/token/tokenCriteria.js +1 -0
- package/dist/nodefony/src/totp/totpCrypto.js +9 -9
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +4 -3
- package/dist/types/nodefony/contracts/IAuditEvent.d.ts +6 -2
- package/dist/types/nodefony/contracts/IFirewall.d.ts +2 -2
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +5 -5
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +4 -4
- package/dist/types/nodefony/contracts/ITokenStore.d.ts +3 -3
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +4 -4
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +1 -1
- package/dist/types/nodefony/errors/InvalidTargetError.d.ts +1 -1
- package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +1 -1
- package/dist/types/nodefony/service/firewall.d.ts +3 -2
- package/dist/types/nodefony/service/securityHeaders.d.ts +5 -5
- package/dist/types/nodefony/src/SecuredArea.d.ts +2 -2
- package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +5 -3
- package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +2 -2
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +2 -2
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +3 -3
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +2 -2
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +2 -2
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +10 -0
- package/docs/api-keys.md +15 -15
- package/docs/audit.md +18 -13
- package/docs/authenticators.md +6 -6
- package/docs/authorization.md +15 -15
- package/docs/cors.md +6 -6
- package/docs/csrf.md +7 -7
- package/docs/external-jwt.md +17 -9
- package/docs/firewall.md +7 -7
- package/docs/headers.md +13 -13
- package/docs/index.md +3 -1
- package/docs/oauth2.md +2 -2
- package/docs/tokens.md +6 -6
- package/docs/webauthn.md +13 -13
- package/docs/webhooks.md +9 -12
- package/package.json +13 -13
|
@@ -223,7 +223,7 @@ var SecurityToken = class extends Command {
|
|
|
223
223
|
identifier = await this.prompts.select({
|
|
224
224
|
message: "Compte porteur du jeton :",
|
|
225
225
|
choices: page.items.map((u) => ({
|
|
226
|
-
name: `${u.identifier}${
|
|
226
|
+
name: `${u.identifier}${u.roles.length ? ` ${DIM}(${u.roles.join(", ")})${RESET}` : ""}`,
|
|
227
227
|
value: u.identifier
|
|
228
228
|
}))
|
|
229
229
|
});
|
|
@@ -269,7 +269,7 @@ var SecurityToken = class extends Command {
|
|
|
269
269
|
throw e;
|
|
270
270
|
}
|
|
271
271
|
const token = issued.access_token;
|
|
272
|
-
const accordes =
|
|
272
|
+
const accordes = issued.scope.split(/\s+/u).filter(Boolean);
|
|
273
273
|
const nonAccordes = scopes.filter((s) => !accordes.includes(s));
|
|
274
274
|
if (opts.json) {
|
|
275
275
|
process.stdout.write(`${JSON.stringify({
|
|
@@ -285,7 +285,7 @@ var SecurityToken = class extends Command {
|
|
|
285
285
|
process.stdout.write(s);
|
|
286
286
|
};
|
|
287
287
|
let write = opts.write === true;
|
|
288
|
-
if (!write && process.stdin.isTTY
|
|
288
|
+
if (!write && process.stdin.isTTY) {
|
|
289
289
|
await this.loadPrompts();
|
|
290
290
|
write = await this.prompts.confirm({
|
|
291
291
|
message: `Poser ${MCP_TOKEN_ENV} dans la configuration des agents présents ?`,
|
|
@@ -293,7 +293,7 @@ var SecurityToken = class extends Command {
|
|
|
293
293
|
});
|
|
294
294
|
}
|
|
295
295
|
if (this.#ephemeralKey()) w(`\n${YELLOW}⚠ Clé de signature ÉPHÉMÈRE — ce jeton sera REFUSÉ.${RESET}\n${DIM} Cette application n'a pas de clé persistante : chaque process en génère\n une au démarrage. Le jeton ci-dessous n'est vérifiable que par le process\n qui vient de le signer — pas par le serveur en marche, qui a la sienne.\n → déclare une source de clés dans nodefony.config.ts :\n use("@nodefony/security", { jwt: { keystore: { dir: "var/keys" } } })\n ou, en production, keySetJson depuis l'environnement.${RESET}\n`);
|
|
296
|
-
const minutes = Math.round(
|
|
296
|
+
const minutes = Math.round(issued.expires_in / 60);
|
|
297
297
|
w(`\n${BOLD}🔑 Jeton d'accès${RESET} ${DIM}— compte ${identifier}, audience ${resource}${RESET}\n${DIM} valable ${minutes} min${accordes.length ? `, scopes : ${accordes.join(" ")}` : ", aucun scope"}${RESET}\n\n`);
|
|
298
298
|
if (nonAccordes.length > 0) w(`${DIM} ⚠️ non accordé(s) : ${nonAccordes.join(" ")} — réservé(s) au rôle d'administration, que le compte « ${identifier} » ne porte pas.${RESET}\n\n`);
|
|
299
299
|
if (write) {
|
|
@@ -64,7 +64,7 @@ var SecurityUserDelete = class extends Command {
|
|
|
64
64
|
process.exitCode = 1;
|
|
65
65
|
return this;
|
|
66
66
|
}
|
|
67
|
-
if (
|
|
67
|
+
if (user.roles.includes(ADMIN_ROLE)) {
|
|
68
68
|
if (await users.countActiveAdmins(ADMIN_ROLE) <= 1) {
|
|
69
69
|
this.log(`« ${identifier} » est le DERNIER administrateur actif — refus.\n Sans lui, plus personne n'administre cette application.\n Crée d'abord un autre admin : nodefony security:user:add <id> --admin`, "ERROR");
|
|
70
70
|
process.exitCode = 1;
|
|
@@ -74,7 +74,7 @@ var SecurityUserDelete = class extends Command {
|
|
|
74
74
|
const w = (t) => {
|
|
75
75
|
process.stdout.write(t);
|
|
76
76
|
};
|
|
77
|
-
w(`\n${BOLD}Compte à supprimer${RESET}\n identifiant : ${BOLD}${user.identifier}${RESET}\n rôles : ${
|
|
77
|
+
w(`\n${BOLD}Compte à supprimer${RESET}\n identifiant : ${BOLD}${user.identifier}${RESET}\n rôles : ${user.roles.join(", ") || "—"}\n id interne : ${DIM}${user.id}${RESET}\n\n`);
|
|
78
78
|
if (!opts.yes) {
|
|
79
79
|
if (!process.stdin.isTTY) {
|
|
80
80
|
this.log("confirmation impossible sans terminal — relance avec --yes si c'est voulu.", "ERROR");
|
|
@@ -52,7 +52,7 @@ var SecurityUserList = class extends Command {
|
|
|
52
52
|
});
|
|
53
53
|
const rows = page.items.map((u) => ({
|
|
54
54
|
identifiant: u.identifier,
|
|
55
|
-
rôles:
|
|
55
|
+
rôles: u.roles.join(", ") || "—",
|
|
56
56
|
actif: u.isActive() ? "oui" : "non",
|
|
57
57
|
verrouillé: u.isLocked() ? "OUI" : "—",
|
|
58
58
|
id: u.id
|
|
@@ -199,7 +199,7 @@ var ApiKeyService = class extends Service {
|
|
|
199
199
|
async revokeAnyPat(id, actorId) {
|
|
200
200
|
const store = this.#resolveStore();
|
|
201
201
|
const record = await store.findById(id);
|
|
202
|
-
if (
|
|
202
|
+
if (record?.kind !== "pat") return null;
|
|
203
203
|
await store.revoke(id, "manual");
|
|
204
204
|
this.log(`api key revoked by admin — id=${id} actor=${actorId} subject=${record.subjectId}`, "INFO");
|
|
205
205
|
recordAudit(this.container, {
|
|
@@ -240,7 +240,7 @@ var ApiKeyService = class extends Service {
|
|
|
240
240
|
async revokeForSubject(subjectId, id) {
|
|
241
241
|
const store = this.#resolveStore();
|
|
242
242
|
const record = await store.findById(id);
|
|
243
|
-
if (
|
|
243
|
+
if (record?.kind !== "pat" || record.subjectId !== subjectId) return false;
|
|
244
244
|
await store.revoke(id, "manual");
|
|
245
245
|
this.log(`api key revoked — id=${id} subject=${subjectId}`, "INFO");
|
|
246
246
|
recordAudit(this.container, {
|
|
@@ -108,14 +108,14 @@ var AuditService = class extends Service {
|
|
|
108
108
|
ts: Date.now()
|
|
109
109
|
};
|
|
110
110
|
this.#store.append(event).catch((error) => this.log(error, "ERROR"));
|
|
111
|
-
if (this.#listeners !== null) for (
|
|
112
|
-
|
|
111
|
+
if (this.#listeners !== null) for (const listener of this.#listeners) try {
|
|
112
|
+
listener(event);
|
|
113
113
|
} catch (error) {
|
|
114
114
|
this.log(error, "ERROR");
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
subscribe(listener) {
|
|
118
|
-
|
|
118
|
+
this.#listeners ??= [];
|
|
119
119
|
this.#listeners.push(listener);
|
|
120
120
|
let active = true;
|
|
121
121
|
return () => {
|
|
@@ -238,7 +238,7 @@ var AuthFlow = class extends Service {
|
|
|
238
238
|
*/
|
|
239
239
|
async logout(context) {
|
|
240
240
|
const session = context.session;
|
|
241
|
-
if (
|
|
241
|
+
if (session?.status !== "active") return false;
|
|
242
242
|
const actor = typeof session.user === "string" ? session.user : null;
|
|
243
243
|
await session.destroy(true);
|
|
244
244
|
context.session = null;
|
|
@@ -315,7 +315,7 @@ var AuthFlow = class extends Service {
|
|
|
315
315
|
#resolveTotp() {
|
|
316
316
|
if (!this.#totpResolved) {
|
|
317
317
|
const svc = this.get("totp") ?? null;
|
|
318
|
-
this.#totp = svc
|
|
318
|
+
this.#totp = svc?.isEnabled() ? svc : null;
|
|
319
319
|
this.#totpResolved = true;
|
|
320
320
|
}
|
|
321
321
|
return this.#totp;
|
|
@@ -45,8 +45,7 @@ var Authorization = class extends Service {
|
|
|
45
45
|
const voters = this.#voters ?? this.#build();
|
|
46
46
|
let granted = false;
|
|
47
47
|
let considered = 0;
|
|
48
|
-
for (
|
|
49
|
-
const voter = voters[i];
|
|
48
|
+
for (const voter of voters) {
|
|
50
49
|
if (!voter.supports(attribute, subject)) continue;
|
|
51
50
|
considered++;
|
|
52
51
|
let vote;
|
|
@@ -88,8 +87,8 @@ var Authorization = class extends Service {
|
|
|
88
87
|
/** Descripteur léger d'un sujet pour l'audit (jamais de JSON.stringify aveugle). */
|
|
89
88
|
function describeSubject(subject) {
|
|
90
89
|
if (typeof subject === "string") return subject;
|
|
91
|
-
if (subject === null) return
|
|
92
|
-
return subject
|
|
90
|
+
if (subject === null || subject === void 0) return String(subject);
|
|
91
|
+
return subject.constructor?.name ?? typeof subject;
|
|
93
92
|
}
|
|
94
93
|
//#endregion
|
|
95
94
|
export { Authorization, Authorization as default };
|
|
@@ -47,6 +47,11 @@ function cookieValue(cookieHeader, name) {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
/**
|
|
50
|
+
* Défi posé sur un 401 quand aucun authenticator de la zone n'en déclare
|
|
51
|
+
* (RFC 9110 §15.5.2). Schéma inconnu des navigateurs : aucune fenêtre de login.
|
|
52
|
+
*/
|
|
53
|
+
const FALLBACK_CHALLENGE = "Session realm=\"nodefony\"";
|
|
54
|
+
/**
|
|
50
55
|
* Orchestrateur de sécurité Nodefony — refonte 2026 (P6).
|
|
51
56
|
*
|
|
52
57
|
* `isSecure()` (hot-path, court-circuit si aucune zone) ne fait QUE matcher la
|
|
@@ -63,8 +68,9 @@ function cookieValue(cookieHeader, name) {
|
|
|
63
68
|
* d'identité down, câblage manquant) → log ERROR serveur + 401 générique
|
|
64
69
|
* (aucun détail ne fuite au client).
|
|
65
70
|
*
|
|
66
|
-
* Conformité : tout 401 porte un challenge `WWW-Authenticate` (RFC
|
|
67
|
-
*
|
|
71
|
+
* Conformité : tout 401 porte un challenge `WWW-Authenticate` (RFC 9110
|
|
72
|
+
* §15.5.2) — celui du premier authenticator de la zone qui en déclare un, sinon
|
|
73
|
+
* le défi de repli {@link FALLBACK_CHALLENGE}.
|
|
68
74
|
*
|
|
69
75
|
* CORS, CSRF et autorisation par décorateurs viennent se brancher en S4/S5.
|
|
70
76
|
* Toutes les structures sont **lazy** (perf : une app sans zone = zéro alloc).
|
|
@@ -109,10 +115,9 @@ var Firewall = class extends Service {
|
|
|
109
115
|
if (this.#config.cors.enabled) this.#cors = new Cors(this.#config.cors);
|
|
110
116
|
if (this.#config.headers.enabled) this.#securityHeaders = new SecurityHeaders(this.#config.headers);
|
|
111
117
|
const areas = this.#config.areas;
|
|
112
|
-
|
|
113
|
-
if (names.length) {
|
|
118
|
+
if (Object.keys(areas).length) {
|
|
114
119
|
const list = [];
|
|
115
|
-
for (const name of
|
|
120
|
+
for (const [name, area] of Object.entries(areas)) list.push(new SecuredArea(name, area));
|
|
116
121
|
list.sort((a, b) => b.pattern.source.length - a.pattern.source.length);
|
|
117
122
|
this.#areas = list;
|
|
118
123
|
this.#instantiateAuthenticators(list);
|
|
@@ -333,7 +338,7 @@ var Firewall = class extends Service {
|
|
|
333
338
|
describeRoleHierarchy() {
|
|
334
339
|
const raw = this.#config?.roleHierarchy ?? {};
|
|
335
340
|
const hierarchy = {};
|
|
336
|
-
for (const role of Object.
|
|
341
|
+
for (const [role, inherited] of Object.entries(raw)) hierarchy[role] = [...inherited];
|
|
337
342
|
const walker = this.roleHierarchy;
|
|
338
343
|
return {
|
|
339
344
|
hierarchy,
|
|
@@ -565,7 +570,10 @@ var Firewall = class extends Service {
|
|
|
565
570
|
if (typeof response?.setHeader !== "function") return void 0;
|
|
566
571
|
const isPreflight = context.method?.toUpperCase() === "OPTIONS" && headerValue(headers, "access-control-request-method") !== void 0;
|
|
567
572
|
const corsHeaders = isPreflight ? this.#cors.preflightHeaders(origin) : this.#cors.actualHeaders(origin);
|
|
568
|
-
if (corsHeaders) for (const name in corsHeaders)
|
|
573
|
+
if (corsHeaders) for (const name in corsHeaders) {
|
|
574
|
+
const value = corsHeaders[name];
|
|
575
|
+
if (value !== void 0) response.setHeader(name, value);
|
|
576
|
+
}
|
|
569
577
|
return isPreflight ? 204 : void 0;
|
|
570
578
|
}
|
|
571
579
|
/**
|
|
@@ -584,7 +592,10 @@ var Firewall = class extends Service {
|
|
|
584
592
|
const response = context.response;
|
|
585
593
|
if (typeof response?.setHeader !== "function") return;
|
|
586
594
|
const headers = sh.headers;
|
|
587
|
-
for (const name in headers)
|
|
595
|
+
for (const name in headers) {
|
|
596
|
+
const value = headers[name];
|
|
597
|
+
if (value !== void 0) response.setHeader(name, value);
|
|
598
|
+
}
|
|
588
599
|
const extra = context.cspDirectives;
|
|
589
600
|
if (sh.hasNonce) {
|
|
590
601
|
const nonce = context.cspNonce;
|
|
@@ -665,7 +676,7 @@ var Firewall = class extends Service {
|
|
|
665
676
|
throw error;
|
|
666
677
|
}
|
|
667
678
|
if (error instanceof UnverifiableTokenError) {
|
|
668
|
-
this.log(`token unverifiable (area "${area.name}", authenticator "${name}") —
|
|
679
|
+
this.log(`token unverifiable (area "${area.name}", authenticator "${name}") — ` + (error.detail ?? "cause non renseignée"), "ERROR");
|
|
669
680
|
this.log(error, "ERROR");
|
|
670
681
|
throw error;
|
|
671
682
|
}
|
|
@@ -689,6 +700,7 @@ var Firewall = class extends Service {
|
|
|
689
700
|
return;
|
|
690
701
|
}
|
|
691
702
|
}
|
|
703
|
+
response.setHeader("WWW-Authenticate", FALLBACK_CHALLENGE);
|
|
692
704
|
}
|
|
693
705
|
log(pci, severity, msgid, msg) {
|
|
694
706
|
if (!msgid) msgid = logColor.cyan("FIREWALL");
|
|
@@ -135,7 +135,7 @@ var OAuth2Service = class extends Service {
|
|
|
135
135
|
listDisplayProviders() {
|
|
136
136
|
if (this.#config === null) return [];
|
|
137
137
|
const configured = this.#config.oauth2.providers;
|
|
138
|
-
return this.listProviders().filter((name) => configured[name]?.hidden
|
|
138
|
+
return this.listProviders().filter((name) => !configured[name]?.hidden).map((name) => ({
|
|
139
139
|
name,
|
|
140
140
|
label: configured[name]?.label ?? oauthDisplayLabel(name)
|
|
141
141
|
}));
|
|
@@ -192,7 +192,7 @@ var OAuth2Service = class extends Service {
|
|
|
192
192
|
codeVerifier
|
|
193
193
|
});
|
|
194
194
|
const profile = await p.fetchProfile(tokens);
|
|
195
|
-
const cfg = this.#
|
|
195
|
+
const cfg = this.#ensureReady().oauth2;
|
|
196
196
|
const defaultRoles = cfg.providers[provider]?.defaultRoles ?? cfg.defaultRoles;
|
|
197
197
|
return { identifier: (await this.#resolveProvisioner().provisionOAuthUser(profile, {
|
|
198
198
|
defaultRoles: [...defaultRoles],
|
|
@@ -210,7 +210,8 @@ var OAuth2Service = class extends Service {
|
|
|
210
210
|
return pending;
|
|
211
211
|
}
|
|
212
212
|
async #buildProvider(name) {
|
|
213
|
-
const
|
|
213
|
+
const providers = this.#ensureReady().oauth2.providers;
|
|
214
|
+
const cfg = Object.hasOwn(providers, name) ? providers[name] : void 0;
|
|
214
215
|
if (!cfg) throw new AuthenticationError(`OAuth provider "${name}" non configuré`);
|
|
215
216
|
const factory = getOAuthProviderFactory(name);
|
|
216
217
|
if (!factory) throw new AuthenticationError(`OAuth provider "${name}" inconnu du registre`);
|
|
@@ -233,6 +234,7 @@ var OAuth2Service = class extends Service {
|
|
|
233
234
|
}
|
|
234
235
|
#ensureReady() {
|
|
235
236
|
if (!this.#ready || this.#config === null) throw new Error("OAuth2Service: non initialisé (social login désactivé ou boot échoué)");
|
|
237
|
+
return this.#config;
|
|
236
238
|
}
|
|
237
239
|
};
|
|
238
240
|
//#endregion
|
|
@@ -39,7 +39,7 @@ var SecurityHeaders = class {
|
|
|
39
39
|
if (o.permissionsPolicy) h["Permissions-Policy"] = o.permissionsPolicy;
|
|
40
40
|
this.#headers = Object.freeze(h);
|
|
41
41
|
this.#cspParts = cspParts ? Object.freeze(cspParts) : null;
|
|
42
|
-
this.#cspRaw = o.csp
|
|
42
|
+
this.#cspRaw = o.csp;
|
|
43
43
|
}
|
|
44
44
|
/** Table d'en-têtes applicatifs CONSTANTS (figée), posée telle quelle par le firewall. */
|
|
45
45
|
get headers() {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AuthenticationError } from "../errors/AuthenticationError.js";
|
|
2
2
|
import { ThrottledError } from "../errors/ThrottledError.js";
|
|
3
3
|
import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
4
|
-
import { resolveJwtRuntime } from "../src/token/jwtRuntime.js";
|
|
4
|
+
import { defaultAudience, resolveJwtRuntime } from "../src/token/jwtRuntime.js";
|
|
5
5
|
import { recordAudit } from "../src/audit/recordAudit.js";
|
|
6
6
|
import { InvalidTargetError } from "../errors/InvalidTargetError.js";
|
|
7
7
|
import { getTokenStoreFactory, listTokenStores } from "../src/token/tokenStoreRegistry.js";
|
|
@@ -250,8 +250,8 @@ var TokenService = class extends Service {
|
|
|
250
250
|
* @throws InvalidTargetError (400) si la ressource est multiple, malformée ou
|
|
251
251
|
* non déclarée
|
|
252
252
|
*/
|
|
253
|
-
#resolveAudience(requested) {
|
|
254
|
-
const fallback =
|
|
253
|
+
#resolveAudience(runtime, requested) {
|
|
254
|
+
const fallback = defaultAudience(runtime);
|
|
255
255
|
if (requested === void 0 || requested === null) return fallback;
|
|
256
256
|
if (Array.isArray(requested)) throw new InvalidTargetError("A single `resource` is accepted — a token valid at several resources lets each of them act at the others (RFC 8707 §3).");
|
|
257
257
|
if (typeof requested !== "string" || requested.length === 0) throw new InvalidTargetError("`resource` must be an absolute URI.");
|
|
@@ -262,7 +262,7 @@ var TokenService = class extends Service {
|
|
|
262
262
|
throw new InvalidTargetError("`resource` must be an absolute URI (RFC 8707 §2).", requested);
|
|
263
263
|
}
|
|
264
264
|
if (url.hash) throw new InvalidTargetError("`resource` must not include a fragment (RFC 8707 §2).", requested);
|
|
265
|
-
if (!
|
|
265
|
+
if (!runtime.audiences.includes(requested)) throw new InvalidTargetError("The requested resource is not available to this application.", requested);
|
|
266
266
|
return requested;
|
|
267
267
|
}
|
|
268
268
|
/**
|
|
@@ -304,12 +304,12 @@ var TokenService = class extends Service {
|
|
|
304
304
|
return requested.filter((scope) => !refuses.includes(scope));
|
|
305
305
|
}
|
|
306
306
|
async issueTokens(user, requestedScopes, resource, accessTtlS) {
|
|
307
|
-
this.#ensureReady();
|
|
307
|
+
const { store, runtime } = this.#ensureReady();
|
|
308
308
|
const scopes = this.#grantableScopes(user, requestedScopes && requestedScopes.length > 0 ? [...requestedScopes] : []);
|
|
309
|
-
const audience = this.#resolveAudience(resource);
|
|
309
|
+
const audience = this.#resolveAudience(runtime, resource);
|
|
310
310
|
const access = await this.#signAccess(user.identifier, scopes, audience, accessTtlS);
|
|
311
311
|
const { record, raw } = this.#buildRefresh(user.identifier, scopes, this.#randomId(), audience);
|
|
312
|
-
await
|
|
312
|
+
await store.put(record);
|
|
313
313
|
recordAudit(this.container, {
|
|
314
314
|
category: "token",
|
|
315
315
|
action: "token.issued",
|
|
@@ -324,7 +324,7 @@ var TokenService = class extends Service {
|
|
|
324
324
|
access_token: access,
|
|
325
325
|
refresh_token: raw,
|
|
326
326
|
token_type: "Bearer",
|
|
327
|
-
expires_in: accessTtlS ??
|
|
327
|
+
expires_in: accessTtlS ?? runtime.accessTtlS,
|
|
328
328
|
scope: scopes.join(" ")
|
|
329
329
|
};
|
|
330
330
|
}
|
|
@@ -345,11 +345,10 @@ var TokenService = class extends Service {
|
|
|
345
345
|
* @throws InvalidTargetError (400) — `resource` demandée ≠ celle accordée
|
|
346
346
|
*/
|
|
347
347
|
async refresh(rawRefresh, resource) {
|
|
348
|
-
this.#ensureReady();
|
|
348
|
+
const { store, runtime } = this.#ensureReady();
|
|
349
349
|
if (typeof rawRefresh !== "string" || rawRefresh.length === 0) throw new AuthenticationError("Invalid token");
|
|
350
|
-
const store = this.#store;
|
|
351
350
|
const record = await store.findByHash(this.#hash(rawRefresh));
|
|
352
|
-
if (
|
|
351
|
+
if (record?.kind !== "refresh") throw new AuthenticationError("Invalid token");
|
|
353
352
|
if (record.revokedAt !== null) {
|
|
354
353
|
if (record.family) await store.revokeFamily(record.family, "reuse_detected");
|
|
355
354
|
recordAudit(this.container, {
|
|
@@ -369,14 +368,14 @@ var TokenService = class extends Service {
|
|
|
369
368
|
if (record.expiresAt !== null && record.expiresAt <= now) throw new AuthenticationError("Invalid token");
|
|
370
369
|
const user = await this.#resolveUserForRefresh(record.subjectId);
|
|
371
370
|
const scopes = [...record.scopes];
|
|
372
|
-
const audience = record.audience?.
|
|
371
|
+
const audience = record.audience?.at(0) ?? defaultAudience(runtime);
|
|
373
372
|
if (resource !== void 0 && resource !== null && resource !== audience) throw new InvalidTargetError("The requested resource does not match the one granted to this token (RFC 8707 §2.2).", typeof resource === "string" ? resource : void 0);
|
|
374
373
|
const access = await this.#signAccess(user.identifier, scopes, audience);
|
|
375
|
-
if (!
|
|
374
|
+
if (!runtime.rotateRefresh) return {
|
|
376
375
|
access_token: access,
|
|
377
376
|
refresh_token: rawRefresh,
|
|
378
377
|
token_type: "Bearer",
|
|
379
|
-
expires_in:
|
|
378
|
+
expires_in: runtime.accessTtlS,
|
|
380
379
|
scope: scopes.join(" ")
|
|
381
380
|
};
|
|
382
381
|
const family = record.family ?? this.#randomId();
|
|
@@ -390,14 +389,14 @@ var TokenService = class extends Service {
|
|
|
390
389
|
access_token: access,
|
|
391
390
|
refresh_token: next.raw,
|
|
392
391
|
token_type: "Bearer",
|
|
393
|
-
expires_in:
|
|
392
|
+
expires_in: runtime.accessTtlS,
|
|
394
393
|
scope: scopes.join(" ")
|
|
395
394
|
};
|
|
396
395
|
}
|
|
397
396
|
async #signAccess(subject, scopes, audience, ttlS) {
|
|
397
|
+
const { keystore, runtime: rt } = this.#ensureReady();
|
|
398
398
|
const jose = await this.#ensureJose();
|
|
399
|
-
const { key, kid } = await
|
|
400
|
-
const rt = this.#runtime;
|
|
399
|
+
const { key, kid } = await keystore.getSigningKey();
|
|
401
400
|
return new jose.SignJWT({ scope: scopes.join(" ") }).setProtectedHeader({
|
|
402
401
|
alg: "EdDSA",
|
|
403
402
|
kid,
|
|
@@ -434,7 +433,7 @@ var TokenService = class extends Service {
|
|
|
434
433
|
family,
|
|
435
434
|
replacedBy: null,
|
|
436
435
|
createdAt: now,
|
|
437
|
-
expiresAt: now + this.#runtime.refreshTtlS * 1e3,
|
|
436
|
+
expiresAt: now + this.#ensureReady().runtime.refreshTtlS * 1e3,
|
|
438
437
|
lastUsedAt: null,
|
|
439
438
|
lastUsedIp: null,
|
|
440
439
|
lastUsedUserAgent: null,
|
|
@@ -445,8 +444,18 @@ var TokenService = class extends Service {
|
|
|
445
444
|
raw
|
|
446
445
|
};
|
|
447
446
|
}
|
|
447
|
+
/**
|
|
448
|
+
* Garde d'initialisation : lève si le service n'est pas prêt, sinon rend le
|
|
449
|
+
* store, le keystore et le runtime typés non nuls. Aucun des trois n'est
|
|
450
|
+
* réassigné après le boot — les capturer équivaut à les relire plus tard.
|
|
451
|
+
*/
|
|
448
452
|
#ensureReady() {
|
|
449
453
|
if (!this.#store || !this.#keystore || !this.#runtime) throw new Error("TokenService: non initialisé (JWT désactivé ou store indisponible)");
|
|
454
|
+
return {
|
|
455
|
+
store: this.#store,
|
|
456
|
+
keystore: this.#keystore,
|
|
457
|
+
runtime: this.#runtime
|
|
458
|
+
};
|
|
450
459
|
}
|
|
451
460
|
async #ensureJose() {
|
|
452
461
|
return this.#jose ??= await import("jose");
|
|
@@ -135,10 +135,10 @@ var WebAuthnService = class extends Service {
|
|
|
135
135
|
* pour empêcher un double enregistrement sur le même authenticator (§7.1).
|
|
136
136
|
*/
|
|
137
137
|
async generateRegistrationOptions(user) {
|
|
138
|
-
this.#ensureReady();
|
|
138
|
+
const { store, config } = this.#ensureReady();
|
|
139
139
|
const lib = await this.#ensureLib();
|
|
140
|
-
const pk =
|
|
141
|
-
const existing = await
|
|
140
|
+
const pk = config.passkeys;
|
|
141
|
+
const existing = await store.findByUser(user.id);
|
|
142
142
|
return lib.generateRegistrationOptions({
|
|
143
143
|
rpName: this.#rpName,
|
|
144
144
|
rpID: this.#rpID,
|
|
@@ -169,23 +169,23 @@ var WebAuthnService = class extends Service {
|
|
|
169
169
|
* @throws WebAuthnError (409) — plafond `passkeys.maxPerUser` atteint.
|
|
170
170
|
*/
|
|
171
171
|
async verifyRegistration(response, expectedChallenge, userId, requestOrigin) {
|
|
172
|
-
this.#ensureReady();
|
|
172
|
+
const { store, config } = this.#ensureReady();
|
|
173
173
|
const lib = await this.#ensureLib();
|
|
174
|
-
const pk =
|
|
174
|
+
const pk = config.passkeys;
|
|
175
175
|
let verification;
|
|
176
176
|
try {
|
|
177
177
|
verification = await lib.verifyRegistrationResponse({
|
|
178
178
|
response,
|
|
179
179
|
expectedChallenge,
|
|
180
|
-
expectedOrigin: this.#expectedOrigin(requestOrigin),
|
|
180
|
+
expectedOrigin: this.#expectedOrigin(pk.origins, requestOrigin),
|
|
181
181
|
expectedRPID: this.#rpID,
|
|
182
182
|
requireUserVerification: pk.userVerification === "required"
|
|
183
183
|
});
|
|
184
184
|
} catch {
|
|
185
185
|
throw new AuthenticationError("WebAuthn registration failed");
|
|
186
186
|
}
|
|
187
|
-
if (!verification.verified
|
|
188
|
-
if (await
|
|
187
|
+
if (!verification.verified) throw new AuthenticationError("WebAuthn registration failed");
|
|
188
|
+
if (await store.countByUser(userId) >= pk.maxPerUser) throw new WebAuthnError(`passkey limit reached (${pk.maxPerUser})`, 409);
|
|
189
189
|
const info = verification.registrationInfo;
|
|
190
190
|
const credential = {
|
|
191
191
|
id: info.credential.id,
|
|
@@ -199,7 +199,7 @@ var WebAuthnService = class extends Service {
|
|
|
199
199
|
createdAt: Date.now(),
|
|
200
200
|
lastUsedAt: null
|
|
201
201
|
};
|
|
202
|
-
await
|
|
202
|
+
await store.save(credential);
|
|
203
203
|
return credential;
|
|
204
204
|
}
|
|
205
205
|
/**
|
|
@@ -216,17 +216,17 @@ var WebAuthnService = class extends Service {
|
|
|
216
216
|
* `WebAuthnController.loginOptions()` ne cible que depuis la session.
|
|
217
217
|
*/
|
|
218
218
|
async generateAuthenticationOptions(userId) {
|
|
219
|
-
this.#ensureReady();
|
|
219
|
+
const { store, config } = this.#ensureReady();
|
|
220
220
|
const lib = await this.#ensureLib();
|
|
221
|
-
const pk =
|
|
221
|
+
const pk = config.passkeys;
|
|
222
222
|
let allowCredentials;
|
|
223
|
-
if (userId) allowCredentials = (await
|
|
223
|
+
if (userId) allowCredentials = (await store.findByUser(userId)).map((c) => ({
|
|
224
224
|
id: c.id,
|
|
225
225
|
transports: [...c.transports]
|
|
226
226
|
}));
|
|
227
227
|
return lib.generateAuthenticationOptions({
|
|
228
228
|
rpID: this.#rpID,
|
|
229
|
-
allowCredentials,
|
|
229
|
+
...allowCredentials !== void 0 && { allowCredentials },
|
|
230
230
|
userVerification: pk.userVerification,
|
|
231
231
|
timeout: pk.timeoutMs
|
|
232
232
|
});
|
|
@@ -240,17 +240,17 @@ var WebAuthnService = class extends Service {
|
|
|
240
240
|
* @throws AuthenticationError (401) — credential inconnu ou vérification échouée.
|
|
241
241
|
*/
|
|
242
242
|
async verifyAuthentication(response, expectedChallenge, requestOrigin) {
|
|
243
|
-
this.#ensureReady();
|
|
243
|
+
const { store, config } = this.#ensureReady();
|
|
244
244
|
const lib = await this.#ensureLib();
|
|
245
|
-
const pk =
|
|
246
|
-
const stored = await
|
|
245
|
+
const pk = config.passkeys;
|
|
246
|
+
const stored = await store.findById(response.id);
|
|
247
247
|
if (!stored) throw new AuthenticationError("WebAuthn authentication failed");
|
|
248
248
|
let verification;
|
|
249
249
|
try {
|
|
250
250
|
verification = await lib.verifyAuthenticationResponse({
|
|
251
251
|
response,
|
|
252
252
|
expectedChallenge,
|
|
253
|
-
expectedOrigin: this.#expectedOrigin(requestOrigin),
|
|
253
|
+
expectedOrigin: this.#expectedOrigin(pk.origins, requestOrigin),
|
|
254
254
|
expectedRPID: this.#rpID,
|
|
255
255
|
credential: {
|
|
256
256
|
id: stored.id,
|
|
@@ -265,7 +265,7 @@ var WebAuthnService = class extends Service {
|
|
|
265
265
|
}
|
|
266
266
|
if (!verification.verified) throw new AuthenticationError("WebAuthn authentication failed");
|
|
267
267
|
const info = verification.authenticationInfo;
|
|
268
|
-
await
|
|
268
|
+
await store.update(stored.id, {
|
|
269
269
|
signCount: info.newCounter,
|
|
270
270
|
backupState: info.credentialBackedUp,
|
|
271
271
|
uvInitialized: stored.uvInitialized || info.userVerified,
|
|
@@ -278,8 +278,7 @@ var WebAuthnService = class extends Service {
|
|
|
278
278
|
}
|
|
279
279
|
/** Liste les credentials d'un utilisateur (UX « mes appareils »). */
|
|
280
280
|
listUserCredentials(userId) {
|
|
281
|
-
this.#ensureReady();
|
|
282
|
-
return this.#store.findByUser(userId);
|
|
281
|
+
return this.#ensureReady().store.findByUser(userId);
|
|
283
282
|
}
|
|
284
283
|
/**
|
|
285
284
|
* Page de passkeys pour le data plane admin (vue TRANSVERSE : « quels appareils
|
|
@@ -290,21 +289,18 @@ var WebAuthnService = class extends Service {
|
|
|
290
289
|
* du store exclut la clé publique.
|
|
291
290
|
*/
|
|
292
291
|
listCredentialsPage(query) {
|
|
293
|
-
this.#ensureReady();
|
|
294
|
-
return this.#store.listPage(query);
|
|
292
|
+
return this.#ensureReady().store.listPage(query);
|
|
295
293
|
}
|
|
296
294
|
/**
|
|
297
295
|
* Nombre de passkeys correspondant aux filtres, ou `-1` si le backend ne sait
|
|
298
296
|
* pas compter à coût raisonnable (Redis).
|
|
299
297
|
*/
|
|
300
298
|
countCredentials(query) {
|
|
301
|
-
this.#ensureReady();
|
|
302
|
-
return this.#store.countCredentials(query);
|
|
299
|
+
return this.#ensureReady().store.countCredentials(query);
|
|
303
300
|
}
|
|
304
301
|
/** Révoque un credential (retrait d'un appareil). */
|
|
305
302
|
removeCredential(credentialId) {
|
|
306
|
-
this.#ensureReady();
|
|
307
|
-
return this.#store.delete(credentialId);
|
|
303
|
+
return this.#ensureReady().store.delete(credentialId);
|
|
308
304
|
}
|
|
309
305
|
/**
|
|
310
306
|
* Supprime un credential **du propriétaire** (self-service, anti-IDOR) : la
|
|
@@ -313,10 +309,10 @@ var WebAuthnService = class extends Service {
|
|
|
313
309
|
* credential d'autrui).
|
|
314
310
|
*/
|
|
315
311
|
async removeUserCredential(userId, credentialId) {
|
|
316
|
-
this.#ensureReady();
|
|
317
|
-
const cred = await
|
|
312
|
+
const { store } = this.#ensureReady();
|
|
313
|
+
const cred = await store.findById(credentialId);
|
|
318
314
|
if (!cred || cred.userId !== userId) return false;
|
|
319
|
-
await
|
|
315
|
+
await store.delete(credentialId);
|
|
320
316
|
return true;
|
|
321
317
|
}
|
|
322
318
|
/**
|
|
@@ -324,9 +320,11 @@ var WebAuthnService = class extends Service {
|
|
|
324
320
|
* priorité (prod). À défaut : l'origine de la requête est acceptée **seulement
|
|
325
321
|
* si son hostname == rpID** (dev : `localhost:port` quel que soit le port, sans
|
|
326
322
|
* jamais ouvrir à un domaine tiers). Dernier recours : `https://{rpID}`.
|
|
323
|
+
*
|
|
324
|
+
* @param origins - liste blanche `passkeys.origins` de la config prête.
|
|
325
|
+
* @param requestOrigin - origine HTTP de la requête, si connue.
|
|
327
326
|
*/
|
|
328
|
-
#expectedOrigin(requestOrigin) {
|
|
329
|
-
const origins = this.#config.passkeys.origins;
|
|
327
|
+
#expectedOrigin(origins, requestOrigin) {
|
|
330
328
|
if (origins.length > 0) return [...origins];
|
|
331
329
|
if (requestOrigin) try {
|
|
332
330
|
if (new URL(requestOrigin).hostname === this.#rpID) return requestOrigin;
|
|
@@ -336,8 +334,17 @@ var WebAuthnService = class extends Service {
|
|
|
336
334
|
async #ensureLib() {
|
|
337
335
|
return this.#lib ??= await import("@simplewebauthn/server");
|
|
338
336
|
}
|
|
337
|
+
/**
|
|
338
|
+
* Garde d'initialisation : lève si le service n'est pas prêt, sinon rend le
|
|
339
|
+
* store et la config typés non nuls (ni l'un ni l'autre ne sont réassignés
|
|
340
|
+
* après le boot — les capturer ici équivaut à les relire plus tard).
|
|
341
|
+
*/
|
|
339
342
|
#ensureReady() {
|
|
340
343
|
if (!this.#ready || !this.#store || !this.#config) throw new Error("WebAuthnService: non initialisé (passkeys désactivés ou boot échoué)");
|
|
344
|
+
return {
|
|
345
|
+
store: this.#store,
|
|
346
|
+
config: this.#config
|
|
347
|
+
};
|
|
341
348
|
}
|
|
342
349
|
};
|
|
343
350
|
//#endregion
|