@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.
Files changed (164) hide show
  1. package/README.md +7 -5
  2. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/index.js +44 -10
  5. package/dist/nodefony/command/security-oauth-doctor.js +66 -0
  6. package/dist/nodefony/command/security-secrets.js +42 -25
  7. package/dist/nodefony/command/security-token.js +46 -15
  8. package/dist/nodefony/command/security-user-delete.js +5 -4
  9. package/dist/nodefony/command/security-user-password.js +4 -11
  10. package/dist/nodefony/config/config.js +80 -5
  11. package/dist/nodefony/contracts/index.js +1 -2
  12. package/dist/nodefony/errors/InsecureTransportError.js +24 -0
  13. package/dist/nodefony/errors/index.js +2 -1
  14. package/dist/nodefony/keycloak/keycloakAdmin.js +255 -0
  15. package/dist/nodefony/keycloak/keycloakRealm.js +260 -0
  16. package/dist/nodefony/keycloak/keycloakRealmInput.js +124 -0
  17. package/dist/nodefony/keycloak/scaffold.js +231 -0
  18. package/dist/nodefony/keycloak/security-keycloak-realm.js +128 -0
  19. package/dist/nodefony/scaffold/contribute.js +20 -0
  20. package/dist/nodefony/service/authFlow.js +111 -11
  21. package/dist/nodefony/service/firewall.js +111 -21
  22. package/dist/nodefony/service/keycloak.js +209 -0
  23. package/dist/nodefony/service/oauth2.js +379 -26
  24. package/dist/nodefony/service/securityHeaders.js +12 -1
  25. package/dist/nodefony/service/tokenService.js +57 -6
  26. package/dist/nodefony/service/totp.js +51 -6
  27. package/dist/nodefony/service/webAuthn.js +20 -2
  28. package/dist/nodefony/src/SecuredArea.js +14 -3
  29. package/dist/nodefony/src/admin/SecurityAdminApi.js +70 -40
  30. package/dist/nodefony/src/admin/adminAudit.js +0 -1
  31. package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
  32. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +66 -6
  33. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +38 -15
  34. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +1 -0
  35. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +13 -3
  36. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +9 -2
  37. package/dist/nodefony/src/authenticator/bearer.js +1 -2
  38. package/dist/nodefony/src/boot/publicRoutesNotice.js +27 -0
  39. package/dist/nodefony/src/entryPoint.js +39 -0
  40. package/dist/nodefony/src/oauth/clientCredentials.js +40 -0
  41. package/dist/nodefony/src/oauth/metadata.js +36 -9
  42. package/dist/nodefony/src/oauth/oauth2Client.js +60 -33
  43. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +39 -6
  44. package/dist/nodefony/src/oauth/providerDiagnosis.js +283 -0
  45. package/dist/nodefony/src/oauth/providerRoles.js +79 -0
  46. package/dist/nodefony/src/oauth/providers/github.js +32 -6
  47. package/dist/nodefony/src/oauth/providers/oidc.js +82 -3
  48. package/dist/nodefony/src/openToSignup.js +47 -0
  49. package/dist/nodefony/src/sessionAuthentication.js +26 -0
  50. package/dist/nodefony/src/token/JwtKeystore.js +78 -46
  51. package/dist/nodefony/src/token/RemoteJwtVerifier.js +40 -13
  52. package/dist/nodefony/src/token/secretFile.js +59 -2
  53. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +51 -0
  54. package/dist/types/index.d.ts +26 -6
  55. package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
  56. package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
  57. package/dist/types/nodefony/command/security-token.d.ts +9 -0
  58. package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
  59. package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
  60. package/dist/types/nodefony/config/config.d.ts +63 -1
  61. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
  62. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
  63. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
  64. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
  65. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
  66. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
  67. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
  68. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
  69. package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
  70. package/dist/types/nodefony/errors/index.d.ts +1 -0
  71. package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
  72. package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
  73. package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
  74. package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
  75. package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
  76. package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
  77. package/dist/types/nodefony/service/authFlow.d.ts +35 -3
  78. package/dist/types/nodefony/service/firewall.d.ts +17 -3
  79. package/dist/types/nodefony/service/keycloak.d.ts +115 -0
  80. package/dist/types/nodefony/service/oauth2.d.ts +125 -9
  81. package/dist/types/nodefony/service/securityHeaders.d.ts +2 -1
  82. package/dist/types/nodefony/service/tokenService.d.ts +7 -1
  83. package/dist/types/nodefony/service/totp.d.ts +28 -4
  84. package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
  85. package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
  86. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +54 -12
  87. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +18 -0
  88. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +13 -0
  89. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
  90. package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
  91. package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
  92. package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
  93. package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
  94. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
  95. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
  96. package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
  97. package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
  98. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
  99. package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
  100. package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
  101. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
  102. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
  103. package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
  104. package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
  105. package/docs/api-keys.md +18 -18
  106. package/docs/audit.md +24 -22
  107. package/docs/authenticators.md +62 -29
  108. package/docs/authorization.md +16 -16
  109. package/docs/cors.md +30 -30
  110. package/docs/csrf.md +35 -33
  111. package/docs/external-jwt.md +68 -5
  112. package/docs/firewall.md +78 -45
  113. package/docs/headers.md +35 -35
  114. package/docs/index.md +15 -7
  115. package/docs/keycloak.md +818 -0
  116. package/docs/lexique.md +15 -15
  117. package/docs/login-page.md +361 -0
  118. package/docs/oauth2.md +242 -125
  119. package/docs/obtenir-un-jeton.md +18 -11
  120. package/docs/tokens.md +138 -86
  121. package/docs/totp.md +113 -71
  122. package/docs/webauthn.md +115 -115
  123. package/docs/webhooks.md +21 -21
  124. package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
  125. package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
  126. package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
  127. package/keycloak/themes/nodefony/account/theme.properties +14 -0
  128. package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
  129. package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
  130. package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
  131. package/keycloak/themes/nodefony/admin/theme.properties +12 -0
  132. package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
  133. package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
  134. package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
  135. package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
  136. package/keycloak/themes/nodefony/email/theme.properties +8 -0
  137. package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
  138. package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
  139. package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
  140. package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
  141. package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
  142. package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
  143. package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
  144. package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
  145. package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
  146. package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
  147. package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
  148. package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
  149. package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
  150. package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
  151. package/keycloak/themes/nodefony/login/template.ftl +423 -0
  152. package/keycloak/themes/nodefony/login/theme.properties +17 -0
  153. package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
  154. package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
  155. package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
  156. package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
  157. package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
  158. package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
  159. package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
  160. package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
  161. package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
  162. package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
  163. package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
  164. 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 { Service } from "nodefony";
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 navigateur
66
- * (le login produit une **session BFF**, gérée hors de ce service par le
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 **au premier login** (cold path — jamais au boot
70
- * ni par requête) puis mémoïsés : c'est là que les points d'entrée d'un émetteur
71
- * OIDC sont découverts, une seule fois par processus. Au boot (si `oauth2.enabled`)
72
- * : seule la config est validée et les fournisseurs configurés sont confrontés au
73
- * registre (un nom inconnu = WARNING, pas fatal).
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
- this.kernel?.once("onBoot", () => this.#build());
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
- label: configured[name]?.label ?? oauthDisplayLabel(name)
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 ?? "/login"
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
- return { identifier: (await this.#resolveProvisioner().provisionOAuthUser(profile, {
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
- })).identifier };
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.catch(() => this.#providers?.delete(name));
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}}`. 1 `join` (segments pré-split au boot). Le nonce est base64 (jamais
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 { JwtKeystore } from "../src/token/JwtKeystore.js";
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
- this.kernel?.once("onBoot", () => this.#build());
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
  /**