diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f7e54a34..394421566 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ * `useOkapiKy` uses || instead of ?? to apply current tenant id when override was not provided. Refs STCOR-814. * Correctly parse `.../_self` permissions object. Refs STCOR-813. * Add `idName` and `limit` as passable props to `useChunkedCQLFetch`. Refs STCOR-821. +* Check for valid token before rotating during XHR send. Refs STCOR-817. ## [10.0.0](https://github.com/folio-org/stripes-core/tree/v10.0.0) (2023-10-11) [Full Changelog](https://github.com/folio-org/stripes-core/compare/v9.0.0...v10.0.0) diff --git a/src/components/Root/FXHR.js b/src/components/Root/FXHR.js index c607e2e92..166061eb5 100644 --- a/src/components/Root/FXHR.js +++ b/src/components/Root/FXHR.js @@ -1,5 +1,10 @@ import { okapi } from 'stripes-config'; -import { isFolioApiRequest, rtr } from './token-util'; +import { isFolioApiRequest, rtr, isValidAT, isValidRT } from './token-util'; +import { getTokenExpiry } from '../../loginServices'; +import { + RTR_ERROR_EVENT, +} from './Events'; +import { RTRError } from './Errors'; export default (deps) => { return class FXHRClass extends XMLHttpRequest { @@ -16,15 +21,38 @@ export default (deps) => { } send = async (payload) => { + const { logger } = this.FFetchContext; this.FFetchContext.logger?.log('rtr', 'capture XHR send'); if (this.shouldEnsureToken) { - await rtr(this.FFetchContext) - .then(() => { + if (!isValidAT(this.FFetchContext.tokenExpiration, logger)) { + logger.log('rtr', 'local tokens expired; fetching from storage for XHR..'); + this.FFetchContext.tokenExpiration = await getTokenExpiry(); + } + + if (isValidAT(this.FFetchContext.tokenExpiration, logger)) { + logger.log('rtr', 'local AT valid, sending XHR...'); + super.send(payload); + } else if (isValidRT(this.FFetchContext.tokenExpiration, logger)) { + logger.log('rtr', 'local RT valid, sending XHR...'); + try { + await rtr(this.FFetchContext); + logger.log('rtr', 'local RTtoken refreshed, sending XHR...'); super.send(payload); - }); + } catch (err) { + if (err instanceof RTRError) { + console.error('RTR failure while attempting XHR', err); // eslint-disable-line no-console + document.dispatchEvent(new Event(RTR_ERROR_EVENT, { detail: err })); + } + throw err; + } + } else { + logger.log('rtr', 'All tokens expired when attempting to send XHR'); + document.dispatchEvent(new Event(RTR_ERROR_EVENT, { detail: 'All tokens expired when sending XHR' })); + } } else { + logger.log('rtr', 'request passed through, sending XHR...'); super.send(payload); } - } + }; }; }; diff --git a/src/components/Root/FXHR.test.js b/src/components/Root/FXHR.test.js index 6bee94523..bae938dd5 100644 --- a/src/components/Root/FXHR.test.js +++ b/src/components/Root/FXHR.test.js @@ -1,4 +1,6 @@ import { rtr } from './token-util'; +import { getTokenExpiry } from '../../loginServices'; +import { RTRError } from './Errors'; import FXHR from './FXHR'; jest.mock('./token-util', () => ({ @@ -6,6 +8,12 @@ jest.mock('./token-util', () => ({ rtr: jest.fn(() => new Promise()), })); +jest.mock('../../loginServices', () => ({ + ...(jest.requireActual('../../loginServices')), + setTokenExpiry: jest.fn(() => Promise.resolve()), + getTokenExpiry: jest.fn(() => Promise.resolve()) +})); + const openSpy = jest.spyOn(XMLHttpRequest.prototype, 'open').mockImplementation(); const sendSpy = jest.spyOn(XMLHttpRequest.prototype, 'send').mockImplementation(() => {}); const aelSpy = jest.spyOn(XMLHttpRequest.prototype, 'addEventListener').mockImplementation(); @@ -17,7 +25,7 @@ describe('FXHR', () => { let testXHR; beforeEach(() => { jest.clearAllMocks(); - FakeXHR = FXHR({ logger: { log: () => {} } }); + FakeXHR = FXHR({ tokenExpiration: { atExpires: Date.now(), rtExpires: Date.now() + 5000 }, logger: { log: () => {} } }); testXHR = new FakeXHR(); }); @@ -47,4 +55,50 @@ describe('FXHR', () => { expect(openSpy.mock.calls).toHaveLength(1); expect(aelSpy.mock.calls).toHaveLength(1); }); + + it('Does not rotate if token is valid', () => { + getTokenExpiry.mockResolvedValue({ + atExpires: Date.now() + (10 * 60 * 1000), + rtExpires: Date.now() + (10 * 60 * 1000), + }); + + testXHR.addEventListener('abort', mockHandler); + testXHR.open('POST', 'okapiUrl'); + testXHR.send(new ArrayBuffer(8)); + expect(openSpy.mock.calls).toHaveLength(1); + expect(aelSpy.mock.calls).toHaveLength(1); + expect(rtr.mock.calls).toHaveLength(0); + }); + + it('If AT is invalid, but RT is valid, refresh the token before sending...', async () => { + getTokenExpiry.mockResolvedValue({ + atExpires: Date.now() - (10 * 60 * 1000), + rtExpires: Date.now() + (10 * 60 * 1000), + }); + testXHR.addEventListener('abort', mockHandler); + testXHR.open('POST', 'okapiUrl'); + await testXHR.send(new ArrayBuffer(8)); + expect(openSpy.mock.calls).toHaveLength(1); + expect(aelSpy.mock.calls).toHaveLength(1); + expect(rtr.mock.calls).toHaveLength(1); + }); + + + it('Handles Errors during token rotation', async () => { + rtr.mockRejectedValueOnce(new RTRError('rtr test failure')); + getTokenExpiry.mockResolvedValue({ + atExpires: Date.now() - (10 * 60 * 1000), + rtExpires: Date.now() + (10 * 60 * 1000), + }); + let error = null; + try { + testXHR.addEventListener('abort', mockHandler); + testXHR.open('POST', 'okapiUrl'); + await testXHR.send(new ArrayBuffer(8)); + } catch (err) { + error = err; + } finally { + expect(error instanceof RTRError).toBe(true); + } + }); });