etic-insa-auth 0.1.0__tar.gz
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.
- etic_insa_auth-0.1.0/.gitignore +4 -0
- etic_insa_auth-0.1.0/LICENSE +21 -0
- etic_insa_auth-0.1.0/PKG-INFO +220 -0
- etic_insa_auth-0.1.0/README.md +201 -0
- etic_insa_auth-0.1.0/package.json +58 -0
- etic_insa_auth-0.1.0/pyproject.toml +71 -0
- etic_insa_auth-0.1.0/python/etic_auth/__init__.py +26 -0
- etic_insa_auth-0.1.0/python/etic_auth/auth.py +475 -0
- etic_insa_auth-0.1.0/python/etic_auth/chiffrement.py +50 -0
- etic_insa_auth-0.1.0/python/etic_auth/config.py +185 -0
- etic_insa_auth-0.1.0/python/etic_auth/erreurs.py +89 -0
- etic_insa_auth-0.1.0/python/etic_auth/members.py +508 -0
- etic_insa_auth-0.1.0/python/etic_auth/origine.py +95 -0
- etic_insa_auth-0.1.0/python/etic_auth/py.typed +0 -0
- etic_insa_auth-0.1.0/python/etic_auth/session.py +315 -0
- etic_insa_auth-0.1.0/python/etic_auth/sqlalchemy.py +89 -0
- etic_insa_auth-0.1.0/python/etic_auth/testing.py +40 -0
- etic_insa_auth-0.1.0/python/etic_auth/transport.py +219 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 etic-insa
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: etic-insa-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Connexion des applications ETIC à ETIC Members (FastAPI) : SSO, session serveur, droits.
|
|
5
|
+
Project-URL: Source, https://github.com/etic-insa-tech/etic-packages/tree/main/packages/auth
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Requires-Dist: cachetools<8,>=6
|
|
10
|
+
Requires-Dist: cryptography<51,>=45
|
|
11
|
+
Requires-Dist: fastapi<1,>=0.141
|
|
12
|
+
Requires-Dist: httpx<0.29,>=0.28
|
|
13
|
+
Requires-Dist: itsdangerous<3,>=2.2
|
|
14
|
+
Requires-Dist: pydantic<3,>=2.11
|
|
15
|
+
Requires-Dist: tenacity<10,>=9.2
|
|
16
|
+
Provides-Extra: sqlalchemy
|
|
17
|
+
Requires-Dist: sqlalchemy<3,>=2.0.40; extra == 'sqlalchemy'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# @etic-insa/auth · etic-insa-auth
|
|
21
|
+
|
|
22
|
+
Connexion des applications ETIC à ETIC Members, des deux côtés :
|
|
23
|
+
|
|
24
|
+
| Côté | Paquet | Rôle |
|
|
25
|
+
| ---------- | ------------------------------------------- | ----------------------------------------------------------- |
|
|
26
|
+
| serveur | `etic-insa-auth` (PyPI), module `etic_auth` | SSO, session serveur, droits et préférences, pour FastAPI |
|
|
27
|
+
| navigateur | `@etic-insa/auth` (npm) | session, droits pour l'affichage, thème partagé, pour React |
|
|
28
|
+
|
|
29
|
+
Les deux portent **le même numéro de version** : ils forment un seul contrat, les routes
|
|
30
|
+
`/api/v1/auth/*` ci-dessous. Une application installe les deux au même numéro.
|
|
31
|
+
|
|
32
|
+
Contrat amont : `docs/etic-members-external/AUTHENTICATION.md` du dépôt ETIC Members.
|
|
33
|
+
|
|
34
|
+
## Le principe
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
navigateur ──cookie──► serveur de l'application ──identifiants d'app + jeton──► ETIC Members
|
|
38
|
+
(l'écran) (session, jetons chiffrés, (membres, droits,
|
|
39
|
+
introspection à chaque requête) préférences)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- Le navigateur ne voit **jamais** de jeton ETIC Members : il porte un cookie `httpOnly` qui
|
|
43
|
+
désigne une session gardée côté serveur, jetons chiffrés en base.
|
|
44
|
+
- Le serveur vérifie la session **à chaque requête** auprès d'ETIC Members (introspection,
|
|
45
|
+
cache de 30 s au plus) : un membre désactivé ou déconnecté est coupé dans les 30 s.
|
|
46
|
+
- **Le serveur fait autorité, toujours.** Ce que le navigateur masque selon les droits n'est
|
|
47
|
+
qu'un confort d'affichage ; chaque route protégée passe par `auth.exiger(…)`.
|
|
48
|
+
- Aucun secret dans ce paquet : le secret d'application, celui du cookie et la clé de
|
|
49
|
+
chiffrement viennent de la configuration de chaque serveur.
|
|
50
|
+
|
|
51
|
+
## Côté serveur (FastAPI)
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
uv add "etic-insa-auth[sqlalchemy]"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from typing import Annotated
|
|
59
|
+
from fastapi import Depends, FastAPI
|
|
60
|
+
from etic_auth import AuthEtic, ConfigSSO, Principal
|
|
61
|
+
from etic_auth.sqlalchemy import ColonnesSession, StockageSQLAlchemy
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SessionSatellite(ColonnesSession, Base): # Base : la base déclarative de l'application
|
|
65
|
+
__tablename__ = "sessions_auth" # migration Alembic à écrire par l'application
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
auth = AuthEtic(
|
|
69
|
+
ConfigSSO.depuis_env(
|
|
70
|
+
"ETIC Intervenant",
|
|
71
|
+
implications={"intervenant.manage": frozenset({"intervenant.read"})},
|
|
72
|
+
),
|
|
73
|
+
StockageSQLAlchemy(SessionLocal, SessionSatellite),
|
|
74
|
+
)
|
|
75
|
+
app = FastAPI(lifespan=…) # appeler `await auth.fermer()` à l'arrêt
|
|
76
|
+
auth.installer(app)
|
|
77
|
+
|
|
78
|
+
Lecture = Annotated[Principal, Depends(auth.exiger("intervenant.read"))]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.get("/api/v1/missions")
|
|
82
|
+
def missions(principal: Lecture): …
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`installer` pose le cookie de session (`__Host-etic_session`, signé, `httpOnly`, `SameSite=Lax` ;
|
|
86
|
+
le préfixe `__Host-` empêche un autre sous-domaine de le poser à sa place), le contrôle
|
|
87
|
+
d'origine des écritures (anti-CSRF entre sous-domaines d'etic-insa.com), le gestionnaire
|
|
88
|
+
d'erreurs et les routes :
|
|
89
|
+
|
|
90
|
+
| Route | Rôle |
|
|
91
|
+
| -------------------------------- | -------------------------------------------------------------------------- |
|
|
92
|
+
| `GET /api/v1/auth/login` | `?suivant=/chemin` : envoie le navigateur vers ETIC Members (PKCE) |
|
|
93
|
+
| `GET /api/v1/auth/callback` | retour du SSO ; en cas d'échec, renvoie vers `/login?erreur=<code>` |
|
|
94
|
+
| `GET /api/v1/auth/me` | identité, droits développés et préférences d'interface de la session |
|
|
95
|
+
| `PATCH /api/v1/auth/preferences` | `{"theme": "system" \| "light" \| "dark"}`, enregistré pour toute la suite |
|
|
96
|
+
| `POST /api/v1/auth/logout` | révoque et supprime la session ; réussit même sans session |
|
|
97
|
+
|
|
98
|
+
Erreurs dans l'enveloppe commune `{"error": {"code", "message", "details"}}` : `401
|
|
99
|
+
unauthenticated` (avec `details.action = "relancer_authorize"` quand seule une reconnexion
|
|
100
|
+
peut aboutir), `403 forbidden` (`details.required_permission`), `403 account_disabled`,
|
|
101
|
+
`403 account_alumni`, `502 external_service_unavailable`.
|
|
102
|
+
|
|
103
|
+
### Réglages (`ConfigSSO`)
|
|
104
|
+
|
|
105
|
+
`ConfigSSO.depuis_env(nom, **reglages)` lit :
|
|
106
|
+
|
|
107
|
+
| Variable | Exemple |
|
|
108
|
+
| ---------------------------- | ------------------------------------------------------- |
|
|
109
|
+
| `ETIC_MEMBERS_API` | `https://auth.etic-insa.com` |
|
|
110
|
+
| `ETIC_MEMBERS_AUTHORIZE_URL` | `https://auth.etic-insa.com/api/v1/auth/sso/authorize` |
|
|
111
|
+
| `ETIC_APP_CODE` | code de l'application enregistrée dans ETIC Members |
|
|
112
|
+
| `ETIC_APP_SECRET` | son secret |
|
|
113
|
+
| `ETIC_APP_URL` | `https://intervenant.etic-insa.com` |
|
|
114
|
+
| `SESSION_SECRET` | `openssl rand -hex 32`, signe le cookie |
|
|
115
|
+
| `ENCRYPTION_KEY` | `openssl rand -hex 32`, chiffre les jetons ; différente |
|
|
116
|
+
|
|
117
|
+
Dans ETIC Members, l'URL de retour à enregistrer est `ETIC_APP_URL` suivie de
|
|
118
|
+
`/api/v1/auth/callback`. Le front et l'API sont servis sous la même origine.
|
|
119
|
+
|
|
120
|
+
Réglages utiles : `statuts_admis` (par défaut les seuls membres `active` ; ajouter `"alumni"`
|
|
121
|
+
pour accueillir les anciens), `implications` (permissions impliquées), `duree_session`
|
|
122
|
+
(12 h), `chemins_sans_cookie` (webhooks exemptés du contrôle d'origine), `cookie_https=False`
|
|
123
|
+
en développement local seulement (le cookie s'appelle alors `etic_session` : sans HTTPS, le
|
|
124
|
+
navigateur refuserait un cookie `__Host-`).
|
|
125
|
+
|
|
126
|
+
Sessions : `StockageSQLAlchemy` (une table, voir ci-dessus) ou `StockageMemoire` (tests, ou un
|
|
127
|
+
seul processus qui accepte de déconnecter tout le monde au redémarrage). Le verrou de
|
|
128
|
+
rafraîchissement est en mémoire : l'application tourne avec un seul processus serveur.
|
|
129
|
+
|
|
130
|
+
### Tests de l'application
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from etic_auth.testing import principal_de_test, simuler_principal
|
|
134
|
+
|
|
135
|
+
simuler_principal(app, auth, principal_de_test(permissions=("intervenant.read",)))
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Passe par `app.dependency_overrides` : il n'existe aucun mode « mock » à oublier activé en
|
|
139
|
+
production.
|
|
140
|
+
|
|
141
|
+
### Droits sans session
|
|
142
|
+
|
|
143
|
+
Pour une requête relayée par un tiers (carte CRM, webhook) dont le membre est déjà connu :
|
|
144
|
+
`await auth.client.droits_membre(membre_id)` puis `auth.verifier_statut(droits.status)` (scope
|
|
145
|
+
`eticmembers.authz.read` requis côté ETIC Members).
|
|
146
|
+
|
|
147
|
+
## Côté navigateur (React)
|
|
148
|
+
|
|
149
|
+
```sh
|
|
150
|
+
npm i @etic-insa/auth @tanstack/react-query
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
```tsx
|
|
154
|
+
import {
|
|
155
|
+
AuthProvider,
|
|
156
|
+
SessionRequise,
|
|
157
|
+
SiPermission,
|
|
158
|
+
lireErreurConnexion,
|
|
159
|
+
signalerNonAuthentifie,
|
|
160
|
+
useAuth,
|
|
161
|
+
} from "@etic-insa/auth"
|
|
162
|
+
|
|
163
|
+
// Sous le QueryClientProvider de l'application.
|
|
164
|
+
;<AuthProvider surNonAuthentifie={() => naviguer("/login")}>
|
|
165
|
+
<SessionRequise
|
|
166
|
+
sansSession={<PageConnexion />}
|
|
167
|
+
chargement={<Squelette />}
|
|
168
|
+
erreur={(erreur, reessayer) => (
|
|
169
|
+
<Erreur erreur={erreur} onReessayer={reessayer} />
|
|
170
|
+
)}
|
|
171
|
+
>
|
|
172
|
+
<Application />
|
|
173
|
+
</SessionRequise>
|
|
174
|
+
</AuthProvider>
|
|
175
|
+
|
|
176
|
+
function PageConnexion() {
|
|
177
|
+
const { seConnecter } = useAuth()
|
|
178
|
+
const echec = lireErreurConnexion(location.search) // { code, message } ou null
|
|
179
|
+
return (
|
|
180
|
+
<Button onClick={() => seConnecter("/")}>
|
|
181
|
+
Se connecter avec ETIC Members
|
|
182
|
+
</Button>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function Barre() {
|
|
187
|
+
const { me, theme, changerTheme, deconnecter } = useAuth()
|
|
188
|
+
// `theme` vient d'ETIC Members ; `changerTheme` l'enregistre pour toute la suite.
|
|
189
|
+
return (
|
|
190
|
+
<SiPermission code="intervenant.manage">
|
|
191
|
+
<Lien vers="/administration" />
|
|
192
|
+
</SiPermission>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
- `SessionRequise` distingue trois états : chargement, panne (« Réessayer », surtout pas un
|
|
198
|
+
renvoi vers la connexion) et absence de session.
|
|
199
|
+
- **Sur tout 401 de ses propres appels**, l'application appelle `signalerNonAuthentifie()` :
|
|
200
|
+
le fournisseur oublie la session et appelle `surNonAuthentifie`.
|
|
201
|
+
- Après `deconnecter()`, l'application affiche sa page de connexion **sans** relancer le SSO :
|
|
202
|
+
la session ETIC Members est toujours ouverte, et la relancer reconnecterait aussitôt.
|
|
203
|
+
- `lireErreurConnexion(location.search, messages?)` rend le code d'échec du SSO et un texte
|
|
204
|
+
par défaut, que `messages` remplace code par code.
|
|
205
|
+
- `nomAffichage(me)` donne le libellé à afficher (jamais prénom + nom recollés : un compte
|
|
206
|
+
fonctionnel n'en a pas) ; `aPermission(me, code)` la même règle que `SiPermission`.
|
|
207
|
+
|
|
208
|
+
## Développer
|
|
209
|
+
|
|
210
|
+
Côté navigateur, comme les autres paquets (`npm run lint`, `typecheck`, `test`, `build`
|
|
211
|
+
depuis la racine). Côté serveur, depuis `packages/auth`, avec [uv](https://docs.astral.sh/uv/) :
|
|
212
|
+
|
|
213
|
+
```sh
|
|
214
|
+
uv sync
|
|
215
|
+
uv run ruff format python && uv run ruff check python
|
|
216
|
+
uv run pytest
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Toute modification de l'un des deux côtés passe par `npx changeset` sur `@etic-insa/auth` : le
|
|
220
|
+
paquet Python en reprend le numéro.
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# @etic-insa/auth · etic-insa-auth
|
|
2
|
+
|
|
3
|
+
Connexion des applications ETIC à ETIC Members, des deux côtés :
|
|
4
|
+
|
|
5
|
+
| Côté | Paquet | Rôle |
|
|
6
|
+
| ---------- | ------------------------------------------- | ----------------------------------------------------------- |
|
|
7
|
+
| serveur | `etic-insa-auth` (PyPI), module `etic_auth` | SSO, session serveur, droits et préférences, pour FastAPI |
|
|
8
|
+
| navigateur | `@etic-insa/auth` (npm) | session, droits pour l'affichage, thème partagé, pour React |
|
|
9
|
+
|
|
10
|
+
Les deux portent **le même numéro de version** : ils forment un seul contrat, les routes
|
|
11
|
+
`/api/v1/auth/*` ci-dessous. Une application installe les deux au même numéro.
|
|
12
|
+
|
|
13
|
+
Contrat amont : `docs/etic-members-external/AUTHENTICATION.md` du dépôt ETIC Members.
|
|
14
|
+
|
|
15
|
+
## Le principe
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
navigateur ──cookie──► serveur de l'application ──identifiants d'app + jeton──► ETIC Members
|
|
19
|
+
(l'écran) (session, jetons chiffrés, (membres, droits,
|
|
20
|
+
introspection à chaque requête) préférences)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
- Le navigateur ne voit **jamais** de jeton ETIC Members : il porte un cookie `httpOnly` qui
|
|
24
|
+
désigne une session gardée côté serveur, jetons chiffrés en base.
|
|
25
|
+
- Le serveur vérifie la session **à chaque requête** auprès d'ETIC Members (introspection,
|
|
26
|
+
cache de 30 s au plus) : un membre désactivé ou déconnecté est coupé dans les 30 s.
|
|
27
|
+
- **Le serveur fait autorité, toujours.** Ce que le navigateur masque selon les droits n'est
|
|
28
|
+
qu'un confort d'affichage ; chaque route protégée passe par `auth.exiger(…)`.
|
|
29
|
+
- Aucun secret dans ce paquet : le secret d'application, celui du cookie et la clé de
|
|
30
|
+
chiffrement viennent de la configuration de chaque serveur.
|
|
31
|
+
|
|
32
|
+
## Côté serveur (FastAPI)
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
uv add "etic-insa-auth[sqlalchemy]"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from typing import Annotated
|
|
40
|
+
from fastapi import Depends, FastAPI
|
|
41
|
+
from etic_auth import AuthEtic, ConfigSSO, Principal
|
|
42
|
+
from etic_auth.sqlalchemy import ColonnesSession, StockageSQLAlchemy
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SessionSatellite(ColonnesSession, Base): # Base : la base déclarative de l'application
|
|
46
|
+
__tablename__ = "sessions_auth" # migration Alembic à écrire par l'application
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
auth = AuthEtic(
|
|
50
|
+
ConfigSSO.depuis_env(
|
|
51
|
+
"ETIC Intervenant",
|
|
52
|
+
implications={"intervenant.manage": frozenset({"intervenant.read"})},
|
|
53
|
+
),
|
|
54
|
+
StockageSQLAlchemy(SessionLocal, SessionSatellite),
|
|
55
|
+
)
|
|
56
|
+
app = FastAPI(lifespan=…) # appeler `await auth.fermer()` à l'arrêt
|
|
57
|
+
auth.installer(app)
|
|
58
|
+
|
|
59
|
+
Lecture = Annotated[Principal, Depends(auth.exiger("intervenant.read"))]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.get("/api/v1/missions")
|
|
63
|
+
def missions(principal: Lecture): …
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`installer` pose le cookie de session (`__Host-etic_session`, signé, `httpOnly`, `SameSite=Lax` ;
|
|
67
|
+
le préfixe `__Host-` empêche un autre sous-domaine de le poser à sa place), le contrôle
|
|
68
|
+
d'origine des écritures (anti-CSRF entre sous-domaines d'etic-insa.com), le gestionnaire
|
|
69
|
+
d'erreurs et les routes :
|
|
70
|
+
|
|
71
|
+
| Route | Rôle |
|
|
72
|
+
| -------------------------------- | -------------------------------------------------------------------------- |
|
|
73
|
+
| `GET /api/v1/auth/login` | `?suivant=/chemin` : envoie le navigateur vers ETIC Members (PKCE) |
|
|
74
|
+
| `GET /api/v1/auth/callback` | retour du SSO ; en cas d'échec, renvoie vers `/login?erreur=<code>` |
|
|
75
|
+
| `GET /api/v1/auth/me` | identité, droits développés et préférences d'interface de la session |
|
|
76
|
+
| `PATCH /api/v1/auth/preferences` | `{"theme": "system" \| "light" \| "dark"}`, enregistré pour toute la suite |
|
|
77
|
+
| `POST /api/v1/auth/logout` | révoque et supprime la session ; réussit même sans session |
|
|
78
|
+
|
|
79
|
+
Erreurs dans l'enveloppe commune `{"error": {"code", "message", "details"}}` : `401
|
|
80
|
+
unauthenticated` (avec `details.action = "relancer_authorize"` quand seule une reconnexion
|
|
81
|
+
peut aboutir), `403 forbidden` (`details.required_permission`), `403 account_disabled`,
|
|
82
|
+
`403 account_alumni`, `502 external_service_unavailable`.
|
|
83
|
+
|
|
84
|
+
### Réglages (`ConfigSSO`)
|
|
85
|
+
|
|
86
|
+
`ConfigSSO.depuis_env(nom, **reglages)` lit :
|
|
87
|
+
|
|
88
|
+
| Variable | Exemple |
|
|
89
|
+
| ---------------------------- | ------------------------------------------------------- |
|
|
90
|
+
| `ETIC_MEMBERS_API` | `https://auth.etic-insa.com` |
|
|
91
|
+
| `ETIC_MEMBERS_AUTHORIZE_URL` | `https://auth.etic-insa.com/api/v1/auth/sso/authorize` |
|
|
92
|
+
| `ETIC_APP_CODE` | code de l'application enregistrée dans ETIC Members |
|
|
93
|
+
| `ETIC_APP_SECRET` | son secret |
|
|
94
|
+
| `ETIC_APP_URL` | `https://intervenant.etic-insa.com` |
|
|
95
|
+
| `SESSION_SECRET` | `openssl rand -hex 32`, signe le cookie |
|
|
96
|
+
| `ENCRYPTION_KEY` | `openssl rand -hex 32`, chiffre les jetons ; différente |
|
|
97
|
+
|
|
98
|
+
Dans ETIC Members, l'URL de retour à enregistrer est `ETIC_APP_URL` suivie de
|
|
99
|
+
`/api/v1/auth/callback`. Le front et l'API sont servis sous la même origine.
|
|
100
|
+
|
|
101
|
+
Réglages utiles : `statuts_admis` (par défaut les seuls membres `active` ; ajouter `"alumni"`
|
|
102
|
+
pour accueillir les anciens), `implications` (permissions impliquées), `duree_session`
|
|
103
|
+
(12 h), `chemins_sans_cookie` (webhooks exemptés du contrôle d'origine), `cookie_https=False`
|
|
104
|
+
en développement local seulement (le cookie s'appelle alors `etic_session` : sans HTTPS, le
|
|
105
|
+
navigateur refuserait un cookie `__Host-`).
|
|
106
|
+
|
|
107
|
+
Sessions : `StockageSQLAlchemy` (une table, voir ci-dessus) ou `StockageMemoire` (tests, ou un
|
|
108
|
+
seul processus qui accepte de déconnecter tout le monde au redémarrage). Le verrou de
|
|
109
|
+
rafraîchissement est en mémoire : l'application tourne avec un seul processus serveur.
|
|
110
|
+
|
|
111
|
+
### Tests de l'application
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from etic_auth.testing import principal_de_test, simuler_principal
|
|
115
|
+
|
|
116
|
+
simuler_principal(app, auth, principal_de_test(permissions=("intervenant.read",)))
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Passe par `app.dependency_overrides` : il n'existe aucun mode « mock » à oublier activé en
|
|
120
|
+
production.
|
|
121
|
+
|
|
122
|
+
### Droits sans session
|
|
123
|
+
|
|
124
|
+
Pour une requête relayée par un tiers (carte CRM, webhook) dont le membre est déjà connu :
|
|
125
|
+
`await auth.client.droits_membre(membre_id)` puis `auth.verifier_statut(droits.status)` (scope
|
|
126
|
+
`eticmembers.authz.read` requis côté ETIC Members).
|
|
127
|
+
|
|
128
|
+
## Côté navigateur (React)
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
npm i @etic-insa/auth @tanstack/react-query
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
```tsx
|
|
135
|
+
import {
|
|
136
|
+
AuthProvider,
|
|
137
|
+
SessionRequise,
|
|
138
|
+
SiPermission,
|
|
139
|
+
lireErreurConnexion,
|
|
140
|
+
signalerNonAuthentifie,
|
|
141
|
+
useAuth,
|
|
142
|
+
} from "@etic-insa/auth"
|
|
143
|
+
|
|
144
|
+
// Sous le QueryClientProvider de l'application.
|
|
145
|
+
;<AuthProvider surNonAuthentifie={() => naviguer("/login")}>
|
|
146
|
+
<SessionRequise
|
|
147
|
+
sansSession={<PageConnexion />}
|
|
148
|
+
chargement={<Squelette />}
|
|
149
|
+
erreur={(erreur, reessayer) => (
|
|
150
|
+
<Erreur erreur={erreur} onReessayer={reessayer} />
|
|
151
|
+
)}
|
|
152
|
+
>
|
|
153
|
+
<Application />
|
|
154
|
+
</SessionRequise>
|
|
155
|
+
</AuthProvider>
|
|
156
|
+
|
|
157
|
+
function PageConnexion() {
|
|
158
|
+
const { seConnecter } = useAuth()
|
|
159
|
+
const echec = lireErreurConnexion(location.search) // { code, message } ou null
|
|
160
|
+
return (
|
|
161
|
+
<Button onClick={() => seConnecter("/")}>
|
|
162
|
+
Se connecter avec ETIC Members
|
|
163
|
+
</Button>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function Barre() {
|
|
168
|
+
const { me, theme, changerTheme, deconnecter } = useAuth()
|
|
169
|
+
// `theme` vient d'ETIC Members ; `changerTheme` l'enregistre pour toute la suite.
|
|
170
|
+
return (
|
|
171
|
+
<SiPermission code="intervenant.manage">
|
|
172
|
+
<Lien vers="/administration" />
|
|
173
|
+
</SiPermission>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
- `SessionRequise` distingue trois états : chargement, panne (« Réessayer », surtout pas un
|
|
179
|
+
renvoi vers la connexion) et absence de session.
|
|
180
|
+
- **Sur tout 401 de ses propres appels**, l'application appelle `signalerNonAuthentifie()` :
|
|
181
|
+
le fournisseur oublie la session et appelle `surNonAuthentifie`.
|
|
182
|
+
- Après `deconnecter()`, l'application affiche sa page de connexion **sans** relancer le SSO :
|
|
183
|
+
la session ETIC Members est toujours ouverte, et la relancer reconnecterait aussitôt.
|
|
184
|
+
- `lireErreurConnexion(location.search, messages?)` rend le code d'échec du SSO et un texte
|
|
185
|
+
par défaut, que `messages` remplace code par code.
|
|
186
|
+
- `nomAffichage(me)` donne le libellé à afficher (jamais prénom + nom recollés : un compte
|
|
187
|
+
fonctionnel n'en a pas) ; `aPermission(me, code)` la même règle que `SiPermission`.
|
|
188
|
+
|
|
189
|
+
## Développer
|
|
190
|
+
|
|
191
|
+
Côté navigateur, comme les autres paquets (`npm run lint`, `typecheck`, `test`, `build`
|
|
192
|
+
depuis la racine). Côté serveur, depuis `packages/auth`, avec [uv](https://docs.astral.sh/uv/) :
|
|
193
|
+
|
|
194
|
+
```sh
|
|
195
|
+
uv sync
|
|
196
|
+
uv run ruff format python && uv run ruff check python
|
|
197
|
+
uv run pytest
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Toute modification de l'un des deux côtés passe par `npx changeset` sur `@etic-insa/auth` : le
|
|
201
|
+
paquet Python en reprend le numéro.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@etic-insa/auth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Session ETIC Members côté navigateur pour les applications ETIC (React).",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/etic-insa-tech/etic-packages.git",
|
|
9
|
+
"directory": "packages/auth"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public",
|
|
24
|
+
"provenance": true
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "vite build",
|
|
28
|
+
"lint": "eslint .",
|
|
29
|
+
"typecheck": "tsc -b",
|
|
30
|
+
"test": "vitest run"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@tanstack/react-query": "^5",
|
|
34
|
+
"react": "^19"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@eslint/js": "^10",
|
|
38
|
+
"@tanstack/react-query": "^5.105.0",
|
|
39
|
+
"@testing-library/jest-dom": "^7.0.1",
|
|
40
|
+
"@testing-library/react": "^16.3.3",
|
|
41
|
+
"@types/node": "^26.6.5",
|
|
42
|
+
"@types/react": "^19",
|
|
43
|
+
"@types/react-dom": "^19",
|
|
44
|
+
"@vitejs/plugin-react": "^6",
|
|
45
|
+
"eslint": "^10",
|
|
46
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
47
|
+
"eslint-plugin-react-refresh": "^0.5.7",
|
|
48
|
+
"globals": "^17",
|
|
49
|
+
"jsdom": "^30.1.3",
|
|
50
|
+
"react": "^19.3.1",
|
|
51
|
+
"react-dom": "^19.3.1",
|
|
52
|
+
"typescript": "~6.0.2",
|
|
53
|
+
"typescript-eslint": "^8",
|
|
54
|
+
"vite": "^8",
|
|
55
|
+
"vite-plugin-dts": "^4",
|
|
56
|
+
"vitest": "^5.0.5"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "etic-insa-auth"
|
|
7
|
+
# Même numéro que `@etic-insa/auth` : les deux côtés forment un seul contrat, versionné par
|
|
8
|
+
# Changesets dans package.json.
|
|
9
|
+
dynamic = ["version"]
|
|
10
|
+
description = "Connexion des applications ETIC à ETIC Members (FastAPI) : SSO, session serveur, droits."
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE"]
|
|
14
|
+
requires-python = ">=3.12"
|
|
15
|
+
dependencies = [
|
|
16
|
+
"fastapi>=0.141,<1",
|
|
17
|
+
"httpx>=0.28,<0.29",
|
|
18
|
+
"tenacity>=9.2,<10",
|
|
19
|
+
"cryptography>=45,<51",
|
|
20
|
+
"cachetools>=6,<8",
|
|
21
|
+
# Signature du cookie (SessionMiddleware de Starlette).
|
|
22
|
+
"itsdangerous>=2.2,<3",
|
|
23
|
+
"pydantic>=2.11,<3",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
sqlalchemy = ["sqlalchemy>=2.0.40,<3"]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Source = "https://github.com/etic-insa-tech/etic-packages/tree/main/packages/auth"
|
|
31
|
+
|
|
32
|
+
[dependency-groups]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=9,<10",
|
|
35
|
+
# Client du TestClient de Starlette (httpx y est déprécié).
|
|
36
|
+
"httpx2>=2.13,<3",
|
|
37
|
+
"pytest-asyncio>=1,<2",
|
|
38
|
+
"ruff==0.17.3",
|
|
39
|
+
"sqlalchemy>=2.0.40,<3",
|
|
40
|
+
"pip-audit>=2.9,<3",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.version]
|
|
44
|
+
path = "package.json"
|
|
45
|
+
pattern = '"version": "(?P<version>[^"]+)"'
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["python/etic_auth"]
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.sdist]
|
|
51
|
+
include = ["python/etic_auth", "package.json", "README.md", "LICENSE"]
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
testpaths = ["python/tests"]
|
|
55
|
+
pythonpath = ["python"]
|
|
56
|
+
asyncio_mode = "auto"
|
|
57
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
58
|
+
|
|
59
|
+
[tool.ruff]
|
|
60
|
+
line-length = 100
|
|
61
|
+
target-version = "py312"
|
|
62
|
+
src = ["python"]
|
|
63
|
+
|
|
64
|
+
[tool.ruff.lint]
|
|
65
|
+
select = ["E", "F", "I", "B", "UP", "N", "SIM", "RUF"]
|
|
66
|
+
# Exceptions nommées en français (`ErreurAuth`), sans suffixe « Error ».
|
|
67
|
+
ignore = ["N818"]
|
|
68
|
+
|
|
69
|
+
[tool.ruff.lint.per-file-ignores]
|
|
70
|
+
# Dépendances FastAPI en valeur par défaut : les annotations des tests citent des locales.
|
|
71
|
+
"python/tests/*" = ["B008"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Connexion des applications ETIC à ETIC Members : SSO, session serveur, droits.
|
|
2
|
+
|
|
3
|
+
Voir le README du paquet (`etic-insa-auth` sur PyPI, `@etic-insa/auth` sur npm pour le front).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .auth import AuthEtic, MeOut, Principal
|
|
7
|
+
from .config import ConfigSSO, lire_cle_chiffrement
|
|
8
|
+
from .erreurs import ErreurAuth, gestionnaire_erreur_auth
|
|
9
|
+
from .members import ClientMembers, DroitsMembre, Preferences, Theme
|
|
10
|
+
from .session import StockageMemoire, StockageSessions
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AuthEtic",
|
|
14
|
+
"ClientMembers",
|
|
15
|
+
"ConfigSSO",
|
|
16
|
+
"DroitsMembre",
|
|
17
|
+
"ErreurAuth",
|
|
18
|
+
"MeOut",
|
|
19
|
+
"Preferences",
|
|
20
|
+
"Principal",
|
|
21
|
+
"StockageMemoire",
|
|
22
|
+
"StockageSessions",
|
|
23
|
+
"Theme",
|
|
24
|
+
"gestionnaire_erreur_auth",
|
|
25
|
+
"lire_cle_chiffrement",
|
|
26
|
+
]
|