UNPKG

react-native-axios-jwt

Version:

Axios interceptor to store, transmit, clear and automatically refresh tokens for authentication in a React Native environment

318 lines (270 loc) 9.15 kB
import jwtDecode, { JwtPayload } from 'jwt-decode' import axios, { AxiosInstance, AxiosRequestConfig } from 'axios' import AsyncStorage from '@react-native-async-storage/async-storage' // a little time before expiration to try refresh (seconds) const EXPIRE_FUDGE = 10 export const STORAGE_KEY = `auth-tokens-${process.env.NODE_ENV}` type Token = string export interface AuthTokens { accessToken: Token refreshToken: Token } // EXPORTS /** * Checks if refresh tokens are stored * @async * @returns {Promise<boolean>} Whether the user is logged in or not */ export const isLoggedIn = async (): Promise<boolean> => { const token = await getRefreshToken() return !!token } /** * Sets the access and refresh tokens * @async * @param {AuthTokens} tokens - Access and Refresh tokens * @returns {Promise} */ export const setAuthTokens = (tokens: AuthTokens): Promise<void> => AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(tokens)) /** * Sets the access token * @async * @param {Promise} token - Access token */ export const setAccessToken = async (token: Token): Promise<void> => { const tokens = await getAuthTokens() if (!tokens) { throw new Error('Unable to update access token since there are not tokens currently stored') } tokens.accessToken = token return setAuthTokens(tokens) } /** * Clears both tokens * @async * @returns {Promise} */ export const clearAuthTokens = (): Promise<void> => AsyncStorage.removeItem(STORAGE_KEY) /** * Returns the stored refresh token * @async * @returns {Promise<string>} Refresh token */ export const getRefreshToken = async (): Promise<Token | undefined> => { const tokens = await getAuthTokens() return tokens ? tokens.refreshToken : undefined } /** * Returns the stored access token * @async * @returns {Promise<string>} Access token */ export const getAccessToken = async (): Promise<Token | undefined> => { const tokens = await getAuthTokens() return tokens ? tokens.accessToken : undefined } /** * @callback requestRefresh * @param {string} refreshToken - Token that is sent to the backend * @returns {Promise} Promise that resolves an access token */ /** * Gets the current access token, exchanges it with a new one if it's expired and then returns the token. * @async * @param {requestRefresh} requestRefresh - Function that is used to get a new access token * @returns {Promise<string>} Access token */ export const refreshTokenIfNeeded = async (requestRefresh: TokenRefreshRequest): Promise<Token | undefined> => { // use access token (if we have it) let accessToken = await getAccessToken() // check if access token is expired if (!accessToken || isTokenExpired(accessToken)) { // do refresh accessToken = await refreshToken(requestRefresh) } return accessToken } /** * * @param {axios} axios - Axios instance to apply the interceptor to * @param {AuthTokenInterceptorConfig} config - Configuration for the interceptor */ export const applyAuthTokenInterceptor = (axios: AxiosInstance, config: AuthTokenInterceptorConfig): void => { if (!axios.interceptors) throw new Error(`invalid axios instance: ${axios}`) axios.interceptors.request.use(authTokenInterceptor(config)) } // PRIVATE /** * Returns the refresh and access tokens * @async * @returns {Promise<AuthTokens>} Object containing refresh and access tokens */ const getAuthTokens = async (): Promise<AuthTokens | undefined> => { const rawTokens = await AsyncStorage.getItem(STORAGE_KEY) if (!rawTokens) return try { // parse stored tokens JSON return JSON.parse(rawTokens) } catch (error) { throw new Error(`Failed to parse auth tokens: ${rawTokens}`) } } /** * Checks if the token is undefined, has expired or is about to expire * * @param {string} token - Access token * @returns {boolean} Whether or not the token is undefined, has expired or is about to expire */ const isTokenExpired = (token: Token): boolean => { if (!token) return true const expiresIn = getExpiresIn(token) return !expiresIn || expiresIn <= EXPIRE_FUDGE } /** * Gets the unix timestamp from the JWT access token * * @param {string} token - Access token * @returns {string} Unix timestamp */ const getTimestampFromToken = (token: Token): number | undefined => { const decoded = jwtDecode<JwtPayload>(token) return decoded.exp } /** * Returns the number of seconds before the access token expires or -1 if it already has * * @param {string} token - Access token * @returns {number} Number of seconds before the access token expires */ const getExpiresIn = (token: Token): number => { const expiration = getTimestampFromToken(token) if (!expiration) return -1 return expiration - Date.now() / 1000 } /** * Refreshes the access token using the provided function * @async * @param {requestRefresh} requestRefresh - Function that is used to get a new access token * @returns {Promise<string>} - Fresh access token */ const refreshToken = async (requestRefresh: TokenRefreshRequest): Promise<Token> => { const refreshToken = await getRefreshToken() if (!refreshToken) throw new Error('No refresh token available') try { // Refresh and store access token using the supplied refresh function const newTokens = await requestRefresh(refreshToken) if (typeof newTokens === 'object' && newTokens?.accessToken) { await setAuthTokens(newTokens) return newTokens.accessToken } else if (typeof newTokens === 'string') { await setAccessToken(newTokens) return newTokens } } catch (error) { if (!axios.isAxiosError(error)) throw error // Failed to refresh token const status = error.response?.status if (status === 401 || status === 422) { // The refresh token is invalid so remove the stored tokens await AsyncStorage.removeItem(STORAGE_KEY) error.message = `Got ${status} on token refresh; clearing both auth tokens` } throw error } throw new Error('requestRefresh must either return a string or an object with an accessToken') } export type TokenRefreshRequest = (refreshToken: string) => Promise<Token | AuthTokens> export interface AuthTokenInterceptorConfig { header?: string headerPrefix?: string requestRefresh: TokenRefreshRequest } /** * Function that returns an Axios Interceptor that: * - Applies that right auth header to requests * - Refreshes the access token when needed * - Puts subsequent requests in a queue and executes them in order after the access token has been refreshed. * * @param {AuthTokenInterceptorConfig} config - Configuration for the interceptor * @returns {Promise<AxiosRequestConfig} Promise that resolves in the supplied requestConfig */ export const authTokenInterceptor = ({ header = 'Authorization', headerPrefix = 'Bearer ', requestRefresh }: AuthTokenInterceptorConfig) => async (requestConfig: AxiosRequestConfig): Promise<AxiosRequestConfig> => { // We need refresh token to do any authenticated requests const refreshToken = await getRefreshToken() if (!refreshToken) return requestConfig const authenticateRequest = (token: string | undefined) => { if (token) { requestConfig.headers = requestConfig.headers ?? {} requestConfig.headers[header] = `${headerPrefix}${token}` } return requestConfig } // Queue the request if another refresh request is currently happening if (isRefreshing) { return new Promise((resolve: (token?: string) => void, reject) => { queue.push({ resolve, reject }) }).then(authenticateRequest) } // Do refresh if needed let accessToken try { setIsRefreshing(true) accessToken = await refreshTokenIfNeeded(requestRefresh) } catch (error) { declineQueue(error as Error) if (error instanceof Error) { error.message = `Unable to refresh access token for request due to token refresh error: ${error.message}` } throw error } finally { setIsRefreshing(false) } resolveQueue(accessToken) // add token to headers return authenticateRequest(accessToken) } type RequestsQueue = { resolve: (token?: string) => void reject: (reason?: unknown) => void }[] let isRefreshing = false let queue: RequestsQueue = [] /** * Check if tokens are currently being refreshed * * @returns {boolean} True if the tokens are currently being refreshed, false is not */ export function getIsRefreshing(): boolean { return isRefreshing } /** * Update refresh state * * @param {boolean} newRefreshingState */ export function setIsRefreshing(newRefreshingState: boolean): void { isRefreshing = newRefreshingState } /** * Function that resolves all items in the queue with the provided token * @param token New access token */ const resolveQueue = (token?: string) => { queue.forEach((p) => { p.resolve(token) }) queue = [] } /** * Function that declines all items in the queue with the provided error * @param error Error */ const declineQueue = (error: Error) => { queue.forEach((p) => { p.reject(error) }) queue = [] }