├── src
├── redux
│ ├── index.jsx
│ ├── storeMain.jsx
│ └── reducerMain.jsx
├── apollo
│ ├── index.jsx
│ ├── clients.jsx
│ └── links.jsx
├── modAuth
│ ├── actions.jsx
│ ├── slices.jsx
│ ├── mutations.jsx
│ ├── login.jsx
│ ├── hooks.jsx
│ └── utils.jsx
├── index.jsx
├── router.jsx
└── pages.jsx
├── public
└── index.html
├── package.json
├── LICENSE
└── README.md
/src/redux/index.jsx:
--------------------------------------------------------------------------------
1 | export { reduxStoreMain, reduxStoreMainPersistor } from './storeMain'
--------------------------------------------------------------------------------
/src/apollo/index.jsx:
--------------------------------------------------------------------------------
1 | export { apolloClientMain, apolloClientAuth } from './clients'
2 | export { errorHandler } from './links'
3 |
--------------------------------------------------------------------------------
/src/modAuth/actions.jsx:
--------------------------------------------------------------------------------
1 | import { bindActionCreators } from '@reduxjs/toolkit'
2 | import { reduxStoreMain } from '../redux/storeMain'
3 | import {authTokenSlice} from'./slices'
4 |
5 |
6 | export const authTokenActions = bindActionCreators(authTokenSlice.actions, reduxStoreMain.dispatch)
7 |
8 | export { useSelector } from 'react-redux'
9 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/src/modAuth/slices.jsx:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit'
2 |
3 | export const authTokenSlice = createSlice({
4 | name: 'authToken',
5 | initialState: {
6 | token: null,
7 | payload: null,
8 | refreshExpiresIn: null
9 | },
10 | reducers: {
11 | setAuthToken: (state, { payload }) => {
12 | state.token = payload.token
13 | state.payload = payload.payload
14 | state.refreshExpiresIn = payload.refreshExpiresIn
15 | },
16 | logOut: (state, { payload }) => {
17 | state.token = null
18 | state.payload = null
19 | state.refreshExpiresIn = null
20 | }
21 | }
22 | })
23 |
--------------------------------------------------------------------------------
/src/modAuth/mutations.jsx:
--------------------------------------------------------------------------------
1 | import { gql } from '@apollo/client'
2 |
3 | export const LOGIN = gql`
4 | mutation Login($email: String!, $password: String!) {
5 | tokenAuth(email: $email, password: $password) {
6 | token
7 | payload
8 | refreshExpiresIn
9 | }
10 | }
11 | `
12 |
13 | export const REFRESH = gql`
14 | mutation Refresh {
15 | refreshToken {
16 | token
17 | payload
18 | refreshExpiresIn
19 | }
20 | }
21 | `
22 |
23 | export const LOGOUT = gql`
24 | mutation LogOut {
25 | revokeToken {
26 | revoked
27 | }
28 | deleteRefreshTokenCookie {
29 | deleted
30 | }
31 | }
32 | `
33 |
--------------------------------------------------------------------------------
/src/redux/storeMain.jsx:
--------------------------------------------------------------------------------
1 | import { configureStore } from '@reduxjs/toolkit'
2 | import { persistStore, FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER } from 'redux-persist'
3 | import { persistedReducerMain } from './reducerMain'
4 |
5 | export const reduxStoreMain = configureStore({
6 | reducer: persistedReducerMain,
7 | middleware: getDefaultMiddleware =>
8 | getDefaultMiddleware({
9 | serializableCheck: {
10 | ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER]
11 | }
12 | }),
13 | // DEV set devTools to false in production
14 | devTools: true
15 | })
16 |
17 | export const reduxStoreMainPersistor = persistStore(reduxStoreMain)
18 |
--------------------------------------------------------------------------------
/src/redux/reducerMain.jsx:
--------------------------------------------------------------------------------
1 | import { combineReducers } from '@reduxjs/toolkit'
2 | import { persistReducer } from 'redux-persist'
3 | import storage from 'redux-persist/lib/storage'
4 | import {authTokenSlice} from '../modAuth/slices'
5 |
6 | const persistConfigMain = {
7 | key: 'root',
8 | storage,
9 | blacklist: ['alerts', 'authToken']
10 | }
11 |
12 | const persistConfigAuthToken = {
13 | key: 'authToken',
14 | storage,
15 | blacklist: ['token']
16 | }
17 |
18 | const rootReducerMain = combineReducers({
19 | authToken: persistReducer(persistConfigAuthToken, authTokenSlice.reducer),
20 | })
21 |
22 | export const persistedReducerMain = persistReducer(persistConfigMain, rootReducerMain)
23 |
--------------------------------------------------------------------------------
/src/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 |
4 | import { Provider } from 'react-redux'
5 | import { PersistGate } from 'redux-persist/integration/react'
6 | import { reduxStoreMain, reduxStoreMainPersistor } from './redux'
7 |
8 | import { ApolloProvider } from '@apollo/client'
9 | import { apolloClientMain } from './apollo'
10 |
11 | import { MainRouter } from './router'
12 |
13 | function App() {
14 | return (
15 |
16 | loading...} persistor={reduxStoreMainPersistor}>
17 |
18 |
19 |
20 |
21 |
22 | )
23 | }
24 |
25 | ReactDOM.render(, document.getElementById('root'))
26 |
--------------------------------------------------------------------------------
/src/apollo/clients.jsx:
--------------------------------------------------------------------------------
1 | import { ApolloClient, ApolloLink, InMemoryCache } from '@apollo/client'
2 | import { linkError, linkAuth, linkMain, linkTokenHeader } from './links'
3 |
4 | const inMemoryCache = new InMemoryCache()
5 | export const apolloClientAuth = new ApolloClient({
6 | //DEV connectToDevTools to false in production
7 | connectToDevTools: true,
8 | link: linkAuth,
9 | cache: inMemoryCache
10 | })
11 |
12 | const options = {
13 | watchQuery: {
14 | fetchPolicy: 'cache-and-network',
15 | errorPolicy: 'none'
16 | },
17 | query: {
18 | fetchPolicy: 'cache-and-network',
19 | errorPolicy: 'none'
20 | },
21 | mutate: {
22 | errorPolicy: 'none'
23 | }
24 | }
25 |
26 | export const apolloClientMain = new ApolloClient({
27 | //DEV connectToDevTools to false in production
28 | connectToDevTools: true,
29 | defaultOptions: options,
30 | link: ApolloLink.from([linkError, linkTokenHeader, linkMain]),
31 | cache: inMemoryCache
32 | })
33 |
--------------------------------------------------------------------------------
/src/router.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { BrowserRouter, Routes, Route } from 'react-router-dom'
3 | import { useIsAuthenticated } from './modAuth/hooks'
4 | import { Login } from './modAuth/login'
5 | import { NotFound404, PrivatePage1, PrivatePage2 } from './pages'
6 |
7 | export function MainRouter() {
8 | const isAuthenticated = useIsAuthenticated()
9 | return (
10 |
11 |
12 | {isAuthenticated && (
13 | <>
14 | } />
15 | } />
16 | } />
17 | >
18 | )}
19 | Public page} />,
20 | Public page 2} />
21 | } />
22 |
23 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@apollo/client": "^3.5.6",
7 | "@reduxjs/toolkit": "^1.7.1",
8 | "graphql": "^16.2.0",
9 | "react": "^17.0.2",
10 | "react-dom": "^17.0.2",
11 | "react-redux": "^7.2.6",
12 | "react-router-dom": "^6.2.1",
13 | "react-scripts": "^5.0.0",
14 | "redux-persist": "^6.0.0"
15 | },
16 | "scripts": {
17 | "start": "BROWSER=none react-scripts start",
18 | "test": "react-scripts test",
19 | "build": "react-scripts build",
20 | "eject": "react-scripts eject"
21 | },
22 | "eslintConfig": {
23 | "extends": "react-app"
24 | },
25 | "browserslist": {
26 | "production": [
27 | ">0.2%",
28 | "not dead",
29 | "not op_mini all"
30 | ],
31 | "development": [
32 | "last 1 chrome version",
33 | "last 1 firefox version",
34 | "last 1 safari version"
35 | ]
36 | },
37 | "devDependencies": {},
38 | "proxy": "http://backend-host:8000"
39 | }
40 |
--------------------------------------------------------------------------------
/src/pages.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Link, useNavigate } from 'react-router-dom'
3 | import { authTokenActions } from './modAuth/actions'
4 |
5 | const Logout = () =>
6 | export const PrivatePage1 = () => (
7 |
8 | Private page
9 |
10 | go to Private page 2
11 |
12 | go to some other page
13 |
14 |
15 |
16 | )
17 | export const PrivatePage2 = () => (
18 |
19 | Private page 2
20 |
21 | go to Private page 1
22 |
23 | go to some other page
24 |
25 |
26 |
27 | )
28 | export const NotFound404 = () => {
29 | const navigate = useNavigate()
30 | return (
31 |
32 | Not Found 404
33 |
34 |
35 |
36 |
37 |
38 | )
39 | }
40 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 earthguestg
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/modAuth/login.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react'
2 | import { useMutation } from '@apollo/client'
3 | import { authTokenActions } from './actions'
4 | import { errorHandler, apolloClientAuth } from '../apollo'
5 |
6 | import { LOGIN } from './mutations'
7 |
8 | export const Login = () => {
9 | const [login, { data, loading }] = useMutation(LOGIN, { client: apolloClientAuth })
10 |
11 | const onChangeEmail = e => (e.target.value = e.target.value.toLowerCase())
12 |
13 | const onSubmit = e => {
14 | e.preventDefault()
15 | login({ variables: { email: e.target.email.value, password: e.target.password.value } }).catch(errorHandler)
16 | }
17 |
18 | useEffect(() => {
19 | if (data && data.tokenAuth) authTokenActions.setAuthToken(data.tokenAuth)
20 | // eslint-disable-next-line react-hooks/exhaustive-deps
21 | }, [data])
22 |
23 | return (
24 |
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/src/modAuth/hooks.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 | import { useSelector } from 'react-redux'
3 | import { authTokenActions } from './actions'
4 | import { LOGOUT } from './mutations'
5 | import { apolloClientAuth, apolloClientMain } from '../apollo'
6 | import { possibleRefreshTokenErrors } from './utils'
7 |
8 | export function useIsAuthenticated() {
9 | const isAuthenticated = useSelector(state => state.authToken.payload)
10 |
11 | const syncTabLogout = event => {
12 | if (event.key === 'isAuthenticated' && event.newValue === 'false') authTokenActions.logOut()
13 | }
14 |
15 | useEffect(() => {
16 | window.addEventListener('storage', syncTabLogout)
17 | return () => {
18 | window.removeEventListener('storage', syncTabLogout)
19 | }
20 | }, [])
21 |
22 | useEffect(() => {
23 | if (!isAuthenticated) {
24 | apolloClientAuth.mutate({ mutation: LOGOUT }).catch(error => {
25 | if (!possibleRefreshTokenErrors.includes(error.message)) {
26 | console.log(error.message)
27 | }
28 | })
29 | apolloClientAuth.clearStore() //apolloClientAuth.resetStore()
30 | apolloClientMain.clearStore() //apolloClientMain.resetStore()
31 | localStorage.setItem('isAuthenticated', false)
32 | } else {
33 | localStorage.setItem('isAuthenticated', true)
34 | }
35 | // eslint-disable-next-line react-hooks/exhaustive-deps
36 | }, [isAuthenticated])
37 |
38 | return isAuthenticated
39 | }
40 |
--------------------------------------------------------------------------------
/src/modAuth/utils.jsx:
--------------------------------------------------------------------------------
1 | import { reduxStoreMain } from '../redux'
2 | import { authTokenActions } from '../modAuth/actions'
3 | import { apolloClientAuth } from '../apollo'
4 | import { REFRESH } from './mutations'
5 |
6 | export const possibleRefreshTokenErrors = [
7 | 'Refresh token is required', // refresh token is not sent or Cookie is deleted
8 | 'Invalid refresh token', // refresh token is not in the database
9 | 'Refresh token is expired' // refresh token is expired
10 | ]
11 |
12 | export const possibleAccessTokenErrors = [
13 | 'Login required.', // access token is not sent or Header key is not correct
14 | 'Error decoding signature', // access token or prefix is invalid
15 | 'Signature has expired' // access token is expired
16 | ]
17 |
18 | async function getRefreshedAccessTokenPromise() {
19 | try {
20 | const { data } = await apolloClientAuth.mutate({ mutation: REFRESH })
21 | if (data && data.refreshToken) authTokenActions.setAuthToken(data.refreshToken)
22 | return data.refreshToken.token
23 | } catch (error) {
24 | authTokenActions.logOut()
25 | console.log(error)
26 | return error
27 | }
28 | }
29 |
30 | let pendingAccessTokenPromise = null
31 |
32 | export function getAccessTokenPromise() {
33 | const authTokenState = reduxStoreMain.getState().authToken
34 | const currentNumericDate = Math.round(Date.now() / 1000)
35 |
36 | if (authTokenState && authTokenState.token && authTokenState.payload && currentNumericDate + 1 * 60 <= authTokenState.payload.exp) {
37 | //if (currentNumericDate + 3 * 60 >= authTokenState.payload.exp) getRefreshedAccessTokenPromise()
38 | return new Promise(resolve => resolve(authTokenState.token))
39 | }
40 |
41 | if (!pendingAccessTokenPromise) pendingAccessTokenPromise = getRefreshedAccessTokenPromise().finally(() => (pendingAccessTokenPromise = null))
42 |
43 | return pendingAccessTokenPromise
44 | }
45 |
--------------------------------------------------------------------------------
/src/apollo/links.jsx:
--------------------------------------------------------------------------------
1 | import { onError } from '@apollo/client/link/error'
2 | import { createHttpLink } from '@apollo/client'
3 | import { setContext } from '@apollo/client/link/context'
4 | import { getAccessTokenPromise } from '../modAuth/utils'
5 |
6 | export const errorHandler = ({ graphQLErrors, networkError }) => {
7 | if (graphQLErrors) graphQLErrors.forEach(({ message }) => console.log(message))
8 | if (networkError) {
9 | console.log(networkError)
10 | }
11 | // response.errors = undefined
12 | }
13 |
14 | export const linkError = onError(errorHandler)
15 |
16 | export const linkAuth = createHttpLink({
17 | // uri: 'http://localhost:8000/auth',
18 | // uri: 'http://localhost:3000/auth',
19 | uri: '/auth',
20 | // DEV purpose of credential header and CORS... check before production release
21 | credentials: 'include'
22 | })
23 |
24 | export const linkMain = createHttpLink({
25 | // uri: 'http://localhost:8000/graphql',
26 | // uri: 'http://localhost:3000/graphql',
27 | uri: '/graphql',
28 | // DEV purpose of credential header and CORS... check before production release
29 | credentials: 'same-origin'
30 | })
31 |
32 | // VERSION 1
33 | export const linkTokenHeader = setContext(async (_, { headers }) => {
34 | const accessToken = await getAccessTokenPromise()
35 | return {
36 | headers: {
37 | ...headers,
38 | Authorization: accessToken ? `JWT ${accessToken}` : ''
39 | }
40 | }
41 | })
42 |
43 | // VERSION 2
44 | // import { ApolloLink, fromPromise } from '@apollo/client';
45 | // export const linkTokenHeader = new ApolloLink((operation, forward) =>
46 | // fromPromise(getAccessTokenPromise())
47 | // .flatMap(accessToken => {
48 | // operation.setContext(({ headers = {} }) => ({
49 | // headers: {
50 | // ...headers,
51 | // Authorization: accessToken ? `JWT ${accessToken}` : '',
52 | // }
53 | // }
54 | // ))
55 | // return forward(operation)
56 | // })
57 | // )
58 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React-GraphQL-JWT-Authentication-Example
2 | - React GraphQL JWT Authentication and silent Token Refresh setup.
3 | - This repository is to help people new to react with setting up their authentication system.
4 | - in this example I'm using separate graphql-end only because I didn't want to send refresh token cookie with each request, by setting the path property of cookie.
5 | - this example only shows the frontend client setup (considering below safety points) and backend settings must be set considering the points below.
6 | - this example used @apollo/client (GraphQL client), react-redux, redux-persist, @redux/toolkit, react-scripts (create-react-app), react-router-dom
7 |
8 |
9 | # Safety points to consider
10 |
11 | https://hasura.io/blog/best-practices-of-using-jwt-with-graphql/
12 |
13 | 1. To prevent data from being stolen:
14 | - BACKEND - send request over https only
15 | - BACKEND - set SECURE flag on cookie to only serve it over HTTPS
16 | 2. To prevent XSS attacks:
17 | - FRONTEND - tokens not to be stored in localstorage
18 | - BACKEND - tokens can be stored in cookie with httponly
19 | 3. To prevent csrf attacks:
20 | - BACKEND - tokens can be stored in cookie with samesite=strict or samesite=lax
21 | 4. For phone app support:
22 | - FRONTEND - save tokens in app state and send in request header instead of from cookie
23 | 5. To support multiple open tabs at once:
24 | - BACKEND - save refresh token in cookie for computer browsers with flags above
25 | - FRONTEND - for phone apps save refresh token also in the app state and send in request header
26 | - BACKEND - thus, backend must be able to read refresh token from both header and cookie
27 | - BACKEND - use long running refresh token saved on database for revocation purpose. it's more secure since without database old unexpired refresh token can still be valid.
28 | 6. To prevent sending refresh token with every request:
29 | - BACKEND - set path flag on the cookie pointing to refresh url (https://stackoverflow.com/questions/57650692/where-to-store-the-refresh-token-on-the-client)
30 | 7. BACKEND - since no session or db to record invalid JWT tokens set access token expiry time short and refresh tokens too
31 | 8. FRONTEND - make sure not to persist the tokens with app state persisting
32 | 9. FRONTEND - make sure to remove tokens from cookie and app state upon logout and
33 | 10. BACKEND - configure CORS properly
34 | 11. BACKEND - configure backend JWT settings properly
35 | 12. configure clickjacking prevention
36 |
37 | Note that the new SameSite cookie spec which is getting increased support in most browsers will make Cookie based approaches safe from CSRF attacks.
38 | It might not be a solution if your Auth and API servers are hosted on different domains, but it should work really well otherwise!
39 |
--------------------------------------------------------------------------------