Metadata-Version: 2.5
Name: etic-insa-auth
Version: 0.2.0
Summary: Connexion des applications ETIC à ETIC Members (FastAPI) : SSO, session serveur, droits.
Project-URL: Source, https://github.com/ETIC-INSA-Technologies/etic-packages/tree/main/packages/auth
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.12
Requires-Dist: cachetools<8,>=6
Requires-Dist: cryptography<51,>=45
Requires-Dist: fastapi<1,>=0.141
Requires-Dist: httpx<0.29,>=0.28
Requires-Dist: itsdangerous<3,>=2.2
Requires-Dist: pydantic<3,>=2.11
Requires-Dist: tenacity<10,>=9.2
Provides-Extra: sqlalchemy
Requires-Dist: sqlalchemy<3,>=2.0.40; extra == 'sqlalchemy'
Description-Content-Type: text/markdown

# @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)

```sh
uv add "etic-insa-auth[sqlalchemy]"
```

```python
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

```python
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)

```sh
npm i @etic-insa/auth @tanstack/react-query
```

```tsx
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.

```ts
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`, `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).
- 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` est appelé sur tout 401, avant que l'erreur soit levée. S'il 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.
- 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](https://docs.astral.sh/uv/) :

```sh
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.
