@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,254 @@
|
|
|
1
|
+
import { isNodefonyKeycloakTheme, keycloakLoginTheme, realmNameFromIssuer } from "./keycloakRealmInput.js";
|
|
2
|
+
// nodefony/keycloak/keycloakAdmin.ts
|
|
3
|
+
/** Refus ou échec d'un appel ; le message nomme la cause et le geste suivant. */
|
|
4
|
+
var KeycloakAdminError = class extends Error {
|
|
5
|
+
status;
|
|
6
|
+
/**
|
|
7
|
+
* @param message - phrase destinée à l'opérateur
|
|
8
|
+
* @param status - statut HTTP de Keycloak, `null` s'il n'a pas répondu
|
|
9
|
+
*/
|
|
10
|
+
constructor(message, status = null) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.name = "KeycloakAdminError";
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const DEFAULT_TIMEOUT_MS = 1e4;
|
|
17
|
+
/**
|
|
18
|
+
* Marge retirée à la durée de vie du jeton : un jeton qui expire PENDANT un
|
|
19
|
+
* appel rend un 401 qu'on lirait comme un compte refusé.
|
|
20
|
+
*/
|
|
21
|
+
const TOKEN_MARGIN_MS = 1e4;
|
|
22
|
+
/**
|
|
23
|
+
* Le realm et le compte d'administration d'un fournisseur, lus dans la
|
|
24
|
+
* configuration de sécurité VALIDÉE.
|
|
25
|
+
*
|
|
26
|
+
* @param config - configuration de sécurité (`defineSecurityConfig`)
|
|
27
|
+
* @param providerName - nom dans `oauth2.providers` (défaut `keycloak`)
|
|
28
|
+
* @returns l'émetteur et le compte
|
|
29
|
+
* @throws {KeycloakAdminError} Fournisseur absent, sans émetteur ou sans
|
|
30
|
+
* compte d'administration
|
|
31
|
+
*/
|
|
32
|
+
function keycloakAdminTarget(config, providerName = "keycloak") {
|
|
33
|
+
const provider = Object.hasOwn(config.oauth2.providers, providerName) ? config.oauth2.providers[providerName] : void 0;
|
|
34
|
+
if (provider?.issuer === void 0) throw new KeycloakAdminError(`aucun fournisseur « ${providerName} » avec un issuer dans oauth2.providers — il n'est monté que si ses variables NF_KEYCLOAK_* sont posées`);
|
|
35
|
+
if (provider.admin === void 0) throw new KeycloakAdminError(`oauth2.providers.${providerName}.admin absent : l'API d'administration exige le compte administrateur de Keycloak (NF_KEYCLOAK_ADMIN_USERNAME / NF_KEYCLOAK_ADMIN_PASSWORD, passés en développement seulement)`);
|
|
36
|
+
return {
|
|
37
|
+
issuer: provider.issuer,
|
|
38
|
+
admin: provider.admin
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Client de l'API d'administration d'UN realm Keycloak.
|
|
43
|
+
*
|
|
44
|
+
* Le jeton `admin-cli` est demandé au premier appel puis GARDÉ jusqu'à son
|
|
45
|
+
* expiration ; un 401 en cours de route le renouvelle une fois. Les gardes
|
|
46
|
+
* (développement seulement, https seulement) sont tenues au constructeur :
|
|
47
|
+
* un client qui existe a le droit de parler.
|
|
48
|
+
*/
|
|
49
|
+
var KeycloakAdminClient = class {
|
|
50
|
+
/** Nom du realm administré. */
|
|
51
|
+
realm;
|
|
52
|
+
/** Origine du serveur (`https://hôte:port`). */
|
|
53
|
+
origin;
|
|
54
|
+
#admin;
|
|
55
|
+
#fetch;
|
|
56
|
+
#timeoutMs;
|
|
57
|
+
#token = null;
|
|
58
|
+
/**
|
|
59
|
+
* @param options - realm, compte, environnement, transport
|
|
60
|
+
* @throws {KeycloakAdminError} Hors développement, émetteur qui n'est pas un
|
|
61
|
+
* realm, ou émetteur en clair
|
|
62
|
+
*/
|
|
63
|
+
constructor(options) {
|
|
64
|
+
if (options.environment !== "development") throw new KeycloakAdminError(`refusé en « ${options.environment} » : l'API d'administration exige le compte administrateur de Keycloak, réservé au développement — en production, passer par la console (Realm settings)`);
|
|
65
|
+
try {
|
|
66
|
+
this.realm = realmNameFromIssuer(options.issuer);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
throw new KeycloakAdminError(error instanceof Error ? error.message : String(error));
|
|
69
|
+
}
|
|
70
|
+
const url = new URL(options.issuer);
|
|
71
|
+
if (url.protocol !== "https:") throw new KeycloakAdminError(`émetteur « ${options.issuer} » en clair : le mot de passe administrateur ne part qu'en https`);
|
|
72
|
+
this.origin = url.origin;
|
|
73
|
+
this.#admin = options.admin;
|
|
74
|
+
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
75
|
+
this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Un appel à l'API d'administration du realm, jeton joint.
|
|
79
|
+
*
|
|
80
|
+
* @param method - verbe HTTP
|
|
81
|
+
* @param path - chemin SOUS `/admin/realms/<realm>` (`""` = le realm, `/users?…`)
|
|
82
|
+
* @param body - corps JSON, ou rien
|
|
83
|
+
* @returns la réponse brute — au consommateur de lire son statut
|
|
84
|
+
* @throws {KeycloakAdminError} Serveur injoignable ou compte refusé
|
|
85
|
+
*/
|
|
86
|
+
async request(method, path, body) {
|
|
87
|
+
const url = `${this.origin}/admin/realms/${encodeURIComponent(this.realm)}${path}`;
|
|
88
|
+
const send = async (token) => this.#call(url, {
|
|
89
|
+
method,
|
|
90
|
+
headers: {
|
|
91
|
+
authorization: `Bearer ${token}`,
|
|
92
|
+
accept: "application/json",
|
|
93
|
+
...body === void 0 ? {} : { "content-type": "application/json" }
|
|
94
|
+
},
|
|
95
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
96
|
+
});
|
|
97
|
+
const response = await send(await this.#accessToken());
|
|
98
|
+
if (response.status !== 401) return response;
|
|
99
|
+
await response.body?.cancel();
|
|
100
|
+
this.#token = null;
|
|
101
|
+
return send(await this.#accessToken());
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* La représentation du realm.
|
|
105
|
+
*
|
|
106
|
+
* @returns l'objet JSON rendu par Keycloak
|
|
107
|
+
* @throws {KeycloakAdminError} Realm introuvable ou lecture refusée
|
|
108
|
+
*/
|
|
109
|
+
async getRealm() {
|
|
110
|
+
const response = await this.request("GET", "");
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
await response.body?.cancel();
|
|
113
|
+
throw new KeycloakAdminError(response.status === 404 ? `realm « ${this.realm} » introuvable sur ${this.origin}` : `lecture du realm « ${this.realm} » refusée (HTTP ${response.status})`, response.status);
|
|
114
|
+
}
|
|
115
|
+
const body = await response.json();
|
|
116
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) throw new KeycloakAdminError(`Keycloak a rendu autre chose qu'un realm pour « ${this.realm} »`);
|
|
117
|
+
return Object.fromEntries(Object.entries(body));
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Change des champs du realm — et eux seuls : Keycloak ne touche pas aux
|
|
121
|
+
* champs absents d'un `PUT` partiel.
|
|
122
|
+
*
|
|
123
|
+
* @param changes - les champs à écrire
|
|
124
|
+
* @throws {KeycloakAdminError} Écriture refusée
|
|
125
|
+
*/
|
|
126
|
+
async updateRealm(changes) {
|
|
127
|
+
const response = await this.request("PUT", "", changes);
|
|
128
|
+
await response.body?.cancel();
|
|
129
|
+
if (!response.ok) throw new KeycloakAdminError(`écriture refusée sur le realm « ${this.realm} » (HTTP ${response.status})`, response.status);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Pose sur le realm le thème de connexion qui porte un habillage de `/login`.
|
|
133
|
+
*
|
|
134
|
+
* Un realm habillé par l'application (thème ni `nodefony` ni `nodefony-*`,
|
|
135
|
+
* ou aucun) n'est JAMAIS modifié : même règle que la dérivation du fichier
|
|
136
|
+
* d'import.
|
|
137
|
+
*
|
|
138
|
+
* @param skin - habillage (`loginPage.skin`, ou celui d'un aperçu)
|
|
139
|
+
* @returns le thème d'avant, celui demandé, et ce qui a été fait
|
|
140
|
+
* @throws {KeycloakAdminError} Keycloak injoignable, compte refusé, realm
|
|
141
|
+
* introuvable
|
|
142
|
+
*/
|
|
143
|
+
async applyLoginTheme(skin) {
|
|
144
|
+
const theme = keycloakLoginTheme(skin);
|
|
145
|
+
const current = (await this.getRealm()).loginTheme;
|
|
146
|
+
const previous = typeof current === "string" && current.length > 0 ? current : null;
|
|
147
|
+
const realm = this.realm;
|
|
148
|
+
if (previous === null || !isNodefonyKeycloakTheme(previous)) return {
|
|
149
|
+
realm,
|
|
150
|
+
previous,
|
|
151
|
+
theme,
|
|
152
|
+
outcome: "unmanaged"
|
|
153
|
+
};
|
|
154
|
+
if (previous === theme) return {
|
|
155
|
+
realm,
|
|
156
|
+
previous,
|
|
157
|
+
theme,
|
|
158
|
+
outcome: "unchanged"
|
|
159
|
+
};
|
|
160
|
+
await this.updateRealm({ loginTheme: theme });
|
|
161
|
+
return {
|
|
162
|
+
realm,
|
|
163
|
+
previous,
|
|
164
|
+
theme,
|
|
165
|
+
outcome: "applied"
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/** Le jeton `admin-cli`, gardé jusqu'à son expiration. */
|
|
169
|
+
async #accessToken() {
|
|
170
|
+
if (this.#token !== null && this.#token.expiresAt > Date.now()) return this.#token.value;
|
|
171
|
+
const response = await this.#call(`${this.origin}/realms/master/protocol/openid-connect/token`, {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
174
|
+
body: new URLSearchParams({
|
|
175
|
+
grant_type: "password",
|
|
176
|
+
client_id: "admin-cli",
|
|
177
|
+
username: this.#admin.username,
|
|
178
|
+
password: this.#admin.password
|
|
179
|
+
}).toString()
|
|
180
|
+
});
|
|
181
|
+
if (!response.ok) {
|
|
182
|
+
await response.body?.cancel();
|
|
183
|
+
throw new KeycloakAdminError(`compte administrateur refusé par Keycloak (HTTP ${response.status}, realm master) — vérifier NF_KEYCLOAK_ADMIN_USERNAME et NF_KEYCLOAK_ADMIN_PASSWORD`, response.status);
|
|
184
|
+
}
|
|
185
|
+
const payload = await response.json();
|
|
186
|
+
const field = (key) => typeof payload === "object" && payload !== null ? Object.getOwnPropertyDescriptor(payload, key)?.value : void 0;
|
|
187
|
+
const accessToken = field("access_token");
|
|
188
|
+
const expiresIn = field("expires_in");
|
|
189
|
+
if (typeof accessToken !== "string" || accessToken === "") throw new KeycloakAdminError("Keycloak n'a rendu aucun jeton d'administration");
|
|
190
|
+
const lifetimeMs = typeof expiresIn === "number" ? expiresIn * 1e3 : 0;
|
|
191
|
+
this.#token = {
|
|
192
|
+
value: accessToken,
|
|
193
|
+
expiresAt: Date.now() + lifetimeMs - TOKEN_MARGIN_MS
|
|
194
|
+
};
|
|
195
|
+
return accessToken;
|
|
196
|
+
}
|
|
197
|
+
/** Un appel réseau borné ; une panne devient une erreur qui nomme le serveur. */
|
|
198
|
+
async #call(url, init) {
|
|
199
|
+
try {
|
|
200
|
+
return await this.#fetch(url, {
|
|
201
|
+
...init,
|
|
202
|
+
signal: AbortSignal.timeout(this.#timeoutMs)
|
|
203
|
+
});
|
|
204
|
+
} catch (error) {
|
|
205
|
+
const cause = error instanceof Error && error.cause instanceof Error ? ` (${error.cause.message})` : "";
|
|
206
|
+
throw new KeycloakAdminError(`Keycloak injoignable à ${this.origin} : ${error instanceof Error ? error.message : String(error)}${cause}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
/**
|
|
211
|
+
* Le thème de connexion qu'une page servie par Keycloak charge.
|
|
212
|
+
*
|
|
213
|
+
* Lu sans aucun identifiant : la page d'autorisation publique charge ses
|
|
214
|
+
* fichiers sous `/resources/<version>/login/<thème>/…`.
|
|
215
|
+
*
|
|
216
|
+
* @param html - début de la page d'autorisation
|
|
217
|
+
* @returns le nom du thème, ou `null` si la page n'est pas celle de Keycloak
|
|
218
|
+
*/
|
|
219
|
+
function readKeycloakLoginTheme(html) {
|
|
220
|
+
const match = /\/resources\/[^/"'\s]+\/login\/([^/"'\s]+)\//u.exec(html);
|
|
221
|
+
return match?.[1] === void 0 ? null : decodeURIComponent(match[1]);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* La sonde « thème » du diagnostic d'un fournisseur : la page de Keycloak
|
|
225
|
+
* suit-elle l'habillage de `/login` ?
|
|
226
|
+
*
|
|
227
|
+
* @param skin - habillage configuré (`loginPage.skin`)
|
|
228
|
+
* @returns une fonction qui juge la page d'autorisation ; elle rend `null` —
|
|
229
|
+
* aucune sonde — sur une page qui n'est pas de Keycloak
|
|
230
|
+
*/
|
|
231
|
+
function keycloakLoginThemeCheck(skin) {
|
|
232
|
+
const expected = keycloakLoginTheme(skin);
|
|
233
|
+
return (html) => {
|
|
234
|
+
const active = readKeycloakLoginTheme(html);
|
|
235
|
+
if (active === null) return null;
|
|
236
|
+
if (!isNodefonyKeycloakTheme(active)) return {
|
|
237
|
+
name: "theme",
|
|
238
|
+
status: "ok",
|
|
239
|
+
message: `thème « ${active} » propre à l'application, laissé tel quel`
|
|
240
|
+
};
|
|
241
|
+
if (active === expected) return {
|
|
242
|
+
name: "theme",
|
|
243
|
+
status: "ok",
|
|
244
|
+
message: `la page de Keycloak suit l'habillage « ${skin} » (${active})`
|
|
245
|
+
};
|
|
246
|
+
return {
|
|
247
|
+
name: "theme",
|
|
248
|
+
status: "failed",
|
|
249
|
+
kind: "login-theme-mismatch",
|
|
250
|
+
message: `Keycloak affiche le thème « ${active} », l'habillage « ${skin} » demande « ${expected} » — la connexion marche, mais ne ressemble pas à /login : nodefony security:keycloak:realm --apply (ou Realm settings › Themes)`
|
|
251
|
+
};
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
export { KeycloakAdminClient, KeycloakAdminError, keycloakAdminTarget, keycloakLoginThemeCheck, readKeycloakLoginTheme };
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// nodefony/keycloak/keycloakRealm.ts
|
|
2
|
+
/** Le type du mapper qui ajoute une audience au jeton d'accès. */
|
|
3
|
+
const AUDIENCE_MAPPER = "oidc-audience-mapper";
|
|
4
|
+
/**
|
|
5
|
+
* Le nom d'un mapper d'audience — stable, pour qu'une régénération réécrive
|
|
6
|
+
* le même mapper au lieu d'en ajouter un.
|
|
7
|
+
*/
|
|
8
|
+
function audienceMapperName(realm, index) {
|
|
9
|
+
return index === 0 ? `audience-${realm}-api` : `audience-${realm}-api-${index + 1}`;
|
|
10
|
+
}
|
|
11
|
+
function audienceMappers(input) {
|
|
12
|
+
return input.audiences.map((audience, index) => ({
|
|
13
|
+
name: audienceMapperName(input.realm, index),
|
|
14
|
+
protocol: "openid-connect",
|
|
15
|
+
protocolMapper: AUDIENCE_MAPPER,
|
|
16
|
+
config: {
|
|
17
|
+
"included.custom.audience": audience,
|
|
18
|
+
"access.token.claim": "true",
|
|
19
|
+
"id.token.claim": "false",
|
|
20
|
+
"introspection.token.claim": "true"
|
|
21
|
+
}
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
function roleObjects(roles) {
|
|
25
|
+
return roles.map((role) => role.description === void 0 ? { name: role.name } : {
|
|
26
|
+
name: role.name,
|
|
27
|
+
description: role.description
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function browserClient(input) {
|
|
31
|
+
const attributes = {
|
|
32
|
+
"pkce.code.challenge.method": "S256",
|
|
33
|
+
"post.logout.redirect.uris": input.postLogoutRedirectUris.join("##")
|
|
34
|
+
};
|
|
35
|
+
if (input.backchannelLogoutUrl !== null) {
|
|
36
|
+
attributes["backchannel.logout.url"] = input.backchannelLogoutUrl;
|
|
37
|
+
attributes["backchannel.logout.session.required"] = "true";
|
|
38
|
+
attributes["backchannel.logout.revoke.offline.tokens"] = "false";
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
clientId: input.clientId,
|
|
42
|
+
...input.clientName === void 0 ? {} : { name: input.clientName },
|
|
43
|
+
...input.clientDescription === void 0 ? {} : { description: input.clientDescription },
|
|
44
|
+
enabled: true,
|
|
45
|
+
protocol: "openid-connect",
|
|
46
|
+
publicClient: false,
|
|
47
|
+
clientAuthenticatorType: "client-secret",
|
|
48
|
+
...input.clientSecret === null ? {} : { secret: input.clientSecret },
|
|
49
|
+
standardFlowEnabled: true,
|
|
50
|
+
implicitFlowEnabled: false,
|
|
51
|
+
directAccessGrantsEnabled: false,
|
|
52
|
+
serviceAccountsEnabled: false,
|
|
53
|
+
frontchannelLogout: false,
|
|
54
|
+
redirectUris: [...input.redirectUris],
|
|
55
|
+
webOrigins: ["+"],
|
|
56
|
+
attributes,
|
|
57
|
+
protocolMappers: audienceMappers(input)
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function machineClient(input, machine) {
|
|
61
|
+
return {
|
|
62
|
+
clientId: machine.clientId,
|
|
63
|
+
...machine.name === void 0 ? {} : { name: machine.name },
|
|
64
|
+
...machine.description === void 0 ? {} : { description: machine.description },
|
|
65
|
+
enabled: true,
|
|
66
|
+
protocol: "openid-connect",
|
|
67
|
+
publicClient: false,
|
|
68
|
+
clientAuthenticatorType: "client-secret",
|
|
69
|
+
...machine.secret === null ? {} : { secret: machine.secret },
|
|
70
|
+
standardFlowEnabled: false,
|
|
71
|
+
implicitFlowEnabled: false,
|
|
72
|
+
directAccessGrantsEnabled: false,
|
|
73
|
+
serviceAccountsEnabled: true,
|
|
74
|
+
protocolMappers: audienceMappers(input)
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Le rôle composite que Keycloak donne à tout compte CRÉÉ dans le realm —
|
|
79
|
+
* `manage-account`, `view-profile`, `offline_access`, `uma_authorization`.
|
|
80
|
+
*
|
|
81
|
+
* ⚠️ Un compte IMPORTÉ ne le reçoit pas : l'import n'accorde que les rôles
|
|
82
|
+
* qu'il liste. Sans lui, la console du compte rend 401 sur le profil même de
|
|
83
|
+
* l'utilisateur.
|
|
84
|
+
*
|
|
85
|
+
* @param realm - nom du realm
|
|
86
|
+
* @returns le nom du rôle par défaut
|
|
87
|
+
*/
|
|
88
|
+
function keycloakDefaultRole(realm) {
|
|
89
|
+
return `default-roles-${realm}`;
|
|
90
|
+
}
|
|
91
|
+
function userObject(realm, clientId, user) {
|
|
92
|
+
return {
|
|
93
|
+
id: user.id,
|
|
94
|
+
username: user.username,
|
|
95
|
+
enabled: true,
|
|
96
|
+
email: user.email,
|
|
97
|
+
emailVerified: true,
|
|
98
|
+
firstName: user.firstName,
|
|
99
|
+
lastName: user.lastName,
|
|
100
|
+
credentials: [{
|
|
101
|
+
type: "password",
|
|
102
|
+
value: user.password,
|
|
103
|
+
temporary: false
|
|
104
|
+
}],
|
|
105
|
+
realmRoles: [keycloakDefaultRole(realm)],
|
|
106
|
+
...user.clientRoles === void 0 || user.clientRoles.length === 0 ? {} : { clientRoles: { [clientId]: [...user.clientRoles] } }
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Construit le realm d'import complet d'une application.
|
|
111
|
+
*
|
|
112
|
+
* @param input - ce que la configuration de l'application dit du realm
|
|
113
|
+
* @returns le realm, prêt à être sérialisé par {@link renderKeycloakRealm}
|
|
114
|
+
*/
|
|
115
|
+
function buildKeycloakRealm(input) {
|
|
116
|
+
const roles = {};
|
|
117
|
+
if (input.realmRoles.length > 0) roles.realm = roleObjects(input.realmRoles);
|
|
118
|
+
if (input.clientRoles.length > 0) roles.client = { [input.clientId]: roleObjects(input.clientRoles) };
|
|
119
|
+
const clients = [browserClient(input)];
|
|
120
|
+
if (input.machine !== null) clients.push(machineClient(input, input.machine));
|
|
121
|
+
return {
|
|
122
|
+
realm: input.realm,
|
|
123
|
+
displayName: input.displayName ?? input.realm,
|
|
124
|
+
enabled: true,
|
|
125
|
+
sslRequired: "external",
|
|
126
|
+
registrationAllowed: false,
|
|
127
|
+
loginWithEmailAllowed: true,
|
|
128
|
+
...input.themes?.login === void 0 ? {} : { loginTheme: input.themes.login },
|
|
129
|
+
...input.themes?.account === void 0 ? {} : { accountTheme: input.themes.account },
|
|
130
|
+
...input.themes?.email === void 0 ? {} : { emailTheme: input.themes.email },
|
|
131
|
+
...input.themes?.admin === void 0 ? {} : { adminTheme: input.themes.admin },
|
|
132
|
+
internationalizationEnabled: true,
|
|
133
|
+
supportedLocales: ["fr", "en"],
|
|
134
|
+
defaultLocale: "fr",
|
|
135
|
+
clients,
|
|
136
|
+
...Object.keys(roles).length === 0 ? {} : { roles },
|
|
137
|
+
...input.users === void 0 || input.users.length === 0 ? {} : { users: input.users.map((u) => userObject(input.realm, input.clientId, u)) },
|
|
138
|
+
rememberMe: true
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function isObject(value) {
|
|
142
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
143
|
+
}
|
|
144
|
+
function objects(value) {
|
|
145
|
+
return Array.isArray(value) ? value.filter(isObject) : [];
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Les champs qu'un client hérite du realm existant quand il y figure : un nom,
|
|
149
|
+
* un texte, un secret machine sont écrits par un humain, pas par la config.
|
|
150
|
+
*/
|
|
151
|
+
const CLIENT_KEPT = /* @__PURE__ */ new Set(["name", "description"]);
|
|
152
|
+
function mergeClient(existing, derived, keepSecret) {
|
|
153
|
+
const merged = { ...existing };
|
|
154
|
+
for (const [key, value] of Object.entries(derived)) {
|
|
155
|
+
if (CLIENT_KEPT.has(key) && key in existing) continue;
|
|
156
|
+
if (key === "secret" && keepSecret && key in existing) continue;
|
|
157
|
+
if (key === "attributes" && isObject(existing.attributes) && isObject(value)) {
|
|
158
|
+
merged.attributes = {
|
|
159
|
+
...existing.attributes,
|
|
160
|
+
...value
|
|
161
|
+
};
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (key === "protocolMappers") {
|
|
165
|
+
merged.protocolMappers = [...objects(existing.protocolMappers).filter((m) => m.protocolMapper !== AUDIENCE_MAPPER), ...objects(value)];
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
merged[key] = value;
|
|
169
|
+
}
|
|
170
|
+
return merged;
|
|
171
|
+
}
|
|
172
|
+
function mergeRoles(existing, derived) {
|
|
173
|
+
const kept = objects(existing);
|
|
174
|
+
const names = new Set(kept.map((r) => r.name));
|
|
175
|
+
return [...kept, ...objects(derived).filter((r) => !names.has(r.name))];
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Fond un realm dérivé de la config dans le realm d'import existant.
|
|
179
|
+
*
|
|
180
|
+
* Ce que la configuration DIT est réécrit : nom du realm, thèmes déclarés,
|
|
181
|
+
* client navigateur (flux, URL de retour, audience, déconnexion), client
|
|
182
|
+
* machine (flux, audience). Ce qu'un humain a écrit SURVIT : comptes,
|
|
183
|
+
* titre, noms et textes des clients, secret machine, rôles déjà déclarés,
|
|
184
|
+
* mappers qui ne sont pas d'audience, clients et clés inconnus. Régénérer
|
|
185
|
+
* ne détruit donc rien de ce que la console a ajouté.
|
|
186
|
+
*
|
|
187
|
+
* @param existing - le realm lu sur disque (JSON quelconque)
|
|
188
|
+
* @param derived - le realm construit par {@link buildKeycloakRealm}
|
|
189
|
+
* @returns le realm fusionné
|
|
190
|
+
* @throws Si `existing` n'est pas un objet JSON
|
|
191
|
+
*/
|
|
192
|
+
function mergeKeycloakRealm(existing, derived) {
|
|
193
|
+
if (!isObject(existing)) throw new TypeError("le realm existant n'est pas un objet JSON");
|
|
194
|
+
const merged = { ...existing };
|
|
195
|
+
for (const [key, value] of Object.entries(derived)) if (!(key in merged)) merged[key] = value;
|
|
196
|
+
merged.realm = derived.realm ?? merged.realm ?? null;
|
|
197
|
+
for (const key of [
|
|
198
|
+
"loginTheme",
|
|
199
|
+
"accountTheme",
|
|
200
|
+
"emailTheme",
|
|
201
|
+
"adminTheme"
|
|
202
|
+
]) {
|
|
203
|
+
const theme = derived[key];
|
|
204
|
+
if (theme !== void 0) merged[key] = theme;
|
|
205
|
+
}
|
|
206
|
+
const derivedClients = objects(derived.clients);
|
|
207
|
+
const existingClients = objects(existing.clients);
|
|
208
|
+
const browserId = derivedClients[0]?.clientId;
|
|
209
|
+
const clients = existingClients.map((client) => {
|
|
210
|
+
const match = derivedClients.find((d) => d.clientId === client.clientId);
|
|
211
|
+
if (match === void 0) return client;
|
|
212
|
+
return mergeClient(client, match, match.clientId !== browserId);
|
|
213
|
+
});
|
|
214
|
+
for (const client of derivedClients) if (!existingClients.some((c) => c.clientId === client.clientId)) clients.push(client);
|
|
215
|
+
merged.clients = clients;
|
|
216
|
+
if (isObject(derived.roles)) {
|
|
217
|
+
const roles = isObject(existing.roles) ? { ...existing.roles } : {};
|
|
218
|
+
if (derived.roles.realm !== void 0) roles.realm = mergeRoles(roles.realm, derived.roles.realm);
|
|
219
|
+
if (isObject(derived.roles.client)) {
|
|
220
|
+
const byClient = isObject(roles.client) ? { ...roles.client } : {};
|
|
221
|
+
for (const [clientId, list] of Object.entries(derived.roles.client)) byClient[clientId] = mergeRoles(byClient[clientId], list);
|
|
222
|
+
roles.client = byClient;
|
|
223
|
+
}
|
|
224
|
+
merged.roles = roles;
|
|
225
|
+
}
|
|
226
|
+
return merged;
|
|
227
|
+
}
|
|
228
|
+
/** Largeur au-delà de laquelle un tableau se déplie (celle de prettier). */
|
|
229
|
+
const PRINT_WIDTH = 80;
|
|
230
|
+
function renderValue(value, indent, lead) {
|
|
231
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
232
|
+
const inner = `${indent} `;
|
|
233
|
+
if (Array.isArray(value)) {
|
|
234
|
+
if (value.length === 0) return "[]";
|
|
235
|
+
if (value.every((v) => v === null || typeof v !== "object")) {
|
|
236
|
+
const flat = `[${value.map((v) => JSON.stringify(v)).join(", ")}]`;
|
|
237
|
+
if (lead + flat.length + 1 <= PRINT_WIDTH) return flat;
|
|
238
|
+
}
|
|
239
|
+
return `[\n${value.map((v) => `${inner}${renderValue(v, inner, inner.length)}`).join(",\n")}\n${indent}]`;
|
|
240
|
+
}
|
|
241
|
+
const entries = Object.entries(value);
|
|
242
|
+
if (entries.length === 0) return "{}";
|
|
243
|
+
return `{\n${entries.map(([key, v]) => {
|
|
244
|
+
const head = `${inner}${JSON.stringify(key)}: `;
|
|
245
|
+
return `${head}${renderValue(v, inner, head.length)}`;
|
|
246
|
+
}).join(",\n")}\n${indent}}`;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Sérialise un realm comme le dépôt l'écrit : deux espaces, tableaux de
|
|
250
|
+
* valeurs simples sur une ligne quand ils tiennent en 80 colonnes, fin de
|
|
251
|
+
* ligne — la forme que prettier donne au fichier versionné.
|
|
252
|
+
*
|
|
253
|
+
* @param realm - le realm à écrire
|
|
254
|
+
* @returns le texte du fichier d'import
|
|
255
|
+
*/
|
|
256
|
+
function renderKeycloakRealm(realm) {
|
|
257
|
+
return `${renderValue(realm, "", 0)}\n`;
|
|
258
|
+
}
|
|
259
|
+
export { buildKeycloakRealm, keycloakDefaultRole, mergeKeycloakRealm, renderKeycloakRealm };
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { KEYCLOAK_THEME_NAME } from "./scaffold.js";
|
|
2
|
+
import { DEFAULT_LOGIN_PAGE_SKIN } from "nodefony";
|
|
3
|
+
// nodefony/keycloak/keycloakRealmInput.ts
|
|
4
|
+
/** Erreur de dérivation : la config ne permet pas d'écrire un realm. */
|
|
5
|
+
var KeycloakRealmError = class extends Error {
|
|
6
|
+
name = "KeycloakRealmError";
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Le nom du realm, lu dans l'émetteur (`https://kc.example/realms/<nom>`).
|
|
10
|
+
*
|
|
11
|
+
* @throws {KeycloakRealmError} Si l'émetteur n'a pas la forme Keycloak
|
|
12
|
+
*/
|
|
13
|
+
function realmNameFromIssuer(issuer) {
|
|
14
|
+
if (issuer === void 0) throw new KeycloakRealmError("le fournisseur n'a pas d'`issuer` : c'est l'adresse du realm (https://…/realms/<nom>)");
|
|
15
|
+
let pathname;
|
|
16
|
+
try {
|
|
17
|
+
pathname = new URL(issuer).pathname;
|
|
18
|
+
} catch {
|
|
19
|
+
throw new KeycloakRealmError(`issuer « ${issuer} » n'est pas une URL`);
|
|
20
|
+
}
|
|
21
|
+
const match = /\/realms\/([^/]+)\/?$/u.exec(pathname);
|
|
22
|
+
if (match?.[1] === void 0) throw new KeycloakRealmError(`issuer « ${issuer} » ne finit pas par /realms/<nom> : ce n'est pas l'adresse d'un realm Keycloak`);
|
|
23
|
+
return decodeURIComponent(match[1]);
|
|
24
|
+
}
|
|
25
|
+
/** Les ressources de toutes les zones qui acceptent des jetons tiers. */
|
|
26
|
+
function externalJwtResources(config) {
|
|
27
|
+
const resources = [];
|
|
28
|
+
for (const area of Object.values(config.areas)) {
|
|
29
|
+
if (!area.authenticators.includes("external-jwt")) continue;
|
|
30
|
+
if (area.resource !== void 0 && !resources.includes(area.resource)) resources.push(area.resource);
|
|
31
|
+
}
|
|
32
|
+
return resources;
|
|
33
|
+
}
|
|
34
|
+
/** Le chemin du callback, réécrit en celui de la déconnexion par canal arrière. */
|
|
35
|
+
function backchannelPath(callbackPath) {
|
|
36
|
+
return callbackPath.replace(/\/callback\/?$/u, "/backchannel-logout");
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Dérive, depuis la configuration EFFECTIVE de l'application, ce que son realm
|
|
40
|
+
* Keycloak doit dire.
|
|
41
|
+
*
|
|
42
|
+
* - **Client** : `clientId`, et son secret hors production.
|
|
43
|
+
* - **URL de retour** : `redirectUri` du fournisseur, plus — hors production —
|
|
44
|
+
* le même chemin sur chaque serveur local (http ET https), parce que
|
|
45
|
+
* l'application répond sur les deux et que Keycloak compare au caractère près.
|
|
46
|
+
* - **Audience** : `audiences` du fournisseur, sinon la `resource` de chaque
|
|
47
|
+
* zone `external-jwt`. Une audience qu'aucune zone ne porte est DITE : le
|
|
48
|
+
* jeton serait refusé partout.
|
|
49
|
+
* - **Rôles** : les clés de `roleMapping`, rangées selon `rolesSource`.
|
|
50
|
+
*
|
|
51
|
+
* @param ctx - la configuration et le décor de l'application
|
|
52
|
+
* @returns l'entrée du constructeur de realm et les constats
|
|
53
|
+
* @throws {KeycloakRealmError} Si le fournisseur est absent ou sans émetteur
|
|
54
|
+
*/
|
|
55
|
+
/**
|
|
56
|
+
* Le thème Keycloak de connexion qui porte un habillage de la page `/login`.
|
|
57
|
+
*
|
|
58
|
+
* @param skin - habillage (`loginPage.skin`)
|
|
59
|
+
* @returns `nodefony` pour l'habillage par défaut, `nodefony-<habillage>` sinon
|
|
60
|
+
* (thème enfant livré sous `keycloak/themes/`)
|
|
61
|
+
*/
|
|
62
|
+
function keycloakLoginTheme(skin) {
|
|
63
|
+
return skin === DEFAULT_LOGIN_PAGE_SKIN ? KEYCLOAK_THEME_NAME : `${KEYCLOAK_THEME_NAME}-${skin}`;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Dit si un thème Keycloak est l'un de ceux que livre Nodefony.
|
|
67
|
+
*
|
|
68
|
+
* @param theme - nom du thème
|
|
69
|
+
* @returns `true` pour `nodefony` et `nodefony-<habillage>`
|
|
70
|
+
*/
|
|
71
|
+
function isNodefonyKeycloakTheme(theme) {
|
|
72
|
+
return theme === "nodefony" || theme.startsWith(`nodefony-`);
|
|
73
|
+
}
|
|
74
|
+
function deriveKeycloakRealmInput(ctx) {
|
|
75
|
+
const provider = ctx.config.oauth2.providers[ctx.providerName];
|
|
76
|
+
if (provider === void 0) {
|
|
77
|
+
const known = Object.keys(ctx.config.oauth2.providers);
|
|
78
|
+
throw new KeycloakRealmError(`aucun fournisseur « ${ctx.providerName} » dans oauth2.providers` + (known.length > 0 ? ` (déclarés : ${known.join(", ")})` : " — il n'est monté que si ses variables NF_KEYCLOAK_* sont posées"));
|
|
79
|
+
}
|
|
80
|
+
const warnings = [];
|
|
81
|
+
const realm = realmNameFromIssuer(provider.issuer);
|
|
82
|
+
const callback = new URL(provider.redirectUri);
|
|
83
|
+
const origins = [];
|
|
84
|
+
if (!ctx.production) {
|
|
85
|
+
const host = callback.hostname;
|
|
86
|
+
if (ctx.servers.httpsPort !== null) origins.push(`https://${host}:${ctx.servers.httpsPort}`);
|
|
87
|
+
if (ctx.servers.httpPort !== null) origins.push(`http://${host}:${ctx.servers.httpPort}`);
|
|
88
|
+
}
|
|
89
|
+
if (!origins.includes(callback.origin)) origins.unshift(callback.origin);
|
|
90
|
+
const redirectUris = origins.map((o) => `${o}${callback.pathname}`);
|
|
91
|
+
const backOrigin = ctx.backchannelOrigin ?? (ctx.production || ctx.servers.httpPort === null ? callback.origin : `http://host.docker.internal:${ctx.servers.httpPort}`);
|
|
92
|
+
const resources = externalJwtResources(ctx.config);
|
|
93
|
+
const audiences = provider.audiences ?? resources;
|
|
94
|
+
for (const audience of audiences) if (!resources.includes(audience)) warnings.push(`audience « ${audience} » : aucune zone external-jwt ne porte cette resource — un jeton qui la porte est refusé partout`);
|
|
95
|
+
if (provider.audiences === void 0 && resources.length > 1) warnings.push(`${resources.length} zones external-jwt, audiences déduites de TOUTES — écrire oauth2.providers.${ctx.providerName}.audiences si l'une doit refuser ses jetons`);
|
|
96
|
+
const sources = provider.rolesSource ?? ["client"];
|
|
97
|
+
const roles = Object.entries(provider.roleMapping ?? {}).map(([name, to]) => ({
|
|
98
|
+
name,
|
|
99
|
+
description: `Traduit en ${to} par la table roleMapping du fournisseur ${ctx.providerName}.`
|
|
100
|
+
}));
|
|
101
|
+
if (sources.includes("groups") && roles.length > 0) warnings.push("rolesSource « groups » : les groupes et leur mapper se déclarent dans la console Keycloak, ce realm ne les écrit pas");
|
|
102
|
+
const skin = ctx.config.loginPage.skin;
|
|
103
|
+
const current = ctx.currentLoginTheme ?? null;
|
|
104
|
+
const loginTheme = current !== null && isNodefonyKeycloakTheme(current) ? keycloakLoginTheme(skin) : null;
|
|
105
|
+
if (loginTheme === null && skin !== DEFAULT_LOGIN_PAGE_SKIN) warnings.push(`loginPage.skin « ${skin} » non appliqué à Keycloak : le realm ` + (current === null ? "ne déclare aucun thème de connexion" : `utilise le thème « ${current} », qui n'est pas un thème Nodefony`) + ` — poser loginTheme « ${keycloakLoginTheme(skin)} » à la main si c'est voulu`);
|
|
106
|
+
return {
|
|
107
|
+
input: {
|
|
108
|
+
realm,
|
|
109
|
+
clientId: provider.clientId,
|
|
110
|
+
clientSecret: ctx.production ? null : provider.clientSecret,
|
|
111
|
+
redirectUris,
|
|
112
|
+
postLogoutRedirectUris: origins.map((o) => `${o}/*`),
|
|
113
|
+
backchannelLogoutUrl: `${backOrigin}${backchannelPath(callback.pathname)}`,
|
|
114
|
+
audiences,
|
|
115
|
+
clientRoles: sources.includes("client") ? roles : [],
|
|
116
|
+
realmRoles: sources.includes("realm") ? roles : [],
|
|
117
|
+
machine: ctx.machine,
|
|
118
|
+
...loginTheme === null ? {} : { themes: { login: loginTheme } }
|
|
119
|
+
},
|
|
120
|
+
warnings
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
export { KeycloakRealmError, deriveKeycloakRealmInput, isNodefonyKeycloakTheme, keycloakLoginTheme, realmNameFromIssuer };
|