Files
PleinR/src/app/api/social/[network]/connect/route.ts
T
Claude 19d1104ab0 Connexion Facebook / LinkedIn en OAuth depuis le backoffice
Les jetons se posaient à la main dans docker-compose. Un écran Backend ›
Réseaux sociaux permet désormais de coller les identifiants de l'application,
cliquer Connecter, choisir la page, et ne plus y revenir.

- Table `social_accounts` : identifiants, jetons et cible par réseau. Tous les
  secrets sont chiffrés en AES-256-GCM (`src/lib/crypto.ts`), clé
  `SOCIAL_TOKEN_KEY` avec repli sur `AUTH_SECRET`.
- Routes `api/social/[network]/{connect,callback}` : `state` anti-CSRF en cookie
  httpOnly, échange du code, récupération des pages administrées. Aucun jeton ne
  transite par une URL — les cibles sont relistées côté serveur au moment de la
  sélection.
- Facebook : jeton utilisateur longue durée puis jeton de PAGE, qui n'expire
  pas. LinkedIn : jeton 60 jours, rafraîchi automatiquement si l'application a
  obtenu les jetons de rafraîchissement programmatiques.
- Faute de quoi le backoffice affiche la date d'expiration, un bandeau sur le
  tableau de bord à J-7 et un bouton Reconnecter.
- `social.ts` lit les identifiants via `social-accounts.ts` : base d'abord,
  variables d'environnement ensuite. Les installations existantes continuent de
  fonctionner sans modification.
- Le secret d'application n'est jamais renvoyé au navigateur : champ vide =
  valeur conservée.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-07-24 23:00:08 +00:00

63 lines
1.8 KiB
TypeScript

import { randomBytes } from "node:crypto";
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { can } from "@/lib/rbac";
import {
authorizeUrl,
getDecryptedAppSecret,
getSocialAccount,
siteUrl,
SOCIAL_NETWORKS,
type SocialNetwork,
} from "@/lib/social-accounts";
export const dynamic = "force-dynamic";
const SETTINGS = "/backend/reseaux";
function back(request: Request, error: string) {
return NextResponse.redirect(new URL(`${SETTINGS}?error=${encodeURIComponent(error)}`, request.url));
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ network: string }> }
) {
const session = await auth();
if (!can(session?.user.role, "manageSettings")) {
return NextResponse.redirect(new URL("/backend", request.url));
}
const { network: raw } = await params;
const network = raw as SocialNetwork;
if (!SOCIAL_NETWORKS.includes(network)) {
return back(request, "Réseau inconnu.");
}
if (!siteUrl()) {
return back(
request,
"L'URL publique du site n'est pas configurée (NEXT_PUBLIC_SITE_URL) : impossible de construire l'adresse de retour."
);
}
const account = await getSocialAccount(network);
const appSecret = await getDecryptedAppSecret(network);
if (!account?.appId || !appSecret) {
return back(request, "Enregistrez d'abord l'identifiant et le secret de l'application.");
}
// `state` en cookie httpOnly : vérifié au retour pour écarter toute requête
// de rappel forgée.
const state = randomBytes(24).toString("hex");
const response = NextResponse.redirect(authorizeUrl(network, account.appId, state));
response.cookies.set(`plr_oauth_${network}`, state, {
httpOnly: true,
sameSite: "lax",
secure: new URL(siteUrl()).protocol === "https:",
path: "/",
maxAge: 600,
});
return response;
}