From 19d00fe3720a6c1dfa9f7ba08e0e19b61e062625 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 5 Mar 2024 15:59:24 +0100 Subject: [PATCH 01/19] feat: add allow static cookie auth Signed-off-by: Camila Belo --- plugins/techdocs-backend/src/plugin.ts | 17 ++++++++++++++++- plugins/techdocs-backend/src/service/router.ts | 13 +++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/plugins/techdocs-backend/src/plugin.ts b/plugins/techdocs-backend/src/plugin.ts index 78e0b07e5c..b576d6577c 100644 --- a/plugins/techdocs-backend/src/plugin.ts +++ b/plugins/techdocs-backend/src/plugin.ts @@ -72,8 +72,17 @@ export const techdocsPlugin = createBackendPlugin({ http: coreServices.httpRouter, discovery: coreServices.discovery, cache: coreServices.cache, + httpAuth: coreServices.httpAuth, }, - async init({ config, logger, urlReader, http, discovery, cache }) { + async init({ + config, + logger, + urlReader, + http, + discovery, + cache, + httpAuth, + }) { const winstonLogger = loggerToWinstonLogger(logger); // Preparers are responsible for fetching source files for documentation. const preparers = await Preparers.fromConfig(config, { @@ -114,8 +123,14 @@ export const techdocsPlugin = createBackendPlugin({ publisher, config, discovery, + httpAuth, }), ); + + http.addAuthPolicy({ + path: '/static', + allow: 'user-cookie', + }); }, }); }, diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index 00aea0b0dd..fbba2a0430 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -16,6 +16,7 @@ import { PluginEndpointDiscovery, PluginCacheManager, + createLegacyAuthAdapters, } from '@backstage/backend-common'; import { CatalogClient } from '@backstage/catalog-client'; import { stringifyEntityRef } from '@backstage/catalog-model'; @@ -37,6 +38,7 @@ import { createCacheMiddleware, TechDocsCache } from '../cache'; import { CachedEntityLoader } from './CachedEntityLoader'; import { DefaultDocsBuildStrategy } from './DefaultDocsBuildStrategy'; import * as winston from 'winston'; +import { HttpAuthService } from '@backstage/backend-plugin-api'; /** * Required dependencies for running TechDocs in the "out-of-the-box" @@ -56,6 +58,7 @@ export type OutOfTheBoxDeploymentOptions = { docsBuildStrategy?: DocsBuildStrategy; buildLogTransport?: winston.transport; catalogClient?: CatalogClient; + httpAuth?: HttpAuthService; }; /** @@ -73,6 +76,7 @@ export type RecommendedDeploymentOptions = { docsBuildStrategy?: DocsBuildStrategy; buildLogTransport?: winston.transport; catalogClient?: CatalogClient; + httpAuth?: HttpAuthService; }; /** @@ -106,6 +110,9 @@ export async function createRouter( ): Promise { const router = Router(); const { publisher, config, logger, discovery } = options; + + const { httpAuth } = createLegacyAuthAdapters(options); + const catalogClient = options.catalogClient ?? new CatalogClient({ discoveryApi: discovery }); const docsBuildStrategy = @@ -287,6 +294,12 @@ export async function createRouter( // Route middleware which serves files from the storage set in the publisher. router.use('/static/docs', publisher.docsRouter()); + // Endpoint that sets the cookie for the user + router.get('/cookie', async (req, res) => { + const { expiresAt } = await httpAuth.issueUserCookie(req); + res.json({ expiresAt: expiresAt.toISOString() }); + }); + return router; } From de083201d24051ad536933d2dfc908f2452a48f1 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 5 Mar 2024 17:35:28 +0100 Subject: [PATCH 02/19] feat: implement refresh cookie on frontend Signed-off-by: Camila Belo --- .../techdocs-backend/src/service/router.ts | 4 +- plugins/techdocs-react/src/api.ts | 1 + plugins/techdocs/src/client.ts | 64 +++++++++++++++++ .../TechDocsReaderPage/TechDocsReaderPage.tsx | 71 +++++++++++++------ 4 files changed, 117 insertions(+), 23 deletions(-) diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index fbba2a0430..10388cea28 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -295,8 +295,8 @@ export async function createRouter( router.use('/static/docs', publisher.docsRouter()); // Endpoint that sets the cookie for the user - router.get('/cookie', async (req, res) => { - const { expiresAt } = await httpAuth.issueUserCookie(req); + router.get('/cookie', async (_, res) => { + const { expiresAt } = await httpAuth.issueUserCookie(res); res.json({ expiresAt: expiresAt.toISOString() }); }); diff --git a/plugins/techdocs-react/src/api.ts b/plugins/techdocs-react/src/api.ts index 6d726fae01..a4bb6ccf34 100644 --- a/plugins/techdocs-react/src/api.ts +++ b/plugins/techdocs-react/src/api.ts @@ -24,6 +24,7 @@ import { TechDocsEntityMetadata, TechDocsMetadata } from './types'; * @public */ export interface TechDocsApi { + issueUserCookie(): Promise<{ expiresAt: string }>; getApiOrigin(): Promise; getTechDocsMetadata(entityId: CompoundEntityRef): Promise; getEntityMetadata( diff --git a/plugins/techdocs/src/client.ts b/plugins/techdocs/src/client.ts index 8680e14468..e7b97a2bce 100644 --- a/plugins/techdocs/src/client.ts +++ b/plugins/techdocs/src/client.ts @@ -51,6 +51,70 @@ export class TechDocsClient implements TechDocsApi { this.fetchApi = options.fetchApi; } + private async getCookie(): Promise<{ expiresAt: string }> { + const apiOrigin = await this.getApiOrigin(); + const requestUrl = `${apiOrigin}/cookie`; + const response = await this.fetchApi.fetch(`${requestUrl}`, { + credentials: 'include', + }); + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + return await response.json(); + } + + private async refreshCookie(options: { + expiresAt?: Date | null; + timeoutId: number | null; + }): Promise<{ expiresAt: string }> { + // Always clear the previous timeout + if (options.timeoutId) clearTimeout(options.timeoutId); + + // Only issue a new cookie when we don't have an expiry date or it's in the past + if (!options.expiresAt || options.expiresAt.getTime() < Date.now()) { + return this.getCookie().then(response => { + const expiresAt = new Date(response.expiresAt); + expiresAt.setMinutes(expiresAt.getMinutes() - 5); + localStorage.setItem( + 'backstage-auth-cookie-last-refresh-expiration-date', + expiresAt.toISOString(), + ); + const timeoutId = setTimeout(this.refreshCookie, expiresAt.getTime()); + localStorage.setItem( + 'backstage-auth-cookie-last-refresh-timeout-id', + timeoutId.toString(), + ); + return { expiresAt: expiresAt.toISOString() }; + }); + } + + // Otherwise, set a new timeout to refresh the cookie with the current expiration date + const timeoutId = setTimeout( + this.refreshCookie, + options.expiresAt.getTime(), + ); + localStorage.setItem( + 'backstage-auth-cookie-last-refresh-timeout-id', + timeoutId.toString(), + ); + + return { expiresAt: options.expiresAt.toISOString() }; + } + + async issueUserCookie() { + const expiresAt = localStorage.getItem( + 'backstage-auth-cookie-last-refresh-expiration-date', + ); + const timeoutId = localStorage.getItem( + 'backstage-auth-cookie-last-refresh-timeout-id', + ); + + return this.refreshCookie({ + expiresAt: expiresAt ? new Date(expiresAt) : null, + timeoutId: timeoutId ? Number(timeoutId) : null, + }); + } + async getApiOrigin(): Promise { return await this.discoveryApi.getBaseUrl('techdocs'); } diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx index e81a455cea..dc8803c962 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -14,15 +14,16 @@ * limitations under the License. */ -import React, { ReactNode, Children, ReactElement } from 'react'; +import React, { ReactNode, Children, ReactElement, useState } from 'react'; import { useOutlet } from 'react-router-dom'; -import { Page } from '@backstage/core-components'; +import { ErrorPanel, Page } from '@backstage/core-components'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { TECHDOCS_ADDONS_WRAPPER_KEY, TECHDOCS_ADDONS_KEY, TechDocsReaderPageProvider, + techdocsApiRef, } from '@backstage/plugin-techdocs-react'; import { TechDocsReaderPageRenderFunction } from '../../../types'; @@ -33,8 +34,11 @@ import { TechDocsReaderPageSubheader } from '../TechDocsReaderPageSubheader'; import { rootDocsRouteRef } from '../../../routes'; import { getComponentData, + useApi, + useApp, useRouteRefParams, } from '@backstage/core-plugin-api'; +import { useAsync } from 'react-use'; /* An explanation for the multiple ways of customizing the TechDocs reader page @@ -143,6 +147,27 @@ export const TechDocsReaderLayout = (props: TechDocsReaderLayoutProps) => { ); }; +function TechDocsAuthProvider({ children }: { children: ReactNode }) { + const app = useApp(); + const { Progress } = app.getComponents(); + + const techdocsApi = useApi(techdocsApiRef); + + const { loading, error } = useAsync(async () => { + return await techdocsApi.issueUserCookie(); + }, [techdocsApi]); + + if (error) { + return ; + } + + if (loading) { + return ; + } + + return children; +} + /** * @public */ @@ -177,29 +202,33 @@ export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => { // As explained above, "page" is configuration 4 and is 1 return ( - - {(page as JSX.Element) || } - + + + {(page as JSX.Element) || } + + ); } // As explained above, a render function is configuration 3 and React element is 2 return ( - - {({ metadata, entityMetadata, onReady }) => ( -
- - {children instanceof Function - ? children({ - entityRef, - techdocsMetadataValue: metadata.value, - entityMetadataValue: entityMetadata.value, - onReady, - }) - : children} - -
- )} -
+ + + {({ metadata, entityMetadata, onReady }) => ( +
+ + {children instanceof Function + ? children({ + entityRef, + techdocsMetadataValue: metadata.value, + entityMetadataValue: entityMetadata.value, + onReady, + }) + : children} + +
+ )} +
+
); }; From ec9e1d2103135334ccbbd0fad47b666234fe40e2 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 7 Mar 2024 09:38:10 +0100 Subject: [PATCH 03/19] feat: migrate non cokie endpoints Signed-off-by: Camila Belo --- plugins/techdocs-backend/src/plugin.ts | 3 ++ .../techdocs-backend/src/service/router.ts | 42 ++++++++++++++----- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/plugins/techdocs-backend/src/plugin.ts b/plugins/techdocs-backend/src/plugin.ts index b576d6577c..e78339cfa6 100644 --- a/plugins/techdocs-backend/src/plugin.ts +++ b/plugins/techdocs-backend/src/plugin.ts @@ -73,6 +73,7 @@ export const techdocsPlugin = createBackendPlugin({ discovery: coreServices.discovery, cache: coreServices.cache, httpAuth: coreServices.httpAuth, + auth: coreServices.auth, }, async init({ config, @@ -82,6 +83,7 @@ export const techdocsPlugin = createBackendPlugin({ discovery, cache, httpAuth, + auth, }) { const winstonLogger = loggerToWinstonLogger(logger); // Preparers are responsible for fetching source files for documentation. @@ -124,6 +126,7 @@ export const techdocsPlugin = createBackendPlugin({ config, discovery, httpAuth, + auth, }), ); diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index 10388cea28..e3b1cd5dea 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -38,7 +38,7 @@ import { createCacheMiddleware, TechDocsCache } from '../cache'; import { CachedEntityLoader } from './CachedEntityLoader'; import { DefaultDocsBuildStrategy } from './DefaultDocsBuildStrategy'; import * as winston from 'winston'; -import { HttpAuthService } from '@backstage/backend-plugin-api'; +import { AuthService, HttpAuthService } from '@backstage/backend-plugin-api'; /** * Required dependencies for running TechDocs in the "out-of-the-box" @@ -59,6 +59,7 @@ export type OutOfTheBoxDeploymentOptions = { buildLogTransport?: winston.transport; catalogClient?: CatalogClient; httpAuth?: HttpAuthService; + auth?: AuthService; }; /** @@ -77,6 +78,7 @@ export type RecommendedDeploymentOptions = { buildLogTransport?: winston.transport; catalogClient?: CatalogClient; httpAuth?: HttpAuthService; + auth?: AuthService; }; /** @@ -111,7 +113,7 @@ export async function createRouter( const router = Router(); const { publisher, config, logger, discovery } = options; - const { httpAuth } = createLegacyAuthAdapters(options); + const { auth, httpAuth } = createLegacyAuthAdapters(options); const catalogClient = options.catalogClient ?? new CatalogClient({ discoveryApi: discovery }); @@ -147,7 +149,13 @@ export async function createRouter( router.get('/metadata/techdocs/:namespace/:kind/:name', async (req, res) => { const { kind, namespace, name } = req.params; const entityName = { kind, namespace, name }; - const token = getBearerToken(req.headers.authorization); + + const credentials = await httpAuth.credentials(req); + + const { token } = await auth.getPluginRequestToken({ + onBehalfOf: credentials, + targetPluginId: 'catalog', + }); // Verify that the related entity exists and the current user has permission to view it. const entity = await entityLoader.load(entityName, token); @@ -180,7 +188,13 @@ export async function createRouter( router.get('/metadata/entity/:namespace/:kind/:name', async (req, res) => { const { kind, namespace, name } = req.params; const entityName = { kind, namespace, name }; - const token = getBearerToken(req.headers.authorization); + + const credentials = await httpAuth.credentials(req); + + const { token } = await auth.getPluginRequestToken({ + onBehalfOf: credentials, + targetPluginId: 'catalog', + }); const entity = await entityLoader.load(entityName, token); @@ -212,7 +226,13 @@ export async function createRouter( // If a build is required, responds with a success when finished router.get('/sync/:namespace/:kind/:name', async (req, res) => { const { kind, namespace, name } = req.params; - const token = getBearerToken(req.headers.authorization); + + const credentials = await httpAuth.credentials(req); + + const { token } = await auth.getPluginRequestToken({ + onBehalfOf: credentials, + targetPluginId: 'catalog', + }); const entity = await entityLoader.load({ kind, namespace, name }, token); @@ -271,7 +291,13 @@ export async function createRouter( async (req, _res, next) => { const { kind, namespace, name } = req.params; const entityName = { kind, namespace, name }; - const token = getBearerToken(req.headers.authorization); + + const credentials = await httpAuth.credentials(req); + + const { token } = await auth.getPluginRequestToken({ + onBehalfOf: credentials, + targetPluginId: 'catalog', + }); const entity = await entityLoader.load(entityName, token); @@ -303,10 +329,6 @@ export async function createRouter( return router; } -function getBearerToken(header?: string): string | undefined { - return header?.match(/(?:Bearer)\s+(\S+)/i)?.[1]; -} - /** * Create an event-stream response that emits the events 'log', 'error', and 'finish'. * From ff9906b68d8a27d954b586d07c705436b4c1848a Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 7 Mar 2024 09:56:10 +0100 Subject: [PATCH 04/19] fix: add issue token to dev api Signed-off-by: Camila Belo --- packages/techdocs-cli-embedded-app/src/apis.ts | 4 ++++ .../components/TechDocsReaderPage/TechDocsReaderPage.tsx | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/techdocs-cli-embedded-app/src/apis.ts b/packages/techdocs-cli-embedded-app/src/apis.ts index 7156da902f..805a748c11 100644 --- a/packages/techdocs-cli-embedded-app/src/apis.ts +++ b/packages/techdocs-cli-embedded-app/src/apis.ts @@ -127,6 +127,10 @@ class TechDocsDevApi implements TechDocsApi { this.identityApi = identityApi; } + async issueUserCookie(): Promise<{ expiresAt: string }> { + return { expiresAt: new Date().toISOString() }; + } + async getApiOrigin() { return await this.discoveryApi.getBaseUrl('techdocs'); } diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx index dc8803c962..5b8c94072f 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { ReactNode, Children, ReactElement, useState } from 'react'; +import React, { ReactNode, Children, ReactElement } from 'react'; import { useOutlet } from 'react-router-dom'; import { ErrorPanel, Page } from '@backstage/core-components'; From 54fb704d199cdd5c5a190ebad39fd95a670c1d44 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 7 Mar 2024 13:41:56 +0100 Subject: [PATCH 05/19] test: add issue user cookie mock Signed-off-by: Camila Belo --- plugins/techdocs-addons-test-utils/src/test-utils.tsx | 3 +++ .../TechDocsReaderPage/TechDocsReaderPage.test.tsx | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/plugins/techdocs-addons-test-utils/src/test-utils.tsx b/plugins/techdocs-addons-test-utils/src/test-utils.tsx index 518e469e1e..5502b0950f 100644 --- a/plugins/techdocs-addons-test-utils/src/test-utils.tsx +++ b/plugins/techdocs-addons-test-utils/src/test-utils.tsx @@ -48,6 +48,9 @@ const { renderToStaticMarkup } = const techdocsApi = { getTechDocsMetadata: jest.fn(), getEntityMetadata: jest.fn(), + issueUserCookie: jest + .fn() + .mockReturnValue({ expiresAt: new Date().toISOString() }), }; const techdocsStorageApi = { diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx index f732afd31d..feebb0d9d4 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx @@ -59,10 +59,12 @@ const mockTechDocsMetadata = { const getEntityMetadata = jest.fn(); const getTechDocsMetadata = jest.fn(); +const issueUserCookie = jest.fn(); const techdocsApiMock = { getEntityMetadata, getTechDocsMetadata, + issueUserCookie, }; const techdocsStorageApiMock: jest.Mocked = { @@ -115,6 +117,9 @@ describe('', () => { beforeEach(() => { getEntityMetadata.mockResolvedValue(mockEntityMetadata); getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); + getTechDocsMetadata.mockResolvedValue({ + expiresAt: new Date().toISOString(), + }); }); afterEach(() => { From ddb8c30ec34f72bb9271772a4fe81e8958ec32b6 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 7 Mar 2024 11:51:13 +0100 Subject: [PATCH 06/19] refactor: apply review suggestions Signed-off-by: Camila Belo --- plugins/techdocs/src/client.ts | 73 ++++++++++++++-------------------- 1 file changed, 30 insertions(+), 43 deletions(-) diff --git a/plugins/techdocs/src/client.ts b/plugins/techdocs/src/client.ts index e7b97a2bce..89e1f9a280 100644 --- a/plugins/techdocs/src/client.ts +++ b/plugins/techdocs/src/client.ts @@ -40,6 +40,7 @@ export class TechDocsClient implements TechDocsApi { public configApi: Config; public discoveryApi: DiscoveryApi; private fetchApi: FetchApi; + private cookieRefreshTimeoutId: NodeJS.Timeout | undefined; constructor(options: { configApi: Config; @@ -63,56 +64,42 @@ export class TechDocsClient implements TechDocsApi { return await response.json(); } - private async refreshCookie(options: { - expiresAt?: Date | null; - timeoutId: number | null; - }): Promise<{ expiresAt: string }> { - // Always clear the previous timeout - if (options.timeoutId) clearTimeout(options.timeoutId); + 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; - // Only issue a new cookie when we don't have an expiry date or it's in the past - if (!options.expiresAt || options.expiresAt.getTime() < Date.now()) { + // 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 expiresAt = new Date(response.expiresAt); - expiresAt.setMinutes(expiresAt.getMinutes() - 5); - localStorage.setItem( - 'backstage-auth-cookie-last-refresh-expiration-date', - expiresAt.toISOString(), + 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(), ); - const timeoutId = setTimeout(this.refreshCookie, expiresAt.getTime()); - localStorage.setItem( - 'backstage-auth-cookie-last-refresh-timeout-id', - timeoutId.toString(), - ); - return { expiresAt: expiresAt.toISOString() }; + return { expiresAt: newExpiresAt.toISOString() }; }); } - // Otherwise, set a new timeout to refresh the cookie with the current expiration date - const timeoutId = setTimeout( - this.refreshCookie, - options.expiresAt.getTime(), - ); - localStorage.setItem( - 'backstage-auth-cookie-last-refresh-timeout-id', - timeoutId.toString(), - ); + // 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: options.expiresAt.toISOString() }; - } - - async issueUserCookie() { - const expiresAt = localStorage.getItem( - 'backstage-auth-cookie-last-refresh-expiration-date', - ); - const timeoutId = localStorage.getItem( - 'backstage-auth-cookie-last-refresh-timeout-id', - ); - - return this.refreshCookie({ - expiresAt: expiresAt ? new Date(expiresAt) : null, - timeoutId: timeoutId ? Number(timeoutId) : null, - }); + return { expiresAt: expiresAt.toISOString() }; } async getApiOrigin(): Promise { From 88226c220a2508eb5b774f923639a6b923603ae2 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 7 Mar 2024 14:03:56 +0100 Subject: [PATCH 07/19] docs: update api reports Signed-off-by: Camila Belo --- plugins/techdocs-react/api-report.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/techdocs-react/api-report.md b/plugins/techdocs-react/api-report.md index 93456b7586..28404a88fc 100644 --- a/plugins/techdocs-react/api-report.md +++ b/plugins/techdocs-react/api-report.md @@ -70,6 +70,10 @@ export interface TechDocsApi { ): Promise; // (undocumented) getTechDocsMetadata(entityId: CompoundEntityRef): Promise; + // (undocumented) + issueUserCookie(): Promise<{ + expiresAt: string; + }>; } // @public From 3f14e9fc5f750cefb3517f900b685f7393256a76 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 7 Mar 2024 18:45:32 +0100 Subject: [PATCH 08/19] docs: add changeset files Signed-off-by: Camila Belo --- .changeset/bright-birds-begin.md | 5 +++++ .changeset/healthy-mirrors-obey.md | 5 +++++ .changeset/mighty-cooks-breathe.md | 5 +++++ .changeset/nice-monkeys-greet.md | 5 +++++ 4 files changed, 20 insertions(+) create mode 100644 .changeset/bright-birds-begin.md create mode 100644 .changeset/healthy-mirrors-obey.md create mode 100644 .changeset/mighty-cooks-breathe.md create mode 100644 .changeset/nice-monkeys-greet.md diff --git a/.changeset/bright-birds-begin.md b/.changeset/bright-birds-begin.md new file mode 100644 index 0000000000..91cfb94d3c --- /dev/null +++ b/.changeset/bright-birds-begin.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs-addons-test-utils': patch +--- + +Mock the new issue user cookie api method. diff --git a/.changeset/healthy-mirrors-obey.md b/.changeset/healthy-mirrors-obey.md new file mode 100644 index 0000000000..23e655e160 --- /dev/null +++ b/.changeset/healthy-mirrors-obey.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs-react': patch +--- + +Create a new api method for issuing user cookie. diff --git a/.changeset/mighty-cooks-breathe.md b/.changeset/mighty-cooks-breathe.md new file mode 100644 index 0000000000..24aa5828ee --- /dev/null +++ b/.changeset/mighty-cooks-breathe.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Implement a client cookie refresh mechanism. diff --git a/.changeset/nice-monkeys-greet.md b/.changeset/nice-monkeys-greet.md new file mode 100644 index 0000000000..cc1a70630e --- /dev/null +++ b/.changeset/nice-monkeys-greet.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs-backend': patch +--- + +Migrate plugin to use the new auth services. From a57f830fe9f1410f677d1d76a1a2de4a8812fac3 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 7 Mar 2024 18:46:06 +0100 Subject: [PATCH 09/19] refactor: use cross tab timeout Signed-off-by: Camila Belo --- .../techdocs-backend/src/service/router.ts | 12 ++- plugins/techdocs/src/client.ts | 91 ++++++++++++------- 2 files changed, 66 insertions(+), 37 deletions(-) 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 { From a846c6fd2a8d259c8b241098e03655eb36852233 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Mon, 11 Mar 2024 15:06:58 +0100 Subject: [PATCH 10/19] fix: local cookie generation on legacy app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Camila Belo Co-authored-by: Fredrik Adelöw --- plugins/techdocs-backend/src/service/router.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index 6db3b8e060..3a2eb4eee6 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -323,12 +323,8 @@ export async function createRouter( router.use('/static/docs', publisher.docsRouter()); // Endpoint that sets the cookie for the user - router.get('/cookie', async (req, res) => { - const credentials = await httpAuth.credentials(req, { - allow: ['user'], - allowLimitedAccess: true, - }); - const { expiresAt } = await httpAuth.issueUserCookie(res, { credentials }); + router.get('/cookie', async (_, res) => { + const { expiresAt } = await httpAuth.issueUserCookie(res); res.json({ expiresAt: expiresAt.toISOString() }); }); From 048b875dbfb187e65b598b0d81378206a436c754 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 12 Mar 2024 08:38:23 +0100 Subject: [PATCH 11/19] refactor: move refresh logic back to TechDocsReaderPage Co-authored-by: Patrik Oldsberg Signed-off-by: Camila Belo --- plugins/techdocs-react/api-report.md | 8 +-- plugins/techdocs-react/src/api.ts | 2 +- plugins/techdocs/src/client.ts | 64 +------------------ .../TechDocsReaderPage/TechDocsReaderPage.tsx | 44 +++++++++++-- 4 files changed, 46 insertions(+), 72 deletions(-) diff --git a/plugins/techdocs-react/api-report.md b/plugins/techdocs-react/api-report.md index 28404a88fc..4feb3b7609 100644 --- a/plugins/techdocs-react/api-report.md +++ b/plugins/techdocs-react/api-report.md @@ -65,15 +65,15 @@ export interface TechDocsApi { // (undocumented) getApiOrigin(): Promise; // (undocumented) + getCookie(): Promise<{ + expiresAt: string; + }>; + // (undocumented) getEntityMetadata( entityId: CompoundEntityRef, ): Promise; // (undocumented) getTechDocsMetadata(entityId: CompoundEntityRef): Promise; - // (undocumented) - issueUserCookie(): Promise<{ - expiresAt: string; - }>; } // @public diff --git a/plugins/techdocs-react/src/api.ts b/plugins/techdocs-react/src/api.ts index a4bb6ccf34..c53481fe31 100644 --- a/plugins/techdocs-react/src/api.ts +++ b/plugins/techdocs-react/src/api.ts @@ -24,7 +24,7 @@ import { TechDocsEntityMetadata, TechDocsMetadata } from './types'; * @public */ export interface TechDocsApi { - issueUserCookie(): Promise<{ expiresAt: string }>; + getCookie(): Promise<{ expiresAt: string }>; getApiOrigin(): Promise; getTechDocsMetadata(entityId: CompoundEntityRef): Promise; getEntityMetadata( diff --git a/plugins/techdocs/src/client.ts b/plugins/techdocs/src/client.ts index 4b960454c2..ae81ecdaaa 100644 --- a/plugins/techdocs/src/client.ts +++ b/plugins/techdocs/src/client.ts @@ -31,8 +31,6 @@ 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`. * @@ -42,10 +40,6 @@ export class TechDocsClient implements TechDocsApi { public configApi: Config; public discoveryApi: DiscoveryApi; private fetchApi: FetchApi; - private locked = false; - private readonly channel = new BroadcastChannel( - 'backstage-techdocs-cookie-refresh', - ); constructor(options: { configApi: Config; @@ -55,23 +49,9 @@ 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 }> { + public async getCookie(): Promise<{ expiresAt: string }> { const apiOrigin = await this.getApiOrigin(); const requestUrl = `${apiOrigin}/cookie`; const response = await this.fetchApi.fetch(`${requestUrl}`, { @@ -83,48 +63,6 @@ export class TechDocsClient implements TechDocsApi { return await response.json(); } - 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; - - 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 }); - }); - } - return this.refreshUserCookie({ expiresAt: this.expiresAt }); - } - async getApiOrigin(): Promise { return await this.discoveryApi.getBaseUrl('techdocs'); } diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx index 5b8c94072f..197bf15de7 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -14,7 +14,14 @@ * limitations under the License. */ -import React, { ReactNode, Children, ReactElement } from 'react'; +import React, { + ReactNode, + Children, + ReactElement, + useEffect, + useState, + useCallback, +} from 'react'; import { useOutlet } from 'react-router-dom'; import { ErrorPanel, Page } from '@backstage/core-components'; @@ -38,7 +45,7 @@ import { useApp, useRouteRefParams, } from '@backstage/core-plugin-api'; -import { useAsync } from 'react-use'; +import { useAsyncRetry } from 'react-use'; /* An explanation for the multiple ways of customizing the TechDocs reader page @@ -151,12 +158,41 @@ function TechDocsAuthProvider({ children }: { children: ReactNode }) { const app = useApp(); const { Progress } = app.getComponents(); + const [channel] = useState(new BroadcastChannel('techdocs-cookie-refresh')); + const techdocsApi = useApi(techdocsApiRef); - const { loading, error } = useAsync(async () => { - return await techdocsApi.issueUserCookie(); + const { loading, error, value, retry } = useAsyncRetry(async () => { + return await techdocsApi.getCookie(); }, [techdocsApi]); + const startCookieRefresh = useCallback( + (expiresAt: string) => { + // Randomize the refreshing margin to avoid all tabs refreshing at the same time + const refreshingMargin = (1 + 3 * Math.random()) * 60_000; + const delay = Date.parse(expiresAt) - Date.now() - refreshingMargin; + const timeout = setTimeout(retry, delay); + return () => clearTimeout(timeout); + }, + [retry], + ); + + useEffect(() => { + if (!value) return () => {}; + channel.postMessage({ action: 'COOKIE_REFRESHED', payload: value }); + let stopCookieRefresh = startCookieRefresh(value.expiresAt); + channel.onmessage = event => { + const { action, payload } = event.data; + if (action === 'COOKIE_REFRESHED') { + stopCookieRefresh(); + stopCookieRefresh = startCookieRefresh(payload.expiresAt); + } + }; + return () => { + stopCookieRefresh(); + }; + }, [value, channel, startCookieRefresh]); + if (error) { return ; } From 43bf40c8123d239f997633b03abbd2cca8d08646 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 12 Mar 2024 08:39:54 +0100 Subject: [PATCH 12/19] fix: techdocs cookie mocking date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Signed-off-by: Camila Belo --- .changeset/healthy-mirrors-obey.md | 2 +- packages/techdocs-cli-embedded-app/src/apis.ts | 5 +++-- plugins/techdocs-addons-test-utils/src/test-utils.tsx | 7 ++++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.changeset/healthy-mirrors-obey.md b/.changeset/healthy-mirrors-obey.md index 23e655e160..f0a52f83e6 100644 --- a/.changeset/healthy-mirrors-obey.md +++ b/.changeset/healthy-mirrors-obey.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-techdocs-react': patch +'@backstage/plugin-techdocs-react': minor --- Create a new api method for issuing user cookie. diff --git a/packages/techdocs-cli-embedded-app/src/apis.ts b/packages/techdocs-cli-embedded-app/src/apis.ts index 805a748c11..d48bee569d 100644 --- a/packages/techdocs-cli-embedded-app/src/apis.ts +++ b/packages/techdocs-cli-embedded-app/src/apis.ts @@ -127,8 +127,9 @@ class TechDocsDevApi implements TechDocsApi { this.identityApi = identityApi; } - async issueUserCookie(): Promise<{ expiresAt: string }> { - return { expiresAt: new Date().toISOString() }; + async getCookie(): Promise<{ expiresAt: string }> { + const tenMinutesFromNow = new Date(Date.now() + 10 * 60 * 1000); + return { expiresAt: tenMinutesFromNow.toISOString() }; } async getApiOrigin() { diff --git a/plugins/techdocs-addons-test-utils/src/test-utils.tsx b/plugins/techdocs-addons-test-utils/src/test-utils.tsx index 5502b0950f..f26dfea6fd 100644 --- a/plugins/techdocs-addons-test-utils/src/test-utils.tsx +++ b/plugins/techdocs-addons-test-utils/src/test-utils.tsx @@ -48,9 +48,10 @@ const { renderToStaticMarkup } = const techdocsApi = { getTechDocsMetadata: jest.fn(), getEntityMetadata: jest.fn(), - issueUserCookie: jest - .fn() - .mockReturnValue({ expiresAt: new Date().toISOString() }), + getCookie: jest.fn().mockReturnValue({ + // Expires in 10 minutes + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), + }), }; const techdocsStorageApi = { From ed1c0d203928f978acf6d3bd71ffe870ccaa1f08 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 12 Mar 2024 09:40:32 +0100 Subject: [PATCH 13/19] test: mock broadcast channel Signed-off-by: Camila Belo --- .../src/setupTests.ts | 25 +++++++++++++++++++ .../TechDocsReaderPage.test.tsx | 4 +-- .../TechDocsReaderPage/TechDocsReaderPage.tsx | 4 ++- plugins/techdocs/src/setupTests.ts | 25 +++++++++++++++++++ 4 files changed, 55 insertions(+), 3 deletions(-) diff --git a/plugins/techdocs-module-addons-contrib/src/setupTests.ts b/plugins/techdocs-module-addons-contrib/src/setupTests.ts index 56b9b18321..228079bc19 100644 --- a/plugins/techdocs-module-addons-contrib/src/setupTests.ts +++ b/plugins/techdocs-module-addons-contrib/src/setupTests.ts @@ -16,3 +16,28 @@ import '@testing-library/jest-dom'; Element.prototype.scrollIntoView = jest.fn(); + +type Listener = (event: { data: any }) => void; + +global.BroadcastChannel = jest + .fn() + .mockImplementation((_channelName: string) => { + let listeners: Listener[] = []; + return { + postMessage: jest.fn((message: any) => { + // Simulate message event for all listeners + listeners.forEach(listener => listener({ data: message })); + }), + addEventListener: jest.fn((event: string, listener: Listener) => { + if (event === 'message') { + listeners.push(listener); + } + }), + removeEventListener: jest.fn((event: string, listener: Listener) => { + if (event === 'message') { + listeners = listeners.filter(l => l !== listener); + } + }), + close: jest.fn(), + }; + }); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx index feebb0d9d4..8a8261cd42 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx @@ -59,12 +59,12 @@ const mockTechDocsMetadata = { const getEntityMetadata = jest.fn(); const getTechDocsMetadata = jest.fn(); -const issueUserCookie = jest.fn(); +const getCookie = jest.fn(); const techdocsApiMock = { getEntityMetadata, getTechDocsMetadata, - issueUserCookie, + getCookie, }; const techdocsStorageApiMock: jest.Mocked = { diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx index 197bf15de7..5259a09bac 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -181,15 +181,17 @@ function TechDocsAuthProvider({ children }: { children: ReactNode }) { if (!value) return () => {}; channel.postMessage({ action: 'COOKIE_REFRESHED', payload: value }); let stopCookieRefresh = startCookieRefresh(value.expiresAt); - channel.onmessage = event => { + const handleMessage = (event: MessageEvent): void => { const { action, payload } = event.data; if (action === 'COOKIE_REFRESHED') { stopCookieRefresh(); stopCookieRefresh = startCookieRefresh(payload.expiresAt); } }; + channel.addEventListener('message', handleMessage); return () => { stopCookieRefresh(); + channel.removeEventListener('message', handleMessage); }; }, [value, channel, startCookieRefresh]); diff --git a/plugins/techdocs/src/setupTests.ts b/plugins/techdocs/src/setupTests.ts index 6c7fc2d3e3..0bdcc73ccb 100644 --- a/plugins/techdocs/src/setupTests.ts +++ b/plugins/techdocs/src/setupTests.ts @@ -17,3 +17,28 @@ import '@testing-library/jest-dom'; Element.prototype.scrollIntoView = jest.fn(); + +type Listener = (event: { data: any }) => void; + +global.BroadcastChannel = jest + .fn() + .mockImplementation((_channelName: string) => { + let listeners: Listener[] = []; + return { + postMessage: jest.fn((message: any) => { + // Simulate message event for all listeners + listeners.forEach(listener => listener({ data: message })); + }), + addEventListener: jest.fn((event: string, listener: Listener) => { + if (event === 'message') { + listeners.push(listener); + } + }), + removeEventListener: jest.fn((event: string, listener: Listener) => { + if (event === 'message') { + listeners = listeners.filter(l => l !== listener); + } + }), + close: jest.fn(), + }; + }); From cd4afcf931fd08318c496ec25addc7d13e3fc22e Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 12 Mar 2024 09:41:05 +0100 Subject: [PATCH 14/19] feat: add retry button on the error panel Signed-off-by: Camila Belo --- .../components/TechDocsReaderPage/TechDocsReaderPage.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx index 5259a09bac..d38b547ec9 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -46,6 +46,7 @@ import { useRouteRefParams, } from '@backstage/core-plugin-api'; import { useAsyncRetry } from 'react-use'; +import { Button } from '@material-ui/core'; /* An explanation for the multiple ways of customizing the TechDocs reader page @@ -196,7 +197,13 @@ function TechDocsAuthProvider({ children }: { children: ReactNode }) { }, [value, channel, startCookieRefresh]); if (error) { - return ; + return ( + + + + ); } if (loading) { From 125d635b1369117f02d67d6eb4aedf9f5e9159fb Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 12 Mar 2024 10:15:58 +0100 Subject: [PATCH 15/19] fix: use async retry import Signed-off-by: Camila Belo --- .../components/TechDocsReaderPage/TechDocsReaderPage.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx index d38b547ec9..23b376b626 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -45,7 +45,7 @@ import { useApp, useRouteRefParams, } from '@backstage/core-plugin-api'; -import { useAsyncRetry } from 'react-use'; +import useAsyncRetry from 'react-use/lib/useAsyncRetry'; import { Button } from '@material-ui/core'; /* An explanation for the multiple ways of customizing the TechDocs reader page @@ -182,7 +182,9 @@ function TechDocsAuthProvider({ children }: { children: ReactNode }) { if (!value) return () => {}; channel.postMessage({ action: 'COOKIE_REFRESHED', payload: value }); let stopCookieRefresh = startCookieRefresh(value.expiresAt); - const handleMessage = (event: MessageEvent): void => { + const handleMessage = ( + event: MessageEvent<{ action: string; payload: { expiresAt: string } }>, + ): void => { const { action, payload } = event.data; if (action === 'COOKIE_REFRESHED') { stopCookieRefresh(); From 07b79f44aa19e5dab53695dc1e89b1c609c072f5 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 12 Mar 2024 10:39:10 +0100 Subject: [PATCH 16/19] refactor: extract techdocs auth to a new file Signed-off-by: Camila Belo --- .../TechDocsAuthProvider.tsx | 101 ++++++++++++++++++ .../TechDocsReaderPage/TechDocsReaderPage.tsx | 77 +------------ 2 files changed, 104 insertions(+), 74 deletions(-) create mode 100644 plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsAuthProvider.tsx diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsAuthProvider.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsAuthProvider.tsx new file mode 100644 index 0000000000..dcd8324169 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsAuthProvider.tsx @@ -0,0 +1,101 @@ +/* + * 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 React, { ReactNode, useEffect, useState, useCallback } from 'react'; +import { ErrorPanel } from '@backstage/core-components'; +import { techdocsApiRef } from '@backstage/plugin-techdocs-react'; +import { useApi, useApp } from '@backstage/core-plugin-api'; +import useAsyncRetry from 'react-use/lib/useAsyncRetry'; +import { Button } from '@material-ui/core'; + +type TechDocsRefreshCookieMessage = MessageEvent<{ + action: string; + payload: { + expiresAt: string; + }; +}>; + +function useTechDocsCookie() { + const techdocsApi = useApi(techdocsApiRef); + + const { retry, ...state } = useAsyncRetry(async () => { + return await techdocsApi.getCookie(); + }, [techdocsApi]); + + const refresh = useCallback( + (expiresAt: string) => { + // Randomize the refreshing margin to avoid all tabs refreshing at the same time + const refreshingMargin = (1 + 3 * Math.random()) * 60000; + const delay = Date.parse(expiresAt) - Date.now() - refreshingMargin; + const timeout = setTimeout(retry, delay); + return () => clearTimeout(timeout); + }, + [retry], + ); + + return { ...state, retry, refresh }; +} + +export function TechDocsAuthProvider({ children }: { children: ReactNode }) { + const app = useApp(); + const { Progress } = app.getComponents(); + + const [channel] = useState(new BroadcastChannel('techdocs-cookie-refresh')); + + const { loading, error, value, retry, refresh } = useTechDocsCookie(); + + useEffect(() => { + if (!value) return () => {}; + + channel.postMessage({ + action: 'TECHDOCS_COOKIE_REFRESHED', + payload: value, + }); + + let cancel = refresh(value.expiresAt); + + const handleMessage = (event: TechDocsRefreshCookieMessage): void => { + const { action, payload } = event.data; + if (action === 'TECHDOCS_COOKIE_REFRESHED') { + cancel(); + cancel = refresh(payload.expiresAt); + } + }; + + channel.addEventListener('message', handleMessage); + + return () => { + cancel(); + channel.removeEventListener('message', handleMessage); + }; + }, [value, refresh, channel]); + + if (error) { + return ( + + + + ); + } + + if (loading) { + return ; + } + + return children; +} diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx index 23b376b626..d7f3843ebb 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -14,23 +14,15 @@ * limitations under the License. */ -import React, { - ReactNode, - Children, - ReactElement, - useEffect, - useState, - useCallback, -} from 'react'; +import React, { ReactNode, Children, ReactElement } from 'react'; import { useOutlet } from 'react-router-dom'; -import { ErrorPanel, Page } from '@backstage/core-components'; +import { Page } from '@backstage/core-components'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { TECHDOCS_ADDONS_WRAPPER_KEY, TECHDOCS_ADDONS_KEY, TechDocsReaderPageProvider, - techdocsApiRef, } from '@backstage/plugin-techdocs-react'; import { TechDocsReaderPageRenderFunction } from '../../../types'; @@ -41,12 +33,9 @@ import { TechDocsReaderPageSubheader } from '../TechDocsReaderPageSubheader'; import { rootDocsRouteRef } from '../../../routes'; import { getComponentData, - useApi, - useApp, useRouteRefParams, } from '@backstage/core-plugin-api'; -import useAsyncRetry from 'react-use/lib/useAsyncRetry'; -import { Button } from '@material-ui/core'; +import { TechDocsAuthProvider } from './TechDocsAuthProvider'; /* An explanation for the multiple ways of customizing the TechDocs reader page @@ -155,66 +144,6 @@ export const TechDocsReaderLayout = (props: TechDocsReaderLayoutProps) => { ); }; -function TechDocsAuthProvider({ children }: { children: ReactNode }) { - const app = useApp(); - const { Progress } = app.getComponents(); - - const [channel] = useState(new BroadcastChannel('techdocs-cookie-refresh')); - - const techdocsApi = useApi(techdocsApiRef); - - const { loading, error, value, retry } = useAsyncRetry(async () => { - return await techdocsApi.getCookie(); - }, [techdocsApi]); - - const startCookieRefresh = useCallback( - (expiresAt: string) => { - // Randomize the refreshing margin to avoid all tabs refreshing at the same time - const refreshingMargin = (1 + 3 * Math.random()) * 60_000; - const delay = Date.parse(expiresAt) - Date.now() - refreshingMargin; - const timeout = setTimeout(retry, delay); - return () => clearTimeout(timeout); - }, - [retry], - ); - - useEffect(() => { - if (!value) return () => {}; - channel.postMessage({ action: 'COOKIE_REFRESHED', payload: value }); - let stopCookieRefresh = startCookieRefresh(value.expiresAt); - const handleMessage = ( - event: MessageEvent<{ action: string; payload: { expiresAt: string } }>, - ): void => { - const { action, payload } = event.data; - if (action === 'COOKIE_REFRESHED') { - stopCookieRefresh(); - stopCookieRefresh = startCookieRefresh(payload.expiresAt); - } - }; - channel.addEventListener('message', handleMessage); - return () => { - stopCookieRefresh(); - channel.removeEventListener('message', handleMessage); - }; - }, [value, channel, startCookieRefresh]); - - if (error) { - return ( - - - - ); - } - - if (loading) { - return ; - } - - return children; -} - /** * @public */ From c3656eaa025bca8a1d7c8d25c0be7e4fb8b196dd Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 12 Mar 2024 10:39:38 +0100 Subject: [PATCH 17/19] docs: fix techdocs api reports Signed-off-by: Camila Belo --- plugins/techdocs-backend/api-report.md | 6 ++++++ .../TechDocsReaderPage/TechDocsReaderPage.test.tsx | 3 --- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/plugins/techdocs-backend/api-report.md b/plugins/techdocs-backend/api-report.md index 9e9a42c388..ec3c2ada82 100644 --- a/plugins/techdocs-backend/api-report.md +++ b/plugins/techdocs-backend/api-report.md @@ -3,6 +3,7 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { AuthService } from '@backstage/backend-plugin-api'; import { CatalogApi } from '@backstage/catalog-client'; import { CatalogClient } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; @@ -11,6 +12,7 @@ import { DocsBuildStrategy as DocsBuildStrategy_2 } from '@backstage/plugin-tech import { Entity } from '@backstage/catalog-model'; import express from 'express'; import { GeneratorBuilder } from '@backstage/plugin-techdocs-node'; +import { HttpAuthService } from '@backstage/backend-plugin-api'; import { Knex } from 'knex'; import { Logger } from 'winston'; import { Permission } from '@backstage/plugin-permission-common'; @@ -65,6 +67,8 @@ export type OutOfTheBoxDeploymentOptions = { docsBuildStrategy?: DocsBuildStrategy_2; buildLogTransport?: winston.transport; catalogClient?: CatalogClient; + httpAuth?: HttpAuthService; + auth?: AuthService; }; // @public @@ -77,6 +81,8 @@ export type RecommendedDeploymentOptions = { docsBuildStrategy?: DocsBuildStrategy_2; buildLogTransport?: winston.transport; catalogClient?: CatalogClient; + httpAuth?: HttpAuthService; + auth?: AuthService; }; // @public diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx index 8a8261cd42..3add949419 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx @@ -117,9 +117,6 @@ describe('', () => { beforeEach(() => { getEntityMetadata.mockResolvedValue(mockEntityMetadata); getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); - getTechDocsMetadata.mockResolvedValue({ - expiresAt: new Date().toISOString(), - }); }); afterEach(() => { From fa34ff03aed2afd72c2d0fa433c4efb4358705cf Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 12 Mar 2024 11:49:16 +0100 Subject: [PATCH 18/19] fix: broadcast channel state Signed-off-by: Camila Belo --- .../TechDocsAuthProvider.tsx | 4 ++- .../TechDocsReaderPage.test.tsx | 27 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsAuthProvider.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsAuthProvider.tsx index dcd8324169..d248af6d1f 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsAuthProvider.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsAuthProvider.tsx @@ -53,7 +53,9 @@ export function TechDocsAuthProvider({ children }: { children: ReactNode }) { const app = useApp(); const { Progress } = app.getComponents(); - const [channel] = useState(new BroadcastChannel('techdocs-cookie-refresh')); + const [channel] = useState( + () => new BroadcastChannel('techdocs-cookie-refresh'), + ); const { loading, error, value, retry, refresh } = useTechDocsCookie(); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx index 3add949419..605decae12 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx @@ -115,8 +115,35 @@ const mountedRoutes = { describe('', () => { beforeEach(() => { + type Listener = (event: { data: any }) => void; + + global.BroadcastChannel = jest + .fn() + .mockImplementation((_channelName: string) => { + let listeners: Listener[] = []; + return { + postMessage: jest.fn((message: any) => { + listeners.forEach(listener => listener({ data: message })); + }), + addEventListener: jest.fn((event: string, listener: Listener) => { + if (event === 'message') { + listeners.push(listener); + } + }), + removeEventListener: jest.fn((event: string, listener: Listener) => { + if (event === 'message') { + listeners = listeners.filter(l => l !== listener); + } + }), + }; + }); + getEntityMetadata.mockResolvedValue(mockEntityMetadata); getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); + getCookie.mockResolvedValue({ + // Expires in 10 minutes + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), + }); }); afterEach(() => { From e1a5506597807b35b9dd74bc09c2dea3d7a0aba8 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 12 Mar 2024 12:04:34 +0100 Subject: [PATCH 19/19] fix: techdocs api report Signed-off-by: Camila Belo --- plugins/techdocs/api-report.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/techdocs/api-report.md b/plugins/techdocs/api-report.md index f351996654..dd26cf2f98 100644 --- a/plugins/techdocs/api-report.md +++ b/plugins/techdocs/api-report.md @@ -259,6 +259,10 @@ export class TechDocsClient implements TechDocsApi_2 { discoveryApi: DiscoveryApi; // (undocumented) getApiOrigin(): Promise; + // (undocumented) + getCookie(): Promise<{ + expiresAt: string; + }>; getEntityMetadata( entityId: CompoundEntityRef, ): Promise;