Merge pull request #23437 from backstage/camilaibs/techdocs-auth-services-migration
[TechDocs] Migrate to use new auth services
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-techdocs-addons-test-utils': patch
|
||||
---
|
||||
|
||||
Mock the new issue user cookie api method.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-techdocs-react': minor
|
||||
---
|
||||
|
||||
Create a new api method for issuing user cookie.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-techdocs': patch
|
||||
---
|
||||
|
||||
Implement a client cookie refresh mechanism.
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -65,6 +65,10 @@ export interface TechDocsApi {
|
||||
// (undocumented)
|
||||
getApiOrigin(): Promise<string>;
|
||||
// (undocumented)
|
||||
getCookie(): Promise<{
|
||||
expiresAt: string;
|
||||
}>;
|
||||
// (undocumented)
|
||||
getEntityMetadata(
|
||||
entityId: CompoundEntityRef,
|
||||
): Promise<TechDocsEntityMetadata>;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user