feat(facebook): renouvellement automatique des jetons de page

Un jeton de page collé à la main meurt au bout de 60 jours — ou du jour au
lendemain si le mot de passe change, si le compte perd son rôle d'admin ou si
Meta révoque l'autorisation — et on ne l'apprenait qu'à la première publication
en échec : le contrôle quotidien existant se contentait de marquer la page
« expired ».

On reprend la chaîne recommandée par Meta, comme dans PleinR : autorisation
OAuth, échange du code contre un jeton utilisateur longue durée (~60 j), puis
dérivation des jetons de page — qui n'expirent pas. Le jeton utilisateur est
conservé chiffré sur chaque page : c'est lui qui permet au TokenManager de
régénérer un jeton révoqué sans intervention humaine, et de se prolonger
lui-même avant échéance.

- facebook_config : App ID / App Secret de l'application, saisis dans les
  paramètres (chiffrés en base) ou par variables d'environnement.
- /api/facebook : configuration, connexion OAuth, callback qui crée ou met à
  jour les pages, contrôle d'une page à la demande, contrôle global.
- TokenManager : /debug_token pour connaître l'état réel du jeton, puis
  renouvellement automatique 15 jours avant l'échéance ou dès révocation ;
  repli sur le contrôle historique tant que l'application n'est pas configurée
  ou si Facebook est injoignable, pour ne pas passer l'UI au rouge sur une
  panne réseau. Le cron passe à deux fois par jour.
- Les comptes Instagram professionnels rattachés à une page reçoivent le même
  traitement, puisqu'ils publient avec le jeton de cette page.
- L'UI montre l'état constaté (jeton valide, bientôt expiré, révoqué), l'erreur
  à corriger et un bouton de contrôle immédiat ; un bandeau signale les pages à
  reconnecter.
- /api/pages ne renvoie plus aucun jeton au navigateur, seulement un indicateur
  de renouvellement automatique.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qns6Gcpd95bCHWUxySh4Ew
This commit is contained in:
Claude committed 2026-09-10 06:11:43 +00:00
1 parent 55e502aa8d
commit d7f002a64f
13 files changed
+1385 -249

No files matched your search

+289
View File
@@ -0,0 +1,289 @@
/**
* Renouvellement des tokens Facebook.
*
* Le problème que ce service résout : un token de page saisi à la main finit
* toujours par mourir (60 jours pour un token issu du Graph Explorer, ou du
* jour au lendemain si le mot de passe change, si l'utilisateur perd son rôle
* d'admin, ou si Meta révoque l'autorisation) — et on ne l'apprend qu'au
* moment où une publication échoue.
*
* La parade est celle recommandée par Meta :
* 1. l'utilisateur autorise l'application (OAuth) ;
* 2. le code est échangé contre un token utilisateur court ;
* 3. ce token court est échangé contre un token utilisateur longue durée
* (~60 jours) — c'est lui que l'on conserve, chiffré ;
* 4. les tokens de page dérivés d'un token utilisateur longue durée
* n'expirent pas.
*
* Conserver le token utilisateur permet de régénérer un token de page sans
* intervention humaine, et de prolonger le token utilisateur lui-même avant
* son échéance : tant que l'utilisateur ne retire pas son autorisation, la
* chaîne se renouvelle toute seule.
*/
import { storage } from '../storage';
const GRAPH_VERSION = process.env.FACEBOOK_GRAPH_VERSION?.trim() || 'v19.0';
const GRAPH_URL = `https://graph.facebook.com/${GRAPH_VERSION}`;
/** Permissions nécessaires pour publier et lire les statistiques d'une page. */
const DEFAULT_SCOPES = [
'pages_show_list',
'pages_manage_posts',
'pages_read_engagement',
'pages_manage_engagement',
'read_insights',
'business_management',
'instagram_basic',
'instagram_content_publish',
].join(',');
export interface FacebookLongLivedToken {
accessToken: string;
/** Nul quand Facebook ne renvoie pas d'échéance (jeton sans expiration). */
expiresAt: Date | null;
}
export interface FacebookPageTarget {
id: string;
name: string;
accessToken: string;
followersCount: number;
/** Compte Instagram professionnel rattaché à la page, s'il existe. */
instagram: { id: string; name: string } | null;
}
/** Résultat de /debug_token : ce que Meta sait vraiment du jeton. */
export interface FacebookTokenInfo {
isValid: boolean;
/** Nul = n'expire pas (cas normal d'un token de page). */
expiresAt: Date | null;
scopes: string[];
error: string | null;
}
export class FacebookAuthError extends Error {
constructor(message: string) {
super(message);
this.name = 'FacebookAuthError';
}
}
export class FacebookOAuthService {
/**
* Identifiants de l'application Facebook (globaux à l'instance).
* La base prime sur les variables d'environnement.
*/
private async getCredentials(): Promise<{ appId: string; appSecret: string }> {
const config = await storage.getFacebookConfig();
const appId = config?.appId || process.env.FACEBOOK_APP_ID;
const appSecret = config?.appSecret || process.env.FACEBOOK_APP_SECRET;
if (!appId || !appSecret) {
throw new FacebookAuthError(
"Facebook n'est pas configuré. Renseignez l'App ID et l'App Secret de " +
'votre application Facebook dans les paramètres.'
);
}
return { appId, appSecret };
}
async isConfigured(): Promise<boolean> {
try {
await this.getCredentials();
return true;
} catch {
return false;
}
}
getRedirectUri(): string {
if (process.env.FACEBOOK_REDIRECT_URI) {
return process.env.FACEBOOK_REDIRECT_URI;
}
const base = (process.env.APP_URL || 'http://localhost:5555').replace(/\/$/, '');
return `${base}/api/facebook/callback`;
}
getScopes(): string {
return process.env.FACEBOOK_SCOPES || DEFAULT_SCOPES;
}
/** URL d'autorisation à ouvrir pour connecter les pages d'un compte Facebook. */
async buildAuthorizationUrl(state: string): Promise<string> {
const { appId } = await this.getCredentials();
const params = new URLSearchParams({
client_id: appId,
redirect_uri: this.getRedirectUri(),
state,
scope: this.getScopes(),
response_type: 'code',
});
return `https://www.facebook.com/${GRAPH_VERSION}/dialog/oauth?${params.toString()}`;
}
/**
* Échange le code d'autorisation contre un token utilisateur **longue durée**.
* L'étape intermédiaire (token court) n'est jamais conservée : seul le token
* longue durée rend les tokens de page permanents.
*/
async exchangeCodeForUserToken(code: string): Promise<FacebookLongLivedToken> {
const { appId, appSecret } = await this.getCredentials();
const shortLived = await this.graphGet<{ access_token?: string }>('/oauth/access_token', {
client_id: appId,
client_secret: appSecret,
redirect_uri: this.getRedirectUri(),
code,
}, "échange du code d'autorisation");
if (!shortLived.access_token) {
throw new FacebookAuthError('Facebook n\'a pas renvoyé de jeton pour ce code d\'autorisation.');
}
return this.extendUserToken(shortLived.access_token);
}
/**
* Prolonge un token utilisateur. Appelé au retour d'OAuth (token court →
* ~60 jours) mais aussi périodiquement : ré-échanger un token longue durée
* encore valide repart pour 60 jours, ce qui évite la reconnexion manuelle.
*/
async extendUserToken(userToken: string): Promise<FacebookLongLivedToken> {
const { appId, appSecret } = await this.getCredentials();
const longLived = await this.graphGet<{ access_token?: string; expires_in?: number }>(
'/oauth/access_token',
{
grant_type: 'fb_exchange_token',
client_id: appId,
client_secret: appSecret,
fb_exchange_token: userToken,
},
'prolongation du jeton utilisateur'
);
if (!longLived.access_token) {
throw new FacebookAuthError("Facebook n'a pas renvoyé de jeton longue durée.");
}
return {
accessToken: longLived.access_token,
// expires_in absent ou nul = jeton sans expiration connue.
expiresAt: longLived.expires_in
? new Date(Date.now() + longLived.expires_in * 1000)
: null,
};
}
/**
* Pages administrées par le compte, avec leur token de page.
* Ces tokens sont permanents lorsqu'ils dérivent d'un token utilisateur
* longue durée : c'est le cœur du renouvellement automatique.
*/
async listPages(userToken: string): Promise<FacebookPageTarget[]> {
const response = await this.graphGet<{
data?: {
id: string;
name: string;
access_token: string;
fan_count?: number;
followers_count?: number;
instagram_business_account?: { id: string; name?: string; username?: string };
}[];
}>(
'/me/accounts',
{
fields: 'id,name,access_token,fan_count,followers_count,instagram_business_account{id,name,username}',
limit: '200',
access_token: userToken,
},
'récupération des pages'
);
return (response.data ?? []).map((page) => ({
id: page.id,
name: page.name,
accessToken: page.access_token,
followersCount: page.followers_count ?? page.fan_count ?? 0,
instagram: page.instagram_business_account
? {
id: page.instagram_business_account.id,
name:
page.instagram_business_account.username ||
page.instagram_business_account.name ||
page.name,
}
: null,
}));
}
/**
* Interroge Meta sur l'état réel d'un jeton. Un simple appel /me ne dit pas
* quand le jeton expire : /debug_token le dit, ce qui permet de renouveler
* *avant* la panne plutôt qu'après.
*/
async inspectToken(token: string): Promise<FacebookTokenInfo> {
const { appId, appSecret } = await this.getCredentials();
const response = await this.graphGet<{
data?: {
is_valid?: boolean;
expires_at?: number;
scopes?: string[];
error?: { message?: string };
};
}>(
'/debug_token',
{
input_token: token,
// Jeton d'application : `app_id|app_secret`, accepté par /debug_token.
access_token: `${appId}|${appSecret}`,
},
'inspection du jeton'
);
const data = response.data ?? {};
return {
isValid: data.is_valid === true,
// `expires_at: 0` signifie « n'expire pas » chez Meta.
expiresAt: data.expires_at ? new Date(data.expires_at * 1000) : null,
scopes: data.scopes ?? [],
error: data.error?.message ?? null,
};
}
private async graphGet<T>(
endpoint: string,
params: Record<string, string>,
context: string
): Promise<T> {
const url = new URL(`${GRAPH_URL}${endpoint}`);
Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value));
let response: Response;
try {
response = await fetch(url.toString());
} catch (error) {
throw new FacebookAuthError(
`Facebook injoignable (${context}) : ${error instanceof Error ? error.message : 'erreur réseau'}`
);
}
const payload: any = await response.json().catch(() => ({}));
if (!response.ok || payload?.error) {
const message = payload?.error?.message || `HTTP ${response.status}`;
throw new FacebookAuthError(`Facebook (${context}) : ${message}`);
}
return payload as T;
}
}
export const facebookOAuthService = new FacebookOAuthService();
+256 -40
View File
@@ -1,10 +1,31 @@
// crypto removed, using shared encryption utility
import { db } from '../db';
import { socialPages, tokenStatusEnum } from '../../shared/schema';
import { eq, and, lte } from 'drizzle-orm';
import { socialPages } from '../../shared/schema';
import { eq } from 'drizzle-orm';
import { GraphAPIClient } from '../utils/graph_client';
import type { SocialPage } from '../../shared/schema';
import { encrypt, decrypt } from '../utils/encryption';
import { facebookOAuthService, FacebookAuthError } from './facebook_oauth';
/**
* Un jeton dont l'échéance est plus proche que cette fenêtre est renouvelé
* sans attendre : il reste largement le temps de réessayer les jours suivants
* si Facebook est indisponible au moment du contrôle.
*/
const RENEWAL_WINDOW_MS = 15 * 24 * 60 * 60 * 1000;
/** En deçà de cette marge, un jeton non renouvelable est signalé « expiring ». */
const WARNING_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
export interface TokenCheckResult {
pageId: string;
pageName: string;
status: 'valid' | 'expiring' | 'expired' | 'error';
/** Vrai quand ce passage a effectivement réécrit un nouveau jeton. */
renewed: boolean;
error?: string;
}
/**
* Service responsible for managing Facebook Page Token lifecycle
@@ -30,8 +51,9 @@ export class TokenManager {
/**
* Checks all active pages for expiring tokens and refreshes them if needed.
*/
static async checkAndRefreshTokens() {
static async checkAndRefreshTokens(): Promise<TokenCheckResult[]> {
console.log('[TokenManager] Starting daily token check...');
const results: TokenCheckResult[] = [];
try {
// Fetch active pages
@@ -41,15 +63,18 @@ export class TokenManager {
for (const page of pages) {
try {
await this.processPageToken(page);
results.push(await this.processPageToken(page));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[TokenManager] Error processing page ${page.pageName} (${page.id}):`, error);
await db.update(socialPages)
.set({
tokenStatus: "error",
lastTokenCheck: new Date()
})
.where(eq(socialPages.id, page.id));
await this.persistStatus(page.id, 'error', message);
results.push({
pageId: page.id,
pageName: page.pageName,
status: 'error',
renewed: false,
error: message,
});
}
}
@@ -57,46 +82,211 @@ export class TokenManager {
} catch (e) {
console.error('[TokenManager] Critical error in checkAndRefreshTokens:', e);
}
return results;
}
private static async processPageToken(page: any) {
/** Contrôle (et renouvellement) d'une seule page, déclenché depuis l'UI. */
static async checkPage(pageId: string): Promise<TokenCheckResult> {
const [page] = await db.select().from(socialPages).where(eq(socialPages.id, pageId));
if (!page) {
throw new Error('Page introuvable');
}
return this.processPageToken(page);
}
private static async processPageToken(page: SocialPage): Promise<TokenCheckResult> {
// Les comptes TikTok ont leur propre cycle de vie : l'access token ne dure
// que 24h et se renouvelle avec le refresh token, pas via la Graph API.
if (page.platform === 'tiktok') {
return await this.processTiktokToken(page);
}
let accessToken = page.accessToken;
// Attempt to decrypt. If fails, assume it's plain text (migration phase)
try {
if (accessToken.includes(':')) {
accessToken = this.decrypt(accessToken);
}
} catch (e) {
// Not encrypted or format error, proceed with original
}
console.log(`[TokenManager] Checking page: ${page.pageName}`);
const accessToken = this.readToken(page.accessToken);
// Sans application Facebook configurée, on ne peut ni interroger
// /debug_token ni régénérer quoi que ce soit : on se rabat sur le
// contrôle historique (le jeton répond-il encore ?).
if (!(await facebookOAuthService.isConfigured())) {
return this.legacyCheck(page, accessToken);
}
let info;
try {
info = await facebookOAuthService.inspectToken(accessToken);
} catch (error) {
// Facebook injoignable : on ne dégrade pas le statut sur un incident
// réseau, sinon toute l'UI passerait au rouge le temps d'une panne.
const message = error instanceof Error ? error.message : String(error);
console.warn(`[TokenManager] Inspection impossible pour ${page.pageName}: ${message}`);
return this.legacyCheck(page, accessToken);
}
const expiresAt = info.expiresAt;
const expiringSoon =
expiresAt !== null && expiresAt.getTime() - Date.now() < RENEWAL_WINDOW_MS;
if (info.isValid && !expiringSoon) {
// Le jeton de page tient : on en profite pour prolonger le jeton
// utilisateur s'il approche de son échéance, car c'est lui qui
// permettra le prochain renouvellement.
await this.extendUserTokenIfNeeded(page);
await this.persistStatus(page.id, 'valid', null, { tokenExpiresAt: expiresAt });
return { pageId: page.id, pageName: page.pageName, status: 'valid', renewed: false };
}
const reason = info.isValid
? `Le jeton expire le ${expiresAt?.toLocaleDateString('fr-FR')}`
: info.error || 'Le jeton a été invalidé par Facebook';
console.warn(`[TokenManager] ${page.pageName}: ${reason} — tentative de renouvellement`);
// Renouvellement automatique : c'est tout l'intérêt d'avoir conservé le
// jeton utilisateur longue durée au moment de la connexion OAuth.
try {
const renewed = await this.renewFromUserToken(page);
if (renewed) {
console.log(`[TokenManager] Jeton renouvelé automatiquement pour ${page.pageName}`);
return { pageId: page.id, pageName: page.pageName, status: 'valid', renewed: true };
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[TokenManager] Renouvellement impossible pour ${page.pageName}: ${message}`);
await this.persistStatus(page.id, info.isValid ? 'expiring' : 'expired', message, {
tokenExpiresAt: expiresAt,
});
return {
pageId: page.id,
pageName: page.pageName,
status: info.isValid ? 'expiring' : 'expired',
renewed: false,
error: message,
};
}
// Pas de jeton utilisateur en base : la page a été ajoutée à la main,
// seule une reconnexion peut la remettre d'aplomb.
const manual =
`${reason}. Reconnectez la page via « Connecter des pages Facebook » ` +
'pour activer le renouvellement automatique.';
const status = info.isValid ? 'expiring' : 'expired';
await this.persistStatus(page.id, status, manual, { tokenExpiresAt: expiresAt });
return { pageId: page.id, pageName: page.pageName, status, renewed: false, error: manual };
}
/**
* Régénère le jeton de page à partir du jeton utilisateur longue durée.
* Renvoie false quand la page n'a pas de jeton utilisateur enregistré.
*/
private static async renewFromUserToken(page: SocialPage): Promise<boolean> {
if (!page.userAccessToken) return false;
const storedUserToken = this.readToken(page.userAccessToken);
// On prolonge d'abord le jeton utilisateur : le ré-échanger repart pour
// ~60 jours, ce qui repousse d'autant la prochaine reconnexion manuelle.
// Un échec ici n'est pas bloquant tant que le jeton actuel fonctionne.
let userToken = storedUserToken;
let userTokenExpiresAt = page.userTokenExpiresAt ?? null;
try {
const extended = await facebookOAuthService.extendUserToken(storedUserToken);
userToken = extended.accessToken;
userTokenExpiresAt = extended.expiresAt;
} catch (error) {
console.warn(
`[TokenManager] Prolongation du jeton utilisateur impossible pour ${page.pageName}:`,
error instanceof Error ? error.message : error
);
}
const targets = await facebookOAuthService.listPages(userToken);
// Une page Instagram est publiée avec le jeton de la page Facebook à
// laquelle son compte professionnel est rattaché.
const match = targets.find(
(target) => target.id === page.pageId || target.instagram?.id === page.pageId
);
if (!match) {
throw new FacebookAuthError(
`La page « ${page.pageName} » n'est plus administrée par le compte Facebook connecté. ` +
'Reconnectez-la avec un compte administrateur de cette page.'
);
}
// Un jeton de page dérivé d'un jeton utilisateur longue durée n'expire
// pas : `expiresAt` reste donc généralement nul.
let pageTokenExpiresAt: Date | null = null;
try {
pageTokenExpiresAt = (await facebookOAuthService.inspectToken(match.accessToken)).expiresAt;
} catch {
// L'inspection est un confort : le jeton fraîchement émis est bon.
}
await db.update(socialPages)
.set({
accessToken: match.accessToken,
tokenExpiresAt: pageTokenExpiresAt,
userAccessToken: encrypt(userToken),
userTokenExpiresAt,
followersCount: match.followersCount || page.followersCount,
tokenStatus: 'valid',
tokenError: null,
lastTokenCheck: new Date(),
})
.where(eq(socialPages.id, page.id));
return true;
}
/**
* Prolonge le jeton utilisateur quand son échéance approche, même si le
* jeton de page se porte bien : c'est ce qui évite de perdre la capacité de
* renouveler le jour où le jeton de page est révoqué.
*/
private static async extendUserTokenIfNeeded(page: SocialPage): Promise<void> {
if (!page.userAccessToken) return;
const expiresAt = page.userTokenExpiresAt ? new Date(page.userTokenExpiresAt).getTime() : null;
if (expiresAt !== null && expiresAt - Date.now() > RENEWAL_WINDOW_MS) return;
try {
const extended = await facebookOAuthService.extendUserToken(this.readToken(page.userAccessToken));
await db.update(socialPages)
.set({
userAccessToken: encrypt(extended.accessToken),
userTokenExpiresAt: extended.expiresAt,
})
.where(eq(socialPages.id, page.id));
console.log(`[TokenManager] Jeton utilisateur prolongé pour ${page.pageName}`);
} catch (error) {
console.warn(
`[TokenManager] Impossible de prolonger le jeton utilisateur de ${page.pageName}:`,
error instanceof Error ? error.message : error
);
}
}
/**
* Contrôle historique, utilisé quand l'application Facebook n'est pas
* configurée : on sait seulement si le jeton répond encore.
*/
private static async legacyCheck(page: SocialPage, accessToken: string): Promise<TokenCheckResult> {
const isValid = await this.verifyToken(accessToken);
if (isValid) {
await db.update(socialPages)
.set({
tokenStatus: "valid",
lastTokenCheck: new Date()
})
.where(eq(socialPages.id, page.id));
} else {
await db.update(socialPages)
.set({
tokenStatus: "expired",
lastTokenCheck: new Date()
})
.where(eq(socialPages.id, page.id));
console.warn(`[TokenManager] Token expired for ${page.pageName}`);
if (!isValid) {
const error =
"Le jeton n'est plus accepté par Facebook. Configurez l'application Facebook " +
'dans les paramètres pour activer le renouvellement automatique, ou saisissez un nouveau jeton.';
await this.persistStatus(page.id, 'expired', error);
return { pageId: page.id, pageName: page.pageName, status: 'expired', renewed: false, error };
}
// Le jeton répond : reste l'échéance déclarée à l'ajout de la page.
const declared = page.tokenExpiresAt ? new Date(page.tokenExpiresAt).getTime() : null;
const status = declared !== null && declared - Date.now() < WARNING_WINDOW_MS ? 'expiring' : 'valid';
await this.persistStatus(page.id, status, null);
return { pageId: page.id, pageName: page.pageName, status, renewed: false };
}
/**
@@ -104,7 +294,7 @@ export class TokenManager {
* On ne teste pas l'access token (il est renouvelé à la demande avant chaque
* publication) mais le refresh token, dont l'expiration déconnecterait le compte.
*/
private static async processTiktokToken(page: any) {
private static async processTiktokToken(page: SocialPage): Promise<TokenCheckResult> {
console.log(`[TokenManager] Checking TikTok account: ${page.pageName}`);
const refreshExpiresAt = page.refreshTokenExpiresAt
@@ -112,21 +302,47 @@ export class TokenManager {
: 0;
let status: "valid" | "expiring" | "expired" = "valid";
let error: string | null = null;
if (!page.refreshToken || (refreshExpiresAt && refreshExpiresAt <= Date.now())) {
status = "expired";
error = "L'autorisation TikTok a expiré : reconnectez le compte.";
console.warn(`[TokenManager] TikTok account ${page.pageName} must be reconnected`);
} else if (refreshExpiresAt && refreshExpiresAt - Date.now() < 30 * 24 * 60 * 60 * 1000) {
// Moins de 30 jours de validité restante
status = "expiring";
}
await this.persistStatus(page.id, status, error);
return { pageId: page.id, pageName: page.pageName, status, renewed: false, error: error ?? undefined };
}
private static async persistStatus(
pageId: string,
status: 'valid' | 'expiring' | 'expired' | 'error',
error: string | null,
extra: { tokenExpiresAt?: Date | null } = {}
): Promise<void> {
await db.update(socialPages)
.set({
tokenStatus: status,
lastTokenCheck: new Date()
tokenError: error,
lastTokenCheck: new Date(),
...(extra.tokenExpiresAt !== undefined ? { tokenExpiresAt: extra.tokenExpiresAt } : {}),
})
.where(eq(socialPages.id, page.id));
.where(eq(socialPages.id, pageId));
}
/**
* Les jetons ont pu être enregistrés en clair (saisie manuelle) ou chiffrés
* (flux OAuth) : `decrypt` renvoie le texte tel quel s'il n'est pas chiffré.
*/
private static readToken(token: string): string {
try {
return this.decrypt(token);
} catch {
return token;
}
}
private static async verifyToken(token: string): Promise<boolean> {