The repository or configuration that created this release differ from a previous release. View details.
@etic-insa/auth · etic-insa-auth
Connexion des applications ETIC à ETIC Members, des deux côtés :
| Côté | Paquet | Rôle |
|---|---|---|
| serveur | etic-insa-auth (PyPI), module etic_auth |
SSO, session serveur, droits et préférences, pour FastAPI |
| navigateur | @etic-insa/auth (npm) |
session, droits pour l'affichage, thème partagé, pour React |
Les deux portent le même numéro de version : ils forment un seul contrat, les routes
/api/v1/auth/* ci-dessous. Une application installe les deux au même numéro.
Contrat amont : docs/etic-members-external/AUTHENTICATION.md du dépôt ETIC Members.
Le principe
navigateur ──cookie──► serveur de l'application ──identifiants d'app + jeton──► ETIC Members
(l'écran) (session, jetons chiffrés, (membres, droits,
introspection à chaque requête) préférences)
- Le navigateur ne voit jamais de jeton ETIC Members : il porte un cookie
httpOnlyqui désigne une session gardée côté serveur, jetons chiffrés en base. - Le serveur vérifie la session à chaque requête auprès d'ETIC Members (introspection, cache de 30 s au plus) : un membre désactivé ou déconnecté est coupé dans les 30 s.
- Le serveur fait autorité, toujours. Ce que le navigateur masque selon les droits n'est
qu'un confort d'affichage ; chaque route protégée passe par
auth.exiger(…). - Aucun secret dans ce paquet : le secret d'application, celui du cookie et la clé de chiffrement viennent de la configuration de chaque serveur.
Côté serveur (FastAPI)
uv add "etic-insa-auth[sqlalchemy]"
from typing import Annotated
from fastapi import Depends, FastAPI
from etic_auth import AuthEtic, ConfigSSO, Principal
from etic_auth.sqlalchemy import ColonnesSession, StockageSQLAlchemy
class SessionSatellite(ColonnesSession, Base): # Base : la base déclarative de l'application
__tablename__ = "sessions_auth" # migration Alembic à écrire par l'application
auth = AuthEtic(
ConfigSSO.depuis_env(
"ETIC Intervenant",
implications={"intervenant.manage": frozenset({"intervenant.read"})},
),
StockageSQLAlchemy(SessionLocal, SessionSatellite),
)
app = FastAPI(lifespan=…) # appeler `await auth.fermer()` à l'arrêt
auth.installer(app)
Lecture = Annotated[Principal, Depends(auth.exiger("intervenant.read"))]
@app.get("/api/v1/missions")
def missions(principal: Lecture): …
installer pose le cookie de session (__Host-etic_session, signé, httpOnly, SameSite=Lax ;
le préfixe __Host- empêche un autre sous-domaine de le poser à sa place), le contrôle
d'origine des écritures (anti-CSRF entre sous-domaines d'etic-insa.com), le gestionnaire
d'erreurs et les routes :
| Route | Rôle |
|---|---|
GET /api/v1/auth/login |
?suivant=/chemin : envoie le navigateur vers ETIC Members (PKCE) |
GET /api/v1/auth/callback |
retour du SSO ; en cas d'échec, renvoie vers /login?erreur=<code> |
GET /api/v1/auth/me |
identité, droits développés et préférences d'interface de la session |
PATCH /api/v1/auth/preferences |
{"theme": "system" | "light" | "dark"}, enregistré pour toute la suite |
POST /api/v1/auth/logout |
révoque et supprime la session ; réussit même sans session |
Erreurs dans l'enveloppe commune {"error": {"code", "message", "details"}} : 401 unauthenticated (avec details.action = "relancer_authorize" quand seule une reconnexion
peut aboutir), 403 forbidden (details.required_permission), 403 account_disabled,
403 account_alumni, 502 external_service_unavailable.
Réglages (ConfigSSO)
ConfigSSO.depuis_env(nom, **reglages) lit :
| Variable | Exemple |
|---|---|
ETIC_MEMBERS_API |
https://auth.etic-insa.com |
ETIC_MEMBERS_AUTHORIZE_URL |
https://auth.etic-insa.com/api/v1/auth/sso/authorize |
ETIC_APP_CODE |
code de l'application enregistrée dans ETIC Members |
ETIC_APP_SECRET |
son secret |
ETIC_APP_URL |
https://intervenant.etic-insa.com |
SESSION_SECRET |
openssl rand -hex 32, signe le cookie |
ENCRYPTION_KEY |
openssl rand -hex 32, chiffre les jetons ; différente |
Dans ETIC Members, l'URL de retour à enregistrer est ETIC_APP_URL suivie de
/api/v1/auth/callback. Le front et l'API sont servis sous la même origine.
Réglages utiles : statuts_admis (par défaut les seuls membres active ; ajouter "alumni"
pour accueillir les anciens), implications (permissions impliquées), duree_session
(12 h), chemins_sans_cookie (webhooks exemptés du contrôle d'origine), cookie_https=False
en développement local seulement (le cookie s'appelle alors etic_session : sans HTTPS, le
navigateur refuserait un cookie __Host-).
Sessions : StockageSQLAlchemy (une table, voir ci-dessus) ou StockageMemoire (tests, ou un
seul processus qui accepte de déconnecter tout le monde au redémarrage). Le verrou de
rafraîchissement est en mémoire : l'application tourne avec un seul processus serveur.
Tests de l'application
from etic_auth.testing import principal_de_test, simuler_principal
simuler_principal(app, auth, principal_de_test(permissions=("intervenant.read",)))
Passe par app.dependency_overrides : il n'existe aucun mode « mock » à oublier activé en
production.
Droits sans session
Pour une requête relayée par un tiers (carte CRM, webhook) dont le membre est déjà connu :
await auth.client.droits_membre(membre_id) puis auth.verifier_statut(droits.status) (scope
eticmembers.authz.read requis côté ETIC Members).
Côté navigateur (React)
npm i @etic-insa/auth @tanstack/react-query
import {
AuthProvider,
SessionRequise,
SiPermission,
lireErreurConnexion,
signalerNonAuthentifie,
useAuth,
} from "@etic-insa/auth"
// Sous le QueryClientProvider de l'application.
;<AuthProvider surNonAuthentifie={() => naviguer("/login")}>
<SessionRequise
sansSession={<PageConnexion />}
chargement={<Squelette />}
erreur={(erreur, reessayer) => (
<Erreur erreur={erreur} onReessayer={reessayer} />
)}
>
<Application />
</SessionRequise>
</AuthProvider>
function PageConnexion() {
const { seConnecter } = useAuth()
const echec = lireErreurConnexion(location.search) // { code, message } ou null
return (
<Button onClick={() => seConnecter("/")}>
Se connecter avec ETIC Members
</Button>
)
}
function Barre() {
const { me, theme, changerTheme, deconnecter } = useAuth()
// `theme` vient d'ETIC Members ; `changerTheme` l'enregistre pour toute la suite.
return (
<SiPermission code="intervenant.manage">
<Lien vers="/administration" />
</SiPermission>
)
}
SessionRequisedistingue trois états : chargement, panne (« Réessayer », surtout pas un renvoi vers la connexion) et absence de session.- Sur tout 401 de ses propres appels, l'application appelle
signalerNonAuthentifie(): le fournisseur oublie la session et appellesurNonAuthentifie. - Après
deconnecter(), l'application affiche sa page de connexion sans relancer le SSO : la session ETIC Members est toujours ouverte, et la relancer reconnecterait aussitôt. lireErreurConnexion(location.search, messages?)rend le code d'échec du SSO et un texte par défaut, quemessagesremplace code par code.nomAffichage(me)donne le libellé à afficher (jamais prénom + nom recollés : un compte fonctionnel n'en a pas) ;aPermission(me, code)la même règle queSiPermission.
Client HTTP commun
creerClientHttp est le client des appels d'une application à son API. Il ne dépend ni de
React ni de react-query.
import {
creerClientHttp,
ErreurApi,
signalerNonAuthentifie,
} from "@etic-insa/auth"
// Application satellite : session par cookie.
const api = creerClientHttp({
base: "/api/v1",
credentials: "include",
surNonAuthentifie: signalerNonAuthentifie,
})
// Ou, comme ETIC Members : jeton Bearer gardé par l'application.
const apiMembers = creerClientHttp({
base: API_BASE_URL,
jeton: () => lireJeton(),
surNonAuthentifie: () => effacerJeton(),
})
await api.get<Membre[]>("/membres", { query: { statut: ["active", "alumni"] } })
await api.post("/membres", { nom: "Ada" })
await api.post("/fichiers", formulaire) // FormData : envoyé tel quel
await api.delete("/poles/p1", { query: { confirm: true } })
const photo = await api.lireBlob(`/membres/${id}/photo`)
location.assign(api.url("/auth/login", { suivant: "/" }))
- Verbes :
get(chemin, options?),post,putetpatch(chemin, corps?, options?),delete(chemin, options?); optionsquery,signal,credentials,enTetes,delaiMax. Ils rendent le JSON de la réponse,nullpour une réponse vide (204 ou 200 vide : react-query refuse unqueryFnqui rendundefined).corpspart tel quel s'il est unFormData, unBlob, unURLSearchParamsou unArrayBuffer(ou une vue), en JSON sinon.lireBlob(chemin, options?)lit un fichier, avec les mêmes options ;url(chemin, query?)rend l'URL complète, pour une navigation. Dansquery, les valeurs vides (undefined,null,"") sont omises, dans un tableau aussi. - Toute requête qui n'aboutit pas lève une
ErreurApi(status,code,message,details) : celle de l'enveloppe{"error": {…}}du backend, ou un code et un texte lisibles déduits du statut :unauthenticated(401),forbidden(403),not_found(404),conflict(409),validation_error(422),not_implemented(501),external_service_unavailable(502, 503),internal_error(autre 5xx),http_<statut>(autre 4xx). - Codes propres au client, avec
status0 :network_error(CODE_RESEAU, pas de réponse) etdelai_depasse(CODE_DELAI_DEPASSE). L'annulation demandée par l'appelant (signal) reste une annulation. delaiMax, sur le client ou sur une requête : 30 s par défaut, plafonné à 2^31-1 ms ;Infinityounull: aucun délai. Une valeur ≤ 0 ou non finie lève uneRangeError. Pour l'envoi ou la lecture d'un gros fichier, l'allonger sur la requête :api.lireBlob(chemin, { delaiMax: 300_000 }).- Une page HTML (ou tout autre corps qui n'est pas du JSON) reçue en 200 lève
reponse_non_json(CODE_REPONSE_NON_JSON) : en pratique,/apin'est pas routé vers le backend et le proxy a servi l'index.html.lireBlobrefuse de même une page HTML. - Le jeton, lu à chaque requête, ne sert qu'à l'en-tête
Authorization: il n'apparaît dans aucune erreur ni aucun journal. redirections:"refusees"(le défaut quandjetonest fourni) fait d'une redirection une erreurredirection_refusee(CODE_REDIRECTION_REFUSEE) au lieu de la suivre, pour que le jeton ne parte pas ailleurs ;"suivies"(le défaut sans jeton) les suit. Une réponse 3xx reçue telle quelle (Node) est refusée de même. Avec FastAPI, un chemin qui diffère de la route par la barre oblique finale reçoit une 307 (redirect_slashes) : l'écrire exactement comme la route.surNonAuthentifieest appelé sur tout 401, avant que l'erreur soit levée. S'il lève, l'appelant reçoit quand même l'ErreurApi401, et l'exception du rappel est signalée à part parreportError(console et événementerrorde la fenêtre), à défaut relancée dans une micro-tâche : ni avalée, ni à la place de l'erreur.- Un chemin commence par un seul
/, sans espace ni caractère de contrôle (l'analyseur d'URL retire tabulations et retours à la ligne :/\t/hotedeviendrait//hote), et l'URL résolue garde l'origine de la base ;/%2F%2F…reste un chemin. Sinon,TypeError: c'est une erreur de programmation, hors du contratErreurApi, qu'aucun écran n'a à traiter. - Sans
AbortSignal.any(Safari avant 17.4), le signal de l'appelant et le délai sont combinés par des écouteurs, retirés en fin de requête.
Migrer depuis le client d'ETIC Members ou d'ETIC Docs
| Avant | Avec @etic-insa/auth |
|---|---|
ApiError |
ErreurApi (ou import { ErreurApi as ApiError }) |
estServiceExterneIndisponible (Docs) |
serviceIndisponible |
analyserEnveloppeErreur (Docs) |
analyserErreur |
queryString (Docs) |
chaineRequete |
urlApi(chemin) (Docs) |
api.url(chemin, query?) |
apiFetch(chemin, { body, bodyBrut }) (Docs) |
verbe, corps (un FormData part tel quel) |
api.get(chemin, query, signal) (Members) |
api.get(chemin, { query, signal }) |
api.delete(chemin, query) (Members) |
api.delete(chemin, { query }) |
api.postForm(chemin, formData) (Members) |
api.post(chemin, formData) |
api.getBlob, apiFetchBlob (Members) |
api.lireBlob(chemin, { signal, delaiMax }) |
401 : événement eticdocs:non-authentifie, etic-admin:unauthorized |
surNonAuthentifie (signalerNonAuthentifie, ou celui de l'application) |
Codes qui changent :
- Members : sans enveloppe,
http_<statut>et « Erreur inattendue (HTTP n). » laissent la place aux codes ci-dessus ; un 200 qui n'est pas du JSON lèvereponse_non_jsonau lieu d'uneSyntaxError; une réponse vide rendnullau lieu deundefined. - Docs : sans enveloppe, un 4xx hors table donne
http_<statut>au lieu d'internal_error; l'erreur réseau ne porte plusdetails.cause. - Les deux :
delai_depassedistingue le délai dépassé de la panne réseau.
Développer
Côté navigateur, comme les autres paquets (npm run lint, typecheck, test, build
depuis la racine). Côté serveur, depuis packages/auth, avec uv :
uv sync
uv run ruff format python && uv run ruff check python
uv run pytest
Toute modification de l'un des deux côtés passe par npx changeset sur @etic-insa/auth : le
paquet Python en reprend le numéro.
Metadata
Release files for etic-insa-auth 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Change of provenance
The repository or configuration that created these release files differ from that of a previous release.
We recommend inspecting the repository and configuration before proceeding. View details.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| etic_insa_auth-0.2.0.tar.gz | 33.3 kB | With provenance | Details for etic_insa_auth-0.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| etic_insa_auth-0.2.0-py3-none-any.whl | Python 3 | none | any | Details for etic_insa_auth-0.2.0-py3-none-any.whl |
Total release size: 72.6 kB
Release files / etic_insa_auth-0.2.0.tar.gz
Change of provenance
This file belongs to a release with changed provenance: the repository or configuration that created the release files differ from that of a previous release.
We recommend inspecting the repository and configuration before proceeding. View details.
| Download URL | etic_insa_auth-0.2.0.tar.gz |
|---|---|
| Size | 33.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
617f92aa6b660c8a359863c611bc05e070e51d51e5244fcac96b3a769480cf2a
|
|
BLAKE2b-256 checksum How to use checksums |
298eb725b31bb3fc1a2c10328e60ac42b2c09b7ebec74772180c7787934cd286
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logRelease files / etic_insa_auth-0.2.0-py3-none-any.whl
Change of provenance
This file belongs to a release with changed provenance: the repository or configuration that created the release files differ from that of a previous release.
We recommend inspecting the repository and configuration before proceeding. View details.
| Download URL | etic_insa_auth-0.2.0-py3-none-any.whl |
|---|---|
| Size | 39.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
87202b4436ffa70e1e191c2cd92469238f9dd87eeff1871b5744b13cc7f985fa
|
|
BLAKE2b-256 checksum How to use checksums |
8a8ab756d96b2020ddff3b4e72db152887352021b4746a10abc99a0a9ad43fa7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logSecurity for etic-insa-auth 0.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Change of provenance
The repository and configuration that created this release differ from a previous release.
While this could reflect a routine change by the project maintainers, it may also indicate that the release files have been compromised.
Repository
Attestation for etic_insa_auth-0.2.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.