From 3b674b8ba1411232d1ccb09757cfd4e0d3a600bb Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 13 Jan 2024 16:22:31 +0100 Subject: [PATCH 01/36] app-backend: add static asset store namespacing Signed-off-by: Patrik Oldsberg --- .../20240113144027_assets-namespace.js | 38 ++++++++++++++++++ .../src/lib/assets/StaticAssetsStore.test.ts | 40 +++++++++++++++++++ .../src/lib/assets/StaticAssetsStore.ts | 28 +++++++++---- 3 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 plugins/app-backend/migrations/20240113144027_assets-namespace.js 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..5fd6ecc5aa --- /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.text('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/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(); } From 97b9d2bd3524107533281aef92985852075770f1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 13 Jan 2024 15:31:10 +0100 Subject: [PATCH 02/36] app-backend: internal refactor to separate out app router Signed-off-by: Patrik Oldsberg --- plugins/app-backend/src/service/router.ts | 73 +++++++++++++++++++---- 1 file changed, 60 insertions(+), 13 deletions(-) diff --git a/plugins/app-backend/src/service/router.ts b/plugins/app-backend/src/service/router.ts index 17c5fd4f84..01b98fcf3a 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'; @@ -134,15 +134,67 @@ export async function createRouter( injectedConfigPath = await injectConfig({ appConfigs, logger, staticDir }); } + const appConfigs = disableConfigInjection + ? await readConfigs({ + config, + appDistDir, + env: process.env, + }) + : undefined; + + const assetStore = + options.database && !disableStaticFallbackCache + ? await StaticAssetsStore.create({ + logger, + database: options.database, + }) + : undefined; const router = Router(); router.use(helmet.frameguard({ action: 'deny' })); + router.use( + await createEntryPointRouter({ + logger, + rootDir: appDistDir, + assetStore, + staticFallbackHandler, + appConfigs, + injectedConfigPath, + }), + ); + + return router; +} + +async function createEntryPointRouter({ + logger, + rootDir, + assetStore, + staticFallbackHandler, + appConfigs, + injectedConfigPath, +}: { + logger: Logger; + rootDir: string; + assetStore?: StaticAssetsStore; + staticFallbackHandler?: express.Handler; + appConfigs?: AppConfig[]; + injectedConfigPath?: string; +}) { + const staticDir = resolvePath(rootDir, 'static'); + + if (appConfigs) { + await injectConfig({ appConfigs, logger, staticDir }); + } + + 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 +205,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 +221,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. @@ -187,7 +234,7 @@ 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. From a8c7b0da6a7c073a7769d5db418ec389ade3cca0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 4 Mar 2024 18:31:03 +0100 Subject: [PATCH 03/36] big improvements Signed-off-by: Patrik Oldsberg --- .../app/src/index-public-experimental.tsx | 41 ++++++--- packages/cli/src/lib/bundler/bundle.ts | 1 - packages/cli/src/lib/bundler/server.ts | 1 - plugins/app-backend/package.json | 1 + plugins/app-backend/src/service/appPlugin.ts | 8 +- plugins/app-backend/src/service/router.ts | 86 ++++++++++++++++++- yarn.lock | 1 + 7 files changed, 122 insertions(+), 17 deletions(-) diff --git a/packages/app/src/index-public-experimental.tsx b/packages/app/src/index-public-experimental.tsx index 9e5f89b4c2..59768a2dd1 100644 --- a/packages/app/src/index-public-experimental.tsx +++ b/packages/app/src/index-public-experimental.tsx @@ -22,26 +22,18 @@ import { SignInPage, } from '@backstage/core-components'; import React from 'react'; +import useAsync from 'react-use/lib/useAsync'; import ReactDOM from 'react-dom/client'; import { providers } from '../src/identityProviders'; import { configApiRef, createApiFactory, discoveryApiRef, + identityApiRef, 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({ @@ -65,8 +57,33 @@ const app = createApp({ }); function RedirectToRoot() { - window.location.pathname = readBasePath(useApi(configApiRef)); - return
; + const identityApi = useApi(identityApiRef); + + const { value, loading, error } = useAsync(async () => { + const { token } = await identityApi.getCredentials(); + if (!token) { + throw new Error('Expected Backstage token in sign-in response'); + } + return token; + }, [identityApi]); + + if (loading) { + return null; + } else if (error) { + return <>An error occurred: {error}; + } + + return ( +
form?.submit()} + action={window.location.href} + method="POST" + > + + + +
+ ); } const App = app.createRoot( diff --git a/packages/cli/src/lib/bundler/bundle.ts b/packages/cli/src/lib/bundler/bundle.ts index 98ef2017b9..06249774fd 100644 --- a/packages/cli/src/lib/bundler/bundle.ts +++ b/packages/cli/src/lib/bundler/bundle.ts @@ -73,7 +73,6 @@ export async function buildBundle(options: BuildOptions) { configs.push( await createConfig(publicPaths, { ...commonConfigOptions, - publicSubPath: '/public', }), ); } diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts index ea0e6e8b94..2fe72cd471 100644 --- a/packages/cli/src/lib/bundler/server.ts +++ b/packages/cli/src/lib/bundler/server.ts @@ -220,7 +220,6 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be config, await createConfig(publicPaths, { ...commonConfigOptions, - publicSubPath: '/public', }), ]) : webpack(config); diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index e775c0d3b2..3dc38bbcff 100644 --- a/plugins/app-backend/package.json +++ b/plugins/app-backend/package.json @@ -49,6 +49,7 @@ "@backstage/backend-plugin-api": "workspace:^", "@backstage/config": "workspace:^", "@backstage/config-loader": "workspace:^", + "@backstage/errors": "workspace:^", "@backstage/plugin-app-node": "workspace:^", "@backstage/types": "workspace:^", "@types/express": "^4.17.6", 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 01b98fcf3a..6bbfd2ad3c 100644 --- a/plugins/app-backend/src/service/router.ts +++ b/plugins/app-backend/src/service/router.ts @@ -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 ?? @@ -154,9 +165,80 @@ export async function createRouter( router.use(helmet.frameguard({ action: 'deny' })); + const publicDistDir = resolvePath(appDistDir, 'public'); + + const enablePublicEntryPoint = await fs.pathExists(publicDistDir); + + /* + TODO: + - Cookie refresh + - Backend endpoint, /.backstage/v1-cookie + - Frontend provider + - Remove issueUserCookie from HttpAuthService and move to auth-node + - Move RedirectToRoot to auth-react + - Document index-public-experimental & how to use + - Logout? How do we clear the cookie? + + */ + + if (enablePublicEntryPoint && auth && httpAuth) { + const publicRouter = Router(); + + // TODO + // publicRouter.use(createCookieAuthRouter({ httpAuth })) + + publicRouter.use(async (req, _res, next) => { + const credentials = await httpAuth.credentials(req, { + allow: ['user', 'service', 'none'], + allowLimitedAccess: true, + }); + + if (credentials.principal.type === 'none') { + next(); + } else { + next('router'); + } + }); + + 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({ + 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({ - logger, + logger: logger.child({ entry: 'main' }), rootDir: appDistDir, assetStore, staticFallbackHandler, diff --git a/yarn.lock b/yarn.lock index 2df04a207e..606e10254f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4691,6 +4691,7 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/config-loader": "workspace:^" + "@backstage/errors": "workspace:^" "@backstage/plugin-app-node": "workspace:^" "@backstage/types": "workspace:^" "@types/express": ^4.17.6 From 7b77befdfb0f3869751b755bc0dea815ca572f3b Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Mon, 18 Mar 2024 10:06:22 +0100 Subject: [PATCH 04/36] feat: create default get cookie endpoint Signed-off-by: Camila Belo --- .../httpRouter/httpRouterServiceFactory.ts | 8 ++++++++ .../hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx | 4 ++-- plugins/techdocs-backend/src/service/router.ts | 6 ------ 3 files changed, 10 insertions(+), 8 deletions(-) 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..0b79425e36 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts @@ -84,6 +84,14 @@ export const httpRouterServiceFactory = createServiceFactory( }, addAuthPolicy(policy: HttpRouterServiceAuthPolicy): void { credentialsBarrier.addAuthPolicy(policy); + if (policy.allow === 'user-cookie') { + // Endpoint that sets the cookie for the user + // TODO: Extract this to a separate createCookieAuthRefreshMiddleware function + router.get('/.backstage/v1-cookie', async (_, res) => { + const { expiresAt } = await httpAuth.issueUserCookie(res); + res.json({ expiresAt: expiresAt.toISOString() }); + }); + } }, }; }, diff --git a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx index dfb13289db..45753d7460 100644 --- a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx +++ b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx @@ -31,13 +31,13 @@ import { ResponseError } from '@backstage/errors'; export function useCookieAuthRefresh(options: { // The plugin id used for discovering the API origin pluginId: string; - // The path used for calling the refresh cookie endpoint, default to '/cookie' + // The path used for calling the refresh cookie endpoint, default to '/.backstage/v1-cookie' path?: string; }): | { status: 'loading' } | { status: 'error'; error: Error; retry: () => void } | { status: 'success'; data: { expiresAt: string } } { - const { pluginId, path = '/cookie' } = options ?? {}; + const { pluginId, path = '/.backstage/v1-cookie' } = options ?? {}; const fetchApi = useApi(fetchApiRef); const discoveryApi = useApi(discoveryApiRef); 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; } From f17da85b08470b0f91d6f21807968b262ff9a251 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Mon, 18 Mar 2024 10:27:26 +0100 Subject: [PATCH 05/36] refactor: extract a create cookie middleware Signed-off-by: Camila Belo --- .../httpRouter/httpRouterServiceFactory.ts | 13 +++---- plugins/app-backend/package.json | 1 + plugins/app-backend/src/service/router.ts | 4 +- plugins/auth-node/api-report.md | 7 ++++ .../createCookieAuthRefreshMiddleware.ts | 37 +++++++++++++++++++ plugins/auth-node/src/identity/index.ts | 1 + .../CookieAuthRefreshProvider.test.tsx | 2 +- .../useCookieAuthRefresh.test.tsx | 2 +- yarn.lock | 1 + 9 files changed, 56 insertions(+), 12 deletions(-) create mode 100644 plugins/auth-node/src/identity/createCookieAuthRefreshMiddleware.ts 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 0b79425e36..fc5abeb43c 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts @@ -14,13 +14,14 @@ * 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 { createCookieAuthRefreshMiddleware } from '@backstage/plugin-auth-node'; import { createLifecycleMiddleware } from './createLifecycleMiddleware'; import { createCredentialsBarrier } from './createCredentialsBarrier'; import { createAuthIntegrationRouter } from './createAuthIntegrationRouter'; @@ -85,12 +86,8 @@ export const httpRouterServiceFactory = createServiceFactory( addAuthPolicy(policy: HttpRouterServiceAuthPolicy): void { credentialsBarrier.addAuthPolicy(policy); if (policy.allow === 'user-cookie') { - // Endpoint that sets the cookie for the user - // TODO: Extract this to a separate createCookieAuthRefreshMiddleware function - router.get('/.backstage/v1-cookie', async (_, res) => { - const { expiresAt } = await httpAuth.issueUserCookie(res); - res.json({ expiresAt: expiresAt.toISOString() }); - }); + // TODO: Make sure this is only added once + router.use(createCookieAuthRefreshMiddleware({ httpAuth })); } }, }; diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index 3dc38bbcff..ec66d24d88 100644 --- a/plugins/app-backend/package.json +++ b/plugins/app-backend/package.json @@ -51,6 +51,7 @@ "@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/service/router.ts b/plugins/app-backend/src/service/router.ts index 6bbfd2ad3c..e46a80c27d 100644 --- a/plugins/app-backend/src/service/router.ts +++ b/plugins/app-backend/src/service/router.ts @@ -40,6 +40,7 @@ import { import { ConfigSchema } from '@backstage/config-loader'; import { AuthService, HttpAuthService } from '@backstage/backend-plugin-api'; import { AuthenticationError } from '@backstage/errors'; +import { createCookieAuthRefreshMiddleware } from '@backstage/plugin-auth-node'; // express uses mime v1 while we only have types for mime v2 type Mime = { lookup(arg0: string): string }; @@ -184,8 +185,7 @@ export async function createRouter( if (enablePublicEntryPoint && auth && httpAuth) { const publicRouter = Router(); - // TODO - // publicRouter.use(createCookieAuthRouter({ httpAuth })) + publicRouter.use(createCookieAuthRefreshMiddleware({ httpAuth })); publicRouter.use(async (req, _res, next) => { const credentials = await httpAuth.credentials(req, { diff --git a/plugins/auth-node/api-report.md b/plugins/auth-node/api-report.md index e79facc7f9..4cb9db70f3 100644 --- a/plugins/auth-node/api-report.md +++ b/plugins/auth-node/api-report.md @@ -10,6 +10,7 @@ import { Entity } from '@backstage/catalog-model'; import { EntityFilterQuery } from '@backstage/catalog-client'; import express from 'express'; import { ExtensionPoint } from '@backstage/backend-plugin-api'; +import { HttpAuthService } from '@backstage/backend-plugin-api'; import { JsonObject } from '@backstage/types'; import { JsonValue } from '@backstage/types'; import { LoggerService } from '@backstage/backend-plugin-api'; @@ -17,6 +18,7 @@ import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { Profile } from 'passport'; import { Request as Request_2 } from 'express'; import { Response as Response_2 } from 'express'; +import { Router } from 'express-serve-static-core'; import { Strategy } from 'passport'; import { ZodSchema } from 'zod'; import { ZodTypeDef } from 'zod'; @@ -148,6 +150,11 @@ export type CookieConfigurer = (ctx: { sameSite?: 'none' | 'lax' | 'strict'; }; +// @public +export function createCookieAuthRefreshMiddleware(options: { + httpAuth: HttpAuthService; +}): Router; + // @public (undocumented) export function createOAuthAuthenticator( authenticator: OAuthAuthenticator, diff --git a/plugins/auth-node/src/identity/createCookieAuthRefreshMiddleware.ts b/plugins/auth-node/src/identity/createCookieAuthRefreshMiddleware.ts new file mode 100644 index 0000000000..85ff5b86d7 --- /dev/null +++ b/plugins/auth-node/src/identity/createCookieAuthRefreshMiddleware.ts @@ -0,0 +1,37 @@ +/* + * 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 { HttpAuthService } from '@backstage/backend-plugin-api'; +import { Router } from 'express'; + +/** + * @public + * Creates a middleware that can be used to refresh the cookie for the user. + */ +export function createCookieAuthRefreshMiddleware(options: { + httpAuth: HttpAuthService; +}) { + const { httpAuth } = options; + const router = Router(); + + // Endpoint that sets the cookie for the user + router.get('/.backstage/v1-cookie', async (_, res) => { + const { expiresAt } = await httpAuth.issueUserCookie(res); + res.json({ expiresAt: expiresAt.toISOString() }); + }); + + return router; +} diff --git a/plugins/auth-node/src/identity/index.ts b/plugins/auth-node/src/identity/index.ts index 24e2dcb691..a81f691698 100644 --- a/plugins/auth-node/src/identity/index.ts +++ b/plugins/auth-node/src/identity/index.ts @@ -22,3 +22,4 @@ export { } from './DefaultIdentityClient'; export { IdentityClient } from './IdentityClient'; export type { IdentityApi, IdentityApiGetIdentityRequest } from './IdentityApi'; +export { createCookieAuthRefreshMiddleware } from './createCookieAuthRefreshMiddleware'; diff --git a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx index 786136261c..ab88361773 100644 --- a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx +++ b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx @@ -119,7 +119,7 @@ describe('CookieAuthRefreshProvider', () => { await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledWith( - 'http://localhost:7000/techdocs/api/cookie', + 'http://localhost:7000/techdocs/api/.backstage/v1-cookie', { credentials: 'include' }, ), ); diff --git a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx index 180de98a46..aeff0be3b0 100644 --- a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx +++ b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx @@ -236,7 +236,7 @@ describe('useCookieAuthRefresh', () => { await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledWith( - 'http://localhost:7000/techdocs/api/cookie', + 'http://localhost:7000/techdocs/api/.backstage/v1-cookie', { credentials: 'include' }, ), ); diff --git a/yarn.lock b/yarn.lock index 606e10254f..9c7429a3c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4693,6 +4693,7 @@ __metadata: "@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 From 60167a3980eaf62f8b2822a6d5903a549ed7dd56 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 19 Mar 2024 19:41:50 +0100 Subject: [PATCH 06/36] refactor: move redirect to root Signed-off-by: Camila Belo --- packages/app/package.json | 1 + .../app/src/index-public-experimental.tsx | 34 +--------- plugins/auth-react/api-report.md | 4 ++ .../RedirectToRoot/RedirectToRoot.test.tsx | 64 +++++++++++++++++++ .../RedirectToRoot/RedirectToRoot.tsx | 57 +++++++++++++++++ .../src/components/RedirectToRoot/index.ts | 17 +++++ plugins/auth-react/src/components/index.ts | 1 + yarn.lock | 2 + 8 files changed, 147 insertions(+), 33 deletions(-) create mode 100644 plugins/auth-react/src/components/RedirectToRoot/RedirectToRoot.test.tsx create mode 100644 plugins/auth-react/src/components/RedirectToRoot/RedirectToRoot.tsx create mode 100644 plugins/auth-react/src/components/RedirectToRoot/index.ts 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 59768a2dd1..59f89c1157 100644 --- a/packages/app/src/index-public-experimental.tsx +++ b/packages/app/src/index-public-experimental.tsx @@ -21,16 +21,14 @@ import { OAuthRequestDialog, SignInPage, } from '@backstage/core-components'; +import { RedirectToRoot } from '@backstage/plugin-auth-react'; import React from 'react'; -import useAsync from 'react-use/lib/useAsync'; import ReactDOM from 'react-dom/client'; import { providers } from '../src/identityProviders'; import { configApiRef, createApiFactory, discoveryApiRef, - identityApiRef, - useApi, } from '@backstage/core-plugin-api'; import { AuthProxyDiscoveryApi } from '../src/AuthProxyDiscoveryApi'; @@ -56,36 +54,6 @@ const app = createApp({ }, }); -function RedirectToRoot() { - const identityApi = useApi(identityApiRef); - - const { value, loading, error } = useAsync(async () => { - const { token } = await identityApi.getCredentials(); - if (!token) { - throw new Error('Expected Backstage token in sign-in response'); - } - return token; - }, [identityApi]); - - if (loading) { - return null; - } else if (error) { - return <>An error occurred: {error}; - } - - return ( -
form?.submit()} - action={window.location.href} - method="POST" - > - - - -
- ); -} - const App = app.createRoot( <> diff --git a/plugins/auth-react/api-report.md b/plugins/auth-react/api-report.md index 01ec9b028a..7f1489543b 100644 --- a/plugins/auth-react/api-report.md +++ b/plugins/auth-react/api-report.md @@ -3,6 +3,7 @@ > 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 @@ -17,6 +18,9 @@ export type CookieAuthRefreshProviderProps = { children: ReactNode; }; +// @public +export function RedirectToRoot(): React_2.JSX.Element | null; + // @public export function useCookieAuthRefresh(options: { pluginId: string; diff --git a/plugins/auth-react/src/components/RedirectToRoot/RedirectToRoot.test.tsx b/plugins/auth-react/src/components/RedirectToRoot/RedirectToRoot.test.tsx new file mode 100644 index 0000000000..d1171a4f81 --- /dev/null +++ b/plugins/auth-react/src/components/RedirectToRoot/RedirectToRoot.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 { RedirectToRoot } from './RedirectToRoot'; + +describe('RedirectToRoot', () => { + 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/RedirectToRoot/RedirectToRoot.tsx b/plugins/auth-react/src/components/RedirectToRoot/RedirectToRoot.tsx new file mode 100644 index 0000000000..86b40b44e1 --- /dev/null +++ b/plugins/auth-react/src/components/RedirectToRoot/RedirectToRoot.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 root of the app after a successful sign-in. + */ +export function RedirectToRoot() { + 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/RedirectToRoot/index.ts b/plugins/auth-react/src/components/RedirectToRoot/index.ts new file mode 100644 index 0000000000..1e9e211adc --- /dev/null +++ b/plugins/auth-react/src/components/RedirectToRoot/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 { RedirectToRoot } from './RedirectToRoot'; diff --git a/plugins/auth-react/src/components/index.ts b/plugins/auth-react/src/components/index.ts index f561672096..c744c9b0d8 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 './RedirectToRoot'; export * from './CookieAuthRefreshProvider'; diff --git a/yarn.lock b/yarn.lock index 9c7429a3c5..ce9665c22d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5118,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 @@ -27441,6 +27442,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:^" From 641a0685149c9f6aa12ab454bda2c305992236de Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Wed, 20 Mar 2024 13:42:24 +0100 Subject: [PATCH 07/36] feat: create app mode provider Signed-off-by: Camila Belo --- packages/app/package.json | 3 +- packages/app/src/App.tsx | 7 +- packages/app/src/AppMode.test.tsx | 148 ++++++++++++++++++++++++++++++ packages/app/src/AppMode.tsx | 55 +++++++++++ yarn.lock | 1 + 5 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 packages/app/src/AppMode.test.tsx create mode 100644 packages/app/src/AppMode.tsx diff --git a/packages/app/package.json b/packages/app/package.json index 9401ca89be..79101dff70 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -142,7 +142,8 @@ "@types/react": "*", "@types/react-dom": "*", "@types/zen-observable": "^0.8.0", - "cross-env": "^7.0.0" + "cross-env": "^7.0.0", + "msw": "^1.0.0" }, "bundled": true } diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 3d8bd45e5a..06851d6ac7 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -108,6 +108,7 @@ import { DevToolsPage } from '@backstage/plugin-devtools'; import { customDevToolsPage } from './components/devtools/CustomDevToolsPage'; import { CatalogUnprocessedEntitiesPage } from '@backstage/plugin-catalog-unprocessed-entities'; import { NotificationsPage } from '@backstage/plugin-notifications'; +import { AppMode } from './AppMode'; const app = createApp({ apis, @@ -282,8 +283,10 @@ export default app.createRoot( - - {routes} + + + {routes} + , ); diff --git a/packages/app/src/AppMode.test.tsx b/packages/app/src/AppMode.test.tsx new file mode 100644 index 0000000000..b110944e1e --- /dev/null +++ b/packages/app/src/AppMode.test.tsx @@ -0,0 +1,148 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { + MockConfigApi, + TestApiProvider, + renderInTestApp, + setupRequestMockHandlers, +} from '@backstage/test-utils'; +import { AppMode } from './AppMode'; +import { + configApiRef, + discoveryApiRef, + fetchApiRef, +} from '@backstage/core-plugin-api'; + +describe('AppMode', () => { + const worker = setupServer(); + setupRequestMockHandlers(worker); + + const configApiMock = new MockConfigApi({ + backend: { + baseUrl: 'http://localhost:7000', + }, + }); + + const fetchApiMock = { + fetch: jest.fn(), + }; + + const discoveryApiMock = { + getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7000/app'), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render the progress component while loading', async () => { + fetchApiMock.fetch.mockReturnValueOnce(new Promise(() => {})); + await renderInTestApp( + + Test content + , + ); + expect(screen.getByTestId('progress')).toBeVisible(); + expect(screen.queryByText('Test Content')).not.toBeInTheDocument(); + }); + + it('should render the children even when there is an error', async () => { + const error = new Error('Failed to fetch'); + fetchApiMock.fetch.mockRejectedValueOnce(error); + await renderInTestApp( + + Test content + , + ); + await waitFor(() => + expect(screen.getByText('Test content')).toBeInTheDocument(), + ); + expect(screen.queryByTestId('progress')).not.toBeInTheDocument(); + expect(screen.queryByText('Failed to fetch')).not.toBeInTheDocument(); + }); + + it('should render the children even when the public index is not available', async () => { + fetchApiMock.fetch.mockResolvedValueOnce({ ok: false }); + await renderInTestApp( + + Test content + , + ); + await waitFor(() => + expect(screen.getByText('Test content')).toBeInTheDocument(), + ); + expect(screen.queryByTestId('progress')).not.toBeInTheDocument(); + }); + + it('should render the children also when the public index is available', async () => { + fetchApiMock.fetch.mockResolvedValueOnce({ ok: true }); + await renderInTestApp(Test content); + await waitFor(() => + expect(screen.getByText('Test content')).toBeInTheDocument(), + ); + expect(screen.queryByTestId('progress')).not.toBeInTheDocument(); + }); + + it('should render the children wrapped in the CookieAuthRefreshProvider', async () => { + worker.use( + rest.get('http://localhost:7000/public/index.html', (_, res, ctx) => { + return res(ctx.status(200)); + }), + rest.get( + 'http://localhost:7000/app/.backstage/v1-cookie', + (_, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ expiresAt: Date.now() + 10 * 60 * 1000 }), + ); + }, + ), + ); + await renderInTestApp( + + Test content + , + ); + await waitFor(() => + expect(screen.getByText('Test content')).toBeInTheDocument(), + ); + }); +}); diff --git a/packages/app/src/AppMode.tsx b/packages/app/src/AppMode.tsx new file mode 100644 index 0000000000..2b11064c65 --- /dev/null +++ b/packages/app/src/AppMode.tsx @@ -0,0 +1,55 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ReactNode } from 'react'; +import useAsync from 'react-use/lib/useAsync'; +import { + configApiRef, + fetchApiRef, + useApi, + useApp, +} from '@backstage/core-plugin-api'; +import { CookieAuthRefreshProvider } from '@backstage/plugin-auth-react'; + +export function AppMode(props: { children: ReactNode }): JSX.Element { + const { children } = props; + const fetchApi = useApi(fetchApiRef); + const configApi = useApi(configApiRef); + const Components = useApp().getComponents(); + + const { loading, error, value } = useAsync(async () => { + const baseUrl = configApi.getString('backend.baseUrl'); + const response = await fetchApi.fetch(`${baseUrl}/public/index.html`); + return response.ok; + }, [fetchApi, configApi]); + + if (loading) { + return ; + } + + // Request failed, or the public index is not available + if (error || !value) { + return <>{children}; + } + + // The public index is available + // That means the app is running in public experimental mode + return ( + + {children} + + ); +} diff --git a/yarn.lock b/yarn.lock index ce9665c22d..ffc26aff2b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -27526,6 +27526,7 @@ __metadata: "@vitejs/plugin-react": ^4.0.4 cross-env: ^7.0.0 history: ^5.0.0 + msw: ^1.0.0 react: ^18.0.2 react-dom: ^18.0.2 react-router: ^6.3.0 From a1950ad5e6b15a5eff6d16f1fc58b1cef619a3ef Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Wed, 20 Mar 2024 13:43:32 +0100 Subject: [PATCH 08/36] feat: remove cookie on sign out Signed-off-by: Camila Belo --- .../httpAuth/httpAuthServiceFactory.ts | 4 ++ .../httpRouter/httpRouterServiceFactory.ts | 10 +++- .../src/auth/createLegacyAuthAdapters.ts | 4 ++ packages/backend-plugin-api/api-report.md | 2 + .../services/definitions/HttpAuthService.ts | 2 + .../src/next/services/MockHttpAuthService.ts | 4 ++ .../src/next/services/mockServices.ts | 1 + .../IdentityApi/AppIdentityProxy.ts | 6 +++ .../core-app-api/src/app/AppManager.test.tsx | 54 ++++++++++++++++++- packages/core-app-api/src/app/AppManager.tsx | 21 +++++++- plugins/app-backend/api-report.md | 6 +++ plugins/auth-node/package.json | 1 + .../createCookieAuthRefreshMiddleware.test.ts | 48 +++++++++++++++++ .../createCookieAuthRefreshMiddleware.ts | 10 +++- yarn.lock | 2 +- 15 files changed, 169 insertions(+), 6 deletions(-) create mode 100644 plugins/auth-node/src/identity/createCookieAuthRefreshMiddleware.test.ts 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..807e31513e 100644 --- a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts @@ -254,6 +254,10 @@ class DefaultHttpAuthService implements HttpAuthService { throw error; } } + + removeUserCookie(res: Response): void { + res.clearCookie(BACKSTAGE_AUTH_COOKIE); + } } /** @public */ 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 fc5abeb43c..5f32eb8d00 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts @@ -59,6 +59,8 @@ export const httpRouterServiceFactory = createServiceFactory( rootHttpRouter, lifecycle, }) { + let hasRegistedCookieAuthRefreshMiddleware = false; + if (options?.getPath) { logger.warn( `DEPRECATION WARNING: The 'getPath' option for HttpRouterService is deprecated. The ability to reconfigure the '/api/' path prefix for plugins will be removed in the future.`, @@ -85,8 +87,12 @@ export const httpRouterServiceFactory = createServiceFactory( }, addAuthPolicy(policy: HttpRouterServiceAuthPolicy): void { credentialsBarrier.addAuthPolicy(policy); - if (policy.allow === 'user-cookie') { - // TODO: Make sure this is only added once + if ( + policy.allow === 'user-cookie' && + !hasRegistedCookieAuthRefreshMiddleware + ) { + // Only add the cookie refresh middleware once + hasRegistedCookieAuthRefreshMiddleware = true; router.use(createCookieAuthRefreshMiddleware({ httpAuth })); } }, diff --git a/packages/backend-common/src/auth/createLegacyAuthAdapters.ts b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts index 0ba23661ef..7718f74ee8 100644 --- a/packages/backend-common/src/auth/createLegacyAuthAdapters.ts +++ b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts @@ -259,6 +259,10 @@ class HttpAuthCompat implements HttpAuthService { async issueUserCookie(_res: Response): Promise<{ expiresAt: Date }> { return { expiresAt: new Date(Date.now() + 3600_000) }; } + + removeUserCookie(res: Response): void { + res.clearCookie('backstage-auth'); + } } export class UserInfoCompat implements UserInfoService { diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index 21a3bf10f8..d91f4a2450 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -330,6 +330,8 @@ export interface HttpAuthService { ): Promise<{ expiresAt: Date; }>; + // (undocumented) + removeUserCookie(res: Response_2): void; } // @public (undocumented) diff --git a/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts index 637109d1f0..eb6ba944f5 100644 --- a/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts +++ b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts @@ -37,4 +37,6 @@ export interface HttpAuthService { credentials?: BackstageCredentials; }, ): Promise<{ expiresAt: Date }>; + + removeUserCookie(res: Response): void; } diff --git a/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts b/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts index 9a69620473..8934def5f0 100644 --- a/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts +++ b/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts @@ -140,4 +140,8 @@ export class MockHttpAuthService implements HttpAuthService { return { expiresAt: new Date(Date.now() + 3600_000) }; } + + removeUserCookie(res: Response): void { + res.clearCookie(MOCK_AUTH_COOKIE); + } } diff --git a/packages/backend-test-utils/src/next/services/mockServices.ts b/packages/backend-test-utils/src/next/services/mockServices.ts index a858bf7dbf..00f1aa901a 100644 --- a/packages/backend-test-utils/src/next/services/mockServices.ts +++ b/packages/backend-test-utils/src/next/services/mockServices.ts @@ -275,6 +275,7 @@ export namespace mockServices { export const mock = simpleMock(coreServices.httpAuth, () => ({ credentials: jest.fn(), issueUserCookie: jest.fn(), + removeUserCookie: jest.fn(), })); } 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..5263d6490f 100644 --- a/packages/core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy.ts +++ b/packages/core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy.ts @@ -50,6 +50,7 @@ export class AppIdentityProxy implements IdentityApi { private waitForTarget: Promise; private resolveTarget: (api: CompatibilityIdentityApi) => void = () => {}; private signOutTargetUrl = '/'; + #signOutCallback?: () => Promise; constructor() { this.waitForTarget = new Promise(resolve => { @@ -67,6 +68,10 @@ export class AppIdentityProxy implements IdentityApi { this.resolveTarget(identityApi); } + setSignOutCallback(signOutCallback: () => Promise): void { + this.#signOutCallback = signOutCallback; + } + getUserId(): string { if (!this.target) { throw mkError('getUserId'); @@ -124,6 +129,7 @@ export class AppIdentityProxy implements IdentityApi { async signOut(): Promise { await this.waitForTarget.then(target => target.signOut()); + await this.#signOutCallback?.(); window.location.href = this.signOutTargetUrl; } } diff --git a/packages/core-app-api/src/app/AppManager.test.tsx b/packages/core-app-api/src/app/AppManager.test.tsx index fe8d2fa91a..9f81d0b62e 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,51 @@ describe('Integration Test', () => { }, ); }); + + it('should clear app cookie when the user logs out', async () => { + const fetchApiMock = { fetch: jest.fn().mockResolvedValue({ ok: true }) }; + 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/v1-cookie', + { method: 'DELETE' }, + ), + ); + }); }); diff --git a/packages/core-app-api/src/app/AppManager.tsx b/packages/core-app-api/src/app/AppManager.tsx index 3843fdfaee..cb2577dd4d 100644 --- a/packages/core-app-api/src/app/AppManager.tsx +++ b/packages/core-app-api/src/app/AppManager.tsx @@ -42,6 +42,8 @@ import { identityApiRef, BackstagePlugin, FeatureFlag, + fetchApiRef, + discoveryApiRef, } from '@backstage/core-plugin-api'; import { AppLanguageApi, @@ -354,8 +356,25 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be const { ThemeProvider = AppThemeProvider, Progress } = this.components; + const apis = this.getApiHolder(); + + this.appIdentityProxy.setSignOutCallback(async () => { + const fetchApi = apis.get(fetchApiRef); + const discoveryApi = apis.get(discoveryApiRef); + if (!fetchApi || !discoveryApi) return; + // It is fine if we do NOT worry yet about deleting cookies for OTHER backends like techdocs + const appBaseUrl = await discoveryApi.getBaseUrl('app'); + try { + await fetchApi.fetch(`${appBaseUrl}/.backstage/v1-cookie`, { + method: 'DELETE', + }); + } catch { + // Ignore the error for those who use static serving of the frontend + } + }); + 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/auth-node/package.json b/plugins/auth-node/package.json index 49d9dd87b9..77c8bcb9cb 100644 --- a/plugins/auth-node/package.json +++ b/plugins/auth-node/package.json @@ -39,6 +39,7 @@ "@backstage/errors": "workspace:^", "@backstage/types": "workspace:^", "@types/express": "*", + "@types/express-serve-static-core": "^4.17.5", "@types/passport": "^1.0.3", "express": "^4.17.1", "jose": "^5.0.0", diff --git a/plugins/auth-node/src/identity/createCookieAuthRefreshMiddleware.test.ts b/plugins/auth-node/src/identity/createCookieAuthRefreshMiddleware.test.ts new file mode 100644 index 0000000000..65426b4ca5 --- /dev/null +++ b/plugins/auth-node/src/identity/createCookieAuthRefreshMiddleware.test.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 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 httpAuth = mockServices.httpAuth(); + const router = createCookieAuthRefreshMiddleware({ httpAuth }); + app = express().use(router); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should issue the user cookie', async () => { + const response = await request(app).get('/.backstage/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/v1-cookie'); + expect(response.status).toBe(200); + expect(response.header['set-cookie'][0]).toMatch('backstage-auth='); + }); +}); diff --git a/plugins/auth-node/src/identity/createCookieAuthRefreshMiddleware.ts b/plugins/auth-node/src/identity/createCookieAuthRefreshMiddleware.ts index 85ff5b86d7..efb58345a1 100644 --- a/plugins/auth-node/src/identity/createCookieAuthRefreshMiddleware.ts +++ b/plugins/auth-node/src/identity/createCookieAuthRefreshMiddleware.ts @@ -17,6 +17,8 @@ import { HttpAuthService } from '@backstage/backend-plugin-api'; import { Router } from 'express'; +const WELL_KNOWN_COOKIE_PATH_V1 = '/.backstage/v1-cookie'; + /** * @public * Creates a middleware that can be used to refresh the cookie for the user. @@ -28,10 +30,16 @@ export function createCookieAuthRefreshMiddleware(options: { const router = Router(); // Endpoint that sets the cookie for the user - router.get('/.backstage/v1-cookie', async (_, res) => { + 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) => { + httpAuth.removeUserCookie(res); + res.send(200); + }); + return router; } diff --git a/yarn.lock b/yarn.lock index ffc26aff2b..2155432223 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5086,6 +5086,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/types": "workspace:^" "@types/express": "*" + "@types/express-serve-static-core": ^4.17.5 "@types/passport": ^1.0.3 cookie-parser: ^1.4.6 express: ^4.17.1 @@ -5118,7 +5119,6 @@ __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 From fc15c4adf5afa554bef4d54484ecac92d3e30d4f Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 21 Mar 2024 16:16:26 +0100 Subject: [PATCH 09/36] refactor: move app mode provider to auth react Signed-off-by: Camila Belo --- packages/app/package.json | 3 +-- packages/app/src/App.tsx | 6 +++--- plugins/auth-react/api-report.md | 5 +++++ plugins/auth-react/package.json | 3 ++- .../ExperimentalAppProtection.test.tsx | 16 ++++++++------- .../ExperimentalAppProtection.tsx | 20 +++++++++++++------ .../ExperimentalAppProtection/index.ts | 17 ++++++++++++++++ plugins/auth-react/src/components/index.ts | 1 + yarn.lock | 2 +- 9 files changed, 53 insertions(+), 20 deletions(-) rename packages/app/src/AppMode.test.tsx => plugins/auth-react/src/components/ExperimentalAppProtection/ExperimentalAppProtection.test.tsx (88%) rename packages/app/src/AppMode.tsx => plugins/auth-react/src/components/ExperimentalAppProtection/ExperimentalAppProtection.tsx (76%) create mode 100644 plugins/auth-react/src/components/ExperimentalAppProtection/index.ts diff --git a/packages/app/package.json b/packages/app/package.json index 79101dff70..9401ca89be 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -142,8 +142,7 @@ "@types/react": "*", "@types/react-dom": "*", "@types/zen-observable": "^0.8.0", - "cross-env": "^7.0.0", - "msw": "^1.0.0" + "cross-env": "^7.0.0" }, "bundled": true } diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 06851d6ac7..2639f2add8 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -108,7 +108,7 @@ import { DevToolsPage } from '@backstage/plugin-devtools'; import { customDevToolsPage } from './components/devtools/CustomDevToolsPage'; import { CatalogUnprocessedEntitiesPage } from '@backstage/plugin-catalog-unprocessed-entities'; import { NotificationsPage } from '@backstage/plugin-notifications'; -import { AppMode } from './AppMode'; +import { ExperimentalAppProtection } from '@backstage/plugin-auth-react'; const app = createApp({ apis, @@ -283,10 +283,10 @@ export default app.createRoot( - + {routes} - + , ); diff --git a/plugins/auth-react/api-report.md b/plugins/auth-react/api-report.md index 7f1489543b..d6b7d2e17f 100644 --- a/plugins/auth-react/api-report.md +++ b/plugins/auth-react/api-report.md @@ -18,6 +18,11 @@ export type CookieAuthRefreshProviderProps = { children: ReactNode; }; +// @public +export function ExperimentalAppProtection(props: { + children: ReactNode; +}): JSX.Element; + // @public export function RedirectToRoot(): React_2.JSX.Element | null; 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/packages/app/src/AppMode.test.tsx b/plugins/auth-react/src/components/ExperimentalAppProtection/ExperimentalAppProtection.test.tsx similarity index 88% rename from packages/app/src/AppMode.test.tsx rename to plugins/auth-react/src/components/ExperimentalAppProtection/ExperimentalAppProtection.test.tsx index b110944e1e..2884400ea9 100644 --- a/packages/app/src/AppMode.test.tsx +++ b/plugins/auth-react/src/components/ExperimentalAppProtection/ExperimentalAppProtection.test.tsx @@ -24,14 +24,14 @@ import { renderInTestApp, setupRequestMockHandlers, } from '@backstage/test-utils'; -import { AppMode } from './AppMode'; +import { ExperimentalAppProtection } from './ExperimentalAppProtection'; import { configApiRef, discoveryApiRef, fetchApiRef, } from '@backstage/core-plugin-api'; -describe('AppMode', () => { +describe('ExperimentalAppProtection', () => { const worker = setupServer(); setupRequestMockHandlers(worker); @@ -62,7 +62,7 @@ describe('AppMode', () => { [fetchApiRef, fetchApiMock], ]} > - Test content + Test content , ); expect(screen.getByTestId('progress')).toBeVisible(); @@ -79,7 +79,7 @@ describe('AppMode', () => { [fetchApiRef, fetchApiMock], ]} > - Test content + Test content , ); await waitFor(() => @@ -98,7 +98,7 @@ describe('AppMode', () => { [fetchApiRef, fetchApiMock], ]} > - Test content + Test content , ); await waitFor(() => @@ -109,7 +109,9 @@ describe('AppMode', () => { it('should render the children also when the public index is available', async () => { fetchApiMock.fetch.mockResolvedValueOnce({ ok: true }); - await renderInTestApp(Test content); + await renderInTestApp( + Test content, + ); await waitFor(() => expect(screen.getByText('Test content')).toBeInTheDocument(), ); @@ -138,7 +140,7 @@ describe('AppMode', () => { [discoveryApiRef, discoveryApiMock], ]} > - Test content + Test content , ); await waitFor(() => diff --git a/packages/app/src/AppMode.tsx b/plugins/auth-react/src/components/ExperimentalAppProtection/ExperimentalAppProtection.tsx similarity index 76% rename from packages/app/src/AppMode.tsx rename to plugins/auth-react/src/components/ExperimentalAppProtection/ExperimentalAppProtection.tsx index 2b11064c65..f76b287d97 100644 --- a/packages/app/src/AppMode.tsx +++ b/plugins/auth-react/src/components/ExperimentalAppProtection/ExperimentalAppProtection.tsx @@ -15,7 +15,6 @@ */ import React, { ReactNode } from 'react'; -import useAsync from 'react-use/lib/useAsync'; import { configApiRef, fetchApiRef, @@ -23,25 +22,34 @@ import { useApp, } from '@backstage/core-plugin-api'; import { CookieAuthRefreshProvider } from '@backstage/plugin-auth-react'; +import { useAsync, useMountEffect } from '@react-hookz/web'; -export function AppMode(props: { children: ReactNode }): JSX.Element { +/** + * @public + * A provider that will protect the app when running in public experimental mode. + */ +export function ExperimentalAppProtection(props: { + children: ReactNode; +}): JSX.Element { const { children } = props; const fetchApi = useApi(fetchApiRef); const configApi = useApi(configApiRef); const Components = useApp().getComponents(); - const { loading, error, value } = useAsync(async () => { + const [state, actions] = useAsync(async () => { const baseUrl = configApi.getString('backend.baseUrl'); const response = await fetchApi.fetch(`${baseUrl}/public/index.html`); return response.ok; - }, [fetchApi, configApi]); + }); - if (loading) { + useMountEffect(actions.execute); + + if (state.status === 'not-executed' || state.status === 'loading') { return ; } // Request failed, or the public index is not available - if (error || !value) { + if (state.status === 'error' || !state.result) { return <>{children}; } diff --git a/plugins/auth-react/src/components/ExperimentalAppProtection/index.ts b/plugins/auth-react/src/components/ExperimentalAppProtection/index.ts new file mode 100644 index 0000000000..c29b1e438d --- /dev/null +++ b/plugins/auth-react/src/components/ExperimentalAppProtection/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 { ExperimentalAppProtection } from './ExperimentalAppProtection'; diff --git a/plugins/auth-react/src/components/index.ts b/plugins/auth-react/src/components/index.ts index c744c9b0d8..a85fad95ae 100644 --- a/plugins/auth-react/src/components/index.ts +++ b/plugins/auth-react/src/components/index.ts @@ -19,3 +19,4 @@ export * from './RedirectToRoot'; export * from './CookieAuthRefreshProvider'; +export * from './ExperimentalAppProtection'; diff --git a/yarn.lock b/yarn.lock index 2155432223..666548dd95 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5119,6 +5119,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 @@ -27526,7 +27527,6 @@ __metadata: "@vitejs/plugin-react": ^4.0.4 cross-env: ^7.0.0 history: ^5.0.0 - msw: ^1.0.0 react: ^18.0.2 react-dom: ^18.0.2 react-router: ^6.3.0 From 14c9f68f38f20be66458334a19019bc703c3f719 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 21 Mar 2024 18:23:39 +0100 Subject: [PATCH 10/36] docs: start drafting a public entrypoint tutorial Signed-off-by: Camila Belo --- docs/tutorials/enable-public-entry.md | 118 ++++++++++++++++++++++ microsite/sidebars.json | 1 + plugins/app-backend/src/service/router.ts | 12 --- 3 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 docs/tutorials/enable-public-entry.md diff --git a/docs/tutorials/enable-public-entry.md b/docs/tutorials/enable-public-entry.md new file mode 100644 index 0000000000..a25d1a8704 --- /dev/null +++ b/docs/tutorials/enable-public-entry.md @@ -0,0 +1,118 @@ +--- +id: enable-public-entry +title: Enabling 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 tutorial will only work for those using backstage-cli to build and serve their Backstage app. + +## Trying out this feature + +1. Add a `index-public-experimental.tsx` to your app `src` folder; + +2. Prepare an unauthenticated version of your application. This will be the public entry point for your site: + + ```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, + createApiFactory, + discoveryApiRef, + } from '@backstage/core-plugin-api'; + import { RedirectToRoot } from '@backstage/plugin-auth-react'; + import { providers } from '../src/identityProviders'; + import { AuthProxyDiscoveryApi } from '../src/AuthProxyDiscoveryApi'; + + // Notice that this is only setting up what is needed by the sign-in pages + const app = createApp({ + apis: [ + createApiFactory({ + api: discoveryApiRef, + deps: { configApi: configApiRef }, + factory: ({ configApi }) => + AuthProxyDiscoveryApi.fromConfig(configApi), + }), + ], + components: { + SignInPage: props => { + return ( + + ); + }, + }, + }); + + const App = app.createRoot( + <> + + + + {/* This is a special component that does the magic to redirect users to access the home page of your authenticated application version */} + + + , + ); + + ReactDOM.createRoot(document.getElementById('root')!).render(); + ``` + +3) Then ensure your main entry is also protected by the experimental app provider, as shown in the following example: + +```tsx title="in packages/app/src/index-public-experimental.tsx" +// ... +export default app.createRoot( + <> + + + + {/* For now, this is just a temporary solution to ensure the user remains logged in properly and has access to the main app content. */} + + + {routes} + + + , +); +``` + +4. You're now ready to build your front-end app: + +```sh +yarn workspace example-app build +``` + +5. And also serve it from your Backstage app backend: + +```sh +yarn start-backend:next +``` + +6. Finally, access http://localhost:7007 to see the public app being served (note that only a minimal app is being served). Log in and you will be redirected to the main app home page (check the protected bundle being served from the app-backend after the redirect). 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/plugins/app-backend/src/service/router.ts b/plugins/app-backend/src/service/router.ts index e46a80c27d..70466da001 100644 --- a/plugins/app-backend/src/service/router.ts +++ b/plugins/app-backend/src/service/router.ts @@ -170,18 +170,6 @@ export async function createRouter( const enablePublicEntryPoint = await fs.pathExists(publicDistDir); - /* - TODO: - - Cookie refresh - - Backend endpoint, /.backstage/v1-cookie - - Frontend provider - - Remove issueUserCookie from HttpAuthService and move to auth-node - - Move RedirectToRoot to auth-react - - Document index-public-experimental & how to use - - Logout? How do we clear the cookie? - - */ - if (enablePublicEntryPoint && auth && httpAuth) { const publicRouter = Router(); From 32a208a548e590c001eb7ef766c37875499cf29b Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Fri, 22 Mar 2024 09:38:10 +0100 Subject: [PATCH 11/36] test: try fixing namespace length Signed-off-by: Camila Belo --- .../app-backend/migrations/20240113144027_assets-namespace.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/app-backend/migrations/20240113144027_assets-namespace.js b/plugins/app-backend/migrations/20240113144027_assets-namespace.js index 5fd6ecc5aa..b533d35dfa 100644 --- a/plugins/app-backend/migrations/20240113144027_assets-namespace.js +++ b/plugins/app-backend/migrations/20240113144027_assets-namespace.js @@ -22,7 +22,7 @@ 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.text('namespace').comment('The namespace of the file'); + table.string('namespace').comment('The namespace of the file'); table.index('namespace', 'static_asset_cache_namespace_idx'); }); }; From c884b9a478d0c9e3c0d13c58248954cf209321ce Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Fri, 22 Mar 2024 13:43:03 +0100 Subject: [PATCH 12/36] docs: add changeset files Signed-off-by: Camila Belo --- .changeset/fair-onions-rule.md | 5 +++++ .changeset/fifty-cameras-shake.md | 5 +++++ .changeset/forty-elephants-count.md | 5 +++++ .changeset/large-hotels-accept.md | 5 +++++ .changeset/nine-rabbits-exist.md | 5 +++++ .changeset/old-parents-try.md | 7 +++++++ .changeset/smooth-squids-design.md | 5 +++++ .changeset/wet-swans-type.md | 5 +++++ 8 files changed, 42 insertions(+) create mode 100644 .changeset/fair-onions-rule.md create mode 100644 .changeset/fifty-cameras-shake.md create mode 100644 .changeset/forty-elephants-count.md create mode 100644 .changeset/large-hotels-accept.md create mode 100644 .changeset/nine-rabbits-exist.md create mode 100644 .changeset/old-parents-try.md create mode 100644 .changeset/smooth-squids-design.md create mode 100644 .changeset/wet-swans-type.md 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..49515502a3 --- /dev/null +++ b/.changeset/fifty-cameras-shake.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-react': patch +--- + +Update the default cookie base path and create a experimental redirect to root and protected app components, the components should be used only when the public entry is enabled. 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/large-hotels-accept.md b/.changeset/large-hotels-accept.md new file mode 100644 index 0000000000..acfac82355 --- /dev/null +++ b/.changeset/large-hotels-accept.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-node': patch +--- + +Create an auth cookie middleware utility to be used by plugins setting a `user-cookie` policy. The middleware adds well known endpoints to issue and delete user cookies. diff --git a/.changeset/nine-rabbits-exist.md b/.changeset/nine-rabbits-exist.md new file mode 100644 index 0000000000..9d14a9f417 --- /dev/null +++ b/.changeset/nine-rabbits-exist.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +Clear the app auth cookie when the when the user sign out. diff --git a/.changeset/old-parents-try.md b/.changeset/old-parents-try.md new file mode 100644 index 0000000000..866cdaee21 --- /dev/null +++ b/.changeset/old-parents-try.md @@ -0,0 +1,7 @@ +--- +'@backstage/backend-plugin-api': patch +'@backstage/backend-test-utils': patch +'@backstage/backend-common': patch +--- + +Add a `removeUserCookie` to the http auth service interface. 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/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. From ffd71105a6e34481854d6926b5b81ed3f205e7b7 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Wed, 3 Apr 2024 13:03:53 +0200 Subject: [PATCH 13/36] refactor: apply review suggestions Co-authored-by: Patrik Oldsberg Signed-off-by: Camila Belo --- .changeset/fifty-cameras-shake.md | 2 +- .changeset/nine-rabbits-exist.md | 2 +- .changeset/old-parents-try.md | 7 - docs/tutorials/enable-public-entry.md | 48 ++---- packages/app/src/App.tsx | 7 +- .../app/src/index-public-experimental.tsx | 4 +- .../httpAuth/httpAuthServiceFactory.ts | 52 +++--- .../httpRouter/httpRouterServiceFactory.ts | 2 +- .../src/auth/createLegacyAuthAdapters.ts | 4 - packages/backend-plugin-api/api-report.md | 4 +- .../services/definitions/HttpAuthService.ts | 10 +- .../src/next/services/MockHttpAuthService.ts | 4 - .../src/next/services/mockServices.ts | 1 - packages/cli/src/lib/bundler/bundle.ts | 6 +- packages/cli/src/lib/bundler/server.ts | 7 +- packages/core-app-api/package.json | 1 + .../core-app-api/src/app/AppManager.test.tsx | 2 +- packages/core-app-api/src/app/AppRouter.tsx | 15 +- packages/frontend-app-api/package.json | 1 + .../src/extensions/AppRoot.tsx | 5 +- .../app-backend/src/service/appPlugin.test.ts | 4 +- plugins/app-backend/src/service/router.ts | 30 ++-- plugins/auth-node/api-report.md | 2 + .../createCookieAuthRefreshMiddleware.test.ts | 7 +- .../createCookieAuthRefreshMiddleware.ts | 10 +- plugins/auth-react/api-report.md | 10 +- plugins/auth-react/package.json | 2 + .../src/components/AppMode/AppMode.test.tsx | 103 ++++++++++++ .../src/components/AppMode/AppMode.tsx | 48 ++++++ .../{RedirectToRoot => AppMode}/index.ts | 2 +- .../CompatAppProgress/CompatAppProgress.tsx | 40 +++++ .../index.ts | 2 +- .../CookieAuthRefreshProvider.test.tsx | 2 +- .../CookieAuthRefreshProvider.tsx | 6 +- .../CookieAuthRootRedirect.test.tsx} | 8 +- .../CookieAuthRootRedirect.tsx} | 2 +- .../CookieAuthRootRedirect/index.ts | 17 ++ .../ExperimentalAppProtection.test.tsx | 150 ------------------ .../ExperimentalAppProtection.tsx | 63 -------- plugins/auth-react/src/components/index.ts | 4 +- .../useCookieAuthRefresh.test.tsx | 2 +- .../useCookieAuthRefresh.tsx | 4 +- yarn.lock | 18 ++- 43 files changed, 351 insertions(+), 369 deletions(-) delete mode 100644 .changeset/old-parents-try.md create mode 100644 plugins/auth-react/src/components/AppMode/AppMode.test.tsx create mode 100644 plugins/auth-react/src/components/AppMode/AppMode.tsx rename plugins/auth-react/src/components/{RedirectToRoot => AppMode}/index.ts (92%) create mode 100644 plugins/auth-react/src/components/CompatAppProgress/CompatAppProgress.tsx rename plugins/auth-react/src/components/{ExperimentalAppProtection => CompatAppProgress}/index.ts (89%) rename plugins/auth-react/src/components/{RedirectToRoot/RedirectToRoot.test.tsx => CookieAuthRootRedirect/CookieAuthRootRedirect.test.tsx} (91%) rename plugins/auth-react/src/components/{RedirectToRoot/RedirectToRoot.tsx => CookieAuthRootRedirect/CookieAuthRootRedirect.tsx} (97%) create mode 100644 plugins/auth-react/src/components/CookieAuthRootRedirect/index.ts delete mode 100644 plugins/auth-react/src/components/ExperimentalAppProtection/ExperimentalAppProtection.test.tsx delete mode 100644 plugins/auth-react/src/components/ExperimentalAppProtection/ExperimentalAppProtection.tsx diff --git a/.changeset/fifty-cameras-shake.md b/.changeset/fifty-cameras-shake.md index 49515502a3..b87d4e92c2 100644 --- a/.changeset/fifty-cameras-shake.md +++ b/.changeset/fifty-cameras-shake.md @@ -2,4 +2,4 @@ '@backstage/plugin-auth-react': patch --- -Update the default cookie base path and create a experimental redirect to root and protected app components, the components should be used only when the public entry is enabled. +Update the default cookie base path and create a experimental redirect to root and app mode components, the components authenticate and keep a cookie refresh loop on the client side. diff --git a/.changeset/nine-rabbits-exist.md b/.changeset/nine-rabbits-exist.md index 9d14a9f417..a26f1f59c3 100644 --- a/.changeset/nine-rabbits-exist.md +++ b/.changeset/nine-rabbits-exist.md @@ -2,4 +2,4 @@ '@backstage/core-app-api': patch --- -Clear the app auth cookie when the when the user sign out. +Clear the app auth cookie after a sign out. diff --git a/.changeset/old-parents-try.md b/.changeset/old-parents-try.md deleted file mode 100644 index 866cdaee21..0000000000 --- a/.changeset/old-parents-try.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@backstage/backend-plugin-api': patch -'@backstage/backend-test-utils': patch -'@backstage/backend-common': patch ---- - -Add a `removeUserCookie` to the http auth service interface. diff --git a/docs/tutorials/enable-public-entry.md b/docs/tutorials/enable-public-entry.md index a25d1a8704..79c2d33e68 100644 --- a/docs/tutorials/enable-public-entry.md +++ b/docs/tutorials/enable-public-entry.md @@ -1,6 +1,6 @@ --- id: enable-public-entry -title: Enabling public entry point +title: Enabling a public entry point description: A guide for how to experiment with public and protected Backstage app bundles --- @@ -21,11 +21,14 @@ With that, Backstage's cli and backend will detect public entry point and serve - The tutorial will only work for those using backstage-cli to build and serve their Backstage app. -## Trying out this feature +## Step-by-step -1. Add a `index-public-experimental.tsx` to your app `src` folder; +1. Create a `index-public-experimental.tsx` in your app `src` folder; + :::note + The filename is a convention, so it is not currently configurable. + ::: -2. Prepare an unauthenticated version of your application. This will be the public entry point for your site: +2. This file is the public entry point for your application, and it should only contain what unauthenticated users should see: ```tsx title="in packages/app/src/index-public-experimental.tsx" import React from 'react'; @@ -42,7 +45,7 @@ With that, Backstage's cli and backend will detect public entry point and serve createApiFactory, discoveryApiRef, } from '@backstage/core-plugin-api'; - import { RedirectToRoot } from '@backstage/plugin-auth-react'; + import { CookieAuthRootRedirect } from '@backstage/plugin-auth-react'; import { providers } from '../src/identityProviders'; import { AuthProxyDiscoveryApi } from '../src/AuthProxyDiscoveryApi'; @@ -76,7 +79,7 @@ With that, Backstage's cli and backend will detect public entry point and serve {/* This is a special component that does the magic to redirect users to access the home page of your authenticated application version */} - + , ); @@ -84,35 +87,12 @@ With that, Backstage's cli and backend will detect public entry point and serve ReactDOM.createRoot(document.getElementById('root')!).render(); ``` -3) Then ensure your main entry is also protected by the experimental app provider, as shown in the following example: +3. The frontend will handle cookie refreshing automatically, so you don't have to worry about it; -```tsx title="in packages/app/src/index-public-experimental.tsx" -// ... -export default app.createRoot( - <> - - - - {/* For now, this is just a temporary solution to ensure the user remains logged in properly and has access to the main app content. */} - - - {routes} - - - , -); -``` +4. You're now ready to build and serve your frontend and backend as usual; -4. You're now ready to build your front-end app: +5. After that, access your backend index endpoint to see the public app being served (note that only a minimal app is being served). -```sh -yarn workspace example-app build -``` +6. Log in and you will be redirected to the main app home page (check the protected bundle being served from the app-backend after the redirect). -5. And also serve it from your Backstage app backend: - -```sh -yarn start-backend:next -``` - -6. Finally, access http://localhost:7007 to see the public app being served (note that only a minimal app is being served). Log in and you will be redirected to the main app home page (check the protected bundle being served from the app-backend after the redirect). +That's it! diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 2639f2add8..3d8bd45e5a 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -108,7 +108,6 @@ import { DevToolsPage } from '@backstage/plugin-devtools'; import { customDevToolsPage } from './components/devtools/CustomDevToolsPage'; import { CatalogUnprocessedEntitiesPage } from '@backstage/plugin-catalog-unprocessed-entities'; import { NotificationsPage } from '@backstage/plugin-notifications'; -import { ExperimentalAppProtection } from '@backstage/plugin-auth-react'; const app = createApp({ apis, @@ -283,10 +282,8 @@ export default app.createRoot( - - - {routes} - + + {routes} , ); diff --git a/packages/app/src/index-public-experimental.tsx b/packages/app/src/index-public-experimental.tsx index 59f89c1157..3de164fb64 100644 --- a/packages/app/src/index-public-experimental.tsx +++ b/packages/app/src/index-public-experimental.tsx @@ -21,7 +21,7 @@ import { OAuthRequestDialog, SignInPage, } from '@backstage/core-components'; -import { RedirectToRoot } from '@backstage/plugin-auth-react'; +import { CookieAuthRootRedirect } from '@backstage/plugin-auth-react'; import React from 'react'; import ReactDOM from 'react-dom/client'; import { providers } from '../src/identityProviders'; @@ -59,7 +59,7 @@ const App = app.createRoot( - + , ); 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 807e31513e..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 { @@ -254,10 +272,6 @@ class DefaultHttpAuthService implements HttpAuthService { throw error; } } - - removeUserCookie(res: Response): void { - res.clearCookie(BACKSTAGE_AUTH_COOKIE); - } } /** @public */ 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 5f32eb8d00..fe8cd0298b 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts @@ -93,7 +93,7 @@ export const httpRouterServiceFactory = createServiceFactory( ) { // Only add the cookie refresh middleware once hasRegistedCookieAuthRefreshMiddleware = true; - router.use(createCookieAuthRefreshMiddleware({ httpAuth })); + router.use(createCookieAuthRefreshMiddleware({ auth, httpAuth })); } }, }; diff --git a/packages/backend-common/src/auth/createLegacyAuthAdapters.ts b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts index 7718f74ee8..0ba23661ef 100644 --- a/packages/backend-common/src/auth/createLegacyAuthAdapters.ts +++ b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts @@ -259,10 +259,6 @@ class HttpAuthCompat implements HttpAuthService { async issueUserCookie(_res: Response): Promise<{ expiresAt: Date }> { return { expiresAt: new Date(Date.now() + 3600_000) }; } - - removeUserCookie(res: Response): void { - res.clearCookie('backstage-auth'); - } } export class UserInfoCompat implements UserInfoService { diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index d91f4a2450..3d20b789c1 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -325,13 +325,11 @@ export interface HttpAuthService { issueUserCookie( res: Response_2, options?: { - credentials?: BackstageCredentials; + credentials?: BackstageCredentials; }, ): Promise<{ expiresAt: Date; }>; - // (undocumented) - removeUserCookie(res: Response_2): void; } // @public (undocumented) diff --git a/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts index eb6ba944f5..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,9 +30,7 @@ export interface HttpAuthService { issueUserCookie( res: Response, options?: { - credentials?: BackstageCredentials; + credentials?: BackstageCredentials; }, ): Promise<{ expiresAt: Date }>; - - removeUserCookie(res: Response): void; } diff --git a/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts b/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts index 8934def5f0..9a69620473 100644 --- a/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts +++ b/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts @@ -140,8 +140,4 @@ export class MockHttpAuthService implements HttpAuthService { return { expiresAt: new Date(Date.now() + 3600_000) }; } - - removeUserCookie(res: Response): void { - res.clearCookie(MOCK_AUTH_COOKIE); - } } diff --git a/packages/backend-test-utils/src/next/services/mockServices.ts b/packages/backend-test-utils/src/next/services/mockServices.ts index 00f1aa901a..a858bf7dbf 100644 --- a/packages/backend-test-utils/src/next/services/mockServices.ts +++ b/packages/backend-test-utils/src/next/services/mockServices.ts @@ -275,7 +275,6 @@ export namespace mockServices { export const mock = simpleMock(coreServices.httpAuth, () => ({ credentials: jest.fn(), issueUserCookie: jest.fn(), - removeUserCookie: jest.fn(), })); } diff --git a/packages/cli/src/lib/bundler/bundle.ts b/packages/cli/src/lib/bundler/bundle.ts index 06249774fd..b99891d70d 100644 --- a/packages/cli/src/lib/bundler/bundle.ts +++ b/packages/cli/src/lib/bundler/bundle.ts @@ -70,11 +70,7 @@ export async function buildBundle(options: BuildOptions) { `⚠️ WARNING: The app /public entry point is an experimental feature that may receive immediate breaking changes.`, ), ); - configs.push( - await createConfig(publicPaths, { - ...commonConfigOptions, - }), - ); + configs.push(await createConfig(publicPaths, commonConfigOptions)); } const isCi = yn(process.env.CI, { default: false }); diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts index 2fe72cd471..2b7311a34c 100644 --- a/packages/cli/src/lib/bundler/server.ts +++ b/packages/cli/src/lib/bundler/server.ts @@ -216,12 +216,7 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be ); } const compiler = publicPaths - ? webpack([ - config, - await createConfig(publicPaths, { - ...commonConfigOptions, - }), - ]) + ? webpack([config, await createConfig(publicPaths, commonConfigOptions)]) : webpack(config); webpackServer = new WebpackDevServer( diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index bb3566062c..dcdb5a78f1 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -44,6 +44,7 @@ "dependencies": { "@backstage/config": "workspace:^", "@backstage/core-plugin-api": "workspace:^", + "@backstage/plugin-auth-react": "workspace:^", "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^", "@types/prop-types": "^15.7.3", diff --git a/packages/core-app-api/src/app/AppManager.test.tsx b/packages/core-app-api/src/app/AppManager.test.tsx index 9f81d0b62e..9300e81567 100644 --- a/packages/core-app-api/src/app/AppManager.test.tsx +++ b/packages/core-app-api/src/app/AppManager.test.tsx @@ -864,7 +864,7 @@ describe('Integration Test', () => { await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledWith( - 'http://localhost:7007/app/.backstage/v1-cookie', + 'http://localhost:7007/app/.backstage/auth/v1/cookie', { method: 'DELETE' }, ), ); diff --git a/packages/core-app-api/src/app/AppRouter.tsx b/packages/core-app-api/src/app/AppRouter.tsx index 7481f6662a..c4bedea4a7 100644 --- a/packages/core-app-api/src/app/AppRouter.tsx +++ b/packages/core-app-api/src/app/AppRouter.tsx @@ -29,6 +29,7 @@ import { isReactRouterBeta } from './isReactRouterBeta'; import { RouteTracker } from '../routing/RouteTracker'; import { Route, Routes } from 'react-router-dom'; import { AppIdentityProxy } from '../apis/implementations/IdentityApi/AppIdentityProxy'; +import { AppMode } from '@backstage/plugin-auth-react'; /** * Get the app base path from the configured app baseUrl. @@ -145,7 +146,10 @@ export function AppRouter(props: AppRouterProps) { - {props.children}} /> + {props.children}} + /> ); @@ -154,7 +158,7 @@ export function AppRouter(props: AppRouterProps) { return ( - {props.children} + {props.children} ); } @@ -168,7 +172,10 @@ export function AppRouter(props: AppRouterProps) { appIdentityProxy={appIdentityProxy} > - {props.children}} /> + {props.children}} + /> @@ -182,7 +189,7 @@ export function AppRouter(props: AppRouterProps) { component={SignInPageComponent} appIdentityProxy={appIdentityProxy} > - {props.children} + {props.children} ); diff --git a/packages/frontend-app-api/package.json b/packages/frontend-app-api/package.json index fb5e982058..097f5a3611 100644 --- a/packages/frontend-app-api/package.json +++ b/packages/frontend-app-api/package.json @@ -38,6 +38,7 @@ "@backstage/core-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", + "@backstage/plugin-auth-react": "workspace:^", "@backstage/theme": "workspace:^", "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^", diff --git a/packages/frontend-app-api/src/extensions/AppRoot.tsx b/packages/frontend-app-api/src/extensions/AppRoot.tsx index f3a962dd10..a25953ed49 100644 --- a/packages/frontend-app-api/src/extensions/AppRoot.tsx +++ b/packages/frontend-app-api/src/extensions/AppRoot.tsx @@ -42,6 +42,7 @@ import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations import { BrowserRouter } from 'react-router-dom'; import { RouteTracker } from '../routing/RouteTracker'; import { getBasePath } from '../routing/getBasePath'; +import { AppMode } from '@backstage/plugin-auth-react'; export const AppRoot = createExtension({ namespace: 'app', @@ -190,7 +191,7 @@ export function AppRouter(props: AppRouterProps) { return ( - {children} + {children} ); } @@ -202,7 +203,7 @@ export function AppRouter(props: AppRouterProps) { component={SignInPageComponent} appIdentityProxy={appIdentityProxy} > - {children} + {children} ); diff --git a/plugins/app-backend/src/service/appPlugin.test.ts b/plugins/app-backend/src/service/appPlugin.test.ts index 647697c204..1507e43b46 100644 --- a/plugins/app-backend/src/service/appPlugin.test.ts +++ b/plugins/app-backend/src/service/appPlugin.test.ts @@ -62,9 +62,9 @@ describe('appPlugin', () => { fetch(`http://localhost:${server.port()}/api/app/derp.html`).then(res => res.text(), ), - ).resolves.toBe('winning'); + ).resolves.toMatch('winning'); await expect( fetch(`http://localhost:${server.port()}`).then(res => res.text()), - ).resolves.toBe('winning'); + ).resolves.toMatch('winning'); }); }); diff --git a/plugins/app-backend/src/service/router.ts b/plugins/app-backend/src/service/router.ts index 70466da001..531fad8179 100644 --- a/plugins/app-backend/src/service/router.ts +++ b/plugins/app-backend/src/service/router.ts @@ -168,12 +168,13 @@ export async function createRouter( const publicDistDir = resolvePath(appDistDir, 'public'); - const enablePublicEntryPoint = await fs.pathExists(publicDistDir); + const enablePublicEntryPoint = + (await fs.pathExists(publicDistDir)) && auth && httpAuth; if (enablePublicEntryPoint && auth && httpAuth) { const publicRouter = Router(); - publicRouter.use(createCookieAuthRefreshMiddleware({ httpAuth })); + publicRouter.use(createCookieAuthRefreshMiddleware({ auth, httpAuth })); publicRouter.use(async (req, _res, next) => { const credentials = await httpAuth.credentials(req, { @@ -214,6 +215,7 @@ export async function createRouter( publicRouter.use( await createEntryPointRouter({ + appMode: 'public', logger: logger.child({ entry: 'public' }), rootDir: publicDistDir, assetStore: assetStore?.withNamespace('public'), @@ -226,6 +228,7 @@ export async function createRouter( router.use( await createEntryPointRouter({ + appMode: enablePublicEntryPoint ? 'protected' : 'public', logger: logger.child({ entry: 'main' }), rootDir: appDistDir, assetStore, @@ -243,6 +246,7 @@ async function createEntryPointRouter({ rootDir, assetStore, staticFallbackHandler, + appMode, appConfigs, injectedConfigPath, }: { @@ -250,6 +254,7 @@ async function createEntryPointRouter({ rootDir: string; assetStore?: StaticAssetsStore; staticFallbackHandler?: express.Handler; + appMode: 'public' | 'protected'; appConfigs?: AppConfig[]; injectedConfigPath?: string; }) { @@ -303,14 +308,21 @@ async function createEntryPointRouter({ }, }), ); + + const indexHtmlContent = Buffer.from( + (await fs.readFile(resolvePath(rootDir, 'index.html'), 'utf8')).replace( + //, + ``, + ), + 'utf8', + ); + router.get('/*', (_req, res) => { - res.sendFile(resolvePath(rootDir, 'index.html'), { - headers: { - // The Cache-Control header instructs the browser to not cache the index.html since it might - // link to static assets from recently deployed versions. - 'cache-control': CACHE_CONTROL_NO_CACHE, - }, - }); + // The Cache-Control header instructs the browser to not cache the index.html since it might + // link to static assets from recently deployed versions. + res.setHeader('Cache-Control', CACHE_CONTROL_NO_CACHE); + res.setHeader('Content-Type', 'text/html;charset=utf-8'); + res.send(indexHtmlContent); }); return router; diff --git a/plugins/auth-node/api-report.md b/plugins/auth-node/api-report.md index 4cb9db70f3..f0696ecd9f 100644 --- a/plugins/auth-node/api-report.md +++ b/plugins/auth-node/api-report.md @@ -3,6 +3,7 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { AuthService } from '@backstage/backend-plugin-api'; import { BackstageIdentityResponse as BackstageIdentityResponse_2 } from '@backstage/plugin-auth-node'; import { BackstageSignInResult as BackstageSignInResult_2 } from '@backstage/plugin-auth-node'; import { Config } from '@backstage/config'; @@ -152,6 +153,7 @@ export type CookieConfigurer = (ctx: { // @public export function createCookieAuthRefreshMiddleware(options: { + auth: AuthService; httpAuth: HttpAuthService; }): Router; diff --git a/plugins/auth-node/src/identity/createCookieAuthRefreshMiddleware.test.ts b/plugins/auth-node/src/identity/createCookieAuthRefreshMiddleware.test.ts index 65426b4ca5..aa5f1a995a 100644 --- a/plugins/auth-node/src/identity/createCookieAuthRefreshMiddleware.test.ts +++ b/plugins/auth-node/src/identity/createCookieAuthRefreshMiddleware.test.ts @@ -23,8 +23,9 @@ describe('createCookieAuthRefreshMiddleware', () => { let app: express.Express; beforeAll(async () => { + const auth = mockServices.auth(); const httpAuth = mockServices.httpAuth(); - const router = createCookieAuthRefreshMiddleware({ httpAuth }); + const router = createCookieAuthRefreshMiddleware({ auth, httpAuth }); app = express().use(router); }); @@ -33,7 +34,7 @@ describe('createCookieAuthRefreshMiddleware', () => { }); it('should issue the user cookie', async () => { - const response = await request(app).get('/.backstage/v1-cookie'); + const response = await request(app).get('/.backstage/auth/v1/cookie'); expect(response.status).toBe(200); expect(response.header['set-cookie'][0]).toMatch( `backstage-auth=${mockCredentials.limitedUser.token()}`, @@ -41,7 +42,7 @@ describe('createCookieAuthRefreshMiddleware', () => { }); it('should remove the user cookie', async () => { - const response = await request(app).delete('/.backstage/v1-cookie'); + const response = await request(app).delete('/.backstage/auth/v1/cookie'); expect(response.status).toBe(200); expect(response.header['set-cookie'][0]).toMatch('backstage-auth='); }); diff --git a/plugins/auth-node/src/identity/createCookieAuthRefreshMiddleware.ts b/plugins/auth-node/src/identity/createCookieAuthRefreshMiddleware.ts index efb58345a1..939fa9197d 100644 --- a/plugins/auth-node/src/identity/createCookieAuthRefreshMiddleware.ts +++ b/plugins/auth-node/src/identity/createCookieAuthRefreshMiddleware.ts @@ -14,19 +14,20 @@ * limitations under the License. */ -import { HttpAuthService } from '@backstage/backend-plugin-api'; +import { AuthService, HttpAuthService } from '@backstage/backend-plugin-api'; import { Router } from 'express'; -const WELL_KNOWN_COOKIE_PATH_V1 = '/.backstage/v1-cookie'; +const WELL_KNOWN_COOKIE_PATH_V1 = '/.backstage/auth/v1/cookie'; /** * @public * Creates a middleware that can be used to refresh the cookie for the user. */ export function createCookieAuthRefreshMiddleware(options: { + auth: AuthService; httpAuth: HttpAuthService; }) { - const { httpAuth } = options; + const { auth, httpAuth } = options; const router = Router(); // Endpoint that sets the cookie for the user @@ -37,7 +38,8 @@ export function createCookieAuthRefreshMiddleware(options: { // Endpoint that removes the cookie for the user router.delete(WELL_KNOWN_COOKIE_PATH_V1, async (_, res) => { - httpAuth.removeUserCookie(res); + const credentials = await auth.getNoneCredentials(); + await httpAuth.issueUserCookie(res, { credentials }); res.send(200); }); diff --git a/plugins/auth-react/api-report.md b/plugins/auth-react/api-report.md index d6b7d2e17f..ef8ae58267 100644 --- a/plugins/auth-react/api-report.md +++ b/plugins/auth-react/api-report.md @@ -6,6 +6,9 @@ import { default as React_2 } from 'react'; import { ReactNode } from 'react'; +// @public +export function AppMode(props: { children: ReactNode }): JSX.Element; + // @public export function CookieAuthRefreshProvider( props: CookieAuthRefreshProviderProps, @@ -19,12 +22,7 @@ export type CookieAuthRefreshProviderProps = { }; // @public -export function ExperimentalAppProtection(props: { - children: ReactNode; -}): JSX.Element; - -// @public -export function RedirectToRoot(): React_2.JSX.Element | null; +export function CookieAuthRootRedirect(): React_2.JSX.Element | null; // @public export function useCookieAuthRefresh(options: { diff --git a/plugins/auth-react/package.json b/plugins/auth-react/package.json index 9de3aade21..e5eb7db192 100644 --- a/plugins/auth-react/package.json +++ b/plugins/auth-react/package.json @@ -35,6 +35,8 @@ "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", + "@backstage/frontend-plugin-api": "workspace:^", + "@backstage/version-bridge": "workspace:^", "@material-ui/core": "^4.9.13", "@react-hookz/web": "^24.0.0", "@types/react": "^16.13.1 || ^17.0.0 || ^18.0.0" diff --git a/plugins/auth-react/src/components/AppMode/AppMode.test.tsx b/plugins/auth-react/src/components/AppMode/AppMode.test.tsx new file mode 100644 index 0000000000..71f6ce9434 --- /dev/null +++ b/plugins/auth-react/src/components/AppMode/AppMode.test.tsx @@ -0,0 +1,103 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { AppMode } from './AppMode'; +import { discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api'; +import { componentsApiRef } from '@backstage/frontend-plugin-api'; + +const now = 1710316886171; +const tenMinutesInMilliseconds = 10 * 60 * 1000; +const tenMinutesFromNowInMilliseconds = now + tenMinutesInMilliseconds; +const expiresAt = new Date(tenMinutesFromNowInMilliseconds).toISOString(); + +jest.mock('@backstage/core-plugin-api', () => { + return { + ...jest.requireActual('@backstage/core-plugin-api'), + useApp: jest.fn().mockReturnValue({ + getComponents: () => ({ Progress: () =>
}), + }), + useApi: jest.fn(ref => { + if (ref === discoveryApiRef) { + return { + getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7000/app'), + }; + } + + if (ref === fetchApiRef) { + return { + fetch: jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ expiresAt }), + }), + }; + } + + if (ref === componentsApiRef) { + return { + getComponent: jest + .fn() + .mockReturnValue(() =>
), + }; + } + + throw new Error(`Attempted to use an unmocked API reference: ${ref}`); + }), + }; +}); + +describe('AppMode', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers({ now }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should render the children when app mode is undefined', async () => { + render(Test content); + await waitFor(() => + expect(screen.getByText('Test content')).toBeInTheDocument(), + ); + }); + + it('should render the children the app mode is public', async () => { + render( + <> + + Test content + , + ); + await waitFor(() => + expect(screen.getByText('Test content')).toBeInTheDocument(), + ); + }); + + it('should render the children wrapped in the CookieAuthRefreshProvider', async () => { + render( + <> + + Test content + , + ); + await waitFor(() => + expect(screen.getByText('Test content')).toBeInTheDocument(), + ); + }); +}); diff --git a/plugins/auth-react/src/components/AppMode/AppMode.tsx b/plugins/auth-react/src/components/AppMode/AppMode.tsx new file mode 100644 index 0000000000..af99bcb23b --- /dev/null +++ b/plugins/auth-react/src/components/AppMode/AppMode.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ReactNode, useEffect, useState } from 'react'; +import { CookieAuthRefreshProvider } from '@backstage/plugin-auth-react'; +import { CompatAppProgress } from '../CompatAppProgress'; + +/** + * @public + * A provider that will protect the app when running in protected experimental mode. + */ +export function AppMode(props: { children: ReactNode }): JSX.Element { + const { children } = props; + + const [appMode, setAppMode] = useState(null); + + useEffect(() => { + const element = document.querySelector('meta[name="backstage-app-mode"]'); + setAppMode(element?.getAttribute('content') ?? 'public'); + }, [setAppMode]); + + if (!appMode) { + return ; + } + + if (appMode === 'protected') { + return ( + + {children} + + ); + } + + return <>{children}; +} diff --git a/plugins/auth-react/src/components/RedirectToRoot/index.ts b/plugins/auth-react/src/components/AppMode/index.ts similarity index 92% rename from plugins/auth-react/src/components/RedirectToRoot/index.ts rename to plugins/auth-react/src/components/AppMode/index.ts index 1e9e211adc..11d64b282a 100644 --- a/plugins/auth-react/src/components/RedirectToRoot/index.ts +++ b/plugins/auth-react/src/components/AppMode/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { RedirectToRoot } from './RedirectToRoot'; +export { AppMode } from './AppMode'; diff --git a/plugins/auth-react/src/components/CompatAppProgress/CompatAppProgress.tsx b/plugins/auth-react/src/components/CompatAppProgress/CompatAppProgress.tsx new file mode 100644 index 0000000000..e624d46304 --- /dev/null +++ b/plugins/auth-react/src/components/CompatAppProgress/CompatAppProgress.tsx @@ -0,0 +1,40 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { useApp } from '@backstage/core-plugin-api'; +import { useVersionedContext } from '@backstage/version-bridge'; + +import { + coreComponentRefs, + useComponentRef, +} from '@backstage/frontend-plugin-api'; + +function LegacyAppProgress() { + const app = useApp(); + const { Progress } = app.getComponents(); + return ; +} + +function NewAppProgress() { + const Progress = useComponentRef(coreComponentRefs.progress); + return ; +} + +export function CompatAppProgress() { + const isInNewApp = !useVersionedContext<{ 1: unknown }>('app-context'); + return isInNewApp ? : ; +} diff --git a/plugins/auth-react/src/components/ExperimentalAppProtection/index.ts b/plugins/auth-react/src/components/CompatAppProgress/index.ts similarity index 89% rename from plugins/auth-react/src/components/ExperimentalAppProtection/index.ts rename to plugins/auth-react/src/components/CompatAppProgress/index.ts index c29b1e438d..22fe51dd85 100644 --- a/plugins/auth-react/src/components/ExperimentalAppProtection/index.ts +++ b/plugins/auth-react/src/components/CompatAppProgress/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { ExperimentalAppProtection } from './ExperimentalAppProtection'; +export { CompatAppProgress } from './CompatAppProgress'; diff --git a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx index ab88361773..77710dc7a4 100644 --- a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx +++ b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx @@ -119,7 +119,7 @@ describe('CookieAuthRefreshProvider', () => { await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledWith( - 'http://localhost:7000/techdocs/api/.backstage/v1-cookie', + 'http://localhost:7000/techdocs/api/.backstage/auth/v1/cookie', { credentials: 'include' }, ), ); diff --git a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx index 7919207692..18dd3c88a0 100644 --- a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx +++ b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx @@ -16,9 +16,9 @@ import React, { ReactNode } from 'react'; import { ErrorPanel } from '@backstage/core-components'; -import { useApp } from '@backstage/core-plugin-api'; import { Button } from '@material-ui/core'; import { useCookieAuthRefresh } from '../../hooks'; +import { CompatAppProgress } from '../CompatAppProgress/CompatAppProgress'; /** * @public @@ -41,13 +41,11 @@ export function CookieAuthRefreshProvider( props: CookieAuthRefreshProviderProps, ): JSX.Element { const { children, ...options } = props; - const app = useApp(); - const { Progress } = app.getComponents(); const result = useCookieAuthRefresh(options); if (result.status === 'loading') { - return ; + return ; } if (result.status === 'error') { diff --git a/plugins/auth-react/src/components/RedirectToRoot/RedirectToRoot.test.tsx b/plugins/auth-react/src/components/CookieAuthRootRedirect/CookieAuthRootRedirect.test.tsx similarity index 91% rename from plugins/auth-react/src/components/RedirectToRoot/RedirectToRoot.test.tsx rename to plugins/auth-react/src/components/CookieAuthRootRedirect/CookieAuthRootRedirect.test.tsx index d1171a4f81..109c65d4ed 100644 --- a/plugins/auth-react/src/components/RedirectToRoot/RedirectToRoot.test.tsx +++ b/plugins/auth-react/src/components/CookieAuthRootRedirect/CookieAuthRootRedirect.test.tsx @@ -18,9 +18,9 @@ import React from 'react'; import { screen, waitFor } from '@testing-library/react'; import { TestApiProvider, renderInTestApp } from '@backstage/test-utils'; import { identityApiRef } from '@backstage/core-plugin-api'; -import { RedirectToRoot } from './RedirectToRoot'; +import { CookieAuthRootRedirect } from './CookieAuthRootRedirect'; -describe('RedirectToRoot', () => { +describe('CookieAuthRootRedirect', () => { const identityApiMock = { getCredentials: jest.fn() }; beforeEach(() => { @@ -32,7 +32,7 @@ describe('RedirectToRoot', () => { await renderInTestApp( - + , ); @@ -50,7 +50,7 @@ describe('RedirectToRoot', () => { await renderInTestApp( - + , ); diff --git a/plugins/auth-react/src/components/RedirectToRoot/RedirectToRoot.tsx b/plugins/auth-react/src/components/CookieAuthRootRedirect/CookieAuthRootRedirect.tsx similarity index 97% rename from plugins/auth-react/src/components/RedirectToRoot/RedirectToRoot.tsx rename to plugins/auth-react/src/components/CookieAuthRootRedirect/CookieAuthRootRedirect.tsx index 86b40b44e1..15a0f7a801 100644 --- a/plugins/auth-react/src/components/RedirectToRoot/RedirectToRoot.tsx +++ b/plugins/auth-react/src/components/CookieAuthRootRedirect/CookieAuthRootRedirect.tsx @@ -22,7 +22,7 @@ import { useAsync, useMountEffect } from '@react-hookz/web'; * @public * A component that redirects to the root of the app after a successful sign-in. */ -export function RedirectToRoot() { +export function CookieAuthRootRedirect() { const identityApi = useApi(identityApiRef); const [state, actions] = useAsync(async () => { diff --git a/plugins/auth-react/src/components/CookieAuthRootRedirect/index.ts b/plugins/auth-react/src/components/CookieAuthRootRedirect/index.ts new file mode 100644 index 0000000000..3e0bc26084 --- /dev/null +++ b/plugins/auth-react/src/components/CookieAuthRootRedirect/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 { CookieAuthRootRedirect } from './CookieAuthRootRedirect'; diff --git a/plugins/auth-react/src/components/ExperimentalAppProtection/ExperimentalAppProtection.test.tsx b/plugins/auth-react/src/components/ExperimentalAppProtection/ExperimentalAppProtection.test.tsx deleted file mode 100644 index 2884400ea9..0000000000 --- a/plugins/auth-react/src/components/ExperimentalAppProtection/ExperimentalAppProtection.test.tsx +++ /dev/null @@ -1,150 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { screen, waitFor } from '@testing-library/react'; -import { rest } from 'msw'; -import { setupServer } from 'msw/node'; -import { - MockConfigApi, - TestApiProvider, - renderInTestApp, - setupRequestMockHandlers, -} from '@backstage/test-utils'; -import { ExperimentalAppProtection } from './ExperimentalAppProtection'; -import { - configApiRef, - discoveryApiRef, - fetchApiRef, -} from '@backstage/core-plugin-api'; - -describe('ExperimentalAppProtection', () => { - const worker = setupServer(); - setupRequestMockHandlers(worker); - - const configApiMock = new MockConfigApi({ - backend: { - baseUrl: 'http://localhost:7000', - }, - }); - - const fetchApiMock = { - fetch: jest.fn(), - }; - - const discoveryApiMock = { - getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7000/app'), - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should render the progress component while loading', async () => { - fetchApiMock.fetch.mockReturnValueOnce(new Promise(() => {})); - await renderInTestApp( - - Test content - , - ); - expect(screen.getByTestId('progress')).toBeVisible(); - expect(screen.queryByText('Test Content')).not.toBeInTheDocument(); - }); - - it('should render the children even when there is an error', async () => { - const error = new Error('Failed to fetch'); - fetchApiMock.fetch.mockRejectedValueOnce(error); - await renderInTestApp( - - Test content - , - ); - await waitFor(() => - expect(screen.getByText('Test content')).toBeInTheDocument(), - ); - expect(screen.queryByTestId('progress')).not.toBeInTheDocument(); - expect(screen.queryByText('Failed to fetch')).not.toBeInTheDocument(); - }); - - it('should render the children even when the public index is not available', async () => { - fetchApiMock.fetch.mockResolvedValueOnce({ ok: false }); - await renderInTestApp( - - Test content - , - ); - await waitFor(() => - expect(screen.getByText('Test content')).toBeInTheDocument(), - ); - expect(screen.queryByTestId('progress')).not.toBeInTheDocument(); - }); - - it('should render the children also when the public index is available', async () => { - fetchApiMock.fetch.mockResolvedValueOnce({ ok: true }); - await renderInTestApp( - Test content, - ); - await waitFor(() => - expect(screen.getByText('Test content')).toBeInTheDocument(), - ); - expect(screen.queryByTestId('progress')).not.toBeInTheDocument(); - }); - - it('should render the children wrapped in the CookieAuthRefreshProvider', async () => { - worker.use( - rest.get('http://localhost:7000/public/index.html', (_, res, ctx) => { - return res(ctx.status(200)); - }), - rest.get( - 'http://localhost:7000/app/.backstage/v1-cookie', - (_, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ expiresAt: Date.now() + 10 * 60 * 1000 }), - ); - }, - ), - ); - await renderInTestApp( - - Test content - , - ); - await waitFor(() => - expect(screen.getByText('Test content')).toBeInTheDocument(), - ); - }); -}); diff --git a/plugins/auth-react/src/components/ExperimentalAppProtection/ExperimentalAppProtection.tsx b/plugins/auth-react/src/components/ExperimentalAppProtection/ExperimentalAppProtection.tsx deleted file mode 100644 index f76b287d97..0000000000 --- a/plugins/auth-react/src/components/ExperimentalAppProtection/ExperimentalAppProtection.tsx +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { ReactNode } from 'react'; -import { - configApiRef, - fetchApiRef, - useApi, - useApp, -} from '@backstage/core-plugin-api'; -import { CookieAuthRefreshProvider } from '@backstage/plugin-auth-react'; -import { useAsync, useMountEffect } from '@react-hookz/web'; - -/** - * @public - * A provider that will protect the app when running in public experimental mode. - */ -export function ExperimentalAppProtection(props: { - children: ReactNode; -}): JSX.Element { - const { children } = props; - const fetchApi = useApi(fetchApiRef); - const configApi = useApi(configApiRef); - const Components = useApp().getComponents(); - - const [state, actions] = useAsync(async () => { - const baseUrl = configApi.getString('backend.baseUrl'); - const response = await fetchApi.fetch(`${baseUrl}/public/index.html`); - return response.ok; - }); - - useMountEffect(actions.execute); - - if (state.status === 'not-executed' || state.status === 'loading') { - return ; - } - - // Request failed, or the public index is not available - if (state.status === 'error' || !state.result) { - return <>{children}; - } - - // The public index is available - // That means the app is running in public experimental mode - return ( - - {children} - - ); -} diff --git a/plugins/auth-react/src/components/index.ts b/plugins/auth-react/src/components/index.ts index a85fad95ae..03345df968 100644 --- a/plugins/auth-react/src/components/index.ts +++ b/plugins/auth-react/src/components/index.ts @@ -17,6 +17,6 @@ // The index file in ./components/ is typically responsible for selecting // which components are public API and should be exported from the package. -export * from './RedirectToRoot'; +export * from './CookieAuthRootRedirect'; export * from './CookieAuthRefreshProvider'; -export * from './ExperimentalAppProtection'; +export * from './AppMode'; diff --git a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx index aeff0be3b0..cd61b9d94b 100644 --- a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx +++ b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx @@ -236,7 +236,7 @@ describe('useCookieAuthRefresh', () => { await waitFor(() => expect(fetchApiMock.fetch).toHaveBeenCalledWith( - 'http://localhost:7000/techdocs/api/.backstage/v1-cookie', + 'http://localhost:7000/techdocs/api/.backstage/auth/v1/cookie', { credentials: 'include' }, ), ); diff --git a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx index 45753d7460..b26d45447e 100644 --- a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx +++ b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx @@ -31,13 +31,13 @@ import { ResponseError } from '@backstage/errors'; export function useCookieAuthRefresh(options: { // The plugin id used for discovering the API origin pluginId: string; - // The path used for calling the refresh cookie endpoint, default to '/.backstage/v1-cookie' + // The path used for calling the refresh cookie endpoint, default to '/.backstage/auth/v1/cookie' path?: string; }): | { status: 'loading' } | { status: 'error'; error: Error; retry: () => void } | { status: 'success'; data: { expiresAt: string } } { - const { pluginId, path = '/.backstage/v1-cookie' } = options ?? {}; + const { pluginId, path = '/.backstage/auth/v1/cookie' } = options ?? {}; const fetchApi = useApi(fetchApiRef); const discoveryApi = useApi(discoveryApiRef); diff --git a/yarn.lock b/yarn.lock index 666548dd95..db7491ccf1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3834,6 +3834,7 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/core-plugin-api": "workspace:^" + "@backstage/plugin-auth-react": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" "@backstage/version-bridge": "workspace:^" @@ -4193,6 +4194,7 @@ __metadata: "@backstage/core-plugin-api": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" + "@backstage/plugin-auth-react": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/theme": "workspace:^" "@backstage/types": "workspace:^" @@ -5112,7 +5114,9 @@ __metadata: "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/frontend-plugin-api": "workspace:^" "@backstage/test-utils": "workspace:^" + "@backstage/version-bridge": "workspace:^" "@material-ui/core": ^4.9.13 "@react-hookz/web": ^24.0.0 "@testing-library/jest-dom": ^6.0.0 @@ -25863,7 +25867,7 @@ __metadata: languageName: node linkType: hard -"domhandler@npm:^5.0.1, domhandler@npm:^5.0.2": +"domhandler@npm:^5.0.1, domhandler@npm:^5.0.2, domhandler@npm:^5.0.3": version: 5.0.3 resolution: "domhandler@npm:5.0.3" dependencies: @@ -26269,7 +26273,7 @@ __metadata: languageName: node linkType: hard -"entities@npm:^4.2.0, entities@npm:^4.3.0, entities@npm:^4.4.0": +"entities@npm:^4.2.0, entities@npm:^4.4.0": version: 4.4.0 resolution: "entities@npm:4.4.0" checksum: 84d250329f4b56b40fa93ed067b194db21e8815e4eb9b59f43a086f0ecd342814f6bc483de8a77da5d64e0f626033192b1b4f1792232a7ea6b970ebe0f3187c2 @@ -30033,14 +30037,14 @@ __metadata: linkType: hard "htmlparser2@npm:^8.0.0": - version: 8.0.1 - resolution: "htmlparser2@npm:8.0.1" + version: 8.0.2 + resolution: "htmlparser2@npm:8.0.2" dependencies: domelementtype: ^2.3.0 - domhandler: ^5.0.2 + domhandler: ^5.0.3 domutils: ^3.0.1 - entities: ^4.3.0 - checksum: 06d5c71e8313597722bc429ae2a7a8333d77bd3ab07ccb916628384b37332027b047f8619448d8f4a3312b6609c6ea3302a4e77435d859e9e686999e6699ca39 + entities: ^4.4.0 + checksum: 29167a0f9282f181da8a6d0311b76820c8a59bc9e3c87009e21968264c2987d2723d6fde5a964d4b7b6cba663fca96ffb373c06d8223a85f52a6089ced942700 languageName: node linkType: hard From b01e709ead34d5393224a5db7167efdbe05be6fb Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Fri, 5 Apr 2024 09:23:49 +0200 Subject: [PATCH 14/36] refactor: more review refinements Signed-off-by: Camila Belo --- docs/tutorials/enable-public-entry.md | 4 ++-- .../app/src/index-public-experimental.tsx | 4 ++-- .../createCookieAuthRefreshMiddleware.test.ts | 0 .../createCookieAuthRefreshMiddleware.ts | 0 .../httpRouter/httpRouterServiceFactory.ts | 13 ++----------- .../core-app-api/src/app/AppManager.test.tsx | 18 +++++++++++++++++- packages/core-app-api/src/app/AppManager.tsx | 5 +++-- packages/core-app-api/src/app/AppRouter.tsx | 10 +++++----- .../src/extensions/AppRoot.tsx | 6 +++--- .../app-backend/src/service/appPlugin.test.ts | 4 ++-- plugins/app-backend/src/service/router.ts | 3 --- plugins/auth-node/api-report.md | 9 --------- plugins/auth-node/package.json | 1 - plugins/auth-node/src/identity/index.ts | 1 - plugins/auth-react/api-report.md | 7 +++++-- .../AppAuthProvider.test.tsx} | 12 ++++++------ .../AppAuthProvider.tsx} | 19 ++++--------------- .../index.ts | 3 ++- .../isProtectedApp.tsx} | 10 +++++++++- .../CookieAuthRedirect.test.tsx} | 8 ++++---- .../CookieAuthRedirect.tsx} | 4 ++-- .../{AppMode => CookieAuthRedirect}/index.ts | 2 +- .../CompatAppProgress.tsx | 0 .../CookieAuthRefreshProvider.tsx | 3 ++- plugins/auth-react/src/components/index.ts | 4 ++-- yarn.lock | 1 - 26 files changed, 73 insertions(+), 78 deletions(-) rename {plugins/auth-node/src/identity => packages/backend-app-api/src/services/implementations/httpRouter}/createCookieAuthRefreshMiddleware.test.ts (100%) rename {plugins/auth-node/src/identity => packages/backend-app-api/src/services/implementations/httpRouter}/createCookieAuthRefreshMiddleware.ts (100%) rename plugins/auth-react/src/components/{AppMode/AppMode.test.tsx => AppAuthProvider/AppAuthProvider.test.tsx} (91%) rename plugins/auth-react/src/components/{AppMode/AppMode.tsx => AppAuthProvider/AppAuthProvider.tsx} (64%) rename plugins/auth-react/src/components/{CookieAuthRootRedirect => AppAuthProvider}/index.ts (85%) rename plugins/auth-react/src/components/{CompatAppProgress/index.ts => AppAuthProvider/isProtectedApp.tsx} (65%) rename plugins/auth-react/src/components/{CookieAuthRootRedirect/CookieAuthRootRedirect.test.tsx => CookieAuthRedirect/CookieAuthRedirect.test.tsx} (91%) rename plugins/auth-react/src/components/{CookieAuthRootRedirect/CookieAuthRootRedirect.tsx => CookieAuthRedirect/CookieAuthRedirect.tsx} (92%) rename plugins/auth-react/src/components/{AppMode => CookieAuthRedirect}/index.ts (91%) rename plugins/auth-react/src/components/{CompatAppProgress => CookieAuthRefreshProvider}/CompatAppProgress.tsx (100%) diff --git a/docs/tutorials/enable-public-entry.md b/docs/tutorials/enable-public-entry.md index 79c2d33e68..651a8dec1e 100644 --- a/docs/tutorials/enable-public-entry.md +++ b/docs/tutorials/enable-public-entry.md @@ -45,7 +45,7 @@ With that, Backstage's cli and backend will detect public entry point and serve createApiFactory, discoveryApiRef, } from '@backstage/core-plugin-api'; - import { CookieAuthRootRedirect } from '@backstage/plugin-auth-react'; + import { CookieAuthRedirect } from '@backstage/plugin-auth-react'; import { providers } from '../src/identityProviders'; import { AuthProxyDiscoveryApi } from '../src/AuthProxyDiscoveryApi'; @@ -79,7 +79,7 @@ With that, Backstage's cli and backend will detect public entry point and serve {/* This is a special component that does the magic to redirect users to access the home page of your authenticated application version */} - + , ); diff --git a/packages/app/src/index-public-experimental.tsx b/packages/app/src/index-public-experimental.tsx index 3de164fb64..20f111dc2b 100644 --- a/packages/app/src/index-public-experimental.tsx +++ b/packages/app/src/index-public-experimental.tsx @@ -21,7 +21,7 @@ import { OAuthRequestDialog, SignInPage, } from '@backstage/core-components'; -import { CookieAuthRootRedirect } from '@backstage/plugin-auth-react'; +import { CookieAuthRedirect } from '@backstage/plugin-auth-react'; import React from 'react'; import ReactDOM from 'react-dom/client'; import { providers } from '../src/identityProviders'; @@ -59,7 +59,7 @@ const App = app.createRoot( - + , ); diff --git a/plugins/auth-node/src/identity/createCookieAuthRefreshMiddleware.test.ts b/packages/backend-app-api/src/services/implementations/httpRouter/createCookieAuthRefreshMiddleware.test.ts similarity index 100% rename from plugins/auth-node/src/identity/createCookieAuthRefreshMiddleware.test.ts rename to packages/backend-app-api/src/services/implementations/httpRouter/createCookieAuthRefreshMiddleware.test.ts diff --git a/plugins/auth-node/src/identity/createCookieAuthRefreshMiddleware.ts b/packages/backend-app-api/src/services/implementations/httpRouter/createCookieAuthRefreshMiddleware.ts similarity index 100% rename from plugins/auth-node/src/identity/createCookieAuthRefreshMiddleware.ts rename to packages/backend-app-api/src/services/implementations/httpRouter/createCookieAuthRefreshMiddleware.ts 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 fe8cd0298b..60c6dcd3c0 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts @@ -21,10 +21,10 @@ import { createServiceFactory, HttpRouterServiceAuthPolicy, } from '@backstage/backend-plugin-api'; -import { createCookieAuthRefreshMiddleware } from '@backstage/plugin-auth-node'; import { createLifecycleMiddleware } from './createLifecycleMiddleware'; import { createCredentialsBarrier } from './createCredentialsBarrier'; import { createAuthIntegrationRouter } from './createAuthIntegrationRouter'; +import { createCookieAuthRefreshMiddleware } from './createCookieAuthRefreshMiddleware'; /** * @public @@ -59,8 +59,6 @@ export const httpRouterServiceFactory = createServiceFactory( rootHttpRouter, lifecycle, }) { - let hasRegistedCookieAuthRefreshMiddleware = false; - if (options?.getPath) { logger.warn( `DEPRECATION WARNING: The 'getPath' option for HttpRouterService is deprecated. The ability to reconfigure the '/api/' path prefix for plugins will be removed in the future.`, @@ -80,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 { @@ -87,14 +86,6 @@ export const httpRouterServiceFactory = createServiceFactory( }, addAuthPolicy(policy: HttpRouterServiceAuthPolicy): void { credentialsBarrier.addAuthPolicy(policy); - if ( - policy.allow === 'user-cookie' && - !hasRegistedCookieAuthRefreshMiddleware - ) { - // Only add the cookie refresh middleware once - hasRegistedCookieAuthRefreshMiddleware = true; - router.use(createCookieAuthRefreshMiddleware({ auth, httpAuth })); - } }, }; }, diff --git a/packages/core-app-api/src/app/AppManager.test.tsx b/packages/core-app-api/src/app/AppManager.test.tsx index 9300e81567..59b53bf6c5 100644 --- a/packages/core-app-api/src/app/AppManager.test.tsx +++ b/packages/core-app-api/src/app/AppManager.test.tsx @@ -824,10 +824,23 @@ describe('Integration Test', () => { }); it('should clear app cookie when the user logs out', async () => { - const fetchApiMock = { fetch: jest.fn().mockResolvedValue({ ok: true }) }; + 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, @@ -855,6 +868,7 @@ describe('Integration Test', () => { const Root = app.createRoot( + , ); @@ -868,5 +882,7 @@ describe('Integration Test', () => { { 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 cb2577dd4d..3ad8387bf6 100644 --- a/packages/core-app-api/src/app/AppManager.tsx +++ b/packages/core-app-api/src/app/AppManager.tsx @@ -88,6 +88,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 '@backstage/plugin-auth-react'; type CompatiblePlugin = | BackstagePlugin @@ -361,11 +362,11 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be this.appIdentityProxy.setSignOutCallback(async () => { const fetchApi = apis.get(fetchApiRef); const discoveryApi = apis.get(discoveryApiRef); - if (!fetchApi || !discoveryApi) return; + if (!fetchApi || !discoveryApi || !isProtectedApp()) return; // It is fine if we do NOT worry yet about deleting cookies for OTHER backends like techdocs const appBaseUrl = await discoveryApi.getBaseUrl('app'); try { - await fetchApi.fetch(`${appBaseUrl}/.backstage/v1-cookie`, { + await fetchApi.fetch(`${appBaseUrl}/.backstage/auth/v1/cookie`, { method: 'DELETE', }); } catch { diff --git a/packages/core-app-api/src/app/AppRouter.tsx b/packages/core-app-api/src/app/AppRouter.tsx index c4bedea4a7..b667c03c4f 100644 --- a/packages/core-app-api/src/app/AppRouter.tsx +++ b/packages/core-app-api/src/app/AppRouter.tsx @@ -29,7 +29,7 @@ import { isReactRouterBeta } from './isReactRouterBeta'; import { RouteTracker } from '../routing/RouteTracker'; import { Route, Routes } from 'react-router-dom'; import { AppIdentityProxy } from '../apis/implementations/IdentityApi/AppIdentityProxy'; -import { AppMode } from '@backstage/plugin-auth-react'; +import { AppAuthProvider } from '@backstage/plugin-auth-react'; /** * Get the app base path from the configured app baseUrl. @@ -148,7 +148,7 @@ export function AppRouter(props: AppRouterProps) { {props.children}} + element={{props.children}} /> @@ -158,7 +158,7 @@ export function AppRouter(props: AppRouterProps) { return ( - {props.children} + {props.children} ); } @@ -174,7 +174,7 @@ export function AppRouter(props: AppRouterProps) { {props.children}} + element={{props.children}} /> @@ -189,7 +189,7 @@ export function AppRouter(props: AppRouterProps) { component={SignInPageComponent} appIdentityProxy={appIdentityProxy} > - {props.children} + {props.children} ); diff --git a/packages/frontend-app-api/src/extensions/AppRoot.tsx b/packages/frontend-app-api/src/extensions/AppRoot.tsx index a25953ed49..2822ebeb3e 100644 --- a/packages/frontend-app-api/src/extensions/AppRoot.tsx +++ b/packages/frontend-app-api/src/extensions/AppRoot.tsx @@ -42,7 +42,7 @@ import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations import { BrowserRouter } from 'react-router-dom'; import { RouteTracker } from '../routing/RouteTracker'; import { getBasePath } from '../routing/getBasePath'; -import { AppMode } from '@backstage/plugin-auth-react'; +import { AppAuthProvider } from '@backstage/plugin-auth-react'; export const AppRoot = createExtension({ namespace: 'app', @@ -191,7 +191,7 @@ export function AppRouter(props: AppRouterProps) { return ( - {children} + {children} ); } @@ -203,7 +203,7 @@ export function AppRouter(props: AppRouterProps) { component={SignInPageComponent} appIdentityProxy={appIdentityProxy} > - {children} + {children} ); diff --git a/plugins/app-backend/src/service/appPlugin.test.ts b/plugins/app-backend/src/service/appPlugin.test.ts index 1507e43b46..647697c204 100644 --- a/plugins/app-backend/src/service/appPlugin.test.ts +++ b/plugins/app-backend/src/service/appPlugin.test.ts @@ -62,9 +62,9 @@ describe('appPlugin', () => { fetch(`http://localhost:${server.port()}/api/app/derp.html`).then(res => res.text(), ), - ).resolves.toMatch('winning'); + ).resolves.toBe('winning'); await expect( fetch(`http://localhost:${server.port()}`).then(res => res.text()), - ).resolves.toMatch('winning'); + ).resolves.toBe('winning'); }); }); diff --git a/plugins/app-backend/src/service/router.ts b/plugins/app-backend/src/service/router.ts index 531fad8179..574e13db97 100644 --- a/plugins/app-backend/src/service/router.ts +++ b/plugins/app-backend/src/service/router.ts @@ -40,7 +40,6 @@ import { import { ConfigSchema } from '@backstage/config-loader'; import { AuthService, HttpAuthService } from '@backstage/backend-plugin-api'; import { AuthenticationError } from '@backstage/errors'; -import { createCookieAuthRefreshMiddleware } from '@backstage/plugin-auth-node'; // express uses mime v1 while we only have types for mime v2 type Mime = { lookup(arg0: string): string }; @@ -174,8 +173,6 @@ export async function createRouter( if (enablePublicEntryPoint && auth && httpAuth) { const publicRouter = Router(); - publicRouter.use(createCookieAuthRefreshMiddleware({ auth, httpAuth })); - publicRouter.use(async (req, _res, next) => { const credentials = await httpAuth.credentials(req, { allow: ['user', 'service', 'none'], diff --git a/plugins/auth-node/api-report.md b/plugins/auth-node/api-report.md index f0696ecd9f..e79facc7f9 100644 --- a/plugins/auth-node/api-report.md +++ b/plugins/auth-node/api-report.md @@ -3,7 +3,6 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { AuthService } from '@backstage/backend-plugin-api'; import { BackstageIdentityResponse as BackstageIdentityResponse_2 } from '@backstage/plugin-auth-node'; import { BackstageSignInResult as BackstageSignInResult_2 } from '@backstage/plugin-auth-node'; import { Config } from '@backstage/config'; @@ -11,7 +10,6 @@ import { Entity } from '@backstage/catalog-model'; import { EntityFilterQuery } from '@backstage/catalog-client'; import express from 'express'; import { ExtensionPoint } from '@backstage/backend-plugin-api'; -import { HttpAuthService } from '@backstage/backend-plugin-api'; import { JsonObject } from '@backstage/types'; import { JsonValue } from '@backstage/types'; import { LoggerService } from '@backstage/backend-plugin-api'; @@ -19,7 +17,6 @@ import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { Profile } from 'passport'; import { Request as Request_2 } from 'express'; import { Response as Response_2 } from 'express'; -import { Router } from 'express-serve-static-core'; import { Strategy } from 'passport'; import { ZodSchema } from 'zod'; import { ZodTypeDef } from 'zod'; @@ -151,12 +148,6 @@ export type CookieConfigurer = (ctx: { sameSite?: 'none' | 'lax' | 'strict'; }; -// @public -export function createCookieAuthRefreshMiddleware(options: { - auth: AuthService; - httpAuth: HttpAuthService; -}): Router; - // @public (undocumented) export function createOAuthAuthenticator( authenticator: OAuthAuthenticator, diff --git a/plugins/auth-node/package.json b/plugins/auth-node/package.json index 77c8bcb9cb..49d9dd87b9 100644 --- a/plugins/auth-node/package.json +++ b/plugins/auth-node/package.json @@ -39,7 +39,6 @@ "@backstage/errors": "workspace:^", "@backstage/types": "workspace:^", "@types/express": "*", - "@types/express-serve-static-core": "^4.17.5", "@types/passport": "^1.0.3", "express": "^4.17.1", "jose": "^5.0.0", diff --git a/plugins/auth-node/src/identity/index.ts b/plugins/auth-node/src/identity/index.ts index a81f691698..24e2dcb691 100644 --- a/plugins/auth-node/src/identity/index.ts +++ b/plugins/auth-node/src/identity/index.ts @@ -22,4 +22,3 @@ export { } from './DefaultIdentityClient'; export { IdentityClient } from './IdentityClient'; export type { IdentityApi, IdentityApiGetIdentityRequest } from './IdentityApi'; -export { createCookieAuthRefreshMiddleware } from './createCookieAuthRefreshMiddleware'; diff --git a/plugins/auth-react/api-report.md b/plugins/auth-react/api-report.md index ef8ae58267..b76bc6973e 100644 --- a/plugins/auth-react/api-report.md +++ b/plugins/auth-react/api-report.md @@ -7,7 +7,10 @@ import { default as React_2 } from 'react'; import { ReactNode } from 'react'; // @public -export function AppMode(props: { children: ReactNode }): JSX.Element; +export function AppAuthProvider(props: { children: ReactNode }): JSX.Element; + +// @public +export function CookieAuthRedirect(): React_2.JSX.Element | null; // @public export function CookieAuthRefreshProvider( @@ -22,7 +25,7 @@ export type CookieAuthRefreshProviderProps = { }; // @public -export function CookieAuthRootRedirect(): React_2.JSX.Element | null; +export function isProtectedApp(): boolean; // @public export function useCookieAuthRefresh(options: { diff --git a/plugins/auth-react/src/components/AppMode/AppMode.test.tsx b/plugins/auth-react/src/components/AppAuthProvider/AppAuthProvider.test.tsx similarity index 91% rename from plugins/auth-react/src/components/AppMode/AppMode.test.tsx rename to plugins/auth-react/src/components/AppAuthProvider/AppAuthProvider.test.tsx index 71f6ce9434..ddf37059f2 100644 --- a/plugins/auth-react/src/components/AppMode/AppMode.test.tsx +++ b/plugins/auth-react/src/components/AppAuthProvider/AppAuthProvider.test.tsx @@ -16,9 +16,9 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; -import { AppMode } from './AppMode'; -import { discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api'; import { componentsApiRef } from '@backstage/frontend-plugin-api'; +import { discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api'; +import { AppAuthProvider } from './AppAuthProvider'; const now = 1710316886171; const tenMinutesInMilliseconds = 10 * 60 * 1000; @@ -60,7 +60,7 @@ jest.mock('@backstage/core-plugin-api', () => { }; }); -describe('AppMode', () => { +describe('AppAuthProvider', () => { beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers({ now }); @@ -71,7 +71,7 @@ describe('AppMode', () => { }); it('should render the children when app mode is undefined', async () => { - render(Test content); + render(Test content); await waitFor(() => expect(screen.getByText('Test content')).toBeInTheDocument(), ); @@ -81,7 +81,7 @@ describe('AppMode', () => { render( <> - Test content + Test content , ); await waitFor(() => @@ -93,7 +93,7 @@ describe('AppMode', () => { render( <> - Test content + Test content , ); await waitFor(() => diff --git a/plugins/auth-react/src/components/AppMode/AppMode.tsx b/plugins/auth-react/src/components/AppAuthProvider/AppAuthProvider.tsx similarity index 64% rename from plugins/auth-react/src/components/AppMode/AppMode.tsx rename to plugins/auth-react/src/components/AppAuthProvider/AppAuthProvider.tsx index af99bcb23b..dbf8aec3da 100644 --- a/plugins/auth-react/src/components/AppMode/AppMode.tsx +++ b/plugins/auth-react/src/components/AppAuthProvider/AppAuthProvider.tsx @@ -14,29 +14,18 @@ * limitations under the License. */ -import React, { ReactNode, useEffect, useState } from 'react'; +import React, { ReactNode } from 'react'; import { CookieAuthRefreshProvider } from '@backstage/plugin-auth-react'; -import { CompatAppProgress } from '../CompatAppProgress'; +import { isProtectedApp } from './isProtectedApp'; /** * @public * A provider that will protect the app when running in protected experimental mode. */ -export function AppMode(props: { children: ReactNode }): JSX.Element { +export function AppAuthProvider(props: { children: ReactNode }): JSX.Element { const { children } = props; - const [appMode, setAppMode] = useState(null); - - useEffect(() => { - const element = document.querySelector('meta[name="backstage-app-mode"]'); - setAppMode(element?.getAttribute('content') ?? 'public'); - }, [setAppMode]); - - if (!appMode) { - return ; - } - - if (appMode === 'protected') { + if (isProtectedApp()) { return ( {children} diff --git a/plugins/auth-react/src/components/CookieAuthRootRedirect/index.ts b/plugins/auth-react/src/components/AppAuthProvider/index.ts similarity index 85% rename from plugins/auth-react/src/components/CookieAuthRootRedirect/index.ts rename to plugins/auth-react/src/components/AppAuthProvider/index.ts index 3e0bc26084..c1515cad7f 100644 --- a/plugins/auth-react/src/components/CookieAuthRootRedirect/index.ts +++ b/plugins/auth-react/src/components/AppAuthProvider/index.ts @@ -14,4 +14,5 @@ * limitations under the License. */ -export { CookieAuthRootRedirect } from './CookieAuthRootRedirect'; +export { isProtectedApp } from './isProtectedApp'; +export { AppAuthProvider } from './AppAuthProvider'; diff --git a/plugins/auth-react/src/components/CompatAppProgress/index.ts b/plugins/auth-react/src/components/AppAuthProvider/isProtectedApp.tsx similarity index 65% rename from plugins/auth-react/src/components/CompatAppProgress/index.ts rename to plugins/auth-react/src/components/AppAuthProvider/isProtectedApp.tsx index 22fe51dd85..575eb59a73 100644 --- a/plugins/auth-react/src/components/CompatAppProgress/index.ts +++ b/plugins/auth-react/src/components/AppAuthProvider/isProtectedApp.tsx @@ -14,4 +14,12 @@ * limitations under the License. */ -export { CompatAppProgress } from './CompatAppProgress'; +/** + * @public + * An utility function that detects whether the app is operating in protected mode. + */ +export function isProtectedApp() { + const element = document.querySelector('meta[name="backstage-app-mode"]'); + const appMode = element?.getAttribute('content') ?? 'public'; + return appMode === 'protected'; +} diff --git a/plugins/auth-react/src/components/CookieAuthRootRedirect/CookieAuthRootRedirect.test.tsx b/plugins/auth-react/src/components/CookieAuthRedirect/CookieAuthRedirect.test.tsx similarity index 91% rename from plugins/auth-react/src/components/CookieAuthRootRedirect/CookieAuthRootRedirect.test.tsx rename to plugins/auth-react/src/components/CookieAuthRedirect/CookieAuthRedirect.test.tsx index 109c65d4ed..6c0141a9d1 100644 --- a/plugins/auth-react/src/components/CookieAuthRootRedirect/CookieAuthRootRedirect.test.tsx +++ b/plugins/auth-react/src/components/CookieAuthRedirect/CookieAuthRedirect.test.tsx @@ -18,9 +18,9 @@ import React from 'react'; import { screen, waitFor } from '@testing-library/react'; import { TestApiProvider, renderInTestApp } from '@backstage/test-utils'; import { identityApiRef } from '@backstage/core-plugin-api'; -import { CookieAuthRootRedirect } from './CookieAuthRootRedirect'; +import { CookieAuthRedirect } from './CookieAuthRedirect'; -describe('CookieAuthRootRedirect', () => { +describe('CookieAuthRedirect', () => { const identityApiMock = { getCredentials: jest.fn() }; beforeEach(() => { @@ -32,7 +32,7 @@ describe('CookieAuthRootRedirect', () => { await renderInTestApp( - + , ); @@ -50,7 +50,7 @@ describe('CookieAuthRootRedirect', () => { await renderInTestApp( - + , ); diff --git a/plugins/auth-react/src/components/CookieAuthRootRedirect/CookieAuthRootRedirect.tsx b/plugins/auth-react/src/components/CookieAuthRedirect/CookieAuthRedirect.tsx similarity index 92% rename from plugins/auth-react/src/components/CookieAuthRootRedirect/CookieAuthRootRedirect.tsx rename to plugins/auth-react/src/components/CookieAuthRedirect/CookieAuthRedirect.tsx index 15a0f7a801..f719aebdae 100644 --- a/plugins/auth-react/src/components/CookieAuthRootRedirect/CookieAuthRootRedirect.tsx +++ b/plugins/auth-react/src/components/CookieAuthRedirect/CookieAuthRedirect.tsx @@ -20,9 +20,9 @@ import { useAsync, useMountEffect } from '@react-hookz/web'; /** * @public - * A component that redirects to the root of the app after a successful sign-in. + * A component that redirects to the page an user was trying to access before sign-in. */ -export function CookieAuthRootRedirect() { +export function CookieAuthRedirect() { const identityApi = useApi(identityApiRef); const [state, actions] = useAsync(async () => { diff --git a/plugins/auth-react/src/components/AppMode/index.ts b/plugins/auth-react/src/components/CookieAuthRedirect/index.ts similarity index 91% rename from plugins/auth-react/src/components/AppMode/index.ts rename to plugins/auth-react/src/components/CookieAuthRedirect/index.ts index 11d64b282a..506d7b3fdb 100644 --- a/plugins/auth-react/src/components/AppMode/index.ts +++ b/plugins/auth-react/src/components/CookieAuthRedirect/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { AppMode } from './AppMode'; +export { CookieAuthRedirect } from './CookieAuthRedirect'; diff --git a/plugins/auth-react/src/components/CompatAppProgress/CompatAppProgress.tsx b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CompatAppProgress.tsx similarity index 100% rename from plugins/auth-react/src/components/CompatAppProgress/CompatAppProgress.tsx rename to plugins/auth-react/src/components/CookieAuthRefreshProvider/CompatAppProgress.tsx diff --git a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx index 18dd3c88a0..2287ab4ad7 100644 --- a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx +++ b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx @@ -18,7 +18,7 @@ import React, { ReactNode } from 'react'; import { ErrorPanel } from '@backstage/core-components'; import { Button } from '@material-ui/core'; import { useCookieAuthRefresh } from '../../hooks'; -import { CompatAppProgress } from '../CompatAppProgress/CompatAppProgress'; +import { CompatAppProgress } from './CompatAppProgress'; /** * @public @@ -45,6 +45,7 @@ export function CookieAuthRefreshProvider( const result = useCookieAuthRefresh(options); if (result.status === 'loading') { + // This component should be compatible with both old and new frontend systems return ; } diff --git a/plugins/auth-react/src/components/index.ts b/plugins/auth-react/src/components/index.ts index 03345df968..9ab41560f1 100644 --- a/plugins/auth-react/src/components/index.ts +++ b/plugins/auth-react/src/components/index.ts @@ -17,6 +17,6 @@ // The index file in ./components/ is typically responsible for selecting // which components are public API and should be exported from the package. -export * from './CookieAuthRootRedirect'; +export * from './AppAuthProvider'; +export * from './CookieAuthRedirect'; export * from './CookieAuthRefreshProvider'; -export * from './AppMode'; diff --git a/yarn.lock b/yarn.lock index db7491ccf1..0b90ddd2e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5088,7 +5088,6 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/types": "workspace:^" "@types/express": "*" - "@types/express-serve-static-core": ^4.17.5 "@types/passport": ^1.0.3 cookie-parser: ^1.4.6 express: ^4.17.1 From feab471511f626b12678c80a5d8d924d6ae6a392 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Fri, 5 Apr 2024 15:59:12 +0200 Subject: [PATCH 15/36] docs: update enable public entry tutorial Co-authored-by: Patrik Oldsberg Signed-off-by: Camila Belo --- docs/tutorials/enable-public-entry.md | 33 ++++++++++++++++++--------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/docs/tutorials/enable-public-entry.md b/docs/tutorials/enable-public-entry.md index 651a8dec1e..499ab7da02 100644 --- a/docs/tutorials/enable-public-entry.md +++ b/docs/tutorials/enable-public-entry.md @@ -19,11 +19,12 @@ With that, Backstage's cli and backend will detect public entry point and serve ## Requirements -- The tutorial will only work for those using backstage-cli to build and serve their Backstage app. +- 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; +1. Create a `index-public-experimental.tsx` in your app `src` folder. :::note The filename is a convention, so it is not currently configurable. ::: @@ -42,11 +43,10 @@ With that, Backstage's cli and backend will detect public entry point and serve } from '@backstage/core-components'; import { configApiRef, - createApiFactory, discoveryApiRef, + createApiFactory, } from '@backstage/core-plugin-api'; import { CookieAuthRedirect } from '@backstage/plugin-auth-react'; - import { providers } from '../src/identityProviders'; import { AuthProxyDiscoveryApi } from '../src/AuthProxyDiscoveryApi'; // Notice that this is only setting up what is needed by the sign-in pages @@ -64,9 +64,8 @@ With that, Backstage's cli and backend will detect public entry point and serve return ( ); }, @@ -78,7 +77,7 @@ With that, Backstage's cli and backend will detect public entry point and serve - {/* This is a special component that does the magic to redirect users to access the home page of your authenticated application version */} + {/* This component triggers an authenticated redirect to the main app, while staying on the same URL */} , @@ -87,12 +86,24 @@ With that, Backstage's cli and backend will detect public entry point and serve ReactDOM.createRoot(document.getElementById('root')!).render(); ``` -3. The frontend will handle cookie refreshing automatically, so you don't have to worry about it; + :::note + The frontend will handle cookie refreshing automatically, so you don't have to worry about it. + ::: -4. You're now ready to build and serve your frontend and backend as usual; +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: -5. After that, access your backend index endpoint to see the public app being served (note that only a minimal app is being served). + ```sh + # building the app package + yarn workspace app start + # starting the backend api + yarn start-backend + ``` -6. Log in and you will be redirected to the main app home page (check the protected bundle being served from the app-backend after the redirect). +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! From b602171cc2a8221ad99a6a464c0f81d10e924ee7 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Fri, 5 Apr 2024 16:51:17 +0200 Subject: [PATCH 16/36] refactor: move app auth proviter to core api Signed-off-by: Camila Belo --- .../src/app}/AppAuthProvider.test.tsx | 16 ++++++---------- .../core-app-api/src/app}/AppAuthProvider.tsx | 4 ---- packages/core-app-api/src/app/AppManager.tsx | 2 +- packages/core-app-api/src/app/AppRouter.tsx | 2 +- .../core-app-api/src/app}/isProtectedApp.tsx | 4 ---- .../src/extensions/AppRoot.tsx | 3 ++- plugins/auth-react/api-report.md | 6 ------ .../src/components/AppAuthProvider/index.ts | 18 ------------------ plugins/auth-react/src/components/index.ts | 1 - 9 files changed, 10 insertions(+), 46 deletions(-) rename {plugins/auth-react/src/components/AppAuthProvider => packages/core-app-api/src/app}/AppAuthProvider.test.tsx (89%) rename {plugins/auth-react/src/components/AppAuthProvider => packages/core-app-api/src/app}/AppAuthProvider.tsx (91%) rename {plugins/auth-react/src/components/AppAuthProvider => packages/core-app-api/src/app}/isProtectedApp.tsx (88%) delete mode 100644 plugins/auth-react/src/components/AppAuthProvider/index.ts diff --git a/plugins/auth-react/src/components/AppAuthProvider/AppAuthProvider.test.tsx b/packages/core-app-api/src/app/AppAuthProvider.test.tsx similarity index 89% rename from plugins/auth-react/src/components/AppAuthProvider/AppAuthProvider.test.tsx rename to packages/core-app-api/src/app/AppAuthProvider.test.tsx index ddf37059f2..2ee0c49f8d 100644 --- a/plugins/auth-react/src/components/AppAuthProvider/AppAuthProvider.test.tsx +++ b/packages/core-app-api/src/app/AppAuthProvider.test.tsx @@ -16,7 +16,6 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; -import { componentsApiRef } from '@backstage/frontend-plugin-api'; import { discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api'; import { AppAuthProvider } from './AppAuthProvider'; @@ -47,15 +46,12 @@ jest.mock('@backstage/core-plugin-api', () => { }; } - if (ref === componentsApiRef) { - return { - getComponent: jest - .fn() - .mockReturnValue(() =>
), - }; - } - - throw new Error(`Attempted to use an unmocked API reference: ${ref}`); + // componentsApiRef + return { + getComponent: jest + .fn() + .mockReturnValue(() =>
), + }; }), }; }); diff --git a/plugins/auth-react/src/components/AppAuthProvider/AppAuthProvider.tsx b/packages/core-app-api/src/app/AppAuthProvider.tsx similarity index 91% rename from plugins/auth-react/src/components/AppAuthProvider/AppAuthProvider.tsx rename to packages/core-app-api/src/app/AppAuthProvider.tsx index dbf8aec3da..8834930ef6 100644 --- a/plugins/auth-react/src/components/AppAuthProvider/AppAuthProvider.tsx +++ b/packages/core-app-api/src/app/AppAuthProvider.tsx @@ -18,10 +18,6 @@ import React, { ReactNode } from 'react'; import { CookieAuthRefreshProvider } from '@backstage/plugin-auth-react'; import { isProtectedApp } from './isProtectedApp'; -/** - * @public - * A provider that will protect the app when running in protected experimental mode. - */ export function AppAuthProvider(props: { children: ReactNode }): JSX.Element { const { children } = props; diff --git a/packages/core-app-api/src/app/AppManager.tsx b/packages/core-app-api/src/app/AppManager.tsx index 3ad8387bf6..bd51f0046b 100644 --- a/packages/core-app-api/src/app/AppManager.tsx +++ b/packages/core-app-api/src/app/AppManager.tsx @@ -88,7 +88,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 '@backstage/plugin-auth-react'; +import { isProtectedApp } from './isProtectedApp'; type CompatiblePlugin = | BackstagePlugin diff --git a/packages/core-app-api/src/app/AppRouter.tsx b/packages/core-app-api/src/app/AppRouter.tsx index b667c03c4f..21704bc26b 100644 --- a/packages/core-app-api/src/app/AppRouter.tsx +++ b/packages/core-app-api/src/app/AppRouter.tsx @@ -29,7 +29,7 @@ import { isReactRouterBeta } from './isReactRouterBeta'; import { RouteTracker } from '../routing/RouteTracker'; import { Route, Routes } from 'react-router-dom'; import { AppIdentityProxy } from '../apis/implementations/IdentityApi/AppIdentityProxy'; -import { AppAuthProvider } from '@backstage/plugin-auth-react'; +import { AppAuthProvider } from './AppAuthProvider'; /** * Get the app base path from the configured app baseUrl. diff --git a/plugins/auth-react/src/components/AppAuthProvider/isProtectedApp.tsx b/packages/core-app-api/src/app/isProtectedApp.tsx similarity index 88% rename from plugins/auth-react/src/components/AppAuthProvider/isProtectedApp.tsx rename to packages/core-app-api/src/app/isProtectedApp.tsx index 575eb59a73..f102989704 100644 --- a/plugins/auth-react/src/components/AppAuthProvider/isProtectedApp.tsx +++ b/packages/core-app-api/src/app/isProtectedApp.tsx @@ -14,10 +14,6 @@ * limitations under the License. */ -/** - * @public - * An utility function that detects whether the app is operating in protected mode. - */ export function isProtectedApp() { const element = document.querySelector('meta[name="backstage-app-mode"]'); const appMode = element?.getAttribute('content') ?? 'public'; diff --git a/packages/frontend-app-api/src/extensions/AppRoot.tsx b/packages/frontend-app-api/src/extensions/AppRoot.tsx index 2822ebeb3e..db8de268b0 100644 --- a/packages/frontend-app-api/src/extensions/AppRoot.tsx +++ b/packages/frontend-app-api/src/extensions/AppRoot.tsx @@ -38,11 +38,12 @@ import { } from '@backstage/core-plugin-api'; import { InternalAppContext } from '../wiring/InternalAppContext'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { AppAuthProvider } from '../../../core-app-api/src/app/AppAuthProvider'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy'; import { BrowserRouter } from 'react-router-dom'; import { RouteTracker } from '../routing/RouteTracker'; import { getBasePath } from '../routing/getBasePath'; -import { AppAuthProvider } from '@backstage/plugin-auth-react'; export const AppRoot = createExtension({ namespace: 'app', diff --git a/plugins/auth-react/api-report.md b/plugins/auth-react/api-report.md index b76bc6973e..a5295e54ff 100644 --- a/plugins/auth-react/api-report.md +++ b/plugins/auth-react/api-report.md @@ -6,9 +6,6 @@ import { default as React_2 } from 'react'; import { ReactNode } from 'react'; -// @public -export function AppAuthProvider(props: { children: ReactNode }): JSX.Element; - // @public export function CookieAuthRedirect(): React_2.JSX.Element | null; @@ -24,9 +21,6 @@ export type CookieAuthRefreshProviderProps = { children: ReactNode; }; -// @public -export function isProtectedApp(): boolean; - // @public export function useCookieAuthRefresh(options: { pluginId: string; diff --git a/plugins/auth-react/src/components/AppAuthProvider/index.ts b/plugins/auth-react/src/components/AppAuthProvider/index.ts deleted file mode 100644 index c1515cad7f..0000000000 --- a/plugins/auth-react/src/components/AppAuthProvider/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { isProtectedApp } from './isProtectedApp'; -export { AppAuthProvider } from './AppAuthProvider'; diff --git a/plugins/auth-react/src/components/index.ts b/plugins/auth-react/src/components/index.ts index 9ab41560f1..928abb0cb5 100644 --- a/plugins/auth-react/src/components/index.ts +++ b/plugins/auth-react/src/components/index.ts @@ -17,6 +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 './AppAuthProvider'; export * from './CookieAuthRedirect'; export * from './CookieAuthRefreshProvider'; From dde4707df6a212e2e153a3aeaff975011d7b2614 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Fri, 5 Apr 2024 16:55:42 +0200 Subject: [PATCH 17/36] fix: remove auth node changeset Signed-off-by: Camila Belo --- .changeset/large-hotels-accept.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/large-hotels-accept.md diff --git a/.changeset/large-hotels-accept.md b/.changeset/large-hotels-accept.md deleted file mode 100644 index acfac82355..0000000000 --- a/.changeset/large-hotels-accept.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-auth-node': patch ---- - -Create an auth cookie middleware utility to be used by plugins setting a `user-cookie` policy. The middleware adds well known endpoints to issue and delete user cookies. From 721359b95007964424555faf969b5564c0dd6503 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Fri, 5 Apr 2024 15:49:18 +0200 Subject: [PATCH 18/36] fix: injecting app mode Signed-off-by: Camila Belo --- plugins/app-backend/src/service/router.ts | 43 ++++++++++++++++------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/plugins/app-backend/src/service/router.ts b/plugins/app-backend/src/service/router.ts index 574e13db97..dc51499d74 100644 --- a/plugins/app-backend/src/service/router.ts +++ b/plugins/app-backend/src/service/router.ts @@ -238,6 +238,27 @@ export async function createRouter( return router; } +async function injectAppMode(options: { + appMode: 'public' | 'protected'; + rootDir: string; +}) { + const { appMode, rootDir } = options; + const originalIndexHtmlContent = await fs.readFile( + resolvePath(rootDir, 'index.html'), + 'utf8', + ); + if (originalIndexHtmlContent.includes('backstage-app-mode')) return; + const modifiedIndexHtmlContent = originalIndexHtmlContent.replace( + //, + ``, + ); + await fs.writeFile( + resolvePath(rootDir, 'index.html'), + modifiedIndexHtmlContent, + 'utf8', + ); +} + async function createEntryPointRouter({ logger, rootDir, @@ -261,6 +282,8 @@ async function createEntryPointRouter({ 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 @@ -306,20 +329,14 @@ async function createEntryPointRouter({ }), ); - const indexHtmlContent = Buffer.from( - (await fs.readFile(resolvePath(rootDir, 'index.html'), 'utf8')).replace( - //, - ``, - ), - 'utf8', - ); - router.get('/*', (_req, res) => { - // The Cache-Control header instructs the browser to not cache the index.html since it might - // link to static assets from recently deployed versions. - res.setHeader('Cache-Control', CACHE_CONTROL_NO_CACHE); - res.setHeader('Content-Type', 'text/html;charset=utf-8'); - res.send(indexHtmlContent); + res.sendFile(resolvePath(rootDir, 'index.html'), { + headers: { + // The Cache-Control header instructs the browser to not cache the index.html since it might + // link to static assets from recently deployed versions. + 'cache-control': CACHE_CONTROL_NO_CACHE, + }, + }); }); return router; From 837071a57bd2b17728bc4b88f560bde8a044fb3b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 6 Apr 2024 11:53:21 +0200 Subject: [PATCH 19/36] cli: copy public files to public dist dir when used Signed-off-by: Patrik Oldsberg --- packages/cli/src/lib/bundler/bundle.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/lib/bundler/bundle.ts b/packages/cli/src/lib/bundler/bundle.ts index b99891d70d..e9b20aea3c 100644 --- a/packages/cli/src/lib/bundler/bundle.ts +++ b/packages/cli/src/lib/bundler/bundle.ts @@ -82,10 +82,15 @@ export async function buildBundle(options: BuildOptions) { await fs.emptyDir(paths.targetDist); if (paths.targetPublic) { - await fs.copy(paths.targetPublic, paths.targetDist, { - dereference: true, - filter: file => file !== paths.targetHtml, - }); + await fs.copy( + paths.targetPublic, + // If we've got a separate public index entry point, copy public content there instead + publicPaths?.targetDist ?? paths.targetDist, + { + dereference: true, + filter: file => file !== paths.targetHtml, + }, + ); } if (configSchema) { From d3344eec6f8710201e569c7741f9483815a617e3 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 9 Apr 2024 12:18:32 +0200 Subject: [PATCH 20/36] backend-app-api: use promise router in cookie auth middleware Signed-off-by: Patrik Oldsberg --- .../httpRouter/createCookieAuthRefreshMiddleware.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend-app-api/src/services/implementations/httpRouter/createCookieAuthRefreshMiddleware.ts b/packages/backend-app-api/src/services/implementations/httpRouter/createCookieAuthRefreshMiddleware.ts index 939fa9197d..1f3d497ce9 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/createCookieAuthRefreshMiddleware.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/createCookieAuthRefreshMiddleware.ts @@ -15,7 +15,7 @@ */ import { AuthService, HttpAuthService } from '@backstage/backend-plugin-api'; -import { Router } from 'express'; +import Router from 'express-promise-router'; const WELL_KNOWN_COOKIE_PATH_V1 = '/.backstage/auth/v1/cookie'; From cd8587d5bd868e67d9c1db387fa741644ea492f2 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 9 Apr 2024 12:18:53 +0200 Subject: [PATCH 21/36] cli: copy public files to both protected and public dist dirs Signed-off-by: Patrik Oldsberg --- packages/cli/src/lib/bundler/bundle.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/lib/bundler/bundle.ts b/packages/cli/src/lib/bundler/bundle.ts index e9b20aea3c..b9b826c247 100644 --- a/packages/cli/src/lib/bundler/bundle.ts +++ b/packages/cli/src/lib/bundler/bundle.ts @@ -82,15 +82,18 @@ export async function buildBundle(options: BuildOptions) { await fs.emptyDir(paths.targetDist); if (paths.targetPublic) { - await fs.copy( - paths.targetPublic, - // If we've got a separate public index entry point, copy public content there instead - publicPaths?.targetDist ?? paths.targetDist, - { + await fs.copy(paths.targetPublic, paths.targetDist, { + 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) { From 2ff147b0d6d0c45fde192cb9ae5fa6fec836dd06 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 9 Apr 2024 12:19:48 +0200 Subject: [PATCH 22/36] app-backend: log when running in protected mode Signed-off-by: Patrik Oldsberg --- plugins/app-backend/src/service/router.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/app-backend/src/service/router.ts b/plugins/app-backend/src/service/router.ts index dc51499d74..ce94720a9a 100644 --- a/plugins/app-backend/src/service/router.ts +++ b/plugins/app-backend/src/service/router.ts @@ -171,6 +171,10 @@ export async function createRouter( (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) => { From f9ad268ff237c0ce6d89d0e81fb2a60e511c1b1c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 9 Apr 2024 12:20:14 +0200 Subject: [PATCH 23/36] app-backend: log users out if session is invalid Signed-off-by: Patrik Oldsberg --- plugins/app-backend/src/service/router.ts | 26 ++++++++++++++++------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/plugins/app-backend/src/service/router.ts b/plugins/app-backend/src/service/router.ts index ce94720a9a..f58091f762 100644 --- a/plugins/app-backend/src/service/router.ts +++ b/plugins/app-backend/src/service/router.ts @@ -177,16 +177,26 @@ export async function createRouter( const publicRouter = Router(); - publicRouter.use(async (req, _res, next) => { - const credentials = await httpAuth.credentials(req, { - allow: ['user', 'service', 'none'], - allowLimitedAccess: true, - }); + publicRouter.use(async (req, res, next) => { + try { + const credentials = await httpAuth.credentials(req, { + allow: ['user', 'service', 'none'], + allowLimitedAccess: true, + }); - if (credentials.principal.type === 'none') { + 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(); - } else { - next('router'); } }); From 52d948d8904ad0b9988d64420f06be3e65631633 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 9 Apr 2024 12:20:41 +0200 Subject: [PATCH 24/36] app-backend: update meta tag injection to be able to update existing tag Signed-off-by: Patrik Oldsberg --- plugins/app-backend/src/service/router.ts | 31 +++++++++++++---------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/plugins/app-backend/src/service/router.ts b/plugins/app-backend/src/service/router.ts index f58091f762..86736d832e 100644 --- a/plugins/app-backend/src/service/router.ts +++ b/plugins/app-backend/src/service/router.ts @@ -257,20 +257,23 @@ async function injectAppMode(options: { rootDir: string; }) { const { appMode, rootDir } = options; - const originalIndexHtmlContent = await fs.readFile( - resolvePath(rootDir, 'index.html'), - 'utf8', - ); - if (originalIndexHtmlContent.includes('backstage-app-mode')) return; - const modifiedIndexHtmlContent = originalIndexHtmlContent.replace( - //, - ``, - ); - await fs.writeFile( - resolvePath(rootDir, 'index.html'), - modifiedIndexHtmlContent, - 'utf8', - ); + const content = await fs.readFile(resolvePath(rootDir, 'index.html'), 'utf8'); + + const metaTag = ``; + + let newContent; + if (content.includes('backstage-app-mode')) { + console.log(`DEBUG: REPLACE`, metaTag); + newContent = content.replace( + //, + metaTag, + ); + console.log(`DEBUG: newContent=`, newContent); + } else { + newContent = content.replace(//, `${metaTag}`); + } + + await fs.writeFile(resolvePath(rootDir, 'index.html'), newContent, 'utf8'); } async function createEntryPointRouter({ From 2b57eac4b47f6dada1b615d036157ecce5146425 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 9 Apr 2024 13:30:44 +0200 Subject: [PATCH 25/36] core-app-api: add startCookieAuthRefresh + test Signed-off-by: Patrik Oldsberg --- .../startCookieAuthRefresh.test.ts | 173 ++++++++++++++++++ .../IdentityApi/startCookieAuthRefresh.ts | 157 ++++++++++++++++ 2 files changed, 330 insertions(+) create mode 100644 packages/core-app-api/src/apis/implementations/IdentityApi/startCookieAuthRefresh.test.ts create mode 100644 packages/core-app-api/src/apis/implementations/IdentityApi/startCookieAuthRefresh.ts 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..e2751788db --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/IdentityApi/startCookieAuthRefresh.test.ts @@ -0,0 +1,173 @@ +/* + * 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: Failed to get cookie again', + ]); + }); +}); 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..ce639890c0 --- /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`, + ); + } + + firstError = true; + + 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'); + } + + 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.message}`); + 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(); + }; +} From d11767c861ecd6bd2a48d02f8b9ff957347c09c5 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 9 Apr 2024 13:31:50 +0200 Subject: [PATCH 26/36] core-app-api: replace cookie auth provider with new implementation in AppIdentityProxy Signed-off-by: Patrik Oldsberg --- packages/core-app-api/package.json | 1 - .../IdentityApi/AppIdentityProxy.ts | 41 ++++++-- .../src/app/AppAuthProvider.test.tsx | 99 ------------------- .../core-app-api/src/app/AppAuthProvider.tsx | 33 ------- packages/core-app-api/src/app/AppManager.tsx | 24 ++--- packages/core-app-api/src/app/AppRouter.tsx | 15 +-- yarn.lock | 1 - 7 files changed, 52 insertions(+), 162 deletions(-) delete mode 100644 packages/core-app-api/src/app/AppAuthProvider.test.tsx delete mode 100644 packages/core-app-api/src/app/AppAuthProvider.tsx diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index dcdb5a78f1..bb3566062c 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -44,7 +44,6 @@ "dependencies": { "@backstage/config": "workspace:^", "@backstage/core-plugin-api": "workspace:^", - "@backstage/plugin-auth-react": "workspace:^", "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^", "@types/prop-types": "^15.7.3", 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 5263d6490f..8db890ad47 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( @@ -50,7 +54,8 @@ export class AppIdentityProxy implements IdentityApi { private waitForTarget: Promise; private resolveTarget: (api: CompatibilityIdentityApi) => void = () => {}; private signOutTargetUrl = '/'; - #signOutCallback?: () => Promise; + + #cookieAuthSignOut?: () => void; constructor() { this.waitForTarget = new Promise(resolve => { @@ -68,10 +73,6 @@ export class AppIdentityProxy implements IdentityApi { this.resolveTarget(identityApi); } - setSignOutCallback(signOutCallback: () => Promise): void { - this.#signOutCallback = signOutCallback; - } - getUserId(): string { if (!this.target) { throw mkError('getUserId'); @@ -129,7 +130,35 @@ export class AppIdentityProxy implements IdentityApi { async signOut(): Promise { await this.waitForTarget.then(target => target.signOut()); - await this.#signOutCallback?.(); + + 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/app/AppAuthProvider.test.tsx b/packages/core-app-api/src/app/AppAuthProvider.test.tsx deleted file mode 100644 index 2ee0c49f8d..0000000000 --- a/packages/core-app-api/src/app/AppAuthProvider.test.tsx +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; -import { discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api'; -import { AppAuthProvider } from './AppAuthProvider'; - -const now = 1710316886171; -const tenMinutesInMilliseconds = 10 * 60 * 1000; -const tenMinutesFromNowInMilliseconds = now + tenMinutesInMilliseconds; -const expiresAt = new Date(tenMinutesFromNowInMilliseconds).toISOString(); - -jest.mock('@backstage/core-plugin-api', () => { - return { - ...jest.requireActual('@backstage/core-plugin-api'), - useApp: jest.fn().mockReturnValue({ - getComponents: () => ({ Progress: () =>
}), - }), - useApi: jest.fn(ref => { - if (ref === discoveryApiRef) { - return { - getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7000/app'), - }; - } - - if (ref === fetchApiRef) { - return { - fetch: jest.fn().mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ expiresAt }), - }), - }; - } - - // componentsApiRef - return { - getComponent: jest - .fn() - .mockReturnValue(() =>
), - }; - }), - }; -}); - -describe('AppAuthProvider', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.useFakeTimers({ now }); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('should render the children when app mode is undefined', async () => { - render(Test content); - await waitFor(() => - expect(screen.getByText('Test content')).toBeInTheDocument(), - ); - }); - - it('should render the children the app mode is public', async () => { - render( - <> - - Test content - , - ); - await waitFor(() => - expect(screen.getByText('Test content')).toBeInTheDocument(), - ); - }); - - it('should render the children wrapped in the CookieAuthRefreshProvider', async () => { - render( - <> - - Test content - , - ); - await waitFor(() => - expect(screen.getByText('Test content')).toBeInTheDocument(), - ); - }); -}); diff --git a/packages/core-app-api/src/app/AppAuthProvider.tsx b/packages/core-app-api/src/app/AppAuthProvider.tsx deleted file mode 100644 index 8834930ef6..0000000000 --- a/packages/core-app-api/src/app/AppAuthProvider.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { ReactNode } from 'react'; -import { CookieAuthRefreshProvider } from '@backstage/plugin-auth-react'; -import { isProtectedApp } from './isProtectedApp'; - -export function AppAuthProvider(props: { children: ReactNode }): JSX.Element { - const { children } = props; - - if (isProtectedApp()) { - return ( - - {children} - - ); - } - - return <>{children}; -} diff --git a/packages/core-app-api/src/app/AppManager.tsx b/packages/core-app-api/src/app/AppManager.tsx index bd51f0046b..64736470d2 100644 --- a/packages/core-app-api/src/app/AppManager.tsx +++ b/packages/core-app-api/src/app/AppManager.tsx @@ -44,6 +44,7 @@ import { FeatureFlag, fetchApiRef, discoveryApiRef, + errorApiRef, } from '@backstage/core-plugin-api'; import { AppLanguageApi, @@ -359,20 +360,21 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be const apis = this.getApiHolder(); - this.appIdentityProxy.setSignOutCallback(async () => { + if (isProtectedApp()) { + const errorApi = apis.get(errorApiRef); const fetchApi = apis.get(fetchApiRef); const discoveryApi = apis.get(discoveryApiRef); - if (!fetchApi || !discoveryApi || !isProtectedApp()) return; - // It is fine if we do NOT worry yet about deleting cookies for OTHER backends like techdocs - const appBaseUrl = await discoveryApi.getBaseUrl('app'); - try { - await fetchApi.fetch(`${appBaseUrl}/.backstage/auth/v1/cookie`, { - method: 'DELETE', - }); - } catch { - // Ignore the error for those who use static serving of the frontend + if (!errorApi || !fetchApi || !discoveryApi) { + throw new Error( + 'App is running in protected mode but missing required APIs', + ); } - }); + this.appIdentityProxy.enableCookieAuth({ + errorApi, + fetchApi, + discoveryApi, + }); + } return ( diff --git a/packages/core-app-api/src/app/AppRouter.tsx b/packages/core-app-api/src/app/AppRouter.tsx index 21704bc26b..7481f6662a 100644 --- a/packages/core-app-api/src/app/AppRouter.tsx +++ b/packages/core-app-api/src/app/AppRouter.tsx @@ -29,7 +29,6 @@ import { isReactRouterBeta } from './isReactRouterBeta'; import { RouteTracker } from '../routing/RouteTracker'; import { Route, Routes } from 'react-router-dom'; import { AppIdentityProxy } from '../apis/implementations/IdentityApi/AppIdentityProxy'; -import { AppAuthProvider } from './AppAuthProvider'; /** * Get the app base path from the configured app baseUrl. @@ -146,10 +145,7 @@ export function AppRouter(props: AppRouterProps) { - {props.children}} - /> + {props.children}} /> ); @@ -158,7 +154,7 @@ export function AppRouter(props: AppRouterProps) { return ( - {props.children} + {props.children} ); } @@ -172,10 +168,7 @@ export function AppRouter(props: AppRouterProps) { appIdentityProxy={appIdentityProxy} > - {props.children}} - /> + {props.children}} /> @@ -189,7 +182,7 @@ export function AppRouter(props: AppRouterProps) { component={SignInPageComponent} appIdentityProxy={appIdentityProxy} > - {props.children} + {props.children} ); diff --git a/yarn.lock b/yarn.lock index 0b90ddd2e9..6ce1ec54f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3834,7 +3834,6 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/core-plugin-api": "workspace:^" - "@backstage/plugin-auth-react": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" "@backstage/version-bridge": "workspace:^" From e2e0c998e6cc40ab88be2ed5d8bd9c738a76ca78 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 9 Apr 2024 13:36:00 +0200 Subject: [PATCH 27/36] docs/tutorials/enable-public-entry: remove AuthProxyDiscoveryApi from example Signed-off-by: Patrik Oldsberg --- docs/tutorials/enable-public-entry.md | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/docs/tutorials/enable-public-entry.md b/docs/tutorials/enable-public-entry.md index 499ab7da02..6282f66418 100644 --- a/docs/tutorials/enable-public-entry.md +++ b/docs/tutorials/enable-public-entry.md @@ -47,18 +47,11 @@ With that, Backstage's cli and backend will detect public entry point and serve createApiFactory, } from '@backstage/core-plugin-api'; import { CookieAuthRedirect } from '@backstage/plugin-auth-react'; - import { AuthProxyDiscoveryApi } from '../src/AuthProxyDiscoveryApi'; // Notice that this is only setting up what is needed by the sign-in pages const app = createApp({ - apis: [ - createApiFactory({ - api: discoveryApiRef, - deps: { configApi: configApiRef }, - factory: ({ configApi }) => - AuthProxyDiscoveryApi.fromConfig(configApi), - }), - ], + // If you have any custom APIs that your sign-in page depends on, you need to add them here + apis: [], components: { SignInPage: props => { return ( From 1bfaadb73eea92c7d94928fc2258aa99dd763190 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 9 Apr 2024 13:42:11 +0200 Subject: [PATCH 28/36] frontend-app-api: update app protection wiring Signed-off-by: Patrik Oldsberg --- packages/frontend-app-api/package.json | 1 - .../src/extensions/AppRoot.tsx | 6 ++---- .../frontend-app-api/src/wiring/createApp.tsx | 21 +++++++++++++++++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/frontend-app-api/package.json b/packages/frontend-app-api/package.json index 097f5a3611..fb5e982058 100644 --- a/packages/frontend-app-api/package.json +++ b/packages/frontend-app-api/package.json @@ -38,7 +38,6 @@ "@backstage/core-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", - "@backstage/plugin-auth-react": "workspace:^", "@backstage/theme": "workspace:^", "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^", diff --git a/packages/frontend-app-api/src/extensions/AppRoot.tsx b/packages/frontend-app-api/src/extensions/AppRoot.tsx index db8de268b0..f3a962dd10 100644 --- a/packages/frontend-app-api/src/extensions/AppRoot.tsx +++ b/packages/frontend-app-api/src/extensions/AppRoot.tsx @@ -38,8 +38,6 @@ import { } from '@backstage/core-plugin-api'; import { InternalAppContext } from '../wiring/InternalAppContext'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { AppAuthProvider } from '../../../core-app-api/src/app/AppAuthProvider'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy'; import { BrowserRouter } from 'react-router-dom'; import { RouteTracker } from '../routing/RouteTracker'; @@ -192,7 +190,7 @@ export function AppRouter(props: AppRouterProps) { return ( - {children} + {children} ); } @@ -204,7 +202,7 @@ export function AppRouter(props: AppRouterProps) { component={SignInPageComponent} appIdentityProxy={appIdentityProxy} > - {children} + {children} ); diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index 6e0a2c7cd7..cf6ea6dab4 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -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) { From 4fecffc7eef339034bde3983f53559aa8d171d31 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 9 Apr 2024 13:48:26 +0200 Subject: [PATCH 29/36] changesets: updated and added changesets for app auth Signed-off-by: Patrik Oldsberg --- .changeset/nine-rabbits-exist.md | 3 ++- .changeset/tall-rats-pull.md | 5 +++++ .changeset/weak-planets-move.md | 5 +++++ 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 .changeset/tall-rats-pull.md create mode 100644 .changeset/weak-planets-move.md diff --git a/.changeset/nine-rabbits-exist.md b/.changeset/nine-rabbits-exist.md index a26f1f59c3..09dadeb69b 100644 --- a/.changeset/nine-rabbits-exist.md +++ b/.changeset/nine-rabbits-exist.md @@ -1,5 +1,6 @@ --- '@backstage/core-app-api': patch +'@backstage/frontend-app-api': patch --- -Clear the app auth cookie after a sign out. +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/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. From 33223c793716bc791ece955e6caf978818f2a41a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 9 Apr 2024 13:53:41 +0200 Subject: [PATCH 30/36] auth-react: remove CompatAppProgress since it's no longer needed Signed-off-by: Patrik Oldsberg --- plugins/auth-react/package.json | 2 - .../CompatAppProgress.tsx | 40 ------------------- .../CookieAuthRefreshProvider.tsx | 9 +++-- yarn.lock | 3 -- 4 files changed, 5 insertions(+), 49 deletions(-) delete mode 100644 plugins/auth-react/src/components/CookieAuthRefreshProvider/CompatAppProgress.tsx diff --git a/plugins/auth-react/package.json b/plugins/auth-react/package.json index e5eb7db192..9de3aade21 100644 --- a/plugins/auth-react/package.json +++ b/plugins/auth-react/package.json @@ -35,8 +35,6 @@ "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", - "@backstage/frontend-plugin-api": "workspace:^", - "@backstage/version-bridge": "workspace:^", "@material-ui/core": "^4.9.13", "@react-hookz/web": "^24.0.0", "@types/react": "^16.13.1 || ^17.0.0 || ^18.0.0" diff --git a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CompatAppProgress.tsx b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CompatAppProgress.tsx deleted file mode 100644 index e624d46304..0000000000 --- a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CompatAppProgress.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { useApp } from '@backstage/core-plugin-api'; -import { useVersionedContext } from '@backstage/version-bridge'; - -import { - coreComponentRefs, - useComponentRef, -} from '@backstage/frontend-plugin-api'; - -function LegacyAppProgress() { - const app = useApp(); - const { Progress } = app.getComponents(); - return ; -} - -function NewAppProgress() { - const Progress = useComponentRef(coreComponentRefs.progress); - return ; -} - -export function CompatAppProgress() { - const isInNewApp = !useVersionedContext<{ 1: unknown }>('app-context'); - return isInNewApp ? : ; -} diff --git a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx index 2287ab4ad7..4f676aa2d5 100644 --- a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx +++ b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx @@ -16,9 +16,9 @@ import React, { ReactNode } from 'react'; import { ErrorPanel } from '@backstage/core-components'; -import { Button } from '@material-ui/core'; +import { useApp } from '@backstage/core-plugin-api'; +import Button from '@material-ui/core/Button'; import { useCookieAuthRefresh } from '../../hooks'; -import { CompatAppProgress } from './CompatAppProgress'; /** * @public @@ -41,12 +41,13 @@ export function CookieAuthRefreshProvider( props: CookieAuthRefreshProviderProps, ): JSX.Element { const { children, ...options } = props; + const app = useApp(); + const { Progress } = app.getComponents(); const result = useCookieAuthRefresh(options); if (result.status === 'loading') { - // This component should be compatible with both old and new frontend systems - return ; + return ; } if (result.status === 'error') { diff --git a/yarn.lock b/yarn.lock index 6ce1ec54f3..200ecd0ff3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4193,7 +4193,6 @@ __metadata: "@backstage/core-plugin-api": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" - "@backstage/plugin-auth-react": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/theme": "workspace:^" "@backstage/types": "workspace:^" @@ -5112,9 +5111,7 @@ __metadata: "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/errors": "workspace:^" - "@backstage/frontend-plugin-api": "workspace:^" "@backstage/test-utils": "workspace:^" - "@backstage/version-bridge": "workspace:^" "@material-ui/core": ^4.9.13 "@react-hookz/web": ^24.0.0 "@testing-library/jest-dom": ^6.0.0 From 30c7ed4badb4d2c4d285ece1bf650c8bc536a57e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 9 Apr 2024 15:17:33 +0200 Subject: [PATCH 31/36] backend-app-api: fix cookie delete response Signed-off-by: Patrik Oldsberg --- .../httpRouter/createCookieAuthRefreshMiddleware.test.ts | 2 +- .../httpRouter/createCookieAuthRefreshMiddleware.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index aa5f1a995a..6ecd52bbbf 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/createCookieAuthRefreshMiddleware.test.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/createCookieAuthRefreshMiddleware.test.ts @@ -43,7 +43,7 @@ describe('createCookieAuthRefreshMiddleware', () => { it('should remove the user cookie', async () => { const response = await request(app).delete('/.backstage/auth/v1/cookie'); - expect(response.status).toBe(200); + 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 index 1f3d497ce9..c33af7df4f 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/createCookieAuthRefreshMiddleware.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/createCookieAuthRefreshMiddleware.ts @@ -40,7 +40,7 @@ export function createCookieAuthRefreshMiddleware(options: { router.delete(WELL_KNOWN_COOKIE_PATH_V1, async (_, res) => { const credentials = await auth.getNoneCredentials(); await httpAuth.issueUserCookie(res, { credentials }); - res.send(200); + res.status(204).end(); }); return router; From d1adc89efc32d24bf1e5af6525f0be7bd57e1ba4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 9 Apr 2024 15:51:29 +0200 Subject: [PATCH 32/36] app-backend auth review fixes Signed-off-by: Patrik Oldsberg --- .../apis/implementations/IdentityApi/AppIdentityProxy.ts | 2 +- plugins/app-backend/src/service/router.ts | 2 -- plugins/auth-react/api-report.md | 5 +---- .../CookieAuthRefreshProvider.test.tsx | 4 ++-- .../CookieAuthRefreshProvider.tsx | 2 +- .../useCookieAuthRefresh/useCookieAuthRefresh.test.tsx | 4 ++-- .../hooks/useCookieAuthRefresh/useCookieAuthRefresh.tsx | 8 ++++---- 7 files changed, 11 insertions(+), 16 deletions(-) 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 8db890ad47..92e2abffb7 100644 --- a/packages/core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy.ts +++ b/packages/core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy.ts @@ -55,7 +55,7 @@ export class AppIdentityProxy implements IdentityApi { private resolveTarget: (api: CompatibilityIdentityApi) => void = () => {}; private signOutTargetUrl = '/'; - #cookieAuthSignOut?: () => void; + #cookieAuthSignOut?: () => Promise; constructor() { this.waitForTarget = new Promise(resolve => { diff --git a/plugins/app-backend/src/service/router.ts b/plugins/app-backend/src/service/router.ts index 86736d832e..002342c7db 100644 --- a/plugins/app-backend/src/service/router.ts +++ b/plugins/app-backend/src/service/router.ts @@ -263,12 +263,10 @@ async function injectAppMode(options: { let newContent; if (content.includes('backstage-app-mode')) { - console.log(`DEBUG: REPLACE`, metaTag); newContent = content.replace( //, metaTag, ); - console.log(`DEBUG: newContent=`, newContent); } else { newContent = content.replace(//, `${metaTag}`); } diff --git a/plugins/auth-react/api-report.md b/plugins/auth-react/api-report.md index a5295e54ff..f9c8a10123 100644 --- a/plugins/auth-react/api-report.md +++ b/plugins/auth-react/api-report.md @@ -22,10 +22,7 @@ export type CookieAuthRefreshProviderProps = { }; // @public -export function useCookieAuthRefresh(options: { - pluginId: string; - path?: string; -}): +export function useCookieAuthRefresh(options: { pluginId: string }): | { status: 'loading'; } diff --git a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.test.tsx index 77710dc7a4..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/.backstage/auth/v1/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 4f676aa2d5..b22dce474a 100644 --- a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx +++ b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx @@ -27,7 +27,7 @@ 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' + // The path used for calling the refresh cookie endpoint, default to '/.backstage/auth/v1/cookie' path?: string; // The children to render when the refresh is successful children: ReactNode; diff --git a/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx b/plugins/auth-react/src/hooks/useCookieAuthRefresh/useCookieAuthRefresh.test.tsx index cd61b9d94b..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/.backstage/auth/v1/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 b26d45447e..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 '/.backstage/auth/v1/cookie' - path?: string; }): | { status: 'loading' } | { status: 'error'; error: Error; retry: () => void } | { status: 'success'; data: { expiresAt: string } } { - const { pluginId, path = '/.backstage/auth/v1/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', }); From 37973c27b59eedbb1802a7389bcbb865d6ef6c6a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 9 Apr 2024 16:00:03 +0200 Subject: [PATCH 33/36] app-backend: fix config injection Signed-off-by: Patrik Oldsberg --- plugins/app-backend/src/service/router.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/app-backend/src/service/router.ts b/plugins/app-backend/src/service/router.ts index 002342c7db..0a577002bd 100644 --- a/plugins/app-backend/src/service/router.ts +++ b/plugins/app-backend/src/service/router.ts @@ -146,12 +146,12 @@ export async function createRouter( injectedConfigPath = await injectConfig({ appConfigs, logger, staticDir }); } const appConfigs = disableConfigInjection - ? await readConfigs({ + ? undefined + : await readConfigs({ config, appDistDir, env: process.env, - }) - : undefined; + }); const assetStore = options.database && !disableStaticFallbackCache From ed4e394308bc05f8063bc11b8bf2b7c8273c5b4a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 9 Apr 2024 16:12:03 +0200 Subject: [PATCH 34/36] app-backend: separate config injection handling for protected and public bundle Signed-off-by: Patrik Oldsberg --- plugins/app-backend/src/service/router.ts | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/plugins/app-backend/src/service/router.ts b/plugins/app-backend/src/service/router.ts index 0a577002bd..eac3b7f82d 100644 --- a/plugins/app-backend/src/service/router.ts +++ b/plugins/app-backend/src/service/router.ts @@ -134,17 +134,6 @@ 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, - }); - - injectedConfigPath = await injectConfig({ appConfigs, logger, staticDir }); - } const appConfigs = disableConfigInjection ? undefined : await readConfigs({ @@ -245,7 +234,6 @@ export async function createRouter( assetStore, staticFallbackHandler, appConfigs, - injectedConfigPath, }), ); @@ -281,7 +269,6 @@ async function createEntryPointRouter({ staticFallbackHandler, appMode, appConfigs, - injectedConfigPath, }: { logger: Logger; rootDir: string; @@ -289,13 +276,11 @@ async function createEntryPointRouter({ staticFallbackHandler?: express.Handler; appMode: 'public' | 'protected'; appConfigs?: AppConfig[]; - injectedConfigPath?: string; }) { const staticDir = resolvePath(rootDir, 'static'); - if (appConfigs) { - await injectConfig({ appConfigs, logger, staticDir }); - } + const injectedConfigPath = + appConfigs && (await injectConfig({ appConfigs, logger, staticDir })); await injectAppMode({ appMode, rootDir }); From 633d30ee8db67bebd9f5c1cd6bc30d92b51a74c1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 9 Apr 2024 17:04:03 +0200 Subject: [PATCH 35/36] auth-react: completely remove path option Signed-off-by: Patrik Oldsberg --- .changeset/fifty-cameras-shake.md | 6 ++++-- plugins/auth-react/api-report.md | 1 - .../CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx | 2 -- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.changeset/fifty-cameras-shake.md b/.changeset/fifty-cameras-shake.md index b87d4e92c2..2ba4d9e277 100644 --- a/.changeset/fifty-cameras-shake.md +++ b/.changeset/fifty-cameras-shake.md @@ -1,5 +1,7 @@ --- -'@backstage/plugin-auth-react': patch +'@backstage/plugin-auth-react': minor --- -Update the default cookie base path and create a experimental redirect to root and app mode components, the components authenticate and keep a cookie refresh loop on the client side. +**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/plugins/auth-react/api-report.md b/plugins/auth-react/api-report.md index f9c8a10123..d5206261d2 100644 --- a/plugins/auth-react/api-report.md +++ b/plugins/auth-react/api-report.md @@ -17,7 +17,6 @@ export function CookieAuthRefreshProvider( // @public export type CookieAuthRefreshProviderProps = { pluginId: string; - path?: string; children: ReactNode; }; diff --git a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx index b22dce474a..307898dbea 100644 --- a/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx +++ b/plugins/auth-react/src/components/CookieAuthRefreshProvider/CookieAuthRefreshProvider.tsx @@ -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 '/.backstage/auth/v1/cookie' - path?: string; // The children to render when the refresh is successful children: ReactNode; }; From 5ce40c4fcffa224b692739e0449dac9f8416e157 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 9 Apr 2024 17:09:32 +0200 Subject: [PATCH 36/36] core-app-api: cookie auth review fixes Signed-off-by: Patrik Oldsberg --- .../IdentityApi/startCookieAuthRefresh.test.ts | 4 +--- .../implementations/IdentityApi/startCookieAuthRefresh.ts | 8 ++++---- 2 files changed, 5 insertions(+), 7 deletions(-) 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 index e2751788db..4db068deb9 100644 --- a/packages/core-app-api/src/apis/implementations/IdentityApi/startCookieAuthRefresh.test.ts +++ b/packages/core-app-api/src/apis/implementations/IdentityApi/startCookieAuthRefresh.test.ts @@ -166,8 +166,6 @@ describe('startCookieAuthRefresh', () => { stop(); }); - expect(error).toEqual([ - 'Session cookie refresh failed: Failed to get cookie again', - ]); + 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 index ce639890c0..597e1b7cc6 100644 --- a/packages/core-app-api/src/apis/implementations/IdentityApi/startCookieAuthRefresh.ts +++ b/packages/core-app-api/src/apis/implementations/IdentityApi/startCookieAuthRefresh.ts @@ -55,7 +55,7 @@ export function startCookieAuthRefresh({ try { const baseUrl = await discoveryApi.getBaseUrl(PLUGIN_ID); const requestUrl = `${baseUrl}/.backstage/auth/v1/cookie`; - const res = await fetchApi.fetch(`${requestUrl}`, { + const res = await fetchApi.fetch(requestUrl, { credentials: 'include', }); @@ -65,8 +65,6 @@ export function startCookieAuthRefresh({ ); } - firstError = true; - const data = await res.json(); if (!data.expiresAt) { throw new Error('No expiration date in response'); @@ -77,6 +75,8 @@ export function startCookieAuthRefresh({ throw new Error('Invalid expiration date in response'); } + firstError = true; + channel?.postMessage({ action: 'COOKIE_REFRESH_SUCCESS', payload: { expiresAt: new Date(expiresAt).toISOString() }, @@ -94,7 +94,7 @@ export function startCookieAuthRefresh({ errorBackoff * ERROR_BACKOFF_FACTOR, ); // eslint-disable-next-line no-console - console.error(`Session cookie refresh failed: ${error.message}`); + console.error('Session cookie refresh failed', error); errorApi.post( new Error( `Session refresh failed, see developer console for details`,