core-app-api: add startCookieAuthRefresh + test
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
+173
@@ -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();
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user