Skip to content

Commit 9863c57

Browse files
author
Gadhi Rodriguez
authored
Fix token refresh flow for multipart requests (#84)
1 parent 3424aca commit 9863c57

File tree

20 files changed

+156
-89
lines changed

20 files changed

+156
-89
lines changed

packages/authentication/CHANGELOG.md

+12
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# @baseapp-frontend/authentication
22

3+
## 2.0.0
4+
5+
### Major Changes
6+
7+
- The getUser function is async now, handle accordingly.
8+
- Some types were added to make testing easier.
9+
10+
### Patch Changes
11+
12+
- Updated dependencies [2300f2d]
13+
- @baseapp-frontend/utils@2.0.0
14+
315
## 1.2.6
416

517
### Patch Changes

packages/authentication/modules/user/getUser/__tests__/getUser.test.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,18 @@ import getUser from '../index'
66
import jwt from './fixtures/jwt.json'
77

88
describe('getUser', () => {
9-
it('should return the user from the JWT cookie', () => {
9+
it('should return the user from the JWT cookie', async () => {
1010
;(Cookies.get as CookiesGetByNameFn) = jest.fn(() => jwt.token)
11-
const user = getUser()
11+
const user = await getUser()
1212

1313
expect(user?.email).toBe('user@company.com')
1414
expect(user?.firstName).toBe('John')
1515
expect(user?.lastName).toBe('Doe')
1616
})
1717

18-
it('should return null if the JWT cookie is not set', () => {
18+
it('should return null if the JWT cookie is not set', async () => {
1919
;(Cookies.get as CookiesGetByNameFn) = jest.fn(() => undefined)
20-
const user = getUser()
20+
const user = await getUser()
2121

2222
expect(user).toBeNull()
2323
})

packages/authentication/modules/user/getUser/index.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import { IJWTContent } from '@baseapp-frontend/utils/types/jwt'
55
import { IUser } from '../../../types/user'
66
import { GetUserOptions } from './types'
77

8-
const getUser = <TUser extends Partial<IUser> & IJWTContent>({
8+
const getUser = async <TUser extends Partial<IUser> & IJWTContent>({
99
cookieName = ACCESS_COOKIE_NAME,
1010
}: GetUserOptions = {}) => {
11-
const token = getToken(cookieName)
11+
const token = await getToken(cookieName)
1212
if (token) {
1313
try {
1414
const user = decodeJWT<TUser>(token)

packages/authentication/modules/user/useSimpleTokenUser/__tests__/useSimpleTokenUser.test.tsx

+26-4
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,38 @@ import {
55
renderHook,
66
waitFor,
77
} from '@baseapp-frontend/test'
8-
import { axios } from '@baseapp-frontend/utils'
98

9+
import { UseQueryResult } from '@tanstack/react-query'
1010
import Cookies from 'js-cookie'
1111

1212
import { IUser } from '../../../../types/user'
13-
import useSimpleTokenUser from '../index'
13+
import { IUseSimpleTokenUser } from '../types'
1414
import request from './fixtures/request.json'
1515

16-
// @ts-ignore TODO: (BA-1081) investigate AxiosRequestHeaders error
17-
export const axiosMock = new MockAdapter(axios)
16+
interface IUseSimpleTokenUserResult<IUser> extends Omit<UseQueryResult<IUser, unknown>, 'data'> {
17+
user?: IUser
18+
}
1819

1920
describe('useSimpleTokenUser', () => {
21+
let useSimpleTokenUser: <TUser extends Partial<IUser>>(
22+
props?: IUseSimpleTokenUser<TUser>,
23+
) => IUseSimpleTokenUserResult<TUser>
24+
let defaultTokenType: string | undefined
25+
let axios: any
26+
let axiosMock: MockAdapter
27+
28+
beforeAll(async () => {
29+
defaultTokenType = process.env.NEXT_PUBLIC_TOKEN_TYPE
30+
process.env.NEXT_PUBLIC_TOKEN_TYPE = 'simple'
31+
axios = (await import('@baseapp-frontend/utils')).axios
32+
axiosMock = new MockAdapter(axios)
33+
useSimpleTokenUser = (await import('../index')).default as any
34+
})
35+
36+
afterAll(() => {
37+
process.env.NEXT_PUBLIC_TOKEN_TYPE = defaultTokenType
38+
})
39+
2040
test('should user be present for authenticated', async () => {
2141
;(Cookies.get as CookiesGetByNameFn) = jest.fn(() => 'fake token')
2242

@@ -80,4 +100,6 @@ describe('useSimpleTokenUser', () => {
80100
await waitFor(() => expect(Cookies.remove).toHaveBeenCalled())
81101
expect(result.current.user).not.toBeDefined()
82102
})
103+
104+
process.env.NEXT_PUBLIC_TOKEN_TYPE = defaultTokenType
83105
})

packages/authentication/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@baseapp-frontend/authentication",
33
"description": "Authentication modules.",
4-
"version": "1.2.6",
4+
"version": "2.0.0",
55
"main": "./dist/index.ts",
66
"module": "./dist/index.mjs",
77
"scripts": {

packages/utils/CHANGELOG.md

+9-1
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
# @baseapp-frontend/utils
22

3+
## 2.0.0
4+
5+
### Major Changes
6+
7+
- The axios instance will try to refresh the access token on the request interceptor if the JWT token flow is used, enabling the flow to be used with file uploads.
8+
- The getToken function is async now, handle accordingly.
9+
- The refreshAccessToken function was moved to its own file and it won't try to make the request again on a 401 error.
10+
- Some 'use client' directives were added to allow imports on server-side components.
11+
312
## 1.4.4
413

514
### Patch Changes
615

716
- Make `OptionalActions` types inside `withController` more flexible.
817

9-
1018
## 1.4.3
1119

1220
### Patch Changes

packages/utils/functions/axios/createAxiosInstance/__tests__/createAxiosInstance.test.ts

+6
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ jest.mock('js-cookie', () => ({
1717
...jest.requireActual('js-cookie'),
1818
get: () => 'someAuthToken',
1919
}))
20+
jest.mock('../../../token/decodeJWT', () => ({
21+
decodeJWT: () => ({ exp: 1234567890 }),
22+
}))
23+
jest.mock('../../../token/isUserTokenValid', () => ({
24+
isUserTokenValid: () => true,
25+
}))
2026

2127
describe('createAxiosInstance', () => {
2228
afterEach(() => {

packages/utils/functions/axios/createAxiosInstance/__tests__/refreshAccessToken.test.ts

+17-12
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,21 @@ import Cookies from 'js-cookie'
44

55
import { ACCESS_COOKIE_NAME, REFRESH_COOKIE_NAME } from '../../../../constants/cookie'
66
import { CookieType } from '../../../../types/cookie'
7+
import { simpleAxios as refreshTokenAxios } from '../../../token'
8+
import { isUserTokenValid } from '../../../token/isUserTokenValid'
79
import { axios } from '../index'
810

911
jest.mock('js-cookie')
12+
jest.mock('../../../token/decodeJWT')
13+
jest.mock('../../../token/isUserTokenValid')
1014

1115
describe('refreshAccessToken', () => {
1216
// @ts-ignore TODO: (BA-1081) investigate AxiosRequestHeaders error
1317
const axiosMock = new MockAdapter(axios)
18+
// @ts-ignore TODO: (BA-1081) investigate AxiosRequestHeaders error
19+
const refreshTokenAxiosMock = new MockAdapter(refreshTokenAxios)
1420

15-
it('should refresh the token if token type is jwt and endpoint returns 401 error ', async () => {
21+
it('should refresh the token if token type is jwt and the token is expired ', async () => {
1622
const cookiesFakeStore = {
1723
[ACCESS_COOKIE_NAME]: 'accessToken',
1824
[REFRESH_COOKIE_NAME]: 'refreshToken',
@@ -26,27 +32,25 @@ describe('refreshAccessToken', () => {
2632
;(Cookies.get as jest.Mock).mockImplementation(
2733
(cookieName: CookieType) => cookiesFakeStore[cookieName],
2834
)
35+
;(isUserTokenValid as jest.Mock).mockImplementation(() => false)
2936

30-
let timesCalled = 0
31-
axiosMock.onPost('/test-endpoint').reply(() => {
32-
timesCalled += 1
33-
if (timesCalled === 1) {
34-
return [401, {}]
35-
}
36-
return [200, {}]
37-
})
38-
axiosMock
37+
axiosMock.onPost('/test-endpoint').reply(200, {})
38+
refreshTokenAxiosMock
3939
.onPost('/auth/refresh')
4040
.reply(200, { access: 'newAccessToken', refresh: 'newRefreshToken' })
4141

4242
await axios.post('/test-endpoint')
4343

44-
expect(timesCalled).toBe(2)
44+
expect(axiosMock.history.post.length).toBe(1)
45+
expect(refreshTokenAxiosMock.history.post.length).toBe(1)
4546
expect(Cookies.set).toBeCalledTimes(1)
4647
expect(cookiesFakeStore[ACCESS_COOKIE_NAME]).toBe('newAccessToken')
4748
})
4849

4950
it('should remove the token cookies if the refresh endpoint fails', async () => {
51+
axiosMock.resetHistory()
52+
refreshTokenAxiosMock.resetHistory()
53+
5054
const cookiesFakeStore = {
5155
[ACCESS_COOKIE_NAME]: 'accessToken',
5256
[REFRESH_COOKIE_NAME]: 'refreshToken',
@@ -59,10 +63,11 @@ describe('refreshAccessToken', () => {
5963
})
6064

6165
axiosMock.onPost('/test-endpoint').reply(401, {})
62-
axiosMock.onPost('/auth/refresh').reply(401, {})
66+
refreshTokenAxiosMock.onPost('/auth/refresh').reply(401, {})
6367

6468
await expect(axios.post('/test-endpoint')).rejects.toThrow()
6569

70+
expect(refreshTokenAxiosMock.history.post.length).toBe(1)
6671
expect(Cookies.remove).toBeCalledTimes(2)
6772
expect(cookiesFakeStore[ACCESS_COOKIE_NAME]).toBeUndefined()
6873
expect(cookiesFakeStore[REFRESH_COOKIE_NAME]).toBeUndefined()
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
1-
import _axios, { AxiosRequestConfig } from 'axios'
1+
import _axios from 'axios'
22
import humps from 'humps'
33
import Cookies from 'js-cookie'
44

55
import { SERVICES_WITHOUT_TOKEN } from '../../../constants/axios'
66
import { ACCESS_COOKIE_NAME, REFRESH_COOKIE_NAME } from '../../../constants/cookie'
77
import { LOGOUT_EVENT } from '../../../constants/events'
88
import { TokenTypes } from '../../../constants/token'
9-
import { IJWTResponse } from '../../../types/jwt'
109
import { eventEmitter } from '../../events'
1110
import { buildQueryString } from '../../string'
12-
13-
const REFRESH_TOKEN_URL = '/auth/refresh'
11+
import { decodeJWT, isUserTokenValid } from '../../token'
12+
import { refreshAccessToken } from '../../token/refreshAccessToken'
1413

1514
export const createAxiosInstance = ({
1615
returnData = true,
@@ -35,9 +34,22 @@ export const createAxiosInstance = ({
3534
instance.defaults.headers.put['Content-Type'] = contentType
3635

3736
const requestInterceptorId = instance.interceptors.request.use(async (request) => {
38-
const authToken = Cookies.get(cookieName)
37+
let authToken = Cookies.get(cookieName)
3938

4039
if (authToken) {
40+
const isTokenValid =
41+
tokenType === TokenTypes.jwt ? isUserTokenValid(decodeJWT(authToken)) : true
42+
if (!isTokenValid) {
43+
try {
44+
authToken = await refreshAccessToken(cookieName, refreshCookieName)
45+
} catch (error) {
46+
if (eventEmitter.listenerCount(LOGOUT_EVENT)) {
47+
eventEmitter.emit(LOGOUT_EVENT)
48+
}
49+
return Promise.reject(error)
50+
}
51+
}
52+
4153
if (
4254
request.headers &&
4355
!request.headers.Authorization &&
@@ -67,22 +79,6 @@ export const createAxiosInstance = ({
6779
return returnData && response.data ? response.data : response
6880
},
6981
async (error) => {
70-
if (
71-
tokenType === TokenTypes.jwt &&
72-
error.response?.status === 401 &&
73-
error.config.url !== REFRESH_TOKEN_URL
74-
) {
75-
try {
76-
const originalRequest = error.config
77-
// eslint-disable-next-line @typescript-eslint/no-use-before-define
78-
return await refreshAccessToken(cookieName, refreshCookieName, originalRequest)
79-
} catch (refreshError) {
80-
if (eventEmitter.listenerCount(LOGOUT_EVENT)) {
81-
eventEmitter.emit(LOGOUT_EVENT)
82-
}
83-
}
84-
}
85-
8682
if (error.response.data && error.response.headers?.['content-type'] === 'application/json') {
8783
const newError = { response: { data: {} } }
8884
newError.response.data = humps.camelizeKeys(error.response.data)
@@ -102,38 +98,3 @@ export const {
10298
requestInterceptorId: requestInterceptorIdForFiles,
10399
responseInterceptorId: responseInterceptorIdForFiles,
104100
} = createAxiosInstance({ file: true })
105-
106-
// TODO: move this function to a separate file (we can't do it now because of a circular dependency)
107-
export const refreshAccessToken = async (
108-
cookieName: string,
109-
refreshCookieName: string,
110-
originalRequest: AxiosRequestConfig,
111-
) => {
112-
const refreshToken = Cookies.get(refreshCookieName)
113-
114-
if (!refreshToken) {
115-
return Promise.reject(new Error('No refresh token'))
116-
}
117-
118-
try {
119-
const response = (await axios.post(REFRESH_TOKEN_URL, {
120-
refresh: refreshToken,
121-
})) as IJWTResponse
122-
123-
Cookies.set(cookieName, response.access, {
124-
secure: process.env.NODE_ENV === 'production',
125-
})
126-
127-
if (originalRequest.headers) {
128-
// eslint-disable-next-line no-param-reassign
129-
originalRequest.headers.Authorization = `Bearer ${response.access}`
130-
}
131-
132-
return await axios(originalRequest)
133-
} catch (error) {
134-
Cookies.remove(cookieName)
135-
Cookies.remove(refreshCookieName)
136-
137-
return Promise.reject(error)
138-
}
139-
}

packages/utils/functions/form/withController/index.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
'use client'
2+
13
import { ChangeEventHandler, FC, FocusEventHandler } from 'react'
24

35
import { Controller } from 'react-hook-form'
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import humps from 'humps'
22
import jwt_decode from 'jwt-decode'
33

4-
export const decodeJWT = <JWTContentType>(token: string) =>
5-
humps.camelizeKeys(jwt_decode(token)) as JWTContentType
4+
export const decodeJWT = <JWTContentType>(token: string) => {
5+
try {
6+
return humps.camelizeKeys(jwt_decode(token)) as JWTContentType
7+
} catch (error) {
8+
// If the token has an invalid format, jwt_decode throws an error
9+
return null
10+
}
11+
}

packages/utils/functions/token/getToken/__tests__/getClientSideToken.test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ describe('getToken function on the client', () => {
2020
jest.clearAllMocks()
2121
})
2222

23-
it('retrieves a client-side cookie', () => {
23+
it('retrieves a client-side cookie', async () => {
2424
;(ClientCookies.get as CookiesGetByNameFn) = jest.fn(() => clientCookieValue)
2525

26-
expect(getToken(cookieName)).toBe(clientCookieValue)
26+
expect(await getToken(cookieName)).toBe(clientCookieValue)
2727
expect(ClientCookies.get).toHaveBeenCalledWith(cookieName)
2828
})
2929
})

packages/utils/functions/token/getToken/__tests__/getServerSideToken.test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ describe('getToken function on the server', () => {
2323
jest.clearAllMocks()
2424
})
2525

26-
it('retrieves a server-side cookie', () => {
26+
it('retrieves a server-side cookie', async () => {
2727
;(ClientCookies.get as CookiesGetByNameFn) = jest.fn(() => clientCookieValue)
2828

29-
expect(getToken(cookieName)).toBe(serverCookieValue)
29+
expect(await getToken(cookieName)).toBe(serverCookieValue)
3030
})
3131
})

packages/utils/functions/token/getToken/index.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import ClientCookies from 'js-cookie'
2-
import { cookies as serverCookies } from 'next/headers'
32

43
import { ACCESS_COOKIE_NAME } from '../../../constants/cookie'
54

6-
export const getToken = (cookieName = ACCESS_COOKIE_NAME) => {
5+
export const getToken = async (cookieName = ACCESS_COOKIE_NAME) => {
76
if (typeof window === typeof undefined) {
7+
const { cookies: serverCookies } = await import('next/headers')
88
return serverCookies().get(cookieName)?.value
99
}
1010
return ClientCookies.get(cookieName)
+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './decodeJWT'
22
export * from './getToken'
33
export * from './isUserTokenValid'
4+
export * from './refreshAccessToken'

0 commit comments

Comments
 (0)