Merge pull request #23437 from backstage/camilaibs/techdocs-auth-services-migration

[TechDocs] Migrate to use new auth services
This commit is contained in:
Patrik Oldsberg
2024-03-12 14:22:40 +01:00
committed by GitHub
18 changed files with 326 additions and 28 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs-addons-test-utils': patch
---
Mock the new issue user cookie api method.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs-react': minor
---
Create a new api method for issuing user cookie.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs': patch
---
Implement a client cookie refresh mechanism.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs-backend': patch
---
Migrate plugin to use the new auth services.
@@ -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');
}
@@ -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 = {
+6
View File
@@ -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
+19 -1
View File
@@ -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',
});
},
});
},
+45 -8
View File
@@ -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<express.Router> {
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;
}
/**
@@ -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(),
};
});
+4
View File
@@ -65,6 +65,10 @@ export interface TechDocsApi {
// (undocumented)
getApiOrigin(): Promise<string>;
// (undocumented)
getCookie(): Promise<{
expiresAt: string;
}>;
// (undocumented)
getEntityMetadata(
entityId: CompoundEntityRef,
): Promise<TechDocsEntityMetadata>;
+1
View File
@@ -24,6 +24,7 @@ import { TechDocsEntityMetadata, TechDocsMetadata } from './types';
* @public
*/
export interface TechDocsApi {
getCookie(): Promise<{ expiresAt: string }>;
getApiOrigin(): Promise<string>;
getTechDocsMetadata(entityId: CompoundEntityRef): Promise<TechDocsMetadata>;
getEntityMetadata(
+4
View File
@@ -259,6 +259,10 @@ export class TechDocsClient implements TechDocsApi_2 {
discoveryApi: DiscoveryApi;
// (undocumented)
getApiOrigin(): Promise<string>;
// (undocumented)
getCookie(): Promise<{
expiresAt: string;
}>;
getEntityMetadata(
entityId: CompoundEntityRef,
): Promise<TechDocsEntityMetadata_2>;
+12
View File
@@ -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<string> {
return await this.discoveryApi.getBaseUrl('techdocs');
}
@@ -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 (
<ErrorPanel error={error}>
<Button variant="outlined" onClick={retry}>
Retry
</Button>
</ErrorPanel>
);
}
if (loading) {
return <Progress />;
}
return children;
}
@@ -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<typeof techdocsStorageApiRef.T> = {
@@ -113,8 +115,35 @@ const mountedRoutes = {
describe('<TechDocsReaderPage />', () => {
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(() => {
@@ -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 <TechDocsReaderLayout> is 1
return (
<TechDocsReaderPageProvider entityRef={entityRef}>
{(page as JSX.Element) || <TechDocsReaderLayout />}
</TechDocsReaderPageProvider>
<TechDocsAuthProvider>
<TechDocsReaderPageProvider entityRef={entityRef}>
{(page as JSX.Element) || <TechDocsReaderLayout />}
</TechDocsReaderPageProvider>
</TechDocsAuthProvider>
);
}
// As explained above, a render function is configuration 3 and React element is 2
return (
<TechDocsReaderPageProvider entityRef={entityRef}>
{({ metadata, entityMetadata, onReady }) => (
<div className="techdocs-reader-page">
<Page themeId="documentation">
{children instanceof Function
? children({
entityRef,
techdocsMetadataValue: metadata.value,
entityMetadataValue: entityMetadata.value,
onReady,
})
: children}
</Page>
</div>
)}
</TechDocsReaderPageProvider>
<TechDocsAuthProvider>
<TechDocsReaderPageProvider entityRef={entityRef}>
{({ metadata, entityMetadata, onReady }) => (
<div className="techdocs-reader-page">
<Page themeId="documentation">
{children instanceof Function
? children({
entityRef,
techdocsMetadataValue: metadata.value,
entityMetadataValue: entityMetadata.value,
onReady,
})
: children}
</Page>
</div>
)}
</TechDocsReaderPageProvider>
</TechDocsAuthProvider>
);
};
+25
View File
@@ -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(),
};
});