@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
package/docs/oauth2.md
CHANGED
|
@@ -33,8 +33,8 @@ source: "src/packages/@nodefony/security/docs/oauth2.md"
|
|
|
33
33
|
> connecté à **ton** application. Nodefony orchestre ce voyage avec la posture **OAuth 2.1**
|
|
34
34
|
> (RFC 9700) : Authorization Code, PKCE, `state` anti-CSRF, `iss` anti-mix-up. Point clé :
|
|
35
35
|
> **aucun jeton n'atteint le navigateur** — le retour produit une **session BFF**, exactement la même
|
|
36
|
-
> qu'un login par mot de passe. Ancré sur `OAuth2Service` (`oauth2.ts
|
|
37
|
-
> `OAuth2Controller` (`OAuth2Controller.ts
|
|
36
|
+
> qu'un login par mot de passe. Ancré sur `OAuth2Service` (`oauth2.ts#OAuth2Service`) et le controller BFF
|
|
37
|
+
> `OAuth2Controller` (`OAuth2Controller.ts#OAuth2Controller`).
|
|
38
38
|
|
|
39
39
|
📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **OAuth2**
|
|
40
40
|
|
|
@@ -111,16 +111,16 @@ qui les ferme ici :
|
|
|
111
111
|
|
|
112
112
|
- **Vol de jeton via XSS** — si un `access_token` transite par le navigateur, tout script injecté
|
|
113
113
|
peut le lire. _Fermé par construction_ : aucun jeton ne sort du serveur, le résultat est un cookie
|
|
114
|
-
de session opaque (`OAuth2Controller.ts
|
|
114
|
+
de session opaque (`OAuth2Controller.ts#OAuth2Controller.constructor`).
|
|
115
115
|
- **Interception du `code`** — un `code` capté (log de proxy, historique, redirection ouverte) est
|
|
116
116
|
échangeable par l'attaquant. _Fermé par **PKCE**_ : l'échange exige le `code_verifier` resté en
|
|
117
|
-
session (`OAuth2Service.createAuthorization()`, `oauth2.ts
|
|
117
|
+
session (`OAuth2Service.createAuthorization()`, `oauth2.ts#OAuth2Service.createAuthorization`).
|
|
118
118
|
- **CSRF de login** — un tiers force ta victime à terminer **son** flux à lui : elle se retrouve
|
|
119
119
|
connectée sur le compte de l'attaquant, qui lit ensuite ce qu'elle y dépose. _Fermé par le `state`_
|
|
120
|
-
comparé au retour (`OAuth2Controller.callback()`, `OAuth2Controller.ts
|
|
120
|
+
comparé au retour (`OAuth2Controller.callback()`, `OAuth2Controller.ts#OAuth2Controller.callback`).
|
|
121
121
|
- **Mix-up d'IdP** — un `code` obtenu chez un fournisseur malveillant est présenté au callback d'un
|
|
122
|
-
fournisseur de confiance. _Fermé par la vérification de l'`iss`_ (`oauth2.ts
|
|
123
|
-
l'exigence « même fournisseur qu'à l'aller » côté controller (`OAuth2Controller.ts
|
|
122
|
+
fournisseur de confiance. _Fermé par la vérification de l'`iss`_ (`oauth2.ts#OAuth2Service.listProviders`) **et** par
|
|
123
|
+
l'exigence « même fournisseur qu'à l'aller » côté controller (`OAuth2Controller.ts#OAuth2Controller`).
|
|
124
124
|
|
|
125
125
|
> [!IMPORTANT]
|
|
126
126
|
> Le fournisseur social te dit **qui** est la personne. Il ne te dit **rien** de ses droits.
|
|
@@ -133,28 +133,38 @@ Trois partis pris, tous vérifiables au code.
|
|
|
133
133
|
|
|
134
134
|
**Le service ne touche ni HTTP ni session.** `OAuth2Service` rend à l'appelant les éléments à
|
|
135
135
|
persister (`url`, `state`, `codeVerifier`) et un simple `{ identifier }` en sortie
|
|
136
|
-
(`IOAuthAuthorization`, `oauth2.ts
|
|
136
|
+
(`IOAuthAuthorization`, `oauth2.ts#IOAuthAuthorization`). Conséquence pratique : la logique OAuth se teste **sans
|
|
137
137
|
serveur**, comme `AuthFlow`. Le transport (cookies, redirections 302) vit dans le controller BFF.
|
|
138
138
|
|
|
139
139
|
**Le login social finit exactement comme un login classique.** Le callback appelle
|
|
140
|
-
`AuthFlow.establishSessionFor()` (`authFlow.ts
|
|
141
|
-
est actif, **régénère l'ID de session** (anti-fixation, `session.regenerateId()`, `authFlow.ts
|
|
140
|
+
`AuthFlow.establishSessionFor()` (`authFlow.ts#AuthFlow.establishSessionFor`), qui re-résout l'identité, vérifie que le compte
|
|
141
|
+
est actif, **régénère l'ID de session** (anti-fixation, `session.regenerateId()`, `authFlow.ts#AuthFlow.logout`)
|
|
142
142
|
et journalise l'événement
|
|
143
143
|
d'audit. Il n'existe **aucun** authenticator `oauth2` dans la chaîne du firewall : après le retour,
|
|
144
144
|
c'est l'authenticator `session` qui identifie chaque requête, comme après un mot de passe.
|
|
145
145
|
|
|
146
146
|
**Coût nul quand on ne s'en sert pas.** Aucune dépendance tierce : le client OAuth 2.0 est écrit
|
|
147
|
-
dans le module (`OAuth2Client`, `oauth2Client.ts
|
|
148
|
-
l'ID token — est importé **paresseusement**. Les fournisseurs sont construits
|
|
149
|
-
mémoïsés (`OAuth2Service.#resolveProvider()
|
|
150
|
-
|
|
151
|
-
|
|
147
|
+
dans le module (`OAuth2Client`, `oauth2Client.ts#OAuth2Client`), et `jose` — seul recours externe, pour lire les claims de
|
|
148
|
+
l'ID token — est importé **paresseusement**. Les fournisseurs sont construits une fois puis
|
|
149
|
+
mémoïsés (`OAuth2Service.#resolveProvider()`) : c'est là, une seule fois par processus, que les
|
|
150
|
+
points d'entrée d'un émetteur OIDC sont découverts. Un run qui sert (ports ouverts) les construit
|
|
151
|
+
dès le boot, sans l'attendre ; une commande console ne touche jamais le réseau.
|
|
152
|
+
|
|
153
|
+
**Une configuration fausse se montre au démarrage, pas au premier clic.** Au boot, l'`issuer` de
|
|
154
|
+
chaque fournisseur est confronté à ce que sa fabrique exige (`checkProviderIssuer()`,
|
|
155
|
+
`oauth2.ts`) : absent alors que la fabrique l'a déclaré requis, ou qui n'est pas une URL d'émetteur
|
|
156
|
+
valide (https, sans requête ni fragment — RFC 8414 §2), il **interrompt le démarrage** en nommant
|
|
157
|
+
la clé (`security.oauth2.providers.<nom>.issuer`). Un émetteur **injoignable**, lui, n'est pas une
|
|
158
|
+
faute de configuration : le boot continue, un WARNING le nomme, et son bouton quitte l'écran de
|
|
159
|
+
connexion — le flux `authorize` reste ouvert. Une nouvelle tentative part au plus toutes les 30 s
|
|
160
|
+
pendant que l'écran est affiché ; le bouton revient dès qu'elle réussit. Les routes sont montées dès
|
|
161
|
+
que `@nodefony/security` est chargé (`framework/index.ts#Framework.onKernelReady`) : sans social login configuré,
|
|
152
162
|
`…/providers` rend une liste vide, `authorize` rend `404 Unknown provider`, et `authorize`/`callback`
|
|
153
163
|
rendent `503 OAuth unavailable` quand `oauth2.enabled` vaut `false`.
|
|
154
164
|
|
|
155
165
|
Au boot, la config est validée et les fournisseurs configurés sont confrontés au registre : un nom
|
|
156
166
|
inconnu produit un **WARNING, pas un échec fatal** — `OAuth2Service.#build()` confronte les noms
|
|
157
|
-
configurés à `listOAuthProviders()` (`oauth2.ts
|
|
167
|
+
configurés à `listOAuthProviders()` (`oauth2.ts#OAuth2Service`) et le
|
|
158
168
|
reste de l'application démarre, le bouton correspondant n'apparaît simplement pas.
|
|
159
169
|
|
|
160
170
|
## 🚀 Démarrage rapide
|
|
@@ -171,10 +181,41 @@ de login quand la variable manque.
|
|
|
171
181
|
> valeur sous un second nom, qui finirait par diverger. La base des callbacks, elle, n'est émise par
|
|
172
182
|
> personne : c'est un réglage que votre application se donne, donc `NF_OAUTH_REDIRECT_BASE`.
|
|
173
183
|
|
|
174
|
-
```
|
|
184
|
+
```ts ignore
|
|
185
|
+
// nodefony/config/security.ts — le fragment du module, importé par le manifeste
|
|
186
|
+
import type { ConfigContext } from "nodefony";
|
|
187
|
+
import type { ISecurityConfigInput } from "@nodefony/security";
|
|
188
|
+
import type { env } from "../../env";
|
|
189
|
+
|
|
190
|
+
export const securityConfig = (ctx: ConfigContext<typeof env>) =>
|
|
191
|
+
({
|
|
192
|
+
oauth2: {
|
|
193
|
+
// Rôles posés à la CRÉATION du compte local, jamais réécrits ensuite.
|
|
194
|
+
defaultRoles: ["ROLE_USER"],
|
|
195
|
+
allowSignup: true, // false = un compte local déjà lié est exigé
|
|
196
|
+
successRedirect: "/",
|
|
197
|
+
failureRedirect: "/login?error=oauth",
|
|
198
|
+
providers: {
|
|
199
|
+
// Secrets absents → fournisseur non monté, bouton non affiché.
|
|
200
|
+
...(ctx.env.GITHUB_CLIENT_ID && ctx.env.GITHUB_CLIENT_SECRET
|
|
201
|
+
? {
|
|
202
|
+
github: {
|
|
203
|
+
clientId: ctx.env.GITHUB_CLIENT_ID,
|
|
204
|
+
clientSecret: ctx.env.GITHUB_CLIENT_SECRET,
|
|
205
|
+
redirectUri: `${ctx.env.NF_OAUTH_REDIRECT_BASE}/nodefony/security/api/oauth2/github/callback`,
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
: {}),
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
}) satisfies ISecurityConfigInput;
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
```typescript ignore
|
|
175
215
|
// env.ts — SEUL lecteur de process.env (catalogue typé, validé au boot).
|
|
176
216
|
// nodefony.config.ts — `ctx.env` EST ce catalogue (typé par le paramètre générique).
|
|
177
217
|
import { defineConfig, defineEnv, envString, use } from "nodefony";
|
|
218
|
+
import { securityConfig } from "./nodefony/config/security";
|
|
178
219
|
|
|
179
220
|
export const env = defineEnv({
|
|
180
221
|
GITHUB_CLIENT_ID: envString({ optional: true }),
|
|
@@ -188,42 +229,23 @@ export default defineConfig<typeof env>((ctx) => ({
|
|
|
188
229
|
modules: [
|
|
189
230
|
"@nodefony/http",
|
|
190
231
|
"@nodefony/framework",
|
|
191
|
-
use("@nodefony/security",
|
|
192
|
-
oauth2: {
|
|
193
|
-
// Rôles posés à la CRÉATION du compte local, jamais réécrits ensuite.
|
|
194
|
-
defaultRoles: ["ROLE_USER"],
|
|
195
|
-
allowSignup: true, // false = un compte local déjà lié est exigé
|
|
196
|
-
successRedirect: "/",
|
|
197
|
-
failureRedirect: "/login?error=oauth",
|
|
198
|
-
providers: {
|
|
199
|
-
// Secrets absents → fournisseur non monté, bouton non affiché.
|
|
200
|
-
...(ctx.env.GITHUB_CLIENT_ID && ctx.env.GITHUB_CLIENT_SECRET
|
|
201
|
-
? {
|
|
202
|
-
github: {
|
|
203
|
-
clientId: ctx.env.GITHUB_CLIENT_ID,
|
|
204
|
-
clientSecret: ctx.env.GITHUB_CLIENT_SECRET,
|
|
205
|
-
redirectUri: `${ctx.env.NF_OAUTH_REDIRECT_BASE}/nodefony/security/api/oauth2/github/callback`,
|
|
206
|
-
},
|
|
207
|
-
}
|
|
208
|
-
: {}),
|
|
209
|
-
},
|
|
210
|
-
},
|
|
211
|
-
}),
|
|
232
|
+
use("@nodefony/security", securityConfig(ctx)),
|
|
212
233
|
],
|
|
213
234
|
}));
|
|
214
235
|
```
|
|
215
236
|
|
|
216
237
|
### Les routes sont FOURNIES — tu n'écris aucun controller
|
|
217
238
|
|
|
218
|
-
`mountOAuth2Routes()` (`OAuth2Controller.ts
|
|
219
|
-
`/nodefony/security/api/oauth2` (`OAuth2Controller.ts
|
|
220
|
-
enregistré, c'est-à-dire dès que `@nodefony/security` est chargé (`framework/index.ts
|
|
239
|
+
`mountOAuth2Routes()` (`OAuth2Controller.ts#mountOAuth2Routes`) monte quatre routes sous
|
|
240
|
+
`/nodefony/security/api/oauth2` (`OAuth2Controller.ts#OAuth2Controller.backchannelLogout`) dès que le service `oauth2` est
|
|
241
|
+
enregistré, c'est-à-dire dès que `@nodefony/security` est chargé (`framework/index.ts#Framework.onKernelReady`) :
|
|
221
242
|
|
|
222
|
-
| Route
|
|
223
|
-
|
|
|
224
|
-
| `GET …/providers`
|
|
225
|
-
| `GET …/{provider}/authorize`
|
|
226
|
-
| `GET …/{provider}/callback`
|
|
243
|
+
| Route | Rôle |
|
|
244
|
+
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
245
|
+
| `GET …/providers` | Noms des fournisseurs **opérationnels** — l'UI n'affiche que ceux-là. |
|
|
246
|
+
| `GET …/{provider}/authorize` | Démarre le flux : pose l'état en session, `302` vers le fournisseur. |
|
|
247
|
+
| `GET …/{provider}/callback` | Valide, échange, provisionne, ouvre la session BFF, `302`. |
|
|
248
|
+
| `POST …/{provider}/backchannel-logout` | Canal arrière : le fournisseur demande de fermer des sessions (cf [§ canal arrière](#déconnexion-demandée-par-le-fournisseur--le-canal-arrière)). |
|
|
227
249
|
|
|
228
250
|
Ton écran de login n'a donc qu'un lien à poser :
|
|
229
251
|
|
|
@@ -234,10 +256,12 @@ Ton écran de login n'a donc qu'un lien à poser :
|
|
|
234
256
|
```
|
|
235
257
|
|
|
236
258
|
> [!WARNING]
|
|
237
|
-
> Ces routes portent `bypassFirewall: true` (`OAuth2Controller.ts
|
|
259
|
+
> Ces routes portent `bypassFirewall: true` (`OAuth2Controller.ts#mountOAuth2Routes`) — elles **sont** le mécanisme
|
|
238
260
|
> d'authentification : l'utilisateur est anonyme pendant tout l'aller-retour. Les protéger créerait
|
|
239
261
|
> un interblocage (il faudrait être connecté pour pouvoir se connecter). La session anonyme ne porte
|
|
240
|
-
> que `state`/`code_verifier`, et son ID est **régénéré** à la promotion.
|
|
262
|
+
> que `state`/`code_verifier`, et son ID est **régénéré** à la promotion. Le canal arrière, lui,
|
|
263
|
+
> est appelé **serveur à serveur** : ni cookie, ni session, ni jeton CSRF — la signature du jeton de
|
|
264
|
+
> déconnexion est sa seule authentification.
|
|
241
265
|
|
|
242
266
|
### Ce qu'on observe
|
|
243
267
|
|
|
@@ -256,7 +280,7 @@ curl -si -b /tmp/jar -c /tmp/jar \
|
|
|
256
280
|
|
|
257
281
|
# 3) L'identité est résolue comme après un login classique
|
|
258
282
|
curl -s -b /tmp/jar http://localhost:5151/nodefony/security/api/auth/me
|
|
259
|
-
# {"user":{"
|
|
283
|
+
# {"user":{"id":"…","identifier":"jane@example.com","roles":["ROLE_USER"]}}
|
|
260
284
|
|
|
261
285
|
# 4) Ce que l'UI de login interroge pour n'afficher que des boutons vivants
|
|
262
286
|
curl -s http://localhost:5151/nodefony/security/api/oauth2/providers
|
|
@@ -269,61 +293,92 @@ Séquence identique prouvée de bout en bout sur serveur réel par `oauth2-flow.
|
|
|
269
293
|
|
|
270
294
|
### Étape 1 — `createAuthorization(provider)`
|
|
271
295
|
|
|
272
|
-
`OAuth2Service.createAuthorization()` (`oauth2.ts
|
|
296
|
+
`OAuth2Service.createAuthorization()` (`oauth2.ts#OAuth2Service.createAuthorization`) fabrique trois choses :
|
|
273
297
|
|
|
274
298
|
1. un **`state`** aléatoire (anti-CSRF) ;
|
|
275
299
|
2. un **`code_verifier`** — **seulement si** le fournisseur pratique PKCE (`usesPkce`,
|
|
276
|
-
`oauth2.ts
|
|
300
|
+
`oauth2.ts#OAuth2Service.getRedirects`) ; `null` sinon (GitHub) ;
|
|
277
301
|
3. l'**URL d'autorisation** construite par l'adaptateur du fournisseur, avec les scopes effectifs
|
|
278
|
-
(ceux de la config, sinon les scopes par défaut du fournisseur, `oauth2.ts
|
|
302
|
+
(ceux de la config, sinon les scopes par défaut du fournisseur, `oauth2.ts#OAuth2Service.rememberLogout`).
|
|
279
303
|
|
|
280
304
|
Le controller pose les trois valeurs en session, **persiste** (`session.save()` — pas seulement en
|
|
281
|
-
mémoire, `OAuth2Controller.ts
|
|
305
|
+
mémoire, `OAuth2Controller.ts#IOAuth2Session.save`), puis redirige en 302.
|
|
282
306
|
|
|
283
307
|
### Étape 2 — le retour, validé avant tout appel réseau
|
|
284
308
|
|
|
285
|
-
`OAuth2Controller.callback()` (`OAuth2Controller.ts
|
|
309
|
+
`OAuth2Controller.callback()` (`OAuth2Controller.ts#OAuth2Controller.callback`) travaille dans cet ordre, et l'ordre est la
|
|
286
310
|
défense :
|
|
287
311
|
|
|
288
|
-
1. **lire l'état de session, puis l'invalider immédiatement** (`OAuth2Controller.ts
|
|
312
|
+
1. **lire l'état de session, puis l'invalider immédiatement** (`OAuth2Controller.ts#oauth2FailureTarget`) — le
|
|
289
313
|
`state` est à **usage unique** : un rejeu du même retour échoue, même avec le bon cookie ;
|
|
290
314
|
2. **comparer** : `code` et `state` présents, `state` reçu ≡ `state` attendu, **et** fournisseur du
|
|
291
|
-
callback ≡ fournisseur démarré (`OAuth2Controller.ts
|
|
292
|
-
`
|
|
315
|
+
callback ≡ fournisseur démarré (`OAuth2Controller.ts#OAuth2Controller.authorize`). Un seul écart → `302` vers
|
|
316
|
+
l'écran d'échec avec `reason=failed`, **sans jamais contacter le fournisseur** ;
|
|
293
317
|
3. seulement ensuite, `exchangeAndProvision()`.
|
|
294
318
|
|
|
295
319
|
### Étape 3 — `exchangeAndProvision(provider, code, verifier, iss)`
|
|
296
320
|
|
|
297
|
-
`OAuth2Service.exchangeAndProvision()` (`oauth2.ts
|
|
321
|
+
`OAuth2Service.exchangeAndProvision()` (`oauth2.ts#OAuth2Service.exchangeAndProvision`) enchaîne :
|
|
298
322
|
|
|
299
323
|
1. **anti-mix-up** — si le fournisseur annonce un émetteur attendu, l'`iss` reçu doit correspondre,
|
|
300
|
-
et un `iss` **absent** est un rejet, pas une tolérance (`oauth2.ts
|
|
324
|
+
et un `iss` **absent** est un rejet, pas une tolérance (`oauth2.ts#OAuth2Service.listProviders`) ;
|
|
301
325
|
2. **échange** du `code` sur le canal serveur, avec le `code_verifier`
|
|
302
|
-
(`validateAuthorizationCode`, `oauth2.ts
|
|
303
|
-
`oauth2.ts
|
|
326
|
+
(`validateAuthorizationCode`, `oauth2.ts#OAuth2Service.createAuthorization`), puis lecture du profil (`fetchProfile`,
|
|
327
|
+
`oauth2.ts#OAuth2Service.exchangeAndProvision`) ;
|
|
304
328
|
3. **provisionnement** du Shadow User avec la politique effective — rôles par défaut surchargeables
|
|
305
|
-
**par fournisseur** (`oauth2.ts
|
|
329
|
+
**par fournisseur** (`oauth2.ts#OAuth2Service.listDisplayProviders`), `allowSignup` global (`oauth2.ts#OAuth2Service.exchangeAndProvision`).
|
|
306
330
|
|
|
307
|
-
Toute erreur de cette étape est convertie en **échec uniforme** par le controller (`302
|
|
308
|
-
|
|
331
|
+
Toute erreur de cette étape est convertie en **échec uniforme** par le controller (`302` vers
|
|
332
|
+
l'écran d'échec avec `reason=failed`) : le client ne distingue pas un `iss` invalide d'un
|
|
309
333
|
échange refusé ou d'un signup interdit.
|
|
310
334
|
|
|
335
|
+
### Les retours — `from` et `back`
|
|
336
|
+
|
|
337
|
+
La page qui lance le flux dit deux choses à `/authorize`, que le serveur revalide en chemins
|
|
338
|
+
locaux (`safeRedirectPath`, garde anti-redirection ouverte du cœur), garde en session avec le
|
|
339
|
+
`state` et purge au callback :
|
|
340
|
+
|
|
341
|
+
| Paramètre | Sens | Absent ou refusé |
|
|
342
|
+
| --------- | ---------------------------------------------------------------------------------------------- | ----------------- |
|
|
343
|
+
| `from` | page où aller une fois connecté | `successRedirect` |
|
|
344
|
+
| `back` | écran où revenir si la connexion n'aboutit pas — le bouton « Retour » du fournisseur, un échec | `failureRedirect` |
|
|
345
|
+
|
|
346
|
+
`back` l'emporte sur `failureRedirect` parce qu'une application peut avoir PLUSIEURS écrans de
|
|
347
|
+
connexion (la page par défaut, la console d'administration) : une seule adresse d'échec renverrait
|
|
348
|
+
l'un chez l'autre. La page par défaut et la console l'envoient d'elles-mêmes, et
|
|
349
|
+
`NodefonyLogin.startProvider()` pose la page courante.
|
|
350
|
+
|
|
351
|
+
L'écran d'échec reçoit `reason` et `from` (une `reason` ou un `from` déjà présents sont
|
|
352
|
+
remplacés) : `reason=access_denied` dit une ANNULATION — l'écran se tait, l'utilisateur est revenu
|
|
353
|
+
choisir une autre méthode —, toute autre valeur un échec à annoncer. Seuls les codes normalisés
|
|
354
|
+
d'OAuth 2.0 et d'OpenID Connect sont recopiés, et seulement sous un `state` valide ; tout le reste
|
|
355
|
+
devient `failed` (`OAUTH2_FAILED_REASON`, cœur).
|
|
356
|
+
|
|
311
357
|
## 🧑⚖️ Le Shadow User — l'identité locale, et pourquoi OAuth n'accorde aucun droit
|
|
312
358
|
|
|
313
359
|
Nodefony ne « connecte pas un compte Google ». Il crée et retrouve une **ligne locale** liée au
|
|
314
360
|
compte externe : le _Shadow User_. C'est cette ligne qui porte l'identifiant, les rôles, l'état
|
|
315
361
|
actif/verrouillé — donc **tout** ce dont l'autorisation a besoin.
|
|
316
362
|
|
|
317
|
-
Le contrat s'appelle `IOAuthUserProvisioner` (`IOAuthUserProvisioner.ts
|
|
318
|
-
défaut est `UserService.provisionOAuthUser()` (`UserService.ts
|
|
319
|
-
|
|
320
|
-
| Situation au retour du fournisseur | Comportement
|
|
321
|
-
| ---------------------------------------- |
|
|
322
|
-
| Lien social déjà connu | Le compte existant est rendu tel quel — rien n'est créé, rien n'est réécrit.
|
|
323
|
-
| Lien inconnu, `allowSignup: true` | Création JIT : `password: null`, rôles = `defaultRoles`, lien social persisté.
|
|
324
|
-
| Lien inconnu, `allowSignup: false` | **Échec fail-closed** (`UserService.ts
|
|
325
|
-
| E-mail identique à un compte local | **Aucune liaison automatique** — un compte SÉPARÉ est créé.
|
|
326
|
-
| Même `providerId` chez deux fournisseurs | Comptes séparés (le couple `provider` + `providerId` fait la clé).
|
|
363
|
+
Le contrat s'appelle `IOAuthUserProvisioner` (`IOAuthUserProvisioner.ts#IOAuthUserProvisioner`) ; l'implémentation par
|
|
364
|
+
défaut est `UserService.provisionOAuthUser()` (`UserService.ts#UserService.provisionOAuthUser`), en **find-or-create** :
|
|
365
|
+
|
|
366
|
+
| Situation au retour du fournisseur | Comportement |
|
|
367
|
+
| ---------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
368
|
+
| Lien social déjà connu | Le compte existant est rendu tel quel — rien n'est créé, rien n'est réécrit. |
|
|
369
|
+
| Lien inconnu, `allowSignup: true` | Création JIT : `password: null`, rôles = `defaultRoles`, lien social persisté. |
|
|
370
|
+
| Lien inconnu, `allowSignup: false` | **Échec fail-closed** (`UserService.ts#UserService.provisionOAuthUser`) — un compte lié est exigé. |
|
|
371
|
+
| E-mail identique à un compte local | **Aucune liaison automatique** — un compte SÉPARÉ est créé. |
|
|
372
|
+
| Même `providerId` chez deux fournisseurs | Comptes séparés (le couple `provider` + `providerId` fait la clé). |
|
|
373
|
+
|
|
374
|
+
⚠️ **`allowSignup: true` (le défaut) + une zone sans `roles` = une zone ouverte à tout compte du
|
|
375
|
+
fournisseur.** Une zone qui n'exige aucun rôle laisse passer toute identité authentifiée ; avec la
|
|
376
|
+
création à la volée, cela veut dire quiconque obtient un compte chez Google ou GitHub. Le pare-feu le
|
|
377
|
+
signale au démarrage (`WARNING`), dans la console (`openToSignup` de la zone) et dans
|
|
378
|
+
`nodefony doctor --live` — c'est un constat, pas une faute (`findZonesOpenToSignup`,
|
|
379
|
+
`openToSignup.ts#findZonesOpenToSignup`). Deux gestes le soldent : `roles: ["ROLE_USER"]` sur la zone, qui ne change
|
|
380
|
+
rien au comportement mais **écrit** l'intention, ou `allowSignup: false`, qui exige un compte
|
|
381
|
+
préexistant.
|
|
327
382
|
|
|
328
383
|
### Pourquoi l'e-mail ne lie jamais automatiquement un compte
|
|
329
384
|
|
|
@@ -331,21 +386,21 @@ C'est le point le plus contre-intuitif, et c'est une décision de sécurité. Si
|
|
|
331
386
|
l'e-mail vaut `admin@ton-domaine.fr` liait automatiquement l'administrateur local, il suffirait de
|
|
332
387
|
créer un compte chez un fournisseur laxiste avec cette adresse pour **prendre le compte admin**. La
|
|
333
388
|
liaison par e-mail est donc refusée y compris quand le fournisseur certifie l'adresse
|
|
334
|
-
(`emailVerified` reste informatif, `IOAuthUserProvisioner.ts
|
|
389
|
+
(`emailVerified` reste informatif, `IOAuthUserProvisioner.ts#IOAuthProfile.emailVerified`).
|
|
335
390
|
|
|
336
391
|
Conséquence assumée : l'utilisateur qui avait un mot de passe et clique « avec GitHub » obtient un
|
|
337
392
|
**second** compte. Le rattachement d'un compte externe à un compte existant est une action explicite,
|
|
338
393
|
faite **utilisateur déjà connecté** — jamais un effet de bord du login.
|
|
339
394
|
|
|
340
395
|
L'identifiant du compte créé dérive de l'e-mail si le fournisseur en donne un, sinon d'une clé
|
|
341
|
-
préfixée `provider:providerId` — jamais de collision entre fournisseurs (`UserService.ts
|
|
396
|
+
préfixée `provider:providerId` — jamais de collision entre fournisseurs (`UserService.ts#UserService.provisionOAuthUser`).
|
|
342
397
|
|
|
343
398
|
### Les rôles sont posés à la création, et plus jamais
|
|
344
399
|
|
|
345
|
-
`defaultRoles` s'applique **au moment du `create`** (`UserService.ts
|
|
400
|
+
`defaultRoles` s'applique **au moment du `create`** (`UserService.ts#UserService.create`). Un second login
|
|
346
401
|
n'écrase rien : promouvoir quelqu'un dans ta base reste effectif, et modifier `defaultRoles` en
|
|
347
402
|
config ne repeint pas les comptes existants. C'est la traduction de la règle « OAuth =
|
|
348
|
-
authentification, pas autorisation » (`oauth2.ts
|
|
403
|
+
authentification, pas autorisation » (`oauth2.ts#OAuth2Service.listDisplayProviders`, `config.ts#loginPageSchema`).
|
|
349
404
|
|
|
350
405
|
> [!TIP]
|
|
351
406
|
> Un fournisseur social ne doit **jamais** figurer dans le chemin d'obtention d'un rôle privilégié.
|
|
@@ -355,18 +410,18 @@ authentification, pas autorisation » (`oauth2.ts:279-283`, `config.ts:1048-1053
|
|
|
355
410
|
### Brancher sa propre politique
|
|
356
411
|
|
|
357
412
|
Le provisioner est le service `users` **s'il implémente la capability**, détecté par duck-typing
|
|
358
|
-
(`OAuth2Service.#resolveProvisioner()`, `oauth2.ts
|
|
413
|
+
(`OAuth2Service.#resolveProvisioner()`, `oauth2.ts#OAuth2Service.#resolveProvisioner`). S'il ne l'implémente pas, le login
|
|
359
414
|
**échoue** — jamais de création silencieuse par défaut. Une application qui veut sa propre politique
|
|
360
415
|
(quota d'inscriptions, allowlist de domaines e-mail, rattachement à un tenant) implémente
|
|
361
416
|
`provisionOAuthUser()` sur son service `users` : le profil normalisé `IOAuthProfile`
|
|
362
|
-
(`IOAuthUserProvisioner.ts
|
|
417
|
+
(`IOAuthUserProvisioner.ts#IOAuthProfile`) lui donne `provider`, `providerId`, `email`, `emailVerified`, `name`
|
|
363
418
|
et la charge brute `raw`.
|
|
364
419
|
|
|
365
420
|
## 🧩 Fournisseurs — catalogue et extension
|
|
366
421
|
|
|
367
|
-
Un fournisseur est un adaptateur qui implémente `IOAuthProvider` (`IOAuthProvider.ts
|
|
422
|
+
Un fournisseur est un adaptateur qui implémente `IOAuthProvider` (`IOAuthProvider.ts#IOAuthProvider`) : il masque
|
|
368
423
|
les divergences (PKCE ou non, profil par ID token ou par appel d'API) derrière un contrat unique.
|
|
369
|
-
Quatre sont livrés, résolus par nom via le registre `oauthProviderRegistry.ts
|
|
424
|
+
Quatre sont livrés, résolus par nom via le registre `oauthProviderRegistry.ts#OAuthProviderFactory`.
|
|
370
425
|
|
|
371
426
|
| Nom | Famille | PKCE | `iss` vérifié | Profil lu depuis | Scopes par défaut |
|
|
372
427
|
| ---------- | ---------------- | :--: | --------------------- | ----------------- | ---------------------------- |
|
|
@@ -377,23 +432,25 @@ Quatre sont livrés, résolus par nom via le registre `oauthProviderRegistry.ts:
|
|
|
377
432
|
|
|
378
433
|
### `google` — OIDC, le cas nominal
|
|
379
434
|
|
|
380
|
-
Construit par le helper générique `createOidcProvider()` (`oidc.ts
|
|
381
|
-
(`usesPkce: true`, `oidc.ts
|
|
382
|
-
(`oauthProviderRegistry.ts
|
|
435
|
+
Construit par le helper générique `createOidcProvider()` (`oidc.ts#createOidcProvider`) : PKCE systématique
|
|
436
|
+
(`usesPkce: true`, `oidc.ts#readLogoutTokenClaims`), émetteur figé `https://accounts.google.com`
|
|
437
|
+
(`oauthProviderRegistry.ts#withKeycloakThemeHint`). Ses points d'entrée ne sont **pas** écrits en dur : ils sont
|
|
383
438
|
demandés à l'émetteur (RFC 8414, cf. « Découverte » plus bas). Le profil se lit dans l'**ID token** —
|
|
384
|
-
claims standard `sub`, `email`, `email_verified`, `name` (`oidc.ts
|
|
439
|
+
claims standard `sub`, `email`, `email_verified`, `name` (`oidc.ts#IOidcProviderOptions.name`), après les contrôles
|
|
385
440
|
obligatoires d'OpenID Connect Core §3.1.3.7 : `iss`, `aud`, `exp`, et un `sub` non vide
|
|
386
|
-
(`assertIdTokenClaims()`, `oidc.ts
|
|
441
|
+
(`assertIdTokenClaims()`, `oidc.ts#assertIdTokenClaims`). Pas d'identifiant stable, pas d'identité.
|
|
387
442
|
|
|
388
443
|
### `keycloak` — OIDC self-hosted, l'émetteur vient de ta config
|
|
389
444
|
|
|
390
445
|
Même helper, mais l'**issuer** (URL du realm) sert à la fois à découvrir les points d'entrée et à
|
|
391
|
-
valider l'`iss
|
|
392
|
-
|
|
446
|
+
valider l'`iss`. Il est donc **obligatoire**, et déclaré comme tel au registre
|
|
447
|
+
(`requiresIssuer: true`) : sans lui, le démarrage est refusé en nommant la clé.
|
|
448
|
+
Créer le realm et le client, lire l'émetteur, réussir le premier login : la page
|
|
449
|
+
[Keycloak](keycloak.md) fait le chemin entier.
|
|
393
450
|
|
|
394
451
|
### `oidc` — n'importe quel serveur OpenID Connect
|
|
395
452
|
|
|
396
|
-
La même mécanique, sans nom de marque : l'entrée `oidc` (`oauthProviderRegistry.ts
|
|
453
|
+
La même mécanique, sans nom de marque : l'entrée `oidc` (`oauthProviderRegistry.ts#withKeycloakThemeHint`) prend
|
|
397
454
|
l'émetteur de sa configuration et n'a besoin de rien d'autre. C'est elle qui rend inutile une classe
|
|
398
455
|
par fournisseur.
|
|
399
456
|
|
|
@@ -418,7 +475,7 @@ anti-mix-up **principale** est ailleurs — chaque fournisseur a son URL de redi
|
|
|
418
475
|
(`…/{provider}/callback`) et le flux vérifie que le fournisseur de retour est celui qui a démarré,
|
|
419
476
|
ce que la RFC 9700 §4.4.2.2 donne comme la protection de référence. `iss` est la seconde ceinture.
|
|
420
477
|
|
|
421
|
-
La politique est portée par le fournisseur (`issuerPolicy`, `IOAuthProvider.ts
|
|
478
|
+
La politique est portée par le fournisseur (`issuerPolicy`, `IOAuthProvider.ts#IOAuthProvider.issuerPolicy`) et remplie par
|
|
422
479
|
la découverte ; elle vaut `null` pour un fournisseur non-OIDC, qui ne relève pas de cette défense.
|
|
423
480
|
|
|
424
481
|
### Découverte des points d'entrée (RFC 8414)
|
|
@@ -430,7 +487,7 @@ points d'entrée sont demandés à l'émetteur, une seule fois par processus, au
|
|
|
430
487
|
bien connues (§3.1 : insertion oauth → insertion oidc → ajout oidc) et l'égalité stricte du §3.3
|
|
431
488
|
vivent dans le cœur (`nodefony` → `src/oauth/authorizationServer.ts`), qui s'en sert aussi pour
|
|
432
489
|
PUBLIER nos propres métadonnées. `metadata.ts` n'ajoute que le transport : requête bornée, sans
|
|
433
|
-
redirection suivie, avec un délai d'attente (`discoverAuthorizationServer()`, `metadata.ts
|
|
490
|
+
redirection suivie, avec un délai d'attente (`discoverAuthorizationServer()`, `metadata.ts#discoverAuthorizationServer`).
|
|
434
491
|
|
|
435
492
|
Deux refus valent d'être connus. Un document dont l'`issuer` diffère de celui demandé est rejeté
|
|
436
493
|
**sans se rabattre** sur l'URL suivante — se rabattre masquerait un document hostile derrière un 404.
|
|
@@ -446,17 +503,17 @@ donnerait l'illusion de PKCE.
|
|
|
446
503
|
### `github` — OAuth simple, l'archétype non-OIDC
|
|
447
504
|
|
|
448
505
|
Pas de PKCE, pas d'ID token, pas d'`iss` (`usesPkce: false`, `issuerPolicy: null`,
|
|
449
|
-
`github.ts
|
|
450
|
-
de l'API REST `/user` (`createGithubProvider()`, `github.ts
|
|
506
|
+
`github.ts#normalizeGithubError`) : ici, la défense anti-CSRF repose **entièrement** sur le `state`. Le profil vient
|
|
507
|
+
de l'API REST `/user` (`createGithubProvider()`, `github.ts#createGithubProvider`). Subtilité GitHub : l'e-mail
|
|
451
508
|
primaire est souvent privé — l'adaptateur bascule alors sur `/user/emails` et n'accepte
|
|
452
|
-
`emailVerified` que si GitHub le certifie (`github.ts
|
|
509
|
+
`emailVerified` que si GitHub le certifie (`github.ts#createGithubProvider`).
|
|
453
510
|
|
|
454
511
|
### Enregistrer le sien — sans éditer le cœur
|
|
455
512
|
|
|
456
513
|
**Tout serveur OpenID Connect conforme est déjà supporté** — Auth0, Okta, Authentik, Entra
|
|
457
514
|
mono-locataire… — sans une ligne de code propre. Le builtin `oidc` suffit quand il n'y en a qu'un ;
|
|
458
|
-
pour en nommer plusieurs, `registerOAuthProvider()`
|
|
459
|
-
|
|
515
|
+
pour en nommer plusieurs, `registerOAuthProvider()` au chargement de ton module (avant le
|
|
516
|
+
`onBoot` du service) :
|
|
460
517
|
|
|
461
518
|
```typescript ignore
|
|
462
519
|
import {
|
|
@@ -466,12 +523,16 @@ import {
|
|
|
466
523
|
|
|
467
524
|
// Le nom sert de clé de configuration ET de `provider` du Shadow User ;
|
|
468
525
|
// l'émetteur vient de la config (`oauth2.providers.microsoft.issuer`).
|
|
469
|
-
registerOAuthProvider(
|
|
470
|
-
|
|
526
|
+
registerOAuthProvider(
|
|
527
|
+
"microsoft",
|
|
528
|
+
(ctx) => createDiscoveredOidcProvider("microsoft", ctx),
|
|
529
|
+
// Déclarer l'émetteur requis : son oubli refuse le démarrage au lieu
|
|
530
|
+
// d'échouer au premier clic.
|
|
531
|
+
{ requiresIssuer: true },
|
|
471
532
|
);
|
|
472
533
|
```
|
|
473
534
|
|
|
474
|
-
La fabrique reçoit `IOAuthProviderContext` (`oauthProviderRegistry.ts
|
|
535
|
+
La fabrique reçoit `IOAuthProviderContext` (`oauthProviderRegistry.ts#IOAuthProviderContext`) : les secrets et l'URL de
|
|
475
536
|
callback issus de la config, rien d'autre. Elle peut être **asynchrone** — découvrir un émetteur est
|
|
476
537
|
une opération de construction, faite une fois par processus.
|
|
477
538
|
|
|
@@ -480,35 +541,36 @@ protocole vient de `OAuth2Client`, la fabrique ne fait que lire le profil. C'est
|
|
|
480
541
|
lignes — `github.ts` en est le modèle.
|
|
481
542
|
|
|
482
543
|
Un fournisseur qui n'est pas OIDC (pas d'ID token, profil lu à son API) s'écrit comme GitHub
|
|
483
|
-
(`createGithubProvider()`, `github.ts
|
|
544
|
+
(`createGithubProvider()`, `github.ts#createGithubProvider`) : `OAuth2Client` porte le protocole, la fabrique ne fait
|
|
484
545
|
que le mapping du profil. Exemple sans réseau dans le dépôt :
|
|
485
546
|
`src/modules/test/nodefony/secure/oauthTestProvider.ts`.
|
|
486
547
|
|
|
487
548
|
## ⚙️ Configuration
|
|
488
549
|
|
|
489
|
-
|
|
490
|
-
(`config.ts
|
|
550
|
+
Schéma Zod `oauth2Schema` (`config.ts#oauth2Schema`), branché sur la section `oauth2` de la config du module
|
|
551
|
+
(`config.ts#securityConfigSchema`). Table dérivée du schéma — les défauts sont ceux du code.
|
|
491
552
|
|
|
492
|
-
| Option | Type | Défaut
|
|
493
|
-
| ----------------- | -------------------- |
|
|
494
|
-
| `enabled` | booléen | `true`
|
|
495
|
-
| `defaultRoles` | liste de rôles | `["ROLE_USER"]`
|
|
496
|
-
| `allowSignup` | booléen | `true`
|
|
497
|
-
| `successRedirect` | chemin | `/`
|
|
498
|
-
| `failureRedirect` | chemin | `/login`
|
|
499
|
-
| `providers` | dictionnaire par nom | `{}`
|
|
553
|
+
| Option | Type | Défaut | Effet |
|
|
554
|
+
| ----------------- | -------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
555
|
+
| `enabled` | booléen | `true` | Coupe le social login : `authorize`/`callback` rendent 503. |
|
|
556
|
+
| `defaultRoles` | liste de rôles | `["ROLE_USER"]` | Rôles du Shadow User **à la création** (`config.ts#oauthProviderSchema`). |
|
|
557
|
+
| `allowSignup` | booléen | `true` | `false` = compte préexistant lié exigé (`config.ts#oauth2Schema`). |
|
|
558
|
+
| `successRedirect` | chemin | `/` | Où revient l'utilisateur après succès, quand la page de départ n'a pas donné `from`. |
|
|
559
|
+
| `failureRedirect` | chemin | `loginPage.path` (`/login`) | Où il revient après échec, quand la page de départ n'a pas donné `back`. Omis = la page de connexion, qu'il suit si elle change de chemin. |
|
|
560
|
+
| `providers` | dictionnaire par nom | `{}` | Fournisseurs activés (`config.ts#oauth2Schema`). |
|
|
500
561
|
|
|
501
|
-
Par fournisseur (`oauthProviderSchema`, `config.ts
|
|
562
|
+
Par fournisseur (`oauthProviderSchema`, `config.ts#oauthProviderSchema`) :
|
|
502
563
|
|
|
503
564
|
<!-- prettier-ignore -->
|
|
504
565
|
| Option | Requis | Effet |
|
|
505
566
|
| --- | :---: | --- |
|
|
506
567
|
| `clientId` / `clientSecret` | ✅ | Identifiants délivrés par l'IdP. Secrets : par `env.ts`, jamais journalisés. |
|
|
507
|
-
| `redirectUri` | ✅ | URL de callback **exacte** (`config.ts
|
|
568
|
+
| `redirectUri` | ✅ | URL de callback **exacte** (`config.ts#oauthProviderSchema`). |
|
|
508
569
|
| `issuer` | OIDC self-hosted | Realm Keycloak ; ignoré par les IdP à endpoints fixes. |
|
|
509
570
|
| `clientAuthMethod` | | Comment le client s'authentifie au point de jeton (RFC 6749 §2.3). Omis = `client_secret_basic`, ce que la RFC demande de préférer. Poser `client_secret_post` quand le serveur l'EXIGE — il le publie dans `token_endpoint_auth_methods_supported`. |
|
|
510
571
|
| `scopes` | | Vide = scopes par défaut du fournisseur. |
|
|
511
|
-
| `successRedirect` / `failureRedirect` / `defaultRoles` | | Surchargent le global **pour ce fournisseur** (`oauth2.ts
|
|
572
|
+
| `successRedirect` / `failureRedirect` / `defaultRoles` | | Surchargent le global **pour ce fournisseur** (`oauth2.ts#OAuth2Service.exchangeAndProvision`). |
|
|
573
|
+
| `postLogoutRedirectUri` | | Adresse **absolue** où le fournisseur renvoie le navigateur après la déconnexion, enregistrée chez lui. Omis = la page de connexion (`failureRedirect` sans sa query) résolue contre `redirectUri`. Sans effet pour un fournisseur qui ne publie pas de point de déconnexion. |
|
|
512
574
|
|
|
513
575
|
Les surcharges par fournisseur permettent la cohabitation : un IdP de recette garde ses redirections
|
|
514
576
|
et ses rôles pendant qu'un IdP de production pointe ailleurs.
|
|
@@ -518,10 +580,10 @@ et ses rôles pendant qu'un IdP de production pointe ailleurs.
|
|
|
518
580
|
### Les jetons du fournisseur ne sont pas conservés
|
|
519
581
|
|
|
520
582
|
C'est un choix, et il a des conséquences à connaître. Les jetons obtenus à l'échange vivent dans la
|
|
521
|
-
portée locale de l'échange (`validateAuthorizationCode` puis `fetchProfile`, `oauth2.ts
|
|
583
|
+
portée locale de l'échange (`validateAuthorizationCode` puis `fetchProfile`, `oauth2.ts#OAuth2Service.exchangeAndProvision`) :
|
|
522
584
|
ils ne sont ni retournés, ni mis en
|
|
523
585
|
session, ni persistés. Le profil normalisé qui traverse le système n'en contient aucun
|
|
524
|
-
(`IOAuthUserProvisioner.ts
|
|
586
|
+
(`IOAuthUserProvisioner.ts#IOAuthProfile`).
|
|
525
587
|
|
|
526
588
|
- **Conséquence 1** — la surface d'exposition est minimale : pas de coffre de jetons à protéger, pas
|
|
527
589
|
de fuite possible par la base ni par la session.
|
|
@@ -529,47 +591,106 @@ session, ni persistés. Le profil normalisé qui traverse le système n'en conti
|
|
|
529
591
|
l'utilisateur plus tard (lire ses dépôts, envoyer un mail). Nodefony fait de l'**authentification**,
|
|
530
592
|
pas de la **délégation d'accès**.
|
|
531
593
|
- **Si tu as besoin de cette délégation** : le seul endroit où les jetons sont visibles est le
|
|
532
|
-
`fetchProfile()` de ton adaptateur (`IOAuthProvider.ts
|
|
594
|
+
`fetchProfile()` de ton adaptateur (`IOAuthProvider.ts#IOAuthProvider.fetchProfile`) — c'est là que ton implémentation les
|
|
533
595
|
capture et les persiste, sous ta responsabilité (chiffrement au repos, rotation, révocation).
|
|
534
596
|
|
|
597
|
+
**Une exception, et une seule : l'ID token d'un fournisseur qui sait déconnecter.** Quand le
|
|
598
|
+
fournisseur publie un point de déconnexion (`end_session_endpoint`, lu par la découverte — Keycloak
|
|
599
|
+
le fait), la session BFF retient, **côté serveur**, le nom du fournisseur, l'ID token et le claim
|
|
600
|
+
`sid`. La console d'administration des sessions n'en montre rien. Le navigateur ne le voit qu'**une
|
|
601
|
+
fois**, à la déconnexion, dans l'adresse `logoutUrl` (`id_token_hint`) : RP-Initiated Logout passe
|
|
602
|
+
par une redirection du navigateur, la norme ne laisse pas d'autre chemin — la session locale est
|
|
603
|
+
alors déjà détruite. Pourquoi : à la déconnexion, l'ID token est rejoué en `id_token_hint` — il désigne la session à
|
|
604
|
+
fermer chez le fournisseur, qui sans lui demanderait une confirmation à chaque fois. Il n'ouvre
|
|
605
|
+
**aucun** accès : ni l'API du fournisseur, ni un rafraîchissement. Le jeton d'accès et le jeton de
|
|
606
|
+
rafraîchissement restent jetés. GitHub, ou un serveur OIDC sans point de déconnexion : rien n'est
|
|
607
|
+
retenu.
|
|
608
|
+
|
|
609
|
+
Pour que le fournisseur puisse, à l'inverse, fermer lui-même ces sessions, le login retient aussi
|
|
610
|
+
un **index** — fournisseur et `sid`, **sans** l'ID token — dans les métadonnées de la session
|
|
611
|
+
(`oauth2.ts#IFederatedLogoutHint.sid`). Il est là parce que l'énumération des sessions efface les attributs, dans tous
|
|
612
|
+
les stores : c'est la seule partie que le canal arrière peut lire en parcourant le parc. L'écran
|
|
613
|
+
**Sessions** ne l'affiche pas (résumé construit par liste blanche).
|
|
614
|
+
|
|
615
|
+
### Déconnexion demandée par le fournisseur — le canal arrière
|
|
616
|
+
|
|
617
|
+
Quand l'utilisateur se déconnecte **ailleurs** — la console d'administration de Keycloak, une autre
|
|
618
|
+
application du même SSO —, le fournisseur appelle l'application : `POST
|
|
619
|
+
…/oauth2/{provider}/backchannel-logout`, avec un paramètre `logout_token` (OpenID Connect
|
|
620
|
+
Back-Channel Logout 1.0). Le contrôleur (`OAuth2Controller.ts#OAuth2Controller.callback`) délègue au service
|
|
621
|
+
(`OAuth2Service.backchannelLogout()`, `oauth2.ts#OAuth2Service.backchannelLogout`) :
|
|
622
|
+
|
|
623
|
+
1. **La signature d'abord.** Le jeton est vérifié par `RemoteJwtVerifier` (`verifyClaims`,
|
|
624
|
+
`RemoteJwtVerifier.ts#RemoteJwtVerifier.verifyClaims`), avec les clés publiques du fournisseur découvertes à son `jwks_uri` :
|
|
625
|
+
émetteur exact, audience = le `clientId`, `iat`, `exp` et `jti` exigés
|
|
626
|
+
(`oidc.ts#createDiscoveredOidcProvider`). Les algorithmes sont ceux des ID tokens annoncés par le fournisseur, **sans**
|
|
627
|
+
ceux à secret partagé (`oidc.ts#createDiscoveredOidcProvider`) — Keycloak annonce `HS256`, qui ferait d'une clé publique un
|
|
628
|
+
secret de signature.
|
|
629
|
+
2. **Le sens ensuite** (`readLogoutTokenClaims()`, `oidc.ts#readLogoutTokenClaims`) : `sub` ou `sid` présent ; `events`
|
|
630
|
+
qui le déclare jeton de déconnexion ; **aucun** `nonce`. C'est ce qui empêche un ID token, signé
|
|
631
|
+
par la même clé, de passer pour un jeton de déconnexion.
|
|
632
|
+
3. **Pas de rejeu** : un `jti` déjà vu est refusé (`oauth2.ts#OAuth2Service.backchannelLogout`).
|
|
633
|
+
4. **Les sessions désignées** sont détruites par `SessionsService.destroyWhere()`
|
|
634
|
+
(`sessions-service.ts#SessionsService.destroyWhere`) : celles ouvertes par **ce** fournisseur et qui portent le `sid` du
|
|
635
|
+
jeton ; sans `sid`, toutes celles du compte lié au `sub`. Une session ouverte par mot de passe ou
|
|
636
|
+
par un autre fournisseur n'est **jamais** touchée : le fournisseur ne ferme que ce qu'il a ouvert.
|
|
637
|
+
|
|
638
|
+
Réponses (norme §2.8) : `200` quand la déconnexion a eu lieu — ou n'avait plus lieu d'être ; `400`
|
|
639
|
+
`invalid_request` pour un jeton refusé **comme** pour une déconnexion impossible (jeu de clés
|
|
640
|
+
injoignable), la cause n'allant qu'au journal ; `404` fournisseur inconnu ; `501` fournisseur qui
|
|
641
|
+
n'émet pas de jeton de déconnexion (GitHub). Toujours `Cache-Control: no-store`.
|
|
642
|
+
|
|
643
|
+
Les **WebSockets** portées par une session détruite se ferment au tick de revalidation du hub temps
|
|
644
|
+
réel, au plus 30 secondes plus tard (`RealtimeHub.ts#channelFamily`), code `4001`.
|
|
645
|
+
|
|
646
|
+
> [!NOTE]
|
|
647
|
+
> L'anti-rejeu vit **dans le processus**. Plusieurs exemplaires : un jeton rejoué sur un autre
|
|
648
|
+
> exemplaire passe — et ne ferme que des sessions que le fournisseur a déjà déclarées closes. La
|
|
649
|
+
> borne est la durée de vie du jeton, que la norme recommande courte (§4).
|
|
650
|
+
|
|
535
651
|
### Ce que « révoquer » veut dire ici
|
|
536
652
|
|
|
537
|
-
| Action
|
|
538
|
-
|
|
|
539
|
-
| Déconnexion (`
|
|
540
|
-
| Compte local désactivé/verrouillé
|
|
541
|
-
|
|
|
542
|
-
| `allowSignup: false` après coup
|
|
653
|
+
| Action | Effet sur ton application |
|
|
654
|
+
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
655
|
+
| Déconnexion (`POST …/auth/logout`) | Session détruite côté serveur + cookie effacé — immédiat. Si la session vient d'un fournisseur qui publie un point de déconnexion, la réponse porte `logoutUrl` : le client y envoie le navigateur, qui ferme **aussi** la session du fournisseur. Sans ce détour, le clic suivant sur « Continuer avec Keycloak » reconnecte **sans mot de passe**. |
|
|
656
|
+
| Compte local désactivé/verrouillé | Rejet à la requête suivante : l'identité est **re-résolue** à chaque requête. |
|
|
657
|
+
| Session fermée **chez le fournisseur** | Fournisseur OpenID Connect qui appelle le canal arrière (Keycloak, client configuré) : les sessions qu'il a ouvertes sont détruites. Sinon (GitHub, fournisseur non configuré pour ce canal) : **aucun effet** — la session locale reste valide jusqu'à son terme. |
|
|
658
|
+
| `allowSignup: false` après coup | Bloque les nouveaux comptes, pas les liens existants. |
|
|
543
659
|
|
|
544
660
|
La troisième ligne est le piège courant : une fois la session BFF ouverte, ton application ne
|
|
545
|
-
redemande plus rien à GitHub. Pour couper l'accès, il faut agir **localement** (désactiver le compte
|
|
661
|
+
redemande plus rien à GitHub — et Keycloak ne prévient que s'il connaît l'adresse du canal arrière. Pour couper l'accès, il faut agir **localement** (désactiver le compte
|
|
546
662
|
ou détruire les sessions), pas chez le fournisseur.
|
|
547
663
|
|
|
548
664
|
### Attaques couvertes, prouvées par les tests
|
|
549
665
|
|
|
550
|
-
| Vecteur
|
|
551
|
-
|
|
|
552
|
-
| Rejeu du retour (même `code`, même `state`)
|
|
553
|
-
| `state` valide présenté au callback d'un autre IdP
|
|
554
|
-
| `iss` falsifié
|
|
555
|
-
| Prise de compte par e-mail collidant un admin
|
|
556
|
-
| Élévation de privilège par re-login
|
|
557
|
-
| Collision d'identifiants entre fournisseurs
|
|
558
|
-
| Interception du `code`
|
|
559
|
-
| Création de compte non voulue
|
|
666
|
+
| Vecteur | Défense | Preuve |
|
|
667
|
+
| ----------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------- |
|
|
668
|
+
| Rejeu du retour (même `code`, même `state`) | `state` consommé + session régénérée à la promotion | `oauth2-attack.test.ts` (S5) |
|
|
669
|
+
| `state` valide présenté au callback d'un autre IdP | Fournisseur attendu conservé en session et comparé | `oauth2-attack.test.ts` (S6) |
|
|
670
|
+
| `iss` falsifié | Comparaison stricte à l'émetteur de la politique | `oauth2Service.test.ts` |
|
|
671
|
+
| Prise de compte par e-mail collidant un admin | Aucune liaison auto : compte séparé, admin intact | `oauth.attack.test.ts` (A1) |
|
|
672
|
+
| Élévation de privilège par re-login | Rôles posés à la création, jamais réécrits | `oauth.attack.test.ts` (A2) |
|
|
673
|
+
| Collision d'identifiants entre fournisseurs | Clé = `provider` + `providerId` | `oauth.attack.test.ts` (A3) |
|
|
674
|
+
| Interception du `code` | PKCE : `code_verifier` exigé, refus si absent | `oauthProviders.test.ts` |
|
|
675
|
+
| Création de compte non voulue | Provisioner absent (`provisionOAuthUser`) → fail-closed | `oauth2Service.test.ts#makeUsers` |
|
|
676
|
+
| Jeton de déconnexion forgé ou signé par une autre clé | Signature vérifiée sur le jeu de clés de l'émetteur | `oauthProviders.test.ts` · `oauth2-keycloak.test.ts` |
|
|
677
|
+
| ID token présenté comme jeton de déconnexion | `events` exigé, `nonce` interdit | `oauthProviders.test.ts` |
|
|
678
|
+
| Rejeu d'un jeton de déconnexion | `jti` déjà vu → refusé | `oauth2Service.test.ts` |
|
|
560
679
|
|
|
561
680
|
## 📜 Normes appliquées
|
|
562
681
|
|
|
563
|
-
| Domaine
|
|
564
|
-
|
|
|
565
|
-
| Flux Authorization Code
|
|
566
|
-
| PKCE
|
|
567
|
-
| Sécurité OAuth (BCP 2.1)
|
|
568
|
-
| Anti-mix-up (`iss`)
|
|
569
|
-
| Callback en correspondance exacte
|
|
570
|
-
| Claims d'identité OIDC
|
|
571
|
-
| ID token consommé en code flow
|
|
572
|
-
| Anti-fixation de session
|
|
682
|
+
| Domaine | Norme | Ancrage |
|
|
683
|
+
| ------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
684
|
+
| Flux Authorization Code | RFC 6749 | `IOAuthProvider.validateAuthorizationCode()` (`IOAuthProvider.ts#IOAuthProvider.validateAuthorizationCode`) |
|
|
685
|
+
| PKCE | RFC 7636 | `usesPkce` (`IOAuthProvider.ts#IOAuthProvider.usesPkce`) · `oidc.ts#readLogoutTokenClaims` |
|
|
686
|
+
| Sécurité OAuth (BCP 2.1) | RFC 9700 | `OAuth2Service` (`oauth2.ts#OAuth2Service`) · `oauth2Schema` (`config.ts#oauth2Schema`) |
|
|
687
|
+
| Anti-mix-up (`iss`) | RFC 9207 | `issuerPolicy` (`IOAuthProvider.ts#IOAuthProvider.issuerPolicy`) · `oauth2.ts#OAuth2Service.listProviders` |
|
|
688
|
+
| Callback en correspondance exacte | RFC 9700 §4 | `redirectUri` (`config.ts#oauthProviderSchema`) |
|
|
689
|
+
| Claims d'identité OIDC | OpenID Connect Core | `fetchProfile()` du helper OIDC (`oidc.ts#createOidcProvider`) |
|
|
690
|
+
| ID token consommé en code flow | OIDC Core §3.1.3.7 | `assertIdTokenClaims()` (`oidc.ts#assertIdTokenClaims`) |
|
|
691
|
+
| Anti-fixation de session | OWASP Session Management | `session.regenerateId()` au login (`authFlow.ts#AuthFlow.logout`) |
|
|
692
|
+
| Déconnexion initiée par l'application | OpenID Connect RP-Initiated Logout 1.0 | `createLogoutURL` (`oidc.ts#createOidcProvider`) · `end_session_endpoint` découvert (`metadata.ts#discoverAuthorizationServer`) |
|
|
693
|
+
| Déconnexion par le fournisseur | OpenID Connect Back-Channel Logout 1.0 | `verifyLogoutToken` (`oidc.ts#createOidcProvider`) · `backchannelLogout()` (`oauth2.ts#OAuth2Service.backchannelLogout`) |
|
|
573
694
|
|
|
574
695
|
Flux **exclus** par posture 2.1, et donc absents du code : `implicit` (jeton en fragment d'URL) et
|
|
575
696
|
`password` / ROPC (l'application verrait le mot de passe du fournisseur).
|
|
@@ -604,35 +725,38 @@ retirer de la configuration.
|
|
|
604
725
|
technique brut.
|
|
605
726
|
|
|
606
727
|
Côté suivi, chaque login réussi produit un événement d'audit `auth` / `login.success` via
|
|
607
|
-
`AuthFlow.establishSessionFor()` (`authFlow.ts
|
|
728
|
+
`AuthFlow.establishSessionFor()` (`authFlow.ts#AuthFlow.establishSessionFor`), consultable dans l'écran **Audit**. La
|
|
608
729
|
session ouverte apparaît dans l'écran **Sessions** (IP et agent capturés à l'ouverture) ; le compte
|
|
609
730
|
provisionné dans l'écran **Users**, avec ses rôles réels.
|
|
610
731
|
|
|
611
732
|
> [!NOTE]
|
|
612
733
|
> L'événement d'audit du login social porte la raison `oauth` : le controller la passe à
|
|
613
|
-
> `AuthFlow.establishSessionFor()` (`OAuth2Controller.ts
|
|
734
|
+
> `AuthFlow.establishSessionFor()` (`OAuth2Controller.ts#IOAuth2BffFlow.establishSessionFor`), comme WebAuthn passe `webauthn`.
|
|
614
735
|
> `federated` n'est que la valeur par défaut d'un appelant qui n'a pas nommé son facteur
|
|
615
|
-
> (`authFlow.ts
|
|
736
|
+
> (`authFlow.ts#AuthFlow.establishSessionFor`).
|
|
616
737
|
|
|
617
738
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
618
739
|
|
|
619
|
-
| Symptôme
|
|
620
|
-
|
|
|
621
|
-
| `404` sur `…/oauth2/…`
|
|
622
|
-
| `503 OAuth unavailable`
|
|
623
|
-
| WARNING « inconnu du registre » au boot
|
|
624
|
-
| `404` « Unknown provider » sur `authorize`
|
|
625
|
-
| Bouton absent de l'écran de login
|
|
626
|
-
|
|
|
627
|
-
|
|
|
628
|
-
|
|
|
629
|
-
|
|
|
630
|
-
|
|
|
631
|
-
|
|
|
632
|
-
|
|
|
633
|
-
|
|
|
634
|
-
|
|
|
635
|
-
|
|
|
740
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
741
|
+
| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
|
742
|
+
| `404` sur `…/oauth2/…` | Module `@nodefony/security` non chargé, ou nom de fournisseur inconnu | Charger `@nodefony/security` ; vérifier le nom du fournisseur |
|
|
743
|
+
| `503 OAuth unavailable` | `oauth2.enabled: false`, ou boot du service échoué | Activer `oauth2` ; lire le WARNING de boot |
|
|
744
|
+
| WARNING « inconnu du registre » au boot | Nom configuré sans fabrique (`OAuth2Service.#build()`) | `registerOAuthProvider()` au chargement du module, ou builtin |
|
|
745
|
+
| `404` « Unknown provider » sur `authorize` | Le nom n'est pas dans `listProviders()` (`OAuth2Controller.ts#IOAuth2Service.listProviders`) | Vérifier le nom exact **et** la présence des secrets |
|
|
746
|
+
| Bouton absent de l'écran de login | Secrets manquants → fournisseur non monté (spread conditionnel) | Renseigner `clientId`/`clientSecret` dans l'env |
|
|
747
|
+
| Bouton absent + WARNING « indisponible » au boot | Émetteur injoignable à la construction (`OAuth2Service.#resolveProvider()`) | Démarrer le serveur d'autorisation ; le bouton revient sous 30 s |
|
|
748
|
+
| `redirect_uri_mismatch` chez le fournisseur | `redirectUri` ≠ URL enregistrée, au caractère près (`config.ts#oauthProviderSchema`) | Aligner schéma, hôte, port et chemin `/…/{provider}/callback` |
|
|
749
|
+
| Retour systématique sur `failureRedirect` | `state`/`verifier` absents (cookie perdu entre les deux requêtes) | Vérifier `SameSite`/domaine du cookie ; un seul hôte en dev |
|
|
750
|
+
| Callback échoue au **deuxième** essai | `state` à usage unique, consommé (`OAuth2Controller.ts#oauth2FailureTarget`) | Refaire le flux depuis `authorize` — comportement attendu |
|
|
751
|
+
| `OAuth issuer mismatch` | `iss` reçu ≠ l'émetteur attendu (`oauth2.ts#OAuth2Service.listProviders`) | Corriger `issuer` (Keycloak : URL exacte du realm) |
|
|
752
|
+
| Boot refusé : `….providers.<nom>.issuer` | Émetteur absent (fabrique `requiresIssuer`) ou mal formé (`checkProviderIssuer()`) | Renseigner l'URL https du realm / de l'émetteur |
|
|
753
|
+
| « provisioning indisponible » | `users` n'implémente pas la capability (`oauth2.ts#OAuth2Service`) | Implémenter `provisionOAuthUser()` sur le service `users` |
|
|
754
|
+
| Profil connu refusé | `allowSignup: false` sans lien préexistant (`UserService.ts#UserService.provisionOAuthUser`) | Activer `allowSignup` ou lier le compte au préalable |
|
|
755
|
+
| Doublon de compte pour un utilisateur existant | Aucune liaison auto par e-mail (choix de sécurité) | Rattacher explicitement, utilisateur connecté |
|
|
756
|
+
| Rôle attendu absent après re-login | Rôles posés à la **création** seulement (`provisionOAuthUser`, `UserService.ts#UserService.provisionOAuthUser`), sauf `roleMapping` | Modifier les rôles en base, ou déclarer `roleMapping` ([Keycloak](keycloak.md)) |
|
|
757
|
+
| Jeton du fournisseur introuvable côté application | `IOAuthProfile` n'en porte aucun, par choix (`IOAuthUserProvisioner.ts#IOAuthProfile`) | Le capturer dans son propre `fetchProfile()` et le stocker soi-même |
|
|
758
|
+
| Keycloak : « Some clients have not been logged out » | Canal arrière injoignable, ou client en _Front channel logout_ ON | Adresse joignable par Keycloak ; _Front channel logout_ OFF |
|
|
759
|
+
| Canal arrière : `0 session(s) fermée(s)` au journal | Session ouverte avant la mise en place de l'index fédéré, ou `sid` différent | Se reconnecter ; vérifier _Backchannel logout session required_ |
|
|
636
760
|
|
|
637
761
|
## 🧪 Tests & couverture
|
|
638
762
|
|
|
@@ -645,13 +769,14 @@ Trois familles couvrent le social login — les chiffres exacts vivent dans la c
|
|
|
645
769
|
par e-mail) ;
|
|
646
770
|
- **intégration** — `oauth2-flow.test` : le flux complet sur **serveur réel**, du `302` d'`authorize`
|
|
647
771
|
à l'identité résolue par `/me`, avec un fournisseur de test déterministe et sans réseau ;
|
|
772
|
+
`oauth2-keycloak.test` : le même flux contre un **vrai** Keycloak, déconnexions comprises (les
|
|
773
|
+
deux sens) ;
|
|
648
774
|
- **attaque** — `oauth2-attack.test` (rejeu du `state`, mix-up de fournisseur) et
|
|
649
775
|
`oauth.attack.test` (collision d'e-mail avec un admin, élévation par re-login, collision
|
|
650
776
|
d'identifiants entre fournisseurs).
|
|
651
777
|
|
|
652
778
|
Ce qui **manque** : aucun banc de charge dédié au social login (le flux est un chemin froid, deux
|
|
653
|
-
requêtes par connexion), et aucun test contre un
|
|
654
|
-
de test couvre la branche PKCE).
|
|
779
|
+
requêtes par connexion), et aucun test contre un fournisseur réel autre que Keycloak.
|
|
655
780
|
|
|
656
781
|
Couverture : `npm run coverage` dans `@nodefony/security`. Campagnes d'attaque : skill
|
|
657
782
|
`nodefony-security-review` (mode red/blue-team).
|