diff --git a/ecoscan_app/context/AuthContext.tsx b/ecoscan_app/context/AuthContext.tsx index edf12a92..0bff667b 100644 --- a/ecoscan_app/context/AuthContext.tsx +++ b/ecoscan_app/context/AuthContext.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useContext, useEffect, + useMemo, useRef, useState, } from "react"; @@ -30,7 +31,7 @@ function getTokenExpiresAt(token: string | null): number | null { try { const decoded = jwtDecode<{ exp?: number }>(token); - if (!decoded || !decoded.exp) return null; + if (!decoded?.exp) return null; return decoded.exp * 1000; } catch (e) { @@ -64,6 +65,7 @@ export const AuthProvider: React.FC = ({ children }) => { !!initialAccessToken && !!initialRefreshToken, ); const [isLoadingState, setIsLoadingState] = useState(isStorageLoading); + const [tokenVersion, setTokenVersion] = useState(0); const handleClearTokens = useCallback(async () => { await clearTokensFromStorage(); @@ -122,7 +124,51 @@ export const AuthProvider: React.FC = ({ children }) => { }); const refreshTimeoutRef = useRef | null>(null); - const isRefreshingRef = useRef(false); + const activeRefreshRef = useRef | null>(null); + + const refreshInternal = useCallback(async () => { + if (activeRefreshRef.current) { + return activeRefreshRef.current; + } + + const currentRefreshToken = refreshTokenRef.current; + if (!currentRefreshToken) { + return; + } + + const promise = (async () => { + try { + console.log("[Auth] Refreshing tokens in background..."); + await refreshOAuth(currentRefreshToken); + console.log("[Auth] Tokens refreshed successfully (silent)"); + setTokenVersion((v) => v + 1); + } catch (e) { + console.error("[Auth] Failed to refresh tokens:", e); + await handleClearTokens(); + } finally { + activeRefreshRef.current = null; + } + })(); + + activeRefreshRef.current = promise; + return promise; + }, [refreshOAuth, handleClearTokens]); + + const refresh = useCallback(async () => { + await refreshInternal(); + }, [refreshInternal]); + + const getAccessToken = useCallback(() => accessTokenRef.current, []); + const getIdToken = useCallback(() => idTokenRef.current, []); + const getRefreshToken = useCallback(() => refreshTokenRef.current, []); + + const login = useCallback(async () => { + await oauthLogin(); + }, [oauthLogin]); + + const logout = useCallback(async () => { + await oauthLogout(idTokenRef.current); + }, [oauthLogout]); useEffect(() => { accessTokenRef.current = initialAccessToken; @@ -141,7 +187,7 @@ export const AuthProvider: React.FC = ({ children }) => { const tokenExpiresAt = tokenExpiresAtRef.current; const refreshToken = refreshTokenRef.current; - if (!tokenExpiresAt || !refreshToken || isRefreshingRef.current) { + if (!tokenExpiresAt || !refreshToken || activeRefreshRef.current) { return; } @@ -167,54 +213,30 @@ export const AuthProvider: React.FC = ({ children }) => { clearTimeout(refreshTimeoutRef.current); } }; - }, [tokenExpiresAtRef.current?.toString()]); - - const refreshInternal = useCallback(async () => { - const currentRefreshToken = refreshTokenRef.current; - - if (isRefreshingRef.current || !currentRefreshToken) { - return; - } - - isRefreshingRef.current = true; - try { - console.log("[Auth] Refreshing tokens in background..."); - await refreshOAuth(currentRefreshToken); - console.log("[Auth] Tokens refreshed successfully (silent)"); - } catch (e) { - console.error("[Auth] Failed to refresh tokens:", e); - await handleClearTokens(); - } finally { - isRefreshingRef.current = false; - } - }, [refreshOAuth, handleClearTokens]); - - const refresh = useCallback(async () => { - await refreshInternal(); - }, [refreshInternal]); - - const getAccessToken = useCallback(() => accessTokenRef.current, []); - const getIdToken = useCallback(() => idTokenRef.current, []); - const getRefreshToken = useCallback(() => refreshTokenRef.current, []); - - const login = useCallback(async () => { - await oauthLogin(); - }, [oauthLogin]); - - const logout = useCallback(async () => { - await oauthLogout(idTokenRef.current); - }, [oauthLogout]); - - const value: AuthContextType = { - refresh, - getAccessToken, - getIdToken, - getRefreshToken, - isAuthenticated: isAuthenticatedState, - isLoading: isLoadingState, - login, - logout, - }; + }, [refreshInternal, tokenVersion]); + + const value = useMemo( + () => ({ + refresh, + getAccessToken, + getIdToken, + getRefreshToken, + isAuthenticated: isAuthenticatedState, + isLoading: isLoadingState, + login, + logout, + }), + [ + refresh, + getAccessToken, + getIdToken, + getRefreshToken, + isAuthenticatedState, + isLoadingState, + login, + logout, + ], + ); return {children}; }; diff --git a/ecoscan_app/hooks/useOAuthFlow.ts b/ecoscan_app/hooks/useOAuthFlow.ts index a144390d..638b5b08 100644 --- a/ecoscan_app/hooks/useOAuthFlow.ts +++ b/ecoscan_app/hooks/useOAuthFlow.ts @@ -3,6 +3,7 @@ import * as AuthSession from "expo-auth-session"; import * as WebBrowser from "expo-web-browser"; import { Alert } from "react-native"; import { AUTH_CONFIG } from "@/utils/authConfig"; +import { useSnackbar } from "@/context/SnackbarContext"; const redirectUri = AuthSession.makeRedirectUri(); @@ -17,22 +18,36 @@ export const useOAuthFlow = ({ saveTokens, clearTokens, }: UseOAuthFlowProps) => { + const { showError } = useSnackbar(); const [discovery, setDiscovery] = useState(null); const [isDiscoveryLoading, setIsDiscoveryLoading] = useState(true); + const loadDiscovery = + useCallback(async (): Promise => { + setIsDiscoveryLoading(true); + try { + const discoveryDoc = await AuthSession.fetchDiscoveryAsync( + AUTH_CONFIG.issuer, + ); + setDiscovery(discoveryDoc); + return discoveryDoc; + } catch (e) { + console.warn("[useOAuthFlow] Discovery failed", e); + showError("Authentifizierungsservice nicht erreichbar."); + return null; + } finally { + setIsDiscoveryLoading(false); + } + }, [showError]); + useEffect(() => { - AuthSession.fetchDiscoveryAsync(AUTH_CONFIG.issuer) - .then(setDiscovery) - .catch((e) => console.error("Discovery failed", e)) - .finally(() => setIsDiscoveryLoading(false)); - }, []); + loadDiscovery(); + }, [loadDiscovery]); const login = useCallback(async () => { - if (!discovery) { - Alert.alert("Fehler", "Auth-Service nicht erreichbar"); - return; - } + const disc = discovery || (await loadDiscovery()); + if (!disc) return; try { const authRequest = new AuthSession.AuthRequest({ @@ -44,7 +59,7 @@ export const useOAuthFlow = ({ usePKCE: true, }); - const result = await authRequest.promptAsync(discovery); + const result = await authRequest.promptAsync(disc); if (result.type === "success") { const { code } = result.params; @@ -59,7 +74,7 @@ export const useOAuthFlow = ({ redirectUri, extraParams: { code_verifier: authRequest.codeVerifier ?? "" }, }, - discovery, + disc, ); await saveTokens(tokenResult); } @@ -67,24 +82,12 @@ export const useOAuthFlow = ({ console.error("Login failed", e); Alert.alert("Login Fehler", "Anmeldung fehlgeschlagen"); } - }, [discovery, saveTokens]); + }, [discovery, loadDiscovery, saveTokens]); const refresh = useCallback( async (refreshToken: string) => { - let currentDiscovery = discovery; - - if (!currentDiscovery) { - try { - console.log("[OAuthFlow] Discovery missing. Loading on-the-fly ..."); - currentDiscovery = await AuthSession.fetchDiscoveryAsync( - AUTH_CONFIG.issuer, - ); - } catch (e) { - throw new Error( - "Cannot refresh: Auth-Service (Discovery) is not available", - ); - } - } + const disc = discovery || (await loadDiscovery()); + if (!disc) throw new Error("OAuth discovery unavailable"); const tokenInstance = new AuthSession.TokenResponse({ accessToken: "", @@ -94,7 +97,7 @@ export const useOAuthFlow = ({ try { const refreshedToken = await tokenInstance.refreshAsync( { clientId: AUTH_CONFIG.clientId }, - currentDiscovery, + disc, ); refreshedToken.refreshToken = @@ -108,7 +111,7 @@ export const useOAuthFlow = ({ throw e; } }, - [discovery, saveTokens], + [discovery, loadDiscovery, saveTokens], ); const getValidAccessToken = useCallback(async (): Promise => { @@ -117,11 +120,13 @@ export const useOAuthFlow = ({ const tokenInstance = new AuthSession.TokenResponse(tokenConfig); if (tokenInstance.shouldRefresh()) { - if (!discovery) return null; + const disc = discovery || (await loadDiscovery()); + if (!disc) return null; + try { const refreshedToken = await tokenInstance.refreshAsync( { clientId: AUTH_CONFIG.clientId }, - discovery, + disc, ); refreshedToken.refreshToken = refreshedToken.refreshToken ?? tokenInstance.refreshToken; @@ -136,17 +141,18 @@ export const useOAuthFlow = ({ } return tokenInstance.accessToken; - }, [tokenConfig, discovery, saveTokens, clearTokens]); + }, [tokenConfig, discovery, loadDiscovery, saveTokens, clearTokens]); const logout = useCallback( async (idToken?: string | null) => { - if (!discovery) { + const disc = discovery || (await loadDiscovery()); + if (!disc) { await clearTokens(); return; } try { - const logoutUrl = `${discovery.endSessionEndpoint}?client_id=${ + const logoutUrl = `${disc.endSessionEndpoint}?client_id=${ AUTH_CONFIG.clientId }${idToken ? `&id_token_hint=${idToken}` : ""}&post_logout_redirect_uri=${encodeURIComponent( redirectUri, @@ -158,7 +164,7 @@ export const useOAuthFlow = ({ await clearTokens(); } }, - [discovery, clearTokens], + [discovery, loadDiscovery, clearTokens], ); return {