// src/auth/AuthProvider.tsx import { createContext, useCallback, useEffect, useMemo, useRef, useState, type PropsWithChildren } from 'react'; import type { User } from 'oidc-client-ts'; import { userManager } from './config'; type AuthContextValue = { isAuthenticated: boolean; isLoading: boolean; login: () => Promise; logout: () => Promise; user: User | null; getAccessToken: () => string | null; /** Renouvelle le token silencieusement. Retourne le nouvel access_token, ou null si échec. */ renewToken: () => Promise; /** * Résout une fois la résolution d'auth initiale terminée (callback OIDC ou * `getUser()`). À attendre avant de lire le token dans un appel API : évite la * course où un effet enfant part avant que la session soit chargée et * déclenche un login interactif intempestif. */ whenReady: () => Promise; }; export const AuthContext = createContext(null); const LOGIN_REDIRECT_COOLDOWN_MS = 15000; const LAST_LOGIN_REDIRECT_AT_KEY = 'inwink.auth.lastLoginRedirectAt'; function isOidcCallback(): boolean { const params = new URLSearchParams(window.location.search); return params.has('code') && params.has('state'); } export function AuthProvider({ children }: PropsWithChildren) { const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); const userRef = useRef(user); const loginInFlightRef = useRef(false); const renewInFlightRef = useRef | null>(null); useEffect(() => { userRef.current = user; }, [user]); // Deferred résolu à la fin de la résolution d'auth initiale (cf. `whenReady`). // Créé en lazy-init pour exister dès le premier rendu (avant tout effet enfant). const readyRef = useRef<{ promise: Promise; resolve: () => void } | null>(null); if (!readyRef.current) { let resolve!: () => void; const promise = new Promise((r) => { resolve = r; }); readyRef.current = { promise, resolve }; } useEffect(() => { let isMounted = true; async function init() { try { if (isOidcCallback()) { const callbackUser = await userManager.signinCallback(window.location.href); window.history.replaceState({}, '', window.location.pathname); if (isMounted) { setUser(callbackUser ?? null); setIsLoading(false); } return; } const loadedUser = await userManager.getUser(); if (isMounted) { setUser(loadedUser); setIsLoading(false); } } catch { if (isMounted) { setUser(null); setIsLoading(false); } } } // Le gate `ready` est résolu quelle que soit l'issue de init() (succès, // absence de session, ou erreur) : les appels en attente reprennent alors, // token présent ou non — le cas « réellement non authentifié » mène à login(). void init().finally(() => readyRef.current?.resolve()); const handleUserLoaded = (u: User) => { setUser(u); setIsLoading(false); }; const handleUserUnloaded = () => { setUser(null); setIsLoading(false); }; userManager.events.addUserLoaded(handleUserLoaded); userManager.events.addUserUnloaded(handleUserUnloaded); return () => { isMounted = false; userManager.events.removeUserLoaded(handleUserLoaded); userManager.events.removeUserUnloaded(handleUserUnloaded); }; }, []); const login = useCallback(async () => { if (loginInFlightRef.current) { return; } const now = Date.now(); const lastRedirectAt = Number(sessionStorage.getItem(LAST_LOGIN_REDIRECT_AT_KEY) ?? '0'); if (Number.isFinite(lastRedirectAt) && now - lastRedirectAt < LOGIN_REDIRECT_COOLDOWN_MS) { return; } loginInFlightRef.current = true; setIsLoading(true); sessionStorage.setItem(LAST_LOGIN_REDIRECT_AT_KEY, String(now)); try { await userManager.signinRedirect(); } finally { loginInFlightRef.current = false; setIsLoading(false); } }, []); const logout = useCallback(async () => { await userManager.removeUser(); setUser(null); }, []); const getAccessToken = useCallback(() => userRef.current?.access_token ?? null, []); const whenReady = useCallback(() => readyRef.current!.promise, []); const renewToken = useCallback(async () => { // Déduplication : des appels concurrents recevant un 401 partagent un seul // signinSilent() en vol plutôt que d'en lancer un chacun. if (renewInFlightRef.current) { return renewInFlightRef.current; } const renewal = (async () => { try { const renewed = await userManager.signinSilent(); setUser(renewed); return renewed?.access_token ?? null; } catch { return null; } finally { renewInFlightRef.current = null; } })(); renewInFlightRef.current = renewal; return renewal; }, []); const value = useMemo(() => ({ isAuthenticated: Boolean(user && !user.expired), isLoading, login, logout, user, getAccessToken, renewToken, whenReady, }), [user, isLoading]); return ( {children} ); }