@nodefony/security 10.0.0-beta.2 → 10.0.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +44 -10
- package/dist/nodefony/command/security-oauth-doctor.js +66 -0
- package/dist/nodefony/command/security-secrets.js +42 -25
- package/dist/nodefony/command/security-token.js +46 -15
- package/dist/nodefony/command/security-user-delete.js +5 -4
- package/dist/nodefony/command/security-user-password.js +4 -11
- package/dist/nodefony/config/config.js +80 -5
- package/dist/nodefony/contracts/index.js +1 -2
- package/dist/nodefony/errors/InsecureTransportError.js +24 -0
- package/dist/nodefony/errors/index.js +2 -1
- package/dist/nodefony/keycloak/keycloakAdmin.js +255 -0
- package/dist/nodefony/keycloak/keycloakRealm.js +260 -0
- package/dist/nodefony/keycloak/keycloakRealmInput.js +124 -0
- package/dist/nodefony/keycloak/scaffold.js +231 -0
- package/dist/nodefony/keycloak/security-keycloak-realm.js +128 -0
- package/dist/nodefony/scaffold/contribute.js +20 -0
- package/dist/nodefony/service/authFlow.js +111 -11
- package/dist/nodefony/service/firewall.js +111 -21
- package/dist/nodefony/service/keycloak.js +209 -0
- package/dist/nodefony/service/oauth2.js +379 -26
- package/dist/nodefony/service/tokenService.js +57 -6
- package/dist/nodefony/service/totp.js +51 -6
- package/dist/nodefony/service/webAuthn.js +20 -2
- package/dist/nodefony/src/SecuredArea.js +14 -3
- package/dist/nodefony/src/admin/SecurityAdminApi.js +70 -40
- package/dist/nodefony/src/admin/adminAudit.js +0 -1
- package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +66 -6
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +38 -15
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +1 -0
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +13 -3
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +9 -2
- package/dist/nodefony/src/authenticator/bearer.js +1 -2
- package/dist/nodefony/src/boot/publicRoutesNotice.js +27 -0
- package/dist/nodefony/src/entryPoint.js +39 -0
- package/dist/nodefony/src/oauth/clientCredentials.js +40 -0
- package/dist/nodefony/src/oauth/metadata.js +36 -9
- package/dist/nodefony/src/oauth/oauth2Client.js +60 -33
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +39 -6
- package/dist/nodefony/src/oauth/providerDiagnosis.js +283 -0
- package/dist/nodefony/src/oauth/providerRoles.js +79 -0
- package/dist/nodefony/src/oauth/providers/github.js +32 -6
- package/dist/nodefony/src/oauth/providers/oidc.js +82 -3
- package/dist/nodefony/src/openToSignup.js +47 -0
- package/dist/nodefony/src/sessionAuthentication.js +26 -0
- package/dist/nodefony/src/token/JwtKeystore.js +78 -46
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +40 -13
- package/dist/nodefony/src/token/secretFile.js +59 -2
- package/dist/nodefony/src/transport/CredentialTransportPolicy.js +51 -0
- package/dist/types/index.d.ts +26 -6
- package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
- package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
- package/dist/types/nodefony/command/security-token.d.ts +9 -0
- package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
- package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
- package/dist/types/nodefony/config/config.d.ts +63 -1
- package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
- package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
- package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
- package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
- package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
- package/dist/types/nodefony/errors/index.d.ts +1 -0
- package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
- package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
- package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
- package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
- package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
- package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
- package/dist/types/nodefony/service/authFlow.d.ts +35 -3
- package/dist/types/nodefony/service/firewall.d.ts +17 -3
- package/dist/types/nodefony/service/keycloak.d.ts +115 -0
- package/dist/types/nodefony/service/oauth2.d.ts +125 -9
- package/dist/types/nodefony/service/tokenService.d.ts +7 -1
- package/dist/types/nodefony/service/totp.d.ts +28 -4
- package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
- package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +54 -12
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +18 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +13 -0
- package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
- package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
- package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
- package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
- package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
- package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
- package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
- package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
- package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
- package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
- package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
- package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
- package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
- package/docs/api-keys.md +17 -17
- package/docs/audit.md +16 -16
- package/docs/authenticators.md +60 -27
- package/docs/authorization.md +16 -16
- package/docs/cors.md +29 -29
- package/docs/csrf.md +35 -33
- package/docs/external-jwt.md +66 -4
- package/docs/firewall.md +57 -24
- package/docs/headers.md +30 -30
- package/docs/index.md +12 -4
- package/docs/keycloak.md +818 -0
- package/docs/login-page.md +361 -0
- package/docs/oauth2.md +236 -122
- package/docs/obtenir-un-jeton.md +18 -11
- package/docs/tokens.md +136 -85
- package/docs/totp.md +105 -64
- package/docs/webauthn.md +90 -87
- package/docs/webhooks.md +3 -3
- package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
- package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/account/theme.properties +14 -0
- package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
- package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/admin/theme.properties +12 -0
- package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
- package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
- package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
- package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/email/theme.properties +8 -0
- package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
- package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
- package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
- package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
- package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/login/template.ftl +423 -0
- package/keycloak/themes/nodefony/login/theme.properties +17 -0
- package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
- package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
- package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
- package/package.json +17 -15
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
//#region 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
|
+
//#endregion
|
|
260
|
+
export { buildKeycloakRealm, keycloakDefaultRole, mergeKeycloakRealm, renderKeycloakRealm };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { KEYCLOAK_THEME_NAME } from "./scaffold.js";
|
|
2
|
+
import { DEFAULT_LOGIN_PAGE_SKIN } from "nodefony";
|
|
3
|
+
//#region 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
|
+
//#endregion
|
|
124
|
+
export { KeycloakRealmError, deriveKeycloakRealmInput, isNodefonyKeycloakTheme, keycloakLoginTheme, realmNameFromIssuer };
|
|
@@ -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
|
+
//#region 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
|
+
if (JSON.parse(readFileSync(manifest, "utf8")).name === PACKAGE_NAME) return dir;
|
|
58
|
+
}
|
|
59
|
+
const parent = path.dirname(dir);
|
|
60
|
+
if (parent === dir) throw new Error(`racine du paquet ${PACKAGE_NAME} introuvable`);
|
|
61
|
+
dir = parent;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Le dossier du thème Keycloak livré par le paquet.
|
|
66
|
+
*
|
|
67
|
+
* @returns chemin natif absolu (`<paquet>/keycloak/themes/nodefony`)
|
|
68
|
+
*/
|
|
69
|
+
function keycloakThemeSource() {
|
|
70
|
+
return path.join(keycloakThemesSource(), KEYCLOAK_THEME_NAME);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Le dossier de TOUS les thèmes Keycloak livrés : `nodefony` et ses thèmes
|
|
74
|
+
* enfants `nodefony-<habillage>`, un par habillage de la page `/login`.
|
|
75
|
+
* Keycloak les monte côte à côte (`/opt/keycloak/themes/`).
|
|
76
|
+
*
|
|
77
|
+
* @returns chemin natif absolu (`<paquet>/keycloak/themes`)
|
|
78
|
+
*/
|
|
79
|
+
function keycloakThemesSource() {
|
|
80
|
+
return path.join(packageRoot(), "keycloak", "themes");
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Les types que le thème livré couvre — un dossier par type. Le realm ne
|
|
84
|
+
* déclare QUE ceux-là : déclarer un type absent ferait retomber Keycloak sur
|
|
85
|
+
* son thème par défaut, avec un avertissement à chaque page.
|
|
86
|
+
*
|
|
87
|
+
* @param themeDir - dossier du thème
|
|
88
|
+
* @returns les types présents, dans l'ordre de {@link THEME_TYPES}
|
|
89
|
+
*/
|
|
90
|
+
function deliveredThemeTypes(themeDir) {
|
|
91
|
+
const present = new Set(readdirSync(themeDir));
|
|
92
|
+
return THEME_TYPES.filter((type) => present.has(type));
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Le secret du client navigateur dans le décor de développement d'une app.
|
|
96
|
+
*
|
|
97
|
+
* ⚠️ Même règle que le gabarit `.env` du cœur (`keycloak.clientSecret` dans
|
|
98
|
+
* `engine.ts`) : la frontière de paquets empêche de l'appeler, un test de ce
|
|
99
|
+
* paquet compare donc les deux sorties.
|
|
100
|
+
*
|
|
101
|
+
* @param appName - nom de l'application
|
|
102
|
+
* @returns le secret, PUBLIC par nature (écrit dans le realm versionné)
|
|
103
|
+
*/
|
|
104
|
+
function devClientSecret(appName) {
|
|
105
|
+
return `${appName}-dev-keycloak-secret`;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Le secret de l'appelant machine (`<app>-machine`) dans le décor de
|
|
109
|
+
* développement d'une app.
|
|
110
|
+
*
|
|
111
|
+
* ⚠️ Même règle que le gabarit `.env` du cœur (`keycloak.machineSecret` dans
|
|
112
|
+
* `engine.ts`), comparée par le même test que {@link devClientSecret}.
|
|
113
|
+
*
|
|
114
|
+
* @param appName - nom de l'application
|
|
115
|
+
* @returns le secret, PUBLIC par nature (écrit dans le realm versionné)
|
|
116
|
+
*/
|
|
117
|
+
function devMachineSecret(appName) {
|
|
118
|
+
return `${appName}-machine-dev-secret`;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Ce que dit la configuration rendue par le gabarit `security.ts` d'une app
|
|
122
|
+
* neuve : client `<app>`, URL de retour sur les deux ports de développement,
|
|
123
|
+
* rôle client `admin` (`roleMapping: { admin: "ROLE_ADMIN" }`), et les
|
|
124
|
+
* audiences des zones `external-jwt` que la configuration rendue déclare.
|
|
125
|
+
*
|
|
126
|
+
* @param appName - nom de l'application (= realm = identifiant de client)
|
|
127
|
+
* @param themes - types de thème à déclarer
|
|
128
|
+
* @param ids - `sub` des comptes de démonstration, FIXÉS dans le fichier :
|
|
129
|
+
* laissés à Keycloak, ils changent à chaque réimport et la connexion
|
|
130
|
+
* suivante est refusée (identifiant déjà lié à un autre `sub`)
|
|
131
|
+
* @param audiences - ressources que les jetons du realm doivent porter
|
|
132
|
+
* (mapper sur les deux clients) ; vide = aucune zone ne les accepte
|
|
133
|
+
* @returns l'entrée du constructeur de realm
|
|
134
|
+
*/
|
|
135
|
+
function appDevRealmInput(appName, themes, ids, audiences = []) {
|
|
136
|
+
const callback = "/nodefony/security/api/oauth2/keycloak/callback";
|
|
137
|
+
return {
|
|
138
|
+
realm: appName,
|
|
139
|
+
clientId: appName,
|
|
140
|
+
clientSecret: devClientSecret(appName),
|
|
141
|
+
clientName: `${appName} (dev)`,
|
|
142
|
+
clientDescription: "Client confidentiel de l'application en développement — secret PUBLIC, jamais en production.",
|
|
143
|
+
redirectUris: [`https://localhost:5152${callback}`, `http://localhost:5151${callback}`],
|
|
144
|
+
postLogoutRedirectUris: ["https://localhost:5152/*", "http://localhost:5151/*"],
|
|
145
|
+
backchannelLogoutUrl: "http://host.docker.internal:5151/nodefony/security/api/oauth2/keycloak/backchannel-logout",
|
|
146
|
+
audiences,
|
|
147
|
+
clientRoles: [{
|
|
148
|
+
name: "admin",
|
|
149
|
+
description: "Administrateur de l'application — traduit en ROLE_ADMIN par la table roleMapping du fournisseur keycloak."
|
|
150
|
+
}],
|
|
151
|
+
realmRoles: [],
|
|
152
|
+
machine: {
|
|
153
|
+
clientId: `${appName}-machine`,
|
|
154
|
+
secret: devMachineSecret(appName),
|
|
155
|
+
name: `${appName} — appelant machine (compte de service)`,
|
|
156
|
+
description: "Appelant machine : obtient un jeton d'accès par client_credentials, sans utilisateur. Secret PUBLIC, jamais en production."
|
|
157
|
+
},
|
|
158
|
+
themes: Object.fromEntries(themes.map((type) => [type, KEYCLOAK_THEME_NAME])),
|
|
159
|
+
users: [{
|
|
160
|
+
id: ids.user,
|
|
161
|
+
username: "alice",
|
|
162
|
+
email: `alice@${appName}.test`,
|
|
163
|
+
firstName: "Alice",
|
|
164
|
+
lastName: "Keycloak",
|
|
165
|
+
password: "alice-dev"
|
|
166
|
+
}, {
|
|
167
|
+
id: ids.admin,
|
|
168
|
+
username: "bob",
|
|
169
|
+
email: `bob@${appName}.test`,
|
|
170
|
+
firstName: "Bob",
|
|
171
|
+
lastName: "Keycloak",
|
|
172
|
+
password: "bob-dev",
|
|
173
|
+
clientRoles: ["admin"]
|
|
174
|
+
}]
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* L'application monte-t-elle un décor Keycloak ? Seul son compose le dit : on
|
|
179
|
+
* ne pose que ce que quelqu'un lit — une application sans profil `keycloak`
|
|
180
|
+
* (preset minimal, sécurité ajoutée plus tard) ne reçoit rien.
|
|
181
|
+
*
|
|
182
|
+
* @param context - contexte de contribution
|
|
183
|
+
* @returns `true` si le compose monte `./docker/keycloak/…`
|
|
184
|
+
*/
|
|
185
|
+
function mountsKeycloak(context) {
|
|
186
|
+
return [
|
|
187
|
+
"compose.yaml",
|
|
188
|
+
"compose.yml",
|
|
189
|
+
"docker-compose.yml"
|
|
190
|
+
].some((file) => context.read(file)?.includes("./docker/keycloak/") === true);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Les audiences que la configuration RENDUE de l'application déclare. Le
|
|
194
|
+
* gabarit `complete` ouvre la zone `partner` (`jwt` + `external-jwt`) : sans
|
|
195
|
+
* son audience dans le realm, le jeton du client machine y est refusé dès le
|
|
196
|
+
* premier essai, et `security:keycloak:realm --check` trouve le realm né en
|
|
197
|
+
* désaccord avec la configuration.
|
|
198
|
+
*
|
|
199
|
+
* @param context - contexte de contribution
|
|
200
|
+
* @returns les ressources à inscrire dans les jetons du realm
|
|
201
|
+
*/
|
|
202
|
+
function renderedAudiences(context) {
|
|
203
|
+
return context.read(APP_SECURITY_CONFIG_FILE)?.includes("partnerResource(") === true ? [APP_PARTNER_DEV_RESOURCE] : [];
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Recopie les thèmes livrés dans l'application — `nodefony` et ses thèmes
|
|
207
|
+
* d'habillage —, fichier par fichier, sans jamais remplacer un fichier présent.
|
|
208
|
+
*
|
|
209
|
+
* @param context - contexte de contribution
|
|
210
|
+
*/
|
|
211
|
+
function copyKeycloakTheme(context) {
|
|
212
|
+
context.copyTree(keycloakThemesSource(), APP_KEYCLOAK_THEMES_DIR);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Pose le realm d'import et le thème Keycloak, si l'application monte un
|
|
216
|
+
* décor Keycloak.
|
|
217
|
+
*
|
|
218
|
+
* @param context - contexte fourni par le cœur (`runAppContributions`)
|
|
219
|
+
*/
|
|
220
|
+
function contributeKeycloak(context) {
|
|
221
|
+
if (!mountsKeycloak(context)) return;
|
|
222
|
+
const themes = deliveredThemeTypes(keycloakThemeSource());
|
|
223
|
+
const realm = buildKeycloakRealm(appDevRealmInput(context.appName, themes, {
|
|
224
|
+
user: randomUUID(),
|
|
225
|
+
admin: randomUUID()
|
|
226
|
+
}, renderedAudiences(context)));
|
|
227
|
+
context.write(APP_KEYCLOAK_REALM_FILE, renderKeycloakRealm(realm));
|
|
228
|
+
copyKeycloakTheme(context);
|
|
229
|
+
}
|
|
230
|
+
//#endregion
|
|
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 };
|