Skip to content

Commit

Permalink
Use async/await to allow for async storage. (#61)
Browse files Browse the repository at this point in the history
Redo the way requests queue in the interceptor to remove a race condition.

Bump the major version
  • Loading branch information
splurgebudget authored Jul 26, 2023
1 parent 6b10df5 commit 0647969
Show file tree
Hide file tree
Showing 13 changed files with 101 additions and 135 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "axios-jwt",
"version": "3.0.2",
"version": "4.0.0",
"description": "Axios interceptor to store, use, and refresh tokens for authentication.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
6 changes: 3 additions & 3 deletions src/BrowserStorageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ export class BrowserStorageService {
this._storage = storage
}

remove(key: string) {
async remove(key: string) {
this._storage.removeItem(key)
}

get(key: string) {
async get(key: string) {
return this._storage.getItem(key)
}

set(key: string, value: string) {
async set(key: string, value: string) {
this._storage.setItem(key, value)
}
}
6 changes: 3 additions & 3 deletions src/StorageType.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export type StorageType = {
remove(key: string): void
set(key: string, value: string): void
get(value: string): string | null
remove(key: string): Promise<void>
set(key: string, value: string): Promise<void>
get(value: string): Promise<string | null>
}
95 changes: 29 additions & 66 deletions src/authTokenInterceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,37 +15,7 @@ import ms from 'ms'
// A little time before expiration to try refresh (seconds)
let expireFudge = 10

type RequestsQueue = {
resolve: (value?: unknown) => void
reject: (reason?: unknown) => void
}[]

let isRefreshing = false
let queue: RequestsQueue = []

/**
* Function that resolves all items in the queue with the provided token
* @param token New access token
*/
const resolveQueue = (token?: Token) => {
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 = []
}
let currentlyRequestingPromise: Promise<Token | undefined> | undefined = undefined

/**
* Gets the unix timestamp from an access token
Expand Down Expand Up @@ -87,17 +57,16 @@ const isTokenExpired = (token: Token): boolean => {

/**
* Refreshes the access token using the provided function
* Note: NOT to be called externally. Only accessible through an interceptor
*
* @param {requestRefresh} requestRefresh - Function that is used to get a new access token
* @returns {string} - Fresh access token
*/
const refreshToken = async (requestRefresh: TokenRefreshRequest): Promise<Token> => {
const refreshToken = getRefreshToken()
const refreshToken = await getRefreshToken()
if (!refreshToken) throw new Error('No refresh token available')

try {
isRefreshing = true

// Refresh and store access token using the supplied refresh function
const newTokens = await requestRefresh(refreshToken)
if (typeof newTokens === 'object' && newTokens?.accessToken) {
Expand All @@ -115,7 +84,7 @@ const refreshToken = async (requestRefresh: TokenRefreshRequest): Promise<Token>
const status = error.response?.status
if (status === 401 || status === 422) {
// The refresh token is invalid so remove the stored tokens
StorageProxy.Storage?.remove(STORAGE_KEY)
await StorageProxy.Storage?.remove(STORAGE_KEY)
throw new Error(`Got ${status} on token refresh; clearing both auth tokens`)
}
}
Expand All @@ -126,8 +95,6 @@ const refreshToken = async (requestRefresh: TokenRefreshRequest): Promise<Token>
} else {
throw new Error('Failed to refresh auth token and failed to parse error')
}
} finally {
isRefreshing = false
}
}

Expand All @@ -146,12 +113,11 @@ export const refreshTokenIfNeeded = async (
requestRefresh: TokenRefreshRequest
): Promise<Token | undefined> => {
// use access token (if we have it)
let accessToken = getAccessToken()
let accessToken = await getAccessToken()

// check if access token is expired
if (!accessToken || isTokenExpired(accessToken)) {
// do refresh

accessToken = await refreshToken(requestRefresh)
}

Expand Down Expand Up @@ -182,33 +148,30 @@ export const authTokenInterceptor = ({
return async (requestConfig: any): Promise<any> => {
// Waiting for a fix in axios types
// We need refresh token to do any authenticated requests
if (!getRefreshToken()) return requestConfig

// Queue the request if another refresh request is currently happening
if (isRefreshing) {
return new Promise((resolve, reject) => {
queue.push({ resolve, reject })
})
.then((token) => {
if (requestConfig.headers) {
requestConfig.headers[header] = `${headerPrefix}${token}`
}
return requestConfig
})
.catch(Promise.reject)
}

// Do refresh if needed
let accessToken
try {
accessToken = await refreshTokenIfNeeded(requestRefresh)
resolveQueue(accessToken)
} catch (error: unknown) {
if (error instanceof Error) {
declineQueue(error)
throw new Error(
`Unable to refresh access token for request due to token refresh error: ${error.message}`
)
if (!(await getRefreshToken())) return requestConfig

let accessToken = undefined

// Try to await a current request
if (currentlyRequestingPromise) accessToken = await currentlyRequestingPromise

if (!accessToken) {
try {
// Sets the promise so everyone else will wait - then get the value
currentlyRequestingPromise = refreshTokenIfNeeded(requestRefresh)
accessToken = await currentlyRequestingPromise

// Reset the promise
currentlyRequestingPromise = undefined
} catch (error: unknown) {
// Reset the promise
currentlyRequestingPromise = undefined

if (error instanceof Error) {
throw new Error(
`Unable to refresh access token for request due to token refresh error: ${error.message}`
)
}
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/setAuthTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ import { IAuthTokens } from './IAuthTokens'
* Sets the access and refresh tokens
* @param {IAuthTokens} tokens - Access and Refresh tokens
*/
export const setAuthTokens = (tokens: IAuthTokens): void =>
StorageProxy.Storage?.set(STORAGE_KEY, JSON.stringify(tokens))
export const setAuthTokens = async (tokens: IAuthTokens): Promise<void> =>
await StorageProxy.Storage?.set(STORAGE_KEY, JSON.stringify(tokens))
25 changes: 13 additions & 12 deletions src/tokensUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import { IAuthTokens } from './IAuthTokens'
* Returns the refresh and access tokens
* @returns {IAuthTokens} Object containing refresh and access tokens
*/
const getAuthTokens = (): IAuthTokens | undefined => {
const rawTokens = StorageProxy.Storage?.get(STORAGE_KEY)
const getAuthTokens = async (): Promise<IAuthTokens | undefined> => {
const rawTokens = await StorageProxy.Storage?.get(STORAGE_KEY)
if (!rawTokens) return

try {
Expand All @@ -29,44 +29,45 @@ const getAuthTokens = (): IAuthTokens | undefined => {
* Sets the access token
* @param {string} token - Access token
*/
export const setAccessToken = (token: Token): void => {
const tokens = getAuthTokens()
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
setAuthTokens(tokens)
await setAuthTokens(tokens)
}

/**
* Returns the stored refresh token
* @returns {string} Refresh token
*/
export const getRefreshToken = (): Token | undefined => {
const tokens = getAuthTokens()
export const getRefreshToken = async (): Promise<Token | undefined> => {
const tokens = await getAuthTokens()
return tokens ? tokens.refreshToken : undefined
}

/**
* Returns the stored access token
* @returns {string} Access token
*/
export const getAccessToken = (): Token | undefined => {
const tokens = getAuthTokens()
export const getAccessToken = async (): Promise<Token | undefined> => {
const tokens = await getAuthTokens()
return tokens ? tokens.accessToken : undefined
}

/**
* Clears both tokens
*/
export const clearAuthTokens = (): void => StorageProxy.Storage?.remove(STORAGE_KEY)
export const clearAuthTokens = async (): Promise<void> =>
await StorageProxy.Storage?.remove(STORAGE_KEY)

/**
* Checks if refresh tokens are stored
* @returns Whether the user is logged in or not
*/
export const isLoggedIn = (): boolean => {
const token = getRefreshToken()
export const isLoggedIn = async (): Promise<boolean> => {
const token = await getRefreshToken()
return !!token
}
6 changes: 3 additions & 3 deletions tests/authTokenInterceptor.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AxiosRequestConfig } from 'axios';
import jwt from 'jsonwebtoken';
import { authTokenInterceptor } from '../index';
import { AxiosRequestConfig } from 'axios'
import jwt from 'jsonwebtoken'
import { authTokenInterceptor } from '../index'

describe('authTokenInterceptor', () => {
it('returns the original request config if refresh token is not set', async () => {
Expand Down
9 changes: 4 additions & 5 deletions tests/clearAuthTokens.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import { STORAGE_KEY } from '../src/StorageKey';
import { clearAuthTokens } from '../index';

import { STORAGE_KEY } from '../src/StorageKey'
import { clearAuthTokens } from '../index'

describe('clearAuthTokens', () => {
it('removes the tokens from localstorage', () => {
it('removes the tokens from localstorage', async () => {
// GIVEN
// Tokens are stored in localStorage
const tokens = { accessToken: 'accesstoken', refreshToken: 'refreshtoken' }
localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens))

// WHEN
// I call clearAuthTokens
clearAuthTokens()
await clearAuthTokens()

// THEN
// I expect the localstorage to be empty
Expand Down
26 changes: 13 additions & 13 deletions tests/getAccessToken.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getAccessToken, authTokenInterceptor, getBrowserSessionStorage } from '../index';
import { STORAGE_KEY } from '../src/StorageKey';
import { getAccessToken, authTokenInterceptor, getBrowserSessionStorage } from '../index'
import { STORAGE_KEY } from '../src/StorageKey'

describe('getAccessToken', () => {
beforeEach(function () {
Expand All @@ -8,67 +8,67 @@ describe('getAccessToken', () => {
})

describe('for localStorage', function () {
it('returns undefined if tokens are not set', () => {
it('returns undefined if tokens are not set', async () => {
// GIVEN
// localStorage is empty
localStorage.removeItem(STORAGE_KEY)

// WHEN
// I call getAccessToken
const result = getAccessToken()
const result = await getAccessToken()

// THEN
// I expect the result to be undefined
expect(result).toEqual(undefined)
})

it('returns the access token is it is set', () => {
it('returns the access token is it is set', async () => {
// GIVEN
// Both tokens are stored in localstorage
const tokens = { accessToken: 'accesstoken', refreshToken: 'refreshtoken' }
localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens))

// WHEN
// I call getAccessToken
const result = getAccessToken()
const result = await getAccessToken()

// THEN
// I expect the result to be the supplied access token
expect(result).toEqual('accesstoken')
})
});
})

describe('for sessionStorage', function () {
beforeEach( () => {
beforeEach(() => {
const getStorage = getBrowserSessionStorage
const requestRefresh = jest.fn()

authTokenInterceptor({getStorage, requestRefresh })
authTokenInterceptor({ getStorage, requestRefresh })
})

it('returns undefined if tokens are not set', () => {
it('returns undefined if tokens are not set', async () => {
// GIVEN
// localStorage is empty
sessionStorage.removeItem(STORAGE_KEY)

// WHEN
// I call getAccessToken
const result = getAccessToken()
const result = await getAccessToken()

// THEN
// I expect the result to be undefined
expect(result).toEqual(undefined)
})

it('returns the access token is it is set', () => {
it('returns the access token is it is set', async () => {
// GIVEN
// Both tokens are stored in localstorage
const tokens = { accessToken: 'accesstoken_session', refreshToken: 'refreshtoken_session' }
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(tokens))

// WHEN
// I call getAccessToken
const result = getAccessToken()
const result = await getAccessToken()

// THEN
// I expect the result to be the supplied access token
Expand Down
Loading

0 comments on commit 0647969

Please sign in to comment.