@nodefony/security 10.0.0-beta.2 → 10.0.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/index.js +48 -16
- package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +62 -24
- package/dist/nodefony/command/security-oauth-doctor.js +68 -0
- package/dist/nodefony/command/security-secrets.js +52 -29
- package/dist/nodefony/command/security-user-add.js +9 -3
- package/dist/nodefony/command/security-user-delete.js +10 -7
- package/dist/nodefony/command/security-user-list.js +5 -3
- package/dist/nodefony/command/security-user-password.js +9 -14
- package/dist/nodefony/config/config.js +99 -8
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/contracts/IAccessVoter.js +1 -2
- package/dist/nodefony/contracts/index.js +1 -2
- package/dist/nodefony/errors/AccessDeniedError.js +1 -2
- package/dist/nodefony/errors/ApiKeyError.js +3 -2
- package/dist/nodefony/errors/AuthenticationError.js +1 -2
- package/dist/nodefony/errors/CsrfError.js +1 -2
- package/dist/nodefony/errors/InsecureTransportError.js +23 -0
- package/dist/nodefony/errors/InvalidTargetError.js +1 -2
- package/dist/nodefony/errors/SsrfError.js +1 -2
- package/dist/nodefony/errors/ThrottledError.js +1 -2
- package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
- package/dist/nodefony/errors/WebAuthnError.js +1 -2
- package/dist/nodefony/errors/index.js +2 -1
- package/dist/nodefony/keycloak/keycloakAdmin.js +254 -0
- package/dist/nodefony/keycloak/keycloakRealm.js +259 -0
- package/dist/nodefony/keycloak/keycloakRealmInput.js +123 -0
- package/dist/nodefony/keycloak/scaffold.js +231 -0
- package/dist/nodefony/keycloak/security-keycloak-realm.js +130 -0
- package/dist/nodefony/scaffold/contribute.js +19 -0
- package/dist/nodefony/service/accessTokenVerifier.js +101 -34
- package/dist/nodefony/service/apiKeys.js +45 -5
- package/dist/nodefony/service/auditService.js +4 -5
- package/dist/nodefony/service/authFlow.js +118 -16
- package/dist/nodefony/service/authorization.js +1 -2
- package/dist/nodefony/service/cors.js +1 -2
- package/dist/nodefony/service/csrf.js +1 -2
- package/dist/nodefony/service/firewall.js +116 -25
- package/dist/nodefony/service/keycloak.js +208 -0
- package/dist/nodefony/service/oauth2.js +403 -28
- package/dist/nodefony/service/securityHeaders.js +1 -2
- package/dist/nodefony/service/tokenService.js +94 -27
- package/dist/nodefony/service/totp.js +55 -11
- package/dist/nodefony/service/webAuthn.js +24 -7
- package/dist/nodefony/service/webhooks.js +4 -5
- package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
- package/dist/nodefony/src/SecuredArea.js +15 -5
- package/dist/nodefony/src/accountBinding.js +96 -0
- package/dist/nodefony/src/admin/SecurityAdminApi.js +71 -42
- package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
- package/dist/nodefony/src/admin/adminAudit.js +1 -3
- package/dist/nodefony/src/admin/userRevocationCascade.js +148 -20
- package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
- package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
- package/dist/nodefony/src/audit/auditBridge.js +1 -2
- package/dist/nodefony/src/audit/auditFilters.js +1 -2
- package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
- package/dist/nodefony/src/audit/readAuditContext.js +1 -2
- package/dist/nodefony/src/audit/recordAudit.js +1 -2
- package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +14 -59
- package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +80 -19
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +92 -16
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +14 -5
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +5 -12
- package/dist/nodefony/src/authenticator/bearer.js +1 -2
- package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
- package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
- package/dist/nodefony/src/boot/publicRoutesNotice.js +26 -0
- package/dist/nodefony/src/crypto/secretCipher.js +1 -2
- package/dist/nodefony/src/csp.js +1 -2
- package/dist/nodefony/src/csrfToken.js +1 -2
- package/dist/nodefony/src/entryPoint.js +38 -0
- package/dist/nodefony/src/net/ssrfGuard.js +1 -2
- package/dist/nodefony/src/net/transportFailure.js +57 -0
- package/dist/nodefony/src/oauth/clientCredentials.js +39 -0
- package/dist/nodefony/src/oauth/httpJson.js +3 -3
- package/dist/nodefony/src/oauth/metadata.js +28 -12
- package/dist/nodefony/src/oauth/oauth2Client.js +61 -35
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +40 -8
- package/dist/nodefony/src/oauth/providerDiagnosis.js +282 -0
- package/dist/nodefony/src/oauth/providerRoles.js +78 -0
- package/dist/nodefony/src/oauth/providers/github.js +48 -12
- package/dist/nodefony/src/oauth/providers/oidc.js +90 -8
- package/dist/nodefony/src/openToSignup.js +46 -0
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
- package/dist/nodefony/src/realtime/frameAuthorizer.js +1 -2
- package/dist/nodefony/src/rolesWithin.js +57 -0
- package/dist/nodefony/src/sessionAuthentication.js +25 -0
- package/dist/nodefony/src/sessionIdentity.js +29 -3
- package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
- package/dist/nodefony/src/token/AnonymousToken.js +1 -2
- package/dist/nodefony/src/token/JwtKeystore.js +89 -48
- package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +46 -16
- package/dist/nodefony/src/token/UserToken.js +1 -2
- package/dist/nodefony/src/token/jwtRuntime.js +1 -2
- package/dist/nodefony/src/token/scopeGrants.js +109 -0
- package/dist/nodefony/src/token/secretFile.js +60 -4
- package/dist/nodefony/src/token/tokenCriteria.js +1 -2
- package/dist/nodefony/src/token/tokenFilters.js +1 -2
- package/dist/nodefony/src/token/tokenSort.js +1 -2
- package/dist/nodefony/src/token/tokenStatus.js +1 -2
- package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
- package/dist/nodefony/src/totp/totpCipher.js +1 -2
- package/dist/nodefony/src/totp/totpCrypto.js +1 -2
- package/dist/nodefony/src/totp/totpOperations.js +1 -2
- package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
- package/dist/nodefony/src/transport/CredentialTransportPolicy.js +50 -0
- package/dist/nodefony/src/voter/RoleVoter.js +1 -2
- package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
- package/dist/nodefony/src/voter/voterRegistry.js +1 -2
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
- package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
- package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
- package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
- package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
- package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
- package/dist/nodefony/src/webhook/webhookSort.js +1 -2
- package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
- package/dist/types/index.d.ts +26 -6
- package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +16 -6
- package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
- package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
- package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
- package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
- package/dist/types/nodefony/config/config.d.ts +64 -1
- package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
- package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
- package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
- package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
- package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
- package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
- package/dist/types/nodefony/errors/index.d.ts +1 -0
- package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
- package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
- package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
- package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
- package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
- package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
- package/dist/types/nodefony/service/accessTokenVerifier.d.ts +35 -14
- package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
- package/dist/types/nodefony/service/authFlow.d.ts +35 -3
- package/dist/types/nodefony/service/firewall.d.ts +17 -3
- package/dist/types/nodefony/service/keycloak.d.ts +115 -0
- package/dist/types/nodefony/service/oauth2.d.ts +145 -9
- package/dist/types/nodefony/service/tokenService.d.ts +7 -1
- package/dist/types/nodefony/service/totp.d.ts +28 -4
- package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
- package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
- package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +72 -12
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -26
- package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +17 -1
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
- package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
- package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
- package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
- package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
- package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
- package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
- package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
- package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
- package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
- package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
- package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
- package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
- package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
- package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
- package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -0
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
- package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
- package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
- package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
- package/docs/api-keys.md +182 -175
- package/docs/audit.md +141 -141
- package/docs/authenticators.md +129 -96
- package/docs/authorization.md +116 -69
- package/docs/cors.md +64 -64
- package/docs/csrf.md +77 -72
- package/docs/external-jwt.md +83 -11
- package/docs/firewall.md +119 -83
- package/docs/headers.md +82 -82
- package/docs/index.md +16 -8
- package/docs/keycloak.md +835 -0
- package/docs/login-page.md +361 -0
- package/docs/oauth2.md +291 -166
- package/docs/obtenir-un-jeton.md +131 -34
- package/docs/tokens.md +197 -140
- package/docs/totp.md +177 -136
- package/docs/webauthn.md +157 -154
- package/docs/webhooks.md +128 -125
- package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
- package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/account/theme.properties +14 -0
- package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
- package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/admin/theme.properties +12 -0
- package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
- package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
- package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
- package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/email/theme.properties +8 -0
- package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
- package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
- package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
- package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
- package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/login/template.ftl +423 -0
- package/keycloak/themes/nodefony/login/theme.properties +17 -0
- package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
- package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
- package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
- package/package.json +19 -17
- package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AuthenticationError } from "../../errors/AuthenticationError.js";
|
|
2
2
|
import { ThrottledError } from "../../errors/ThrottledError.js";
|
|
3
3
|
import { UserToken } from "../token/UserToken.js";
|
|
4
|
-
|
|
4
|
+
// nodefony/src/authenticator/UserPasswordAuthenticator.ts
|
|
5
5
|
const BASIC_SCHEME = /^basic\s+/i;
|
|
6
6
|
const INVALID_CREDENTIALS = "Invalid credentials";
|
|
7
7
|
/**
|
|
@@ -24,22 +24,32 @@ var UserPasswordAuthenticator = class {
|
|
|
24
24
|
#verifier = null;
|
|
25
25
|
#resolveVerifier;
|
|
26
26
|
#throttler;
|
|
27
|
+
#transport;
|
|
27
28
|
/**
|
|
28
29
|
* @param resolveVerifier - résolution lazy de la source de vérification
|
|
29
30
|
* (typiquement `container.get("users")`) — appelée au premier login.
|
|
30
31
|
* @param throttler - limiteur de tentatives (backoff NIST), `null` = désactivé.
|
|
32
|
+
* @param transport - politique du transport des secrets, `null` = aucune.
|
|
31
33
|
*/
|
|
32
|
-
constructor(resolveVerifier, throttler = null) {
|
|
34
|
+
constructor(resolveVerifier, throttler = null, transport = null) {
|
|
33
35
|
this.#resolveVerifier = resolveVerifier;
|
|
34
36
|
this.#throttler = throttler;
|
|
37
|
+
this.#transport = transport;
|
|
35
38
|
}
|
|
36
39
|
/** La requête porte-t-elle un en-tête `Authorization: Basic ...` ? */
|
|
37
40
|
supports(context) {
|
|
38
41
|
const auth = context.request?.headers.authorization;
|
|
39
42
|
return typeof auth === "string" && BASIC_SCHEME.test(auth);
|
|
40
43
|
}
|
|
41
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Décode l'enveloppe Basic — un contenu malformé donne un credential vide
|
|
46
|
+
* (échec uniforme).
|
|
47
|
+
*
|
|
48
|
+
* @throws InsecureTransportError (403) — en production, en-tête reçu hors
|
|
49
|
+
* TLS : refusé avant même d'être décodé.
|
|
50
|
+
*/
|
|
42
51
|
createToken(context) {
|
|
52
|
+
this.#transport?.assert(context, "basic");
|
|
43
53
|
const auth = context.request?.headers.authorization;
|
|
44
54
|
const decoded = Buffer.from(auth.replace(BASIC_SCHEME, ""), "base64").toString("utf8");
|
|
45
55
|
const sep = decoded.indexOf(":");
|
|
@@ -86,10 +96,9 @@ var UserPasswordAuthenticator = class {
|
|
|
86
96
|
onFailure(_context, _error) {
|
|
87
97
|
return Promise.resolve();
|
|
88
98
|
}
|
|
89
|
-
/** Challenge RFC
|
|
99
|
+
/** Challenge RFC 9110 §11 posé par le firewall sur les 401 de la zone. */
|
|
90
100
|
challenge() {
|
|
91
101
|
return "Basic realm=\"nodefony\", charset=\"UTF-8\"";
|
|
92
102
|
}
|
|
93
103
|
};
|
|
94
|
-
//#endregion
|
|
95
104
|
export { UserPasswordAuthenticator, UserPasswordAuthenticator as default };
|
|
@@ -3,9 +3,10 @@ import { SessionAuthenticator } from "./SessionAuthenticator.js";
|
|
|
3
3
|
import { UserPasswordAuthenticator } from "./UserPasswordAuthenticator.js";
|
|
4
4
|
import { JwtAuthenticator } from "./JwtAuthenticator.js";
|
|
5
5
|
import { ApiKeyAuthenticator } from "./ApiKeyAuthenticator.js";
|
|
6
|
+
import { externalSubjectOptions } from "./ExternalSubjectResolver.js";
|
|
6
7
|
import { ExternalJwtAuthenticator } from "./ExternalJwtAuthenticator.js";
|
|
7
8
|
import { resolveJwtRuntime } from "../token/jwtRuntime.js";
|
|
8
|
-
|
|
9
|
+
// nodefony/src/authenticator/authenticatorRegistry.ts
|
|
9
10
|
const factories = /* @__PURE__ */ new Map();
|
|
10
11
|
/**
|
|
11
12
|
* Enregistre (ou remplace) la fabrique d'un authenticator. Appelé par les
|
|
@@ -26,11 +27,12 @@ function listAuthenticatorFactories() {
|
|
|
26
27
|
registerAuthenticatorFactory("anonymous", () => new AnonymousAuthenticator());
|
|
27
28
|
registerAuthenticatorFactory("userpassword", ({ container }) => {
|
|
28
29
|
const throttler = container.get("loginThrottler") ?? null;
|
|
30
|
+
const transport = container.get("credentialTransport") ?? null;
|
|
29
31
|
return new UserPasswordAuthenticator(() => {
|
|
30
32
|
const verifier = container.get("users");
|
|
31
33
|
if (!verifier) throw new Error("UserPasswordAuthenticator: aucun service \"users\" (IPasswordVerifier) dans le container — enregistrer un UserService au boot de l'application.");
|
|
32
34
|
return verifier;
|
|
33
|
-
}, throttler);
|
|
35
|
+
}, throttler, transport);
|
|
34
36
|
});
|
|
35
37
|
registerAuthenticatorFactory("session", ({ container }) => {
|
|
36
38
|
return new SessionAuthenticator(() => {
|
|
@@ -43,15 +45,7 @@ registerAuthenticatorFactory("jwt", ({ container, config }) => {
|
|
|
43
45
|
return new JwtAuthenticator(container, resolveJwtRuntime(config.jwt));
|
|
44
46
|
});
|
|
45
47
|
registerAuthenticatorFactory("external-jwt", ({ container, config }) => {
|
|
46
|
-
|
|
47
|
-
return new ExternalJwtAuthenticator(container, {
|
|
48
|
-
issuers: rs.issuers.map((i) => ({
|
|
49
|
-
issuer: i.issuer,
|
|
50
|
-
subjectMapping: i.subjectMapping
|
|
51
|
-
})),
|
|
52
|
-
subjectPolicy: rs.subjectPolicy,
|
|
53
|
-
ephemeralRoles: rs.ephemeralRoles
|
|
54
|
-
});
|
|
48
|
+
return new ExternalJwtAuthenticator(container, externalSubjectOptions(config));
|
|
55
49
|
});
|
|
56
50
|
registerAuthenticatorFactory("apikey", ({ container, config }) => {
|
|
57
51
|
return new ApiKeyAuthenticator(container, {
|
|
@@ -59,5 +53,4 @@ registerAuthenticatorFactory("apikey", ({ container, config }) => {
|
|
|
59
53
|
lastUsedThrottleS: config.apiKeys.lastUsedThrottleS
|
|
60
54
|
});
|
|
61
55
|
});
|
|
62
|
-
//#endregion
|
|
63
56
|
export { getAuthenticatorFactory, listAuthenticatorFactories, registerAuthenticatorFactory };
|
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export { bearerToken };
|
|
1
|
+
export { bearerToken } from "nodefony";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// nodefony/src/authenticator/externalSubject.ts
|
|
2
2
|
/**
|
|
3
3
|
* Séparateur entre l'émetteur et le sujet.
|
|
4
4
|
*
|
|
@@ -32,5 +32,4 @@ function localIdentifierFor(issuer, subject, mapping) {
|
|
|
32
32
|
if (mapping === "subject") return subject;
|
|
33
33
|
return `${issuer}${SEPARATOR}${subject}`;
|
|
34
34
|
}
|
|
35
|
-
//#endregion
|
|
36
35
|
export { localIdentifierFor };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// nodefony/src/authenticator/peekIssuer.ts
|
|
2
2
|
/**
|
|
3
3
|
* Lecture NON VÉRIFIÉE de l'émetteur d'un JWS compact.
|
|
4
4
|
*
|
|
@@ -52,5 +52,4 @@ function peekIssuer(raw) {
|
|
|
52
52
|
return null;
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
-
//#endregion
|
|
56
55
|
export { peekIssuer as default, peekIssuer };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { isFrameworkZone } from "nodefony";
|
|
2
|
+
// nodefony/src/boot/publicRoutesNotice.ts
|
|
3
|
+
/**
|
|
4
|
+
* Le point d'attention « routes métier publiques » du bilan de démarrage.
|
|
5
|
+
*
|
|
6
|
+
* Hors d'une zone du pare-feu, une requête n'est jamais authentifiée : son
|
|
7
|
+
* identité n'est pas résolue, et une route métier qu'on croyait protégée
|
|
8
|
+
* répond à n'importe qui. Les seules zones présentes sont alors les aires du
|
|
9
|
+
* framework (`/nodefony/*`) ; on le DIT au démarrage, avec la recette.
|
|
10
|
+
*
|
|
11
|
+
* La classification « applicative ou framework » n'est pas recopiée : elle
|
|
12
|
+
* vient du cœur (`isFrameworkZone`), qui l'applique aussi au tableau des zones.
|
|
13
|
+
*
|
|
14
|
+
* @param zones - zones montées (`firewall.describe().zones`).
|
|
15
|
+
* @returns le point à déclarer, ou `null` si une zone applicative existe.
|
|
16
|
+
*/
|
|
17
|
+
function publicRoutesNotice(zones) {
|
|
18
|
+
if (zones.some((z) => !isFrameworkZone(z))) return null;
|
|
19
|
+
return {
|
|
20
|
+
code: "FIREWALL_PUBLIC_ROUTES",
|
|
21
|
+
level: "warning",
|
|
22
|
+
message: "Tes routes métier sont PUBLIQUES — aucune zone de pare-feu ne les couvre",
|
|
23
|
+
fix: "use(\"@nodefony/security\", { areas: { main: { pattern: \"^/api\", authenticators: [\"session\"] } } })"
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export { publicRoutesNotice };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto";
|
|
2
2
|
import { Buffer } from "node:buffer";
|
|
3
|
-
|
|
3
|
+
// nodefony/src/crypto/secretCipher.ts
|
|
4
4
|
/**
|
|
5
5
|
* Chiffrement **réversible** de secrets applicatifs au repos — AES-256-GCM
|
|
6
6
|
* (authentifié). Brique générique partagée par tous les domaines qui doivent
|
|
@@ -75,5 +75,4 @@ function decryptSecret(blob, key) {
|
|
|
75
75
|
decipher.setAuthTag(tag);
|
|
76
76
|
return Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
77
77
|
}
|
|
78
|
-
//#endregion
|
|
79
78
|
export { decryptSecret, deriveKey, encryptSecret, generateEphemeralKey };
|
package/dist/nodefony/src/csp.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// nodefony/src/csp.ts
|
|
2
2
|
/**
|
|
3
3
|
* Parse une chaîne CSP en directives ordonnées `[nom, sources[]]`. L'ordre des
|
|
4
4
|
* directives et des sources est préservé (déterminisme : header stable, tests
|
|
@@ -49,5 +49,4 @@ function mergeCspFragments(base, fragments) {
|
|
|
49
49
|
}
|
|
50
50
|
return serializeCsp(directives);
|
|
51
51
|
}
|
|
52
|
-
//#endregion
|
|
53
52
|
export { mergeCspFragments, parseCsp, serializeCsp };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
-
|
|
2
|
+
// nodefony/src/csrfToken.ts
|
|
3
3
|
/**
|
|
4
4
|
* Jeton synchronizer CSRF — modèle **double-submit signé** (OWASP CSRF Prevention
|
|
5
5
|
* Cheat Sheet, « Signed Double-Submit Cookie »). Le token est
|
|
@@ -61,5 +61,4 @@ var CsrfTokenManager = class CsrfTokenManager {
|
|
|
61
61
|
return timingSafeEqual(ba, bb);
|
|
62
62
|
}
|
|
63
63
|
};
|
|
64
|
-
//#endregion
|
|
65
64
|
export { CsrfTokenManager, CsrfTokenManager as default };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { loginPagePath, safeRedirectPath } from "nodefony";
|
|
2
|
+
// nodefony/src/entryPoint.ts
|
|
3
|
+
/**
|
|
4
|
+
* Dit si une requête refusée est la NAVIGATION d'un navigateur — le seul cas
|
|
5
|
+
* où conduire à la page de connexion aide, au lieu de casser un client.
|
|
6
|
+
*
|
|
7
|
+
* `GET` ou `HEAD`, `Accept` qui demande du HTML, et `Sec-Fetch-Mode: navigate`
|
|
8
|
+
* quand l'en-tête est présent : un `fetch()` d'une page porte `cors` ou
|
|
9
|
+
* `same-origin` même s'il accepte du HTML, et recevrait une page de connexion
|
|
10
|
+
* à la place de ses données.
|
|
11
|
+
*
|
|
12
|
+
* @param request - la requête refusée
|
|
13
|
+
* @returns `true` pour une navigation
|
|
14
|
+
*/
|
|
15
|
+
function isBrowserNavigation(request) {
|
|
16
|
+
const method = request.method?.toUpperCase();
|
|
17
|
+
if (method !== "GET" && method !== "HEAD") return false;
|
|
18
|
+
if (request.accept?.toLowerCase().includes("text/html") !== true) return false;
|
|
19
|
+
return request.fetchMode === void 0 || request.fetchMode.toLowerCase() === "navigate";
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* L'adresse de la page de connexion pour un visiteur refusé :
|
|
23
|
+
* `<entryPoint>?from=<chemin demandé>`, composée par `loginPagePath` (cœur).
|
|
24
|
+
* Le chemin demandé repasse par `safeRedirectPath` — la seule garde
|
|
25
|
+
* anti-redirection ouverte du framework, que la page de connexion appliquera
|
|
26
|
+
* de nouveau au retour.
|
|
27
|
+
*
|
|
28
|
+
* @param entryPoint - chemin de la page de connexion (vérifié au boot)
|
|
29
|
+
* @param request - la requête refusée
|
|
30
|
+
* @returns l'adresse du `Location`, ou `null` quand ce n'est pas une
|
|
31
|
+
* navigation, ou quand la page demandée EST la page de connexion
|
|
32
|
+
*/
|
|
33
|
+
function entryPointLocation(entryPoint, request) {
|
|
34
|
+
if (!isBrowserNavigation(request)) return null;
|
|
35
|
+
if (request.path.split("?", 1)[0] === entryPoint.split("?", 1)[0]) return null;
|
|
36
|
+
return loginPagePath(safeRedirectPath(request.path), entryPoint);
|
|
37
|
+
}
|
|
38
|
+
export { entryPointLocation, isBrowserNavigation };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { SsrfError } from "../../errors/SsrfError.js";
|
|
2
2
|
import { BlockList, isIP } from "node:net";
|
|
3
3
|
import { lookup } from "node:dns/promises";
|
|
4
|
-
|
|
4
|
+
// nodefony/src/net/ssrfGuard.ts
|
|
5
5
|
/**
|
|
6
6
|
* Protection **SSRF** (Server-Side Request Forgery) générique pour les URL
|
|
7
7
|
* sortantes — endpoints webhook, fetch applicatif, futurs agents/RAG.
|
|
@@ -126,5 +126,4 @@ async function assertPublicUrl(rawUrl, options = {}) {
|
|
|
126
126
|
addresses
|
|
127
127
|
};
|
|
128
128
|
}
|
|
129
|
-
//#endregion
|
|
130
129
|
export { assertPublicUrl, isBlockedAddress };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// nodefony/src/net/transportFailure.ts
|
|
2
|
+
/**
|
|
3
|
+
* Codes TLS d'une chaîne de certificats qu'aucune autorité CONNUE ne signe.
|
|
4
|
+
*
|
|
5
|
+
* Leur remède est le même : faire reconnaître l'autorité. Un certificat expiré
|
|
6
|
+
* (`CERT_HAS_EXPIRED`) ou émis pour un autre nom (`ERR_TLS_CERT_ALTNAME_INVALID`)
|
|
7
|
+
* n'en fait pas partie : ajouter une autorité n'y changerait rien.
|
|
8
|
+
*/
|
|
9
|
+
const UNTRUSTED_AUTHORITY_CODES = /* @__PURE__ */ new Set([
|
|
10
|
+
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
11
|
+
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
12
|
+
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
13
|
+
"UNABLE_TO_GET_ISSUER_CERT",
|
|
14
|
+
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
|
|
15
|
+
"CERT_UNTRUSTED"
|
|
16
|
+
]);
|
|
17
|
+
/** Le code porté par la `cause` d'un échec de `fetch`, s'il y en a un. */
|
|
18
|
+
function causeCode(error) {
|
|
19
|
+
if (!(error instanceof Error)) return null;
|
|
20
|
+
const cause = error.cause;
|
|
21
|
+
if (!(cause instanceof Error)) return null;
|
|
22
|
+
const code = cause.code;
|
|
23
|
+
return typeof code === "string" ? code : null;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Rend un échec de transport LISIBLE : `fetch` (undici) ne dit que
|
|
27
|
+
* `fetch failed` et range la vraie raison dans `cause` — certificat refusé,
|
|
28
|
+
* connexion refusée, nom introuvable.
|
|
29
|
+
*
|
|
30
|
+
* @param error - ce que `fetch` (ou une bibliothèque qui l'appelle) a levé
|
|
31
|
+
* @returns `message (CODE : raison)`, ou le message seul faute de cause
|
|
32
|
+
*/
|
|
33
|
+
function describeTransportFailure(error) {
|
|
34
|
+
if (!(error instanceof Error)) return String(error);
|
|
35
|
+
const cause = error.cause;
|
|
36
|
+
if (!(cause instanceof Error)) return error.message;
|
|
37
|
+
const code = causeCode(error);
|
|
38
|
+
return code !== null ? `${error.message} (${code} : ${cause.message})` : `${error.message} (${cause.message})`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Le remède d'un certificat signé par une autorité que le processus ne
|
|
42
|
+
* reconnaît pas — `null` pour tout autre échec.
|
|
43
|
+
*
|
|
44
|
+
* En développement, Nodefony reconnaît d'office l'autorité qu'il a générée ; en
|
|
45
|
+
* production jamais, et c'est voulu. Sans ce remède, le journal ne montrait que
|
|
46
|
+
* `SELF_SIGNED_CERT_IN_CHAIN` : la variable qui le règle n'était écrite que dans
|
|
47
|
+
* un commentaire du `.env`, fichier qu'un serveur de production n'a pas.
|
|
48
|
+
*
|
|
49
|
+
* @param error - ce que `fetch` (ou une bibliothèque qui l'appelle) a levé
|
|
50
|
+
* @returns une phrase d'action, ou `null`
|
|
51
|
+
*/
|
|
52
|
+
function untrustedAuthorityHint(error) {
|
|
53
|
+
const code = causeCode(error);
|
|
54
|
+
if (code === null || !UNTRUSTED_AUTHORITY_CODES.has(code)) return null;
|
|
55
|
+
return "le certificat de ce serveur est signé par une autorité que l'application ne reconnaît pas : déclare-la dans NF_TLS_CA_FILE (fichier PEM, `http.certificates.extraCaFiles`)";
|
|
56
|
+
}
|
|
57
|
+
export { describeTransportFailure, untrustedAuthorityHint };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { postTokenRequest } from "./oauth2Client.js";
|
|
2
|
+
import { discoverAuthorizationServer } from "./metadata.js";
|
|
3
|
+
// nodefony/src/oauth/clientCredentials.ts
|
|
4
|
+
/**
|
|
5
|
+
* Obtient un jeton d'accès pour un programme, par le flux `client_credentials`
|
|
6
|
+
* (RFC 6749 §4.4), auprès d'un émetteur découvert (RFC 8414).
|
|
7
|
+
*
|
|
8
|
+
* C'est la voie de l'appelant MACHINE : il s'authentifie avec son propre
|
|
9
|
+
* secret, et le jeton rendu le désigne lui, pas un utilisateur. La même
|
|
10
|
+
* authentification de client que l'échange de code ({@link postTokenRequest}) —
|
|
11
|
+
* une seule implémentation, pour qu'aucune règle ne diverge d'un flux à l'autre.
|
|
12
|
+
*
|
|
13
|
+
* @param request - émetteur, identité du client, portées et ressource.
|
|
14
|
+
* @returns Les jetons rendus ; `accessToken()` est celui à présenter en `Bearer`.
|
|
15
|
+
* @throws Error - secret vide, émetteur invalide ou injoignable, métadonnées discordantes.
|
|
16
|
+
* @throws OAuth2RequestError - le serveur a refusé (`invalid_client` : secret
|
|
17
|
+
* faux ; `unauthorized_client` : le client n'a pas droit à ce flux).
|
|
18
|
+
*/
|
|
19
|
+
async function requestClientCredentialsToken(request) {
|
|
20
|
+
if (request.clientSecret.length === 0) throw new Error(`client « ${request.clientId} » : secret vide — le flux client_credentials n'existe que pour un client confidentiel (RFC 6749 §4.4).`);
|
|
21
|
+
const server = await discoverAuthorizationServer(request.issuer, {
|
|
22
|
+
discoveryUrl: request.discoveryUrl,
|
|
23
|
+
...request.fetch ? { fetch: request.fetch } : {},
|
|
24
|
+
...request.timeoutMs !== void 0 ? { timeoutMs: request.timeoutMs } : {}
|
|
25
|
+
});
|
|
26
|
+
const body = new URLSearchParams();
|
|
27
|
+
body.set("grant_type", "client_credentials");
|
|
28
|
+
if (request.scopes !== void 0 && request.scopes.length > 0) body.set("scope", request.scopes.join(" "));
|
|
29
|
+
if (request.resource !== void 0) body.set("resource", request.resource);
|
|
30
|
+
return postTokenRequest({
|
|
31
|
+
tokenEndpoint: server.tokenEndpoint,
|
|
32
|
+
clientId: request.clientId,
|
|
33
|
+
clientSecret: request.clientSecret,
|
|
34
|
+
clientAuthMethod: request.clientAuthMethod ?? "client_secret_basic",
|
|
35
|
+
fetch: request.fetch,
|
|
36
|
+
timeoutMs: request.timeoutMs
|
|
37
|
+
}, body);
|
|
38
|
+
}
|
|
39
|
+
export { requestClientCredentialsToken };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import { isPlainObject } from "nodefony";
|
|
2
|
+
// nodefony/src/oauth/httpJson.ts
|
|
2
3
|
/**
|
|
3
4
|
* Lecture BORNÉE d'un corps JSON — la brique de transport commune aux trois
|
|
4
5
|
* appels sortants du social login (découverte, point de jeton, API d'un
|
|
@@ -57,8 +58,7 @@ async function readJsonBounded(response, maxBytes, subject) {
|
|
|
57
58
|
*/
|
|
58
59
|
async function readJsonObjectBounded(response, maxBytes, subject) {
|
|
59
60
|
const payload = await readJsonBounded(response, maxBytes, subject);
|
|
60
|
-
if (
|
|
61
|
+
if (!isPlainObject(payload)) throw new Error(`${subject} → corps inattendu (objet JSON attendu)`);
|
|
61
62
|
return payload;
|
|
62
63
|
}
|
|
63
|
-
//#endregion
|
|
64
64
|
export { readJsonBounded, readJsonObjectBounded };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { describeTransportFailure, untrustedAuthorityHint } from "../net/transportFailure.js";
|
|
1
2
|
import { readJsonObjectBounded } from "./httpJson.js";
|
|
2
|
-
import { canonicalIssuer,
|
|
3
|
-
|
|
3
|
+
import { canonicalIssuer, isBackChannelTransportAllowed, metadataCandidateUrls, validateIssuerMetadata } from "nodefony";
|
|
4
|
+
// nodefony/src/oauth/metadata.ts
|
|
4
5
|
/**
|
|
5
6
|
* **Découverte des métadonnées d'un serveur d'autorisation** (RFC 8414) — la face
|
|
6
7
|
* CLIENTE de la règle que le cœur porte déjà.
|
|
@@ -34,7 +35,7 @@ async function fetchMetadataDocument(url, options) {
|
|
|
34
35
|
if (!response.ok) throw new Error(`${url} → HTTP ${response.status}`);
|
|
35
36
|
return readJsonObjectBounded(response, MAX_METADATA_BYTES, url);
|
|
36
37
|
}
|
|
37
|
-
function requireEndpoint(document, field, issuer) {
|
|
38
|
+
function requireEndpoint(document, field, issuer, backChannelOf) {
|
|
38
39
|
const value = document[field];
|
|
39
40
|
if (typeof value !== "string" || value.length === 0) throw new Error(`métadonnées de « ${issuer} » : champ « ${field} » absent (RFC 8414 §2).`);
|
|
40
41
|
let url;
|
|
@@ -43,10 +44,20 @@ function requireEndpoint(document, field, issuer) {
|
|
|
43
44
|
} catch {
|
|
44
45
|
throw new Error(`métadonnées de « ${issuer} » : « ${field} » n'est pas une URL.`);
|
|
45
46
|
}
|
|
46
|
-
if (url.protocol
|
|
47
|
+
if (!(backChannelOf === void 0 ? url.protocol === "https:" : isBackChannelTransportAllowed(url, backChannelOf))) throw new Error(`métadonnées de « ${issuer} » : « ${field} » doit être en https` + (backChannelOf === void 0 ? "." : ` (ou en http sur l'origine de discoveryUrl « ${backChannelOf} »).`));
|
|
47
48
|
return value;
|
|
48
49
|
}
|
|
49
50
|
/**
|
|
51
|
+
* Point d'entrée FACULTATIF : absent ⇒ `null` ; présent, il obéit aux mêmes
|
|
52
|
+
* règles qu'un point requis (URL https) — un serveur qui publie un point
|
|
53
|
+
* malformé se signale à la découverte, pas au premier clic de l'utilisateur.
|
|
54
|
+
*/
|
|
55
|
+
function optionalEndpoint(document, field, issuer) {
|
|
56
|
+
const value = document[field];
|
|
57
|
+
if (value === void 0 || value === null) return null;
|
|
58
|
+
return requireEndpoint(document, field, issuer);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
50
61
|
* Interroge un serveur d'autorisation et rend ses points d'entrée.
|
|
51
62
|
*
|
|
52
63
|
* Les URL candidates sont celles du cœur (`issuerMetadataUrls`, ordre normatif
|
|
@@ -56,33 +67,38 @@ function requireEndpoint(document, field, issuer) {
|
|
|
56
67
|
* d'entrée — la même attaque que le paramètre `iss` couvre au retour (RFC 9207).
|
|
57
68
|
*
|
|
58
69
|
* @param rawIssuer - identifiant d'émetteur tel qu'écrit en configuration.
|
|
59
|
-
* @param options - transport injectable
|
|
70
|
+
* @param options - transport injectable, délai d'attente, adresse de découverte.
|
|
60
71
|
* @returns Les points d'entrée, prêts pour `OAuth2Client`.
|
|
61
72
|
* @throws Error - émetteur mal formé, document introuvable, incomplet, ou `issuer` discordant.
|
|
62
73
|
*/
|
|
63
74
|
async function discoverAuthorizationServer(rawIssuer, options = {}) {
|
|
64
75
|
const issuer = canonicalIssuer(rawIssuer);
|
|
65
76
|
const failures = [];
|
|
66
|
-
|
|
77
|
+
let hint = null;
|
|
78
|
+
const discoveryUrl = options.discoveryUrl;
|
|
79
|
+
for (const candidate of metadataCandidateUrls(issuer, discoveryUrl)) {
|
|
67
80
|
let document;
|
|
68
81
|
try {
|
|
69
82
|
document = await fetchMetadataDocument(candidate, options);
|
|
70
83
|
} catch (error) {
|
|
71
|
-
failures.push(
|
|
84
|
+
failures.push(describeTransportFailure(error));
|
|
85
|
+
hint ??= untrustedAuthorityHint(error);
|
|
72
86
|
continue;
|
|
73
87
|
}
|
|
74
|
-
const identity = validateIssuerMetadata(document, issuer);
|
|
88
|
+
const identity = validateIssuerMetadata(document, issuer, discoveryUrl);
|
|
75
89
|
const methods = document.code_challenge_methods_supported;
|
|
90
|
+
const idTokenAlgs = document.id_token_signing_alg_values_supported;
|
|
76
91
|
return {
|
|
77
92
|
issuer: identity.issuer,
|
|
78
93
|
jwksUri: identity.jwksUri,
|
|
79
94
|
authorizationEndpoint: requireEndpoint(document, "authorization_endpoint", issuer),
|
|
80
|
-
tokenEndpoint: requireEndpoint(document, "token_endpoint", issuer),
|
|
95
|
+
tokenEndpoint: requireEndpoint(document, "token_endpoint", issuer, discoveryUrl),
|
|
81
96
|
codeChallengeMethodsSupported: Array.isArray(methods) ? methods.filter((m) => typeof m === "string") : null,
|
|
82
|
-
issParameterSupported: document.authorization_response_iss_parameter_supported === true
|
|
97
|
+
issParameterSupported: document.authorization_response_iss_parameter_supported === true,
|
|
98
|
+
endSessionEndpoint: optionalEndpoint(document, "end_session_endpoint", issuer),
|
|
99
|
+
idTokenSigningAlgValuesSupported: Array.isArray(idTokenAlgs) ? idTokenAlgs.filter((a) => typeof a === "string") : null
|
|
83
100
|
};
|
|
84
101
|
}
|
|
85
|
-
throw new Error(`métadonnées introuvables pour « ${issuer} » — ${failures.join(" ; ")}`);
|
|
102
|
+
throw new Error(`métadonnées introuvables pour « ${issuer} » — ${failures.join(" ; ")}` + (hint === null ? "" : ` — ${hint}`));
|
|
86
103
|
}
|
|
87
|
-
//#endregion
|
|
88
104
|
export { discoverAuthorizationServer };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readJsonObjectBounded } from "./httpJson.js";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
|
|
3
|
+
// nodefony/src/oauth/oauth2Client.ts
|
|
4
4
|
/**
|
|
5
5
|
* Client **OAuth 2.0 / Authorization Code** minimal — la face cliente du protocole
|
|
6
6
|
* dont Nodefony écrit déjà la face serveur (émetteur de jetons, métadonnées,
|
|
@@ -79,11 +79,21 @@ var OAuth2RequestError = class extends Error {
|
|
|
79
79
|
code;
|
|
80
80
|
/** Description lisible fournie par le serveur, ou `null`. */
|
|
81
81
|
description;
|
|
82
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Statut HTTP de la réponse, ou `null` s'il n'est pas connu.
|
|
84
|
+
*
|
|
85
|
+
* Le code `error` ne suffit pas toujours à dire QUI est refusé : la RFC 6749
|
|
86
|
+
* §5.2 réserve `401` à l'échec d'authentification du client, et Keycloak
|
|
87
|
+
* répond `unauthorized_client` en `401` sur un secret faux — le même code
|
|
88
|
+
* qu'en `400` pour un flux que le client n'a pas le droit d'employer.
|
|
89
|
+
*/
|
|
90
|
+
status;
|
|
91
|
+
constructor(code, description, status = null) {
|
|
83
92
|
super(description === null ? code : `${code}: ${description}`);
|
|
84
93
|
this.name = "OAuth2RequestError";
|
|
85
94
|
this.code = code;
|
|
86
95
|
this.description = description;
|
|
96
|
+
this.status = status;
|
|
87
97
|
}
|
|
88
98
|
};
|
|
89
99
|
/**
|
|
@@ -229,38 +239,54 @@ var OAuth2Client = class {
|
|
|
229
239
|
body.set("code", request.code);
|
|
230
240
|
body.set("redirect_uri", this.#options.redirectUri);
|
|
231
241
|
if (request.codeVerifier !== null) body.set("code_verifier", request.codeVerifier);
|
|
232
|
-
|
|
233
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
234
|
-
Accept: "application/json",
|
|
235
|
-
"User-Agent": "nodefony"
|
|
236
|
-
};
|
|
237
|
-
switch (this.#options.clientAuthMethod) {
|
|
238
|
-
case "client_secret_basic":
|
|
239
|
-
headers.Authorization = `Basic ${this.#basicCredentials()}`;
|
|
240
|
-
break;
|
|
241
|
-
case "client_secret_post":
|
|
242
|
-
body.set("client_id", this.#options.clientId);
|
|
243
|
-
body.set("client_secret", this.#options.clientSecret);
|
|
244
|
-
break;
|
|
245
|
-
case "none": body.set("client_id", this.#options.clientId);
|
|
246
|
-
}
|
|
247
|
-
const response = await (this.#options.fetch ?? globalThis.fetch)(this.#options.tokenEndpoint, {
|
|
248
|
-
method: "POST",
|
|
249
|
-
headers,
|
|
250
|
-
body: body.toString(),
|
|
251
|
-
redirect: "error",
|
|
252
|
-
signal: AbortSignal.timeout(this.#options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS)
|
|
253
|
-
});
|
|
254
|
-
const data = await readJsonObjectBounded(response, MAX_TOKEN_RESPONSE_BYTES, `point de jeton (HTTP ${response.status})`);
|
|
255
|
-
if (typeof data.error === "string") throw new OAuth2RequestError(data.error, typeof data.error_description === "string" ? data.error_description : null);
|
|
256
|
-
if (!response.ok) throw new Error(`Point de jeton en échec (HTTP ${response.status}).`);
|
|
257
|
-
return new OAuth2Tokens(data);
|
|
258
|
-
}
|
|
259
|
-
#basicCredentials() {
|
|
260
|
-
const id = formUrlencode(this.#options.clientId);
|
|
261
|
-
const secret = formUrlencode(this.#options.clientSecret);
|
|
262
|
-
return Buffer.from(`${id}:${secret}`, "utf8").toString("base64");
|
|
242
|
+
return postTokenRequest(this.#options, body);
|
|
263
243
|
}
|
|
264
244
|
};
|
|
265
|
-
|
|
266
|
-
|
|
245
|
+
/**
|
|
246
|
+
* Présente une requête au point de jeton et rend les jetons (RFC 6749 §5.1).
|
|
247
|
+
*
|
|
248
|
+
* SEULE implémentation de l'authentification du client et de la lecture de la
|
|
249
|
+
* réponse : l'échange de code et le flux `client_credentials` y passent tous
|
|
250
|
+
* deux, pour qu'une règle (méthode déclarée, redirection refusée, réponse
|
|
251
|
+
* bornée, refus lu sur `error`) ne diverge jamais entre deux flux.
|
|
252
|
+
*
|
|
253
|
+
* @param client - point de jeton et identité du client.
|
|
254
|
+
* @param body - corps de la requête, `grant_type` compris ; l'identité du
|
|
255
|
+
* client y est ajoutée selon la méthode déclarée.
|
|
256
|
+
* @throws OAuth2RequestError - le serveur a refusé, en nommant la cause (RFC 6749 §5.2).
|
|
257
|
+
* @throws Error - réponse inintelligible, hors gabarit, ou serveur injoignable.
|
|
258
|
+
*/
|
|
259
|
+
async function postTokenRequest(client, body) {
|
|
260
|
+
const headers = {
|
|
261
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
262
|
+
Accept: "application/json",
|
|
263
|
+
"User-Agent": "nodefony"
|
|
264
|
+
};
|
|
265
|
+
switch (client.clientAuthMethod) {
|
|
266
|
+
case "client_secret_basic":
|
|
267
|
+
headers.Authorization = `Basic ${basicCredentials(client)}`;
|
|
268
|
+
break;
|
|
269
|
+
case "client_secret_post":
|
|
270
|
+
body.set("client_id", client.clientId);
|
|
271
|
+
body.set("client_secret", client.clientSecret);
|
|
272
|
+
break;
|
|
273
|
+
case "none": body.set("client_id", client.clientId);
|
|
274
|
+
}
|
|
275
|
+
const response = await (client.fetch ?? globalThis.fetch)(client.tokenEndpoint, {
|
|
276
|
+
method: "POST",
|
|
277
|
+
headers,
|
|
278
|
+
body: body.toString(),
|
|
279
|
+
redirect: "error",
|
|
280
|
+
signal: AbortSignal.timeout(client.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS)
|
|
281
|
+
});
|
|
282
|
+
const data = await readJsonObjectBounded(response, MAX_TOKEN_RESPONSE_BYTES, `point de jeton (HTTP ${response.status})`);
|
|
283
|
+
if (typeof data.error === "string") throw new OAuth2RequestError(data.error, typeof data.error_description === "string" ? data.error_description : null, response.status);
|
|
284
|
+
if (!response.ok) throw new Error(`Point de jeton en échec (HTTP ${response.status}).`);
|
|
285
|
+
return new OAuth2Tokens(data);
|
|
286
|
+
}
|
|
287
|
+
function basicCredentials(client) {
|
|
288
|
+
const id = formUrlencode(client.clientId);
|
|
289
|
+
const secret = formUrlencode(client.clientSecret);
|
|
290
|
+
return Buffer.from(`${id}:${secret}`, "utf8").toString("base64");
|
|
291
|
+
}
|
|
292
|
+
export { OAuth2Client, OAuth2RequestError, OAuth2Tokens, createCodeChallenge, generateCodeVerifier, generateState, postTokenRequest };
|
|
@@ -1,26 +1,58 @@
|
|
|
1
1
|
import { createDiscoveredOidcProvider } from "./providers/oidc.js";
|
|
2
2
|
import { createGithubProvider } from "./providers/github.js";
|
|
3
|
-
|
|
3
|
+
// nodefony/src/oauth/oauthProviderRegistry.ts
|
|
4
4
|
const factories = /* @__PURE__ */ new Map();
|
|
5
5
|
/**
|
|
6
6
|
* Enregistre (ou remplace) la fabrique d'un fournisseur OAuth. Appelée par les
|
|
7
7
|
* builtins au chargement, et par une application pour ses fournisseurs.
|
|
8
|
+
*
|
|
9
|
+
* @param name - nom sous lequel le fournisseur se configure (`oauth2.providers.<name>`).
|
|
10
|
+
* @param factory - fabrique du fournisseur.
|
|
11
|
+
* @param registration - exigences de configuration, vérifiées au démarrage.
|
|
8
12
|
*/
|
|
9
|
-
function registerOAuthProvider(name, factory) {
|
|
10
|
-
factories.set(name,
|
|
13
|
+
function registerOAuthProvider(name, factory, registration = {}) {
|
|
14
|
+
factories.set(name, {
|
|
15
|
+
factory,
|
|
16
|
+
requiresIssuer: registration.requiresIssuer === true
|
|
17
|
+
});
|
|
11
18
|
}
|
|
12
19
|
/** Fabrique d'un fournisseur par nom, ou `undefined` si inconnu. */
|
|
13
20
|
function getOAuthProviderFactory(name) {
|
|
14
|
-
return factories.get(name);
|
|
21
|
+
return factories.get(name)?.factory;
|
|
22
|
+
}
|
|
23
|
+
/** `true` si le fournisseur nommé a déclaré exiger un émetteur en configuration. */
|
|
24
|
+
function oauthProviderRequiresIssuer(name) {
|
|
25
|
+
return factories.get(name)?.requiresIssuer === true;
|
|
15
26
|
}
|
|
16
27
|
/** Noms enregistrés (validation boot, introspection Studio, tests). */
|
|
17
28
|
function listOAuthProviders() {
|
|
18
29
|
return [...factories.keys()];
|
|
19
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Paramètre d'autorisation que lit le thème Keycloak livré (`template.ftl`)
|
|
33
|
+
* pour afficher l'écran dans le thème de l'application (`light` | `dark`).
|
|
34
|
+
*/
|
|
35
|
+
const KEYCLOAK_THEME_PARAMETER = "nf_theme";
|
|
36
|
+
/**
|
|
37
|
+
* Ajoute le thème demandé ({@link IAuthorizationRequest.theme}) à l'URL
|
|
38
|
+
* d'autorisation d'un fournisseur Keycloak, sous {@link KEYCLOAK_THEME_PARAMETER}.
|
|
39
|
+
*
|
|
40
|
+
* @param provider - fournisseur OIDC découvert, inchangé pour tout le reste
|
|
41
|
+
* @returns le même fournisseur, dont l'URL d'autorisation porte le thème
|
|
42
|
+
*/
|
|
43
|
+
function withKeycloakThemeHint(provider) {
|
|
44
|
+
return {
|
|
45
|
+
...provider,
|
|
46
|
+
createAuthorizationURL(request) {
|
|
47
|
+
const url = provider.createAuthorizationURL(request);
|
|
48
|
+
if (request.theme === "light" || request.theme === "dark") url.searchParams.set(KEYCLOAK_THEME_PARAMETER, request.theme);
|
|
49
|
+
return url;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
20
53
|
const GOOGLE_ISSUER = "https://accounts.google.com";
|
|
21
54
|
registerOAuthProvider("google", (ctx) => createDiscoveredOidcProvider("google", ctx, { issuer: GOOGLE_ISSUER }));
|
|
22
|
-
registerOAuthProvider("keycloak", (ctx) => createDiscoveredOidcProvider("keycloak", ctx));
|
|
23
|
-
registerOAuthProvider("oidc", (ctx) => createDiscoveredOidcProvider("oidc", ctx));
|
|
55
|
+
registerOAuthProvider("keycloak", async (ctx) => withKeycloakThemeHint(await createDiscoveredOidcProvider("keycloak", ctx)), { requiresIssuer: true });
|
|
56
|
+
registerOAuthProvider("oidc", (ctx) => createDiscoveredOidcProvider("oidc", ctx), { requiresIssuer: true });
|
|
24
57
|
registerOAuthProvider("github", createGithubProvider);
|
|
25
|
-
|
|
26
|
-
export { getOAuthProviderFactory, listOAuthProviders, registerOAuthProvider };
|
|
58
|
+
export { KEYCLOAK_THEME_PARAMETER, getOAuthProviderFactory, listOAuthProviders, oauthProviderRequiresIssuer, registerOAuthProvider, withKeycloakThemeHint };
|