Merge pull request #23719 from backstage/rugvip/app-auth

[Auth] Migrate `app-backend` to use new auth services
This commit is contained in:
Patrik Oldsberg
2024-04-09 17:45:46 +02:00
committed by GitHub
46 changed files with 1239 additions and 118 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Fix the bundle public subpath configuration.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-auth-react': minor
---
**BREAKING**: Removed the path option from `CookieAuthRefreshProvider` and `useCookieAuthRefresh`.
A new `CookieAuthRedirect` component has been added to redirect a public app bundle to the protected one when using the `app-backend` with a separate public entry point.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs-backend': patch
---
Use the default cookie endpoints added automatically when a cookie policy is set.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/core-app-api': patch
'@backstage/frontend-app-api': patch
---
The app is now aware of if it is being served from the `app-backend` with a separate public and protected bundles. When in protected mode the app will now continuously refresh the session cookie, as well as clear the cookie if the user signs out.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-app-backend': patch
---
Track assets namespace in the cache store, implement a cookie authentication for when the public entry is enabled and used with the new auth services.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
When building the frontend app public assets are now also copied to the public dist directory when in use.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-plugin-api': patch
---
The credentials passed to the `issueUserCookie` method of the `HttpAuthService` are no longer required to represent a user principal.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-app-api': patch
---
Automatically creates a get and delete cookie endpoint when a `user-cookie` policy is added.
+102
View File
@@ -0,0 +1,102 @@
---
id: enable-public-entry
title: Enabling a public entry point
description: A guide for how to experiment with public and protected Backstage app bundles
---
# Enable Public Entry (Experimental)
In this tutorial, you will learn how to restrict access to your main Backstage app bundle to authenticated users only.
It is expected that the protected bundle feature will be refined in future development iterations, but for now, here is a simplified explanation of how it works:
Your Backstage app bundle is split into two code entries:
- Public entry point containing login pages;
- There is also a protected main entry point that contains the code for what you see after signing in.
With that, Backstage's cli and backend will detect public entry point and serve it to unauthenticated users, while serving the main, protected entry point only to authenticated users.
## Requirements
- The app needs to be served by the `app-backend` plugin, or this won't work;
- Also it will only work for those using `backstage-cli` to build and serve their Backstage app.
## Step-by-step
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. 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';
import ReactDOM from 'react-dom/client';
import { createApp } from '@backstage/app-defaults';
import { AppRouter } from '@backstage/core-app-api';
import {
AlertDisplay,
OAuthRequestDialog,
SignInPage,
} from '@backstage/core-components';
import {
configApiRef,
discoveryApiRef,
createApiFactory,
} from '@backstage/core-plugin-api';
import { CookieAuthRedirect } from '@backstage/plugin-auth-react';
// Notice that this is only setting up what is needed by the sign-in pages
const app = createApp({
// If you have any custom APIs that your sign-in page depends on, you need to add them here
apis: [],
components: {
SignInPage: props => {
return (
<SignInPage
{...props}
providers={['guest']}
title="Select a sign-in method"
/>
);
},
},
});
const App = app.createRoot(
<>
<AlertDisplay transientTimeoutMs={2500} />
<OAuthRequestDialog />
<AppRouter>
{/* This component triggers an authenticated redirect to the main app, while staying on the same URL */}
<CookieAuthRedirect />
</AppRouter>
</>,
);
ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
```
:::note
The frontend will handle cookie refreshing automatically, so you don't have to worry about it.
:::
3. Let's verify that everything is working locally. From your project root folder, run the following commands to build the app and start the backend:
```sh
# building the app package
yarn workspace app start
# starting the backend api
yarn start-backend
```
4. Visit http://localhost:7007 to see the public app and validate that the _index.html_ response only contains a minimal application.
:::note
Regular app serving will always serve protected apps without authenticating.
:::
5. Finally, as soon as you log in, you will be redirected to the main app home page (inspect the page and see that the protected bundle was served from the app backend after the redirect).
That's it!
+1
View File
@@ -504,6 +504,7 @@
"tutorials/yarn-migration",
"tutorials/migrate-to-mui5",
"tutorials/auth-service-migration",
"tutorials/enable-public-entry",
"tutorials/setup-opentelemetry"
],
"Architecture Decision Records (ADRs)": [
+1
View File
@@ -48,6 +48,7 @@
"@backstage/plugin-airbrake": "workspace:^",
"@backstage/plugin-apache-airflow": "workspace:^",
"@backstage/plugin-api-docs": "workspace:^",
"@backstage/plugin-auth-react": "workspace:^",
"@backstage/plugin-azure-devops": "workspace:^",
"@backstage/plugin-azure-sites": "workspace:^",
"@backstage/plugin-badges": "workspace:^",
+2 -17
View File
@@ -21,6 +21,7 @@ import {
OAuthRequestDialog,
SignInPage,
} from '@backstage/core-components';
import { CookieAuthRedirect } from '@backstage/plugin-auth-react';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { providers } from '../src/identityProviders';
@@ -28,20 +29,9 @@ import {
configApiRef,
createApiFactory,
discoveryApiRef,
useApi,
} from '@backstage/core-plugin-api';
import { AuthProxyDiscoveryApi } from '../src/AuthProxyDiscoveryApi';
// TODO(Rugvip): make this available via some util, or maybe Utility API?
function readBasePath(configApi: typeof configApiRef.T) {
let { pathname } = new URL(
configApi.getOptionalString('app.baseUrl') ?? '/',
'http://sample.dev', // baseUrl can be specified as just a path
);
pathname = pathname.replace(/\/*$/, '');
return pathname;
}
const app = createApp({
apis: [
createApiFactory({
@@ -64,17 +54,12 @@ const app = createApp({
},
});
function RedirectToRoot() {
window.location.pathname = readBasePath(useApi(configApiRef));
return <div />;
}
const App = app.createRoot(
<>
<AlertDisplay transientTimeoutMs={2500} />
<OAuthRequestDialog />
<AppRouter>
<RedirectToRoot />
<CookieAuthRedirect />
</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> {
@@ -0,0 +1,49 @@
/*
* 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 express from 'express';
import request from 'supertest';
import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
import { createCookieAuthRefreshMiddleware } from './createCookieAuthRefreshMiddleware';
describe('createCookieAuthRefreshMiddleware', () => {
let app: express.Express;
beforeAll(async () => {
const auth = mockServices.auth();
const httpAuth = mockServices.httpAuth();
const router = createCookieAuthRefreshMiddleware({ auth, httpAuth });
app = express().use(router);
});
beforeEach(() => {
jest.resetAllMocks();
});
it('should issue the user cookie', async () => {
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()}`,
);
});
it('should remove the user cookie', async () => {
const response = await request(app).delete('/.backstage/auth/v1/cookie');
expect(response.status).toBe(204);
expect(response.header['set-cookie'][0]).toMatch('backstage-auth=');
});
});
@@ -0,0 +1,47 @@
/*
* 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 { AuthService, HttpAuthService } from '@backstage/backend-plugin-api';
import Router from 'express-promise-router';
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 { auth, httpAuth } = options;
const router = Router();
// Endpoint that sets the cookie for the user
router.get(WELL_KNOWN_COOKIE_PATH_V1, async (_, res) => {
const { expiresAt } = await httpAuth.issueUserCookie(res);
res.json({ expiresAt: expiresAt.toISOString() });
});
// Endpoint that removes the cookie for the user
router.delete(WELL_KNOWN_COOKIE_PATH_V1, async (_, res) => {
const credentials = await auth.getNoneCredentials();
await httpAuth.issueUserCookie(res, { credentials });
res.status(204).end();
});
return router;
}
@@ -14,16 +14,17 @@
* limitations under the License.
*/
import { Handler } from 'express';
import PromiseRouter from 'express-promise-router';
import {
coreServices,
createServiceFactory,
HttpRouterServiceAuthPolicy,
} from '@backstage/backend-plugin-api';
import { Handler } from 'express';
import PromiseRouter from 'express-promise-router';
import { createLifecycleMiddleware } from './createLifecycleMiddleware';
import { createCredentialsBarrier } from './createCredentialsBarrier';
import { createAuthIntegrationRouter } from './createAuthIntegrationRouter';
import { createCookieAuthRefreshMiddleware } from './createCookieAuthRefreshMiddleware';
/**
* @public
@@ -77,6 +78,7 @@ export const httpRouterServiceFactory = createServiceFactory(
router.use(createLifecycleMiddleware({ lifecycle }));
router.use(createAuthIntegrationRouter({ auth }));
router.use(credentialsBarrier.middleware);
router.use(createCookieAuthRefreshMiddleware({ auth, httpAuth }));
return {
use(handler: Handler): void {
+1 -1
View File
@@ -325,7 +325,7 @@ export interface HttpAuthService {
issueUserCookie(
res: Response_2,
options?: {
credentials?: BackstageCredentials<BackstageUserPrincipal>;
credentials?: BackstageCredentials;
},
): Promise<{
expiresAt: Date;
@@ -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,7 +30,7 @@ export interface HttpAuthService {
issueUserCookie(
res: Response,
options?: {
credentials?: BackstageCredentials<BackstageUserPrincipal>;
credentials?: BackstageCredentials;
},
): Promise<{ expiresAt: Date }>;
}
+9 -6
View File
@@ -70,12 +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,
publicSubPath: '/public',
}),
);
configs.push(await createConfig(publicPaths, commonConfigOptions));
}
const isCi = yn(process.env.CI, { default: false });
@@ -91,6 +86,14 @@ export async function buildBundle(options: BuildOptions) {
dereference: true,
filter: file => file !== paths.targetHtml,
});
// If we've got a separate public index entry point, copy public content there too
if (publicPaths) {
await fs.copy(paths.targetPublic, publicPaths.targetDist, {
dereference: true,
filter: file => file !== paths.targetHtml,
});
}
}
if (configSchema) {
+1 -7
View File
@@ -216,13 +216,7 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be
);
}
const compiler = publicPaths
? webpack([
config,
await createConfig(publicPaths, {
...commonConfigOptions,
publicSubPath: '/public',
}),
])
? webpack([config, await createConfig(publicPaths, commonConfigOptions)])
: webpack(config);
webpackServer = new WebpackDevServer(
@@ -18,7 +18,11 @@ import {
IdentityApi,
ProfileInfo,
BackstageUserIdentity,
ErrorApi,
DiscoveryApi,
FetchApi,
} from '@backstage/core-plugin-api';
import { startCookieAuthRefresh } from './startCookieAuthRefresh';
function mkError(thing: string) {
return new Error(
@@ -51,6 +55,8 @@ export class AppIdentityProxy implements IdentityApi {
private resolveTarget: (api: CompatibilityIdentityApi) => void = () => {};
private signOutTargetUrl = '/';
#cookieAuthSignOut?: () => Promise<void>;
constructor() {
this.waitForTarget = new Promise<CompatibilityIdentityApi>(resolve => {
this.resolveTarget = resolve;
@@ -124,6 +130,35 @@ export class AppIdentityProxy implements IdentityApi {
async signOut(): Promise<void> {
await this.waitForTarget.then(target => target.signOut());
await this.#cookieAuthSignOut?.();
window.location.href = this.signOutTargetUrl;
}
enableCookieAuth(ctx: {
errorApi: ErrorApi;
fetchApi: FetchApi;
discoveryApi: DiscoveryApi;
}) {
if (this.#cookieAuthSignOut) {
return;
}
const stopRefresh = startCookieAuthRefresh(ctx);
this.#cookieAuthSignOut = async () => {
stopRefresh();
// It is fine if we do NOT worry yet about deleting cookies for OTHER backends like techdocs
const appBaseUrl = await ctx.discoveryApi.getBaseUrl('app');
try {
await ctx.fetchApi.fetch(`${appBaseUrl}/.backstage/auth/v1/cookie`, {
method: 'DELETE',
});
} catch {
// Ignore the error for those who use static serving of the frontend
}
};
}
}
@@ -0,0 +1,171 @@
/*
* 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 { withLogCollector } from '@backstage/test-utils';
import { startCookieAuthRefresh } from './startCookieAuthRefresh';
describe('startCookieAuthRefresh', () => {
const discoveryApiMock = {
getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7000/app/api'),
};
const tenMinutesInMilliseconds = 10 * 60 * 1000;
const fetchApiMock = {
fetch: jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockImplementation(async () => ({
expiresAt: new Date(
Date.now() + tenMinutesInMilliseconds,
).toISOString(),
})),
}),
};
const errorApiMock = {
post: jest.fn(),
error$: jest.fn(),
};
const mockOptions = {
errorApi: errorApiMock,
fetchApi: fetchApiMock,
discoveryApi: discoveryApiMock,
};
beforeEach(() => {
jest.useFakeTimers({ now: 0 });
jest.clearAllMocks();
});
afterEach(() => {
jest.useRealTimers();
});
it('should refresh cookie', async () => {
const postMessage = jest.fn();
global.BroadcastChannel = jest.fn().mockImplementation(() => ({
postMessage,
addEventListener() {},
removeEventListener() {},
close() {},
}));
const stop = startCookieAuthRefresh(mockOptions);
await jest.advanceTimersByTimeAsync(0);
expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1);
expect(fetchApiMock.fetch).toHaveBeenCalledWith(
'http://localhost:7000/app/api/.backstage/auth/v1/cookie',
{ credentials: 'include' },
);
expect(postMessage).toHaveBeenCalledTimes(1);
expect(postMessage).toHaveBeenCalledWith({
action: 'COOKIE_REFRESH_SUCCESS',
payload: { expiresAt: new Date(tenMinutesInMilliseconds).toISOString() },
});
// Should never refresh within the first 5 minutes
await jest.advanceTimersByTimeAsync(tenMinutesInMilliseconds / 2);
expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1);
expect(postMessage).toHaveBeenCalledTimes(1);
// Should always refresh within the next 5
await jest.advanceTimersByTimeAsync(tenMinutesInMilliseconds / 2);
expect(fetchApiMock.fetch).toHaveBeenCalledTimes(2);
expect(postMessage).toHaveBeenCalledTimes(2);
stop();
});
it('should bump refresh when receiving a broadcast message', async () => {
let messageListener: undefined | ((params: any) => void) = undefined;
global.BroadcastChannel = jest.fn().mockImplementation(() => ({
postMessage() {},
addEventListener: jest.fn().mockImplementation((type, listener) => {
expect(type).toBe('message');
messageListener = listener;
}),
removeEventListener() {},
close() {},
}));
const stop = startCookieAuthRefresh(mockOptions);
await jest.advanceTimersByTimeAsync(0);
expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1);
expect(fetchApiMock.fetch).toHaveBeenCalledWith(
'http://localhost:7000/app/api/.backstage/auth/v1/cookie',
{ credentials: 'include' },
);
messageListener!({
data: {
action: 'COOKIE_REFRESH_SUCCESS',
payload: {
expiresAt: new Date(tenMinutesInMilliseconds * 2).toISOString(),
},
},
});
// Usually the refresh would happen after 10 minutes, but now we need to wait 20 instead
await jest.advanceTimersByTimeAsync(tenMinutesInMilliseconds);
expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(tenMinutesInMilliseconds);
expect(fetchApiMock.fetch).toHaveBeenCalledTimes(2);
stop();
});
it('should ignore first error, but post the second one', async () => {
fetchApiMock.fetch.mockRejectedValueOnce(new Error('Failed to get cookie'));
const { error } = await withLogCollector(['error'], async () => {
const stop = startCookieAuthRefresh(mockOptions);
await 'a tick';
expect(fetchApiMock.fetch).toHaveBeenCalledTimes(1);
expect(fetchApiMock.fetch).toHaveBeenCalledWith(
'http://localhost:7000/app/api/.backstage/auth/v1/cookie',
{ credentials: 'include' },
);
expect(errorApiMock.post).toHaveBeenCalledTimes(0);
fetchApiMock.fetch.mockRejectedValueOnce(
new Error('Failed to get cookie again'),
);
// Backoff time for first error
await jest.advanceTimersByTimeAsync(5000);
expect(fetchApiMock.fetch).toHaveBeenCalledTimes(2);
expect(errorApiMock.post).toHaveBeenCalledTimes(1);
expect(errorApiMock.post).toHaveBeenCalledWith(
new Error('Session refresh failed, see developer console for details'),
);
stop();
});
expect(error).toEqual(['Session cookie refresh failed']);
});
});
@@ -0,0 +1,157 @@
/*
* 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 { DiscoveryApi, ErrorApi, FetchApi } from '@backstage/core-plugin-api';
const PLUGIN_ID = 'app';
const CHANNEL_ID = `${PLUGIN_ID}-auth-cookie-expires-at`;
const MIN_BASE_DELAY_MS = 5 * 60_000;
const ERROR_BACKOFF_START = 5_000;
const ERROR_BACKOFF_FACTOR = 2;
const ERROR_BACKOFF_MAX = 5 * 60_000;
// Messaging implementation and IDs must match
// plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx
export function startCookieAuthRefresh({
discoveryApi,
fetchApi,
errorApi,
}: {
discoveryApi: DiscoveryApi;
fetchApi: FetchApi;
errorApi: ErrorApi;
}) {
let stopped = false;
let timeout: NodeJS.Timeout | undefined;
let firstError = true;
let errorBackoff = ERROR_BACKOFF_START;
const channel =
'BroadcastChannel' in window ? new BroadcastChannel(CHANNEL_ID) : undefined;
// Randomize the refreshing margin with a margin of 1-4 minutes to avoid all tabs refreshing at the same time
const getDelay = (expiresAt: number) => {
const margin = (1 + 3 * Math.random()) * 60000;
const delay = Math.max(expiresAt - Date.now(), MIN_BASE_DELAY_MS) - margin;
return delay;
};
const refresh = async () => {
try {
const baseUrl = await discoveryApi.getBaseUrl(PLUGIN_ID);
const requestUrl = `${baseUrl}/.backstage/auth/v1/cookie`;
const res = await fetchApi.fetch(requestUrl, {
credentials: 'include',
});
if (!res.ok) {
throw new Error(
`Request failed with status ${res.status} ${res.statusText}, see request towards ${requestUrl} for more details`,
);
}
const data = await res.json();
if (!data.expiresAt) {
throw new Error('No expiration date in response');
}
const expiresAt = Date.parse(data.expiresAt);
if (Number.isNaN(expiresAt)) {
throw new Error('Invalid expiration date in response');
}
firstError = true;
channel?.postMessage({
action: 'COOKIE_REFRESH_SUCCESS',
payload: { expiresAt: new Date(expiresAt).toISOString() },
});
scheduleRefresh(getDelay(expiresAt));
} catch (error) {
// Ignore the first error after successful requests
if (firstError) {
firstError = false;
errorBackoff = ERROR_BACKOFF_START;
} else {
errorBackoff = Math.min(
ERROR_BACKOFF_MAX,
errorBackoff * ERROR_BACKOFF_FACTOR,
);
// eslint-disable-next-line no-console
console.error('Session cookie refresh failed', error);
errorApi.post(
new Error(
`Session refresh failed, see developer console for details`,
),
);
}
scheduleRefresh(errorBackoff);
}
};
const onMessage = (
event: MessageEvent<
| {
action: 'COOKIE_REFRESH_SUCCESS';
payload: { expiresAt: string };
}
| object
>,
) => {
const { data } = event;
if (data === null || typeof data !== 'object') {
return;
}
if ('action' in data && data.action === 'COOKIE_REFRESH_SUCCESS') {
const expiresAt = Date.parse(data.payload.expiresAt);
if (Number.isNaN(expiresAt)) {
// eslint-disable-next-line no-console
console.warn(
'Received invalid expiration from session refresh channel',
);
return;
}
scheduleRefresh(getDelay(expiresAt));
}
};
function scheduleRefresh(delayMs: number) {
if (stopped) {
return;
}
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(refresh, delayMs);
}
channel?.addEventListener('message', onMessage);
refresh();
return () => {
stopped = true;
if (timeout) {
clearTimeout(timeout);
}
channel?.removeEventListener('message', onMessage);
channel?.close();
};
}
@@ -20,7 +20,8 @@ import {
renderWithEffects,
withLogCollector,
} from '@backstage/test-utils';
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { PropsWithChildren, ReactNode } from 'react';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import {
@@ -36,7 +37,11 @@ import {
analyticsApiRef,
useApi,
errorApiRef,
fetchApiRef,
discoveryApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import { AppRouter } from './AppRouter';
import { AppManager } from './AppManager';
import { AppComponents, AppIcons } from './types';
import { FeatureFlagged } from '../routing/FeatureFlagged';
@@ -817,4 +822,67 @@ describe('Integration Test', () => {
},
);
});
it('should clear app cookie when the user logs out', async () => {
const meta = global.document.createElement('meta');
meta.name = 'backstage-app-mode';
meta.content = 'protected';
global.document.head.appendChild(meta);
const fetchApiMock = {
fetch: jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
}),
}),
};
const discoveryApiMock = {
getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7007/app'),
};
const app = new AppManager({
icons,
themes,
components,
configLoader: async () => [],
defaultApis: [
noopErrorApi,
createApiFactory({
api: fetchApiRef,
deps: {},
factory: () => fetchApiMock,
}),
createApiFactory({
api: discoveryApiRef,
deps: {},
factory: () => discoveryApiMock,
}),
],
});
const SignOutButton = () => {
const identityApi = useApi(identityApiRef);
return <button onClick={() => identityApi.signOut()}>Sign Out</button>;
};
const Root = app.createRoot(
<AppRouter>
<meta name="backstage-app-mode" content="protected" />
<SignOutButton />
</AppRouter>,
);
await renderWithEffects(<Root />);
await userEvent.click(screen.getByText('Sign Out'));
await waitFor(() =>
expect(fetchApiMock.fetch).toHaveBeenCalledWith(
'http://localhost:7007/app/.backstage/auth/v1/cookie',
{ method: 'DELETE' },
),
);
global.document.head.removeChild(meta);
});
});
+23 -1
View File
@@ -42,6 +42,9 @@ import {
identityApiRef,
BackstagePlugin,
FeatureFlag,
fetchApiRef,
discoveryApiRef,
errorApiRef,
} from '@backstage/core-plugin-api';
import {
AppLanguageApi,
@@ -86,6 +89,7 @@ import { AppRouter, getBasePath } from './AppRouter';
import { AppLanguageSelector } from '../apis/implementations/AppLanguageApi';
import { I18nextTranslationApi } from '../apis/implementations/TranslationApi';
import { overrideBaseUrlConfigs } from './overrideBaseUrlConfigs';
import { isProtectedApp } from './isProtectedApp';
type CompatiblePlugin =
| BackstagePlugin
@@ -354,8 +358,26 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be
const { ThemeProvider = AppThemeProvider, Progress } = this.components;
const apis = this.getApiHolder();
if (isProtectedApp()) {
const errorApi = apis.get(errorApiRef);
const fetchApi = apis.get(fetchApiRef);
const discoveryApi = apis.get(discoveryApiRef);
if (!errorApi || !fetchApi || !discoveryApi) {
throw new Error(
'App is running in protected mode but missing required APIs',
);
}
this.appIdentityProxy.enableCookieAuth({
errorApi,
fetchApi,
discoveryApi,
});
}
return (
<ApiProvider apis={this.getApiHolder()}>
<ApiProvider apis={apis}>
<AppContextProvider appContext={appContext}>
<ThemeProvider>
<RoutingProvider
@@ -0,0 +1,21 @@
/*
* 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 function isProtectedApp() {
const element = document.querySelector('meta[name="backstage-app-mode"]');
const appMode = element?.getAttribute('content') ?? 'public';
return appMode === 'protected';
}
@@ -43,6 +43,9 @@ import {
featureFlagsApiRef,
identityApiRef,
AppTheme,
errorApiRef,
discoveryApiRef,
fetchApiRef,
} from '@backstage/core-plugin-api';
import { getAvailableFeatures } from './discovery';
import {
@@ -54,6 +57,8 @@ import {
// TODO: Get rid of all of these
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { isProtectedApp } from '../../../core-app-api/src/app/isProtectedApp';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { AppThemeProvider } from '../../../core-app-api/src/app/AppThemeProvider';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy';
@@ -281,6 +286,22 @@ export function createSpecializedApp(options?: {
options?.icons,
);
if (isProtectedApp()) {
const discoveryApi = apiHolder.get(discoveryApiRef);
const errorApi = apiHolder.get(errorApiRef);
const fetchApi = apiHolder.get(fetchApiRef);
if (!discoveryApi || !errorApi || !fetchApi) {
throw new Error(
'App is running in protected mode but missing required APIs',
);
}
appIdentityProxy.enableCookieAuth({
discoveryApi,
errorApi,
fetchApi,
});
}
const featureFlagApi = apiHolder.get(featureFlagsApiRef);
if (featureFlagApi) {
for (const feature of features) {
+6
View File
@@ -3,9 +3,11 @@
> 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 { Config } from '@backstage/config';
import { ConfigSchema } from '@backstage/config-loader';
import express from 'express';
import { HttpAuthService } from '@backstage/backend-plugin-api';
import { Logger } from 'winston';
import { PluginDatabaseManager } from '@backstage/backend-common';
@@ -16,10 +18,14 @@ export function createRouter(options: RouterOptions): Promise<express.Router>;
export interface RouterOptions {
appPackageName: string;
// (undocumented)
auth?: AuthService;
// (undocumented)
config: Config;
database?: PluginDatabaseManager;
disableConfigInjection?: boolean;
// (undocumented)
httpAuth?: HttpAuthService;
// (undocumented)
logger: Logger;
schema?: ConfigSchema;
staticFallbackHandler?: express.Handler;
@@ -0,0 +1,38 @@
/*
* Copyright 2021 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.
*/
// @ts-check
/**
* @param {import('knex').Knex} knex
*/
exports.up = async function up(knex) {
await knex.schema.alterTable('static_assets_cache', table => {
// The namespace is used to allow operations on asset groups, e.g. delete all assets in a namespace
table.string('namespace').comment('The namespace of the file');
table.index('namespace', 'static_asset_cache_namespace_idx');
});
};
/**
* @param {import('knex').Knex} knex
*/
exports.down = async function down(knex) {
await knex.schema.alterTable('static_assets_cache', table => {
table.dropIndex([], 'static_asset_cache_namespace_idx');
table.dropColumn('namespace');
});
};
+2
View File
@@ -49,7 +49,9 @@
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/config-loader": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/plugin-app-node": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/types": "workspace:^",
"@types/express": "^4.17.6",
"express": "^4.17.1",
@@ -177,4 +177,44 @@ describe('StaticAssetsStore', () => {
await expect(store.getAsset('old')).resolves.toBeUndefined();
},
);
it.each(databases.eachSupportedId())(
'should isolate assets in namespace, %p',
async databaseId => {
const knex = await databases.init(databaseId);
const database = createDatabaseManager(knex);
const store = await StaticAssetsStore.create({
logger,
database,
});
const otherStore = store.withNamespace('other');
await store.storeAssets([
{
path: 'foo',
content: async () => Buffer.alloc(0),
},
]);
await otherStore.storeAssets([
{
path: 'bar',
content: async () => Buffer.alloc(0),
},
]);
await expect(store.getAsset('foo')).resolves.toBeDefined();
await expect(store.getAsset('bar')).resolves.not.toBeDefined();
await expect(otherStore.getAsset('foo')).resolves.not.toBeDefined();
await expect(otherStore.getAsset('bar')).resolves.toBeDefined();
await store.trimAssets({ maxAgeSeconds: 0 });
await expect(store.getAsset('foo')).resolves.not.toBeDefined();
await expect(otherStore.getAsset('bar')).resolves.toBeDefined();
await otherStore.trimAssets({ maxAgeSeconds: 0 });
await expect(otherStore.getAsset('bar')).resolves.not.toBeDefined();
},
);
});
@@ -32,6 +32,7 @@ const migrationsDir = resolvePackagePath(
interface StaticAssetRow {
path: string;
content: Buffer;
namespace: string | null;
last_modified_at: Date;
}
@@ -49,6 +50,7 @@ export interface StaticAssetsStoreOptions {
export class StaticAssetsStore implements StaticAssetProvider {
#db: Knex;
#logger: Logger;
#namespace: string | null;
static async create(options: StaticAssetsStoreOptions) {
const { database } = options;
@@ -63,9 +65,17 @@ export class StaticAssetsStore implements StaticAssetProvider {
return new StaticAssetsStore(client, options.logger);
}
private constructor(client: Knex, logger: Logger) {
private constructor(client: Knex, logger: Logger, namespace?: string) {
this.#db = client;
this.#logger = logger;
this.#namespace = namespace ?? null;
}
/**
* Creates a new store with the provided namespace, using the same underlying storage.
*/
withNamespace(namespace: string): StaticAssetsStore {
return new StaticAssetsStore(this.#db, this.#logger, namespace);
}
/**
@@ -75,12 +85,12 @@ export class StaticAssetsStore implements StaticAssetProvider {
* updated, but the contents will not.
*/
async storeAssets(assets: StaticAssetInput[]) {
const existingRows = await this.#db<StaticAssetRow>(
'static_assets_cache',
).whereIn(
'path',
assets.map(a => a.path),
);
const existingRows = await this.#db<StaticAssetRow>('static_assets_cache')
.where('namespace', this.#namespace)
.whereIn(
'path',
assets.map(a => a.path),
);
const existingAssetPaths = new Set(existingRows.map(r => r.path));
const [modified, added] = partition(assets, asset =>
@@ -95,6 +105,7 @@ export class StaticAssetsStore implements StaticAssetProvider {
.update({
last_modified_at: this.#db.fn.now(),
})
.where('namespace', this.#namespace)
.whereIn(
'path',
modified.map(a => a.path),
@@ -107,6 +118,7 @@ export class StaticAssetsStore implements StaticAssetProvider {
.insert({
path: asset.path,
content: await asset.content(),
namespace: this.#namespace,
})
.onConflict('path')
.ignore();
@@ -119,6 +131,7 @@ export class StaticAssetsStore implements StaticAssetProvider {
async getAsset(path: string): Promise<StaticAsset | undefined> {
const [row] = await this.#db<StaticAssetRow>('static_assets_cache').where({
path,
namespace: this.#namespace,
});
if (!row) {
return undefined;
@@ -151,6 +164,7 @@ export class StaticAssetsStore implements StaticAssetProvider {
]);
}
await this.#db<StaticAssetRow>('static_assets_cache')
.where('namespace', this.#namespace)
.where('last_modified_at', '<=', lastModifiedInterval)
.delete();
}
+7 -1
View File
@@ -65,8 +65,10 @@ export const appPlugin = createBackendPlugin({
config: coreServices.rootConfig,
database: coreServices.database,
httpRouter: coreServices.httpRouter,
auth: coreServices.auth,
httpAuth: coreServices.httpAuth,
},
async init({ logger, config, database, httpRouter }) {
async init({ logger, config, database, httpRouter, auth, httpAuth }) {
const appPackageName =
config.getOptionalString('app.packageName') ?? 'app';
@@ -76,11 +78,15 @@ export const appPlugin = createBackendPlugin({
logger: winstonLogger,
config,
database,
auth,
httpAuth,
appPackageName,
staticFallbackHandler,
schema,
});
httpRouter.use(router);
// Access control is handled within the router
httpRouter.addAuthPolicy({
allow: 'unauthenticated',
path: '/',
+167 -24
View File
@@ -19,7 +19,7 @@ import {
PluginDatabaseManager,
resolvePackagePath,
} from '@backstage/backend-common';
import { Config } from '@backstage/config';
import { AppConfig, Config } from '@backstage/config';
import helmet from 'helmet';
import express from 'express';
import Router from 'express-promise-router';
@@ -38,6 +38,8 @@ import {
CACHE_CONTROL_REVALIDATE_CACHE,
} from '../lib/headers';
import { ConfigSchema } from '@backstage/config-loader';
import { AuthService, HttpAuthService } from '@backstage/backend-plugin-api';
import { AuthenticationError } from '@backstage/errors';
// express uses mime v1 while we only have types for mime v2
type Mime = { lookup(arg0: string): string };
@@ -46,6 +48,8 @@ type Mime = { lookup(arg0: string): string };
export interface RouterOptions {
config: Config;
logger: Logger;
auth?: AuthService;
httpAuth?: HttpAuthService;
/**
* If a database is provided it will be used to cache previously deployed static assets.
@@ -99,7 +103,14 @@ export interface RouterOptions {
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { config, logger, appPackageName, staticFallbackHandler } = options;
const {
config,
logger,
appPackageName,
staticFallbackHandler,
auth,
httpAuth,
} = options;
const disableConfigInjection =
options.disableConfigInjection ??
@@ -123,26 +134,162 @@ export async function createRouter(
logger.info(`Serving static app content from ${appDistDir}`);
let injectedConfigPath: string | undefined;
if (!disableConfigInjection) {
const appConfigs = await readConfigs({
config,
appDistDir,
env: process.env,
schema: options.schema,
});
const appConfigs = disableConfigInjection
? undefined
: await readConfigs({
config,
appDistDir,
env: process.env,
});
injectedConfigPath = await injectConfig({ appConfigs, logger, staticDir });
}
const assetStore =
options.database && !disableStaticFallbackCache
? await StaticAssetsStore.create({
logger,
database: options.database,
})
: undefined;
const router = Router();
router.use(helmet.frameguard({ action: 'deny' }));
const publicDistDir = resolvePath(appDistDir, 'public');
const enablePublicEntryPoint =
(await fs.pathExists(publicDistDir)) && auth && httpAuth;
if (enablePublicEntryPoint && auth && httpAuth) {
logger.info(
`App is running in protected mode, serving public content from ${publicDistDir}`,
);
const publicRouter = Router();
publicRouter.use(async (req, res, next) => {
try {
const credentials = await httpAuth.credentials(req, {
allow: ['user', 'service', 'none'],
allowLimitedAccess: true,
});
if (credentials.principal.type === 'none') {
next();
} else {
next('router');
}
} catch {
// If we fail to authenticate, make sure the session cookie is cleared
// and continue as unauthenticated. If the user is logged in they will
// immediately be redirected back to the protected app via the POST.
await httpAuth.issueUserCookie(res, {
credentials: await auth.getNoneCredentials(),
});
next();
}
});
publicRouter.post(
'*',
express.urlencoded({ extended: true }),
async (req, res, next) => {
if (req.body.type === 'sign-in') {
const credentials = await auth.authenticate(req.body.token);
if (!auth.isPrincipal(credentials, 'user')) {
throw new AuthenticationError('Invalid token, not a user');
}
await httpAuth.issueUserCookie(res, {
credentials,
});
// Resume as if it was a GET request towards the outer protected router, serving index.html
req.method = 'GET';
next('router');
} else {
throw new Error('Invalid POST request to /');
}
},
);
publicRouter.use(
await createEntryPointRouter({
appMode: 'public',
logger: logger.child({ entry: 'public' }),
rootDir: publicDistDir,
assetStore: assetStore?.withNamespace('public'),
appConfigs, // TODO(Rugvip): We should not be including the full config here
}),
);
router.use(publicRouter);
}
router.use(
await createEntryPointRouter({
appMode: enablePublicEntryPoint ? 'protected' : 'public',
logger: logger.child({ entry: 'main' }),
rootDir: appDistDir,
assetStore,
staticFallbackHandler,
appConfigs,
}),
);
return router;
}
async function injectAppMode(options: {
appMode: 'public' | 'protected';
rootDir: string;
}) {
const { appMode, rootDir } = options;
const content = await fs.readFile(resolvePath(rootDir, 'index.html'), 'utf8');
const metaTag = `<meta name="backstage-app-mode" content="${appMode}">`;
let newContent;
if (content.includes('backstage-app-mode')) {
newContent = content.replace(
/<meta name="backstage-app-mode" content="[^"]+">/,
metaTag,
);
} else {
newContent = content.replace(/<head>/, `<head>${metaTag}`);
}
await fs.writeFile(resolvePath(rootDir, 'index.html'), newContent, 'utf8');
}
async function createEntryPointRouter({
logger,
rootDir,
assetStore,
staticFallbackHandler,
appMode,
appConfigs,
}: {
logger: Logger;
rootDir: string;
assetStore?: StaticAssetsStore;
staticFallbackHandler?: express.Handler;
appMode: 'public' | 'protected';
appConfigs?: AppConfig[];
}) {
const staticDir = resolvePath(rootDir, 'static');
const injectedConfigPath =
appConfigs && (await injectConfig({ appConfigs, logger, staticDir }));
await injectAppMode({ appMode, rootDir });
const router = Router();
// Use a separate router for static content so that a fallback can be provided by backend
const staticRouter = Router();
staticRouter.use(
express.static(resolvePath(appDistDir, 'static'), {
express.static(staticDir, {
setHeaders: (res, path) => {
if (path === injectedConfigPath) {
res.setHeader('Cache-Control', CACHE_CONTROL_REVALIDATE_CACHE);
@@ -153,18 +300,13 @@ export async function createRouter(
}),
);
if (options.database && !disableStaticFallbackCache) {
const store = await StaticAssetsStore.create({
logger,
database: options.database,
});
if (assetStore) {
const assets = await findStaticAssets(staticDir);
await store.storeAssets(assets);
await assetStore.storeAssets(assets);
// Remove any assets that are older than 7 days
await store.trimAssets({ maxAgeSeconds: 60 * 60 * 24 * 7 });
await assetStore.trimAssets({ maxAgeSeconds: 60 * 60 * 24 * 7 });
staticRouter.use(createStaticAssetMiddleware(store));
staticRouter.use(createStaticAssetMiddleware(assetStore));
}
if (staticFallbackHandler) {
@@ -174,7 +316,7 @@ export async function createRouter(
router.use('/static', staticRouter);
router.use(
express.static(appDistDir, {
express.static(rootDir, {
setHeaders: (res, path) => {
// The Cache-Control header instructs the browser to not cache html files since it might
// link to static assets from recently deployed versions.
@@ -186,8 +328,9 @@ export async function createRouter(
},
}),
);
router.get('/*', (_req, res) => {
res.sendFile(resolvePath(appDistDir, 'index.html'), {
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.
+5 -5
View File
@@ -3,8 +3,12 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { default as React_2 } from 'react';
import { ReactNode } from 'react';
// @public
export function CookieAuthRedirect(): React_2.JSX.Element | null;
// @public
export function CookieAuthRefreshProvider(
props: CookieAuthRefreshProviderProps,
@@ -13,15 +17,11 @@ export function CookieAuthRefreshProvider(
// @public
export type CookieAuthRefreshProviderProps = {
pluginId: string;
path?: string;
children: ReactNode;
};
// @public
export function useCookieAuthRefresh(options: {
pluginId: string;
path?: string;
}):
export function useCookieAuthRefresh(options: { pluginId: string }):
| {
status: 'loading';
}
+2 -1
View File
@@ -44,7 +44,8 @@
"@backstage/test-utils": "workspace:^",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.0.0"
"@testing-library/user-event": "^14.0.0",
"msw": "^1.0.0"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0 || ^18.0.0"
@@ -0,0 +1,64 @@
/*
* 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 { TestApiProvider, renderInTestApp } from '@backstage/test-utils';
import { identityApiRef } from '@backstage/core-plugin-api';
import { CookieAuthRedirect } from './CookieAuthRedirect';
describe('CookieAuthRedirect', () => {
const identityApiMock = { getCredentials: jest.fn() };
beforeEach(() => {
jest.clearAllMocks();
});
it('should render an error message if token is not available', async () => {
identityApiMock.getCredentials.mockResolvedValue({ token: undefined });
await renderInTestApp(
<TestApiProvider apis={[[identityApiRef, identityApiMock]]}>
<CookieAuthRedirect />
</TestApiProvider>,
);
await waitFor(() =>
expect(
screen.getByText(
'An error occurred: Expected Backstage token in sign-in response',
),
).toBeInTheDocument(),
);
});
it('should render a form with token if token is available', async () => {
identityApiMock.getCredentials.mockResolvedValue({ token: 'test-token' });
await renderInTestApp(
<TestApiProvider apis={[[identityApiRef, identityApiMock]]}>
<CookieAuthRedirect />
</TestApiProvider>,
);
await waitFor(() =>
expect(screen.getByText('Continue')).toBeInTheDocument(),
);
expect(screen.getByDisplayValue('sign-in')).toBeInTheDocument();
expect(screen.getByDisplayValue('test-token')).toBeInTheDocument();
});
});
@@ -0,0 +1,57 @@
/*
* 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 { identityApiRef, useApi } from '@backstage/core-plugin-api';
import { useAsync, useMountEffect } from '@react-hookz/web';
/**
* @public
* A component that redirects to the page an user was trying to access before sign-in.
*/
export function CookieAuthRedirect() {
const identityApi = useApi(identityApiRef);
const [state, actions] = useAsync(async () => {
const { token } = await identityApi.getCredentials();
if (!token) {
throw new Error('Expected Backstage token in sign-in response');
}
return token;
});
useMountEffect(actions.execute);
if (state.status === 'error' && state.error) {
return <>An error occurred: {state.error.message}</>;
}
if (state.status === 'success' && state.result) {
return (
<form
ref={form => form?.submit()}
action={window.location.href}
method="POST"
>
<input type="hidden" name="type" value="sign-in" />
<input type="hidden" name="token" value={state.result} />
<input type="submit" value="Continue" />
</form>
);
}
return null;
}
@@ -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 { CookieAuthRedirect } from './CookieAuthRedirect';
@@ -34,7 +34,7 @@ describe('CookieAuthRefreshProvider', () => {
const discoveryApiMock = {
getBaseUrl: jest
.fn()
.mockResolvedValue('http://localhost:7000/techdocs/api'),
.mockResolvedValue('http://localhost:7000/api/techdocs'),
};
function getExpiresAtInFuture() {
@@ -119,7 +119,7 @@ describe('CookieAuthRefreshProvider', () => {
await waitFor(() =>
expect(fetchApiMock.fetch).toHaveBeenCalledWith(
'http://localhost:7000/techdocs/api/cookie',
'http://localhost:7000/api/techdocs/.backstage/auth/v1/cookie',
{ credentials: 'include' },
),
);
@@ -17,7 +17,7 @@
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 Button from '@material-ui/core/Button';
import { useCookieAuthRefresh } from '../../hooks';
/**
@@ -27,8 +27,6 @@ import { useCookieAuthRefresh } from '../../hooks';
export type CookieAuthRefreshProviderProps = {
// The plugin ID used for discovering the API origin
pluginId: string;
// The path used for calling the refresh cookie endpoint, default to '/cookie'
path?: string;
// The children to render when the refresh is successful
children: ReactNode;
};
@@ -17,4 +17,5 @@
// The index file in ./components/ is typically responsible for selecting
// which components are public API and should be exported from the package.
export * from './CookieAuthRedirect';
export * from './CookieAuthRefreshProvider';
@@ -24,7 +24,7 @@ describe('useCookieAuthRefresh', () => {
const discoveryApiMock = {
getBaseUrl: jest
.fn()
.mockResolvedValue('http://localhost:7000/techdocs/api'),
.mockResolvedValue('http://localhost:7000/api/techdocs'),
};
const now = 1710316886171;
@@ -236,7 +236,7 @@ describe('useCookieAuthRefresh', () => {
await waitFor(() =>
expect(fetchApiMock.fetch).toHaveBeenCalledWith(
'http://localhost:7000/techdocs/api/cookie',
'http://localhost:7000/api/techdocs/.backstage/auth/v1/cookie',
{ credentials: 'include' },
),
);
@@ -23,6 +23,8 @@ import {
import { useAsync, useMountEffect } from '@react-hookz/web';
import { ResponseError } from '@backstage/errors';
const COOKIE_PATH = '/.backstage/auth/v1/cookie';
/**
* @public
* A hook that will refresh the cookie when it is about to expire.
@@ -31,13 +33,11 @@ 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 '/cookie'
path?: string;
}):
| { status: 'loading' }
| { status: 'error'; error: Error; retry: () => void }
| { status: 'success'; data: { expiresAt: string } } {
const { pluginId, path = '/cookie' } = options ?? {};
const { pluginId } = options ?? {};
const fetchApi = useApi(fetchApiRef);
const discoveryApi = useApi(discoveryApiRef);
@@ -49,7 +49,7 @@ export function useCookieAuthRefresh(options: {
const [state, actions] = useAsync<{ expiresAt: string }>(async () => {
const apiOrigin = await discoveryApi.getBaseUrl(pluginId);
const requestUrl = `${apiOrigin}${path}`;
const requestUrl = `${apiOrigin}${COOKIE_PATH}`;
const response = await fetchApi.fetch(`${requestUrl}`, {
credentials: 'include',
});
@@ -322,12 +322,6 @@ export async function createRouter(
// Route middleware which serves files from the storage set in the publisher.
router.use('/static/docs', publisher.docsRouter());
// Endpoint that sets the cookie for the user
router.get('/cookie', async (_, res) => {
const { expiresAt } = await httpAuth.issueUserCookie(res);
res.json({ expiresAt: expiresAt.toISOString() });
});
return router;
}
+11 -7
View File
@@ -4691,7 +4691,9 @@ __metadata:
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/config-loader": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/plugin-app-node": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
"@backstage/types": "workspace:^"
"@types/express": ^4.17.6
"@types/supertest": ^2.0.8
@@ -5116,6 +5118,7 @@ __metadata:
"@testing-library/react": ^14.0.0
"@testing-library/user-event": ^14.0.0
"@types/react": ^16.13.1 || ^17.0.0 || ^18.0.0
msw: ^1.0.0
peerDependencies:
react: ^16.13.1 || ^17.0.0 || ^18.0.0
languageName: unknown
@@ -25893,7 +25896,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:
@@ -26299,7 +26302,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
@@ -27473,6 +27476,7 @@ __metadata:
"@backstage/plugin-airbrake": "workspace:^"
"@backstage/plugin-apache-airflow": "workspace:^"
"@backstage/plugin-api-docs": "workspace:^"
"@backstage/plugin-auth-react": "workspace:^"
"@backstage/plugin-azure-devops": "workspace:^"
"@backstage/plugin-azure-sites": "workspace:^"
"@backstage/plugin-badges": "workspace:^"
@@ -30078,14 +30082,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