Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 19 additions & 38 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const setAccessToken = async (token: Token): Promise<void> => {
/**
* Clears both tokens
* @async
* @param {Promise}
* @returns {Promise}
*/
export const clearAuthTokens = (): Promise<void> => AsyncStorage.removeItem(STORAGE_KEY)

Expand All @@ -79,7 +79,7 @@ export const getAccessToken = async (): Promise<Token | undefined> => {
/**
* @callback requestRefresh
* @param {string} refreshToken - Token that is sent to the backend
* @returns {Promise} Promise that resolves in an access token
* @returns {Promise} Promise that resolves an access token
*/

/**
Expand All @@ -104,7 +104,7 @@ export const refreshTokenIfNeeded = async (requestRefresh: TokenRefreshRequest):

/**
*
* @param {Axios} axios - Axios instance to apply the interceptor to
* @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 => {
Expand Down Expand Up @@ -132,10 +132,10 @@ const getAuthTokens = async (): Promise<AuthTokens | undefined> => {
}

/**
* Checks if the token is undefined, has expired or is about the expire
* 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 the expire
* @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
Expand All @@ -144,7 +144,7 @@ const isTokenExpired = (token: Token): boolean => {
}

/**
* Gets the unix timestamp from an access token
* Gets the unix timestamp from the JWT access token
*
* @param {string} token - Access token
* @returns {string} Unix timestamp
Expand Down Expand Up @@ -189,8 +189,6 @@ const refreshToken = async (requestRefresh: TokenRefreshRequest): Promise<Token>
await setAccessToken(newTokens)
return newTokens
}

throw new Error('requestRefresh must either return a string or an object with an accessToken')
} catch (error) {
if (!axios.isAxiosError(error)) throw error

Expand All @@ -204,6 +202,8 @@ const refreshToken = async (requestRefresh: TokenRefreshRequest): Promise<Token>

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>
Expand All @@ -215,7 +215,7 @@ export interface AuthTokenInterceptorConfig {
}

/**
* Function that returns an Axios Intercepter that:
* 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.
Expand All @@ -230,24 +230,23 @@ export const authTokenInterceptor =
const refreshToken = await getRefreshToken()
if (!refreshToken) return requestConfig

const authenticateRequest = (token: string | undefined) => {
if (token) requestConfig.headers[header] = `${headerPrefix}${token}`
return requestConfig
}

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

// Do refresh if needed
let accessToken
try {
isRefreshing = true
accessToken = await refreshTokenIfNeeded(requestRefresh)
resolveQueue(accessToken)
} catch (error) {
declineQueue(error as Error)

Expand All @@ -259,38 +258,20 @@ export const authTokenInterceptor =
} finally {
isRefreshing = false
}
resolveQueue(accessToken)

// add token to headers
if (accessToken) requestConfig.headers[header] = `${headerPrefix}${accessToken}`
return requestConfig
return authenticateRequest(accessToken)
}

type RequestsQueue = {
resolve: (value?: unknown) => void
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
Expand Down
80 changes: 66 additions & 14 deletions tests/authTokenInterceptor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,17 @@ describe('authTokenInterceptor', () => {

it('sets the original access token as header if has not yet expired', async () => {
// GIVEN
// I have an access token that expired an hour ago
const expiredToken = jwt.sign(
// I have an access token that expires in 5 minutes
const validToken = jwt.sign(
{
exp: Math.floor(Date.now() / 1000) - 60 * 60,
exp: Math.floor(Date.now() / 1000) + 5 * 60,
data: 'foobar',
},
'secret'
)

// and this token is stored in local storage
const tokens = { accessToken: expiredToken, refreshToken: 'refreshtoken' }
const tokens = { accessToken: validToken, refreshToken: 'refreshtoken' }
localStorage.setItem('auth-tokens-test', JSON.stringify(tokens))

// and I have a config defined
Expand All @@ -68,16 +68,16 @@ describe('authTokenInterceptor', () => {
const result = await interceptor(exampleConfig)

// THEN
// I expect the result to have an updated header
// I expect the result to use the current token
expect(result).toEqual({
...exampleConfig,
headers: {
Auth: 'Prefix newtoken',
Auth: `Prefix ${validToken}`,
},
})
})

it('throws an error if refreshTokenIfNeeded produces one', async () => {
it('re-throws an error if refreshTokenIfNeeded throws one', async () => {
// GIVEN
// I have an access token that expired an hour ago
const expiredToken = jwt.sign(
Expand Down Expand Up @@ -187,11 +187,8 @@ describe('authTokenInterceptor', () => {
// and I have a config defined
const config = {
requestRefresh: async () => {
await new Promise((resolve) => {
refreshes++
setTimeout(resolve, 100)
})

await new Promise(resolve => setTimeout(resolve, 100))
refreshes++
return 'updatedaccesstoken'
},
}
Expand All @@ -213,8 +210,63 @@ describe('authTokenInterceptor', () => {
])

// THEN
// I expect the result to have an updated header
expect(results[0].headers).toEqual({ Authorization: 'Bearer updatedaccesstoken' })
// I expect all results to use the updated access token
for( const result of results) {
expect(result.headers).toEqual({ Authorization: 'Bearer updatedaccesstoken' })
}

// and the number of refreshes to be 1
expect(refreshes).toEqual(1)
})

it('decline queued calls when error occurred during token refresh', async () => {
// GIVEN
// We are counting the number of times a token is being refreshed
let refreshes = 0

// and I have an access token that expired an hour ago
const expiredToken = jwt.sign(
{
exp: Math.floor(Date.now() / 1000) - 60 * 60,
data: 'foobar',
},
'secret'
)

// and this token is stored in local storage
const tokens = { accessToken: expiredToken, refreshToken: 'refreshtoken' }
localStorage.setItem('auth-tokens-test', JSON.stringify(tokens))

// and I have a config defined
const config = {
requestRefresh: async () => {
await new Promise(resolve => setTimeout(resolve, 100))
refreshes++
throw Error("Network Error")
},
}

// and I have a request config
const exampleConfig: AxiosRequestConfig = {
url: 'https://example.com',
method: 'POST',
headers: {},
}

// WHEN
// I create 3 interceptor and call them all at once
const interceptor = authTokenInterceptor(config)
await expect(
Promise.all([
interceptor(exampleConfig).catch(error => error.message),
interceptor(exampleConfig).catch(error => error.message),
interceptor(exampleConfig).catch(error => error.message),
])
).resolves.toEqual([
"Unable to refresh access token for request due to token refresh error: Network Error",
"Unable to refresh access token for request due to token refresh error: Network Error",
"Unable to refresh access token for request due to token refresh error: Network Error",
])

// and the number of refreshes to be 1
expect(refreshes).toEqual(1)
Expand Down
2 changes: 1 addition & 1 deletion tests/refreshTokenIfNeeded.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ describe('refreshTokenIfNeeded', () => {
const result = await refreshTokenIfNeeded(requestRefresh)

// THEN
// I expect both the stord tokens to have been updated
// I expect both the stored tokens to have been updated
const storedTokens = localStorage.getItem(STORAGE_KEY) as string
expect(JSON.parse(storedTokens)).toEqual({ accessToken: 'newaccesstoken', refreshToken: 'newrefreshtoken' })

Expand Down