diff --git a/docs/superpowers/plans/2026-06-24-linkedin-oauth.md b/docs/superpowers/plans/2026-06-24-linkedin-oauth.md new file mode 100644 index 0000000..dd02dbb --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-linkedin-oauth.md @@ -0,0 +1,852 @@ +# 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`: + +```python +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: + +```python +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: + +```python +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`: + +```python +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** + +```bash +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 " +``` + +--- + +### 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 `db` → `models`. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/auth/test_oauth_resolve.py`: + +```python +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: + +```python +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** + +```bash +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 " +``` + +--- + +### 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`): + +```toml + "email-validator", + "authlib", +``` + +Then install it: + +Run: `uv pip install authlib` +Expected: installs Authlib successfully. + +- [ ] **Step 2: Create `src/auth/oauth.py`** + +```python +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: + +```python +from src.auth.oauth import init_oauth +``` + +And inside `init_auth`, after `app.register_blueprint(auth_bp)`: + +```python + init_oauth(app) +``` + +Also warn if LinkedIn isn't configured — append to the bottom of `init_auth`, next to the existing BREVO warning: + +```python + 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: + +```bash +# 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)`: + +```python + 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** + +```bash +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 " +``` + +--- + +### 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`: + +```python +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: + +```python +from flask import Blueprint, redirect, request, session +``` + +Add the import of the oauth registry near the top imports: + +```python +from src.auth.oauth import oauth +``` + +Append the two routes at the end of the file: + +```python +@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: + +```python +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** + +```bash +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 " +``` + +--- + +### 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 `` stylé, libellé « Connexion avec LinkedIn », + messages d'erreur oauth. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/auth/test_oauth_routes.py`: + +```python +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`: + +```python +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`: + +```python +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()`: + +```python + 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: + +```python +from src.pages.connexion import linkedin_button +``` + +In `inscription.py` `layout`, insert between the `html.Form(...)` and `html.Hr()`: + +```python + 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** + +```bash +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 " +``` + +--- + +### 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`.