From 3af7f9418b077df57537471f8ca6bb22d92481b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 18:01:28 +0000 Subject: [PATCH] =?UTF-8?q?fix(oauth):=20d=C3=A9duire=20l'URI=20de=20redir?= =?UTF-8?q?ection=20du=20domaine=20public=20r=C3=A9el?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sans APP_URL, Facebook et TikTok recevaient http://localhost:5555/api/{facebook,tiktok}/callback : une URI que les fournisseurs refusent, donc aucune page connectable derrière un reverse proxy. Le repli localhost n'est plus utilisé que hors requête HTTP ; en service, la base publique est reconstruite depuis X-Forwarded-Proto / X-Forwarded-Host (trust proxy est déjà activé). - `resolvePublicBaseUrl(req)` centralise la résolution (APP_URL prioritaire) - l'URI de redirection est déduite de la requête au démarrage du flux, à l'échange du code et dans l'affichage des paramètres : les trois restent identiques, comme l'exige OAuth - docker-compose ne force plus APP_URL à localhost, qui rendait l'auto détection inopérante - cookie de session en `secure: 'auto'` à défaut d'APP_URL, pour ne pas émettre un cookie non sécurisé derrière un proxy HTTPS Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Db2EazEcvnqgTTeg8oGTif --- .env.example | 11 ++++++-- DOCKER.md | 21 ++++++++++++++ docker-compose.yml | 5 +++- server/index.ts | 6 ++-- server/routes/facebook.ts | 8 +++--- server/routes/tiktok.ts | 8 +++--- server/services/facebook_oauth.ts | 20 ++++++++----- server/services/tiktok.ts | 20 ++++++++----- server/utils/public_url.ts | 47 +++++++++++++++++++++++++++++++ 9 files changed, 119 insertions(+), 27 deletions(-) create mode 100644 server/utils/public_url.ts diff --git a/.env.example b/.env.example index 0751ede..4fede88 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,11 @@ DATABASE_URL=postgresql://socialflow:changeme@postgres:4523/socialflow # Configuration de l'application PORT=5555 NODE_ENV=production +# URL publique de l'application, telle que vue par le navigateur. +# En production, mettez le vrai domaine (ex. https://socialflow.exemple.fr) : +# les URI de redirection OAuth (Facebook/TikTok) en découlent. +# Laissée vide derrière un reverse proxy, elle est déduite des en-têtes +# X-Forwarded-Proto / X-Forwarded-Host. APP_URL=http://localhost:5555 # Clé secrète pour les sessions (CHANGEZ CETTE VALEUR) @@ -41,7 +46,8 @@ MINIO_PUBLIC_URL=https://media.example.com # FACEBOOK_APP_SECRET= # # URI de redirection OAuth à déclarer chez Facebook. -# Par défaut : ${APP_URL}/api/facebook/callback +# Par défaut : ${APP_URL}/api/facebook/callback, ou le domaine vu par le +# reverse proxy quand APP_URL n'est pas défini. # FACEBOOK_REDIRECT_URI= # # Permissions demandées à l'autorisation. Défaut : pages_show_list, @@ -66,7 +72,8 @@ MINIO_PUBLIC_URL=https://media.example.com # TIKTOK_CLIENT_SECRET= # # URL de redirection OAuth à déclarer chez TikTok. -# Par défaut : ${APP_URL}/api/tiktok/callback +# Par défaut : ${APP_URL}/api/tiktok/callback, ou le domaine vu par le +# reverse proxy quand APP_URL n'est pas défini. # TIKTOK_REDIRECT_URI= # # Scopes demandés à l'autorisation (défaut : user.info.basic,video.publish). diff --git a/DOCKER.md b/DOCKER.md index e735565..484ea7c 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -136,6 +136,27 @@ docker-compose down docker-compose down -v ``` +## 🔗 URL publique et connexions OAuth (Facebook / TikTok) + +Les boutons « Connecter une page » construisent une URI de redirection à partir +de l'URL publique de l'application. Renseignez donc `APP_URL` avec le domaine +réel : + +```bash +APP_URL=https://socialflow.exemple.fr +``` + +Puis déclarez l'URI correspondante chez le fournisseur : + +- Facebook : *Connexion Facebook → Paramètres → URI de redirection OAuth valides* + → `https://socialflow.exemple.fr/api/facebook/callback` +- TikTok : *Login Kit → Redirect URI* → `https://socialflow.exemple.fr/api/tiktok/callback` + +Si `APP_URL` est absent, l'URL est déduite des en-têtes `X-Forwarded-Proto` et +`X-Forwarded-Host` envoyés par Nginx — vérifiez que le proxy les transmet +(voir la configuration Nginx ci-dessus). Un `APP_URL` réglé sur +`http://localhost:5555` en production produit une URI que Facebook refuse. + ## 🔒 Sécurité en production 1. **Variables d'environnement** : Ne commitez JAMAIS le fichier `.env` diff --git a/docker-compose.yml b/docker-compose.yml index 97c95f8..e29a1dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,7 +38,10 @@ services: OPENROUTER_API_KEY: ${OPENROUTER_API_KEY} GEMINI_API_KEY: ${GEMINI_API_KEY} SESSION_SECRET: ${SESSION_SECRET:-your-secret-key-change-me} - APP_URL: ${APP_URL:-http://localhost:5555} + # URL publique (ex. https://socialflow.exemple.fr). Laissée vide, elle est + # déduite des en-têtes du reverse proxy — ne jamais forcer localhost ici, + # les URI de redirection OAuth (Facebook/TikTok) en dépendent. + APP_URL: ${APP_URL:-} # URL interne Docker pour que ffmpeg-api puisse télécharger les fichiers audio INTERNAL_APP_URL: http://socialflow-app:5555 # Configuration FFmpeg Service diff --git a/server/index.ts b/server/index.ts index d015e68..7ba3bec 100644 --- a/server/index.ts +++ b/server/index.ts @@ -106,8 +106,10 @@ if (!process.env.SESSION_SECRET) { // Générer un secret aléatoire pour le dev si non défini const sessionSecret = process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex'); -// Déterminer si on utilise HTTPS basé sur APP_URL -const isHttps = process.env.APP_URL?.startsWith('https://') || false; +// HTTPS déduit d'APP_URL ; à défaut, 'auto' laisse express-session décider par +// requête à partir de X-Forwarded-Proto (cf. `trust proxy` plus haut), ce qui +// évite un cookie non sécurisé derrière un reverse proxy HTTPS sans APP_URL. +const isHttps: boolean | 'auto' = process.env.APP_URL?.startsWith('https://') || 'auto'; // Configuration du store de session pour production const sessionStore = process.env.NODE_ENV === 'production' && process.env.DATABASE_URL diff --git a/server/routes/facebook.ts b/server/routes/facebook.ts index 952dcb1..2f4c742 100644 --- a/server/routes/facebook.ts +++ b/server/routes/facebook.ts @@ -50,7 +50,7 @@ facebookRouter.get('/config', async (req: Request, res: Response) => { configured, appId: config?.appId || (process.env.FACEBOOK_APP_ID ? "(défini par variable d'environnement)" : ''), hasAppSecret: !!(config?.appSecret || process.env.FACEBOOK_APP_SECRET), - redirectUri: facebookOAuthService.getRedirectUri(), + redirectUri: facebookOAuthService.getRedirectUri(req), scopes: facebookOAuthService.getScopes().split(','), }); } catch (error) { @@ -76,7 +76,7 @@ facebookRouter.put('/config', async (req: Request, res: Response) => { res.json({ success: true, appId: config.appId, - redirectUri: facebookOAuthService.getRedirectUri(), + redirectUri: facebookOAuthService.getRedirectUri(req), }); } catch (error: any) { console.error('❌ [Facebook] Erreur enregistrement configuration:', error); @@ -102,7 +102,7 @@ facebookRouter.get('/connect', async (req: Request, res: Response) => { const state = crypto.randomBytes(16).toString('hex'); (req.session as any).facebookOAuthState = state; - const authUrl = await facebookOAuthService.buildAuthorizationUrl(state); + const authUrl = await facebookOAuthService.buildAuthorizationUrl(state, req); // La session doit être écrite avant la redirection, sinon l'état est perdu req.session.save((err) => { @@ -145,7 +145,7 @@ facebookRouter.get('/callback', async (req: Request, res: Response) => { return res.redirect(buildReturnUrl('error', "Aucun code d'autorisation reçu de Facebook.")); } - const userToken = await facebookOAuthService.exchangeCodeForUserToken(code); + const userToken = await facebookOAuthService.exchangeCodeForUserToken(code, req); const targets = await facebookOAuthService.listPages(userToken.accessToken); if (targets.length === 0) { diff --git a/server/routes/tiktok.ts b/server/routes/tiktok.ts index c13453f..2264354 100644 --- a/server/routes/tiktok.ts +++ b/server/routes/tiktok.ts @@ -47,7 +47,7 @@ tiktokRouter.get('/config', async (req: Request, res: Response) => { configured, clientKey: config?.clientKey || (process.env.TIKTOK_CLIENT_KEY ? '(défini par variable d\'environnement)' : ''), hasClientSecret: !!(config?.clientSecret || process.env.TIKTOK_CLIENT_SECRET), - redirectUri: tiktokService.getRedirectUri(), + redirectUri: tiktokService.getRedirectUri(req), }); } catch (error) { console.error('❌ [TikTok] Erreur lecture configuration:', error); @@ -72,7 +72,7 @@ tiktokRouter.put('/config', async (req: Request, res: Response) => { res.json({ success: true, clientKey: config.clientKey, - redirectUri: tiktokService.getRedirectUri(), + redirectUri: tiktokService.getRedirectUri(req), }); } catch (error: any) { console.error('❌ [TikTok] Erreur enregistrement configuration:', error); @@ -98,7 +98,7 @@ tiktokRouter.get('/connect', async (req: Request, res: Response) => { const state = crypto.randomBytes(16).toString('hex'); (req.session as any).tiktokOAuthState = state; - const authUrl = await tiktokService.buildAuthorizationUrl(state); + const authUrl = await tiktokService.buildAuthorizationUrl(state, req); // La session doit être écrite avant la redirection, sinon l'état est perdu req.session.save((err) => { @@ -141,7 +141,7 @@ tiktokRouter.get('/callback', async (req: Request, res: Response) => { return res.redirect(buildReturnUrl('error', "Aucun code d'autorisation reçu de TikTok.")); } - const tokens = await tiktokService.exchangeCodeForToken(code); + const tokens = await tiktokService.exchangeCodeForToken(code, req); const profile = await tiktokService.getUserInfo(tokens.access_token); const openId = profile.openId || tokens.open_id; diff --git a/server/services/facebook_oauth.ts b/server/services/facebook_oauth.ts index a5b52da..d412c9d 100644 --- a/server/services/facebook_oauth.ts +++ b/server/services/facebook_oauth.ts @@ -21,7 +21,9 @@ * chaîne se renouvelle toute seule. */ +import type { Request } from 'express'; import { storage } from '../storage'; +import { resolvePublicBaseUrl } from '../utils/public_url'; const GRAPH_VERSION = process.env.FACEBOOK_GRAPH_VERSION?.trim() || 'v19.0'; const GRAPH_URL = `https://graph.facebook.com/${GRAPH_VERSION}`; @@ -99,12 +101,16 @@ export class FacebookOAuthService { } } - getRedirectUri(): string { + /** + * URI de redirection OAuth. `req` permet de la déduire du domaine réellement + * utilisé quand `APP_URL` n'est pas défini : sans lui on renverrait + * `http://localhost:5555`, que Facebook rejette. + */ + getRedirectUri(req?: Request): 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`; + return `${resolvePublicBaseUrl(req)}/api/facebook/callback`; } getScopes(): string { @@ -112,12 +118,12 @@ export class FacebookOAuthService { } /** URL d'autorisation à ouvrir pour connecter les pages d'un compte Facebook. */ - async buildAuthorizationUrl(state: string): Promise { + async buildAuthorizationUrl(state: string, req?: Request): Promise { const { appId } = await this.getCredentials(); const params = new URLSearchParams({ client_id: appId, - redirect_uri: this.getRedirectUri(), + redirect_uri: this.getRedirectUri(req), state, scope: this.getScopes(), response_type: 'code', @@ -131,13 +137,13 @@ export class FacebookOAuthService { * 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 { + async exchangeCodeForUserToken(code: string, req?: Request): Promise { 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(), + redirect_uri: this.getRedirectUri(req), code, }, "échange du code d'autorisation"); diff --git a/server/services/tiktok.ts b/server/services/tiktok.ts index b46cd85..246b656 100644 --- a/server/services/tiktok.ts +++ b/server/services/tiktok.ts @@ -15,7 +15,9 @@ * compte). Le code s'adapte automatiquement à ce que le compte autorise. */ +import type { Request } from 'express'; import { storage } from '../storage'; +import { resolvePublicBaseUrl } from '../utils/public_url'; import type { SocialPage } from '@shared/schema'; const AUTH_BASE_URL = 'https://www.tiktok.com/v2/auth/authorize/'; @@ -98,26 +100,30 @@ export class TiktokService { } } - getRedirectUri(): string { + /** + * URI de redirection OAuth. `req` sert de repli quand `APP_URL` n'est pas + * défini : le domaine public est alors lu sur la requête plutôt que remplacé + * par `http://localhost:5555`. + */ + getRedirectUri(req?: Request): string { if (process.env.TIKTOK_REDIRECT_URI) { return process.env.TIKTOK_REDIRECT_URI; } - const base = (process.env.APP_URL || 'http://localhost:5555').replace(/\/$/, ''); - return `${base}/api/tiktok/callback`; + return `${resolvePublicBaseUrl(req)}/api/tiktok/callback`; } /** * URL d'autorisation à ouvrir pour connecter le compte TikTok d'un magasin. * Chaque magasin répète ce flux : un compte = une ligne dans social_pages. */ - async buildAuthorizationUrl(state: string): Promise { + async buildAuthorizationUrl(state: string, req?: Request): Promise { const { clientKey } = await this.getCredentials(); const params = new URLSearchParams({ client_key: clientKey, scope: process.env.TIKTOK_SCOPES || DEFAULT_SCOPES, response_type: 'code', - redirect_uri: this.getRedirectUri(), + redirect_uri: this.getRedirectUri(req), state, }); @@ -125,7 +131,7 @@ export class TiktokService { } /** Échange le code d'autorisation contre un couple access/refresh token. */ - async exchangeCodeForToken(code: string): Promise { + async exchangeCodeForToken(code: string, req?: Request): Promise { const { clientKey, clientSecret } = await this.getCredentials(); return await this.requestToken({ @@ -134,7 +140,7 @@ export class TiktokService { // TikTok renvoie le code URL-encodé dans la query string code: decodeURIComponent(code), grant_type: 'authorization_code', - redirect_uri: this.getRedirectUri(), + redirect_uri: this.getRedirectUri(req), }); } diff --git a/server/utils/public_url.ts b/server/utils/public_url.ts new file mode 100644 index 0000000..c721e8b --- /dev/null +++ b/server/utils/public_url.ts @@ -0,0 +1,47 @@ +/** + * URL publique de l'application. + * + * Les flux OAuth (Facebook, TikTok) exigent une URI de redirection *exacte* : + * celle envoyée au moment de l'autorisation doit être identique à celle + * envoyée lors de l'échange du code, et déclarée telle quelle chez le + * fournisseur. Se rabattre en silence sur `http://localhost:5555` quand + * `APP_URL` n'est pas défini produit une URI que Facebook refuse — et + * l'erreur n'apparaît qu'au moment de connecter une page. + * + * On préfère donc, à défaut d'`APP_URL`, reconstruire l'URL à partir de la + * requête entrante : derrière Nginx, les en-têtes `X-Forwarded-Proto` et + * `X-Forwarded-Host` portent le domaine public réel (`trust proxy` est activé + * dans `server/index.ts`). Le repli localhost ne sert plus qu'au développement + * local, hors requête HTTP. + */ + +import type { Request } from 'express'; + +const DEV_FALLBACK = 'http://localhost:5555'; + +/** Premier élément d'un en-tête potentiellement chaîné (« a, b, c »). */ +function firstHeaderValue(value: string | undefined): string | undefined { + const first = value?.split(',')[0]?.trim(); + return first || undefined; +} + +/** + * Base publique sans slash final (« https://exemple.fr »). + * `APP_URL` prime toujours ; sinon la requête fait foi. + */ +export function resolvePublicBaseUrl(req?: Request): string { + const configured = process.env.APP_URL?.trim(); + if (configured) { + return configured.replace(/\/$/, ''); + } + + if (req) { + const host = firstHeaderValue(req.get('x-forwarded-host')) || req.get('host'); + if (host) { + const protocol = firstHeaderValue(req.get('x-forwarded-proto')) || req.protocol || 'http'; + return `${protocol}://${host}`; + } + } + + return DEV_FALLBACK; +}