mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
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:
9 files changed
+119
-27
No files matched your search
+9
-2
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user