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
124 changes: 73 additions & 51 deletions ecoscan_app/context/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React, {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -64,6 +65,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
!!initialAccessToken && !!initialRefreshToken,
);
const [isLoadingState, setIsLoadingState] = useState(isStorageLoading);
const [tokenVersion, setTokenVersion] = useState(0);

const handleClearTokens = useCallback(async () => {
await clearTokensFromStorage();
Expand Down Expand Up @@ -122,7 +124,51 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
});

const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isRefreshingRef = useRef(false);
const activeRefreshRef = useRef<Promise<void> | 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]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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;
Expand All @@ -141,7 +187,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const tokenExpiresAt = tokenExpiresAtRef.current;
const refreshToken = refreshTokenRef.current;

if (!tokenExpiresAt || !refreshToken || isRefreshingRef.current) {
if (!tokenExpiresAt || !refreshToken || activeRefreshRef.current) {
return;
}

Expand All @@ -167,54 +213,30 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ 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<AuthContextType>(
() => ({
refresh,
getAccessToken,
getIdToken,
getRefreshToken,
isAuthenticated: isAuthenticatedState,
isLoading: isLoadingState,
login,
logout,
}),
[
refresh,
getAccessToken,
getIdToken,
getRefreshToken,
isAuthenticatedState,
isLoadingState,
login,
logout,
],
);

return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
Expand Down
74 changes: 40 additions & 34 deletions ecoscan_app/hooks/useOAuthFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -17,22 +18,36 @@ export const useOAuthFlow = ({
saveTokens,
clearTokens,
}: UseOAuthFlowProps) => {
const { showError } = useSnackbar();
const [discovery, setDiscovery] =
useState<AuthSession.DiscoveryDocument | null>(null);
const [isDiscoveryLoading, setIsDiscoveryLoading] = useState(true);

const loadDiscovery =
useCallback(async (): Promise<AuthSession.DiscoveryDocument | null> => {
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({
Expand All @@ -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;
Expand All @@ -59,32 +74,20 @@ export const useOAuthFlow = ({
redirectUri,
extraParams: { code_verifier: authRequest.codeVerifier ?? "" },
},
discovery,
disc,
);
await saveTokens(tokenResult);
}
} catch (e) {
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: "",
Expand All @@ -94,7 +97,7 @@ export const useOAuthFlow = ({
try {
const refreshedToken = await tokenInstance.refreshAsync(
{ clientId: AUTH_CONFIG.clientId },
currentDiscovery,
disc,
);

refreshedToken.refreshToken =
Expand All @@ -108,7 +111,7 @@ export const useOAuthFlow = ({
throw e;
}
},
[discovery, saveTokens],
[discovery, loadDiscovery, saveTokens],
);

const getValidAccessToken = useCallback(async (): Promise<string | null> => {
Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -158,7 +164,7 @@ export const useOAuthFlow = ({
await clearTokens();
}
},
[discovery, clearTokens],
[discovery, loadDiscovery, clearTokens],
);

return {
Expand Down
Loading