docs(auth): plan d'implémentation connexion LinkedIn

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Colin Maudry
2026-06-24 22:58:28 +02:00
parent 6038b44fa8
commit 8037f2733e
@@ -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 <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 `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 <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`):
```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 <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`:
```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 <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`:
```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 <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`.