Metadata-Version: 2.5
Name: sagecoders-auth
Version: 0.2.0
Summary: SDK SageAuth (comptes SageCoders) : vérification des jetons, rôles, connexion OIDC + PKCE, comptes de service, webhooks, registre des événements. Indépendant du framework.
License-Expression: MIT
Keywords: auth,jwt,oidc,sageauth,sagecoders,sso
Classifier: Framework :: Django
Classifier: Framework :: FastAPI
Classifier: Framework :: Flask
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Security
Requires-Python: >=3.9
Requires-Dist: httpx>=0.27
Requires-Dist: pyjwt[crypto]>=2.8
Description-Content-Type: text/markdown

# sagecoders-auth

SDK Python de **SageAuth** (comptes SageCoders). Il ne dépend d'aucun framework : FastAPI, Django, Flask ou script.

```sh
pip install sagecoders-auth
```

Ce qu'il fait :

- **API** : vérification des access tokens (clés publiques en cache, rotation) et des rôles du projet.
- **Connexion d'une application web** : code + PKCE, retour (callback), rafraîchissement, déconnexion, déconnexion back-channel.
- **Comptes de service** : jeton automatique (JWT bearer), import de comptes, déclaration des rôles et du catalogue d'événements, attribution des rôles.
- **Webhooks** de compte : vérification de la signature.
- **SageEvent** : droits d'émission et d'écoute (registre des événements).

Toutes les erreurs sont des `SageAuthError` avec un `code` stable et un `status` HTTP.

## Protéger une API

```python
from sagecoders_auth import SageAuth, SageAuthError

auth = SageAuth(issuer="https://account.sagecoders.com", project_id="<ID du projet>")

user = auth.authenticate(request_authorization_header, roles=["hotel.manager"])  # SageUser
user.sub, user.roles, user.phone, user.email
```

**FastAPI**

```python
from fastapi import Depends, FastAPI, Header, HTTPException

def current_user(authorization: str | None = Header(None)):
    try:
        return auth.authenticate(authorization)
    except SageAuthError as e:
        raise HTTPException(e.status or 401, e.code)

def require(*roles):
    def dep(user=Depends(current_user)):
        if not user.has_role(roles):
            raise HTTPException(403, "forbidden")
        return user
    return dep

@app.get("/rooms")
def rooms(user=Depends(require("hotel.manager"))): ...
```

**Django** (middleware) : `request.sage_user = auth.authenticate(request.headers.get("Authorization"))`, avec renvoi de `JsonResponse({"error": e.code}, status=e.status)` sur `SageAuthError`.

**Flask** : même appel dans un `before_request` ; garder l'utilisateur dans `g.user`.

Ne contactez pas SageAuth à chaque requête : les clés sont gardées 10 minutes, rechargées si une nouvelle clé apparaît, et conservées si SageAuth est momentanément injoignable.

## Connexion d'une application web

Le SDK ne touche pas aux cookies : gardez `state`, `nonce` et `code_verifier` dans **votre** session.

```python
auth = SageAuth(issuer=ISSUER, client_id="<client_id>", redirect_uri="https://app.exemple.ci/auth/callback", project_id="<ID projet>")

# /auth/login
req = auth.create_authorization()            # prompt="none", max_age=…, login_hint=…, offline=True
session["sageauth"] = {"state": req.state, "nonce": req.nonce, "code_verifier": req.code_verifier}
return redirect(req.url)

# /auth/callback
s = session.pop("sageauth")
result = auth.handle_callback(str(request.url), **s)   # vérifie state, code, ID token, nonce
session["user"] = result.user.sub                       # result.tokens.refresh_token si offline=True

# /auth/logout
return redirect(auth.logout_url(id_token_hint=..., post_logout_redirect_uri="https://app.exemple.ci/"))

# déconnexion back-channel (POST form logout_token)
who = auth.verify_logout_token(form["logout_token"])     # {"sub": ..., "sid": ...}
```

### Confirmer une action sensible par un code (mode vérification)

L'utilisateur reçoit un code sur **son** numéro ou **son** email, non modifiable :

```python
from dataclasses import asdict

req = auth.create_authorization(verify="phone", login_hint=user.phone)   # ou verify="email"
session["confirm"] = {"state": req.state, "nonce": req.nonce, "code_verifier": req.code_verifier,
                      "verify": asdict(req.verify), "expected_sub": user.sub}
return redirect(req.url)

# callback : compte, contact vérifié, vérification de moins de 5 min (max_auth_age)
auth.handle_callback(str(request.url), **session.pop("confirm"))   # wrong_account, contact_not_verified, stale_authentication
```

## Compte de service

Clé JSON téléchargée dans la console (Projets → votre projet → Comptes de service), à mettre dans une variable d'environnement, idéalement en base64 :

```python
import os
from sagecoders_auth import ServiceAccount

svc = ServiceAccount(issuer=ISSUER, key=os.environ["SAGEAUTH_SERVICE_KEY_B64"], sage_auth_project_id="<ID projet SageAuth>")

# Rôles et permissions (interrupteur « Déclare les rôles et événements du projet »)
svc.roles.sync(PROJECT_ID, [
    {"key": "hotel.manager", "name": "Gérant", "permissions": ["rooms:write", "bookings:*"]},
    {"key": "hotel.staff", "name": "Réception", "permissions": ["bookings:write"]},
], prune=True)
svc.roles.set_for_user(PROJECT_ID, sub, ["hotel.manager"])
perms = svc.roles.of_user(PROJECT_ID, sub)["permissions"]   # à garder en cache

from sagecoders_auth import has_permission
has_permission(perms, "console:data:read:sage_hotel")   # « console:* », « console:data:* » et « * » sont des jokers
has_permission(perms, ["a:read", "b:read"], all=True)

# Import de comptes existants (rôle accounts.import sur SageAuth)
svc.import_users("paradapi", [{"externalId": "42", "givenName": "Aya", "familyName": "Kouassi", "phone": "+2250700000000"}])

# Catalogue d'événements (espace de noms choisi dans la console)
svc.events.sync(PROJECT_ID, [{"key": "sagehotel.booking.created", "name": "Réservation créée", "version": 1}])

# Appeler l'API d'un autre projet sans utilisateur
token = svc.get_token(audience="<ID du projet appelé>")
```

## SageEvent

Compte de service avec le rôle `events.check` sur le projet SageAuth :

```python
svc.events.check("publish", "sagemessaging.message.sent", sub="<compte de service émetteur>")
svc.events.check("subscribe", "sagehotel.booking.*", client_id="<application du navigateur SSE>")
svc.events.rights(sub="...")      # à garder 1 min en cache
svc.events.catalog()              # à garder 5 min en cache
svc.events.grants()               # autorisations entre projets (owner, grantee, action, pattern)
```

## Annuaire (lecture seule)

Compte de service avec le rôle `directory.read` sur le projet SageAuth. Aucune donnée de sécurité.

```python
svc.directory.organizations()                    # {"items": [{id, name, createdAt, products}], "nextCursor": ...}
svc.directory.users(query="aya", cursor=None)    # {"items": [{sub, name, email, orgId, orgName, state, createdAt}], "nextCursor": ...}
svc.directory.service_accounts(project_id=...)   # [{userId, name, projectId, projectName}]
```

## Webhooks

```python
from sagecoders_auth import verify_webhook
event = verify_webhook(raw_body, request.headers.get("SageAuth-Signature"), os.environ["SAGEAUTH_WEBHOOK_SECRET"])
```

Passez le corps **brut**, avant tout parsing JSON.

## Développement

```sh
python -m venv .venv && .venv/Scripts/pip install -e .   # .venv/bin/pip sous Linux
python -m unittest discover -s tests
```
