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..f0a52f83e6 --- /dev/null +++ b/.changeset/healthy-mirrors-obey.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs-react': minor +--- + +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. diff --git a/packages/techdocs-cli-embedded-app/src/apis.ts b/packages/techdocs-cli-embedded-app/src/apis.ts index 7156da902f..d48bee569d 100644 --- a/packages/techdocs-cli-embedded-app/src/apis.ts +++ b/packages/techdocs-cli-embedded-app/src/apis.ts @@ -127,6 +127,11 @@ class TechDocsDevApi implements TechDocsApi { this.identityApi = identityApi; } + async getCookie(): Promise<{ expiresAt: string }> { + const tenMinutesFromNow = new Date(Date.now() + 10 * 60 * 1000); + return { expiresAt: tenMinutesFromNow.toISOString() }; + } + async getApiOrigin() { return await this.discoveryApi.getBaseUrl('techdocs'); } diff --git a/plugins/techdocs-addons-test-utils/src/test-utils.tsx b/plugins/techdocs-addons-test-utils/src/test-utils.tsx index 518e469e1e..f26dfea6fd 100644 --- a/plugins/techdocs-addons-test-utils/src/test-utils.tsx +++ b/plugins/techdocs-addons-test-utils/src/test-utils.tsx @@ -48,6 +48,10 @@ const { renderToStaticMarkup } = const techdocsApi = { getTechDocsMetadata: jest.fn(), getEntityMetadata: jest.fn(), + getCookie: jest.fn().mockReturnValue({ + // Expires in 10 minutes + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), + }), }; const techdocsStorageApi = { 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-backend/src/plugin.ts b/plugins/techdocs-backend/src/plugin.ts index 78e0b07e5c..e78339cfa6 100644 --- a/plugins/techdocs-backend/src/plugin.ts +++ b/plugins/techdocs-backend/src/plugin.ts @@ -72,8 +72,19 @@ export const techdocsPlugin = createBackendPlugin({ http: coreServices.httpRouter, discovery: coreServices.discovery, cache: coreServices.cache, + httpAuth: coreServices.httpAuth, + auth: coreServices.auth, }, - async init({ config, logger, urlReader, http, discovery, cache }) { + async init({ + config, + logger, + urlReader, + http, + discovery, + cache, + httpAuth, + auth, + }) { const winstonLogger = loggerToWinstonLogger(logger); // Preparers are responsible for fetching source files for documentation. const preparers = await Preparers.fromConfig(config, { @@ -114,8 +125,15 @@ export const techdocsPlugin = createBackendPlugin({ publisher, config, discovery, + httpAuth, + auth, }), ); + + 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..3a2eb4eee6 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 { AuthService, HttpAuthService } from '@backstage/backend-plugin-api'; /** * Required dependencies for running TechDocs in the "out-of-the-box" @@ -56,6 +58,8 @@ export type OutOfTheBoxDeploymentOptions = { docsBuildStrategy?: DocsBuildStrategy; buildLogTransport?: winston.transport; catalogClient?: CatalogClient; + httpAuth?: HttpAuthService; + auth?: AuthService; }; /** @@ -73,6 +77,8 @@ export type RecommendedDeploymentOptions = { docsBuildStrategy?: DocsBuildStrategy; buildLogTransport?: winston.transport; catalogClient?: CatalogClient; + httpAuth?: HttpAuthService; + auth?: AuthService; }; /** @@ -106,6 +112,9 @@ export async function createRouter( ): Promise { const router = Router(); const { publisher, config, logger, discovery } = options; + + const { auth, httpAuth } = createLegacyAuthAdapters(options); + const catalogClient = options.catalogClient ?? new CatalogClient({ discoveryApi: discovery }); const docsBuildStrategy = @@ -140,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); @@ -173,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); @@ -205,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); @@ -264,7 +291,15 @@ 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, { + allowLimitedAccess: true, + }); + + const { token } = await auth.getPluginRequestToken({ + onBehalfOf: credentials, + targetPluginId: 'catalog', + }); const entity = await entityLoader.load(entityName, token); @@ -287,11 +322,13 @@ export async function createRouter( // Route middleware which serves files from the storage set in the publisher. router.use('/static/docs', publisher.docsRouter()); - return router; -} + // Endpoint that sets the cookie for the user + router.get('/cookie', async (_, res) => { + const { expiresAt } = await httpAuth.issueUserCookie(res); + res.json({ expiresAt: expiresAt.toISOString() }); + }); -function getBearerToken(header?: string): string | undefined { - return header?.match(/(?:Bearer)\s+(\S+)/i)?.[1]; + return router; } /** 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-react/api-report.md b/plugins/techdocs-react/api-report.md index 93456b7586..4feb3b7609 100644 --- a/plugins/techdocs-react/api-report.md +++ b/plugins/techdocs-react/api-report.md @@ -65,6 +65,10 @@ export interface TechDocsApi { // (undocumented) getApiOrigin(): Promise; // (undocumented) + getCookie(): Promise<{ + expiresAt: string; + }>; + // (undocumented) getEntityMetadata( entityId: CompoundEntityRef, ): Promise; diff --git a/plugins/techdocs-react/src/api.ts b/plugins/techdocs-react/src/api.ts index 6d726fae01..c53481fe31 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 { + getCookie(): Promise<{ expiresAt: string }>; getApiOrigin(): Promise; getTechDocsMetadata(entityId: CompoundEntityRef): Promise; getEntityMetadata( 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; diff --git a/plugins/techdocs/src/client.ts b/plugins/techdocs/src/client.ts index 8680e14468..ae81ecdaaa 100644 --- a/plugins/techdocs/src/client.ts +++ b/plugins/techdocs/src/client.ts @@ -51,6 +51,18 @@ export class TechDocsClient implements TechDocsApi { this.fetchApi = options.fetchApi; } + public 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(); + } + async getApiOrigin(): Promise { return await this.discoveryApi.getBaseUrl('techdocs'); } 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..d248af6d1f --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsAuthProvider.tsx @@ -0,0 +1,103 @@ +/* + * 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.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx index f732afd31d..605decae12 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 getCookie = jest.fn(); const techdocsApiMock = { getEntityMetadata, getTechDocsMetadata, + getCookie, }; const techdocsStorageApiMock: jest.Mocked = { @@ -113,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(() => { diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx index e81a455cea..d7f3843ebb 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -35,6 +35,7 @@ import { getComponentData, useRouteRefParams, } from '@backstage/core-plugin-api'; +import { TechDocsAuthProvider } from './TechDocsAuthProvider'; /* An explanation for the multiple ways of customizing the TechDocs reader page @@ -177,29 +178,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} + +
+ )} +
+
); }; 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(), + }; + });