Hello developers,
I am building Cross Platform Mobile app using React native with expo and Frappe in Back-end.
In it I am using Frappes Oauth access token method for Authentication
Problem : “ if user opens app after long period of time access token revokes , and after force log out some time i have to press log-in multiple time . code is give below “
import * as AuthSession from "expo-auth-session";
import * as Linking from "expo-linking";
import * as SecureStore from "expo-secure-store";
import { FrappeApp } from "frappe-js-sdk";
import React, {
createContext,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
BASE_URI,
OAUTH_CLIENT_ID,
SECURE_AUTH_STATE_KEY,
} from "../constant/constant";
const AuthContext = createContext({});
const REFRESH_BUFFER_SECONDS = 60;
const REFRESH_POLL_INTERVAL_MS = 60000;
const DEFAULT_EXPIRES_IN_SECONDS = 3600;
const MAX_RETRY_ATTEMPTS = 3;
const discovery = {
authorizationEndpoint: `${BASE_URI}/api/method/frappe.integrations.oauth2.authorize`,
tokenEndpoint: `${BASE_URI}/api/method/frappe.integrations.oauth2.get_token`,
revocationEndpoint: `${BASE_URI}/api/method/frappe.integrations.oauth2.revoke_token`,
};
const AuthProvider = (props) => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [accessToken, setAccessToken] = useState(null);
const [refreshToken, setRefreshToken] = useState(null);
const [expiresIn, setExpiresIn] = useState(null);
const [userInfo, setUserInfo] = useState(null);
const [error, setError] = useState(null);
const isExchangingCode = useRef(false);
const lastExchangedCode = useRef(null);
const isRefreshing = useRef(false); // ← NEW: prevent overlapping refreshes
const tokenRef = useRef({ accessToken, refreshToken, expiresIn });
useEffect(() => {
tokenRef.current = { accessToken, refreshToken, expiresIn };
}, [accessToken, refreshToken, expiresIn]);
const redirectUri = Linking.createURL("auth/callback");
useEffect(() => {
console.log("OAuth Redirect URI:", redirectUri);
}, [redirectUri]);
const [request, response, promptAsync] = AuthSession.useAuthRequest(
{
clientId: OAUTH_CLIENT_ID,
redirectUri,
responseType: "code",
scopes: ["all", "openid"],
usePKCE: false,
},
discovery
);
// ─── Storage Helpers ───────────────────────────────────────────
const saveAuthState = useCallback(async (authData) => {
try {
const expirationTime = new Date();
const expiresInSeconds = authData.expiresIn || DEFAULT_EXPIRES_IN_SECONDS;
expirationTime.setSeconds(
expirationTime.getSeconds() + Number(expiresInSeconds)
);
const isoExpiry = expirationTime.toISOString();
console.log("💾 Saving auth state:", {
expiresInFromServer: authData.expiresIn,
calculatedExpiry: isoExpiry,
willRefreshAt: new Date(
expirationTime.getTime() - REFRESH_BUFFER_SECONDS * 1000
).toISOString(),
});
const storageValue = JSON.stringify({
accessToken: authData.accessToken,
refreshToken: authData.refreshToken,
expiresIn: isoExpiry,
idToken: authData.idToken || null,
});
await SecureStore.setItemAsync(SECURE_AUTH_STATE_KEY, storageValue);
setAccessToken(authData.accessToken);
setRefreshToken(authData.refreshToken);
setExpiresIn(isoExpiry);
setIsAuthenticated(true);
setError(null);
} catch (e) {
console.error("Failed to save auth state:", e);
setError("Failed to save authentication state");
}
}, []);
const clearAuthState = useCallback(async () => {
try {
await SecureStore.deleteItemAsync(SECURE_AUTH_STATE_KEY);
} catch (e) {
console.error("Failed to clear secure store:", e);
}
setIsAuthenticated(false);
setAccessToken(null);
setRefreshToken(null);
setExpiresIn(null);
setUserInfo(null);
}, []);
// ─── Token Validation ─────────────────────────────────────────
const isTokenExpired = useCallback(() => {
const { expiresIn: exp } = tokenRef.current;
if (!exp) return true;
const now = new Date();
const expirationTime = new Date(exp);
// Consider expired if within REFRESH_BUFFER_SECONDS of expiry
const bufferedExpiry = new Date(
expirationTime.getTime() - REFRESH_BUFFER_SECONDS * 1000
);
const expired = now >= bufferedExpiry;
console.log("🔍 Token expiry check:", {
now: now.toISOString(),
actualExpiry: expirationTime.toISOString(),
bufferedExpiry: bufferedExpiry.toISOString(),
isExpired: expired,
secondsUntilExpiry: Math.round(
(expirationTime.getTime() - now.getTime()) / 1000
),
});
return expired;
}, []);
// ─── Revoke Token ─────────────────────────────────────────────
const revokeToken = useCallback(async (token) => {
const tokenToRevoke = token || tokenRef.current.accessToken;
if (!tokenToRevoke) return;
try {
await AuthSession.revokeAsync(
{ token: tokenToRevoke },
{ revocationEndpoint: discovery.revocationEndpoint }
);
} catch (e) {
console.error("Failed to revoke token:", e);
}
}, []);
// ─── Refresh Token ────────────────────────────────────────────
const refreshAccessToken = useCallback(
async (retryCount = 0) => {
if (isRefreshing.current) {
console.warn("⏳ Refresh already in progress, skipping...");
return tokenRef.current.accessToken;
}
const { refreshToken: currentRefreshToken } = tokenRef.current;
if (!currentRefreshToken) {
console.error("No refresh token available");
await clearAuthState();
return null;
}
isRefreshing.current = true;
try {
console.log("🔄 Refreshing token (attempt", retryCount + 1, ")...");
const res = await AuthSession.refreshAsync(
{
clientId: OAUTH_CLIENT_ID,
refreshToken: currentRefreshToken,
},
{ tokenEndpoint: discovery.tokenEndpoint }
);
console.log("✅ Token refreshed successfully, new expiresIn:", res.expiresIn);
await saveAuthState({
accessToken: res.accessToken,
refreshToken: res.refreshToken || currentRefreshToken,
expiresIn: res.expiresIn,
idToken: res.idToken,
});
return res.accessToken;
} catch (err) {
console.error(`Refresh attempt ${retryCount + 1} failed:`, err);
if (retryCount < MAX_RETRY_ATTEMPTS - 1) {
isRefreshing.current = false; // allow retry
return await refreshAccessToken(retryCount + 1);
}
await revokeToken();
await clearAuthState();
return null;
} finally {
isRefreshing.current = false;
}
},
[clearAuthState, revokeToken, saveAuthState]
);
// ─── Get Valid Token ───────────────────────────────────────────
const getValidToken = useCallback(async () => {
const { accessToken: currentToken } = tokenRef.current;
if (!currentToken) return null;
if (!isTokenExpired()) return currentToken;
return await refreshAccessToken();
}, [isTokenExpired, refreshAccessToken]);
// ─── Frappe Client Factory ─────────────────────────────────────
const getFrappeClient = useCallback(() => {
return new FrappeApp(BASE_URI, {
useToken: true,
type: "Bearer",
token: () => tokenRef.current.accessToken,
});
}, []);
// ─── Fetch User Info ───────────────────────────────────────────
const fetchUserInfo = useCallback(async () => {
const token = await getValidToken();
if (!token) return null;
try {
const frappe = getFrappeClient();
const call = frappe.call();
const info = await call.get(
"frappe.integrations.oauth2.openid_profile"
);
setUserInfo(info);
return info;
} catch (e) {
console.error("Failed to fetch user info:", e);
const newToken = await refreshAccessToken();
if (newToken) {
try {
const frappe = getFrappeClient();
const call = frappe.call();
const info = await call.get(
"frappe.integrations.oauth2.openid_profile"
);
setUserInfo(info);
return info;
} catch (retryError) {
console.error("Retry failed:", retryError);
await clearAuthState();
}
}
return null;
}
}, [getValidToken, getFrappeClient, refreshAccessToken, clearAuthState]);
// ─── Exchange Code ─────────────────────────────────────────────
const exchangeCode = useCallback(
async (code) => {
if (isExchangingCode.current) {
console.warn("Code exchange already in progress, skipping...");
return false;
}
if (lastExchangedCode.current === code) {
console.warn("This code was already exchanged, skipping...");
return false;
}
isExchangingCode.current = true;
lastExchangedCode.current = code;
try {
const res = await AuthSession.exchangeCodeAsync(
{
clientId: OAUTH_CLIENT_ID,
redirectUri,
code,
},
{ tokenEndpoint: discovery.tokenEndpoint }
);
console.log("🎟️ Code exchanged, expiresIn from server:", res.expiresIn);
await saveAuthState({
accessToken: res.accessToken,
refreshToken: res.refreshToken,
expiresIn: res.expiresIn,
idToken: res.idToken,
});
return true;
} catch (err) {
console.error("Code exchange failed:", err);
setError("Authentication failed during code exchange");
return false;
} finally {
isExchangingCode.current = false;
}
},
[redirectUri, saveAuthState]
);
// ─── Logout ────────────────────────────────────────────────────
const logout = useCallback(async () => {
await revokeToken();
await clearAuthState();
}, [revokeToken, clearAuthState]);
// ─── Initialize: Restore session ──────────────────────────────
useEffect(() => {
const restoreSession = async () => {
try {
const result = await SecureStore.getItemAsync(SECURE_AUTH_STATE_KEY);
if (result) {
const parsed = JSON.parse(result);
setAccessToken(parsed.accessToken);
setRefreshToken(parsed.refreshToken);
setExpiresIn(parsed.expiresIn);
setIsAuthenticated(true);
console.log("📦 Session restored, expiry:", parsed.expiresIn);
}
} catch (e) {
console.error("Failed to restore session:", e);
} finally {
setIsLoading(false);
}
};
restoreSession();
}, []);
// ─── Handle Auth Response ──────────────────────────────────────
useEffect(() => {
if (response?.type === "success") {
const { code } = response.params;
if (code) exchangeCode(code);
} else if (response?.type === "error") {
console.error("Auth error:", response.error);
setError(response.error?.message || "Authentication failed");
}
}, [response, exchangeCode]);
// ─── Fetch user info when authenticated ────────────────────────
useEffect(() => {
if (accessToken && isAuthenticated) {
fetchUserInfo();
}
}, [accessToken, isAuthenticated]); // eslint-disable-line
//
// BEFORE (broken): setTimeout that fires (expiresIn - 60s) later
// → If token expires in 1 hour, fires at 59 min. NOT 1 minute.
//
// AFTER (working): setInterval every REFRESH_POLL_INTERVAL_MS
// that checks if token is near expiry and refreshes if needed.
//
useEffect(() => {
if (!isAuthenticated || !expiresIn) return;
console.log(
"⏰ Starting auto-refresh polling every",
REFRESH_POLL_INTERVAL_MS / 1000,
"seconds"
);
// Also check immediately on mount
if (isTokenExpired()) {
console.log("🔄 Token already expired/near expiry, refreshing now...");
refreshAccessToken();
}
const interval = setInterval(async () => {
console.log("⏰ Polling: checking if token needs refresh...");
if (isTokenExpired()) {
console.log("🔄 Token near expiry, refreshing...");
await refreshAccessToken();
} else {
const exp = new Date(tokenRef.current.expiresIn);
const secsLeft = Math.round((exp.getTime() - Date.now()) / 1000);
console.log(`✅ Token still valid (${secsLeft}s remaining)`);
}
}, REFRESH_POLL_INTERVAL_MS);
return () => {
console.log("🧹 Clearing auto-refresh interval");
clearInterval(interval);
};
}, [isAuthenticated, expiresIn, isTokenExpired, refreshAccessToken]);
// ─── Context Value ─────────────────────────────────────────────
const contextValue = useMemo(
() => ({
isAuthenticated,
isLoading,
accessToken,
refreshToken,
userInfo,
error,
request,
promptAsync,
logout,
refreshAccessToken,
fetchUserInfo,
getValidToken,
getFrappeClient,
}),
[
isAuthenticated, isLoading, accessToken, refreshToken,
userInfo, error, request, promptAsync, logout,
refreshAccessToken, fetchUserInfo, getValidToken, getFrappeClient,
]
);
return (
<AuthContext.Provider value={contextValue}>
{props.children}
</AuthContext.Provider>
);
};
export { AuthContext, AuthProvider };
