Files
colibre/docs/superpowers/plans/2026-06-24-linkedin-oauth.md
T
2026-06-24 22:58:28 +02:00

27 KiB

Connexion avec LinkedIn (OIDC) — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Permettre la création de compte / connexion via LinkedIn (OIDC) en parallèle de l'auth email+mot de passe existante.

Architecture: Authlib (intégration Flask) gère le flux OIDC LinkedIn. Deux routes (/auth/linkedin, /auth/linkedin/callback) s'ajoutent au blueprint auth existant. Une fonction resolve_oauth_user relie l'identité LinkedIn à un compte (existant par email, ou nouveau). Le schéma SQLite gagne une table oauth_identities et rend password_hash nullable.

Tech Stack: Flask, Flask-Login, Authlib, SQLite, Dash (pages), pytest.

Global Constraints

  • Imports internes toujours préfixés src. (ex. from src.auth import db).
  • Tests : uv run pytest (pas de source .venv/bin/activate).
  • Provider unique : linkedin. Pas d'abstraction multi-provider.
  • On ne stocke que l'email (pas de nom/photo).
  • Liaison automatique par email (l'email LinkedIn est vérifié).
  • Libellé du bouton : exactement « Connexion avec LinkedIn ».
  • Couleur du bouton : fond rgb(10, 102, 194), texte blanc.
  • Discovery OIDC LinkedIn : https://www.linkedin.com/oauth/.well-known/openid-configuration.
  • Scopes : openid profile email.
  • URL de callback construite depuis APP_BASE_URL : {APP_BASE_URL}/auth/linkedin/callback.

Task 1: Schéma DB — password_hash nullable + table oauth_identities + helpers

Files:

  • Modify: src/auth/db.py
  • Test: tests/auth/test_oauth_db.py (create)

Interfaces:

  • Consumes: helpers existants get_conn, init_schema, get_user_by_email, get_user_by_id, _now.

  • Produces:

    • create_oauth_user(email: str) -> int — crée un user password_hash = NULL, email_verified = 1, renvoie l'id.
    • get_oauth_identity(provider: str, subject: str) -> sqlite3.Row | None.
    • link_oauth_identity(provider: str, subject: str, user_id: int) -> None.
    • Schéma : users.password_hash nullable ; table oauth_identities(provider, subject, user_id, created_at) PK (provider, subject).
  • Step 1: Write the failing tests

Create tests/auth/test_oauth_db.py:

import sqlite3

import pytest

from src.auth import db


def test_oauth_identities_table_created(users_db_path):
    db.init_schema()
    tables = {
        r[0]
        for r in db.get_conn().execute(
            "SELECT name FROM sqlite_master WHERE type='table'"
        )
    }
    assert "oauth_identities" in tables


def test_password_hash_is_nullable_on_fresh_schema(users_db_path):
    db.init_schema()
    cols = {r["name"]: r for r in db.get_conn().execute("PRAGMA table_info(users)")}
    assert cols["password_hash"]["notnull"] == 0


def test_create_oauth_user(users_db_path):
    db.init_schema()
    uid = db.create_oauth_user("oauth@example.com")
    row = db.get_user_by_id(uid)
    assert row["email"] == "oauth@example.com"
    assert row["password_hash"] is None
    assert row["email_verified"] == 1


def test_link_and_get_oauth_identity(users_db_path):
    db.init_schema()
    uid = db.create_oauth_user("oauth@example.com")
    assert db.get_oauth_identity("linkedin", "sub-123") is None
    db.link_oauth_identity("linkedin", "sub-123", uid)
    row = db.get_oauth_identity("linkedin", "sub-123")
    assert row["user_id"] == uid


def test_oauth_identity_pk_prevents_duplicate(users_db_path):
    db.init_schema()
    uid = db.create_oauth_user("oauth@example.com")
    db.link_oauth_identity("linkedin", "sub-123", uid)
    with pytest.raises(sqlite3.IntegrityError):
        db.link_oauth_identity("linkedin", "sub-123", uid)


def test_migration_makes_legacy_password_hash_nullable(users_db_path):
    # Simule un schéma hérité où password_hash est NOT NULL.
    conn = db.get_conn()
    conn.executescript(
        """
        CREATE TABLE users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            email TEXT NOT NULL UNIQUE,
            password_hash TEXT NOT NULL,
            email_verified INTEGER NOT NULL DEFAULT 0,
            pending_email TEXT,
            created_at TEXT NOT NULL,
            updated_at TEXT NOT NULL
        );
        CREATE TABLE email_verification_tokens (
            token_hash TEXT PRIMARY KEY,
            user_id INTEGER NOT NULL,
            expires_at TEXT NOT NULL,
            created_at TEXT NOT NULL,
            FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
        );
        """
    )
    now = db._now()
    conn.execute(
        "INSERT INTO users (id, email, password_hash, email_verified, created_at, updated_at)"
        " VALUES (1, 'legacy@example.com', 'hash', 1, ?, ?)",
        (now, now),
    )
    conn.execute(
        "INSERT INTO email_verification_tokens (token_hash, user_id, expires_at, created_at)"
        " VALUES ('tok', 1, ?, ?)",
        (now, now),
    )

    db.init_schema()  # déclenche _migrate

    cols = {r["name"]: r for r in conn.execute("PRAGMA table_info(users)")}
    assert cols["password_hash"]["notnull"] == 0
    # Données préservées, pas de cascade-delete déclenchée pendant la reconstruction.
    assert db.get_user_by_id(1)["email"] == "legacy@example.com"
    assert conn.execute(
        "SELECT COUNT(*) FROM email_verification_tokens"
    ).fetchone()[0] == 1
  • Step 2: Run tests to verify they fail

Run: uv run pytest tests/auth/test_oauth_db.py -v Expected: FAIL (oauth_identities absent, create_oauth_user non défini, password_hash encore notnull=1).

  • Step 3: Update schema and add helpers in src/auth/db.py

In USERS_SCHEMA, change the password_hash line to nullable and append the new table. The users CREATE becomes:

USERS_SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    email TEXT NOT NULL UNIQUE,
    password_hash TEXT,
    email_verified INTEGER NOT NULL DEFAULT 0,
    pending_email TEXT,
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email);

CREATE TABLE IF NOT EXISTS email_verification_tokens (
    token_hash TEXT PRIMARY KEY,
    user_id INTEGER NOT NULL,
    expires_at TEXT NOT NULL,
    created_at TEXT NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS password_reset_tokens (
    token_hash TEXT PRIMARY KEY,
    user_id INTEGER NOT NULL,
    expires_at TEXT NOT NULL,
    created_at TEXT NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS oauth_identities (
    provider TEXT NOT NULL,
    subject TEXT NOT NULL,
    user_id INTEGER NOT NULL,
    created_at TEXT NOT NULL,
    PRIMARY KEY (provider, subject),
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
"""

Replace _migrate with a version that also drops the NOT NULL on legacy DBs, and add the rebuild helper:

def _migrate(conn: sqlite3.Connection) -> None:
    cols = {row["name"]: row for row in conn.execute("PRAGMA table_info(users)")}
    if "pending_email" not in cols:
        conn.execute("ALTER TABLE users ADD COLUMN pending_email TEXT")
        cols = {row["name"]: row for row in conn.execute("PRAGMA table_info(users)")}
    if cols.get("password_hash") and cols["password_hash"]["notnull"] == 1:
        _rebuild_users_password_nullable(conn)


def _rebuild_users_password_nullable(conn: sqlite3.Connection) -> None:
    # SQLite ne peut pas retirer un NOT NULL via ALTER : on reconstruit la table.
    # foreign_keys OFF pour éviter le cascade-delete pendant le DROP.
    conn.execute("PRAGMA foreign_keys = OFF")
    try:
        conn.execute("BEGIN")
        conn.execute(
            """
            CREATE TABLE users_new (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                email TEXT NOT NULL UNIQUE,
                password_hash TEXT,
                email_verified INTEGER NOT NULL DEFAULT 0,
                pending_email TEXT,
                created_at TEXT NOT NULL,
                updated_at TEXT NOT NULL
            )
            """
        )
        conn.execute(
            "INSERT INTO users_new (id, email, password_hash, email_verified, "
            "pending_email, created_at, updated_at) "
            "SELECT id, email, password_hash, email_verified, pending_email, "
            "created_at, updated_at FROM users"
        )
        conn.execute("DROP TABLE users")
        conn.execute("ALTER TABLE users_new RENAME TO users")
        conn.execute(
            "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email)"
        )
        conn.execute("COMMIT")
    except Exception:
        conn.execute("ROLLBACK")
        raise
    finally:
        conn.execute("PRAGMA foreign_keys = ON")

Append the new helpers at the end of src/auth/db.py:

def create_oauth_user(email: str) -> int:
    conn = get_conn()
    now = _now()
    cur = conn.execute(
        "INSERT INTO users (email, password_hash, email_verified, created_at, updated_at) "
        "VALUES (?, NULL, 1, ?, ?)",
        (email.lower(), now, now),
    )
    return cur.lastrowid


def get_oauth_identity(provider: str, subject: str) -> sqlite3.Row | None:
    return (
        get_conn()
        .execute(
            "SELECT * FROM oauth_identities WHERE provider = ? AND subject = ?",
            (provider, subject),
        )
        .fetchone()
    )


def link_oauth_identity(provider: str, subject: str, user_id: int) -> None:
    get_conn().execute(
        "INSERT INTO oauth_identities (provider, subject, user_id, created_at) "
        "VALUES (?, ?, ?, ?)",
        (provider, subject, user_id, _now()),
    )
  • Step 4: Run tests to verify they pass

Run: uv run pytest tests/auth/test_oauth_db.py tests/auth/test_db.py -v Expected: PASS (new tests + existing db tests still green).

  • Step 5: Commit
git add src/auth/db.py tests/auth/test_oauth_db.py
git commit -m "feat(auth): oauth_identities table + password_hash nullable

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 2: Résolution de compte resolve_oauth_user

Files:

  • Modify: src/auth/routes.py
  • Test: tests/auth/test_oauth_resolve.py (create)

Interfaces:

  • Consumes (from Task 1): db.get_oauth_identity, db.link_oauth_identity, db.create_oauth_user, plus existants db.get_user_by_email, db.get_user_by_id, db.set_email_verified.
  • Produces:
    • resolve_oauth_user(provider: str, subject: str, email: str, email_verified: bool) -> User — renvoie l'utilisateur (existant lié, lié par email, ou nouvellement créé).

Placée dans routes.py (et non db.py) car elle construit un User, ce qui éviterait un import circulaire dbmodels.

  • Step 1: Write the failing tests

Create tests/auth/test_oauth_resolve.py:

from werkzeug.security import generate_password_hash

from src.auth import db
from src.auth.models import User
from src.auth.routes import resolve_oauth_user


def test_creates_new_user_when_unknown(users_db_path):
    db.init_schema()
    user = resolve_oauth_user("linkedin", "sub-1", "new@example.com", True)
    assert isinstance(user, User)
    row = db.get_user_by_id(int(user.get_id()))
    assert row["email"] == "new@example.com"
    assert row["password_hash"] is None
    assert row["email_verified"] == 1
    assert db.get_oauth_identity("linkedin", "sub-1")["user_id"] == row["id"]


def test_links_to_existing_email_account(users_db_path):
    db.init_schema()
    uid = db.create_user("alice@example.com", generate_password_hash("password12"))
    db.set_email_verified(uid)

    user = resolve_oauth_user("linkedin", "sub-2", "alice@example.com", True)
    assert int(user.get_id()) == uid
    assert db.get_oauth_identity("linkedin", "sub-2")["user_id"] == uid
    # Le compte garde son mot de passe.
    assert db.get_user_by_id(uid)["password_hash"] is not None


def test_links_and_verifies_unverified_existing_account(users_db_path):
    db.init_schema()
    uid = db.create_user("bob@example.com", generate_password_hash("password12"))
    assert db.get_user_by_id(uid)["email_verified"] == 0

    resolve_oauth_user("linkedin", "sub-3", "bob@example.com", True)
    assert db.get_user_by_id(uid)["email_verified"] == 1


def test_returns_same_user_for_known_identity(users_db_path):
    db.init_schema()
    first = resolve_oauth_user("linkedin", "sub-4", "carol@example.com", True)
    second = resolve_oauth_user("linkedin", "sub-4", "carol@example.com", True)
    assert first.get_id() == second.get_id()
    # Pas de doublon d'identité.
    count = db.get_conn().execute(
        "SELECT COUNT(*) FROM oauth_identities WHERE provider='linkedin' AND subject='sub-4'"
    ).fetchone()[0]
    assert count == 1
  • Step 2: Run tests to verify they fail

Run: uv run pytest tests/auth/test_oauth_resolve.py -v Expected: FAIL with ImportError: cannot import name 'resolve_oauth_user'.

  • Step 3: Implement resolve_oauth_user in src/auth/routes.py

Add after the imports / _DUMMY_HASH definition:

def resolve_oauth_user(
    provider: str, subject: str, email: str, email_verified: bool
) -> User:
    identity = db.get_oauth_identity(provider, subject)
    if identity is not None:
        return User(db.get_user_by_id(identity["user_id"]))

    row = db.get_user_by_email(email)
    if row is not None:
        db.link_oauth_identity(provider, subject, row["id"])
        if email_verified and not row["email_verified"]:
            db.set_email_verified(row["id"])
        return User(db.get_user_by_id(row["id"]))

    user_id = db.create_oauth_user(email)
    db.link_oauth_identity(provider, subject, user_id)
    return User(db.get_user_by_id(user_id))
  • Step 4: Run tests to verify they pass

Run: uv run pytest tests/auth/test_oauth_resolve.py -v Expected: PASS (4 tests).

  • Step 5: Commit
git add src/auth/routes.py tests/auth/test_oauth_resolve.py
git commit -m "feat(auth): resolve_oauth_user (liaison/création de compte OIDC)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 3: Initialisation Authlib + dépendance + variables d'env

Files:

  • Create: src/auth/oauth.py
  • Modify: src/auth/setup.py
  • Modify: pyproject.toml
  • Modify: .template.env
  • Modify: tests/auth/conftest.py

Interfaces:

  • Produces:

    • src.auth.oauth.oauth — instance authlib.integrations.flask_client.OAuth (registre).
    • src.auth.oauth.init_oauth(app: Flask) -> None — init + register du provider linkedin.
    • init_auth appelle init_oauth(app).
  • Consumes: env LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET.

  • Step 1: Add the dependency

In pyproject.toml, add "authlib" to the dependencies list (alphabetical-ish, near email-validator):

  "email-validator",
  "authlib",

Then install it:

Run: uv pip install authlib Expected: installs Authlib successfully.

  • Step 2: Create src/auth/oauth.py
import os

from authlib.integrations.flask_client import OAuth
from flask import Flask

LINKEDIN_DISCOVERY_URL = (
    "https://www.linkedin.com/oauth/.well-known/openid-configuration"
)

oauth = OAuth()


def init_oauth(app: Flask) -> None:
    oauth.init_app(app)
    oauth.register(
        name="linkedin",
        client_id=os.getenv("LINKEDIN_CLIENT_ID"),
        client_secret=os.getenv("LINKEDIN_CLIENT_SECRET"),
        server_metadata_url=LINKEDIN_DISCOVERY_URL,
        client_kwargs={"scope": "openid profile email"},
    )
  • Step 3: Wire it into init_auth

In src/auth/setup.py, after app.register_blueprint(auth_bp) and before/after the CSRF init, add the import and call. Add near the other from src.auth... imports at top:

from src.auth.oauth import init_oauth

And inside init_auth, after app.register_blueprint(auth_bp):

    init_oauth(app)

Also warn if LinkedIn isn't configured — append to the bottom of init_auth, next to the existing BREVO warning:

    if not os.getenv("LINKEDIN_CLIENT_ID"):
        logger.warning(
            "LINKEDIN_CLIENT_ID non défini : la connexion LinkedIn échouera. "
            "Définissez LINKEDIN_CLIENT_ID / LINKEDIN_CLIENT_SECRET dans .env."
        )
  • Step 4: Add env template entries

In .template.env, under the # Comptes utilisateurs section, add:

# Connexion LinkedIn (OpenID Connect) — créer une app sur le LinkedIn Developer Portal,
# activer "Sign In with LinkedIn using OpenID Connect", déclarer le redirect URI
# {APP_BASE_URL}/auth/linkedin/callback
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
  • Step 5: Set test env so init_auth can register the provider

In tests/auth/conftest.py, in the app fixture, set dummy LinkedIn credentials before init_auth(app):

    monkeypatch.setenv("SECRET_KEY", "test-secret-key")
    monkeypatch.setenv("LINKEDIN_CLIENT_ID", "test-client-id")
    monkeypatch.setenv("LINKEDIN_CLIENT_SECRET", "test-client-secret")
    monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050")
    app = Flask(__name__)
  • Step 6: Verify the app still boots under tests

Run: uv run pytest tests/auth/test_app_integration.py -v Expected: PASS (registering the provider is lazy — no network call at init).

  • Step 7: Commit
git add src/auth/oauth.py src/auth/setup.py pyproject.toml .template.env tests/auth/conftest.py
git commit -m "feat(auth): init Authlib + provider LinkedIn (config env)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 4: Routes /auth/linkedin et /auth/linkedin/callback

Files:

  • Modify: src/auth/routes.py
  • Test: tests/auth/test_oauth_routes.py (create)

Interfaces:

  • Consumes: resolve_oauth_user (Task 2), oauth (Task 3), safe_next, login_user, session.

  • Produces: routes GET /auth/linkedin, GET /auth/linkedin/callback. Redirections d'erreur vers /connexion?error=oauth_cancelled|oauth_failed.

  • Step 1: Write the failing tests

Create tests/auth/test_oauth_routes.py:

import pytest

from src.auth import db
from src.auth import oauth as oauth_module


@pytest.fixture
def fake_userinfo(monkeypatch):
    """Patche authorize_access_token pour éviter tout appel réseau."""

    state = {"userinfo": None, "raise": False}

    def _authorize_access_token():
        if state["raise"]:
            raise RuntimeError("token exchange failed")
        return {"userinfo": state["userinfo"]}

    monkeypatch.setattr(
        oauth_module.oauth.linkedin,
        "authorize_access_token",
        _authorize_access_token,
        raising=False,
    )
    return state


def test_login_route_redirects_to_linkedin(client, monkeypatch):
    from flask import redirect

    monkeypatch.setattr(
        oauth_module.oauth.linkedin,
        "authorize_redirect",
        lambda redirect_uri, **kw: redirect("https://www.linkedin.com/oauth/authorize"),
        raising=False,
    )
    resp = client.get("/auth/linkedin")
    assert resp.status_code == 302
    assert "linkedin.com" in resp.headers["Location"]


def test_callback_creates_user_and_logs_in(client, fake_userinfo, users_db_path):
    db.init_schema()
    fake_userinfo["userinfo"] = {
        "sub": "sub-xyz",
        "email": "newbie@example.com",
        "email_verified": True,
    }
    resp = client.get("/auth/linkedin/callback")
    assert resp.status_code == 302
    assert resp.headers["Location"].endswith("/compte/admin")
    assert db.get_user_by_email("newbie@example.com") is not None


def test_callback_without_email_fails(client, fake_userinfo, users_db_path):
    db.init_schema()
    fake_userinfo["userinfo"] = {"sub": "sub-noemail", "email_verified": True}
    resp = client.get("/auth/linkedin/callback")
    assert resp.status_code == 302
    assert "error=oauth_failed" in resp.headers["Location"]


def test_callback_token_error_redirects(client, fake_userinfo, users_db_path):
    db.init_schema()
    fake_userinfo["raise"] = True
    resp = client.get("/auth/linkedin/callback")
    assert resp.status_code == 302
    assert "error=oauth_failed" in resp.headers["Location"]


def test_callback_user_cancelled_redirects(client, users_db_path):
    db.init_schema()
    resp = client.get("/auth/linkedin/callback?error=user_cancelled_login")
    assert resp.status_code == 302
    assert "error=oauth_cancelled" in resp.headers["Location"]
  • Step 2: Run tests to verify they fail

Run: uv run pytest tests/auth/test_oauth_routes.py -v Expected: FAIL (routes 404 / not defined).

  • Step 3: Implement the routes in src/auth/routes.py

Add session to the flask import line:

from flask import Blueprint, redirect, request, session

Add the import of the oauth registry near the top imports:

from src.auth.oauth import oauth

Append the two routes at the end of the file:

@auth_bp.route("/linkedin", methods=["GET"])
def linkedin_login():
    session["oauth_next"] = safe_next(
        request.args.get("next"), fallback="/compte/admin"
    )
    redirect_uri = f"{os.getenv('APP_BASE_URL', '')}/auth/linkedin/callback"
    return oauth.linkedin.authorize_redirect(redirect_uri)


@auth_bp.route("/linkedin/callback", methods=["GET"])
def linkedin_callback():
    next_url = safe_next(session.pop("oauth_next", None), fallback="/compte/admin")
    if request.args.get("error"):
        # L'utilisateur a refusé / annulé l'autorisation côté LinkedIn.
        return _redirect_with_error("/connexion", "oauth_cancelled")
    try:
        token = oauth.linkedin.authorize_access_token()
    except Exception:
        logger.exception("Échec de l'échange de token LinkedIn")
        return _redirect_with_error("/connexion", "oauth_failed")

    userinfo = token.get("userinfo") or {}
    subject = userinfo.get("sub")
    email = (userinfo.get("email") or "").strip().lower()
    if not subject or not email:
        logger.error("Réponse LinkedIn sans sub/email : %s", userinfo)
        return _redirect_with_error("/connexion", "oauth_failed")

    user = resolve_oauth_user(
        "linkedin", subject, email, bool(userinfo.get("email_verified"))
    )
    login_user(user, remember=True)
    return redirect(next_url)

Add the os import at the top of the file if absent:

import os
  • Step 4: Run tests to verify they pass

Run: uv run pytest tests/auth/test_oauth_routes.py -v Expected: PASS (4 tests).

  • Step 5: Run the full auth suite

Run: uv run pytest tests/auth -v Expected: PASS (all auth tests green).

  • Step 6: Commit
git add src/auth/routes.py tests/auth/test_oauth_routes.py
git commit -m "feat(auth): routes /auth/linkedin et callback OIDC

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 5: Bouton « Connexion avec LinkedIn » sur /connexion et /inscription

Files:

  • Modify: src/pages/connexion.py
  • Modify: src/pages/inscription.py
  • Test: tests/auth/test_oauth_routes.py (extend — render assertions)

Interfaces:

  • Consumes: route /auth/linkedin (Task 4).

  • Produces: lien <a href="/auth/linkedin"> stylé, libellé « Connexion avec LinkedIn », + messages d'erreur oauth.

  • Step 1: Write the failing tests

Append to tests/auth/test_oauth_routes.py:

def test_connexion_layout_has_linkedin_button():
    from src.pages.connexion import layout

    html_str = str(layout())
    assert "Connexion avec LinkedIn" in html_str
    assert "/auth/linkedin" in html_str


def test_connexion_layout_shows_oauth_error():
    from src.pages.connexion import layout

    assert "LinkedIn" in str(layout(error="oauth_failed"))


def test_inscription_layout_has_linkedin_button():
    from src.pages.inscription import layout

    html_str = str(layout())
    assert "Connexion avec LinkedIn" in html_str
    assert "/auth/linkedin" in html_str
  • Step 2: Run tests to verify they fail

Run: uv run pytest tests/auth/test_oauth_routes.py -k layout -v Expected: FAIL (button/text absent).

  • Step 3: Add a shared button helper and use it

In src/pages/connexion.py, add the error messages and the button. Extend ERROR_MESSAGES:

ERROR_MESSAGES = {
    "invalid_credentials": "Identifiants invalides.",
    "email_not_verified": "Vérifiez d'abord votre adresse email (consultez votre boîte de réception).",
    "oauth_cancelled": "Connexion LinkedIn annulée.",
    "oauth_failed": "Échec de la connexion via LinkedIn. Réessayez.",
}

Add this helper (above layout) in connexion.py:

def linkedin_button():
    return html.A(
        "Connexion avec LinkedIn",
        href="/auth/linkedin",
        className="btn w-100 mb-2",
        style={"backgroundColor": "rgb(10, 102, 194)", "color": "white"},
    )

In connexion.py layout, insert a separator + button between the html.Form(...) and the html.Hr():

            html.Form(
                ...
            ),
            html.Div("ou", className="text-center text-muted my-2"),
            linkedin_button(),
            html.Hr(),

In src/pages/inscription.py, import the helper and place it likewise. At the top, after the existing imports:

from src.pages.connexion import linkedin_button

In inscription.py layout, insert between the html.Form(...) and html.Hr():

            html.Form(
                ...
            ),
            html.Div("ou", className="text-center text-muted my-2"),
            linkedin_button(),
            html.Hr(),
  • Step 4: Run tests to verify they pass

Run: uv run pytest tests/auth/test_oauth_routes.py -k layout -v Expected: PASS (3 layout tests).

  • Step 5: Manual smoke check (no network)

Run: uv run python -c "from src.pages.connexion import layout; from src.pages.inscription import layout as l2; assert 'Connexion avec LinkedIn' in str(layout()) and 'Connexion avec LinkedIn' in str(l2()); print('ok')" Expected: prints ok.

  • Step 6: Commit
git add src/pages/connexion.py src/pages/inscription.py tests/auth/test_oauth_routes.py
git commit -m "feat(auth): bouton Connexion avec LinkedIn sur connexion/inscription

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 6: Vérification finale

  • Step 1: Run the whole test suite touched by this work

Run: uv run pytest tests/auth -v Expected: all green.

  • Step 2: Confirm the docs/spec prerequisites are accurate

Re-read docs/superpowers/specs/2026-06-24-linkedin-oauth-design.md § Prérequis. Ensure the redirect URIs and env var names match what was implemented (/auth/linkedin/callback, LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET).

  • Step 3: No commit needed if nothing changed. Otherwise commit doc fixes.

Manual setup required before LinkedIn login works in production

(Not code — for the project admin.)

  1. LinkedIn Developer Portal → create app → add product « Sign In with LinkedIn using OpenID Connect ».
  2. Authorized redirect URLs:
    • http://localhost:8050/auth/linkedin/callback
    • https://test.decp.info/auth/linkedin/callback
    • https://decp.info/auth/linkedin/callback
  3. Copy Client ID / Secret into each environment's .env (LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET).
  4. Ensure APP_BASE_URL matches the deployment origin in each .env.