fix(oauth): déduire l'URI de redirection du domaine public réel

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Db2EazEcvnqgTTeg8oGTif
This commit is contained in:
Claude committed 2026-09-11 18:01:28 +00:00
1 parent 94e961d0f1
commit 3af7f9418b
9 files changed
+119 -27

No files matched your search

+9 -2
View File
@@ -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).
+21
View File
@@ -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`
+4 -1
View File
@@ -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
+4 -2
View File
@@ -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
+4 -4
View File
@@ -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) {
+4 -4
View File
@@ -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;
+13 -7
View File
@@ -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<string> {
async buildAuthorizationUrl(state: string, req?: Request): Promise<string> {
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<FacebookLongLivedToken> {
async exchangeCodeForUserToken(code: string, req?: Request): 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(),
redirect_uri: this.getRedirectUri(req),
code,
}, "échange du code d'autorisation");
+13 -7
View File
@@ -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<string> {
async buildAuthorizationUrl(state: string, req?: Request): Promise<string> {
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<TiktokTokenResponse> {
async exchangeCodeForToken(code: string, req?: Request): Promise<TiktokTokenResponse> {
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),
});
}
+47
View File
@@ -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;
}