Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions back/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ------------------------------
Expand Down
15 changes: 13 additions & 2 deletions back/config/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 5 additions & 3 deletions back/services/auth.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -214,19 +214,21 @@ 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) {
throw httpError("idToken manquant.", 400, "GOOGLE_TOKEN_MISSING");
}

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");
Expand Down
12 changes: 6 additions & 6 deletions back/tests/googleAuth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down Expand Up @@ -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'];
});
});
73 changes: 70 additions & 3 deletions front/src/hooks/useGoogleAuth.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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é
Expand All @@ -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(
Expand All @@ -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,
};
}
4 changes: 2 additions & 2 deletions front/src/screens/Auth/LoginScreen.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
Expand Down Expand Up @@ -205,7 +205,7 @@ export default function LoginScreen({ navigation }) {
<TouchableOpacity
style={s.googleBtn}
onPress={handleGoogleLogin}
disabled={googleLoading}
disabled={googleLoading || !googleRequest}
activeOpacity={0.82}
>
{googleLoading
Expand Down
7 changes: 7 additions & 0 deletions front/vercel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"buildCommand": "npm run build:web",
"outputDirectory": "dist",
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]
}
Loading