@nodefony/security 10.0.0-beta.1 → 10.0.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +44 -10
- package/dist/nodefony/command/security-oauth-doctor.js +66 -0
- package/dist/nodefony/command/security-secrets.js +42 -25
- package/dist/nodefony/command/security-token.js +46 -15
- package/dist/nodefony/command/security-user-delete.js +5 -4
- package/dist/nodefony/command/security-user-password.js +4 -11
- package/dist/nodefony/config/config.js +80 -5
- package/dist/nodefony/contracts/index.js +1 -2
- package/dist/nodefony/errors/InsecureTransportError.js +24 -0
- package/dist/nodefony/errors/index.js +2 -1
- package/dist/nodefony/keycloak/keycloakAdmin.js +255 -0
- package/dist/nodefony/keycloak/keycloakRealm.js +260 -0
- package/dist/nodefony/keycloak/keycloakRealmInput.js +124 -0
- package/dist/nodefony/keycloak/scaffold.js +231 -0
- package/dist/nodefony/keycloak/security-keycloak-realm.js +128 -0
- package/dist/nodefony/scaffold/contribute.js +20 -0
- package/dist/nodefony/service/authFlow.js +111 -11
- package/dist/nodefony/service/firewall.js +111 -21
- package/dist/nodefony/service/keycloak.js +209 -0
- package/dist/nodefony/service/oauth2.js +379 -26
- package/dist/nodefony/service/securityHeaders.js +12 -1
- package/dist/nodefony/service/tokenService.js +57 -6
- package/dist/nodefony/service/totp.js +51 -6
- package/dist/nodefony/service/webAuthn.js +20 -2
- package/dist/nodefony/src/SecuredArea.js +14 -3
- package/dist/nodefony/src/admin/SecurityAdminApi.js +70 -40
- package/dist/nodefony/src/admin/adminAudit.js +0 -1
- package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +66 -6
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +38 -15
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +1 -0
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +13 -3
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +9 -2
- package/dist/nodefony/src/authenticator/bearer.js +1 -2
- package/dist/nodefony/src/boot/publicRoutesNotice.js +27 -0
- package/dist/nodefony/src/entryPoint.js +39 -0
- package/dist/nodefony/src/oauth/clientCredentials.js +40 -0
- package/dist/nodefony/src/oauth/metadata.js +36 -9
- package/dist/nodefony/src/oauth/oauth2Client.js +60 -33
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +39 -6
- package/dist/nodefony/src/oauth/providerDiagnosis.js +283 -0
- package/dist/nodefony/src/oauth/providerRoles.js +79 -0
- package/dist/nodefony/src/oauth/providers/github.js +32 -6
- package/dist/nodefony/src/oauth/providers/oidc.js +82 -3
- package/dist/nodefony/src/openToSignup.js +47 -0
- package/dist/nodefony/src/sessionAuthentication.js +26 -0
- package/dist/nodefony/src/token/JwtKeystore.js +78 -46
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +40 -13
- package/dist/nodefony/src/token/secretFile.js +59 -2
- package/dist/nodefony/src/transport/CredentialTransportPolicy.js +51 -0
- package/dist/types/index.d.ts +26 -6
- package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
- package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
- package/dist/types/nodefony/command/security-token.d.ts +9 -0
- package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
- package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
- package/dist/types/nodefony/config/config.d.ts +63 -1
- package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
- package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
- package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
- package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
- package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
- package/dist/types/nodefony/errors/index.d.ts +1 -0
- package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
- package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
- package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
- package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
- package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
- package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
- package/dist/types/nodefony/service/authFlow.d.ts +35 -3
- package/dist/types/nodefony/service/firewall.d.ts +17 -3
- package/dist/types/nodefony/service/keycloak.d.ts +115 -0
- package/dist/types/nodefony/service/oauth2.d.ts +125 -9
- package/dist/types/nodefony/service/securityHeaders.d.ts +2 -1
- package/dist/types/nodefony/service/tokenService.d.ts +7 -1
- package/dist/types/nodefony/service/totp.d.ts +28 -4
- package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
- package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +54 -12
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +18 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +13 -0
- package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
- package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
- package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
- package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
- package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
- package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
- package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
- package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
- package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
- package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
- package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
- package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
- package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
- package/docs/api-keys.md +18 -18
- package/docs/audit.md +24 -22
- package/docs/authenticators.md +62 -29
- package/docs/authorization.md +16 -16
- package/docs/cors.md +30 -30
- package/docs/csrf.md +35 -33
- package/docs/external-jwt.md +68 -5
- package/docs/firewall.md +78 -45
- package/docs/headers.md +35 -35
- package/docs/index.md +15 -7
- package/docs/keycloak.md +818 -0
- package/docs/lexique.md +15 -15
- package/docs/login-page.md +361 -0
- package/docs/oauth2.md +242 -125
- package/docs/obtenir-un-jeton.md +18 -11
- package/docs/tokens.md +138 -86
- package/docs/totp.md +113 -71
- package/docs/webauthn.md +115 -115
- package/docs/webhooks.md +21 -21
- package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
- package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/account/theme.properties +14 -0
- package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
- package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/admin/theme.properties +12 -0
- package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
- package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
- package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
- package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/email/theme.properties +8 -0
- package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
- package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
- package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
- package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
- package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/login/template.ftl +423 -0
- package/keycloak/themes/nodefony/login/theme.properties +17 -0
- package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
- package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
- package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
- package/package.json +17 -15
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
2
|
+
import { recordAudit } from "../src/audit/recordAudit.js";
|
|
3
|
+
import { readAuditContext } from "../src/audit/readAuditContext.js";
|
|
2
4
|
import { getTotpStoreFactory, listTotpStores } from "../src/totp/totpSecretStoreRegistry.js";
|
|
3
5
|
import { generateEphemeralKey } from "../src/crypto/secretCipher.js";
|
|
4
6
|
import { deriveTotpKey } from "../src/totp/totpCipher.js";
|
|
@@ -157,17 +159,60 @@ var TotpService = class extends Service {
|
|
|
157
159
|
beginEnrollment(userId, account) {
|
|
158
160
|
return beginTotpEnrollment(this.#ensureReady(), userId, account);
|
|
159
161
|
}
|
|
160
|
-
/**
|
|
161
|
-
|
|
162
|
-
|
|
162
|
+
/**
|
|
163
|
+
* Confirme l'enrôlement par un 1ᵉʳ code → active + codes de récupération clairs.
|
|
164
|
+
*
|
|
165
|
+
* Activer un second facteur est un geste de SÉCURITÉ : il est audité ici
|
|
166
|
+
* (`user.totp_enabled`), avec la requête qui l'a porté — sans trace, un facteur
|
|
167
|
+
* posé à l'insu du titulaire ne se retrouve pas.
|
|
168
|
+
*
|
|
169
|
+
* @param userId - compte dont on active le 2FA (le porteur de la session).
|
|
170
|
+
* @param code - premier code de l'application d'authentification.
|
|
171
|
+
* @param context - contexte de la requête (IP, navigateur, identifiant de
|
|
172
|
+
* requête) recopié dans l'événement d'audit ; absent, l'événement part sans.
|
|
173
|
+
* @returns les codes de récupération, affichés une seule fois.
|
|
174
|
+
* @throws quand le code est faux, l'enrôlement absent ou déjà confirmé.
|
|
175
|
+
*/
|
|
176
|
+
async confirmEnrollment(userId, code, context) {
|
|
177
|
+
const activation = await confirmTotpEnrollment(this.#ensureReady(), userId, code);
|
|
178
|
+
recordAudit(this.container, {
|
|
179
|
+
category: "auth",
|
|
180
|
+
action: "user.totp_enabled",
|
|
181
|
+
outcome: "success",
|
|
182
|
+
actor: userId,
|
|
183
|
+
resource: userId,
|
|
184
|
+
...readAuditContext(context)
|
|
185
|
+
});
|
|
186
|
+
return activation;
|
|
163
187
|
}
|
|
164
188
|
/** Vérifie un second facteur au login (code TOTP ou code de récupération). */
|
|
165
189
|
verifyLogin(userId, code) {
|
|
166
190
|
return verifyTotpLogin(this.#ensureReady(), userId, code);
|
|
167
191
|
}
|
|
168
|
-
/**
|
|
169
|
-
|
|
170
|
-
|
|
192
|
+
/**
|
|
193
|
+
* Désactive le 2FA d'un utilisateur.
|
|
194
|
+
*
|
|
195
|
+
* Avec un `context`, c'est le TITULAIRE qui retire son facteur (porte
|
|
196
|
+
* libre-service) : le geste est audité ici (`user.totp_disabled`). Sans
|
|
197
|
+
* contexte, l'appelant porte lui-même l'audit — le plan d'administration le
|
|
198
|
+
* fait avec l'identité de l'administrateur (`viaAdmin`), qu'ici on ignore.
|
|
199
|
+
* Un seul événement par geste, jamais deux.
|
|
200
|
+
*
|
|
201
|
+
* @param userId - compte dont on retire le 2FA.
|
|
202
|
+
* @param context - contexte de la requête du titulaire (audit libre-service).
|
|
203
|
+
*/
|
|
204
|
+
async disable(userId, context) {
|
|
205
|
+
await disableTotp(this.#ensureReady(), userId);
|
|
206
|
+
if (context === void 0) return;
|
|
207
|
+
recordAudit(this.container, {
|
|
208
|
+
category: "auth",
|
|
209
|
+
action: "user.totp_disabled",
|
|
210
|
+
outcome: "success",
|
|
211
|
+
actor: userId,
|
|
212
|
+
resource: userId,
|
|
213
|
+
metadata: { viaAdmin: false },
|
|
214
|
+
...readAuditContext(context)
|
|
215
|
+
});
|
|
171
216
|
}
|
|
172
217
|
/** État 2FA d'un utilisateur (absent / pending / activé + codes restants). */
|
|
173
218
|
status(userId) {
|
|
@@ -3,12 +3,30 @@ import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
|
3
3
|
import { WebAuthnError } from "../errors/WebAuthnError.js";
|
|
4
4
|
import { getWebAuthnStoreFactory, listWebAuthnStores } from "../src/webauthn/webAuthnCredentialStoreRegistry.js";
|
|
5
5
|
import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
6
7
|
import { Buffer } from "node:buffer";
|
|
7
8
|
//#region nodefony/service/webAuthn.ts
|
|
8
9
|
const serviceName = "webauthn";
|
|
9
10
|
function isFlushable(s) {
|
|
10
11
|
return s !== null && typeof s.flushNow === "function";
|
|
11
12
|
}
|
|
13
|
+
/** Taille maximale d'un `userHandle` (WebAuthn L3 §4, « User Handle »). */
|
|
14
|
+
const USER_HANDLE_MAX_BYTES = 64;
|
|
15
|
+
/**
|
|
16
|
+
* Octets du `userHandle` d'une cérémonie d'enregistrement — opaques, bornés, sans
|
|
17
|
+
* donnée personnelle (cf {@link IWebAuthnUser.handle}).
|
|
18
|
+
*
|
|
19
|
+
* @param user - sujet de la cérémonie.
|
|
20
|
+
* @returns `handle` encodé s'il est fourni et tient en 64 octets, sinon le
|
|
21
|
+
* condensat SHA-256 de l'`id`.
|
|
22
|
+
*/
|
|
23
|
+
function userHandleBytes(user) {
|
|
24
|
+
if (user.handle !== void 0 && user.handle.length > 0) {
|
|
25
|
+
const bytes = Buffer.from(user.handle, "utf8");
|
|
26
|
+
if (bytes.length <= USER_HANDLE_MAX_BYTES) return new Uint8Array(bytes);
|
|
27
|
+
}
|
|
28
|
+
return new Uint8Array(createHash("sha256").update(user.id, "utf8").digest());
|
|
29
|
+
}
|
|
12
30
|
/**
|
|
13
31
|
* **WebAuthn / passkeys** (P6 J9) — orchestrateur des deux cérémonies FIDO2
|
|
14
32
|
* (WebAuthn L3 §7.1 enregistrement, §7.2 authentification).
|
|
@@ -143,7 +161,7 @@ var WebAuthnService = class extends Service {
|
|
|
143
161
|
rpName: this.#rpName,
|
|
144
162
|
rpID: this.#rpID,
|
|
145
163
|
userName: user.name,
|
|
146
|
-
userID:
|
|
164
|
+
userID: userHandleBytes(user),
|
|
147
165
|
userDisplayName: user.displayName ?? user.name,
|
|
148
166
|
attestationType: pk.attestation,
|
|
149
167
|
timeout: pk.timeoutMs,
|
|
@@ -348,4 +366,4 @@ var WebAuthnService = class extends Service {
|
|
|
348
366
|
}
|
|
349
367
|
};
|
|
350
368
|
//#endregion
|
|
351
|
-
export { WebAuthnService, WebAuthnService as default };
|
|
369
|
+
export { WebAuthnService, WebAuthnService as default, userHandleBytes };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stripTrailingSlashes } from "nodefony";
|
|
1
2
|
//#region nodefony/src/SecuredArea.ts
|
|
2
3
|
/**
|
|
3
4
|
* Zone sécurisée concrète — pattern d'URL compilé + métadonnées d'authentification.
|
|
@@ -16,22 +17,30 @@ var SecuredArea = class {
|
|
|
16
17
|
host;
|
|
17
18
|
realtime;
|
|
18
19
|
resource;
|
|
20
|
+
entryPoint;
|
|
21
|
+
subjectPolicy;
|
|
22
|
+
ephemeralRoles;
|
|
19
23
|
/**
|
|
20
24
|
* Rôles exigés par défaut dans la zone, ou `null` quand elle n'en exige
|
|
21
25
|
* aucun. `null` plutôt qu'un tableau vide : le hot path teste une référence,
|
|
22
26
|
* et aucune zone ne porte de tableau qu'elle n'utilise pas.
|
|
23
27
|
*/
|
|
24
28
|
roles;
|
|
29
|
+
#hostKey;
|
|
25
30
|
constructor(name, config) {
|
|
26
31
|
this.name = name;
|
|
27
|
-
this.pattern = new RegExp(config.pattern, "
|
|
32
|
+
this.pattern = new RegExp(config.pattern, "iu");
|
|
28
33
|
this.security = config.security;
|
|
29
34
|
this.stateless = config.stateless;
|
|
30
35
|
this.mode = config.mode;
|
|
31
36
|
this.authenticators = config.authenticators;
|
|
32
37
|
this.host = config.host;
|
|
38
|
+
this.#hostKey = config.host ? config.host.toLowerCase() : null;
|
|
33
39
|
this.realtime = config.realtime;
|
|
34
40
|
this.resource = config.resource;
|
|
41
|
+
this.entryPoint = config.entryPoint;
|
|
42
|
+
this.subjectPolicy = config.subjectPolicy;
|
|
43
|
+
this.ephemeralRoles = config.subjectPolicy === "ephemeral" ? config.ephemeralRoles ?? [] : void 0;
|
|
35
44
|
this.roles = config.roles.length > 0 ? config.roles : null;
|
|
36
45
|
}
|
|
37
46
|
/**
|
|
@@ -40,8 +49,10 @@ var SecuredArea = class {
|
|
|
40
49
|
* UNIQUE de la décision de zone (invariant `api.request {path}` ≤ `GET {path}`).
|
|
41
50
|
*/
|
|
42
51
|
matchPath(pathname, host) {
|
|
43
|
-
if (this
|
|
44
|
-
|
|
52
|
+
if (this.#hostKey !== null && this.#hostKey !== host?.toLowerCase()) return false;
|
|
53
|
+
if (this.pattern.test(pathname)) return true;
|
|
54
|
+
const bare = stripTrailingSlashes(pathname);
|
|
55
|
+
return bare !== pathname && this.pattern.test(bare);
|
|
45
56
|
}
|
|
46
57
|
/** La requête tombe-t-elle dans cette zone ? (host éventuel + pathname). */
|
|
47
58
|
match(context) {
|
|
@@ -99,6 +99,39 @@ function parseAuditQuery(query) {
|
|
|
99
99
|
if (page.cursor !== void 0) filter.cursor = page.cursor;
|
|
100
100
|
return filter;
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Traduit l'`id` d'un compte (paramètre `{id}` d'une route admin) en sa **clé de
|
|
104
|
+
* sujet** `IUser.identifier` — la clé sous laquelle vivent ses facteurs (TOTP,
|
|
105
|
+
* passkeys), sa session et ses jetons.
|
|
106
|
+
*
|
|
107
|
+
* Viser un store avec l'`id` brut agit sur une clé qui n'existe pas et rend un
|
|
108
|
+
* succès : l'administrateur croit avoir débloqué un compte resté bloqué. D'où
|
|
109
|
+
* le 503 quand le service `users` manque — sans lui, l'`id` ne se traduit pas.
|
|
110
|
+
*
|
|
111
|
+
* @param container - container du kernel (service `users`).
|
|
112
|
+
* @param id - paramètre `{id}` de la route.
|
|
113
|
+
* @returns le compte (`id` + `identifier`), ou la réponse d'erreur à rendre (404/503).
|
|
114
|
+
*/
|
|
115
|
+
async function resolveSubject(container, id) {
|
|
116
|
+
if (typeof id !== "string" || id.length === 0) return {
|
|
117
|
+
status: 404,
|
|
118
|
+
body: { error: "not found" }
|
|
119
|
+
};
|
|
120
|
+
const users = container.get("users");
|
|
121
|
+
if (!users) return {
|
|
122
|
+
status: 503,
|
|
123
|
+
body: { error: "users unavailable" }
|
|
124
|
+
};
|
|
125
|
+
const user = await users.findById(id);
|
|
126
|
+
if (!user || typeof user.identifier !== "string") return {
|
|
127
|
+
status: 404,
|
|
128
|
+
body: { error: "not found" }
|
|
129
|
+
};
|
|
130
|
+
return {
|
|
131
|
+
id,
|
|
132
|
+
identifier: user.identifier
|
|
133
|
+
};
|
|
134
|
+
}
|
|
102
135
|
/** Projette un credential serveur vers sa vue admin (sans `publicKey`/`userId`). */
|
|
103
136
|
function toCredentialView(c) {
|
|
104
137
|
return {
|
|
@@ -170,6 +203,25 @@ function createSecurityAdminApi(container) {
|
|
|
170
203
|
return firewall.describe();
|
|
171
204
|
}
|
|
172
205
|
},
|
|
206
|
+
{
|
|
207
|
+
path: "oauth/diagnosis",
|
|
208
|
+
method: "GET",
|
|
209
|
+
role: "ROLE_NODEFONY_ADMIN",
|
|
210
|
+
summary: "Diagnostic des fournisseurs OAuth sans connexion humaine : émetteur, URL de retour, secret (?provider=<nom>). Interroge les fournisseurs à chaque appel ; aucun secret rendu.",
|
|
211
|
+
handler: async (request) => {
|
|
212
|
+
const oauth = container.get("oauth2");
|
|
213
|
+
if (!oauth || !oauth.isEnabled()) return {
|
|
214
|
+
enabled: false,
|
|
215
|
+
providers: []
|
|
216
|
+
};
|
|
217
|
+
const raw = request.query.provider;
|
|
218
|
+
const provider = typeof raw === "string" && raw !== "" ? raw : void 0;
|
|
219
|
+
return {
|
|
220
|
+
enabled: true,
|
|
221
|
+
providers: await oauth.diagnose(provider)
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
},
|
|
173
225
|
{
|
|
174
226
|
path: "roleHierarchy",
|
|
175
227
|
method: "GET",
|
|
@@ -324,17 +376,9 @@ function createSecurityAdminApi(container) {
|
|
|
324
376
|
status: 503,
|
|
325
377
|
body: { error: "webauthn unavailable" }
|
|
326
378
|
};
|
|
327
|
-
const
|
|
328
|
-
if (
|
|
329
|
-
|
|
330
|
-
body: { error: "not found" }
|
|
331
|
-
};
|
|
332
|
-
const users = container.get("users");
|
|
333
|
-
if (users && !await users.findById(id)) return {
|
|
334
|
-
status: 404,
|
|
335
|
-
body: { error: "not found" }
|
|
336
|
-
};
|
|
337
|
-
return { credentials: (await svc.listUserCredentials(id)).map(toCredentialView) };
|
|
379
|
+
const account = await resolveSubject(container, request.params.id);
|
|
380
|
+
if (!("identifier" in account)) return account;
|
|
381
|
+
return { credentials: (await svc.listUserCredentials(account.identifier)).map(toCredentialView) };
|
|
338
382
|
}
|
|
339
383
|
},
|
|
340
384
|
{
|
|
@@ -348,13 +392,14 @@ function createSecurityAdminApi(container) {
|
|
|
348
392
|
status: 503,
|
|
349
393
|
body: { error: "webauthn unavailable" }
|
|
350
394
|
};
|
|
351
|
-
const id = request.params.id;
|
|
352
395
|
const credentialId = request.params.credentialId;
|
|
353
|
-
if (typeof
|
|
396
|
+
if (typeof credentialId !== "string" || credentialId.length === 0) return {
|
|
354
397
|
status: 404,
|
|
355
398
|
body: { error: "not found" }
|
|
356
399
|
};
|
|
357
|
-
|
|
400
|
+
const account = await resolveSubject(container, request.params.id);
|
|
401
|
+
if (!("identifier" in account)) return account;
|
|
402
|
+
if (!await svc.removeUserCredential(account.identifier, credentialId)) return {
|
|
358
403
|
status: 404,
|
|
359
404
|
body: { error: "not found" }
|
|
360
405
|
};
|
|
@@ -363,7 +408,7 @@ function createSecurityAdminApi(container) {
|
|
|
363
408
|
action: "user.passkey_revoked",
|
|
364
409
|
outcome: "success",
|
|
365
410
|
actor: adminActor(request.user),
|
|
366
|
-
resource: id,
|
|
411
|
+
resource: account.id,
|
|
367
412
|
metadata: {
|
|
368
413
|
credentialId,
|
|
369
414
|
viaAdmin: true
|
|
@@ -381,7 +426,8 @@ function createSecurityAdminApi(container) {
|
|
|
381
426
|
const svc = container.get("totp");
|
|
382
427
|
const pageQuery = parsePageQuery(request.query, {
|
|
383
428
|
defaultLimit: KEYS_DEFAULT_LIMIT,
|
|
384
|
-
maxLimit: KEYS_MAX_LIMIT
|
|
429
|
+
maxLimit: KEYS_MAX_LIMIT,
|
|
430
|
+
searchable: true
|
|
385
431
|
});
|
|
386
432
|
const limit = pageQuery.limit;
|
|
387
433
|
const offset = pageQuery.offset ?? 0;
|
|
@@ -393,7 +439,7 @@ function createSecurityAdminApi(container) {
|
|
|
393
439
|
offset
|
|
394
440
|
};
|
|
395
441
|
const confirmed = one(request.query, "confirmed");
|
|
396
|
-
const q =
|
|
442
|
+
const q = pageQuery.q;
|
|
397
443
|
const page = await svc.listPage({
|
|
398
444
|
limit,
|
|
399
445
|
offset,
|
|
@@ -420,17 +466,9 @@ function createSecurityAdminApi(container) {
|
|
|
420
466
|
status: 503,
|
|
421
467
|
body: { error: "totp unavailable" }
|
|
422
468
|
};
|
|
423
|
-
const
|
|
424
|
-
if (
|
|
425
|
-
|
|
426
|
-
body: { error: "not found" }
|
|
427
|
-
};
|
|
428
|
-
const users = container.get("users");
|
|
429
|
-
if (users && !await users.findById(id)) return {
|
|
430
|
-
status: 404,
|
|
431
|
-
body: { error: "not found" }
|
|
432
|
-
};
|
|
433
|
-
return svc.status(id);
|
|
469
|
+
const account = await resolveSubject(container, request.params.id);
|
|
470
|
+
if (!("identifier" in account)) return account;
|
|
471
|
+
return svc.status(account.identifier);
|
|
434
472
|
}
|
|
435
473
|
},
|
|
436
474
|
{
|
|
@@ -444,23 +482,15 @@ function createSecurityAdminApi(container) {
|
|
|
444
482
|
status: 503,
|
|
445
483
|
body: { error: "totp unavailable" }
|
|
446
484
|
};
|
|
447
|
-
const
|
|
448
|
-
if (
|
|
449
|
-
|
|
450
|
-
body: { error: "not found" }
|
|
451
|
-
};
|
|
452
|
-
const users = container.get("users");
|
|
453
|
-
if (users && !await users.findById(id)) return {
|
|
454
|
-
status: 404,
|
|
455
|
-
body: { error: "not found" }
|
|
456
|
-
};
|
|
457
|
-
await svc.disable(id);
|
|
485
|
+
const account = await resolveSubject(container, request.params.id);
|
|
486
|
+
if (!("identifier" in account)) return account;
|
|
487
|
+
await svc.disable(account.identifier);
|
|
458
488
|
auditAdmin(container, {
|
|
459
489
|
category: "auth",
|
|
460
490
|
action: "user.totp_disabled",
|
|
461
491
|
outcome: "success",
|
|
462
492
|
actor: adminActor(request.user),
|
|
463
|
-
resource: id,
|
|
493
|
+
resource: account.id,
|
|
464
494
|
metadata: { viaAdmin: true }
|
|
465
495
|
});
|
|
466
496
|
return { ok: true };
|
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
function adminActor(user) {
|
|
17
17
|
if (user && typeof user === "object") {
|
|
18
18
|
const u = user;
|
|
19
|
-
if (typeof u.username === "string" && u.username) return u.username;
|
|
20
19
|
if (typeof u.identifier === "string" && u.identifier) return u.identifier;
|
|
21
20
|
}
|
|
22
21
|
return "admin";
|
|
@@ -1,15 +1,22 @@
|
|
|
1
|
-
import { USER_REVOKED_EVENT } from "@nodefony/user";
|
|
1
|
+
import { USER_CREATED_EVENT, USER_REVOKED_EVENT } from "@nodefony/user";
|
|
2
2
|
//#region nodefony/src/admin/userRevocationCascade.ts
|
|
3
3
|
/**
|
|
4
4
|
* Cascade de révocation déclenchée par {@link USER_REVOKED_EVENT} : éjecte
|
|
5
5
|
* **immédiatement** les artefacts d'accès du porteur — ses **sessions** (http,
|
|
6
6
|
* `destroyByUser`) et ses **jetons/PAT** (`tokenStore.revokeAllForSubject`,
|
|
7
|
-
* seuil `invalidBefore
|
|
8
|
-
* l'autre) — l'accès était DÉJÀ neutralisé par
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* seuil `invalidBefore`, qui couvre aussi les clés d'API). Best-effort par
|
|
8
|
+
* brique (une indispo n'empêche pas l'autre) — l'accès était DÉJÀ neutralisé par
|
|
9
|
+
* le re-fetch des authenticators. `tenantId` du payload est réservé (scoping non
|
|
10
|
+
* câblé en mono-tenant).
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* **Suppression du compte** (`reason: "deleted"`) : retire AUSSI ses facteurs —
|
|
13
|
+
* secret TOTP (codes de récupération compris) et passkeys. Ils sont rangés sous
|
|
14
|
+
* l'`identifier`, qu'un compte recréé peut reprendre : laissés en place, le
|
|
15
|
+
* nouveau titulaire hériterait du second facteur de l'ancien. Un compte
|
|
16
|
+
* seulement désactivé ou verrouillé les GARDE — il les retrouve à sa
|
|
17
|
+
* réactivation.
|
|
18
|
+
*
|
|
19
|
+
* @param container - container du kernel (résolution lazy de `sessions`, `tokenStore`, `totp`, `webauthn`).
|
|
13
20
|
* @param event - charge utile de l'événement (porteur + raison).
|
|
14
21
|
* @param now - horloge (epoch ms) injectable pour les tests.
|
|
15
22
|
*/
|
|
@@ -21,20 +28,77 @@ async function cascadeUserRevocation(container, event, now = Date.now()) {
|
|
|
21
28
|
try {
|
|
22
29
|
await container.get("tokenStore")?.revokeAllForSubject(identifier, now);
|
|
23
30
|
} catch {}
|
|
31
|
+
if (event.reason === "deleted") await removeAccountFactors(container, identifier);
|
|
24
32
|
}
|
|
25
33
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
34
|
+
* Retire les facteurs rangés sous un identifiant — secret TOTP (codes de
|
|
35
|
+
* récupération compris) et passkeys. SEULE implémentation du geste, appelée à
|
|
36
|
+
* la suppression d'un compte ET à la création d'un compte sous cet identifiant.
|
|
37
|
+
* Best-effort par brique : un TOTP indisponible n'empêche pas le retrait des
|
|
38
|
+
* passkeys.
|
|
39
|
+
*
|
|
40
|
+
* @param container - container du kernel (services `totp`, `webauthn`).
|
|
41
|
+
* @param identifier - `IUser.identifier` du compte.
|
|
42
|
+
*/
|
|
43
|
+
async function removeAccountFactors(container, identifier) {
|
|
44
|
+
try {
|
|
45
|
+
const totp = container.get("totp");
|
|
46
|
+
if (totp?.isEnabled?.() !== false) await totp?.disable?.(identifier);
|
|
47
|
+
} catch {}
|
|
48
|
+
try {
|
|
49
|
+
const webauthn = container.get("webauthn");
|
|
50
|
+
if (webauthn?.isEnabled?.() !== false) {
|
|
51
|
+
const creds = await webauthn?.listUserCredentials?.(identifier) ?? [];
|
|
52
|
+
for (const cred of creds) await webauthn?.removeUserCredential?.(identifier, cred.id);
|
|
53
|
+
}
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
/** Garde : charge utile de {@link USER_CREATED_EVENT} (identifiant non vide). */
|
|
57
|
+
function isUserCreatedEvent(value) {
|
|
58
|
+
return typeof value === "object" && value !== null && "identifier" in value && typeof value.identifier === "string" && value.identifier.length > 0;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Abonne la cascade au bus kernel, UNE fois, au `onKernelBoot` de
|
|
62
|
+
* `@nodefony/security` — avant qu'aucun compte ne puisse être créé ou révoqué
|
|
63
|
+
* (les comptes semés au démarrage compris). Deux abonnements :
|
|
64
|
+
* - {@link USER_REVOKED_EVENT} → {@link cascadeUserRevocation} ;
|
|
65
|
+
* - {@link USER_CREATED_EVENT} → purge des facteurs HÉRITÉS : un compte qui
|
|
66
|
+
* vient de naître n'a encore rien enrôlé, donc tout facteur déjà rangé sous
|
|
67
|
+
* son identifiant appartient à un ancien titulaire. Règle exacte, aucune
|
|
68
|
+
* date à comparer. ⚠️ Un import qui chargerait les facteurs AVANT les
|
|
69
|
+
* comptes les verrait retirés : importer les comptes d'abord.
|
|
70
|
+
*
|
|
71
|
+
* Les deux sont émis par `UserService` seul, et attendus : chaque handler rend
|
|
72
|
+
* sa promesse. **Extensible** : tout autre module (webhooks…) s'abonne aux
|
|
73
|
+
* MÊMES événements pour ses propres artefacts, sans toucher à ce fichier.
|
|
30
74
|
*
|
|
31
75
|
* @param kernel - bus d'événements (kernel) exposant `on`.
|
|
32
|
-
* @param container - container capturé par
|
|
76
|
+
* @param container - container capturé par les handlers.
|
|
33
77
|
*/
|
|
34
78
|
function registerUserRevocationCascade(kernel, container) {
|
|
35
|
-
kernel.on(USER_REVOKED_EVENT, (event) =>
|
|
36
|
-
|
|
37
|
-
|
|
79
|
+
kernel.on(USER_REVOKED_EVENT, (event) => cascadeUserRevocation(container, event));
|
|
80
|
+
kernel.on(USER_CREATED_EVENT, (event) => isUserCreatedEvent(event) ? removeAccountFactors(container, event.identifier) : void 0);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Avis de démarrage quand le service `users` n'émet pas sur le bus du kernel —
|
|
84
|
+
* construit sans container (`new UserService(repo, encoder)`). Ses événements
|
|
85
|
+
* partent alors sur son propre bus, où personne n'écoute : supprimer un compte
|
|
86
|
+
* laisserait ses sessions, ses jetons et ses facteurs en place, SANS UN MOT.
|
|
87
|
+
*
|
|
88
|
+
* @param container - container du kernel.
|
|
89
|
+
* @param kernel - le kernel attendu comme bus des événements.
|
|
90
|
+
* @returns l'avis à déclarer, ou `null` si `users` est absent ou bien câblé.
|
|
91
|
+
*/
|
|
92
|
+
function usersEventBusNotice(container, kernel) {
|
|
93
|
+
const users = container.get("users");
|
|
94
|
+
if (typeof users !== "object" || users === null) return null;
|
|
95
|
+
if (!("kernel" in users) || users.kernel === kernel) return null;
|
|
96
|
+
return {
|
|
97
|
+
code: "USERS_SERVICE_WITHOUT_KERNEL",
|
|
98
|
+
level: "warning",
|
|
99
|
+
message: "le service « users » n'atteint pas le kernel : supprimer, désactiver un compte ou changer son mot de passe ne révoquera ni ses sessions, ni ses jetons, ni ses facteurs.",
|
|
100
|
+
fix: "new UserService(repository, encoder, module.container)"
|
|
101
|
+
};
|
|
38
102
|
}
|
|
39
103
|
//#endregion
|
|
40
|
-
export { cascadeUserRevocation, registerUserRevocationCascade };
|
|
104
|
+
export { cascadeUserRevocation, registerUserRevocationCascade, removeAccountFactors, usersEventBusNotice };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { mapProviderRoles } from "../oauth/providerRoles.js";
|
|
1
2
|
import { AuthenticationError } from "../../errors/AuthenticationError.js";
|
|
2
3
|
import { UnverifiableTokenError } from "../../errors/UnverifiableTokenError.js";
|
|
3
4
|
import { UserToken } from "../token/UserToken.js";
|
|
@@ -16,6 +17,11 @@ const INVALID_TOKEN = "Invalid token";
|
|
|
16
17
|
/** Clé de transport de l'audience entre `createToken` et `authenticate`. */
|
|
17
18
|
const AUDIENCE = "audience";
|
|
18
19
|
/**
|
|
20
|
+
* Clé de transport de la zone qui SURCHARGE la politique de rattachement.
|
|
21
|
+
* Posée seulement par ces zones-là : les autres ne paient rien de plus.
|
|
22
|
+
*/
|
|
23
|
+
const SUBJECT_AREA = "subjectArea";
|
|
24
|
+
/**
|
|
19
25
|
* Authentification par **jeton d'accès émis par un serveur d'autorisation
|
|
20
26
|
* TIERS** (Keycloak, Auth0, Entra, ou l'émetteur d'une flotte d'agents).
|
|
21
27
|
*
|
|
@@ -58,6 +64,10 @@ var ExternalJwtAuthenticator = class {
|
|
|
58
64
|
#issuers;
|
|
59
65
|
#policy;
|
|
60
66
|
#ephemeralRoles;
|
|
67
|
+
/** Émetteur canonique → fournisseur de connexion ; `null` = aucun lien. */
|
|
68
|
+
#linkedProviders;
|
|
69
|
+
/** Fournisseur de connexion → table des rôles ; `null` = aucune table. */
|
|
70
|
+
#roleMappings;
|
|
61
71
|
#userProvider = null;
|
|
62
72
|
/**
|
|
63
73
|
* @param container - container DI (résolution lazy du vérificateur et de `users`)
|
|
@@ -74,6 +84,8 @@ var ExternalJwtAuthenticator = class {
|
|
|
74
84
|
this.#issuers = issuers;
|
|
75
85
|
this.#policy = options.subjectPolicy;
|
|
76
86
|
this.#ephemeralRoles = options.ephemeralRoles;
|
|
87
|
+
this.#linkedProviders = linkProviders(issuers, options.oauthProviders);
|
|
88
|
+
this.#roleMappings = collectRoleMappings(options.oauthProviders);
|
|
77
89
|
}
|
|
78
90
|
/**
|
|
79
91
|
* La requête porte-t-elle un jeton se réclamant d'un émetteur de confiance ?
|
|
@@ -108,6 +120,7 @@ var ExternalJwtAuthenticator = class {
|
|
|
108
120
|
const token = new UserToken("external-jwt", bearerToken(auth) ?? "");
|
|
109
121
|
const area = context.security;
|
|
110
122
|
if (area?.resource) token.setAttribute(AUDIENCE, area.resource);
|
|
123
|
+
if (area?.subjectPolicy !== void 0) token.setAttribute(SUBJECT_AREA, area);
|
|
111
124
|
return Promise.resolve(token);
|
|
112
125
|
}
|
|
113
126
|
/**
|
|
@@ -136,7 +149,7 @@ var ExternalJwtAuthenticator = class {
|
|
|
136
149
|
if (!subject) throw new AuthenticationError(INVALID_TOKEN);
|
|
137
150
|
const mapping = this.#issuers.get(principal.issuer);
|
|
138
151
|
if (mapping === void 0) throw new UnverifiableTokenError(`émetteur « ${principal.issuer} » vérifié mais absent de la table de rattachement des sujets — configuration incohérente.`);
|
|
139
|
-
const user = await this.#resolveUser(principal.issuer, subject, mapping);
|
|
152
|
+
const user = await this.#resolveUser(principal.issuer, subject, mapping, principal.claims, token.getAttribute(SUBJECT_AREA));
|
|
140
153
|
const ut = token;
|
|
141
154
|
ut.promote(user);
|
|
142
155
|
ut.setAttribute("scopes", [...principal.scopes]);
|
|
@@ -194,16 +207,21 @@ var ExternalJwtAuthenticator = class {
|
|
|
194
207
|
* nous, est cette personne ? » et « quel pouvoir accorde-t-on à une machine
|
|
195
208
|
* que l'annuaire a authentifiée ? ».
|
|
196
209
|
*/
|
|
197
|
-
async #resolveUser(issuer, subject, mapping) {
|
|
210
|
+
async #resolveUser(issuer, subject, mapping, claims, area) {
|
|
198
211
|
const identifier = localIdentifierFor(issuer, subject, mapping);
|
|
199
|
-
if (this.#policy === "ephemeral") return new BaseUser({
|
|
212
|
+
if ((area?.subjectPolicy ?? this.#policy) === "ephemeral") return new BaseUser({
|
|
200
213
|
id: identifier,
|
|
201
214
|
identifier,
|
|
202
|
-
roles: [...this.#ephemeralRoles]
|
|
215
|
+
roles: [...area?.ephemeralRoles ?? this.#ephemeralRoles]
|
|
203
216
|
});
|
|
204
217
|
const provider = this.#resolveUserProvider();
|
|
205
|
-
let user;
|
|
206
|
-
|
|
218
|
+
let user = null;
|
|
219
|
+
const linked = this.#linkedProviders?.get(issuer);
|
|
220
|
+
if (linked !== void 0) try {
|
|
221
|
+
const roleMapping = this.#roleMappings?.get(linked);
|
|
222
|
+
user = roleMapping !== void 0 && canSyncRoles(provider) ? await provider.syncOAuthRoles(linked, subject, mapProviderRoles(roleMapping, claims)) : await provider.loadUserByOAuth(linked, subject);
|
|
223
|
+
} catch {}
|
|
224
|
+
if (user === null) try {
|
|
207
225
|
user = await provider.loadUserByIdentifier(identifier);
|
|
208
226
|
} catch {
|
|
209
227
|
throw new AuthenticationError(INVALID_TOKEN);
|
|
@@ -220,5 +238,47 @@ var ExternalJwtAuthenticator = class {
|
|
|
220
238
|
return this.#userProvider;
|
|
221
239
|
}
|
|
222
240
|
};
|
|
241
|
+
/** Le fournisseur d'utilisateurs sait-il recalculer les rôles gérés par un annuaire ? */
|
|
242
|
+
function canSyncRoles(provider) {
|
|
243
|
+
return typeof provider.syncOAuthRoles === "function";
|
|
244
|
+
}
|
|
245
|
+
/** Tables de rôles par fournisseur de connexion — `null` quand aucun n'en déclare. */
|
|
246
|
+
function collectRoleMappings(providers) {
|
|
247
|
+
if (!providers) return null;
|
|
248
|
+
let mappings = null;
|
|
249
|
+
for (const { name, roleMapping } of providers) {
|
|
250
|
+
if (roleMapping == null) continue;
|
|
251
|
+
mappings ??= /* @__PURE__ */ new Map();
|
|
252
|
+
mappings.set(name, roleMapping);
|
|
253
|
+
}
|
|
254
|
+
return mappings;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Associe chaque émetteur de confiance au fournisseur de connexion qui partage
|
|
258
|
+
* son émetteur canonique.
|
|
259
|
+
*
|
|
260
|
+
* Un émetteur revendiqué par DEUX fournisseurs n'est lié à aucun : choisir l'un
|
|
261
|
+
* des deux ferait dépendre l'identité de l'ordre de la configuration.
|
|
262
|
+
*
|
|
263
|
+
* @returns la table, ou `null` quand aucun émetteur n'est lié (rien à chercher).
|
|
264
|
+
*/
|
|
265
|
+
function linkProviders(trusted, providers) {
|
|
266
|
+
if (!providers || providers.length === 0) return null;
|
|
267
|
+
const links = /* @__PURE__ */ new Map();
|
|
268
|
+
const ambiguous = /* @__PURE__ */ new Set();
|
|
269
|
+
for (const { name, issuer } of providers) {
|
|
270
|
+
let canonical;
|
|
271
|
+
try {
|
|
272
|
+
canonical = canonicalIssuer(issuer);
|
|
273
|
+
} catch {
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (!trusted.has(canonical)) continue;
|
|
277
|
+
if (links.has(canonical)) ambiguous.add(canonical);
|
|
278
|
+
links.set(canonical, name);
|
|
279
|
+
}
|
|
280
|
+
for (const issuer of ambiguous) links.delete(issuer);
|
|
281
|
+
return links.size > 0 ? links : null;
|
|
282
|
+
}
|
|
223
283
|
//#endregion
|
|
224
284
|
export { ExternalJwtAuthenticator, ExternalJwtAuthenticator as default };
|