If user opens app after long period of time access token revokes

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 “

@revant_one

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 };

You need to use the refresh token and generate a new access token once the first access token expires.

Refer to the Raven mobile app (though we still haven’t done it in the best way possible): raven/apps/mobile/app/[site_id]/_layout.tsx at develop · The-Commit-Company/raven · GitHub

On Raven, we proactively refresh the access token since we don’t want any requests to fail mid-way. So we refresh it 5 minutes before the token actually expires. Once refreshed, we revoke the older token.

Yes I am aware of it i am doing it in my code but there is some mistake of mine which causing this bug i have built it using reference of this :
https://gitlab.com/castlecraft/frappe-mobile-starter/-/blob/master/src/app/api/token.service.ts?ref_type=heads

Token will be cleared in 30 days of expiry by default.

Use “Log Settings” to change the default.