@nodefony/security 10.0.0-beta.1 → 10.0.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +44 -10
- package/dist/nodefony/command/security-oauth-doctor.js +66 -0
- package/dist/nodefony/command/security-secrets.js +42 -25
- package/dist/nodefony/command/security-token.js +46 -15
- package/dist/nodefony/command/security-user-delete.js +5 -4
- package/dist/nodefony/command/security-user-password.js +4 -11
- package/dist/nodefony/config/config.js +80 -5
- package/dist/nodefony/contracts/index.js +1 -2
- package/dist/nodefony/errors/InsecureTransportError.js +24 -0
- package/dist/nodefony/errors/index.js +2 -1
- package/dist/nodefony/keycloak/keycloakAdmin.js +255 -0
- package/dist/nodefony/keycloak/keycloakRealm.js +260 -0
- package/dist/nodefony/keycloak/keycloakRealmInput.js +124 -0
- package/dist/nodefony/keycloak/scaffold.js +231 -0
- package/dist/nodefony/keycloak/security-keycloak-realm.js +128 -0
- package/dist/nodefony/scaffold/contribute.js +20 -0
- package/dist/nodefony/service/authFlow.js +111 -11
- package/dist/nodefony/service/firewall.js +111 -21
- package/dist/nodefony/service/keycloak.js +209 -0
- package/dist/nodefony/service/oauth2.js +379 -26
- package/dist/nodefony/service/securityHeaders.js +12 -1
- package/dist/nodefony/service/tokenService.js +57 -6
- package/dist/nodefony/service/totp.js +51 -6
- package/dist/nodefony/service/webAuthn.js +20 -2
- package/dist/nodefony/src/SecuredArea.js +14 -3
- package/dist/nodefony/src/admin/SecurityAdminApi.js +70 -40
- package/dist/nodefony/src/admin/adminAudit.js +0 -1
- package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +66 -6
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +38 -15
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +1 -0
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +13 -3
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +9 -2
- package/dist/nodefony/src/authenticator/bearer.js +1 -2
- package/dist/nodefony/src/boot/publicRoutesNotice.js +27 -0
- package/dist/nodefony/src/entryPoint.js +39 -0
- package/dist/nodefony/src/oauth/clientCredentials.js +40 -0
- package/dist/nodefony/src/oauth/metadata.js +36 -9
- package/dist/nodefony/src/oauth/oauth2Client.js +60 -33
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +39 -6
- package/dist/nodefony/src/oauth/providerDiagnosis.js +283 -0
- package/dist/nodefony/src/oauth/providerRoles.js +79 -0
- package/dist/nodefony/src/oauth/providers/github.js +32 -6
- package/dist/nodefony/src/oauth/providers/oidc.js +82 -3
- package/dist/nodefony/src/openToSignup.js +47 -0
- package/dist/nodefony/src/sessionAuthentication.js +26 -0
- package/dist/nodefony/src/token/JwtKeystore.js +78 -46
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +40 -13
- package/dist/nodefony/src/token/secretFile.js +59 -2
- package/dist/nodefony/src/transport/CredentialTransportPolicy.js +51 -0
- package/dist/types/index.d.ts +26 -6
- package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
- package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
- package/dist/types/nodefony/command/security-token.d.ts +9 -0
- package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
- package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
- package/dist/types/nodefony/config/config.d.ts +63 -1
- package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
- package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
- package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
- package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
- package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
- package/dist/types/nodefony/errors/index.d.ts +1 -0
- package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
- package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
- package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
- package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
- package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
- package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
- package/dist/types/nodefony/service/authFlow.d.ts +35 -3
- package/dist/types/nodefony/service/firewall.d.ts +17 -3
- package/dist/types/nodefony/service/keycloak.d.ts +115 -0
- package/dist/types/nodefony/service/oauth2.d.ts +125 -9
- package/dist/types/nodefony/service/securityHeaders.d.ts +2 -1
- package/dist/types/nodefony/service/tokenService.d.ts +7 -1
- package/dist/types/nodefony/service/totp.d.ts +28 -4
- package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
- package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +54 -12
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +18 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +13 -0
- package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
- package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
- package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
- package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
- package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
- package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
- package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
- package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
- package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
- package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
- package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
- package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
- package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
- package/docs/api-keys.md +18 -18
- package/docs/audit.md +24 -22
- package/docs/authenticators.md +62 -29
- package/docs/authorization.md +16 -16
- package/docs/cors.md +30 -30
- package/docs/csrf.md +35 -33
- package/docs/external-jwt.md +68 -5
- package/docs/firewall.md +78 -45
- package/docs/headers.md +35 -35
- package/docs/index.md +15 -7
- package/docs/keycloak.md +818 -0
- package/docs/lexique.md +15 -15
- package/docs/login-page.md +361 -0
- package/docs/oauth2.md +242 -125
- package/docs/obtenir-un-jeton.md +18 -11
- package/docs/tokens.md +138 -86
- package/docs/totp.md +113 -71
- package/docs/webauthn.md +115 -115
- package/docs/webhooks.md +21 -21
- package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
- package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/account/theme.properties +14 -0
- package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
- package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/admin/theme.properties +12 -0
- package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
- package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
- package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
- package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/email/theme.properties +8 -0
- package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
- package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
- package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
- package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
- package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/login/template.ftl +423 -0
- package/keycloak/themes/nodefony/login/theme.properties +17 -0
- package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
- package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
- package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
- package/package.json +17 -15
|
@@ -1,11 +1,57 @@
|
|
|
1
|
+
import { compileProviderRoleMapping, isPlatformRole, mapProviderRoles } from "../src/oauth/providerRoles.js";
|
|
1
2
|
import { AuthenticationError } from "../errors/AuthenticationError.js";
|
|
2
3
|
import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
4
|
+
import { recordAudit } from "../src/audit/recordAudit.js";
|
|
5
|
+
import { keycloakLoginThemeCheck } from "../keycloak/keycloakAdmin.js";
|
|
3
6
|
import { generateCodeVerifier, generateState } from "../src/oauth/oauth2Client.js";
|
|
4
|
-
import { getOAuthProviderFactory, listOAuthProviders } from "../src/oauth/oauthProviderRegistry.js";
|
|
5
|
-
import {
|
|
7
|
+
import { getOAuthProviderFactory, listOAuthProviders, oauthProviderRequiresIssuer } from "../src/oauth/oauthProviderRegistry.js";
|
|
8
|
+
import { diagnoseOAuthProvider } from "../src/oauth/providerDiagnosis.js";
|
|
9
|
+
import { BootConfigurationError, LOGIN_PAGE_PATH, Service, canonicalIssuer } from "nodefony";
|
|
6
10
|
//#region nodefony/service/oauth2.ts
|
|
7
11
|
const serviceName = "oauth2";
|
|
8
12
|
/**
|
|
13
|
+
* Clé de session de l'indice de déconnexion. Lue et écrite par CE service
|
|
14
|
+
* seulement ({@link OAuth2Service.rememberLogout},
|
|
15
|
+
* {@link OAuth2Service.logoutUrlFor}) : les contrôleurs ne la connaissent pas.
|
|
16
|
+
*/
|
|
17
|
+
const LOGOUT_HINT_KEY = "oauth2:logout";
|
|
18
|
+
/**
|
|
19
|
+
* Clé de MÉTADONNÉE de session (`metaBag`) qui indexe un login fédéré :
|
|
20
|
+
* fournisseur et `sid`, sans l'ID token. C'est elle que lit le canal arrière.
|
|
21
|
+
*
|
|
22
|
+
* @remarks Pas dans les attributs : l'énumération des sessions les REDACTE
|
|
23
|
+
* (aucun store ne les rend hors de la session elle-même), quand `metaBag` en
|
|
24
|
+
* sort — et le résumé d'administration ne l'expose que par liste blanche.
|
|
25
|
+
*/
|
|
26
|
+
const FEDERATION_KEY = "oauth2:federation";
|
|
27
|
+
/**
|
|
28
|
+
* Plafond de la mémoire anti-rejeu du canal arrière — au-delà, les plus
|
|
29
|
+
* anciens `jti` sont oubliés. Un fournisseur n'émet qu'un jeton par session
|
|
30
|
+
* fermée : ce plafond n'est atteint que sous un flot anormal.
|
|
31
|
+
*/
|
|
32
|
+
const MAX_SEEN_LOGOUT_TOKENS = 1e4;
|
|
33
|
+
/** Relit l'index fédéré d'une session énumérée — sa forme se vérifie. */
|
|
34
|
+
function readFederation(value) {
|
|
35
|
+
if (typeof value !== "object" || value === null) return null;
|
|
36
|
+
const { provider, sid } = value;
|
|
37
|
+
if (typeof provider !== "string") return null;
|
|
38
|
+
return {
|
|
39
|
+
provider,
|
|
40
|
+
sid: typeof sid === "string" ? sid : null
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** Relit un indice de session — il vient du stockage, sa forme se vérifie. */
|
|
44
|
+
function readLogoutHint(value) {
|
|
45
|
+
if (typeof value !== "object" || value === null) return null;
|
|
46
|
+
const { provider, idToken, sid } = value;
|
|
47
|
+
if (typeof provider !== "string" || typeof idToken !== "string") return null;
|
|
48
|
+
return {
|
|
49
|
+
provider,
|
|
50
|
+
idToken,
|
|
51
|
+
sid: typeof sid === "string" ? sid : null
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
9
55
|
* Sigles qui se lisent en capitales — les capitaliser mot à mot rendrait
|
|
10
56
|
* « Oidc », « Sso », qu'aucun utilisateur ne reconnaît comme la technologie.
|
|
11
57
|
*/
|
|
@@ -58,19 +104,72 @@ function oauthDisplayLabel(name) {
|
|
|
58
104
|
return name.split(/[-_.\s]+/).filter((word) => word.length > 0).map((word) => ACRONYMS.has(word.toLowerCase()) ? word.toUpperCase() : word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
59
105
|
}
|
|
60
106
|
/**
|
|
107
|
+
* Délai avant de retenter un fournisseur dont la construction a échoué — assez
|
|
108
|
+
* long pour ne pas marteler un émetteur en panne à chaque affichage de l'écran
|
|
109
|
+
* de connexion, assez court pour qu'un bouton revienne sans redémarrage.
|
|
110
|
+
*/
|
|
111
|
+
const UNREACHABLE_RETRY_MS = 3e4;
|
|
112
|
+
/**
|
|
113
|
+
* Confronte l'émetteur configuré d'un fournisseur à ce que sa fabrique exige.
|
|
114
|
+
*
|
|
115
|
+
* Une URL qui ne peut pas être un émetteur est refusée QUEL QUE SOIT le
|
|
116
|
+
* fournisseur : aucune valeur mal formée n'est une configuration voulue. Une
|
|
117
|
+
* absence ne l'est que si la fabrique a déclaré l'émetteur requis.
|
|
118
|
+
*
|
|
119
|
+
* @param name - nom du fournisseur (`oauth2.providers.<name>`).
|
|
120
|
+
* @param issuer - émetteur lu dans sa configuration.
|
|
121
|
+
* @throws BootConfigurationError - émetteur requis absent, ou mal formé.
|
|
122
|
+
*/
|
|
123
|
+
function checkProviderIssuer(name, issuer) {
|
|
124
|
+
const key = `security.oauth2.providers.${name}.issuer`;
|
|
125
|
+
if (issuer === void 0 || issuer === "") {
|
|
126
|
+
if (oauthProviderRequiresIssuer(name)) throw new BootConfigurationError(`[@nodefony/security] ${key} est requis : le fournisseur « ${name} » découvre ses points d'entrée à partir de son émetteur. Attendu : l'URL https de l'émetteur OpenID Connect (Keycloak : URL du realm, ex. « https://auth.example.com/realms/mon-royaume »).`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
canonicalIssuer(issuer);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
throw new BootConfigurationError(`[@nodefony/security] ${key} — ${error instanceof Error ? error.message : String(error)}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Claims du jeton d'ACCÈS, quand c'est un JWT — `null` sinon (jeton opaque).
|
|
137
|
+
*
|
|
138
|
+
* Keycloak pose les rôles dans le jeton d'accès, et seulement là par défaut
|
|
139
|
+
* (`realm_access`, `resource_access`) : ni le jeton d'identité ni userinfo ne
|
|
140
|
+
* les portent sans « mapper » ajouté. La signature n'est pas vérifiée, pour la
|
|
141
|
+
* raison qui vaut pour le jeton d'identité (OpenID Connect Core §3.1.3.7) : il
|
|
142
|
+
* vient d'être reçu du point de jeton, sur un canal TLS direct et authentifié.
|
|
143
|
+
*/
|
|
144
|
+
async function accessTokenClaims(tokens) {
|
|
145
|
+
try {
|
|
146
|
+
return (await import("jose")).decodeJwt(tokens.accessToken());
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
61
152
|
* **Social login OAuth 2.0** (P6 J9) — orchestrateur du flux *Authorization Code*.
|
|
62
153
|
*
|
|
63
154
|
* Posture OAuth 2.1 (RFC 9700) : Authorization Code uniquement (jamais implicit /
|
|
64
155
|
* ROPC), **PKCE S256** quand le fournisseur le supporte (RFC 7636), **state**
|
|
65
|
-
* anti-CSRF, **iss** anti-mix-up (RFC 9207) ; aucun jeton n'atteint le
|
|
66
|
-
* (le login produit une **session BFF**, gérée hors de ce service par
|
|
67
|
-
* controller + `AuthFlow`)
|
|
156
|
+
* anti-CSRF, **iss** anti-mix-up (RFC 9207) ; aucun jeton d'accès n'atteint le
|
|
157
|
+
* navigateur (le login produit une **session BFF**, gérée hors de ce service par
|
|
158
|
+
* le controller + `AuthFlow`) — seul l'ID token y passe, une fois, à la
|
|
159
|
+
* déconnexion ({@link IFederatedLogoutHint}).
|
|
160
|
+
*
|
|
161
|
+
* Au boot (si `oauth2.enabled`), la configuration de chaque fournisseur est
|
|
162
|
+
* confrontée à ce que sa fabrique exige : un émetteur requis absent, ou qui n'est
|
|
163
|
+
* pas une URL d'émetteur valide, INTERROMPT le démarrage en nommant la clé — une
|
|
164
|
+
* faute d'écriture ne doit pas attendre le premier clic pour se montrer. Un nom
|
|
165
|
+
* inconnu du registre reste un WARNING.
|
|
68
166
|
*
|
|
69
|
-
* Les fournisseurs sont construits
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
167
|
+
* Les fournisseurs sont construits une fois puis mémoïsés : c'est là que les
|
|
168
|
+
* points d'entrée d'un émetteur OIDC sont découverts. Un run qui sert (ports
|
|
169
|
+
* ouverts) les construit DÈS le boot, sans l'attendre : un émetteur injoignable
|
|
170
|
+
* n'est pas une faute de configuration, il se journalise et son bouton quitte
|
|
171
|
+
* l'écran de connexion jusqu'à ce qu'une nouvelle tentative réussisse. Un run
|
|
172
|
+
* console ne touche jamais le réseau.
|
|
74
173
|
*
|
|
75
174
|
* Le service ne touche **ni HTTP ni session** : il rend à l'appelant les éléments
|
|
76
175
|
* (URL, state, verifier) que le controller persiste en session — testable sans
|
|
@@ -80,11 +179,15 @@ var OAuth2Service = class extends Service {
|
|
|
80
179
|
module;
|
|
81
180
|
#config = null;
|
|
82
181
|
#providers = null;
|
|
182
|
+
#unreachable = null;
|
|
183
|
+
#seenLogoutTokens = null;
|
|
184
|
+
#roleMappings = null;
|
|
83
185
|
#ready = false;
|
|
84
186
|
constructor(module) {
|
|
85
187
|
super(serviceName, module.container, module.notificationsCenter, module.options);
|
|
86
188
|
this.module = module;
|
|
87
|
-
|
|
189
|
+
const oauth2ConfigCheck = () => this.#build();
|
|
190
|
+
this.kernel?.once("onBoot", oauth2ConfigCheck);
|
|
88
191
|
}
|
|
89
192
|
#build() {
|
|
90
193
|
let config;
|
|
@@ -97,13 +200,34 @@ var OAuth2Service = class extends Service {
|
|
|
97
200
|
this.log("oauth2 idle — social login désactivé en config", "DEBUG");
|
|
98
201
|
return;
|
|
99
202
|
}
|
|
100
|
-
this.#config = config;
|
|
101
203
|
const known = new Set(listOAuthProviders());
|
|
102
204
|
const configured = Object.keys(config.oauth2.providers);
|
|
103
205
|
for (const name of configured) if (!known.has(name)) this.log(`oauth2 provider "${name}" inconnu du registre — ignoré (registerOAuthProvider manquant ?)`, "WARNING");
|
|
104
|
-
this.#ready = true;
|
|
105
206
|
const active = configured.filter((n) => known.has(n));
|
|
207
|
+
for (const name of active) {
|
|
208
|
+
const provider = config.oauth2.providers[name];
|
|
209
|
+
checkProviderIssuer(name, provider?.issuer);
|
|
210
|
+
const mapping = provider === void 0 ? null : compileProviderRoleMapping(provider);
|
|
211
|
+
if (mapping !== null) {
|
|
212
|
+
if (provider?.allowPlatformRoles === true) {
|
|
213
|
+
const platform = Object.values(mapping.table).filter(isPlatformRole);
|
|
214
|
+
if (platform.length > 0) this.log(`oauth2 "${name}" : l'annuaire accorde des rôles de PLATEFORME (${[...new Set(platform)].join(", ")}) — son administrateur administre cette instance (allowPlatformRoles)`, "WARNING");
|
|
215
|
+
}
|
|
216
|
+
this.#roleMappings ??= Object.create(null);
|
|
217
|
+
this.#roleMappings[name] = mapping;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
this.#config = config;
|
|
221
|
+
this.#ready = true;
|
|
106
222
|
this.log(`oauth2 ready — providers: [${active.join(", ") || "aucun"}]`, "DEBUG");
|
|
223
|
+
if (this.kernel?.runProfile.servers === true) for (const name of active) this.#probe(name);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Construit un fournisseur sans attendre le résultat — l'échec est consigné
|
|
227
|
+
* par {@link #resolveProvider}, rien n'est levé vers l'appelant.
|
|
228
|
+
*/
|
|
229
|
+
#probe(name) {
|
|
230
|
+
this.#resolveProvider(name).catch(() => void 0);
|
|
107
231
|
}
|
|
108
232
|
/** `true` si le social login est opérationnel (activé + boot OK). */
|
|
109
233
|
isEnabled() {
|
|
@@ -135,30 +259,43 @@ var OAuth2Service = class extends Service {
|
|
|
135
259
|
listDisplayProviders() {
|
|
136
260
|
if (this.#config === null) return [];
|
|
137
261
|
const configured = this.#config.oauth2.providers;
|
|
138
|
-
return this.listProviders().filter((name) => !configured[name]?.hidden).map((name) =>
|
|
139
|
-
name
|
|
140
|
-
|
|
141
|
-
|
|
262
|
+
return this.listProviders().filter((name) => !configured[name]?.hidden).filter((name) => this.#isReachable(name)).map((name) => {
|
|
263
|
+
const label = configured[name]?.label ?? oauthDisplayLabel(name);
|
|
264
|
+
const icon = configured[name]?.icon;
|
|
265
|
+
return icon === void 0 ? {
|
|
266
|
+
name,
|
|
267
|
+
label
|
|
268
|
+
} : {
|
|
269
|
+
name,
|
|
270
|
+
label,
|
|
271
|
+
icon
|
|
272
|
+
};
|
|
273
|
+
});
|
|
142
274
|
}
|
|
143
275
|
/**
|
|
144
276
|
* Redirections post-login (succès / échec) — lues par le controller.
|
|
145
277
|
* Surcharge PAR FOURNISSEUR si fournie, sinon valeur globale, sinon défaut.
|
|
278
|
+
*
|
|
279
|
+
* @remarks L'échec mène par défaut à la page de connexion (`loginPage.path`) :
|
|
280
|
+
* déplacer la page déplace la redirection, les deux ne peuvent pas diverger.
|
|
146
281
|
*/
|
|
147
282
|
getRedirects(provider) {
|
|
148
283
|
const o = this.#config?.oauth2;
|
|
149
284
|
const p = provider ? o?.providers[provider] : void 0;
|
|
150
285
|
return {
|
|
151
286
|
success: p?.successRedirect ?? o?.successRedirect ?? "/",
|
|
152
|
-
failure: p?.failureRedirect ?? o?.failureRedirect ??
|
|
287
|
+
failure: p?.failureRedirect ?? o?.failureRedirect ?? this.#config?.loginPage.path ?? LOGIN_PAGE_PATH
|
|
153
288
|
};
|
|
154
289
|
}
|
|
155
290
|
/**
|
|
156
291
|
* Étape 1 — prépare l'URL d'autorisation + les éléments anti-replay à stocker
|
|
157
292
|
* en session (`state`, et `code_verifier` si PKCE).
|
|
158
293
|
*
|
|
294
|
+
* @param options.theme - thème rendu par l'application, transmis au seul
|
|
295
|
+
* fournisseur qui sait l'afficher (Keycloak) ; les autres l'ignorent
|
|
159
296
|
* @throws AuthenticationError — fournisseur non configuré / inconnu du registre.
|
|
160
297
|
*/
|
|
161
|
-
async createAuthorization(provider) {
|
|
298
|
+
async createAuthorization(provider, options = {}) {
|
|
162
299
|
const resolved = await this.#resolveProvider(provider);
|
|
163
300
|
const state = generateState();
|
|
164
301
|
const codeVerifier = resolved.provider.usesPkce ? generateCodeVerifier() : null;
|
|
@@ -166,7 +303,8 @@ var OAuth2Service = class extends Service {
|
|
|
166
303
|
url: resolved.provider.createAuthorizationURL({
|
|
167
304
|
state,
|
|
168
305
|
codeVerifier,
|
|
169
|
-
scopes: resolved.scopes
|
|
306
|
+
scopes: resolved.scopes,
|
|
307
|
+
...options.theme ? { theme: options.theme } : {}
|
|
170
308
|
}).toString(),
|
|
171
309
|
state,
|
|
172
310
|
codeVerifier
|
|
@@ -194,10 +332,202 @@ var OAuth2Service = class extends Service {
|
|
|
194
332
|
const profile = await p.fetchProfile(tokens);
|
|
195
333
|
const cfg = this.#ensureReady().oauth2;
|
|
196
334
|
const defaultRoles = cfg.providers[provider]?.defaultRoles ?? cfg.defaultRoles;
|
|
197
|
-
|
|
335
|
+
const mapping = this.#roleMappings?.[provider];
|
|
336
|
+
const providerRoles = mapping === void 0 ? void 0 : mapProviderRoles(mapping, await accessTokenClaims(tokens), profile.raw);
|
|
337
|
+
const user = await this.#resolveProvisioner().provisionOAuthUser(profile, {
|
|
198
338
|
defaultRoles: [...defaultRoles],
|
|
199
|
-
allowSignup: cfg.allowSignup
|
|
200
|
-
|
|
339
|
+
allowSignup: cfg.allowSignup,
|
|
340
|
+
...providerRoles === void 0 ? {} : { providerRoles }
|
|
341
|
+
});
|
|
342
|
+
let logoutHint = null;
|
|
343
|
+
if (p.createLogoutURL !== void 0) {
|
|
344
|
+
const sid = profile.raw?.sid;
|
|
345
|
+
logoutHint = {
|
|
346
|
+
provider,
|
|
347
|
+
idToken: tokens.idToken(),
|
|
348
|
+
sid: typeof sid === "string" ? sid : null
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
identifier: user.identifier,
|
|
353
|
+
logoutHint
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Retient en session l'indice de déconnexion d'un login fédéré — à appeler
|
|
358
|
+
* APRÈS l'ouverture de la session authentifiée (l'ID de session est
|
|
359
|
+
* régénéré à l'ouverture).
|
|
360
|
+
*
|
|
361
|
+
* @param session - session authentifiée de la requête.
|
|
362
|
+
* @param hint - indice rendu par {@link exchangeAndProvision}, ou `null`.
|
|
363
|
+
*/
|
|
364
|
+
rememberLogout(session, hint) {
|
|
365
|
+
if (hint === null) return;
|
|
366
|
+
session.set(LOGOUT_HINT_KEY, hint);
|
|
367
|
+
session.setMetaBag?.(FEDERATION_KEY, {
|
|
368
|
+
provider: hint.provider,
|
|
369
|
+
sid: hint.sid
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Adresse de déconnexion chez le fournisseur qui a ouvert la session, ou
|
|
374
|
+
* `null` : session locale, fournisseur sans point de déconnexion, ou
|
|
375
|
+
* fournisseur devenu indisponible. À lire AVANT de détruire la session.
|
|
376
|
+
*
|
|
377
|
+
* @param session - session courante, ou `null`.
|
|
378
|
+
* @returns l'URL vers laquelle envoyer le navigateur, ou `null`.
|
|
379
|
+
*/
|
|
380
|
+
async logoutUrlFor(session) {
|
|
381
|
+
const hint = readLogoutHint(session?.get(LOGOUT_HINT_KEY));
|
|
382
|
+
if (hint === null || !this.#ready) return null;
|
|
383
|
+
let p;
|
|
384
|
+
try {
|
|
385
|
+
({provider: p} = await this.#resolveProvider(hint.provider));
|
|
386
|
+
} catch {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
if (p.createLogoutURL === void 0) return null;
|
|
390
|
+
return p.createLogoutURL({
|
|
391
|
+
idTokenHint: hint.idToken,
|
|
392
|
+
postLogoutRedirectUri: this.#postLogoutRedirectUri(hint.provider)
|
|
393
|
+
}).toString();
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Déconnexion demandée PAR le fournisseur sur le canal arrière (OpenID
|
|
397
|
+
* Connect Back-Channel Logout 1.0) : vérifie le jeton, refuse un rejeu, puis
|
|
398
|
+
* détruit les sessions locales qu'il désigne.
|
|
399
|
+
*
|
|
400
|
+
* Une session est désignée si elle a été ouverte par CE fournisseur (index
|
|
401
|
+
* fédéré posé au login par {@link rememberLogout}) et, selon le jeton : porte son `sid` ; ou,
|
|
402
|
+
* sans `sid`, appartient à l'utilisateur lié à son `sub`. Une session ouverte
|
|
403
|
+
* par mot de passe ou par un autre fournisseur n'est jamais touchée — le
|
|
404
|
+
* fournisseur ne ferme que ce qu'il a ouvert.
|
|
405
|
+
*
|
|
406
|
+
* @remarks Les connexions WebSocket portées par une session détruite se
|
|
407
|
+
* ferment au tick de revalidation du hub temps réel (code `4001`).
|
|
408
|
+
*
|
|
409
|
+
* @param provider - fournisseur désigné par l'adresse appelée.
|
|
410
|
+
* @param logoutToken - paramètre `logout_token` reçu, brut.
|
|
411
|
+
* @returns `unsupported` si le fournisseur ne sait pas émettre de jeton de
|
|
412
|
+
* déconnexion ; `refused` si le jeton est invalide ou rejoué ; sinon le
|
|
413
|
+
* nombre de sessions détruites (0 : déjà déconnecté, c'est un succès §2.7).
|
|
414
|
+
* @throws Error - vérification impossible (jeu de clés injoignable) ou
|
|
415
|
+
* sessions impossibles à parcourir : la déconnexion a ÉCHOUÉ.
|
|
416
|
+
*/
|
|
417
|
+
async backchannelLogout(provider, logoutToken) {
|
|
418
|
+
const { provider: p } = await this.#resolveProvider(provider);
|
|
419
|
+
if (p.verifyLogoutToken === void 0) return { outcome: "unsupported" };
|
|
420
|
+
const claims = await p.verifyLogoutToken(logoutToken);
|
|
421
|
+
if (claims === null || !this.#firstSeen(provider, claims)) return { outcome: "refused" };
|
|
422
|
+
let owner = null;
|
|
423
|
+
if (claims.subject !== null) {
|
|
424
|
+
const users = this.get("users");
|
|
425
|
+
try {
|
|
426
|
+
owner = (await users?.loadUserByOAuth?.(provider, claims.subject))?.identifier ?? null;
|
|
427
|
+
} catch {
|
|
428
|
+
owner = null;
|
|
429
|
+
}
|
|
430
|
+
if (owner === null && claims.sid === null) return {
|
|
431
|
+
outcome: "done",
|
|
432
|
+
destroyed: 0
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
const sessions = this.get("sessions");
|
|
436
|
+
if (sessions?.destroyWhere === void 0) throw new Error(`aucun service "sessions" capable de révocation groupée (destroyWhere)`);
|
|
437
|
+
const destroyed = await sessions.destroyWhere(owner !== null ? { user: owner } : void 0, (data) => {
|
|
438
|
+
if (owner !== null && data.user !== owner) return false;
|
|
439
|
+
const federation = readFederation(data.metaBag?.[FEDERATION_KEY]);
|
|
440
|
+
if (federation === null || federation.provider !== provider) return false;
|
|
441
|
+
return claims.sid === null || federation.sid === claims.sid;
|
|
442
|
+
});
|
|
443
|
+
this.log(`oauth2 back-channel logout "${provider}" — ${destroyed} session(s) fermée(s)` + (owner !== null ? ` user=${owner}` : ""), "INFO");
|
|
444
|
+
if (destroyed > 0) recordAudit(this.container, {
|
|
445
|
+
category: "session",
|
|
446
|
+
action: "session.revoked",
|
|
447
|
+
outcome: "success",
|
|
448
|
+
actor: `oauth2:${provider}`,
|
|
449
|
+
resource: owner,
|
|
450
|
+
reason: "backchannel_logout",
|
|
451
|
+
metadata: {
|
|
452
|
+
count: destroyed,
|
|
453
|
+
bySid: claims.sid !== null
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
return {
|
|
457
|
+
outcome: "done",
|
|
458
|
+
destroyed
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Anti-rejeu du canal arrière (§2.6, point 8) : `true` au premier passage
|
|
463
|
+
* d'un `jti`, `false` s'il a déjà été vu et n'a pas expiré.
|
|
464
|
+
*
|
|
465
|
+
* @remarks Mémoire PAR PROCESSUS, bornée : le même jeton rejoué sur un autre
|
|
466
|
+
* exemplaire passerait. Le rejeu n'y fermerait que des sessions que le
|
|
467
|
+
* fournisseur a déjà déclarées closes — la borne est la durée de vie du
|
|
468
|
+
* jeton, que le fournisseur garde courte (§4).
|
|
469
|
+
*/
|
|
470
|
+
#firstSeen(provider, claims) {
|
|
471
|
+
const now = Date.now();
|
|
472
|
+
const seen = this.#seenLogoutTokens ??= /* @__PURE__ */ new Map();
|
|
473
|
+
for (const [key, expiresAt] of seen) {
|
|
474
|
+
if (expiresAt > now && seen.size < MAX_SEEN_LOGOUT_TOKENS) break;
|
|
475
|
+
seen.delete(key);
|
|
476
|
+
}
|
|
477
|
+
const key = `${provider}\n${claims.tokenId}`;
|
|
478
|
+
const known = seen.get(key);
|
|
479
|
+
if (known !== void 0 && known > now) return false;
|
|
480
|
+
seen.set(key, claims.expiresAt * 1e3);
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Retour après déconnexion chez le fournisseur : la valeur configurée, sinon
|
|
485
|
+
* l'écran d'échec (la page de connexion) résolu contre `redirectUri` — une
|
|
486
|
+
* adresse ABSOLUE, sur une origine déjà enregistrée chez le fournisseur.
|
|
487
|
+
*
|
|
488
|
+
* @remarks Query et fragment de l'écran d'échec sont RETIRÉS : ils signalent
|
|
489
|
+
* un échec (`?error=oauth`), et un fournisseur OpenID Connect refuse une
|
|
490
|
+
* adresse de retour qui porte un paramètre du protocole (`error`, `code`…) —
|
|
491
|
+
* Keycloak répond `invalid_redirect_uri`.
|
|
492
|
+
*/
|
|
493
|
+
#postLogoutRedirectUri(provider) {
|
|
494
|
+
const cfg = this.#ensureReady().oauth2.providers[provider];
|
|
495
|
+
if (cfg?.postLogoutRedirectUri !== void 0) return cfg.postLogoutRedirectUri;
|
|
496
|
+
const { failure } = this.getRedirects(provider);
|
|
497
|
+
const url = new URL(failure, cfg?.redirectUri);
|
|
498
|
+
url.search = "";
|
|
499
|
+
url.hash = "";
|
|
500
|
+
return url.toString();
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Diagnostique les fournisseurs CONFIGURÉS sans connexion humaine : découverte
|
|
504
|
+
* et émetteur, URL de retour acceptée, secret accepté (cf
|
|
505
|
+
* `diagnoseOAuthProvider`).
|
|
506
|
+
*
|
|
507
|
+
* Le fournisseur est construit à NEUF, hors de la mémoïsation du login : un
|
|
508
|
+
* diagnostic doit voir le serveur tel qu'il est maintenant, et son échec ne
|
|
509
|
+
* doit pas retirer un bouton de l'écran de connexion.
|
|
510
|
+
*
|
|
511
|
+
* @param name - un seul fournisseur ; à défaut, tous ceux de la configuration.
|
|
512
|
+
* @returns un diagnostic par fournisseur ; vide si le social login est éteint.
|
|
513
|
+
*/
|
|
514
|
+
async diagnose(name) {
|
|
515
|
+
if (!this.#ready || this.#config === null) return [];
|
|
516
|
+
const providers = this.#config.oauth2.providers;
|
|
517
|
+
const names = name === void 0 ? Object.keys(providers) : [name];
|
|
518
|
+
const pageCheck = keycloakLoginThemeCheck(this.#config.loginPage.skin);
|
|
519
|
+
return Promise.all(names.map((n) => {
|
|
520
|
+
const cfg = Object.hasOwn(providers, n) ? providers[n] : void 0;
|
|
521
|
+
return diagnoseOAuthProvider({
|
|
522
|
+
name: n,
|
|
523
|
+
build: async () => (await this.#buildProvider(n)).provider,
|
|
524
|
+
clientId: cfg?.clientId ?? "",
|
|
525
|
+
redirectUri: cfg?.redirectUri ?? "",
|
|
526
|
+
clientAuthMethod: cfg?.clientAuthMethod,
|
|
527
|
+
scopes: cfg?.scopes,
|
|
528
|
+
pageCheck
|
|
529
|
+
});
|
|
530
|
+
}));
|
|
201
531
|
}
|
|
202
532
|
#resolveProvider(name) {
|
|
203
533
|
this.#ensureReady();
|
|
@@ -206,9 +536,31 @@ var OAuth2Service = class extends Service {
|
|
|
206
536
|
if (cached) return cached;
|
|
207
537
|
const pending = this.#buildProvider(name);
|
|
208
538
|
this.#providers.set(name, pending);
|
|
209
|
-
pending.
|
|
539
|
+
pending.then(() => {
|
|
540
|
+
if (this.#unreachable !== null && name in this.#unreachable) {
|
|
541
|
+
delete this.#unreachable[name];
|
|
542
|
+
this.log(`oauth2 provider "${name}" de nouveau joignable`, "INFO");
|
|
543
|
+
}
|
|
544
|
+
}, (error) => {
|
|
545
|
+
this.#providers?.delete(name);
|
|
546
|
+
this.#unreachable ??= Object.create(null);
|
|
547
|
+
this.#unreachable[name] = Date.now();
|
|
548
|
+
this.log(`oauth2 provider "${name}" indisponible — bouton retiré de l'écran de connexion : ${error instanceof Error ? error.message : String(error)}`, "WARNING");
|
|
549
|
+
});
|
|
210
550
|
return pending;
|
|
211
551
|
}
|
|
552
|
+
/**
|
|
553
|
+
* `true` si le fournisseur n'a pas échoué récemment. Un échec plus ancien que
|
|
554
|
+
* {@link UNREACHABLE_RETRY_MS} relance une construction en arrière-plan : sans
|
|
555
|
+
* elle, un bouton retiré ne reviendrait jamais, puisque plus personne ne
|
|
556
|
+
* pourrait cliquer pour déclencher la tentative suivante.
|
|
557
|
+
*/
|
|
558
|
+
#isReachable(name) {
|
|
559
|
+
const failedAt = this.#unreachable?.[name];
|
|
560
|
+
if (failedAt === void 0) return true;
|
|
561
|
+
if (Date.now() - failedAt >= UNREACHABLE_RETRY_MS && !this.#providers?.has(name)) this.#probe(name);
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
212
564
|
async #buildProvider(name) {
|
|
213
565
|
const providers = this.#ensureReady().oauth2.providers;
|
|
214
566
|
const cfg = Object.hasOwn(providers, name) ? providers[name] : void 0;
|
|
@@ -220,7 +572,8 @@ var OAuth2Service = class extends Service {
|
|
|
220
572
|
clientSecret: cfg.clientSecret,
|
|
221
573
|
clientAuthMethod: cfg.clientAuthMethod,
|
|
222
574
|
redirectUri: cfg.redirectUri,
|
|
223
|
-
issuer: cfg.issuer
|
|
575
|
+
issuer: cfg.issuer,
|
|
576
|
+
discoveryUrl: cfg.discoveryUrl
|
|
224
577
|
});
|
|
225
578
|
return {
|
|
226
579
|
provider,
|
|
@@ -238,4 +591,4 @@ var OAuth2Service = class extends Service {
|
|
|
238
591
|
}
|
|
239
592
|
};
|
|
240
593
|
//#endregion
|
|
241
|
-
export { OAuth2Service, OAuth2Service as default, oauthDisplayLabel };
|
|
594
|
+
export { OAuth2Service, OAuth2Service as default, checkProviderIssuer, oauthDisplayLabel };
|
|
@@ -20,6 +20,8 @@ const NONCE_PLACEHOLDER = "{{nonce}}";
|
|
|
20
20
|
var SecurityHeaders = class {
|
|
21
21
|
#headers;
|
|
22
22
|
#cspParts;
|
|
23
|
+
#cspPrefix;
|
|
24
|
+
#cspSuffix;
|
|
23
25
|
#cspRaw;
|
|
24
26
|
constructor(o) {
|
|
25
27
|
const h = {};
|
|
@@ -39,6 +41,13 @@ var SecurityHeaders = class {
|
|
|
39
41
|
if (o.permissionsPolicy) h["Permissions-Policy"] = o.permissionsPolicy;
|
|
40
42
|
this.#headers = Object.freeze(h);
|
|
41
43
|
this.#cspParts = cspParts ? Object.freeze(cspParts) : null;
|
|
44
|
+
if (cspParts?.length === 2) {
|
|
45
|
+
this.#cspPrefix = cspParts[0] ?? "";
|
|
46
|
+
this.#cspSuffix = cspParts[1] ?? "";
|
|
47
|
+
} else {
|
|
48
|
+
this.#cspPrefix = null;
|
|
49
|
+
this.#cspSuffix = "";
|
|
50
|
+
}
|
|
42
51
|
this.#cspRaw = o.csp;
|
|
43
52
|
}
|
|
44
53
|
/** Table d'en-têtes applicatifs CONSTANTS (figée), posée telle quelle par le firewall. */
|
|
@@ -51,13 +60,15 @@ var SecurityHeaders = class {
|
|
|
51
60
|
}
|
|
52
61
|
/**
|
|
53
62
|
* Recompose le CSP en injectant le `nonce` de la requête aux emplacements
|
|
54
|
-
* `{{nonce}}
|
|
63
|
+
* `{{nonce}}` : une concaténation s'il n'y en a qu'un, un `join` sinon (segments
|
|
64
|
+
* pré-split au boot). Le nonce est base64 (jamais
|
|
55
65
|
* `'`/`;`/espace) → aucune évasion possible du token CSP.
|
|
56
66
|
*
|
|
57
67
|
* @param nonce - nonce base64 de la requête (`Context.cspNonce`).
|
|
58
68
|
* @returns la valeur `Content-Security-Policy` à poser sur la réponse.
|
|
59
69
|
*/
|
|
60
70
|
cspFor(nonce) {
|
|
71
|
+
if (this.#cspPrefix !== null) return this.#cspPrefix + nonce + this.#cspSuffix;
|
|
61
72
|
return this.#cspParts.join(nonce);
|
|
62
73
|
}
|
|
63
74
|
/**
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
import { JwtKeystore } from "../src/token/JwtKeystore.js";
|
|
2
|
+
import { InsecureTransportError } from "../errors/InsecureTransportError.js";
|
|
1
3
|
import { AuthenticationError } from "../errors/AuthenticationError.js";
|
|
2
4
|
import { ThrottledError } from "../errors/ThrottledError.js";
|
|
3
5
|
import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
4
6
|
import { defaultAudience, resolveJwtRuntime } from "../src/token/jwtRuntime.js";
|
|
5
7
|
import { recordAudit } from "../src/audit/recordAudit.js";
|
|
8
|
+
import { readAuditContext } from "../src/audit/readAuditContext.js";
|
|
6
9
|
import { InvalidTargetError } from "../errors/InvalidTargetError.js";
|
|
7
10
|
import { getTokenStoreFactory, listTokenStores } from "../src/token/tokenStoreRegistry.js";
|
|
8
|
-
import {
|
|
9
|
-
import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, canonicalIssuer, durableStoreRemedy, readStoreConnector, readStoreLocation, refusedAdminScopes, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
11
|
+
import { AUTO_STORE, BootConfigurationError, EMPTY_INFRA, GcScheduler, Service, canonicalIssuer, durableStoreRemedy, readStoreConnector, readStoreLocation, refusedAdminScopes, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
10
12
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
11
13
|
//#region nodefony/service/tokenService.ts
|
|
12
14
|
const serviceName = "tokenService";
|
|
@@ -36,13 +38,46 @@ var TokenService = class extends Service {
|
|
|
36
38
|
#users = null;
|
|
37
39
|
#throttler = null;
|
|
38
40
|
#throttlerResolved = false;
|
|
41
|
+
#transport = null;
|
|
39
42
|
#gc = null;
|
|
40
43
|
constructor(module) {
|
|
41
44
|
super(serviceName, module.container, module.notificationsCenter, module.options);
|
|
42
45
|
this.module = module;
|
|
43
|
-
|
|
46
|
+
const tokenServiceBoot = () => this.#build();
|
|
47
|
+
this.kernel?.once("onBoot", tokenServiceBoot);
|
|
44
48
|
this.kernel?.once("onTerminate", () => this.#shutdown());
|
|
45
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Refuse de SERVIR en production sans clé de signature partagée.
|
|
52
|
+
*
|
|
53
|
+
* Sans `keySetJson` ni `dir`, le keystore tire une clé ÉPHÉMÈRE propre au
|
|
54
|
+
* process : derrière plusieurs pods ou workers, un jeton signé par l'un est
|
|
55
|
+
* refusé par l'autre (401 au hasard de la répartition), et chaque
|
|
56
|
+
* redémarrage invalide tous les jetons. Un avertissement au premier jeton ne
|
|
57
|
+
* suffisait pas — il tombait en production, loin de celui qui pouvait agir.
|
|
58
|
+
*
|
|
59
|
+
* Seul un run qui SERT est concerné (`runProfile.servers`) : une commande de
|
|
60
|
+
* console (`orm:migrate`, `security:user:add`) ne signe aucun jeton, et la
|
|
61
|
+
* bloquer casserait le job de migration pour rien.
|
|
62
|
+
*
|
|
63
|
+
* @throws BootConfigurationError - production, serveur, et aucune source.
|
|
64
|
+
*/
|
|
65
|
+
#requireSharedSigningKey(keystore) {
|
|
66
|
+
if (keystore.keySetJson || keystore.dir) return;
|
|
67
|
+
if (this.kernel?.environment !== "production") return;
|
|
68
|
+
if (!this.kernel.runProfile.servers) return;
|
|
69
|
+
throw new BootConfigurationError([
|
|
70
|
+
"[@nodefony/security] DÉMARRAGE REFUSÉ — aucune clé de signature des jetons partagée (jwt.keystore).",
|
|
71
|
+
" Pourquoi : chaque process signerait avec la sienne. Un jeton émis par un pod ou un worker",
|
|
72
|
+
" serait refusé par les autres (401 au hasard), et tous seraient perdus au redémarrage.",
|
|
73
|
+
" Que faire — une des trois :",
|
|
74
|
+
" 1. Poser NF_JWT_KEYSET (câblée sur jwt.keystore.keySetJson) : valeur générée UNE fois par",
|
|
75
|
+
" `npx nodefony security:secrets --jwt-keyset`, rangée dans le gestionnaire de secrets,",
|
|
76
|
+
" injectée dans chaque pod.",
|
|
77
|
+
" 2. Serveur unique à disque persistant : jwt.keystore.dir.",
|
|
78
|
+
" 3. L'application n'émet aucun jeton : jwt.enabled: false."
|
|
79
|
+
].join("\n"));
|
|
80
|
+
}
|
|
46
81
|
#build() {
|
|
47
82
|
let config;
|
|
48
83
|
try {
|
|
@@ -89,6 +124,7 @@ var TokenService = class extends Service {
|
|
|
89
124
|
});
|
|
90
125
|
this.container?.set("tokenStore", this.#store);
|
|
91
126
|
if (jwtEnabled) {
|
|
127
|
+
this.#requireSharedSigningKey(config.jwt.keystore);
|
|
92
128
|
this.#runtime = resolveJwtRuntime(config.jwt);
|
|
93
129
|
this.#keystore = new JwtKeystore(config.jwt.keystore, (m, s) => this.log(m, s));
|
|
94
130
|
this.container?.set("jwtKeystore", this.#keystore);
|
|
@@ -193,10 +229,24 @@ var TokenService = class extends Service {
|
|
|
193
229
|
* Émet un couple access/refresh après vérification d'un credential
|
|
194
230
|
* identifiant/mot de passe (grant M2M/CLI). Throttling NIST partagé si activé.
|
|
195
231
|
*
|
|
232
|
+
* @param context - requête HTTP qui porte le credential. Présente, elle
|
|
233
|
+
* soumet l'appel au refus du clair en production ; absente (CLI, appel
|
|
234
|
+
* programmatique), il n'y a pas de transport à juger. Un endpoint HTTP DOIT
|
|
235
|
+
* la passer.
|
|
236
|
+
* @throws InsecureTransportError (403) — credential reçu en clair en production.
|
|
196
237
|
* @throws ThrottledError (429) — backoff actif.
|
|
197
238
|
* @throws AuthenticationError (401, message uniforme) — credential invalide.
|
|
198
239
|
*/
|
|
199
|
-
async issueForCredentials(identifier, password, requestedScopes, resource) {
|
|
240
|
+
async issueForCredentials(identifier, password, requestedScopes, resource, context) {
|
|
241
|
+
if (context !== void 0) {
|
|
242
|
+
this.#transport ??= this.get("credentialTransport") ?? null;
|
|
243
|
+
try {
|
|
244
|
+
this.#transport?.assert(context, "token");
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (error instanceof InsecureTransportError) this.#auditGrant("login.failure", typeof identifier === "string" && identifier.length > 0 ? identifier : null, "insecure_transport", readAuditContext(context));
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
200
250
|
if (typeof identifier !== "string" || identifier.length === 0 || typeof password !== "string" || password.length === 0) {
|
|
201
251
|
this.#auditGrant("login.failure", typeof identifier === "string" && identifier.length > 0 ? identifier : null, "invalid_credentials");
|
|
202
252
|
throw new AuthenticationError("Invalid credentials");
|
|
@@ -218,13 +268,14 @@ var TokenService = class extends Service {
|
|
|
218
268
|
throttler?.recordSuccess(identifier);
|
|
219
269
|
return this.issueTokens(user, requestedScopes, resource);
|
|
220
270
|
}
|
|
221
|
-
#auditGrant(action, actor, reason) {
|
|
271
|
+
#auditGrant(action, actor, reason, info) {
|
|
222
272
|
recordAudit(this.container, {
|
|
223
273
|
category: "auth",
|
|
224
274
|
action,
|
|
225
275
|
outcome: "failure",
|
|
226
276
|
actor,
|
|
227
|
-
reason
|
|
277
|
+
reason,
|
|
278
|
+
...info
|
|
228
279
|
});
|
|
229
280
|
}
|
|
230
281
|
/**
|