diff --git a/back/.env.example b/back/.env.example index aa5a73b..f759839 100644 --- a/back/.env.example +++ b/back/.env.example @@ -60,6 +60,16 @@ SMTP_FROM=votre.adresse.verifiee@domaine.com NODE_ENV=development +# ------------------------------ +# 🔑 Google OAuth (Section VIII) +# ------------------------------ +# Liste des Client IDs OAuth 2.0 créés dans Google Cloud Console, sĂ©parĂ©s par +# des virgules (un par plateforme front : iOS, Android, Web, Expo Go — voir +# front/.env.example). Vide → POST /api/auth/google rĂ©pond 501. +# Exemple : GOOGLE_CLIENT_IDS=xxxx-ios.apps.googleusercontent.com,xxxx-android.apps.googleusercontent.com,xxxx-web.apps.googleusercontent.com +GOOGLE_CLIENT_IDS= + + # ------------------------------ # 🌐 CORS (production) # ------------------------------ diff --git a/back/config/env.js b/back/config/env.js index aaf5015..7c69bf6 100644 --- a/back/config/env.js +++ b/back/config/env.js @@ -18,9 +18,20 @@ const config = { // ── Google OAuth (Section VIII) ───────────────────────────────────────────── // Non requis pour dĂ©marrer le serveur (contrairement Ă  mongoUri/jwtSecret) : - // tant que la variable n'est pas dĂ©finie, /api/auth/google rĂ©pond 501 + // tant que GOOGLE_CLIENT_IDS est vide, /api/auth/google rĂ©pond 501 // "non configurĂ©" au lieu de planter tout le serveur au dĂ©marrage. - googleClientId: process.env.GOOGLE_CLIENT_ID || null, + // + // Liste (pas un seul ID) : le front obtient un idToken via l'un de PLUSIEURS + // Client IDs selon la plateforme (iOS/Android/Web/Expo Go — voir + // front/.env.example), et le claim `aud` du token contient EXACTEMENT celui + // qui l'a Ă©mis. verifyIdToken doit donc accepter cette liste complĂšte comme + // audience valide, sinon la connexion Ă©chouerait silencieusement sur toutes + // les plateformes sauf une. GOOGLE_CLIENT_ID (singulier) reste supportĂ© pour + // compat ascendante si un seul ID est configurĂ©. + googleClientIds: (process.env.GOOGLE_CLIENT_IDS || process.env.GOOGLE_CLIENT_ID || "") + .split(",") + .map((id) => id.trim()) + .filter(Boolean), }; if (!config.mongoUri) { diff --git a/back/services/auth.service.js b/back/services/auth.service.js index f07d72c..f08c621 100644 --- a/back/services/auth.service.js +++ b/back/services/auth.service.js @@ -214,7 +214,7 @@ class AuthService { * mot de passe alĂ©atoire jamais utilisable pour se connecter autrement). */ async googleLogin(idToken) { - if (!config.googleClientId) { + if (!config.googleClientIds.length) { throw httpError("Connexion Google non configurĂ©e sur ce serveur.", 501, "GOOGLE_OAUTH_NOT_CONFIGURED"); } if (!idToken) { @@ -222,11 +222,13 @@ class AuthService { } const { OAuth2Client } = require("google-auth-library"); - const client = new OAuth2Client(config.googleClientId); + const client = new OAuth2Client(); let payload; try { - const ticket = await client.verifyIdToken({ idToken, audience: config.googleClientId }); + // audience accepte un tableau : le token peut avoir Ă©tĂ© Ă©mis pour + // n'importe lequel des Client IDs configurĂ©s (iOS/Android/Web/Expo). + const ticket = await client.verifyIdToken({ idToken, audience: config.googleClientIds }); payload = ticket.getPayload(); } catch (_err) { throw httpError("Token Google invalide.", 401, "GOOGLE_TOKEN_INVALID"); diff --git a/back/tests/googleAuth.test.js b/back/tests/googleAuth.test.js index b095838..308946d 100644 --- a/back/tests/googleAuth.test.js +++ b/back/tests/googleAuth.test.js @@ -24,12 +24,12 @@ describe('POST /api/auth/google — connexion Google OAuth (Section VIII)', () = if (mongoose.connection.readyState === 0) { await mongoose.connect(process.env.MONGO_URI); } - originalClientId = config.googleClientId; - config.googleClientId = 'test-client-id.apps.googleusercontent.com'; + originalClientId = config.googleClientIds; + config.googleClientIds = ['test-client-id.apps.googleusercontent.com']; }); afterAll(async () => { - config.googleClientId = originalClientId; + config.googleClientIds = originalClientId; await User.deleteMany({}); await mongoose.connection.close(); }); @@ -101,10 +101,10 @@ describe('POST /api/auth/google — connexion Google OAuth (Section VIII)', () = expect(res.statusCode).toBe(400); }); - it("❌ 501 si Google OAuth n'est pas configurĂ© (pas de GOOGLE_CLIENT_ID)", async () => { - config.googleClientId = null; + it("❌ 501 si Google OAuth n'est pas configurĂ© (pas de GOOGLE_CLIENT_IDS)", async () => { + config.googleClientIds = []; const res = await request(app).post('/api/auth/google').send({ idToken: 'whatever' }); expect(res.statusCode).toBe(501); - config.googleClientId = 'test-client-id.apps.googleusercontent.com'; + config.googleClientIds = ['test-client-id.apps.googleusercontent.com']; }); }); diff --git a/front/src/hooks/useGoogleAuth.js b/front/src/hooks/useGoogleAuth.js index 54794e4..5b19d2e 100644 --- a/front/src/hooks/useGoogleAuth.js +++ b/front/src/hooks/useGoogleAuth.js @@ -1,4 +1,5 @@ -import { useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Platform } from 'react-native'; import * as Google from 'expo-auth-session/providers/google'; import * as WebBrowser from 'expo-web-browser'; import { @@ -13,6 +14,20 @@ import { // certaines plateformes (voir la doc expo-auth-session). WebBrowser.maybeCompleteAuthSession(); +// ClĂ© sessionStorage utilisĂ©e pour vĂ©rifier l'Ă©tat retour d'un flux en plein +// Ă©cran (voir plus bas — PWA installĂ©e). sessionStorage survit Ă  une +// navigation complĂšte de la page (contrairement Ă  un state React), ce qui +// est indispensable ici puisque tout le contexte JS est dĂ©truit pendant que +// Google Sign-In s'affiche. +const PENDING_STATE_KEY = 'athly:google:pending_state:v1'; + +function isStandaloneDisplayMode() { + if (Platform.OS !== 'web' || typeof window === 'undefined') return false; + const mediaStandalone = window.matchMedia?.('(display-mode: standalone)').matches; + const iosStandalone = window.navigator?.standalone === true; // Safari iOS n'a pas display-mode + return Boolean(mediaStandalone || iosStandalone); +} + // ─── useGoogleAuth ──────────────────────────────────────────────────────────── // Connexion Google en un clic (Section VIII) : demande un idToken via le SDK // Expo Auth Session, Ă  envoyer ensuite Ă  POST /api/auth/google (vĂ©rifiĂ© cĂŽtĂ© @@ -21,6 +36,17 @@ WebBrowser.maybeCompleteAuthSession(); // Les Client IDs viennent de .env (voir .env.example pour la marche Ă  suivre // dans Google Cloud Console). Tant qu'ils ne sont pas renseignĂ©s, `isConfigured` // vaut false et le bouton doit rester dĂ©sactivĂ© cĂŽtĂ© Ă©cran appelant. +// +// ── Cas particulier PWA installĂ©e (Section IX) ──────────────────────────── +// expo-auth-session ouvre une popup (window.open + postMessage) pour rĂ©cupĂ©rer +// le rĂ©sultat cĂŽtĂ© web. Ce mĂ©canisme est fragile depuis une PWA en mode +// standalone (surtout iOS Safari "Ajouter Ă  l'Ă©cran d'accueil") : window.open +// y fait souvent sortir l'utilisateur de l'app installĂ©e sans jamais pouvoir +// relayer le message Ă  la fenĂȘtre d'origine, et la connexion reste bloquĂ©e. +// Dans ce cas prĂ©cis, on bascule sur une redirection plein Ă©cran (navigue +// loin de l'app, puis revient dessus) : l'idToken est alors rĂ©cupĂ©rĂ© au +// prochain montage du hook via le hash de l'URL de retour, pas via la Promise +// de promptAsync (qui ne peut pas survivre au rechargement complet de la page). export function useGoogleAuth() { const isConfigured = Boolean( @@ -34,16 +60,57 @@ export function useGoogleAuth() { webClientId: GOOGLE_WEB_CLIENT_ID || undefined, }); + const [redirectIdToken, setRedirectIdToken] = useState(null); + + // RĂ©cupĂšre un Ă©ventuel idToken laissĂ© dans le hash de l'URL par le flux de + // redirection plein Ă©cran (PWA standalone) — vĂ©rifiĂ© une seule fois au + // montage, jamais pendant le flux popup normal (le hash n'existe alors pas + // sur CETTE page, seulement sur la popup). + useEffect(() => { + if (Platform.OS !== 'web' || typeof window === 'undefined') return; + if (!window.location.hash || !window.location.hash.includes('id_token=')) return; + + let expectedState = null; + try { expectedState = window.sessionStorage.getItem(PENDING_STATE_KEY); } catch (_) {} + + const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, '')); + const returnedIdToken = hashParams.get('id_token'); + const returnedState = hashParams.get('state'); + + // Nettoie systĂ©matiquement l'URL — un idToken ne doit jamais rester + // visible dans la barre d'adresse ou l'historique du navigateur. + window.history.replaceState(null, '', window.location.pathname + window.location.search); + try { window.sessionStorage.removeItem(PENDING_STATE_KEY); } catch (_) {} + + // Anti-CSRF : le state retournĂ© doit correspondre exactement Ă  celui + // gĂ©nĂ©rĂ© avant la redirection (voir handlePromptAsync ci-dessous). + if (returnedIdToken && expectedState && returnedState === expectedState) { + setRedirectIdToken(returnedIdToken); + } + }, []); + const idToken = useMemo(() => { + if (redirectIdToken) return redirectIdToken; if (!response || response.type !== 'success') return null; return response.authentication?.idToken || response.params?.id_token || null; - }, [response]); + }, [response, redirectIdToken]); + + const handlePromptAsync = useCallback(async (...args) => { + if (isStandaloneDisplayMode() && request?.url && request?.state) { + try { window.sessionStorage.setItem(PENDING_STATE_KEY, request.state); } catch (_) {} + window.location.assign(request.url); + // La navigation dĂ©truit ce contexte JS avant toute rĂ©solution possible — + // le rĂ©sultat sera repris par l'effet ci-dessus au prochain montage. + return { type: 'opened' }; + } + return promptAsync(...args); + }, [request, promptAsync]); return { isConfigured, request, response, idToken, - promptAsync, + promptAsync: handlePromptAsync, }; } diff --git a/front/src/screens/Auth/LoginScreen.js b/front/src/screens/Auth/LoginScreen.js index 0ae90bd..f535d91 100644 --- a/front/src/screens/Auth/LoginScreen.js +++ b/front/src/screens/Auth/LoginScreen.js @@ -31,7 +31,7 @@ function FadeLoader() { export default function LoginScreen({ navigation }) { const { signIn } = useAuth(); - const { isConfigured: googleConfigured, idToken, promptAsync } = useGoogleAuth(); + const { isConfigured: googleConfigured, request: googleRequest, idToken, promptAsync } = useGoogleAuth(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -205,7 +205,7 @@ export default function LoginScreen({ navigation }) { {googleLoading diff --git a/front/vercel.json b/front/vercel.json new file mode 100644 index 0000000..e4637d2 --- /dev/null +++ b/front/vercel.json @@ -0,0 +1,7 @@ +{ + "buildCommand": "npm run build:web", + "outputDirectory": "dist", + "rewrites": [ + { "source": "/(.*)", "destination": "/index.html" } + ] +}