├── .github
└── workflows
│ ├── ci.yml
│ └── coverage.yml
├── .gitignore
├── CHANGELOG.md
├── README.md
├── dist
├── CHANGELOG.md
├── LICENSE
├── README.md
└── package.json
├── jest.config.js
├── package-lock.json
├── package.json
├── src
├── .eslintrc.json
├── index.ts
├── state.ts
├── tests
│ ├── caching.test.ts
│ ├── errorStatus.test.ts
│ ├── getToken.test.ts
│ ├── initialisation.test.ts
│ ├── preFetchToken.test.ts
│ ├── recovery.test.ts
│ ├── recoveryErrored.test.ts
│ ├── recoveryRace.test.ts
│ ├── setPreFetchTimer.test.ts
│ └── shouldRecover.test.ts
├── tokenManager.ts
├── types.ts
└── utils
│ ├── getFreshToken.ts
│ ├── getToken.ts
│ ├── initialValues.ts
│ ├── isTokenValid.ts
│ ├── noop.ts
│ ├── preFetchToken.ts
│ ├── setPreFetchTimer.ts
│ └── shouldRecover.ts
├── tsconfig.es5.json
└── tsconfig.json
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
3 |
4 | name: CI
5 |
6 | on:
7 | push:
8 | branches: [ "main" ]
9 | pull_request:
10 | branches: [ "main" ]
11 |
12 | jobs:
13 | build:
14 |
15 | runs-on: ubuntu-latest
16 |
17 | strategy:
18 | matrix:
19 | node-version: [14.x, 16.x, 18.x]
20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
21 |
22 | steps:
23 | - uses: actions/checkout@v3
24 | - name: Use Node.js ${{ matrix.node-version }}
25 | uses: actions/setup-node@v3
26 | with:
27 | node-version: ${{ matrix.node-version }}
28 | cache: 'npm'
29 | - run: npm ci
30 | - run: npm run build --if-present
31 | - run: npm test
32 |
--------------------------------------------------------------------------------
/.github/workflows/coverage.yml:
--------------------------------------------------------------------------------
1 | on: ["push", "pull_request"]
2 |
3 | name: Test Coverage
4 |
5 | jobs:
6 |
7 | build:
8 | name: Build
9 | runs-on: ubuntu-latest
10 | steps:
11 |
12 | - uses: actions/checkout@v3
13 |
14 | - name: Use Node.js 16.x
15 | uses: actions/setup-node@v3
16 | with:
17 | node-version: 16.x
18 |
19 | - name: npm install, make test-coverage
20 | run: |
21 | npm install
22 | npm test
23 |
24 | - name: Coveralls
25 | uses: coverallsapp/github-action@v2
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | dist/lib
2 | node_modules/
3 | coverage
4 | .env
5 | .env.test
6 | logs
7 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | # [v 1.0.0](https://github.com/mickeypuri/axios-token-manager/compare/v0.2.8...v1.0.0) (2023-05-16)
4 |
5 | ## Changes from pre-release v 0.2.8
6 |
7 | * **documentation:** added full documentation including description, installation, usage examples and api.
8 | * **naming of interfaces:** removed prefix I from interfaces to bring in line with the more common approach of interface naming.
9 | * **renamed ITokenManager:** the `ITokenManager` interface was renamed to `Settings`
10 | * **pre fetch of token:** added feature of pre fetch of token, to replace a token a short time before it is due to expire so we never have any delay
11 |
12 | # [v 1.0.1](https://github.com/mickeypuri/axios-token-manager/compare/v1.0.0...v1.0.1) (2023-05-22)
13 |
14 | ## Changes from v1.0.0
15 |
16 | * **documentation:** Although described in the API, the code example in the readme was missing four configurable properties, these are now included in the example.
17 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # axios-token-manager
2 |
3 | A library to manage caching of Axios Tokens with automatic refresh of Token on expiry or on error due to early revocation of the Token by the backend system. It can be used on a Node API Server as well as on a front end application.
4 |
5 |
6 |
7 | [](https://www.npmjs.org/package/axios-token-manager)
8 | [](https://github.com/mickeypuri/axios-token-manager/actions/workflows/ci.yml?branch=main)
9 | [](https://coveralls.io/github/mickeypuri/axios-token-manager?branch=main)
10 | [](https://snyk.io/test/npm/axios-token-manager)
11 |
12 |
13 |
14 | ## Table of Contents
15 |
16 | - [Purpose](#purpose)
17 | - [Features](#features)
18 | - [Installing](#installing)
19 | - [Usage](#usage)
20 | - [API](#api)
21 |
22 | ## Purpose
23 |
24 | The purpose of Axios-Token-Manager is to cache an Authentication Token during its validity period. This reduces the number of calls that need to be made on the back end to fetch authentication tokens, and improves response speeds and reduces latency.
25 |
26 | The Axios-Token-Manager needs to be provided with a function which when invoked returns a new Authentication Token. Along with an `access_token` field, the token response should include an `expires_in` field giving the number of seconds till the token expires.
27 |
28 | The Axios-Token-Manager is setup with an instance of Axios to which it applies request and response interceptors. All requests are intercepted and the Authentication Token is applied to the header of the outgoing request.
29 |
30 | The Axios-Token-Manager will get a new Authentication Token a short time before the current token is due to expire, thereby ensuring that there is always a valid token available to be used. In the event that a request is being made and there is no valid token, then a fresh token will be requested and used in the Authentication header of the request, and then the fresh token will get cached.
31 |
32 | Backend systems can sometimes revoke an Authentication Token before its expiry time. A request using such a token will fail with a 401 and possibly a 403 error. The Axios Token Manager intercepts all response and looks out for 401 errors. On receipt of a 401, it starts a recovery cycle in which it invalidates the cached token and fetches a new Token, which it caches. It then tries to recover the failed request by making a fresh request to the back end using the new Token. Only once this succeeds will the response be sent back to the caller. So the caller is unaware of what is happening and just gets a slightly delayed success response.
33 |
34 | In case callers want to be kept aware of what is happening under the hood there are a number of callbacks that can be hooked into.
35 |
36 | ## Features
37 |
38 | - Fetch Authentication token using provided function
39 | - Works out time till token expires
40 | - Caches Authentication Token
41 | - Applies Authentication Token to all outgoing requests on instance
42 | - Fetches new Authentication Token a short time before current cached token expires
43 | - Recovers from situation where the back end revokes a token before its expiry time is up
44 | - Monitors response and in case of a 401, it fetches a new Authentication token and retries the request with fresh token
45 | - After recovery from an early revokation of the Authentication token, it caches the fresh token
46 | - Adds interceptors on the Request and Response of the provided Axios Instance
47 | - Callbacks to notify calling application of token refresh, authentication fail, recovery try and recovery abort
48 | - Format and configure authentication header
49 | - Full Configuration of settings
50 |
51 | ## Installing
52 |
53 | ### Package manager
54 |
55 | Using npm:
56 |
57 | `$ npm install axios-token-manager`
58 |
59 | Using yarn:
60 |
61 | `$ yarn add axios-token-manager`
62 |
63 | Once the package is installed, you can import the library using `import` or `require` approach:
64 |
65 | `import tokenManager from 'axios-token-manager';`
66 |
67 | If you use `require` for importing:
68 |
69 | `const tokenManager = require('axios');`
70 |
71 | ## Usage
72 |
73 | This defines a file which has a default export of an axios instance wired up with the axios-token-manager.
74 |
75 | The axios-oauth-client library has been used here as an example for the implementation of the function to get a new Token, it can of course be replaced by your preferred oauth or token library, or your own implementation to get a token.
76 |
77 | **note**: `instance` and `getCredentials` are the two required configuration settings, the other settings are all optional.
78 |
79 | ```ts
80 | import axios from 'axios';
81 | import oauth from 'axios-oauth-client';
82 | import tokenManager, { TokenProvider, Settings } from 'axios-token-manager';
83 |
84 | // Define an Axios instance using a common baseURL, timeout and the common headers for all requests
85 | ...
86 |
87 | const instance = axios.create({
88 | baseURL,
89 | timeout,
90 | headers
91 | });
92 |
93 | // define tokenURL to fetch the authorization token from, a clientId and a client secret
94 | ...
95 |
96 | const getCredentials = oauth.clientCredentials(
97 | axios.create(),
98 | tokenURL,
99 | clientId,
100 | clientSecret
101 | ) as TokenProvider;
102 |
103 | // define other optional settings for callbacks and other configurations (see API for config)
104 | ...
105 |
106 | const settings: Settings = {
107 | instance,
108 | getCredentials,
109 | ... // define other optional configuration
110 | };
111 |
112 | tokenManager(settings);
113 |
114 | export default instance;
115 | ```
116 |
117 | ### Using all configurable options
118 |
119 | The above example only used the two required settings. Below is an example using all the settings.
120 |
121 | ```ts
122 | const settings: Settings = {
123 | instance,
124 | getCredentials,
125 | refreshBuffer,
126 | header,
127 | formatter,
128 | refreshOnStatus,
129 | tokenTryThreshold,
130 | maxRecoveryTries,
131 | addTokenToLogs,
132 | onTokenRefresh,
133 | onAuthFail,
134 | onTokenRequestFail,
135 | onRecoveryTry,
136 | onTokenTryThreshold,
137 | onRecoveryAbort,
138 | };
139 |
140 | tokenManager(settings);
141 |
142 | export default instance;
143 | ```
144 |
145 | ## Token Shape
146 | The Token has the interface shown below.
147 |
148 | ```ts
149 | export interface Token {
150 | access_token: string;
151 | token_type?: string;
152 | expires_in: number;
153 | scope?: string;
154 | }
155 | ```
156 |
157 | * `access_token` and `expires_in` are mandatory keys.
158 | * `token_type` and `scope` are currently not used.
159 | * `access_token` is sent across in the Authorization Header.
160 | * `expires_in` field is expected to be in seconds and is used to work out how long to cache the token.
161 |
162 | ## API
163 |
164 | ### instance
165 | An Axios Instance. This instance should be used to make all requests that require an authentication header. The axios-token-manager will intercepts requests made by this instance and add an authentication header.
166 |
167 | ### getCredentials
168 | A function called by the axios-token-manager whenever it needs to get a fresh Token. It needs to implement the `TokenProvider` type shown below. It is a mandatory setting.
169 |
170 | ```ts
171 | export type TokenProvider = () => Promise;
172 | ```
173 |
174 | ### refreshBuffer
175 | The number of seconds at which the Token is refreshed before expiration of the currently cached token. Defaults to 10 seconds.
176 |
177 | ### header
178 | The Header which is added to outgoing requests. Default is `Authorization`
179 |
180 | ### formatter
181 | A function which takes in the `access_token` and returns the value to be assigned to the Header. It has the following type definition.
182 | ```ts
183 | export type Formatter = (accessToken: string) => string;
184 | ```
185 |
186 | ### refreshOnStatus
187 | Array of Http Status codes on return of which the Token Manager will attempt to recover by fetching a new Token and retrying the request with the new Token. The default is `[401]`.
188 |
189 | ### tokenTryThreshold
190 | If a request for a Token fails, then a new attempt is made to get a Token. Once the number of attempts reaches the `tokenTryThreshold` then a callback is invoked. The callback will be invoked again for every multiple of the `tokenTryThreshold`. The default is 10, therefore on every 10th failed attempt to get a fresh token the `onTokenTryThreshold` callback will be invoked.
191 |
192 | ### maxRecoveryTries
193 | The number of attempts to recover from a request which failed with an authentication error are counted. At each failure, a new token will be requested and a fresh attempt to recover made. The `maxRecoveryTries` sets the limit after which the system will no longer try and recover and it will send an error response back to the original caller. The default value is 5.
194 |
195 | ### addTokenToLogs
196 | A boolean which controls whether the `access_token` value is returned in callbacks. When the `access_token` is returned it is part of a longer string giving the context. Default is `false`.
197 |
198 | ### onTokenRefresh
199 | Callback function when the Token is refreshed. If `addTokenToLogs` is true, a message containing the new Token's `access_token` is sent as an argument.
200 |
201 | ### onAuthFail
202 | Callback function when the authentication fails and a status in the `refreshOnStatus` array is returned. If `addTokenToLogs` is true, a message containing the failed Token's `access_token` is sent as an argument.
203 |
204 | ### onTokenRequestFail
205 | Callback function when a request for a new Token fails.
206 |
207 | ### onRecoveryTry
208 | Callback invoked when an a attempt is made to resend the request using a fresh token after the earlier one failed with an authentication error defined in the `refreshOnStatus` setting. When `addTokenToLogs` is true, the callback will get invoked with a message giving the new `access_token` being used in the recovery attempt.
209 |
210 | ### onTokenTryThreshold
211 | Callback invoked when the number of failed attempts to get a new token reaches the `tokenTryThreshold` or any of its multiples. The callback is called with the number of failed tries an a parameter.
212 |
213 | ### onRecoveryAbort
214 | Callback invoked when the number of attempts to recover from authentication failures reaches the `maxRecoveryTries`.
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 |
--------------------------------------------------------------------------------
/dist/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | # [v 1.0.0](https://github.com/mickeypuri/axios-token-manager/compare/v0.2.8...v1.0.0) (2023-05-16)
4 |
5 | ## Changes from pre-release v 0.2.8
6 |
7 | * **documentation:** added full documentation including description, installation, usage examples and api.
8 | * **naming of interfaces:** removed prefix I from interfaces to bring in line with the more common approach of interface naming.
9 | * **renamed ITokenManager:** the `ITokenManager` interface was renamed to `Settings`
10 | * **pre fetch of token:** added feature of pre fetch of token, to replace a token a short time before it is due to expire so we never have any delay
11 |
12 | # [v 1.0.1](https://github.com/mickeypuri/axios-token-manager/compare/v1.0.0...v1.0.1) (2023-05-22)
13 |
14 | ## Changes from v1.0.0
15 |
16 | * **documentation:** Although described in the API, the code example in the readme was missing four configurable properties, these are now included in the example.
17 |
--------------------------------------------------------------------------------
/dist/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Mickey Puri
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 |
--------------------------------------------------------------------------------
/dist/README.md:
--------------------------------------------------------------------------------
1 | # axios-token-manager
2 |
3 | A library to manage caching of Axios Tokens with automatic refresh of Token on expiry or on error due to early revocation of the Token by the backend system. It can be used on a Node API Server as well as on a front end application.
4 |
5 |
6 |
7 | [](https://www.npmjs.org/package/axios-token-manager)
8 | [](https://github.com/mickeypuri/axios-token-manager/actions/workflows/ci.yml?branch=main)
9 | [](https://coveralls.io/github/mickeypuri/axios-token-manager?branch=main)
10 | [](https://snyk.io/test/npm/axios-token-manager)
11 |
12 |
13 |
14 | ## Table of Contents
15 |
16 | - [Purpose](#purpose)
17 | - [Features](#features)
18 | - [Installing](#installing)
19 | - [Usage](#usage)
20 | - [API](#api)
21 |
22 | ## Purpose
23 |
24 | The purpose of Axios-Token-Manager is to cache an Authentication Token during its validity period. This reduces the number of calls that need to be made on the back end to fetch authentication tokens, and improves response speeds and reduces latency.
25 |
26 | The Axios-Token-Manager needs to be provided with a function which when invoked returns a new Authentication Token. Along with an `access_token` field, the token response should include an `expires_in` field giving the number of seconds till the token expires.
27 |
28 | The Axios-Token-Manager is setup with an instance of Axios to which it applies request and response interceptors. All requests are intercepted and the Authentication Token is applied to the header of the outgoing request.
29 |
30 | The Axios-Token-Manager will get a new Authentication Token a short time before the current token is due to expire, thereby ensuring that there is always a valid token available to be used. In the event that a request is being made and there is no valid token, then a fresh token will be requested and used in the Authentication header of the request, and then the fresh token will get cached.
31 |
32 | Backend systems can sometimes revoke an Authentication Token before its expiry time. A request using such a token will fail with a 401 and possibly a 403 error. The Axios Token Manager intercepts all response and looks out for 401 errors. On receipt of a 401, it starts a recovery cycle in which it invalidates the cached token and fetches a new Token, which it caches. It then tries to recover the failed request by making a fresh request to the back end using the new Token. Only once this succeeds will the response be sent back to the caller. So the caller is unaware of what is happening and just gets a slightly delayed success response.
33 |
34 | In case callers want to be kept aware of what is happening under the hood there are a number of callbacks that can be hooked into.
35 |
36 | ## Features
37 |
38 | - Fetch Authentication token using provided function
39 | - Works out time till token expires
40 | - Caches Authentication Token
41 | - Applies Authentication Token to all outgoing requests on instance
42 | - Fetches new Authentication Token a short time before current cached token expires
43 | - Recovers from situation where the back end revokes a token before its expiry time is up
44 | - Monitors response and in case of a 401, it fetches a new Authentication token and retries the request with fresh token
45 | - After recovery from an early revokation of the Authentication token, it caches the fresh token
46 | - Adds interceptors on the Request and Response of the provided Axios Instance
47 | - Callbacks to notify calling application of token refresh, authentication fail, recovery try and recovery abort
48 | - Format and configure authentication header
49 | - Full Configuration of settings
50 |
51 | ## Installing
52 |
53 | ### Package manager
54 |
55 | Using npm:
56 |
57 | `$ npm install axios-token-manager`
58 |
59 | Using yarn:
60 |
61 | `$ yarn add axios-token-manager`
62 |
63 | Once the package is installed, you can import the library using `import` or `require` approach:
64 |
65 | `import tokenManager from 'axios-token-manager';`
66 |
67 | If you use `require` for importing:
68 |
69 | `const tokenManager = require('axios');`
70 |
71 | ## Usage
72 |
73 | This defines a file which has a default export of an axios instance wired up with the axios-token-manager.
74 |
75 | The axios-oauth-client library has been used here as an example for the implementation of the function to get a new Token, it can of course be replaced by your preferred oauth or token library, or your own implementation to get a token.
76 |
77 | **note**: `instance` and `getCredentials` are the two required configuration settings, the other settings are all optional.
78 |
79 | ```ts
80 | import axios from 'axios';
81 | import oauth from 'axios-oauth-client';
82 | import tokenManager, { TokenProvider, Settings } from 'axios-token-manager';
83 |
84 | // Define an Axios instance using a common baseURL, timeout and the common headers for all requests
85 | ...
86 |
87 | const instance = axios.create({
88 | baseURL,
89 | timeout,
90 | headers
91 | });
92 |
93 | // define tokenURL to fetch the authorization token from, a clientId and a client secret
94 | ...
95 |
96 | const getCredentials = oauth.clientCredentials(
97 | axios.create(),
98 | tokenURL,
99 | clientId,
100 | clientSecret
101 | ) as TokenProvider;
102 |
103 | // define other optional settings for callbacks and other configurations (see API for config)
104 | ...
105 |
106 | const settings: Settings = {
107 | instance,
108 | getCredentials,
109 | ... // define other optional configuration
110 | };
111 |
112 | tokenManager(settings);
113 |
114 | export default instance;
115 | ```
116 |
117 | ### Using all configurable options
118 |
119 | The above example only used the two required settings. Below is an example using all the settings.
120 |
121 | ```ts
122 | const settings: Settings = {
123 | instance,
124 | getCredentials,
125 | refreshBuffer,
126 | header,
127 | formatter,
128 | refreshOnStatus,
129 | tokenTryThreshold,
130 | maxRecoveryTries,
131 | addTokenToLogs,
132 | onTokenRefresh,
133 | onAuthFail,
134 | onTokenRequestFail,
135 | onRecoveryTry,
136 | onTokenTryThreshold,
137 | onRecoveryAbort,
138 | };
139 |
140 | tokenManager(settings);
141 |
142 | export default instance;
143 | ```
144 |
145 | ## Token Shape
146 | The Token has the interface shown below.
147 |
148 | ```ts
149 | export interface Token {
150 | access_token: string;
151 | token_type?: string;
152 | expires_in: number;
153 | scope?: string;
154 | }
155 | ```
156 |
157 | * `access_token` and `expires_in` are mandatory keys.
158 | * `token_type` and `scope` are currently not used.
159 | * `access_token` is sent across in the Authorization Header.
160 | * `expires_in` field is expected to be in seconds and is used to work out how long to cache the token.
161 |
162 | ## API
163 |
164 | ### instance
165 | An Axios Instance. This instance should be used to make all requests that require an authentication header. The axios-token-manager will intercepts requests made by this instance and add an authentication header.
166 |
167 | ### getCredentials
168 | A function called by the axios-token-manager whenever it needs to get a fresh Token. It needs to implement the `TokenProvider` type shown below. It is a mandatory setting.
169 |
170 | ```ts
171 | export type TokenProvider = () => Promise;
172 | ```
173 |
174 | ### refreshBuffer
175 | The number of seconds at which the Token is refreshed before expiration of the currently cached token. Defaults to 10 seconds.
176 |
177 | ### header
178 | The Header which is added to outgoing requests. Default is `Authorization`
179 |
180 | ### formatter
181 | A function which takes in the `access_token` and returns the value to be assigned to the Header. It has the following type definition.
182 | ```ts
183 | export type Formatter = (accessToken: string) => string;
184 | ```
185 |
186 | ### refreshOnStatus
187 | Array of Http Status codes on return of which the Token Manager will attempt to recover by fetching a new Token and retrying the request with the new Token. The default is `[401]`.
188 |
189 | ### tokenTryThreshold
190 | If a request for a Token fails, then a new attempt is made to get a Token. Once the number of attempts reaches the `tokenTryThreshold` then a callback is invoked. The callback will be invoked again for every multiple of the `tokenTryThreshold`. The default is 10, therefore on every 10th failed attempt to get a fresh token the `onTokenTryThreshold` callback will be invoked.
191 |
192 | ### maxRecoveryTries
193 | The number of attempts to recover from a request which failed with an authentication error are counted. At each failure, a new token will be requested and a fresh attempt to recover made. The `maxRecoveryTries` sets the limit after which the system will no longer try and recover and it will send an error response back to the original caller. The default value is 5.
194 |
195 | ### addTokenToLogs
196 | A boolean which controls whether the `access_token` value is returned in callbacks. When the `access_token` is returned it is part of a longer string giving the context. Default is `false`.
197 |
198 | ### onTokenRefresh
199 | Callback function when the Token is refreshed. If `addTokenToLogs` is true, a message containing the new Token's `access_token` is sent as an argument.
200 |
201 | ### onAuthFail
202 | Callback function when the authentication fails and a status in the `refreshOnStatus` array is returned. If `addTokenToLogs` is true, a message containing the failed Token's `access_token` is sent as an argument.
203 |
204 | ### onTokenRequestFail
205 | Callback function when a request for a new Token fails.
206 |
207 | ### onRecoveryTry
208 | Callback invoked when an a attempt is made to resend the request using a fresh token after the earlier one failed with an authentication error defined in the `refreshOnStatus` setting. When `addTokenToLogs` is true, the callback will get invoked with a message giving the new `access_token` being used in the recovery attempt.
209 |
210 | ### onTokenTryThreshold
211 | Callback invoked when the number of failed attempts to get a new token reaches the `tokenTryThreshold` or any of its multiples. The callback is called with the number of failed tries an a parameter.
212 |
213 | ### onRecoveryAbort
214 | Callback invoked when the number of attempts to recover from authentication failures reaches the `maxRecoveryTries`.
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 |
--------------------------------------------------------------------------------
/dist/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "axios-token-manager",
3 | "version": "1.0.1",
4 | "description": "A manager for Auth Tokens using Axios in a Node API Server or a JS Client",
5 | "license": "MIT",
6 | "main": "lib/cjs/index.js",
7 | "module": "lib/esm/index.js",
8 | "sideEffects": false,
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/mickeypuri/axios-token-manager"
12 | },
13 | "files": [
14 | "lib",
15 | "LICENSE",
16 | "package.json",
17 | "README.md",
18 | "CHANGELOG.md"
19 | ],
20 | "keywords": [
21 | "javascript",
22 | "axios",
23 | "token",
24 | "interceptor",
25 | "refresh",
26 | "oauth"
27 | ],
28 | "author": "Mickey Puri",
29 | "peerDependencies": {
30 | "axios": "^1.3.4"
31 | },
32 | "dependencies": {
33 | "semaphore-async-await": "^1.5.1"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('ts-jest').JestConfigWithTsJest} */
2 | module.exports = {
3 | preset: 'ts-jest',
4 | testEnvironment: 'node',
5 | rootDir: 'src'
6 | };
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "axios-token-manager",
3 | "version": "1.0.1",
4 | "repository": "https://github.com/mickeypuri/axios-token-manager",
5 | "author": "Mickey Puri",
6 | "license": "MIT",
7 | "scripts": {
8 | "build": "rimraf dist/lib && tsc && tsc --build tsconfig.es5.json",
9 | "eslint": "eslint src/index.ts",
10 | "test": "jest --coverage --runInBand",
11 | "test:watch": "jest --watch"
12 | },
13 | "devDependencies": {
14 | "@types/jest": "^29.5.0",
15 | "@typescript-eslint/eslint-plugin": "^5.54.1",
16 | "@typescript-eslint/parser": "^5.54.1",
17 | "eslint": "^8.35.0",
18 | "jest": "^29.5.0",
19 | "nock": "^13.3.0",
20 | "rimraf": "^4.3.0",
21 | "ts-jest": "^29.1.0",
22 | "typescript": "^4.9.5"
23 | },
24 | "dependencies": {
25 | "axios": "^1.3.4",
26 | "semaphore-async-await": "^1.5.1"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "@typescript-eslint/parser",
3 | "parserOptions": {
4 | "ecmaVersion": 2022,
5 | "sourceType": "module"
6 | },
7 | "plugins": ["@typescript-eslint"],
8 | "extends": ["plugin:@typescript-eslint/recommended"]
9 | }
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import tokenManager from './tokenManager';
2 | export default tokenManager;
3 | export { Settings, TokenProvider, Token, Formatter } from './types';
--------------------------------------------------------------------------------
/src/state.ts:
--------------------------------------------------------------------------------
1 | import { State, Config, TokenProvider } from './types';
2 | import { initCache } from './utils/initialValues';
3 |
4 | let state: State;
5 |
6 | export const setInitialState = (options: Config, getCredentials: TokenProvider) => {
7 | state = {
8 | cache: initCache,
9 | options,
10 | tokenTries: 0,
11 | recoveryTries: 0,
12 | inRecovery: false,
13 | getCredentials
14 | };
15 | };
16 |
17 | export const updateState = (update: Partial) => {
18 | state = { ...state, ...update };
19 | };
20 |
21 | export const getState = () => state;
22 |
--------------------------------------------------------------------------------
/src/tests/caching.test.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import nock from 'nock';
3 | import { Token, TokenProvider, LogFunction } from '../types';
4 | import tokenManager from '../tokenManager';
5 | import { getState } from '../state';
6 | import { defaultSettings } from '../utils/initialValues';
7 | import { setPreFetchTimer } from '../utils/setPreFetchTimer';
8 |
9 | jest.mock('../utils/setPreFetchTimer', () => ({
10 | setPreFetchTimer: jest.fn()
11 | }));
12 |
13 | const baseURL = 'https://api.news.com';
14 | const channelsPath = '/channel';
15 | const channels = ['bbc', 'itv', 'netflix', 'prime'];
16 |
17 | const ACCESS_TOKEN = 'token 1';
18 | const EXPIRES_IN_SECS = 300;
19 |
20 | const token_one: Token = {
21 | access_token: ACCESS_TOKEN,
22 | token_type: 'Bearer',
23 | expires_in: EXPIRES_IN_SECS,
24 | scope: 'scope'
25 | };
26 |
27 | beforeAll(() => {
28 | nock(baseURL, {
29 | reqheaders: {
30 | "accept": "application/json, text/plain, */*",
31 | "authorization": `Bearer ${ACCESS_TOKEN}`,
32 | "user-agent": "axios/1.3.4",
33 | "accept-encoding": "gzip, compress, deflate, br"
34 | }
35 | })
36 | .get(channelsPath)
37 | .reply(200, { channels })
38 | .persist();
39 | });
40 |
41 | afterEach(() => {
42 | jest.clearAllMocks();
43 | })
44 |
45 | afterAll(() => {
46 | nock.cleanAll();
47 | nock.restore();
48 | });
49 |
50 | describe('tokenManager caching', () => {
51 |
52 | it('calls for a Token when it gets a request and no token exists', async () => {
53 | const getCredentials: TokenProvider = jest.fn();
54 | const instance = axios.create({ baseURL });
55 | (getCredentials as jest.Mock).mockResolvedValue(token_one);
56 | tokenManager({ instance, getCredentials });
57 | await instance.get(`${baseURL}${channelsPath}`);
58 | expect((getCredentials as jest.Mock)).toBeCalledTimes(1);
59 | expect((setPreFetchTimer as jest.Mock)).toBeCalledTimes(1);
60 | });
61 |
62 | it('caches the Token after getting it', async () => {
63 | const getCredentials: TokenProvider = jest.fn();
64 | const instance = axios.create({ baseURL });
65 | (getCredentials as jest.Mock).mockResolvedValue(token_one);
66 | tokenManager({ instance, getCredentials });
67 | await instance.get(`${baseURL}${channelsPath}`);
68 |
69 | const { cache: { token } } = getState();
70 | const { access_token } = token as Token
71 |
72 | expect (access_token).toEqual(ACCESS_TOKEN);
73 | });
74 |
75 | it('sets the cache expiration after getting the token', async () => {
76 | const getCredentials: TokenProvider = jest.fn();
77 | const instance = axios.create({ baseURL });
78 | const { refreshBuffer } = defaultSettings;
79 | (getCredentials as jest.Mock).mockResolvedValue(token_one);
80 | tokenManager({ instance, getCredentials });
81 | await instance.get(`${baseURL}${channelsPath}`);
82 |
83 | const expectedExpiry = Date.now() + (EXPIRES_IN_SECS) * 1000;
84 | const { cache : { expiration }} = getState();
85 |
86 | const expectedExpirySecs = Math.round(expectedExpiry/1000);
87 | const actualExpirySecs = Math.round(expiration/1000);
88 |
89 | expect(actualExpirySecs).toEqual(expectedExpirySecs);
90 | });
91 |
92 | it('after start up it gets token in first call, then uses cached token for next four calls', async () => {
93 | const getCredentials: TokenProvider = jest.fn();
94 | const instance = axios.create({ baseURL });
95 | (getCredentials as jest.Mock).mockResolvedValue(token_one);
96 | tokenManager({ instance, getCredentials });
97 |
98 | await instance.get(`${baseURL}${channelsPath}`);
99 | await instance.get(`${baseURL}${channelsPath}`);
100 | await instance.get(`${baseURL}${channelsPath}`);
101 | await instance.get(`${baseURL}${channelsPath}`);
102 | await instance.get(`${baseURL}${channelsPath}`);
103 |
104 | expect((getCredentials as jest.Mock)).toBeCalledTimes(1);
105 | });
106 |
107 | it('after getting a token, it uses cached token for next four simultaneous calls', async () => {
108 | const getCredentials: TokenProvider = jest.fn();
109 | const instance = axios.create({ baseURL });
110 | (getCredentials as jest.Mock).mockResolvedValue(token_one);
111 | tokenManager({ instance, getCredentials });
112 |
113 | await instance.get(`${baseURL}${channelsPath}`);
114 | await Promise.all([
115 | instance.get(`${baseURL}${channelsPath}`),
116 | instance.get(`${baseURL}${channelsPath}`),
117 | instance.get(`${baseURL}${channelsPath}`),
118 | instance.get(`${baseURL}${channelsPath}`)
119 | ]);
120 |
121 | expect((getCredentials as jest.Mock)).toBeCalledTimes(1);
122 | });
123 |
124 | it('when the expiry time of cached token has passed it makes a call for a new token and calls the onTokenRefresh callback', async () => {
125 | const getCredentials: TokenProvider = jest.fn();
126 | const onTokenRefresh: LogFunction = jest.fn();
127 | const instance = axios.create({ baseURL });
128 | (getCredentials as jest.Mock).mockResolvedValue(token_one);
129 |
130 | tokenManager({ instance, getCredentials, onTokenRefresh });
131 |
132 | await instance.get(`${baseURL}${channelsPath}`);
133 | await instance.get(`${baseURL}${channelsPath}`);
134 | await instance.get(`${baseURL}${channelsPath}`);
135 |
136 | // move time forward by 500 seconds
137 | const currentTime = Date.now();
138 | jest.spyOn(global.Date, 'now').mockImplementation(() => currentTime + 500000);
139 |
140 | await instance.get(`${baseURL}${channelsPath}`);
141 |
142 | expect((getCredentials as jest.Mock)).toBeCalledTimes(2);
143 | expect((onTokenRefresh as jest.Mock)).toBeCalledTimes(2);
144 | });
145 |
146 | it('further testing that when expiry time of cached token has passed it makes a call for a new token', async () => {
147 | const getCredentials: TokenProvider = jest.fn();
148 | (getCredentials as jest.Mock).mockResolvedValue(token_one);
149 |
150 | let timePassed = 0;
151 | const currentTime = Date.now();
152 | jest.spyOn(global.Date, 'now').mockImplementation(() => currentTime + timePassed);
153 |
154 | const instance = axios.create({ baseURL });
155 | tokenManager({ instance, getCredentials });
156 |
157 | await instance.get(`${baseURL}${channelsPath}`); // gets first token with 300 sec expiry
158 | await instance.get(`${baseURL}${channelsPath}`);
159 | await instance.get(`${baseURL}${channelsPath}`);
160 |
161 | timePassed = 500000; // move time forward by 500 seconds
162 |
163 | await instance.get(`${baseURL}${channelsPath}`); // gets a new token
164 | await instance.get(`${baseURL}${channelsPath}`);
165 | await instance.get(`${baseURL}${channelsPath}`);
166 | await instance.get(`${baseURL}${channelsPath}`);
167 |
168 | timePassed = 1000000;
169 |
170 | await instance.get(`${baseURL}${channelsPath}`); // gets another new token
171 | await instance.get(`${baseURL}${channelsPath}`);
172 | await instance.get(`${baseURL}${channelsPath}`);
173 |
174 | expect((getCredentials as jest.Mock)).toBeCalledTimes(3);
175 | expect((setPreFetchTimer as jest.Mock)).toBeCalledTimes(3);
176 | });
177 | });
178 |
--------------------------------------------------------------------------------
/src/tests/errorStatus.test.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosError } from 'axios';
2 | import nock from 'nock';
3 | import { Token, TokenProvider, LogFunction } from '../types';
4 | import tokenManager from '../tokenManager';
5 | import { setPreFetchTimer } from '../utils/setPreFetchTimer';
6 |
7 | jest.mock('../utils/setPreFetchTimer', () => ({
8 | setPreFetchTimer: jest.fn()
9 | }));
10 |
11 | const baseURL = 'https://api.news.com';
12 | const channelsPath = '/channel';
13 | const schedulePath = '/schedule';
14 | const channels = ['bbc', 'itv', 'netflix', 'prime'];
15 |
16 | const ACCESS_TOKEN_ONE = 'Token One';
17 | const ACCESS_TOKEN_TWO = 'Token Two';
18 | const EXPIRES_IN_SECS = 300;
19 |
20 | const token_one: Token = {
21 | access_token: ACCESS_TOKEN_ONE,
22 | token_type: 'Bearer',
23 | expires_in: EXPIRES_IN_SECS,
24 | scope: 'scope'
25 | };
26 |
27 | const token_two: Token = {
28 | access_token: ACCESS_TOKEN_TWO,
29 | token_type: 'Bearer',
30 | expires_in: EXPIRES_IN_SECS,
31 | scope: 'scope'
32 | };
33 |
34 | beforeEach(() => {
35 | jest.resetAllMocks;
36 |
37 | nock(baseURL, {
38 | reqheaders: {
39 | "accept": "application/json, text/plain, */*",
40 | "authorization": `Bearer ${ACCESS_TOKEN_ONE}`,
41 | "user-agent": "axios/1.3.4",
42 | "accept-encoding": "gzip, compress, deflate, br"
43 | }
44 | })
45 | .get(channelsPath)
46 | .times(3)
47 | .reply(200, { channels })
48 | .get(schedulePath)
49 | .reply(500)
50 | .get(channelsPath)
51 | .reply(200, {channels});
52 |
53 | });
54 |
55 | afterAll(() => {
56 | nock.cleanAll();
57 | nock.restore();
58 | });
59 |
60 | describe('tokenManager caching', () => {
61 |
62 | it('on getting a status such as 500 which is not in the list of recovery status it will not try to recover and the request will fail', async () => {
63 | const getCredentials: TokenProvider = jest.fn();
64 | const onTokenRefresh: LogFunction = jest.fn();
65 | const onAuthFail: LogFunction = jest.fn();
66 | const onRecoveryTry: LogFunction = jest.fn();
67 | const instance = axios.create({ baseURL });
68 | (getCredentials as jest.Mock)
69 | .mockResolvedValueOnce(token_one)
70 | .mockResolvedValueOnce(token_two);
71 |
72 |
73 | tokenManager({ instance, getCredentials, onRecoveryTry, onAuthFail, onTokenRefresh });
74 |
75 | await instance.get(`${baseURL}${channelsPath}`); // uses token 1
76 | await instance.get(`${baseURL}${channelsPath}`);
77 | await instance.get(`${baseURL}${channelsPath}`);
78 |
79 | // next call will fail with a 500, we intercept, see its not in our list to recover from, and allow the request to fail
80 |
81 | try {
82 | await instance.get(`${baseURL}${schedulePath}`);
83 | } catch (error) {
84 | const status = (error as AxiosError)?.response?.status;
85 | expect(status).toEqual(500);
86 | }
87 |
88 | await instance.get(`${baseURL}${channelsPath}`);
89 |
90 | expect((onAuthFail as jest.Mock)).toBeCalledTimes(0);
91 | expect((onRecoveryTry as jest.Mock)).toBeCalledTimes(0);
92 |
93 | expect((onTokenRefresh as jest.Mock)).toBeCalledTimes(1);
94 | expect((getCredentials as jest.Mock)).toBeCalledTimes(1);
95 | expect((setPreFetchTimer as jest.Mock)).toBeCalledTimes(1);
96 | });
97 | });
98 |
--------------------------------------------------------------------------------
/src/tests/getToken.test.ts:
--------------------------------------------------------------------------------
1 | import { getToken } from '../utils/getToken';
2 | import { getState } from '../state';
3 | import { TokenProvider } from '../types';
4 | import Semaphore from 'semaphore-async-await';
5 | import { getFreshToken } from '../utils/getFreshToken';
6 |
7 | const ACCESS_TOKEN_ONE = 'Token One';
8 | const ACCESS_TOKEN_TWO = 'Token Two';
9 | const EXPIRES_IN_SECS = 300;
10 |
11 | const expiredToken = {
12 | access_token: ACCESS_TOKEN_ONE,
13 | token_type: 'Bearer',
14 | expires_in: EXPIRES_IN_SECS,
15 | scope: 'scope'
16 | };
17 |
18 | const expiredCache = {
19 | token: expiredToken,
20 | expiration: Date.now() - 10000
21 | };
22 |
23 | const validToken = {
24 | access_token: ACCESS_TOKEN_TWO,
25 | token_type: 'Bearer',
26 | expires_in: EXPIRES_IN_SECS,
27 | scope: 'scope'
28 | };
29 |
30 | const validCache = {
31 | token: validToken,
32 | expiration: Date.now() + 10000
33 | };
34 |
35 | jest.mock('../state');
36 | jest.mock('../utils/getFreshToken');
37 |
38 | afterEach(() => {
39 | jest.clearAllMocks();
40 | })
41 |
42 | describe('getToken', () => {
43 | it('will not get a fresh token if the previous request has already updated the token', async () => {
44 | const getCredentials: TokenProvider = jest.fn();
45 | const lock = {
46 | acquire: jest.fn().mockResolvedValueOnce(''),
47 | release: jest.fn()
48 | } as unknown as Semaphore;
49 |
50 | (getState as jest.Mock)
51 | .mockReturnValueOnce({
52 | cache: expiredCache,
53 | getCredentials
54 | })
55 | .mockReturnValueOnce({
56 | cache: validCache,
57 | getCredentials
58 | });
59 |
60 | const token = await getToken(lock);
61 |
62 | expect((lock.acquire as jest.Mock)).toBeCalledTimes(1);
63 | expect((lock.release as jest.Mock)).toBeCalledTimes(1);
64 | expect((getCredentials as jest.Mock)).not.toBeCalled();
65 | expect(token).toEqual(validToken);
66 | });
67 |
68 | it('will return a rejected promise if there is an error getting the fresh token', async () => {
69 | const getCredentials: TokenProvider = jest.fn();
70 | const lock = {
71 | acquire: jest.fn().mockResolvedValueOnce(''),
72 | release: jest.fn()
73 | } as unknown as Semaphore;
74 |
75 | (getState as jest.Mock).mockReturnValue({
76 | cache: expiredCache,
77 | getCredentials
78 | });
79 |
80 | const getFreshTokenError = new Error('Error getting fresh Token');
81 | (getFreshToken as jest.Mock).mockRejectedValueOnce(getFreshTokenError);
82 |
83 | let actualError = null;
84 | try {
85 | await getToken(lock);
86 | } catch (error) {
87 | actualError = error;
88 | }
89 |
90 | expect(getFreshToken as jest.Mock).toBeCalledTimes(1);
91 | expect(actualError).toEqual(getFreshTokenError);
92 | expect((lock.release as jest.Mock)).toBeCalledTimes(1);
93 | });
94 | });
95 |
--------------------------------------------------------------------------------
/src/tests/initialisation.test.ts:
--------------------------------------------------------------------------------
1 | import { TokenProvider } from '../types';
2 | import { defaultSettings } from '../utils/initialValues';
3 | import tokenManager from '../tokenManager';
4 | import { AxiosInstance } from 'axios';
5 | import { getState } from '../state';
6 |
7 | const getCredentials: TokenProvider = jest.fn();
8 |
9 | const getMockAxiosInstance = () => ({
10 | interceptors: {
11 | request: {
12 | use: jest.fn()
13 | },
14 | response: {
15 | use: jest.fn()
16 | }
17 | }
18 | }) as unknown as AxiosInstance;
19 |
20 | describe('tokenManager initialisation', () => {
21 | it('sets options to default settings if values not provided', () => {
22 | const instance = getMockAxiosInstance();
23 | tokenManager({ instance, getCredentials });
24 | const { options } = getState();
25 | expect(options).toEqual(defaultSettings);
26 | });
27 |
28 | it('sets rest of options to default settings and uses two options provided', () => {
29 | const instance = getMockAxiosInstance();
30 |
31 | const maxRecoveryTries = 10;
32 | const onAuthFail = () => console.log('Authorisation has failed');
33 | const expectedOptions = {...defaultSettings, maxRecoveryTries, onAuthFail };
34 |
35 | tokenManager({ instance, getCredentials, maxRecoveryTries, onAuthFail });
36 | const { options } = getState();
37 | expect(options).toEqual(expectedOptions);
38 | });
39 |
40 | it('sets rest of options to default settings and uses the three options provided', () => {
41 | const instance = getMockAxiosInstance();
42 |
43 | const tokenTryThreshold = 10;
44 | const onTokenRefresh = () => console.log('Authorisation has failed');
45 | const header = 'Test Header';
46 | const expectedOptions = {...defaultSettings, tokenTryThreshold, onTokenRefresh, header };
47 |
48 | tokenManager({ instance, getCredentials, tokenTryThreshold, onTokenRefresh, header });
49 | const { options } = getState();
50 | expect(options).toEqual(expectedOptions);
51 | });
52 |
53 | it('is set up to intercept all requests', () => {
54 | const instance = getMockAxiosInstance();
55 | tokenManager({ instance, getCredentials });
56 | const { interceptors : { request : { use : requestInterceptMock }}} = instance;
57 | expect((requestInterceptMock as jest.Mock)).toBeCalledTimes(1);
58 | });
59 |
60 | it('is set up to intercept all responses', () => {
61 | const instance = getMockAxiosInstance();
62 | tokenManager({ instance, getCredentials });
63 | const { interceptors : { response : { use : responseInterceptMock }}} = instance;
64 | expect((responseInterceptMock as jest.Mock)).toBeCalledTimes(1);
65 | });
66 |
67 | it('will not invoke getCredentials at initialisation', () => {
68 | const instance = getMockAxiosInstance();
69 | tokenManager({ instance, getCredentials });
70 | expect((getCredentials as jest.Mock)).not.toBeCalled();
71 | });
72 | });
73 |
--------------------------------------------------------------------------------
/src/tests/preFetchToken.test.ts:
--------------------------------------------------------------------------------
1 | import { Token } from '../types';
2 | import { getState } from '../state';
3 | import { getFreshToken } from '../utils/getFreshToken';
4 | import { defaultSettings } from '../utils/initialValues';
5 |
6 | import { preFetchToken } from '../utils/preFetchToken';
7 |
8 | jest.mock('../state');
9 |
10 | jest.mock('../utils/getFreshToken', () => ({
11 | getFreshToken: jest.fn()
12 | }));
13 |
14 | const EXPIRES_IN_SECS = 3600;
15 | const ACCESS_TOKEN_ONE = 'Token One';
16 | const ACCESS_TOKEN_TWO = 'Token Two';
17 | const EXPIRATION = Date.now() + EXPIRES_IN_SECS * 1000;
18 | const EXPIRED = Date.now() - 5000;
19 |
20 | const token: Token = {
21 | access_token: ACCESS_TOKEN_ONE,
22 | token_type: 'Bearer',
23 | expires_in: EXPIRES_IN_SECS,
24 | scope: 'site wide'
25 | };
26 |
27 | describe('preFetchToken', () => {
28 |
29 | it('does not get a fresh token if Token is no longer valid', async () => {
30 | (getState as jest.Mock).mockImplementationOnce(() => ({
31 | cache: { token, expiration: EXPIRED },
32 | options: defaultSettings,
33 | tokenTries: 0,
34 | recoveryTries: 0,
35 | inRecovery: false,
36 | getCredentials: jest.fn()
37 | }));
38 |
39 | await preFetchToken(ACCESS_TOKEN_ONE);
40 | expect((getFreshToken as jest.Mock)).not.toBeCalled();
41 | });
42 |
43 | it('does not get a fresh token if Token has changed since timer started', async () => {
44 | (getState as jest.Mock).mockImplementationOnce(() => ({
45 | cache: { token, expiration: EXPIRATION },
46 | options: defaultSettings,
47 | tokenTries: 0,
48 | recoveryTries: 0,
49 | inRecovery: false,
50 | getCredentials: jest.fn()
51 | }));
52 |
53 | await preFetchToken(ACCESS_TOKEN_TWO);
54 | expect((getFreshToken as jest.Mock)).not.toBeCalled();
55 | });
56 |
57 | it('will get a fresh token if token is valid and is the same as when timer started', async () => {
58 | (getState as jest.Mock).mockImplementationOnce(() => ({
59 | cache: { token, expiration: EXPIRATION },
60 | options: defaultSettings,
61 | tokenTries: 0,
62 | recoveryTries: 0,
63 | inRecovery: false,
64 | getCredentials: jest.fn()
65 | }));
66 |
67 | await preFetchToken(ACCESS_TOKEN_ONE);
68 | expect((getFreshToken as jest.Mock)).toBeCalledTimes(1);
69 | });
70 |
71 | it('will return an error if the request for a fresh token fails', async () => {
72 | (getState as jest.Mock).mockImplementationOnce(() => ({
73 | cache: { token, expiration: EXPIRATION },
74 | options: defaultSettings,
75 | tokenTries: 0,
76 | recoveryTries: 0,
77 | inRecovery: false,
78 | getCredentials: jest.fn()
79 | }));
80 |
81 | const ERROR = new Error('Token Refresh Failed');
82 | (getFreshToken as jest.Mock).mockRejectedValueOnce(ERROR);
83 |
84 | try {
85 | await preFetchToken(ACCESS_TOKEN_ONE);
86 | } catch (error) {
87 | expect(error).toEqual(ERROR);
88 | }
89 | });
90 | });
91 |
--------------------------------------------------------------------------------
/src/tests/recovery.test.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import nock from 'nock';
3 | import { Token, TokenProvider, LogFunction } from '../types';
4 | import tokenManager from '../tokenManager';
5 |
6 | jest.mock('../utils/setPreFetchTimer');
7 |
8 | const baseURL = 'https://api.news.com';
9 | const channelsPath = '/channel';
10 | const schedulePath = '/schedule';
11 | const channels = ['bbc', 'itv', 'netflix', 'prime'];
12 | const schedules = ['sat', 'sun', 'mon', 'tue', 'wed'];
13 |
14 | const ACCESS_TOKEN_ONE = 'Token One';
15 | const ACCESS_TOKEN_TWO = 'Token Two';
16 | const EXPIRES_IN_SECS = 300;
17 |
18 | const token_one: Token = {
19 | access_token: ACCESS_TOKEN_ONE,
20 | token_type: 'Bearer',
21 | expires_in: EXPIRES_IN_SECS,
22 | scope: 'scope'
23 | };
24 |
25 | const token_two: Token = {
26 | access_token: ACCESS_TOKEN_TWO,
27 | token_type: 'Bearer',
28 | expires_in: EXPIRES_IN_SECS,
29 | scope: 'scope'
30 | };
31 |
32 | beforeEach(() => {
33 | jest.resetAllMocks;
34 |
35 | nock(baseURL, {
36 | reqheaders: {
37 | "accept": "application/json, text/plain, */*",
38 | "authorization": `Bearer ${ACCESS_TOKEN_ONE}`,
39 | "user-agent": "axios/1.3.4",
40 | "accept-encoding": "gzip, compress, deflate, br"
41 | }
42 | })
43 | .get(channelsPath)
44 | .times(3)
45 | .reply(200, { channels })
46 | .get(schedulePath)
47 | .reply(401);
48 |
49 | nock(baseURL, {
50 | reqheaders: {
51 | "accept": "application/json, text/plain, */*",
52 | "authorization": `Bearer ${ACCESS_TOKEN_TWO}`,
53 | "user-agent": "axios/1.3.4",
54 | "accept-encoding": "gzip, compress, deflate, br"
55 | }
56 | })
57 | .get(schedulePath)
58 | .times(4)
59 | .reply(200, { schedules });
60 | });
61 |
62 | afterAll(() => {
63 | nock.cleanAll();
64 | nock.restore();
65 | });
66 |
67 | describe('tokenManager caching', () => {
68 |
69 | it('on getting a 401 it tries to recover by getting another token and retries with the fresh token', async () => {
70 | const getCredentials: TokenProvider = jest.fn();
71 | const instance = axios.create({ baseURL });
72 | (getCredentials as jest.Mock)
73 | .mockResolvedValueOnce(token_one)
74 | .mockResolvedValueOnce(token_two);
75 |
76 |
77 | tokenManager({ instance, getCredentials });
78 |
79 | await instance.get(`${baseURL}${channelsPath}`); // uses token 1
80 | await instance.get(`${baseURL}${channelsPath}`);
81 | await instance.get(`${baseURL}${channelsPath}`);
82 |
83 | await instance.get(`${baseURL}${schedulePath}`); // call will fail with a 401, we intercept the 401 and get another token, token 2, and retry with that
84 | await instance.get(`${baseURL}${schedulePath}`);
85 | await instance.get(`${baseURL}${schedulePath}`);
86 | await instance.get(`${baseURL}${schedulePath}`);
87 |
88 |
89 | expect((getCredentials as jest.Mock)).toBeCalledTimes(2);
90 | });
91 |
92 | it('on getting a 401 it tries to recover and get a fresh token and retry and calls the onAuthFail and onRecoveryTry and onTokenRefresh callback', async () => {
93 | const getCredentials: TokenProvider = jest.fn();
94 | const onTokenRefresh: LogFunction = jest.fn();
95 | const onAuthFail: LogFunction = jest.fn();
96 | const onRecoveryTry: LogFunction = jest.fn();
97 | const addTokenToLogs = true;
98 |
99 | const instance = axios.create({ baseURL });
100 | (getCredentials as jest.Mock)
101 | .mockResolvedValueOnce(token_one)
102 | .mockResolvedValueOnce(token_two);
103 |
104 |
105 | tokenManager({ instance, getCredentials, onRecoveryTry, onAuthFail, onTokenRefresh, addTokenToLogs });
106 |
107 | await instance.get(`${baseURL}${channelsPath}`); // uses token 1
108 | await instance.get(`${baseURL}${channelsPath}`);
109 | await instance.get(`${baseURL}${channelsPath}`);
110 |
111 | await instance.get(`${baseURL}${schedulePath}`); // call will fail with a 401, we intercept the 401 and get another token, token 2, and retry with that
112 | await instance.get(`${baseURL}${schedulePath}`);
113 | await instance.get(`${baseURL}${schedulePath}`);
114 | await instance.get(`${baseURL}${schedulePath}`);
115 |
116 | expect((onAuthFail as jest.Mock)).toBeCalledTimes(1);
117 | expect((onRecoveryTry as jest.Mock)).toBeCalledTimes(1);
118 | expect((onRecoveryTry as jest.Mock)).toBeCalledWith(`Using token: ${ACCESS_TOKEN_TWO}.`);
119 | expect((onTokenRefresh as jest.Mock)).toBeCalledTimes(2);
120 | });
121 | });
122 |
--------------------------------------------------------------------------------
/src/tests/recoveryErrored.test.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosError, AxiosResponse } from 'axios';
2 | import nock from 'nock';
3 | import { Token, TokenProvider, LogFunction } from '../types';
4 | import tokenManager from '../tokenManager';
5 | import { setPreFetchTimer } from '../utils/setPreFetchTimer';
6 |
7 | jest.mock('../utils/setPreFetchTimer', () => ({
8 | setPreFetchTimer: jest.fn()
9 | }));
10 |
11 | const baseURL = 'https://api.news.com';
12 | const channelsPath = '/channel';
13 | const schedulePath = '/schedule';
14 | const channels = ['bbc', 'itv', 'netflix', 'prime'];
15 |
16 | const ACCESS_TOKEN_ONE = 'Token One';
17 | const EXPIRES_IN_SECS = 300;
18 |
19 | const token_one: Token = {
20 | access_token: ACCESS_TOKEN_ONE,
21 | token_type: 'Bearer',
22 | expires_in: EXPIRES_IN_SECS,
23 | scope: 'scope'
24 | };
25 |
26 | beforeEach(() => {
27 | jest.resetAllMocks;
28 |
29 | nock(baseURL, {
30 | reqheaders: {
31 | "accept": "application/json, text/plain, */*",
32 | "authorization": `Bearer ${ACCESS_TOKEN_ONE}`,
33 | "user-agent": "axios/1.3.4",
34 | "accept-encoding": "gzip, compress, deflate, br"
35 | }
36 | })
37 | .get(channelsPath)
38 | .times(3)
39 | .reply(200, { channels })
40 | .get(schedulePath)
41 | .reply(401);
42 | });
43 |
44 | afterAll(() => {
45 | nock.cleanAll();
46 | nock.restore();
47 | });
48 |
49 | describe('tokenManager caching', () => {
50 |
51 | it('on getting a 401 it tries to recover but if the call to get the fresh token fails then it rejects the request', async () => {
52 | const getCredentials: TokenProvider = jest.fn();
53 | const onTokenRefresh: LogFunction = jest.fn();
54 | const onAuthFail: LogFunction = jest.fn();
55 | const onRecoveryTry: LogFunction = jest.fn();
56 | const instance = axios.create({ baseURL });
57 | const errorResponse = {data: 'some data', status: 500} as AxiosResponse;
58 | (getCredentials as jest.Mock)
59 | .mockResolvedValueOnce(token_one)
60 | .mockRejectedValueOnce(new AxiosError('recovery failed', undefined, undefined, undefined, errorResponse));
61 |
62 | tokenManager({ instance, getCredentials, onRecoveryTry, onAuthFail, onTokenRefresh });
63 |
64 | await instance.get(`${baseURL}${channelsPath}`); // uses token 1
65 | await instance.get(`${baseURL}${channelsPath}`);
66 | await instance.get(`${baseURL}${channelsPath}`);
67 |
68 | // call will fail with a 401, we intercept the 401 and try get another token but it errors and returns a 500 status
69 | // as second attempt to get token fails, we will not call setPreFetchTimer again
70 | try {
71 | await instance.get(`${baseURL}${schedulePath}`);
72 | } catch (error) {
73 | expect((error as AxiosError)?.response?.status).toEqual(500);
74 | }
75 |
76 | expect((onAuthFail as jest.Mock)).toBeCalledTimes(1);
77 | expect((onRecoveryTry as jest.Mock)).toBeCalledTimes(0);
78 | expect((onTokenRefresh as jest.Mock)).toBeCalledTimes(1);
79 | expect((getCredentials as jest.Mock)).toBeCalledTimes(2);
80 | expect((setPreFetchTimer as jest.Mock)).toBeCalledTimes(1);
81 | });
82 | });
83 |
--------------------------------------------------------------------------------
/src/tests/recoveryRace.test.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import nock from 'nock';
3 | import { Token, TokenProvider, LogFunction } from '../types';
4 | import tokenManager from '../tokenManager';
5 | import { setPreFetchTimer } from '../utils/setPreFetchTimer';
6 |
7 | // This mocks the shouldRecover function, and forces it to recover when there is an error
8 | // As shouldRecover is mocked, it skips resetting the cache in this test.
9 | // So the cache and token remains valid, which simulates the situation that a previous request has got a new token
10 | // and has cached it and hence the cache and token are valid
11 |
12 | jest.mock('../utils/setPreFetchTimer', () => ({
13 | setPreFetchTimer: jest.fn()
14 | }));
15 |
16 | jest.mock('../utils/shouldRecover', () => ({
17 | shouldRecover: () => true
18 | }));
19 |
20 | const baseURL = 'https://api.news.com';
21 | const channelsPath = '/channel';
22 | const schedulePath = '/schedule';
23 | const channels = ['bbc', 'itv', 'netflix', 'prime'];
24 | const schedules = ['sat', 'sun', 'mon', 'tue', 'wed'];
25 |
26 | const ACCESS_TOKEN_ONE = 'Token One';
27 | const ACCESS_TOKEN_TWO = 'Token Two';
28 | const EXPIRES_IN_SECS = 300;
29 |
30 | const token_one: Token = {
31 | access_token: ACCESS_TOKEN_ONE,
32 | token_type: 'Bearer',
33 | expires_in: EXPIRES_IN_SECS,
34 | scope: 'scope'
35 | };
36 |
37 | const token_two: Token = {
38 | access_token: ACCESS_TOKEN_TWO,
39 | token_type: 'Bearer',
40 | expires_in: EXPIRES_IN_SECS,
41 | scope: 'scope'
42 | };
43 |
44 | beforeEach(() => {
45 | jest.resetAllMocks;
46 |
47 | nock(baseURL, {
48 | reqheaders: {
49 | "accept": "application/json, text/plain, */*",
50 | "authorization": `Bearer ${ACCESS_TOKEN_ONE}`,
51 | "user-agent": "axios/1.3.4",
52 | "accept-encoding": "gzip, compress, deflate, br"
53 | }
54 | })
55 | .get(channelsPath)
56 | .times(3)
57 | .reply(200, { channels })
58 | .get(schedulePath)
59 | .reply(401)
60 | .get(schedulePath)
61 | .times(3)
62 | .reply(200, {schedules});
63 |
64 | });
65 |
66 | afterAll(() => {
67 | nock.cleanAll();
68 | nock.restore();
69 | jest.restoreAllMocks();
70 | });
71 |
72 | describe('tokenManager caching', () => {
73 |
74 | it('on getting a 401, if previous failed auth call has fetched a new token then it uses that and will not get a further token', async () => {
75 |
76 | const getCredentials: TokenProvider = jest.fn();
77 |
78 | const onTokenRefresh: LogFunction = jest.fn();
79 | const onRecoveryTry: LogFunction = jest.fn();
80 |
81 | const instance = axios.create({ baseURL });
82 | (getCredentials as jest.Mock)
83 | .mockResolvedValueOnce(token_one)
84 | .mockResolvedValueOnce(token_two);
85 |
86 |
87 | tokenManager({ instance, getCredentials, onRecoveryTry, onTokenRefresh });
88 |
89 | await instance.get(`${baseURL}${channelsPath}`); // uses token 1
90 | await instance.get(`${baseURL}${channelsPath}`);
91 | await instance.get(`${baseURL}${channelsPath}`);
92 |
93 | await instance.get(`${baseURL}${schedulePath}`); // call will fail with a 401, we intercept the 401
94 |
95 | await instance.get(`${baseURL}${schedulePath}`);
96 | await instance.get(`${baseURL}${schedulePath}`);
97 |
98 | expect((setPreFetchTimer as jest.Mock)).toBeCalledTimes(1); // the timer is set to do a prefetch
99 | expect((onRecoveryTry as jest.Mock)).toBeCalledTimes(1); // tries to recover the failed call
100 | expect((onTokenRefresh as jest.Mock)).toBeCalledTimes(1); // does not call for a further token refresh as Token was still valid
101 | expect((getCredentials as jest.Mock)).toBeCalledTimes(1); // does not call for a token again as the Token was still valid
102 | });
103 | });
104 |
--------------------------------------------------------------------------------
/src/tests/setPreFetchTimer.test.ts:
--------------------------------------------------------------------------------
1 | import { setPreFetchTimer } from '../utils/setPreFetchTimer';
2 | import { preFetchToken } from '../utils/preFetchToken';
3 | import { getState } from '../state';
4 | import { Token } from '../types';
5 | import { defaultSettings } from '../utils/initialValues';
6 |
7 | jest.mock('../utils/preFetchToken', () => ({
8 | preFetchToken: jest.fn()
9 | }));
10 | jest.mock('../state');
11 |
12 | jest.useFakeTimers();
13 | jest.spyOn(global, 'setTimeout');
14 |
15 | afterAll(() => {
16 | jest.useRealTimers();
17 | });
18 |
19 | const EXPIRES_IN_SECS = 3600;
20 | const ACCESS_TOKEN = 'Token One';
21 | const EXPIRATION = Date.now() + EXPIRES_IN_SECS * 1000;
22 |
23 | const token: Token = {
24 | access_token: ACCESS_TOKEN,
25 | token_type: 'Bearer',
26 | expires_in: EXPIRES_IN_SECS,
27 | scope: 'site wide'
28 | };
29 |
30 | describe('setPreFetchTimer', () => {
31 | it('calls setTimeout with the correct refresh period and access_token', () => {
32 | (getState as jest.Mock).mockImplementationOnce(() => ({
33 | cache: { token, expiration: EXPIRATION },
34 | options: defaultSettings,
35 | tokenTries: 0,
36 | recoveryTries: 0,
37 | inRecovery: false,
38 | getCredentials: jest.fn()
39 | }));
40 |
41 | const { refreshBuffer } = defaultSettings;
42 | const refreshPeriod = (EXPIRES_IN_SECS - refreshBuffer) * 1000;
43 |
44 | setPreFetchTimer(token);
45 |
46 | expect(setTimeout).toBeCalled();
47 | expect(setTimeout).toHaveBeenLastCalledWith(preFetchToken, refreshPeriod, ACCESS_TOKEN);
48 | });
49 |
50 | it('preFetchToken is invoked after the refresh period', () => {
51 | (getState as jest.Mock).mockImplementationOnce(() => ({
52 | cache: { token, expiration: EXPIRATION },
53 | options: defaultSettings,
54 | tokenTries: 0,
55 | recoveryTries: 0,
56 | inRecovery: false,
57 | getCredentials: jest.fn()
58 | }));
59 |
60 | const { refreshBuffer } = defaultSettings;
61 | const refreshPeriod = (EXPIRES_IN_SECS - refreshBuffer) * 1000;
62 |
63 | setPreFetchTimer(token);
64 |
65 | // Fast Forward till all timers have been called
66 | jest.runAllTimers();
67 | expect(preFetchToken).toBeCalled();
68 | });
69 | });
70 |
--------------------------------------------------------------------------------
/src/tests/shouldRecover.test.ts:
--------------------------------------------------------------------------------
1 | import { AxiosError } from 'axios';
2 | import { shouldRecover } from '../utils/shouldRecover';
3 | import { initCache, defaultSettings } from '../utils/initialValues';
4 | import { Token, LogFunction } from '../types';
5 |
6 | import { getState, updateState } from '../state';
7 |
8 | jest.mock('../state');
9 |
10 | const token: Token = {
11 | access_token: 'Token One',
12 | token_type: 'Bearer',
13 | expires_in: 0,
14 | scope: 'site wide'
15 | };
16 |
17 | describe('shouldRecover from error', () => {
18 | it('will return false if there is no response in the error', () => {
19 | (getState as jest.Mock).mockImplementationOnce(() => ({
20 | cache: { token, expiration: 0 },
21 | options: defaultSettings,
22 | tokenTries: 0,
23 | recoveryTries: 0,
24 | inRecovery: false,
25 | getCredentials: jest.fn()
26 | }));
27 |
28 | const error = {} as unknown as AxiosError;
29 | const returnValue = shouldRecover(error);
30 |
31 | expect(returnValue).toBe(false);
32 | });
33 |
34 | it('will abort recovery and reset state if recovery tries is equal to max tries', () => {
35 | const onRecoveryAbort: LogFunction = jest.fn();
36 | const { maxRecoveryTries } = defaultSettings;
37 |
38 | (getState as jest.Mock).mockImplementationOnce(() => ({
39 | cache: { token, expiration: 0 },
40 | options: { ...defaultSettings, onRecoveryAbort },
41 | tokenTries: 0,
42 | recoveryTries: maxRecoveryTries,
43 | inRecovery: true,
44 | getCredentials: jest.fn()
45 | }));
46 |
47 | const error = {
48 | response: {
49 | status: 401
50 | }
51 | } as unknown as AxiosError;
52 |
53 | const returnValue = shouldRecover(error);
54 |
55 | expect((updateState as jest.Mock)).toBeCalledWith({
56 | inRecovery: false,
57 | recoveryTries: 0,
58 | cache: initCache
59 | });
60 | expect((onRecoveryAbort as jest.Mock)).toBeCalledTimes(1);
61 | expect(returnValue).toBe(false);
62 | });
63 | });
64 |
--------------------------------------------------------------------------------
/src/tokenManager.ts:
--------------------------------------------------------------------------------
1 | import { AxiosError, AxiosHeaders, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig} from 'axios';
2 | import Semaphore from 'semaphore-async-await';
3 | import { Settings, Token } from './types';
4 | import { defaultSettings } from './utils/initialValues';
5 | import { getFreshToken } from './utils/getFreshToken';
6 | import { isTokenValid } from './utils/isTokenValid';
7 | import { shouldRecover } from './utils/shouldRecover';
8 | import { getToken } from './utils/getToken';
9 | import { updateState, getState, setInitialState } from './state';
10 |
11 | const lock = new Semaphore(1);
12 | let _instance: AxiosInstance;
13 |
14 | const requestInterceptor = async (config : InternalAxiosRequestConfig) => {
15 | const { options } = getState();
16 | const token = await getToken(lock);
17 | const { access_token } = token;
18 | const { header, formatter } = options;
19 | (config.headers as AxiosHeaders)[header] = formatter(access_token);
20 | return config;
21 | };
22 |
23 | const successInterceptor = (response: AxiosResponse) => {
24 | const { inRecovery } = getState();
25 | if (inRecovery) {
26 | updateState({
27 | recoveryTries: 0,
28 | inRecovery: false
29 | });
30 | }
31 | return response;
32 | };
33 |
34 | const errorInterceptor = async (error: AxiosError) => {
35 | const needsToRecover = shouldRecover(error);
36 |
37 | if (needsToRecover) {
38 | let token : Token;
39 | await lock.acquire();
40 | const { cache } = getState();
41 |
42 | if (isTokenValid(cache)) {
43 | token = cache.token as Token;
44 | lock.release();
45 | } else {
46 | try {
47 | token = await getFreshToken();
48 | }
49 | catch (error) {
50 | return Promise.reject(error);
51 | }
52 | finally {
53 | lock.release();
54 | }
55 | }
56 | const { options } = getState();
57 | const { response } = error;
58 | const { config } = response as AxiosResponse;
59 | const { access_token } = token;
60 | const { header, formatter, onRecoveryTry, addTokenToLogs } = options;
61 | (config.headers)[header] = formatter(access_token);
62 | const message = addTokenToLogs ? `Using token: ${access_token}.` : '';
63 | onRecoveryTry(message);
64 | return _instance(config);
65 | } else {
66 | return Promise.reject(error);
67 | }
68 | };
69 |
70 | const tokenManager = (settings: Settings) => {
71 | const { instance, getCredentials, ...rest} = settings;
72 | _instance = instance;
73 | const options = {...defaultSettings, ...rest };
74 | setInitialState(options, getCredentials);
75 | instance.interceptors.request.use(requestInterceptor);
76 | instance.interceptors.response.use(successInterceptor, errorInterceptor);
77 | };
78 |
79 | export default tokenManager;
80 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import { AxiosInstance } from 'axios';
2 |
3 | export interface Cache {
4 | token: Token | null;
5 | expiration: number;
6 | }
7 |
8 | export interface Settings extends DefaultSettings {
9 | instance: AxiosInstance;
10 | getCredentials: TokenProvider;
11 | }
12 |
13 | export type LogFunction = (message?: string) => void;
14 |
15 | type DefaultSettings = Partial;
16 |
17 | export interface Config {
18 | refreshBuffer: number;
19 | header: string;
20 | formatter: Formatter;
21 | onTokenRefresh: LogFunction;
22 | onAuthFail: LogFunction;
23 | onTokenRequestFail: VoidFunction;
24 | refreshOnStatus: number [];
25 | tokenTryThreshold: number;
26 | onTokenTryThreshold: (retries: number) => void;
27 | onRecoveryTry: LogFunction;
28 | maxRecoveryTries: number;
29 | onRecoveryAbort: VoidFunction;
30 | addTokenToLogs: boolean;
31 | }
32 |
33 | export interface Token {
34 | access_token: string;
35 | token_type?: string;
36 | expires_in: number;
37 | scope?: string;
38 | }
39 |
40 | export interface State {
41 | cache: Cache;
42 | options: Config;
43 | tokenTries: number;
44 | recoveryTries: number;
45 | inRecovery: boolean;
46 | getCredentials: TokenProvider;
47 | }
48 |
49 | export type TokenProvider = () => Promise;
50 |
51 | export type Formatter = (accessToken: string) => string;
--------------------------------------------------------------------------------
/src/utils/getFreshToken.ts:
--------------------------------------------------------------------------------
1 | import { Token } from '../types';
2 | import { getState, updateState } from '../state';
3 | import { setPreFetchTimer } from './setPreFetchTimer';
4 |
5 | export const getFreshToken = async () : Promise => {
6 | const {options, getCredentials} = getState();
7 |
8 | try {
9 | const token = await getCredentials();
10 | const { access_token, expires_in } = token;
11 | const { onTokenRefresh, addTokenToLogs } = options;
12 | const expiration = Date.now() + expires_in * 1000;
13 | setPreFetchTimer(token);
14 |
15 | updateState({
16 | cache: { token, expiration },
17 | tokenTries: 0
18 | });
19 |
20 | const message = addTokenToLogs ? ` New token: ${access_token}.` : '';
21 | onTokenRefresh(message);
22 | return Promise.resolve(token);
23 | }
24 | catch (error) {
25 | const { tokenTries } = getState();
26 | updateState({ tokenTries: tokenTries + 1 });
27 | const { onTokenRequestFail, tokenTryThreshold, onTokenTryThreshold } = options;
28 | onTokenRequestFail();
29 | if (tokenTries % tokenTryThreshold === 0) {
30 | onTokenTryThreshold(tokenTries);
31 | }
32 | return Promise.reject(error);
33 | }
34 | };
--------------------------------------------------------------------------------
/src/utils/getToken.ts:
--------------------------------------------------------------------------------
1 | import { Token } from '../types';
2 | import Semaphore from 'semaphore-async-await';
3 | import { getState } from '../state';
4 | import { isTokenValid } from './isTokenValid';
5 | import { getFreshToken } from './getFreshToken';
6 |
7 | export const getToken = async (lock : Semaphore) => {
8 | const { cache } = getState();
9 | if (isTokenValid(cache)) {
10 | const { token } = cache;
11 | return Promise.resolve(token as Token);
12 | }
13 |
14 | await lock.acquire();
15 |
16 | // check if previous request updated token while this request waited
17 | const { cache: currentCache } = getState();
18 | if (isTokenValid(currentCache)) {
19 | lock.release();
20 | const { token } = currentCache;
21 | return Promise.resolve(token as Token);
22 | }
23 |
24 | try {
25 | const credentials = await getFreshToken();
26 | return Promise.resolve(credentials);
27 | }
28 | catch (error) {
29 | return Promise.reject(error);
30 | }
31 | finally {
32 | lock.release();
33 | }
34 | };
35 |
--------------------------------------------------------------------------------
/src/utils/initialValues.ts:
--------------------------------------------------------------------------------
1 | import { Cache, Config } from '../types';
2 | import { noop } from './noop';
3 |
4 | export const initCache: Cache = {
5 | token: null,
6 | expiration: 0
7 | };
8 |
9 | export const defaultSettings: Config = {
10 | refreshBuffer: 10,
11 | header: 'Authorization',
12 | formatter: (access_token) => `Bearer ${access_token}`,
13 | onTokenRefresh: noop,
14 | onAuthFail: noop,
15 | onTokenRequestFail: noop,
16 | refreshOnStatus: [401],
17 | tokenTryThreshold: 10,
18 | onTokenTryThreshold: noop,
19 | onRecoveryTry: noop,
20 | maxRecoveryTries: 5,
21 | onRecoveryAbort: noop,
22 | addTokenToLogs: false
23 | };
--------------------------------------------------------------------------------
/src/utils/isTokenValid.ts:
--------------------------------------------------------------------------------
1 | import { Cache } from '../types';
2 |
3 | export const isTokenValid = (cache: Cache) => {
4 | const { token, expiration } = cache;
5 | if (!token) {
6 | return false;
7 | }
8 | return expiration > Date.now();
9 | };
10 |
--------------------------------------------------------------------------------
/src/utils/noop.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line @typescript-eslint/no-empty-function
2 | export const noop = () => {};
3 |
--------------------------------------------------------------------------------
/src/utils/preFetchToken.ts:
--------------------------------------------------------------------------------
1 | import { Token } from '../types';
2 | import { getState } from '../state';
3 | import { isTokenValid } from './isTokenValid';
4 | import { getFreshToken } from './getFreshToken';
5 |
6 | const isSameToken = (accessToken: string) => {
7 | const { cache } = getState();
8 | if (isTokenValid(cache)) {
9 | const { token } = cache;
10 | const { access_token } = token as Token;
11 | if (access_token === accessToken) {
12 | return true;
13 | }
14 | }
15 | return false;
16 | };
17 |
18 | export const preFetchToken = async (accessToken: string) => {
19 | if (isSameToken(accessToken)) {
20 | try {
21 | await getFreshToken();
22 | }
23 | catch (error) {
24 | return Promise.reject(error);
25 | }
26 | }
27 | };
28 |
--------------------------------------------------------------------------------
/src/utils/setPreFetchTimer.ts:
--------------------------------------------------------------------------------
1 | import { Token } from '../types';
2 | import { preFetchToken } from './preFetchToken';
3 | import { getState } from '../state';
4 |
5 | export const setPreFetchTimer = (token : Token) => {
6 | const { options: { refreshBuffer } } = getState();
7 | const { access_token, expires_in } = token;
8 | const refreshPeriod = (expires_in - refreshBuffer) * 1000;
9 | setTimeout(preFetchToken, refreshPeriod, access_token);
10 | };
11 |
--------------------------------------------------------------------------------
/src/utils/shouldRecover.ts:
--------------------------------------------------------------------------------
1 | import { AxiosError } from 'axios';
2 | import { getState, updateState } from '../state';
3 | import { Token } from '../types';
4 | import { initCache } from './initialValues';
5 |
6 | export const shouldRecover = (error: AxiosError) => {
7 | const { options, cache : { token }, recoveryTries } = getState();
8 | const { response } = error;
9 | if (!response) {
10 | return false;
11 | }
12 | const { status } = response;
13 | const { refreshOnStatus, maxRecoveryTries, onAuthFail, onRecoveryAbort, addTokenToLogs } = options;
14 | const authFailed = refreshOnStatus.includes(status as number);
15 |
16 | if (authFailed) {
17 | const { access_token } = token as Token;
18 | const message = addTokenToLogs ? `Used token: ${access_token}` : '';
19 | onAuthFail(message);
20 |
21 | if (recoveryTries < maxRecoveryTries) {
22 | updateState({
23 | inRecovery: true,
24 | recoveryTries: recoveryTries + 1,
25 | cache: initCache
26 | });
27 | return true;
28 | }
29 | else {
30 | updateState({
31 | inRecovery: false,
32 | recoveryTries: 0,
33 | cache: initCache
34 | });
35 | onRecoveryAbort();
36 | }
37 | }
38 |
39 | return false;
40 | };
41 |
--------------------------------------------------------------------------------
/tsconfig.es5.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Visit https://aka.ms/tsconfig to read more about this file */
4 |
5 | /* Projects */
6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
12 |
13 | /* Language and Environment */
14 | "target": "ES5", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
15 | "lib": ["ES2020", "DOM"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
16 | // "jsx": "preserve", /* Specify what JSX code is generated. */
17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
26 |
27 | /* Modules */
28 | "module": "CommonJS", /* Specify what module code is generated. */
29 | // "rootDir": "./", /* Specify the root folder within your source files. */
30 | "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */
36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
38 | // "resolveJsonModule": true, /* Enable importing .json files. */
39 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */
40 |
41 | /* JavaScript Support */
42 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
43 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
44 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
45 |
46 | /* Emit */
47 | "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
48 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */
49 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
50 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */
51 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
52 | "outDir": "./dist/lib/cjs", /* Specify an output folder for all emitted files. */
53 | // "removeComments": true, /* Disable emitting comments. */
54 | // "noEmit": true, /* Disable emitting files from a compilation. */
55 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
56 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
57 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
58 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
60 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
61 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
62 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
63 | // "newLine": "crlf", /* Set the newline character for emitting files. */
64 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
65 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
66 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
67 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
68 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */
69 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
70 |
71 | /* Interop Constraints */
72 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
73 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
74 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
75 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
76 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
77 |
78 | /* Type Checking */
79 | "strict": true, /* Enable all strict type-checking options. */
80 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
81 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
82 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
83 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
84 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
85 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
86 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
87 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
88 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
89 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
90 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
91 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
92 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
93 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
94 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
95 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
96 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
97 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
98 |
99 | /* Completeness */
100 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
101 | "skipLibCheck": true /* Skip type checking all .d.ts files. */
102 | },
103 | "include": ["src/**/*"],
104 | "exclude": ["node_modules/", "**/tests/**", "coverage/", "dist/"]
105 | }
106 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Visit https://aka.ms/tsconfig to read more about this file */
4 |
5 | /* Projects */
6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
12 |
13 | /* Language and Environment */
14 | "target": "ES5", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
15 | "lib": ["ES2020", "DOM"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
16 | // "jsx": "preserve", /* Specify what JSX code is generated. */
17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
26 |
27 | /* Modules */
28 | "module": "ES6", /* Specify what module code is generated. */
29 | // "rootDir": "./", /* Specify the root folder within your source files. */
30 | "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */
36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
38 | // "resolveJsonModule": true, /* Enable importing .json files. */
39 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */
40 |
41 | /* JavaScript Support */
42 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
43 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
44 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
45 |
46 | /* Emit */
47 | "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
48 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */
49 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
50 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */
51 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
52 | "outDir": "./dist/lib/esm", /* Specify an output folder for all emitted files. */
53 | // "removeComments": true, /* Disable emitting comments. */
54 | // "noEmit": true, /* Disable emitting files from a compilation. */
55 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
56 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
57 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
58 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
60 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
61 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
62 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
63 | // "newLine": "crlf", /* Set the newline character for emitting files. */
64 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
65 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
66 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
67 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
68 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */
69 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
70 |
71 | /* Interop Constraints */
72 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
73 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
74 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
75 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
76 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
77 |
78 | /* Type Checking */
79 | "strict": true, /* Enable all strict type-checking options. */
80 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
81 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
82 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
83 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
84 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
85 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
86 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
87 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
88 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
89 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
90 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
91 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
92 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
93 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
94 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
95 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
96 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
97 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
98 |
99 | /* Completeness */
100 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
101 | "skipLibCheck": true /* Skip type checking all .d.ts files. */
102 | },
103 | "include": ["src/**/*"],
104 | "exclude": ["node_modules/", "**/tests/**", "coverage/", "dist/"]
105 | }
106 |
--------------------------------------------------------------------------------