diff --git a/.changeset/fair-onions-rule.md b/.changeset/fair-onions-rule.md new file mode 100644 index 0000000000..8d082b1823 --- /dev/null +++ b/.changeset/fair-onions-rule.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Fix the bundle public subpath configuration. diff --git a/.changeset/fifty-cameras-shake.md b/.changeset/fifty-cameras-shake.md new file mode 100644 index 0000000000..2ba4d9e277 --- /dev/null +++ b/.changeset/fifty-cameras-shake.md @@ -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. diff --git a/.changeset/forty-elephants-count.md b/.changeset/forty-elephants-count.md new file mode 100644 index 0000000000..d78f27f9eb --- /dev/null +++ b/.changeset/forty-elephants-count.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs-backend': patch +--- + +Use the default cookie endpoints added automatically when a cookie policy is set. diff --git a/.changeset/nine-rabbits-exist.md b/.changeset/nine-rabbits-exist.md new file mode 100644 index 0000000000..09dadeb69b --- /dev/null +++ b/.changeset/nine-rabbits-exist.md @@ -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. diff --git a/.changeset/smooth-squids-design.md b/.changeset/smooth-squids-design.md new file mode 100644 index 0000000000..290cd9b078 --- /dev/null +++ b/.changeset/smooth-squids-design.md @@ -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. diff --git a/.changeset/tall-rats-pull.md b/.changeset/tall-rats-pull.md new file mode 100644 index 0000000000..ca5ce8412f --- /dev/null +++ b/.changeset/tall-rats-pull.md @@ -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. diff --git a/.changeset/weak-planets-move.md b/.changeset/weak-planets-move.md new file mode 100644 index 0000000000..8831c8c05e --- /dev/null +++ b/.changeset/weak-planets-move.md @@ -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. diff --git a/.changeset/wet-swans-type.md b/.changeset/wet-swans-type.md new file mode 100644 index 0000000000..72499c26c8 --- /dev/null +++ b/.changeset/wet-swans-type.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-app-api': patch +--- + +Automatically creates a get and delete cookie endpoint when a `user-cookie` policy is added. diff --git a/docs/tutorials/enable-public-entry.md b/docs/tutorials/enable-public-entry.md new file mode 100644 index 0000000000..6282f66418 --- /dev/null +++ b/docs/tutorials/enable-public-entry.md @@ -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 ( + + ); + }, + }, + }); + + const App = app.createRoot( + <> + + + + {/* This component triggers an authenticated redirect to the main app, while staying on the same URL */} + + + , + ); + + ReactDOM.createRoot(document.getElementById('root')!).render(); + ``` + + :::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! diff --git a/microsite/sidebars.json b/microsite/sidebars.json index 7d941e5821..62e5774098 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -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)": [ diff --git a/packages/app/package.json b/packages/app/package.json index 3980d6b507..9401ca89be 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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:^", diff --git a/packages/app/src/index-public-experimental.tsx b/packages/app/src/index-public-experimental.tsx index 9e5f89b4c2..20f111dc2b 100644 --- a/packages/app/src/index-public-experimental.tsx +++ b/packages/app/src/index-public-experimental.tsx @@ -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
; -} - const App = app.createRoot( <> - + , ); diff --git a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts index 06e65a1413..fe927eaa5d 100644 --- a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts @@ -181,6 +181,13 @@ class DefaultHttpAuthService implements HttpAuthService { let credentials: BackstageCredentials; 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 { diff --git a/packages/backend-app-api/src/services/implementations/httpRouter/createCookieAuthRefreshMiddleware.test.ts b/packages/backend-app-api/src/services/implementations/httpRouter/createCookieAuthRefreshMiddleware.test.ts new file mode 100644 index 0000000000..6ecd52bbbf --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/httpRouter/createCookieAuthRefreshMiddleware.test.ts @@ -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='); + }); +}); diff --git a/packages/backend-app-api/src/services/implementations/httpRouter/createCookieAuthRefreshMiddleware.ts b/packages/backend-app-api/src/services/implementations/httpRouter/createCookieAuthRefreshMiddleware.ts new file mode 100644 index 0000000000..c33af7df4f --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/httpRouter/createCookieAuthRefreshMiddleware.ts @@ -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; +} diff --git a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts index f8ccdde780..60c6dcd3c0 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts @@ -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 { diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index 21a3bf10f8..3d20b789c1 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -325,7 +325,7 @@ export interface HttpAuthService { issueUserCookie( res: Response_2, options?: { - credentials?: BackstageCredentials; + credentials?: BackstageCredentials; }, ): Promise<{ expiresAt: Date; diff --git a/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts index 637109d1f0..5d1a2090d4 100644 --- a/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts +++ b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts @@ -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; + credentials?: BackstageCredentials; }, ): Promise<{ expiresAt: Date }>; } diff --git a/packages/cli/src/lib/bundler/bundle.ts b/packages/cli/src/lib/bundler/bundle.ts index 98ef2017b9..b9b826c247 100644 --- a/packages/cli/src/lib/bundler/bundle.ts +++ b/packages/cli/src/lib/bundler/bundle.ts @@ -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) { diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts index ea0e6e8b94..2b7311a34c 100644 --- a/packages/cli/src/lib/bundler/server.ts +++ b/packages/cli/src/lib/bundler/server.ts @@ -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( diff --git a/packages/core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy.ts b/packages/core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy.ts index 4a51444879..92e2abffb7 100644 --- a/packages/core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy.ts +++ b/packages/core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy.ts @@ -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; + constructor() { this.waitForTarget = new Promise(resolve => { this.resolveTarget = resolve; @@ -124,6 +130,35 @@ export class AppIdentityProxy implements IdentityApi { async signOut(): Promise { 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 + } + }; + } } diff --git a/packages/core-app-api/src/apis/implementations/IdentityApi/startCookieAuthRefresh.test.ts b/packages/core-app-api/src/apis/implementations/IdentityApi/startCookieAuthRefresh.test.ts new file mode 100644 index 0000000000..4db068deb9 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/IdentityApi/startCookieAuthRefresh.test.ts @@ -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']); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/IdentityApi/startCookieAuthRefresh.ts b/packages/core-app-api/src/apis/implementations/IdentityApi/startCookieAuthRefresh.ts new file mode 100644 index 0000000000..597e1b7cc6 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/IdentityApi/startCookieAuthRefresh.ts @@ -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(); + }; +} diff --git a/packages/core-app-api/src/app/AppManager.test.tsx b/packages/core-app-api/src/app/AppManager.test.tsx index fe8d2fa91a..59b53bf6c5 100644 --- a/packages/core-app-api/src/app/AppManager.test.tsx +++ b/packages/core-app-api/src/app/AppManager.test.tsx @@ -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 ; + }; + + const Root = app.createRoot( + + + + , + ); + await renderWithEffects(); + + 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); + }); }); diff --git a/packages/core-app-api/src/app/AppManager.tsx b/packages/core-app-api/src/app/AppManager.tsx index 3843fdfaee..64736470d2 100644 --- a/packages/core-app-api/src/app/AppManager.tsx +++ b/packages/core-app-api/src/app/AppManager.tsx @@ -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 ( - + 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; 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; diff --git a/plugins/app-backend/migrations/20240113144027_assets-namespace.js b/plugins/app-backend/migrations/20240113144027_assets-namespace.js new file mode 100644 index 0000000000..b533d35dfa --- /dev/null +++ b/plugins/app-backend/migrations/20240113144027_assets-namespace.js @@ -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'); + }); +}; diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index e775c0d3b2..ec66d24d88 100644 --- a/plugins/app-backend/package.json +++ b/plugins/app-backend/package.json @@ -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", diff --git a/plugins/app-backend/src/lib/assets/StaticAssetsStore.test.ts b/plugins/app-backend/src/lib/assets/StaticAssetsStore.test.ts index e18d136b9d..d975751676 100644 --- a/plugins/app-backend/src/lib/assets/StaticAssetsStore.test.ts +++ b/plugins/app-backend/src/lib/assets/StaticAssetsStore.test.ts @@ -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(); + }, + ); }); diff --git a/plugins/app-backend/src/lib/assets/StaticAssetsStore.ts b/plugins/app-backend/src/lib/assets/StaticAssetsStore.ts index 9f6ada717d..43bd38030f 100644 --- a/plugins/app-backend/src/lib/assets/StaticAssetsStore.ts +++ b/plugins/app-backend/src/lib/assets/StaticAssetsStore.ts @@ -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( - 'static_assets_cache', - ).whereIn( - 'path', - assets.map(a => a.path), - ); + const existingRows = await this.#db('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 { const [row] = await this.#db('static_assets_cache').where({ path, + namespace: this.#namespace, }); if (!row) { return undefined; @@ -151,6 +164,7 @@ export class StaticAssetsStore implements StaticAssetProvider { ]); } await this.#db('static_assets_cache') + .where('namespace', this.#namespace) .where('last_modified_at', '<=', lastModifiedInterval) .delete(); } diff --git a/plugins/app-backend/src/service/appPlugin.ts b/plugins/app-backend/src/service/appPlugin.ts index 518b0dd307..b3ab9ed29a 100644 --- a/plugins/app-backend/src/service/appPlugin.ts +++ b/plugins/app-backend/src/service/appPlugin.ts @@ -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: '/', diff --git a/plugins/app-backend/src/service/router.ts b/plugins/app-backend/src/service/router.ts index 17c5fd4f84..eac3b7f82d 100644 --- a/plugins/app-backend/src/service/router.ts +++ b/plugins/app-backend/src/service/router.ts @@ -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 { - 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 = ``; + + let newContent; + if (content.includes('backstage-app-mode')) { + newContent = content.replace( + //, + metaTag, + ); + } else { + newContent = content.replace(//, `${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. diff --git a/plugins/auth-react/api-report.md b/plugins/auth-react/api-report.md index 01ec9b028a..d5206261d2 100644 --- a/plugins/auth-react/api-report.md +++ b/plugins/auth-react/api-report.md @@ -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'; } diff --git a/plugins/auth-react/package.json b/plugins/auth-react/package.json index 4e4989ea22..9de3aade21 100644 --- a/plugins/auth-react/package.json +++ b/plugins/auth-react/package.json @@ -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" diff --git a/plugins/auth-react/src/components/CookieAuthRedirect/CookieAuthRedirect.test.tsx b/plugins/auth-react/src/components/CookieAuthRedirect/CookieAuthRedirect.test.tsx new file mode 100644 index 0000000000..6c0141a9d1 --- /dev/null +++ b/plugins/auth-react/src/components/CookieAuthRedirect/CookieAuthRedirect.test.tsx @@ -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( + + + , + ); + + 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( + + + , + ); + + await waitFor(() => + expect(screen.getByText('Continue')).toBeInTheDocument(), + ); + + expect(screen.getByDisplayValue('sign-in')).toBeInTheDocument(); + expect(screen.getByDisplayValue('test-token')).toBeInTheDocument(); + }); +}); diff --git a/plugins/auth-react/src/components/CookieAuthRedirect/CookieAuthRedirect.tsx b/plugins/auth-react/src/components/CookieAuthRedirect/CookieAuthRedirect.tsx new file mode 100644 index 0000000000..f719aebdae --- /dev/null +++ b/plugins/auth-react/src/components/CookieAuthRedirect/CookieAuthRedirect.tsx @@ -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?.submit()} + action={window.location.href} + method="POST" + > + + + +
+ ); + } + + return null; +} diff --git a/plugins/auth-react/src/components/CookieAuthRedirect/index.ts b/plugins/auth-react/src/components/CookieAuthRedirect/index.ts new file mode 100644 index 0000000000..506d7b3fdb --- /dev/null +++ b/plugins/auth-react/src/components/CookieAuthRedirect/index.ts @@ -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'; diff --git a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx index 786136261c..bba876e6be 100644 --- a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx +++ b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx @@ -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' }, ), ); diff --git a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx index 7919207692..307898dbea 100644 --- a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx +++ b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx @@ -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; }; diff --git a/plugins/auth-react/src/components/index.ts b/plugins/auth-react/src/components/index.ts index f561672096..928abb0cb5 100644 --- a/plugins/auth-react/src/components/index.ts +++ b/plugins/auth-react/src/components/index.ts @@ -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'; diff --git a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx index 180de98a46..df5c833953 100644 --- a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx +++ b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx @@ -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' }, ), ); diff --git a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx index dfb13289db..244491f7e6 100644 --- a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx +++ b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx @@ -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', }); diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index 3a2eb4eee6..b6f95d4e1b 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -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; } diff --git a/yarn.lock b/yarn.lock index 425563ad1e..6da4ed86b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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