diff --git a/package.json b/package.json index bf3ba3f1b..dc58feeab 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,8 @@ "scripts": { "start": "./node_modules/webpack-dev-server/bin/webpack-dev-server.js --progress --host 0.0.0.0 --port ${WEB_PORT} --env API_URL=${API_URL} --env NODE_ENV=${NODE_ENV} --env RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY} --env GA_ACCOUNT_ID=${GA_ACCOUNT_ID} --env HOTJAR_ID=${HOTJAR_ID} --env ERRBIT_URL=${ERRBIT_URL} --env ERRBIT_KEY=${ERRBIT_KEY} --env LOGIN_REDIRECT_URL=${LOGIN_REDIRECT_URL} --env OIDC_RP_CLIENT_ID=${OIDC_RP_CLIENT_ID} --env OIDC_SERVER_URL=${OIDC_SERVER_URL} --env OIDC_REALM=${OIDC_REALM} --env ANALYTICS_API=${ANALYTICS_API} --env AI_ASSISTANT_API_URL=${AI_ASSISTANT_API_URL} --mode ${NODE_ENV} --hot", "build": "node --max-old-space-size=1536 ./node_modules/webpack/bin/webpack.js --progress --env API_URL=${API_URL} --env NODE_ENV=${NODE_ENV} --env RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY} --env GA_ACCOUNT_ID=${GA_ACCOUNT_ID} --env HOTJAR_ID=${HOTJAR_ID} --env ERRBIT_URL=${ERRBIT_URL} --env ERRBIT_KEY=${ERRBIT_KEY} --env LOGIN_REDIRECT_URL=${LOGIN_REDIRECT_URL} --env OIDC_RP_CLIENT_ID=${OIDC_RP_CLIENT_ID} --env OIDC_SERVER_URL=${OIDC_SERVER_URL} --env OIDC_REALM=${OIDC_REALM} --env ANALYTICS_API=${ANALYTICS_API} --env AI_ASSISTANT_API_URL=${AI_ASSISTANT_API_URL} --mode ${NODE_ENV}", - "eslint": "./node_modules/.bin/eslint ./src --ext .jsx,.js" + "eslint": "./node_modules/.bin/eslint ./src --ext .jsx,.js", + "test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test 'src/**/__tests__/**/*.test.js'" }, "devDependencies": { "@babel/core": "^7.29.7", diff --git a/src/common/__tests__/ssoSession.test.js b/src/common/__tests__/ssoSession.test.js new file mode 100644 index 000000000..a92a520b7 --- /dev/null +++ b/src/common/__tests__/ssoSession.test.js @@ -0,0 +1,266 @@ +/* global globalThis */ +import test from 'node:test' +import assert from 'node:assert/strict' + +const listeners = {} +const appended = [] +const makeIframe = () => { + const iframe = { + style: {}, contentWindow: {}, removed: false, + setAttribute() {}, remove() { this.removed = true }, + } + return iframe +} +globalThis.window = { + location: { origin: 'http://localhost:4004' }, + OIDC_SERVER_URL: 'https://sso.example.org/auth/', + OIDC_REALM: 'ocl', + OIDC_RP_CLIENT_ID: 'mapper', + addEventListener: (type, fn) => { listeners[type] = fn }, + removeEventListener: type => { delete listeners[type] }, +} +globalThis.document = { + createElement: () => makeIframe(), + body: { appendChild: el => appended.push(el) }, +} + +const { + isSSOSessionEnabled, buildSilentAuthURL, silentSignIn, ensureIdToken, + buildSSOLogoutURL, prepareSignOut, takeSigninToResume, settleForcedSignOut, renewSessionSilently, + FORCED_SIGN_OUT_WINDOW_MS, +} = await import('../ssoSession.js') + +const reset = () => { appended.length = 0 } +const answer = (data, overrides = {}) => listeners.message({ + origin: 'http://localhost:4004', source: appended[0].contentWindow, data: { type: 'ocl-silent-sso', ...data }, ...overrides, +}) +const stateOf = () => new URL(appended[0].src).searchParams.get('state') +const tick = async () => { + for(let i = 0; i < 200 && !appended.length; i++) + await new Promise(resolve => setTimeout(resolve, 5)) +} + +test('is enabled only when server, realm and client id are set', () => { + assert.equal(isSSOSessionEnabled(), true) + const realm = window.OIDC_REALM + window.OIDC_REALM = '' + assert.equal(isSSOSessionEnabled(), false) + window.OIDC_REALM = realm +}) + +test('builds a prompt=none PKCE auth URL against Keycloak', () => { + const url = new URL(buildSilentAuthURL({ + serverURL: 'https://sso.example.org/auth', realm: 'ocl', clientId: 'mapper', + redirectURI: 'http://localhost:4004/silent-sso.html', state: 's', nonce: 'n', codeChallenge: 'c', + })) + assert.equal(url.pathname, '/auth/realms/ocl/protocol/openid-connect/auth') + assert.equal(url.searchParams.get('prompt'), 'none') + assert.equal(url.searchParams.get('code_challenge_method'), 'S256') + assert.equal(url.searchParams.get('response_type'), 'code') +}) + +test('exchanges the code and removes the iframe when Keycloak has a session', async () => { + reset() + let sent + const promise = silentSignIn(async body => { sent = body; return { access_token: 't', id_token: 'i' } }) + await tick() + answer({ code: 'code-1', state: stateOf() }) + const tokens = await promise + assert.equal(tokens.access_token, 't') + assert.equal(sent.code, 'code-1') + assert.equal(sent.redirect_uri, 'http://localhost:4004/silent-sso.html') + assert.equal(sent.client_id, 'mapper') + assert.ok(sent.code_verifier.length >= 43) + assert.equal(appended[0].removed, true) +}) + +test('resolves null on login_required without exchanging', async () => { + reset() + let called = false + const promise = silentSignIn(async () => { called = true }) + await tick() + answer({ error: 'login_required', state: stateOf() }) + assert.equal(await promise, null) + assert.equal(called, false) +}) + +test('ignores messages from another origin, another frame or another state', async () => { + reset() + const promise = silentSignIn(async () => ({ access_token: 't' }), 100) + await tick() + answer({ code: 'x', state: stateOf() }, { origin: 'https://evil.example' }) + answer({ code: 'x', state: stateOf() }, { source: {} }) + answer({ code: 'x', state: 'other' }) + assert.equal(await promise, null) +}) + +test('resolves null on timeout and removes the iframe', async () => { + reset() + assert.equal(await silentSignIn(async () => ({ access_token: 't' }), 30), null) + assert.equal(appended[0].removed, true) +}) + +test('resolves null when the exchange fails', async () => { + reset() + const promise = silentSignIn(async () => { throw new Error('boom') }) + await tick() + answer({ code: 'code-1', state: stateOf() }) + assert.equal(await promise, null) +}) + +test('does nothing when disabled', async () => { + reset() + window.OIDC_SERVER_URL = '' + assert.equal(await silentSignIn(async () => ({})), null) + assert.equal(appended.length, 0) +}) + +test('resolves null instead of throwing when crypto is unavailable', async () => { + reset() + window.OIDC_SERVER_URL = 'https://sso.example.org/auth' + const original = Object.getOwnPropertyDescriptor(globalThis, 'crypto') + Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true }) + try { + assert.equal(await silentSignIn(async () => ({ access_token: 't' })), null) + } finally { + Object.defineProperty(globalThis, 'crypto', original) + } +}) + +test('ensureIdToken fetches a fresh id token only when one is missing', async () => { + reset() + window.OIDC_SERVER_URL = 'https://sso.example.org/auth' + const store = {} + globalThis.localStorage = { getItem: key => store[key] || null, setItem: (key, value) => { store[key] = value } } + const exchange = async () => ({ access_token: 't', id_token: 'fresh' }) + + store.id_token = 'existing' + await ensureIdToken(exchange) + assert.equal(appended.length, 0) + assert.equal(store.id_token, 'existing') + + delete store.id_token + const promise = ensureIdToken(exchange) + await tick() + answer({ code: 'code-1', state: stateOf() }) + await promise + assert.equal(store.id_token, 'fresh') +}) + +test('ensureIdToken leaves storage alone when there is no Keycloak session or SSO is off', async () => { + reset() + const store = {} + globalThis.localStorage = { getItem: key => store[key] || null, setItem: (key, value) => { store[key] = value } } + const promise = ensureIdToken(async () => ({ access_token: 't', id_token: 'fresh' })) + await tick() + answer({ error: 'login_required', state: stateOf() }) + await promise + assert.equal(store.id_token, undefined) + + reset() + window.OIDC_SERVER_URL = '' + await ensureIdToken(async () => ({})) + assert.equal(appended.length, 0) +}) + +test('buildSSOLogoutURL sends the whole post-logout address, encoded', () => { + const url = new URL(buildSSOLogoutURL('https://api.example.org', 'id-token', 'http://localhost:4004/#/signin?returnTo=x')) + assert.equal(url.pathname, '/users/logout/') + assert.equal(url.hash, '') + assert.equal(url.searchParams.get('id_token_hint'), 'id-token') + assert.equal(url.searchParams.get('post_logout_redirect_uri'), 'http://localhost:4004/#/signin?returnTo=x') + assert.equal(buildSSOLogoutURL('https://api.example.org', '', 'http://localhost:4004'), undefined) + assert.equal(buildSSOLogoutURL('https://api.example.org', 'id-token', ''), undefined) +}) + +const useSessionStorage = () => { + const store = {} + globalThis.sessionStorage = { + getItem: key => store[key] ?? null, setItem: (key, value) => { store[key] = value }, removeItem: key => { delete store[key] }, + } + return store +} +const signOut = overrides => prepareSignOut({ + forced: true, apiURL: 'https://api.example.org', idToken: 'id-token', loginRedirectURL: 'http://localhost:4004', + returnTo: 'http://localhost:4004/map-projects/1/?tab=2', now: 1000, ...overrides, +}) +const home = { pathname: '/', search: '' } + +test('a forced sign-out returns to LOGIN_REDIRECT_URL and signs back in to the page once', () => { + useSessionStorage() + const { logoutURL, signInAgain } = signOut() + const url = new URL(logoutURL) + assert.equal(url.searchParams.get('post_logout_redirect_uri'), 'http://localhost:4004') + assert.equal(url.hash, '') + assert.equal(signInAgain, true) + + assert.equal(takeSigninToResume(home, 2000), 'http://localhost:4004/map-projects/1/?tab=2') + assert.equal(takeSigninToResume(home, 2000), null) +}) + +test('a normal sign-out returns to LOGIN_REDIRECT_URL and drops any forced sign-out', () => { + useSessionStorage() + signOut() + const { logoutURL, signInAgain } = signOut({ forced: false }) + assert.equal(new URL(logoutURL).searchParams.get('post_logout_redirect_uri'), 'http://localhost:4004') + assert.equal(signInAgain, false) + assert.equal(takeSigninToResume(home, 2000), null) +}) + +test('without an id token there is no logout URL and nothing to resume on load', () => { + useSessionStorage() + const { logoutURL, signInAgain } = signOut({ idToken: undefined }) + assert.equal(logoutURL, undefined) + assert.equal(signInAgain, true) + assert.equal(takeSigninToResume(home, 2000), null) +}) + +test('a second forced sign-out soon after the first does not sign in again', () => { + useSessionStorage() + signOut({ now: 1000 }) + assert.ok(takeSigninToResume(home, 2000)) + const { signInAgain } = signOut({ now: 1000 + FORCED_SIGN_OUT_WINDOW_MS - 1 }) + assert.equal(signInAgain, false) + assert.equal(takeSigninToResume(home, 1000 + FORCED_SIGN_OUT_WINDOW_MS), null) + + assert.equal(signOut({ now: 1000 + 3 * FORCED_SIGN_OUT_WINDOW_MS }).signInAgain, true) +}) + +test('the page to resume expires and is dropped', () => { + useSessionStorage() + signOut({ now: 1000 }) + assert.equal(takeSigninToResume(home, 1000 + FORCED_SIGN_OUT_WINDOW_MS), null) + assert.equal(takeSigninToResume(home, 2000), null) +}) + +test('the sign-in callback is left alone and settles the forced sign-out', () => { + useSessionStorage() + signOut() + assert.equal(takeSigninToResume({ pathname: '/oidc/login/', search: '?code=code-1&state=s' }, 2000), null) + assert.equal(takeSigninToResume({ pathname: '/', search: '?code=code-1&state=s' }, 2000), null) + settleForcedSignOut() + assert.equal(takeSigninToResume(home, 2000), null) +}) + +test('unavailable storage never throws', () => { + globalThis.sessionStorage = { getItem() { throw new Error('denied') }, setItem() { throw new Error('denied') }, removeItem() { throw new Error('denied') } } + assert.ok(signOut().logoutURL) + assert.equal(takeSigninToResume(home, 2000), null) + settleForcedSignOut() +}) + +test('renewSessionSilently tries at most once a window', async () => { + reset() + useSessionStorage() + window.OIDC_SERVER_URL = 'https://sso.example.org/auth' + const exchange = async () => ({ access_token: 't', id_token: 'i' }) + + const promise = renewSessionSilently(exchange, 1000) + await tick() + answer({ code: 'code-1', state: stateOf() }) + assert.equal((await promise).access_token, 't') + + reset() + assert.equal(await renewSessionSilently(exchange, 1000 + FORCED_SIGN_OUT_WINDOW_MS - 1), null) + assert.equal(appended.length, 0) +}) diff --git a/src/common/ssoSession.js b/src/common/ssoSession.js index dea869fd5..a27f577e2 100644 --- a/src/common/ssoSession.js +++ b/src/common/ssoSession.js @@ -139,3 +139,72 @@ export const ensureIdToken = async exchangeCode => { if(tokens?.id_token) localStorage.setItem('id_token', tokens.id_token) } + +export const buildSSOLogoutURL = (apiURL, idToken, redirectURL) => { + if(idToken && redirectURL) + return `${apiURL}/users/logout/?id_token_hint=${encodeURIComponent(idToken)}&post_logout_redirect_uri=${encodeURIComponent(redirectURL)}` +} + +// Keycloak's logout only returns to an address registered for the client (LOGIN_REDIRECT_URL), so after a +// forced sign-out the page to sign back in to waits here, in this tab, until the app loads again. The record +// outlives the sign-in for a while, so a token the API keeps turning down stops after one retry, not a loop. +const FORCED_SIGN_OUT_KEY = 'forced_sign_out' +const SILENT_RENEWAL_KEY = 'silent_renewal_at' +export const FORCED_SIGN_OUT_WINDOW_MS = 10 * 60 * 1000 + +const readSession = key => { + try { + return JSON.parse(sessionStorage.getItem(key)) + } catch { + return null + } +} + +const writeSession = (key, value) => { + try { + if(value === null) + sessionStorage.removeItem(key) + else + sessionStorage.setItem(key, JSON.stringify(value)) + return true + } catch { + return false + } +} + +const isRecent = (at, now) => Number.isFinite(at) && now - at < FORCED_SIGN_OUT_WINDOW_MS + +export const prepareSignOut = ({ forced, apiURL, idToken, loginRedirectURL, returnTo, now = Date.now() }) => { + const logoutURL = buildSSOLogoutURL(apiURL, idToken, loginRedirectURL) + if(!forced) { + writeSession(FORCED_SIGN_OUT_KEY, null) + return { logoutURL, signInAgain: false } + } + const signInAgain = !isRecent(readSession(FORCED_SIGN_OUT_KEY)?.at, now) + writeSession(FORCED_SIGN_OUT_KEY, { returnTo, at: now, resume: Boolean(logoutURL) && signInAgain }) + return { logoutURL, signInAgain } +} + +const isSignInCallback = ({ pathname, search }) => /^\/oidc\/login(\/|$)/i.test(pathname) || /[?&]code=/.test(search) + +export const takeSigninToResume = (location, now = Date.now()) => { + const record = readSession(FORCED_SIGN_OUT_KEY) + if(!record?.resume || isSignInCallback(location)) + return null + writeSession(FORCED_SIGN_OUT_KEY, { ...record, resume: false }) + return isRecent(record.at, now) && record.returnTo ? record.returnTo : null +} + +export const settleForcedSignOut = () => { + const record = readSession(FORCED_SIGN_OUT_KEY) + if(record?.resume) + writeSession(FORCED_SIGN_OUT_KEY, { ...record, resume: false }) +} + +// A token the API turns down may only have expired while the Keycloak session lives on: renew it without a +// prompt, at most once a window, so a token the API keeps rejecting can't reload the page forever. +export const renewSessionSilently = async (exchangeCode, now = Date.now()) => { + if(isRecent(readSession(SILENT_RENEWAL_KEY), now) || !writeSession(SILENT_RENEWAL_KEY, now)) + return null + return silentSignIn(exchangeCode) +} diff --git a/src/common/utils.js b/src/common/utils.js index 8cfaf1ff5..fef78d58a 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -15,7 +15,7 @@ import { } from './constants'; import APIService from '../services/APIService'; import GAService from '../services/GAService'; -import { ensureIdToken } from './ssoSession'; +import { ensureIdToken, prepareSignOut, settleForcedSignOut, renewSessionSilently } from './ssoSession'; import { SERVER_CONFIGS } from './serverConfigs'; import { legacyHashRoute, isSameSitePath } from './history'; @@ -301,12 +301,13 @@ export const arrayToCSV = objArray => { }, str); } -export const refreshCurrentUserCache = callback => { +export const refreshCurrentUserCache = (callback, onFailure) => { APIService.user().get(null, null, {includeSubscribedOrgs: true, includeAuthGroups: true, includePins: true, includeFollowing: true}).then(response => { - if(response.status === 200) { + if(response?.status === 200) { localStorage.setItem('user', JSON.stringify(response.data)); if(callback) callback(response); - } + } else if(onFailure) + onFailure(response); }); } @@ -669,7 +670,32 @@ export const getRandomColor = () => `#${Math.floor(Math.random()*16777215).toStr const exchangeSilentCode = body => APIService.users().appendToUrl('oidc/code-exchange/').post(body, false).then(res => res.data); -export const logoutUser = (...args) => ensureIdToken(exchangeSilentCode).then(() => signOutUser(...args)); +// Several requests can be turned down at once; the first one decides, so later ones can't undo its sign-out. +let signingOut = null +export const logoutUser = (...args) => { + signingOut = signingOut || ensureIdToken(exchangeSilentCode).then(() => signOutUser(...args)).catch(error => { + signingOut = null + throw error + }) + return signingOut +} + +// The API turned the token down: renew it silently while the Keycloak session lives on, and sign out only if +// that fails. The reload sends every request again with the new token. +let renewing = null +export const renewOrSignOut = () => { + renewing = renewing || renewSessionSilently(exchangeSilentCode).then(tokens => { + if(!tokens?.id_token) + return logoutUser(true, true) + localStorage.removeItem('server_configs') + localStorage.setItem('token', tokens.access_token) + localStorage.setItem('id_token', tokens.id_token) + settleForcedSignOut() + const reload = () => window.location.reload() + refreshCurrentUserCache(reload, reload) + }) + return renewing +} const signOutUser = (redirectToLogin, forced) => { const clearTokens = () => { @@ -680,17 +706,19 @@ const signOutUser = (redirectToLogin, forced) => { } const returnTo = window.location.origin + window.location.pathname + window.location.search - if(forced) - sessionStorage.setItem('session_expired', 'true') - - const redirectURL = forced ? - window.location.origin + '/#/signin?returnTo=' + encodeURIComponent(returnTo) : - undefined - const logoutURL = getSSOLogoutURL(redirectURL) + const { logoutURL, signInAgain } = prepareSignOut({ + forced, + returnTo, + apiURL: getAPIURL(), + idToken: localStorage.id_token, + loginRedirectURL: window.LOGIN_REDIRECT_URL || process.env.LOGIN_REDIRECT_URL, + }) clearTokens() if(logoutURL) window.location = logoutURL + else if(forced && !signInAgain) + return // turned down again right after signing back in: stay signed out here rather than reload in a loop else if(redirectToLogin) getLoginURL(forced ? returnTo : undefined).then(url => { window.location.href = url }) else @@ -984,14 +1012,6 @@ export const getRegisterURL = async returnTo => { } -export const getSSOLogoutURL = returnTo => { - const redirectURL = returnTo || window.LOGIN_REDIRECT_URL || process.env.LOGIN_REDIRECT_URL - const idToken = localStorage.id_token - if(redirectURL && idToken) - return `${getAPIURL()}/users/logout/?id_token_hint=${idToken}&post_logout_redirect_uri=${redirectURL}` -} - - export const urlSearchParamsToObject = urlSearchParams => { const result = {} for(const [key, value] of urlSearchParams.entries()) { // each 'entry' is a [key, value] tuple diff --git a/src/components/app/App.jsx b/src/components/app/App.jsx index fb0659a2e..5e3f35559 100644 --- a/src/components/app/App.jsx +++ b/src/components/app/App.jsx @@ -5,7 +5,7 @@ import { isLoggedIn, refreshCurrentUserCache, getCurrentUser, getLoginURL, isOtherOCLClientURL, isRedirectingToLoginViaReferrer } from '../../common/utils'; import GAService from '../../services/GAService'; -import { silentSignIn, isSSOSessionEnabled } from '../../common/ssoSession'; +import { silentSignIn, isSSOSessionEnabled, settleForcedSignOut } from '../../common/ssoSession'; import { REPO_ROUTE_PATHS, getRepoKey } from '../../common/repoRoute'; import Error404 from '../errors/Error404'; import Error403 from '../errors/Error403'; @@ -154,7 +154,7 @@ const App = props => { localStorage.removeItem('server_configs') localStorage.setItem('token', tokens.access_token) localStorage.setItem('id_token', tokens.id_token) - sessionStorage.removeItem('session_expired') + settleForcedSignOut() refreshCurrentUserCache(() => setAuthVersion(version => version + 1)) }).finally(() => { silentCheckRunning.current = false }) } diff --git a/src/components/users/OIDLoginCallback.jsx b/src/components/users/OIDLoginCallback.jsx index 980a4a26e..7c042ec1e 100644 --- a/src/components/users/OIDLoginCallback.jsx +++ b/src/components/users/OIDLoginCallback.jsx @@ -7,6 +7,7 @@ import { isSignupOAuthState, isLoggedIn, getLoginURL } from '../../common/utils'; import { navigate } from '../../common/history'; +import { settleForcedSignOut } from '../../common/ssoSession'; import APIService from '../../services/APIService' import GAService from '../../services/GAService' import { OperationsContext } from '../app/LayoutContext'; @@ -51,7 +52,7 @@ class OIDLoginCallback extends React.Component { localStorage.removeItem('server_configs') localStorage.setItem('token', res.data.access_token) localStorage.setItem('id_token', res.data.id_token) - sessionStorage.removeItem('session_expired') + settleForcedSignOut() setAlert({ duration: 2000, severity: 'success', diff --git a/src/index.jsx b/src/index.jsx index 638135a91..6381dfb71 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -11,6 +11,8 @@ import './index.scss'; import { COLORS } from './common/colors'; import './i18n/config'; import { setAppHistory, handleLinkClick, keepLinkClickBubbling, legacyHashRoute, redirectLegacyHashRoute } from './common/history'; +import { takeSigninToResume } from './common/ssoSession'; +import { isLoggedIn, getLoginURL } from './common/utils'; const theme = createTheme(); const v5Theme = createTheme(theme, { @@ -135,9 +137,7 @@ window.addEventListener('click', keepLinkClickBubbling, true) document.addEventListener('click', handleLinkClick) window.addEventListener('hashchange', redirectLegacyHashRoute) -const container = document.getElementById('root'); -const root = createRoot(container); -root.render( +const renderApp = () => createRoot(document.getElementById('root')).render( @@ -149,3 +149,18 @@ root.render( ); + +// Back from Keycloak's logout after a forced sign-out: sign in again, then return to the page the user was on. +const signinToResume = () => { + try { + return isLoggedIn() ? null : takeSigninToResume(window.location) + } catch { + return null + } +} + +const returnTo = signinToResume() +if(returnTo) + getLoginURL(returnTo).then(url => { window.location.replace(url) }).catch(renderApp) +else + renderApp() diff --git a/src/services/APIService.js b/src/services/APIService.js index aae9881a5..b82b7c0ab 100644 --- a/src/services/APIService.js +++ b/src/services/APIService.js @@ -1,7 +1,7 @@ /*eslint no-process-env: 0*/ import axios from 'axios'; import {get, omit, isPlainObject, isString, defaults } from 'lodash'; -import { currentUserToken, getAPIURL, logoutUser } from '../common/utils'; +import { currentUserToken, getAPIURL, renewOrSignOut } from '../common/utils'; import { OCL_CLIENT_HEADERS } from '../common/constants'; const APIServiceProvider = {}; @@ -83,7 +83,7 @@ class APIService { .then(response => response || null) .catch(error => { if(error?.response?.status === 401) { - logoutUser(true, true) + renewOrSignOut() } else { if(raw) return error;