Author SHA1 Message Date
Codex fba5a060df fix(auth): corriger les retours de revue du PIN Pronote 2026-09-12 22:20:09 +02:00
OpenCode 1f03b114a9 Merge pull request 'docs(auth): rendre la référence Pronote vérifiable' (#45) from docs/issue-10-pronote-auth-reference into main 2026-09-12 21:18:05 +02:00
OpenCode 4c6b8dbee8 Merge remote-tracking branch 'origin/main' into docs/issue-10-pronote-auth-reference 2026-09-12 21:17:35 +02:00
OpenCode 423a32f220 docs(pronote-auth): reconstruire le source LaTeX et régénérer le PDF
Les fichiers de chapitres référencés par docs/pronote-auth.tex (preamble, frontmatter, ch-*, annex-*) n'avaient jamais été committés, rendant le PDF non reproductible. Reconstitution fidèle depuis docs/pronote-auth.md, correction des instructions de compilation (TEXINPUTS + -output-directory), ajout de l'Annexe C, du Glossaire et de l'Historique des révisions, PDF régénéré (27 pages).
2026-09-12 21:17:23 +02:00
OpenCode 79ef5434ee Merge pull request 'refactor(ia): unifier le contrat AI_BASE_URL entre les providers' (#43) from refactor/issue-17-ai-base-url into main 2026-09-12 20:00:41 +02:00
OpenCode d5e4964887 Merge remote-tracking branch 'origin/main' into refactor/issue-17-ai-base-url
# Conflicts:
#	.secrets.baseline
2026-09-12 19:58:13 +02:00
OpenCode 35e93cc993 Merge pull request 'fix(pronote): ignorer les informations en mode qr_token' (#44) from fix/issue-21-qr-token-informations into main 2026-09-12 19:21:25 +02:00
OpenCode 9cd3918291 Merge pull request 'fix(config): refuser les fenêtres de synchronisation négatives' (#42) from fix/issue-14-negative-sync-window into main 2026-09-12 19:21:08 +02:00
OpenCode f421a386f5 refactor(ia): unifier le contrat AI_BASE_URL entre les providers
Applique une validation structurelle partagée (https, absence de credentials et de paramètres sensibles, aucun ajout /v1) aux providers openai, litellm et openai-compatible ; homogénéise les warnings expurgés et durcit le parsing pour que la factory ne lève jamais.

Refs #17
2026-09-12 19:20:03 +02:00
OpenCode 79858a0849 fix(pronote): ignorer les informations en mode qr_token
En mode qr_token, get_informations() retourne [] sans connexion ni verrou, ce qui évite l'échec systématique de PageActualites (erreur pronotepy 20) et le refresh redondant du token sur les instances HubEduConnect.

Refs #21
Refs #20
2026-09-12 19:20:03 +02:00
OpenCode 8efac8645b docs(auth): rendre la référence Pronote vérifiable
Qualifie sources et hypothèses (pronotepy 2.15.7, 2026-09-12), corrige le nombre d'ENT, exclut CAS/SAML/EduConnect générique, aligne la procédure QR sur .env.example et supprime les exemples copiables.

Refs #10
2026-09-12 19:18:28 +02:00
OpenCode 8c6a0e3f29 fix(config): refuser les fenêtres de synchronisation négatives
Contraint SYNC_PAST_DAYS et SYNC_FUTURE_DAYS à ge=0 et documente l'effet réel de 0 jour (le jour courant reste inclus).

Refs #14
2026-09-12 19:18:27 +02:00
Codex 4ad62ad50f Merge branch 'main' into feat/issue-8-account-pin
# Conflicts:
#	docs/exploitation.md
2026-09-12 18:11:57 +02:00
Codex 2d55ac8d2b feat(auth): prendre en charge le PIN de compte Pronote 2026-09-12 15:18:00 +02:00
34 changed files with 2501 additions and 691 deletions
+12 -2
View File
@@ -22,7 +22,10 @@ PRONOTE_AUTH_MODE=password
# Le QR code expire ~10 minutes après génération
# PRONOTE_AUTH_MODE=qr_token
# PRONOTE_QR_CODE_FILE=/path/to/qr_code.json
# PRONOTE_QR_PIN=1234
# PRONOTE_QR_PIN=
# Valeur à définir localement dans .env ; ne jamais la committer.
# PIN de second facteur du compte Pronote, distinct du PIN de déchiffrement du QR code
# PRONOTE_ACCOUNT_PIN=
# --- CalDAV ---
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
@@ -32,7 +35,7 @@ CALDAV_CALENDAR_PATH=/pronote-sync/
# Autoriser HTTP (non-HTTPS) pour un serveur CalDAV local (localhost uniquement)
CALDAV_ALLOW_INSECURE_HTTP=false
# Fenêtre de synchronisation (jours)
# Fenêtre de synchronisation (jours) — entier >= 0 ; 0 = aucune journée supplémentaire de ce côté (le jour courant reste inclus). Valeurs négatives refusées au chargement (ValidationError).
SYNC_PAST_DAYS=7
SYNC_FUTURE_DAYS=30
@@ -66,6 +69,13 @@ AI_BASE_URL=https://api.openai.com/v1
# AI_API_KEY=
# AI_MODEL=gpt-4o-mini # exemple recommandé, non activé par défaut
# NOTE : la validation structurelle de AI_BASE_URL s'applique à TOUS les
# providers (openai, litellm, openai-compatible) : HTTPS obligatoire sauf si
# AI_ALLOW_INSECURE_HTTP=true, aucun credential embarqué (user:pass@hôte),
# aucun paramètre sensible dans la query string (token, key, api_key,
# secret, password, auth), et aucune manipulation automatique de /v1.
# Seul le provider openai-compatible exige AI_BASE_URL et AI_MODEL.
# Exemple : OpenRouter (HTTPS)
# AI_PROVIDER=openai-compatible
# AI_BASE_URL=https://openrouter.ai/api/v1
+2 -2
View File
@@ -140,7 +140,7 @@
"filename": "GUIDE_DEV_PYTHON.md",
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
"is_verified": false,
"line_number": 5125
"line_number": 5186
}
],
"tests/unit/test_caldav_gateway.py": [
@@ -185,5 +185,5 @@
}
]
},
"generated_at": "2026-09-12T12:04:07Z"
"generated_at": "2026-09-12T17:57:39Z"
}
+13 -4
View File
@@ -159,9 +159,12 @@ pronote-sync --dry-run
- Après chaque login réussi, les credentials exportées par `pronotepy.export_credentials()` sont
persistées dans `.pronote_auth_state.json` (permissions `0600`, format JSON versionné, écriture
atomique). Le token rotate à chaque session et peut également être rafraîchi pendant l'exécution
(refresh automatique pronotepy après une `PronoteAPIError`). Les credentials sont persistées après
chaque login réussi **et après chaque opération de données réussie** (agenda, devoirs, messages,
informations) pour garantir la persistance du token valide.
(refresh automatique pronotepy après une `PronoteAPIError`). La persistance s'applique après
chaque login réussi **et après chaque opération de données réussie** (agenda, devoirs, messages)
pour garantir la persistance du token valide ; seules les opérations qui se connectent réellement
et récupèrent des données déclenchent la persistance. En mode `qr_token`, `get_informations()`
est ignorée (retour immédiat `[]` sans connexion ni verrou) et ne déclenche donc aucune
persistance.
- Les logins suivants utilisent `pronotepy.token_login(**credentials)` avec le token persisté.
- En cas d'échec de `token_login` (token expiré/invalide), une `PronoteAuthRotationError` est levée.
Cette erreur se propage sans wrapping à travers `PronoteFetcher` et `fetch_step` jusqu'à
@@ -172,6 +175,11 @@ pronote-sync --dry-run
- `PronoteAuthRotationError` est re-levée telle quelle (`except PronoteAuthRotationError: raise`)
dans toutes les couches d'enveloppement du chemin critique (fetch_agenda, fetch_homework,
fetch_step). Ne pas l'attraper avec `except Exception` sans la re-léver d'abord.
- En mode `qr_token`, `get_informations()` retourne **inconditionnellement** une liste vide
(`[]`) sans connexion, verrou, chargement d'état ni appel réseau, et journalise un message
INFO unique : l'endpoint `PageActualites` renvoie une erreur pronotepy 20 sur les instances
HubEduConnect testées, provoquant un refresh redondant du token. Ce contournement n'est pas
configurable ; aucun état anti-répétition n'est conservé.
- Le fichier `.pronote_auth_state.json` ne doit jamais être committé (couvert par `.gitignore`).
Son contenu (token vivant) ne doit jamais apparaître dans les logs, les messages d'erreur ou
les notifications XMPP.
@@ -222,7 +230,8 @@ def fetch_ical(url: str) -> str:
- **Masquage** : Utiliser systématiquement `redact_url()`, `redact_secrets()`, et `redact_exception()` depuis `utils/redaction.py`.
- **Chaînage d'exceptions** : Ne jamais conserver comme `__cause__` ou `__context__` une exception
externe brute susceptible de contenir un secret. Journaliser la version expurgée puis utiliser
`raise ... from None`, ou chaîner une cause elle-même expurgée.
`raise ... from None` hors du bloc `except` (car `from None` seul laisse
l'exception externe dans `__context__`), ou chaîner une cause elle-même expurgée.
- **Tests de non-fuite** : Vérifier les messages, les logs, `__cause__`, `__context__` et le
traceback complet avec des sentinelles distinctes pour chaque secret.
+1 -5
View File
@@ -25,17 +25,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `PRONOTE_URL` ignoré à cause du double préfixe `env_prefix` (renommage `pronote_url``url` dans `PronoteSettings`)
- `PRONOTE_ENT` rendu optionnel pour les connexions pronotepy directes
- `.env.example` corrigé (`eleve.html``parent.html`)
- #20/#21`get_informations()` ignorée en mode `qr_token` : retourne `[]` immédiatement, sans connexion, verrou ni appel réseau, évitant l'échec systématique de l'endpoint `PageActualites` (erreur pronotepy 20 sur les instances HubEduConnect testées) et le refresh redondant du token associé.
### Changed
- Wiki `GuidePronote` enrichi : section "Quand l'ENT est obligatoire" (EduConnect/HubEduConnect), exemple Bordeaux
- `AGENTS.md` : ajout de la section §13 "Versionnage et releases"
### Known Issues
- #20 — Triple authentification pronotepy (double INIT + refresh) lors d'un run
- #21 — Erreur pronotepy 20 « La page a expiré ! (11) » sur `get_informations`
### Tests
- 694 tests passés, couverture 94.93%
+107 -46
View File
@@ -299,18 +299,18 @@ d'un besoin réel et testé.
| `PRONOTE_AGENDA_SOURCE` | Source pour l'agenda (`auto`, `ical`, `pronotepy`). | `auto` | `Literal` |
| `PRONOTE_HOMEWORK_SOURCE` | Source pour les devoirs (`auto`, `ical`, `pronotepy`). | `auto` | `Literal` |
| `PRONOTE_MESSAGES_SOURCE` | Source pour les messages (`pronotepy` uniquement). | `pronotepy` | `Literal` |
| `SYNC_PAST_DAYS` | Nombre de jours dans le passé pour la sync CalDAV. | `7` | `int` |
| `SYNC_FUTURE_DAYS` | Nombre de jours dans le futur pour la sync CalDAV. | `30` | `int` |
| `SYNC_PAST_DAYS` | Nombre de jours dans le passé pour la sync CalDAV, entier `>= 0` (`0` = aucune journée supplémentaire de ce côté ; le jour courant reste inclus). | `7` | `int` |
| `SYNC_FUTURE_DAYS` | Nombre de jours dans le futur pour la sync CalDAV, entier `>= 0` (`0` = aucune journée supplémentaire de ce côté ; le jour courant reste inclus). | `30` | `int` |
| `THEORETICAL_AGENDA_PATH` | Chemin vers le fichier JSON de l'agenda théorique. | `None` | `str \| None`|
| `SCHOOL_HOLIDAYS_PATH` | Chemin vers le fichier JSON des vacances scolaires. | `None` | `str \| None`|
| `THEORETICAL_WEEK_ANCHOR_DATE` | Date de référence pour la parité des semaines (paire/impaire). | `None` | `date \| None`|
| `THEORETICAL_WEEK_ANCHOR_TYPE` | Parité de la semaine de référence (`even` ou `odd`). | `None` | `Literal["even", "odd"] \| None`|
| `AI_ENABLED` | Activer la synthèse IA. | `False` | `bool` |
| `AI_PROVIDER` | Fournisseur IA (`openai`, `openai-compatible` ou `litellm`). | `openai` | `Literal["openai", "litellm", "openai-compatible"]` |
| `AI_BASE_URL` | URL de base pour l'API IA (ex: OpenAI compatible). | `None` | `str \| None`|
| `AI_BASE_URL` | URL de base pour l'API IA (validée structurellement pour tous les providers, voir ci-dessous). | `None` | `str \| None`|
| `AI_API_KEY` | Clé API pour l'API IA. | `None` | `SecretStr` |
| `AI_MODEL` | Modèle IA à utiliser (exemple recommandé : `gpt-4o-mini`). | `None` | `str \| None`|
| `AI_ALLOW_INSECURE_HTTP` | Autoriser HTTP (non sécurisé) pour `openai-compatible` uniquement. | `False` | `bool` |
| `AI_ALLOW_INSECURE_HTTP` | Autoriser HTTP (non sécurisé) pour tous les providers (openai, litellm, openai-compatible). | `False` | `bool` |
| `DRY_RUN` | Simulation sans sortie distante ni état local persistant ; incompatible avec `qr_token`. | `False` | `bool` |
| `LOG_LEVEL` | Niveau de log (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | `INFO` | `str` |
@@ -394,7 +394,7 @@ LOG_LEVEL=INFO
> `CalDAVSettings.calendar_path` a pour valeur par défaut `"/pronote-sync/"`.
> `XmppSettings.resource` a pour valeur par défaut `"pronote-sync"`.
> > ``AISettings.provider`` accepte également ``openai-compatible`` (réutilise ``OpenAISynthesisProvider`` avec un ``base_url`` personnalisé).
> > ``AISettings.allow_insecure_http`` (défaut ``False``) autorise les URLs HTTP pour le provider ``openai-compatible`` uniquement.
> > ``AISettings.allow_insecure_http`` (défaut ``False``) autorise les URLs HTTP pour les trois providers (``openai``, ``litellm`` et ``openai-compatible``).
```python
from typing import Literal
@@ -421,8 +421,8 @@ class CalDAVSettings(BaseSettings):
password: SecretStr | None = None
calendar_path: str = "/pronote-sync/"
allow_insecure_http: bool = False
sync_past_days: int = 7
sync_future_days: int = 30
sync_past_days: int = Field(default=7, ge=0)
sync_future_days: int = Field(default=30, ge=0)
class AISettings(BaseSettings):
@@ -2279,6 +2279,12 @@ méthodes agenda/devoirs ne transforment jamais une erreur en liste vide : elles
version expurgée puis lèvent une erreur expurgée avec `from None`. Les méthodes de messages et
d'informations sont non critiques et peuvent retourner une liste vide avec un warning.
En mode `qr_token`, `get_informations()` est ignorée : elle retourne immédiatement `[]` sans
connexion, verrou ni appel réseau, et journalise un message INFO unique. L'endpoint
`PageActualites` renvoie en effet une erreur pronotepy 20 sur les instances HubEduConnect
testées, provoquant un refresh redondant du token. Ce comportement n'est pas configurable ;
`get_messages()` n'est pas concernée par ce contournement.
Les objets renvoyés par `client.homework(start, end)` couvrent une fenêtre. Le résultat destiné à
un jour cible est donc filtré explicitement sur `homework.date == target_date`.
@@ -2290,11 +2296,13 @@ d'authentification et de récupération par un verrou POSIX local non bloquant,
`.pronote_auth_state.json.lock`, à côté de `.pronote_auth_state.json`.
Le verrou couvre l'ensemble du cycle QR/token : chargement de l'état, connexion par token ou
enrôlement QR initial, opération de données (agenda, devoirs, messages ou informations), puis
persistance des credentials actualisées. Une tentative concurrente échoue immédiatement avec une
erreur d'état d'authentification expurgée ; elle ne patiente pas et ne relance pas
l'authentification. Le contenu du token, le PIN et les autres credentials ne sont jamais inclus
dans les logs ni dans ce message d'erreur.
enrôlement QR initial, opération de données (agenda, devoirs, messages ; informations hors mode
`qr_token`), puis persistance des credentials actualisées. En mode `qr_token`, `get_informations()`
est ignorée (retour immédiat `[]` sans connexion ni verrou) : elle n'acquiert pas le verrou et ne
déclenche aucune persistance. Une tentative concurrente échoue immédiatement avec une erreur d'état
d'authentification expurgée ; elle ne patiente pas et ne relance pas l'authentification. Le contenu
du token, le PIN et les autres credentials ne sont jamais inclus dans les logs ni dans ce message
d'erreur.
Ce mécanisme est un contrat **local** : il coordonne des processus sur le même hôte Linux et un
filesystem local. Pour des déploiements conteneurisés, les conteneurs qui partagent le même compte
@@ -3917,16 +3925,26 @@ L'import de `litellm` est conditionnel avec `try/except ImportError` → `None`.
| ``openai-compatible`` | Proxy ou serveur compatible OpenAI | ``OpenAISynthesisProvider`` |
| ``litellm`` | Bibliothèque LiteLLM embarquée | ``LiteLLMSynthesisProvider`` |
Pour le provider ``openai-compatible``, la validation de la configuration est stricte :
Pour le provider ``openai-compatible``, ``AI_BASE_URL`` et ``AI_MODEL`` sont requis ; pour
``openai`` et ``litellm``, ils sont optionnels. La validation structurelle de ``AI_BASE_URL``
(partagée via ``_validate_base_url``) s'applique de façon identique aux trois providers dès que
l'URL est renseignée :
- ``AI_BASE_URL`` est requis.
- ``AI_MODEL`` est requis et ne doit pas être vide.
- ``AI_API_KEY`` est requis (MVP).
- L'URL doit utiliser le schéma ``https`` sauf si ``AI_ALLOW_INSECURE_HTTP=true``.
- Les credentials dans l'URL sont refusés.
- Les paramètres sensibles dans la *query string* sont refusés.
- URL parsable par ``urlparse`` (``ValueError`` ou schéma vide → refusée) et hostname non vide.
- Schéma limité à ``http``/``https`` ; ``http`` refusé sauf si ``AI_ALLOW_INSECURE_HTTP=true``.
- Credentials dans l'URL (``user:pass@host``) refusés.
- Paramètres sensibles dans la *query string* refusés, y compris sans valeur
(``token``, ``key``, ``api_key``, ``secret``, ``password``, ``auth``).
- Aucune manipulation automatique de ``/v1`` n'est effectuée.
- Si la configuration est incomplète, la factory retourne ``None`` avec un avertissement (mode dégradé).
- En cas de violation, un avertissement est journalisé (URL masquée via ``redact_url``) et la
factory retourne ``None`` (mode dégradé) ; la factory ne lève jamais d'exception et ne fait
aucun appel réseau.
| Provider | `AI_BASE_URL` | `AI_MODEL` | Validation structurelle |
|---------------------|---------------|------------|---------------------------------------------|
| ``openai`` | optionnel | optionnel | `_validate_base_url("openai", ...)` |
| ``litellm`` | optionnel | optionnel | `_validate_base_url("litellm", ...)` |
| ``openai-compatible`` | requis | requis | `_validate_openai_compatible_config` (présence puis `_validate_base_url`) |
La politique hors réseau de la table des modèles litellm est gérée par `LITELLM_LOCAL_MODEL_COST_MAP=true`. Les tests utilisent `pytest.importorskip("litellm")`.
@@ -3942,37 +3960,69 @@ from ..utils.redaction import redact_url
logger = logging.getLogger(__name__)
_SENSITIVE_QUERY_PARAMS = {"token", "key", "api_key", "secret", "password", "auth"}
def _validate_base_url(provider: str, url: str, allow_insecure_http: bool) -> str | None:
"""Valide structurellement une URL de base IA, partagée entre providers."""
try:
parsed = urlparse(url)
if not parsed.scheme:
logger.warning("URL invalide pour le provider %s : %s", provider, redact_url(url))
return None
if not parsed.hostname:
logger.warning(
"URL sans hostname pour le provider %s : %s", provider, redact_url(url)
)
return None
if parsed.scheme not in ("http", "https"):
logger.warning(
"Schéma d'URL non supporté pour le provider %s : %s", provider, redact_url(url)
)
return None
if parsed.scheme == "http" and not allow_insecure_http:
logger.warning(
"URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true pour le provider %s : %s",
provider,
redact_url(url),
)
return None
if parsed.username is not None or parsed.password is not None:
logger.warning(
"Credentials dans l'URL refusés pour le provider %s : %s",
provider,
redact_url(url),
)
return None
param_names = [
name.lower() for name, _ in parse_qsl(parsed.query, keep_blank_values=True)
]
if any(name in _SENSITIVE_QUERY_PARAMS for name in param_names):
logger.warning(
"Paramètres sensibles dans l'URL refusés pour le provider %s : %s",
provider,
redact_url(url),
)
return None
# Accéder à parsed.port peut lever ValueError (port invalide/hors bornes).
parsed.port # noqa: B018
except ValueError:
logger.warning("URL invalide pour le provider %s : %s", provider, redact_url(url))
return None
return url
def _validate_openai_compatible_config(
url: str | None, model: str | None, allow_insecure_http: bool
) -> str | None:
"""Valide la configuration du provider ``openai-compatible``."""
if not url or not model:
if not url:
logger.warning("URL de base requise pour le provider openai-compatible")
return None
try:
parsed = urlparse(url)
except ValueError:
logger.warning("URL invalide : %s", redact_url(url))
if not model:
logger.warning("Modèle requis pour le provider openai-compatible")
return None
if not parsed.hostname:
logger.warning("URL sans hostname : %s", redact_url(url))
return None
if parsed.scheme not in ("http", "https"):
return None
if parsed.scheme == "http" and not allow_insecure_http:
return None
if parsed.username is not None or parsed.password is not None:
logger.warning("Credentials dans l'URL refusés : %s", redact_url(url))
return None
sensitive_names = {"token", "key", "api_key", "secret", "password", "auth"}
param_names = [
name.lower() for name, _ in parse_qsl(parsed.query, keep_blank_values=True)
]
if any(name in sensitive_names for name in param_names):
logger.warning(
"Paramètres sensibles dans l'URL refusés : %s", redact_url(url)
)
return None
return url
return _validate_base_url("openai-compatible", url, allow_insecure_http)
def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
@@ -3981,7 +4031,10 @@ def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
Retourne ``None`` lorsque la synthèse IA est désactivée ou qu'aucune clé
API n'est configurée. Pour le provider ``litellm``, le paquet ``litellm``
(extra ``ai-litellm``) est requis : s'il est absent, un avertissement est
journalisé et ``None`` est retourné.
journalisé et ``None`` est retourné. Pour ``openai`` et ``litellm``,
une ``base_url`` éventuelle est validée par :func:`_validate_base_url` ;
pour le provider ``openai-compatible``, la configuration est validée par
:func:`_validate_openai_compatible_config`.
:param settings: Paramètres IA.
:return: Le fournisseur configuré, ou ``None`` si désactivé ou sans clé API.
@@ -4001,6 +4054,10 @@ def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
except ImportError:
logger.warning("Extra 'ai-litellm' requis pour le provider litellm")
return None
if base_url is not None and (
_validate_base_url("litellm", base_url, settings.allow_insecure_http) is None
):
return None
return LiteLLMSynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
if settings.provider == "openai-compatible":
@@ -4011,6 +4068,10 @@ def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
return None
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=url, model=model)
if base_url is not None and (
_validate_base_url("openai", base_url, settings.allow_insecure_http) is None
):
return None
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
```
+1 -1
View File
@@ -75,7 +75,7 @@ The following variables can be safely pre-configured in `/etc/pronote-sync/prono
- `XMPP_USE_TLS` is deprecated but still supported (aliased to `XMPP_TLS_MODE`)
- **AI:**
- `AI_ENABLED`, `AI_PROVIDER`, `AI_BASE_URL`, `AI_MODEL`, `AI_ALLOW_INSECURE_HTTP`
- `AI_ENABLED`, `AI_PROVIDER`, `AI_BASE_URL`, `AI_MODEL`, `AI_ALLOW_INSECURE_HTTP` — la validation structurelle de `AI_BASE_URL` s'applique à tous les providers (HTTPS sauf `AI_ALLOW_INSECURE_HTTP=true`, pas de credentials ni de paramètres sensibles dans l'URL, pas de `/v1` automatique) ; seul `openai-compatible` exige `AI_BASE_URL` et `AI_MODEL`.
- **Blog:**
- `BLOG_ENABLED`, `BLOG_RSS_URL`
+51
View File
@@ -0,0 +1,51 @@
% =============================================================================
% ANNEXE B : TABLEAU COMPARATIF SYNTHÉTIQUE
% =============================================================================
\chapter{Tableau comparatif synthétique}
\label{annex:comparison}
\begin{table}[htbp]
\centering
\caption{Comparatif des méthodes d'authentification}
\label{tab:comparison}
\begin{tabularx}{\textwidth}{L{2cm} L{2.5cm} L{2.5cm} L{2cm} L{2cm} L{2.5cm}}
\toprule
\textbf{Méthode} & \textbf{Périmètre} & \textbf{Identifiants} & \textbf{Expiration} & \textbf{Complexité} & \textbf{Statut dans \texttt{pronote-sync}} \\
\midrule
\textbf{URL iCal sécurisée} & EDT + devoirs (si inclus dans le flux) & Jeton URL & Longue durée (dépend de l'instance) & Très faible & \emojicheck\ Pris en charge \\
\textbf{Connexion directe} (mot de passe + ENT) & Agenda/EDT, devoirs, messages & Identifiant + mot de passe + ENT & Session dépendante de l'instance & Élevée & \emojicheck\ Pris en charge \\
\textbf{QR Code + token mobile} & Agenda/EDT, devoirs, messages & PIN 4 chiffres \rightarrow token + UUID & QR ~10 min ({\warningicon\ hypothèse}) ; token dépendant de l'instance & Modérée & \emojicheck\ Pris en charge \\
\textbf{SSO ENT (CAS/SAML)} && Identifiants ENT & Session ENT & Très élevée & \emojicross\ Non implémenté (seuls les 30 ENT de la liste fermée \texttt{\_ENT\_NAMES} sont supportés via \texttt{pronotepy}) \\
\textbf{EduConnect} && Identifiants nationaux & Session EduConnect & Très élevée & \emojicross\ Non implémenté \\
\textbf{CAS direct} && Identifiants CAS & Ticket single-use & Modérée & \emojicross\ Non implémenté \\
\textbf{API publique} & N/A & N/A & N/A & N/A & \emojicross\ Inexistante \\
\bottomrule
\end{tabularx}
\end{table}
\section*{Légende}
\addcontentsline{toc}{section}{Légende}
\begin{itemize}
\item \textbf{Périmètre} : Les méthodes \textbf{iCal} sont en lecture seule (EDT + devoirs si inclus
dans le flux). Les méthodes \textbf{Connexion directe} et \textbf{QR Code + token mobile}
permettent les opérations \texttt{pronotepy} effectivement implémentées par ce projet (agenda,
devoirs, messages ; informations ignorées en mode \texttt{qr\_token}).
\item \textbf{QR ~10 min} : {\warningicon\ \textbf{Hypothèse non vérifiée}} (observé dans
\texttt{pronotepy} via un message d'exception, dépend de l'instance).
\item \textbf{Session dépendante de l'instance} : {\warningicon\ \textbf{Hypothèse non vérifiée}}
(nécessite un essai réel).
\end{itemize}
% =============================================================================
% FIN DE L'ANNEXE
% =============================================================================
+62
View File
@@ -0,0 +1,62 @@
% =============================================================================
% ANNEXE C : ÉCARTS ET NOTES DE COHÉRENCE
% =============================================================================
\chapter{Écarts et notes de cohérence}
\label{annex:ecarts}
\section{Alignement avec \texttt{.env.example}}
\label{sec:alignement-env}
\begin{table}[htbp]
\centering
\caption{Alignement avec \texttt{.env.example}}
\label{tab:alignement-env}
\begin{tabularx}{\textwidth}{L{4.2cm} L{3.2cm} L{4.4cm} L{2.2cm}}
\toprule
\textbf{Paramètre} & \textbf{Document} & \textbf{Code} & \textbf{Statut} \\
\midrule
\texttt{PRONOTE\_ICAL\_URL} & \emojicheck\ Lignes 2, 42--50 & \emojicheck\ \texttt{sources/ical.py} & \textbf{Cohérent} \\
\texttt{PRONOTE\_URL} & \emojicheck\ Ligne 3 & \emojicheck\ \texttt{client.py} (ligne 294) & \textbf{Cohérent} \\
\texttt{PRONOTE\_ENT} & \emojicheck\ Ligne 7 & \emojicheck\ \texttt{client.py} (ligne 297, \texttt{\_ENT\_NAMES}) & \textbf{Cohérent} \\
\texttt{PRONOTE\_AUTH\_MODE=qr\_token} & \emojicheck\ Ligne 23 & \emojicheck\ \texttt{client.py} (ligne 334) & \textbf{Cohérent} \\
\texttt{PRONOTE\_QR\_CODE\_FILE} & \emojicheck\ Ligne 24 & \emojicheck\ \texttt{client.py} (ligne 387) & \textbf{Cohérent} \\
\texttt{PRONOTE\_QR\_PIN} & \emojicheck\ Ligne 25 & \emojicheck\ \texttt{client.py} (ligne 388) & \textbf{Cohérent} \\
\emph{« Le QR code se génère sur le site web »} & \emojicheck\ Ligne 21 & \emojicheck\ Section « Procédure QR pour \texttt{pronote-sync} » & \textbf{Cohérent} \\
\bottomrule
\end{tabularx}
\end{table}
\section{Écarts avec \texttt{docs/exploitation.md}}
\label{sec:ecarts-exploitation}
\begin{table}[htbp]
\centering
\caption{Écarts avec \texttt{docs/exploitation.md}}
\label{tab:ecarts-exploitation}
\begin{tabularx}{\textwidth}{L{4.2cm} L{2.6cm} L{5cm} L{2.6cm}}
\toprule
\textbf{Élément} & \textbf{\texttt{exploitation.md}} & \textbf{Ce document} & \textbf{Action} \\
\midrule
\emph{« Le QR code expire \textasciitilde{}10 minutes »} & Ligne 22 & \warningicon\ \textbf{Hypothèse non vérifiée} (section « Cycle de vie des tokens ») & \textbf{Aucune} (hors périmètre). \\
\emph{« Mode \texttt{qr\_token} incompatible avec dry-run »} & Lignes 65--66 & \emojicheck\ Section « Incompatibilités » & \textbf{Cohérent} \\
\emph{« Fichier \texttt{.pronote\_auth\_state.json} (mode 0600) »} & Lignes 70--75 & \emojicheck\ Section « Sécurité » & \textbf{Cohérent} \\
\bottomrule
\end{tabularx}
\end{table}
% =============================================================================
% FIN DE L'ANNEXE
% =============================================================================
+34
View File
@@ -0,0 +1,34 @@
% =============================================================================
% ANNEXE A : BIBLIOTHÈQUES TIERCES
% =============================================================================
\chapter{Bibliothèques tierces}
\label{annex:libraries}
\begin{table}[htbp]
\centering
\caption{Bibliothèques tierces et leur statut dans \texttt{pronote-sync}}
\label{tab:libraries}
\begin{tabularx}{\textwidth}{L{2.5cm} L{1.5cm} L{3cm} L{3cm} L{2.5cm}}
\toprule
\textbf{Bibliothèque} & \textbf{Langage} & \textbf{Dépôt} & \textbf{Méthodes supportées} & \textbf{Statut dans \texttt{pronote-sync}} \\
\midrule
\textbf{pronotepy} & Python & \url{https://github.com/bain3/pronotepy} & Connexion directe, QR code/token, \textbf{30 ENT} (liste fermée) & \emojicheck\ \textbf{Dépendance principale} (version \textbf{2.15.7} vérifiée). \\
\textbf{pronote-api} & TypeScript & \url{https://github.com/Litarvan/pronote-api} & Connexion directe, CAS, ENT & \emojicross\ Non utilisée. \\
\textbf{pronote-qrcode-api} & JavaScript & \url{https://github.com/Androz2091/pronote-qrcode-api} & Déchiffrement QR code & \emojicross\ Non utilisée (intégration native via \texttt{pronotepy}). \\
\bottomrule
\end{tabularx}
\end{table}
\begin{infobox}
\textbf{Source} : {\emojiinfo\ Observé dans \texttt{pyproject.toml}} (dépendances) + code source local
(2026-09-12).
\end{infobox}
% =============================================================================
% FIN DE L'ANNEXE
% =============================================================================
+36
View File
@@ -0,0 +1,36 @@
% =============================================================================
% CHAPITRE : INTRODUCTION
% =============================================================================
\chapter{Introduction}
\label{ch:introduction}
Ce document documente \textbf{uniquement les méthodes d'authentification prises en charge ou
délégables par \texttt{pronote-sync}}, en alignement strict avec :
\begin{itemize}
\item Le code source de \texttt{pronote\_sync/sources/pronote/client.py} (liste fermée
\texttt{\_ENT\_NAMES}, gestion des tokens).
\item La bibliothèque \texttt{pronotepy} \textbf{2.15.7} (contrat des méthodes
\texttt{qrcode\_login}, \texttt{token\_login}, \texttt{export\_credentials}).
\item Les paramètres de configuration \texttt{.env.example} (lignes 2125).
\end{itemize}
\section{Périmètre}
\begin{itemize}
\item {\emojicheck\ \textbf{Pris en charge}} : Méthodes implémentées et testées dans
\texttt{pronote-sync}.
\item {\emojiinfo\ \textbf{Délégable à pronotepy}} : Méthodes gérées par \texttt{pronotepy}
mais non directement exposées par \texttt{pronote-sync}.
\item {\emojicross\ \textbf{Non implémenté}} : Méthodes non supportées (ex. CAS/SAML
générique, EduConnect direct).
\end{itemize}
\section{Public cible}
Développeurs et intégrateurs de \texttt{pronote-sync}.
% =============================================================================
% FIN DU CHAPITRE
% =============================================================================
+112
View File
@@ -0,0 +1,112 @@
% =============================================================================
% CHAPITRE : MÉTHODE 1 — URL ICAL SÉCURISÉE
% =============================================================================
\chapter{Méthode 1 : URL iCal sécurisée (\texttt{icalsecurise})}
\label{ch:method1}
\section{Principe général}
Pronote expose un flux de calendrier en lecture seule conforme à la norme
\textbf{iCalendar (RFC 5545)}. L'accès est contrôlé par un \textbf{jeton secret} intégré dans
l'URL sous forme de paramètre de requête \texttt{icalsecurise}.
\begin{infobox}
\textbf{Statut} : {\emojiinfo\ Observé localement} (fonctionnalité native de Pronote, version non spécifiée).
\end{infobox}
\section{Obtention du jeton}
\begin{enumerate}
\item Se connecter à Pronote (Espace Parents ou Élève) via n'importe quelle méthode.
\item Accéder à la vue \guillemotleft Emploi du temps \guillemotright.
\item Utiliser la fonction \guillemotleft Export iCal \guillemotright ou \guillemotleft Exporter \guillemotright.
\item Pronote génère une URL contenant un jeton secret \texttt{icalsecurise}.
\item \textbf{Copier cette URL} : elle constitue une crédentiale unique sensible qui doit être
protégée comme un mot de passe.
\end{enumerate}
\begin{infobox}
\textbf{Source} : {\emojiinfo\ Observé localement dans les interfaces Pronote} (version non spécifiée,
hypothèse à valider).
\textbf{Note} : Les étapes dépendent de l'instance et du type de compte. L'exemple ci-dessus est
non fonctionnel et ne contient aucun secret.
\end{infobox}
\section{Format de l'URL}
\begin{lstlisting}[language=bash, caption=Format d'URL iCal Pronote, label=lst:ical-url]
https://{etablissement}.index-education.net/pronote/ical/Edt_{prenom}.ics?icalsecurise={jeton}&version={version}&param={param}
\end{lstlisting}
\begin{itemize}
\item \texttt{icalsecurise} : \textbf{Jeton secret} (crédentiale).
\item \texttt{version} : Version de Pronote (ex. \texttt{2024}).
\item \texttt{param} : Paramètres optionnels.
\end{itemize}
\begin{infobox}
\textbf{Source} : {\emojiinfo\ Analysé via \texttt{pronote\_sync/sources/ical.py}}.
\end{infobox}
\section{Cycle de vie}
\begin{itemize}
\item \textbf{Pérennité} : Le jeton reste valide jusqu'à :
\begin{itemize}
\item Révocation manuelle par l'utilisateur dans Pronote.
\item Régénération par l'établissement (ex. à la rentrée scolaire).
\end{itemize}
\item {\warningicon\ \textbf{Hypothèse à valider}} : La durée exacte dépend des politiques
de l'établissement (non documentée officiellement).
\end{itemize}
\begin{infobox}
\textbf{Source} : {\warningicon\ Comportement variable selon les instances} (à tester localement).
\end{infobox}
\section{Sécurité}
\begin{enumerate}
\item \textbf{Surface d'attaque} : Le jeton est encodé dans l'URL \rightarrow risque d'exposition via :
\begin{itemize}
\item Logs serveur/proxy.
\item En-tête \texttt{Referer}.
\item Historique du navigateur.
\end{itemize}
\item \textbf{Recommandations} :
\begin{itemize}
\item \textbf{Ne jamais versionner} l'URL (ex. dans \texttt{.env} ou Git).
\item Utiliser \textbf{HTTPS} (obligatoire).
\item Masquer l'URL dans les logs (ex. via \texttt{redact\_url()} dans
\texttt{pronote-sync}).
\end{itemize}
\end{enumerate}
\begin{infobox}
\textbf{Source} : {\warningicon\ Recommandation du projet} (inspirée des bonnes pratiques générales
de sécurité).
\end{infobox}
\section{Intégration dans \texttt{pronote-sync}}
\begin{itemize}
\item \textbf{Paramètre} : \texttt{PRONOTE\_ICAL\_URL} (ex. \texttt{.env.example} ligne 2).
\item \textbf{Comportement} :
\begin{itemize}
\item Prioritaire en mode \texttt{PRONOTE\_AGENDA\_SOURCE=auto}.
\item Si l'URL est invalide ou expire, repli automatique vers \texttt{pronotepy} (si
\texttt{PRONOTE\_AGENDA\_SOURCE=auto}).
\item \textbf{Aucun repli} si \texttt{PRONOTE\_AGENDA\_SOURCE=ical} (échec explicite).
\end{itemize}
\end{itemize}
\begin{infobox}
\textbf{Source} : {\emojiinfo\ \texttt{pronote\_sync/config/settings.py} +
\texttt{pronote\_sync/sources/ical.py}}.
\end{infobox}
% =============================================================================
% FIN DU CHAPITRE
% =============================================================================
+90
View File
@@ -0,0 +1,90 @@
% =============================================================================
% CHAPITRE : MÉTHODE 2 — CONNEXION DIRECTE
% =============================================================================
\chapter{Méthode 2 : Connexion directe (identifiant / mot de passe + ENT)}
\label{ch:method2}
\section{Principe général}
Connexion via le protocole propriétaire de Pronote (JSON sur HTTPS), avec
\textbf{chiffrement AES-CBC utilisant une clé dérivée par MD5 (16 octets, soit AES-128)} comme
implémenté dans pronotepy 2.15.7 et mécanisme de défi-réponse. Utilisé par l'interface web.
\begin{infobox}
\textbf{Statut} : {\emojiinfo\ Observé dans le code local} (délégable à pronotepy via
\texttt{ParentClient} ou \texttt{Client}).
\end{infobox}
\section{Flux d'authentification}
\begin{enumerate}
\item \textbf{Initialisation} : Requête GET vers \texttt{/pronote/\{espace\}.html} (ex.
\texttt{parent.html}).
\begin{itemize}
\item Récupère \texttt{h} (ID de session), \texttt{a} (ID espace), \texttt{sCrA}/\texttt{sCoA}
(drapeaux chiffrement/compression).
\end{itemize}
\item \textbf{Échange de clés} : Requête POST vers
\texttt{/pronote/appelfonction/\{a\}/\{h\}/\{numeroOrdre\}}.
\item \textbf{Identification} : Soumission de \texttt{identifiant},
\texttt{genreConnexion=0}, \texttt{genreEspace=\{a\}}.
\item \textbf{Résolution du défi} :
\begin{itemize}
\item Calcul de \texttt{mtp = MAJUSCULE(HEX(SHA256(alea + mot\_de\_passe)))}.
\item Dérivation de \texttt{key\_challenge = MD5(nom\_utilisateur + mtp)}.
\item Déchiffrement du \texttt{challenge} avec
\textbf{AES-CBC utilisant une clé dérivée par MD5 (16 octets, soit AES-128)}.
\end{itemize}
\item \textbf{Authentification} : Soumission de la réponse au défi.
\end{enumerate}
\begin{infobox}
\textbf{Source} : {\emojiinfo\ Observé dans le code local de \texttt{pronotepy} 2.15.7} (module
\texttt{clients.py} et \texttt{pronoteAPI.py}, vérifié le 2026-09-12).
\end{infobox}
\section{Intégration dans \texttt{pronote-sync}}
\begin{itemize}
\item \textbf{Paramètres} :
\begin{itemize}
\item \texttt{PRONOTE\_URL} (ex. \texttt{.env.example} ligne 3).
\item \texttt{PRONOTE\_USERNAME}, \texttt{PRONOTE\_PASSWORD}.
\item \texttt{PRONOTE\_ENT} (slug dans \texttt{\_ENT\_NAMES}).
\item \texttt{PRONOTE\_ACCOUNT\_TYPE} (ex. \texttt{parent}).
\end{itemize}
\item \textbf{Comportement} :
\begin{itemize}
\item Utilise \texttt{pronotepy.ParentClient} pour les comptes parents.
\item \textbf{Repli} : Si iCal échoue en mode \texttt{auto}, \texttt{pronotepy} est utilisé.
\item \textbf{Pas de repli} si \texttt{PRONOTE\_AGENDA\_SOURCE=pronotepy} (échec explicite).
\end{itemize}
\end{itemize}
\begin{infobox}
\textbf{Source} : {\emojiinfo\ \texttt{pronote\_sync/sources/pronote/client.py}} (méthode
\texttt{\_connect\_password}).
\end{infobox}
\section{ENT supportés}
Liste \textbf{fermée} des \textbf{30 ENT} résolubles (lignes 5283 de
\texttt{pronote\_sync/sources/pronote/client.py}) :
\begin{lstlisting}[language=Python, caption=Liste des ENT supportés, label=lst:ent-list]
_monbureaunumerique, ent_elyco, bordeaux, ent_creuse, occitanie_montpellier, ...
\end{lstlisting}
\begin{warningbox}
\textbf{Hypothèse à valider} : Les ENT non listés nécessitent une contribution à
\texttt{pronotepy}.
\end{warningbox}
\begin{infobox}
\textbf{Source} : {\emojiinfo\ Code source local de \texttt{pronote-sync}} (2026-09-12).
\end{infobox}
% =============================================================================
% FIN DU CHAPITRE
% =============================================================================
+212
View File
@@ -0,0 +1,212 @@
% =============================================================================
% CHAPITRE : MÉTHODE 3 — QR CODE + TOKEN MOBILE
% =============================================================================
\chapter{Méthode 3 : QR Code + token mobile (pour \texttt{pronote-sync})}
\label{ch:method3}
\section{Principe général}
Mécanisme d'appairage par QR code pour les appareils mobiles, \textbf{contournant
l'authentification ENT/EduConnect}. Le QR code est généré \textbf{depuis l'interface web
Pronote, espace parent \rightarrow paramètres \rightarrow QR code}, puis utilisé avec un
\textbf{PIN à 4 chiffres} pour obtenir un token dont la durée dépend de l'instance/serveur.
\begin{infobox}
\textbf{Statut} : {\emojicheck\ Pris en charge} par \texttt{pronote-sync} (mode
\texttt{PRONOTE\_AUTH\_MODE=qr\_token}).
\end{infobox}
\section{Procédure QR pour \texttt{pronote-sync}}
\label{sec:procedure-qr}
\subsection{Étape 1 : Génération du QR code (interface web)}
\begin{enumerate}
\item Se connecter à Pronote via un navigateur (espace \textbf{Parent}).
\item Aller dans \textbf{Paramètres \rightarrow Accès mobile / Application mobile}.
\item Définir un \textbf{PIN temporaire à 4 chiffres}.
\item Pronote affiche un \textbf{QR code} contenant un JSON avec les clés :
\begin{itemize}
\item \texttt{login}, \texttt{jeton}, et \texttt{url} (ex.
\texttt{https://[host]/pronote/mobile.parent.html}).
\end{itemize}
\textbf{Le fichier JSON réel contient des identifiants chiffrés et ne doit jamais être
copié, partagé, ou committé.}
\item \textbf{Exporter le QR code} :
\begin{itemize}
\item Sauvegardez le fichier JSON localement ou scannez-le avec un appareil.
\item \textbf{Ne jamais partager} le JSON ou le PIN.
\item \textbf{Le fichier JSON du QR code contient des identifiants chiffrés : ne jamais
le coller dans la documentation ni le committer.}
\end{itemize}
\end{enumerate}
\begin{infobox}
\textbf{Source} : {\emojiinfo\ Observé localement dans l'interface web Pronote} (version non
spécifiée, hypothèse à valider).
\end{infobox}
\subsection{Étape 2 : Configuration de \texttt{pronote-sync}}
\begin{enumerate}
\item \textbf{Enregistrer le QR code} :
\begin{itemize}
\item Sauvegarder le JSON dans un fichier (ex. \texttt{/path/to/qr\_code.json}).
\item \textbf{Permissions} : \texttt{chmod 600 /path/to/qr\_code.json}.
\end{itemize}
\item \textbf{Configurer \texttt{.env}} :
\begin{lstlisting}[language=bash, caption=Configuration QR Code dans .env, label=lst:qr-env]
PRONOTE_AUTH_MODE=qr_token
PRONOTE_QR_CODE_FILE=/path/to/qr_code.json
PRONOTE_QR_PIN= # renseigner localement la valeur secrète du PIN (jamais committée)
\end{lstlisting}
\textbf{Note} : \texttt{.env.example} ne doit \textbf{jamais} contenir de PIN concret.
\end{enumerate}
\begin{infobox}
\textbf{Source} : {\emojiinfo\ Observé dans \texttt{.env.example}} (lignes 2125) et
\texttt{pronote\_sync/sources/pronote/client.py}.
\end{infobox}
\subsection{Étape 3 : Premier login (enrôlement)}
\begin{enumerate}
\item \texttt{pronote-sync} lit \texttt{PRONOTE\_QR\_CODE\_FILE} et \texttt{PRONOTE\_QR\_PIN}.
\item Appel à \texttt{pronotepy.ParentClient.qrcode\_login(qr\_code, pin, uuid)} :
\begin{itemize}
\item \texttt{qr\_code} : JSON du fichier QR.
\item \texttt{pin} : PIN à 4 chiffres.
\item \texttt{uuid} : UUID permanent généré par \texttt{pronote-sync} (ex.
\texttt{pronote-sync-\{uuid4()\}}).
\end{itemize}
\item \textbf{Déchiffrement} :
\begin{itemize}
\item Algorithme :
\textbf{AES-CBC utilisant une clé dérivée par MD5 (16 octets, soit AES-128)}.
\item Clé : \texttt{MD5(PIN)} (dérivée du PIN secret).
\item IV : 16 octets nuls.
\item Résultat : \texttt{login} et \texttt{jeton} en clair.
\end{itemize}
\item \textbf{Échange de token} : Pronote retourne un \texttt{jetonConnexionAppliMobile} (token
dont la durée dépend de l'instance/serveur).
\item \textbf{Persistance} : \texttt{pronote-sync} sauvegarde les credentials via
\texttt{export\_credentials()} dans \texttt{.pronote\_auth\_state.json} (mode \texttt{0600}).
\end{enumerate}
\begin{infobox}
\textbf{Source} : {\emojiinfo\ Observé dans le code local de \texttt{pronotepy} 2.15.7}
(\texttt{clients.py} lignes 181189) + \texttt{pronote\_sync/sources/pronote/client.py} (méthode
\texttt{\_enroll\_qr\_code}).
\end{infobox}
\subsection{Étape 4 : Connexions ultérieures (auto-login)}
\begin{enumerate}
\item \texttt{pronote-sync} charge \texttt{.pronote\_auth\_state.json}.
\item Appel à \texttt{pronotepy.ParentClient.token\_login(**credentials)} :
\begin{itemize}
\item \texttt{pronote\_url}, \texttt{username}, \texttt{password} (token), \texttt{uuid}.
\end{itemize}
\item \textbf{Rotation du token} : Le token est \textbf{remplacé uniquement si le serveur
renvoie \texttt{jetonConnexionAppliMobile}} (observé dans \texttt{pronotepy} 2.15.7,
\texttt{clients.py:382387}).
\item \textbf{Persistance} : Les credentials sont sauvegardés dans
\texttt{.pronote\_auth\_state.json} après chaque opération réussie.
\end{enumerate}
\begin{infobox}
\textbf{Source} : {\emojiinfo\ Observé dans le code local de \texttt{pronotepy} 2.15.7}
(\texttt{clients.py} lignes 245280) + \texttt{pronote\_sync/sources/pronote/client.py} (méthode
\texttt{\_connect\_qr\_token}).
\end{infobox}
\section{Cycle de vie des tokens}
\begin{itemize}
\item \textbf{QR code} : Valide \textbf{~10 minutes} après génération
({\warningicon\ \textbf{Hypothèse à valider}} : cette durée n'est attestée que par un
message d'exception dans \texttt{pronotepy} et n'est pas une garantie officielle/indépendante
de l'instance).
\item \textbf{\texttt{jetonConnexionAppliMobile}} :
\begin{itemize}
\item Durée \textbf{dépendante de l'instance/serveur} (observation du 2026-09-12,
hypothèse : peut persister jusqu'à la fin de l'année scolaire, non garanti).
\item \textbf{Remplacé uniquement si le serveur renvoie
\texttt{jetonConnexionAppliMobile}} (observé dans \texttt{pronotepy} 2.15.7,
\texttt{clients.py:382387}).
\item \textbf{Révocable} manuellement dans Pronote (Paramètres \rightarrow Accès mobile).
\end{itemize}
\end{itemize}
\begin{infobox}
\textbf{Source} : {\warningicon\ Comportement variable selon les instances} (à tester localement).
\end{infobox}
\section{Sécurité}
\begin{enumerate}
\item \textbf{Fichiers sensibles} :
\begin{itemize}
\item \texttt{.pronote\_auth\_state.json} : \textbf{Ne jamais versionner} (couvert par
\texttt{.gitignore}).
\item \texttt{PRONOTE\_QR\_CODE\_FILE} : \textbf{Ne jamais committer} (ex. dans Git).
\end{itemize}
\item \textbf{Secrets} :
\begin{itemize}
\item Le PIN et le contenu du QR code sont \textbf{masqués} dans les logs (via
\texttt{redact\_secrets()}).
\item Les exceptions sont \textbf{expurgées} (via \texttt{redact\_exception()}).
\end{itemize}
\item \textbf{Recommandations} :
\begin{itemize}
\item Utiliser un \textbf{PIN robuste} (éviter les codes simples comme \guillemotleft 0000 \guillemotright ou
des séquences évidentes).
\item \textbf{Révoquer} le token en cas de compromission (via Pronote web).
\end{itemize}
\end{enumerate}
\begin{infobox}
\textbf{Source} : {\emojiinfo\ \texttt{pronote\_sync/utils/redaction.py} +
\texttt{pronote\_sync/sources/pronote/client.py}} (méthode \texttt{\_collect\_auth\_secrets}).
\end{infobox}
\section{Erreurs et repli}
\begin{itemize}
\item \textbf{\texttt{PronoteAuthRotationError}} : Levée si :
\begin{itemize}
\item Le token persisté est \textbf{invalide/expiré}.
\item Le fichier QR ou le PIN est \textbf{manquant/invalide}.
\end{itemize}
\item \textbf{Action requise} :
\begin{enumerate}
\item Supprimer \texttt{.pronote\_auth\_state.json}.
\item Générer un \textbf{nouveau QR code depuis l'interface web Pronote, espace parent}.
\item Relancer \texttt{pronote-sync}.
\end{enumerate}
\end{itemize}
\begin{infobox}
\textbf{Source} : {\emojiinfo\ Observé dans \texttt{pronote\_sync/errors.py} +
\texttt{pronote\_sync/sources/pronote/client.py}} (lignes 346364).
\end{infobox}
\section{Incompatibilités}
\begin{itemize}
\item \textbf{Mode \texttt{dry-run}} : \textbf{Incompatible} avec
\texttt{PRONOTE\_AUTH\_MODE=qr\_token} (risque de désynchronisation du token local).
\begin{itemize}
\item \texttt{pronote-sync --dry-run} \textbf{refuse} le mode \texttt{qr\_token} avant toute
connexion.
\end{itemize}
\end{itemize}
\begin{infobox}
\textbf{Source} : {\emojiinfo\ \texttt{docs/exploitation.md}} (ligne 6566).
\end{infobox}
% =============================================================================
% FIN DU CHAPITRE
% =============================================================================
+17
View File
@@ -0,0 +1,17 @@
% =============================================================================
% CHAPITRE : MÉTHODE 4 — SSO ENT (CAS/SAML)
% =============================================================================
\chapter{Méthode 4 : SSO ENT (CAS/SAML)}
\label{ch:method4}
\begin{warningbox}
\textbf{Statut} : {\emojicross\ Non implémenté} dans \texttt{pronote-sync}.
Seuls les 30 ENT de la liste fermée \texttt{\_ENT\_NAMES} (pronotepy 2.15.7) sont supportés via
\texttt{ent} dans \texttt{ParentClient}. \textbf{Pas de SSO générique CAS/SAML.}
\end{warningbox}
% =============================================================================
% FIN DU CHAPITRE
% =============================================================================
+16
View File
@@ -0,0 +1,16 @@
% =============================================================================
% CHAPITRE : MÉTHODE 5 — EDUCONNECT (HUBEDUCONNECT)
% =============================================================================
\chapter{Méthode 5 : EduConnect (HubEduConnect)}
\label{ch:method5}
\begin{warningbox}
\textbf{Statut} : {\emojicross\ Non implémenté} dans \texttt{pronote-sync}.
Nécessite une intégration CAS/SAML générique, \textbf{non supportée par ce projet}.
\end{warningbox}
% =============================================================================
% FIN DU CHAPITRE
% =============================================================================
+16
View File
@@ -0,0 +1,16 @@
% =============================================================================
% CHAPITRE : MÉTHODE 6 — CAS DIRECT
% =============================================================================
\chapter{Méthode 6 : CAS direct}
\label{ch:method6}
\begin{warningbox}
\textbf{Statut} : {\emojicross\ Non implémenté} dans \texttt{pronote-sync}.
Non supporté.
\end{warningbox}
% =============================================================================
% FIN DU CHAPITRE
% =============================================================================
+17
View File
@@ -0,0 +1,17 @@
% =============================================================================
% CHAPITRE : MÉTHODE 7 — API PUBLIQUE
% =============================================================================
\chapter{Méthode 7 : API publique}
\label{ch:method7}
\begin{warningbox}
\textbf{Statut} : {\emojicross\ Inexistante}.
Aucune API publique n'est utilisée/implémentée par ce projet ; aucune API officielle
publique n'a été vérifiée à la date du 2026-09-12.
\end{warningbox}
% =============================================================================
% FIN DU CHAPITRE
% =============================================================================
+38
View File
@@ -0,0 +1,38 @@
% =============================================================================
% CHAPITRE : TABLEAU DE COMPATIBILITÉ
% =============================================================================
\chapter{Tableau de compatibilité avec \texttt{pronote-sync}}
\label{ch:overview}
\begin{table}[htbp]
\centering
\caption{Méthodes d'authentification et leur statut dans \texttt{pronote-sync}}
\label{tab:compatibilite}
\begin{tabularx}{\textwidth}{L{2.5cm} L{3cm} L{3cm} L{2.5cm} L{4cm}}
\toprule
\textbf{Méthode} & \textbf{Statut dans \texttt{pronote-sync}} & \textbf{Implémentation} & \textbf{Source} & \textbf{Notes} \\
\midrule
\textbf{URL iCal sécurisée} (\texttt{icalsecurise}) & \emojicheck\ Pris en charge & Source \texttt{ical} (prioritaire en mode \texttt{auto}) & \emojiinfo\ \texttt{pronote\_sync/sources/ical.py} & Jeton dans l'URL traité comme secret. \\
\textbf{Connexion directe} (mot de passe + ENT) & \emojicheck\ Pris en charge & Source \texttt{pronotepy} (repli si iCal échoue) & \emojiinfo\ \texttt{pronote\_sync/sources/pronote/client.py} & Liste fermée \texttt{\_ENT\_NAMES} (lignes 5284). \\
\textbf{QR Code + token mobile} & \emojicheck\ Pris en charge & Mode \texttt{PRONOTE\_AUTH\_MODE=qr\_token} & \emojiinfo\ \texttt{pronotepy.Client.qrcode\_login} + \texttt{token\_login} & Voir \hyperref[sec:procedure-qr]{Procédure QR} (Section \ref{sec:procedure-qr}). \\
\textbf{SSO ENT (CAS/SAML)} & \emojicross\ Non implémenté &&& Seuls les 30 ENT de la liste fermée \texttt{\_ENT\_NAMES} (pronotepy 2.15.7) sont supportés via \texttt{ent} dans \texttt{ParentClient}. \textbf{Pas de SSO générique CAS/SAML.} \\
\textbf{EduConnect (HubEduConnect)} & \emojicross\ Non implémenté &&& Nécessite une intégration CAS/SAML générique, \textbf{non supportée par ce projet}. \\
\textbf{CAS direct} & \emojicross\ Non implémenté &&& Non supporté. \\
\textbf{API publique} & \emojicross\ Inexistante &&& Aucune API publique n'est utilisée/implémentée par ce projet ; aucune API officielle publique n'a été vérifiée à la date du 2026-09-12. \\
\bottomrule
\end{tabularx}
\end{table}
% =============================================================================
% FIN DU CHAPITRE
% =============================================================================
+9 -11
View File
@@ -66,6 +66,11 @@ processus. Le mode `PRONOTE_AUTH_MODE=qr_token` est incompatible avec cette gara
le refuse avant toute connexion afin de ne pas désynchroniser le token local du token distant.
Le dry-run ne remplace pas une vérification des paramètres réellement chargés.
En mode `PRONOTE_AUTH_MODE=qr_token`, `PRONOTE_QR_PIN` déchiffre le QR code
exporté depuis le site web Pronote. Si le compte exige un second facteur,
configurez aussi `PRONOTE_ACCOUNT_PIN` avec le PIN du compte. Ce PIN est
transmis uniquement à `pronotepy` lors de l'enrôlement QR et des connexions par
token ; il n'est jamais écrit dans `.pronote_auth_state.json` ni dans les logs.
En mode `PRONOTE_AUTH_MODE=qr_token`, le fichier
`.pronote_auth_state.json` et son verrou frère sont créés dans le répertoire
de travail du service (par exemple `/var/lib/pronote-sync`) avec le mode
@@ -105,17 +110,10 @@ sudo systemctl start pronote-sync.service
sudo systemctl status pronote-sync.service
```
La CLI expose un contrat de sortie stable : `0` signifie une exécution complète,
`2` une exécution dégradée (les données Pronote sont disponibles mais une étape
optionnelle, CalDAV ou XMPP a échoué), et `1` un échec critique. Tout code non
nul laisse l'unité `pronote-sync.service` en état `failed` ; la supervision doit
donc alerter sur cet état ou sur le code de sortie. Le code `2` permet de
distinguer automatiquement une alerte dégradée d'une panne critique, sans lire
les journaux.
Le `--dry-run` n'écrit ni dans CalDAV/XMPP ni dans l'état local. Il conserve le
même contrat de codes : `0` si la simulation est complète, `2` si elle est
dégradée et `1` si elle est critique.
Une exécution en échec laisse l'unité `pronote-sync.service` en état `failed`.
La supervision de l'hôte doit donc déclencher une alerte sur cet état ou sur un
échec du timer/service ; le transport de cette alerte (courriel, XMPP ou système
de supervision) relève de l'exploitation locale.
## Journaux et alertes
+100
View File
@@ -0,0 +1,100 @@
% =============================================================================
% PAGE DE TITRE — Authentification Pronote
% =============================================================================
\begin{titlepage}
\newgeometry{margin=2cm}
\begin{center}
\vspace*{2cm}
{\Huge\bfseries\color{primaryNavy} Authentification Pronote\\}
{\huge\bfseries\color{secondarySlate} Référence technique pour \texttt{pronote-sync}}
\vspace{1.5cm}
{\Large\color{secondarySlate} Version 1.0 — 12 septembre 2026}
\vspace{2cm}
{\large\color{gray} Équipe Architecture et Sécurité}
\vspace{1cm}
{\small\color{gray}
Ce document n'est pas un guide officiel. Il n'est ni approuvé, ni validé, ni autorisé
par le Ministère de l'Éducation Nationale française ni par Docaposte (éditeur de
Pronote / Index Éducation).
}
\vspace{2cm}
\begin{warningbox}
\centering
{\bfseries\large\color{warningRed} AVERTISSEMENT}\\
\vspace{0.5cm}
{\small
Ce document repose sur des observations directes du code source de
\texttt{pronotepy} (version 2.15.7, vérifiée localement le 2026-09-12), des issues
publiques du dépôt \texttt{bain3/pronotepy}, et des tests d'intégration du projet
\texttt{pronote-sync}.\\
{\bfseries Un essai réel sur l'instance Pronote cible est nécessaire avant tout usage
en production. Les tests/mocks ne prouvent pas la compatibilité d'instance.}
}
\end{warningbox}
\vfill
{\small\color{gray} Compilé avec LuaLaTeX}
\end{center}
\restoregeometry
\end{titlepage}
% =============================================================================
% TABLE DES MATIÈRES
% =============================================================================
\newpage
\tableofcontents
\thispagestyle{fancy}
% =============================================================================
% LISTE DES TABLEAUX (optionnel)
% =============================================================================
\newpage
\listoftables
\thispagestyle{fancy}
% =============================================================================
% AVERTISSEMENT INITIAL
% =============================================================================
\newpage
\section*{Avertissement initial}
\addcontentsline{toc}{section}{Avertissement initial}
\begin{infobox}
\textbf{Convention de qualification} :
\begin{itemize}
\item {\emojiinfo\ \textbf{Observé dans le code local}} : Mécanisme vérifié dans le code
source de \texttt{pronotepy} 2.15.7 ou \texttt{pronote-sync}.
\item {\emojiinfo\ \textbf{Observé localement}} : Comportement constaté dans les interfaces
Pronote (version non spécifiée, hypothèse à valider).
\item {\warningicon\ \textbf{Hypothèse à valider}} : Affirmation non vérifiée, nécessitant
une confirmation par test sur une instance réelle.
\end{itemize}
\end{infobox}
\vspace{1cm}
\begin{warningbox}
\textbf{Note importante (2026-09-12)} :
Un essai réel sur l'instance Pronote cible est nécessaire avant tout usage en production.
Les tests/mocks ne prouvent pas la compatibilité d'instance.
\end{warningbox}
\newpage
+28
View File
@@ -0,0 +1,28 @@
% =============================================================================
% GLOSSAIRE (chapitre non numéroté)
% =============================================================================
\chapter*{Glossaire}
\addcontentsline{toc}{chapter}{Glossaire}
\begin{tabularx}{\textwidth}{L{4cm} L{12cm}}
\toprule
\textbf{Terme} & \textbf{Définition} \\
\midrule
\textbf{ENT} & Espace Numérique de Travail (ex. Mon Bureau Numérique, Paris Classe Numérique). \\
\textbf{Jeton \texttt{icalsecurise}} & Token secret intégré dans l'URL iCal, équivalent à un mot de passe. \\
\textbf{\texttt{jetonConnexionAppliMobile}} & Token obtenu après appairage QR code, dont la durée dépend de l'instance/du serveur et n'est pas garantie. \\
\textbf{UUID} & Identifiant unique permanent pour l'application (ex. \texttt{pronote-sync-\{uuid4()\}}). \\
\textbf{PIN} & Code à 4 chiffres défini lors de la génération du QR code. \\
\bottomrule
\end{tabularx}
% =============================================================================
% FIN DU GLOSSAIRE
% =============================================================================
+24
View File
@@ -0,0 +1,24 @@
% =============================================================================
% HISTORIQUE DES RÉVISIONS (chapitre non numéroté)
% =============================================================================
\chapter*{Historique des révisions}
\addcontentsline{toc}{chapter}{Historique des révisions}
\begin{tabularx}{\textwidth}{L{2.4cm} L{2.6cm} L{11cm}}
\toprule
\textbf{Date} & \textbf{Auteur} & \textbf{Modifications} \\
\midrule
2026-09-12 & Agent tech-writer & Refonte complète : qualification des affirmations, ajout des sources, tableau de compatibilité, procédure QR alignée sur pronotepy 2.15.7. \\
2026-09-12 & Agent tech-writer & Corrections suite à revue indépendante (ticket \#10) : précision cryptographie (AES-128), qualification des sources, alignement nombre d'ENT (30), clarification SSO/CAS, exemples non copiables, note d'essai réel. \\
2026-09-12 & Agent tech-writer & Corrections suite à revue ticket \#10 : suppression de tous les placeholders de secrets remplacés par de la prose descriptive ; clarification du périmètre réel des méthodes d'authentification (accès limité aux opérations implémentées) ; qualification de la durée du token \texttt{jetonConnexionAppliMobile} comme dépendante de l'instance/serveur et non garantie. \\
\bottomrule
\end{tabularx}
% =============================================================================
% FIN DE L'HISTORIQUE
% =============================================================================
+159
View File
@@ -0,0 +1,159 @@
% =============================================================================
% PRÉAMBULE LaTeX — Authentification Pronote
% =============================================================================
% --- Classe de document ---
\documentclass[11pt,a4paper,oneside]{report}
% --- Encodage et langue ---
\usepackage[utf8]{inputenc}
\usepackage[T1]{fontenc}
\usepackage[french]{babel}
\usepackage{lmodern}
% --- Polices (plan documentaire : EB Garamond / Carlito / DejaVu Sans Mono) ---
% EB Garamond (sérif) : police principale du document
\usepackage{ebgaramond}
% Carlito (sans-sérif, compatible métriquement Calibri)
\usepackage[sfdefault]{carlito}
% DejaVu Sans Mono (chasse fixe) : code, JSON, requêtes HTTP
% (fontspec est déjà chargé par EB Garamond sous LuaLaTeX)
\setmonofont{DejaVu Sans Mono}[Scale=0.9]
% --- Couleurs (palette Bleu Institutionnel & Ardoise) ---
\usepackage{xcolor}
\definecolor{primaryNavy}{RGB}{20,45,85}
\definecolor{secondarySlate}{RGB}{70,95,125}
\definecolor{accentSteel}{RGB}{0,115,150}
\definecolor{lightGray}{RGB}{240,240,240}
\definecolor{warningRed}{RGB}{180,50,50}
% --- Mise en page ---
\usepackage[a4paper, margin=2.5cm, top=2cm, bottom=2cm]{geometry}
\usepackage{parskip}
\setlength{\parskip}{1em}
\setlength{\parindent}{0pt}
% --- Hyperliens et PDF ---
\usepackage{hyperref}
\hypersetup{
colorlinks=true,
linkcolor=accentSteel,
urlcolor=accentSteel,
citecolor=secondarySlate,
filecolor=primaryNavy,
menucolor=primaryNavy,
runcolor=primaryNavy,
pdfauthor={Équipe Architecture & Sécurité},
pdftitle={Authentification Pronote — Référence technique},
pdfsubject={Référence technique d'authentification pour pronote-sync},
pdfkeywords={Pronote, authentification, pronote-sync, QR code, token, ENT, iCal},
bookmarksopen=true,
bookmarksnumbered=true
}
% --- Titres et sections ---
\usepackage{titlesec}
\titleformat{\chapter}[display]
{\normalfont\huge\bfseries\color{primaryNavy}}
{\chaptertitlename\ \thechapter}
{20pt}
{\Huge\color{primaryNavy}}
\titleformat{\section}
{\normalfont\Large\bfseries\color{primaryNavy}}
{\thesection}
{1em}
{\color{primaryNavy}}
\titleformat{\subsection}
{\normalfont\large\bfseries\color{secondarySlate}}
{\thesubsection}
{1em}
{\color{secondarySlate}}
\titleformat{\subsubsection}
{\normalfont\bfseries\color{secondarySlate}}
{\thesubsubsection}
{1em}
{\color{secondarySlate}}
% --- Tableaux ---
\usepackage{tabularx}
\usepackage{booktabs}
\usepackage{array}
\newcolumntype{L}[1]{>{\raggedright\let\newline\\\arraybackslash\hspace{0pt}\hspace{1em}}p{#1}}
\newcolumntype{C}[1]{>{\centering\let\newline\\\arraybackslash\hspace{0pt}\hspace{1em}}p{#1}}
\newcolumntype{R}[1]{>{\raggedleft\let\newline\\\arraybackslash\hspace{0pt}\hspace{1em}}p{#1}}
% --- Listes et encadrés ---
\usepackage{enumitem}
\setlist[itemize]{leftmargin=*, topsep=0.5em, itemsep=0.25em}
\setlist[enumerate]{leftmargin=*, topsep=0.5em, itemsep=0.25em}
% --- Callouts (encadrés colorés) ---
\usepackage{mdframed}
\newmdenv[linecolor=warningRed, linewidth=2pt, leftmargin=10pt, rightmargin=10pt, innerleftmargin=10pt, innerrightmargin=10pt, backgroundcolor=red!5!white, roundcorner=5pt, skipabove=10pt, skipbelow=10pt]{warningbox}
\newmdenv[linecolor=secondarySlate, linewidth=2pt, leftmargin=10pt, rightmargin=10pt, innerleftmargin=10pt, innerrightmargin=10pt, backgroundcolor=blue!5!white, roundcorner=5pt, skipabove=10pt, skipbelow=10pt]{infobox}
% --- Code source ---
\usepackage{listings}
\lstdefinestyle{pronote}{
basicstyle=\ttfamily\footnotesize,
breaklines=true,
frame=single,
rulecolor=\color{gray!30},
backgroundcolor=\color{lightGray},
keywordstyle=\color{primaryNavy},
stringstyle=\color{secondarySlate},
commentstyle=\color{gray},
showstringspaces=false,
tabsize=2,
captionpos=b,
xleftmargin=10pt,
xrightmargin=10pt,
aboveskip=10pt,
belowskip=10pt
}
\lstset{style=pronote}
% --- Symboles et icônes ---
\usepackage{amssymb}
\usepackage{pifont}
\newcommand{\checkmarkicon}{\ding{51}} % ✓
\newcommand{\warningicon}{\ding{43}} % ⚠
\newcommand{\infoicon}{\ding{48}} %
\newcommand{\crossicon}{\ding{55}} % ✗
% --- Mathématiques ---
\usepackage{amsmath}
% --- Table des matières ---
\usepackage{tocloft}
\renewcommand{\cftchapfont}{\normalfont\bfseries\color{primaryNavy}}
\renewcommand{\cftsecfont}{\normalfont\color{primaryNavy}}
\renewcommand{\cftsubsecfont}{\normalfont\color{secondarySlate}}
% --- En-têtes et pieds de page ---
\usepackage{fancyhdr}
\pagestyle{fancy}
\fancyhf{}
\fancyhead[LE,RO]{\thepage}
\fancyhead[LO]{\nouppercase{\rightmark}}
\fancyhead[RE]{\nouppercase{\leftmark}}
\renewcommand{\headrulewidth}{0.5pt}
\renewcommand{\footrulewidth}{0pt}
% --- Espacement vertical ---
\usepackage{setspace}
\onehalfspacing
% --- Césure ---
\usepackage{microtype}
% --- Support des emojis (repli sur texte si non disponible) ---
\newcommand{\emojiwarning}{\warningicon}
\newcommand{\emojicheck}{\checkmarkicon}
\newcommand{\emojicross}{\crossicon}
\newcommand{\emojiinfo}{\infoicon}
% =============================================================================
% FIN DU PRÉAMBULE
% =============================================================================
+252 -496
View File
@@ -1,570 +1,326 @@
> ⚠️ **AVERTISSEMENT**
>
> Ce document **n'est pas un guide officiel**. Il n'est ni approuvé, ni validé, ni autorisé par le Ministère de l'Éducation Nationale française ni par Docaposte (éditeur de Pronote / Index Éducation). Les informations présentées reposent sur des recherches publiques, des travaux de rétro-ingénierie menés par la communauté open-source et des analyses techniques. Les protocoles décrits ne sont pas officiellement publiés par Index Éducation et peuvent évoluer sans préavis.
> Ce document **n'est pas un guide officiel**. Il n'est ni approuvé, ni validé, ni autorisé par le Ministère de l'Éducation Nationale française ni par Docaposte (éditeur de Pronote / Index Éducation). Les informations présentées reposent sur :
> - des **observations directes** du code source de `pronotepy` (version **2.15.7**, vérifiée localement le 2026-09-12) ;
> - des **issues publiques** du dépôt [bain3/pronotepy](https://github.com/bain3/pronotepy) (ex. #309, #344) ;
> - des **tests d'intégration** du projet `pronote-sync`.
>
> Ce document a été produit à l'aide de plusieurs agents basés sur des modèles de langage (LLM) :
> - **Mercury 2.5** (agent explorer) — exploration du code pour établir les faits techniques.
> - **Gemini 3.5 Flash** (agent web-explorer) — recherche des méthodes d'authentification externes.
> - **Hy3** (agent planner) — planification de la structure du document et découpage en sections.
> - **Mistral Medium** (agent tech-writer) — rédaction du document.
> - **GPT-5.6 Luna** (agent reviewer) — revue du contenu pour l'exactitude et la cohérence.
> - **GLM-5.2** (agent orchestrator) — coordination et intégration du travail.
> - **Gemini 3.7 Flash** (agent ui-designer) — conception de la version LaTeX/PDF.
# Authentification Pronote — Référence technique
> **⚠️ Note importante (2026-09-12)** : Un essai réel sur l'instance Pronote cible est nécessaire avant tout usage en production. Les tests/mocks ne prouvent pas la compatibilité d'instance.
>
> **Convention de qualification** :
> **Observé dans le code local** : Mécanisme vérifié dans le code source de `pronotepy` 2.15.7 ou `pronote-sync`.
> 🔍 **Observé localement** : Comportement constaté dans les interfaces Pronote (version non spécifiée, hypothèse à valider).
> ⚠️ **Hypothèse à valider** : Affirmation non vérifiée, nécessitant une confirmation par test sur une instance réelle.
---
# Authentification Pronote — Référence technique pour `pronote-sync`
## Introduction
Ce manuel documente lensemble des méthodes dauthentification connues pour accéder aux données élèves/parents de Pronote (notes, emploi du temps, devoirs, absences, etc.). Il couvre à la fois les mécanismes officiellement supportés et les protocoles issus de lanalyse communautaire.
Ce document documente **uniquement les méthodes d'authentification prises en charge ou délégables par `pronote-sync`**, en alignement strict avec :
- Le code source de `pronote_sync/sources/pronote/client.py` (liste fermée `_ENT_NAMES`, gestion des tokens).
- La bibliothèque `pronotepy` **2.15.7** (contrat des méthodes `qrcode_login`, `token_login`, `export_credentials`).
- Les paramètres de configuration `.env.example` (lignes 2125).
Le public visé inclut les développeurs, ingénieurs sécurité et intégrateurs système devant maîtriser lauthentification Pronote au niveau protocolaire. Les descriptions utilisent du pseudocode générique, des échanges HTTP et des schémas protocoles, sans présupposer de langage ou framework spécifique.
**Périmètre** :
- **Pris en charge** : Méthodes implémentées et testées dans `pronote-sync`.
- **Délégable à pronotepy** : Méthodes gérées par `pronotepy` mais non directement exposées par `pronote-sync`.
- **Non implémenté** : Méthodes non supportées (ex. CAS/SAML générique, EduConnect direct).
*Remarque méthodologique* : Les méthodes au-delà de lexport iCal sappuient sur des recherches publiques et lanalyse de la communauté open source. Ces protocoles, non publiés officiellement par Index Éducation, peuvent évoluer sans préavis.
**Public cible** : Développeurs et intégrateurs de `pronote-sync`.
## Vue d'ensemble comparative
---
| Méthode | Périmètre de données | Identifiants requis | Expiration du jeton | Complexité | Statut |
|---|---|---|---|---|---|
| URL iCal sécurisée (`icalsecurise`) | Emploi du temps et, si inclus, cahier de textes/devoirs | Jeton dans l'URL | Longue durée | Très faible | Officiel |
| Connexion directe (identifiant/mot de passe) | Complet | Identifiant + mot de passe établissement | Session ~1530 min | Élevée | Reverse-engineered |
| SSO ENT (CAS / SAML / Oze) | Complet | Identifiants ENT | Dépend de la session ENT | Très élevée | Reverse-engineered |
| SSO EduConnect | Complet | Identifiants nationaux EduConnect | Dépend de la session EduConnect | Très élevée | Reverse-engineered |
| CAS (Central Authentication Service) | Complet | Identifiants CAS | Dépend du ticket de service | Modérée | Officiel |
| QR Code + jeton mobile | Complet | Code PIN à 4 chiffres → `jetonConnexionAppliMobile` + UUID | QR valable 10 min ; jeton longue durée | Modérée | Reverse-engineered |
| API publique | N/A | N/A | N/A | N/A | Inexistante |
## Tableau de compatibilité avec `pronote-sync`
*Complet* désigne laccès aux notes, emploi du temps, devoirs, absences, messagerie et paramètres, tandis que la méthode iCal se limite à lemploi du temps et éventuellement aux devoirs.
| Méthode | Statut dans `pronote-sync` | Implémentation | Source | Notes |
|---------|----------------------------|----------------|--------|-------|
| **URL iCal sécurisée** (`icalsecurise`) | ✅ Pris en charge | Source `ical` (prioritaire en mode `auto`) | 🔍 `pronote_sync/sources/ical.py` | Jeton dans l'URL traité comme secret. |
| **Connexion directe (mot de passe + ENT)** | ✅ Pris en charge | Source `pronotepy` (repli si iCal échoue) | 🔍 `pronote_sync/sources/pronote/client.py` | Liste fermée `_ENT_NAMES` (lignes 5284). |
| **QR Code + token mobile** | ✅ Pris en charge | Mode `PRONOTE_AUTH_MODE=qr_token` | 🔍 `pronotepy.Client.qrcode_login` + `token_login` | Voir [Procédure QR](#procédure-qr-pour-pronote-sync). |
| **SSO ENT (CAS/SAML)** | ❌ Non implémenté | — | — | Seuls les 30 ENT de la liste fermée `_ENT_NAMES` (pronotepy 2.15.7) sont supportés via `ent` dans `ParentClient`. **Pas de SSO générique CAS/SAML.** |
| **EduConnect (HubEduConnect)** | ❌ Non implémenté | — | — | Nécessite une intégration CAS/SAML générique, **non supportée par ce projet**. |
| **CAS direct** | ❌ Non implémenté | — | — | Non supporté. |
| **API publique** | ❌ Inexistante | — | — | Aucune API publique n'est utilisée/implémentée par ce projet ; aucune API officielle publique n'a été vérifiée à la date du 2026-09-12.
## Méthode 1 : URL iCal sécurisée (icalsecurise)
---
## Méthode 1 : URL iCal sécurisée (`icalsecurise`)
### Principe général
Pronote expose un flux de calendrier en lecture seule conforme à la norme iCalendar (RFC 5545). Laccès est contrôlé par un jeton secret intégré dans lURL sous forme de paramètre de requête `icalsecurise`. Ce jeton est unique par utilisateur et par établissement. **Le jeton constitue la seule crédentiale** : il doit être traité comme un mot de passe. Toute requête HTTP GET vers cette URL permet de récupérer les données du calendrier, sans nécessiter de cookies, de session ni den-têtes dauthentification.
Pronote expose un flux de calendrier en lecture seule conforme à la norme **iCalendar (RFC 5545)**. L'accès est contrôlé par un **jeton secret** intégré dans l'URL sous forme de paramètre de requête `icalsecurise`.
**Statut** : **Observé localement** (fonctionnalité native de Pronote, version non spécifiée).
### Obtention du jeton
Le jeton sobtient manuellement depuis linterface web de Pronote :
1. Se connecter à Pronote (Espace Parents ou Espace Élève) via nimporte quelle méthode dauthentification.
2. Accéder à la vue « Emploi du temps ».
3. Utiliser la fonction « Export iCal » ou « Exporter ».
4. Pronote génère une URL contenant le paramètre `icalsecurise`.
5. Copier cette URL : elle constitue la crédentiale.
Cette URL doit être stockée de manière sécurisée (ex. : gestionnaire de secrets, variables protégées). **Elle ne doit jamais être versionnée ou partagée en clair.**
1. Se connecter à Pronote (Espace Parents ou Élève) via n'importe quelle méthode.
2. Accéder à la vue *« Emploi du temps »*.
3. Utiliser la fonction *« Export iCal »* ou *« Exporter »*.
4. Pronote génère une URL contenant un jeton secret `icalsecurise`.
5. **Copier cette URL** : elle constitue une crédentiale unique sensible qui doit être protégée comme un mot de passe.
🔹 **Source** : 🔍 Observé localement dans les interfaces Pronote (version non spécifiée, hypothèse à valider).
⚠️ **Note** : Les étapes dépendent de l'instance et du type de compte. L'exemple ci-dessus est non fonctionnel et ne contient aucun secret.
### Format de l'URL
LURL suit la structure suivante :
```
https://{etablissement}.index-education.net/pronote/ical/Edt_{prenom}.ics?icalsecurise={jeton}&version={version}&param={param}
```
Exemple masqué :
`https://XXXXXXX.index-education.net/pronote/ical/Edt_Alice.ics?icalsecurise=••••••••&version=2023&param=...`
- `icalsecurise` : **Jeton secret** (crédentiale).
- `version` : Version de Pronote (ex. `2024`).
- `param` : Paramètres optionnels.
Paramètres de requête :
- `icalsecurise` : jeton secret (crédentiale).
- `version` : version de Pronote.
- `param` : paramètres supplémentaires (optionnels).
### Flux d'authentification (protocole)
Le protocole dauthentification se résume ainsi :
1. **Préparation** :
Le client dispose de lURL iCal sécurisée (obtenue comme décrit ci-dessus).
2. **Requête HTTP** :
Le client effectue une requête HTTP GET :
```
GET {ical-url}
Accept: text/calendar, */*;q=0.5
User-Agent: {identifiant-client}
```
- Délai dattente : configurable (recommandé : 20 secondes).
3. **Validation de la réponse** :
- Si le code HTTP nest pas 2xx → échec dauthentification ou erreur serveur.
- Si le corps de la réponse ne contient pas `BEGIN:VCALENDAR` → le jeton est probablement expiré ou lURL est invalide. Le serveur peut retourner une page HTML derreur au lieu des données de calendrier.
4. **Traitement** :
Si la validation réussit, le corps de la réponse est une donnée iCalendar valide, prête à être analysée.
### Données échangées
- Le client envoie une seule requête HTTP GET vers l'URL iCal sécurisée.
- En-têtes de requête : `Accept: text/calendar, */*;q=0.5` et `User-Agent: <identifiant-client>`.
- Le serveur retourne une charge utile iCalendar (RFC 5545) si le jeton est valide.
- Si le jeton est invalide ou expiré, le serveur retourne une réponse non-calendrier (typiquement du HTML).
- Aucun cookie, jeton de session ou en-tête d'authentification n'est échangé — l'URL **est** la crédentiale.
### Identifiants et jetons
Le seul identifiant utilisé est le jeton `icalsecurise`, intégré directement dans lURL. Ce jeton est :
- **Unique** par utilisateur et par établissement.
- **Sensible** : il équivaut à un mot de passe et doit être traité comme tel.
- **Transmis en clair** dans la chaîne de requête de lURL, mais protégé en transit par HTTPS.
- **Autosuffisant** : aucune autre information (nom dutilisateur, mot de passe, cookie de session ou jeton OAuth) nest requise. LURL **est** lauthentification.
🔹 **Source** : 🔍 Analysé via `pronote_sync/sources/ical.py`.
### Cycle de vie
- **Pérennité** : Le jeton reste valide jusqu'à :
- Révocation manuelle par l'utilisateur dans Pronote.
- Régénération par l'établissement (ex. à la rentrée scolaire).
⚠️ **Hypothèse à valider** : La durée exacte dépend des politiques de l'établissement (non documentée officiellement).
Le jeton est **pérenne** : il reste valide jusqu’à sa révocation manuelle par lutilisateur dans les paramètres Pronote, ou jusqu’à sa régénération par l’établissement (généralement à la rentrée scolaire).
Aucun mécanisme de rafraîchissement automatique ou de rotation nexiste. En cas dexpiration ou de rotation, le serveur retourne une réponse non-iCalendar (souvent une page HTML mentionnant *« Session expirée »*). Le client détecte cette situation en vérifiant labsence de `BEGIN:VCALENDAR` dans le corps de la réponse.
La récupération nécessite une **réextraction manuelle** dune nouvelle URL iCal depuis linterface Pronote.
**Bonnes pratiques** : tester lURL avant chaque rentrée et la régénérer proactivement si l’établissement est connu pour rotater les jetons à cette période.
🔹 **Source** : ⚠️ Comportement variable selon les instances (à tester localement).
### Sécurité
1. **Surface d'attaque** : Le jeton est encodé dans l'URL → risque d'exposition via :
- Logs serveur/proxy.
- En-tête `Referer`.
- Historique du navigateur.
2. **Recommandations** :
- **Ne jamais versionner** l'URL (ex. dans `.env` ou Git).
- Utiliser **HTTPS** (obligatoire).
- Masquer l'URL dans les logs (ex. via `redact_url()` dans `pronote-sync`).
1. **Surface dattaque** : Le jeton est encodé dans lURL. Il peut être exposé via les logs daccès du serveur, les logs proxy, les en-têtes `Referer`, lhistorique du navigateur ou une interception réseau (atténué par HTTPS).
2. **Exposition des identifiants** : En cas de fuite, le jeton accorde un accès en lecture à lemploi du temps (et aux devoirs, si inclus) jusqu’à sa rotation par l’établissement.
3. **Résistance au rejeu** : Faible — absence de *nonce*, de validation temporelle ou de protection contre le *replay*. Toute entité disposant de lURL peut récupérer les données à tout moment.
4. **Rotation** : Manuellement uniquement, via la régénération de lURL dans Pronote. L’établissement contrôle les réinitialisations côté serveur.
5. **Recommandations** : Conserver lURL comme un secret (jamais en contrôle de version). Utiliser HTTPS (par défaut). Limiter laccès à lURL en besoin den connaître. Rotater proactivement aux changements dannée scolaire. Masquer lURL dans les messages derreur et les logs pour éviter les fuites accidentelles.
🔹 **Source** : ⚠️ Recommandation du projet (inspirée des bonnes pratiques générales de sécurité).
### Limitations
### Intégration dans `pronote-sync`
- **Paramètre** : `PRONOTE_ICAL_URL` (ex. `.env.example` ligne 2).
- **Comportement** :
- Prioritaire en mode `PRONOTE_AGENDA_SOURCE=auto`.
- Si l'URL est invalide ou expire, repli automatique vers `pronotepy` (si `PRONOTE_AGENDA_SOURCE=auto`).
- **Aucun repli** si `PRONOTE_AGENDA_SOURCE=ical` (échec explicite).
- **Périmètre des données** : limité à lemploi du temps et, si inclus par l’établissement, aux devoirs (*cahier de textes*).
- **Accès en lecture seule** : aucune modification possible.
- **Exclusions** : notes, absences, messagerie, bulletins ou paramètres sont inaccessibles.
- **Latence** : lexport iCal peut présenter un délai de mise à jour (plusieurs heures) avant de refléter les modifications Pronote.
- **Rafraîchissement** : impossible par programmation — une intervention manuelle est toujours requise.
🔹 **Source** : 🔍 `pronote_sync/config/settings.py` + `pronote_sync/sources/ical.py`.
### Statut
---
**Officiel** — Lexport iCal est une fonctionnalité supportée par Pronote, éditée par Index Éducation. Le mécanisme de jeton fait partie intégrante du produit, bien que son format interne et sa logique de génération ne soient pas documentés publiquement.
## Méthode 2 : Connexion directe (identifiant / mot de passe)
## Méthode 2 : Connexion directe (identifiant / mot de passe + ENT)
### Principe général
La connexion directe utilise un protocole propriétaire de type JSON sur HTTP(S), sécurisé par un chiffrement AES-256-CBC spécifique à la session et un mécanisme de défi-réponse. Il sagit du protocole natif de Pronote, tel quutilisé par son interface web.
Connexion via le protocole propriétaire de Pronote (JSON sur HTTPS), avec **chiffrement AES-CBC utilisant une clé dérivée par MD5 (16 octets, soit AES-128) comme implémenté dans pronotepy 2.15.7** et mécanisme de défi-réponse. Utilisé par l'interface web.
### Flux détaillé
**Statut** : **Observé dans le code local** (délégable à pronotepy via `ParentClient` ou `Client`).
**Étape 1 — Initialisation de la session (`GET /pronote/<espace>.html`)**
Le client effectue une requête GET vers lespace cible (ex. `/pronote/eleve.html` pour un élève, `/pronote/parent.html` pour un parent). La réponse HTML contient un gestionnaire JavaScript `onload` avec les paramètres de session :
### Flux d'authentification
1. **Initialisation** : Requête GET vers `/pronote/{espace}.html` (ex. `parent.html`).
- Récupère `h` (ID de session), `a` (ID espace), `sCrA`/`sCoA` (drapeaux chiffrement/compression).
2. **Échange de clés** : Requête POST vers `/pronote/appelfonction/{a}/{h}/{numeroOrdre}`.
3. **Identification** : Soumission de `identifiant`, `genreConnexion=0`, `genreEspace={a}`.
4. **Résolution du défi** :
- Calcul de `mtp = MAJUSCULE(HEX(SHA256(alea + mot_de_passe)))`.
- Dérivation de `key_challenge = MD5(nom_utilisateur + mtp)`.
- Déchiffrement du `challenge` avec **AES-CBC utilisant une clé dérivée par MD5 (16 octets, soit AES-128)**.
5. **Authentification** : Soumission de la réponse au défi.
🔹 **Source** : 🔍 Observé dans le code local de `pronotepy` 2.15.7 (module `clients.py` et `pronoteAPI.py`, vérifié le 2026-09-12).
### Intégration dans `pronote-sync`
- **Paramètres** :
- `PRONOTE_URL` (ex. `.env.example` ligne 3).
- `PRONOTE_USERNAME`, `PRONOTE_PASSWORD`.
- `PRONOTE_ENT` (slug dans `_ENT_NAMES`).
- `PRONOTE_ACCOUNT_TYPE` (ex. `parent`).
- **Comportement** :
- Utilise `pronotepy.ParentClient` pour les comptes parents.
- **Repli** : Si iCal échoue en mode `auto`, `pronotepy` est utilisé.
- **Pas de repli** si `PRONOTE_AGENDA_SOURCE=pronotepy` (échec explicite).
🔹 **Source** : 🔍 `pronote_sync/sources/pronote/client.py` (méthode `_connect_password`).
### ENT supportés
Liste **fermée** des **30 ENT** résolubles (lignes 5283 de `pronote_sync/sources/pronote/client.py`) :
```python
_monbureaunumerique, ent_elyco, bordeaux, ent_creuse, occitanie_montpellier, ...
```
Session initialization parameters:
h = <session_id>
a = <espace_id> (3 = Élève, 7 = Parent)
sCrA = <encryption_flag>
sCoA = <compression_flag>
```
- `h` : identifiant de session (chaîne ou nombre unique).
- `a` : identifiant de lespace (`3` pour Élève, `7` pour Parent).
- `sCrA` / `sCoA` : indicateurs de chiffrement et de compression.
**Étape 2 — Échange de clés (`POST /pronote/appelfonction/<espace_id>/<session_id>/<numeroOrdre>`)**
Le client envoie une charge utile `FonctionParametres` contenant `donneesSec.donnees.Uuid` :
- En HTTPS : un IV AES de 16 octets encodé en base64.
- En HTTP non sécurisé : un IV chiffré avec RSA-1024.
- `numeroOrdre` est un compteur incrémental (début à 1).
- Pour la première requête, `numeroOrdre` est chiffré avec AES-256-CBC, une clé vide MD5 (`d41d8cd98f00b204e9800998ecf8427e`) et un IV nul.
- Pour les requêtes suivantes, lIV de session (issu de `Uuid`) est utilisé.
⚠️ **Hypothèse à valider** : Les ENT non listés nécessitent une contribution à `pronotepy`.
**Étape 3 — Identification (`POST ... / Identification`)**
Le client soumet une charge utile JSON :
```
{
"nom": "Identification",
"session": "<session_id>",
"numeroOrdre": "<compteur_chiffré>",
"donneesSec": {
"donnees": {
"identifiant": "<nom_utilisateur>",
"genreConnexion": 0,
"genreEspace": <espace_id>,
"pourENT": false,
"enConnexionAuto": false
}
}
}
```
Le serveur retourne : `alea` (sel aléatoire), `challenge` (chaîne hexadécimale), `modeCompLog` et `modeCompMdp` (indicateurs de normalisation de casse).
🔹 **Source** : 🔍 Code source local de `pronote-sync` (2026-09-12).
**Étape 4 — Résolution du défi**
Le client calcule le hachage du mot de passe :
```
mtp = MAJUSCULE(HEX(SHA256(alea + mot_de_passe_utilisateur)))
```
La clé de déchiffrement du défi est dérivée :
```
key_challenge = MD5(nom_utilisateur + mtp)
```
Le client déchiffre `challenge` avec AES-256-CBC, `key_challenge` et lIV de session. Il supprime ensuite un caractère sur deux dans le texte en clair (ex. `abcdef` → `ace`), puis rechiffre la chaîne modifiée avec les mêmes paramètres AES et lencode en hexadécimal.
---
**Étape 5 — Authentification (`POST ... / Authentification`)**
Le client envoie la réponse au défi via la fonction `Authentification`. Le serveur retourne les métadonnées utilisateur et une chaîne `cle` (entiers séparés par des virgules). Le client déchiffre `cle`, analyse les octets et calcule `MD5(octets)` pour obtenir la clé principale de chiffrement de session pour toutes les appels API ultérieurs.
### Données échangées
Identifiant de session, identifiant despace, IV AES (base64 ou chiffré RSA), compteur `numeroOrdre`, sel `alea`, chaîne de défi `challenge`, clé de session (`cle`). Toutes les données sensibles sont chiffrées en transit (AES-256-CBC).
### Identifiants et jetons
- **Identifiants** : nom dutilisateur Pronote et mot de passe attribués par l’établissement.
- **Jetons de session** : identifiant de session (`h`), compteur de séquence (`numeroOrdre`), clé symétrique AES-256 (dérivée de `cle`).
### Cycle de vie
- Les identifiants de session expirent après une courte période dinactivité (généralement 15 à 30 minutes).
- La clé de session doit être utilisée pour tous les appels API ultérieurs dans la même session.
- Une réauthentification est nécessaire après expiration de la session.
### Sécurité
1. **Surface dattaque** : protocole propriétaire avec cryptographie personnalisée. Le point de terminaison de connexion est accessible depuis Internet. Les attaques MITM sont atténuées par HTTPS (mais RSA-1024 est utilisé pour l’échange dIV en HTTP non sécurisé, ce qui est faible selon les normes modernes).
2. **Exposition des identifiants** : le nom dutilisateur est transmis dans la requête didentification (chiffré). Le mot de passe nest jamais transmis en clair : seule la réponse au défi est envoyée.
3. **Résistance au rejeu** : modérée. Le mécanisme de défi-réponse utilise un sel aléatoire (`alea`) par session, rendant difficile le rejou dune réponse de défi capturée. Cependant, le compteur `numeroOrdre` doit être géré avec soin pour éviter toute manipulation de séquence.
4. **Rotation** : aucune rotation automatique des jetons. La rotation des mots de passe dépend de la politique de l’établissement. Les clés de session expirent avec la session.
5. **Recommandations** : utiliser systématiquement HTTPS. Implémenter une gestion rigoureuse de `numeroOrdre`. Stocker les identifiants de manière sécurisée. Noter que la cryptographie personnalisée nest pas équivalente à une authentification TLS standard : sappuyer sur HTTPS pour la sécurité du transport.
### Limitations
- La plupart des établissements secondaires français liés à un ENT ou à EduConnect bloquent les connexions directes par nom dutilisateur/mot de passe et imposent le SSO.
- Le protocole propriétaire nest pas officiellement documenté et peut changer sans préavis.
- La cryptographie personnalisée (AES-256-CBC avec des clés dérivées de MD5) est non standard et na pas fait lobjet dun audit indépendant.
### Statut
**Rétro-conçu** — Index Éducation ne publie pas le protocole. Toutes les connaissances proviennent de lanalyse communautaire du client web Pronote en JavaScript.
## Méthode 3 : ENT (Espace Numérique de Travail)
## Méthode 3 : QR Code + token mobile (pour `pronote-sync`)
### Principe général
La plupart des collèges et lycées français accèdent à Pronote via un ENT (Espace Numérique de Travail) régional ou départemental. LENT agit comme fournisseur didentité (IdP) : lutilisateur sauthentifie auprès de lENT, qui établit ensuite une session avec Pronote par SSO (Single Sign-On). Pronote reçoit des identifiants délégués sans gérer directement la connexion.
Mécanisme d'appairage par QR code pour les appareils mobiles, **contournant l'authentification ENT/EduConnect**. Le QR code est généré **depuis l'interface web Pronote, espace parent → paramètres → QR code**, puis utilisé avec un **PIN à 4 chiffres** pour obtenir un token dont la durée dépend de l'instance/serveur.
### Flux détaillé
1. **Connexion ENT** : Lutilisateur soumet ses identifiants à lendpoint de connexion spécifique à lENT. Chaque ENT utilise son propre mécanisme (formulaire, CAS, SAML, Keycloak, etc.).
**Statut** : **Pris en charge** par `pronote-sync` (mode `PRONOTE_AUTH_MODE=qr_token`).
2. **Redirection SSO vers Pronote** : LENT redirige la session authentifiée vers Pronote via un lien connecteur ou une URL proxy (ex. `/cas/proxySSO/...` ou un lien SAML direct). LENT valide la session et redirige vers linstance Pronote de l’établissement avec des cookies ou assertions SAML valides.
### Procédure QR pour `pronote-sync`
3. **Handshake Pronote** : La réponse HTML initiale de Pronote contient des identifiants temporaires dans lattribut `onload` du `<body>` :
```
Session initialization parameters:
e = <temp_login>
f = <temp_auth_token>
...
```
- `e` : chaîne de connexion temporaire (unique par session SSO).
- `f` : jeton dauthentification temporaire.
#### Étape 1 : Génération du QR code (interface web)
1. Se connecter à Pronote via un navigateur (espace **Parent**).
2. Aller dans **Paramètres → Accès mobile / Application mobile**.
3. Définir un **PIN temporaire à 4 chiffres**.
4. Pronote affiche un **QR code** contenant un JSON avec les clés :
`login`, `jeton`, et `url` (ex. `https://[host]/pronote/mobile.parent.html`).
**Le fichier JSON réel contient des identifiants chiffrés et ne doit jamais être copié, partagé, ou committé.**
5. **Exporter le QR code** :
- Sauvegardez le fichier JSON localement ou scannez-le avec un appareil.
- **Ne jamais partager** le JSON ou le PIN.
- **Le fichier JSON du QR code contient des identifiants chiffrés : ne jamais le coller dans la documentation ni le committer.**
4. **Challenge de session** : Le client exécute la requête standard `Identification` de Pronote (comme en Méthode 2) avec `pourENT: true`. Le challenge est résolu via une dérivation simplifiée :
```
key_ENT = MD5(UPPERCASE(HEX(SHA256(f))))
```
Cela remplace la dérivation `MD5(username + mtp)` utilisée en connexion directe. Aucun mot de passe utilisateur nest nécessaire : le jeton `f` émis par lENT sert de justificatif.
🔹 **Source** : 🔍 Observé localement dans l'interface web Pronote (version non spécifiée, hypothèse à valider).
#### Étape 2 : Configuration de `pronote-sync`
1. **Enregistrer le QR code** :
- Sauvegarder le JSON dans un fichier (ex. `/path/to/qr_code.json`).
- **Permissions** : `chmod 600 /path/to/qr_code.json`.
2. **Configurer `.env`** :
```ini
PRONOTE_AUTH_MODE=qr_token
PRONOTE_QR_CODE_FILE=/path/to/qr_code.json
PRONOTE_QR_PIN= # renseigner localement la valeur secrète du PIN (jamais committée)
```
**⚠️ Note** : `.env.example` ne doit **jamais** contenir de PIN concret.
### Architectures ENT prises en charge
🔹 **Source** : 🔍 Observé dans `.env.example` (lignes 2125) et `pronote_sync/sources/pronote/client.py`.
| Architecture | Exemples | Mécanisme SSO |
|---|---|---|
| Open ENT NG / Open Digital Education | ent.iledefrance.fr, Paris Classe Numérique, Mon Collège Val dOise, L’Éduc de Normandie | SAML / redirection |
| Kosmos / Skolengo CAS | Mon Bureau Numérique, Mon-ENT-Occitanie, Cybercollèges42 | CAS |
| Oze ENT | (divers) | Keycloak avec endpoints proxy `/v1/ozapps` |
| WAYF / Shibboleth | e-lyco (Pays de la Loire) | SAML / Shibboleth |
| Portails personnalisés | Atrium Sud, LaClasse Lyon | Formulaires simples |
#### Étape 3 : Premier login (enrôlement)
1. `pronote-sync` lit `PRONOTE_QR_CODE_FILE` et `PRONOTE_QR_PIN`.
2. Appel à `pronotepy.ParentClient.qrcode_login(qr_code, pin, uuid)` :
- `qr_code` : JSON du fichier QR.
- `pin` : PIN à 4 chiffres.
- `uuid` : UUID permanent généré par `pronote-sync` (ex. `pronote-sync-{uuid4()}`).
3. **Déchiffrement** :
- Algorithme : **AES-CBC utilisant une clé dérivée par MD5 (16 octets, soit AES-128)**.
- Clé : `MD5(PIN)` (dérivée du PIN secret).
- IV : 16 octets nuls.
- Résultat : `login` et `jeton` en clair.
4. **Échange de token** : Pronote retourne un `jetonConnexionAppliMobile` (token dont la durée dépend de l'instance/serveur).
5. **Persistance** : `pronote-sync` sauvegarde les credentials via `export_credentials()` dans `.pronote_auth_state.json` (mode `0600`).
🔹 **Source** : 🔍 Observé dans le code local de `pronotepy` 2.15.7 (`clients.py` lignes 181189) + `pronote_sync/sources/pronote/client.py` (méthode `_enroll_qr_code`).
### Données échangées
Cookies/assertions de session ENT → identifiants temporaires Pronote (`e`, `f`) → session Pronote (via challenge-response avec clé dérivée de lENT).
#### Étape 4 : Connexions ultérieures (auto-login)
1. `pronote-sync` charge `.pronote_auth_state.json`.
2. Appel à `pronotepy.ParentClient.token_login(**credentials)` :
- `pronote_url`, `username`, `password` (token), `uuid`.
3. **Rotation du token** : Le token est **remplacé uniquement si le serveur renvoie `jetonConnexionAppliMobile`** (observé dans `pronotepy` 2.15.7, `clients.py:382387`).
4. **Persistance** : Les credentials sont sauvegardés dans `.pronote_auth_state.json` après chaque opération réussie.
🔹 **Source** : 🔍 Observé dans le code local de `pronotepy` 2.15.7 (`clients.py` lignes 245280) + `pronote_sync/sources/pronote/client.py` (méthode `_connect_qr_token`).
### Identifiants et jetons
- **Identifiants principaux** : nom dutilisateur et mot de passe ENT (spécifiques à chaque plateforme ENT).
- **Identifiants délégués** : `e` (connexion temporaire) et `f` (jeton) émis par Pronote après la redirection SSO.
- **Jeton de session** : clé AES-256 standard de Pronote (identique à la Méthode 2, dérivée après résolution du challenge).
### Cycle de vie
- Les sessions ENT sont temporaires : leur durée dépend des politiques de chaque plateforme.
- La session Pronote établie via lENT suit le même cycle quune session en connexion directe (timeout dinactivité ~1530 min).
- Les tâches automatisées récurrentes doivent se réauthentifier régulièrement auprès de lENT.
### Cycle de vie des tokens
- **QR code** : Valide **~10 minutes** après génération (⚠️ **Hypothèse à valider** : cette durée n'est attestée que par un message d'exception dans `pronotepy` et n'est pas une garantie officielle/indépendante de l'instance).
- **`jetonConnexionAppliMobile`** :
- Durée **dépendante de l'instance/serveur** (observation du 2026-09-12, hypothèse : peut persister jusqu'à la fin de l'année scolaire, non garanti).
- **Remplacé uniquement si le serveur renvoie `jetonConnexionAppliMobile`** (observé dans `pronotepy` 2.15.7, `clients.py:382387`).
- **Révocable** manuellement dans Pronote (Paramètres → Accès mobile).
🔹 **Source** : ⚠️ Comportement variable selon les instances (à tester localement).
### Sécurité
1. **Surface dattaque** : Les chaînes de redirection multiples (ENT → Pronote) augmentent la surface dattaque. Chaque redirection est une opportunité dinterception de jetons.
2. **Exposition des identifiants** : Les identifiants utilisateur natteignent jamais Pronote directement. LENT agit comme intermédiaire de confiance. Le jeton `f` est éphémère (valide uniquement pour l’établissement initial de la session).
3. **Résistance au rejeu** : Le mécanisme challenge-response (identique à la Méthode 2) offre une résistance au rejeu pour la session Pronote. Les jetons de redirection SSO sont à usage unique.
4. **Rotation** : Le cycle de vie de la session ENT contrôle la rotation des identifiants. La clé de session Pronote est rotative par session.
5. **Recommandations** : Valider les certificats SSL à chaque étape de redirection. Ne pas journaliser les jetons intermédiaires. Les formulaires de connexion ENT évoluent fréquemment, ce qui peut rompre les clients automatisés.
1. **Fichiers sensibles** :
- `.pronote_auth_state.json` : **Ne jamais versionner** (couvert par `.gitignore`).
- `PRONOTE_QR_CODE_FILE` : **Ne jamais committer** (ex. dans Git).
2. **Secrets** :
- Le PIN et le contenu du QR code sont **masqués** dans les logs (via `redact_secrets()`).
- Les exceptions sont **expurgées** (via `redact_exception()`).
3. **Recommandations** :
- Utiliser un **PIN robuste** (éviter les codes simples comme `0000` ou des séquences évidentes).
- **Révoquer** le token en cas de compromission (via Pronote web).
🔹 **Source** : 🔍 `pronote_sync/utils/redaction.py` + `pronote_sync/sources/pronote/client.py` (méthode `_collect_auth_secrets`).
### Limitations
- Les modifications des formulaires web de connexion ENT, des endpoints SAML ou de lauthentification multifacteur (MFA) rompent souvent les clients automatisés sans interface.
- Chaque ENT possède un flux de connexion différent : aucune automatisation universelle nest possible.
- Les sessions ENT sont temporaires ; les tâches automatisées récurrentes doivent se réauthentifier régulièrement.
- Certains ENT implémentent du MFA ou des CAPTCHA empêchant une automatisation complète.
### Erreurs et repli
- **`PronoteAuthRotationError`** : Levée si :
- Le token persisté est **invalide/expiré**.
- Le fichier QR ou le PIN est **manquant/invalide**.
- **Action requise** :
1. Supprimer `.pronote_auth_state.json`.
2. Générer un **nouveau QR code depuis l'interface web Pronote, espace parent**.
3. Relancer `pronote-sync`.
🔹 **Source** : 🔍 Observé dans `pronote_sync/errors.py` + `pronote_sync/sources/pronote/client.py` (lignes 346364).
### Statut
Implémentation SSO standardisée par Index Éducation et les éditeurs dENT, mais la consommation du protocole par des clients tiers est **reverse-engineered**.
### Incompatibilités
- **Mode `dry-run`** : **Incompatible** avec `PRONOTE_AUTH_MODE=qr_token` (risque de désynchronisation du token local).
- `pronote-sync --dry-run` **refuse** le mode `qr_token` avant toute connexion.
## Méthode 4 : EduConnect
🔹 **Source** : 🔍 `docs/exploitation.md` (ligne 6566).
### Principe général
EduConnect est le service national dauthentification et de gestion des accès opéré par le Ministère de l’Éducation Nationale et de la Jeunesse (MENJ). Il agit comme fournisseur didentité (IdP) pour les élèves et parents en France. Lauthentification vers Pronote seffectue selon deux modes, selon linfrastructure de l’établissement.
---
### Flux détaillé
## Annexe A : Bibliothèques tierces
### Mode A : EduConnect via ENT régional
1. Le client initie une requête vers la page de connexion de lENT régional avec le paramètre `selection=EDU_parent_eleve`.
2. LENT redirige vers lendpoint SAML2 dEduConnect :
```
https://educonnect.education.gouv.fr/idp/profile/SAML2/Unsolicited/SSO
```
3. Le client soumet les identifiants à EduConnect :
- `j_username` : identifiant EduConnect,
- `j_password` : mot de passe EduConnect,
- `_eventId_proceed` : chaîne vide.
4. EduConnect retourne un formulaire `SAMLResponse` signé, posté vers le service de consommation dassertions de lENT (ex. `/Shibboleth.sso/SAML2/POST`).
5. LENT établit des cookies de session et redirige vers Pronote (le flux ENT→Pronote suit alors la Méthode 3).
| Bibliothèque | Langage | Dépôt | Méthodes supportées | Statut dans `pronote-sync` |
|--------------|---------|-------|---------------------|-----------------------------|
| **pronotepy** | Python | [bain3/pronotepy](https://github.com/bain3/pronotepy) | Connexion directe, QR code/token, **30 ENT** (liste fermée) | ✅ **Dépendance principale** (version **2.15.7** vérifiée). |
| **pronote-api** | TypeScript | [Litarvan/pronote-api](https://github.com/Litarvan/pronote-api) | Connexion directe, CAS, ENT | ❌ Non utilisée. |
| **pronote-qrcode-api** | JavaScript | [Androz2091/pronote-qrcode-api](https://github.com/Androz2091/pronote-qrcode-api) | Déchiffrement QR code | ❌ Non utilisée (intégration native via `pronotepy`). |
### Mode B : HubEduConnect direct (SSO Index Éducation Cloud)
Pour les établissements sans ENT régional :
1. Le client accède à la passerelle CAS centralisée dIndex Éducation :
```
https://hubeduconnect.index-education.net/EduConnect/cas/login?service=<URL_INSTANCE_PRONOTE>
```
2. La passerelle initie une requête SAML vers `educonnect.education.gouv.fr`.
3. Lutilisateur sauthentifie sur EduConnect (mêmes identifiants que le Mode A).
4. HubEduConnect reçoit lassertion, la valide via une liste blanche, et redirige vers Pronote avec un ticket de service.
5. Pronote valide le ticket et établit la session (flux côté Pronote identique à la Méthode 2).
🔹 **Source** : 🔍 Observé dans `pyproject.toml` (dépendances) + code source local (2026-09-12).
### Données échangées
- **Mode A** : Identifiants EduConnect → assertion SAML2 → cookies ENT → handshake SSO Pronote.
- **Mode B** : Identifiants EduConnect → assertion SAML2 → ticket CAS → session Pronote.
### Identifiants et jetons
- Identifiants principaux : identifiants nationaux EduConnect (gérés par le MENJ).
- Jetons intermédiaires : assertions SAML2 (Modes A/B), tickets de service CAS (Mode B).
- Jeton final : clé de session AES-256 Pronote (identique à la Méthode 2).
### Cycle de vie
- Les sessions EduConnect sont temporaires (durée définie par le MENJ).
- Le ticket CAS (Mode B) est à usage unique et consommé lors de la validation par Pronote.
- La session Pronote suit un timeout dinactivité standard (~1530 min).
### Sécurité
1. **Surface dattaque** : Chaînes de redirections multiples (EduConnect → ENT/Hub → Pronote). Chaque redirection est un point dinterception. Les assertions SAML sont signées, réduisant les risques de contrefaçon.
2. **Exposition des identifiants** : Les identifiants EduConnect sont soumis directement à lIdP EduConnect et ne transitent jamais vers Pronote ou lENT. Seule lassertion SAML signée est transmise.
3. **Résistance au rejeu** : Les assertions SAML incluent des timestamps et sont à usage unique. Les tickets CAS le sont également.
4. **Rotation** : Gérée par le MENJ. Aucun mécanisme local.
5. **Recommandations** : Valider les signatures des assertions SAML. Ne pas mettre en cache les identifiants EduConnect. Les authentifications 2FA par SMS ou via FranceConnect ne sont pas gérables par des clients automatisés.
### Limitations
- Les authentifications 2FA par SMS ou FranceConnect ne sont pas contournables par des clients programmatiques.
- Le flux repose sur des redirections HTTP multiples, fragiles et sensibles à la gestion des cookies.
- Les identifiants nationaux sont hautement sensibles : leur compromission affecte tous les services éducatifs.
### Statut
**Service officiel (MENJ / Index Éducation)** — EduConnect est un service gouvernemental officiel. Cependant, linteraction programmatique par des clients tiers relève du **reverse engineering**.
## Méthode 5 : CAS (Central Authentication Service)
### Principe général
CAS (Central Authentication Service) est le protocole SSO sous-jacent utilisé dans les réseaux scolaires propriétaires et les ENT régionaux pour autoriser laccès à Pronote. Protocole standardisé (RFC 4520), il est implémenté côté serveur. Pronote délègue lauthentification au serveur CAS, sans gérer directement les identifiants.
### Flux détaillé
1. **Demande de ticket de service** :
Pronote redirige lutilisateur vers le serveur CAS :
```
https://<cas-server>/login?service=https://<pronote-host>/pronote/<espace>.html
```
2. **Authentification CAS** :
Lutilisateur soumet ses identifiants (ou effectue une authentification fédérée via EduConnect) sur le formulaire CAS.
3. **Génération du ticket** :
CAS renvoie une redirection HTTP 302 vers Pronote avec un paramètre `ticket` :
```
https://<pronote-host>/pronote/<espace>.html?ticket=ST-XXXXX-cas
```
Le ticket, préfixé par `ST-` (Service Ticket), est à usage unique.
4. **Validation du ticket de service** :
Le backend Pronote contacte directement lURL de validation CAS pour vérifier le ticket et obtenir les attributs utilisateur :
```
https://<cas-server>/serviceValidate?service=https://<pronote-host>/pronote/<espace>.html&ticket=ST-XXXXX-cas
```
Le serveur CAS retourne les attributs (ex. : code UAI de l’établissement, identifiant élève). Pronote génère la session active et injecte le contexte dinitialisation dans le HTML client (mécanisme `onload` identique aux Méthodes 2 et 3).
### Données échangées
Identifiants CAS → Validation serveur CAS → Ticket de service (`ST-XXXXX-cas`) → Réponse de validation (attributs utilisateur : UAI, identifiant élève) → Initialisation de la session Pronote.
### Identifiants et jetons
- **Identifiants principaux** : Nom dutilisateur et mot de passe CAS (ou identifiants fédérés EduConnect).
- **Jeton** : Ticket de service CAS (`ST-`, à usage unique).
- **Jeton de session** : Clé de session AES-256 Pronote (identique à la Méthode 2).
### Cycle de vie
- Le ticket de service CAS est **à usage unique** : il est consommé lors de la validation et ne peut être réutilisé.
- La session Pronote résultante suit un timeout dinactivité standard (~1530 min).
- La durée de vie de la session CAS est régie par la politique de *Ticket-Granting Ticket* (TGT) du serveur CAS.
### Sécurité
1. **Surface dattaque** : Protocole standardisé et documenté. La surface dattaque concerne principalement le formulaire de connexion CAS et la transmission du ticket (protégée par HTTPS).
2. **Exposition des identifiants** : Les identifiants sont soumis uniquement au serveur CAS — Pronote ne les voit jamais. Seul le ticket de service est transmis à Pronote.
3. **Résistance au rejeu** : Élevée — les tickets de service sont à usage unique et liés à une URL de service spécifique.
4. **Rotation** : La rotation des TGT est gérée par la politique du serveur CAS. Les tickets de service expirent rapidement (généralement en quelques secondes ou minutes).
5. **Recommandations** : Utiliser systématiquement HTTPS. Valider le paramètre `service` pour éviter le vol de tickets via des URL de service malveillantes. Appliquer une gestion rigoureuse du cycle de vie des TGT.
### Limitations
- CAS est un protocole côté serveur : le serveur CAS doit être correctement configuré et accessible.
- Lappel `serviceValidate` seffectue de serveur à serveur (backend Pronote → serveur CAS), nécessitant une connectivité réseau entre eux.
- Toutes les écoles nutilisent pas CAS : certaines privilégient SAML ou des solutions SSO personnalisées.
### Statut
**Officiel** — CAS est un protocole standardisé (RFC 4520) officiellement implémenté côté backend Pronote et serveurs CAS.
## Méthode 6 : QR Code et jeton mobile
### Principe général
Pronote propose un mécanisme dappairage par QR code pour connecter les appareils mobiles sans saisir les identifiants ENT complexes. Lutilisateur génère un QR code depuis linterface web, définit un code PIN temporaire à 4 chiffres, puis le scanne avec lapplication mobile. Le QR code contient des identifiants chiffrés qui, une fois déchiffrés, permettent un échange de jeton à longue durée de vie. Ce mécanisme contourne entièrement lauthentification ENT/EduConnect.
### Flux détaillé
**Phase 1 — Génération (interface web Pronote)**
1. Dans linterface web, lutilisateur accède à *Paramètres → Accès mobile / Application mobile*.
2. Il définit un code PIN temporaire à 4 chiffres.
3. Pronote affiche un QR code contenant un JSON chiffré :
```
{
"login": "<AES_HEX_encrypted_username>",
"jeton": "<AES_HEX_encrypted_token>",
"url": "https://<host>/pronote/mobile.eleve.html"
}
```
**Phase 2 — Déchiffrement**
- Algorithme : **AES-256-CBC**.
- IV : 16 octets nuls (`0x00...`).
- Clé : `MD5(PIN_code_string)` (ex. `MD5("1234")`).
- Résultat : `login` et `jeton` en clair.
**Phase 3 — Échange dappairage initial**
1. Le client génère un UUID permanent (`uuidAppliMobile`).
2. Il envoie une requête à : `https://<host>/pronote/mobile.<espace>.html?login=true` (contourne la redirection ENT).
3. Requête `Identification` avec :
- `demandeConnexionAppliMobile: true`
- `demandeConnexionAppliMobileJeton: true`
- `uuidAppliMobile: "<DEVICE_UUID>"`
- `identifiant: "<decrypted_login>"`
4. Le défi est résolu avec `<decrypted_jeton>` comme mot de passe (mécanisme identique à la Méthode 2).
5. Le serveur retourne `jetonConnexionAppliMobile` (jeton à longue durée de vie).
**Phase 4 — Connexions ultérieures (auto-login)**
- `identifiant: <decrypted_login>`
- `uuidAppliMobile: <DEVICE_UUID>`
- `enConnexionAppliMobile: true`
- Mot de passe pour le défi : `jetonConnexionAppliMobile`
- À chaque connexion réussie, Pronote retourne un nouveau `jetonConnexionAppliMobile` à conserver.
### Données échangées
QR code JSON (login + jeton chiffrés + URL) → déchiffrement → `Identification` avec drapeaux mobiles → défi-réponse → `jetonConnexionAppliMobile`.
### Identifiants et jetons
- **Initial** : Code PIN à 4 chiffres (temporaire, utilisé uniquement pour le déchiffrement du QR code).
- **Déchiffrés** : `login` et `jeton` extraits du QR code (usage unique pour lappairage initial).
- **Persistants** : `uuidAppliMobile` (UUID de lappareil, permanent) + `jetonConnexionAppliMobile` (jeton à longue durée de vie, rafraîchi à chaque connexion).
### Cycle de vie
- Le QR code est valide **10 minutes** après génération.
- Le `jetonConnexionAppliMobile` reste valide indéfiniment (souvent toute lannée scolaire), sauf :
- Révoqué par lutilisateur dans les paramètres Pronote.
- Invalidé côté serveur.
- Le jeton est rafraîchi à chaque connexion réussie.
### Sécurité
1. **Surface dattaque** : Le QR code est affiché à l’écran (risque de *shoulder-surfing*). Le PIN à 4 chiffres offre 10 000 combinaisons. Le `jetonConnexionAppliMobile` est un identifiant à longue durée de vie stocké sur lappareil.
2. **Exposition des identifiants** : Le QR code contient des identifiants chiffrés. Si intercepté avant déchiffrement, lattaquant a besoin du PIN. Une fois le `jetonConnexionAppliMobile` obtenu, aucun PIN ni mot de passe nest requis.
3. **Résistance au rejeu** : Le `jetonConnexionAppliMobile` est un *bearer token* : toute partie en possession du jeton peut sauthentifier. Le `uuidAppliMobile` offre un lien faible avec lappareil, mais non vérifié cryptographiquement.
4. **Rotation** : Le `jetonConnexionAppliMobile` est rafraîchi à chaque connexion, mais lancien reste valide jusqu’à invalidation côté serveur. Aucune expiration automatique.
5. **Recommandations** : Utiliser un PIN robuste. Traiter le `jetonConnexionAppliMobile` comme un identifiant à longue durée de vie (stockage sécurisé). En cas de vol de lappareil, révoquer laccès mobile dans Pronote.
### Limitations
- Le QR code expire après **10 minutes** : lappairage doit être rapide.
- Le PIN à 4 chiffres est faible selon les normes modernes.
- Le `jetonConnexionAppliMobile` na pas dexpiration automatique : il persiste jusqu’à révocation manuelle.
- Le `uuidAppliMobile` nest pas lié cryptographiquement à lappareil : il peut être copié.
### Statut
Mécanisme **officiellement intégré** à lapplication mobile Index Éducation. Lutilisation par des clients tiers repose sur de l**ingénierie inverse**.
## Méthode 7 : API officielle et application mobile
### Principe général
Index Éducation ne propose pas dAPI publique pour Pronote. Laccès tiers aux données repose sur lingénierie inverse des mêmes endpoints JSON-over-HTTP(S) utilisés par les clients web et mobile officiels. Lapplication mobile officielle sauthentifie via le mécanisme dappairage par QR Code (Méthode 6) et communique via le même protocole propriétaire que le client web.
### Flux détaillé
L'application mobile s'authentifie via le flux d'appairage par QR Code (Méthode 6) puis communique via le même protocole JSON-over-HTTP(S) que le client web, en utilisant les endpoints `/pronote/mobile.<espace>.html` et `/pronote/appelfonction/<espace_id>/<session_id>/<numeroOrdre>`.
### Disponibilité d'une API développeur
- **API publique** : Inexistante. Index Éducation ne propose ni API REST ni GraphQL pour les élèves, parents ou développeurs tiers.
- **API institutionnelle/entreprise** : Des services dintégration propriétaires sont proposés pour les systèmes partenaires (ex. connecteurs UDTS, HYPERPLANNING, ENT officiels). Ceux-ci nécessitent des accords de partenariat signés et des licences serveurs institutionnelles.
### Authentification de l'application mobile officielle
Lapplication mobile officielle (iOS/Android) se connecte via les mêmes endpoints JSON-over-HTTP(S) que linterface web mobile :
- Chemins de base : `/pronote/mobile.<espace>.html`
- Dispatcher de fonctions : `/pronote/appelfonction/<espace_id>/<session_id>/<numeroOrdre>`
- Authentification : Utilise le flux dappairage par QR Code (Méthode 6) et le jeton mobile persistant (`jetonConnexionAppliMobile` + `uuidAppliMobile`).
### Données échangées
Mêmes charges utiles JSON chiffrées en AES-256-CBC que le client web (Méthode 2). La variante mobile (`mobile.<espace>.html`) contourne les redirections SSO ENT/EduConnect.
### Identifiants et jetons
- `jetonConnexionAppliMobile` : Jeton bearer à longue durée de vie (voir Méthode 6).
- `uuidAppliMobile` : UUID de lappareil.
- Clé de session : Clé AES-256 (même dérivation que la Méthode 2).
### Cycle de vie
Le `jetonConnexionAppliMobile` est rafraîchi à chaque connexion. La durée de vie de la session suit le même délai dinactivité (~1530 min) que le client web.
### Sécurité
1. **Surface dattaque** : Les endpoints mobiles sont accessibles publiquement. Aucune clé API ni enregistrement développeur nexiste — néanmoins, laccès exige un matériel de session Pronote valide et, pour lauto-login mobile, le matériel dauthentification correspondant (`login`, `uuidAppliMobile`, `jetonConnexionAppliMobile`) issu du flux dappairage par QR Code (Méthode 6).
2. **Exposition des identifiants** : Le `jetonConnexionAppliMobile` est un jeton bearer stocké sur lappareil. Sil est extrait, il accorde un accès complet jusqu’à révocation.
3. **Résistance au rejeu** : Faible — le jeton est basé sur un bearer. Le `uuidAppliMobile` offre un lien faible avec lappareil, non appliqué cryptographiquement.
4. **Rotation** : Le jeton est rafraîchi à chaque connexion, mais les anciens restent valides. Aucune expiration automatique.
5. **Recommandations** : Évitez lingénierie inverse du protocole pour un usage en production sans comprendre les implications légales. Stockez les jetons mobiles de manière sécurisée. Soyez conscient que Index Éducation peut modifier le protocole à tout moment.
### Limitations
- Aucune documentation ou support officiel pour les développeurs tiers.
- Protocole propriétaire susceptible de changer sans préavis.
- Les implémentations basées sur lingénierie inverse peuvent cesser de fonctionner après les mises à jour de Pronote.
- Le statut légal de lingénierie inverse est incertain dans certaines juridictions.
### Statut
- API publique : **Inexistante**.
- Endpoints mobiles : **Ingénierie inverse** (même protocole que le client web, accessible via appairage QR Code).
## Annexe A : Bibliothèques open source tierces
Les bibliothèques open source ci-dessous implémentent les protocoles d'authentification Pronote décrits dans ce manuel. Ces projets sont maintenus par la communauté et ne sont pas affiliés à Index Éducation. Les fonctionnalités décrites reposent sur les informations publiques disponibles dans leurs dépôts et peuvent avoir évolué depuis la rédaction de ce document.
| Bibliothèque | Langage | Dépôt | Méthodes d'authentification prises en charge |
|---|---|---|---|
| **pronotepy** | Python | `bain3/pronotepy` | Connexion directe, jeton mobile / QR code, CAS SSO, EduConnect (HubEduConnect direct et via 30+ ENT régionaux : Open ENT NG, Skolengo, Oze, Shibboleth/WAYF). |
| **pronote-api** | TypeScript / JS | `Litarvan/pronote-api` | Connexion directe, CAS SSO, redirections ENT, authentification par jeton. |
| **pronote-qrcode-api** | JavaScript | `Androz2091/pronote-qrcode-api` | Implémentation de référence pour le déchiffrement des QR codes Pronote et l'échange initial de jeton mobile. |
| **pawnote / Blocksnote** | TypeScript / JS | `BlocksHub/Blocksnote` | Clients TypeScript modernes implémentant le protocole Pronote complet (direct, ENT, QR code, jetons). |
Ces bibliothèques illustrent la faisabilité des méthodes d'authentification présentées. Leur utilisation en environnement de production comporte des risques : les modifications de protocole par Index Éducation peuvent rendre les implémentations obsolètes sans préavis, et le statut juridique de l'ingénierie inverse des protocoles propriétaires varie selon les juridictions.
---
## Annexe B : Tableau comparatif synthétique
Cette annexe consolide les caractéristiques clés des 7 méthodes d'authentification Pronote en un tableau de référence unique, incluant les dimensions d'analyse de sécurité.
| Méthode | Périmètre | Identifiants | Expiration | Complexité | Surface d'attaque | Exposition | Rejeu | Rotation | Recommandation | Statut |
|---------|-----------|--------------|------------|-----------|-------------------|-------------|-------|----------|----------------|--------|
| **URL iCal sécurisée** | EDT + devoirs (si inclus) | Jeton URL | Longue durée | Très faible | Jeton dans URL (logs, referers) | Credential longue durée | Faible (pas de nonce) | Manuelle | Stocker en secret, HTTPS, rotate à la rentrée | Officiel |
| **Connexion directe** | Complet | Identifiant + mot de passe | Session ~1530 min | Élevée | Endpoint public, crypto custom | Mot de passe non transmis (challenge) | Modérée (alea aléatoire) | Session seulement | HTTPS obligatoire, gérer `numeroOrdre` | Reverse-engineered |
| **SSO ENT** | Complet | Identifiants ENT | Session ENT | Très élevée | Redirections multiples | Credentials via ENT (intermédiaire) | Jetons SSO single-use | Session ENT | Valider SSL à chaque redirection | Reverse-engineered |
| **SSO EduConnect** | Complet | Identifiants nationaux | Session EduConnect | Très élevée | Redirections EduConnect→ENT/Hub | Credentials MENJ (très sensibles) | SAML single-use + timestamps | MENJ | Ne pas cacher les credentials, 2FA bloque automation | Reverse-engineered |
| **CAS** | Complet | Identifiants CAS | Ticket single-use | Modérée | Login form CAS | Credentials via CAS uniquement | Ticket single-use | TGT (politique CAS) | Valider paramètre `service`, HTTPS | Officiel |
| **QR Code + jeton mobile** | Complet | PIN 4 chiffres → jeton + UUID | QR 10 min ; jeton longue durée | Modérée | QR écran, PIN faible, jeton bearer | Jeton longue durée stocké sur appareil | Faible (bearer) | À chaque login (ancien reste valide) | PIN fort, stockage sécurisé, révoquer si perte | Reverse-engineered |
| **API publique** | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | Inexistante |
| Méthode | Périmètre | Identifiants | Expiration | Complexité | Statut dans `pronote-sync` |
|---------|-----------|--------------|------------|-----------|-----------------------------|
| **URL iCal sécurisée** | EDT + devoirs (si inclus dans le flux) | Jeton URL | Longue durée (dépend de l'instance) | Très faible | ✅ Pris en charge |
| **Connexion directe (mot de passe + ENT)** | Agenda/EDT, devoirs, messages | Identifiant + mot de passe + ENT | Session dépendante de l'instance | Élevée | ✅ Pris en charge |
| **QR Code + token mobile** | Agenda/EDT, devoirs, messages | PIN 4 chiffres → token + UUID | QR ~10 min (⚠️ hypothèse) ; token dépendant de l'instance | Modérée | ✅ Pris en charge |
| **SSO ENT (CAS/SAML)** | — | Identifiants ENT | Session ENT | Très élevée | ❌ Non implémenté (seuls les 30 ENT de la liste fermée `_ENT_NAMES` sont supportés via `pronotepy`) |
| **EduConnect** | — | Identifiants nationaux | Session EduConnect | Très élevée | ❌ Non implémenté |
| **CAS direct** | | Identifiants CAS | Ticket single-use | Modérée | ❌ Non implémenté |
| **API publique** | N/A | N/A | N/A | N/A | ❌ Inexistante |
**Légende** :
- *Complet* : accès aux notes, emploi du temps, devoirs, absences, messagerie et paramètres.
- *Reverse-engineered* : protocole non publié officiellement par Index Éducation, basé sur l'analyse communautaire.
- *Officiel* : mécanisme fourni et pris en charge par Index Éducation.
- *Périmètre* : Les méthodes **iCal** sont en lecture seule (EDT + devoirs si inclus dans le flux). Les méthodes **Connexion directe** et **QR Code + token mobile** permettent les opérations `pronotepy` effectivement implémentées par ce projet (agenda, devoirs, messages ; informations ignorées en mode `qr_token`).
- *QR ~10 min* : ⚠️ **Hypothèse non vérifiée** (observé dans `pronotepy` via un message d'exception, dépend de l'instance).
- *Session dépendante de l'instance* : ⚠️ **Hypothèse non vérifiée** (nécessite un essai réel).
---
## Annexe C : Écarts et notes de cohérence
### Alignement avec `.env.example`
| Paramètre | Document | Code | Statut |
|-----------|----------|------|--------|
| `PRONOTE_ICAL_URL` | ✅ Lignes 2, 4250 | ✅ `sources/ical.py` | **Cohérent** |
| `PRONOTE_URL` | ✅ Ligne 3 | ✅ `client.py` (ligne 294) | **Cohérent** |
| `PRONOTE_ENT` | ✅ Ligne 7 | ✅ `client.py` (ligne 297, `_ENT_NAMES`) | **Cohérent** |
| `PRONOTE_AUTH_MODE=qr_token` | ✅ Ligne 23 | ✅ `client.py` (ligne 334) | **Cohérent** |
| `PRONOTE_QR_CODE_FILE` | ✅ Ligne 24 | ✅ `client.py` (ligne 387) | **Cohérent** |
| `PRONOTE_QR_PIN` | ✅ Ligne 25 | ✅ `client.py` (ligne 388) | **Cohérent** |
| *« Le QR code se génère sur le site web »* | ✅ Ligne 21 | ✅ Section [Procédure QR](#procédure-qr-pour-pronote-sync) | **Cohérent** |
### Écarts avec `docs/exploitation.md`
| Élément | `exploitation.md` | Ce document | Action |
|---------|-------------------|-------------|--------|
| *« Le QR code expire ~10 minutes »* | Ligne 22 | ⚠️ **Hypothèse non vérifiée** (section [Cycle de vie](#cycle-de-vie-des-tokens)) | **Aucune** (hors périmètre). |
| *« Mode `qr_token` incompatible avec dry-run »* | Lignes 6566 | ✅ Section [Incompatibilités](#incompatibilités) | **Cohérent** |
| *« Fichier `.pronote_auth_state.json` (mode 0600) »* | Lignes 7075 | ✅ Section [Sécurité](#sécurité) | **Cohérent** |
---
## Glossaire
| Terme | Définition |
|-------|------------|
| **ENT** | Espace Numérique de Travail (ex. Mon Bureau Numérique, Paris Classe Numérique). |
| **Jeton `icalsecurise`** | Token secret intégré dans l'URL iCal, équivalent à un mot de passe. |
| **`jetonConnexionAppliMobile`** | Token obtenu après appairage QR code, dont la durée dépend de l'instance/du serveur et n'est pas garantie. |
| **UUID** | Identifiant unique permanent pour l'application (ex. `pronote-sync-{uuid4()}`). |
| **PIN** | Code à 4 chiffres défini lors de la génération du QR code. |
---
## Historique des révisions
| Date | Auteur | Modifications |
|------|--------|---------------|
| 2026-09-12 | Agent tech-writer | Refonte complète : qualification des affirmations, ajout des sources, tableau de compatibilité, procédure QR alignée sur pronotepy 2.15.7. |
| 2026-09-12 | Agent tech-writer | Corrections suite à revue indépendante (ticket #10) : précision cryptographie (AES-128), qualification des sources, alignement nombre d'ENT (30), clarification SSO/CAS, exemples non copiables, note d'essai réel. |
| 2026-09-12 | Agent tech-writer | Corrections suite à revue ticket #10 : suppression de tous les placeholders de secrets remplacés par de la prose descriptive ; clarification du périmètre réel des méthodes d'authentification (accès limité aux opérations implémentées) ; qualification de la durée du token `jetonConnexionAppliMobile` comme dépendante de l'instance/serveur et non garantie.
Binary file not shown.
+6 -2
View File
@@ -9,8 +9,8 @@
%%
%% INSTRUCTIONS DE COMPILATION :
%% Ce document est conçu pour être compilé avec LuaLaTeX :
%% lualatex -interaction=nonstopmode docs/pronote-auth.tex
%% lualatex -interaction=nonstopmode docs/pronote-auth.tex
%% TEXINPUTS="docs:" lualatex -interaction=nonstopmode -output-directory=docs docs/pronote-auth.tex
%% TEXINPUTS="docs:" lualatex -interaction=nonstopmode -output-directory=docs docs/pronote-auth.tex
%% (Deux passes nécessaires pour la génération de la table des matières,
%% des références croisées et des hyperliens).
%%
@@ -52,5 +52,9 @@
\include{annex-libraries}
\include{annex-comparison}
\include{annex-ecarts}
\include{glossaire}
\include{historique}
\end{document}
+4 -23
View File
@@ -11,7 +11,6 @@ from pydantic import SecretStr
from pronote_sync.config.env import load_settings
from pronote_sync.config.settings import Settings
from pronote_sync.errors import ErrorSeverity, PipelineError
from pronote_sync.pipeline.run import PipelineRunner
from pronote_sync.utils.logging import setup_logging
from pronote_sync.utils.redaction import redact_secrets
@@ -20,26 +19,6 @@ logger = logging.getLogger(__name__)
_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
# Contrat stable pour systemd et les outils de supervision.
EXIT_SUCCESS = 0
EXIT_CRITICAL = 1
EXIT_DEGRADED = 2
def _pipeline_exit_code(data: object | None, errors: Sequence[PipelineError]) -> int:
"""Convertit le résultat du pipeline en code de sortie supervisable.
:param data: Données normalisées produites, ou ``None`` en cas d'échec critique.
:param errors: Erreurs et avertissements de l'exécution.
:return: ``0`` si complet, ``2`` si dégradé, ``1`` si critique.
:rtype: int
"""
if data is None or any(error.severity == ErrorSeverity.CRITICAL for error in errors):
return EXIT_CRITICAL
if errors:
return EXIT_DEGRADED
return EXIT_SUCCESS
def _parse_arguments(arguments: Sequence[str] | None = None) -> argparse.Namespace:
"""Analyse les options de lancement du programme.
@@ -141,7 +120,7 @@ def main(arguments: Sequence[str] | None = None) -> int:
bruts afin de préserver le diagnostic sans exposer de secret.
:param arguments: Arguments optionnels, principalement utiles aux appels programmatiques.
:return: Code machine-readable : ``0`` complet, ``2`` dégradé, ``1`` critique.
:return: ``0`` en cas de succès, ``1`` sinon (après analyse des arguments).
:rtype: int
:raises SystemExit: Si argparse rejette les arguments (code de sortie 2).
"""
@@ -168,7 +147,9 @@ def main(arguments: Sequence[str] | None = None) -> int:
secrets = _settings_secrets(settings)
for error in errors:
logger.error("%s", redact_secrets(error.message, extra_secrets=secrets))
return _pipeline_exit_code(data, errors)
if data is None:
return 1
return 0
if __name__ == "__main__":
+16 -2
View File
@@ -47,6 +47,7 @@ class PronoteSettings(BaseSettings):
auth_mode: Literal["password", "qr_token"] = "password"
qr_code_file: str | None = None
qr_pin: SecretStr | None = None
account_pin: SecretStr | None = None
@field_serializer("ical_url")
def _serialize_ical_url(self, value: SecretStr | None) -> str | None:
@@ -72,6 +73,18 @@ class PronoteSettings(BaseSettings):
return None
return "**********"
@field_serializer("account_pin")
def _serialize_account_pin(self, value: SecretStr | None) -> str | None:
"""Masque le PIN du compte lors de la sérialisation.
:param value: Valeur du PIN de second facteur du compte.
:return: ``"**********"`` si la valeur est définie, ``None`` sinon.
:rtype: str | None
"""
if value is None:
return None
return "**********"
class CalDAVSettings(BaseSettings):
"""Paramètres d'accès au serveur CalDAV de destination.
@@ -293,8 +306,8 @@ class AppSettings(BaseSettings):
school_holidays_path: str | None = None
theoretical_week_anchor_date: date | None = None
theoretical_week_anchor_type: Literal["even", "odd"] | None = None
sync_past_days: int = 7
sync_future_days: int = 30
sync_past_days: int = Field(default=7, ge=0)
sync_future_days: int = Field(default=30, ge=0)
class Settings(BaseSettings):
@@ -328,6 +341,7 @@ class Settings(BaseSettings):
self.pronote.ical_url,
self.pronote.password,
self.pronote.qr_pin,
self.pronote.account_pin,
self.caldav.url,
self.caldav.password,
self.xmpp.password,
+51 -8
View File
@@ -119,6 +119,9 @@ def _collect_auth_secrets(client: PronoteClient) -> list[str]:
# PIN QR
if settings.qr_pin is not None:
secrets.append(settings.qr_pin.get_secret_value())
# PIN de second facteur du compte
if settings.account_pin is not None:
secrets.append(settings.account_pin.get_secret_value())
# Contenu du fichier QR (jeton, login, url)
if settings.qr_code_file is not None:
try:
@@ -336,8 +339,14 @@ class PronoteClient:
if self._auth_state is not None:
creds = self._auth_state.load()
if creds is not None:
rotation_error: PronoteAuthRotationError | None = None
try:
client = client_class.token_login(**creds)
account_pin = (
self._settings.account_pin.get_secret_value()
if self._settings.account_pin is not None
else None
)
client = client_class.token_login(**creds, account_pin=account_pin)
if client.logged_in:
self._client = client
self._persist_credentials()
@@ -356,12 +365,16 @@ class PronoteClient:
redact_exception(exc, extra_secrets=_collect_auth_secrets(self)),
)
# Token expiré/invalide — pas de repli vers l'enrôlement QR
raise PronoteAuthRotationError(
rotation_error = PronoteAuthRotationError(
"Le token d'authentification Pronote est expiré ou invalide. "
"Action requise : supprimez le fichier .pronote_auth_state.json "
"et relancez avec un nouveau QR code (PRONOTE_QR_CODE_FILE + "
"PRONOTE_QR_PIN)."
) from None
)
if rotation_error is not None:
# Lever hors du bloc ``except`` évite de conserver l'erreur
# externe dans ``__context__``.
raise rotation_error from None
# Enrôlement : premier login via QR code (aucun credential persisté)
client = self._enroll_qr_code(client_class)
@@ -396,6 +409,7 @@ class PronoteClient:
) from None
# Read and validate QR code JSON
read_error: PronoteAuthRotationError | None = None
try:
qr_path = Path(qr_file)
qr_data: Any = json.loads(qr_path.read_text(encoding="utf-8"))
@@ -405,10 +419,14 @@ class PronoteClient:
redact_secrets(qr_file, extra_secrets=_collect_auth_secrets(self)),
redact_exception(exc, extra_secrets=_collect_auth_secrets(self)),
)
raise PronoteAuthRotationError(
read_error = PronoteAuthRotationError(
"Impossible de lire le fichier QR code : "
f"{redact_secrets(qr_file, extra_secrets=_collect_auth_secrets(self))}"
) from None
)
if read_error is not None:
# Lever hors du bloc ``except`` évite de conserver l'erreur
# externe dans ``__context__``.
raise read_error from None
# Validate required keys
for key in ("login", "jeton", "url"):
@@ -420,22 +438,33 @@ class PronoteClient:
pin_value = qr_pin.get_secret_value()
app_uuid = f"pronote-sync-{uuid4().hex}"
enrollment_error: PronoteAuthRotationError | None = None
try:
account_pin = (
self._settings.account_pin.get_secret_value()
if self._settings.account_pin is not None
else None
)
client = client_class.qrcode_login(
qr_code=qr_data,
pin=pin_value,
uuid=app_uuid,
account_pin=account_pin,
)
except Exception as exc:
logger.error(
"Échec de l'enrôlement QR : %s",
redact_exception(exc, extra_secrets=_collect_auth_secrets(self)),
)
raise PronoteAuthRotationError(
enrollment_error = PronoteAuthRotationError(
"Échec de l'enrôlement par QR code : PIN invalide ou QR code expiré. "
"Générez un nouveau QR code dans l'application Pronote et mettez à "
"jour PRONOTE_QR_CODE_FILE."
) from None
)
if enrollment_error is not None:
# Lever hors du bloc ``except`` évite de conserver l'erreur
# externe dans ``__context__``.
raise enrollment_error from None
return client
@@ -488,9 +517,23 @@ class PronoteClient:
Chaque entrée est mappée sur un modèle :class:`Message` de type
``SURVEY`` si c'est un sondage, ``INFORMATION`` sinon.
:return: Liste des informations et sondages ; vide en cas d'erreur.
En mode ``qr_token``, la récupération est ignorée sans connexion ni
appel réseau : l'endpoint ``PageActualites`` renvoie une erreur
pronotepy 20 sur les instances HubEduConnect testées, provoquant un
refresh redondant du token. La méthode retourne alors immédiatement
une liste vide et journalise un message INFO unique ; ce comportement
n'est pas configurable.
:return: Liste des informations et sondages ; vide en cas d'erreur ou
en mode ``qr_token``.
:rtype: list[Message]
"""
if self._settings.auth_mode == "qr_token":
logger.info(
"Récupération des informations Pronote ignorée : endpoint "
"PageActualites indisponible en mode d'authentification qr_token."
)
return []
with self._qr_token_operation_lock():
try:
client = self._connect()
+87 -50
View File
@@ -15,20 +15,83 @@ logger = logging.getLogger(__name__)
__all__ = ["get_synthesis_provider", "SynthesisProvider", "OpenAISynthesisProvider"]
_SENSITIVE_QUERY_PARAMS = {"token", "key", "api_key", "secret", "password", "auth"}
def _validate_base_url(provider: str, url: str, allow_insecure_http: bool) -> str | None:
"""Valide structurellement une URL de base IA, partagée entre providers.
Applique les règles structurelles identiques aux trois providers
(``openai``, ``litellm`` et ``openai-compatible``) : URL parsable par
``urlparse`` (``ValueError`` rejeté), hostname non vide, schéma limité
à ``http``/``https`` (HTTP refusé sauf si ``allow_insecure_http`` vaut
``True``), absence d'identifiants dans le netloc et de paramètres
sensibles dans la requête (y compris les paramètres sans valeur). L'URL
est retournée strictement inchangée : aucune manipulation automatique
du suffixe ``/v1`` n'est effectuée. En cas de violation, un
avertissement est journalisé (l'URL est toujours masquée via
:func:`redact_url`) et ``None`` est retourné ; la fonction ne lève
jamais d'exception et n'effectue aucun appel réseau.
:param provider: Nom du provider (utilisé pour le message d'avertissement).
:param url: URL de base à valider (non vide).
:param allow_insecure_http: Autorise ou non les URLs en HTTP.
:return: L'URL validée, strictement inchangée, ou ``None`` si invalide.
:rtype: str | None
"""
try:
parsed = urlparse(url)
if not parsed.scheme:
logger.warning("URL invalide pour le provider %s : %s", provider, redact_url(url))
return None
if not parsed.hostname:
logger.warning("URL sans hostname pour le provider %s : %s", provider, redact_url(url))
return None
if parsed.scheme not in ("http", "https"):
logger.warning(
"Schéma d'URL non supporté pour le provider %s : %s", provider, redact_url(url)
)
return None
if parsed.scheme == "http" and not allow_insecure_http:
logger.warning(
"URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true pour le provider %s : %s",
provider,
redact_url(url),
)
return None
if parsed.username is not None or parsed.password is not None:
logger.warning(
"Credentials dans l'URL refusés pour le provider %s : %s",
provider,
redact_url(url),
)
return None
param_names = [name.lower() for name, _ in parse_qsl(parsed.query, keep_blank_values=True)]
if any(name in _SENSITIVE_QUERY_PARAMS for name in param_names):
logger.warning(
"Paramètres sensibles dans l'URL refusés pour le provider %s : %s",
provider,
redact_url(url),
)
return None
# Accéder à parsed.port peut lever ValueError (port invalide/hors bornes).
parsed.port # noqa: B018
except ValueError:
logger.warning("URL invalide pour le provider %s : %s", provider, redact_url(url))
return None
return url
def _validate_openai_compatible_config(
url: str | None, model: str | None, allow_insecure_http: bool
) -> str | None:
"""Valide la configuration du provider ``openai-compatible``.
Vérifie la présence de l'URL de base et du modèle, le schéma de l'URL
(HTTPS obligatoire, HTTP accepté uniquement si ``allow_insecure_http``
vaut ``True``), la présence d'un hostname non vide, l'absence
d'identifiants dans le netloc et de paramètres sensibles dans la
requête (y compris les paramètres sans valeur). Une URL malformée
(``ValueError`` levé par ``urlparse``) est également rejetée. En cas
d'échec, un avertissement est journalisé (l'URL est toujours masquée
via :func:`redact_url`) et ``None`` est retourné : la synthèse IA se
dégrade silencieusement, sans jamais lever d'exception.
Vérifie d'abord la présence de l'URL de base et du modèle (spécifique
à ``openai-compatible``), puis délègue les règles structurelles
partagées à :func:`_validate_base_url`. En cas d'échec, un
avertissement est journalisé et ``None`` est retourné : la synthèse IA
se dégrade silencieusement, sans jamais lever d'exception.
:param url: URL de base de l'API compatible OpenAI.
:param model: Identifiant du modèle à utiliser.
@@ -43,42 +106,7 @@ def _validate_openai_compatible_config(
if not model:
logger.warning("Modèle requis pour le provider openai-compatible")
return None
try:
parsed = urlparse(url)
except ValueError:
logger.warning(
"URL invalide pour le provider openai-compatible : %s",
redact_url(url),
)
return None
if not parsed.hostname:
logger.warning(
"URL sans hostname pour le provider openai-compatible : %s",
redact_url(url),
)
return None
if parsed.scheme not in ("http", "https"):
logger.warning(
"Schéma d'URL non supporté pour le provider openai-compatible : %s",
redact_url(url),
)
return None
if parsed.scheme == "http" and not allow_insecure_http:
logger.warning(
"URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true : %s",
redact_url(url),
)
return None
if parsed.username is not None or parsed.password is not None:
logger.warning("Credentials dans l'URL refusés : %s", redact_url(url))
return None
sensitive_names = {"token", "key", "api_key", "secret", "password", "auth"}
param_names = [name.lower() for name, _ in parse_qsl(parsed.query, keep_blank_values=True)]
if any(name in sensitive_names for name in param_names):
logger.warning("Paramètres sensibles dans l'URL refusés : %s", redact_url(url))
return None
return url
return _validate_base_url("openai-compatible", url, allow_insecure_http)
def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
@@ -87,14 +115,15 @@ def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
Retourne ``None`` lorsque la synthèse IA est désactivée ou qu'aucune clé
API n'est configurée. Pour le provider ``litellm``, le paquet ``litellm``
(extra ``ai-litellm``) est requis : s'il est absent, un avertissement est
journalisé et ``None`` est retourné. Pour le provider
``openai-compatible``, la configuration (URL de base et modèle) est
validée par :func:`_validate_openai_compatible_config` ; en cas de
rejet, ``None`` est retourné avec un avertissement.
journalisé et ``None`` est retourné. Pour ``openai`` et ``litellm``,
une ``base_url`` éventuelle est validée par :func:`_validate_base_url` ;
pour le provider ``openai-compatible``, la configuration (URL de base
et modèle) est validée par :func:`_validate_openai_compatible_config` ;
en cas de rejet, ``None`` est retourné avec un avertissement.
:param settings: Paramètres IA.
:return: Le fournisseur configuré, ou ``None`` si désactivé, sans clé API
ou avec une configuration ``openai-compatible`` invalide.
ou avec une configuration invalide.
:rtype: SynthesisProvider | None
"""
if not settings.enabled:
@@ -111,6 +140,10 @@ def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
except ImportError:
logger.warning("Extra 'ai-litellm' requis pour le provider litellm")
return None
if base_url is not None and (
_validate_base_url("litellm", base_url, settings.allow_insecure_http) is None
):
return None
return LiteLLMSynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
if settings.provider == "openai-compatible":
@@ -121,4 +154,8 @@ def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
return None
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=url, model=model)
if base_url is not None and (
_validate_base_url("openai", base_url, settings.allow_insecure_http) is None
):
return None
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
+3 -23
View File
@@ -52,10 +52,10 @@ def test_main_runs_composition_root_in_dry_run_with_requested_log_level(
runner.run.assert_called_once_with()
def test_main_preserves_configured_dry_run_and_returns_degraded_with_warnings(
def test_main_preserves_configured_dry_run_and_returns_success_with_warnings(
mocker: MockerFixture,
) -> None:
"""Sans option, la CLI préserve le dry-run configuré et signale l'état dégradé."""
"""Sans option, la CLI préserve le dry-run configuré et accepte les avertissements."""
from pronote_sync.cli.main import main
settings = Settings(app=AppSettings(dry_run=True, log_level="WARNING"))
@@ -72,32 +72,12 @@ def test_main_preserves_configured_dry_run_and_returns_degraded_with_warnings(
exit_code = main([])
assert exit_code == 2
assert exit_code == 0
assert setup_logging.call_args_list == [mocker.call("INFO"), mocker.call("WARNING")]
composition_root.assert_called_once_with(settings, dry_run=None)
runner.run.assert_called_once_with()
@pytest.mark.parametrize("step", ["caldav_sync", "send"])
def test_main_returns_degraded_code_for_caldav_or_xmpp_failure(
mocker: MockerFixture,
step: str,
) -> None:
"""Les échecs récupérables CalDAV et XMPP sont observables par le code 2."""
from pronote_sync.cli.main import main
settings = Settings()
mocker.patch("pronote_sync.cli.main.load_settings", return_value=settings)
runner = mocker.Mock()
runner.run.return_value = (
mocker.Mock(spec=PronoteData),
[PipelineWarning(f"Échec récupérable de {step}", step=step)],
)
mocker.patch("pronote_sync.cli.main.PipelineRunner.from_settings", return_value=runner)
assert main([]) == 2
def test_main_returns_failure_and_redacts_pipeline_secrets_at_debug_level(
mocker: MockerFixture,
capsys: pytest.CaptureFixture[str],
+137 -3
View File
@@ -10,10 +10,10 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from pydantic import SecretStr
from pydantic import SecretStr, ValidationError
from pronote_sync.config.env import load_settings
from pronote_sync.config.settings import PronoteSettings, Settings
from pronote_sync.config.settings import AppSettings, PronoteSettings, Settings
if TYPE_CHECKING:
from _pytest.monkeypatch import MonkeyPatch
@@ -207,4 +207,138 @@ def test_qr_pin_in_redaction_secrets(monkeypatch: MonkeyPatch) -> None:
assert "**********" in repr(settings.pronote.qr_pin)
# Ensure trailing newline
def test_account_pin_loaded_as_secretstr_and_redacted(monkeypatch: MonkeyPatch) -> None:
"""Vérifie que ``PRONOTE_ACCOUNT_PIN`` est secret et expurgé.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
monkeypatch.setenv("PRONOTE_ACCOUNT_PIN", "account-pin-42")
settings = load_settings()
assert isinstance(settings.pronote.account_pin, SecretStr)
assert settings.pronote.account_pin.get_secret_value() == "account-pin-42"
assert settings.pronote.account_pin in settings.redaction_secrets()
assert "account-pin-42" not in str(settings)
assert "account-pin-42" not in settings.model_dump_json()
assert "**********" in settings.model_dump_json()
def test_sync_past_days_negative_direct_instantiation() -> None:
"""Vérifie que ``sync_past_days`` négatif lève ``ValidationError`` à l'instanciation.
:return: None
"""
with pytest.raises(ValidationError):
AppSettings(sync_past_days=-1)
def test_sync_future_days_negative_direct_instantiation() -> None:
"""Vérifie que ``sync_future_days`` négatif lève ``ValidationError`` à l'instanciation.
:return: None
"""
with pytest.raises(ValidationError):
AppSettings(sync_future_days=-1)
def test_sync_past_days_zero_accepted() -> None:
"""Vérifie que ``sync_past_days=0`` est accepté.
:return: None
"""
settings = AppSettings(sync_past_days=0)
assert settings.sync_past_days == 0
def test_sync_future_days_zero_accepted() -> None:
"""Vérifie que ``sync_future_days=0`` est accepté.
:return: None
"""
settings = AppSettings(sync_future_days=0)
assert settings.sync_future_days == 0
def test_sync_past_days_positive_accepted() -> None:
"""Vérifie que ``sync_past_days`` positif est accepté.
:return: None
"""
settings = AppSettings(sync_past_days=7)
assert settings.sync_past_days == 7
def test_sync_future_days_positive_accepted() -> None:
"""Vérifie que ``sync_future_days`` positif est accepté.
:return: None
"""
settings = AppSettings(sync_future_days=30)
assert settings.sync_future_days == 30
def test_sync_past_days_negative_env_loading(monkeypatch: MonkeyPatch) -> None:
"""Vérifie que ``SYNC_PAST_DAYS=-1`` lève ``ValidationError`` via chargement env.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
monkeypatch.setenv("SYNC_PAST_DAYS", "-1")
with pytest.raises(ValidationError):
load_settings()
def test_sync_future_days_negative_env_loading(monkeypatch: MonkeyPatch) -> None:
"""Vérifie que ``SYNC_FUTURE_DAYS=-1`` lève ``ValidationError`` via chargement env.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
monkeypatch.setenv("SYNC_FUTURE_DAYS", "-1")
with pytest.raises(ValidationError):
load_settings()
def test_sync_past_days_zero_env_loading(monkeypatch: MonkeyPatch) -> None:
"""Vérifie que ``SYNC_PAST_DAYS=0`` est accepté via chargement env.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
monkeypatch.setenv("SYNC_PAST_DAYS", "0")
settings = load_settings()
assert settings.app.sync_past_days == 0
def test_sync_future_days_zero_env_loading(monkeypatch: MonkeyPatch) -> None:
"""Vérifie que ``SYNC_FUTURE_DAYS=0`` est accepté via chargement env.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
monkeypatch.setenv("SYNC_FUTURE_DAYS", "0")
settings = load_settings()
assert settings.app.sync_future_days == 0
def test_sync_past_days_positive_env_loading(monkeypatch: MonkeyPatch) -> None:
"""Vérifie que ``SYNC_PAST_DAYS`` positif est accepté via chargement env.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
monkeypatch.setenv("SYNC_PAST_DAYS", "7")
settings = load_settings()
assert settings.app.sync_past_days == 7
def test_sync_future_days_positive_env_loading(monkeypatch: MonkeyPatch) -> None:
"""Vérifie que ``SYNC_FUTURE_DAYS`` positif est accepté via chargement env.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
monkeypatch.setenv("SYNC_FUTURE_DAYS", "30")
settings = load_settings()
assert settings.app.sync_future_days == 30
+322 -12
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import json
import logging
import traceback
from collections.abc import Generator
from contextlib import contextmanager
from datetime import date, datetime
@@ -621,12 +622,17 @@ def test_get_messages_degraded_on_error(
def test_get_informations_degraded_on_error(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
mocker: pytest_mock.MockerFixture,
pronote_settings: PronoteSettings,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie que get_informations retourne une liste vide en cas d'erreur réseau.
Assert que le chemin d'erreur retourne toujours [] avec un log ERROR.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:param caplog: Fixture pour capturer les logs.
:return: None
"""
mock_client = mocker.MagicMock()
@@ -634,22 +640,265 @@ def test_get_informations_degraded_on_error(
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
client = PronoteClient(pronote_settings)
messages = client.get_informations()
with caplog.at_level(logging.ERROR, logger="pronote_sync.sources.pronote.client"):
messages = client.get_informations()
assert messages == []
# Assert ERROR log is present
error_records = [r for r in caplog.records if r.levelno == logging.ERROR]
assert len(error_records) >= 1
assert any(
"Échec de la récupération des informations Pronote" in r.message for r in error_records
)
# --- QR code / token authentication tests ---
# --- get_informations qr_token mode guard tests ---
def test_get_informations_skips_in_qr_token_mode(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Vérifie que get_informations retourne [] immédiatement en mode qr_token.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
settings = PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent="bordeaux",
account_type="parent",
auth_mode="qr_token",
)
client = PronoteClient(settings)
assert client._client is None
messages = client.get_informations()
assert messages == []
assert client._client is None
def test_get_informations_no_connect_in_qr_token_mode(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Vérifie que _connect n'est jamais appelé en mode qr_token pour get_informations.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
settings = PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent="bordeaux",
account_type="parent",
auth_mode="qr_token",
)
connect_spy = mocker.spy(PronoteClient, "_connect")
client = PronoteClient(settings)
messages = client.get_informations()
assert messages == []
connect_spy.assert_not_called()
def test_get_informations_no_information_and_surveys_in_qr_token_mode(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Vérifie que information_and_surveys n'est jamais appelé en mode qr_token.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
settings = PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent="bordeaux",
account_type="parent",
auth_mode="qr_token",
)
mock_client = mocker.MagicMock()
mock_client.information_and_surveys = mocker.MagicMock()
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
client = PronoteClient(settings)
messages = client.get_informations()
assert messages == []
mock_client.information_and_surveys.assert_not_called()
def test_get_informations_qr_token_no_side_effects(
mocker: pytest_mock.MockerFixture,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Prouve le contrat complet sans effet de bordure du chemin de saut qr_token.
Avec PronoteAuthState et _qr_token_operation_lock et _persist_credentials
mockés, assert que sur le saut : le gestionnaire de contexte de verrou n'est
PAS entré, auth-state load() n'est PAS appelé, _persist_credentials() n'est
PAS appelé, et l'export des credentials n'est PAS invoqué. Assert aussi que
self._client est inchangé.
:param mocker: Fixture pytest-mock pour le mocking.
:param caplog: Fixture pour capturer les logs.
:return: None
"""
auth_state = mocker.MagicMock(spec=PronoteAuthState)
auth_state.load = mocker.MagicMock()
auth_state.lock = mocker.MagicMock()
settings = PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent="bordeaux",
account_type="parent",
auth_mode="qr_token",
)
client = PronoteClient(settings, auth_state=auth_state)
sentinel = MagicMock()
client._client = sentinel
persist_spy = mocker.spy(client, "_persist_credentials")
with caplog.at_level(logging.INFO, logger="pronote_sync.sources.pronote.client"):
messages = client.get_informations()
# Assert no side effects
assert messages == []
assert client._client is sentinel
assert isinstance(sentinel, MagicMock)
sentinel.export_credentials.assert_not_called()
persist_spy.assert_not_called()
auth_state.load.assert_not_called()
auth_state.lock.assert_not_called()
# Assert exactly one INFO log record with the exact message
info_records = [r for r in caplog.records if r.levelno == logging.INFO]
assert len(info_records) == 1
assert (
info_records[0].message
== "Récupération des informations Pronote ignorée : endpoint PageActualites "
"indisponible en mode d'authentification qr_token."
)
# Assert no secret sentinel appears in any log record
for record in caplog.records:
assert "testpass" not in record.message
assert "testuser" not in record.message
assert "pronote.example.com" not in record.message
def test_get_informations_logs_info_in_qr_token_mode(
mocker: pytest_mock.MockerFixture,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie que get_informations log un message INFO exact en mode qr_token.
Assert exactement un enregistrement logging.INFO avec le message exact
(inspection de caplog.records, pas seulement caplog.text), et qu'aucune
sentinelle de secret n'apparaît.
:param mocker: Fixture pytest-mock pour le mocking.
:param caplog: Fixture pour capturer les logs.
:return: None
"""
settings = PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent="bordeaux",
account_type="parent",
auth_mode="qr_token",
)
with caplog.at_level(logging.INFO, logger="pronote_sync.sources.pronote.client"):
client = PronoteClient(settings)
messages = client.get_informations()
assert messages == []
# Assert exactly one INFO record with the exact message
info_records = [r for r in caplog.records if r.levelno == logging.INFO]
assert len(info_records) == 1
assert (
info_records[0].message
== "Récupération des informations Pronote ignorée : endpoint PageActualites "
"indisponible en mode d'authentification qr_token."
)
# Assert no secret sentinel appears in any record
for record in caplog.records:
assert "testpass" not in record.message
assert "testuser" not in record.message
assert "pronote.example.com" not in record.message
def test_get_informations_unchanged_in_password_mode(
mocker: pytest_mock.MockerFixture,
pronote_settings: PronoteSettings,
) -> None:
"""Vérifie que get_informations en mode password reste inchangé (régression).
Assert que _connect() A ÉTÉ appelé et information_and_surveys() A ÉTÉ appelé
(en cas de succès), en conservant les assertions de mappage existantes.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides en mode password.
:return: None
"""
mock_client = mocker.MagicMock()
mock_info = mocker.MagicMock()
mock_info.id = "info-456"
mock_info.title = "Important Info"
mock_info.content.return_value = "Important content"
mock_info.author = "Admin"
mock_info.creation_date = datetime(2024, 9, 2, 14, 30, 0)
mock_info.read = False
mock_info.survey = True
mock_client.information_and_surveys.return_value = [mock_info]
# Patch _connect to return mock_client and track calls
connect_patch = mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
client = PronoteClient(pronote_settings)
messages = client.get_informations()
# Assert _connect() WAS called and information_and_surveys() WAS called
connect_patch.assert_called_once()
mock_client.information_and_surveys.assert_called_once()
# Retain existing mapping assertions
assert isinstance(messages, list)
assert len(messages) == 1
message = messages[0]
assert isinstance(message, Message)
assert message.id == "info-456"
assert message.type == MessageType.SURVEY
assert message.title == "Important Info"
assert message.content == "Important content"
assert message.author == "Admin"
assert message.date == datetime(2024, 9, 2, 14, 30, 0)
assert message.read is False
@pytest.mark.parametrize("account_pin", [None, "account-pin-42"])
def test_connect_password_mode_unchanged(
mocker: pytest_mock.MockerFixture,
pronote_settings: PronoteSettings,
account_pin: str | None,
) -> None:
"""Vérifie que le mode password conserve le comportement historique.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides en mode password.
:param account_pin: PIN de compte éventuellement configuré.
:return: None
"""
from unittest.mock import Mock
@@ -659,7 +908,10 @@ def test_connect_password_mode_unchanged(
mocker.patch("pronotepy.ParentClient", new=mock_client_class)
mocker.patch("pronotepy.Client")
client = PronoteClient(pronote_settings, auth_state=None)
settings = pronote_settings.model_copy(
update={"account_pin": SecretStr(account_pin) if account_pin is not None else None}
)
client = PronoteClient(settings, auth_state=None)
connected = client._connect()
assert connected is mock_client
@@ -674,8 +926,10 @@ def test_connect_password_mode_unchanged(
assert mock_client_class.call_count == 1
@pytest.mark.parametrize("account_pin", [None, "account-pin-42"])
def test_connect_qr_token_with_persisted_creds(
mocker: pytest_mock.MockerFixture,
account_pin: str | None,
) -> None:
"""Vérifie le login par token persisté en mode qr_token.
@@ -683,6 +937,7 @@ def test_connect_qr_token_with_persisted_creds(
``token_login`` et le token rotate est resauvegardé.
:param mocker: Fixture pytest-mock pour le mocking.
:param account_pin: PIN de second facteur facultatif.
:return: None
"""
creds = {
@@ -695,10 +950,26 @@ def test_connect_qr_token_with_persisted_creds(
auth_state = mocker.MagicMock(spec=PronoteAuthState)
auth_state.load.return_value = creds
mock_client = mocker.MagicMock()
mock_client: MagicMock = mocker.MagicMock()
mock_client.logged_in = True
mock_client.export_credentials.return_value = rotated_creds
mocker.patch("pronotepy.ParentClient.token_login", return_value=mock_client)
def token_login_fake(
pronote_url: str,
username: str,
password: str,
uuid: str,
account_pin: str | None = None,
client_identifier: str | None = None,
device_name: str | None = None,
) -> MagicMock:
"""Retourne le faux client avec la signature pronotepy 2.15.7."""
del pronote_url, username, password, uuid, account_pin, client_identifier, device_name
return mock_client
token_login = mocker.patch(
"pronotepy.ParentClient.token_login", autospec=True, side_effect=token_login_fake
)
settings = PronoteSettings(
url="https://pronote.example.com",
@@ -707,23 +978,27 @@ def test_connect_qr_token_with_persisted_creds(
ent=None,
account_type="parent",
auth_mode="qr_token",
account_pin=SecretStr(account_pin) if account_pin is not None else None,
)
client = PronoteClient(settings, auth_state=auth_state)
connected = client._connect()
assert connected is mock_client
pronotepy.ParentClient.token_login.assert_called_once_with(**creds) # type: ignore[attr-defined]
token_login.assert_called_once_with(**creds, account_pin=account_pin)
auth_state.save.assert_called_once_with(rotated_creds)
@pytest.mark.parametrize("account_pin", [None, "account-pin-42"])
def test_connect_qr_token_no_creds_with_qr_code(
mocker: pytest_mock.MockerFixture,
tmp_path: Path,
account_pin: str | None,
) -> None:
"""Vérifie l'enrôlement initial par QR code quand aucun token n'est persisté.
:param mocker: Fixture pytest-mock pour le mocking.
:param tmp_path: Répertoire temporaire de test.
:param account_pin: PIN de second facteur facultatif.
:return: None
"""
qr_file = tmp_path / "qr_code.json"
@@ -746,10 +1021,26 @@ def test_connect_qr_token_no_creds_with_qr_code(
"password": "new-token", # pragma: allowlist secret
"uuid": "new-uuid",
}
mock_client = mocker.MagicMock()
mock_client: MagicMock = mocker.MagicMock()
mock_client.logged_in = True
mock_client.export_credentials.return_value = creds
mocker.patch("pronotepy.ParentClient.qrcode_login", return_value=mock_client)
def qrcode_login_fake(
qr_code: dict[str, str],
pin: str,
uuid: str,
account_pin: str | None = None,
client_identifier: str | None = None,
device_name: str | None = None,
skip_2fa: bool = False,
) -> MagicMock:
"""Retourne le faux client avec la signature pronotepy 2.15.7."""
del qr_code, pin, uuid, account_pin, client_identifier, device_name, skip_2fa
return mock_client
qrcode_login = mocker.patch(
"pronotepy.ParentClient.qrcode_login", autospec=True, side_effect=qrcode_login_fake
)
settings = PronoteSettings(
url="https://pronote.example.com",
@@ -760,15 +1051,16 @@ def test_connect_qr_token_no_creds_with_qr_code(
auth_mode="qr_token",
qr_code_file=str(qr_file),
qr_pin=SecretStr("123456"),
account_pin=SecretStr(account_pin) if account_pin is not None else None,
)
client = PronoteClient(settings, auth_state=auth_state)
connected = client._connect()
assert connected is mock_client
qrcode_login = pronotepy.ParentClient.qrcode_login
qrcode_login.assert_called_once() # type: ignore[attr-defined]
kwargs = qrcode_login.call_args.kwargs # type: ignore[attr-defined]
qrcode_login.assert_called_once()
kwargs = qrcode_login.call_args.kwargs
assert kwargs["pin"] == "123456"
assert kwargs["account_pin"] == account_pin
assert kwargs["qr_code"] == {
"login": "testuser",
"jeton": "qr-jeton",
@@ -1145,6 +1437,12 @@ def test_no_secrets_in_rotation_error_messages(
assert sentinel_pin not in caplog.text
assert sentinel_token not in caplog.text
assert "sentinel-url" not in caplog.text
assert exc_info.value.__cause__ is None
assert exc_info.value.__context__ is None
formatted_traceback = "".join(traceback.format_exception(exc_info.value))
assert sentinel_pin not in formatted_traceback
assert sentinel_token not in formatted_traceback
assert "sentinel-url" not in formatted_traceback
def test_no_raw_secrets_in_logs(
@@ -1167,6 +1465,7 @@ def test_no_raw_secrets_in_logs(
sentinel_token = "SENTINEL_RAW_TOKEN_ALPHA"
sentinel_pin = "SENTINEL_RAW_PIN_BRAVO"
sentinel_jeton = "SENTINEL_RAW_JETON_CHARLIE"
sentinel_account_pin = "SENTINEL_RAW_ACCOUNT_PIN_DELTA"
qr_file = tmp_path / "qr_code.json"
qr_file.write_text(
@@ -1190,7 +1489,8 @@ def test_no_raw_secrets_in_logs(
mocker.patch(
"pronotepy.ParentClient.token_login",
side_effect=pronotepy.PronoteAPIError(
f"login refusé {sentinel_token} puis {sentinel_pin} puis {sentinel_jeton}"
f"login refusé {sentinel_token} puis {sentinel_pin} puis {sentinel_jeton} "
f"puis {sentinel_account_pin}"
),
)
@@ -1203,6 +1503,7 @@ def test_no_raw_secrets_in_logs(
auth_mode="qr_token",
qr_code_file=str(qr_file),
qr_pin=SecretStr(sentinel_pin),
account_pin=SecretStr(sentinel_account_pin),
)
client = PronoteClient(settings, auth_state=auth_state)
@@ -1214,10 +1515,19 @@ def test_no_raw_secrets_in_logs(
assert sentinel_token not in message
assert sentinel_pin not in message
assert sentinel_jeton not in message
assert sentinel_account_pin not in message
assert caplog.text
assert sentinel_token not in caplog.text
assert sentinel_pin not in caplog.text
assert sentinel_jeton not in caplog.text
assert sentinel_account_pin not in caplog.text
assert exc_info.value.__cause__ is None
assert exc_info.value.__context__ is None
formatted_traceback = "".join(traceback.format_exception(exc_info.value))
assert sentinel_token not in formatted_traceback
assert sentinel_pin not in formatted_traceback
assert sentinel_jeton not in formatted_traceback
assert sentinel_account_pin not in formatted_traceback
# --- Persistence of credentials after data operations ---
+466 -1
View File
@@ -11,7 +11,7 @@ factory de sélection, en vérifiant :
from __future__ import annotations
from datetime import date, datetime, time
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Literal
from unittest.mock import MagicMock
import pytest
@@ -29,6 +29,7 @@ from pronote_sync.models.diff import AgendaChange, AgendaChangeType, AgendaDiff
from pronote_sync.models.message import Message, MessageType
from pronote_sync.models.synthesis import SynthesisInput
from pronote_sync.synthesis import get_synthesis_provider
from pronote_sync.synthesis.litellm import LiteLLMSynthesisProvider
from pronote_sync.synthesis.openai import OpenAISynthesisProvider
from pronote_sync.synthesis.provider import SynthesisProvider
@@ -872,6 +873,70 @@ def test_openai_compatible_valid_https_url_accepted() -> None:
assert isinstance(result, OpenAISynthesisProvider)
def test_openai_compatible_url_returned_unchanged() -> None:
"""Vérifie que l'URL est retournée strictement inchangée, sans manipulation de /v1."""
custom_url = "https://api.example.com/custom/path?query=value"
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="openai-compatible",
base_url=custom_url,
model="test-model",
)
result = get_synthesis_provider(settings)
assert isinstance(result, OpenAISynthesisProvider)
# OpenAI SDK appends a trailing slash to base_url, so we check the string representation
assert str(result._client.base_url).rstrip("/") == custom_url
def test_openai_url_returned_unchanged() -> None:
"""Vérifie que l'URL est retournée strictement inchangée pour openai."""
custom_url = "https://api.example.com/custom/path?query=value"
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="openai",
base_url=custom_url,
)
result = get_synthesis_provider(settings)
assert isinstance(result, OpenAISynthesisProvider)
# OpenAI SDK appends a trailing slash to base_url, so we check the string representation
assert str(result._client.base_url).rstrip("/") == custom_url
def test_litellm_url_returned_unchanged() -> None:
"""Vérifie que l'URL est retournée strictement inchangée pour litellm."""
pytest.importorskip("litellm")
custom_url = "https://api.example.com/custom/path?query=value"
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="litellm",
base_url=custom_url,
)
result = get_synthesis_provider(settings)
assert result is not None
assert isinstance(result, LiteLLMSynthesisProvider)
assert result._base_url == custom_url
def test_openai_compatible_malformed_port_refused(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie qu'un port malformé est refusé pour openai-compatible."""
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="openai-compatible",
base_url="https://host:bad/v1",
model="test-model",
)
result = get_synthesis_provider(settings)
assert result is None
assert "URL invalide" in caplog.text
assert "openai-compatible" in caplog.text
def test_openai_compatible_http_refused_by_default(
caplog: pytest.LogCaptureFixture,
) -> None:
@@ -887,6 +952,7 @@ def test_openai_compatible_http_refused_by_default(
result = get_synthesis_provider(settings)
assert result is None
assert "URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true" in caplog.text
assert "openai-compatible" in caplog.text
def test_openai_compatible_http_accepted_with_allow_insecure_http() -> None:
@@ -917,6 +983,7 @@ def test_openai_compatible_credentials_in_url_refused(
result = get_synthesis_provider(settings)
assert result is None
assert "Credentials dans l'URL refusés" in caplog.text
assert "openai-compatible" in caplog.text
def test_openai_compatible_sensitive_query_params_refused(
@@ -933,6 +1000,24 @@ def test_openai_compatible_sensitive_query_params_refused(
result = get_synthesis_provider(settings)
assert result is None
assert "Paramètres sensibles dans l'URL refusés" in caplog.text
assert "openai-compatible" in caplog.text
def test_openai_compatible_sensitive_query_params_valueless_refused(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie que les query params sensibles sans valeur sont refusés pour openai-compatible."""
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="openai-compatible",
base_url="https://host/v1?token",
model="test-model",
)
result = get_synthesis_provider(settings)
assert result is None
assert "Paramètres sensibles dans l'URL refusés" in caplog.text
assert "openai-compatible" in caplog.text
def test_openai_compatible_connection_error_returns_none(
@@ -991,3 +1076,383 @@ def test_openai_compatible_factory_no_network_calls(
assert isinstance(result, OpenAISynthesisProvider)
mock_get.assert_not_called()
mock_post.assert_not_called()
# --- Tests de validation AI_BASE_URL pour openai et litellm (Issue #17) ---
def test_openai_valid_https_base_url_accepted() -> None:
"""Vérifie qu'une URL HTTPS valide est acceptée pour openai."""
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="openai",
base_url="https://api.openai.com/v1",
)
result = get_synthesis_provider(settings)
assert isinstance(result, OpenAISynthesisProvider)
def test_openai_http_refused_by_default(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie que HTTP est refusé par défaut pour openai."""
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="openai",
base_url="http://127.0.0.1:11434/v1",
allow_insecure_http=False,
)
result = get_synthesis_provider(settings)
assert result is None
assert "URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true" in caplog.text
assert "openai" in caplog.text
def test_openai_http_accepted_with_allow_insecure_http() -> None:
"""Vérifie que HTTP est accepté avec allow_insecure_http=True pour openai."""
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="openai",
base_url="http://127.0.0.1:11434/v1",
allow_insecure_http=True,
)
result = get_synthesis_provider(settings)
assert isinstance(result, OpenAISynthesisProvider)
def test_openai_credentials_in_url_refused(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie que les credentials dans l'URL sont refusés pour openai."""
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="openai",
base_url="https://user:pass@host/v1", # pragma: allowlist secret
)
result = get_synthesis_provider(settings)
assert result is None
assert "Credentials dans l'URL refusés" in caplog.text
assert "openai" in caplog.text
def test_openai_sensitive_query_params_refused(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie que les query params sensibles sont refusés pour openai."""
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="openai",
base_url="https://host/v1?token=secret",
)
result = get_synthesis_provider(settings)
assert result is None
assert "Paramètres sensibles dans l'URL refusés" in caplog.text
assert "openai" in caplog.text
def test_openai_sensitive_query_params_valueless_refused(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie que les query params sensibles sans valeur sont refusés pour openai."""
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="openai",
base_url="https://host/v1?token",
)
result = get_synthesis_provider(settings)
assert result is None
assert "Paramètres sensibles dans l'URL refusés" in caplog.text
assert "openai" in caplog.text
def test_openai_malformed_url_refused(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie qu'une URL malformée est refusée pour openai."""
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="openai",
base_url="not-a-valid-url",
)
result = get_synthesis_provider(settings)
assert result is None
assert "URL invalide" in caplog.text
assert "openai" in caplog.text
def test_openai_no_hostname_url_refused(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie qu'une URL sans hostname est refusée pour openai."""
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="openai",
base_url="https:///v1",
)
result = get_synthesis_provider(settings)
assert result is None
assert "URL sans hostname" in caplog.text
assert "openai" in caplog.text
def test_openai_malformed_port_refused(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie qu'un port malformé est refusé pour openai."""
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="openai",
base_url="https://host:bad/v1",
)
result = get_synthesis_provider(settings)
assert result is None
assert "URL invalide" in caplog.text
assert "openai" in caplog.text
def test_openai_no_network_calls_during_validation(
mocker: MockerFixture,
) -> None:
"""Vérifie qu'aucun appel réseau n'est effectué pendant la validation pour openai."""
mock_get = mocker.patch("requests.get")
mock_post = mocker.patch("requests.post")
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="openai",
base_url="https://api.example.com/v1",
)
result = get_synthesis_provider(settings)
assert isinstance(result, OpenAISynthesisProvider)
mock_get.assert_not_called()
mock_post.assert_not_called()
def test_openai_sentinel_key_not_in_logs(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie qu'une clé sentinelle est absente des logs pour openai."""
sentinel = "sk-SENTINEL-OPENAI-BASE-URL-12345"
settings = AISettings(
enabled=True,
api_key=SecretStr(sentinel),
provider="openai",
base_url="https://user:pass@host/v1", # pragma: allowlist secret
)
result = get_synthesis_provider(settings)
assert result is None
assert sentinel not in caplog.text
def test_litellm_valid_https_base_url_accepted() -> None:
"""Vérifie qu'une URL HTTPS valide est acceptée pour litellm."""
pytest.importorskip("litellm")
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="litellm",
base_url="https://api.litellm.ai/v1",
)
result = get_synthesis_provider(settings)
assert result is not None
def test_litellm_http_refused_by_default(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie que HTTP est refusé par défaut pour litellm."""
pytest.importorskip("litellm")
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="litellm",
base_url="http://127.0.0.1:11434/v1",
allow_insecure_http=False,
)
result = get_synthesis_provider(settings)
assert result is None
assert "URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true" in caplog.text
assert "litellm" in caplog.text
def test_litellm_http_accepted_with_allow_insecure_http() -> None:
"""Vérifie que HTTP est accepté avec allow_insecure_http=True pour litellm."""
pytest.importorskip("litellm")
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="litellm",
base_url="http://127.0.0.1:11434/v1",
allow_insecure_http=True,
)
result = get_synthesis_provider(settings)
assert result is not None
def test_litellm_credentials_in_url_refused(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie que les credentials dans l'URL sont refusés pour litellm."""
pytest.importorskip("litellm")
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="litellm",
base_url="https://user:pass@host/v1", # pragma: allowlist secret
)
result = get_synthesis_provider(settings)
assert result is None
assert "Credentials dans l'URL refusés" in caplog.text
assert "litellm" in caplog.text
def test_litellm_sensitive_query_params_refused(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie que les query params sensibles sont refusés pour litellm."""
pytest.importorskip("litellm")
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="litellm",
base_url="https://host/v1?token=secret",
)
result = get_synthesis_provider(settings)
assert result is None
assert "Paramètres sensibles dans l'URL refusés" in caplog.text
assert "litellm" in caplog.text
def test_litellm_sensitive_query_params_valueless_refused(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie que les query params sensibles sans valeur sont refusés pour litellm."""
pytest.importorskip("litellm")
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="litellm",
base_url="https://host/v1?token",
)
result = get_synthesis_provider(settings)
assert result is None
assert "Paramètres sensibles dans l'URL refusés" in caplog.text
assert "litellm" in caplog.text
def test_litellm_malformed_url_refused(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie qu'une URL malformée est refusée pour litellm."""
pytest.importorskip("litellm")
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="litellm",
base_url="not-a-valid-url",
)
result = get_synthesis_provider(settings)
assert result is None
assert "URL invalide" in caplog.text
assert "litellm" in caplog.text
def test_litellm_no_hostname_url_refused(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie qu'une URL sans hostname est refusée pour litellm."""
pytest.importorskip("litellm")
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="litellm",
base_url="https:///v1",
)
result = get_synthesis_provider(settings)
assert result is None
assert "URL sans hostname" in caplog.text
assert "litellm" in caplog.text
def test_litellm_malformed_port_refused(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie qu'un port malformé est refusé pour litellm."""
pytest.importorskip("litellm")
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="litellm",
base_url="https://host:bad/v1",
)
result = get_synthesis_provider(settings)
assert result is None
assert "URL invalide" in caplog.text
assert "litellm" in caplog.text
def test_litellm_no_network_calls_during_validation(
mocker: MockerFixture,
) -> None:
"""Vérifie qu'aucun appel réseau n'est effectué pendant la validation pour litellm."""
pytest.importorskip("litellm")
mock_get = mocker.patch("requests.get")
mock_post = mocker.patch("requests.post")
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider="litellm",
base_url="https://api.example.com/v1",
)
result = get_synthesis_provider(settings)
assert result is not None
mock_get.assert_not_called()
mock_post.assert_not_called()
def test_litellm_sentinel_key_not_in_logs(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie qu'une clé sentinelle est absente des logs pour litellm."""
pytest.importorskip("litellm")
sentinel = "sk-SENTINEL-LITELLM-BASE-URL-67890"
settings = AISettings(
enabled=True,
api_key=SecretStr(sentinel),
provider="litellm",
base_url="https://user:pass@host/v1", # pragma: allowlist secret
)
result = get_synthesis_provider(settings)
assert result is None
assert sentinel not in caplog.text
@pytest.mark.parametrize("provider", ["openai", "litellm", "openai-compatible"])
def test_all_providers_http_refused_same_warning(
provider: Literal["openai", "litellm", "openai-compatible"], caplog: pytest.LogCaptureFixture
) -> None:
"""Vérifie que tous les providers émettent le même message d'avertissement pour HTTP refusé."""
if provider == "litellm":
pytest.importorskip("litellm")
settings = AISettings(
enabled=True,
api_key=SecretStr("test"),
provider=provider,
base_url="http://127.0.0.1:11434/v1",
allow_insecure_http=False,
model="test-model" if provider == "openai-compatible" else None,
)
result = get_synthesis_provider(settings)
assert result is None
assert "URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true" in caplog.text