@nodefony/security 10.0.0-beta.3 → 10.0.0-beta.5

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 (165) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  2. package/dist/index.js +6 -8
  3. package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +18 -11
  4. package/dist/nodefony/command/security-oauth-doctor.js +5 -3
  5. package/dist/nodefony/command/security-secrets.js +10 -4
  6. package/dist/nodefony/command/security-user-add.js +9 -3
  7. package/dist/nodefony/command/security-user-delete.js +5 -3
  8. package/dist/nodefony/command/security-user-list.js +5 -3
  9. package/dist/nodefony/command/security-user-password.js +5 -3
  10. package/dist/nodefony/config/config.js +20 -4
  11. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  12. package/dist/nodefony/contracts/IAccessVoter.js +1 -2
  13. package/dist/nodefony/errors/AccessDeniedError.js +1 -2
  14. package/dist/nodefony/errors/ApiKeyError.js +3 -2
  15. package/dist/nodefony/errors/AuthenticationError.js +1 -2
  16. package/dist/nodefony/errors/CsrfError.js +1 -2
  17. package/dist/nodefony/errors/InsecureTransportError.js +1 -2
  18. package/dist/nodefony/errors/InvalidTargetError.js +1 -2
  19. package/dist/nodefony/errors/SsrfError.js +1 -2
  20. package/dist/nodefony/errors/ThrottledError.js +1 -2
  21. package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
  22. package/dist/nodefony/errors/WebAuthnError.js +1 -2
  23. package/dist/nodefony/keycloak/keycloakAdmin.js +1 -2
  24. package/dist/nodefony/keycloak/keycloakRealm.js +1 -2
  25. package/dist/nodefony/keycloak/keycloakRealmInput.js +1 -2
  26. package/dist/nodefony/keycloak/scaffold.js +3 -3
  27. package/dist/nodefony/keycloak/security-keycloak-realm.js +5 -3
  28. package/dist/nodefony/scaffold/contribute.js +1 -2
  29. package/dist/nodefony/service/accessTokenVerifier.js +101 -34
  30. package/dist/nodefony/service/apiKeys.js +45 -5
  31. package/dist/nodefony/service/auditService.js +4 -5
  32. package/dist/nodefony/service/authFlow.js +10 -8
  33. package/dist/nodefony/service/authorization.js +1 -2
  34. package/dist/nodefony/service/cors.js +1 -2
  35. package/dist/nodefony/service/csrf.js +1 -2
  36. package/dist/nodefony/service/firewall.js +5 -4
  37. package/dist/nodefony/service/keycloak.js +1 -2
  38. package/dist/nodefony/service/oauth2.js +29 -7
  39. package/dist/nodefony/service/securityHeaders.js +1 -2
  40. package/dist/nodefony/service/tokenService.js +38 -22
  41. package/dist/nodefony/service/totp.js +4 -5
  42. package/dist/nodefony/service/webAuthn.js +4 -5
  43. package/dist/nodefony/service/webhooks.js +4 -5
  44. package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
  45. package/dist/nodefony/src/SecuredArea.js +1 -2
  46. package/dist/nodefony/src/accountBinding.js +96 -0
  47. package/dist/nodefony/src/admin/SecurityAdminApi.js +1 -2
  48. package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
  49. package/dist/nodefony/src/admin/adminAudit.js +1 -2
  50. package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
  51. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
  52. package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
  53. package/dist/nodefony/src/audit/auditBridge.js +1 -2
  54. package/dist/nodefony/src/audit/auditFilters.js +1 -2
  55. package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
  56. package/dist/nodefony/src/audit/readAuditContext.js +1 -2
  57. package/dist/nodefony/src/audit/recordAudit.js +1 -2
  58. package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
  59. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
  60. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +8 -113
  61. package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
  62. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +42 -4
  63. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +91 -16
  64. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
  65. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +1 -2
  66. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +3 -17
  67. package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
  68. package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
  69. package/dist/nodefony/src/boot/publicRoutesNotice.js +1 -2
  70. package/dist/nodefony/src/crypto/secretCipher.js +1 -2
  71. package/dist/nodefony/src/csp.js +1 -2
  72. package/dist/nodefony/src/csrfToken.js +1 -2
  73. package/dist/nodefony/src/entryPoint.js +1 -2
  74. package/dist/nodefony/src/net/ssrfGuard.js +1 -2
  75. package/dist/nodefony/src/net/transportFailure.js +57 -0
  76. package/dist/nodefony/src/oauth/clientCredentials.js +1 -2
  77. package/dist/nodefony/src/oauth/httpJson.js +3 -3
  78. package/dist/nodefony/src/oauth/metadata.js +5 -16
  79. package/dist/nodefony/src/oauth/oauth2Client.js +1 -2
  80. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +1 -2
  81. package/dist/nodefony/src/oauth/providerDiagnosis.js +5 -6
  82. package/dist/nodefony/src/oauth/providerRoles.js +1 -2
  83. package/dist/nodefony/src/oauth/providers/github.js +16 -6
  84. package/dist/nodefony/src/oauth/providers/oidc.js +9 -6
  85. package/dist/nodefony/src/openToSignup.js +1 -2
  86. package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
  87. package/dist/nodefony/src/realtime/frameAuthorizer.js +2 -3
  88. package/dist/nodefony/src/rolesWithin.js +57 -0
  89. package/dist/nodefony/src/sessionAuthentication.js +1 -2
  90. package/dist/nodefony/src/sessionIdentity.js +29 -3
  91. package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
  92. package/dist/nodefony/src/token/AnonymousToken.js +1 -2
  93. package/dist/nodefony/src/token/JwtKeystore.js +14 -5
  94. package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
  95. package/dist/nodefony/src/token/RemoteJwtVerifier.js +6 -3
  96. package/dist/nodefony/src/token/UserToken.js +1 -2
  97. package/dist/nodefony/src/token/jwtRuntime.js +1 -2
  98. package/dist/nodefony/src/token/scopeGrants.js +109 -0
  99. package/dist/nodefony/src/token/secretFile.js +1 -2
  100. package/dist/nodefony/src/token/tokenCriteria.js +1 -2
  101. package/dist/nodefony/src/token/tokenFilters.js +1 -2
  102. package/dist/nodefony/src/token/tokenSort.js +1 -2
  103. package/dist/nodefony/src/token/tokenStatus.js +1 -2
  104. package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
  105. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
  106. package/dist/nodefony/src/totp/totpCipher.js +1 -2
  107. package/dist/nodefony/src/totp/totpCrypto.js +1 -2
  108. package/dist/nodefony/src/totp/totpOperations.js +1 -2
  109. package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
  110. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +1 -2
  111. package/dist/nodefony/src/voter/RoleVoter.js +1 -2
  112. package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
  113. package/dist/nodefony/src/voter/voterRegistry.js +1 -2
  114. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
  115. package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
  116. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
  117. package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
  118. package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
  119. package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
  120. package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
  121. package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
  122. package/dist/nodefony/src/webhook/webhookSort.js +1 -2
  123. package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
  124. package/dist/nodefony/testing/index.js +108 -0
  125. package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +7 -6
  126. package/dist/types/nodefony/config/config.d.ts +1 -0
  127. package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
  128. package/dist/types/nodefony/service/accessTokenVerifier.d.ts +35 -14
  129. package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
  130. package/dist/types/nodefony/service/oauth2.d.ts +20 -0
  131. package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
  132. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +23 -5
  133. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -44
  134. package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
  135. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +4 -1
  136. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
  137. package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
  138. package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
  139. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
  140. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +2 -2
  141. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
  142. package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
  143. package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -0
  144. package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
  145. package/dist/types/nodefony/testing/index.d.ts +51 -0
  146. package/docs/api-keys.md +181 -174
  147. package/docs/audit.md +140 -140
  148. package/docs/authenticators.md +101 -101
  149. package/docs/authorization.md +116 -69
  150. package/docs/cors.md +64 -64
  151. package/docs/csrf.md +74 -71
  152. package/docs/external-jwt.md +22 -12
  153. package/docs/firewall.md +85 -82
  154. package/docs/headers.md +81 -81
  155. package/docs/index.md +4 -4
  156. package/docs/keycloak.md +95 -78
  157. package/docs/login-page.md +23 -23
  158. package/docs/oauth2.md +157 -146
  159. package/docs/obtenir-un-jeton.md +116 -26
  160. package/docs/tokens.md +128 -122
  161. package/docs/totp.md +136 -136
  162. package/docs/webauthn.md +153 -153
  163. package/docs/webhooks.md +136 -124
  164. package/package.json +19 -13
  165. package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
- //#region nodefony/src/apikey/apiKeyFormat.ts
2
+ // nodefony/src/apikey/apiKeyFormat.ts
3
3
  /**
4
4
  * Encodage / décodage / hachage des clés API (PAT) — logique **pure** (aucun I/O,
5
5
  * aucun container) → testable sans serveur et partagée par l'émetteur
@@ -103,5 +103,4 @@ function parseApiKey(token, prefix) {
103
103
  secretHash: hashApiKey(token)
104
104
  };
105
105
  }
106
- //#endregion
107
106
  export { generateApiKey, hashApiKey, looksLikeApiKey, parseApiKey };
@@ -1,5 +1,5 @@
1
1
  import { assertPageQuery } from "nodefony";
2
- //#region nodefony/src/audit/MemoryAuditStore.ts
2
+ // nodefony/src/audit/MemoryAuditStore.ts
3
3
  const DEFAULT_MAX_ENTRIES = 1e4;
4
4
  const MAX_LIMIT = 500;
5
5
  /**
@@ -113,5 +113,4 @@ var MemoryAuditStore = class {
113
113
  for (const event of snapshot.events) this.#events.push(event);
114
114
  }
115
115
  };
116
- //#endregion
117
116
  export { MemoryAuditStore };
@@ -1,5 +1,5 @@
1
1
  import { PLATFORM_CHANNELS } from "nodefony";
2
- //#region nodefony/src/audit/auditBridge.ts
2
+ // nodefony/src/audit/auditBridge.ts
3
3
  /**
4
4
  * Canal WS du flux live d'audit (P6.14 lot 4). Le préfixe `security:` le place
5
5
  * sous le plancher `SECURITY_CHANNEL_POLICY` (ROLE_NODEFONY_ADMIN) du verrou de
@@ -82,5 +82,4 @@ function createAuditBridge(source, publish, channel, opts = {}) {
82
82
  unsubscribe();
83
83
  };
84
84
  }
85
- //#endregion
86
85
  export { SECURITY_AUDIT_CHANNEL, createAuditBridge, createAuditBridge as default };
@@ -1,4 +1,4 @@
1
- //#region nodefony/src/audit/auditFilters.ts
1
+ // nodefony/src/audit/auditFilters.ts
2
2
  /**
3
3
  * **Le vocabulaire de filtre du journal d'audit**, en noms PUBLICS — ceux qu'un
4
4
  * auditeur écrit dans l'URL (`?category=authz&outcome=denied&since=…`).
@@ -56,5 +56,4 @@ const AUDIT_FILTERS = {
56
56
  * les deux à jour », qui avait échoué en silence.
57
57
  */
58
58
  const AUDIT_FILTER_VOCABULARY_IS_COMPLETE = [true, true];
59
- //#endregion
60
59
  export { AUDIT_FILTERS, AUDIT_FILTER_VOCABULARY_IS_COMPLETE };
@@ -1,5 +1,5 @@
1
1
  import { MemoryAuditStore } from "./MemoryAuditStore.js";
2
- //#region nodefony/src/audit/auditStoreRegistry.ts
2
+ // nodefony/src/audit/auditStoreRegistry.ts
3
3
  const factories = /* @__PURE__ */ new Map();
4
4
  /**
5
5
  * Enregistre (ou remplace) la fabrique d'un store d'audit. Appelée par le builtin
@@ -21,5 +21,4 @@ registerAuditStore("memory", (ctx) => {
21
21
  const days = ctx?.config?.audit?.retentionDays;
22
22
  return typeof days === "number" ? new MemoryAuditStore(Date.now, days * MS_PER_DAY) : new MemoryAuditStore();
23
23
  });
24
- //#endregion
25
24
  export { getAuditStoreFactory, listAuditStores, registerAuditStore };
@@ -1,4 +1,4 @@
1
- //#region nodefony/src/audit/readAuditContext.ts
1
+ // nodefony/src/audit/readAuditContext.ts
2
2
  /**
3
3
  * Extrait IP / User-Agent / requestId + drapeaux de présence (jamais la valeur)
4
4
  * d'un contexte de requête — pour enrichir un événement d'audit avec la
@@ -20,5 +20,4 @@ function readAuditContext(context) {
20
20
  }
21
21
  };
22
22
  }
23
- //#endregion
24
23
  export { readAuditContext };
@@ -1,4 +1,4 @@
1
- //#region nodefony/src/audit/recordAudit.ts
1
+ // nodefony/src/audit/recordAudit.ts
2
2
  /**
3
3
  * Émet un événement d'audit **si** le service est présent — no-op sinon. La
4
4
  * résolution se fait par le container (`auditService`) sur le **cold-path**
@@ -12,5 +12,4 @@
12
12
  function recordAudit(container, event) {
13
13
  (container?.get("auditService"))?.record(event);
14
14
  }
15
- //#endregion
16
15
  export { recordAudit };
@@ -1,5 +1,5 @@
1
1
  import { AnonymousToken } from "../token/AnonymousToken.js";
2
- //#region nodefony/src/authenticator/AnonymousAuthenticator.ts
2
+ // nodefony/src/authenticator/AnonymousAuthenticator.ts
3
3
  /**
4
4
  * Acceptation EXPLICITE de l'anonymat dans une zone — le seul authenticator
5
5
  * autorisé à produire un token non authentifié sans déclencher le Zero Trust.
@@ -32,5 +32,4 @@ var AnonymousAuthenticator = class {
32
32
  return Promise.resolve();
33
33
  }
34
34
  };
35
- //#endregion
36
35
  export { AnonymousAuthenticator, AnonymousAuthenticator as default };
@@ -1,8 +1,9 @@
1
1
  import { AuthenticationError } from "../../errors/AuthenticationError.js";
2
+ import { isBoundToAccount } from "../accountBinding.js";
2
3
  import { UserToken } from "../token/UserToken.js";
3
4
  import { bearerToken } from "./bearer.js";
4
5
  import { looksLikeApiKey, parseApiKey } from "../apikey/apiKeyFormat.js";
5
- //#region nodefony/src/authenticator/ApiKeyAuthenticator.ts
6
+ // nodefony/src/authenticator/ApiKeyAuthenticator.ts
6
7
  const INVALID_TOKEN = "Invalid token";
7
8
  /**
8
9
  * Marqueur interne posé par `authenticate()` et consommé par `onSuccess()` :
@@ -81,6 +82,7 @@ var ApiKeyAuthenticator = class {
81
82
  const invalidBefore = await store.getInvalidBefore(record.subjectId);
82
83
  if (invalidBefore !== null && record.createdAt < invalidBefore) throw new AuthenticationError(INVALID_TOKEN);
83
84
  const user = await this.#resolveUserOrReject(record.subjectId);
85
+ if (!isBoundToAccount(user, record.metadata["accountId"], record.metadata["credentialsVersion"])) throw new AuthenticationError(INVALID_TOKEN);
84
86
  const last = record.lastUsedAt;
85
87
  if (this.#throttleMs === 0 || last === null || now - last >= this.#throttleMs) token.setAttribute(MARK_USED_AT, now);
86
88
  const ut = token;
@@ -160,5 +162,4 @@ var ApiKeyAuthenticator = class {
160
162
  return this.#userProvider;
161
163
  }
162
164
  };
163
- //#endregion
164
165
  export { ApiKeyAuthenticator, ApiKeyAuthenticator as default };
@@ -1,13 +1,11 @@
1
- import { mapProviderRoles } from "../oauth/providerRoles.js";
2
1
  import { AuthenticationError } from "../../errors/AuthenticationError.js";
3
2
  import { UnverifiableTokenError } from "../../errors/UnverifiableTokenError.js";
4
3
  import { UserToken } from "../token/UserToken.js";
5
4
  import { bearerToken } from "./bearer.js";
6
5
  import peekIssuer from "./peekIssuer.js";
7
- import { localIdentifierFor } from "./externalSubject.js";
8
- import { ACCESS_TOKEN_VERIFIER, buildBearerChallenge, canonicalIssuer, protectedResourceMetadataUrl } from "nodefony";
9
- import { BaseUser } from "@nodefony/user";
10
- //#region nodefony/src/authenticator/ExternalJwtAuthenticator.ts
6
+ import { ExternalSubjectResolver } from "./ExternalSubjectResolver.js";
7
+ import { ACCESS_TOKEN_VERIFIER, buildBearerChallenge, protectedResourceMetadataUrl } from "nodefony";
8
+ // nodefony/src/authenticator/ExternalJwtAuthenticator.ts
11
9
  /**
12
10
  * Message UNIFORME de refus — la cause fine (expiré, audience, signature, sujet
13
11
  * inconnu) part au journal, jamais au client : un refus détaillé est un oracle
@@ -61,31 +59,15 @@ const SUBJECT_AREA = "subjectArea";
61
59
  var ExternalJwtAuthenticator = class {
62
60
  name = "external-jwt";
63
61
  #container;
64
- #issuers;
65
- #policy;
66
- #ephemeralRoles;
67
- /** Émetteur canonique → fournisseur de connexion ; `null` = aucun lien. */
68
- #linkedProviders;
69
- /** Fournisseur de connexion → table des rôles ; `null` = aucune table. */
70
- #roleMappings;
71
- #userProvider = null;
62
+ /** Rattachement du sujet à un compte local — partagé, jamais recopié. */
63
+ #subjects;
72
64
  /**
73
65
  * @param container - container DI (résolution lazy du vérificateur et de `users`)
74
66
  * @param options - émetteurs reconnus et politique de rattachement
75
67
  */
76
68
  constructor(container, options) {
77
69
  this.#container = container;
78
- const issuers = /* @__PURE__ */ new Map();
79
- for (const binding of options.issuers) try {
80
- issuers.set(canonicalIssuer(binding.issuer), binding.subjectMapping);
81
- } catch {
82
- continue;
83
- }
84
- this.#issuers = issuers;
85
- this.#policy = options.subjectPolicy;
86
- this.#ephemeralRoles = options.ephemeralRoles;
87
- this.#linkedProviders = linkProviders(issuers, options.oauthProviders);
88
- this.#roleMappings = collectRoleMappings(options.oauthProviders);
70
+ this.#subjects = new ExternalSubjectResolver(container, options);
89
71
  }
90
72
  /**
91
73
  * La requête porte-t-elle un jeton se réclamant d'un émetteur de confiance ?
@@ -101,12 +83,7 @@ var ExternalJwtAuthenticator = class {
101
83
  if (raw === null) return false;
102
84
  const issuer = peekIssuer(raw);
103
85
  if (issuer === null) return false;
104
- if (this.#issuers.has(issuer)) return true;
105
- try {
106
- return this.#issuers.has(canonicalIssuer(issuer));
107
- } catch {
108
- return false;
109
- }
86
+ return this.#subjects.recognizes(issuer);
110
87
  }
111
88
  /**
112
89
  * Extrait le jeton brut ET l'audience de la zone.
@@ -147,9 +124,7 @@ var ExternalJwtAuthenticator = class {
147
124
  if (principal === null) throw new AuthenticationError(INVALID_TOKEN);
148
125
  const subject = principal.subject;
149
126
  if (!subject) throw new AuthenticationError(INVALID_TOKEN);
150
- const mapping = this.#issuers.get(principal.issuer);
151
- if (mapping === void 0) throw new UnverifiableTokenError(`émetteur « ${principal.issuer} » vérifié mais absent de la table de rattachement des sujets — configuration incohérente.`);
152
- const user = await this.#resolveUser(principal.issuer, subject, mapping, principal.claims, token.getAttribute(SUBJECT_AREA));
127
+ const user = await this.#subjects.resolve(principal.issuer, subject, principal.claims, token.getAttribute(SUBJECT_AREA));
153
128
  const ut = token;
154
129
  ut.promote(user);
155
130
  ut.setAttribute("scopes", [...principal.scopes]);
@@ -200,85 +175,5 @@ var ExternalJwtAuthenticator = class {
200
175
  validateArea(area) {
201
176
  if (!area.resource) throw new Error(`area "${area.name}": l'authenticator "${this.name}" exige que la zone déclare sa ressource (\`resource\`) — c'est l'audience que le jeton doit porter (RFC 8707 §2). Sans elle, un jeton valide délivré au même porteur pour un AUTRE service serait accepté ici.`);
202
177
  }
203
- /**
204
- * Établit l'utilisateur applicatif à partir du sujet du jeton.
205
- *
206
- * Les deux politiques répondent à deux questions différentes : « qui, chez
207
- * nous, est cette personne ? » et « quel pouvoir accorde-t-on à une machine
208
- * que l'annuaire a authentifiée ? ».
209
- */
210
- async #resolveUser(issuer, subject, mapping, claims, area) {
211
- const identifier = localIdentifierFor(issuer, subject, mapping);
212
- if ((area?.subjectPolicy ?? this.#policy) === "ephemeral") return new BaseUser({
213
- id: identifier,
214
- identifier,
215
- roles: [...area?.ephemeralRoles ?? this.#ephemeralRoles]
216
- });
217
- const provider = this.#resolveUserProvider();
218
- let user = null;
219
- const linked = this.#linkedProviders?.get(issuer);
220
- if (linked !== void 0) try {
221
- const roleMapping = this.#roleMappings?.get(linked);
222
- user = roleMapping !== void 0 && canSyncRoles(provider) ? await provider.syncOAuthRoles(linked, subject, mapProviderRoles(roleMapping, claims)) : await provider.loadUserByOAuth(linked, subject);
223
- } catch {}
224
- if (user === null) try {
225
- user = await provider.loadUserByIdentifier(identifier);
226
- } catch {
227
- throw new AuthenticationError(INVALID_TOKEN);
228
- }
229
- if (!user.isActive() || user.isLocked()) throw new AuthenticationError(INVALID_TOKEN);
230
- return user;
231
- }
232
- #resolveUserProvider() {
233
- if (this.#userProvider === null) {
234
- const provider = this.#container.get("users");
235
- if (!provider) throw new Error("ExternalJwtAuthenticator: aucun service \"users\" (IUserProvider) dans le container — enregistrer un UserService au boot, ou passer la politique de sujet à \"ephemeral\" si l'appelant est une machine sans compte local.");
236
- this.#userProvider = provider;
237
- }
238
- return this.#userProvider;
239
- }
240
178
  };
241
- /** Le fournisseur d'utilisateurs sait-il recalculer les rôles gérés par un annuaire ? */
242
- function canSyncRoles(provider) {
243
- return typeof provider.syncOAuthRoles === "function";
244
- }
245
- /** Tables de rôles par fournisseur de connexion — `null` quand aucun n'en déclare. */
246
- function collectRoleMappings(providers) {
247
- if (!providers) return null;
248
- let mappings = null;
249
- for (const { name, roleMapping } of providers) {
250
- if (roleMapping == null) continue;
251
- mappings ??= /* @__PURE__ */ new Map();
252
- mappings.set(name, roleMapping);
253
- }
254
- return mappings;
255
- }
256
- /**
257
- * Associe chaque émetteur de confiance au fournisseur de connexion qui partage
258
- * son émetteur canonique.
259
- *
260
- * Un émetteur revendiqué par DEUX fournisseurs n'est lié à aucun : choisir l'un
261
- * des deux ferait dépendre l'identité de l'ordre de la configuration.
262
- *
263
- * @returns la table, ou `null` quand aucun émetteur n'est lié (rien à chercher).
264
- */
265
- function linkProviders(trusted, providers) {
266
- if (!providers || providers.length === 0) return null;
267
- const links = /* @__PURE__ */ new Map();
268
- const ambiguous = /* @__PURE__ */ new Set();
269
- for (const { name, issuer } of providers) {
270
- let canonical;
271
- try {
272
- canonical = canonicalIssuer(issuer);
273
- } catch {
274
- continue;
275
- }
276
- if (!trusted.has(canonical)) continue;
277
- if (links.has(canonical)) ambiguous.add(canonical);
278
- links.set(canonical, name);
279
- }
280
- for (const issuer of ambiguous) links.delete(issuer);
281
- return links.size > 0 ? links : null;
282
- }
283
- //#endregion
284
179
  export { ExternalJwtAuthenticator, ExternalJwtAuthenticator as default };
@@ -0,0 +1,180 @@
1
+ import { compileProviderRoleMapping, mapProviderRoles } from "../oauth/providerRoles.js";
2
+ import { AuthenticationError } from "../../errors/AuthenticationError.js";
3
+ import { UnverifiableTokenError } from "../../errors/UnverifiableTokenError.js";
4
+ import { localIdentifierFor } from "./externalSubject.js";
5
+ import { canonicalIssuer } from "nodefony";
6
+ import { BaseUser } from "@nodefony/user";
7
+ // nodefony/src/authenticator/ExternalSubjectResolver.ts
8
+ /**
9
+ * Message UNIFORME de refus — la cause fine (sujet inconnu, compte verrouillé)
10
+ * part au journal, jamais au client : un refus détaillé est un oracle.
11
+ */
12
+ const INVALID_TOKEN = "Invalid token";
13
+ /**
14
+ * Les options de rattachement des sujets externes, lues dans la configuration
15
+ * de sécurité — UNE lecture, pour la zone `external-jwt` comme pour le
16
+ * résolveur de compte d'une ressource protégée.
17
+ *
18
+ * @param config - configuration de sécurité validée
19
+ * @returns émetteurs, politique de sujet, fournisseurs de connexion liables
20
+ */
21
+ function externalSubjectOptions(config) {
22
+ const rs = config.resourceServer;
23
+ return {
24
+ issuers: rs.issuers.map((i) => ({
25
+ issuer: i.issuer,
26
+ subjectMapping: i.subjectMapping
27
+ })),
28
+ subjectPolicy: rs.subjectPolicy,
29
+ ephemeralRoles: rs.ephemeralRoles,
30
+ oauthProviders: Object.entries(config.oauth2.providers).flatMap(([name, p]) => p.issuer ? [{
31
+ name,
32
+ issuer: p.issuer,
33
+ roleMapping: compileProviderRoleMapping(p)
34
+ }] : [])
35
+ };
36
+ }
37
+ /**
38
+ * Établit l'utilisateur applicatif que désigne le sujet d'un jeton TIERS —
39
+ * la décision qui transforme « un annuaire a authentifié ce sujet » en « ce
40
+ * compte agit chez nous ».
41
+ *
42
+ * UNE implémentation, deux consommateurs : la zone `external-jwt` du pare-feu
43
+ * (`ExternalJwtAuthenticator`) et le résolveur de compte d'une ressource
44
+ * protégée (porte MCP). Recopiée, la règle d'espace de noms aurait divergé dès
45
+ * le premier émetteur ajouté — et c'est elle qui empêche un annuaire tiers de
46
+ * réclamer le compte local `admin`.
47
+ */
48
+ var ExternalSubjectResolver = class {
49
+ #container;
50
+ #issuers;
51
+ #policy;
52
+ #ephemeralRoles;
53
+ /** Émetteur canonique → fournisseur de connexion ; `null` = aucun lien. */
54
+ #linkedProviders;
55
+ /** Fournisseur de connexion → table des rôles ; `null` = aucune table. */
56
+ #roleMappings;
57
+ #userProvider = null;
58
+ /**
59
+ * @param container - container DI (résolution lazy de `users`)
60
+ * @param options - émetteurs reconnus et politique de rattachement
61
+ */
62
+ constructor(container, options) {
63
+ this.#container = container;
64
+ const issuers = /* @__PURE__ */ new Map();
65
+ for (const binding of options.issuers) try {
66
+ issuers.set(canonicalIssuer(binding.issuer), binding.subjectMapping);
67
+ } catch {
68
+ continue;
69
+ }
70
+ this.#issuers = issuers;
71
+ this.#policy = options.subjectPolicy;
72
+ this.#ephemeralRoles = options.ephemeralRoles;
73
+ this.#linkedProviders = linkProviders(issuers, options.oauthProviders);
74
+ this.#roleMappings = collectRoleMappings(options.oauthProviders);
75
+ }
76
+ /**
77
+ * L'émetteur revendiqué est-il l'un des nôtres ? Ne lève JAMAIS : la valeur
78
+ * vient d'un jeton que n'importe qui peut forger.
79
+ *
80
+ * @param issuer - émetteur lu dans le jeton, non vérifié
81
+ */
82
+ recognizes(issuer) {
83
+ if (this.#issuers.has(issuer)) return true;
84
+ try {
85
+ return this.#issuers.has(canonicalIssuer(issuer));
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+ /**
91
+ * Établit l'utilisateur applicatif à partir du sujet d'un jeton VÉRIFIÉ.
92
+ *
93
+ * Les deux politiques répondent à deux questions différentes : « qui, chez
94
+ * nous, est cette personne ? » et « quel pouvoir accorde-t-on à une machine
95
+ * que l'annuaire a authentifiée ? ».
96
+ *
97
+ * @param issuer - émetteur canonique, tel que le vérificateur l'a établi
98
+ * @param subject - sujet brut (`sub`) chez cet émetteur
99
+ * @param claims - revendications du jeton (rôles d'annuaire)
100
+ * @param area - zone qui SURCHARGE la politique, s'il y en a une
101
+ * @throws AuthenticationError (401) — sujet sans compte local utilisable
102
+ * @throws UnverifiableTokenError (503) — émetteur vérifié mais absent de la
103
+ * table de rattachement : configuration incohérente
104
+ */
105
+ async resolve(issuer, subject, claims, area) {
106
+ const mapping = this.#issuers.get(issuer);
107
+ if (mapping === void 0) throw new UnverifiableTokenError(`émetteur « ${issuer} » vérifié mais absent de la table de rattachement des sujets — configuration incohérente.`);
108
+ const identifier = localIdentifierFor(issuer, subject, mapping);
109
+ if ((area?.subjectPolicy ?? this.#policy) === "ephemeral") return new BaseUser({
110
+ id: identifier,
111
+ identifier,
112
+ roles: [...area?.ephemeralRoles ?? this.#ephemeralRoles]
113
+ });
114
+ const provider = this.#resolveUserProvider();
115
+ let user = null;
116
+ const linked = this.#linkedProviders?.get(issuer);
117
+ if (linked !== void 0) try {
118
+ const roleMapping = this.#roleMappings?.get(linked);
119
+ user = roleMapping !== void 0 && canSyncRoles(provider) ? await provider.syncOAuthRoles(linked, subject, mapProviderRoles(roleMapping, claims)) : await provider.loadUserByOAuth(linked, subject);
120
+ } catch {}
121
+ if (user === null) try {
122
+ user = await provider.loadUserByIdentifier(identifier);
123
+ } catch {
124
+ throw new AuthenticationError(INVALID_TOKEN);
125
+ }
126
+ if (!user.isActive() || user.isLocked()) throw new AuthenticationError(INVALID_TOKEN);
127
+ return user;
128
+ }
129
+ #resolveUserProvider() {
130
+ if (this.#userProvider === null) {
131
+ const provider = this.#container.get("users");
132
+ if (!provider) throw new Error("ExternalJwtAuthenticator: aucun service \"users\" (IUserProvider) dans le container — enregistrer un UserService au boot, ou passer la politique de sujet à \"ephemeral\" si l'appelant est une machine sans compte local.");
133
+ this.#userProvider = provider;
134
+ }
135
+ return this.#userProvider;
136
+ }
137
+ };
138
+ /** Le fournisseur d'utilisateurs sait-il recalculer les rôles gérés par un annuaire ? */
139
+ function canSyncRoles(provider) {
140
+ return typeof provider.syncOAuthRoles === "function";
141
+ }
142
+ /** Tables de rôles par fournisseur de connexion — `null` quand aucun n'en déclare. */
143
+ function collectRoleMappings(providers) {
144
+ if (!providers) return null;
145
+ let mappings = null;
146
+ for (const { name, roleMapping } of providers) {
147
+ if (roleMapping == null) continue;
148
+ mappings ??= /* @__PURE__ */ new Map();
149
+ mappings.set(name, roleMapping);
150
+ }
151
+ return mappings;
152
+ }
153
+ /**
154
+ * Associe chaque émetteur de confiance au fournisseur de connexion qui partage
155
+ * son émetteur canonique.
156
+ *
157
+ * Un émetteur revendiqué par DEUX fournisseurs n'est lié à aucun : choisir l'un
158
+ * des deux ferait dépendre l'identité de l'ordre de la configuration.
159
+ *
160
+ * @returns la table, ou `null` quand aucun émetteur n'est lié (rien à chercher).
161
+ */
162
+ function linkProviders(trusted, providers) {
163
+ if (!providers || providers.length === 0) return null;
164
+ const links = /* @__PURE__ */ new Map();
165
+ const ambiguous = /* @__PURE__ */ new Set();
166
+ for (const { name, issuer } of providers) {
167
+ let canonical;
168
+ try {
169
+ canonical = canonicalIssuer(issuer);
170
+ } catch {
171
+ continue;
172
+ }
173
+ if (!trusted.has(canonical)) continue;
174
+ if (links.has(canonical)) ambiguous.add(canonical);
175
+ links.set(canonical, name);
176
+ }
177
+ for (const issuer of ambiguous) links.delete(issuer);
178
+ return links.size > 0 ? links : null;
179
+ }
180
+ export { ExternalSubjectResolver, externalSubjectOptions };
@@ -1,8 +1,9 @@
1
1
  import { AuthenticationError } from "../../errors/AuthenticationError.js";
2
+ import { isBoundToAccount } from "../accountBinding.js";
2
3
  import { UserRealtimeToken } from "../realtime/UserRealtimeToken.js";
3
4
  import { Nodefony, RequestContext } from "nodefony";
4
5
  import { authenticatedUser } from "@nodefony/user";
5
- //#region nodefony/src/authenticator/FirewallRealtimeAuthenticator.ts
6
+ // nodefony/src/authenticator/FirewallRealtimeAuthenticator.ts
6
7
  /**
7
8
  * Authenticator realtime des identités résolues par le **firewall** — équivalent
8
9
  * WS de tout ce que le pipeline HTTP sait authentifier.
@@ -57,12 +58,21 @@ var FirewallRealtimeAuthenticator = class {
57
58
  */
58
59
  #resolveStore;
59
60
  /**
61
+ * Accès paresseux à l'annuaire des comptes, pour confirmer avant chaque
62
+ * `api.request` que le compte de la socket existe toujours sous le même `id`.
63
+ * Absent (`null`) → aucune confirmation (seul `isValid` fait foi).
64
+ */
65
+ #resolveUsers;
66
+ /**
60
67
  * @param resolveStore - fournit le store de révocation des jetons (le firewall
61
68
  * passe une closure sur son container). Omis → mode dégradé documenté :
62
69
  * seules les bornes portées par le jeton lui-même sont vérifiables.
70
+ * @param resolveUsers - fournit l'annuaire des comptes (`users`). Omis → pas
71
+ * de confirmation du compte avant `api.request`.
63
72
  */
64
- constructor(resolveStore = null) {
73
+ constructor(resolveStore = null, resolveUsers = null) {
65
74
  this.#resolveStore = resolveStore;
75
+ this.#resolveUsers = resolveUsers;
66
76
  }
67
77
  /** Une identité authentifiée a-t-elle été résolue (par le firewall) au handshake ? */
68
78
  supports(_handshake) {
@@ -82,7 +92,7 @@ var FirewallRealtimeAuthenticator = class {
82
92
  const type = issuedType(issued);
83
93
  const revalidate = this.#revalidatorFor(user, issued, type);
84
94
  const scopes = issued ? issued.getScopes() : void 0;
85
- return new UserRealtimeToken(user, revalidate, type, scopes);
95
+ return new UserRealtimeToken(user, revalidate, type, scopes, this.#accountConfirmationFor(user, type));
86
96
  }
87
97
  /**
88
98
  * Le revalidateur de l'identité que le firewall a posée sur la requête
@@ -106,6 +116,29 @@ var FirewallRealtimeAuthenticator = class {
106
116
  * Choisit la preuve de révocation selon le mode d'authentification : la
107
117
  * session se relit, un jeton porteur se borne par `exp` et la denylist.
108
118
  */
119
+ /**
120
+ * Confirmation du compte, pour les modes dont l'APPLICATION émet l'artefact
121
+ * (session, JWT maison, clé d'API) : le compte relu doit exister, être actif
122
+ * et porter le même `id` immuable qu'au handshake — sinon il a disparu ou un
123
+ * autre compte a repris son identifiant hors du framework (#139). Un sujet
124
+ * `external-jwt` peut être éphémère, sans compte local : il n'est pas
125
+ * confirmé. `null` = rien à confirmer.
126
+ */
127
+ #accountConfirmationFor(user, type) {
128
+ const resolveUsers = this.#resolveUsers;
129
+ if (resolveUsers === null || !ACCOUNT_CONFIRMED_TYPES.has(type)) return null;
130
+ return async () => {
131
+ const users = resolveUsers();
132
+ if (users === null) return true;
133
+ let current;
134
+ try {
135
+ current = await users.loadUserByIdentifier(user.identifier);
136
+ } catch {
137
+ return false;
138
+ }
139
+ return current.isActive() && !current.isLocked() && isBoundToAccount(current, user.id, user.credentialsVersion);
140
+ };
141
+ }
109
142
  #revalidatorFor(user, issued, type) {
110
143
  return type === "session" ? buildSessionRevalidator(user.identifier) : this.#buildBearerRevalidator(issued, user.identifier, type);
111
144
  }
@@ -150,6 +183,12 @@ var FirewallRealtimeAuthenticator = class {
150
183
  }
151
184
  };
152
185
  const EMPTY_CLAIMS = Object.freeze({});
186
+ /** Modes dont l'application émet l'artefact : leur compte est local, donc relisible. */
187
+ const ACCOUNT_CONFIRMED_TYPES = /* @__PURE__ */ new Set([
188
+ "session",
189
+ "jwt",
190
+ "apikey"
191
+ ]);
153
192
  /** Claim temporel JWT (secondes, RFC 7519) → epoch ms, ou `null` si absent/invalide. */
154
193
  function secondsToMs(value) {
155
194
  return typeof value === "number" && Number.isFinite(value) ? value * 1e3 : null;
@@ -193,5 +232,4 @@ function buildSessionRevalidator(identifier) {
193
232
  }
194
233
  };
195
234
  }
196
- //#endregion
197
235
  export { FirewallRealtimeAuthenticator, FirewallRealtimeAuthenticator as default };