core-app-api: add startCookieAuthRefresh + test

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-04-09 13:30:44 +02:00
parent 52d948d890
commit 2b57eac4b4
2 changed files with 330 additions and 0 deletions
@@ -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',
]);
});
});
@@ -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();
};
}