refactor: apply review suggestions

Co-authored-by: Patrik Oldsberg <poldsberg@gmail.com>
Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2024-04-03 13:03:53 +02:00
committed by Patrik Oldsberg
parent c884b9a478
commit ffd71105a6
43 changed files with 351 additions and 369 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
'@backstage/plugin-auth-react': patch
---
Update the default cookie base path and create a experimental redirect to root and protected app components, the components should be used only when the public entry is enabled.
Update the default cookie base path and create a experimental redirect to root and app mode components, the components authenticate and keep a cookie refresh loop on the client side.
+1 -1
View File
@@ -2,4 +2,4 @@
'@backstage/core-app-api': patch
---
Clear the app auth cookie when the when the user sign out.
Clear the app auth cookie after a sign out.
-7
View File
@@ -1,7 +0,0 @@
---
'@backstage/backend-plugin-api': patch
'@backstage/backend-test-utils': patch
'@backstage/backend-common': patch
---
Add a `removeUserCookie` to the http auth service interface.
+14 -34
View File
@@ -1,6 +1,6 @@
---
id: enable-public-entry
title: Enabling public entry point
title: Enabling a public entry point
description: A guide for how to experiment with public and protected Backstage app bundles
---
@@ -21,11 +21,14 @@ With that, Backstage's cli and backend will detect public entry point and serve
- The tutorial will only work for those using backstage-cli to build and serve their Backstage app.
## Trying out this feature
## Step-by-step
1. Add a `index-public-experimental.tsx` to your app `src` folder;
1. Create a `index-public-experimental.tsx` in your app `src` folder;
:::note
The filename is a convention, so it is not currently configurable.
:::
2. Prepare an unauthenticated version of your application. This will be the public entry point for your site:
2. This file is the public entry point for your application, and it should only contain what unauthenticated users should see:
```tsx title="in packages/app/src/index-public-experimental.tsx"
import React from 'react';
@@ -42,7 +45,7 @@ With that, Backstage's cli and backend will detect public entry point and serve
createApiFactory,
discoveryApiRef,
} from '@backstage/core-plugin-api';
import { RedirectToRoot } from '@backstage/plugin-auth-react';
import { CookieAuthRootRedirect } from '@backstage/plugin-auth-react';
import { providers } from '../src/identityProviders';
import { AuthProxyDiscoveryApi } from '../src/AuthProxyDiscoveryApi';
@@ -76,7 +79,7 @@ With that, Backstage's cli and backend will detect public entry point and serve
<OAuthRequestDialog />
<AppRouter>
{/* This is a special component that does the magic to redirect users to access the home page of your authenticated application version */}
<RedirectToRoot />
<CookieAuthRootRedirect />
</AppRouter>
</>,
);
@@ -84,35 +87,12 @@ With that, Backstage's cli and backend will detect public entry point and serve
ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
```
3) Then ensure your main entry is also protected by the experimental app provider, as shown in the following example:
3. The frontend will handle cookie refreshing automatically, so you don't have to worry about it;
```tsx title="in packages/app/src/index-public-experimental.tsx"
// ...
export default app.createRoot(
<>
<AlertDisplay transientTimeoutMs={2500} />
<OAuthRequestDialog />
<AppRouter>
{/* For now, this is just a temporary solution to ensure the user remains logged in properly and has access to the main app content. */}
<ExperimentalAppProtection>
<VisitListener />
<Root>{routes}</Root>
</ExperimentalAppProtection>
</AppRouter>
</>,
);
```
4. You're now ready to build and serve your frontend and backend as usual;
4. You're now ready to build your front-end app:
5. After that, access your backend index endpoint to see the public app being served (note that only a minimal app is being served).
```sh
yarn workspace example-app build
```
6. Log in and you will be redirected to the main app home page (check the protected bundle being served from the app-backend after the redirect).
5. And also serve it from your Backstage app backend:
```sh
yarn start-backend:next
```
6. Finally, access http://localhost:7007 to see the public app being served (note that only a minimal app is being served). Log in and you will be redirected to the main app home page (check the protected bundle being served from the app-backend after the redirect).
That's it!
+2 -5
View File
@@ -108,7 +108,6 @@ import { DevToolsPage } from '@backstage/plugin-devtools';
import { customDevToolsPage } from './components/devtools/CustomDevToolsPage';
import { CatalogUnprocessedEntitiesPage } from '@backstage/plugin-catalog-unprocessed-entities';
import { NotificationsPage } from '@backstage/plugin-notifications';
import { ExperimentalAppProtection } from '@backstage/plugin-auth-react';
const app = createApp({
apis,
@@ -283,10 +282,8 @@ export default app.createRoot(
<AlertDisplay transientTimeoutMs={2500} />
<OAuthRequestDialog />
<AppRouter>
<ExperimentalAppProtection>
<VisitListener />
<Root>{routes}</Root>
</ExperimentalAppProtection>
<VisitListener />
<Root>{routes}</Root>
</AppRouter>
</>,
);
@@ -21,7 +21,7 @@ import {
OAuthRequestDialog,
SignInPage,
} from '@backstage/core-components';
import { RedirectToRoot } from '@backstage/plugin-auth-react';
import { CookieAuthRootRedirect } from '@backstage/plugin-auth-react';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { providers } from '../src/identityProviders';
@@ -59,7 +59,7 @@ const App = app.createRoot(
<AlertDisplay transientTimeoutMs={2500} />
<OAuthRequestDialog />
<AppRouter>
<RedirectToRoot />
<CookieAuthRootRedirect />
</AppRouter>
</>,
);
@@ -181,6 +181,13 @@ class DefaultHttpAuthService implements HttpAuthService {
let credentials: BackstageCredentials<BackstageUserPrincipal>;
if (options?.credentials) {
if (this.#auth.isPrincipal(options.credentials, 'none')) {
res.clearCookie(
BACKSTAGE_AUTH_COOKIE,
await this.#getCookieOptions(res.req),
);
return { expiresAt: new Date() };
}
if (!this.#auth.isPrincipal(options.credentials, 'user')) {
throw new AuthenticationError(
'Refused to issue cookie for non-user principal',
@@ -196,16 +203,6 @@ class DefaultHttpAuthService implements HttpAuthService {
return { expiresAt: existingExpiresAt };
}
const originHeader = res.req.headers.origin;
const origin =
!originHeader || originHeader === 'null' ? undefined : originHeader;
// https://backstage.example.com/api/catalog
const externalBaseUrlStr = await this.#discovery.getExternalBaseUrl(
this.#pluginId,
);
const externalBaseUrl = new URL(origin ?? externalBaseUrlStr);
const { token, expiresAt } = await this.#auth.getLimitedUserToken(
credentials,
);
@@ -213,20 +210,41 @@ class DefaultHttpAuthService implements HttpAuthService {
throw new Error('User credentials is unexpectedly missing token');
}
res.cookie(BACKSTAGE_AUTH_COOKIE, token, {
...(await this.#getCookieOptions(res.req)),
expires: expiresAt,
});
return { expiresAt };
}
async #getCookieOptions(req: Request): Promise<{
domain: string;
httpOnly: true;
secure: boolean;
priority: 'high';
sameSite: 'none' | 'lax';
}> {
const originHeader = req.headers.origin;
const origin =
!originHeader || originHeader === 'null' ? undefined : originHeader;
const externalBaseUrlStr = await this.#discovery.getExternalBaseUrl(
this.#pluginId,
);
const externalBaseUrl = new URL(origin ?? externalBaseUrlStr);
const secure =
externalBaseUrl.protocol === 'https:' ||
externalBaseUrl.hostname === 'localhost';
res.cookie(BACKSTAGE_AUTH_COOKIE, token, {
return {
domain: externalBaseUrl.hostname,
httpOnly: true,
expires: expiresAt,
secure,
priority: 'high',
sameSite: secure ? 'none' : 'lax',
});
return { expiresAt };
};
}
async #existingCookieExpiration(req: Request): Promise<Date | undefined> {
@@ -254,10 +272,6 @@ class DefaultHttpAuthService implements HttpAuthService {
throw error;
}
}
removeUserCookie(res: Response): void {
res.clearCookie(BACKSTAGE_AUTH_COOKIE);
}
}
/** @public */
@@ -93,7 +93,7 @@ export const httpRouterServiceFactory = createServiceFactory(
) {
// Only add the cookie refresh middleware once
hasRegistedCookieAuthRefreshMiddleware = true;
router.use(createCookieAuthRefreshMiddleware({ httpAuth }));
router.use(createCookieAuthRefreshMiddleware({ auth, httpAuth }));
}
},
};
@@ -259,10 +259,6 @@ class HttpAuthCompat implements HttpAuthService {
async issueUserCookie(_res: Response): Promise<{ expiresAt: Date }> {
return { expiresAt: new Date(Date.now() + 3600_000) };
}
removeUserCookie(res: Response): void {
res.clearCookie('backstage-auth');
}
}
export class UserInfoCompat implements UserInfoService {
+1 -3
View File
@@ -325,13 +325,11 @@ export interface HttpAuthService {
issueUserCookie(
res: Response_2,
options?: {
credentials?: BackstageCredentials<BackstageUserPrincipal>;
credentials?: BackstageCredentials;
},
): Promise<{
expiresAt: Date;
}>;
// (undocumented)
removeUserCookie(res: Response_2): void;
}
// @public (undocumented)
@@ -15,11 +15,7 @@
*/
import { Request, Response } from 'express';
import {
BackstageCredentials,
BackstagePrincipalTypes,
BackstageUserPrincipal,
} from './AuthService';
import { BackstageCredentials, BackstagePrincipalTypes } from './AuthService';
/** @public */
export interface HttpAuthService {
@@ -34,9 +30,7 @@ export interface HttpAuthService {
issueUserCookie(
res: Response,
options?: {
credentials?: BackstageCredentials<BackstageUserPrincipal>;
credentials?: BackstageCredentials;
},
): Promise<{ expiresAt: Date }>;
removeUserCookie(res: Response): void;
}
@@ -140,8 +140,4 @@ export class MockHttpAuthService implements HttpAuthService {
return { expiresAt: new Date(Date.now() + 3600_000) };
}
removeUserCookie(res: Response): void {
res.clearCookie(MOCK_AUTH_COOKIE);
}
}
@@ -275,7 +275,6 @@ export namespace mockServices {
export const mock = simpleMock(coreServices.httpAuth, () => ({
credentials: jest.fn(),
issueUserCookie: jest.fn(),
removeUserCookie: jest.fn(),
}));
}
+1 -5
View File
@@ -70,11 +70,7 @@ export async function buildBundle(options: BuildOptions) {
`⚠️ WARNING: The app /public entry point is an experimental feature that may receive immediate breaking changes.`,
),
);
configs.push(
await createConfig(publicPaths, {
...commonConfigOptions,
}),
);
configs.push(await createConfig(publicPaths, commonConfigOptions));
}
const isCi = yn(process.env.CI, { default: false });
+1 -6
View File
@@ -216,12 +216,7 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be
);
}
const compiler = publicPaths
? webpack([
config,
await createConfig(publicPaths, {
...commonConfigOptions,
}),
])
? webpack([config, await createConfig(publicPaths, commonConfigOptions)])
: webpack(config);
webpackServer = new WebpackDevServer(
+1
View File
@@ -44,6 +44,7 @@
"dependencies": {
"@backstage/config": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/plugin-auth-react": "workspace:^",
"@backstage/types": "workspace:^",
"@backstage/version-bridge": "workspace:^",
"@types/prop-types": "^15.7.3",
@@ -864,7 +864,7 @@ describe('Integration Test', () => {
await waitFor(() =>
expect(fetchApiMock.fetch).toHaveBeenCalledWith(
'http://localhost:7007/app/.backstage/v1-cookie',
'http://localhost:7007/app/.backstage/auth/v1/cookie',
{ method: 'DELETE' },
),
);
+11 -4
View File
@@ -29,6 +29,7 @@ import { isReactRouterBeta } from './isReactRouterBeta';
import { RouteTracker } from '../routing/RouteTracker';
import { Route, Routes } from 'react-router-dom';
import { AppIdentityProxy } from '../apis/implementations/IdentityApi/AppIdentityProxy';
import { AppMode } from '@backstage/plugin-auth-react';
/**
* Get the app base path from the configured app baseUrl.
@@ -145,7 +146,10 @@ export function AppRouter(props: AppRouterProps) {
<RouterComponent>
<RouteTracker routeObjects={routeObjects} />
<Routes>
<Route path={mountPath} element={<>{props.children}</>} />
<Route
path={mountPath}
element={<AppMode>{props.children}</AppMode>}
/>
</Routes>
</RouterComponent>
);
@@ -154,7 +158,7 @@ export function AppRouter(props: AppRouterProps) {
return (
<RouterComponent basename={basePath}>
<RouteTracker routeObjects={routeObjects} />
{props.children}
<AppMode>{props.children}</AppMode>
</RouterComponent>
);
}
@@ -168,7 +172,10 @@ export function AppRouter(props: AppRouterProps) {
appIdentityProxy={appIdentityProxy}
>
<Routes>
<Route path={mountPath} element={<>{props.children}</>} />
<Route
path={mountPath}
element={<AppMode>{props.children}</AppMode>}
/>
</Routes>
</SignInPageWrapper>
</RouterComponent>
@@ -182,7 +189,7 @@ export function AppRouter(props: AppRouterProps) {
component={SignInPageComponent}
appIdentityProxy={appIdentityProxy}
>
{props.children}
<AppMode>{props.children}</AppMode>
</SignInPageWrapper>
</RouterComponent>
);
+1
View File
@@ -38,6 +38,7 @@
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/plugin-auth-react": "workspace:^",
"@backstage/theme": "workspace:^",
"@backstage/types": "workspace:^",
"@backstage/version-bridge": "workspace:^",
@@ -42,6 +42,7 @@ import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations
import { BrowserRouter } from 'react-router-dom';
import { RouteTracker } from '../routing/RouteTracker';
import { getBasePath } from '../routing/getBasePath';
import { AppMode } from '@backstage/plugin-auth-react';
export const AppRoot = createExtension({
namespace: 'app',
@@ -190,7 +191,7 @@ export function AppRouter(props: AppRouterProps) {
return (
<RouterComponent>
<RouteTracker routeObjects={routeObjects} />
{children}
<AppMode>{children}</AppMode>
</RouterComponent>
);
}
@@ -202,7 +203,7 @@ export function AppRouter(props: AppRouterProps) {
component={SignInPageComponent}
appIdentityProxy={appIdentityProxy}
>
{children}
<AppMode>{children}</AppMode>
</SignInPageWrapper>
</RouterComponent>
);
@@ -62,9 +62,9 @@ describe('appPlugin', () => {
fetch(`http://localhost:${server.port()}/api/app/derp.html`).then(res =>
res.text(),
),
).resolves.toBe('winning');
).resolves.toMatch('winning');
await expect(
fetch(`http://localhost:${server.port()}`).then(res => res.text()),
).resolves.toBe('winning');
).resolves.toMatch('winning');
});
});
+21 -9
View File
@@ -168,12 +168,13 @@ export async function createRouter(
const publicDistDir = resolvePath(appDistDir, 'public');
const enablePublicEntryPoint = await fs.pathExists(publicDistDir);
const enablePublicEntryPoint =
(await fs.pathExists(publicDistDir)) && auth && httpAuth;
if (enablePublicEntryPoint && auth && httpAuth) {
const publicRouter = Router();
publicRouter.use(createCookieAuthRefreshMiddleware({ httpAuth }));
publicRouter.use(createCookieAuthRefreshMiddleware({ auth, httpAuth }));
publicRouter.use(async (req, _res, next) => {
const credentials = await httpAuth.credentials(req, {
@@ -214,6 +215,7 @@ export async function createRouter(
publicRouter.use(
await createEntryPointRouter({
appMode: 'public',
logger: logger.child({ entry: 'public' }),
rootDir: publicDistDir,
assetStore: assetStore?.withNamespace('public'),
@@ -226,6 +228,7 @@ export async function createRouter(
router.use(
await createEntryPointRouter({
appMode: enablePublicEntryPoint ? 'protected' : 'public',
logger: logger.child({ entry: 'main' }),
rootDir: appDistDir,
assetStore,
@@ -243,6 +246,7 @@ async function createEntryPointRouter({
rootDir,
assetStore,
staticFallbackHandler,
appMode,
appConfigs,
injectedConfigPath,
}: {
@@ -250,6 +254,7 @@ async function createEntryPointRouter({
rootDir: string;
assetStore?: StaticAssetsStore;
staticFallbackHandler?: express.Handler;
appMode: 'public' | 'protected';
appConfigs?: AppConfig[];
injectedConfigPath?: string;
}) {
@@ -303,14 +308,21 @@ async function createEntryPointRouter({
},
}),
);
const indexHtmlContent = Buffer.from(
(await fs.readFile(resolvePath(rootDir, 'index.html'), 'utf8')).replace(
/<head>/,
`<head><meta name="backstage-app-mode" content="${appMode}" />`,
),
'utf8',
);
router.get('/*', (_req, res) => {
res.sendFile(resolvePath(rootDir, 'index.html'), {
headers: {
// The Cache-Control header instructs the browser to not cache the index.html since it might
// link to static assets from recently deployed versions.
'cache-control': CACHE_CONTROL_NO_CACHE,
},
});
// The Cache-Control header instructs the browser to not cache the index.html since it might
// link to static assets from recently deployed versions.
res.setHeader('Cache-Control', CACHE_CONTROL_NO_CACHE);
res.setHeader('Content-Type', 'text/html;charset=utf-8');
res.send(indexHtmlContent);
});
return router;
+2
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 { BackstageIdentityResponse as BackstageIdentityResponse_2 } from '@backstage/plugin-auth-node';
import { BackstageSignInResult as BackstageSignInResult_2 } from '@backstage/plugin-auth-node';
import { Config } from '@backstage/config';
@@ -152,6 +153,7 @@ export type CookieConfigurer = (ctx: {
// @public
export function createCookieAuthRefreshMiddleware(options: {
auth: AuthService;
httpAuth: HttpAuthService;
}): Router;
@@ -23,8 +23,9 @@ describe('createCookieAuthRefreshMiddleware', () => {
let app: express.Express;
beforeAll(async () => {
const auth = mockServices.auth();
const httpAuth = mockServices.httpAuth();
const router = createCookieAuthRefreshMiddleware({ httpAuth });
const router = createCookieAuthRefreshMiddleware({ auth, httpAuth });
app = express().use(router);
});
@@ -33,7 +34,7 @@ describe('createCookieAuthRefreshMiddleware', () => {
});
it('should issue the user cookie', async () => {
const response = await request(app).get('/.backstage/v1-cookie');
const response = await request(app).get('/.backstage/auth/v1/cookie');
expect(response.status).toBe(200);
expect(response.header['set-cookie'][0]).toMatch(
`backstage-auth=${mockCredentials.limitedUser.token()}`,
@@ -41,7 +42,7 @@ describe('createCookieAuthRefreshMiddleware', () => {
});
it('should remove the user cookie', async () => {
const response = await request(app).delete('/.backstage/v1-cookie');
const response = await request(app).delete('/.backstage/auth/v1/cookie');
expect(response.status).toBe(200);
expect(response.header['set-cookie'][0]).toMatch('backstage-auth=');
});
@@ -14,19 +14,20 @@
* limitations under the License.
*/
import { HttpAuthService } from '@backstage/backend-plugin-api';
import { AuthService, HttpAuthService } from '@backstage/backend-plugin-api';
import { Router } from 'express';
const WELL_KNOWN_COOKIE_PATH_V1 = '/.backstage/v1-cookie';
const WELL_KNOWN_COOKIE_PATH_V1 = '/.backstage/auth/v1/cookie';
/**
* @public
* Creates a middleware that can be used to refresh the cookie for the user.
*/
export function createCookieAuthRefreshMiddleware(options: {
auth: AuthService;
httpAuth: HttpAuthService;
}) {
const { httpAuth } = options;
const { auth, httpAuth } = options;
const router = Router();
// Endpoint that sets the cookie for the user
@@ -37,7 +38,8 @@ export function createCookieAuthRefreshMiddleware(options: {
// Endpoint that removes the cookie for the user
router.delete(WELL_KNOWN_COOKIE_PATH_V1, async (_, res) => {
httpAuth.removeUserCookie(res);
const credentials = await auth.getNoneCredentials();
await httpAuth.issueUserCookie(res, { credentials });
res.send(200);
});
+4 -6
View File
@@ -6,6 +6,9 @@
import { default as React_2 } from 'react';
import { ReactNode } from 'react';
// @public
export function AppMode(props: { children: ReactNode }): JSX.Element;
// @public
export function CookieAuthRefreshProvider(
props: CookieAuthRefreshProviderProps,
@@ -19,12 +22,7 @@ export type CookieAuthRefreshProviderProps = {
};
// @public
export function ExperimentalAppProtection(props: {
children: ReactNode;
}): JSX.Element;
// @public
export function RedirectToRoot(): React_2.JSX.Element | null;
export function CookieAuthRootRedirect(): React_2.JSX.Element | null;
// @public
export function useCookieAuthRefresh(options: {
+2
View File
@@ -35,6 +35,8 @@
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/version-bridge": "workspace:^",
"@material-ui/core": "^4.9.13",
"@react-hookz/web": "^24.0.0",
"@types/react": "^16.13.1 || ^17.0.0 || ^18.0.0"
@@ -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 from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { AppMode } from './AppMode';
import { discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api';
import { componentsApiRef } from '@backstage/frontend-plugin-api';
const now = 1710316886171;
const tenMinutesInMilliseconds = 10 * 60 * 1000;
const tenMinutesFromNowInMilliseconds = now + tenMinutesInMilliseconds;
const expiresAt = new Date(tenMinutesFromNowInMilliseconds).toISOString();
jest.mock('@backstage/core-plugin-api', () => {
return {
...jest.requireActual('@backstage/core-plugin-api'),
useApp: jest.fn().mockReturnValue({
getComponents: () => ({ Progress: () => <div data-testid="progress" /> }),
}),
useApi: jest.fn(ref => {
if (ref === discoveryApiRef) {
return {
getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7000/app'),
};
}
if (ref === fetchApiRef) {
return {
fetch: jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({ expiresAt }),
}),
};
}
if (ref === componentsApiRef) {
return {
getComponent: jest
.fn()
.mockReturnValue(() => <div data-testid="progress" />),
};
}
throw new Error(`Attempted to use an unmocked API reference: ${ref}`);
}),
};
});
describe('AppMode', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers({ now });
});
afterEach(() => {
jest.useRealTimers();
});
it('should render the children when app mode is undefined', async () => {
render(<AppMode>Test content</AppMode>);
await waitFor(() =>
expect(screen.getByText('Test content')).toBeInTheDocument(),
);
});
it('should render the children the app mode is public', async () => {
render(
<>
<meta name="backstage-app-mode" content="public" />
<AppMode>Test content</AppMode>
</>,
);
await waitFor(() =>
expect(screen.getByText('Test content')).toBeInTheDocument(),
);
});
it('should render the children wrapped in the CookieAuthRefreshProvider', async () => {
render(
<>
<meta name="backstage-app-mode" content="protected" />
<AppMode>Test content</AppMode>
</>,
);
await waitFor(() =>
expect(screen.getByText('Test content')).toBeInTheDocument(),
);
});
});
@@ -0,0 +1,48 @@
/*
* 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 } from 'react';
import { CookieAuthRefreshProvider } from '@backstage/plugin-auth-react';
import { CompatAppProgress } from '../CompatAppProgress';
/**
* @public
* A provider that will protect the app when running in protected experimental mode.
*/
export function AppMode(props: { children: ReactNode }): JSX.Element {
const { children } = props;
const [appMode, setAppMode] = useState<string | null>(null);
useEffect(() => {
const element = document.querySelector('meta[name="backstage-app-mode"]');
setAppMode(element?.getAttribute('content') ?? 'public');
}, [setAppMode]);
if (!appMode) {
return <CompatAppProgress />;
}
if (appMode === 'protected') {
return (
<CookieAuthRefreshProvider pluginId="app">
{children}
</CookieAuthRefreshProvider>
);
}
return <>{children}</>;
}
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { RedirectToRoot } from './RedirectToRoot';
export { AppMode } from './AppMode';
@@ -0,0 +1,40 @@
/*
* 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 from 'react';
import { useApp } from '@backstage/core-plugin-api';
import { useVersionedContext } from '@backstage/version-bridge';
import {
coreComponentRefs,
useComponentRef,
} from '@backstage/frontend-plugin-api';
function LegacyAppProgress() {
const app = useApp();
const { Progress } = app.getComponents();
return <Progress />;
}
function NewAppProgress() {
const Progress = useComponentRef(coreComponentRefs.progress);
return <Progress />;
}
export function CompatAppProgress() {
const isInNewApp = !useVersionedContext<{ 1: unknown }>('app-context');
return isInNewApp ? <NewAppProgress /> : <LegacyAppProgress />;
}
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { ExperimentalAppProtection } from './ExperimentalAppProtection';
export { CompatAppProgress } from './CompatAppProgress';
@@ -119,7 +119,7 @@ describe('CookieAuthRefreshProvider', () => {
await waitFor(() =>
expect(fetchApiMock.fetch).toHaveBeenCalledWith(
'http://localhost:7000/techdocs/api/.backstage/v1-cookie',
'http://localhost:7000/techdocs/api/.backstage/auth/v1/cookie',
{ credentials: 'include' },
),
);
@@ -16,9 +16,9 @@
import React, { ReactNode } from 'react';
import { ErrorPanel } from '@backstage/core-components';
import { useApp } from '@backstage/core-plugin-api';
import { Button } from '@material-ui/core';
import { useCookieAuthRefresh } from '../../hooks';
import { CompatAppProgress } from '../CompatAppProgress/CompatAppProgress';
/**
* @public
@@ -41,13 +41,11 @@ export function CookieAuthRefreshProvider(
props: CookieAuthRefreshProviderProps,
): JSX.Element {
const { children, ...options } = props;
const app = useApp();
const { Progress } = app.getComponents();
const result = useCookieAuthRefresh(options);
if (result.status === 'loading') {
return <Progress />;
return <CompatAppProgress />;
}
if (result.status === 'error') {
@@ -18,9 +18,9 @@ import React from 'react';
import { screen, waitFor } from '@testing-library/react';
import { TestApiProvider, renderInTestApp } from '@backstage/test-utils';
import { identityApiRef } from '@backstage/core-plugin-api';
import { RedirectToRoot } from './RedirectToRoot';
import { CookieAuthRootRedirect } from './CookieAuthRootRedirect';
describe('RedirectToRoot', () => {
describe('CookieAuthRootRedirect', () => {
const identityApiMock = { getCredentials: jest.fn() };
beforeEach(() => {
@@ -32,7 +32,7 @@ describe('RedirectToRoot', () => {
await renderInTestApp(
<TestApiProvider apis={[[identityApiRef, identityApiMock]]}>
<RedirectToRoot />
<CookieAuthRootRedirect />
</TestApiProvider>,
);
@@ -50,7 +50,7 @@ describe('RedirectToRoot', () => {
await renderInTestApp(
<TestApiProvider apis={[[identityApiRef, identityApiMock]]}>
<RedirectToRoot />
<CookieAuthRootRedirect />
</TestApiProvider>,
);
@@ -22,7 +22,7 @@ import { useAsync, useMountEffect } from '@react-hookz/web';
* @public
* A component that redirects to the root of the app after a successful sign-in.
*/
export function RedirectToRoot() {
export function CookieAuthRootRedirect() {
const identityApi = useApi(identityApiRef);
const [state, actions] = useAsync(async () => {
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { CookieAuthRootRedirect } from './CookieAuthRootRedirect';
@@ -1,150 +0,0 @@
/*
* 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 from 'react';
import { screen, waitFor } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import {
MockConfigApi,
TestApiProvider,
renderInTestApp,
setupRequestMockHandlers,
} from '@backstage/test-utils';
import { ExperimentalAppProtection } from './ExperimentalAppProtection';
import {
configApiRef,
discoveryApiRef,
fetchApiRef,
} from '@backstage/core-plugin-api';
describe('ExperimentalAppProtection', () => {
const worker = setupServer();
setupRequestMockHandlers(worker);
const configApiMock = new MockConfigApi({
backend: {
baseUrl: 'http://localhost:7000',
},
});
const fetchApiMock = {
fetch: jest.fn(),
};
const discoveryApiMock = {
getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7000/app'),
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should render the progress component while loading', async () => {
fetchApiMock.fetch.mockReturnValueOnce(new Promise(() => {}));
await renderInTestApp(
<TestApiProvider
apis={[
[configApiRef, configApiMock],
[fetchApiRef, fetchApiMock],
]}
>
<ExperimentalAppProtection>Test content</ExperimentalAppProtection>
</TestApiProvider>,
);
expect(screen.getByTestId('progress')).toBeVisible();
expect(screen.queryByText('Test Content')).not.toBeInTheDocument();
});
it('should render the children even when there is an error', async () => {
const error = new Error('Failed to fetch');
fetchApiMock.fetch.mockRejectedValueOnce(error);
await renderInTestApp(
<TestApiProvider
apis={[
[configApiRef, configApiMock],
[fetchApiRef, fetchApiMock],
]}
>
<ExperimentalAppProtection>Test content</ExperimentalAppProtection>
</TestApiProvider>,
);
await waitFor(() =>
expect(screen.getByText('Test content')).toBeInTheDocument(),
);
expect(screen.queryByTestId('progress')).not.toBeInTheDocument();
expect(screen.queryByText('Failed to fetch')).not.toBeInTheDocument();
});
it('should render the children even when the public index is not available', async () => {
fetchApiMock.fetch.mockResolvedValueOnce({ ok: false });
await renderInTestApp(
<TestApiProvider
apis={[
[configApiRef, configApiMock],
[fetchApiRef, fetchApiMock],
]}
>
<ExperimentalAppProtection>Test content</ExperimentalAppProtection>
</TestApiProvider>,
);
await waitFor(() =>
expect(screen.getByText('Test content')).toBeInTheDocument(),
);
expect(screen.queryByTestId('progress')).not.toBeInTheDocument();
});
it('should render the children also when the public index is available', async () => {
fetchApiMock.fetch.mockResolvedValueOnce({ ok: true });
await renderInTestApp(
<ExperimentalAppProtection>Test content</ExperimentalAppProtection>,
);
await waitFor(() =>
expect(screen.getByText('Test content')).toBeInTheDocument(),
);
expect(screen.queryByTestId('progress')).not.toBeInTheDocument();
});
it('should render the children wrapped in the CookieAuthRefreshProvider', async () => {
worker.use(
rest.get('http://localhost:7000/public/index.html', (_, res, ctx) => {
return res(ctx.status(200));
}),
rest.get(
'http://localhost:7000/app/.backstage/v1-cookie',
(_, res, ctx) => {
return res(
ctx.status(200),
ctx.json({ expiresAt: Date.now() + 10 * 60 * 1000 }),
);
},
),
);
await renderInTestApp(
<TestApiProvider
apis={[
[configApiRef, configApiMock],
[discoveryApiRef, discoveryApiMock],
]}
>
<ExperimentalAppProtection>Test content</ExperimentalAppProtection>
</TestApiProvider>,
);
await waitFor(() =>
expect(screen.getByText('Test content')).toBeInTheDocument(),
);
});
});
@@ -1,63 +0,0 @@
/*
* 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 } from 'react';
import {
configApiRef,
fetchApiRef,
useApi,
useApp,
} from '@backstage/core-plugin-api';
import { CookieAuthRefreshProvider } from '@backstage/plugin-auth-react';
import { useAsync, useMountEffect } from '@react-hookz/web';
/**
* @public
* A provider that will protect the app when running in public experimental mode.
*/
export function ExperimentalAppProtection(props: {
children: ReactNode;
}): JSX.Element {
const { children } = props;
const fetchApi = useApi(fetchApiRef);
const configApi = useApi(configApiRef);
const Components = useApp().getComponents();
const [state, actions] = useAsync(async () => {
const baseUrl = configApi.getString('backend.baseUrl');
const response = await fetchApi.fetch(`${baseUrl}/public/index.html`);
return response.ok;
});
useMountEffect(actions.execute);
if (state.status === 'not-executed' || state.status === 'loading') {
return <Components.Progress />;
}
// Request failed, or the public index is not available
if (state.status === 'error' || !state.result) {
return <>{children}</>;
}
// The public index is available
// That means the app is running in public experimental mode
return (
<CookieAuthRefreshProvider pluginId="app">
{children}
</CookieAuthRefreshProvider>
);
}
+2 -2
View File
@@ -17,6 +17,6 @@
// The index file in ./components/ is typically responsible for selecting
// which components are public API and should be exported from the package.
export * from './RedirectToRoot';
export * from './CookieAuthRootRedirect';
export * from './CookieAuthRefreshProvider';
export * from './ExperimentalAppProtection';
export * from './AppMode';
@@ -236,7 +236,7 @@ describe('useCookieAuthRefresh', () => {
await waitFor(() =>
expect(fetchApiMock.fetch).toHaveBeenCalledWith(
'http://localhost:7000/techdocs/api/.backstage/v1-cookie',
'http://localhost:7000/techdocs/api/.backstage/auth/v1/cookie',
{ credentials: 'include' },
),
);
@@ -31,13 +31,13 @@ import { ResponseError } from '@backstage/errors';
export function useCookieAuthRefresh(options: {
// The plugin id used for discovering the API origin
pluginId: string;
// The path used for calling the refresh cookie endpoint, default to '/.backstage/v1-cookie'
// The path used for calling the refresh cookie endpoint, default to '/.backstage/auth/v1/cookie'
path?: string;
}):
| { status: 'loading' }
| { status: 'error'; error: Error; retry: () => void }
| { status: 'success'; data: { expiresAt: string } } {
const { pluginId, path = '/.backstage/v1-cookie' } = options ?? {};
const { pluginId, path = '/.backstage/auth/v1/cookie' } = options ?? {};
const fetchApi = useApi(fetchApiRef);
const discoveryApi = useApi(discoveryApiRef);
+11 -7
View File
@@ -3834,6 +3834,7 @@ __metadata:
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/plugin-auth-react": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/types": "workspace:^"
"@backstage/version-bridge": "workspace:^"
@@ -4193,6 +4194,7 @@ __metadata:
"@backstage/core-plugin-api": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/plugin-auth-react": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/theme": "workspace:^"
"@backstage/types": "workspace:^"
@@ -5112,7 +5114,9 @@ __metadata:
"@backstage/core-components": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/version-bridge": "workspace:^"
"@material-ui/core": ^4.9.13
"@react-hookz/web": ^24.0.0
"@testing-library/jest-dom": ^6.0.0
@@ -25863,7 +25867,7 @@ __metadata:
languageName: node
linkType: hard
"domhandler@npm:^5.0.1, domhandler@npm:^5.0.2":
"domhandler@npm:^5.0.1, domhandler@npm:^5.0.2, domhandler@npm:^5.0.3":
version: 5.0.3
resolution: "domhandler@npm:5.0.3"
dependencies:
@@ -26269,7 +26273,7 @@ __metadata:
languageName: node
linkType: hard
"entities@npm:^4.2.0, entities@npm:^4.3.0, entities@npm:^4.4.0":
"entities@npm:^4.2.0, entities@npm:^4.4.0":
version: 4.4.0
resolution: "entities@npm:4.4.0"
checksum: 84d250329f4b56b40fa93ed067b194db21e8815e4eb9b59f43a086f0ecd342814f6bc483de8a77da5d64e0f626033192b1b4f1792232a7ea6b970ebe0f3187c2
@@ -30033,14 +30037,14 @@ __metadata:
linkType: hard
"htmlparser2@npm:^8.0.0":
version: 8.0.1
resolution: "htmlparser2@npm:8.0.1"
version: 8.0.2
resolution: "htmlparser2@npm:8.0.2"
dependencies:
domelementtype: ^2.3.0
domhandler: ^5.0.2
domhandler: ^5.0.3
domutils: ^3.0.1
entities: ^4.3.0
checksum: 06d5c71e8313597722bc429ae2a7a8333d77bd3ab07ccb916628384b37332027b047f8619448d8f4a3312b6609c6ea3302a4e77435d859e9e686999e6699ca39
entities: ^4.4.0
checksum: 29167a0f9282f181da8a6d0311b76820c8a59bc9e3c87009e21968264c2987d2723d6fde5a964d4b7b6cba663fca96ffb373c06d8223a85f52a6089ced942700
languageName: node
linkType: hard