Files
colibre/docs/superpowers/specs/2026-06-24-linkedin-oauth-design.md
Colin Maudry 6038b44fa8 docs(auth): spec connexion LinkedIn (OIDC)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:51:56 +02:00

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

  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 :

    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 emailliaison 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 <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 /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).