use-http icon indicating copy to clipboard operation
use-http copied to clipboard

[Request Interceptor] Refresh token and update global option

Open lgm-dev opened this issue 5 years ago • 16 comments

Hi,

I have been struggling for two days on this. I'm not sure if it's a bug or me not getting the art of this Provider feature.

I have this Provider :

const FetchProvider = ({ children }) => {
    let [token, setToken] = useState(localStorage.getItem("token"))
    let [refresh, setRefresh] = useState(localStorage.getItem("refresh"))

    const url = 'http://127.0.0.1:8000'
    const [request_refresh_token, response_refresh_token] = useFetch(url);
      const getToken = async () => {
            console.log("is_executed")
            let bodyFormData = new FormData();
                bodyFormData.append('refresh', refresh );
                let newToken = await request_refresh_token.post('/api/user/token_jwt/refresh/',bodyFormData)

                if (response_refresh_token.ok) {
                    return newToken.access
                }
                else {
                console.error('there was a problem authenticating the user')
          }
      }

  const globalOptions = {
    interceptors: {
      async request(options) {
        options =  {headers: {Authorization: `Bearer ${token}`}};
        console.log("OPTION_ENTREE=",options)

        if (isExpired(token)) {
              const new_token = await getToken()
              options.headers.Authorization = `Bearer ${new_token}`
              setToken(new_token)

          }
        return options
      }
    }
  }


        if (isExpired(refresh)){
      return <Redirect to="/login" />
    }


  return <Provider url={url} options={globalOptions}> {children} </Provider>
}

export default FetchProvider

The child component is as follow :

export default function DietHistory(props) {
  const [request, response] = useFetch(); //no argument there, eveyrhting is passed with the interceptor
  const [recipes, updateRecipes] = useState([])

  const [sent_recipes,updateSentRecipes] = useState([])
  const [spinner_on_off,setSpinner] =  useState(false)
  const [open_snackbar,setSnackBar]=  useState(false)
  let [request_send, response_send] = useFetch(); //no argument there, eveyrhting is passed with the interceptor
  const [recipe_being_sent,setRecipe_being_sent] = useState(['owner_email',0])
  const { state: authState } = useContext(AuthContext);

  useEffect(() => {
    initializeRecipes()
  }, [])

  async function initializeRecipes() {
    const initialRecipes = await request.get('/api/recipe/recipe_list/')
    if (response.ok) updateRecipes(initialRecipes)
  }


 async function sendPDF (recipe_id,recipe_email){
     setRecipe_being_sent([recipe_email,recipe_id])
     setSpinner(true)
     setSnackBar(true)
    const send_pdf = await request_send.get('/api/recipe/send-email?id='+recipe_id)
        if (response_send.ok)  updateSentRecipes([...sent_recipes,recipe_id])
        setSpinner(false)
        setTimeout(() => {
               setSnackBar(false)
        }, 2000)


};


    if (request.loading) return <CircularProgress/>
    if (request.error) return <p>Error!</p>
    if (recipes) {
        return (
            <div>
                <div style={{maxWidth: "100%"}}>
                    <MaterialTable
                        icons={tableIcons}
                        columns={[
                           {title : "Print PDF", field:'PDF', render: rowData => {return (<ButtonPrintPdf recipe_id = {rowData.id} />)} },

                            {title : "Send PDF", field:'PDF',  render : (rowData) => {
                                return(<ButtonLoadding
                                email = {rowData.owner_email}
                                loading = {spinner_on_off}
                                sent_recipes = {sent_recipes}
                                recipeid = {rowData.id}
                                recipeid_being_sent = {recipe_being_sent[1]}
                                tool_to_sendPDF = {sendPDF}
                               />)
                                     }

                                },
]}
                        data={recipes}
                    />
                    
                </div>


            </div>

        );
    }
}

So when I hit sendPDF after the token has expired, the provider doesn't seem to update the globalOption and just send back the previous token (which has now expired), causing a 401 error. However If I just re-render the whole children component, then the token is nicely refreshed and pass to all of my global option.

It seems like global option cannot be updated on the go (conditionally, when the token has expired for exemple).

Am I missing something ?

lgm-dev avatar May 20 '20 17:05 lgm-dev

  1. Please make a codesandbox
  2. try using react-use-localstorage
  3. what version of use-http are you using?

Here is a cleaned up version below, but I need some runnable code in a codesandbox

import useLocalStorage from 'react-use-localstorage'

const FetchProvider = props => {
  const [token, setToken] = useLocalStorage('token') // try using this package
  const [refresh, setRefresh] = useLocalStorage('refresh')

  const url = 'http://127.0.0.1:8000'
  const [request, response] = useFetch(url);
  const getToken = async () => {
    console.log("is_executed")
    let bodyFormData = new FormData();
    bodyFormData.append('refresh', refresh);
    let newToken = await request.post('/api/user/token_jwt/refresh/', bodyFormData)
    if (response.ok) return newToken.access
    console.error('there was a problem authenticating the user')
  }

  const globalOptions = {
    interceptors: {
      async request({ options }) { // <- probably need to add curly braces here
  	    options.headers = {
		  ...(options.headers || {}),
          Authorization: `Bearer ${token}`
        }
        console.log("OPTION_ENTREE=", options)

        if (isExpired(token)) {
          const new_token = await getToken()
          options.headers.Authorization = `Bearer ${new_token}`
          setToken(new_token)
        }
        return options
      }
    }
  }
  if (isExpired(refresh)) return <Redirect to="/login" />
  return <Provider {...props} url={url} options={globalOptions} />
}

export default FetchProvider

and

export default function DietHistory(props) {
  const [request, response] = useFetch(); // no need for 2 of these
  const [recipes, updateRecipes] = useState([]);

  const [sent_recipes, updateSentRecipes] = useState([]);
  const [spinner_on_off, setSpinner] = useState(false);
  const [open_snackbar, setSnackBar] = useState(false);
  const [recipe_being_sent, setRecipe_being_sent] = useState([
    "owner_email",
    0,
  ]);
  const { state: authState } = useContext(AuthContext);

  useEffect(() => {
    initializeRecipes();
  }, []);

  async function initializeRecipes() {
    const initialRecipes = await request.get("/api/recipe/recipe_list/");
    if (response.ok) updateRecipes(initialRecipes);
  }

  async function sendPDF(recipe_id, recipe_email) {
    setRecipe_being_sent([recipe_email, recipe_id]);
    setSpinner(true);
    setSnackBar(true);
    const send_pdf = await request.get(
      "/api/recipe/send-email?id=" + recipe_id
    );
    if (request.ok) updateSentRecipes([...sent_recipes, recipe_id]);
    setSpinner(false);
    setTimeout(() => {
      setSnackBar(false);
    }, 2000);
  }

  if (request.loading) return <CircularProgress />;
  if (request.error) return <p>Error!</p>;
  if (recipes) {
    return (
      <div>
        <div style={{ maxWidth: "100%" }}>
          <MaterialTable
            icons={tableIcons}
            columns={[
              {
                title: "Print PDF",
                field: "PDF",
                render: (rowData) => {
                  return <ButtonPrintPdf recipe_id={rowData.id} />;
                },
              },

              {
                title: "Send PDF",
                field: "PDF",
                render: (rowData) => {
                  return (
                    <ButtonLoadding
                      email={rowData.owner_email}
                      loading={spinner_on_off}
                      sent_recipes={sent_recipes}
                      recipeid={rowData.id}
                      recipeid_being_sent={recipe_being_sent[1]}
                      tool_to_sendPDF={sendPDF}
                    />
                  );
                },
              },
            ]}
            data={recipes}
          />
        </div>
      </div>
    );
  }
}

iamthesiz avatar May 20 '20 19:05 iamthesiz

  1. Alright, can you tell me how I can mimick in SandBox token refresh or delivery ?

  2. Got it.

  3. "use-http": "^0.4.5",

  4. On a side note, if you merge the two request object from my component DietHistory, then you cannot split the loading. I didn't understand why you wanted to merge them.

lgm-dev avatar May 21 '20 02:05 lgm-dev

start by forking this codesandbox and try to reproduce. Then put a link to your reproduced bug here.

iamthesiz avatar May 21 '20 19:05 iamthesiz

Hey @alex-cory, Thanks to find some time to help. So here is a codesandbox

Behaviour :

  1. Initialize a list of users with a given token.
  2. Wait 30s. The token expired.
  3. Fire <SendIcon/> to get a post.
  4. This trigger a get_token async call which is supposed to return a new token.
  5. The headers is not changed, token is undefined.

That's the closest I'm getting from the real bug. The real bug I'm just getting the old expired token. I would say it's a problem with await or a state update problem.

What's your take on this ?

lgm-dev avatar May 21 '20 21:05 lgm-dev

@lgm-dev Hi, I think I have the same problem with you, and here is my sandbox example , it may be more simple than yours. I found the Provider can not read the real time state value, it may cache some thing.

Hi, @alex-cory , Am I missing something ?

theowenyoung avatar May 23 '20 08:05 theowenyoung

I will take a look tomorrow. Heading to sleep. It's 2:13am.

iamthesiz avatar May 23 '20 09:05 iamthesiz

I think I know what happened... I don't know if it's a bug... @alex-cory

Here is the reason: https://github.com/ava/use-http/blob/master/src/Provider.tsx#L14

Because you used useMemo to set the provider context value, and if we set globalOptions only include 2 functions, so, even token is update, but globalOptions will not change(because function is a pointer), so it still use the previous value.

For now, we can do this for avoid that :

const App = () => {
  const [token, setToken] = useState()
  useEffect(() => {
    setTimeout(() => {
      setToken("new token")
    }, 1000)
  }, [])
  const globalOptions = {
    headers:{
      token:token,
    },
    interceptors: {
      request: ({ options }) => {
        options.headers = {
          Authorization: `Bearer ${token}`
        }
        console.log("interceptors token", token)
        return options
      },
      response: ({ response }) => {
        console.log("initial resopnse.data", response.data)
        return response
      }
    }
  }
  return (
    <Provider options={globalOptions}>
      <TestUseFetch token={token} />
    </Provider>
  )
}

@lgm-dev

theowenyoung avatar May 23 '20 13:05 theowenyoung

Thanks for the idea @theowenyoung , but I fear that if we proceed this way, the point of intercepting the request sounds a bit useless. What do you think @alex-cory ?

lgm-dev avatar May 25 '20 15:05 lgm-dev

This issue is similar (or likely the same) as mine, and I've been hesitating to suggest this, but I've tried using a different means of determining deep equality on the following line and it seems to fix the issue:

https://github.com/ava/use-http/blob/0656ec547b805ecaadac035b986a5e0a4ec8bfd5/src/utils.ts#L235

At the risk of coming across as ignorant, I believe the reason this works is because the interceptor references change here:

https://github.com/ava/use-http/blob/0656ec547b805ecaadac035b986a5e0a4ec8bfd5/src/useFetchArgs.ts#L36

...and while a deep equality function can pick up on the changes to function references, JSON.stringify cannot.

The options are also memoized, and the interceptor function references should change each time the options change. This should happen when the provider gets re-rendered, so the function references shouldn't change an indefinite number of times.

Comparing function references might cause other issues outside of interceptors, but since the interceptors are already memoized, and I don't think there are any other functions in the dependency array of makeFetch (as far as I can see) that aren't memoized in some way, it should work.

CaveSeal avatar May 26 '20 13:05 CaveSeal

Hey Guys, Does someone have.a nice workaround for this issue? I don't understand exactly @theowenyoung solution.
Thanks for your help, Kind regards

lgm-dev avatar Jul 01 '20 12:07 lgm-dev

Apologies guys. I've been dealing with a lot of personal/family issues recently. I will get to this asap.

iamthesiz avatar Jul 02 '20 18:07 iamthesiz

@theowenyoung : give a desperate try to your solution today. No success (again). Even when you pass the token down to the children component, the globablOptionare keeping the previous token as a bearer. Would you be kind enough to provide a SandBox ?

@CaveSeal : in another issue you seemed to have found a simpler solution to this token-refreshing problem ? anything to help :) ?

@alex-cory : good luck with your issues :'(

lgm-dev avatar Aug 10 '20 18:08 lgm-dev

@lgm-dev The way I solved my issue won't work for you, because I didn't need to do any checks (i.e. for expiry) on each request. However, the issue of being unable to see an updated token in the interceptor is (I believe) due to the object comparison that happens when the various options are memoized, which doesn't take account for changes in function references. The function references never change, so you always see the same outer environment, which means you'll only see the token as it was initially. Wouldn't call myself a Javascript expert though, so this is all wild speculation.

One thing that might work as a workaround (though not ideal) is if you retrieved your token from local storage inside the interceptor. It's also likely that you'll have to do the same with the refresh token.

interceptors: {
      async request(options) {
        const token = localStorage.getItem("token")
        options =  {headers: {Authorization: `Bearer ${token}`}};
        console.log("OPTION_ENTREE=",options)

        if (isExpired(token)) {
              const new_token = await getToken()
              options.headers.Authorization = `Bearer ${new_token}`
              setToken(new_token)
          }
        return options
      }
}

I've got a PR up that I believe solves this issue and all similar issues, but we'll see what happens.

CaveSeal avatar Aug 12 '20 13:08 CaveSeal

I'm facing this issue too, any update about when this will be solved/published? I can help with a PR or anything to delivery this bug fix, let me know how I can help

iagolaguna avatar Oct 07 '20 13:10 iagolaguna

I search in the Pull requests and I found this one submitted by @CaveSeal https://github.com/ava/use-http/issues/268 just adding here to let more information about the status of this issue

iagolaguna avatar Oct 07 '20 13:10 iagolaguna

I am having the same issue right know and I was wondering, if there is any solution to this. Thank you

iBims1JFK avatar Jul 19 '22 21:07 iBims1JFK