Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 desource .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 userpassword_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_hashnullable ; tableoauth_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 existantsdb.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:
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_userinsrc/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— instanceauthlib.integrations.flask_client.OAuth(registre).src.auth.oauth.init_oauth(app: Flask) -> None— init + register du providerlinkedin.init_authappelleinit_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_authcan 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.)
- LinkedIn Developer Portal → create app → add product « Sign In with LinkedIn using OpenID Connect ».
- Authorized redirect URLs:
http://localhost:8050/auth/linkedin/callbackhttps://test.decp.info/auth/linkedin/callbackhttps://decp.info/auth/linkedin/callback
- Copy Client ID / Secret into each environment's
.env(LINKEDIN_CLIENT_ID,LINKEDIN_CLIENT_SECRET). - Ensure
APP_BASE_URLmatches the deployment origin in each.env.