├── 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 |
25 | 26 | 27 | 28 |
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 | --------------------------------------------------------------------------------