unjs / ofetch

😱 A better fetch API. Works on node, browser and workers.
MIT License
4k stars 125 forks source link

[Interceptors] Creating an API client with automatic token refresh functionality #79

Closed attevaltojarvi closed 1 year ago

attevaltojarvi commented 2 years ago

Hi!

We talked about this yesterday over on the Nuxt discord's #nuxt3 channel, but since it's more of an ohmyfetch thing, I decided to create the issue here instead of the nuxt/framework repo.

We're currently firing up a development effort for a new Nuxt3 project in our company (we've previously used Nuxt2 in some projects). Axios was the preferred way of doing API requests previously but since ohmyfetch is now the recommended choice, I thought of rewriting our API client wrapper to use ohmyfetch instead of Axios.

I'm sure the problem is familiar to most if not all of you: how to write an API client that can automatically refresh your access tokens behind the scenes? This is pretty much our previous solution that uses Axios' interceptors:

const createAPIClient = () => {
  const apiClient = axios.create({ headers, baseUrl })

  apiClient.interceptors.request.use(config => {
    const accessTokens = authStore.tokens  // { accessToken: '', refreshToken: '' }
    if (tokens) {
      config.headers.common.Authorization = `Bearer ${tokens.accessToken}`
    }
    return config
  })

  apiClient.interceptors.response.use(
    response => response,
    async error => {
      const originalRequest = error.config

      // .isRetry is a non-axios property we use to differentiate the actual request from the one we're firing in this interceptor
      if (error.response?.status === 401 && !originalRequest.isRetry) {
        originalRequest.isRetry = true
        try {
          // fetch new tokens from our API
          const refreshToken = authStore.refreshToken
          const { data } = axios.post('/our/nuxt/server-middleware/auth/route/that/proxies/to/our/api/', { refreshToken })

          // simplified for brevity
          setNewAccessTokensToStore(data)

          // retry the original request
          originalRequest.headers = { ...originalRequest.headers, Authorization: `Bearer ${data.refreshToken}` }
          return apiClient(originalRequest)
        } catch (refreshError) {
          return Promise.reject(refreshError)
        }  
      }
    }
  )

  return apiClient
}

Historically we've done this by creating one larger doRequest function that gets called recursively, but it was refactored to use interceptors.

I'd like to replicate this functionality using ohmyfetch, so that I could do something like:

const $customFetch = () => {
  const client = $fetch.create({
    baseURL: 'https://example.com',
    async onRequest(ctx => {
      // like apiClient.interceptors.request.use
    }),
    async onResponseError(ctx => {
      // like apiClient.interceptors.response.use
    })
  })

// in any Nuxt3 component
const { data, pending, error, refresh } = await useAsyncData('getSomeData', () => $customFetch('/some/endpoint/in/our/api'))

and the access tokens get set to request headers and possibly refreshed automatically.

I was checking out ohmyfetch's interceptors, but apparently they don't return anything, only functioning as hooks where you can set up logging or do something else, instead of acting as sort of middleware like Axios' counterparts. This leads me to the conclusion that this probably isn't currently possible with ohmyfetch? It's no problem to use the recursive approach we had previously, but I feel that this is a bit of a shortcoming with the current interceptors.

vulpeep commented 2 years ago

Yes, and even more, looks like that some essential types that are required to implement mutating interceptors are not exposed as a public API, so you cannot just wrap ohmyfetch by providing custom implementation of $Fetch interface.

So the side issue is: please expose all typings that are currently available at error-[hash].d.ts with some deterministic filename.

Side issue 2: please add some sort of userdata field in FetchContext to persist arbitrary request state between different hooks.

pi0 commented 2 years ago

Thanks for the feedback @evfoxin @attevaltojarvi

As a summary what I could understand is missing:

Actually it is currently possible to achieve this by modification to the context but it is probably more convenience to allow chaining.

attevaltojarvi commented 2 years ago

@pi0 I have a follow-up issue with this one.

While waiting for an update for the points raised, I implemented the API client + authentication/token refresh using a recursive approach. It goes something like this:

export const useAPIClient = () => {
  const doRequest = async (method, endpoint, config: FetchOptions) => {
    const { authClient, refreshSession, invalidateSession } = useAuthProxyClient()
    const client = authClient.create({ baseURL: <our API url> })

    const authCookie = useCookie('authTokens')

    if (authCookie.value) {
      config.headers = { ...config.headers, Authorization: `Bearer ${authCookie.value.accessToken}` }
    }

    try {
      return await client(endpoint, { method, ...config })
    } catch (requestError) {
      const refreshToken = authCookie.value.refreshToken

      if (!requestError.response?.status === 401 || !refreshToken) {
        // Legitimate 4xx-5xx error, abort
        throw requestError
      }

      try {
        await refreshSession(refreshToken)
        // call function recursively after refreshSession has done a request to /api/oauth/refresh API route and updated the cookie
        return await doRequest(method, endpoint, config)
      } catch (refreshError) {
        await invalidateSession()
        await navigateTo('/login')
      }
    }
  }

  return {
    doRequest
  }
}

export const useAuthProxyClient = () => {
  const authClient = $fetch.create({ retry: 0 })
  const authCookie = useCookie('auth')

  const refreshSession = async refreshToken => 
    authClient('/api/oauth/refresh', { method: 'post', body: { refreshToken, ... } })
      .then(response => {
        return { <access and refresh token values from response> }
      })
      .then(tokens => { authCookie.value = tokens })
  const invalidateSession = async () => 
    authClient('/api/oauth/revoke', { method: 'post', body: { ... } })
      .then(() => { // ignore errors })

  return {
    authClient,
    refreshSession,
    invalidateSession
  }
}

The API routes are in Nuxt's server folder and work correctly when called from client-side. This whole thing works as it should everywhere I normally call it, but during first page load, if the access tokens are not valid anymore, refreshing them doesn't work. Both refreshSession and invalidateSession throw a FetchError: Invalid URL (), as if the underlying $fetch instance can't resolve /api/oauth/<whatever> as a Nuxt server route.

Using the onRequestError interceptor example from the library's README:

async onRequestError ({ request, error }) {
  console.log('[fetch request error]', process.server, process.client, request, error)
}

I get

[fetch request error]
true
false
/api/oauth/revoke
TypeError [ERR_INVALID_URL]: Invalid URL
    at new NodeError (node:internal/errors:372:5)
    at URL.onParseError (node:internal/url:553:9)
    at new URL (node:internal/url:629:5)
    at new Request (file:///home/atte/Projects/dashboard/node_modules/node-fetch-native/dist/chunks/abort-controller.mjs:5964:16)
    at file:///home/atte/Projects/dashboard/node_modules/node-fetch-native/dist/chunks/abort-controller.mjs:6274:19
    at new Promise (<anonymous>)
    at fetch (file:///home/atte/Projects/dashboard/node_modules/node-fetch-native/dist/chunks/abort-controller.mjs:6272:9)
    at $fetchRaw2 (file:///home/atte/Projects/dashboard/node_modules/ohmyfetch/dist/chunks/fetch.mjs:131:26)
    at processTicksAndRejections (node:internal/process/task_queues:96:5)
{
  input: '/api/oauth/revoke',
  code: 'ERR_INVALID_URL'
}

respectively. Really don't know how to fix this, and as said, this only happens on first page load. If I request access tokens successfully, go to our API admin panel and revoke them manually, and then in the Nuxt app go to a different page (I have a page middleware that tries to use the API client to fetch my /me/ endpoint), the whole process works; refreshSession gets called successfully.

I understand this is not 100% an ohmyfetch issue, but since you also contribute to Nuxt, I thought that you could help me with this.

attevaltojarvi commented 2 years ago

An update to the above: this was a Nuxt issue. I had import { $fetch } from 'ohmyfetch' in my client module, and removing that and relying on Nuxt auto-importing it seems to have fixed the issue. The auto-imported $fetch, though, doesn't have .create(), so I had to feed the common parameters manually. Not too horrible, but not optimal either.

eskiesirius commented 2 years ago

+1

Denoder commented 2 years ago

Hey guys I repurposed the @nuxt/http module to work for nuxt3 and ohmyfetch while also porting axios interceptor-like functionality to it. If you're still interested in this issue, can you take the time to test it out and provide feedback?

https://www.npmjs.com/package/@nuxtjs-alt/http https://github.com/Teranode/nuxt-module-alternatives/tree/master/@nuxtjs-alt/http

AscaL commented 1 year ago

Hi, I'm having a similar flow/issue with Nuxt 3 and was wondering if:

Sorry to bother but I'm starting a project with Nuxt 3 at work. I'm just trying to avoid any issues. Thanks for all the hard work!

Cheers

reslear commented 1 year ago

need https://www.npmjs.com/package/fetch-retry

mrc-bsllt commented 1 year ago

Hello guys, is there any news about this feature? I am having the same problem with Nuxt 3-ohmyfetch-refresh token. Thanks!

attevaltojarvi commented 1 year ago

@mrc-bsllt I've been happy with my custom wrapper approach, give that a try?

Shyam-Chen commented 1 year ago

@mrc-bsllt wrap ofetch.raw

// request.ts
import type { FetchRequest, FetchOptions, FetchResponse } from 'ofetch';
import { ofetch } from 'ofetch';

const fetcher = ofetch.create({
  baseURL: process.env.API_URL + '/api',
  async onRequest({ options }) {
    const accessToken = localStorage.getItem('accessToken');
    const language = localStorage.getItem('language');

    if (accessToken) {
      options.headers = {
        ...options.headers,
        Authorization: `Bearer ${accessToken}`,
      };
    }

    if (language) {
      options.headers = {
        ...options.headers,
        'Accept-Language': language,
      };
    }
  },
  async onResponse({ response }) {
    if (response.status === 401 && localStorage.getItem('refreshToken')) {
      const { accessToken } = await ofetch('/auth/token', {
        baseURL: process.env.API_URL + '/api',
        method: 'POST',
        body: {
          accessToken: localStorage.getItem('accessToken'),
          refreshToken: localStorage.getItem('refreshToken'),
        },
      });

      localStorage.setItem('accessToken', accessToken);
    }
  },
});

export default async <T>(request: FetchRequest, options?: FetchOptions) => {
  try {
    const response = await fetcher.raw(request, options);
    return response as FetchResponse<T>;
  } catch (error: any) {
    if (error.response?.status === 401 && localStorage.getItem('refreshToken')) {
      const response = await fetcher.raw(request, options);
      return response as FetchResponse<T>;
    }

    return error.response as FetchResponse<T>;
  }
};

image

mrc-bsllt commented 1 year ago

@mrc-bsllt wrap ofetch.raw

import type { FetchRequest, FetchOptions, FetchResponse } from 'ofetch';
import { ofetch } from 'ofetch';

const fetcher = ofetch.create({
  baseURL: process.env.API_URL + '/api',
  async onRequest({ options }) {
    const accessToken = localStorage.getItem('accessToken');
    const language = localStorage.getItem('language');

    if (accessToken) {
      options.headers = {
        ...options.headers,
        Authorization: `Bearer ${accessToken}`,
      };
    }

    if (language) {
      options.headers = {
        ...options.headers,
        'Accept-Language': language,
      };
    }
  },
  async onResponse({ response }) {
    if (response.status === 401 && localStorage.getItem('refreshToken')) {
      const { accessToken } = await ofetch('/auth/token', {
        baseURL: process.env.API_URL + '/api',
        method: 'POST',
        body: {
          accessToken: localStorage.getItem('accessToken'),
          refreshToken: localStorage.getItem('refreshToken'),
        },
      });

      localStorage.setItem('accessToken', accessToken);
    }
  },
});

export default async <T>(request: FetchRequest, options?: FetchOptions) => {
  try {
    const response = await fetcher.raw(request, options);
    return response as FetchResponse<T>;
  } catch (error: any) {
    if (error.response?.status === 401 && localStorage.getItem('refreshToken')) {
      const response = await fetcher.raw(request, options);
      return response as FetchResponse<T>;
    }

    return error.response as FetchResponse<T>;
  }
};

image

Hi @Shyam-Chen, how can I use this solution with the useAsyncData?

Shyam-Chen commented 1 year ago

@mrc-bsllt I'm not wrapping to the composition API.

<script lang="ts" setup>
import request from '~/utilities/request';

onMounted(async () => {
  const response = await request<UserList>('/user-list', { method: 'POST', body: {} });
  users.value = response._data.result;
});
</script>
frasza commented 1 year ago

Is there any way to make a wrapper/composable so the API using composition (like using useFetch) remains same?

Shyam-Chen commented 1 year ago
/**
 * wrapping
 */
import { useFetch } from '~/composables';

const todos = useFetch('/todos').json<Todos>();

const todosId = ref<TodoItem['_id']>('');
const todosById = useFetch(computed(() => '/todos/' + todosId.value)).json<TodosById>();

const getTodos = async () => {
  await todos.post({}).execute();
  console.log(todos.data.value);
};

const getTodoItem = async (id: TodoItem['_id']) => {
  todosId.value = id;
  await todosById.get().execute();
  console.log(todosById.data.value);
};

/**
 * not wrapping
 */
import request from '~/utilities/request';

const getTodos = async () => {
  const response = await request<Todos>('/todos', { method: 'POST', body: {} });
  console.log(response);
};

const getTodoItem = async (id: TodoItem['_id']) => {
  const response = await request<TodosById>(`/todos/${id}`, { method: 'GET' });
  console.log(response);
};
RaminderRandhawa91 commented 1 year ago

@attevaltojarvi I have been using axios to intercept response and request using the example that you mentioned but now I am trying to use ofetch and needed the same functionality on onRequest and onResponse. This is what I am doing

const apiFetch = ofetch.create({
  baseURL: '/api',
  headers: {
    Accept: 'application/json'
  },
  async onRequest({ options }) {
    const token = getAuthToken();
    if (token && options.headers) {
      options.headers = {
        ...options.headers,
        Authorization: `Bearer ${token.accessToken}`,
      };
    }
  },
  async onResponse({ response }) {

  }
})

Can you please help me with onResponse in ofetch doing same functionality as in axios


apiClient.interceptors.response.use(
    response => response,
    async error => {
      const originalRequest = error.config

      // .isRetry is a non-axios property we use to differentiate the actual request from the one we're firing in this interceptor
      if (error.response?.status === 401 && !originalRequest.isRetry) {
        originalRequest.isRetry = true
        try {
          // fetch new tokens from our API
          const refreshToken = authStore.refreshToken
          const { data } = axios.post('/our/nuxt/server-middleware/auth/route/that/proxies/to/our/api/', { refreshToken })

          // simplified for brevity
          setNewAccessTokensToStore(data)

          // retry the original request
          originalRequest.headers = { ...originalRequest.headers, Authorization: `Bearer ${data.refreshToken}` }
          return apiClient(originalRequest)
        } catch (refreshError) {
          return Promise.reject(refreshError)
        }  
      }
    }
  )
chrissyast commented 1 year ago

@frasza

Is there any way to make a wrapper/composable so the API using composition (like using useFetch) remains same?

useFetch is itself an implementation of ofetch, so the same onRequest and onResponse functions can be defined as in @Shyam-Chen's example.

Instead of

ofetch.create({
  // ...
  async onResponse({response}) {
    // yadda yadda yadda
  },
 // ...
})

you'd use

useFetch(url, {
  // ...
  async onResponse({response}) {
    // blah blah blah
  },
 // ...
})

,

kompetenzlandkarte commented 1 year ago

@Shyam-Chen thank you for your example provided. Can you please show how the import { useFetch } from '~/composables'; looks like? I am struggeling with combining the fetcher.raw with the composable.

Shyam-Chen commented 1 year ago

@kompetenzlandkarte I'm sorry, but I haven't packaged it in a composable way at the moment. I use import request from '~/utilities/request';.

The link below shows how I created request.ts: https://github.com/Shyam-Chen/Vue-Starter/blob/main/src/utilities/request.ts

pi0 commented 1 year ago

I think such composable would fit in best in nuxt auth module instead of adding to ofetch core size .

frasza commented 11 months ago

@pi0 I see what you mean but this kind of issue has been seen so many times when I searched for the answer. If nothing else, it would be great to add like an example to the docs -- of composable that can handle token refresh, might help a lot of people using separate backend with such strategy. Would add it myself but still haven't fully figured it out.

tsotnesharvadze commented 10 months ago

Hi Guys,

I Have created new composable for automatic token refresh.

import type {CookieRef, UseFetchOptions} from 'nuxt/app'
import { defu } from 'defu'

export function useCustomFetch<T> (url: string | (() => string), _options: UseFetchOptions<T> = {}) {
  const config = useRuntimeConfig()
  const tokenAuthUrl = useApiUrl('tokenAuth')
  const tokensRefreshUrl = useApiUrl('tokensRefresh')
  const userAuth: CookieRef<Record<string, string>> = useCookie('token')

  const defaults: UseFetchOptions<T> = {
    baseURL: config.public.API_BASE_URL,
    retryStatusCodes: [401],
    retry: 1,
    onRequest ({options}) {
      if (userAuth.value?.access){
        options.headers = {
          ...options.headers,
          'Authorization': `JWT ${userAuth.value?.access}`
        }
      }
    },
    async onResponseError ({response}) {
      if (response.status === 401 && response.url !== tokenAuthUrl && response.url !== tokensRefreshUrl && userAuth.value.refresh) {
        const response = await $fetch(tokensRefreshUrl, {
          baseURL: config.public.API_BASE_URL,
          method: 'POST',
          body:{
            refresh: userAuth.value?.refresh,
          }
        }).then(
          (response) => {
            userAuth.value = response
            return response
          }
        ).catch((error) => {
          console.log(error, 'ErrorRefreshToken')
          return error
        })
      }
    }
  }
  // for nice deep defaults, please use unjs/defu
  const params = defu(_options, defaults)
  return useFetch(url, params)
}

You can use it as useFetch!