@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
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { describeTransportFailure, untrustedAuthorityHint } from "../net/transportFailure.js";
|
|
2
|
+
import { OAuth2RequestError, generateCodeVerifier, generateState } from "./oauth2Client.js";
|
|
3
|
+
import { IssuerMismatchError } from "nodefony";
|
|
4
|
+
// nodefony/src/oauth/providerDiagnosis.ts
|
|
5
|
+
/** Le code présenté au point de jeton : il n'a jamais été émis, il sera refusé. */
|
|
6
|
+
const PROBE_CODE = "nodefony-doctor-probe";
|
|
7
|
+
const AUTHORIZATION_TIMEOUT_MS = 1e4;
|
|
8
|
+
/**
|
|
9
|
+
* Lu au plus sur une page d'erreur : de quoi y trouver le nom du paramètre en
|
|
10
|
+
* cause, pas de quoi charger une page entière.
|
|
11
|
+
*/
|
|
12
|
+
const MAX_ERROR_PAGE_BYTES = 65536;
|
|
13
|
+
function errorMessage(error) {
|
|
14
|
+
const hint = untrustedAuthorityHint(error);
|
|
15
|
+
const text = describeTransportFailure(error);
|
|
16
|
+
return hint === null ? text : `${text} — ${hint}`;
|
|
17
|
+
}
|
|
18
|
+
/** Les premiers octets d'une page, en texte — le reste est abandonné. */
|
|
19
|
+
async function readHead(response, maxBytes) {
|
|
20
|
+
const body = response.body;
|
|
21
|
+
if (body === null) return "";
|
|
22
|
+
const reader = body.getReader();
|
|
23
|
+
const chunks = [];
|
|
24
|
+
let seen = 0;
|
|
25
|
+
for (;;) {
|
|
26
|
+
const { done, value } = await reader.read();
|
|
27
|
+
if (done) break;
|
|
28
|
+
chunks.push(value);
|
|
29
|
+
seen += value.byteLength;
|
|
30
|
+
if (seen >= maxBytes) {
|
|
31
|
+
await reader.cancel();
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
36
|
+
}
|
|
37
|
+
/** `true` si `location` désigne l'URL de retour (même origine, même chemin). */
|
|
38
|
+
function pointsTo(location, redirectUri) {
|
|
39
|
+
let target;
|
|
40
|
+
try {
|
|
41
|
+
target = new URL(redirectUri);
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
return location.origin === target.origin && location.pathname === target.pathname;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Sonde 1 — construit le fournisseur.
|
|
49
|
+
*
|
|
50
|
+
* @returns le fournisseur, ou la sonde en échec.
|
|
51
|
+
*/
|
|
52
|
+
async function probeDiscovery(input) {
|
|
53
|
+
try {
|
|
54
|
+
const provider = await input.build();
|
|
55
|
+
const pkce = provider.usesPkce ? ", PKCE S256" : ", sans PKCE";
|
|
56
|
+
return {
|
|
57
|
+
provider,
|
|
58
|
+
check: {
|
|
59
|
+
name: "discovery",
|
|
60
|
+
status: "ok",
|
|
61
|
+
message: provider.issuerPolicy === null ? `fournisseur construit — points d'entrée fixes, sans découverte${pkce}` : `métadonnées lues, émetteur « ${provider.issuerPolicy.issuer} » identique${pkce}`
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error instanceof IssuerMismatchError) return {
|
|
66
|
+
provider: null,
|
|
67
|
+
check: {
|
|
68
|
+
name: "discovery",
|
|
69
|
+
status: "failed",
|
|
70
|
+
kind: "issuer-mismatch",
|
|
71
|
+
message: `l'émetteur se déclare « ${error.declaredIssuer} », la configuration écrit « ${error.expectedIssuer} » (RFC 8414 §3.3) — reprends l'adresse EXACTE que le serveur annonce (hôte, port, chemin du realm)`
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
provider: null,
|
|
76
|
+
check: {
|
|
77
|
+
name: "discovery",
|
|
78
|
+
status: "failed",
|
|
79
|
+
kind: "discovery-failed",
|
|
80
|
+
message: errorMessage(error)
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Sonde 2 — la requête du bouton de connexion, sans la suivre.
|
|
87
|
+
*
|
|
88
|
+
* Un serveur qui refuse l'URL de retour ne doit PAS y renvoyer (RFC 6749
|
|
89
|
+
* §4.1.2.1) : il répond lui-même, en erreur. Un renvoi vers l'URL de retour
|
|
90
|
+
* prouve donc qu'elle est acceptée ; une page de connexion aussi.
|
|
91
|
+
*/
|
|
92
|
+
async function probeAuthorization(input, provider) {
|
|
93
|
+
const url = provider.createAuthorizationURL({
|
|
94
|
+
state: generateState(),
|
|
95
|
+
codeVerifier: provider.usesPkce ? generateCodeVerifier() : null,
|
|
96
|
+
scopes: input.scopes !== void 0 && input.scopes.length > 0 ? input.scopes : provider.defaultScopes
|
|
97
|
+
});
|
|
98
|
+
let response;
|
|
99
|
+
try {
|
|
100
|
+
response = await (input.fetch ?? globalThis.fetch)(url, {
|
|
101
|
+
headers: {
|
|
102
|
+
Accept: "text/html",
|
|
103
|
+
"User-Agent": "nodefony"
|
|
104
|
+
},
|
|
105
|
+
redirect: "manual",
|
|
106
|
+
signal: AbortSignal.timeout(input.timeoutMs ?? AUTHORIZATION_TIMEOUT_MS)
|
|
107
|
+
});
|
|
108
|
+
} catch (error) {
|
|
109
|
+
return {
|
|
110
|
+
client: null,
|
|
111
|
+
page: null,
|
|
112
|
+
check: {
|
|
113
|
+
name: "authorization",
|
|
114
|
+
status: "failed",
|
|
115
|
+
kind: "authorization-unreachable",
|
|
116
|
+
message: `point d'autorisation injoignable : ${errorMessage(error)}`
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (response.status >= 200 && response.status < 300) {
|
|
121
|
+
let page = null;
|
|
122
|
+
if (input.pageCheck === void 0) await response.body?.cancel();
|
|
123
|
+
else {
|
|
124
|
+
const html = await readHead(response, MAX_ERROR_PAGE_BYTES).catch(() => "");
|
|
125
|
+
page = input.pageCheck(html);
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
client: "known",
|
|
129
|
+
page,
|
|
130
|
+
check: {
|
|
131
|
+
name: "authorization",
|
|
132
|
+
status: "ok",
|
|
133
|
+
message: "client et URL de retour acceptés (page de connexion servie)"
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (response.status >= 300 && response.status < 400) {
|
|
138
|
+
await response.body?.cancel();
|
|
139
|
+
const raw = response.headers.get("location");
|
|
140
|
+
const location = raw === null ? null : new URL(raw, url);
|
|
141
|
+
if (location !== null && pointsTo(location, input.redirectUri)) {
|
|
142
|
+
const refused = location.searchParams.get("error");
|
|
143
|
+
if (refused === null) return {
|
|
144
|
+
client: "known",
|
|
145
|
+
page: null,
|
|
146
|
+
check: {
|
|
147
|
+
name: "authorization",
|
|
148
|
+
status: "ok",
|
|
149
|
+
message: "client et URL de retour acceptés"
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
const description = location.searchParams.get("error_description");
|
|
153
|
+
return {
|
|
154
|
+
client: "known",
|
|
155
|
+
page: null,
|
|
156
|
+
check: {
|
|
157
|
+
name: "authorization",
|
|
158
|
+
status: "failed",
|
|
159
|
+
kind: "authorization-refused",
|
|
160
|
+
message: `URL de retour acceptée, mais la demande est refusée : « ${refused} »` + (description === null ? "" : ` — ${description}`)
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
client: null,
|
|
166
|
+
page: null,
|
|
167
|
+
check: {
|
|
168
|
+
name: "authorization",
|
|
169
|
+
status: "inconclusive",
|
|
170
|
+
message: `le point d'autorisation renvoie vers ${location?.origin ?? "une adresse absente"} — l'URL de retour ne se juge alors qu'après une connexion`
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if ((await readHead(response, MAX_ERROR_PAGE_BYTES).catch(() => "")).includes("redirect_uri")) return {
|
|
175
|
+
client: null,
|
|
176
|
+
page: null,
|
|
177
|
+
check: {
|
|
178
|
+
name: "authorization",
|
|
179
|
+
status: "failed",
|
|
180
|
+
kind: "redirect-uri-rejected",
|
|
181
|
+
message: `URL de retour refusée (HTTP ${response.status}) : « ${input.redirectUri} » n'est pas enregistrée sur le client « ${input.clientId} » — ajoute-la telle quelle aux URL de retour du client`
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
return {
|
|
185
|
+
client: null,
|
|
186
|
+
page: null,
|
|
187
|
+
check: {
|
|
188
|
+
name: "authorization",
|
|
189
|
+
status: "failed",
|
|
190
|
+
kind: "authorization-refused",
|
|
191
|
+
message: `le point d'autorisation refuse la demande (HTTP ${response.status}) sans renvoyer vers l'application — client « ${input.clientId} » inconnu ou désactivé, ou URL de retour non enregistrée`
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Sonde 3 — un code inventé, présenté avec les vrais identifiants.
|
|
197
|
+
*
|
|
198
|
+
* @param client - ce que la sonde 2 a établi : un client CONNU qui échoue à
|
|
199
|
+
* s'authentifier ne peut avoir qu'un secret faux.
|
|
200
|
+
*/
|
|
201
|
+
async function probeToken(input, provider, client) {
|
|
202
|
+
const publicClient = input.clientAuthMethod === "none";
|
|
203
|
+
try {
|
|
204
|
+
await provider.validateAuthorizationCode({
|
|
205
|
+
code: PROBE_CODE,
|
|
206
|
+
codeVerifier: provider.usesPkce ? generateCodeVerifier() : null
|
|
207
|
+
});
|
|
208
|
+
return {
|
|
209
|
+
name: "token",
|
|
210
|
+
status: "inconclusive",
|
|
211
|
+
message: "le point de jeton a ACCEPTÉ un code inventé — ce serveur ne se comporte pas comme un serveur OAuth 2.0, rien ne peut en être conclu"
|
|
212
|
+
};
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (!(error instanceof OAuth2RequestError)) return {
|
|
215
|
+
name: "token",
|
|
216
|
+
status: "failed",
|
|
217
|
+
kind: "token-unreachable",
|
|
218
|
+
message: `point de jeton injoignable : ${errorMessage(error)}`
|
|
219
|
+
};
|
|
220
|
+
if (error.code === "invalid_grant") return {
|
|
221
|
+
name: "token",
|
|
222
|
+
status: "ok",
|
|
223
|
+
message: publicClient ? "client public reconnu (le code de sonde est refusé, comme prévu)" : "secret accepté (le code de sonde est refusé, comme prévu)"
|
|
224
|
+
};
|
|
225
|
+
if (error.code === "invalid_client" || error.status === 401) {
|
|
226
|
+
if (client === "known" && !publicClient) return {
|
|
227
|
+
name: "token",
|
|
228
|
+
status: "failed",
|
|
229
|
+
kind: "secret-rejected",
|
|
230
|
+
message: `secret refusé : le client « ${input.clientId} » existe (le point d'autorisation l'accepte), mais son secret n'est pas celui que le serveur attend — recopie-le depuis le fournisseur`
|
|
231
|
+
};
|
|
232
|
+
return {
|
|
233
|
+
name: "token",
|
|
234
|
+
status: "failed",
|
|
235
|
+
kind: "client-rejected",
|
|
236
|
+
message: `authentification du client « ${input.clientId} » refusée (${error.code}) : client inconnu de ce serveur, ou secret faux`
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
name: "token",
|
|
241
|
+
status: "failed",
|
|
242
|
+
kind: "token-refused",
|
|
243
|
+
message: `le point de jeton refuse le client : « ${error.code} »` + (error.description === null ? "" : ` — ${error.description}`)
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Diagnostique un fournisseur OAuth sans connexion humaine.
|
|
249
|
+
*
|
|
250
|
+
* Ne lève jamais : un serveur muet ou hostile devient une sonde en échec, et
|
|
251
|
+
* les sondes que l'échec rend impossibles sont DITES sautées — un silence ne
|
|
252
|
+
* vaut pas quitus.
|
|
253
|
+
*
|
|
254
|
+
* @param input - fournisseur à construire et configuration à confronter.
|
|
255
|
+
* @returns les trois sondes et leur verdict.
|
|
256
|
+
*/
|
|
257
|
+
async function diagnoseOAuthProvider(input) {
|
|
258
|
+
const discovery = await probeDiscovery(input);
|
|
259
|
+
const checks = [discovery.check];
|
|
260
|
+
if (discovery.provider === null) {
|
|
261
|
+
const reason = "impossible sans fournisseur construit (découverte en échec)";
|
|
262
|
+
checks.push({
|
|
263
|
+
name: "authorization",
|
|
264
|
+
status: "skipped",
|
|
265
|
+
message: reason
|
|
266
|
+
}, {
|
|
267
|
+
name: "token",
|
|
268
|
+
status: "skipped",
|
|
269
|
+
message: reason
|
|
270
|
+
});
|
|
271
|
+
} else {
|
|
272
|
+
const authorization = await probeAuthorization(input, discovery.provider);
|
|
273
|
+
checks.push(authorization.check, await probeToken(input, discovery.provider, authorization.client));
|
|
274
|
+
if (authorization.page !== null) checks.push(authorization.page);
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
provider: input.name,
|
|
278
|
+
ok: checks.every((c) => c.status !== "failed"),
|
|
279
|
+
checks
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
export { diagnoseOAuthProvider };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// nodefony/src/oauth/providerRoles.ts
|
|
2
|
+
/** Source par défaut : les rôles du CLIENT — ceux que l'annuaire attribue à CETTE application. */
|
|
3
|
+
const DEFAULT_ROLE_SOURCES = ["client"];
|
|
4
|
+
/**
|
|
5
|
+
* Préfixe des rôles de PLATEFORME (console d'administration, plan
|
|
6
|
+
* d'administration) — jamais accordés par un annuaire externe.
|
|
7
|
+
*/
|
|
8
|
+
const PLATFORM_ROLE_PREFIX = "ROLE_NODEFONY_";
|
|
9
|
+
/** `true` si le rôle relève de la plateforme (`ROLE_NODEFONY_*`). */
|
|
10
|
+
function isPlatformRole(role) {
|
|
11
|
+
return role.startsWith(PLATFORM_ROLE_PREFIX);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Compile la table d'un fournisseur — `null` quand il n'en déclare aucune, ce
|
|
15
|
+
* qui laisse les rôles du compte entièrement locaux (comportement historique).
|
|
16
|
+
*
|
|
17
|
+
* @param config - configuration du fournisseur.
|
|
18
|
+
* @returns la table compilée, ou `null`.
|
|
19
|
+
*/
|
|
20
|
+
function compileProviderRoleMapping(config) {
|
|
21
|
+
const mapping = config.roleMapping;
|
|
22
|
+
if (mapping === void 0) return null;
|
|
23
|
+
const table = Object.create(null);
|
|
24
|
+
for (const [from, to] of Object.entries(mapping)) table[from] = to;
|
|
25
|
+
return {
|
|
26
|
+
clientId: config.clientId,
|
|
27
|
+
sources: config.rolesSource !== void 0 && config.rolesSource.length > 0 ? config.rolesSource : DEFAULT_ROLE_SOURCES,
|
|
28
|
+
table
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Rôles de l'application accordés par le fournisseur, d'après ses claims.
|
|
33
|
+
*
|
|
34
|
+
* Un rôle du fournisseur absent de la table est IGNORÉ — jamais recopié tel
|
|
35
|
+
* quel : l'annuaire ne fabrique pas de rôle que l'application n'a pas déclaré.
|
|
36
|
+
* Plusieurs jeux de claims (jeton d'accès, jeton d'identité) se cumulent : ils
|
|
37
|
+
* viennent du même émetteur, chacun peut porter une partie des rôles selon
|
|
38
|
+
* les « mappers » configurés chez lui.
|
|
39
|
+
*
|
|
40
|
+
* @param mapping - table compilée du fournisseur.
|
|
41
|
+
* @param claimSets - claims à lire ; `null`/`undefined` ignorés.
|
|
42
|
+
* @returns rôles de l'application, triés et sans doublon.
|
|
43
|
+
*/
|
|
44
|
+
function mapProviderRoles(mapping, ...claimSets) {
|
|
45
|
+
const granted = /* @__PURE__ */ new Set();
|
|
46
|
+
for (const claims of claimSets) {
|
|
47
|
+
if (claims === null || claims === void 0) continue;
|
|
48
|
+
for (const source of mapping.sources) for (const role of readSource(claims, source, mapping.clientId)) {
|
|
49
|
+
const mapped = mapping.table[role];
|
|
50
|
+
if (mapped !== void 0) granted.add(mapped);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return [...granted].sort();
|
|
54
|
+
}
|
|
55
|
+
/** Lit les rôles bruts d'une source — tableau vide si la forme n'est pas celle attendue. */
|
|
56
|
+
function readSource(claims, source, clientId) {
|
|
57
|
+
switch (source) {
|
|
58
|
+
case "client": {
|
|
59
|
+
const access = asRecord(claims.resource_access);
|
|
60
|
+
return access === null ? [] : rolesOf(access[clientId]);
|
|
61
|
+
}
|
|
62
|
+
case "realm": return rolesOf(claims.realm_access);
|
|
63
|
+
case "groups": return strings(claims.groups);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** `{ roles: [...] }` → les chaînes de `roles`. */
|
|
67
|
+
function rolesOf(value) {
|
|
68
|
+
const record = asRecord(value);
|
|
69
|
+
return record === null ? [] : strings(record.roles);
|
|
70
|
+
}
|
|
71
|
+
function asRecord(value) {
|
|
72
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
73
|
+
}
|
|
74
|
+
function strings(value) {
|
|
75
|
+
if (!Array.isArray(value)) return [];
|
|
76
|
+
return value.filter((v) => typeof v === "string");
|
|
77
|
+
}
|
|
78
|
+
export { DEFAULT_ROLE_SOURCES, PLATFORM_ROLE_PREFIX, compileProviderRoleMapping, isPlatformRole, mapProviderRoles };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { readJsonBounded } from "../httpJson.js";
|
|
2
|
-
import { OAuth2Client } from "../oauth2Client.js";
|
|
3
|
-
|
|
1
|
+
import { readJsonBounded, readJsonObjectBounded } from "../httpJson.js";
|
|
2
|
+
import { OAuth2Client, OAuth2RequestError } from "../oauth2Client.js";
|
|
3
|
+
import { isPlainObject } from "nodefony";
|
|
4
|
+
// nodefony/src/oauth/providers/github.ts
|
|
4
5
|
/** Scopes minimaux : profil public + emails (l'email primaire peut être privé). */
|
|
5
6
|
const DEFAULT_SCOPES = ["read:user", "user:email"];
|
|
6
7
|
const API = "https://api.github.com";
|
|
@@ -25,7 +26,39 @@ async function ghGet(url, accessToken) {
|
|
|
25
26
|
signal: AbortSignal.timeout(API_TIMEOUT_MS)
|
|
26
27
|
});
|
|
27
28
|
if (!res.ok) throw new Error(`GitHub API ${url} → ${res.status}`);
|
|
28
|
-
return
|
|
29
|
+
return res;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Les adresses que rend `GET /user/emails` — un tableau, sinon une erreur qui
|
|
33
|
+
* nomme l'appel ; une entrée qui n'est pas un objet est écartée.
|
|
34
|
+
*/
|
|
35
|
+
async function ghEmails(accessToken) {
|
|
36
|
+
const url = `${API}/user/emails`;
|
|
37
|
+
const list = await readJsonBounded(await ghGet(url, accessToken), MAX_PROFILE_BYTES, `GitHub API ${url}`);
|
|
38
|
+
if (!Array.isArray(list)) throw new Error(`GitHub API ${url} → corps inattendu (tableau JSON attendu)`);
|
|
39
|
+
return list.filter(isPlainObject);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Codes d'erreur PROPRES à GitHub, rendus dans leur équivalent RFC 6749 §5.2
|
|
43
|
+
* (relevés sur le point de jeton réel : un code inventé rend
|
|
44
|
+
* `bad_verification_code`, un secret faux `incorrect_client_credentials`,
|
|
45
|
+
* tous deux en HTTP 200).
|
|
46
|
+
*
|
|
47
|
+
* Sans cette traduction, tout ce qui lit le code normalisé — le diagnostic
|
|
48
|
+
* `security:oauth:doctor` d'abord — prendrait un secret ACCEPTÉ pour un refus.
|
|
49
|
+
* Le code d'origine reste dans la description, pour le journal.
|
|
50
|
+
*/
|
|
51
|
+
const GITHUB_ERROR_CODES = {
|
|
52
|
+
bad_verification_code: "invalid_grant",
|
|
53
|
+
incorrect_client_credentials: "invalid_client",
|
|
54
|
+
redirect_uri_mismatch: "invalid_grant"
|
|
55
|
+
};
|
|
56
|
+
function normalizeGithubError(error) {
|
|
57
|
+
if (!(error instanceof OAuth2RequestError)) return error;
|
|
58
|
+
const code = GITHUB_ERROR_CODES[error.code];
|
|
59
|
+
if (code === void 0) return error;
|
|
60
|
+
const description = `${error.description ?? error.code} (GitHub : ${error.code})`;
|
|
61
|
+
return new OAuth2RequestError(code, description, error.status);
|
|
29
62
|
}
|
|
30
63
|
/**
|
|
31
64
|
* Fournisseur **GitHub** (OAuth 2.0 simple, NON-OIDC). Pas de PKCE, pas d'ID
|
|
@@ -52,15 +85,19 @@ function createGithubProvider(ctx) {
|
|
|
52
85
|
codeVerifier: null
|
|
53
86
|
});
|
|
54
87
|
},
|
|
55
|
-
validateAuthorizationCode(request) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
88
|
+
async validateAuthorizationCode(request) {
|
|
89
|
+
try {
|
|
90
|
+
return await client.validateAuthorizationCode({
|
|
91
|
+
...request,
|
|
92
|
+
codeVerifier: null
|
|
93
|
+
});
|
|
94
|
+
} catch (error) {
|
|
95
|
+
throw normalizeGithubError(error);
|
|
96
|
+
}
|
|
60
97
|
},
|
|
61
98
|
async fetchProfile(tokens) {
|
|
62
99
|
const accessToken = tokens.accessToken();
|
|
63
|
-
const user = await ghGet(`${API}/user`, accessToken);
|
|
100
|
+
const user = await readJsonObjectBounded(await ghGet(`${API}/user`, accessToken), MAX_PROFILE_BYTES, `GitHub API ${API}/user`);
|
|
64
101
|
const id = user.id;
|
|
65
102
|
if (typeof id !== "number" && typeof id !== "string") throw new Error("GitHub /user sans identifiant.");
|
|
66
103
|
const login = typeof user.login === "string" ? user.login : null;
|
|
@@ -68,7 +105,7 @@ function createGithubProvider(ctx) {
|
|
|
68
105
|
let email = typeof user.email === "string" ? user.email : null;
|
|
69
106
|
let emailVerified = email !== null;
|
|
70
107
|
if (email === null) {
|
|
71
|
-
const list = await
|
|
108
|
+
const list = await ghEmails(accessToken);
|
|
72
109
|
const primary = list.find((e) => e.primary === true) ?? list.at(0) ?? null;
|
|
73
110
|
if (primary && typeof primary.email === "string") {
|
|
74
111
|
email = primary.email;
|
|
@@ -86,5 +123,4 @@ function createGithubProvider(ctx) {
|
|
|
86
123
|
}
|
|
87
124
|
};
|
|
88
125
|
}
|
|
89
|
-
//#endregion
|
|
90
126
|
export { createGithubProvider };
|
|
@@ -1,11 +1,55 @@
|
|
|
1
|
+
import { RemoteJwtVerifier } from "../../token/RemoteJwtVerifier.js";
|
|
1
2
|
import { OAuth2Client } from "../oauth2Client.js";
|
|
2
3
|
import { discoverAuthorizationServer } from "../metadata.js";
|
|
3
|
-
|
|
4
|
+
import { isPlainObject } from "nodefony";
|
|
5
|
+
// nodefony/src/oauth/providers/oidc.ts
|
|
4
6
|
const DEFAULT_OIDC_SCOPES = [
|
|
5
7
|
"openid",
|
|
6
8
|
"profile",
|
|
7
9
|
"email"
|
|
8
10
|
];
|
|
11
|
+
/** Membre de `events` qui déclare un jeton de déconnexion (§2.4). */
|
|
12
|
+
const BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
|
13
|
+
/**
|
|
14
|
+
* Algorithme d'ID token par défaut quand l'émetteur n'en annonce aucun
|
|
15
|
+
* (OpenID Connect Back-Channel Logout 1.0 §2.6, point 3).
|
|
16
|
+
*/
|
|
17
|
+
const DEFAULT_LOGOUT_TOKEN_ALGORITHMS = ["RS256"];
|
|
18
|
+
/**
|
|
19
|
+
* Lit ce qu'un jeton de déconnexion DÉSIGNE, une fois sa signature vérifiée —
|
|
20
|
+
* les points 5 à 7 de la validation (OpenID Connect Back-Channel Logout 1.0
|
|
21
|
+
* §2.6) : `sub` ou `sid` présent, `events` qui le déclare jeton de
|
|
22
|
+
* déconnexion, AUCUN `nonce`.
|
|
23
|
+
*
|
|
24
|
+
* @remarks Ces gardes ferment la confusion entre jetons (§4.1) : un ID token
|
|
25
|
+
* du même émetteur, pour le même client, porte une signature tout aussi
|
|
26
|
+
* valide — c'est `events` qui le distingue, et l'absence de `nonce` qui
|
|
27
|
+
* empêche le chemin inverse. Fonction PURE, éprouvable sans clé ni réseau.
|
|
28
|
+
*
|
|
29
|
+
* @param issuer - émetteur canonique rendu par la vérification de signature.
|
|
30
|
+
* @param payload - claims vérifiés.
|
|
31
|
+
* @returns les claims utiles, ou `null` si le jeton n'est pas un jeton de
|
|
32
|
+
* déconnexion valide.
|
|
33
|
+
*/
|
|
34
|
+
function readLogoutTokenClaims(issuer, payload) {
|
|
35
|
+
const sub = typeof payload.sub === "string" ? payload.sub : null;
|
|
36
|
+
const sid = typeof payload.sid === "string" ? payload.sid : null;
|
|
37
|
+
if ((sub === null || sub === "") && (sid === null || sid === "")) return null;
|
|
38
|
+
const events = payload.events;
|
|
39
|
+
if (!isPlainObject(events)) return null;
|
|
40
|
+
const event = events[BACKCHANNEL_LOGOUT_EVENT];
|
|
41
|
+
if (typeof event !== "object" || event === null || Array.isArray(event)) return null;
|
|
42
|
+
if ("nonce" in payload) return null;
|
|
43
|
+
if (typeof payload.jti !== "string" || payload.jti === "") return null;
|
|
44
|
+
if (typeof payload.exp !== "number") return null;
|
|
45
|
+
return {
|
|
46
|
+
issuer,
|
|
47
|
+
subject: sub === "" ? null : sub,
|
|
48
|
+
sid: sid === "" ? null : sid,
|
|
49
|
+
tokenId: payload.jti,
|
|
50
|
+
expiresAt: payload.exp
|
|
51
|
+
};
|
|
52
|
+
}
|
|
9
53
|
/**
|
|
10
54
|
* Lit les claims d'un ID token SANS vérifier sa signature.
|
|
11
55
|
*
|
|
@@ -27,14 +71,17 @@ async function decodeIdTokenClaims(idToken) {
|
|
|
27
71
|
* écarts : un jeton d'un autre émetteur (point 2), délivré à une autre
|
|
28
72
|
* application (point 3), ou périmé (point 9), n'a rien à faire ici.
|
|
29
73
|
*
|
|
74
|
+
* @returns le `sub` du jeton, éprouvé non vide.
|
|
30
75
|
* @throws Error - un claim obligatoire est absent, discordant ou périmé.
|
|
31
76
|
*/
|
|
32
77
|
function assertIdTokenClaims(claims, opts) {
|
|
33
|
-
|
|
78
|
+
const sub = claims.sub;
|
|
79
|
+
if (typeof sub !== "string" || sub.length === 0) throw new Error(`${opts.name}: ID token sans claim 'sub'.`);
|
|
34
80
|
if (claims.iss !== opts.issuer) throw new Error(`${opts.name}: ID token émis par « ${String(claims.iss)} », attendu « ${opts.issuer} ».`);
|
|
35
81
|
const aud = claims.aud;
|
|
36
82
|
if (!(aud === opts.clientId || Array.isArray(aud) && aud.includes(opts.clientId))) throw new Error(`${opts.name}: ID token délivré à une autre application.`);
|
|
37
83
|
if (typeof claims.exp !== "number" || claims.exp * 1e3 <= Date.now()) throw new Error(`${opts.name}: ID token périmé ou sans 'exp'.`);
|
|
84
|
+
return sub;
|
|
38
85
|
}
|
|
39
86
|
/**
|
|
40
87
|
* Fabrique un {@link IOAuthProvider} **générique OIDC** — couvre TOUT fournisseur
|
|
@@ -49,7 +96,7 @@ function createOidcProvider(opts) {
|
|
|
49
96
|
if (codeVerifier === null) throw new Error(`${opts.name}: code_verifier requis (PKCE OIDC).`);
|
|
50
97
|
return codeVerifier;
|
|
51
98
|
};
|
|
52
|
-
|
|
99
|
+
const provider = {
|
|
53
100
|
usesPkce: true,
|
|
54
101
|
issuerPolicy: {
|
|
55
102
|
issuer: opts.issuer,
|
|
@@ -70,8 +117,8 @@ function createOidcProvider(opts) {
|
|
|
70
117
|
},
|
|
71
118
|
async fetchProfile(tokens) {
|
|
72
119
|
const claims = await opts.decodeIdToken(tokens.idToken());
|
|
73
|
-
|
|
74
|
-
const sub = claims
|
|
120
|
+
if (!isPlainObject(claims)) throw new Error(`${opts.name}: ID token sans charge utile objet.`);
|
|
121
|
+
const sub = assertIdTokenClaims(claims, opts);
|
|
75
122
|
return {
|
|
76
123
|
provider: opts.name,
|
|
77
124
|
providerId: sub,
|
|
@@ -82,6 +129,21 @@ function createOidcProvider(opts) {
|
|
|
82
129
|
};
|
|
83
130
|
}
|
|
84
131
|
};
|
|
132
|
+
const endSession = opts.endSessionEndpoint ?? null;
|
|
133
|
+
if (endSession !== null) provider.createLogoutURL = (request) => {
|
|
134
|
+
const url = new URL(endSession);
|
|
135
|
+
url.searchParams.set("id_token_hint", request.idTokenHint);
|
|
136
|
+
url.searchParams.set("post_logout_redirect_uri", request.postLogoutRedirectUri);
|
|
137
|
+
url.searchParams.set("client_id", opts.clientId);
|
|
138
|
+
return url;
|
|
139
|
+
};
|
|
140
|
+
const verifySignature = opts.verifyLogoutTokenSignature;
|
|
141
|
+
if (verifySignature !== void 0) provider.verifyLogoutToken = async (logoutToken) => {
|
|
142
|
+
const verified = await verifySignature(logoutToken);
|
|
143
|
+
if (verified === null) return null;
|
|
144
|
+
return readLogoutTokenClaims(verified.issuer, verified.payload);
|
|
145
|
+
};
|
|
146
|
+
return provider;
|
|
85
147
|
}
|
|
86
148
|
/**
|
|
87
149
|
* Construit un fournisseur OIDC **en demandant ses points d'entrée à l'émetteur**
|
|
@@ -98,12 +160,33 @@ function createOidcProvider(opts) {
|
|
|
98
160
|
async function createDiscoveredOidcProvider(name, ctx, options = {}) {
|
|
99
161
|
const effectiveIssuer = options.issuer ?? ctx.issuer;
|
|
100
162
|
if (!effectiveIssuer) throw new Error(`OAuth provider "${name}" : config "issuer" requise (URL de l'émetteur OIDC).`);
|
|
101
|
-
const metadata = await discoverAuthorizationServer(effectiveIssuer,
|
|
163
|
+
const metadata = await discoverAuthorizationServer(effectiveIssuer, {
|
|
164
|
+
...options,
|
|
165
|
+
discoveryUrl: options.discoveryUrl ?? ctx.discoveryUrl
|
|
166
|
+
});
|
|
102
167
|
if (metadata.codeChallengeMethodsSupported !== null && !metadata.codeChallengeMethodsSupported.includes("S256")) throw new Error(`OAuth provider "${name}" : l'émetteur « ${effectiveIssuer} » n'annonce pas PKCE S256 (RFC 7636).`);
|
|
168
|
+
const announced = (metadata.idTokenSigningAlgValuesSupported ?? []).filter((alg) => !alg.startsWith("HS") && alg.toLowerCase() !== "none");
|
|
169
|
+
const logoutTokenVerifier = new RemoteJwtVerifier({
|
|
170
|
+
issuers: [{
|
|
171
|
+
issuer: metadata.issuer,
|
|
172
|
+
jwksUri: metadata.jwksUri,
|
|
173
|
+
algorithms: announced.length > 0 ? announced : DEFAULT_LOGOUT_TOKEN_ALGORITHMS,
|
|
174
|
+
requiredClaims: [
|
|
175
|
+
"iat",
|
|
176
|
+
"exp",
|
|
177
|
+
"jti",
|
|
178
|
+
"events"
|
|
179
|
+
]
|
|
180
|
+
}],
|
|
181
|
+
...options.fetch !== void 0 ? { fetch: options.fetch } : {},
|
|
182
|
+
...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {}
|
|
183
|
+
});
|
|
103
184
|
return createOidcProvider({
|
|
104
185
|
name,
|
|
105
186
|
issuer: metadata.issuer,
|
|
106
187
|
issParameterSupported: metadata.issParameterSupported,
|
|
188
|
+
endSessionEndpoint: metadata.endSessionEndpoint,
|
|
189
|
+
verifyLogoutTokenSignature: (logoutToken) => logoutTokenVerifier.verifyClaims(logoutToken, ctx.clientId),
|
|
107
190
|
clientId: ctx.clientId,
|
|
108
191
|
decodeIdToken: decodeIdTokenClaims,
|
|
109
192
|
client: new OAuth2Client({
|
|
@@ -118,5 +201,4 @@ async function createDiscoveredOidcProvider(name, ctx, options = {}) {
|
|
|
118
201
|
})
|
|
119
202
|
});
|
|
120
203
|
}
|
|
121
|
-
|
|
122
|
-
export { createDiscoveredOidcProvider, createOidcProvider };
|
|
204
|
+
export { createDiscoveredOidcProvider, createOidcProvider, readLogoutTokenClaims };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// nodefony/src/openToSignup.ts
|
|
2
|
+
/**
|
|
3
|
+
* Les zones protégées qui n'exigent aucun rôle alors que la connexion OAuth
|
|
4
|
+
* crée les comptes à la volée — c'est-à-dire ouvertes à tout compte que le
|
|
5
|
+
* fournisseur délivre.
|
|
6
|
+
*
|
|
7
|
+
* Une zone sans `roles` laisse passer toute identité authentifiée : c'est un
|
|
8
|
+
* défaut voulu. Il devient un piège quand `oauth2.allowSignup` vaut `true`
|
|
9
|
+
* (le défaut) : avec Google ou GitHub, « authentifié » veut dire « n'importe
|
|
10
|
+
* qui sur Internet ». Une zone est donc retenue quand TOUT ceci est vrai :
|
|
11
|
+
* protégée, sans `anonymous`, sans `roles`, avec `session` (c'est par la
|
|
12
|
+
* session qu'un compte OAuth entre), et `oauth2` actif avec au moins un
|
|
13
|
+
* fournisseur et `allowSignup`.
|
|
14
|
+
*
|
|
15
|
+
* Le remède est une DÉCLARATION : `roles: ["ROLE_USER"]` ne change rien au
|
|
16
|
+
* comportement (un compte créé reçoit ce rôle) mais écrit l'intention.
|
|
17
|
+
*
|
|
18
|
+
* Calculée au démarrage et dans `describe()`, jamais par requête. C'est la
|
|
19
|
+
* SEULE implémentation de la règle : `doctor` lit son résultat par le plan
|
|
20
|
+
* d'administration, il ne la recalcule pas.
|
|
21
|
+
*
|
|
22
|
+
* @param config - la configuration de sécurité validée.
|
|
23
|
+
* @returns les zones concernées, dans l'ordre de déclaration ; vide sinon.
|
|
24
|
+
*/
|
|
25
|
+
function findZonesOpenToSignup(config) {
|
|
26
|
+
const { oauth2 } = config;
|
|
27
|
+
if (!oauth2.enabled || !oauth2.allowSignup) return [];
|
|
28
|
+
const providers = Object.keys(oauth2.providers).sort();
|
|
29
|
+
if (providers.length === 0) return [];
|
|
30
|
+
const found = [];
|
|
31
|
+
for (const [zone, area] of Object.entries(config.areas)) {
|
|
32
|
+
if (!area.security) continue;
|
|
33
|
+
if (area.roles.length > 0) continue;
|
|
34
|
+
if (area.authenticators.includes("anonymous")) continue;
|
|
35
|
+
if (!area.authenticators.includes("session")) continue;
|
|
36
|
+
const who = providers.join(", ");
|
|
37
|
+
found.push({
|
|
38
|
+
zone,
|
|
39
|
+
providers,
|
|
40
|
+
message: `la zone « ${zone} » est ouverte à tout compte que ${who} ${providers.length > 1 ? "délivrent" : "délivre"} : elle n'exige aucun rôle, et la connexion OAuth crée les comptes à la volée (\`oauth2.allowSignup\`)`,
|
|
41
|
+
action: `pour l'assumer, déclarer \`roles: ["ROLE_USER"]\` sur \`areas.${zone}\` ; pour la fermer, poser \`oauth2.allowSignup: false\` (un compte local préexistant devient alors requis)`
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return found;
|
|
45
|
+
}
|
|
46
|
+
export { findZonesOpenToSignup };
|