Skip to main content

@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 httpOnly qui 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>
  )
}
  • SessionRequise distingue 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 appelle surNonAuthentifie.
  • 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, que messages remplace 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 que SiPermission.

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. Le rappel
// n'est pas appelé pour le 401 tardif d'une requête partie avant une
// reconnexion : il ne purge jamais le nouveau jeton.
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, put et patch(chemin, corps?, options?), delete(chemin, options?) ; options query, signal, credentials, enTetes, delaiMax. Ils rendent le JSON de la réponse, null pour une réponse vide (204 ou 200 vide : react-query refuse un queryFn qui rend undefined). corps part tel quel s'il est un FormData, un Blob, un URLSearchParams ou un ArrayBuffer (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. Dans query, 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). Ce texte de repli porte le statut, à sa fin (« La requête n'a pas abouti. (HTTP 413) », « Session expirée ou absente. Reconnectez-vous. (HTTP 401) ») : une erreur rendue par un proxy reste diagnostiquable depuis l'écran.
  • 304 Not Modified, reçu seulement si l'appelant a posé lui-même If-None-Match ou If-Modified-Since dans enTetes (le cache du navigateur revalide sans le montrer), lève not_modified (CODE_NON_MODIFIE, status 304), en JSON comme en lireBlob : la réponse n'a pas de corps et le client pas de cache, l'appelant garde la version qu'il a. Ce n'est pas une redirection : jamais redirection_refusee.
  • Codes propres au client, avec status 0 : network_error (CODE_RESEAU, pas de réponse) et delai_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 ; Infinity ou null : aucun délai. Une valeur ≤ 0 ou non finie lève une RangeError. 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, /api n'est pas routé vers le backend et le proxy a servi l'index.html. lireBlob refuse 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 quand jeton est fourni) fait d'une redirection une erreur redirection_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.
  • surNonAuthentifie(erreur) est appelé sur un 401, avant que l'erreur soit levée. Une requête partie avec un ancien jeton peut répondre 401 après une reconnexion : le client compare lui-même le jeton envoyé et celui que rend jeton() à cet instant, et n'appelle le rappel que s'ils sont égaux (ou si la requête ne portait pas de jeton) ; le nouveau jeton n'est donc jamais purgé par un 401 tardif. L'ErreurApi 401 est levée dans tous les cas. Si le rappel lève, l'appelant reçoit quand même l'ErreurApi 401, et l'exception du rappel est signalée à part par reportError (console et événement error de 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/hote deviendrait //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 contrat ErreurApi, qu'aucun écran n'a à traiter. Une base malformée (variable d'environnement mal saisie) ne fait pas échouer creerClientHttp, donc l'import du module qui crée le client : chaque requête est rejetée par une TypeError « Base d'API invalide ».
  • 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ève reponse_non_json au lieu d'une SyntaxError ; une réponse vide rend null au lieu de undefined.
  • Docs : sans enveloppe, un 4xx hors table donne http_<statut> au lieu d'internal_error ; l'erreur réseau ne porte plus details.cause.
  • Les deux : delai_depasse distingue 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.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for etic-insa-auth 0.3.0
File Size Uploaded Provenance Actions
etic_insa_auth-0.3.0.tar.gz 33.9 kB With provenance Details for etic_insa_auth-0.3.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for etic-insa-auth 0.3.0
File Interpreter ABI Platform Actions
etic_insa_auth-0.3.0-py3-none-any.whl Python 3 none any Details for etic_insa_auth-0.3.0-py3-none-any.whl

Total release size: 73.8 kB

Release files / etic_insa_auth-0.3.0.tar.gz

Download URL etic_insa_auth-0.3.0.tar.gz
Size 33.9 kB
Tags Source
SHA-256 checksum
How to use checksums
e293933a46ab14e82172d2a790a868115e94cd73e079f541f988011f59afcb25
BLAKE2b-256 checksum
How to use checksums
0c03925b89565a5947bae9187dd42e072c5f60f1cf191132e5269a1d4d2209e3
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 2, 2026.

Transparency log

Release files / etic_insa_auth-0.3.0-py3-none-any.whl

Download URL etic_insa_auth-0.3.0-py3-none-any.whl
Size 39.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d25a15e548a0018bbf0167cc2472a900bc6286696e57f85a6188364943260fa9
BLAKE2b-256 checksum
How to use checksums
00cffde28d93f67b9766a3816ecbe546a9c6574f22b06d3f68491d8291d1e685
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 2, 2026.

Transparency log

Security for etic-insa-auth 0.3.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.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for etic_insa_auth-0.3.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 2, 2026.

Transparency log

A 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.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page