@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
package/docs/keycloak.md
ADDED
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Keycloak — brancher un annuaire, du realm au premier login"
|
|
3
|
+
navTitle: Keycloak
|
|
4
|
+
lang: fr
|
|
5
|
+
module: "@nodefony/security"
|
|
6
|
+
topic: keycloak
|
|
7
|
+
coverageModule: security
|
|
8
|
+
coverageFiles: "oauth2.ts,oauthProviderRegistry,oidc.ts,providerRoles"
|
|
9
|
+
section: "Sécurité"
|
|
10
|
+
audience: [developer, devops]
|
|
11
|
+
tags:
|
|
12
|
+
[
|
|
13
|
+
security,
|
|
14
|
+
keycloak,
|
|
15
|
+
oidc,
|
|
16
|
+
oauth2,
|
|
17
|
+
realm,
|
|
18
|
+
sso,
|
|
19
|
+
pkce,
|
|
20
|
+
bff,
|
|
21
|
+
resource-server,
|
|
22
|
+
roles,
|
|
23
|
+
roleMapping,
|
|
24
|
+
]
|
|
25
|
+
version: "doc"
|
|
26
|
+
status: stable
|
|
27
|
+
updated: 2026-10-12
|
|
28
|
+
source: "src/packages/@nodefony/security/docs/keycloak.md"
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
# Keycloak — brancher un annuaire, du realm au premier login
|
|
32
|
+
|
|
33
|
+
> Ton entreprise tient ses comptes dans un **Keycloak** et tu veux que tes utilisateurs se connectent
|
|
34
|
+
> à ton application avec ces comptes, sans que l'application voie jamais un mot de passe. Côté
|
|
35
|
+
> Nodefony, il n'y a **aucun code à écrire** : le fournisseur `keycloak` est fourni, et l'adresse
|
|
36
|
+
> du realm suffit à le décrire. Le travail se passe surtout dans Keycloak : créer le realm, déclarer
|
|
37
|
+
> un client **confidentiel**, enregistrer l'URL de retour au caractère près. Cette page fait le
|
|
38
|
+
> chemin entier, puis montre comment le jeton Keycloak de la même personne ouvre aussi ton API, sur
|
|
39
|
+
> le **même compte**. Ancré sur le fournisseur `createDiscoveredOidcProvider()` (`oidc.ts#createDiscoveredOidcProvider`) et
|
|
40
|
+
> sur `OAuth2Service` (`oauth2.ts#OAuth2Service`).
|
|
41
|
+
|
|
42
|
+
📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **Keycloak**
|
|
43
|
+
|
|
44
|
+
## 🧠 Schéma général
|
|
45
|
+
|
|
46
|
+
```mermaid
|
|
47
|
+
sequenceDiagram
|
|
48
|
+
autonumber
|
|
49
|
+
actor U as Navigateur
|
|
50
|
+
participant A as Application Nodefony
|
|
51
|
+
participant K as Keycloak (realm)
|
|
52
|
+
Note over A,K: Au démarrage : A lit les métadonnées du realm (.well-known)
|
|
53
|
+
U->>A: GET …/oauth2/keycloak/authorize
|
|
54
|
+
A-->>U: 302 vers K (state + code_challenge PKCE)
|
|
55
|
+
U->>K: page de connexion du realm
|
|
56
|
+
K-->>U: 302 vers …/oauth2/keycloak/callback?code=…&iss=…
|
|
57
|
+
U->>A: GET …/callback (code)
|
|
58
|
+
A->>K: échange code + code_verifier + secret du client (serveur à serveur)
|
|
59
|
+
K-->>A: ID token + jeton d'accès
|
|
60
|
+
A-->>U: 302 + cookie de session (aucun jeton dans le navigateur)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Le navigateur ne voit passer qu'un `code` à usage unique. Le secret du client et les jetons restent
|
|
64
|
+
entre l'application et Keycloak, sur un canal TLS.
|
|
65
|
+
|
|
66
|
+
## 📖 Lexique
|
|
67
|
+
|
|
68
|
+
| Terme | Développé et rôle |
|
|
69
|
+
| ------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
70
|
+
| Keycloak | Serveur d'identité libre, auto-hébergé : il tient les comptes, affiche la page de connexion et délivre les jetons. |
|
|
71
|
+
| Realm | Espace de comptes isolé dans Keycloak : ses utilisateurs, ses clients, ses clés. Son URL est l'**émetteur**. |
|
|
72
|
+
| Client | L'application telle que Keycloak la connaît : un identifiant, un secret, des URL de retour autorisées. |
|
|
73
|
+
| Client confidentiel | Client capable de garder un secret, parce qu'il tourne sur un serveur. Keycloak l'appelle « Client authentication ON ». |
|
|
74
|
+
| OIDC | _OpenID Connect_ : la couche d'identité au-dessus d'OAuth 2.0, qui ajoute l'ID token (qui est l'utilisateur). |
|
|
75
|
+
| Émetteur (`iss`) | L'identifiant du serveur qui signe les jetons. Pour Keycloak : `https://<hôte>/realms/<realm>`. |
|
|
76
|
+
| `sub` | _Subject_ : l'identifiant stable de l'utilisateur **dans son realm**. Unique chez son émetteur, nulle part ailleurs. |
|
|
77
|
+
| PKCE | _Proof Key for Code Exchange_ (RFC 7636) : rend inutilisable un `code` volé en route. |
|
|
78
|
+
| BFF | _Backend For Frontend_ : le serveur garde les jetons et ne donne au navigateur qu'un cookie de session. |
|
|
79
|
+
| Shadow User | Le compte local que l'application crée au premier login, lié à `(keycloak, sub)`. |
|
|
80
|
+
| Audience (`aud`) | La ressource à laquelle un jeton d'accès est destiné ; ton API refuse un jeton qui ne la nomme pas. |
|
|
81
|
+
|
|
82
|
+
## Qu'est-ce que c'est ?
|
|
83
|
+
|
|
84
|
+
Keycloak est le **guichet d'accueil d'un immeuble**. Chaque société de l'immeuble (chaque
|
|
85
|
+
application) fait confiance au badge qu'il délivre, sans tenir son propre registre de visiteurs. Le
|
|
86
|
+
visiteur montre ses papiers une fois, au guichet ; les sociétés ne voient que le badge.
|
|
87
|
+
|
|
88
|
+
Pour ton application, cela bloque trois risques concrets :
|
|
89
|
+
|
|
90
|
+
- **Le mot de passe ne transite jamais par l'application** : une faille chez toi ne fait pas fuir
|
|
91
|
+
les identifiants de l'annuaire.
|
|
92
|
+
- **Un `code` intercepté ne sert à rien** : sans le `code_verifier` (PKCE) et sans le secret du
|
|
93
|
+
client, Keycloak refuse l'échange.
|
|
94
|
+
- **Un départ se gère à un seul endroit** : désactiver le compte dans le realm ferme la porte de
|
|
95
|
+
toutes les applications qui s'y fient, à leur prochaine connexion.
|
|
96
|
+
|
|
97
|
+
## La vision Nodefony
|
|
98
|
+
|
|
99
|
+
**Aucun code propre à Keycloak.** Un serveur OpenID Connect publie lui-même ses points d'entrée ;
|
|
100
|
+
le fournisseur `keycloak` les **découvre** à partir de l'émetteur, et c'est tout ce qu'il sait faire
|
|
101
|
+
(`createDiscoveredOidcProvider()`, `oidc.ts#createDiscoveredOidcProvider`). Changer de realm, c'est changer une URL.
|
|
102
|
+
|
|
103
|
+
**Une configuration fausse arrête le démarrage.** Le fournisseur `keycloak` est enregistré avec
|
|
104
|
+
`requiresIssuer: true` (`oauthProviderRegistry.ts#withKeycloakThemeHint`). Au boot, un émetteur absent, ou qui n'est
|
|
105
|
+
pas une URL `https` sans requête ni fragment, lève une erreur qui nomme la clé
|
|
106
|
+
(`checkProviderIssuer()`, `oauth2.ts#checkProviderIssuer`). Un Keycloak **éteint**, lui, ne bloque rien : le bouton
|
|
107
|
+
disparaît de l'écran de connexion et revient tout seul quand le realm répond à nouveau
|
|
108
|
+
(`#isReachable()`, `oauth2.ts#OAuth2Service.#isReachable`).
|
|
109
|
+
|
|
110
|
+
**L'identité est la paire `(keycloak, sub)`, jamais l'email.** Au premier login, l'application crée
|
|
111
|
+
un compte local lié à cette paire (`UserService.provisionOAuthUser()`, `UserService.ts#UserService.provisionOAuthUser`). Un
|
|
112
|
+
compte local qui porte déjà le même identifiant sans ce lien n'est **jamais** rattaché en silence :
|
|
113
|
+
la connexion est refusée (`UserService.ts#UserService.changePassword`).
|
|
114
|
+
|
|
115
|
+
**Keycloak authentifie, l'application autorise.** Les rôles du realm ne sont pas lus : le compte
|
|
116
|
+
reçoit `defaultRoles` à sa création, puis la base locale fait foi. Une élévation se fait dans
|
|
117
|
+
l'application, pas dans Keycloak.
|
|
118
|
+
|
|
119
|
+
## 🚀 Démarrage rapide
|
|
120
|
+
|
|
121
|
+
### Essayer en deux minutes : le Keycloak du dépôt
|
|
122
|
+
|
|
123
|
+
Le dépôt fournit un Keycloak 26.8 **déjà configuré** : un realm `nodefony`, un client confidentiel
|
|
124
|
+
`nodefony-dev`, ses deux rôles client `admin` et `admin-nodefony`, et trois utilisateurs. Tout est
|
|
125
|
+
écrit dans `docker/keycloak/import/realm-nodefony.json`, importé au premier démarrage du conteneur.
|
|
126
|
+
|
|
127
|
+
| Utilisateur | Mot de passe | Rôles Keycloak (client `nodefony-dev`) | Rôles obtenus dans l'application |
|
|
128
|
+
| ----------- | ------------ | -------------------------------------- | ------------------------------------ |
|
|
129
|
+
| `alice` | `alice-dev` | aucun | `ROLE_USER` |
|
|
130
|
+
| `bob` | `bob-dev` | `admin` | `ROLE_USER`, `ROLE_ADMIN` |
|
|
131
|
+
| `cci` | `cci-dev` | `admin`, `admin-nodefony` | + `ROLE_NODEFONY_ADMIN` (plateforme) |
|
|
132
|
+
|
|
133
|
+
`cci` doit configurer un **code à usage unique** (TOTP) à sa première connexion
|
|
134
|
+
(`"requiredActions": ["CONFIGURE_TOTP"]`) : le compte porte le rôle de plateforme, il exige un second
|
|
135
|
+
facteur, et aucune graine n'est écrite dans le fichier.
|
|
136
|
+
|
|
137
|
+
1. **Démarrer le conteneur** (en `https` sur le port 8444, avec le certificat de l'application) :
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
docker compose -f docker/docker-compose.yml --profile keycloak up -d keycloak
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
2. **Brancher l'application** : poser dans `.env` (ignoré par git) les trois lignes
|
|
144
|
+
ci-dessous — valeurs publiques, écrites dans le realm importé.
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
NF_KEYCLOAK_ISSUER=https://localhost:8444/realms/nodefony
|
|
148
|
+
NF_KEYCLOAK_CLIENT_ID=nodefony-dev
|
|
149
|
+
NF_KEYCLOAK_CLIENT_SECRET=nodefony-dev-keycloak-secret
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Ces valeurs sont **publiques** : elles n'existent que dans ce décor de développement.
|
|
153
|
+
|
|
154
|
+
3. **Démarrer l'application**, ouvrir `https://localhost:5152/nodefony/login` : le bouton
|
|
155
|
+
**Keycloak** est là. Se connecter avec `alice` / `alice-dev`.
|
|
156
|
+
|
|
157
|
+
La console d'administration du realm est sur `https://localhost:8444/admin` (`admin` /
|
|
158
|
+
`nodefony-dev`). Les retouches qu'on y fait survivent aux redémarrages ;
|
|
159
|
+
`docker compose … down -v` repart du fichier.
|
|
160
|
+
|
|
161
|
+
### Essayer dans une application générée
|
|
162
|
+
|
|
163
|
+
Une application créée par `nodefony create app` avec le préréglage `complete` porte déjà le
|
|
164
|
+
même décor, **à son nom** : un profil `keycloak` dans son `compose.yaml`, les variables
|
|
165
|
+
`NF_KEYCLOAK_*` déclarées dans `env.ts` et le fournisseur conditionnel dans
|
|
166
|
+
`nodefony/config/security.ts`. Tant qu'on ne l'allume pas, l'application démarre sans bouton ni
|
|
167
|
+
avertissement.
|
|
168
|
+
|
|
169
|
+
Le realm `docker/keycloak/import/realm.json` (realm et client nommés comme l'application,
|
|
170
|
+
utilisateur `alice`) et le thème `docker/keycloak/themes/nodefony/` ne viennent PAS du
|
|
171
|
+
générateur : ils sont **livrés par ce paquet** (`nodefony/keycloak/scaffold.ts`, appelé par le point
|
|
172
|
+
de contribution `nodefony/scaffold/contribute.ts` déclaré dans
|
|
173
|
+
`package.json` sous `nodefony.contribute`), et posés après l'installation. Ce sont de vrais
|
|
174
|
+
fichiers, versionnés avec l'application : le thème se retouche à ses couleurs. Les fichiers du thème
|
|
175
|
+
sont des **copies suivies** (empreintes dans `scaffold.lock.json`) ; le realm est une **semence**,
|
|
176
|
+
qui appartient à l'application dès qu'il existe (`security:keycloak:realm --write` le fait évoluer).
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
npx nodefony scaffold:sync # pose ce qui manque (création en --no-install)
|
|
180
|
+
npx nodefony scaffold:sync --update # après un npm update : rafraîchit le thème resté intact
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Un fichier du thème retouché par l'application n'est jamais remplacé : il est signalé, et pour
|
|
184
|
+
reprendre la version du paquet on le supprime avant de relancer.
|
|
185
|
+
|
|
186
|
+
Rien ne prévient au démarrage qu'un `npm update` a laissé le thème en arrière : la page de
|
|
187
|
+
connexion de Keycloak garde simplement l'ancienne apparence. C'est `nodefony doctor --live` qui
|
|
188
|
+
le dit, famille « Fichiers livrés par les paquets » — une copie intacte en retard, ou un fichier
|
|
189
|
+
livré absent (un habillage ajouté par la nouvelle version), avec le geste
|
|
190
|
+
`nodefony scaffold:sync --update`. Une copie retouchée n'y est jamais signalée. Le diagnostic lit
|
|
191
|
+
le même plan que la console d'administration (`GET /nodefony/kernel/api/scaffold`), donc en
|
|
192
|
+
développement seulement ; ailleurs la famille est rendue « non contrôlée », avec sa raison.
|
|
193
|
+
|
|
194
|
+
Le thème couvre les **cinq types** de Keycloak, chacun hérité du thème de Keycloak qu'il habille
|
|
195
|
+
(seuls le logo, les couleurs et quelques textes sont posés — les pages restent celles de
|
|
196
|
+
Keycloak, donc justes à chaque mise à jour) :
|
|
197
|
+
|
|
198
|
+
| Type | Ce qu'il habille | Hérite de | Choisi par |
|
|
199
|
+
| --------- | -------------------------------------------------- | ------------- | ---------------------------------------------------- |
|
|
200
|
+
| `login` | connexion, mot de passe oublié, erreurs | `keycloak.v2` | le realm (`loginTheme`) |
|
|
201
|
+
| `account` | console du compte de l'utilisateur | `keycloak.v3` | le realm (`accountTheme`) |
|
|
202
|
+
| `admin` | console d'administration | `keycloak.v2` | le realm (`adminTheme`) |
|
|
203
|
+
| `email` | enveloppe HTML de tous les courriels | `keycloak` | le realm (`emailTheme`) |
|
|
204
|
+
| `welcome` | page d'accueil du serveur (avant le premier admin) | `keycloak` | le SERVEUR : `KC_SPI_THEME__WELCOME_THEME: nodefony` |
|
|
205
|
+
|
|
206
|
+
**L'habillage de la page `/login` suit jusqu'à Keycloak.** Chaque habillage de
|
|
207
|
+
`loginPage.skin` a son thème de connexion Keycloak : `nodefony` pour `frontispiece` (le défaut),
|
|
208
|
+
`nodefony-<habillage>` pour les huit autres (`nodefony-blueprint`, `nodefony-horizon`…). Ce sont
|
|
209
|
+
des thèmes ENFANTS de `nodefony` : même gabarit, mêmes textes, plus la feuille de l'habillage (copie
|
|
210
|
+
conforme de celle du framework) et sa mise en page. Toutes les pages du parcours en héritent —
|
|
211
|
+
connexion, code à usage unique, configuration du second facteur, mot de passe oublié. Le compose
|
|
212
|
+
monte le dossier `docker/keycloak/themes/` ENTIER, pour que Keycloak les voie tous ; la console les
|
|
213
|
+
propose aussi dans _Realm settings › Themes_. Limite : seule l'enveloppe de la page est à Nodefony,
|
|
214
|
+
les formulaires restent ceux de Keycloak — `ledger` (colonnes et étapes numérotées) et `horizon`
|
|
215
|
+
(bandeau photo dans la carte) n'y portent que leurs couleurs et leur mise en page.
|
|
216
|
+
|
|
217
|
+
Le compose pose aussi `KC_SPI_THEME__DEFAULT: nodefony` : le realm `master`, que l'import ne touche
|
|
218
|
+
pas, prend le même thème — sa connexion et sa console d'administration comprises. Clair et sombre
|
|
219
|
+
suivent le réglage du système (`prefers-color-scheme`) ; le réglage _Dark mode_ du realm le coupe.
|
|
220
|
+
Le realm `master` n'est **pas importé** (Keycloak le crée au premier démarrage, l'import ne le touche
|
|
221
|
+
pas) : il naît sans internationalisation, donc en anglais et sans sélecteur de langue. Le régler une
|
|
222
|
+
fois dans sa console — _Realm settings › Localization_ : internationalisation activée, `fr` et `en`,
|
|
223
|
+
`fr` par défaut. Le réglage vit dans le volume `keycloak-data` : il survit aux redémarrages, pas à
|
|
224
|
+
un `down -v`. Sur sa page de connexion, le bouton « Retour à … » n'apparaît pas : il annule la
|
|
225
|
+
connexion, et les consoles de Keycloak lui-même ne savent pas traiter cette annulation.
|
|
226
|
+
|
|
227
|
+
Les comptes du realm généré reçoivent le rôle `default-roles-<realm>` : un compte importé n'a QUE
|
|
228
|
+
les rôles qu'on lui liste, et sans celui-là la console du compte lui refuse son propre profil
|
|
229
|
+
(`401`).
|
|
230
|
+
|
|
231
|
+
Le realm généré ne déclare **aucun serveur d'envoi** de courriels : « mot de passe oublié » reste
|
|
232
|
+
coupé tant que l'application n'en branche pas un (console : _Realm settings › Email_). Le dépôt,
|
|
233
|
+
lui, en a un de test — Mailpit (`--profile keycloak`, interface `http://localhost:8025`).
|
|
234
|
+
|
|
235
|
+
1. **Fabriquer le certificat de développement** (une fois — Keycloak le sert en `https`) :
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
npx nodefony http:certificates
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
2. **Démarrer le conteneur** :
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
docker compose --profile keycloak up -d keycloak
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Sous Linux natif, poser d'abord `export KEYCLOAK_UID=$(id -u)` : sinon la clé privée montée
|
|
248
|
+
est illisible par le conteneur.
|
|
249
|
+
|
|
250
|
+
3. **Brancher l'application** : rien à faire. Le `.env` généré porte déjà les lignes
|
|
251
|
+
`NF_KEYCLOAK_*`, actives et alignées sur le realm généré. Keycloak éteint, l'application démarre
|
|
252
|
+
quand même : un avertissement, pas de bouton, et le bouton revient tout seul dès que Keycloak
|
|
253
|
+
répond. Pour t'en passer, commente ces lignes.
|
|
254
|
+
|
|
255
|
+
4. **Démarrer l'application**, puis ouvrir `https://localhost:5152/nodefony/login` et se
|
|
256
|
+
connecter avec `alice` / `alice-dev` :
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
npm run dev
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Rien d'autre à poser, sous Windows comme ailleurs. Le Keycloak du décor sert le certificat de
|
|
263
|
+
développement de l'application, et l'application **reconnaît d'office l'autorité qui l'a
|
|
264
|
+
signé** : en développement, le service de certificats l'ajoute aux autorités de confiance du
|
|
265
|
+
processus dès le démarrage, commandes en ligne comprises (`addDefaultCaCertificate()`,
|
|
266
|
+
`certificates.ts#addDefaultCaCertificate`). La variable `NODE_EXTRA_CA_CERTS` n'est plus nécessaire.
|
|
267
|
+
|
|
268
|
+
Le realm généré porte aussi, comme celui du dépôt, un client **machine** `<app>-machine` (compte de
|
|
269
|
+
service, `client_credentials`). Ses jetons portent l'audience de la zone `partner` que le gabarit
|
|
270
|
+
`complete` ouvre (`https://localhost:5152/api/partner`) : `npx nodefony app:partner:call --issuer
|
|
271
|
+
keycloak` obtient un jeton et appelle `GET /api/partner/whoami` dès le premier démarrage. Quand tu
|
|
272
|
+
ouvres une autre zone (section « Le jeton Keycloak sur ton API », plus bas),
|
|
273
|
+
`npx nodefony security:keycloak:realm --write` ajoute le mapper qui inscrit sa `resource` dans les
|
|
274
|
+
jetons.
|
|
275
|
+
|
|
276
|
+
Le port de Keycloak (8444) se change par `KEYCLOAK_PORT` au `up` — l'émetteur suit, donc
|
|
277
|
+
`NF_KEYCLOAK_ISSUER` aussi. En production, le décor ne sert plus : les trois variables pointent
|
|
278
|
+
ton Keycloak, et `NF_OAUTH_REDIRECT_BASE` porte l'URL publique de l'application, base de l'URL de
|
|
279
|
+
retour.
|
|
280
|
+
|
|
281
|
+
### Le realm écrit depuis ta configuration
|
|
282
|
+
|
|
283
|
+
Les sections 1 et 2 ci-dessous déroulent les réglages à la main. Une fois le fournisseur branché
|
|
284
|
+
(section 4), Nodefony sait les écrire lui-même : **`nodefony security:keycloak:realm`** dérive le
|
|
285
|
+
realm de la configuration EFFECTIVE de l'application — client, URL de retour, déconnexion par canal
|
|
286
|
+
arrière, audience de chaque zone, rôles de `roleMapping`.
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
npx nodefony security:keycloak:realm # affiche le realm
|
|
290
|
+
npx nodefony security:keycloak:realm --write # l'écrit dans docker/keycloak/import/
|
|
291
|
+
npx nodefony security:keycloak:realm --check # sort en 1 s'il ne suit plus la config (CI)
|
|
292
|
+
npx nodefony security:keycloak:realm --apply # pose le thème de connexion sur le realm VIVANT (dev)
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
- **Il fusionne, il n'écrase pas.** Ce que la configuration dit est réécrit ; ce qu'un humain a
|
|
296
|
+
ajouté survit : comptes, titres, secret du client machine, rôles déjà déclarés, mappers qui ne sont
|
|
297
|
+
pas d'audience, autres clients (`mergeKeycloakRealm`, `nodefony/keycloak/keycloakRealm.ts`).
|
|
298
|
+
- **Hors développement, aucun secret n'est écrit** et seules les adresses de `redirectUri` sont
|
|
299
|
+
déclarées : Keycloak génère le secret, tu le copies dans ton gestionnaire.
|
|
300
|
+
- **Le thème de connexion suit `loginPage.skin`** (`loginTheme`, `keycloakLoginTheme`,
|
|
301
|
+
`nodefony/keycloak/keycloakRealmInput.ts`) — mais seulement sur un realm déjà habillé par
|
|
302
|
+
Nodefony : un thème propre à l'application n'est jamais remplacé, et l'habillage non appliqué
|
|
303
|
+
s'annonce en avertissement.
|
|
304
|
+
- **Keycloak n'importe un realm qu'à sa création.** Le thème d'un realm déjà là se pose par
|
|
305
|
+
`--apply` (ci-dessous), ou à la main dans **Realm settings** › **Themes** › _Login theme_. Le
|
|
306
|
+
reste d'un realm déjà là se met à jour par
|
|
307
|
+
**Realm settings** › **Action** › **Partial import**, clients en « Overwrite » : comptes et seconds
|
|
308
|
+
facteurs restent.
|
|
309
|
+
|
|
310
|
+
### Le thème suit l'habillage de `/login` — `--apply`, et le service `keycloak`
|
|
311
|
+
|
|
312
|
+
Changer `loginPage.skin` ne change pas la page de Keycloak : le thème vit dans SA base (réglage
|
|
313
|
+
`loginTheme` du realm). `--apply` le pose par l'**API d'administration** de Keycloak, qui exige le
|
|
314
|
+
compte administrateur du SERVEUR (realm `master`) — celui du compose de développement :
|
|
315
|
+
|
|
316
|
+
```typescript ignore
|
|
317
|
+
// nodefony/config/security.ts — le compte ne passe qu'en développement
|
|
318
|
+
keycloak: {
|
|
319
|
+
issuer: ctx.env.NF_KEYCLOAK_ISSUER,
|
|
320
|
+
// …
|
|
321
|
+
...(ctx.isDev
|
|
322
|
+
? { admin: { username: ctx.env.NF_KEYCLOAK_ADMIN_USERNAME, password: ctx.env.NF_KEYCLOAK_ADMIN_PASSWORD } }
|
|
323
|
+
: {}),
|
|
324
|
+
},
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
- **Développement seulement** : refusé dans tout autre environnement, même compte posé ; le mot de
|
|
328
|
+
passe ne part qu'en https et n'est jamais journalisé.
|
|
329
|
+
- **Un thème de l'application n'est jamais remplacé** : seul un realm déjà sur `nodefony` ou
|
|
330
|
+
`nodefony-*` change d'habillage.
|
|
331
|
+
- **Toutes les actions Keycloak passent par le service `keycloak`** (`KeycloakService`) : realm
|
|
332
|
+
dérivé (`realmPlan`, `writeRealm`), client d'administration (`admin()`, jeton gardé jusqu'à son
|
|
333
|
+
expiration), thème (`applyLoginTheme`). La commande n'en garde que l'affichage ; une route de
|
|
334
|
+
développement l'appelle aussi — c'est ce que fait l'application de ce dépôt pour que le sélecteur
|
|
335
|
+
d'aperçu de `/login` (`loginPage.previewHook`) entraîne Keycloak.
|
|
336
|
+
- **L'écart se voit sans rien appliquer** : `nodefony doctor --live` et `security:oauth:doctor`
|
|
337
|
+
lisent le thème sur la page publique de Keycloak (aucun identifiant) et signalent
|
|
338
|
+
`login-theme-mismatch` quand il ne suit pas `loginPage.skin`.
|
|
339
|
+
|
|
340
|
+
Le client machine suivi est celui qu'on nomme (`--machine <clientId>`), sinon le compte de service
|
|
341
|
+
que le fichier déclare déjà. Le canal arrière vise `http://host.docker.internal:<port>` en
|
|
342
|
+
développement (Keycloak vit dans un conteneur) ; `--backchannel-origin` le déplace.
|
|
343
|
+
|
|
344
|
+
### 1. Créer le realm
|
|
345
|
+
|
|
346
|
+
Dans la console d'administration de **ton** Keycloak :
|
|
347
|
+
|
|
348
|
+
1. **Create realm** (à côté de _Current realm_), donner un nom — par exemple `mon-entreprise`.
|
|
349
|
+
2. Le realm créé devient le realm courant. Ses utilisateurs s'y créent sous **Users**, ou s'y
|
|
350
|
+
importent depuis un annuaire LDAP (**User federation**).
|
|
351
|
+
|
|
352
|
+
Ne pas travailler dans le realm `master` : il administre Keycloak lui-même.
|
|
353
|
+
|
|
354
|
+
### 2. Déclarer le client confidentiel
|
|
355
|
+
|
|
356
|
+
**Clients** › **Create client**, type **OpenID Connect**. Les réglages qui comptent :
|
|
357
|
+
|
|
358
|
+
| Réglage Keycloak | Valeur | Pourquoi |
|
|
359
|
+
| --------------------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
|
360
|
+
| **Client ID** | `mon-app` | Devient `NF_KEYCLOAK_CLIENT_ID`. |
|
|
361
|
+
| **Client authentication** | **ON** | Client **confidentiel** : l'application tourne sur un serveur, elle garde un secret. Nodefony en exige un. |
|
|
362
|
+
| **Standard flow** | coché | C'est le flux _Authorization Code_, le seul que Nodefony emploie. |
|
|
363
|
+
| **Direct access grants** | décoché | Ce flux fait transiter le mot de passe par l'application — exactement ce qu'on évite. |
|
|
364
|
+
| **Implicit flow** | décoché | Retiré par OAuth 2.1 : il livre le jeton dans l'URL. |
|
|
365
|
+
| **PKCE method** | `S256` | Keycloak **exige** alors PKCE pour ce client ; Nodefony l'envoie toujours. |
|
|
366
|
+
| **Valid redirect URIs** | `https://app.example.com/nodefony/security/api/oauth2/keycloak/callback` | Comparée **au caractère près** (RFC 9700). Pas de joker en production. |
|
|
367
|
+
| **Valid post logout redirect URIs** | `https://app.example.com/*` | Retour du navigateur après la déconnexion initiée par l'application. |
|
|
368
|
+
| **Front channel logout** | **OFF** | Il exige une page que l'application n'a pas ; activé, Keycloak n'appelle PAS le canal arrière. |
|
|
369
|
+
| **Backchannel logout URL** | `https://app.example.com/nodefony/security/api/oauth2/keycloak/backchannel-logout` | Keycloak y prévient l'application quand la session SSO se ferme ailleurs. Joignable **par Keycloak**. |
|
|
370
|
+
| **Backchannel logout session required** | **ON** | Le jeton porte le `sid` : seule LA session concernée est fermée, pas toutes celles du compte. |
|
|
371
|
+
|
|
372
|
+
Puis l'onglet **Credentials** : copier le **Client secret**. C'est `NF_KEYCLOAK_CLIENT_SECRET`.
|
|
373
|
+
|
|
374
|
+
> [!WARNING]
|
|
375
|
+
> L'URL de retour enregistrée chez Keycloak et celle que l'application envoie doivent être
|
|
376
|
+
> **identiques** : schéma, hôte, port, chemin. `localhost` et `127.0.0.1` sont deux hôtes
|
|
377
|
+
> différents. Une différence d'un caractère donne `Invalid parameter: redirect_uri` sur la page
|
|
378
|
+
> Keycloak, avant même le formulaire de connexion.
|
|
379
|
+
|
|
380
|
+
### 3. Lire l'émetteur
|
|
381
|
+
|
|
382
|
+
**Realm settings** › onglet **General** › lien **OpenID Endpoint Configuration**. Le document qui
|
|
383
|
+
s'ouvre commence par la valeur à recopier :
|
|
384
|
+
|
|
385
|
+
```json
|
|
386
|
+
{ "issuer": "https://sso.example.com/realms/mon-entreprise", "…": "…" }
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
C'est `NF_KEYCLOAK_ISSUER`, sans barre finale ni `/.well-known/…`. Elle doit être en `https`.
|
|
390
|
+
|
|
391
|
+
Keycloak calcule cette valeur à partir de son nom d'hôte. Derrière un proxy, ou quand le navigateur
|
|
392
|
+
et l'application ne joignent pas Keycloak par la même adresse, fixer `KC_HOSTNAME` à l'URL publique
|
|
393
|
+
complète : sinon l'émetteur change selon qui demande, et l'application refuse les jetons.
|
|
394
|
+
|
|
395
|
+
### 4. Brancher l'application
|
|
396
|
+
|
|
397
|
+
Les trois valeurs entrent par `env.ts`, seul lecteur des variables d'environnement. Le fournisseur
|
|
398
|
+
n'est déclaré que si les trois sont présentes : pas de bouton mort quand une variable manque.
|
|
399
|
+
|
|
400
|
+
```ts ignore
|
|
401
|
+
// nodefony/config/security.ts — le fragment du module, importé par le manifeste
|
|
402
|
+
import type { ConfigContext } from "nodefony";
|
|
403
|
+
import type { ISecurityConfigInput } from "@nodefony/security";
|
|
404
|
+
import type { env } from "../../env";
|
|
405
|
+
|
|
406
|
+
export const securityConfig = (ctx: ConfigContext<typeof env>) =>
|
|
407
|
+
({
|
|
408
|
+
oauth2: {
|
|
409
|
+
// Rôles posés à la CRÉATION du compte local ; ceux du realm ne sont pas lus.
|
|
410
|
+
defaultRoles: ["ROLE_USER"],
|
|
411
|
+
providers: {
|
|
412
|
+
...(ctx.env.NF_KEYCLOAK_ISSUER &&
|
|
413
|
+
ctx.env.NF_KEYCLOAK_CLIENT_ID &&
|
|
414
|
+
ctx.env.NF_KEYCLOAK_CLIENT_SECRET
|
|
415
|
+
? {
|
|
416
|
+
keycloak: {
|
|
417
|
+
issuer: ctx.env.NF_KEYCLOAK_ISSUER,
|
|
418
|
+
clientId: ctx.env.NF_KEYCLOAK_CLIENT_ID,
|
|
419
|
+
clientSecret: ctx.env.NF_KEYCLOAK_CLIENT_SECRET,
|
|
420
|
+
redirectUri: `${ctx.env.NF_OAUTH_REDIRECT_BASE}/nodefony/security/api/oauth2/keycloak/callback`,
|
|
421
|
+
// Libellé du bouton ; omis, il vaut « Keycloak ».
|
|
422
|
+
label: "Compte entreprise",
|
|
423
|
+
},
|
|
424
|
+
}
|
|
425
|
+
: {}),
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
}) satisfies ISecurityConfigInput;
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
```typescript ignore
|
|
432
|
+
// env.ts — SEUL lecteur de process.env (catalogue typé, validé au boot).
|
|
433
|
+
// nodefony.config.ts — `ctx.env` EST ce catalogue (typé par le paramètre générique).
|
|
434
|
+
import { defineConfig, defineEnv, envString, use } from "nodefony";
|
|
435
|
+
import { securityConfig } from "./nodefony/config/security";
|
|
436
|
+
|
|
437
|
+
export const env = defineEnv({
|
|
438
|
+
// Émetteur = URL du realm, en https (la découverte et le contrôle de `iss` en partent).
|
|
439
|
+
NF_KEYCLOAK_ISSUER: envString({ optional: true }),
|
|
440
|
+
NF_KEYCLOAK_CLIENT_ID: envString({ optional: true }),
|
|
441
|
+
NF_KEYCLOAK_CLIENT_SECRET: envString({ optional: true }),
|
|
442
|
+
// Base des URL de retour : celle que Keycloak a enregistrée, au caractère près.
|
|
443
|
+
NF_OAUTH_REDIRECT_BASE: envString({ default: "https://localhost:5152" }),
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
export default defineConfig<typeof env>((ctx) => ({
|
|
447
|
+
modules: [
|
|
448
|
+
"@nodefony/http",
|
|
449
|
+
"@nodefony/framework",
|
|
450
|
+
use("@nodefony/security", securityConfig(ctx)),
|
|
451
|
+
],
|
|
452
|
+
}));
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
L'application doit faire **confiance au certificat** de Keycloak, puisqu'elle l'appelle en `https`.
|
|
456
|
+
C'est comme un badge présenté à l'accueil : l'application ne laisse entrer que les badges signés
|
|
457
|
+
par une autorité qu'elle connaît. Trois cas :
|
|
458
|
+
|
|
459
|
+
| Le certificat de Keycloak est signé par… | Ce qu'il faut faire |
|
|
460
|
+
| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
|
|
461
|
+
| une autorité publique (Let's Encrypt…) | rien |
|
|
462
|
+
| l'autorité de **développement** de Nodefony (le Keycloak du décor) | rien : reconnue d'office en développement |
|
|
463
|
+
| la même, mais l'application tourne en **production** sur le poste | la déclarer : `NF_TLS_CA_FILE=nodefony/config/certificates/ca/nodefony-root-ca.crt.pem` |
|
|
464
|
+
| l'autorité de **ton entreprise** (Keycloak interne) | la déclarer : `NF_TLS_CA_FILE=certs/autorite-entreprise.pem` dans `.env`, ou dans le Secret du pod |
|
|
465
|
+
|
|
466
|
+
`NF_TLS_CA_FILE` alimente la clé `certificates.extraCaFiles` du module `@nodefony/http` : un ou
|
|
467
|
+
plusieurs fichiers PEM, absolus ou relatifs à la racine de l'application, une chaîne entière
|
|
468
|
+
(racine + intermédiaires) pouvant tenir dans un seul fichier. Ils sont ajoutés aux autorités du
|
|
469
|
+
processus au démarrage, avant tout service (`trustCaFile()`, `certificates.ts#trustCaFile`), et servent à
|
|
470
|
+
**tous** ses appels sortants : découverte, point de jeton, jeu de clés. Un fichier introuvable, ou
|
|
471
|
+
sans certificat, **interrompt le démarrage** en nommant le chemin (code 78) — plutôt qu'un
|
|
472
|
+
`fetch failed` au premier clic d'un utilisateur. Seule exception : l'autorité de développement pas
|
|
473
|
+
encore engendrée est ignorée en mode `development` (le journal le dit) ; en production, elle doit
|
|
474
|
+
exister — `npx nodefony http:certificates` l'engendre. C'est l'équivalent, porté par la configuration, de
|
|
475
|
+
`NODE_EXTRA_CA_CERTS` : même effet, sans syntaxe de shell à traduire sous Windows.
|
|
476
|
+
|
|
477
|
+
Pourquoi la deuxième ligne du tableau : l'autorité de développement n'est reconnue d'office qu'en
|
|
478
|
+
mode `development` (`Certificate.trustDevelopmentAuthority()`, `certificates.ts#Certificate.trustDevelopmentAuthority`). Lancée
|
|
479
|
+
en production — pour essayer le build sur le poste, par exemple —, l'application ne lui fait plus
|
|
480
|
+
confiance sans qu'on le lui dise, et c'est voulu : une application de production qui adopterait en
|
|
481
|
+
silence une autorité de développement ouvrirait une porte qu'aucune configuration ne montre. Le
|
|
482
|
+
refus nomme la variable à poser (`certificat refusé (SELF_SIGNED_CERT_IN_CHAIN)` →
|
|
483
|
+
`NF_TLS_CA_FILE=…`), et le `.env` généré porte déjà la ligne exacte, en commentaire.
|
|
484
|
+
|
|
485
|
+
### 5. Le premier login — ce qu'on observe
|
|
486
|
+
|
|
487
|
+
```bash
|
|
488
|
+
# Le bouton est offert à l'écran de connexion :
|
|
489
|
+
curl -s https://localhost:5152/nodefony/security/api/oauth2/providers
|
|
490
|
+
# {"providers":[{"name":"keycloak","label":"Compte entreprise"}]}
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
Au clic, l'utilisateur passe par la page de connexion du realm, puis revient connecté. Côté
|
|
494
|
+
application :
|
|
495
|
+
|
|
496
|
+
- un **compte local** est créé, identifiant = l'email du realm (ou `keycloak:<sub>` sans email),
|
|
497
|
+
rôles = `defaultRoles`, sans mot de passe local ;
|
|
498
|
+
- ce compte porte le lien `(keycloak, sub)` : les connexions suivantes le retrouvent par ce lien,
|
|
499
|
+
même si l'email change dans le realm ;
|
|
500
|
+
- la session est une **session BFF** ordinaire, la même qu'après un mot de passe.
|
|
501
|
+
|
|
502
|
+
Les écrans **Utilisateurs** et **Sessions** de Studio montrent le compte et sa session.
|
|
503
|
+
|
|
504
|
+
## ⚙️ Configuration
|
|
505
|
+
|
|
506
|
+
Les clés de `security.oauth2.providers.keycloak` — le schéma complet vit sur la page
|
|
507
|
+
[OAuth2](oauth2.md).
|
|
508
|
+
|
|
509
|
+
| Clé | Requise | Effet |
|
|
510
|
+
| -------------------- | :-----: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
511
|
+
| `issuer` | ✅ | URL `https` du realm. Absente ou mal formée : le démarrage est refusé. |
|
|
512
|
+
| `clientId` | ✅ | **Client ID** du client Keycloak. |
|
|
513
|
+
| `clientSecret` | ✅ | **Client secret** de l'onglet _Credentials_. Jamais journalisé. |
|
|
514
|
+
| `redirectUri` | ✅ | URL de retour, identique à l'une des **Valid redirect URIs** du client. |
|
|
515
|
+
| `discoveryUrl` | | Adresse COMPLÈTE où lire le document de découverte, quand l'application ne joint pas l'émetteur (service interne d'un cluster). Voir [En cluster](#-en-cluster--joindre-keycloak-par-son-adresse-interne). |
|
|
516
|
+
| `clientAuthMethod` | | `client_secret_basic` par défaut ; `client_secret_post` si le client Keycloak l'exige. |
|
|
517
|
+
| `scopes` | | Vide = `openid`, `profile`, `email`. |
|
|
518
|
+
| `defaultRoles` | | Rôles du compte à sa création, pour CE fournisseur (sinon la valeur globale `oauth2.defaultRoles`). |
|
|
519
|
+
| `roleMapping` | | Rôles Keycloak → rôles de l'application, recalculés à chaque connexion et à chaque jeton. Voir [Rôles](#-rôles--gérer-les-droits-dans-keycloak). |
|
|
520
|
+
| `rolesSource` | | Où lire les rôles : `client` (défaut), `realm`, `groups`. Sans `roleMapping`, sans effet. |
|
|
521
|
+
| `allowPlatformRoles` | | `true` autorise `roleMapping` à donner un `ROLE_NODEFONY_*`. Omis : démarrage refusé. |
|
|
522
|
+
| `audiences` | | Ressources pour lesquelles ce realm émet des jetons à l'application. Lue par `security:keycloak:realm` seulement. Omis : toutes les zones `external-jwt`. |
|
|
523
|
+
| `label` | | Libellé du bouton. Omis : « Keycloak ». |
|
|
524
|
+
| `hidden` | | Retire le bouton sans fermer le flux (lien direct, sous-domaine dédié). |
|
|
525
|
+
|
|
526
|
+
## 🎭 Rôles — gérer les droits dans Keycloak
|
|
527
|
+
|
|
528
|
+
**L'idée.** Sans réglage, Keycloak dit seulement à l'application **qui** se connecte. Les droits
|
|
529
|
+
(`ROLE_ADMIN`…) sont posés à la création du compte, puis gérés dans l'application, à la main.
|
|
530
|
+
Une entreprise qui a Keycloak veut souvent l'inverse : promouvoir quelqu'un **une fois**, dans
|
|
531
|
+
Keycloak, et que l'application suive. C'est le rôle de la table `roleMapping` — un **dictionnaire
|
|
532
|
+
de traduction** : « quand Keycloak dit `admin`, chez moi ça s'appelle `ROLE_ADMIN` ».
|
|
533
|
+
|
|
534
|
+
**Côté Keycloak**, deux gestes dans la console du realm :
|
|
535
|
+
|
|
536
|
+
1. **Clients** › ton client › **Roles** › _Create role_ : `admin`. Le rôle appartient au client,
|
|
537
|
+
c'est-à-dire à TON application — pas à tout le realm.
|
|
538
|
+
2. **Users** › l'utilisateur › **Role mapping** › _Assign role_ › filtre _client roles_ › `admin`.
|
|
539
|
+
|
|
540
|
+
Dans un realm importé, c'est le bloc `roles.client` et la clé `clientRoles` de l'utilisateur :
|
|
541
|
+
|
|
542
|
+
```json
|
|
543
|
+
"roles": { "client": { "mon-app": [{ "name": "admin" }] } },
|
|
544
|
+
"users": [{ "username": "bob", "clientRoles": { "mon-app": ["admin"] } }]
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
**Côté Nodefony**, la table sur le fournisseur :
|
|
548
|
+
|
|
549
|
+
```typescript ignore
|
|
550
|
+
keycloak: {
|
|
551
|
+
issuer: ctx.env.NF_KEYCLOAK_ISSUER,
|
|
552
|
+
clientId: ctx.env.NF_KEYCLOAK_CLIENT_ID,
|
|
553
|
+
clientSecret: ctx.env.NF_KEYCLOAK_CLIENT_SECRET,
|
|
554
|
+
redirectUri: "https://app.example.com/nodefony/security/api/oauth2/keycloak/callback",
|
|
555
|
+
roleMapping: { admin: "ROLE_ADMIN" },
|
|
556
|
+
},
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
**Ce qui se passe à la connexion de bob** :
|
|
560
|
+
|
|
561
|
+
1. Keycloak rend un jeton d'accès qui porte `"resource_access": { "mon-app": { "roles": ["admin"] } }`
|
|
562
|
+
— c'est là, et seulement là par défaut, que Keycloak met les rôles (ni dans l'ID token, ni
|
|
563
|
+
dans _userinfo_).
|
|
564
|
+
2. `mapProviderRoles()` (`providerRoles.ts#mapProviderRoles`) traduit `admin` → `ROLE_ADMIN` par la table.
|
|
565
|
+
3. Le compte de bob reçoit `ROLE_ADMIN` (`reconcileProviderRoles()`, `user/nodefony/src/providerRoles.ts#reconcileProviderRoles` côté
|
|
566
|
+
`@nodefony/user`).
|
|
567
|
+
|
|
568
|
+
**Les règles, à connaître avant de l'activer :**
|
|
569
|
+
|
|
570
|
+
- **Recalcul à chaque connexion ET à chaque jeton d'API.** Un rôle retiré dans Keycloak disparaît
|
|
571
|
+
au login suivant, et dès le jeton suivant sur l'API (`syncOAuthRoles()`, `UserService.ts#UserService.syncOAuthRoles`).
|
|
572
|
+
Une session déjà ouverte garde ses droits jusqu'à la reconnexion.
|
|
573
|
+
- **Un rôle Keycloak absent de la table est ignoré**, jamais recopié tel quel : l'annuaire ne
|
|
574
|
+
fabrique pas de droit que l'application n'a pas déclaré.
|
|
575
|
+
- **Les rôles donnés à la main survivent.** L'application retient, dans les métadonnées du compte
|
|
576
|
+
(`metadata.providerRoles.keycloak`), ce que Keycloak a accordé : elle ne retire que cela. Seule
|
|
577
|
+
ambiguïté : un rôle à la fois donné à la main ET accordé par Keycloak devient « géré » — le
|
|
578
|
+
retirer dans Keycloak le retire aussi.
|
|
579
|
+
- **Aucune écriture en base si rien ne change** : le recalcul tombe sur chaque requête porteuse
|
|
580
|
+
de jeton, il ne coûte une écriture que lorsqu'un rôle bouge.
|
|
581
|
+
- **Rôles du realm ou groupes** : `rolesSource: ["realm"]` lit `realm_access.roles`,
|
|
582
|
+
`["groups"]` le claim `groups` (à poser chez Keycloak par un mapper _Group Membership_).
|
|
583
|
+
Plusieurs sources se cumulent.
|
|
584
|
+
- **Rôles de plateforme (`ROLE_NODEFONY_*`) refusés par défaut.** Ils ouvrent la console
|
|
585
|
+
d'administration, la génération de code, les secrets : les faire venir de Keycloak fait de
|
|
586
|
+
l'administrateur du realm un administrateur de l'instance. La table qui en contient un refuse
|
|
587
|
+
le démarrage (`config.ts#oauthProviderSchema`) — sauf `allowPlatformRoles: true` ÉCRIT sur le fournisseur, que
|
|
588
|
+
chaque démarrage rappelle par un avertissement. Sinon, ce rôle se donne à la main
|
|
589
|
+
(`security:user:add --admin`, console d'administration).
|
|
590
|
+
|
|
591
|
+
## 🧩 Le jeton Keycloak sur ton API — le même compte
|
|
592
|
+
|
|
593
|
+
Une fois les humains connectés par le navigateur, un script ou un service veut souvent appeler l'API
|
|
594
|
+
avec un **jeton d'accès** du même realm. La page [Jetons d'un émetteur tiers](external-jwt.md)
|
|
595
|
+
décrit cette brique en entier ; voici ce que Keycloak y ajoute.
|
|
596
|
+
|
|
597
|
+
**Côté Keycloak**, le jeton doit nommer ton API dans son audience (`aud`) — par défaut il porte
|
|
598
|
+
`account`, que toute zone refuse. `nodefony security:keycloak:realm --write` écrit le mapper pour
|
|
599
|
+
chaque ressource listée dans `audiences` du fournisseur, sinon pour la `resource` de chaque zone
|
|
600
|
+
`external-jwt`. À la main : dans le client, **Client scopes** › le scope dédié › **Add mapper** ›
|
|
601
|
+
_Audience_, avec **Included Custom Audience** = la `resource` de la zone et **Add to access token**
|
|
602
|
+
coché.
|
|
603
|
+
|
|
604
|
+
> [!TIP]
|
|
605
|
+
> Écris `audiences` dès qu'une zone `external-jwt` ne doit PAS recevoir les jetons de ce realm (une
|
|
606
|
+
> API d'un autre domaine de confiance, une zone de test) : sans la liste, la commande déduit
|
|
607
|
+
> l'audience de toutes les zones, et Keycloak imprimerait aussi l'adresse de celle-là.
|
|
608
|
+
|
|
609
|
+
**Côté Nodefony**, déclarer le realm comme émetteur de confiance et ouvrir une zone qui accepte ses
|
|
610
|
+
jetons :
|
|
611
|
+
|
|
612
|
+
```typescript ignore
|
|
613
|
+
// nodefony/config/security.ts
|
|
614
|
+
import type { ISecurityConfigInput } from "@nodefony/security";
|
|
615
|
+
|
|
616
|
+
export const securityConfig = () =>
|
|
617
|
+
({
|
|
618
|
+
resourceServer: {
|
|
619
|
+
issuers: [
|
|
620
|
+
// Les clés publiques du realm sont découvertes par l'émetteur.
|
|
621
|
+
{
|
|
622
|
+
issuer: "https://sso.example.com/realms/mon-entreprise",
|
|
623
|
+
algorithms: ["RS256"],
|
|
624
|
+
},
|
|
625
|
+
],
|
|
626
|
+
},
|
|
627
|
+
areas: {
|
|
628
|
+
api: {
|
|
629
|
+
pattern: "^/api",
|
|
630
|
+
authenticators: ["external-jwt"],
|
|
631
|
+
stateless: true,
|
|
632
|
+
// L'audience exigée : celle que le mapper inscrit dans le jeton.
|
|
633
|
+
resource: "https://api.example.com",
|
|
634
|
+
},
|
|
635
|
+
},
|
|
636
|
+
}) satisfies ISecurityConfigInput;
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
**Le même compte, pas un second.** Quand l'émetteur d'un jeton est celui d'un fournisseur de
|
|
640
|
+
connexion déclaré (`oauth2.providers.keycloak.issuer`), l'authenticator cherche d'abord le compte lié
|
|
641
|
+
à `(keycloak, sub)` — celui que le login par navigateur a créé (`ExternalSubjectResolver.ts#IExternalIssuerBinding.issuer`).
|
|
642
|
+
Sans ce lien, le jeton désignerait un compte distinct, sous l'identifiant `<émetteur>#<sub>`.
|
|
643
|
+
|
|
644
|
+
## ☸️ En cluster — joindre Keycloak par son adresse interne
|
|
645
|
+
|
|
646
|
+
Dans un cluster Kubernetes, ton application et Keycloak tournent côte à côte. Pour se parler, ils
|
|
647
|
+
passent par le **service interne** (`http://keycloak.sso:8080`), sans ressortir sur Internet. Les
|
|
648
|
+
navigateurs, eux, ne connaissent que l'adresse **publique** (`https://sso.example.com`).
|
|
649
|
+
|
|
650
|
+
C'est comme le standard téléphonique d'une entreprise : entre collègues on compose le poste
|
|
651
|
+
interne, de l'extérieur le numéro public — mais la personne qui décroche se présente toujours
|
|
652
|
+
**sous le même nom**. Ce nom, c'est l'**émetteur** (`issuer`) : l'adresse publique du realm, celle
|
|
653
|
+
qu'on retrouve dans chaque jeton.
|
|
654
|
+
|
|
655
|
+
Sans réglage, l'application lit la « carte d'identité » de Keycloak (le document de découverte)
|
|
656
|
+
**à l'adresse de l'émetteur**, donc l'adresse publique — que bien des pods ne joignent pas, ou
|
|
657
|
+
seulement en ressortant par l'ingress. Et si on écrit l'adresse interne à la place de l'émetteur,
|
|
658
|
+
Keycloak répond « je m'appelle `https://sso.example.com/…` » : l'application refuse, et elle a
|
|
659
|
+
raison — c'est la garde qui empêche un serveur de parler au nom d'un autre (RFC 8414 §3.3).
|
|
660
|
+
|
|
661
|
+
`discoveryUrl` dit les deux à la fois : **lis la carte ici, mais exige que la personne se présente
|
|
662
|
+
sous ce nom-là.**
|
|
663
|
+
|
|
664
|
+
**Côté Keycloak**, deux réglages (guide Keycloak _Configuring the hostname_) :
|
|
665
|
+
|
|
666
|
+
```bash
|
|
667
|
+
# Le nom public, en URL COMPLÈTE : c'est lui que porteront `iss` et les pages de connexion.
|
|
668
|
+
KC_HOSTNAME=https://sso.example.com
|
|
669
|
+
# Les points appelés de serveur à serveur suivent l'adresse par laquelle on interroge Keycloak.
|
|
670
|
+
KC_HOSTNAME_BACKCHANNEL_DYNAMIC=true
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
**Côté application**, une variable de plus dans le Secret du pod, à côté des trois `NF_KEYCLOAK_*` :
|
|
674
|
+
|
|
675
|
+
```bash
|
|
676
|
+
NF_KEYCLOAK_DISCOVERY_URL=http://keycloak.sso:8080/realms/mon-entreprise/.well-known/openid-configuration
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
Le gabarit `complete` la porte déjà vers les deux usages du realm — la connexion (le bouton) et
|
|
680
|
+
les jetons sur ton API :
|
|
681
|
+
|
|
682
|
+
```typescript ignore
|
|
683
|
+
use("@nodefony/security", {
|
|
684
|
+
oauth2: {
|
|
685
|
+
providers: {
|
|
686
|
+
keycloak: {
|
|
687
|
+
issuer: ctx.env.NF_KEYCLOAK_ISSUER, // https://sso.example.com/realms/mon-entreprise
|
|
688
|
+
discoveryUrl: ctx.env.NF_KEYCLOAK_DISCOVERY_URL, // le service interne
|
|
689
|
+
clientId: ctx.env.NF_KEYCLOAK_CLIENT_ID,
|
|
690
|
+
clientSecret: ctx.env.NF_KEYCLOAK_CLIENT_SECRET,
|
|
691
|
+
redirectUri: `${ctx.env.NF_OAUTH_REDIRECT_BASE}/nodefony/security/api/oauth2/keycloak/callback`,
|
|
692
|
+
},
|
|
693
|
+
},
|
|
694
|
+
},
|
|
695
|
+
resourceServer: {
|
|
696
|
+
issuers: [
|
|
697
|
+
{
|
|
698
|
+
issuer: ctx.env.NF_KEYCLOAK_ISSUER,
|
|
699
|
+
discoveryUrl: ctx.env.NF_KEYCLOAK_DISCOVERY_URL,
|
|
700
|
+
algorithms: ["RS256"],
|
|
701
|
+
},
|
|
702
|
+
],
|
|
703
|
+
},
|
|
704
|
+
});
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
**Ce qu'on observe** — le document qu'un Keycloak 26.8 réglé ainsi rend quand on l'interroge par
|
|
708
|
+
son adresse interne (constaté, pas déduit) :
|
|
709
|
+
|
|
710
|
+
| Point d'entrée | Qui l'appelle | Adresse publiée |
|
|
711
|
+
| ------------------------ | ------------------------------------ | ---------------------------- |
|
|
712
|
+
| `issuer` | — | `https://sso.example.com/…` |
|
|
713
|
+
| `authorization_endpoint` | le **navigateur** | `https://sso.example.com/…` |
|
|
714
|
+
| `end_session_endpoint` | le **navigateur** | `https://sso.example.com/…` |
|
|
715
|
+
| `token_endpoint` | l'application (de serveur à serveur) | `http://keycloak.sso:8080/…` |
|
|
716
|
+
| `jwks_uri` | l'application (de serveur à serveur) | `http://keycloak.sso:8080/…` |
|
|
717
|
+
|
|
718
|
+
**Ce qui reste vérifié**, et ne se relâche jamais :
|
|
719
|
+
|
|
720
|
+
- **L'émetteur est comparé à l'identique.** Seul le lieu de lecture change
|
|
721
|
+
(`metadataCandidateUrls()`, `authorizationServer.ts#metadataCandidateUrls`) : un document lu à l'adresse interne
|
|
722
|
+
qui se déclare d'un autre émetteur est refusé.
|
|
723
|
+
- **Le `http` n'est admis que sur l'origine EXACTE de `discoveryUrl`** — même schéma, même hôte,
|
|
724
|
+
même port —, et seulement pour le point de jeton et le jeu de clés
|
|
725
|
+
(`isBackChannelTransportAllowed()`, `authorizationServer.ts#isBackChannelTransportAllowed`). L'opérateur a déjà choisi
|
|
726
|
+
cette adresse en l'écrivant ; un `http` vers une autre adresse est une adresse que personne n'a
|
|
727
|
+
écrite, refusée.
|
|
728
|
+
- **Les points du navigateur restent en `https`** (autorisation, déconnexion) : un navigateur
|
|
729
|
+
renvoyé vers une adresse interne n'aboutirait nulle part.
|
|
730
|
+
- **L'adresse est vérifiée au démarrage** : ni `http(s)`, ou porteuse d'identifiants ou d'un
|
|
731
|
+
fragment → démarrage refusé (`parseDiscoveryUrl()`, `authorizationServer.ts#parseDiscoveryUrl`).
|
|
732
|
+
|
|
733
|
+
> [!WARNING]
|
|
734
|
+
> Une adresse interne en `http` fait passer le **secret du client** et les jetons **en clair** sur
|
|
735
|
+
> le réseau du cluster. C'est le choix courant derrière un maillage de services (mTLS) ou dans un
|
|
736
|
+
> réseau isolé ; sinon, servir Keycloak en `https` sur l'adresse interne aussi, et déclarer son
|
|
737
|
+
> autorité par `NF_TLS_CA_FILE`.
|
|
738
|
+
|
|
739
|
+
## 🔐 Sécurité
|
|
740
|
+
|
|
741
|
+
- **`https` partout, même en développement.** L'ID token est lu sans vérifier sa signature, ce
|
|
742
|
+
qu'OIDC Core §3.1.3.7 n'admet que parce qu'il arrive du point de jeton par TLS. Un émetteur en
|
|
743
|
+
`http` est refusé au démarrage (`canonicalIssuer()`, `authorizationServer.ts#canonicalIssuer`).
|
|
744
|
+
- **Client confidentiel obligatoire** : le schéma exige un secret non vide. Un client public ne
|
|
745
|
+
conviendrait qu'à une application sans serveur, ce que n'est pas une application Nodefony.
|
|
746
|
+
- **Anti-mix-up** : le paramètre `iss` du retour et le claim `iss` de l'ID token doivent égaler
|
|
747
|
+
l'émetteur découvert (RFC 9207). Un retour venu d'un autre realm est rejeté.
|
|
748
|
+
- **Rôles de plateforme hors de portée de l'annuaire**, sauf ouverture écrite
|
|
749
|
+
(`allowPlatformRoles`) : un realm compromis ne donne pas, par défaut, la console
|
|
750
|
+
d'administration. Un rôle Keycloak absent de `roleMapping` ne donne rien.
|
|
751
|
+
- **Aucune liaison par email** : un email, même vérifié par le realm, ne donne jamais accès à un
|
|
752
|
+
compte local existant. Le rattachement d'un compte local à un `sub` Keycloak se fait
|
|
753
|
+
explicitement.
|
|
754
|
+
- **Déconnexion, dans les deux sens.** Se déconnecter de l'application ferme aussi la session du
|
|
755
|
+
realm : la réponse porte `logoutUrl`, où le navigateur est envoyé avec l'ID token en
|
|
756
|
+
`id_token_hint` — sans page de confirmation, et le clic suivant redemande le mot de passe. À
|
|
757
|
+
l'inverse, une session fermée **chez Keycloak** (console d'administration, autre application du
|
|
758
|
+
SSO) ferme la session de l'application par le **canal arrière** : Keycloak appelle
|
|
759
|
+
`…/oauth2/keycloak/backchannel-logout` avec un jeton signé, que l'application vérifie sur les clés
|
|
760
|
+
du realm avant de détruire quoi que ce soit (détail : [OAuth2 § canal arrière](oauth2.md#déconnexion-demandée-par-le-fournisseur--le-canal-arrière)).
|
|
761
|
+
|
|
762
|
+
## ⚠️ Pièges
|
|
763
|
+
|
|
764
|
+
Avant de lire le tableau, **demander le diagnostic** : il éprouve le branchement sans qu'un humain
|
|
765
|
+
se connecte, et nomme la cause là où le journal ne montre qu'un `401`.
|
|
766
|
+
|
|
767
|
+
```bash
|
|
768
|
+
npx nodefony security:oauth:doctor # tous les fournisseurs configurés
|
|
769
|
+
npx nodefony security:oauth:doctor -p keycloak # un seul ; --json pour un script
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
Trois sondes, chacune avec son verdict : **découverte** (émetteur annoncé ≠ émetteur configuré,
|
|
773
|
+
RFC 8414 §3.3), **URL de retour** (la requête du bouton, non suivie : Keycloak refuse en 400 une
|
|
774
|
+
URL qu'il ne connaît pas), **secret** (un code inventé présenté avec le vrai secret :
|
|
775
|
+
`invalid_grant` = secret accepté). Sortie en 1 si une sonde échoue. `nodefony doctor --live` rend
|
|
776
|
+
les mêmes échecs (famille « Fournisseurs OAuth »), l'administration les sert sur
|
|
777
|
+
`/nodefony/security/api/oauth/diagnosis`. Côté Keycloak, la sonde ouvre une session de connexion
|
|
778
|
+
vide, qui expire seule.
|
|
779
|
+
|
|
780
|
+
| Symptôme | Cause | Correction |
|
|
781
|
+
| ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
782
|
+
| Démarrage refusé : `security.oauth2.providers.keycloak.issuer est requis` | Les trois variables ne sont pas posées ensemble, ou `issuer` manque à la main | Poser `NF_KEYCLOAK_ISSUER` (`checkProviderIssuer()`, `oauth2.ts#checkProviderIssuer`) |
|
|
783
|
+
| Démarrage refusé : `émetteur invalide … https` | Émetteur en `http`, ou avec `?…`/`#…` | Recopier l'`issuer` du document _OpenID Endpoint Configuration_ |
|
|
784
|
+
| Pas de bouton + WARNING `oauth2 provider "keycloak" indisponible` | Keycloak injoignable, ou certificat non reconnu (`fetch failed (SELF_SIGNED_CERT_IN_CHAIN …)`) | Démarrer Keycloak ; déclarer l'autorité d'entreprise par `NF_TLS_CA_FILE`. Retour sous 30 s |
|
|
785
|
+
| Démarrage refusé (78) : `http.certificates.extraCaFiles : « … » est illisible` | Le fichier de `NF_TLS_CA_FILE` n'existe pas à ce chemin (relatif = à la racine de l'application) | Corriger le chemin, ou monter le fichier dans le pod |
|
|
786
|
+
| Avec `discoveryUrl` : `le document déclare « http://keycloak.sso:8080/… » alors qu'il a été demandé à « https://sso… »` | `KC_HOSTNAME` absent : Keycloak prend le nom de l'adresse par laquelle on l'interroge | `KC_HOSTNAME` = l'URL publique COMPLÈTE |
|
|
787
|
+
| Avec `discoveryUrl` : le bouton est là, mais le retour de connexion échoue en `fetch failed` vers l'adresse publique | `KC_HOSTNAME_BACKCHANNEL_DYNAMIC` absent : Keycloak publie son point de jeton sur l'adresse publique | `KC_HOSTNAME_BACKCHANNEL_DYNAMIC=true` |
|
|
788
|
+
| Avec `discoveryUrl` : `« token_endpoint » doit être en https (ou en http sur l'origine de discoveryUrl …)` | Keycloak publie une AUTRE origine que celle de `discoveryUrl` (hôte ou port réécrit par un intermédiaire) | Écrire dans `discoveryUrl` exactement l'origine que Keycloak publie |
|
|
789
|
+
| `Invalid parameter: redirect_uri` sur la page Keycloak | `redirectUri` ≠ les **Valid redirect URIs** du client | Aligner schéma, hôte (`localhost` ≠ `127.0.0.1`), port et chemin |
|
|
790
|
+
| Retour sur `failureRedirect` + WARNING `unauthorized_client` (secret faux) ou `invalid_client` (client inconnu) | Mauvais secret, client absent du realm, ou client public (_Client authentication OFF_) | Recopier le secret de l'onglet _Credentials_ ; passer le client en ON |
|
|
791
|
+
| Retour sur `failureRedirect` + WARNING `OAuth issuer mismatch` | Keycloak joint par deux adresses différentes : l'émetteur varie | Fixer `KC_HOSTNAME` à l'URL publique complète |
|
|
792
|
+
| Retour sur `failureRedirect` + WARNING `local account exists without a keycloak link` | Un compte local porte déjà cet identifiant (`UserService.ts#UserService.changePassword`) — typiquement un `sub` changé après réimport du realm | Rattacher le compte explicitement, ou fixer l'`id` des utilisateurs dans l'export du realm |
|
|
793
|
+
| API : `401` avec un jeton Keycloak valide | Audience absente du jeton : pas de mapper _Audience_ | `security:keycloak:realm --write` puis import partiel ; `aud` doit contenir la `resource` de la zone |
|
|
794
|
+
| Session de l'application vivante après une déconnexion chez Keycloak ; Keycloak journalise `Some clients have not been logged out` | _Front channel logout_ ON, ou **Backchannel logout URL** absente ou injoignable depuis Keycloak | _Front channel logout_ OFF ; une URL que Keycloak joint (en développement Linux : serveur à l'écoute au-delà de la boucle locale) |
|
|
795
|
+
|
|
796
|
+
| Démarrage refusé : `… est un rôle de PLATEFORME` | `roleMapping` traduit vers un `ROLE_NODEFONY_*` | Donner ce rôle à la main, ou écrire `allowPlatformRoles: true` en connaissance de cause |
|
|
797
|
+
| Le rôle Keycloak n'apparaît pas dans l'application | Rôle du REALM alors que la source est `client`, ou rôle d'un autre client, ou session ouverte avant le changement | Créer le rôle sur le client de l'application, ou `rolesSource: ["realm"]` ; se reconnecter |
|
|
798
|
+
|
|
799
|
+
La cause d'un retour sur `failureRedirect` n'est jamais montrée au navigateur ; elle est dans le
|
|
800
|
+
journal du serveur, préfixée `oauth2 callback "keycloak"` (`OAuth2Controller.ts#OAuth2Controller.authorize`).
|
|
801
|
+
|
|
802
|
+
## 🧪 Tests & couverture
|
|
803
|
+
|
|
804
|
+
| Type | Fichier | Ce qu'il prouve |
|
|
805
|
+
| ----------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
806
|
+
| Unitaire | `security/tests/unit/oauth2Service.test.ts` | Démarrage refusé sur émetteur absent ou mal formé ; bouton retiré puis rendu quand l'émetteur tombe. |
|
|
807
|
+
| Unitaire | `security/tests/unit/oauthProviders.test.ts` | Découverte, refus d'un serveur sans PKCE S256, contrôle des claims de l'ID token. |
|
|
808
|
+
| Unitaire | `security/tests/unit/providerRoles.test.ts` | Lecture des claims (client, realm, groupes), rôle inconnu ignoré, refus des rôles de plateforme sauf `allowPlatformRoles`, recalcul à la connexion et au jeton, aucune écriture sans changement. |
|
|
809
|
+
| Unitaire | `security/tests/unit/keycloakRealmInput.test.ts` | La configuration de l'application de dev, dérivée puis fusionnée, rend `realm-nodefony.json` octet pour octet ; audiences déduites ou écrites, production sans secret, rôles rangés selon `rolesSource`. |
|
|
810
|
+
| Unitaire | `security/tests/unit/providerDiagnosis.test.ts` | Diagnostic : trois verdicts distincts (émetteur, URL de retour, secret) ; un `401` n'accuse le secret que si le client est prouvé connu ; non concluant ≠ échec ; sondes passées par le client du login. |
|
|
811
|
+
| Unitaire | `security/tests/unit/keycloakRealm.test.ts` | Fusion : ce que la config dit est réécrit, ce qu'un humain a écrit survit ; mapper d'audience remplacé, jamais doublé. |
|
|
812
|
+
| Unitaire | `user/tests/unit/providerRoles.test.ts` | Rôles gérés contre rôles locaux : retrait, ajout, rôle à la main conservé, profil préservé. |
|
|
813
|
+
| Unitaire | `security/tests/unit/oauthMetadata.test.ts` | `discoveryUrl` : lu à cette adresse seule ; émetteur différent refusé ; point de jeton en `http` admis sur l'origine déclarée seulement ; point d'autorisation toujours en `https`. |
|
|
814
|
+
| Unitaire | `nodefony/src/tests/authorizationServer.test.ts` | La règle elle-même, au cœur : adresse validée, `jwks_uri` en `http` admis sur l'origine exacte (port compris), refusé ailleurs. |
|
|
815
|
+
| Unitaire | `http/nodefony/tests/unit/certificates.test.ts` | `NF_TLS_CA_FILE` : une chaîne en un fichier devient N autorités ; fichier absent, vide, ou Node trop ancien → démarrage refusé ; un **vrai** serveur https signé par l'autorité déclarée devient joignable. |
|
|
816
|
+
| Intégration | `http/nodefony/tests/integration/oauth2-keycloak.test.ts` | Contre un **vrai** Keycloak : flux complet, même compte entre session et jeton d'API, `invalid_grant` rendu par Keycloak, déconnexion dans les deux sens, jeton de déconnexion forgé refusé ; `bob` obtient puis perd `ROLE_ADMIN` (session et jeton) quand le rôle bouge dans le realm, un rôle local survit ; `cci` obtient `ROLE_NODEFONY_ADMIN` par `allowPlatformRoles` ; le diagnostic rend ses trois verdicts sur des branchements faussés (secret, URL de retour, émetteur `127.0.0.1` au lieu de `localhost`) ; le realm lu par `127.0.0.1` avec `discoveryUrl` rend un jeton machine, et refuse un émetteur réellement différent. |
|
|
817
|
+
|
|
818
|
+
Le banc d'intégration exige le conteneur et les trois variables (décor `KEYCLOAK_GATE` de
|
|
819
|
+
`scripts/test/vitest/gates.ts`) ; sans eux il est sauté, et la passe le dit en fin de run. Le cas du canal
|
|
820
|
+
arrière constate en plus que le conteneur JOINT l'application (`host.docker.internal`) : un serveur
|
|
821
|
+
de développement sous Linux n'écoute que la boucle locale, le cas est alors sauté et le dit.
|
|
822
|
+
|
|
823
|
+
**Ce qui manque** : aucun banc versionné ne joue un Keycloak en `KC_HOSTNAME_BACKCHANNEL_DYNAMIC`
|
|
824
|
+
(points internes en `http`) — le tableau « Ce qu'on observe » a été constaté à la main sur un
|
|
825
|
+
Keycloak 26.8 jetable, et le banc versionné ne couvre que le cas `https`. Aucun ne joue non plus un
|
|
826
|
+
Keycloak derrière un proxy (émetteur variable), ni une rotation des clés du realm pendant une
|
|
827
|
+
session.
|
|
828
|
+
|
|
829
|
+
## 🔗 Pour aller plus loin
|
|
830
|
+
|
|
831
|
+
- ⬆️ **Retour au hub** : [Sécurité — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
|
|
832
|
+
- [OAuth2](oauth2.md) — le flux, le contrat des fournisseurs, les autres fournisseurs (Google, GitHub, tout OIDC).
|
|
833
|
+
- [Jetons d'un émetteur tiers](external-jwt.md) — accepter les jetons d'accès du realm sur une API.
|
|
834
|
+
- [Authenticators](authenticators.md) — la session BFF qui suit la connexion.
|
|
835
|
+
- [`@nodefony/user`](../../user/docs/index.md) — le compte local et ses liens externes.
|