From 6038b44fa82e722a7389576f137918092e68704a Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 24 Jun 2026 22:51:56 +0200 Subject: [PATCH] docs(auth): spec connexion LinkedIn (OIDC) Co-Authored-By: Claude Opus 4.8 --- .../specs/2026-06-24-linkedin-oauth-design.md | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-24-linkedin-oauth-design.md diff --git a/docs/superpowers/specs/2026-06-24-linkedin-oauth-design.md b/docs/superpowers/specs/2026-06-24-linkedin-oauth-design.md new file mode 100644 index 0000000..e801692 --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-linkedin-oauth-design.md @@ -0,0 +1,145 @@ +# Connexion avec LinkedIn (OIDC) — Design + +Date : 2026-06-24 +Branche : `dev` + +## Objectif + +Permettre aux utilisateurs de créer un compte / se connecter à decp.info via +LinkedIn, comme les boutons « Se connecter avec Google/GitHub » d'autres sites. +Beaucoup d'utilisateurs viennent de LinkedIn ; réduire la friction d'inscription. + +Portée : **LinkedIn uniquement** (pas d'abstraction multi-provider pour +l'instant). Code simple et direct. + +## Mécanisme + +LinkedIn fournit _« Sign In with LinkedIn using OpenID Connect »_ (OIDC). +On utilise la bibliothèque **Authlib** (intégration Flask) : elle gère la +découverte OIDC, la redirection, l'échange `code → token`, la validation du +token et le `state` anti-CSRF. Scopes demandés : `openid profile email`. + +On ne stocke que l'**email** (comme l'auth existante), pas le nom ni la photo. + +## Flux + +1. L'utilisateur clique sur **« Connexion avec LinkedIn »** (présent sur + `/connexion` et `/inscription`). +2. `GET /auth/linkedin` → Authlib redirige vers LinkedIn. +3. L'utilisateur autorise → LinkedIn redirige vers + `GET /auth/linkedin/callback?code=...&state=...`. +4. Authlib échange le `code`, valide le token, expose l'identité OIDC : + `sub`, `email`, `email_verified`. +5. Résolution / liaison du compte (voir ci-dessous). +6. `login_user(user, remember=True)` → redirection vers `/compte/admin` + (ou le `next` validé via `safe_next`). + +## Schéma (migrations additives, dans `db._migrate`) + +- `users.password_hash` devient **nullable** : un compte créé via LinkedIn n'a + pas de mot de passe. Un tel utilisateur pourra s'en créer un plus tard via + le flux « mot de passe oublié ». + + - SQLite ne permet pas de retirer un `NOT NULL` par `ALTER COLUMN`. La + migration recrée la table `users` sans la contrainte `NOT NULL` sur + `password_hash` si elle est encore présente (copie des données, + `PRAGMA table_info` pour détecter l'état). Les nouvelles installations + créent directement le schéma sans `NOT NULL` sur `password_hash`. + +- Nouvelle table : + + ```sql + CREATE TABLE IF NOT EXISTS oauth_identities ( + provider TEXT NOT NULL, -- 'linkedin' + subject TEXT NOT NULL, -- le 'sub' OIDC stable + user_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (provider, subject), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + ``` + +## Résolution / liaison du compte (au callback) + +Logique extraite dans une fonction testable `resolve_oauth_user(provider, subject, email, email_verified)` : + +1. Si `(linkedin, sub)` existe dans `oauth_identities` → on connecte le + `user_id` lié. +2. Sinon, si un `user` existe déjà avec cet **email** → **liaison + automatique** : insert dans `oauth_identities`, et si `email_verified` + était `0`, on le passe à `1` (LinkedIn garantit un email vérifié). +3. Sinon → **création** d'un nouvel utilisateur (`email`, + `password_hash = NULL`, `email_verified = 1`) + insert `oauth_identities`. + Aucun email de vérification Brevo n'est envoyé. + +Décision produit : la liaison par email est automatique (l'email LinkedIn est +vérifié, le risque est faible). + +## Fichiers touchés + +- **`src/auth/oauth.py`** (nouveau) : `init_oauth(app)` — initialise Authlib et + enregistre le provider LinkedIn (config OIDC, client id/secret depuis l'env). +- **`src/auth/routes.py`** : deux routes + - `GET /auth/linkedin` — démarre le flux (`authorize_redirect`). + - `GET /auth/linkedin/callback` — récupère l'identité, appelle + `resolve_oauth_user`, `login_user`, redirige. + - `resolve_oauth_user(...)` — logique de résolution (testable). +- **`src/auth/db.py`** : schéma `oauth_identities`, migration `password_hash` + nullable, helpers `get_oauth_identity(provider, subject)`, + `link_oauth_identity(provider, subject, user_id)`, + `create_oauth_user(email)`. +- **`src/auth/setup.py`** : appel à `init_oauth(app)`. +- **`src/pages/connexion.py`** et **`src/pages/inscription.py`** : bouton + « Connexion avec LinkedIn ». +- **`pyproject.toml`** : ajout de la dépendance `authlib`. +- **`.template.env`** : `LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET`. + +## UI du bouton + +- Libellé : **« Connexion avec LinkedIn »**. +- Style : texte blanc sur fond `rgb(10, 102, 194)` (bleu de marque LinkedIn, + `#0A66C2`). +- Un lien `` (initiation par GET, pas de CSRF requis), + placé sous le formulaire email/mot de passe, séparé par un « ou ». +- Présent sur `/connexion` et `/inscription`. + +## Gestion d'erreurs + +- L'utilisateur annule / refuse sur LinkedIn → `/connexion?error=oauth_cancelled`. +- Échange de token échoué, ou pas d'email renvoyé par LinkedIn → + `/connexion?error=oauth_failed` (loggé via `logger.exception`). +- `state` invalide → détecté par Authlib → `oauth_failed`. +- Messages ajoutés dans `ERROR_MESSAGES` de `connexion.py`. + +## Tests (dans `tests/auth/`) + +Sans appel réseau réel à LinkedIn : on **mocke** le retour `userinfo` / +l'identité OIDC exposée par Authlib. + +- `resolve_oauth_user` : + - nouvel utilisateur (création + `email_verified = 1`, pas de password). + - liaison par email à un compte existant (insert identity, promotion + `email_verified`). + - identité déjà liée (retour du même `user_id`, pas de doublon). +- Migration : `password_hash` accepte `NULL` ; table `oauth_identities` créée. +- Helpers db : `get_oauth_identity`, `link_oauth_identity`, `create_oauth_user`. + +## Prérequis hors code (côté admin du projet) + +1. Créer une application sur le **LinkedIn Developer Portal**. +2. Activer le produit _« Sign In with LinkedIn using OpenID Connect »_. +3. Déclarer les redirect URIs autorisées : + - `http://localhost:8050/auth/linkedin/callback` (dev) + - `https://test.decp.info/auth/linkedin/callback` (test.decp.info) + - `https://decp.info/auth/linkedin/callback` (prod) +4. Récupérer le **Client ID** et le **Client Secret**, les renseigner dans + `.env` (`LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET`). + +L'URL de callback est construite à partir de `APP_BASE_URL` (déjà présent dans +l'env). + +## Hors périmètre (YAGNI) + +- Google / GitHub / autres providers. +- Stockage du nom ou de la photo de profil LinkedIn. +- Page de gestion « délier mon compte LinkedIn » (pourra venir plus tard).