From 2b57eac4b47f6dada1b615d036157ecce5146425 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 9 Apr 2024 13:30:44 +0200 Subject: [PATCH] core-app-api: add startCookieAuthRefresh + test Signed-off-by: Patrik Oldsberg --- .../startCookieAuthRefresh.test.ts | 173 ++++++++++++++++++ .../IdentityApi/startCookieAuthRefresh.ts | 157 ++++++++++++++++ 2 files changed, 330 insertions(+) create mode 100644 packages/core-app-api/src/apis/implementations/IdentityApi/startCookieAuthRefresh.test.ts create mode 100644 packages/core-app-api/src/apis/implementations/IdentityApi/startCookieAuthRefresh.ts diff --git a/packages/core-app-api/src/apis/implementations/IdentityApi/startCookieAuthRefresh.test.ts b/packages/core-app-api/src/apis/implementations/IdentityApi/startCookieAuthRefresh.test.ts new file mode 100644 index 0000000000..e2751788db --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/IdentityApi/startCookieAuthRefresh.test.ts @@ -0,0 +1,173 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { withLogCollector } from '@backstage/test-utils'; +import { startCookieAuthRefresh } from './startCookieAuthRefresh'; + +describe('startCookieAuthRefresh', () => { + const discoveryApiMock = { + getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7000/app/api'), + }; + + const tenMinutesInMilliseconds = 10 * 60 * 1000; + + const fetchApiMock = { + fetch: jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockImplementation(async () => ({ + expiresAt: new Date( + Date.now() + tenMinutesInMilliseconds, + ).toISOString(), + })), + }), + }; + + const errorApiMock = { + post: jest.fn(), + error$: jest.fn(), + }; + + const mockOptions = { + errorApi: errorApiMock, + fetchApi: fetchApiMock, + discoveryApi: discoveryApiMock, + }; + + beforeEach(() => { + jest.useFakeTimers({ now: 0 }); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should refresh cookie', async () => { + const postMessage = jest.fn(); + global.BroadcastChannel = jest.fn().mockImplementation(() => ({ + postMessage, + addEventListener() {}, + removeEventListener() {}, + close() {}, + })); + + const stop = startCookieAuthRefresh(mockOptions); + + await jest.advanceTimersByTimeAsync(0); + + expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1); + expect(fetchApiMock.fetch).toHaveBeenCalledWith( + 'http://localhost:7000/app/api/.backstage/auth/v1/cookie', + { credentials: 'include' }, + ); + + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage).toHaveBeenCalledWith({ + action: 'COOKIE_REFRESH_SUCCESS', + payload: { expiresAt: new Date(tenMinutesInMilliseconds).toISOString() }, + }); + + // Should never refresh within the first 5 minutes + await jest.advanceTimersByTimeAsync(tenMinutesInMilliseconds / 2); + + expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1); + expect(postMessage).toHaveBeenCalledTimes(1); + + // Should always refresh within the next 5 + await jest.advanceTimersByTimeAsync(tenMinutesInMilliseconds / 2); + + expect(fetchApiMock.fetch).toHaveBeenCalledTimes(2); + expect(postMessage).toHaveBeenCalledTimes(2); + + stop(); + }); + + it('should bump refresh when receiving a broadcast message', async () => { + let messageListener: undefined | ((params: any) => void) = undefined; + global.BroadcastChannel = jest.fn().mockImplementation(() => ({ + postMessage() {}, + addEventListener: jest.fn().mockImplementation((type, listener) => { + expect(type).toBe('message'); + messageListener = listener; + }), + removeEventListener() {}, + close() {}, + })); + const stop = startCookieAuthRefresh(mockOptions); + + await jest.advanceTimersByTimeAsync(0); + + expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1); + expect(fetchApiMock.fetch).toHaveBeenCalledWith( + 'http://localhost:7000/app/api/.backstage/auth/v1/cookie', + { credentials: 'include' }, + ); + + messageListener!({ + data: { + action: 'COOKIE_REFRESH_SUCCESS', + payload: { + expiresAt: new Date(tenMinutesInMilliseconds * 2).toISOString(), + }, + }, + }); + + // Usually the refresh would happen after 10 minutes, but now we need to wait 20 instead + await jest.advanceTimersByTimeAsync(tenMinutesInMilliseconds); + expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(tenMinutesInMilliseconds); + expect(fetchApiMock.fetch).toHaveBeenCalledTimes(2); + + stop(); + }); + + it('should ignore first error, but post the second one', async () => { + fetchApiMock.fetch.mockRejectedValueOnce(new Error('Failed to get cookie')); + + const { error } = await withLogCollector(['error'], async () => { + const stop = startCookieAuthRefresh(mockOptions); + + await 'a tick'; + + expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1); + expect(fetchApiMock.fetch).toHaveBeenCalledWith( + 'http://localhost:7000/app/api/.backstage/auth/v1/cookie', + { credentials: 'include' }, + ); + expect(errorApiMock.post).toHaveBeenCalledTimes(0); + + fetchApiMock.fetch.mockRejectedValueOnce( + new Error('Failed to get cookie again'), + ); + + // Backoff time for first error + await jest.advanceTimersByTimeAsync(5000); + + expect(fetchApiMock.fetch).toHaveBeenCalledTimes(2); + expect(errorApiMock.post).toHaveBeenCalledTimes(1); + expect(errorApiMock.post).toHaveBeenCalledWith( + new Error('Session refresh failed, see developer console for details'), + ); + + stop(); + }); + + expect(error).toEqual([ + 'Session cookie refresh failed: Failed to get cookie again', + ]); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/IdentityApi/startCookieAuthRefresh.ts b/packages/core-app-api/src/apis/implementations/IdentityApi/startCookieAuthRefresh.ts new file mode 100644 index 0000000000..ce639890c0 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/IdentityApi/startCookieAuthRefresh.ts @@ -0,0 +1,157 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DiscoveryApi, ErrorApi, FetchApi } from '@backstage/core-plugin-api'; + +const PLUGIN_ID = 'app'; +const CHANNEL_ID = `${PLUGIN_ID}-auth-cookie-expires-at`; + +const MIN_BASE_DELAY_MS = 5 * 60_000; + +const ERROR_BACKOFF_START = 5_000; +const ERROR_BACKOFF_FACTOR = 2; +const ERROR_BACKOFF_MAX = 5 * 60_000; + +// Messaging implementation and IDs must match +// plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx +export function startCookieAuthRefresh({ + discoveryApi, + fetchApi, + errorApi, +}: { + discoveryApi: DiscoveryApi; + fetchApi: FetchApi; + errorApi: ErrorApi; +}) { + let stopped = false; + let timeout: NodeJS.Timeout | undefined; + let firstError = true; + let errorBackoff = ERROR_BACKOFF_START; + + const channel = + 'BroadcastChannel' in window ? new BroadcastChannel(CHANNEL_ID) : undefined; + + // Randomize the refreshing margin with a margin of 1-4 minutes to avoid all tabs refreshing at the same time + const getDelay = (expiresAt: number) => { + const margin = (1 + 3 * Math.random()) * 60000; + const delay = Math.max(expiresAt - Date.now(), MIN_BASE_DELAY_MS) - margin; + return delay; + }; + + const refresh = async () => { + try { + const baseUrl = await discoveryApi.getBaseUrl(PLUGIN_ID); + const requestUrl = `${baseUrl}/.backstage/auth/v1/cookie`; + const res = await fetchApi.fetch(`${requestUrl}`, { + credentials: 'include', + }); + + if (!res.ok) { + throw new Error( + `Request failed with status ${res.status} ${res.statusText}, see request towards ${requestUrl} for more details`, + ); + } + + firstError = true; + + const data = await res.json(); + if (!data.expiresAt) { + throw new Error('No expiration date in response'); + } + + const expiresAt = Date.parse(data.expiresAt); + if (Number.isNaN(expiresAt)) { + throw new Error('Invalid expiration date in response'); + } + + channel?.postMessage({ + action: 'COOKIE_REFRESH_SUCCESS', + payload: { expiresAt: new Date(expiresAt).toISOString() }, + }); + + scheduleRefresh(getDelay(expiresAt)); + } catch (error) { + // Ignore the first error after successful requests + if (firstError) { + firstError = false; + errorBackoff = ERROR_BACKOFF_START; + } else { + errorBackoff = Math.min( + ERROR_BACKOFF_MAX, + errorBackoff * ERROR_BACKOFF_FACTOR, + ); + // eslint-disable-next-line no-console + console.error(`Session cookie refresh failed: ${error.message}`); + errorApi.post( + new Error( + `Session refresh failed, see developer console for details`, + ), + ); + } + + scheduleRefresh(errorBackoff); + } + }; + + const onMessage = ( + event: MessageEvent< + | { + action: 'COOKIE_REFRESH_SUCCESS'; + payload: { expiresAt: string }; + } + | object + >, + ) => { + const { data } = event; + if (data === null || typeof data !== 'object') { + return; + } + if ('action' in data && data.action === 'COOKIE_REFRESH_SUCCESS') { + const expiresAt = Date.parse(data.payload.expiresAt); + if (Number.isNaN(expiresAt)) { + // eslint-disable-next-line no-console + console.warn( + 'Received invalid expiration from session refresh channel', + ); + return; + } + + scheduleRefresh(getDelay(expiresAt)); + } + }; + + function scheduleRefresh(delayMs: number) { + if (stopped) { + return; + } + if (timeout) { + clearTimeout(timeout); + } + timeout = setTimeout(refresh, delayMs); + } + + channel?.addEventListener('message', onMessage); + refresh(); + + return () => { + stopped = true; + if (timeout) { + clearTimeout(timeout); + } + channel?.removeEventListener('message', onMessage); + channel?.close(); + }; +}