Overview
Clone the repo
git clone git@github.com:ihaback/refresh-token-redux-toolkit.git your-project-name
Project setup
Run React and Express backend simultaneously
Credentials to test implementation
const users = [
{
id: '1',
username: 'john',
password: 'john123',
isAdmin: true,
},
{
id: '2',
username: 'joe',
password: 'joe123',
isAdmin: false,
},
]
The backend expects the token to be refreshed after 3 seconds
const generateAccessToken = (user) => {
return jwt.sign({ id: user?.id, isAdmin: user?.isAdmin }, 'mySecretKey', {
expiresIn: '3s',
})
}
const verify = (req, res, next) => {
const authHeader = req.headers.authorization
if (authHeader) {
const token = authHeader.split(' ')[1]
jwt.verify(token, 'mySecretKey', (err, user) => {
if (err) {
return res.status(403).json('Token is not valid!')
}
req.user = user
next()
})
} else {
res.status(401).json('You are not authenticated!')
}
}
Two instances of axios for communicating with public and private endpoints
import axios from 'axios'
export const axiosPublic = axios.create({ baseURL: 'http://localhost:5000/api' })
export const axiosPrivate = axios.create({ baseURL: 'http://localhost:5000/api' })
Refreshing tokens is handled by Axios request interceptors
axiosPrivate.interceptors.request.use(
async (config) => {
const user = store?.getState()?.userData?.user
let currentDate = new Date()
if (user?.accessToken) {
const decodedToken: { exp: number } = jwt_decode(user?.accessToken)
if (decodedToken.exp * 1000 < currentDate.getTime()) {
await store.dispatch(refreshToken())
if (config?.headers) {
config.headers['authorization'] = `Bearer ${
store?.getState()?.userData?.user?.accessToken
}`
}
}
}
return config
},
(error) => {
return Promise.reject(error)
}
)
State handling and communication with the backend is handled through Redux actions
export const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
updateUserName(state, action: PayloadAction<AppState['username']>) {
state.username = action.payload
},
updatePassword(state, action: PayloadAction<AppState['password']>) {
state.password = action.payload
},
},
extraReducers: (builder) => {
builder
.addCase(login.fulfilled, (state, action: PayloadAction<AppState['user']>) => {
localStorage.setItem('user', JSON.stringify(action.payload))
state.user = action.payload
})
.addCase(logout.fulfilled, (state) => {
localStorage.removeItem('user')
state.user = null
state.username = ''
state.password = ''
state.success = false
state.error = false
})
.addCase(deleteUser.pending, (state) => {
state.success = false
state.error = false
})
.addCase(deleteUser.fulfilled, (state) => {
state.success = true
})
.addCase(deleteUser.rejected, (state) => {
state.error = true
})
.addCase(refreshToken.fulfilled, (state, action) => {
localStorage.setItem('user', JSON.stringify(action.payload))
state.user = action.payload as AppState['user']
})
},
})
The shape of the state
export interface AppState {
user: {
accessToken: string
isAdmin: boolean
refreshToken: string
username: string
} | null
username: string
password: string
success: boolean
error: boolean
}
Automatic dependabot merge if all tests pass
name: Test on PR
on:
pull_request:
permissions:
pull-requests: write
contents: write
jobs:
build:
runs-on: ubuntu-latest
if: ${{ github.actor == 'dependabot[bot]' }}
steps:
- uses: actions/checkout@v2
- name: Set up node
uses: actions/setup-node@v1
with:
node-version: 16.x
- name: Install packages
run: npm install
- name: Run a security audit
run: npm audit --audit-level=critical
- name: Lint application
run: npm run lint
- name: Build application
run: npm run build
- name: E2E tests
uses: cypress-io/github-action@v2
continue-on-error: false
with:
record: false
start: npm run start
wait-on: 'http://localhost:3000'
wait-on-timeout: 60
spec: cypress/integration/*.js
browser: chrome
- name: Enable auto-merge for Dependabot PRs
run: gh pr merge --auto --merge "$PR_URL"
env:
PR_URL: ${{github.event.pull_request.html_url}}
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
The code
The full example is here