Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6.2 KiB
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
- L'utilisateur clique sur « Connexion avec LinkedIn » (présent sur
/connexionet/inscription). GET /auth/linkedin→ Authlib redirige vers LinkedIn.- L'utilisateur autorise → LinkedIn redirige vers
GET /auth/linkedin/callback?code=...&state=.... - Authlib échange le
code, valide le token, expose l'identité OIDC :sub,email,email_verified. - Résolution / liaison du compte (voir ci-dessous).
login_user(user, remember=True)→ redirection vers/compte/admin(ou lenextvalidé viasafe_next).
Schéma (migrations additives, dans db._migrate)
-
users.password_hashdevient 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 NULLparALTER COLUMN. La migration recrée la tableuserssans la contrainteNOT NULLsurpassword_hashsi elle est encore présente (copie des données,PRAGMA table_infopour détecter l'état). Les nouvelles installations créent directement le schéma sansNOT NULLsurpassword_hash.
- SQLite ne permet pas de retirer un
-
Nouvelle table :
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) :
- Si
(linkedin, sub)existe dansoauth_identities→ on connecte leuser_idlié. - Sinon, si un
userexiste déjà avec cet email → liaison automatique : insert dansoauth_identities, et siemail_verifiedétait0, on le passe à1(LinkedIn garantit un email vérifié). - Sinon → création d'un nouvel utilisateur (
email,password_hash = NULL,email_verified = 1) + insertoauth_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 routesGET /auth/linkedin— démarre le flux (authorize_redirect).GET /auth/linkedin/callback— récupère l'identité, appelleresolve_oauth_user,login_user, redirige.resolve_oauth_user(...)— logique de résolution (testable).
src/auth/db.py: schémaoauth_identities, migrationpassword_hashnullable, helpersget_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.pyetsrc/pages/inscription.py: bouton « Connexion avec LinkedIn ».pyproject.toml: ajout de la dépendanceauthlib..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
<a href="/auth/linkedin">(initiation par GET, pas de CSRF requis), placé sous le formulaire email/mot de passe, séparé par un « ou ». - Présent sur
/connexionet/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é vialogger.exception). stateinvalide → détecté par Authlib →oauth_failed.- Messages ajoutés dans
ERROR_MESSAGESdeconnexion.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).
- nouvel utilisateur (création +
- Migration :
password_hashaccepteNULL; tableoauth_identitiescréée. - Helpers db :
get_oauth_identity,link_oauth_identity,create_oauth_user.
Prérequis hors code (côté admin du projet)
- Créer une application sur le LinkedIn Developer Portal.
- Activer le produit « Sign In with LinkedIn using OpenID Connect ».
- 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)
- 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).