diff --git a/README.md b/README.md index a86fd1e..2058884 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A JavaScript SDK for Switcher API [![Master CI](https://github.com/switcherapi/switcher-client-js/actions/workflows/master.yml/badge.svg)](https://github.com/switcherapi/switcher-client-js/actions/workflows/master.yml) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=switcherapi_switcher-client-master&metric=alert_status)](https://sonarcloud.io/dashboard?id=switcherapi_switcher-client-master) +[![Known Vulnerabilities](https://snyk.io/test/github/switcherapi/switcher-client-js/badge.svg)](https://snyk.io/test/github/switcherapi/switcher-client-js) [![npm version](https://badge.fury.io/js/switcher-client.svg)](https://badge.fury.io/js/switcher-client) [![install size](https://packagephobia.com/badge?p=switcher-client)](https://packagephobia.com/result?p=switcher-client) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) @@ -145,7 +146,8 @@ Client.buildContext({ silentMode: '5m', // Fallback timeout restrictRelay: true, // Relay restrictions in local mode regexSafe: true, // Prevent reDOS attacks - certPath: './certs/ca.pem' // SSL certificate path + certPath: './certs/ca.pem', // SSL certificate path + autoRefreshToken: true // Automatically refresh API token }); ``` @@ -165,6 +167,7 @@ Client.buildContext({ | `regexMaxBlackList` | number | Max cached regex failures | | `regexMaxTimeLimit` | number | Regex timeout in milliseconds | | `certPath` | string | Path to SSL certificate file | +| `autoRefreshToken` | boolean | Automatically refresh API token | > **Security Note:** `regexSafe` prevents ReDoS attacks. Keep this enabled in production. diff --git a/package.json b/package.json index 26d3f82..e78aab6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "switcher-client", - "version": "4.5.1", + "version": "4.6.0", "description": "Client JS SDK for working with Switcher-API", "main": "./switcher-client.js", "type": "module", @@ -31,12 +31,12 @@ "src/" ], "devDependencies": { - "@typescript-eslint/eslint-plugin": "^8.59.3", - "@typescript-eslint/parser": "^8.59.3", + "@typescript-eslint/eslint-plugin": "^8.59.4", + "@typescript-eslint/parser": "^8.59.4", "c8": "^11.0.0", "chai": "^6.2.2", "env-cmd": "^11.0.0", - "eslint": "^10.3.0", + "eslint": "^10.4.0", "mocha": "^11.7.5", "mocha-sonarqube-reporter": "^1.0.2", "sinon": "^22.0.0" diff --git a/sonar-project.properties b/sonar-project.properties index 5322859..8791d9b 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,7 +1,7 @@ sonar.projectKey=switcherapi_switcher-client-master sonar.projectName=switcher-client-js sonar.organization=switcherapi -sonar.projectVersion=4.5.1 +sonar.projectVersion=4.6.0 sonar.links.homepage=https://github.com/switcherapi/switcher-client-js sonar.javascript.lcov.reportPaths=coverage/lcov.info diff --git a/src/client.d.ts b/src/client.d.ts index d62c34e..51fd9fd 100644 --- a/src/client.d.ts +++ b/src/client.d.ts @@ -265,6 +265,13 @@ export type SwitcherOptions = { * If not set, it will use the default certificate */ certPath?: string; + + /** + * When enabled it will automatically refresh the token before it expires + * + * If not set, it will not refresh the token automatically + */ + autoRefreshToken?: boolean; } /** diff --git a/src/client.js b/src/client.js index 790d63d..75cd708 100644 --- a/src/client.js +++ b/src/client.js @@ -11,7 +11,8 @@ import { DEFAULT_REGEX_MAX_TIME_LIMIT, DEFAULT_FREEZE, DEFAULT_TEST_MODE, - SWITCHER_OPTIONS + DEFAULT_AUTO_REFRESH_TOKEN, + SWITCHER_OPTIONS, } from './lib/constants.js'; import TimedMatch from './lib/utils/timed-match/index.js'; import ExecutionLogger from './lib/utils/executionLogger.js'; @@ -42,7 +43,8 @@ export class Client { snapshotLocation: options?.snapshotLocation, local: util.get(options?.local, DEFAULT_LOCAL), freeze: util.get(options?.freeze, DEFAULT_FREEZE), - logger: util.get(options?.logger, DEFAULT_LOGGER) + logger: util.get(options?.logger, DEFAULT_LOGGER), + autoRefreshToken: util.get(options?.autoRefreshToken, DEFAULT_AUTO_REFRESH_TOKEN), }); // Initialize Auth @@ -69,7 +71,10 @@ export class Client { [SWITCHER_OPTIONS.SNAPSHOT_WATCHER]: (val) => { GlobalOptions.updateOptions({ snapshotWatcher: val }); this.watchSnapshot(); - } + }, + [SWITCHER_OPTIONS.AUTO_REFRESH_TOKEN]: (val) => { + GlobalOptions.updateOptions({ autoRefreshToken: val }); + }, }; for (const [key, handler] of Object.entries(optionsHandler)) { diff --git a/src/lib/constants.js b/src/lib/constants.js index c1805d7..b1be8bc 100644 --- a/src/lib/constants.js +++ b/src/lib/constants.js @@ -3,6 +3,7 @@ export const DEFAULT_LOCAL = false; export const DEFAULT_FREEZE = false; export const DEFAULT_LOGGER = false; export const DEFAULT_TEST_MODE = false; +export const DEFAULT_AUTO_REFRESH_TOKEN = false; export const DEFAULT_REGEX_MAX_BLACKLISTED = 50; export const DEFAULT_REGEX_MAX_TIME_LIMIT = 3000; @@ -16,4 +17,5 @@ export const SWITCHER_OPTIONS = Object.freeze({ REGEX_MAX_BLACK_LIST: 'regexMaxBlackList', REGEX_MAX_TIME_LIMIT: 'regexMaxTimeLimit', CERT_PATH: 'certPath', + AUTO_REFRESH_TOKEN: 'autoRefreshToken', }); \ No newline at end of file diff --git a/src/lib/globals/globalOptions.js b/src/lib/globals/globalOptions.js index e35210a..f47d322 100644 --- a/src/lib/globals/globalOptions.js +++ b/src/lib/globals/globalOptions.js @@ -41,4 +41,8 @@ export class GlobalOptions { static get restrictRelay() { return this.#options.restrictRelay; } + + static get autoRefreshToken() { + return this.#options.autoRefreshToken; + } } diff --git a/src/lib/remoteAuth.js b/src/lib/remoteAuth.js index f1c8176..8c3743e 100644 --- a/src/lib/remoteAuth.js +++ b/src/lib/remoteAuth.js @@ -1,4 +1,5 @@ import { GlobalAuth } from './globals/globalAuth.js'; +import { GlobalOptions } from './globals/globalOptions.js'; import { auth, checkAPIHealth } from './remote.js'; import DateMoment from './utils/datemoment.js'; import * as util from './utils/index.js'; @@ -9,12 +10,33 @@ import * as util from './utils/index.js'; export class Auth { static #context; static #retryOptions; + static #refreshTimer; static init(context) { this.#context = context; GlobalAuth.init(context.url); } + static #scheduleNextAuth() { + const msUntilExpiry = ((GlobalAuth.exp ?? 0) * 1000) - Date.now(); + const refreshAt = Math.max(msUntilExpiry - 5000, 0); // 5s before expiry + + this.#refreshTimer = setTimeout(() => { + console.debug(`Refreshing auth token, ${Math.round(refreshAt / 1000)}s until expiry...`); + this.#authUpdate().then(() => { + this.#scheduleNextAuth(); + }).catch(() => { + this.terminateAutoRefresh(); + }); + }, refreshAt); + } + + static async #authUpdate() { + const response = await auth(this.#context); + GlobalAuth.token = response.token; + GlobalAuth.exp = response.exp; + } + static setRetryOptions(silentMode) { this.#retryOptions = { retryTime: Number.parseInt(silentMode.slice(0, -1)), @@ -22,10 +44,19 @@ export class Auth { }; } + static terminateAutoRefresh() { + if (this.#refreshTimer) { + clearTimeout(this.#refreshTimer); + this.#refreshTimer = undefined; + } + } + static async auth() { - const response = await auth(this.#context); - GlobalAuth.token = response.token; - GlobalAuth.exp = response.exp; + await this.#authUpdate(); + + if (GlobalOptions.autoRefreshToken && !this.#refreshTimer) { + this.#scheduleNextAuth(); + } } static checkHealth() { diff --git a/tests/switcher-remote.test.js b/tests/switcher-remote.test.js index 011d504..cb6964d 100644 --- a/tests/switcher-remote.test.js +++ b/tests/switcher-remote.test.js @@ -4,6 +4,8 @@ import { unwatchFile } from 'node:fs'; import FetchFacade from '../src/lib/utils/fetchFacade.js'; import ExecutionLogger from '../src/lib/utils/executionLogger.js'; +import { Auth } from '../src/lib/remoteAuth.js'; +import { GlobalAuth } from '../src/lib/globals/globalAuth.js'; import { Client } from '../switcher-client.js'; import { given, generateAuth, generateResult, assertReject, assertResolve, generateDetailedResult, sleep } from './helper/utils.js'; @@ -343,4 +345,68 @@ describe('Switcher Remote:', function () { }); + describe('auto refresh token:', function () { + + let fetchStub; + + beforeEach(function() { + fetchStub = stub(FetchFacade, 'fetch'); + }); + + afterEach(function() { + fetchStub.restore(); + Auth.terminateAutoRefresh(); + }); + + it('should refresh the token before it expires in the background', async function () { + this.timeout(5000); + + // given API responses + given(fetchStub, 0, { json: () => generateAuth('[auth_token_1]', 5), status: 200 }); + given(fetchStub, 1, { json: () => generateAuth('[auth_token_2]', 5), status: 200 }); + + // test + Client.buildContext(contextSettings, { autoRefreshToken: true }); + await Client.getSwitcher('FLAG_1').prepare(); + + assert.equal(GlobalAuth.token, '[auth_token_1]'); + await sleep(1000); + assert.equal(GlobalAuth.token, '[auth_token_2]'); + }); + + it('should not refresh the token if autoRefreshToken is false', async function () { + this.timeout(5000); + + // given API responses + given(fetchStub, 0, { json: () => generateAuth('[auth_token_1]', 1), status: 200 }); + given(fetchStub, 1, { json: () => generateAuth('[auth_token_2]', 5), status: 200 }); + + // test + Client.buildContext(contextSettings, { autoRefreshToken: false }); + await Client.getSwitcher('FLAG_1').prepare(); + + assert.equal(GlobalAuth.token, '[auth_token_1]'); + await sleep(1500); + assert.equal(GlobalAuth.token, '[auth_token_1]', 'Token should not have been refreshed'); + }); + + it('should handle token refresh failure gracefully', async function () { + this.timeout(5000); + spy(Auth, 'terminateAutoRefresh'); + + // given API responses + given(fetchStub, 0, { json: () => generateAuth('[auth_token_1]', 1), status: 200 }); + given(fetchStub, 1, { error: 'Network error', status: 500 }); + + // test + Client.buildContext(contextSettings, { autoRefreshToken: true }); + await Client.getSwitcher('FLAG_1').prepare(); + + assert.equal(GlobalAuth.token, '[auth_token_1]'); + await sleep(1500); + assert.equal(GlobalAuth.token, '[auth_token_1]', 'Token should not have been refreshed'); + assert(Auth.terminateAutoRefresh.called, 'terminateAutoRefresh should have been called'); + }); + }); + }); \ No newline at end of file