@nodefony/security 10.0.0-beta.2 → 10.0.0-beta.4
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.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/index.js +48 -16
- package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +62 -24
- package/dist/nodefony/command/security-oauth-doctor.js +68 -0
- package/dist/nodefony/command/security-secrets.js +52 -29
- package/dist/nodefony/command/security-user-add.js +9 -3
- package/dist/nodefony/command/security-user-delete.js +10 -7
- package/dist/nodefony/command/security-user-list.js +5 -3
- package/dist/nodefony/command/security-user-password.js +9 -14
- package/dist/nodefony/config/config.js +99 -8
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/contracts/IAccessVoter.js +1 -2
- package/dist/nodefony/contracts/index.js +1 -2
- package/dist/nodefony/errors/AccessDeniedError.js +1 -2
- package/dist/nodefony/errors/ApiKeyError.js +3 -2
- package/dist/nodefony/errors/AuthenticationError.js +1 -2
- package/dist/nodefony/errors/CsrfError.js +1 -2
- package/dist/nodefony/errors/InsecureTransportError.js +23 -0
- package/dist/nodefony/errors/InvalidTargetError.js +1 -2
- package/dist/nodefony/errors/SsrfError.js +1 -2
- package/dist/nodefony/errors/ThrottledError.js +1 -2
- package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
- package/dist/nodefony/errors/WebAuthnError.js +1 -2
- package/dist/nodefony/errors/index.js +2 -1
- package/dist/nodefony/keycloak/keycloakAdmin.js +254 -0
- package/dist/nodefony/keycloak/keycloakRealm.js +259 -0
- package/dist/nodefony/keycloak/keycloakRealmInput.js +123 -0
- package/dist/nodefony/keycloak/scaffold.js +231 -0
- package/dist/nodefony/keycloak/security-keycloak-realm.js +130 -0
- package/dist/nodefony/scaffold/contribute.js +19 -0
- package/dist/nodefony/service/accessTokenVerifier.js +101 -34
- package/dist/nodefony/service/apiKeys.js +45 -5
- package/dist/nodefony/service/auditService.js +4 -5
- package/dist/nodefony/service/authFlow.js +118 -16
- package/dist/nodefony/service/authorization.js +1 -2
- package/dist/nodefony/service/cors.js +1 -2
- package/dist/nodefony/service/csrf.js +1 -2
- package/dist/nodefony/service/firewall.js +116 -25
- package/dist/nodefony/service/keycloak.js +208 -0
- package/dist/nodefony/service/oauth2.js +403 -28
- package/dist/nodefony/service/securityHeaders.js +1 -2
- package/dist/nodefony/service/tokenService.js +94 -27
- package/dist/nodefony/service/totp.js +55 -11
- package/dist/nodefony/service/webAuthn.js +24 -7
- package/dist/nodefony/service/webhooks.js +4 -5
- package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
- package/dist/nodefony/src/SecuredArea.js +15 -5
- package/dist/nodefony/src/accountBinding.js +96 -0
- package/dist/nodefony/src/admin/SecurityAdminApi.js +71 -42
- package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
- package/dist/nodefony/src/admin/adminAudit.js +1 -3
- package/dist/nodefony/src/admin/userRevocationCascade.js +148 -20
- package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
- package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
- package/dist/nodefony/src/audit/auditBridge.js +1 -2
- package/dist/nodefony/src/audit/auditFilters.js +1 -2
- package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
- package/dist/nodefony/src/audit/readAuditContext.js +1 -2
- package/dist/nodefony/src/audit/recordAudit.js +1 -2
- package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +14 -59
- package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +80 -19
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +92 -16
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +14 -5
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +5 -12
- package/dist/nodefony/src/authenticator/bearer.js +1 -2
- package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
- package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
- package/dist/nodefony/src/boot/publicRoutesNotice.js +26 -0
- package/dist/nodefony/src/crypto/secretCipher.js +1 -2
- package/dist/nodefony/src/csp.js +1 -2
- package/dist/nodefony/src/csrfToken.js +1 -2
- package/dist/nodefony/src/entryPoint.js +38 -0
- package/dist/nodefony/src/net/ssrfGuard.js +1 -2
- package/dist/nodefony/src/net/transportFailure.js +57 -0
- package/dist/nodefony/src/oauth/clientCredentials.js +39 -0
- package/dist/nodefony/src/oauth/httpJson.js +3 -3
- package/dist/nodefony/src/oauth/metadata.js +28 -12
- package/dist/nodefony/src/oauth/oauth2Client.js +61 -35
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +40 -8
- package/dist/nodefony/src/oauth/providerDiagnosis.js +282 -0
- package/dist/nodefony/src/oauth/providerRoles.js +78 -0
- package/dist/nodefony/src/oauth/providers/github.js +48 -12
- package/dist/nodefony/src/oauth/providers/oidc.js +90 -8
- package/dist/nodefony/src/openToSignup.js +46 -0
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
- package/dist/nodefony/src/realtime/frameAuthorizer.js +1 -2
- package/dist/nodefony/src/rolesWithin.js +57 -0
- package/dist/nodefony/src/sessionAuthentication.js +25 -0
- package/dist/nodefony/src/sessionIdentity.js +29 -3
- package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
- package/dist/nodefony/src/token/AnonymousToken.js +1 -2
- package/dist/nodefony/src/token/JwtKeystore.js +89 -48
- package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +46 -16
- package/dist/nodefony/src/token/UserToken.js +1 -2
- package/dist/nodefony/src/token/jwtRuntime.js +1 -2
- package/dist/nodefony/src/token/scopeGrants.js +109 -0
- package/dist/nodefony/src/token/secretFile.js +60 -4
- package/dist/nodefony/src/token/tokenCriteria.js +1 -2
- package/dist/nodefony/src/token/tokenFilters.js +1 -2
- package/dist/nodefony/src/token/tokenSort.js +1 -2
- package/dist/nodefony/src/token/tokenStatus.js +1 -2
- package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
- package/dist/nodefony/src/totp/totpCipher.js +1 -2
- package/dist/nodefony/src/totp/totpCrypto.js +1 -2
- package/dist/nodefony/src/totp/totpOperations.js +1 -2
- package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
- package/dist/nodefony/src/transport/CredentialTransportPolicy.js +50 -0
- package/dist/nodefony/src/voter/RoleVoter.js +1 -2
- package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
- package/dist/nodefony/src/voter/voterRegistry.js +1 -2
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
- package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
- package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
- package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
- package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
- package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
- package/dist/nodefony/src/webhook/webhookSort.js +1 -2
- package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
- package/dist/types/index.d.ts +26 -6
- package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +16 -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-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 +64 -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/ApiKeyError.d.ts +3 -1
- 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/accessTokenVerifier.d.ts +35 -14
- package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
- 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 +145 -9
- 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/accountBinding.d.ts +82 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +72 -12
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -26
- package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +17 -1
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
- 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/net/transportFailure.d.ts +22 -0
- package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
- package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
- 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/realtime/UserRealtimeToken.d.ts +7 -1
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
- package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
- package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
- package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -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/scopeGrants.d.ts +77 -0
- 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 +182 -175
- package/docs/audit.md +141 -141
- package/docs/authenticators.md +129 -96
- package/docs/authorization.md +116 -69
- package/docs/cors.md +64 -64
- package/docs/csrf.md +77 -72
- package/docs/external-jwt.md +83 -11
- package/docs/firewall.md +119 -83
- package/docs/headers.md +82 -82
- package/docs/index.md +16 -8
- package/docs/keycloak.md +835 -0
- package/docs/login-page.md +361 -0
- package/docs/oauth2.md +291 -166
- package/docs/obtenir-un-jeton.md +131 -34
- package/docs/tokens.md +197 -140
- package/docs/totp.md +177 -136
- package/docs/webauthn.md +157 -154
- package/docs/webhooks.md +128 -125
- 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 +19 -17
- package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { buildKeycloakRealm, renderKeycloakRealm } from "./keycloakRealm.js";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
// nodefony/keycloak/scaffold.ts
|
|
7
|
+
/**
|
|
8
|
+
* Le décor Keycloak de développement que `@nodefony/security` livre à une
|
|
9
|
+
* application : realm d'import et thème aux couleurs du framework.
|
|
10
|
+
*
|
|
11
|
+
* Appelé par le point de contribution du paquet (`scaffold/contribute.ts`,
|
|
12
|
+
* déclaré dans son `package.json` sous `nodefony.contribute`), que le cœur
|
|
13
|
+
* joue sans en rien connaître : `create app` après l'installation, puis
|
|
14
|
+
* `nodefony scaffold:sync` à la demande. Un fichier déjà présent n'est jamais
|
|
15
|
+
* remplacé — il appartient à l'application, qui a pu le retoucher.
|
|
16
|
+
*
|
|
17
|
+
* @module
|
|
18
|
+
*/
|
|
19
|
+
/** Le nom du paquet — sert à retrouver sa racine, en source comme en `dist`. */
|
|
20
|
+
const PACKAGE_NAME = "@nodefony/security";
|
|
21
|
+
/** Le nom du thème, tel que le realm le déclare et que Keycloak le monte. */
|
|
22
|
+
const KEYCLOAK_THEME_NAME = "nodefony";
|
|
23
|
+
/** Où l'application reçoit le décor Keycloak (chemins qui voyagent : `/`). */
|
|
24
|
+
const APP_KEYCLOAK_REALM_FILE = "docker/keycloak/import/realm.json";
|
|
25
|
+
const APP_KEYCLOAK_THEMES_DIR = "docker/keycloak/themes";
|
|
26
|
+
const APP_KEYCLOAK_THEME_DIR = `${APP_KEYCLOAK_THEMES_DIR}/${KEYCLOAK_THEME_NAME}`;
|
|
27
|
+
/** Fichier de configuration de sécurité d'une application générée. */
|
|
28
|
+
const APP_SECURITY_CONFIG_FILE = "nodefony/config/security.ts";
|
|
29
|
+
/**
|
|
30
|
+
* Ressource de l'API partenaire d'une application neuve en développement :
|
|
31
|
+
* ce que rend `partnerResource()` du gabarit `complete` sans `NF_JWT_ISSUER`
|
|
32
|
+
* ni `NF_PORT_HTTPS` — le port HTTPS par défaut, comme les URL de retour du
|
|
33
|
+
* realm. Deux paquets, une valeur : le test de contribution compare le
|
|
34
|
+
* gabarit rendu à cette constante.
|
|
35
|
+
*/
|
|
36
|
+
const APP_PARTNER_DEV_RESOURCE = "https://localhost:5152/api/partner";
|
|
37
|
+
/** Les types de thème que le realm sait déclarer, et la clé qui les porte. */
|
|
38
|
+
const THEME_TYPES = [
|
|
39
|
+
"login",
|
|
40
|
+
"account",
|
|
41
|
+
"email",
|
|
42
|
+
"admin"
|
|
43
|
+
];
|
|
44
|
+
/**
|
|
45
|
+
* La racine du paquet, trouvée en remontant depuis ce fichier : il vit à une
|
|
46
|
+
* profondeur différente en source (`nodefony/scaffold/`) et une fois bâti
|
|
47
|
+
* (`dist/nodefony/scaffold/`).
|
|
48
|
+
*
|
|
49
|
+
* @returns le dossier qui porte le `package.json` du paquet
|
|
50
|
+
* @throws Si aucun ancêtre n'est le paquet (installation corrompue)
|
|
51
|
+
*/
|
|
52
|
+
function packageRoot() {
|
|
53
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
54
|
+
for (;;) {
|
|
55
|
+
const manifest = path.join(dir, "package.json");
|
|
56
|
+
if (existsSync(manifest)) {
|
|
57
|
+
const parsed = JSON.parse(readFileSync(manifest, "utf8"));
|
|
58
|
+
if (typeof parsed === "object" && parsed !== null && "name" in parsed && parsed.name === PACKAGE_NAME) return dir;
|
|
59
|
+
}
|
|
60
|
+
const parent = path.dirname(dir);
|
|
61
|
+
if (parent === dir) throw new Error(`racine du paquet ${PACKAGE_NAME} introuvable`);
|
|
62
|
+
dir = parent;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Le dossier du thème Keycloak livré par le paquet.
|
|
67
|
+
*
|
|
68
|
+
* @returns chemin natif absolu (`<paquet>/keycloak/themes/nodefony`)
|
|
69
|
+
*/
|
|
70
|
+
function keycloakThemeSource() {
|
|
71
|
+
return path.join(keycloakThemesSource(), KEYCLOAK_THEME_NAME);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Le dossier de TOUS les thèmes Keycloak livrés : `nodefony` et ses thèmes
|
|
75
|
+
* enfants `nodefony-<habillage>`, un par habillage de la page `/login`.
|
|
76
|
+
* Keycloak les monte côte à côte (`/opt/keycloak/themes/`).
|
|
77
|
+
*
|
|
78
|
+
* @returns chemin natif absolu (`<paquet>/keycloak/themes`)
|
|
79
|
+
*/
|
|
80
|
+
function keycloakThemesSource() {
|
|
81
|
+
return path.join(packageRoot(), "keycloak", "themes");
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Les types que le thème livré couvre — un dossier par type. Le realm ne
|
|
85
|
+
* déclare QUE ceux-là : déclarer un type absent ferait retomber Keycloak sur
|
|
86
|
+
* son thème par défaut, avec un avertissement à chaque page.
|
|
87
|
+
*
|
|
88
|
+
* @param themeDir - dossier du thème
|
|
89
|
+
* @returns les types présents, dans l'ordre de {@link THEME_TYPES}
|
|
90
|
+
*/
|
|
91
|
+
function deliveredThemeTypes(themeDir) {
|
|
92
|
+
const present = new Set(readdirSync(themeDir));
|
|
93
|
+
return THEME_TYPES.filter((type) => present.has(type));
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Le secret du client navigateur dans le décor de développement d'une app.
|
|
97
|
+
*
|
|
98
|
+
* ⚠️ Même règle que le gabarit `.env` du cœur (`keycloak.clientSecret` dans
|
|
99
|
+
* `engine.ts`) : la frontière de paquets empêche de l'appeler, un test de ce
|
|
100
|
+
* paquet compare donc les deux sorties.
|
|
101
|
+
*
|
|
102
|
+
* @param appName - nom de l'application
|
|
103
|
+
* @returns le secret, PUBLIC par nature (écrit dans le realm versionné)
|
|
104
|
+
*/
|
|
105
|
+
function devClientSecret(appName) {
|
|
106
|
+
return `${appName}-dev-keycloak-secret`;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Le secret de l'appelant machine (`<app>-machine`) dans le décor de
|
|
110
|
+
* développement d'une app.
|
|
111
|
+
*
|
|
112
|
+
* ⚠️ Même règle que le gabarit `.env` du cœur (`keycloak.machineSecret` dans
|
|
113
|
+
* `engine.ts`), comparée par le même test que {@link devClientSecret}.
|
|
114
|
+
*
|
|
115
|
+
* @param appName - nom de l'application
|
|
116
|
+
* @returns le secret, PUBLIC par nature (écrit dans le realm versionné)
|
|
117
|
+
*/
|
|
118
|
+
function devMachineSecret(appName) {
|
|
119
|
+
return `${appName}-machine-dev-secret`;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Ce que dit la configuration rendue par le gabarit `security.ts` d'une app
|
|
123
|
+
* neuve : client `<app>`, URL de retour sur les deux ports de développement,
|
|
124
|
+
* rôle client `admin` (`roleMapping: { admin: "ROLE_ADMIN" }`), et les
|
|
125
|
+
* audiences des zones `external-jwt` que la configuration rendue déclare.
|
|
126
|
+
*
|
|
127
|
+
* @param appName - nom de l'application (= realm = identifiant de client)
|
|
128
|
+
* @param themes - types de thème à déclarer
|
|
129
|
+
* @param ids - `sub` des comptes de démonstration, FIXÉS dans le fichier :
|
|
130
|
+
* laissés à Keycloak, ils changent à chaque réimport et la connexion
|
|
131
|
+
* suivante est refusée (identifiant déjà lié à un autre `sub`)
|
|
132
|
+
* @param audiences - ressources que les jetons du realm doivent porter
|
|
133
|
+
* (mapper sur les deux clients) ; vide = aucune zone ne les accepte
|
|
134
|
+
* @returns l'entrée du constructeur de realm
|
|
135
|
+
*/
|
|
136
|
+
function appDevRealmInput(appName, themes, ids, audiences = []) {
|
|
137
|
+
const callback = "/nodefony/security/api/oauth2/keycloak/callback";
|
|
138
|
+
return {
|
|
139
|
+
realm: appName,
|
|
140
|
+
clientId: appName,
|
|
141
|
+
clientSecret: devClientSecret(appName),
|
|
142
|
+
clientName: `${appName} (dev)`,
|
|
143
|
+
clientDescription: "Client confidentiel de l'application en développement — secret PUBLIC, jamais en production.",
|
|
144
|
+
redirectUris: [`https://localhost:5152${callback}`, `http://localhost:5151${callback}`],
|
|
145
|
+
postLogoutRedirectUris: ["https://localhost:5152/*", "http://localhost:5151/*"],
|
|
146
|
+
backchannelLogoutUrl: "http://host.docker.internal:5151/nodefony/security/api/oauth2/keycloak/backchannel-logout",
|
|
147
|
+
audiences,
|
|
148
|
+
clientRoles: [{
|
|
149
|
+
name: "admin",
|
|
150
|
+
description: "Administrateur de l'application — traduit en ROLE_ADMIN par la table roleMapping du fournisseur keycloak."
|
|
151
|
+
}],
|
|
152
|
+
realmRoles: [],
|
|
153
|
+
machine: {
|
|
154
|
+
clientId: `${appName}-machine`,
|
|
155
|
+
secret: devMachineSecret(appName),
|
|
156
|
+
name: `${appName} — appelant machine (compte de service)`,
|
|
157
|
+
description: "Appelant machine : obtient un jeton d'accès par client_credentials, sans utilisateur. Secret PUBLIC, jamais en production."
|
|
158
|
+
},
|
|
159
|
+
themes: Object.fromEntries(themes.map((type) => [type, KEYCLOAK_THEME_NAME])),
|
|
160
|
+
users: [{
|
|
161
|
+
id: ids.user,
|
|
162
|
+
username: "alice",
|
|
163
|
+
email: `alice@${appName}.test`,
|
|
164
|
+
firstName: "Alice",
|
|
165
|
+
lastName: "Keycloak",
|
|
166
|
+
password: "alice-dev"
|
|
167
|
+
}, {
|
|
168
|
+
id: ids.admin,
|
|
169
|
+
username: "bob",
|
|
170
|
+
email: `bob@${appName}.test`,
|
|
171
|
+
firstName: "Bob",
|
|
172
|
+
lastName: "Keycloak",
|
|
173
|
+
password: "bob-dev",
|
|
174
|
+
clientRoles: ["admin"]
|
|
175
|
+
}]
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* L'application monte-t-elle un décor Keycloak ? Seul son compose le dit : on
|
|
180
|
+
* ne pose que ce que quelqu'un lit — une application sans profil `keycloak`
|
|
181
|
+
* (preset minimal, sécurité ajoutée plus tard) ne reçoit rien.
|
|
182
|
+
*
|
|
183
|
+
* @param context - contexte de contribution
|
|
184
|
+
* @returns `true` si le compose monte `./docker/keycloak/…`
|
|
185
|
+
*/
|
|
186
|
+
function mountsKeycloak(context) {
|
|
187
|
+
return [
|
|
188
|
+
"compose.yaml",
|
|
189
|
+
"compose.yml",
|
|
190
|
+
"docker-compose.yml"
|
|
191
|
+
].some((file) => context.read(file)?.includes("./docker/keycloak/") === true);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Les audiences que la configuration RENDUE de l'application déclare. Le
|
|
195
|
+
* gabarit `complete` ouvre la zone `partner` (`jwt` + `external-jwt`) : sans
|
|
196
|
+
* son audience dans le realm, le jeton du client machine y est refusé dès le
|
|
197
|
+
* premier essai, et `security:keycloak:realm --check` trouve le realm né en
|
|
198
|
+
* désaccord avec la configuration.
|
|
199
|
+
*
|
|
200
|
+
* @param context - contexte de contribution
|
|
201
|
+
* @returns les ressources à inscrire dans les jetons du realm
|
|
202
|
+
*/
|
|
203
|
+
function renderedAudiences(context) {
|
|
204
|
+
return context.read(APP_SECURITY_CONFIG_FILE)?.includes("partnerResource(") === true ? [APP_PARTNER_DEV_RESOURCE] : [];
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Recopie les thèmes livrés dans l'application — `nodefony` et ses thèmes
|
|
208
|
+
* d'habillage —, fichier par fichier, sans jamais remplacer un fichier présent.
|
|
209
|
+
*
|
|
210
|
+
* @param context - contexte de contribution
|
|
211
|
+
*/
|
|
212
|
+
function copyKeycloakTheme(context) {
|
|
213
|
+
context.copyTree(keycloakThemesSource(), APP_KEYCLOAK_THEMES_DIR);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Pose le realm d'import et le thème Keycloak, si l'application monte un
|
|
217
|
+
* décor Keycloak.
|
|
218
|
+
*
|
|
219
|
+
* @param context - contexte fourni par le cœur (`runAppContributions`)
|
|
220
|
+
*/
|
|
221
|
+
function contributeKeycloak(context) {
|
|
222
|
+
if (!mountsKeycloak(context)) return;
|
|
223
|
+
const themes = deliveredThemeTypes(keycloakThemeSource());
|
|
224
|
+
const realm = buildKeycloakRealm(appDevRealmInput(context.appName, themes, {
|
|
225
|
+
user: randomUUID(),
|
|
226
|
+
admin: randomUUID()
|
|
227
|
+
}, renderedAudiences(context)));
|
|
228
|
+
context.write(APP_KEYCLOAK_REALM_FILE, renderKeycloakRealm(realm));
|
|
229
|
+
copyKeycloakTheme(context);
|
|
230
|
+
}
|
|
231
|
+
export { APP_KEYCLOAK_REALM_FILE, APP_KEYCLOAK_THEMES_DIR, APP_KEYCLOAK_THEME_DIR, APP_PARTNER_DEV_RESOURCE, KEYCLOAK_THEME_NAME, appDevRealmInput, contributeKeycloak, copyKeycloakTheme, deliveredThemeTypes, devClientSecret, devMachineSecret, keycloakThemeSource, keycloakThemesSource };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { KeycloakRealmError } from "./keycloakRealmInput.js";
|
|
2
|
+
import { KeycloakAdminError } from "./keycloakAdmin.js";
|
|
3
|
+
import { Command } from "nodefony";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
// nodefony/keycloak/security-keycloak-realm.ts
|
|
6
|
+
const options = {
|
|
7
|
+
helpGroup: "COMPTES ET SECRETS",
|
|
8
|
+
showBanner: false,
|
|
9
|
+
kernelEvent: "onReady",
|
|
10
|
+
quietBoot: true
|
|
11
|
+
};
|
|
12
|
+
const GREEN = "\x1B[32m";
|
|
13
|
+
const YELLOW = "\x1B[33m";
|
|
14
|
+
const DIM = "\x1B[2m";
|
|
15
|
+
const RESET = "\x1B[0m";
|
|
16
|
+
/** Codes de sortie (sysexits). */
|
|
17
|
+
const EXIT_DIFFERS = 1;
|
|
18
|
+
const EXIT_CONFIG = 78;
|
|
19
|
+
/**
|
|
20
|
+
* `nodefony security:keycloak:realm` — le realm Keycloak que la configuration
|
|
21
|
+
* de l'application implique.
|
|
22
|
+
*
|
|
23
|
+
* Le realm d'import répète ce que la config dit déjà : identifiant du client,
|
|
24
|
+
* URL de retour, audience de chaque zone, rôles de `roleMapping`. Écrit à la
|
|
25
|
+
* main, il diverge — et le symptôme est un 401 ou une URL de retour refusée,
|
|
26
|
+
* jamais un message qui nomme la cause. Cette commande le DÉRIVE, par le
|
|
27
|
+
* service `keycloak` — qui porte toutes les actions Keycloak ; elle n'en
|
|
28
|
+
* garde que l'affichage et les codes de sortie.
|
|
29
|
+
*
|
|
30
|
+
* Elle FUSIONNE dans le fichier existant au lieu de l'écraser : comptes de
|
|
31
|
+
* démonstration, titres, secret machine, mappers posés dans la console
|
|
32
|
+
* survivent (`mergeKeycloakRealm`, cœur — le même constructeur que
|
|
33
|
+
* `create app`).
|
|
34
|
+
*
|
|
35
|
+
* - sans option : affiche le realm fusionné ;
|
|
36
|
+
* - `--write` : l'écrit (le fichier par défaut est l'unique `.json` de
|
|
37
|
+
* `docker/keycloak/import/`) ;
|
|
38
|
+
* - `--check` : sort en 1 si le fichier n'est plus à jour — la garde de CI ;
|
|
39
|
+
* - `--apply` : pose sur le realm EXISTANT le thème de connexion qui suit
|
|
40
|
+
* `loginPage.skin`, par l'API d'administration (service `keycloak`) —
|
|
41
|
+
* développement seulement, compte `oauth2.providers.<nom>.admin` requis.
|
|
42
|
+
*
|
|
43
|
+
* ⚠️ Keycloak n'importe un realm qu'à sa CRÉATION : un realm déjà présent
|
|
44
|
+
* n'est pas relu. Après `--write`, le porter par un import PARTIEL (clients en
|
|
45
|
+
* « Overwrite » : comptes et seconds facteurs survivent) ; retirer le volume
|
|
46
|
+
* ne se fait que sur un realm qui ne porte rien à garder.
|
|
47
|
+
*/
|
|
48
|
+
var SecurityKeycloakRealm = class extends Command {
|
|
49
|
+
constructor(cli) {
|
|
50
|
+
super("security:keycloak:realm", "écrit le realm Keycloak déduit de la configuration", cli, {
|
|
51
|
+
...options,
|
|
52
|
+
examples: ["nodefony security:keycloak:realm --check", "nodefony security:keycloak:realm --write"]
|
|
53
|
+
});
|
|
54
|
+
this.addOption("-w, --write [fichier]", "écrit le realm fusionné dans le fichier d'import");
|
|
55
|
+
this.addOption("-c, --check [fichier]", "sort en 1 si le fichier d'import n'est plus à jour");
|
|
56
|
+
this.addOption("-a, --apply", "pose le thème de connexion sur le realm existant (développement)");
|
|
57
|
+
this.addOption("-p, --provider <nom>", "fournisseur de oauth2.providers (défaut keycloak)");
|
|
58
|
+
this.addOption("-m, --machine <clientId>", "client machine dont l'audience suit la config");
|
|
59
|
+
this.addOption("--backchannel-origin <url>", "origine par laquelle Keycloak joint l'application (canal arrière)");
|
|
60
|
+
}
|
|
61
|
+
/** Le service `keycloak`, ou `null` (code de sortie posé). */
|
|
62
|
+
#service() {
|
|
63
|
+
const service = this.kernel?.container?.get("keycloak");
|
|
64
|
+
if (service) return service;
|
|
65
|
+
this.log("service keycloak absent — module security non chargé ?", "ERROR");
|
|
66
|
+
process.exitCode = EXIT_CONFIG;
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
async generate(opts) {
|
|
70
|
+
const service = this.#service();
|
|
71
|
+
if (service === null) return this;
|
|
72
|
+
if (opts.apply === true) {
|
|
73
|
+
if (!await this.#apply(service, opts.provider) || opts.write === void 0 && opts.check === void 0) return this;
|
|
74
|
+
}
|
|
75
|
+
let plan;
|
|
76
|
+
try {
|
|
77
|
+
plan = service.realmPlan({
|
|
78
|
+
provider: opts.provider,
|
|
79
|
+
machine: opts.machine,
|
|
80
|
+
backchannelOrigin: opts.backchannelOrigin,
|
|
81
|
+
file: typeof opts.write === "string" ? opts.write : typeof opts.check === "string" ? opts.check : void 0
|
|
82
|
+
});
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (!(error instanceof KeycloakRealmError)) throw error;
|
|
85
|
+
this.log(error.message, "ERROR");
|
|
86
|
+
process.exitCode = EXIT_CONFIG;
|
|
87
|
+
return this;
|
|
88
|
+
}
|
|
89
|
+
for (const warning of plan.warnings) process.stderr.write(`${YELLOW}⚠ ${warning}${RESET}\n`);
|
|
90
|
+
const relative = path.relative(this.kernel?.path ?? process.cwd(), plan.file);
|
|
91
|
+
if (opts.check !== void 0) {
|
|
92
|
+
if (plan.upToDate) {
|
|
93
|
+
process.stdout.write(`${GREEN}✓${RESET} ${relative} est à jour\n`);
|
|
94
|
+
return this;
|
|
95
|
+
}
|
|
96
|
+
process.stderr.write(`${relative} ne correspond plus à la configuration — nodefony security:keycloak:realm --write\n`);
|
|
97
|
+
process.exitCode = EXIT_DIFFERS;
|
|
98
|
+
return this;
|
|
99
|
+
}
|
|
100
|
+
if (opts.write !== void 0) {
|
|
101
|
+
if (!service.writeRealm(plan)) {
|
|
102
|
+
process.stdout.write(`${GREEN}✓${RESET} ${relative} déjà à jour, rien d'écrit\n`);
|
|
103
|
+
return this;
|
|
104
|
+
}
|
|
105
|
+
process.stdout.write(`${GREEN}✓${RESET} ${relative} écrit\n${DIM} Keycloak n'importe un realm qu'à sa CRÉATION — un realm déjà là ne relit pas ce fichier :\n · thème de connexion : nodefony security:keycloak:realm --apply ;\n · le reste : console › Realm settings › Action › Partial import, clients en « Overwrite » (comptes et seconds facteurs gardés) ;\n · ou recréer le realm (retirer le volume Keycloak) s'il ne porte rien à garder.${RESET}\n`);
|
|
106
|
+
return this;
|
|
107
|
+
}
|
|
108
|
+
process.stdout.write(plan.text);
|
|
109
|
+
return this;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* `--apply` : le thème que l'habillage demande, posé sur le realm vivant.
|
|
113
|
+
*
|
|
114
|
+
* @returns `false` si le geste a échoué (code de sortie posé)
|
|
115
|
+
*/
|
|
116
|
+
async #apply(service, provider) {
|
|
117
|
+
try {
|
|
118
|
+
const result = await service.applyLoginTheme(provider === void 0 ? {} : { provider });
|
|
119
|
+
const line = result.outcome === "applied" ? `${GREEN}✓${RESET} realm « ${result.realm} » : thème « ${result.previous ?? "—"} » → « ${result.theme} »` : result.outcome === "unchanged" ? `${GREEN}✓${RESET} realm « ${result.realm} » : déjà sur « ${result.theme} »` : `${YELLOW}⚠${RESET} realm « ${result.realm} » : thème « ${result.previous ?? "aucun"} » propre à l'application, laissé tel quel`;
|
|
120
|
+
process.stdout.write(`${line}\n`);
|
|
121
|
+
return true;
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (!(error instanceof KeycloakAdminError)) throw error;
|
|
124
|
+
this.log(error.message, "ERROR");
|
|
125
|
+
process.exitCode = EXIT_CONFIG;
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
export { SecurityKeycloakRealm as default };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { contributeKeycloak } from "../keycloak/scaffold.js";
|
|
2
|
+
// nodefony/scaffold/contribute.ts
|
|
3
|
+
/**
|
|
4
|
+
* Le point de contribution de `@nodefony/security` : ce que le paquet livre à
|
|
5
|
+
* une application, déclaré dans son `package.json` (`nodefony.contribute`) et
|
|
6
|
+
* joué par le cœur, qui n'en connaît rien.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* La contribution du paquet — aujourd'hui le seul décor Keycloak de
|
|
12
|
+
* développement (`nodefony/keycloak/`).
|
|
13
|
+
*
|
|
14
|
+
* @param context - contexte fourni par le cœur (`runAppContributions`)
|
|
15
|
+
*/
|
|
16
|
+
function contribute(context) {
|
|
17
|
+
contributeKeycloak(context);
|
|
18
|
+
}
|
|
19
|
+
export { contribute };
|
|
@@ -1,25 +1,52 @@
|
|
|
1
|
+
import { AuthenticationError } from "../errors/AuthenticationError.js";
|
|
1
2
|
import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
3
|
+
import { UserToken } from "../src/token/UserToken.js";
|
|
4
|
+
import peekIssuer from "../src/authenticator/peekIssuer.js";
|
|
5
|
+
import { JwtAuthenticator } from "../src/authenticator/JwtAuthenticator.js";
|
|
6
|
+
import { ExternalSubjectResolver, externalSubjectOptions } from "../src/authenticator/ExternalSubjectResolver.js";
|
|
7
|
+
import { resolveJwtRuntime } from "../src/token/jwtRuntime.js";
|
|
2
8
|
import { RemoteJwtVerifier } from "../src/token/RemoteJwtVerifier.js";
|
|
3
|
-
import { ACCESS_TOKEN_VERIFIER, Service
|
|
4
|
-
|
|
9
|
+
import { ACCESS_ACCOUNT_RESOLVER, ACCESS_TOKEN_VERIFIER, Service } from "nodefony";
|
|
10
|
+
// nodefony/service/accessTokenVerifier.ts
|
|
5
11
|
const serviceName = ACCESS_TOKEN_VERIFIER;
|
|
6
12
|
/**
|
|
7
|
-
* Pose (ou non) le vérificateur de jetons d'accès
|
|
13
|
+
* Pose (ou non) le vérificateur de jetons d'accès dans le conteneur — celui
|
|
14
|
+
* qu'une ressource protégée (porte MCP, zone `external-jwt`) interroge.
|
|
8
15
|
*
|
|
9
|
-
* Ce service est une **décision de câblage**, pas de la cryptographie :
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
16
|
+
* Ce service est une **décision de câblage**, pas de la cryptographie : la
|
|
17
|
+
* vérification d'un jeton TIERS vit dans {@link RemoteJwtVerifier}, celle d'un
|
|
18
|
+
* jeton de CETTE application dans {@link JwtAuthenticator}, et la doctrine de
|
|
19
|
+
* refus dans le cœur (`nodefony/src/oauth/`). Ici, on tranche deux questions :
|
|
20
|
+
* l'application signe-t-elle ses jetons, et en accepte-t-elle d'ailleurs ?
|
|
13
21
|
*
|
|
14
|
-
* **
|
|
15
|
-
*
|
|
16
|
-
*
|
|
22
|
+
* ⭐ **Un jeton signé par cette application est TOUJOURS vérifiable**, dès que
|
|
23
|
+
* sa capacité JWT est active (`security.jwt.enabled`, le défaut) — sans rien
|
|
24
|
+
* déclarer dans `resourceServer.issuers`. Une application qui émet un jeton
|
|
25
|
+
* pour sa propre porte (`nodefony security:mcp:token --resource …`) doit pouvoir
|
|
26
|
+
* le lire : sans cette règle, la porte refusait un jeton parfaitement valide,
|
|
27
|
+
* et l'application était un serveur d'autorisation dont aucune ressource ne
|
|
28
|
+
* reconnaissait la signature. Rien de nouveau ne devient valide : l'audience
|
|
29
|
+
* reste exigée, et seules celles de `jwt.audiences` sont émises.
|
|
30
|
+
*
|
|
31
|
+
* 🔴 **Il est vérifié par les règles de l'authentificateur `jwt`, pas par son
|
|
32
|
+
* jeu de clés seul** : révocation ciblée et en masse, compte encore actif. Lu
|
|
33
|
+
* comme un jeton tiers, un jeton RÉVOQUÉ — ou celui d'un compte désactivé —
|
|
34
|
+
* ouvrait encore la porte jusqu'à son expiration.
|
|
35
|
+
*
|
|
36
|
+
* L'aiguillage se fait sur l'émetteur revendiqué (`peekIssuer`), comme dans le
|
|
37
|
+
* pare-feu : l'émetteur de l'application va à la vérification locale, tout
|
|
38
|
+
* autre à la liste fermée des émetteurs tiers. Un émetteur inconnu est refusé
|
|
39
|
+
* sans la moindre requête sortante.
|
|
40
|
+
*
|
|
41
|
+
* **Rien à vérifier = rien n'est posé** (capacité JWT coupée, aucun émetteur
|
|
42
|
+
* tiers) : une porte protégée qui ne trouve pas de vérificateur refuse alors de
|
|
43
|
+
* servir en le disant (503 + CRITIC), là où un vérificateur vide refuserait
|
|
17
44
|
* chaque jeton un par un — même résultat pour l'appelant, diagnostic beaucoup
|
|
18
45
|
* plus difficile pour l'exploitant.
|
|
19
46
|
*
|
|
20
|
-
* Le coût est nul
|
|
21
|
-
*
|
|
22
|
-
*
|
|
47
|
+
* Le coût est nul tant qu'aucun jeton n'est présenté : aucune requête au
|
|
48
|
+
* démarrage, clés locales lues au premier jeton, découverte d'un émetteur tiers
|
|
49
|
+
* au premier jeton qui s'en réclame.
|
|
23
50
|
*/
|
|
24
51
|
var AccessTokenVerifierService = class extends Service {
|
|
25
52
|
module;
|
|
@@ -29,7 +56,7 @@ var AccessTokenVerifierService = class extends Service {
|
|
|
29
56
|
this.module = module;
|
|
30
57
|
this.kernel?.once("onBoot", () => this.#build());
|
|
31
58
|
}
|
|
32
|
-
/** Le vérificateur
|
|
59
|
+
/** Le vérificateur posé, ou `null` si rien n'est vérifiable. */
|
|
33
60
|
get verifier() {
|
|
34
61
|
return this.#verifier;
|
|
35
62
|
}
|
|
@@ -40,23 +67,13 @@ var AccessTokenVerifierService = class extends Service {
|
|
|
40
67
|
} catch {
|
|
41
68
|
return;
|
|
42
69
|
}
|
|
70
|
+
const runtime = config.jwt.enabled ? resolveJwtRuntime(config.jwt) : null;
|
|
71
|
+
const local = runtime === null ? null : new JwtAuthenticator(this.container, runtime);
|
|
43
72
|
const { issuers, ...tuning } = config.resourceServer;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const localIssuer = config.jwt.issuer;
|
|
49
|
-
const armed = issuers.map((trusted) => localIssuer && canonicalIssuer(trusted.issuer) === canonicalIssuer(localIssuer) ? {
|
|
50
|
-
...trusted,
|
|
51
|
-
localJwks: async () => {
|
|
52
|
-
const keystore = this.container?.get("jwtKeystore");
|
|
53
|
-
if (!keystore) throw new Error(`l'émetteur « ${trusted.issuer} » est cette application, mais sa capacité JWT n'est pas armée (security.jwt) — aucune clé locale à présenter`);
|
|
54
|
-
return keystore.getPublicJWKS();
|
|
55
|
-
}
|
|
56
|
-
} : trusted);
|
|
57
|
-
try {
|
|
58
|
-
this.#verifier = new RemoteJwtVerifier({
|
|
59
|
-
issuers: armed,
|
|
73
|
+
let remote = null;
|
|
74
|
+
if (issuers.length > 0) try {
|
|
75
|
+
remote = new RemoteJwtVerifier({
|
|
76
|
+
issuers,
|
|
60
77
|
timeoutMs: tuning.timeoutMs,
|
|
61
78
|
cooldownMs: tuning.cooldownMs,
|
|
62
79
|
cacheMaxAgeMs: tuning.cacheMaxAgeMs,
|
|
@@ -67,11 +84,61 @@ var AccessTokenVerifierService = class extends Service {
|
|
|
67
84
|
this.log(`vérificateur de jetons non armé — ${error.message}`, "CRITIC");
|
|
68
85
|
return;
|
|
69
86
|
}
|
|
70
|
-
|
|
87
|
+
if (local === null && remote === null) {
|
|
88
|
+
this.log("rien à vérifier — capacité JWT coupée (security.jwt.enabled) et aucun émetteur tiers (security.resourceServer.issuers) : une porte protégée refusera de servir.", "DEBUG");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const localIssuer = runtime?.issuer ?? null;
|
|
92
|
+
const verify = (token, audience) => {
|
|
93
|
+
if (local !== null && peekIssuer(token) === localIssuer) return local.verifyForResource(token, audience);
|
|
94
|
+
return remote === null ? Promise.resolve(null) : remote.verify(token, audience);
|
|
95
|
+
};
|
|
96
|
+
this.#verifier = verify;
|
|
71
97
|
this.container?.set(serviceName, verify);
|
|
72
|
-
|
|
73
|
-
|
|
98
|
+
this.container?.set(ACCESS_ACCOUNT_RESOLVER, accountResolver(local, issuers.length > 0 ? new ExternalSubjectResolver(this.container, externalSubjectOptions(config)) : null));
|
|
99
|
+
const trusted = [...localIssuer === null ? [] : [`${localIssuer} (cette application)`], ...issuers.map((i) => i.issuer)];
|
|
100
|
+
this.log(`vérificateur de jetons armé — ${trusted.length} émetteur(s) de confiance : ` + trusted.join(", "), "INFO");
|
|
74
101
|
}
|
|
75
102
|
};
|
|
76
|
-
|
|
103
|
+
/**
|
|
104
|
+
* Le résolveur de compte posé à côté du vérificateur : il retrouve, pour un
|
|
105
|
+
* principal que celui-ci vient d'établir, le compte de l'application qui agit.
|
|
106
|
+
*
|
|
107
|
+
* Aiguillé par l'émetteur, comme le vérificateur : un jeton maison rejoue la
|
|
108
|
+
* règle de la zone `jwt` (`JwtAuthenticator.resolveAccount`), un jeton tiers
|
|
109
|
+
* celle de la zone `external-jwt` (`ExternalSubjectResolver`). Aucune n'est
|
|
110
|
+
* recopiée ici. Un refus rend `null` ; une panne (annuaire non câblé,
|
|
111
|
+
* émetteur absent de la table de rattachement) traverse.
|
|
112
|
+
*
|
|
113
|
+
* Le jeton rendu est un `UserToken` de type `"mcp"` — hors des types humains
|
|
114
|
+
* que le voteur de scopes laisse passer : les scopes du jeton bornent les
|
|
115
|
+
* attributs `api:action`, et les rôles restent ceux du compte, comme dans une
|
|
116
|
+
* zone `jwt`.
|
|
117
|
+
*/
|
|
118
|
+
function accountResolver(local, external) {
|
|
119
|
+
return async (principal) => {
|
|
120
|
+
let user = null;
|
|
121
|
+
if (local !== null) user = await local.resolveAccount(principal);
|
|
122
|
+
if (user === null && external !== null && principal.subject) {
|
|
123
|
+
if (!external.recognizes(principal.issuer)) return null;
|
|
124
|
+
try {
|
|
125
|
+
user = await external.resolve(principal.issuer, principal.subject, principal.claims);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (error instanceof AuthenticationError) return null;
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (user === null) return null;
|
|
132
|
+
const token = new UserToken("mcp").promote(user);
|
|
133
|
+
token.setAttribute("scopes", [...principal.scopes]);
|
|
134
|
+
token.setAttribute("issuer", principal.issuer);
|
|
135
|
+
token.setAttribute("subject", principal.subject);
|
|
136
|
+
if (principal.tokenId !== void 0) token.setAttribute("jti", principal.tokenId);
|
|
137
|
+
if (principal.claims !== void 0) token.setAttribute("claims", principal.claims);
|
|
138
|
+
return {
|
|
139
|
+
token,
|
|
140
|
+
user
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
}
|
|
77
144
|
export { AccessTokenVerifierService, AccessTokenVerifierService as default };
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
+
import { ScopeGrantPolicy, refusedScopes } from "../src/token/scopeGrants.js";
|
|
1
2
|
import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
3
|
+
import { ACCOUNT_ID_KEY, CREDENTIALS_VERSION_KEY, credentialsVersionOf } from "../src/accountBinding.js";
|
|
2
4
|
import { generateApiKey } from "../src/apikey/apiKeyFormat.js";
|
|
3
5
|
import { recordAudit } from "../src/audit/recordAudit.js";
|
|
4
6
|
import { TOKEN_FACETS } from "../src/token/tokenFilters.js";
|
|
5
7
|
import { ApiKeyError } from "../errors/ApiKeyError.js";
|
|
6
8
|
import { Service, countFacets } from "nodefony";
|
|
7
9
|
import { randomUUID } from "node:crypto";
|
|
8
|
-
|
|
10
|
+
// nodefony/service/apiKeys.ts
|
|
9
11
|
const serviceName = "apiKeys";
|
|
10
12
|
const MAX_NAME_LEN = 100;
|
|
11
13
|
const MS_PER_DAY = 864e5;
|
|
@@ -32,6 +34,8 @@ var ApiKeyService = class extends Service {
|
|
|
32
34
|
#defaultExpiryDays = 90;
|
|
33
35
|
#maxPerSubject = 100;
|
|
34
36
|
#allowedScopes = null;
|
|
37
|
+
/** `grantableScopes` compilé au boot ; `null` tant que la config n'est pas lue. */
|
|
38
|
+
#scopeGrants = null;
|
|
35
39
|
constructor(module) {
|
|
36
40
|
super(serviceName, module.container, module.notificationsCenter, module.options);
|
|
37
41
|
this.module = module;
|
|
@@ -54,6 +58,7 @@ var ApiKeyService = class extends Service {
|
|
|
54
58
|
this.#defaultExpiryDays = ak.defaultExpiryDays;
|
|
55
59
|
this.#maxPerSubject = ak.maxPerSubject;
|
|
56
60
|
this.#allowedScopes = ak.allowedScopes;
|
|
61
|
+
this.#scopeGrants = new ScopeGrantPolicy(config.grantableScopes);
|
|
57
62
|
this.log(`api keys ready — prefix "${ak.prefix}_", max ${ak.maxPerSubject}/porteur`, "DEBUG");
|
|
58
63
|
}
|
|
59
64
|
/** `true` si les clés API sont activées en config. */
|
|
@@ -83,17 +88,31 @@ var ApiKeyService = class extends Service {
|
|
|
83
88
|
* Émet une nouvelle clé API pour un porteur — renvoie sa vue publique **+ le
|
|
84
89
|
* token en clair** (affiché une seule fois).
|
|
85
90
|
*
|
|
91
|
+
* Un scope que le porteur ne peut pas OBTENIR — réservé par
|
|
92
|
+
* `grantableScopes` à des rôles qu'il n'a pas, ou `admin:*` sans le rôle
|
|
93
|
+
* d'administration — refuse la création entière : une clé émise avec moins
|
|
94
|
+
* que demandé surprendrait sans qu'aucune réponse ne le dise. Même juge que
|
|
95
|
+
* l'émission des jetons ({@link refusedScopes}).
|
|
96
|
+
*
|
|
97
|
+
* @param roles - rôles du porteur tels que son compte les porte ; omis =
|
|
98
|
+
* aucun rôle, donc tout scope réservé est refusé (fail-closed).
|
|
86
99
|
* @throws ApiKeyError 400 — nom vide/trop long, scope hors catalogue, expiry invalide.
|
|
100
|
+
* @throws ApiKeyError 403 — scope réservé à des rôles que le porteur n'a pas.
|
|
87
101
|
* @throws ApiKeyError 409 — plafond `maxPerSubject` atteint.
|
|
88
|
-
* @throws ApiKeyError
|
|
102
|
+
* @throws ApiKeyError 403 — aucun compte sous cet identifiant (la clé est liée
|
|
103
|
+
* à l'`id` immuable du compte, lu dans l'annuaire).
|
|
104
|
+
* @throws ApiKeyError 503 — store ou annuaire `users` indisponible.
|
|
89
105
|
*/
|
|
90
|
-
async createForSubject(subjectId, subjectType, opts) {
|
|
106
|
+
async createForSubject(subjectId, subjectType, opts, roles = []) {
|
|
91
107
|
const store = this.#resolveStore();
|
|
92
108
|
const name = this.#normalizeName(opts.name);
|
|
93
109
|
const scopes = this.#normalizeScopes(opts.scopes);
|
|
110
|
+
const refusals = refusedScopes(scopes, roles, this.#scopeGrants ?? new ScopeGrantPolicy(), this.container?.get("roleHierarchy") ?? null);
|
|
111
|
+
if (refusals.length > 0) throw new ApiKeyError(`scope not grantable to this account: ${refusals.map((r) => r.scope).join(", ")}`, 403);
|
|
94
112
|
const now = Date.now();
|
|
95
113
|
const expiresAt = this.#resolveExpiry(opts.expiresInDays, now);
|
|
96
114
|
if ((await store.findBySubject(subjectId)).filter((r) => r.kind === "pat" && this.#isActive(r, now)).length >= this.#maxPerSubject) throw new ApiKeyError(`API key limit reached (${this.#maxPerSubject})`, 409);
|
|
115
|
+
const holder = await this.#resolveHolder(subjectId);
|
|
97
116
|
const generated = generateApiKey(this.#prefix);
|
|
98
117
|
const record = {
|
|
99
118
|
id: randomUUID(),
|
|
@@ -119,7 +138,10 @@ var ApiKeyService = class extends Service {
|
|
|
119
138
|
lastUsedUserAgent: null,
|
|
120
139
|
revokedAt: null,
|
|
121
140
|
revokedReason: null,
|
|
122
|
-
metadata: {
|
|
141
|
+
metadata: {
|
|
142
|
+
[ACCOUNT_ID_KEY]: holder.id,
|
|
143
|
+
[CREDENTIALS_VERSION_KEY]: credentialsVersionOf(holder)
|
|
144
|
+
}
|
|
123
145
|
};
|
|
124
146
|
await store.put(record);
|
|
125
147
|
this.log(`api key created — id=${record.id} subject=${subjectId} scopes=[${scopes.join(",")}]`, "INFO");
|
|
@@ -253,6 +275,25 @@ var ApiKeyService = class extends Service {
|
|
|
253
275
|
});
|
|
254
276
|
return true;
|
|
255
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* Le compte qui reçoit la clé — son identifiant IMMUABLE et sa version
|
|
280
|
+
* d'identifiants sont la liaison qu'elle portera, contrôlée à chaque
|
|
281
|
+
* présentation (`isBoundToAccount`). Lu dans
|
|
282
|
+
* l'annuaire, jamais reçu de l'appelant : la clé appartient au compte qui
|
|
283
|
+
* existe MAINTENANT sous cet identifiant.
|
|
284
|
+
*
|
|
285
|
+
* @throws ApiKeyError 503 — annuaire `users` absent (câblage).
|
|
286
|
+
* @throws ApiKeyError 403 — aucun compte sous cet identifiant.
|
|
287
|
+
*/
|
|
288
|
+
async #resolveHolder(subjectId) {
|
|
289
|
+
const users = this.get("users");
|
|
290
|
+
if (!users) throw new ApiKeyError("user directory unavailable", 503);
|
|
291
|
+
try {
|
|
292
|
+
return await users.loadUserByIdentifier(subjectId);
|
|
293
|
+
} catch {
|
|
294
|
+
throw new ApiKeyError("API key holder unknown", 403);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
256
297
|
#resolveStore() {
|
|
257
298
|
if (this.#store === null) {
|
|
258
299
|
const store = this.get("tokenStore");
|
|
@@ -306,5 +347,4 @@ var ApiKeyService = class extends Service {
|
|
|
306
347
|
};
|
|
307
348
|
}
|
|
308
349
|
};
|
|
309
|
-
//#endregion
|
|
310
350
|
export { ApiKeyService, ApiKeyService as default };
|