├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── README.md ├── package.json ├── plopfile.js ├── plops └── functionalComponent.hbs ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── App.css ├── App.test.tsx ├── App.tsx ├── example │ ├── api.ts │ ├── auth.ts │ ├── components │ │ ├── Clock.tsx │ │ ├── ErrorControls.tsx │ │ ├── Login.tsx │ │ └── Private.tsx │ └── errorSimulator.ts ├── helpers.ts ├── index.css ├── index.tsx ├── logo.svg ├── modules.d.ts ├── react-app-env.d.ts ├── serviceWorker.ts ├── setupTests.ts └── token-query │ └── tokenQuery.ts ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | coverage 4 | src/serviceWorker.ts 5 | plopfile.js 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | parserOptions: { 5 | project: './tsconfig.json' 6 | }, 7 | plugins: ['@typescript-eslint'], 8 | extends: [ 9 | 'airbnb-typescript', 10 | 'prettier', 11 | 'prettier/react', 12 | 'prettier/@typescript-eslint' 13 | ], 14 | rules: { 15 | 'react/prop-types': 0 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | build 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'none', 3 | singleQuote: true 4 | }; 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | `token-query` is a tool to help you manage your authentication tokens in your react webapp. 2 | It offers login & logout functionality, transparent token refreshing on per token request basis, and background refreshing, persistence in `localStorage`, etc. 3 | 4 | It is based on and requires [`react-query`](https://github.com/tannerlinsley/react-query) package. So it better fits in projects that use `react-query` for remote data handling. 5 | 6 | # Assumptions 7 | 8 | `token-query` was designed to cover the following use-case regarding authentication: 9 | 10 | During authentication your client app will send credentials to the authentication server and receive a set of tokens, the authentication token and the refresh token. 11 | 12 | You use the refresh token to obtain a new authentication token if (or before) it expires. 13 | 14 | The authentication token is used by your network client in each private request (typically as a request header). Your network client will request the token from `token-query`, without worrying about managing it. 15 | 16 | # what `token-query` offers 17 | 18 | - Network client agnostic: use `axios`, `fetch`, or anything else. 19 | - Management of refreshing. Your network client just asks for a token. Nothing more. 20 | - Auto refresh token when expired, refresh on background before it expires, and/or refresh on background periodically. 21 | - Consolidate multiple refresh requests into one. 22 | - Token persistence using `localStorage`. 23 | 24 | # How to install 25 | 26 | Just copy the source file `src\token-query\tokenQuery.ts` into your project. 27 | 28 | Prerequesites: 29 | 30 | - The project must be setup using `Typescript` 31 | - install [`react-query`](https://github.com/tannerlinsley/react-query) package 32 | - install [`loadash`](https://lodash.com/) 33 | 34 | # Setup 35 | 36 | Create a new query instance by providing a configuration object. 37 | 38 | ``` 39 | import createTokenQuery from './tokenQuery'; 40 | 41 | const exampleQuery = createTokenQuery({ 42 | queryKey: 'token', 43 | tokenExpired, 44 | refreshExpired, 45 | sendLogin, 46 | sendRefresh, 47 | retry, 48 | refreshExpiredError: new Error('401-Refresh token expired'), 49 | shouldRefreshOnBackground 50 | }); 51 | ``` 52 | 53 | ## TToken 54 | 55 | Is the shape of your token set, as it will be stored and served by `token-query`. 56 | 57 | example: 58 | 59 | ``` 60 | interface TToken { 61 | authToken: string; 62 | refreshToken: string; 63 | authTokenExpiry: number; 64 | refreshTokenExpiry: number; 65 | } 66 | ``` 67 | 68 | ## Login parameters 69 | 70 | An object shape that hosts the parameters for the login request to the authentication server. 71 | 72 | example: 73 | 74 | ``` 75 | interface TLoginParams { 76 | email: string; 77 | password: string; 78 | } 79 | ``` 80 | 81 | ## Configuration 82 | 83 | The configuration object should have the following properties: 84 | 85 | ### queryKey (optional) 86 | 87 | ``` 88 | queryKey?: string = 'token'; 89 | ``` 90 | 91 | The key that will be used by `react-query` to host the token, and the `localStorage` key. Defaults to `token` 92 | 93 | ### sendLogin 94 | 95 | Your async newtork function that sends login request to the authentication server. 96 | It should accept a single parameter of `TLoginParams` type and should return the token set as `TToken` type. 97 | 98 | ``` 99 | sendLogin: (loginParams: TLoginParams) => Promise; 100 | ``` 101 | 102 | ### sendRefresh 103 | 104 | Your async network function that sends a refresh request to the authentication server. 105 | 106 | It should expect the current token set as parameter and return the new one. 107 | 108 | ``` 109 | sendRefresh: (token: TToken) => Promise; 110 | ``` 111 | 112 | ### retry 113 | 114 | This function should provide the decision logic on wether a failed attempt (login or refresh) should be retried. 115 | 116 | It should accept two parameters: 117 | 118 | - `failCount`: the number of retries so far 119 | - `error`: the error occured from the last failed request. 120 | 121 | It should return a boolean result indicating if another attempt should be made. 122 | 123 | example: Retry 3 times, but not if the error is a permanent one (e.g. wrong credentials) 124 | 125 | ``` 126 | const retry = (count: number, error: Error) => 127 | count < 3 && !error.statusCode === 401; 128 | 129 | ``` 130 | 131 | See [`react-query`](https://github.com/tannerlinsley/react-query) for more details 132 | 133 | ### tokenExpired 134 | 135 | This function should provide the logic for determining if the authentication token expired or not. 136 | 137 | ``` 138 | tokenExpired: (token: TToken) => boolean; 139 | ``` 140 | 141 | ### refreshExpired 142 | 143 | This function should provide the logic for determining if the refresh token expired or not. 144 | 145 | ``` 146 | refreshExpired: (token: TToken) => boolean; 147 | ``` 148 | 149 | ### refreshExpiredError 150 | 151 | If `token-query` determines that the refresh token has already expired, it will not lauch a refresh request at all, but will throw an error. 152 | 153 | Determine here what error you wish to be thrown back to your network client. It can be of `any` type. 154 | 155 | ### shouldRefreshOnBackground (optional) 156 | 157 | When your client request an authentication token from `token-query`, and the token is still valid, the latter will return the token immediately. 158 | 159 | You can have it trigger a background refresh operation, so you can refresh the token before it actually expires. (so there's no delay on the requests waiting for the token to refresh) 160 | 161 | Provide the funcation that implements the decision logic on launching the background refresh operation or not. If it is missing, `token-query` will not check for lauching background refresh on each token request by network clients. 162 | 163 | ``` 164 | shouldRefreshOnBackground?: (token: TToken) => boolean; 165 | ``` 166 | 167 | # Use 168 | 169 | Once you create an instance of `token-query`, the instance provide to you a set of usefull functions: 170 | 171 | ``` 172 | const exampleQuery = createTokenQuery({ 173 | queryKey: 'token', 174 | tokenExpired, 175 | refreshExpired, 176 | sendLogin, 177 | sendRefresh, 178 | retry, 179 | refreshExpiredError: new Error('401-Refresh token expired'), 180 | shouldRefreshOnBackground 181 | }); 182 | ``` 183 | 184 | This will create an object with utility functions. 185 | 186 | ``` 187 | const { init, useToken, useLogin, logout, refresh, getToken } = exampleQuery; 188 | ``` 189 | 190 | ## init 191 | 192 | You must use this first to initialize the query. 193 | 194 | example: 195 | 196 | ``` 197 | exampleQuery.init(1000 * 60 * 40); // 40 min 198 | ``` 199 | 200 | On initialization `token-query` attempts to load any stored token from the `localStorage`. If the refresh-token has expired then it will ignore it and remove it from the storage. 201 | 202 | You can pass an optiona parameter of interval in milliseconds. This will trigger a periodic token refresh in the backround. If not provided no periodic refresh will trigger. 203 | 204 | `token-query` also automatically persist the token in the `localStorage` of the browser, on each refresh/login. 205 | 206 | ## useLogin 207 | 208 | This is a hook that exposes state and funcationality for reuesting (login) a new token. 209 | 210 | ``` 211 | const { data, isFetching, error, requestLogin } = exampleQuery.useLogin(); 212 | ``` 213 | 214 | - `data` stores the token returned by the login process 215 | - `isFetching` (boolean) indicates if the login is in progress or not 216 | - `error` stores the error if the last login attempt failed 217 | 218 | `requestLogin` is an async function for triggering a login. You can use it in two ways: 219 | 220 | _As hook_ 221 | You can just fire it up and have the hook manage your component's lifecycle. 222 | 223 | example: 224 | 225 | ``` 226 | const { data, isFetching, error, requestLogin } = exampleQuery.useLogin(); 227 | 228 | return ( 229 | 234 | 235 | {error &&

{error.message}

} 236 | ) 237 | ``` 238 | 239 | _As async function_ 240 | Or you can handle the `requestLogin` function in async way: 241 | 242 | example: 243 | 244 | ``` 245 | const {requestLogin} = exampleQuery.useLogin(); 246 | 247 | const login = asyc (email, password) => { 248 | try { 249 | await requestLogin({email, password}, true); // pass TRUE as optional parameter to throw error on failure 250 | // and do stuff on successfull login 251 | } catch(error) { 252 | // do something with error 253 | } 254 | } 255 | ``` 256 | 257 | By default `requestLogin` will suppress any error. If you pass `true` as second parameter it will throw any error occured during the process. 258 | 259 | _`requestLogin`_: 260 | 261 | ``` 262 | const requestLogin = async ( 263 | loginParams: TLoginParams, 264 | throwOnError = false 265 | ) => {/* ... */} 266 | ``` 267 | 268 | ## useToken 269 | 270 | The `useToken` hook provides the current token stored in the query. 271 | 272 | example: 273 | 274 | ``` 275 | const token = exampleQuery.useToken(); 276 | 277 | return token !== undefined ? : ; 278 | ``` 279 | 280 | ## logout 281 | 282 | Call the `logout` function you want to logout from your app. 283 | It will clear token and any scheduled background refresh operation. 284 | 285 | example: 286 | 287 | ``` 288 | 296 | ``` 297 | 298 | ## refresh 299 | 300 | Use the `refresh` async function in any case you wish to manually trigger a token refresh operation. It will launch a refresh operation using the currently stored token. 301 | 302 | example: 303 | 304 | ``` 305 | const manualRefresh = async () => { 306 | try { 307 | await exampleQuery.refresh(true) 308 | } catch (error) { 309 | //do something with error 310 | } 311 | } 312 | ``` 313 | 314 | `refresh` function will take `throwOnError` optional parameter. If `true`, it will throw any error that occured during refresh process. By default it will supress the error. 315 | 316 | ## getToken 317 | 318 | This is core function of `token-query`. Your network clients should use this to get and use the current authentication token. 319 | 320 | example: 321 | 322 | ``` 323 | const fetchUserProfile = async () => { 324 | const token = await exampleQuery.getToken(); 325 | // here inject the token and send the actual request 326 | } 327 | ``` 328 | 329 | `getToken` will return immediately the stored token if it valid. If you want to force it to refresh the token even it if has not expired, then you can pass `true` as parameter to force it refresh a token before returning it. 330 | 331 | ``` 332 | const getToken = async (force = false) => {/*...*/} 333 | ``` 334 | 335 | `getToken` async function acts as follows: 336 | 337 | - If there is no token, it will return the `undefined` value. 338 | - If the refresh token expired then it will throw the `refreshExpiredError` (see setup) 339 | - If the token itself has expired or `force` parameter is passed, then it will launch refresh and return the new one. 340 | - If the above refresh operation fails, it will throw the error that occured. 341 | - If the token has not expired and it is not `force`ed, it will return immediately the stored token. 342 | - If a `shouldRefreshOnBackground` condition is setup and met, it will launch a background refresh operation. 343 | - Refresh error on background operations are supressed. 344 | - In case multiple clients request for a token while it is refreshing, only one request is lauched towards authentication server. Once resolved, all client requests receive the same token. 345 | 346 | ## Example 347 | 348 | You can find the source code of an example project under `src/example`. 349 | 350 | To run test the example as follows: 351 | 352 | 1. Clone this project locally 353 | 1. `yarn install` 354 | 1. `yarn start` 355 | 356 | # License 357 | 358 | MIT License 359 | 360 | Copyright 2020, Costas Ioannou 361 | 362 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 363 | 364 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 365 | 366 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 367 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-template", 3 | "version": "0.2.1", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "4.2.4", 7 | "@testing-library/react": "9.3.2", 8 | "@testing-library/user-event": "7.1.2", 9 | "@types/jest": "24.0.0", 10 | "@types/lodash": "4.14.150", 11 | "@types/node": "12.0.0", 12 | "@types/react": "16.9.0", 13 | "@types/react-dom": "16.9.0", 14 | "lodash": "4.17.15", 15 | "react": "16.13.1", 16 | "react-dom": "16.13.1", 17 | "react-query": "1.3.4", 18 | "react-query-devtools": "1.1.5", 19 | "react-scripts": "3.4.1", 20 | "typescript": "3.7.2" 21 | }, 22 | "scripts": { 23 | "start": "react-scripts start", 24 | "build": "react-scripts build", 25 | "test": "react-scripts test", 26 | "eject": "react-scripts eject", 27 | "lint": "tsc --noEmit && eslint . --ext .js,.jsx,.ts,.tsx", 28 | "pretty": "prettier --write '**/*.{js,ts}'" 29 | }, 30 | "eslintConfig": { 31 | "extends": "react-app" 32 | }, 33 | "browserslist": { 34 | "production": [ 35 | ">0.2%", 36 | "not dead", 37 | "not op_mini all" 38 | ], 39 | "development": [ 40 | "last 1 chrome version", 41 | "last 1 firefox version", 42 | "last 1 safari version" 43 | ] 44 | }, 45 | "devDependencies": { 46 | "@typescript-eslint/eslint-plugin": "2.27.0", 47 | "@typescript-eslint/parser": "2.27.0", 48 | "eslint": "6.8.0", 49 | "eslint-config-airbnb-typescript": "7.2.1", 50 | "eslint-config-prettier": "6.10.1", 51 | "eslint-plugin-import": "2.20.2", 52 | "eslint-plugin-jsx-a11y": "6.2.3", 53 | "eslint-plugin-prettier": "3.1.2", 54 | "eslint-plugin-react": "7.19.0", 55 | "eslint-plugin-react-hooks": "3.0.0", 56 | "husky": "4.2.5", 57 | "plop": "2.6.0", 58 | "prettier": "2.0.4", 59 | "pretty-quick": "2.0.1" 60 | }, 61 | "husky": { 62 | "hooks": { 63 | "pre-commit": "pretty-quick --staged", 64 | "pre-push": "pretty-quick --staged && yarn lint" 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /plopfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function (plop) { 2 | // controller generator 3 | plop.setGenerator('Functional Component', { 4 | description: 'react FC component in TS', 5 | prompts: [ 6 | { 7 | type: 'input', 8 | name: 'name', 9 | message: 'component name' 10 | }, 11 | { 12 | type: 'input', 13 | name: 'path', 14 | message: 'target folder path', 15 | default: 'src' 16 | } 17 | ], 18 | actions: [ 19 | { 20 | type: 'add', 21 | path: '{{path}}/{{pascalCase name}}.tsx', 22 | templateFile: 'plops/functionalComponent.hbs' 23 | } 24 | ] 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /plops/functionalComponent.hbs: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | 3 | interface {{pascalCase name}}Props {} 4 | 5 | const {{pascalCase name}}: FC<{{pascalCase name}}Props> = ({}) => { 6 | return null; 7 | }; 8 | 9 | export default {{pascalCase name}}; 10 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/killerchip/token-query/935f033c51ffd918dacf33eded834ed02aec1374/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/killerchip/token-query/935f033c51ffd918dacf33eded834ed02aec1374/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/killerchip/token-query/935f033c51ffd918dacf33eded834ed02aec1374/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | 40 | button { 41 | border: white; 42 | } 43 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ReactQueryDevtools } from 'react-query-devtools'; 3 | 4 | import logo from './logo.svg'; 5 | import './App.css'; 6 | import Clock from './example/components/Clock'; 7 | import Login from './example/components/Login'; 8 | import Private from './example/components/Private'; 9 | import { useToken } from './example/auth'; 10 | import ErrorControls from './example/components/ErrorControls'; 11 | 12 | function App() { 13 | const token = useToken(); 14 | const isLoggedIn = token !== undefined; 15 | 16 | return ( 17 | <> 18 |
19 |
20 | 21 | 22 | logo 28 | {isLoggedIn ? : } 29 |
30 |
31 | 32 | 33 | ); 34 | } 35 | 36 | export default App; 37 | -------------------------------------------------------------------------------- /src/example/api.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-alert */ 2 | import { getToken, logout, Token } from './auth'; 3 | import { milisToTime } from '../helpers'; 4 | 5 | export async function privateRequest( 6 | asyncFunc: (token: Token) => Promise 7 | ) { 8 | try { 9 | const token = (await getToken()) as Token; 10 | 11 | // simulate that use of token in asyncFun 12 | // typically the token will be injected in the headers 13 | // that async func calls 14 | const result = (await asyncFunc(token)) as TResult; 15 | 16 | return result; 17 | } catch (error) { 18 | if ((error as Error).message.includes('401-')) { 19 | alert('Permanent Error. You will have to re-login'); 20 | 21 | logout(); 22 | } 23 | throw error; 24 | } 25 | } 26 | 27 | // simulating fetching user's profile which is based 28 | // on authentication token 29 | export const fetchMe = () => 30 | privateRequest( 31 | (token) => 32 | new Promise((resolve) => 33 | setTimeout( 34 | () => resolve(`John Doe - ${milisToTime(token?.token)}`), 35 | 1500 36 | ) 37 | ) 38 | ); 39 | -------------------------------------------------------------------------------- /src/example/auth.ts: -------------------------------------------------------------------------------- 1 | import createTokenQuery from '../token-query/tokenQuery'; 2 | import { responses } from './errorSimulator'; 3 | 4 | export interface Token { 5 | token: number; 6 | refresh: number; 7 | holder: string; 8 | } 9 | 10 | interface LoginParams { 11 | email: string; 12 | } 13 | 14 | const tokenExpired = (token: Token) => { 15 | const now = new Date().getTime(); 16 | 17 | return token.token < now; 18 | }; 19 | 20 | const refreshExpired = (token: Token) => { 21 | const now = new Date().getTime(); 22 | 23 | return token.refresh < now; 24 | }; 25 | 26 | // simulating sending a login request 27 | // and the response 28 | const sendLogin = async (data: LoginParams) => { 29 | const TOKEN_LIFE = 1000 * 60 * 2; 30 | const REFRESH_LIFE = 1000 * 60 * 3; 31 | const now = new Date().getTime(); 32 | 33 | return new Promise((resolve, reject) => 34 | setTimeout(() => { 35 | if (responses.loginResponse === 'permanent') { 36 | reject(new Error('401-Unauthorized')); 37 | return; 38 | } 39 | 40 | if (responses.loginResponse === 'temporary') { 41 | reject(new Error('Network error')); 42 | return; 43 | } 44 | 45 | resolve({ 46 | token: now + TOKEN_LIFE, 47 | refresh: now + REFRESH_LIFE, 48 | holder: data.email 49 | }); 50 | }, 2000) 51 | ); 52 | }; 53 | 54 | // simulating sending a refresh-token request 55 | // and the response with the new token 56 | const sendRefresh = async (data: Token) => { 57 | const TOKEN_LIFE = 1000 * 60 * 2; 58 | const REFRESH_LIFE = 1000 * 60 * 3; 59 | const now = new Date().getTime(); 60 | 61 | return new Promise((resolve, reject) => 62 | setTimeout(() => { 63 | if (responses.refreshResponse === 'permanent') { 64 | reject(new Error('401-Unauthorized')); 65 | return; 66 | } 67 | 68 | if (responses.refreshResponse === 'temporary') { 69 | reject(new Error('Network error')); 70 | return; 71 | } 72 | 73 | resolve({ 74 | token: now + TOKEN_LIFE, 75 | refresh: now + REFRESH_LIFE, 76 | holder: data.holder 77 | }); 78 | }, 2000) 79 | ); 80 | }; 81 | 82 | const retry = (count: number, error: Error) => 83 | count < 3 && !error.message.includes('401-'); 84 | 85 | const shouldRefreshOnBackground = (token: Token) => { 86 | const REFRESH_TIME_BEFORE_EXPIRE = 1000 * 60 * 1; 87 | 88 | const now = new Date().getTime(); 89 | return now > token.token - REFRESH_TIME_BEFORE_EXPIRE; 90 | }; 91 | 92 | const mockTokenQuery = createTokenQuery({ 93 | queryKey: 'token', 94 | tokenExpired, 95 | refreshExpired, 96 | sendLogin, 97 | sendRefresh, 98 | retry, 99 | refreshExpiredError: new Error('401-Refresh token expired'), 100 | shouldRefreshOnBackground 101 | }); 102 | 103 | mockTokenQuery.init(1000 * 60); // 1min 104 | 105 | /* eslint-disable prefer-destructuring */ 106 | export const useToken = mockTokenQuery.useToken; 107 | export const useLogin = mockTokenQuery.useLogin; 108 | export const logout = mockTokenQuery.logout; 109 | export const refresh = mockTokenQuery.refresh; 110 | export const getToken = mockTokenQuery.getToken; 111 | /* eslint-enable prefer-destructuring */ 112 | 113 | export default mockTokenQuery; 114 | -------------------------------------------------------------------------------- /src/example/components/Clock.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState, useEffect } from 'react'; 2 | 3 | const getTime = () => new Date().toTimeString().split(' ')[0]; 4 | 5 | const Clock: FC = () => { 6 | const [time, setTime] = useState(getTime()); 7 | 8 | useEffect(() => { 9 | const handler = setInterval(() => { 10 | setTime(getTime()); 11 | }, 1000); 12 | 13 | return () => clearInterval(handler); 14 | }, []); 15 | 16 | return
{time}
; 17 | }; 18 | 19 | export default Clock; 20 | -------------------------------------------------------------------------------- /src/example/components/ErrorControls.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState } from 'react'; 2 | import { ResponseState, setResponse } from '../errorSimulator'; 3 | 4 | const ErrorControls: FC = () => { 5 | const [loginResponse, setLoginResponse] = useState('normal'); 6 | const [refreshResponse, setRefreshResponse] = useState( 7 | 'normal' 8 | ); 9 | const [meResponse, setMeResponse] = useState('normal'); 10 | 11 | return ( 12 |
13 |
14 |
Login
15 | 26 |
27 | 28 |
29 |
Refresh
30 | 41 |
42 | 43 |
44 |
Me
45 | 56 |
57 |
58 | ); 59 | }; 60 | 61 | export default ErrorControls; 62 | -------------------------------------------------------------------------------- /src/example/components/Login.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-alert */ 2 | import React, { FC, useEffect } from 'react'; 3 | import { useLogin } from '../auth'; 4 | 5 | const Login: FC = () => { 6 | const { isFetching, requestLogin, error } = useLogin(); 7 | 8 | useEffect(() => { 9 | if (error) { 10 | alert((error as Error).message); 11 | } 12 | }, [error]); 13 | 14 | return ( 15 |
16 | 24 |
25 | ); 26 | }; 27 | 28 | export default Login; 29 | -------------------------------------------------------------------------------- /src/example/components/Private.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { useQuery } from 'react-query'; 3 | 4 | import { milisToTime } from '../../helpers'; 5 | import { useToken, logout, refresh } from '../auth'; 6 | import { fetchMe } from '../api'; 7 | 8 | const Private: FC = () => { 9 | const token = useToken(); 10 | 11 | const { data: me, isFetching, refetch } = useQuery('me', fetchMe); 12 | 13 | return ( 14 |
15 |
    16 | {token &&
  • token expires: {milisToTime(token.token)}
  • } 17 | {token &&
  • refresh expires: {milisToTime(token.refresh)}
  • } 18 | {token &&
  • holder: {token.holder}
  • } 19 |
20 |

21 | me: {isFetching && '...'} {me} 22 |

23 | 24 | 32 | 33 | 36 | 37 | 40 |
41 | ); 42 | }; 43 | 44 | export default Private; 45 | -------------------------------------------------------------------------------- /src/example/errorSimulator.ts: -------------------------------------------------------------------------------- 1 | export type ResponseState = 'normal' | 'permanent' | 'temporary'; 2 | 3 | interface Responses { 4 | loginResponse: ResponseState; 5 | refreshResponse: ResponseState; 6 | meResponse: ResponseState; 7 | } 8 | 9 | export const responses: Responses = { 10 | loginResponse: 'normal', 11 | refreshResponse: 'normal', 12 | meResponse: 'normal' 13 | }; 14 | 15 | export const setResponse = ( 16 | response: ResponseState, 17 | prop: 'loginResponse' | 'refreshResponse' | 'meResponse' 18 | ) => { 19 | responses[prop] = response; 20 | }; 21 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | export const milisToTime = (milis: number) => 3 | new Date(milis).toTimeString().split(' ')[0]; 4 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import * as serviceWorker from './serviceWorker'; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById('root') 12 | ); 13 | 14 | // If you want your app to work offline and load faster, you can change 15 | // unregister() to register() below. Note this comes with some pitfalls. 16 | // Learn more about service workers: https://bit.ly/CRA-PWA 17 | serviceWorker.unregister(); 18 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-query-devtools'; 2 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line spaced-comment 2 | /// 3 | -------------------------------------------------------------------------------- /src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | type Config = { 24 | onSuccess?: (registration: ServiceWorkerRegistration) => void; 25 | onUpdate?: (registration: ServiceWorkerRegistration) => void; 26 | }; 27 | 28 | export function register(config?: Config) { 29 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 30 | // The URL constructor is available in all browsers that support SW. 31 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 32 | if (publicUrl.origin !== window.location.origin) { 33 | // Our service worker won't work if PUBLIC_URL is on a different origin 34 | // from what our page is served on. This might happen if a CDN is used to 35 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 36 | return; 37 | } 38 | 39 | window.addEventListener('load', () => { 40 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 41 | 42 | if (isLocalhost) { 43 | // This is running on localhost. Let's check if a service worker still exists or not. 44 | checkValidServiceWorker(swUrl, config); 45 | 46 | // Add some additional logging to localhost, pointing developers to the 47 | // service worker/PWA documentation. 48 | navigator.serviceWorker.ready.then(() => { 49 | console.log( 50 | 'This web app is being served cache-first by a service ' + 51 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 52 | ); 53 | }); 54 | } else { 55 | // Is not localhost. Just register service worker 56 | registerValidSW(swUrl, config); 57 | } 58 | }); 59 | } 60 | } 61 | 62 | function registerValidSW(swUrl: string, config?: Config) { 63 | navigator.serviceWorker 64 | .register(swUrl) 65 | .then((registration) => { 66 | registration.onupdatefound = () => { 67 | const installingWorker = registration.installing; 68 | if (installingWorker == null) { 69 | return; 70 | } 71 | installingWorker.onstatechange = () => { 72 | if (installingWorker.state === 'installed') { 73 | if (navigator.serviceWorker.controller) { 74 | // At this point, the updated precached content has been fetched, 75 | // but the previous service worker will still serve the older 76 | // content until all client tabs are closed. 77 | console.log( 78 | 'New content is available and will be used when all ' + 79 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 80 | ); 81 | 82 | // Execute callback 83 | if (config && config.onUpdate) { 84 | config.onUpdate(registration); 85 | } 86 | } else { 87 | // At this point, everything has been precached. 88 | // It's the perfect time to display a 89 | // "Content is cached for offline use." message. 90 | console.log('Content is cached for offline use.'); 91 | 92 | // Execute callback 93 | if (config && config.onSuccess) { 94 | config.onSuccess(registration); 95 | } 96 | } 97 | } 98 | }; 99 | }; 100 | }) 101 | .catch((error) => { 102 | console.error('Error during service worker registration:', error); 103 | }); 104 | } 105 | 106 | function checkValidServiceWorker(swUrl: string, config?: Config) { 107 | // Check if the service worker can be found. If it can't reload the page. 108 | fetch(swUrl, { 109 | headers: { 'Service-Worker': 'script' } 110 | }) 111 | .then((response) => { 112 | // Ensure service worker exists, and that we really are getting a JS file. 113 | const contentType = response.headers.get('content-type'); 114 | if ( 115 | response.status === 404 || 116 | (contentType != null && contentType.indexOf('javascript') === -1) 117 | ) { 118 | // No service worker found. Probably a different app. Reload the page. 119 | navigator.serviceWorker.ready.then((registration) => { 120 | registration.unregister().then(() => { 121 | window.location.reload(); 122 | }); 123 | }); 124 | } else { 125 | // Service worker found. Proceed as normal. 126 | registerValidSW(swUrl, config); 127 | } 128 | }) 129 | .catch(() => { 130 | console.log( 131 | 'No internet connection found. App is running in offline mode.' 132 | ); 133 | }); 134 | } 135 | 136 | export function unregister() { 137 | if ('serviceWorker' in navigator) { 138 | navigator.serviceWorker.ready 139 | .then((registration) => { 140 | registration.unregister(); 141 | }) 142 | .catch((error) => { 143 | console.error(error.message); 144 | }); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /src/token-query/tokenQuery.ts: -------------------------------------------------------------------------------- 1 | import { queryCache } from 'react-query'; 2 | import { useState, useEffect } from 'react'; 3 | import isEqual from 'lodash/isEqual'; 4 | 5 | export interface Config { 6 | tokenExpired: (token: TToken) => boolean; 7 | refreshExpired: (token: TToken) => boolean; 8 | sendLogin: (loginParams: TLoginParams) => Promise; 9 | sendRefresh: (token: TToken) => Promise; 10 | retry: (failCount: number, error: any) => boolean; 11 | refreshExpiredError: any; 12 | queryKey?: string; 13 | shouldRefreshOnBackground?: (token: TToken) => boolean; 14 | } 15 | 16 | function createTokenQuery({ 17 | queryKey = 'token', 18 | tokenExpired, 19 | refreshExpired, 20 | sendLogin, 21 | sendRefresh, 22 | retry, 23 | refreshExpiredError, 24 | shouldRefreshOnBackground 25 | }: Config) { 26 | let tokenRefreshIntervalHandler: any; 27 | let tokenRefreshInterval: number; 28 | 29 | const getTokenFromStorage = () => { 30 | const storedValue = localStorage.getItem(queryKey); 31 | 32 | if (!storedValue) { 33 | return undefined; 34 | } 35 | 36 | let token: TToken | undefined; 37 | 38 | try { 39 | token = JSON.parse(storedValue); 40 | // eslint-disable-next-line no-empty 41 | } catch {} 42 | 43 | return token; 44 | }; 45 | 46 | const setTokenValue = (token: TToken | undefined) => { 47 | if (token === undefined) { 48 | localStorage.removeItem(queryKey); 49 | } else { 50 | localStorage.setItem(queryKey, JSON.stringify(token)); 51 | } 52 | 53 | queryCache.setQueryData(queryKey, token); 54 | }; 55 | 56 | const refresh = async (throwOnError = false) => { 57 | const token = queryCache.getQueryData(queryKey) as TToken; 58 | 59 | const newToken = await queryCache.prefetchQuery({ 60 | queryKey: [`temp-refresh-${queryKey}`], 61 | variables: [token], 62 | queryFn: (_: string, data: TToken) => sendRefresh(data), 63 | config: { 64 | retry, 65 | throwOnError 66 | } 67 | }); 68 | 69 | // If token is undefined then refresh has failed 70 | if (newToken !== undefined) { 71 | setTokenValue(newToken); 72 | } 73 | 74 | queryCache.removeQueries(`temp-refresh-${queryKey}`); 75 | 76 | return newToken; 77 | }; 78 | 79 | const startBackgroundRefreshing = () => { 80 | clearInterval(tokenRefreshIntervalHandler); 81 | 82 | tokenRefreshIntervalHandler = setInterval(() => { 83 | refresh(); 84 | }, tokenRefreshInterval); 85 | }; 86 | 87 | const stopBackgroundRefreshing = () => { 88 | clearInterval(tokenRefreshIntervalHandler); 89 | }; 90 | 91 | const login = async (loginParams: TLoginParams) => { 92 | const token = await queryCache.prefetchQuery({ 93 | queryKey: [`temp-login-${queryKey}`], 94 | variables: [loginParams], 95 | queryFn: (_: string, params: TLoginParams) => sendLogin(params), 96 | config: { 97 | retry, 98 | throwOnError: true 99 | } 100 | }); 101 | 102 | if (tokenRefreshInterval) { 103 | startBackgroundRefreshing(); 104 | } 105 | 106 | queryCache.removeQueries(`temp-login-${queryKey}`); 107 | 108 | return token; 109 | }; 110 | 111 | const logout = async () => { 112 | setTokenValue(undefined); 113 | stopBackgroundRefreshing(); 114 | }; 115 | 116 | const useLogin = () => { 117 | const [data, setData] = useState(null); 118 | const [isFetching, setIsFetching] = useState(false); 119 | const [error, setError] = useState(null); 120 | 121 | const requestLogin = async ( 122 | loginParams: TLoginParams, 123 | throwOnError = false 124 | ) => { 125 | setIsFetching(true); 126 | setData(null); 127 | setError(null); 128 | 129 | try { 130 | const token = await login(loginParams); 131 | 132 | setIsFetching(false); 133 | setData(token); 134 | setTokenValue(token); 135 | 136 | return token; 137 | } catch (loginError) { 138 | setIsFetching(false); 139 | setError(loginError); 140 | 141 | if (throwOnError) { 142 | throw loginError; 143 | } 144 | } 145 | 146 | return undefined; 147 | }; 148 | 149 | return { data, isFetching, error, requestLogin }; 150 | }; 151 | 152 | const useToken = () => { 153 | const existingToken = queryCache.getQueryData(queryKey) as TToken; 154 | const [token, setToken] = useState(existingToken); 155 | 156 | useEffect(() => { 157 | const unsubscribe = queryCache.subscribe((newQueryCache) => { 158 | const newToken = newQueryCache.getQueryData([queryKey]) as 159 | | TToken 160 | | undefined; 161 | 162 | if (!isEqual(token, newToken)) { 163 | setToken(newToken); 164 | } 165 | }); 166 | 167 | return () => { 168 | unsubscribe(); 169 | }; 170 | }); 171 | 172 | return token; 173 | }; 174 | 175 | const getToken = async (force = false) => { 176 | const token = queryCache.getQueryData(queryKey) as TToken | undefined; 177 | 178 | if (token === undefined) return undefined; 179 | 180 | if (refreshExpired(token)) { 181 | throw refreshExpiredError; 182 | } 183 | 184 | if (tokenExpired(token) || force) { 185 | const newToken = await refresh(true); 186 | 187 | return newToken; 188 | } 189 | 190 | if (shouldRefreshOnBackground && shouldRefreshOnBackground(token)) { 191 | refresh(); 192 | } 193 | 194 | return token; 195 | }; 196 | 197 | const init = async (refreshInterval?: number) => { 198 | if (refreshInterval) { 199 | tokenRefreshInterval = refreshInterval; 200 | } 201 | 202 | const token = getTokenFromStorage(); 203 | 204 | if (!token || refreshExpired(token)) { 205 | setTokenValue(undefined); 206 | 207 | return; 208 | } 209 | 210 | setTokenValue(token); 211 | 212 | if (refreshInterval) { 213 | startBackgroundRefreshing(); 214 | } 215 | }; 216 | 217 | return { init, useLogin, useToken, logout, refresh, getToken }; 218 | } 219 | 220 | export default createTokenQuery; 221 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | --------------------------------------------------------------------------------