Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
attevaltojarvi opened this issue May 6, 2022 · 22 comments
Labels
enhancement New feature or request

Comments

@attevaltojarvi
Copy link

attevaltojarvi commented May 6, 2022

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
Copy link

vulpeep commented May 10, 2022

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
Copy link
Member

pi0 commented May 10, 2022

Thanks for the feedback @evfoxin @attevaltojarvi

As a summary what I could understand is missing:

  • Allow chaining fetch requests using interceptors
  • Expose internal type for FetchContext

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

@attevaltojarvi
Copy link
Author

attevaltojarvi commented May 18, 2022

@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
Copy link
Author

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
Copy link

+1

@Denoder
Copy link

Denoder commented Jul 21, 2022

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
Copy link

AscaL commented Oct 6, 2022

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

  • Support for this feature (interceptors with behaviour similar to axios) is coming.
  • There is any new functionality that allows me to handle this at this time.
  • Should I develop using an alternative solution (maybe nuxt-alt/http as suggested)

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
Copy link

reslear commented Nov 28, 2022

@NozomuIkuta NozomuIkuta added the enhancement New feature or request label Jan 11, 2023
@mrc-bsllt
Copy link

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

@attevaltojarvi
Copy link
Author

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

@Shyam-Chen
Copy link

Shyam-Chen commented Feb 1, 2023

@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
Copy link

@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
Copy link

@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
Copy link

frasza commented Feb 14, 2023

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

@Shyam-Chen
Copy link

Shyam-Chen commented Mar 3, 2023

/**
 * 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
Copy link

@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
Copy link

chrissyast commented Apr 18, 2023

@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
Copy link

@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
Copy link

@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
Copy link
Member

pi0 commented Aug 23, 2023

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

@pi0 pi0 closed this as not planned Won't fix, can't repro, duplicate, stale Aug 23, 2023
@frasza
Copy link

frasza commented Oct 6, 2023

@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
Copy link

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests