diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index e3b1cd5dea..6db3b8e060 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -292,7 +292,9 @@ export async function createRouter( const { kind, namespace, name } = req.params; const entityName = { kind, namespace, name }; - const credentials = await httpAuth.credentials(req); + const credentials = await httpAuth.credentials(req, { + allowLimitedAccess: true, + }); const { token } = await auth.getPluginRequestToken({ onBehalfOf: credentials, @@ -321,8 +323,12 @@ export async function createRouter( router.use('/static/docs', publisher.docsRouter()); // Endpoint that sets the cookie for the user - router.get('/cookie', async (_, res) => { - const { expiresAt } = await httpAuth.issueUserCookie(res); + router.get('/cookie', async (req, res) => { + const credentials = await httpAuth.credentials(req, { + allow: ['user'], + allowLimitedAccess: true, + }); + const { expiresAt } = await httpAuth.issueUserCookie(res, { credentials }); res.json({ expiresAt: expiresAt.toISOString() }); }); diff --git a/plugins/techdocs/src/client.ts b/plugins/techdocs/src/client.ts index 89e1f9a280..4b960454c2 100644 --- a/plugins/techdocs/src/client.ts +++ b/plugins/techdocs/src/client.ts @@ -31,6 +31,8 @@ import { } from '@backstage/plugin-techdocs-react'; import { EventSourcePolyfill } from 'event-source-polyfill'; +const EXPIRES_AT_STORAGE_KEY = 'backstage-techdocs-cookie-expires-at'; + /** * API to talk to `techdocs-backend`. * @@ -40,7 +42,10 @@ export class TechDocsClient implements TechDocsApi { public configApi: Config; public discoveryApi: DiscoveryApi; private fetchApi: FetchApi; - private cookieRefreshTimeoutId: NodeJS.Timeout | undefined; + private locked = false; + private readonly channel = new BroadcastChannel( + 'backstage-techdocs-cookie-refresh', + ); constructor(options: { configApi: Config; @@ -50,6 +55,20 @@ export class TechDocsClient implements TechDocsApi { this.configApi = options.configApi; this.discoveryApi = options.discoveryApi; this.fetchApi = options.fetchApi; + this.channel.onmessage = event => { + const { action, payload } = event.data; + if (action === 'REFRESH_COOKIE') { + this.locked = payload.locked; + } + }; + } + + set expiresAt(value: string) { + localStorage.setItem(EXPIRES_AT_STORAGE_KEY, value); + } + + get expiresAt(): string | null { + return localStorage.getItem(EXPIRES_AT_STORAGE_KEY); } private async getCookie(): Promise<{ expiresAt: string }> { @@ -64,42 +83,46 @@ export class TechDocsClient implements TechDocsApi { return await response.json(); } - public async issueUserCookie(): Promise<{ expiresAt: string }> { - const expiresAtStorageKey = 'backstage-auth-cookie-last-refresh-expires-at'; - const formatedExpiresAt = localStorage.getItem(expiresAtStorageKey); - const expiresAt = formatedExpiresAt ? new Date(formatedExpiresAt) : null; + private refreshUserCookie(options: { expiresAt: string }) { + const { expiresAt } = options; + const tenMinutesInMilliseconds = 10 * 60000; + const intervalId = setInterval(() => { + if (this.locked) return; + if (Date.parse(expiresAt) - Date.now() - tenMinutesInMilliseconds > 0) + return; - // get a new cookie if it doesn't exist or has expired - if ( - // first time issuing a cookie or user deleted the stored expiration date - !expiresAt || - // the application was reloaded and the cookie was not refreshed - expiresAt.getTime() < Date.now() - ) { - return this.getCookie().then(response => { - const newExpiresAt = new Date(response.expiresAt); - // refresh the cookie 5 minutes before it expires - newExpiresAt.setMinutes(newExpiresAt.getMinutes() - 5); - // store the expiration date to keep it even if the application is reloaded - localStorage.setItem(expiresAtStorageKey, newExpiresAt.toISOString()); - // maintain the timeout id per tab instance so we know that there is a timeout running - this.cookieRefreshTimeoutId = setTimeout( - this.issueUserCookie, - newExpiresAt.getTime(), - ); - return { expiresAt: newExpiresAt.toISOString() }; + this.channel.postMessage({ + action: 'REFRESH_COOKIE', + payload: { locked: true }, + }); + + this.issueUserCookie({ force: true }) + .then(() => clearInterval(intervalId)) + .finally(() => { + this.locked = false; + this.channel.postMessage({ + action: 'REFRESH_COOKIE', + payload: { locked: false }, + }); + }); + }, tenMinutesInMilliseconds); + + return { expiresAt }; + } + + public async issueUserCookie( + options: { + force?: boolean; + } = {}, + ): Promise<{ expiresAt: string }> { + const { force } = options; + if (force || !this.expiresAt || Date.parse(this.expiresAt) < Date.now()) { + return this.getCookie().then(({ expiresAt }) => { + this.expiresAt = expiresAt; + return this.refreshUserCookie({ expiresAt }); }); } - - // restart timeout when the cookie is still valid but the application was reloaded - if (!this.cookieRefreshTimeoutId) { - this.cookieRefreshTimeoutId = setTimeout( - this.issueUserCookie, - expiresAt.getTime(), - ); - } - - return { expiresAt: expiresAt.toISOString() }; + return this.refreshUserCookie({ expiresAt: this.expiresAt }); } async getApiOrigin(): Promise {