From ae7d42696ee80195e3c0b925e35aa820c91430d1 Mon Sep 17 00:00:00 2001 From: mario ma Date: Wed, 11 Jun 2025 16:36:25 +0800 Subject: [PATCH 001/107] fix: update about card links style for pretty display with other language Signed-off-by: mario ma --- .changeset/young-doodles-enter.md | 5 +++++ .../src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/young-doodles-enter.md diff --git a/.changeset/young-doodles-enter.md b/.changeset/young-doodles-enter.md new file mode 100644 index 0000000000..63e1f33eda --- /dev/null +++ b/.changeset/young-doodles-enter.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +update about card links style for pretty display with other language diff --git a/packages/core-components/src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx b/packages/core-components/src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx index 1fcf664bab..3c46e6ba27 100644 --- a/packages/core-components/src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx +++ b/packages/core-components/src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx @@ -25,7 +25,7 @@ const useStyles = makeStyles( margin: theme.spacing(2, 0), display: 'grid', gridAutoFlow: 'column', - gridAutoColumns: 'min-content', + gridAutoColumns: 'max-content', gridGap: theme.spacing(3), }, }), From 86436efe91ba4e7c84e0355aaf4e56972fe8c6f1 Mon Sep 17 00:00:00 2001 From: mario ma Date: Wed, 25 Jun 2025 17:13:10 +0800 Subject: [PATCH 002/107] fix: code review Signed-off-by: mario ma --- .../src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core-components/src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx b/packages/core-components/src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx index 3c46e6ba27..0b8fe8cd8c 100644 --- a/packages/core-components/src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx +++ b/packages/core-components/src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx @@ -25,8 +25,9 @@ const useStyles = makeStyles( margin: theme.spacing(2, 0), display: 'grid', gridAutoFlow: 'column', - gridAutoColumns: 'max-content', + gridAutoColumns: 'min-content', gridGap: theme.spacing(3), + wordBreak: 'keep-all', }, }), { name: 'BackstageHeaderIconLinkRow' }, From a73f4958401c2f6e6a46331b53dea6403e28a9e1 Mon Sep 17 00:00:00 2001 From: Hellgren Heikki Date: Fri, 1 Aug 2025 15:45:22 +0300 Subject: [PATCH 003/107] feat(config): allow specifying env specific config files this change allows to specify loading of environment specific config files based on `BACKSTAGE_ENVIRONMENT` environment variable. relates to #30716 Signed-off-by: Hellgren Heikki --- .changeset/better-eagles-tickle.md | 5 +++++ docs/conf/index.md | 21 +++++++++++++++---- .../src/sources/ConfigSources.test.ts | 12 +++++++++++ .../src/sources/ConfigSources.ts | 14 +++++++++++++ 4 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 .changeset/better-eagles-tickle.md diff --git a/.changeset/better-eagles-tickle.md b/.changeset/better-eagles-tickle.md new file mode 100644 index 0000000000..b199ae21d7 --- /dev/null +++ b/.changeset/better-eagles-tickle.md @@ -0,0 +1,5 @@ +--- +'@backstage/config-loader': patch +--- + +Allow using `BACKSTAGE_ENVIRONMENT` for loading environment specific config files diff --git a/docs/conf/index.md b/docs/conf/index.md index 659ffccfe3..7c5be174e5 100644 --- a/docs/conf/index.md +++ b/docs/conf/index.md @@ -16,10 +16,23 @@ allowing for customization. ## Supplying Configuration Configuration is stored in YAML files where the defaults are `app-config.yaml` -and `app-config.local.yaml` for local overrides. Other sets of files can by -loaded by passing `--config ` flags. The configuration files themselves -contain plain YAML, but with support for loading in data and secrets from -various sources using for example `$env` and `$file` keys. +and `app-config.local.yaml` for local overrides. Additionally, it is possible +to define environment based configuration files with `BACKSTAGE_ENVIRONMENT` +environment variable, which will load `app-config..yaml`. + +Loading order of these files is as follows: + +1. `app-config.yaml` +2. `app-config..yaml` +3. `app-config.local.yaml` + +Other sets of files can by loaded by passing `--config ` flags. +Read more about the configuration loading order in the +[Configuration Files](./writing.md#configuration-files) section. + +The configuration files themselves contain plain YAML, but with support for +loading in data and secrets from various sources using for example +`$env` and `$file` keys. It is also possible to supply configuration through environment variables, for example `APP_CONFIG_app_baseUrl=https://staging.example.com`. However these diff --git a/packages/config-loader/src/sources/ConfigSources.test.ts b/packages/config-loader/src/sources/ConfigSources.test.ts index e19ac1d51f..9b2932e439 100644 --- a/packages/config-loader/src/sources/ConfigSources.test.ts +++ b/packages/config-loader/src/sources/ConfigSources.test.ts @@ -89,6 +89,18 @@ describe('ConfigSources', () => { { name: 'FileConfigSource', path: `${root}app-config.yaml` }, { name: 'FileConfigSource', path: `${root}app-config.local.yaml` }, ]); + + process.env = Object.assign(process.env, { BACKSTAGE_ENVIRONMENT: 'test' }); + expect( + mergeSources( + ConfigSources.defaultForTargets({ rootDir: '/', targets: [] }), + ), + ).toEqual([ + { name: 'FileConfigSource', path: `${root}app-config.yaml` }, + { name: 'FileConfigSource', path: `${root}app-config.test.yaml` }, + { name: 'FileConfigSource', path: `${root}app-config.local.yaml` }, + ]); + fsSpy.mockRestore(); expect( diff --git a/packages/config-loader/src/sources/ConfigSources.ts b/packages/config-loader/src/sources/ConfigSources.ts index 37c1c6b087..0d958bb81d 100644 --- a/packages/config-loader/src/sources/ConfigSources.ts +++ b/packages/config-loader/src/sources/ConfigSources.ts @@ -182,6 +182,10 @@ export class ConfigSources { if (argSources.length === 0) { const defaultPath = resolvePath(rootDir, 'app-config.yaml'); const localPath = resolvePath(rootDir, 'app-config.local.yaml'); + const envPath = resolvePath( + rootDir, + `app-config.${process.env.BACKSTAGE_ENVIRONMENT}.yaml`, + ); const alwaysIncludeDefaultConfigSource = !options.allowMissingDefaultConfig; @@ -195,6 +199,16 @@ export class ConfigSources { ); } + if (process.env.BACKSTAGE_ENVIRONMENT && fs.pathExistsSync(envPath)) { + argSources.push( + FileConfigSource.create({ + watch: options.watch, + path: envPath, + substitutionFunc: options.substitutionFunc, + }), + ); + } + if (fs.pathExistsSync(localPath)) { argSources.push( FileConfigSource.create({ From 4ce58318da0bd4b64939d9ed348102a418b2bf40 Mon Sep 17 00:00:00 2001 From: Gabriel Dugny Date: Fri, 1 Aug 2025 14:47:56 +0200 Subject: [PATCH 004/107] fix(techdocs): support dompurify 3.2.6 Element.tagName is uppercased, see https://developer.mozilla.org/fr/docs/Web/API/Element/tagName Signed-off-by: Gabriel Dugny --- .changeset/stupid-areas-share.md | 5 +++++ plugins/techdocs/src/reader/transformers/html/transformer.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/stupid-areas-share.md diff --git a/.changeset/stupid-areas-share.md b/.changeset/stupid-areas-share.md new file mode 100644 index 0000000000..2238f9d942 --- /dev/null +++ b/.changeset/stupid-areas-share.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Support Techdocs redirect with dompurify 3.2.6+ diff --git a/plugins/techdocs/src/reader/transformers/html/transformer.ts b/plugins/techdocs/src/reader/transformers/html/transformer.ts index 7c341c783e..d4b31a34e5 100644 --- a/plugins/techdocs/src/reader/transformers/html/transformer.ts +++ b/plugins/techdocs/src/reader/transformers/html/transformer.ts @@ -65,7 +65,7 @@ export const useSanitizerTransformer = (): Transformer => { // Only allow http-equiv and content attributes on meta tags. They are required for the redirect feature. DOMPurify.addHook('uponSanitizeAttribute', (currNode, data) => { - if (currNode.tagName !== 'meta') { + if (currNode.tagName !== 'META') { if (data.attrName === 'http-equiv' || data.attrName === 'content') { currNode.removeAttribute(data.attrName); } From b321dd90406223c4b8a05f462bab9b02c560c3fd Mon Sep 17 00:00:00 2001 From: Hellgren Heikki Date: Mon, 11 Aug 2025 14:46:51 +0300 Subject: [PATCH 005/107] feat: add support for local env specific config files Signed-off-by: Hellgren Heikki --- docs/conf/index.md | 1 + .../src/sources/ConfigSources.test.ts | 1 + .../config-loader/src/sources/ConfigSources.ts | 17 +++++++++++++++++ 3 files changed, 19 insertions(+) diff --git a/docs/conf/index.md b/docs/conf/index.md index 7c5be174e5..0b2240e2de 100644 --- a/docs/conf/index.md +++ b/docs/conf/index.md @@ -25,6 +25,7 @@ Loading order of these files is as follows: 1. `app-config.yaml` 2. `app-config..yaml` 3. `app-config.local.yaml` +4. `app-config..local.yaml` Other sets of files can by loaded by passing `--config ` flags. Read more about the configuration loading order in the diff --git a/packages/config-loader/src/sources/ConfigSources.test.ts b/packages/config-loader/src/sources/ConfigSources.test.ts index 9b2932e439..da56f5ccf9 100644 --- a/packages/config-loader/src/sources/ConfigSources.test.ts +++ b/packages/config-loader/src/sources/ConfigSources.test.ts @@ -99,6 +99,7 @@ describe('ConfigSources', () => { { name: 'FileConfigSource', path: `${root}app-config.yaml` }, { name: 'FileConfigSource', path: `${root}app-config.test.yaml` }, { name: 'FileConfigSource', path: `${root}app-config.local.yaml` }, + { name: 'FileConfigSource', path: `${root}app-config.test.local.yaml` }, ]); fsSpy.mockRestore(); diff --git a/packages/config-loader/src/sources/ConfigSources.ts b/packages/config-loader/src/sources/ConfigSources.ts index 0d958bb81d..1dacdc4752 100644 --- a/packages/config-loader/src/sources/ConfigSources.ts +++ b/packages/config-loader/src/sources/ConfigSources.ts @@ -186,6 +186,10 @@ export class ConfigSources { rootDir, `app-config.${process.env.BACKSTAGE_ENVIRONMENT}.yaml`, ); + const envLocalPath = resolvePath( + rootDir, + `app-config.${process.env.BACKSTAGE_ENVIRONMENT}.local.yaml`, + ); const alwaysIncludeDefaultConfigSource = !options.allowMissingDefaultConfig; @@ -218,6 +222,19 @@ export class ConfigSources { }), ); } + + if ( + process.env.BACKSTAGE_ENVIRONMENT && + fs.pathExistsSync(envLocalPath) + ) { + argSources.push( + FileConfigSource.create({ + watch: options.watch, + path: envLocalPath, + substitutionFunc: options.substitutionFunc, + }), + ); + } } return this.merge(argSources); From 117195400c7f2f181c6589f26465e280e9ba1279 Mon Sep 17 00:00:00 2001 From: Jan Remunda Date: Tue, 19 Aug 2025 14:46:25 +0200 Subject: [PATCH 006/107] microsite: add datacontract plugin Signed-off-by: Jan Remunda --- microsite/data/plugins/datacontract.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 microsite/data/plugins/datacontract.yaml diff --git a/microsite/data/plugins/datacontract.yaml b/microsite/data/plugins/datacontract.yaml new file mode 100644 index 0000000000..a267730496 --- /dev/null +++ b/microsite/data/plugins/datacontract.yaml @@ -0,0 +1,10 @@ +--- +title: Data Contract +author: Jan Remunda +authorUrl: https://www.remunda.cz/#backstage +category: Discovery +description: Ingest and visualize Data Contracts to your catalog via API Entities. +documentation: https://www.npmjs.com/package/@remunda/backstage-plugin-datacontract +iconUrl: https://www.remunda.cz/logos/datacontract.png +npmPackageName: '@remunda/backstage-plugin-datacontract' +addedDate: '2025-08-19' From 58fc10884612869139adaa87cb2f6ceb66c7eaac Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Wed, 20 Aug 2025 23:16:24 -0400 Subject: [PATCH 007/107] Set a minimum height for scaffolder task log stream Signed-off-by: Stephen Glass --- .changeset/tangy-squids-film.md | 5 +++++ .../src/next/components/TaskLogStream/TaskLogStream.tsx | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/tangy-squids-film.md diff --git a/.changeset/tangy-squids-film.md b/.changeset/tangy-squids-film.md new file mode 100644 index 0000000000..acb5546031 --- /dev/null +++ b/.changeset/tangy-squids-film.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-react': patch +--- + +Fix scaffolder task log stream not having a minimum height diff --git a/plugins/scaffolder-react/src/next/components/TaskLogStream/TaskLogStream.tsx b/plugins/scaffolder-react/src/next/components/TaskLogStream/TaskLogStream.tsx index a0c4ce94cd..b88d96510e 100644 --- a/plugins/scaffolder-react/src/next/components/TaskLogStream/TaskLogStream.tsx +++ b/plugins/scaffolder-react/src/next/components/TaskLogStream/TaskLogStream.tsx @@ -21,6 +21,7 @@ const useStyles = makeStyles({ width: '100%', height: '100%', position: 'relative', + minHeight: 240, }, }); From 85c5e045c9778623644e86716dd0aa76cf670ee6 Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Thu, 21 Aug 2025 16:22:44 +0100 Subject: [PATCH 008/107] catalog: fix incorrect defaultTarget in createComponentRouteRef The previous value doesn't exist in the scaffolder. I believe the logical place for this to point to is the root of the scaffolder plugin, which renders the list of templates. Signed-off-by: MT Lewis --- .changeset/warm-emus-itch.md | 5 +++++ packages/app-next/app-config.yaml | 1 - plugins/catalog/src/routes.ts | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 .changeset/warm-emus-itch.md diff --git a/.changeset/warm-emus-itch.md b/.changeset/warm-emus-itch.md new file mode 100644 index 0000000000..3275098425 --- /dev/null +++ b/.changeset/warm-emus-itch.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Fix incorrect defaultTarget on `createComponentRouteRef`. diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml index f83e4d4ba0..35380c961f 100644 --- a/packages/app-next/app-config.yaml +++ b/packages/app-next/app-config.yaml @@ -4,7 +4,6 @@ app: routes: bindings: catalog.viewTechDoc: techdocs.docRoot - catalog.createComponent: catalog-import.importPage org.catalogIndex: catalog.catalogIndex pluginOverrides: diff --git a/plugins/catalog/src/routes.ts b/plugins/catalog/src/routes.ts index 4f7f0427b1..6043ecd1a1 100644 --- a/plugins/catalog/src/routes.ts +++ b/plugins/catalog/src/routes.ts @@ -22,7 +22,7 @@ import { export const createComponentRouteRef = createExternalRouteRef({ id: 'create-component', optional: true, - defaultTarget: 'scaffolder.createComponent', + defaultTarget: 'scaffolder.root', }); export const viewTechDocRouteRef = createExternalRouteRef({ From c3405dbc91b7795013091e6507819af56bc639c5 Mon Sep 17 00:00:00 2001 From: Adam Letizia Date: Mon, 25 Aug 2025 09:35:48 -0500 Subject: [PATCH 009/107] fix(scaffolder-backend): sets router max upload size to 10MB Signed-off-by: Adam Letizia --- .changeset/silent-results-stick.md | 5 +++ .../src/service/router.test.ts | 42 +++++++------------ .../scaffolder-backend/src/service/router.ts | 9 ++-- 3 files changed, 27 insertions(+), 29 deletions(-) create mode 100644 .changeset/silent-results-stick.md diff --git a/.changeset/silent-results-stick.md b/.changeset/silent-results-stick.md new file mode 100644 index 0000000000..6cb501027c --- /dev/null +++ b/.changeset/silent-results-stick.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Fixed a regression that prevented uploads greater than 100KB. Uploads up to 10MB are supported again. diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 467b590bed..4144c552fb 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -1385,34 +1385,24 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ expect.anything(), ); }); - it('disallows users from seeing tasks they do not own', async () => { - const { permissions, router, taskBroker } = await createTestRouter(); - jest - .spyOn(permissions, 'authorizeConditional') - .mockImplementationOnce(async () => [ - { - conditions: { - resourceType: 'scaffolder-task', - rule: 'IS_TASK_OWNER', - params: { createdBy: ['user'] }, - }, - pluginId: 'scaffolder', - resourceType: 'scaffolder-task', - result: AuthorizeResult.CONDITIONAL, + it('allows payloads up to 10MB', async () => { + const { unwrappedRouter } = await createTestRouter(); + const mockToken = mockCredentials.user.token(); + const mockTemplate = generateMockTemplate(); + + const response = await request(unwrappedRouter) + .post('/v2/dry-run') + .set('Authorization', `Bearer ${mockToken}`) + .send({ + template: mockTemplate, + values: { + requiredParameter1: 'A'.repeat(9 * 1024 * 1024), // ~9MB + requiredParameter2: 'required-value-2', }, - ]); - const response = await request(router).get( - `/v2/tasks?createdBy=not-user`, - ); - expect(taskBroker.list).toHaveBeenCalledWith({ - filters: { createdBy: ['not-user'], status: undefined }, - order: undefined, - pagination: { limit: undefined, offset: undefined }, - permissionFilters: { key: 'created_by', values: ['user'] }, - }); + directoryContents: [], + }); + expect(response.status).toBe(200); - expect(response.body.totalTasks).toBe(0); - expect(response.body.tasks).toEqual([]); }); }); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 9315e8ccb2..8296a6003f 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -186,9 +186,12 @@ const readDuration = ( export async function createRouter( options: RouterOptions, ): Promise { - const router = await createOpenApiRouter(); - // Be generous in upload size to support a wide range of templates in dry-run mode. - router.use(express.json({ limit: '10MB' })); + const router = await createOpenApiRouter({ + middleware: [ + // Be generous in upload size to support a wide range of templates in dry-run mode. + express.json({ limit: '10MB' }), + ], + }); const { logger: parentLogger, From a0b604cb6a50765450c3675405994f94137e38fd Mon Sep 17 00:00:00 2001 From: Owen Shartle Date: Mon, 25 Aug 2025 15:39:29 -0400 Subject: [PATCH 010/107] Adding handling which checks if the current entity (the catalog entity being loaded) has an annotation for an external entity's TechDocs. If it does then we will redirect there rather than allowing a 404 (mic drop). This helps keep older URLs routing to the updated locations. Adding changesets. Adding test coverage for external TechDocs entitiy redirect. Signed-off-by: Owen Shartle --- .changeset/eighty-numbers-act.md | 5 + .changeset/hungry-carrots-grow.md | 5 + .changeset/lovely-actors-love.md | 5 + .../well-known-annotations.md | 2 +- docs/features/techdocs/FAQ.md | 4 + .../documented-component/catalog-info.yaml | 14 ++ .../docs/inner-component-docs/index.md | 5 + .../examples/documented-component/mkdocs.yml | 2 + plugins/techdocs-react/src/helpers.ts | 4 +- .../TechDocsReaderPage.test.tsx | 131 +++++++++++++++++- .../TechDocsReaderPage/TechDocsReaderPage.tsx | 98 ++++++++++--- 11 files changed, 255 insertions(+), 20 deletions(-) create mode 100644 .changeset/eighty-numbers-act.md create mode 100644 .changeset/hungry-carrots-grow.md create mode 100644 .changeset/lovely-actors-love.md create mode 100644 plugins/techdocs-backend/examples/documented-component/docs/inner-component-docs/index.md diff --git a/.changeset/eighty-numbers-act.md b/.changeset/eighty-numbers-act.md new file mode 100644 index 0000000000..183d857d92 --- /dev/null +++ b/.changeset/eighty-numbers-act.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs-backend': patch +--- + +Update to documentation regarding TechDocs redirects. diff --git a/.changeset/hungry-carrots-grow.md b/.changeset/hungry-carrots-grow.md new file mode 100644 index 0000000000..748ace8d94 --- /dev/null +++ b/.changeset/hungry-carrots-grow.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': minor +--- + +Adding redirect handling for TechDocs URLs that reference entities that now reference an external entity for TechDocs. Including tests and documentation. diff --git a/.changeset/lovely-actors-love.md b/.changeset/lovely-actors-love.md new file mode 100644 index 0000000000..f645cc5b3f --- /dev/null +++ b/.changeset/lovely-actors-love.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs-backend': minor +--- + +Adding new entity that specifies an external entity in the techdocs-entity annotation. diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md index cdd93b50ac..d8a2e1d232 100644 --- a/docs/features/software-catalog/well-known-annotations.md +++ b/docs/features/software-catalog/well-known-annotations.md @@ -126,7 +126,7 @@ metadata: The value of this annotation informs of the path to this component's TechDocs within an external entity that owns the TechDocs. In conjunction with [backstage.io/techdocs-entity](#backstageiotechdocs-entity) this allows for deep linking into the TechDocs of -another entity, not just linking to the root of another entities TechDocs. +another entity, not just linking to the root of another entity's TechDocs. ### backstage.io/view-url, backstage.io/edit-url diff --git a/docs/features/techdocs/FAQ.md b/docs/features/techdocs/FAQ.md index 117471f749..323489ddab 100644 --- a/docs/features/techdocs/FAQ.md +++ b/docs/features/techdocs/FAQ.md @@ -57,3 +57,7 @@ your `mkdocs.yml` files per If the host name of your source code hosting URL does not include `github` or `gitlab`, an `integrations` entry in your `app-config.yaml` pointed at your source code provider is also needed (only the `host` key is necessary). + +#### What happens when you navigate to a TechDocs URL for an entity uses the `backstage.io/techdocs-entity` annotation? + +If you navigate to a TechDocs URL in the format `docs/{namespace}/{kind}/{name}` for an entity that has the `backstage.io/techdocs-entity` annotation (instead of the `backstage.io/techdocs-ref` annotation), then Backstage will redirect to the TechDocs page of the entity referenced in the value of that annotation. diff --git a/plugins/techdocs-backend/examples/documented-component/catalog-info.yaml b/plugins/techdocs-backend/examples/documented-component/catalog-info.yaml index 6b30213a60..4d3b71eb2a 100644 --- a/plugins/techdocs-backend/examples/documented-component/catalog-info.yaml +++ b/plugins/techdocs-backend/examples/documented-component/catalog-info.yaml @@ -10,3 +10,17 @@ spec: type: service lifecycle: experimental owner: user:guest +--- +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: techdocs-entity-documented-component + title: Example Entity Documented By TechDocs Entity Annotation + description: A Service with TechDocs documentation via the `backstage.io/techdocs-entity` annotation. + annotations: + backstage.io/techdocs-entity: component:default/documented-component + backstage.io/techdocs-entity-path: /inner-component-docs +spec: + type: service + lifecycle: experimental + owner: user:guest \ No newline at end of file diff --git a/plugins/techdocs-backend/examples/documented-component/docs/inner-component-docs/index.md b/plugins/techdocs-backend/examples/documented-component/docs/inner-component-docs/index.md new file mode 100644 index 0000000000..8cb6498c55 --- /dev/null +++ b/plugins/techdocs-backend/examples/documented-component/docs/inner-component-docs/index.md @@ -0,0 +1,5 @@ +# Inner Component Docs + +This is a basic example of documentation within a larger suite of TechDocs that can be referenced by whatever entities necessary. It is intended as a showcase of the `backstage.io/techdocs-entity-path` annotation for linking to a "subpage" in TechDocs that are declared by another entity. + +Please review the [How-To Guides - Deep Linking Into TechDocs](../../../../../../docs/features/techdocs/how-to-guides.md#deep-linking-into-techdocs) section for more information and you can view the example usage on the "Example Entity Documented By TechDocs Entity Annotation" component in this [catalog-info.yaml](../../catalog-info.yaml) file. diff --git a/plugins/techdocs-backend/examples/documented-component/mkdocs.yml b/plugins/techdocs-backend/examples/documented-component/mkdocs.yml index 1a159a4d12..ab97326913 100644 --- a/plugins/techdocs-backend/examples/documented-component/mkdocs.yml +++ b/plugins/techdocs-backend/examples/documented-component/mkdocs.yml @@ -7,6 +7,8 @@ nav: - Subpage: sub-page.md - 'Code Sample': code/code-sample.md - Extensions: extensions.md + - 'Inner Component Docs': inner-component-docs/index.md plugins: - techdocs-core + \ No newline at end of file diff --git a/plugins/techdocs-react/src/helpers.ts b/plugins/techdocs-react/src/helpers.ts index e6daf99b26..aebb0b3e52 100644 --- a/plugins/techdocs-react/src/helpers.ts +++ b/plugins/techdocs-react/src/helpers.ts @@ -64,7 +64,7 @@ export function getEntityRootTechDocsPath(entity: Entity): string { /** * Build the TechDocs URL for the given entity. This helper should be used anywhere there - * is a link to an entities TechDocs. + * is a link to an entity's TechDocs. * * @public */ @@ -104,7 +104,7 @@ export const buildTechDocsURL = ( }); // Add on the external entity path to the url if one exists. This allows deep linking into another - // entities TechDocs. + // entity's TechDocs. const path = getEntityRootTechDocsPath(entity); return `${url}${path}`; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx index 2af4915ef5..93f24e9f97 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx @@ -18,6 +18,7 @@ import { ReactNode } from 'react'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { + catalogApiRef, entityPresentationApiRef, entityRouteRef, } from '@backstage/plugin-catalog-react'; @@ -30,9 +31,10 @@ import { import { techdocsApiRef, techdocsStorageApiRef } from '../../../api'; import { rootRouteRef, rootDocsRouteRef } from '../../../routes'; +import { TECHDOCS_EXTERNAL_ANNOTATION } from '@backstage/plugin-techdocs-common'; import { TechDocsReaderPage } from './TechDocsReaderPage'; -import { Route, useParams } from 'react-router-dom'; +import { Route, useNavigate, useParams } from 'react-router-dom'; import { TechDocsAddons } from '@backstage/plugin-techdocs-react'; import { ReportIssue } from '@backstage/plugin-techdocs-module-addons-contrib'; import { FlatRoutes } from '@backstage/core-app-api'; @@ -94,6 +96,10 @@ const entityPresentationApiMock: jest.Mocked< }), }; +const catalogApiMock = { + getEntityByRef: jest.fn().mockResolvedValue(mockEntityMetadata), +}; + const fetchApiMock = { fetch: jest.fn().mockResolvedValue({ ok: true, @@ -114,6 +120,11 @@ jest.mock('@backstage/core-components', () => ({ Page: jest.fn(), })); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: jest.fn(), +})); + const configApi = mockApis.config({ data: { app: { baseUrl: 'http://localhost:3000' } }, }); @@ -129,6 +140,7 @@ const Wrapper = ({ children }: { children: ReactNode }) => { [techdocsApiRef, techdocsApiMock], [techdocsStorageApiRef, techdocsStorageApiMock], [entityPresentationApiRef, entityPresentationApiMock], + [catalogApiRef, catalogApiMock], ]} > {children} @@ -143,6 +155,8 @@ const mountedRoutes = { }; describe('', () => { + const mockNavigate = jest.fn(); + beforeEach(() => { getEntityMetadata.mockResolvedValue(mockEntityMetadata); getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); @@ -150,6 +164,8 @@ describe('', () => { // Expires in 10 minutes expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), }); + + (useNavigate as jest.Mock).mockReturnValue(mockNavigate); }); afterEach(() => { @@ -285,4 +301,117 @@ describe('', () => { expect(text).toHaveStyle('fontFamily: Comic Sans MS'); }); + + describe('external TechDocs redirect', () => { + beforeEach(() => { + mockNavigate.mockClear(); + catalogApiMock.getEntityByRef.mockReset(); + catalogApiMock.getEntityByRef.mockResolvedValue(mockEntityMetadata); + }); + + it('should navigate to external URL when entity has external techdocs annotation', async () => { + const mockEntityWithExternalAnnotation = { + ...mockEntityMetadata, + metadata: { + ...mockEntityMetadata.metadata, + annotations: { + [TECHDOCS_EXTERNAL_ANNOTATION]: + 'component:external-namespace/external-docs', + }, + }, + }; + + catalogApiMock.getEntityByRef.mockResolvedValue( + mockEntityWithExternalAnnotation, + ); + + await renderInTestApp( + + + , + { + mountedRoutes, + }, + ); + + expect(mockNavigate).toHaveBeenCalledWith( + '/docs/external-namespace/component/external-docs', + { replace: true }, + ); + }); + + it('should render normally when entity has no external techdocs annotation', async () => { + const mockEntityWithoutExternalAnnotation = { + ...mockEntityMetadata, + metadata: { + ...mockEntityMetadata.metadata, + annotations: undefined, + }, + }; + + catalogApiMock.getEntityByRef.mockResolvedValue( + mockEntityWithoutExternalAnnotation, + ); + + const rendered = await renderInTestApp( + + + , + { + mountedRoutes, + }, + ); + + expect(rendered.container.querySelector('header')).toBeInTheDocument(); + expect(rendered.container.querySelector('article')).toBeInTheDocument(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('should render normally when entity has external annotation but no value', async () => { + const mockEntityWithEmptyExternalAnnotation = { + ...mockEntityMetadata, + metadata: { + ...mockEntityMetadata.metadata, + annotations: { + [TECHDOCS_EXTERNAL_ANNOTATION]: '', + }, + }, + }; + + catalogApiMock.getEntityByRef.mockResolvedValue( + mockEntityWithEmptyExternalAnnotation, + ); + + const rendered = await renderInTestApp( + + + , + { + mountedRoutes, + }, + ); + + expect(rendered.container.querySelector('header')).toBeInTheDocument(); + expect(rendered.container.querySelector('article')).toBeInTheDocument(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + }); }); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx index 80545151d9..9b4999899d 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -14,19 +14,26 @@ * limitations under the License. */ -import { Children, ReactElement, ReactNode } from 'react'; -import { useOutlet } from 'react-router-dom'; - +import { + Children, + ReactElement, + ReactNode, + useEffect, + useMemo, + useCallback, +} from 'react'; +import { useOutlet, useNavigate } from 'react-router-dom'; import { Page } from '@backstage/core-components'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { TECHDOCS_ADDONS_KEY, TECHDOCS_ADDONS_WRAPPER_KEY, TechDocsReaderPageProvider, + buildTechDocsURL, } from '@backstage/plugin-techdocs-react'; - +import { TECHDOCS_EXTERNAL_ANNOTATION } from '@backstage/plugin-techdocs-common'; +import useAsync from 'react-use/esm/useAsync'; import { TechDocsReaderPageRenderFunction } from '../../../types'; - import { TechDocsReaderPageContent } from '../TechDocsReaderPageContent'; import { TechDocsReaderPageHeader } from '../TechDocsReaderPageHeader'; import { TechDocsReaderPageSubheader } from '../TechDocsReaderPageSubheader'; @@ -34,9 +41,11 @@ import { rootDocsRouteRef } from '../../../routes'; import { getComponentData, useRouteRefParams, + useApi, + useRouteRef, } from '@backstage/core-plugin-api'; - import { CookieAuthRefreshProvider } from '@backstage/plugin-auth-react'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { createTheme, styled, @@ -44,6 +53,7 @@ import { ThemeProvider, useTheme, } from '@material-ui/core/styles'; +import { Progress } from '@backstage/core-components'; /* An explanation for the multiple ways of customizing the TechDocs reader page @@ -177,44 +187,100 @@ const StyledPage = styled(Page)({ export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => { const currentTheme = useTheme(); - const readerPageTheme = createTheme({ - ...currentTheme, - ...(props.overrideThemeOptions || {}), - }); + const readerPageTheme = useMemo( + () => + createTheme({ + ...currentTheme, + ...(props.overrideThemeOptions || {}), + }), + [currentTheme, props.overrideThemeOptions], + ); + const { kind, name, namespace } = useRouteRefParams(rootDocsRouteRef); const { children, entityRef = { kind, name, namespace } } = props; const outlet = useOutlet(); - if (!children) { + const catalogApi = useApi(catalogApiRef); + const navigate = useNavigate(); + const viewTechdocLink = useRouteRef(rootDocsRouteRef); + + const memoizedEntityRef = useMemo( + () => ({ + kind: entityRef.kind, + name: entityRef.name, + namespace: entityRef.namespace, + }), + [entityRef.kind, entityRef.name, entityRef.namespace], + ); + + const externalEntityTechDocsUrl = useAsync(async () => { + const catalogEntity = await catalogApi.getEntityByRef(memoizedEntityRef); + + if (catalogEntity?.metadata?.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]) { + return buildTechDocsURL(catalogEntity, viewTechdocLink); + } + + return undefined; + }, [memoizedEntityRef, catalogApi, viewTechdocLink]); + + const handleNavigation = useCallback( + (url: string) => { + navigate(url, { replace: true }); + }, + [navigate], + ); + + useEffect(() => { + if (!externalEntityTechDocsUrl.loading && externalEntityTechDocsUrl.value) { + handleNavigation(externalEntityTechDocsUrl.value); + } + }, [ + externalEntityTechDocsUrl.loading, + externalEntityTechDocsUrl.value, + handleNavigation, + ]); + + const page: ReactNode = useMemo(() => { + if (children) { + return null; + } + const childrenList = outlet ? Children.toArray(outlet.props.children) : []; const grandChildren = childrenList.flatMap( child => (child as ReactElement)?.props?.children ?? [], ); - const page: ReactNode = grandChildren.find( + return grandChildren.find( grandChild => !getComponentData(grandChild, TECHDOCS_ADDONS_WRAPPER_KEY) && !getComponentData(grandChild, TECHDOCS_ADDONS_KEY), ); + }, [children, outlet]); - // As explained above, "page" is configuration 4 and is 1 + if (externalEntityTechDocsUrl.loading || externalEntityTechDocsUrl.value) { + return ; + } + + // As explained above, "page" is configuration 4 and is 1 + if (!children) { return ( - + {(page as JSX.Element) || } ); } + // As explained above, a render function is configuration 3 and React element is 2 return ( - + {({ metadata, entityMetadata, onReady }) => ( { > {children instanceof Function ? children({ - entityRef, + entityRef: memoizedEntityRef, techdocsMetadataValue: metadata.value, entityMetadataValue: entityMetadata.value, onReady, From dcdb03c7f491179188ab53c8f26958051dba92ca Mon Sep 17 00:00:00 2001 From: Owen Shartle Date: Mon, 25 Aug 2025 15:49:41 -0400 Subject: [PATCH 011/107] Correcting chagesets -- had duplicate changeset. Signed-off-by: Owen Shartle --- .changeset/eighty-numbers-act.md | 2 +- .changeset/lovely-actors-love.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/eighty-numbers-act.md b/.changeset/eighty-numbers-act.md index 183d857d92..dc7d504adc 100644 --- a/.changeset/eighty-numbers-act.md +++ b/.changeset/eighty-numbers-act.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-techdocs-backend': patch +'@backstage/plugin-techdocs-react': patch --- Update to documentation regarding TechDocs redirects. diff --git a/.changeset/lovely-actors-love.md b/.changeset/lovely-actors-love.md index f645cc5b3f..b428a11619 100644 --- a/.changeset/lovely-actors-love.md +++ b/.changeset/lovely-actors-love.md @@ -2,4 +2,4 @@ '@backstage/plugin-techdocs-backend': minor --- -Adding new entity that specifies an external entity in the techdocs-entity annotation. +Adding new entity that specifies an external entity in the techdocs-entity annotation and updates to documentation regarding TechDocs redirects. From 40fe543447f190cdaa6465abd4916cefed3117ff Mon Sep 17 00:00:00 2001 From: Owen Shartle Date: Mon, 25 Aug 2025 16:43:58 -0400 Subject: [PATCH 012/107] Running prettier on files. Signed-off-by: Owen Shartle --- .../examples/documented-component/catalog-info.yaml | 2 +- .../techdocs-backend/examples/documented-component/mkdocs.yml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/techdocs-backend/examples/documented-component/catalog-info.yaml b/plugins/techdocs-backend/examples/documented-component/catalog-info.yaml index 4d3b71eb2a..5b90693e23 100644 --- a/plugins/techdocs-backend/examples/documented-component/catalog-info.yaml +++ b/plugins/techdocs-backend/examples/documented-component/catalog-info.yaml @@ -23,4 +23,4 @@ metadata: spec: type: service lifecycle: experimental - owner: user:guest \ No newline at end of file + owner: user:guest diff --git a/plugins/techdocs-backend/examples/documented-component/mkdocs.yml b/plugins/techdocs-backend/examples/documented-component/mkdocs.yml index ab97326913..fd954225a3 100644 --- a/plugins/techdocs-backend/examples/documented-component/mkdocs.yml +++ b/plugins/techdocs-backend/examples/documented-component/mkdocs.yml @@ -11,4 +11,3 @@ nav: plugins: - techdocs-core - \ No newline at end of file From 8eb950ff649b18430d29364ff0a11a5593e1515d Mon Sep 17 00:00:00 2001 From: Owen Shartle Date: Mon, 25 Aug 2025 21:36:57 -0400 Subject: [PATCH 013/107] Adding a try-catch around the usage of the catalog API in the TechDocsReaderPage as it could still attempt to load a standard TechDocs page. Signed-off-by: Owen Shartle --- .../TechDocsReaderPage.test.tsx | 25 +++++++++++++++++++ .../TechDocsReaderPage/TechDocsReaderPage.tsx | 12 ++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx index 93f24e9f97..c645d1db83 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx @@ -413,5 +413,30 @@ describe('', () => { expect(rendered.container.querySelector('article')).toBeInTheDocument(); expect(mockNavigate).not.toHaveBeenCalled(); }); + + it('should render normally when catalog API throws an error', async () => { + catalogApiMock.getEntityByRef.mockRejectedValue( + new Error('Catalog API error'), + ); + + const rendered = await renderInTestApp( + + + , + { + mountedRoutes, + }, + ); + + expect(rendered.container.querySelector('header')).toBeInTheDocument(); + expect(rendered.container.querySelector('article')).toBeInTheDocument(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); }); }); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx index 9b4999899d..c67660fd79 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -215,10 +215,16 @@ export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => { ); const externalEntityTechDocsUrl = useAsync(async () => { - const catalogEntity = await catalogApi.getEntityByRef(memoizedEntityRef); + try { + const catalogEntity = await catalogApi.getEntityByRef(memoizedEntityRef); - if (catalogEntity?.metadata?.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]) { - return buildTechDocsURL(catalogEntity, viewTechdocLink); + if ( + catalogEntity?.metadata?.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION] + ) { + return buildTechDocsURL(catalogEntity, viewTechdocLink); + } + } catch (error) { + // Ignore error and allow an attempt at loading the current entity's TechDocs when unable to fetch an external entity from the catalog. } return undefined; From def6247bb6cc42437a77c5c9bbb3b303fa195d02 Mon Sep 17 00:00:00 2001 From: Owen Shartle Date: Mon, 25 Aug 2025 22:00:22 -0400 Subject: [PATCH 014/107] Adding catalogApiRef to test-utils.tsx. Signed-off-by: Owen Shartle --- plugins/techdocs-addons-test-utils/src/test-utils.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/plugins/techdocs-addons-test-utils/src/test-utils.tsx b/plugins/techdocs-addons-test-utils/src/test-utils.tsx index 91b85e7885..2d7420ec62 100644 --- a/plugins/techdocs-addons-test-utils/src/test-utils.tsx +++ b/plugins/techdocs-addons-test-utils/src/test-utils.tsx @@ -39,6 +39,7 @@ import { } from '@backstage/plugin-techdocs-react'; import { TechDocsReaderPage, techdocsPlugin } from '@backstage/plugin-techdocs'; import { + catalogApiRef, EntityPresentationApi, entityPresentationApiRef, entityRouteRef, @@ -235,8 +236,16 @@ export class TechDocsAddonTester { }), }; + const catalogApi = { + getEntityByRef: jest.fn().mockResolvedValue({ + kind: 'Component', + metadata: { namespace: 'default', name: 'docs' }, + }), + }; + const apis: TechdocsAddonTesterApis = [ [fetchApiRef, fetchApi], + [catalogApiRef, catalogApi], [entityPresentationApiRef, entityPresentationApi], [discoveryApiRef, discoveryApi], [techdocsApiRef, techdocsApi], From 72543e92b8bae988f7ebfcf6fa08930ca5e27c8e Mon Sep 17 00:00:00 2001 From: Owen Shartle Date: Mon, 25 Aug 2025 22:05:59 -0400 Subject: [PATCH 015/107] Adding catalogApiRef to test-utils to support catalog API usage by TechDocs reader page. Signed-off-by: Owen Shartle --- .changeset/tiny-spoons-mix.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tiny-spoons-mix.md diff --git a/.changeset/tiny-spoons-mix.md b/.changeset/tiny-spoons-mix.md new file mode 100644 index 0000000000..bb607aca3f --- /dev/null +++ b/.changeset/tiny-spoons-mix.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs-addons-test-utils': minor +--- + +Adding catalogApiRef to test-utils to support catalog API usage by TechDocs reader page. From 0d415ae01409b022b4d2fe8457cc8d301d2fadcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Fankh=C3=A4nel?= Date: Tue, 26 Aug 2025 15:08:55 +0200 Subject: [PATCH 016/107] fix(scaffolder): render TechDocs link on Template List page for TechDocs annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show “View TechDocs” link when template has backstage.io/techdocs-ref or backstage.io/techdocs-entity. Append backstage.io/techdocs-entity-path when set. Use buildTechDocsURL from @backstage/plugin-techdocs-react. Add tests covering both annotations and path handling. Update dependencies to include @backstage/plugin-techdocs-common and @backstage/plugin-techdocs-react. Add sample TechDocs scaffolding to notifications-demo template. Closes #29076. Signed-off-by: David Fankhänel --- .changeset/itchy-moons-start.md | 5 + plugins/scaffolder/package.json | 3 + .../TemplateListPage.test.tsx | 129 +++++++++++++++++- .../TemplateListPage/TemplateListPage.tsx | 17 ++- yarn.lock | 3 + 5 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 .changeset/itchy-moons-start.md diff --git a/.changeset/itchy-moons-start.md b/.changeset/itchy-moons-start.md new file mode 100644 index 0000000000..e5768b58b2 --- /dev/null +++ b/.changeset/itchy-moons-start.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Render a TechDocs link on the Scaffolder Template List page when templates include either `backstage.io/techdocs-ref` or `backstage.io/techdocs-entity` annotations, using the shared `buildTechDocsURL` helper. Also adds tests to verify both annotations and optional `backstage.io/techdocs-entity-path` are respected. diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index ff56a19175..df75a65656 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -72,6 +72,8 @@ "@backstage/plugin-permission-react": "workspace:^", "@backstage/plugin-scaffolder-common": "workspace:^", "@backstage/plugin-scaffolder-react": "workspace:^", + "@backstage/plugin-techdocs-common": "workspace:^", + "@backstage/plugin-techdocs-react": "workspace:^", "@backstage/types": "workspace:^", "@codemirror/language": "^6.0.0", "@codemirror/legacy-modes": "^6.1.0", @@ -109,6 +111,7 @@ "@backstage/dev-utils": "workspace:^", "@backstage/plugin-catalog": "workspace:^", "@backstage/plugin-permission-common": "workspace:^", + "@backstage/plugin-techdocs": "workspace:^", "@backstage/test-utils": "workspace:^", "@testing-library/dom": "^10.0.0", "@testing-library/jest-dom": "^6.0.0", diff --git a/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.test.tsx b/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.test.tsx index a16474a4cf..da544cb2c0 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.test.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.test.tsx @@ -27,13 +27,19 @@ import { TestApiProvider, mockApis, } from '@backstage/test-utils'; -import { rootRouteRef } from '../../../routes'; +import { rootRouteRef, viewTechDocRouteRef } from '../../../routes'; import { TemplateListPage } from './TemplateListPage'; +import { + TECHDOCS_ANNOTATION, + TECHDOCS_EXTERNAL_ANNOTATION, + TECHDOCS_EXTERNAL_PATH_ANNOTATION, +} from '@backstage/plugin-techdocs-common'; const mountedRoutes = { mountedRoutes: { '/': rootRouteRef, '/catalog/:namespace/:kind/:name': entityRouteRef, + '/docs/:namespace/:kind/:name': viewTechDocRouteRef, }, }; @@ -51,6 +57,127 @@ describe('TemplateListPage', () => { ], }); + describe('TechDocs link rendering', () => { + it('shows TechDocs link when template has backstage.io/techdocs-ref', async () => { + const mockCatalogApiWithDocs = catalogApiMock({ + entities: [ + { + apiVersion: 'scaffolder.backstage.io/v1beta3', + kind: 'Template', + metadata: { + name: 'tmpl-a', + annotations: { [TECHDOCS_ANNOTATION]: 'dir:.' }, + }, + spec: { type: 'service' }, + }, + ], + }); + + const { findByText } = await renderInTestApp( + + + , + mountedRoutes, + ); + + expect(await findByText('View TechDocs')).toBeInTheDocument(); + }); + + it('shows TechDocs link when template has backstage.io/techdocs-entity', async () => { + const mockCatalogApiWithExternal = catalogApiMock({ + entities: [ + { + apiVersion: 'scaffolder.backstage.io/v1beta3', + kind: 'Template', + metadata: { + name: 'tmpl-b', + annotations: { + [TECHDOCS_EXTERNAL_ANNOTATION]: 'component:default/other', + }, + }, + spec: { type: 'service' }, + }, + ], + }); + + const { findByText } = await renderInTestApp( + + + , + mountedRoutes, + ); + + expect(await findByText('View TechDocs')).toBeInTheDocument(); + }); + + it('appends path when backstage.io/techdocs-entity-path is set', async () => { + const mockCatalogApiWithPath = catalogApiMock({ + entities: [ + { + apiVersion: 'scaffolder.backstage.io/v1beta3', + kind: 'Template', + metadata: { + name: 'tmpl-c', + annotations: { + [TECHDOCS_EXTERNAL_ANNOTATION]: 'component:default/other', + [TECHDOCS_EXTERNAL_PATH_ANNOTATION]: '/guides/start', + }, + }, + spec: { type: 'service' }, + }, + ], + }); + + const { findByText } = await renderInTestApp( + + + , + mountedRoutes, + ); + + const link = (await findByText('View TechDocs')).closest('a')!; + expect(link).toHaveAttribute( + 'href', + expect.stringMatching( + /\/docs\/default\/component\/other\/?(index\.html)?#?\/guides\/start|\/docs\/default\/component\/other\/guides\/start/, + ), + ); + }); + }); + it('should render the search bar for templates', async () => { const { getByPlaceholderText } = await renderInTestApp( { const additionalLinksForEntity = useCallback( (template: TemplateEntityV1beta3) => { - const { kind, namespace, name } = parseEntityRef( - stringifyEntityRef(template), - ); - return template.metadata.annotations?.['backstage.io/techdocs-ref'] && - viewTechDocsLink + const hasTechDocs = + !!template.metadata.annotations?.[TECHDOCS_ANNOTATION] || + !!template.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]; + + return hasTechDocs && viewTechDocsLink ? [ { icon: app.getSystemIcon('docs') ?? DocsIcon, text: t( 'templateListPage.additionalLinksForEntity.viewTechDocsTitle', ), - url: viewTechDocsLink({ kind, namespace, name }), + url: buildTechDocsURL(template, viewTechDocsLink), }, ] : []; diff --git a/yarn.lock b/yarn.lock index 5487f23662..aae8a0ef0f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6573,6 +6573,9 @@ __metadata: "@backstage/plugin-permission-react": "workspace:^" "@backstage/plugin-scaffolder-common": "workspace:^" "@backstage/plugin-scaffolder-react": "workspace:^" + "@backstage/plugin-techdocs": "workspace:^" + "@backstage/plugin-techdocs-common": "workspace:^" + "@backstage/plugin-techdocs-react": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" "@codemirror/language": "npm:^6.0.0" From 5b6416938412da34b1459a484d9d6827c92c8800 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Fankh=C3=A4nel?= Date: Wed, 27 Aug 2025 10:28:28 +0200 Subject: [PATCH 017/107] fix(TemplateListPage): fix tsc error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: David Fankhänel --- .../TemplateListPage/TemplateListPage.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.tsx b/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.tsx index 0a7a3df888..37a50ed904 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.tsx @@ -149,18 +149,25 @@ export const TemplateListPage = (props: TemplateListPageProps) => { const additionalLinksForEntity = useCallback( (template: TemplateEntityV1beta3) => { - const hasTechDocs = - !!template.metadata.annotations?.[TECHDOCS_ANNOTATION] || - !!template.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]; + if ( + !( + template.metadata.annotations?.[TECHDOCS_ANNOTATION] || + template.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION] + ) || + !viewTechDocsLink + ) { + return []; + } - return hasTechDocs && viewTechDocsLink + const url = buildTechDocsURL(template, viewTechDocsLink); + return url ? [ { icon: app.getSystemIcon('docs') ?? DocsIcon, text: t( 'templateListPage.additionalLinksForEntity.viewTechDocsTitle', ), - url: buildTechDocsURL(template, viewTechDocsLink), + url, }, ] : []; From f2d3a2c4da1819938e7af9616194090b118f3ebe Mon Sep 17 00:00:00 2001 From: Kurt King Date: Wed, 27 Aug 2025 20:47:59 -0600 Subject: [PATCH 018/107] Change import path for ActionsRegistryService Signed-off-by: Kurt King --- docs/backend-system/core-services/actions-registry.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/backend-system/core-services/actions-registry.md b/docs/backend-system/core-services/actions-registry.md index b1e8fe5f3d..9e493e6bb2 100644 --- a/docs/backend-system/core-services/actions-registry.md +++ b/docs/backend-system/core-services/actions-registry.md @@ -45,7 +45,7 @@ When an action is executed, it receives a context object (`ActionsRegistryAction Here's an example of how to register an action with the Actions Registry Service: ```typescript -import { ActionsRegistryService } from '@backstage/backend-plugin-api'; +import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; export function registerMyActions(actionsRegistry: ActionsRegistryService) { // Register a simple read-only action From 28ea47efe43f5b9176921334aedf25ca836c975b Mon Sep 17 00:00:00 2001 From: Owen Shartle Date: Mon, 1 Sep 2025 20:42:44 -0400 Subject: [PATCH 019/107] Adding 'subpage' as an accepted word for the spell checker. Signed-off-by: Owen Shartle --- .github/vale/config/vocabularies/Backstage/accept.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index ff6ea3aacb..495325a90e 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -459,6 +459,7 @@ subfolders subheader subheaders subkey +subpage subpath subroutes substring From ff40e2297ca4dde38b1a82a96428a1308fb62531 Mon Sep 17 00:00:00 2001 From: Owen Shartle Date: Mon, 1 Sep 2025 21:09:50 -0400 Subject: [PATCH 020/107] Using catalogApiMock from @backstage/plugin-catalog-react/testUtils. Signed-off-by: Owen Shartle --- .../src/test-utils.tsx | 16 ++++++++++------ .../TechDocsReaderPage.test.tsx | 5 ++--- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/plugins/techdocs-addons-test-utils/src/test-utils.tsx b/plugins/techdocs-addons-test-utils/src/test-utils.tsx index 2d7420ec62..a7cb74cb93 100644 --- a/plugins/techdocs-addons-test-utils/src/test-utils.tsx +++ b/plugins/techdocs-addons-test-utils/src/test-utils.tsx @@ -44,6 +44,7 @@ import { entityPresentationApiRef, entityRouteRef, } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { searchApiRef } from '@backstage/plugin-search-react'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; @@ -236,12 +237,15 @@ export class TechDocsAddonTester { }), }; - const catalogApi = { - getEntityByRef: jest.fn().mockResolvedValue({ - kind: 'Component', - metadata: { namespace: 'default', name: 'docs' }, - }), - }; + const catalogApi = catalogApiMock({ + entities: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { namespace: 'default', name: 'docs' }, + }, + ], + }); const apis: TechdocsAddonTesterApis = [ [fetchApiRef, fetchApi], diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx index c645d1db83..aea45a60fa 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx @@ -27,6 +27,7 @@ import { renderInTestApp, TestApiProvider, } from '@backstage/test-utils'; +import { catalogApiMock as catalogApiMockFactory } from '@backstage/plugin-catalog-react/testUtils'; import { techdocsApiRef, techdocsStorageApiRef } from '../../../api'; @@ -96,9 +97,7 @@ const entityPresentationApiMock: jest.Mocked< }), }; -const catalogApiMock = { - getEntityByRef: jest.fn().mockResolvedValue(mockEntityMetadata), -}; +const catalogApiMock = catalogApiMockFactory.mock(); const fetchApiMock = { fetch: jest.fn().mockResolvedValue({ From eb772f5f1886fa46e1bbc2dfa046e48609cc6c29 Mon Sep 17 00:00:00 2001 From: Yannik Daellenbach Date: Tue, 12 Aug 2025 15:03:11 +0200 Subject: [PATCH 021/107] Init `auth-backend-module-openshift-provider` Signed-off-by: Yannik Daellenbach --- packages/backend/package.json | 1 + .../.eslintrc.js | 1 + .../README.md | 5 + .../catalog-info.yaml | 10 + .../config.d.ts | 44 +++ .../dev/index.ts | 26 ++ .../package.json | 55 +++ .../report.api.md | 28 ++ .../src/authenticator.test.ts | 338 ++++++++++++++++++ .../src/authenticator.ts | 184 ++++++++++ .../src/index.ts | 25 ++ .../src/module.ts | 45 +++ .../src/resolvers.ts | 68 ++++ yarn.lock | 98 +++-- 14 files changed, 890 insertions(+), 38 deletions(-) create mode 100644 plugins/auth-backend-module-openshift-provider/.eslintrc.js create mode 100644 plugins/auth-backend-module-openshift-provider/README.md create mode 100644 plugins/auth-backend-module-openshift-provider/catalog-info.yaml create mode 100644 plugins/auth-backend-module-openshift-provider/config.d.ts create mode 100644 plugins/auth-backend-module-openshift-provider/dev/index.ts create mode 100644 plugins/auth-backend-module-openshift-provider/package.json create mode 100644 plugins/auth-backend-module-openshift-provider/report.api.md create mode 100644 plugins/auth-backend-module-openshift-provider/src/authenticator.test.ts create mode 100644 plugins/auth-backend-module-openshift-provider/src/authenticator.ts create mode 100644 plugins/auth-backend-module-openshift-provider/src/index.ts create mode 100644 plugins/auth-backend-module-openshift-provider/src/module.ts create mode 100644 plugins/auth-backend-module-openshift-provider/src/resolvers.ts diff --git a/packages/backend/package.json b/packages/backend/package.json index 48cd018321..6e1e5cb4a7 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -37,6 +37,7 @@ "@backstage/plugin-auth-backend": "workspace:^", "@backstage/plugin-auth-backend-module-github-provider": "workspace:^", "@backstage/plugin-auth-backend-module-guest-provider": "workspace:^", + "@backstage/plugin-auth-backend-module-openshift-provider": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-catalog-backend": "workspace:^", "@backstage/plugin-catalog-backend-module-backstage-openapi": "workspace:^", diff --git a/plugins/auth-backend-module-openshift-provider/.eslintrc.js b/plugins/auth-backend-module-openshift-provider/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/auth-backend-module-openshift-provider/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/auth-backend-module-openshift-provider/README.md b/plugins/auth-backend-module-openshift-provider/README.md new file mode 100644 index 0000000000..ae4700d561 --- /dev/null +++ b/plugins/auth-backend-module-openshift-provider/README.md @@ -0,0 +1,5 @@ +# @backstage/plugin-auth-backend-module-openshift-provider + +The openshift-provider backend module for the auth plugin. + +_This plugin was created through the Backstage CLI_ diff --git a/plugins/auth-backend-module-openshift-provider/catalog-info.yaml b/plugins/auth-backend-module-openshift-provider/catalog-info.yaml new file mode 100644 index 0000000000..3db615244a --- /dev/null +++ b/plugins/auth-backend-module-openshift-provider/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-auth-backend-module-openshift-provider + title: '@backstage/plugin-auth-backend-module-openshift-provider' + description: The OpenShift backend module for the auth plugin. +spec: + lifecycle: experimental + type: backstage-backend-plugin-module + owner: auth-maintainers diff --git a/plugins/auth-backend-module-openshift-provider/config.d.ts b/plugins/auth-backend-module-openshift-provider/config.d.ts new file mode 100644 index 0000000000..1fdaeb1532 --- /dev/null +++ b/plugins/auth-backend-module-openshift-provider/config.d.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2025 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 { HumanDuration } from '@backstage/types'; + +export interface Config { + auth?: { + providers?: { + /** @visibility frontend */ + openshift?: { + [authEnv: string]: { + clientId: string; + /** + * @visibility secret + */ + clientSecret: string; + authorizationUrl: string; + tokenUrl: string; + callbackUrl?: string; + openshiftApiServerUrl: string; + signIn?: { + resolvers: Array<{ + resolver: 'displayNameMatchingUserEntityName'; + dangerouslyAllowSignInWithoutUserInCatalog?: boolean; + }>; + }; + sessionDuration?: HumanDuration | string; + }; + }; + }; + }; +} diff --git a/plugins/auth-backend-module-openshift-provider/dev/index.ts b/plugins/auth-backend-module-openshift-provider/dev/index.ts new file mode 100644 index 0000000000..99f6828292 --- /dev/null +++ b/plugins/auth-backend-module-openshift-provider/dev/index.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2025 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 { createBackend } from '@backstage/backend-defaults'; +import authPlugin from '@backstage/plugin-auth-backend'; +import authModuleOpenShiftProvider from '../src'; + +const backend = createBackend(); + +backend.add(authPlugin); +backend.add(authModuleOpenShiftProvider); + +backend.start(); diff --git a/plugins/auth-backend-module-openshift-provider/package.json b/plugins/auth-backend-module-openshift-provider/package.json new file mode 100644 index 0000000000..9fad0b542e --- /dev/null +++ b/plugins/auth-backend-module-openshift-provider/package.json @@ -0,0 +1,55 @@ +{ + "name": "@backstage/plugin-auth-backend-module-openshift-provider", + "version": "0.0.0", + "description": "The OpenShift backend module for the auth plugin.", + "backstage": { + "role": "backend-plugin-module", + "pluginId": "auth", + "pluginPackage": "@backstage/plugin-auth-backend" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/auth-backend-module-openshift-provider" + }, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist", + "config.d.ts" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "start": "backstage-cli package start", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/backend-plugin-api": "workspace:^", + "@backstage/catalog-model": "workspace:^", + "@backstage/plugin-auth-node": "workspace:^", + "@backstage/types": "workspace:^", + "passport-oauth2": "^1.8.0", + "zod": "^3.24.2" + }, + "devDependencies": { + "@backstage/backend-defaults": "workspace:^", + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^", + "@backstage/config": "workspace:^", + "@backstage/plugin-auth-backend": "workspace:^", + "express": "^4.18.2", + "msw": "^2.7.3", + "supertest": "^7.1.0" + }, + "configSchema": "config.d.ts" +} diff --git a/plugins/auth-backend-module-openshift-provider/report.api.md b/plugins/auth-backend-module-openshift-provider/report.api.md new file mode 100644 index 0000000000..aa429e1d47 --- /dev/null +++ b/plugins/auth-backend-module-openshift-provider/report.api.md @@ -0,0 +1,28 @@ +## API Report File for "@backstage/plugin-auth-backend-module-openshift-provider" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; +import { OAuthAuthenticator } from '@backstage/plugin-auth-node'; +import { PassportOAuthAuthenticatorHelper } from '@backstage/plugin-auth-node'; +import { PassportProfile } from '@backstage/plugin-auth-node'; + +// @public (undocumented) +const authModuleOpenshiftProvider: BackendFeature; +export default authModuleOpenshiftProvider; + +// @public (undocumented) +export const openshiftAuthenticator: OAuthAuthenticator< + OpenShiftAuthenticatorContext, + PassportProfile +>; + +// @public (undocumented) +export interface OpenShiftAuthenticatorContext { + // (undocumented) + helper: PassportOAuthAuthenticatorHelper; + // (undocumented) + openshiftApiServerUrl: string; +} +``` diff --git a/plugins/auth-backend-module-openshift-provider/src/authenticator.test.ts b/plugins/auth-backend-module-openshift-provider/src/authenticator.test.ts new file mode 100644 index 0000000000..a5875ed35c --- /dev/null +++ b/plugins/auth-backend-module-openshift-provider/src/authenticator.test.ts @@ -0,0 +1,338 @@ +/* + * Copyright 2025 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 { setupServer } from 'msw/node'; +import { + decodeOAuthState, + encodeOAuthState, +} from '@backstage/plugin-auth-node'; +import { registerMswTestHooks } from '@backstage/backend-test-utils'; +import { http, HttpResponse } from 'msw'; +import { openshiftAuthenticator } from './authenticator'; +import { ConfigReader } from '@backstage/config'; +import { + OAuthState, + OAuthAuthenticatorStartInput, + OAuthAuthenticatorAuthenticateInput, +} from '@backstage/plugin-auth-node'; +import express from 'express'; + +describe('openshiftAuthenticator', () => { + let implementation: any; + let oauthState: OAuthState; + + const mswServer = setupServer(); + registerMswTestHooks(mswServer); + + beforeEach(() => { + mswServer.use( + http.post('https://openshift.test/oauth/token', () => { + return HttpResponse.json({ + access_token: 'accessToken', + scope: 'user:full', + expires_in: 60 * 60 * 24, + }); + }), + http.get( + 'https://api.openshift.test/apis/user.openshift.io/v1/users/~', + async () => { + return HttpResponse.json({ + kind: 'User', + apiVersion: 'user.openshift.io/v1', + metadata: { + name: 'alice', + uid: 'ca993628-8817-4a3b-9811-be4a34c60bf4', + resourceVersion: '1', + creationTimestamp: '2022-01-11T13:10:45Z', + managedFields: [], + }, + fullName: 'Alice Adams', + identities: ['SSO:id'], + groups: ['system:authenticated', 'system:authenticated:oauth'], + }); + }, + ), + http.delete( + 'https://api.openshift.test/apis/oauth.openshift.io/v1/oauthaccesstokens/:id', + ({ params }) => { + const { id } = params; + + if (typeof id !== 'string') { + return new Response(null, { status: 401 }); + } + + if (!id.startsWith('sha256~')) { + return new Response(null, { status: 401 }); + } + + return new Response(null, { status: 200 }); + }, + ), + ); + + implementation = openshiftAuthenticator.initialize({ + callbackUrl: 'https://backstage.test/callback', + config: new ConfigReader({ + clientId: 'clientId', + clientSecret: 'clientSecret', + authorizationUrl: 'https://openshift.test/oauth/authorize', + tokenUrl: 'https://openshift.test/oauth/token', + openshiftApiServerUrl: 'https://api.openshift.test', + }), + }); + + oauthState = { + nonce: 'nonce', + env: 'env', + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('#start', () => { + let fakeSession: Record; + let startRequest: OAuthAuthenticatorStartInput; + + beforeEach(() => { + fakeSession = {}; + startRequest = { + state: encodeOAuthState(oauthState), + req: { + method: 'GET', + url: 'test', + session: fakeSession, + }, + } as unknown as OAuthAuthenticatorStartInput; + }); + + it('initiates authorization code grant', async () => { + const startResponse = await openshiftAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + + expect(searchParams.get('response_type')).toBe('code'); + }); + + it('passes client ID from config', async () => { + const startResponse = await openshiftAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + + expect(searchParams.get('client_id')).toBe('clientId'); + }); + + it('passes callback URL from config', async () => { + const startResponse = await openshiftAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + + expect(searchParams.get('redirect_uri')).toBe( + 'https://backstage.test/callback', + ); + }); + + it('encodes OAuth state in query param', async () => { + const startResponse = await openshiftAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + const stateParam = searchParams.get('state'); + const decodedState = decodeOAuthState(stateParam!); + + expect(decodedState).toMatchObject(oauthState); + }); + }); + + describe('#authenticate', () => { + let handlerRequest: OAuthAuthenticatorAuthenticateInput; + + beforeEach(() => { + handlerRequest = { + req: { + method: 'GET', + query: { + code: 'authorization_code', + state: encodeOAuthState(oauthState), + }, + session: { + 'oauth2:openshift': { + state: encodeOAuthState(oauthState), + }, + }, + } as unknown as express.Request, + }; + }); + + it('exchanges authorization code for access token', async () => { + const authenticatorResult = await openshiftAuthenticator.authenticate( + handlerRequest, + implementation, + ); + const accessToken = authenticatorResult.session.accessToken; + + expect(accessToken).toEqual('accessToken'); + }); + + it('returns granted scope', async () => { + const authenticatorResult = await openshiftAuthenticator.authenticate( + handlerRequest, + implementation, + ); + const responseScope = authenticatorResult.session.scope; + + expect(responseScope).toEqual('user:full'); + }); + + it('returns a default session.tokentype field', async () => { + const authenticatorResult = await openshiftAuthenticator.authenticate( + handlerRequest, + implementation, + ); + const tokenType = authenticatorResult.session.tokenType; + + expect(tokenType).toEqual('bearer'); + }); + + it('returns displayName', async () => { + const authenticatorResult = await openshiftAuthenticator.authenticate( + handlerRequest, + implementation, + ); + + expect(authenticatorResult).toMatchObject({ + fullProfile: { + displayName: 'alice', + }, + }); + }); + + it('should store access token as refresh token', async () => { + const authenticatorResult = await openshiftAuthenticator.authenticate( + handlerRequest, + implementation, + ); + + expect(authenticatorResult.session.refreshToken).toBe( + authenticatorResult.session.accessToken, + ); + }); + }); + + describe('#refresh', () => { + it('gets new refresh token (access token)', async () => { + const refreshResponse = await openshiftAuthenticator.refresh( + { + scope: 'user:full', + refreshToken: 'access-token', + req: {} as express.Request, + }, + implementation, + ); + + expect(refreshResponse.session.refreshToken).toBe('access-token'); + }); + + it('should throw error when invalid access token was provided', async () => { + mswServer.use( + http.get( + 'https://api.openshift.test/apis/user.openshift.io/v1/users/~', + async () => { + return HttpResponse.json( + { + kind: 'Status', + apiVersion: 'v1', + metadata: {}, + status: 'Failure', + message: 'Unauthorized', + reason: 'Unauthorized', + code: 401, + }, + { + status: 401, + }, + ); + }, + ), + ); + + await expect( + openshiftAuthenticator.refresh( + { + scope: 'user:full', + refreshToken: 'invalid-access-token', + req: {} as express.Request, + }, + implementation, + ), + ).rejects.toThrow('HTTP error! Status: 401'); + }); + }); + + describe('#logout', () => { + it('should delete valid access token', async () => { + await expect( + openshiftAuthenticator.logout?.( + { + refreshToken: 'access-token', + req: {} as express.Request, + }, + implementation, + ), + ).resolves.not.toThrow(); + }); + + it('should throw when refresh token is not set', async () => { + await expect( + openshiftAuthenticator.logout?.( + { + req: {} as express.Request, + }, + implementation, + ), + ).rejects.toThrow(); + }); + + it('should throw when access cannot be deleted', async () => { + mswServer.use( + http.delete( + 'https://api.openshift.test/apis/oauth.openshift.io/v1/oauthaccesstokens/:id', + () => { + return new Response(null, { status: 401 }); + }, + ), + ); + + await expect( + openshiftAuthenticator.logout?.( + { + refreshToken: 'access-token', + req: {} as express.Request, + }, + implementation, + ), + ).rejects.toThrow(); + }); + }); +}); diff --git a/plugins/auth-backend-module-openshift-provider/src/authenticator.ts b/plugins/auth-backend-module-openshift-provider/src/authenticator.ts new file mode 100644 index 0000000000..0c9acb6287 --- /dev/null +++ b/plugins/auth-backend-module-openshift-provider/src/authenticator.ts @@ -0,0 +1,184 @@ +/* + * Copyright 2025 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 { + createOAuthAuthenticator, + PassportOAuthAuthenticatorHelper, + PassportOAuthDoneCallback, + PassportProfile, +} from '@backstage/plugin-auth-node'; +import { createHash } from 'node:crypto'; +import OAuth2Strategy from 'passport-oauth2'; +import { z } from 'zod'; + +/** @public */ +export interface OpenShiftAuthenticatorContext { + openshiftApiServerUrl: string; + helper: PassportOAuthAuthenticatorHelper; +} + +/** @private + * Schema for user.openshift.io/v1, + * see https://docs.redhat.com/en/documentation/openshift_container_platform/latest/html/user_and_group_apis/user-user-openshift-io-v1#user-user-openshift-io-v1 + */ +const OpenShiftUser = z.object({ + metadata: z.object({ + name: z.string(), + }), +}); + +/** @public */ +export const openshiftAuthenticator = createOAuthAuthenticator< + OpenShiftAuthenticatorContext, + PassportProfile +>({ + defaultProfileTransform: + PassportOAuthAuthenticatorHelper.defaultProfileTransform, + scopes: { + required: ['user:full'], + }, + initialize({ callbackUrl, config }) { + const clientId = config.getString('clientId'); + const clientSecret = config.getString('clientSecret'); + const authorizationUrl = config.getString('authorizationUrl'); + const tokenUrl = config.getString('tokenUrl'); + const openshiftApiServerUrl = config.getString('openshiftApiServerUrl'); + + // userUrl: `${openshiftApiServerUrl}/apis/user.openshift.io/v1/users/~`, + const strategy = new OAuth2Strategy( + { + clientID: clientId, + clientSecret: clientSecret, + callbackURL: callbackUrl, + authorizationURL: authorizationUrl, + tokenURL: tokenUrl, + passReqToCallback: false, + }, + ( + accessToken: any, + refreshToken: string, + params: any, + fullProfile: PassportProfile, + done: PassportOAuthDoneCallback, + ) => { + done(undefined, { fullProfile, params, accessToken }, { refreshToken }); + }, + ); + + strategy.userProfile = function userProfile( + accessToken: string, + done: (err?: unknown, profile?: any) => void, + ): void { + this._oauth2.useAuthorizationHeaderforGET(true); + + this._oauth2.get( + `${openshiftApiServerUrl}/apis/user.openshift.io/v1/users/~`, + accessToken, + (error, data, _) => { + if (error !== null && error.statusCode !== 200) { + done(new Error(`HTTP error! Status: ${error.statusCode}`)); + return; + } + + if (!data) { + done(new Error('No data provided!')); + return; + } + + if (typeof data !== 'string') { + done(new Error('Data of type Buffer is not supported!')); + return; + } + + const user = OpenShiftUser.parse(JSON.parse(data)); + done(null, { displayName: user.metadata.name }); + }, + ); + }; + + return { + openshiftApiServerUrl, + helper: PassportOAuthAuthenticatorHelper.from(strategy), + }; + }, + async start(input, { helper }) { + return helper.start(input, { + accessType: 'offline', + prompt: 'consent', + }); + }, + async authenticate(input, { helper }) { + // Same workaround as the GitHub provider; see https://github.com/backstage/backstage/issues/25383 + const { fullProfile, session } = await helper.authenticate(input); + session.refreshToken = session.accessToken; + session.refreshTokenExpiresInSeconds = session.expiresInSeconds; + return { fullProfile, session }; + }, + async refresh(input, { helper }) { + // Because the session is refreshed on login, this override is crucial, + // see https://github.com/backstage/backstage/issues/25383 + const accessToken = input.refreshToken; + + const fullProfile = await helper.fetchProfile(accessToken).catch(error => { + if (error.oauthError?.statusCode === 401) { + throw new Error('Invalid access token'); + } + throw error; + }); + + return { + fullProfile, + session: { + accessToken, + tokenType: 'bearer', + scope: input.scope, + refreshToken: input.refreshToken, + }, + }; + }, + async logout(input, { openshiftApiServerUrl, helper }) { + // Due to the implementation of createOAuthRouteHandlers, only the refresh token is set. + // In this provider, the refresh token actually IS the access token. + const accessToken = input.refreshToken; + if (!accessToken) { + throw new Error('access token/refresh token needs to be set for logout'); + } + + // Check if access token is still valid. + try { + await helper.fetchProfile(accessToken); + } catch { + // Invalid token, no need to delete OAuthAccessToken. + return; + } + + // Calculate token name, see: + // https://docs.redhat.com/en/documentation/openshift_container_platform/latest/html/oauth_apis/oauthaccesstoken-oauth-openshift-io-v1#apis-oauth-openshift-io-v1-oauthaccesstokens + const tokenName = createHash('sha256') + .update(accessToken.slice('sha256~'.length)) + .digest() + .toString('base64url'); + + const response = await fetch( + `${openshiftApiServerUrl}/apis/oauth.openshift.io/v1/oauthaccesstokens/sha256~${tokenName}`, + { method: 'DELETE', headers: { Authorization: `Bearer ${accessToken}` } }, + ); + + if (response.status === 401) { + throw new Error('unauthorized'); + } + }, +}); diff --git a/plugins/auth-backend-module-openshift-provider/src/index.ts b/plugins/auth-backend-module-openshift-provider/src/index.ts new file mode 100644 index 0000000000..4c8dd6cb22 --- /dev/null +++ b/plugins/auth-backend-module-openshift-provider/src/index.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2025 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. + */ +/** + * The openshift-provider backend module for the auth plugin. + * + * @packageDocumentation + */ +export { + openshiftAuthenticator, + type OpenShiftAuthenticatorContext, +} from './authenticator'; +export { authModuleOpenshiftProvider as default } from './module'; diff --git a/plugins/auth-backend-module-openshift-provider/src/module.ts b/plugins/auth-backend-module-openshift-provider/src/module.ts new file mode 100644 index 0000000000..18d96c4778 --- /dev/null +++ b/plugins/auth-backend-module-openshift-provider/src/module.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2025 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 { createBackendModule } from '@backstage/backend-plugin-api'; +import { + authProvidersExtensionPoint, + createOAuthProviderFactory, +} from '@backstage/plugin-auth-node'; +import { openshiftAuthenticator } from './authenticator'; +import { openshiftSignInResolvers } from './resolvers'; + +/** @public */ +export const authModuleOpenshiftProvider = createBackendModule({ + pluginId: 'auth', + moduleId: 'openshift-provider', + register(reg) { + reg.registerInit({ + deps: { providers: authProvidersExtensionPoint }, + async init({ providers }) { + providers.registerProvider({ + providerId: 'openshift', + factory: createOAuthProviderFactory({ + authenticator: openshiftAuthenticator, + signInResolverFactories: { + ...openshiftSignInResolvers, + }, + }), + }); + }, + }); + }, +}); diff --git a/plugins/auth-backend-module-openshift-provider/src/resolvers.ts b/plugins/auth-backend-module-openshift-provider/src/resolvers.ts new file mode 100644 index 0000000000..dee55ec4ca --- /dev/null +++ b/plugins/auth-backend-module-openshift-provider/src/resolvers.ts @@ -0,0 +1,68 @@ +/* + * Copyright 2025 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 { + createSignInResolverFactory, + OAuthAuthenticatorResult, + PassportProfile, + SignInInfo, +} from '@backstage/plugin-auth-node'; + +import { + DEFAULT_NAMESPACE, + stringifyEntityRef, +} from '@backstage/catalog-model'; +import { z } from 'zod'; + +export namespace openshiftSignInResolvers { + export const displayNameMatchingUserEntityName = createSignInResolverFactory({ + optionsSchema: z + .object({ + dangerouslyAllowSignInWithoutUserInCatalog: z.boolean().optional(), + }) + .optional(), + create(options = {}) { + return async ( + info: SignInInfo>, + ctx, + ) => { + const { displayName } = info.profile; + + if (!displayName) { + throw new Error( + `OpenShift user profile does not contain a displayName`, + ); + } + + const userRef = stringifyEntityRef({ + kind: 'User', + name: displayName, + namespace: DEFAULT_NAMESPACE, + }); + + return await ctx.signInWithCatalogUser( + { entityRef: userRef }, + { + dangerousEntityRefFallback: + options?.dangerouslyAllowSignInWithoutUserInCatalog + ? { entityRef: { name: displayName } } + : undefined, + }, + ); + }; + }, + }); +} diff --git a/yarn.lock b/yarn.lock index 853749b99e..5642a3bf10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4120,6 +4120,27 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-auth-backend-module-openshift-provider@workspace:^, @backstage/plugin-auth-backend-module-openshift-provider@workspace:plugins/auth-backend-module-openshift-provider": + version: 0.0.0-use.local + resolution: "@backstage/plugin-auth-backend-module-openshift-provider@workspace:plugins/auth-backend-module-openshift-provider" + dependencies: + "@backstage/backend-defaults": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" + "@backstage/catalog-model": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/plugin-auth-backend": "workspace:^" + "@backstage/plugin-auth-node": "workspace:^" + "@backstage/types": "workspace:^" + express: "npm:^4.18.2" + msw: "npm:^2.7.3" + passport-oauth2: "npm:^1.8.0" + supertest: "npm:^7.1.0" + zod: "npm:^3.24.2" + languageName: unknown + linkType: soft + "@backstage/plugin-auth-backend-module-pinniped-provider@workspace:plugins/auth-backend-module-pinniped-provider": version: 0.0.0-use.local resolution: "@backstage/plugin-auth-backend-module-pinniped-provider@workspace:plugins/auth-backend-module-pinniped-provider" @@ -11081,9 +11102,9 @@ __metadata: languageName: node linkType: hard -"@mswjs/interceptors@npm:^0.39.1": - version: 0.39.2 - resolution: "@mswjs/interceptors@npm:0.39.2" +"@mswjs/interceptors@npm:^0.37.0": + version: 0.37.1 + resolution: "@mswjs/interceptors@npm:0.37.1" dependencies: "@open-draft/deferred-promise": "npm:^2.2.0" "@open-draft/logger": "npm:^0.3.0" @@ -11091,7 +11112,7 @@ __metadata: is-node-process: "npm:^1.2.0" outvariant: "npm:^1.4.3" strict-event-emitter: "npm:^0.5.1" - checksum: 10/faaa95d636363a197f125c32066457fa74d5063d8ccae4c9c0e0510179060d92b1faf8640df45a0623e0bf42a30d610c83364a58e0eb0ca412c87b2e835936c1 + checksum: 10/332d8aa50beb4834ccbda6a800ca00b1204adc0eba23e1c1f7bb9f4e564a92707e563f7a2424d4a8607404ec91424e5d8c34a87c250b191ca7b24dff12eba2c5 languageName: node linkType: hard @@ -26393,10 +26414,10 @@ __metadata: languageName: node linkType: hard -"component-emitter@npm:^1.3.1": - version: 1.3.1 - resolution: "component-emitter@npm:1.3.1" - checksum: 10/94550aa462c7bd5a61c1bc480e28554aa306066930152d1b1844a0dd3845d4e5db7e261ddec62ae184913b3e59b55a2ad84093b9d3596a8f17c341514d6c483d +"component-emitter@npm:^1.3.0": + version: 1.3.0 + resolution: "component-emitter@npm:1.3.0" + checksum: 10/dfc1ec2e7aa2486346c068f8d764e3eefe2e1ca0b24f57506cd93b2ae3d67829a7ebd7cc16e2bf51368fac2f45f78fcff231718e40b1975647e4a86be65e1d05 languageName: node linkType: hard @@ -27620,15 +27641,15 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.3.6, debug@npm:^4.3.7, debug@npm:^4.4.0": - version: 4.4.1 - resolution: "debug@npm:4.4.1" +"debug@npm:4, debug@npm:^4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.3.6, debug@npm:^4.4.0": + version: 4.4.0 + resolution: "debug@npm:4.4.0" dependencies: ms: "npm:^2.1.3" peerDependenciesMeta: supports-color: optional: true - checksum: 10/8e2709b2144f03c7950f8804d01ccb3786373df01e406a0f66928e47001cf2d336cbed9ee137261d4f90d68d8679468c755e3548ed83ddacdc82b194d2468afe + checksum: 10/1847944c2e3c2c732514b93d11886575625686056cd765336212dc15de2d2b29612b6cd80e1afba767bb8e1803b778caf9973e98169ef1a24a7a7009e1820367 languageName: node linkType: hard @@ -30049,6 +30070,7 @@ __metadata: "@backstage/plugin-auth-backend": "workspace:^" "@backstage/plugin-auth-backend-module-github-provider": "workspace:^" "@backstage/plugin-auth-backend-module-guest-provider": "workspace:^" + "@backstage/plugin-auth-backend-module-openshift-provider": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-catalog-backend": "workspace:^" "@backstage/plugin-catalog-backend-module-backstage-openapi": "workspace:^" @@ -31103,7 +31125,7 @@ __metadata: languageName: node linkType: hard -"formidable@npm:^3.5.4": +"formidable@npm:^3.5.1": version: 3.5.4 resolution: "formidable@npm:3.5.4" dependencies: @@ -38633,15 +38655,15 @@ __metadata: languageName: node linkType: hard -"msw@npm:^2.0.0, msw@npm:^2.0.8": - version: 2.10.4 - resolution: "msw@npm:2.10.4" +"msw@npm:^2.0.0, msw@npm:^2.0.8, msw@npm:^2.7.3": + version: 2.7.3 + resolution: "msw@npm:2.7.3" dependencies: "@bundled-es-modules/cookie": "npm:^2.0.1" "@bundled-es-modules/statuses": "npm:^1.0.1" "@bundled-es-modules/tough-cookie": "npm:^0.1.6" "@inquirer/confirm": "npm:^5.0.0" - "@mswjs/interceptors": "npm:^0.39.1" + "@mswjs/interceptors": "npm:^0.37.0" "@open-draft/deferred-promise": "npm:^2.2.0" "@open-draft/until": "npm:^2.1.0" "@types/cookie": "npm:^0.6.0" @@ -38662,7 +38684,7 @@ __metadata: optional: true bin: msw: cli/index.js - checksum: 10/e2f25dda1aba66c7444c29c41d3157cb15c0332055ab7ebfb74ef4b506e7b90098cf37c577768edb5b2b2dbf0d6ed6a7a3ca8ee6da3d72df5a25823d82f33316 + checksum: 10/f193329a68fc22e477a6f8504aa44a92bd12847f2eeac1dfbd8ec1cc43ff293112ec067de1c7fe312ba02beecb313fb00aeeebf5817432b57af2d796b2dff2fa languageName: node linkType: hard @@ -40612,7 +40634,7 @@ __metadata: languageName: node linkType: hard -"passport-oauth2@npm:1.8.0, passport-oauth2@npm:1.x.x, passport-oauth2@npm:^1.1.2, passport-oauth2@npm:^1.4.0, passport-oauth2@npm:^1.6.0, passport-oauth2@npm:^1.6.1, passport-oauth2@npm:^1.7.0": +"passport-oauth2@npm:1.8.0, passport-oauth2@npm:1.x.x, passport-oauth2@npm:^1.1.2, passport-oauth2@npm:^1.4.0, passport-oauth2@npm:^1.6.0, passport-oauth2@npm:^1.6.1, passport-oauth2@npm:^1.7.0, passport-oauth2@npm:^1.8.0": version: 1.8.0 resolution: "passport-oauth2@npm:1.8.0" dependencies: @@ -42268,7 +42290,7 @@ __metadata: languageName: node linkType: hard -"qs@npm:^6.10.1, qs@npm:^6.10.3, qs@npm:^6.11.2, qs@npm:^6.12.2, qs@npm:^6.12.3, qs@npm:^6.14.0, qs@npm:^6.7.0, qs@npm:^6.9.4": +"qs@npm:^6.10.1, qs@npm:^6.10.3, qs@npm:^6.11.0, qs@npm:^6.11.2, qs@npm:^6.12.2, qs@npm:^6.12.3, qs@npm:^6.14.0, qs@npm:^6.7.0, qs@npm:^6.9.4": version: 6.14.0 resolution: "qs@npm:6.14.0" dependencies: @@ -46507,30 +46529,30 @@ __metadata: languageName: node linkType: hard -"superagent@npm:^10.2.3": - version: 10.2.3 - resolution: "superagent@npm:10.2.3" +"superagent@npm:^9.0.1": + version: 9.0.2 + resolution: "superagent@npm:9.0.2" dependencies: - component-emitter: "npm:^1.3.1" + component-emitter: "npm:^1.3.0" cookiejar: "npm:^2.1.4" - debug: "npm:^4.3.7" + debug: "npm:^4.3.4" fast-safe-stringify: "npm:^2.1.1" - form-data: "npm:^4.0.4" - formidable: "npm:^3.5.4" + form-data: "npm:^4.0.0" + formidable: "npm:^3.5.1" methods: "npm:^1.1.2" mime: "npm:2.6.0" - qs: "npm:^6.11.2" - checksum: 10/377bf938e68927dd772169c5285be27872bf6e84fac01c52bcd9396bc5b348c9ded8f8be54649510ec09a67bc5096055847b37cb01b3bca0eb06ff1856170e35 + qs: "npm:^6.11.0" + checksum: 10/d3c0c9051ceec84d5b431eaa410ad81bcd53255cea57af1fc66d683a24c34f3ba4761b411072a9bf489a70e3d5b586a78a0e6f2eac6a561067e7d196ddab0907 languageName: node linkType: hard -"supertest@npm:^7.0.0": - version: 7.1.4 - resolution: "supertest@npm:7.1.4" +"supertest@npm:^7.0.0, supertest@npm:^7.1.0": + version: 7.1.0 + resolution: "supertest@npm:7.1.0" dependencies: methods: "npm:^1.1.2" - superagent: "npm:^10.2.3" - checksum: 10/ecb5d41f2b62b257dbdcabac245c32b8e8fb264fe2636dd85c2c883569d23dc14adc0a471abb84187cbdb49bc36ad870ad355b4a0b85973f510fd57fc229e6cc + superagent: "npm:^9.0.1" + checksum: 10/20069f739a44821dfa4f7f397b9086ef31a358366331138f97945eedb2e231796e7c55b032125d3bd12f9839f089fbb809893dbc0f98edc57e12333b9f42b726 languageName: node linkType: hard @@ -50029,10 +50051,10 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.22.4, zod@npm:^3.23.8": - version: 3.25.76 - resolution: "zod@npm:3.25.76" - checksum: 10/f0c963ec40cd96858451d1690404d603d36507c1fc9682f2dae59ab38b578687d542708a7fdbf645f77926f78c9ed558f57c3d3aa226c285f798df0c4da16995 +"zod@npm:^3.22.4, zod@npm:^3.23.8, zod@npm:^3.24.2": + version: 3.25.67 + resolution: "zod@npm:3.25.67" + checksum: 10/0e35432dcca7f053e63f5dd491a87c78abe0d981817547252c3b6d05f0f58788695d1a69724759c6501dff3fd62929be24c9f314a3625179bee889150f7a61fa languageName: node linkType: hard From 909a5cc65aa33b35d654c38331896ce6e5aff199 Mon Sep 17 00:00:00 2001 From: Yannik Daellenbach Date: Sat, 12 Apr 2025 11:55:36 +0200 Subject: [PATCH 022/107] Add `openshiftAuthApiRef` and `OpenShiftAuth` to core API Signed-off-by: Yannik Daellenbach --- packages/core-app-api/report.api.md | 7 +++ .../src/apis/implementations/auth/index.ts | 1 + .../auth/openshift/OpenShiftAuth.ts | 52 +++++++++++++++++++ .../implementations/auth/openshift/index.ts | 17 ++++++ packages/core-plugin-api/report.api.md | 5 ++ .../src/apis/definitions/auth.ts | 17 ++++++ 6 files changed, 99 insertions(+) create mode 100644 packages/core-app-api/src/apis/implementations/auth/openshift/OpenShiftAuth.ts create mode 100644 packages/core-app-api/src/apis/implementations/auth/openshift/index.ts diff --git a/packages/core-app-api/report.api.md b/packages/core-app-api/report.api.md index 445d73392d..0cecc22f4a 100644 --- a/packages/core-app-api/report.api.md +++ b/packages/core-app-api/report.api.md @@ -52,6 +52,7 @@ import { Observable } from '@backstage/types'; import { oktaAuthApiRef } from '@backstage/core-plugin-api'; import { oneloginAuthApiRef } from '@backstage/core-plugin-api'; import { OpenIdConnectApi } from '@backstage/core-plugin-api'; +import { openshiftAuthApiRef } from '@backstage/core-plugin-api'; import { PendingOAuthRequest } from '@backstage/core-plugin-api'; import { ProfileInfo } from '@backstage/core-plugin-api'; import { ProfileInfoApi } from '@backstage/core-plugin-api'; @@ -651,6 +652,12 @@ export type OpenLoginPopupOptions = { height?: number; }; +// @public +export class OpenShiftAuth { + // (undocumented) + static create(options: OAuthApiCreateOptions): typeof openshiftAuthApiRef.T; +} + // @public export type PopupOptions = { size?: diff --git a/packages/core-app-api/src/apis/implementations/auth/index.ts b/packages/core-app-api/src/apis/implementations/auth/index.ts index e02e07961a..00effb9384 100644 --- a/packages/core-app-api/src/apis/implementations/auth/index.ts +++ b/packages/core-app-api/src/apis/implementations/auth/index.ts @@ -26,4 +26,5 @@ export * from './bitbucket'; export * from './bitbucketServer'; export * from './atlassian'; export * from './vmwareCloud'; +export * from './openshift'; export type { OAuthApiCreateOptions, AuthApiCreateOptions } from './types'; diff --git a/packages/core-app-api/src/apis/implementations/auth/openshift/OpenShiftAuth.ts b/packages/core-app-api/src/apis/implementations/auth/openshift/OpenShiftAuth.ts new file mode 100644 index 0000000000..1d820189d2 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/openshift/OpenShiftAuth.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2025 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 { openshiftAuthApiRef } from '@backstage/core-plugin-api'; +import { OAuth2 } from '../oauth2'; +import { OAuthApiCreateOptions } from '../types'; + +const DEFAULT_PROVIDER = { + id: 'openshift', + title: 'OpenShift', + icon: () => null, +}; + +/** + * Implements the OAuth flow to OpenShift + * + * @public + */ +export default class OpenShiftAuth { + static create(options: OAuthApiCreateOptions): typeof openshiftAuthApiRef.T { + const { + configApi, + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + defaultScopes = ['user:info'], + } = options; + + return OAuth2.create({ + configApi, + discoveryApi, + oauthRequestApi, + provider, + environment, + defaultScopes, + }); + } +} diff --git a/packages/core-app-api/src/apis/implementations/auth/openshift/index.ts b/packages/core-app-api/src/apis/implementations/auth/openshift/index.ts new file mode 100644 index 0000000000..65452114ae --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/openshift/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2025 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 { default as OpenShiftAuth } from './OpenShiftAuth'; diff --git a/packages/core-plugin-api/report.api.md b/packages/core-plugin-api/report.api.md index 54bbfb94cf..b40088dc78 100644 --- a/packages/core-plugin-api/report.api.md +++ b/packages/core-plugin-api/report.api.md @@ -604,6 +604,11 @@ export type OpenIdConnectApi = { getIdToken(options?: AuthRequestOptions): Promise; }; +// @public +export const openshiftAuthApiRef: ApiRef< + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi +>; + // @public @deprecated export type OptionalParams< Params extends { diff --git a/packages/core-plugin-api/src/apis/definitions/auth.ts b/packages/core-plugin-api/src/apis/definitions/auth.ts index 9339a3e18a..f8686cb128 100644 --- a/packages/core-plugin-api/src/apis/definitions/auth.ts +++ b/packages/core-plugin-api/src/apis/definitions/auth.ts @@ -474,3 +474,20 @@ export const vmwareCloudAuthApiRef: ApiRef< > = createApiRef({ id: 'core.auth.vmware-cloud', }); + +/** + * Provides authentication towards OpenShift APIs and identities. + * + * @public + * @remarks + * + * See {@link https://docs.redhat.com/en/documentation/openshift_container_platform/latest/html/authentication_and_authorization/configuring-oauth-clients} + * on how to configure the OAuth clients and + * {@link https://docs.redhat.com/en/documentation/openshift_container_platform/latest/html-single/authentication_and_authorization/index#tokens-scoping-about_configuring-internal-oauth} + * for available scopes. + */ +export const openshiftAuthApiRef: ApiRef< + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi +> = createApiRef({ + id: 'core.auth.openshift', +}); From ac720abcf802e33c7a09219536672999705513c9 Mon Sep 17 00:00:00 2001 From: Yannik Daellenbach Date: Sat, 12 Apr 2025 12:01:26 +0200 Subject: [PATCH 023/107] Add OpenShift authenticator to the default user-settings providers page Signed-off-by: Yannik Daellenbach --- .../components/AuthProviders/DefaultProviderSettings.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx b/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx index b0ddbf31cf..ce9c1093ca 100644 --- a/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx +++ b/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx @@ -26,6 +26,7 @@ import { bitbucketServerAuthApiRef, atlassianAuthApiRef, oneloginAuthApiRef, + openshiftAuthApiRef, } from '@backstage/core-plugin-api'; import { userSettingsTranslationRef } from '../../translation'; import { useTranslationRef } from '@backstage/frontend-plugin-api'; @@ -128,6 +129,14 @@ export const DefaultProviderSettings = (props: { icon={Star} /> )} + {configuredProviders.includes('openshift') && ( + + )} ); }; From f80b2f765a37dd20683cd85138b4aa118966bb14 Mon Sep 17 00:00:00 2001 From: Christoph Raaflaub Date: Tue, 8 Apr 2025 09:31:18 +0200 Subject: [PATCH 024/107] Add openshift-provider backend module documentation Signed-off-by: Christoph Raaflaub --- docs/auth/index.md | 1 + docs/auth/openshift/provider.md | 77 +++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 docs/auth/openshift/provider.md diff --git a/docs/auth/index.md b/docs/auth/index.md index 9f4c229e7f..bff1f3df26 100644 --- a/docs/auth/index.md +++ b/docs/auth/index.md @@ -34,6 +34,7 @@ Backstage comes with many common authentication providers in the core library: - [Okta](okta/provider.md) - [OAuth 2 Custom Proxy](oauth2-proxy/provider.md) - [OneLogin](onelogin/provider.md) +- [OpenShift](openshift/provider.md) - [VMware Cloud](vmware-cloud/provider.md) These built-in providers handle the authentication flow for a particular service, including required scopes, callbacks, etc. These providers are each added to a diff --git a/docs/auth/openshift/provider.md b/docs/auth/openshift/provider.md new file mode 100644 index 0000000000..f208aaca81 --- /dev/null +++ b/docs/auth/openshift/provider.md @@ -0,0 +1,77 @@ +--- +id: provider +title: OpenShift Authentication Provider +sidebar_label: OpenShift +description: Adding OpenShift OAuth as an authentication provider in Backstage +--- + +The Backstage `core-plugin-api` package comes with a OpenShift authentication +provider that can authenticate users using OpenShift OAuth. + +## Use Case + +This setup enables the Kubernetes integration to use the users rights to access the OpenShift clusters (OAuth 2.0 On-Behalf-Of / [Kubernetes Client Side Provider](https://backstage.io/docs/features/kubernetes/authentication/#client-side-providers)). + +The users in Backstage are imported from LDAP using the [LDAP organizational data provider](https://backstage.io/docs/integrations/ldap/org). +The OpenShift OAuth server is connected to an SSO, which is also backed by the same LDAP service. + +With this setup everything is aligned across services. The LDAP relative distinguished name (RDN) matches the name of the OpenShift user entity. + +The OpenShift [built-in OAuth server](https://docs.redhat.com/en/documentation/openshift_container_platform/latest/html/authentication_and_authorization/configuring-internal-oauth#oauth-server-metadata_configuring-internal-oauth) is based on OAuth 2.0. Therefore this Auth implementation builds on [passport-oauth2](https://github.com/jaredhanson/passport-oauth2) + +## Create an OAuth client in OpenShift + +Make sure that an OAuth client exists in the OpenShift cluster. + +To configure the OpenShift integration, create an [`OAuthClient`](https://docs.redhat.com/en/documentation/openshift_container_platform/latest/html/authentication_and_authorization/configuring-oauth-clients). + +The redirect URI must be in the following format: `https:///api/auth/openshift/handler/frame`. + +## Configuration + +The provider configuration can then be added to your `app-config.yaml` under the +root `auth` configuration: + +```yaml +auth: + environment: development + providers: + openshift: + development: + clientId: ${AUTH_OPENSHIFT_CLIENT_ID} + clientSecret: ${AUTH_OPENSHIFT_CLIENT_SECRET} + authorizationUrl: ${AUTH_OPENSHIFT_AUTHORIZATION_URL} + tokenUrl: ${AUTH_OPENSHIFT_TOKEN_URL} + openshiftApiServerUrl: ${OPENSHIFT_API_SERVER_URL} + signIn: + resolvers: + - resolver: displayNameMatchingUserEntityName +``` + +The OpenShift provider is a structure with these configuration keys: + +- `clientId`: The client ID of your OpenShift OAuth client, e.g., `my-backstage` +- `clientSecret`: The client secret tied to the OpenShift OAuth client. +- `authorizationUrl`: The OpenShift OAuth client auth endpoint, format: `https:///oauth/authorize`. +- `tokenUrl`: The OpenShift OAuth client token endpoint, format: `https:///oauth/token`. +- `openshiftApiServerUrl`: The OpenShift API server endpoint, format: `https://`. +- `signIn`: The configuration for the sign-in process, including the **resolvers** + that should be used to match the user from the auth provider with the user + entity in the Backstage catalog (typically a single resolver is sufficient). + +## Backend Installation + +To add the provider to the backend we will first need to install the package by running this command: + +```bash title="from your Backstage root directory" +yarn --cwd packages/backend add @backstage/plugin-auth-backend-module-openshift-provider +``` + +Then we will need to add this line: + +```ts title="in packages/backend/src/index.ts" +backend.add(import('@backstage/plugin-auth-backend')); +/* highlight-add-start */ +backend.add(import('@backstage/plugin-auth-backend-module-openshift-provider')); +/* highlight-add-end */ +``` From 0173a3d14c82a556bb5255156dcce839bb122953 Mon Sep 17 00:00:00 2001 From: Yannik Daellenbach Date: Thu, 10 Apr 2025 17:52:26 +0200 Subject: [PATCH 025/107] Document `sessionDuration` Signed-off-by: Yannik Daellenbach --- docs/auth/openshift/provider.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/auth/openshift/provider.md b/docs/auth/openshift/provider.md index f208aaca81..ab458a80e9 100644 --- a/docs/auth/openshift/provider.md +++ b/docs/auth/openshift/provider.md @@ -43,6 +43,9 @@ auth: authorizationUrl: ${AUTH_OPENSHIFT_AUTHORIZATION_URL} tokenUrl: ${AUTH_OPENSHIFT_TOKEN_URL} openshiftApiServerUrl: ${OPENSHIFT_API_SERVER_URL} + ## uncomment to set lifespan of user session + # sessionDuration: { hours: 24 } # supports `ms` library format (e.g. '24h', '2 days'), ISO duration, "human duration" as used in code + # sessionDuration: 1d signIn: resolvers: - resolver: displayNameMatchingUserEntityName @@ -55,10 +58,13 @@ The OpenShift provider is a structure with these configuration keys: - `authorizationUrl`: The OpenShift OAuth client auth endpoint, format: `https:///oauth/authorize`. - `tokenUrl`: The OpenShift OAuth client token endpoint, format: `https:///oauth/token`. - `openshiftApiServerUrl`: The OpenShift API server endpoint, format: `https://`. +- `sessionDuration`: (optional): Lifespan of the user session. - `signIn`: The configuration for the sign-in process, including the **resolvers** that should be used to match the user from the auth provider with the user entity in the Backstage catalog (typically a single resolver is sufficient). +The provider needs to use the scope **user:full**. + ## Backend Installation To add the provider to the backend we will first need to install the package by running this command: From 7502dd06787cff1aad2a3025c8603abe33f3e3da Mon Sep 17 00:00:00 2001 From: Yannik Daellenbach Date: Sat, 12 Apr 2025 12:26:10 +0200 Subject: [PATCH 026/107] Add auth provider for OpenShift Signed-off-by: Yannik Daellenbach --- packages/app-defaults/src/defaults/apis.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/app-defaults/src/defaults/apis.ts b/packages/app-defaults/src/defaults/apis.ts index 63ace7b4d5..97989dff91 100644 --- a/packages/app-defaults/src/defaults/apis.ts +++ b/packages/app-defaults/src/defaults/apis.ts @@ -35,6 +35,7 @@ import { FetchMiddlewares, VMwareCloudAuth, FrontendHostDiscovery, + OpenShiftAuth, } from '@backstage/core-app-api'; import { @@ -58,6 +59,7 @@ import { bitbucketServerAuthApiRef, atlassianAuthApiRef, vmwareCloudAuthApiRef, + openshiftAuthApiRef, } from '@backstage/core-plugin-api'; import { permissionApiRef, @@ -275,6 +277,22 @@ export const apis = [ }); }, }), + createApiFactory({ + api: openshiftAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => { + return OpenShiftAuth.create({ + configApi, + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }); + }, + }), createApiFactory({ api: permissionApiRef, deps: { From a9ba7c5a262fc44f12044a791be107cc7279e9cc Mon Sep 17 00:00:00 2001 From: Yannik Daellenbach Date: Sat, 12 Apr 2025 12:35:00 +0200 Subject: [PATCH 027/107] Configure example app to support sign in with OpenShift Signed-off-by: Yannik Daellenbach --- packages/app/src/identityProviders.ts | 7 +++++++ packages/backend/src/index.ts | 1 + 2 files changed, 8 insertions(+) diff --git a/packages/app/src/identityProviders.ts b/packages/app/src/identityProviders.ts index 66f1460210..372477cd63 100644 --- a/packages/app/src/identityProviders.ts +++ b/packages/app/src/identityProviders.ts @@ -23,6 +23,7 @@ import { oneloginAuthApiRef, bitbucketAuthApiRef, bitbucketServerAuthApiRef, + openshiftAuthApiRef, } from '@backstage/core-plugin-api'; export const providers = [ @@ -74,4 +75,10 @@ export const providers = [ message: 'Sign In using Bitbucket Server', apiRef: bitbucketServerAuthApiRef, }, + { + id: 'openshift-auth-provider', + title: 'OpenShift', + message: 'Sign In using OpenShift', + apiRef: openshiftAuthApiRef, + }, ]; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index f46e947b0d..e32d3148b3 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -33,6 +33,7 @@ const searchLoader = createBackendFeatureLoader({ backend.add(import('@backstage/plugin-auth-backend')); backend.add(import('./authModuleGithubProvider')); backend.add(import('@backstage/plugin-auth-backend-module-guest-provider')); +backend.add(import('@backstage/plugin-auth-backend-module-openshift-provider')); backend.add(import('@backstage/plugin-app-backend')); backend.add(import('@backstage/plugin-catalog-backend-module-unprocessed')); backend.add( From 5a842530fddef7d8d111620f253df926686f7322 Mon Sep 17 00:00:00 2001 From: Yannik Daellenbach Date: Sat, 12 Apr 2025 12:40:41 +0200 Subject: [PATCH 028/107] Add changeset for init of `auth-backend-module-openshift-provider` Signed-off-by: Yannik Daellenbach --- .changeset/ten-boxes-lie.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/ten-boxes-lie.md diff --git a/.changeset/ten-boxes-lie.md b/.changeset/ten-boxes-lie.md new file mode 100644 index 0000000000..751b01e7d9 --- /dev/null +++ b/.changeset/ten-boxes-lie.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-openshift-provider': minor +--- + +Add new `auth-backend-module-openshift-provider`. This authentication provider enables Backstage to sign in with OpenShift. From 51146276696fa5a5bfc5a019f4c2a40ae04f2998 Mon Sep 17 00:00:00 2001 From: Yannik Daellenbach Date: Mon, 19 May 2025 08:57:15 +0200 Subject: [PATCH 029/107] Add changeset for integration of `auth-backend-module-openshift-provider` to the core and `user-settings` Signed-off-by: Yannik Daellenbach --- .changeset/hot-friends-act.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/hot-friends-act.md diff --git a/.changeset/hot-friends-act.md b/.changeset/hot-friends-act.md new file mode 100644 index 0000000000..37b2e3f112 --- /dev/null +++ b/.changeset/hot-friends-act.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-plugin-api': minor +--- + +Make `openshiftAuthApiRef` available in `@backstage/core-plugin-api`. From 3fca9069fe032061a1a1c6cac143d058d2ef18ff Mon Sep 17 00:00:00 2001 From: Yannik Daellenbach Date: Tue, 15 Jul 2025 11:33:02 +0200 Subject: [PATCH 030/107] Add changeset for core-plugin Signed-off-by: Yannik Daellenbach --- .changeset/wet-onions-sneeze.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/wet-onions-sneeze.md diff --git a/.changeset/wet-onions-sneeze.md b/.changeset/wet-onions-sneeze.md new file mode 100644 index 0000000000..0fd61d764a --- /dev/null +++ b/.changeset/wet-onions-sneeze.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': minor +--- + +Add `OpenShiftAuth` helper to create default OAuth flow for OpenShift. From 320a9ac35c88a1eab1e1151d65b67e925d580199 Mon Sep 17 00:00:00 2001 From: Yannik Daellenbach Date: Tue, 15 Jul 2025 11:36:03 +0200 Subject: [PATCH 031/107] Add changeset for user-settings Signed-off-by: Yannik Daellenbach --- .changeset/lemon-jobs-create.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/lemon-jobs-create.md diff --git a/.changeset/lemon-jobs-create.md b/.changeset/lemon-jobs-create.md new file mode 100644 index 0000000000..d0e28b9c5c --- /dev/null +++ b/.changeset/lemon-jobs-create.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-user-settings': patch +--- + +Add the OpenShift authenticator provider to the default `user-settings` providers page. From 99567045c546bad5772e8e53b6eb218634bc74fe Mon Sep 17 00:00:00 2001 From: Yannik Daellenbach Date: Tue, 15 Jul 2025 11:40:39 +0200 Subject: [PATCH 032/107] Add changeset for app-defaults Signed-off-by: Yannik Daellenbach --- .changeset/dirty-spies-drop.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/dirty-spies-drop.md diff --git a/.changeset/dirty-spies-drop.md b/.changeset/dirty-spies-drop.md new file mode 100644 index 0000000000..88bff91892 --- /dev/null +++ b/.changeset/dirty-spies-drop.md @@ -0,0 +1,5 @@ +--- +'@backstage/app-defaults': minor +--- + +Add and configure the OpenShift authentication provider to the default APIs. From 2a2f54c7de1dd7a5c8daed0bea499b5f435f371f Mon Sep 17 00:00:00 2001 From: Yannik Daellenbach Date: Mon, 28 Apr 2025 10:39:26 +0200 Subject: [PATCH 033/107] Remove `refresh` and `logout` tests because of false positives in CodeQL action Signed-off-by: Yannik Daellenbach --- .../src/authenticator.test.ts | 112 ------------------ 1 file changed, 112 deletions(-) diff --git a/plugins/auth-backend-module-openshift-provider/src/authenticator.test.ts b/plugins/auth-backend-module-openshift-provider/src/authenticator.test.ts index a5875ed35c..28a7738d21 100644 --- a/plugins/auth-backend-module-openshift-provider/src/authenticator.test.ts +++ b/plugins/auth-backend-module-openshift-provider/src/authenticator.test.ts @@ -65,22 +65,6 @@ describe('openshiftAuthenticator', () => { }); }, ), - http.delete( - 'https://api.openshift.test/apis/oauth.openshift.io/v1/oauthaccesstokens/:id', - ({ params }) => { - const { id } = params; - - if (typeof id !== 'string') { - return new Response(null, { status: 401 }); - } - - if (!id.startsWith('sha256~')) { - return new Response(null, { status: 401 }); - } - - return new Response(null, { status: 200 }); - }, - ), ); implementation = openshiftAuthenticator.initialize({ @@ -239,100 +223,4 @@ describe('openshiftAuthenticator', () => { ); }); }); - - describe('#refresh', () => { - it('gets new refresh token (access token)', async () => { - const refreshResponse = await openshiftAuthenticator.refresh( - { - scope: 'user:full', - refreshToken: 'access-token', - req: {} as express.Request, - }, - implementation, - ); - - expect(refreshResponse.session.refreshToken).toBe('access-token'); - }); - - it('should throw error when invalid access token was provided', async () => { - mswServer.use( - http.get( - 'https://api.openshift.test/apis/user.openshift.io/v1/users/~', - async () => { - return HttpResponse.json( - { - kind: 'Status', - apiVersion: 'v1', - metadata: {}, - status: 'Failure', - message: 'Unauthorized', - reason: 'Unauthorized', - code: 401, - }, - { - status: 401, - }, - ); - }, - ), - ); - - await expect( - openshiftAuthenticator.refresh( - { - scope: 'user:full', - refreshToken: 'invalid-access-token', - req: {} as express.Request, - }, - implementation, - ), - ).rejects.toThrow('HTTP error! Status: 401'); - }); - }); - - describe('#logout', () => { - it('should delete valid access token', async () => { - await expect( - openshiftAuthenticator.logout?.( - { - refreshToken: 'access-token', - req: {} as express.Request, - }, - implementation, - ), - ).resolves.not.toThrow(); - }); - - it('should throw when refresh token is not set', async () => { - await expect( - openshiftAuthenticator.logout?.( - { - req: {} as express.Request, - }, - implementation, - ), - ).rejects.toThrow(); - }); - - it('should throw when access cannot be deleted', async () => { - mswServer.use( - http.delete( - 'https://api.openshift.test/apis/oauth.openshift.io/v1/oauthaccesstokens/:id', - () => { - return new Response(null, { status: 401 }); - }, - ), - ); - - await expect( - openshiftAuthenticator.logout?.( - { - refreshToken: 'access-token', - req: {} as express.Request, - }, - implementation, - ), - ).rejects.toThrow(); - }); - }); }); From 1845e57a3d27394712ac0a20540543c01e64a68e Mon Sep 17 00:00:00 2001 From: Yannik Daellenbach Date: Fri, 20 Jun 2025 14:38:09 +0200 Subject: [PATCH 034/107] Describe Kubernetes plugin integration as use case Signed-off-by: Yannik Daellenbach --- docs/auth/openshift/provider.md | 57 ++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/docs/auth/openshift/provider.md b/docs/auth/openshift/provider.md index ab458a80e9..37c5064580 100644 --- a/docs/auth/openshift/provider.md +++ b/docs/auth/openshift/provider.md @@ -10,14 +10,61 @@ provider that can authenticate users using OpenShift OAuth. ## Use Case -This setup enables the Kubernetes integration to use the users rights to access the OpenShift clusters (OAuth 2.0 On-Behalf-Of / [Kubernetes Client Side Provider](https://backstage.io/docs/features/kubernetes/authentication/#client-side-providers)). +This setup enables the [Kubernetes plugin](../../features/kubernetes/index.md) to access OpenShift clusters using the user's permissions, +leveraging OAuth 2.0 _On-Behalf-Of_ flow via the [Kubernetes Client Side Provider](../../features/kubernetes/authentication.md). -The users in Backstage are imported from LDAP using the [LDAP organizational data provider](https://backstage.io/docs/integrations/ldap/org). -The OpenShift OAuth server is connected to an SSO, which is also backed by the same LDAP service. +To make this work, the corresponding `User` entities must exist in the Backstage catalog, +and their names must match the OpenShift users. -With this setup everything is aligned across services. The LDAP relative distinguished name (RDN) matches the name of the OpenShift user entity. +Although the OpenShift authentication provider does not support OIDC natively, +you can still configure it for use with the Kubernetes integration by treating it as an OIDC provider +in the `KubernetesAuthProviders` configuration. -The OpenShift [built-in OAuth server](https://docs.redhat.com/en/documentation/openshift_container_platform/latest/html/authentication_and_authorization/configuring-internal-oauth#oauth-server-metadata_configuring-internal-oauth) is based on OAuth 2.0. Therefore this Auth implementation builds on [passport-oauth2](https://github.com/jaredhanson/passport-oauth2) +```ts title="packages/app/src/apis.ts" +import { + KubernetesAuthProviders, + kubernetesAuthProvidersApiRef, +} from '@backstage/plugin-kubernetes'; +import { + googleAuthApiRef, + microsoftAuthApiRef, + openshiftAuthApiRef, +} from '@backstage/core-plugin-api'; + +export const apis: AnyApiFactory[] = [ + // ... + createApiFactory({ + api: kubernetesAuthProvidersApiRef, + deps: { + microsoftAuthApi: microsoftAuthApiRef, + googleAuthApi: googleAuthApiRef, + openshiftAuthApi: openshiftAuthApiRef, + }, + factory({ microsoftAuthApi, googleAuthApi, openshiftAuthApi }) { + return new KubernetesAuthProviders({ + microsoftAuthApi, + googleAuthApi, + oidcProviders: { + openshift: { + async getIdToken(_) { + return await openshiftAuthApi.getAccessToken('user:full'); + }, + }, + }, + }); + }, + }), + //... +]; +``` + +:::note Note + +The OpenShift auth API does **not** implement the `OpenIdConnectApi` interface. In other words, it does **not** return an ID token. +Instead, it returns an **access token**, which is used by the Kubernetes integration in place of an ID token. +This is the only functional difference from the standard OIDC-based authentication flow. + +::: ## Create an OAuth client in OpenShift From f6309a56d9542ad450c455f62023f176bb903253 Mon Sep 17 00:00:00 2001 From: Yannik Daellenbach Date: Tue, 15 Jul 2025 11:54:27 +0200 Subject: [PATCH 035/107] Forward `openshiftAuthApiRef` to the new frontend system Signed-off-by: Yannik Daellenbach --- packages/frontend-defaults/src/createApp.test.tsx | 1 + packages/frontend-plugin-api/report.api.md | 3 +++ packages/frontend-plugin-api/src/apis/definitions/auth.ts | 1 + 3 files changed, 5 insertions(+) diff --git a/packages/frontend-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx index 351bc8f1b5..7b5e345e12 100644 --- a/packages/frontend-defaults/src/createApp.test.tsx +++ b/packages/frontend-defaults/src/createApp.test.tsx @@ -372,6 +372,7 @@ describe('createApp', () => { + diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index e6259c70d7..f9e2336408 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -69,6 +69,7 @@ import { OAuthScope } from '@backstage/core-plugin-api'; import { oktaAuthApiRef } from '@backstage/core-plugin-api'; import { oneloginAuthApiRef } from '@backstage/core-plugin-api'; import { OpenIdConnectApi } from '@backstage/core-plugin-api'; +import { openshiftAuthApiRef } from '@backstage/core-plugin-api'; import { PendingOAuthRequest } from '@backstage/core-plugin-api'; import { ProfileInfo } from '@backstage/core-plugin-api'; import { ProfileInfoApi } from '@backstage/core-plugin-api'; @@ -1518,6 +1519,8 @@ export { oneloginAuthApiRef }; export { OpenIdConnectApi }; +export { openshiftAuthApiRef }; + // @public export interface OverridableFrontendPlugin< TRoutes extends { diff --git a/packages/frontend-plugin-api/src/apis/definitions/auth.ts b/packages/frontend-plugin-api/src/apis/definitions/auth.ts index 89509082f0..5a31c1603d 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/auth.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/auth.ts @@ -37,4 +37,5 @@ export { microsoftAuthApiRef, oneloginAuthApiRef, vmwareCloudAuthApiRef, + openshiftAuthApiRef, } from '@backstage/core-plugin-api'; From 894d51497f14137a37d8e7b2aa355bfe827497b7 Mon Sep 17 00:00:00 2001 From: Yannik Daellenbach Date: Tue, 15 Jul 2025 11:58:26 +0200 Subject: [PATCH 036/107] Add changeset for `openshiftApiRef` addition in frontend-plugin-api Signed-off-by: Yannik Daellenbach --- .changeset/tired-cobras-fly.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tired-cobras-fly.md diff --git a/.changeset/tired-cobras-fly.md b/.changeset/tired-cobras-fly.md new file mode 100644 index 0000000000..e2e19803f8 --- /dev/null +++ b/.changeset/tired-cobras-fly.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': minor +--- + +Make `openshiftApiRef` available to the new frontend system. From 3c1d47131e10235355174b4763abb841174ba1e6 Mon Sep 17 00:00:00 2001 From: Yannik Daellenbach Date: Tue, 15 Jul 2025 11:55:37 +0200 Subject: [PATCH 037/107] Add authentication provider implementation for OpenShift to the app plugin Signed-off-by: Yannik Daellenbach --- plugins/app/report.api.md | 15 +++++++++++++++ plugins/app/src/defaultApis.ts | 22 ++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index 24885bed4b..d12ada15b4 100644 --- a/plugins/app/report.api.md +++ b/plugins/app/report.api.md @@ -539,6 +539,21 @@ const appPlugin: OverridableFrontendPlugin< params: ApiFactory, ) => ExtensionBlueprintParams; }>; + 'api:app/openshift-auth': ExtensionDefinition<{ + kind: 'api'; + name: 'openshift-auth'; + config: {}; + configInput: {}; + output: ExtensionDataRef; + inputs: {}; + params: < + TApi, + TImpl extends TApi, + TDeps extends { [name in string]: unknown }, + >( + params: ApiFactory, + ) => ExtensionBlueprintParams; + }>; 'api:app/permission': ExtensionDefinition<{ kind: 'api'; name: 'permission'; diff --git a/plugins/app/src/defaultApis.ts b/plugins/app/src/defaultApis.ts index 0337f6c4f4..0b4eb5c825 100644 --- a/plugins/app/src/defaultApis.ts +++ b/plugins/app/src/defaultApis.ts @@ -35,6 +35,7 @@ import { createFetchApi, FetchMiddlewares, VMwareCloudAuth, + OpenShiftAuth, } from '../../../packages/core-app-api/src/apis/implementations'; import { @@ -56,6 +57,7 @@ import { bitbucketServerAuthApiRef, atlassianAuthApiRef, vmwareCloudAuthApiRef, + openshiftAuthApiRef, } from '@backstage/core-plugin-api'; import { ApiBlueprint, dialogApiRef } from '@backstage/frontend-plugin-api'; import { @@ -353,6 +355,26 @@ export const apis = [ }, }), }), + ApiBlueprint.make({ + name: 'openshift-auth', + params: defineParams => + defineParams({ + api: openshiftAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => { + return OpenShiftAuth.create({ + configApi, + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }); + }, + }), + }), ApiBlueprint.make({ name: 'permission', params: defineParams => From 99790dbf90fd9454adbe1caf608905c78f3664c8 Mon Sep 17 00:00:00 2001 From: Yannik Daellenbach Date: Tue, 15 Jul 2025 12:01:11 +0200 Subject: [PATCH 038/107] Add changeset for the addition of the OpenShift auth provider to app Signed-off-by: Yannik Daellenbach --- .changeset/kind-eyes-worry.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/kind-eyes-worry.md diff --git a/.changeset/kind-eyes-worry.md b/.changeset/kind-eyes-worry.md new file mode 100644 index 0000000000..5568a00e28 --- /dev/null +++ b/.changeset/kind-eyes-worry.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app': minor +--- + +Add implementation of OpenShift authentication provider. From 1ad3d94a227ca8a80d528ae358f6c040cce1ac2b Mon Sep 17 00:00:00 2001 From: Hellgren Heikki Date: Thu, 19 Jun 2025 13:37:46 +0300 Subject: [PATCH 039/107] feat: allow opening dependency graph in fullscreen Signed-off-by: Hellgren Heikki --- .changeset/quiet-papayas-mate.md | 5 + packages/core-components/package.json | 2 + packages/core-components/report-alpha.api.md | 1 + packages/core-components/report.api.md | 1 + .../DependencyGraph/DependencyGraph.test.tsx | 25 +-- .../DependencyGraph/DependencyGraph.tsx | 194 ++++++++++++------ packages/core-components/src/translation.ts | 3 + yarn.lock | 27 +++ 8 files changed, 181 insertions(+), 77 deletions(-) create mode 100644 .changeset/quiet-papayas-mate.md diff --git a/.changeset/quiet-papayas-mate.md b/.changeset/quiet-papayas-mate.md new file mode 100644 index 0000000000..1d2089f4e8 --- /dev/null +++ b/.changeset/quiet-papayas-mate.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Dependency graph can now be opened in full screen mode diff --git a/packages/core-components/package.json b/packages/core-components/package.json index 509a3bb38f..ba7e98aa96 100644 --- a/packages/core-components/package.json +++ b/packages/core-components/package.json @@ -80,6 +80,7 @@ "pluralize": "^8.0.0", "qs": "^6.9.4", "rc-progress": "3.5.1", + "react-full-screen": "^1.1.1", "react-helmet": "6.1.0", "react-hook-form": "^7.12.2", "react-idle-timer": "5.7.2", @@ -106,6 +107,7 @@ "@types/d3-selection": "^3.0.1", "@types/d3-shape": "^3.0.1", "@types/d3-zoom": "^3.0.1", + "@types/fscreen": "^1", "@types/google-protobuf": "^3.7.2", "@types/react": "^18.0.0", "@types/react-helmet": "^6.1.0", diff --git a/packages/core-components/report-alpha.api.md b/packages/core-components/report-alpha.api.md index 32bd2f4939..96ddebcd3a 100644 --- a/packages/core-components/report-alpha.api.md +++ b/packages/core-components/report-alpha.api.md @@ -61,6 +61,7 @@ export const coreComponentsTranslationRef: TranslationRef< readonly 'alertDisplay.message_other': '({{ count }} newer messages)'; readonly 'autoLogout.stillTherePrompt.title': 'Logging out due to inactivity'; readonly 'autoLogout.stillTherePrompt.buttonText': "Yes! Don't log me out"; + readonly 'dependencyGraph.fullscreenTooltip': 'Toggle fullscreen'; readonly 'proxiedSignInPage.title': 'You do not appear to be signed in. Please try reloading the browser page.'; } >; diff --git a/packages/core-components/report.api.md b/packages/core-components/report.api.md index fff1d0365a..effd6ac6ed 100644 --- a/packages/core-components/report.api.md +++ b/packages/core-components/report.api.md @@ -257,6 +257,7 @@ export interface DependencyGraphProps extends SVGProps { acyclicer?: 'greedy'; align?: DependencyGraphTypes.Alignment; + allowFullscreen?: boolean; curve?: 'curveStepBefore' | 'curveMonotoneX'; defs?: JSX.Element | JSX.Element[]; direction?: DependencyGraphTypes.Direction; diff --git a/packages/core-components/src/components/DependencyGraph/DependencyGraph.test.tsx b/packages/core-components/src/components/DependencyGraph/DependencyGraph.test.tsx index 6535808059..0dbf3a36b9 100644 --- a/packages/core-components/src/components/DependencyGraph/DependencyGraph.test.tsx +++ b/packages/core-components/src/components/DependencyGraph/DependencyGraph.test.tsx @@ -14,10 +14,10 @@ * limitations under the License. */ -import { render } from '@testing-library/react'; import { DependencyGraph } from './DependencyGraph'; import { DependencyGraphTypes as Types } from './types'; import { EDGE_TEST_ID, LABEL_TEST_ID, NODE_TEST_ID } from './constants'; +import { renderInTestApp } from '@backstage/test-utils'; describe('', () => { beforeAll(() => { @@ -36,9 +36,8 @@ describe('', () => { const CUSTOM_TEST_ID = 'custom-test-id'; it('renders each node and edge supplied', async () => { - const { getByText, queryAllByTestId, findAllByTestId } = render( - , - ); + const { getByText, queryAllByTestId, findAllByTestId } = + await renderInTestApp(); const renderedNodes = await findAllByTestId(NODE_TEST_ID); expect(renderedNodes).toHaveLength(3); expect(getByText(nodes[0].id)).toBeInTheDocument(); @@ -49,9 +48,10 @@ describe('', () => { }); it('update render if already referenced nodes are added later', async () => { - const { getByText, queryAllByTestId, findAllByTestId, rerender } = render( - , - ); + const { getByText, queryAllByTestId, findAllByTestId, rerender } = + await renderInTestApp( + , + ); let renderedNodes = await findAllByTestId(NODE_TEST_ID); expect(renderedNodes).toHaveLength(2); @@ -75,9 +75,10 @@ describe('', () => { { ...edges[0], label: 'first' }, { ...edges[1], label: 'second' }, ]; - const { getByText, getAllByTestId, findAllByTestId } = render( - , - ); + const { getByText, getAllByTestId, findAllByTestId } = + await renderInTestApp( + , + ); const renderedEdges = await findAllByTestId(EDGE_TEST_ID); expect(renderedEdges).toHaveLength(2); expect(getAllByTestId(LABEL_TEST_ID)).toHaveLength(2); @@ -94,7 +95,7 @@ describe('', () => { ); - const { getByText, findByTestId, container } = render( + const { getByText, findByTestId, container } = await renderInTestApp( , ); const node = await findByTestId(CUSTOM_TEST_ID); @@ -112,7 +113,7 @@ describe('', () => { ); - const { getByText, findByTestId, container } = render( + const { getByText, findByTestId, container } = await renderInTestApp( ({ + root: { + overflow: 'hidden', + minHeight: '100%', + minWidth: '100%', + }, + fullscreen: { + backgroundColor: theme.palette.background.paper, + }, +})); /** * Properties of {@link DependencyGraph} @@ -181,9 +200,18 @@ export interface DependencyGraphProps * Default: 'grow' */ fit?: 'grow' | 'contain'; + /** + * Controls if user can toggle fullscreen mode + * + * @remarks + * + * Default: true + */ + allowFullscreen?: boolean; } const WORKSPACE_ID = 'workspace'; +const DEPENDENCY_GRAPH_SVG = 'dependency-graph'; /** * Graph component used to visualize relations between entities @@ -216,11 +244,15 @@ export function DependencyGraph( curve = 'curveMonotoneX', showArrowHeads = false, fit = 'grow', + allowFullscreen = true, ...svgProps } = props; const theme = useTheme(); const [containerWidth, setContainerWidth] = useState(100); const [containerHeight, setContainerHeight] = useState(100); + const fullScreenHandle = useFullScreenHandle(); + const styles = useStyles(); + const { t } = useTranslationRef(coreComponentsTranslationRef); const graph = useRef>>( new dagre.graphlib.Graph(), @@ -242,11 +274,17 @@ export function DependencyGraph( const containerRef = useMemo( () => - debounce((node: SVGSVGElement) => { - if (!node) { + debounce((root: HTMLDivElement) => { + if (!root) { return; } // Set up zooming + panning + const node: SVGSVGElement = root.querySelector( + `svg#${DEPENDENCY_GRAPH_SVG}`, + ) as SVGSVGElement; + if (!node) { + return; + } const container = d3Selection.select(node); const workspace = d3Selection.select(node.getElementById(WORKSPACE_ID)); @@ -282,7 +320,7 @@ export function DependencyGraph( } const { width: newContainerWidth, height: newContainerHeight } = - node.getBoundingClientRect(); + root.getBoundingClientRect(); if (containerWidth !== newContainerWidth) { setContainerWidth(newContainerWidth); } @@ -406,68 +444,94 @@ export function DependencyGraph( } return ( - - - - - - {defs} - - +
+ + {allowFullscreen && ( + + + {fullScreenHandle.active ? ( + + ) : ( + + )} + + + )} + - {graphEdges.map(e => { - const edge = graph.current.edge(e) as GraphEdge; - if (!edge) return null; - return ( - + + - ); - })} - {graphNodes.map((id: string) => { - const node = graph.current.node(id); - if (!node) return null; - return ( - - ); - })} + + {defs} + + + + {graphEdges.map(e => { + const edge = graph.current.edge(e) as GraphEdge; + if (!edge) return null; + return ( + + ); + })} + {graphNodes.map((id: string) => { + const node = graph.current.node(id); + if (!node) return null; + return ( + + ); + })} + + - - + +
); } diff --git a/packages/core-components/src/translation.ts b/packages/core-components/src/translation.ts index b884f0af92..aa644fa909 100644 --- a/packages/core-components/src/translation.ts +++ b/packages/core-components/src/translation.ts @@ -120,6 +120,9 @@ export const coreComponentsTranslationRef = createTranslationRef({ buttonText: "Yes! Don't log me out", }, }, + dependencyGraph: { + fullscreenTooltip: 'Toggle fullscreen', + }, proxiedSignInPage: { title: 'You do not appear to be signed in. Please try reloading the browser page.', diff --git a/yarn.lock b/yarn.lock index b7c187d56d..efecc7d9c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3182,6 +3182,7 @@ __metadata: "@types/d3-selection": "npm:^3.0.1" "@types/d3-shape": "npm:^3.0.1" "@types/d3-zoom": "npm:^3.0.1" + "@types/fscreen": "npm:^1" "@types/google-protobuf": "npm:^3.7.2" "@types/react": "npm:^18.0.0" "@types/react-helmet": "npm:^6.1.0" @@ -3207,6 +3208,7 @@ __metadata: rc-progress: "npm:3.5.1" react: "npm:^18.0.2" react-dom: "npm:^18.0.2" + react-full-screen: "npm:^1.1.1" react-helmet: "npm:6.1.0" react-hook-form: "npm:^7.12.2" react-idle-timer: "npm:5.7.2" @@ -20266,6 +20268,13 @@ __metadata: languageName: node linkType: hard +"@types/fscreen@npm:^1": + version: 1.0.4 + resolution: "@types/fscreen@npm:1.0.4" + checksum: 10/78459a457ce7a6b7d72a5f17fdb54bbeb93c58ab77fd2858aac610fed2435bc4be9e5d2fb9883b6669b7f3a1204115cc2be59a027ab937ee8b5186225d2ea53d + languageName: node + linkType: hard + "@types/git-url-parse@npm:^9.0.0": version: 9.0.3 resolution: "@types/git-url-parse@npm:9.0.3" @@ -31293,6 +31302,13 @@ __metadata: languageName: node linkType: hard +"fscreen@npm:^1.0.2": + version: 1.2.0 + resolution: "fscreen@npm:1.2.0" + checksum: 10/ac50f9ac52a157b8fe6aaecdf9efa7c1cfa90b42a76c3bc6b85372fab05c5a9cd72c1b7f4c2e273eba1a0e630e381fd72ae135fcc57acd05a0943d5d0c21b451 + languageName: node + linkType: hard + "fsevents@npm:2.3.2": version: 2.3.2 resolution: "fsevents@npm:2.3.2" @@ -42823,6 +42839,17 @@ __metadata: languageName: node linkType: hard +"react-full-screen@npm:^1.1.1": + version: 1.1.1 + resolution: "react-full-screen@npm:1.1.1" + dependencies: + fscreen: "npm:^1.0.2" + peerDependencies: + react: ">= 16.8.0" + checksum: 10/70ad927b9d6c485ac46b5bb4b1639ef9a860da28290b3a1c419c42b9c427d78b80e8dba403eb6451458af56838012c81d5e12ef05097395f154defc32fe06c34 + languageName: node + linkType: hard + "react-grid-layout@npm:1.3.4": version: 1.3.4 resolution: "react-grid-layout@npm:1.3.4" From ba9e598c64a654beeb36441772eb6b8d8214a588 Mon Sep 17 00:00:00 2001 From: Aditya Kumar Date: Wed, 3 Sep 2025 11:46:21 +0530 Subject: [PATCH 040/107] Fixed a broken link Signed-off-by: Aditya Kumar --- docs/frontend-system/building-plugins/01-index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/frontend-system/building-plugins/01-index.md b/docs/frontend-system/building-plugins/01-index.md index 283c162fa7..3ccda08db1 100644 --- a/docs/frontend-system/building-plugins/01-index.md +++ b/docs/frontend-system/building-plugins/01-index.md @@ -185,7 +185,7 @@ export const examplePlugin = createFrontendPlugin({ ## Plugin specific extensions -There are many different plugins that you can extend with additional functionality through extensions. One such plugin is [the catalog plugin](../../features/software-catalog/), one of the core features of Backstage. It lets you catalog the software in your organization, where each item in the catalog has its own page that can be populated with tools and information relating to that catalog entity. In this example we will explore how our plugin can provide such a tool to display on an entity page. +There are many different plugins that you can extend with additional functionality through extensions. One such plugin is [the catalog plugin](../../features/software-catalog/index.md), one of the core features of Backstage. It lets you catalog the software in your organization, where each item in the catalog has its own page that can be populated with tools and information relating to that catalog entity. In this example we will explore how our plugin can provide such a tool to display on an entity page. ```tsx title="in src/plugin.ts - An example entity content extension" import { EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha'; From 676b704db21df381c9472efa3564b86be964ea1b Mon Sep 17 00:00:00 2001 From: Aditya Kumar Date: Wed, 3 Sep 2025 13:37:26 +0530 Subject: [PATCH 041/107] Fixed a broken link Signed-off-by: Aditya Kumar --- docs/frontend-system/building-plugins/01-index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/frontend-system/building-plugins/01-index.md b/docs/frontend-system/building-plugins/01-index.md index 3ccda08db1..03da20ea73 100644 --- a/docs/frontend-system/building-plugins/01-index.md +++ b/docs/frontend-system/building-plugins/01-index.md @@ -185,7 +185,7 @@ export const examplePlugin = createFrontendPlugin({ ## Plugin specific extensions -There are many different plugins that you can extend with additional functionality through extensions. One such plugin is [the catalog plugin](../../features/software-catalog/index.md), one of the core features of Backstage. It lets you catalog the software in your organization, where each item in the catalog has its own page that can be populated with tools and information relating to that catalog entity. In this example we will explore how our plugin can provide such a tool to display on an entity page. +There are many different plugins that you can extend with additional functionality through extensions. One such plugin is [the catalog plugin](https://backstage.io/docs/features/software-catalog/), one of the core features of Backstage. It lets you catalog the software in your organization, where each item in the catalog has its own page that can be populated with tools and information relating to that catalog entity. In this example we will explore how our plugin can provide such a tool to display on an entity page. ```tsx title="in src/plugin.ts - An example entity content extension" import { EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha'; From 2204f5b77edff470b212b6c4ff2a2e58c41e0e44 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Wed, 3 Sep 2025 14:53:40 +0200 Subject: [PATCH 042/107] Prevent deadlock in catalog deferred stitching resolves #30843 Signed-off-by: Andreas Berger --- .changeset/late-swans-press.md | 5 + .../stitcher/markForStitching.test.ts | 139 ++++++++++++++++++ .../operations/stitcher/markForStitching.ts | 106 +++++++++---- 3 files changed, 219 insertions(+), 31 deletions(-) create mode 100644 .changeset/late-swans-press.md diff --git a/.changeset/late-swans-press.md b/.changeset/late-swans-press.md new file mode 100644 index 0000000000..987672bd77 --- /dev/null +++ b/.changeset/late-swans-press.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Prevent deadlock in catalog deferred stitching diff --git a/plugins/catalog-backend/src/database/operations/stitcher/markForStitching.test.ts b/plugins/catalog-backend/src/database/operations/stitcher/markForStitching.test.ts index 1afa0b9c30..33712b99f4 100644 --- a/plugins/catalog-backend/src/database/operations/stitcher/markForStitching.test.ts +++ b/plugins/catalog-backend/src/database/operations/stitcher/markForStitching.test.ts @@ -435,4 +435,143 @@ describe('markForStitching', () => { } }, ); + + const deadlockTestDatabases = TestDatabases.create({ + ids: ['POSTGRES_17', 'POSTGRES_16', 'SQLITE_3'], + disableDocker: false, + }); + it.each(deadlockTestDatabases.eachSupportedId())( + 'reproduces deadlock scenario when concurrent transactions update overlapping entity sets %p', + async databaseId => { + const knex = await deadlockTestDatabases.init(databaseId); + await applyDatabaseMigrations(knex); + + // Setup test data with multiple entities + const entityRefs = [ + 'k:ns/entity-a', + 'k:ns/entity-b', + 'k:ns/entity-c', + 'k:ns/entity-d', + 'k:ns/entity-e', + 'k:ns/entity-f', + ]; + + await knex('refresh_state').insert( + entityRefs.map((ref, i) => ({ + entity_id: `${i + 1}`, + entity_ref: ref, + unprocessed_entity: '{}', + processed_entity: '{}', + errors: '[]', + next_update_at: knex.fn.now(), + last_discovery_at: knex.fn.now(), + next_stitch_at: null, + next_stitch_ticket: null, + })), + ); + + // This test attempts to reproduce the deadlock by running concurrent transactions + // that update overlapping sets of entities in different orders + const errorResults = []; + + for (let attempt = 0; attempt < 10; attempt++) { + // Transaction 1: Update entities A, B, C, D, E + const transaction1 = knex.transaction(async trx => { + await markForStitching({ + knex: trx, + strategy: { + mode: 'deferred', + pollingInterval: { seconds: 1 }, + stitchTimeout: { seconds: 1 }, + }, + entityRefs: [ + 'k:ns/entity-a', + 'k:ns/entity-b', + 'k:ns/entity-c', + 'k:ns/entity-d', + 'k:ns/entity-e', + ], + }); + + // Add a small delay to increase chance of collision + await new Promise(resolve => setTimeout(resolve, 10)); + + await markForStitching({ + knex: trx, + strategy: { + mode: 'deferred', + pollingInterval: { seconds: 1 }, + stitchTimeout: { seconds: 1 }, + }, + entityRefs: ['k:ns/entity-f'], + }); + }); + + // Transaction 2: Update entities F, E, D, C, B (reverse order) + const transaction2 = knex.transaction(async trx => { + await markForStitching({ + knex: trx, + strategy: { + mode: 'deferred', + pollingInterval: { seconds: 1 }, + stitchTimeout: { seconds: 1 }, + }, + entityRefs: [ + 'k:ns/entity-f', + 'k:ns/entity-e', + 'k:ns/entity-d', + 'k:ns/entity-c', + 'k:ns/entity-b', + ], + }); + + // Add a small delay to increase chance of collision + await new Promise(resolve => setTimeout(resolve, 10)); + + await markForStitching({ + knex: trx, + strategy: { + mode: 'deferred', + pollingInterval: { seconds: 1 }, + stitchTimeout: { seconds: 1 }, + }, + entityRefs: ['k:ns/entity-a'], + }); + }); + + // Run both transactions concurrently to create potential deadlock + errorResults.push( + Promise.allSettled([transaction1, transaction2]).then(results => + results + .filter(r => r.status === 'rejected') + .map(r => (r as PromiseRejectedResult).reason), + ), + ); + } + + const allResults = await Promise.all(errorResults); + + const deadlockErrors = allResults + .flat() + .filter( + error => + error?.code === '40P01' || + error?.message?.includes('deadlock detected') || + error?.message?.includes('deadlock'), + ); + expect(deadlockErrors.length).toEqual(0); + + // Verify final state - all entities should have been marked for stitching + const finalState = await knex('refresh_state') + .select('entity_ref', 'next_stitch_at', 'next_stitch_ticket') + .whereNotNull('next_stitch_at') + .orderBy('entity_ref'); + + expect(finalState.length).toBeGreaterThan(0); + finalState.forEach(row => { + expect(row.next_stitch_at).not.toBeNull(); + expect(row.next_stitch_ticket).not.toBeNull(); + }); + }, + ); }); diff --git a/plugins/catalog-backend/src/database/operations/stitcher/markForStitching.ts b/plugins/catalog-backend/src/database/operations/stitcher/markForStitching.ts index ecc364a9cc..a3d63778c3 100644 --- a/plugins/catalog-backend/src/database/operations/stitcher/markForStitching.ts +++ b/plugins/catalog-backend/src/database/operations/stitcher/markForStitching.ts @@ -20,6 +20,11 @@ import { v4 as uuid } from 'uuid'; import { StitchingStrategy } from '../../../stitching/types'; import { DbFinalEntitiesRow, DbRefreshStateRow } from '../../tables'; +const UPDATE_CHUNK_SIZE = 100; // Smaller chunks reduce contention +const DEADLOCK_SQLSTATE = '40P01'; +const DEADLOCK_RETRY_ATTEMPTS = 3; +const DEADLOCK_BASE_DELAY_MS = 25; + /** * Marks a number of entities for stitching some time in the near * future. @@ -32,9 +37,9 @@ export async function markForStitching(options: { entityRefs?: Iterable; entityIds?: Iterable; }): Promise { - // Splitting to chunks just to cover pathological cases that upset the db - const entityRefs = split(options.entityRefs); - const entityIds = split(options.entityIds); + // Sort inputs to ensure consistent lock order across concurrent writers + const entityRefs = split(options.entityRefs, true); + const entityIds = split(options.entityIds, true); const knex = options.knex; const mode = options.strategy.mode; @@ -51,13 +56,15 @@ export async function markForStitching(options: { .select('entity_id') .whereIn('entity_ref', chunk), ); - await knex - .table('refresh_state') - .update({ - result_hash: 'force-stitching', - next_update_at: knex.fn.now(), - }) - .whereIn('entity_ref', chunk); + await retryOnDeadlock(async () => { + await knex + .table('refresh_state') + .update({ + result_hash: 'force-stitching', + next_update_at: knex.fn.now(), + }) + .whereIn('entity_ref', chunk); + }); } for (const chunk of entityIds) { @@ -67,44 +74,81 @@ export async function markForStitching(options: { hash: 'force-stitching', }) .whereIn('entity_id', chunk); - await knex - .table('refresh_state') - .update({ - result_hash: 'force-stitching', - next_update_at: knex.fn.now(), - }) - .whereIn('entity_id', chunk); + await retryOnDeadlock(async () => { + await knex + .table('refresh_state') + .update({ + result_hash: 'force-stitching', + next_update_at: knex.fn.now(), + }) + .whereIn('entity_id', chunk); + }); } } else if (mode === 'deferred') { // It's OK that this is shared across refresh state rows; it just needs to // be uniquely generated for every new stitch request. const ticket = uuid(); + // Update by primary key in deterministic order to avoid deadlocks for (const chunk of entityRefs) { - await knex('refresh_state') - .update({ - next_stitch_at: knex.fn.now(), - next_stitch_ticket: ticket, - }) - .whereIn('entity_ref', chunk); + await retryOnDeadlock(async () => { + await knex('refresh_state') + .update({ + next_stitch_at: knex.fn.now(), + next_stitch_ticket: ticket, + }) + .whereIn('entity_ref', chunk); + }); } for (const chunk of entityIds) { - await knex('refresh_state') - .update({ - next_stitch_at: knex.fn.now(), - next_stitch_ticket: ticket, - }) - .whereIn('entity_id', chunk); + // Ensure ids are sorted for deterministic lock order + + await retryOnDeadlock(async () => { + await knex('refresh_state') + .update({ + next_stitch_at: knex.fn.now(), + next_stitch_ticket: ticket, + }) + .whereIn('entity_id', chunk); + }); } } else { throw new Error(`Unknown stitching strategy mode ${mode}`); } } -function split(input: Iterable | undefined): string[][] { +function split(input: Iterable | undefined, sort = false): string[][] { if (!input) { return []; } - return splitToChunks(Array.isArray(input) ? input : [...input], 200); + const array = Array.isArray(input) ? input.slice() : [...input]; + if (sort) { + array.sort(); + } + return splitToChunks(array, UPDATE_CHUNK_SIZE); +} + +async function retryOnDeadlock( + fn: () => Promise, + retries = DEADLOCK_RETRY_ATTEMPTS, + baseMs = DEADLOCK_BASE_DELAY_MS, +): Promise { + let attempt = 0; + for (;;) { + try { + return await fn(); + } catch (e: any) { + if (e?.code === DEADLOCK_SQLSTATE && attempt < retries) { + await sleep(baseMs * Math.pow(2, attempt)); + attempt++; + continue; + } + throw e; + } + } +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); } From afd368e1a224391e114471aabdd3e8a7b1b5ee76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 3 Sep 2025 14:59:39 +0200 Subject: [PATCH 043/107] remove the last remnants of old system structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/brave-jars-speak.md | 5 +++++ .changeset/gold-words-smoke.md | 5 +++++ plugins/app-backend/src/index.ts | 1 - .../package.json | 4 ---- .../report-alpha.api.md | 13 ------------- .../report.api.md | 5 +++++ .../src/alpha.ts | 18 ------------------ .../src/index.ts | 1 + .../catalogModulePuppetDbEntityProvider.ts | 2 +- 9 files changed, 17 insertions(+), 37 deletions(-) create mode 100644 .changeset/brave-jars-speak.md create mode 100644 .changeset/gold-words-smoke.md delete mode 100644 plugins/catalog-backend-module-puppetdb/report-alpha.api.md delete mode 100644 plugins/catalog-backend-module-puppetdb/src/alpha.ts diff --git a/.changeset/brave-jars-speak.md b/.changeset/brave-jars-speak.md new file mode 100644 index 0000000000..2060239326 --- /dev/null +++ b/.changeset/brave-jars-speak.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-puppetdb': patch +--- + +**BREAKING ALPHA**: The module has been moved from the `/alpha` export to the root of the package. diff --git a/.changeset/gold-words-smoke.md b/.changeset/gold-words-smoke.md new file mode 100644 index 0000000000..85141e402c --- /dev/null +++ b/.changeset/gold-words-smoke.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app-backend': patch +--- + +Internal update to not expose the old `createRouter`. diff --git a/plugins/app-backend/src/index.ts b/plugins/app-backend/src/index.ts index 9f83f6e30a..3f8a0002a7 100644 --- a/plugins/app-backend/src/index.ts +++ b/plugins/app-backend/src/index.ts @@ -21,4 +21,3 @@ */ export { appPlugin as default } from './service/appPlugin'; -export * from './service/router'; diff --git a/plugins/catalog-backend-module-puppetdb/package.json b/plugins/catalog-backend-module-puppetdb/package.json index d82414a792..c777cb51bb 100644 --- a/plugins/catalog-backend-module-puppetdb/package.json +++ b/plugins/catalog-backend-module-puppetdb/package.json @@ -24,16 +24,12 @@ "license": "Apache-2.0", "exports": { ".": "./src/index.ts", - "./alpha": "./src/alpha.ts", "./package.json": "./package.json" }, "main": "src/index.ts", "types": "src/index.ts", "typesVersions": { "*": { - "alpha": [ - "src/alpha.ts" - ], "package.json": [ "package.json" ] diff --git a/plugins/catalog-backend-module-puppetdb/report-alpha.api.md b/plugins/catalog-backend-module-puppetdb/report-alpha.api.md deleted file mode 100644 index 211add3895..0000000000 --- a/plugins/catalog-backend-module-puppetdb/report-alpha.api.md +++ /dev/null @@ -1,13 +0,0 @@ -## API Report File for "@backstage/plugin-catalog-backend-module-puppetdb" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts -import { BackendFeature } from '@backstage/backend-plugin-api'; - -// @alpha -const catalogModulePuppetDbEntityProvider: BackendFeature; -export default catalogModulePuppetDbEntityProvider; - -// (No @packageDocumentation comment for this package) -``` diff --git a/plugins/catalog-backend-module-puppetdb/report.api.md b/plugins/catalog-backend-module-puppetdb/report.api.md index f6fa5bfc83..6b846b29dd 100644 --- a/plugins/catalog-backend-module-puppetdb/report.api.md +++ b/plugins/catalog-backend-module-puppetdb/report.api.md @@ -3,6 +3,7 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; import { Config } from '@backstage/config'; import { EntityProvider } from '@backstage/plugin-catalog-node'; import { EntityProviderConnection } from '@backstage/plugin-catalog-node'; @@ -16,6 +17,10 @@ import { SchedulerServiceTaskScheduleDefinition } from '@backstage/backend-plugi // @public export const ANNOTATION_PUPPET_CERTNAME = 'puppet.com/certname'; +// @public +const catalogModulePuppetDbEntityProvider: BackendFeature; +export default catalogModulePuppetDbEntityProvider; + // @public export const DEFAULT_PROVIDER_ID = 'default'; diff --git a/plugins/catalog-backend-module-puppetdb/src/alpha.ts b/plugins/catalog-backend-module-puppetdb/src/alpha.ts deleted file mode 100644 index 01a9b25f7c..0000000000 --- a/plugins/catalog-backend-module-puppetdb/src/alpha.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright 2023 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 * from './module'; -export { default } from './module'; diff --git a/plugins/catalog-backend-module-puppetdb/src/index.ts b/plugins/catalog-backend-module-puppetdb/src/index.ts index b5c5f9d209..9b10bd630f 100644 --- a/plugins/catalog-backend-module-puppetdb/src/index.ts +++ b/plugins/catalog-backend-module-puppetdb/src/index.ts @@ -22,3 +22,4 @@ export * from './providers'; export * from './puppet'; +export { default } from './module'; diff --git a/plugins/catalog-backend-module-puppetdb/src/module/catalogModulePuppetDbEntityProvider.ts b/plugins/catalog-backend-module-puppetdb/src/module/catalogModulePuppetDbEntityProvider.ts index aa524f2409..1ab95c90d8 100644 --- a/plugins/catalog-backend-module-puppetdb/src/module/catalogModulePuppetDbEntityProvider.ts +++ b/plugins/catalog-backend-module-puppetdb/src/module/catalogModulePuppetDbEntityProvider.ts @@ -24,7 +24,7 @@ import { PuppetDbEntityProvider } from '../providers/PuppetDbEntityProvider'; /** * Registers the `PuppetDbEntityProvider` with the catalog processing extension point. * - * @alpha + * @public */ export const catalogModulePuppetDbEntityProvider = createBackendModule({ pluginId: 'catalog', From 6474b04936ee6a57de1761d9803e0b8abc8e1755 Mon Sep 17 00:00:00 2001 From: Riley Martine Date: Tue, 26 Aug 2025 17:16:38 -0600 Subject: [PATCH 044/107] Add detail in error messages when yarn plugin can't detect backstage version I was confused for ~15 minutes today when updating to using the yarn plugin. It was failing in docker but not locally, and I didn't know why. It turned out to be because I forgot to copy the backstage.json into the docker image. This was confusing, because the error seemed to indicate I was failing the semver checks. This change propagates error detail down the line, so people will see the actual cause. (i.e. missing file, no version field, semver wrong) Signed-off-by: Riley Martine --- packages/yarn-plugin/package.json | 1 + .../src/util/getCurrentBackstageVersion.test.ts | 16 +++++++--------- .../src/util/getCurrentBackstageVersion.ts | 14 ++++++++++---- yarn.lock | 1 + 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/packages/yarn-plugin/package.json b/packages/yarn-plugin/package.json index c534c50c76..4bd43fce40 100644 --- a/packages/yarn-plugin/package.json +++ b/packages/yarn-plugin/package.json @@ -31,6 +31,7 @@ }, "dependencies": { "@backstage/cli-common": "workspace:^", + "@backstage/errors": "workspace:^", "@backstage/release-manifests": "workspace:^", "@yarnpkg/core": "^4.4.1", "@yarnpkg/fslib": "^3.1.2", diff --git a/packages/yarn-plugin/src/util/getCurrentBackstageVersion.test.ts b/packages/yarn-plugin/src/util/getCurrentBackstageVersion.test.ts index 4b1f008fce..a0ce45c18f 100644 --- a/packages/yarn-plugin/src/util/getCurrentBackstageVersion.test.ts +++ b/packages/yarn-plugin/src/util/getCurrentBackstageVersion.test.ts @@ -73,17 +73,15 @@ describe('getCurrentBackstageVersion', () => { }); it.each` - description | content - ${'is missing'} | ${{}} - ${'is invalid'} | ${{ 'backstage.json': '}{' }} - ${'has missing version'} | ${{ 'backstage.json': '{"a":"b"}' }} - ${'has invalid version'} | ${{ 'backstage.json': '{"version":"foobar"}' }} - `('throws if backstage.json $description', ({ content }) => { + description | content | message + ${'is missing'} | ${{}} | ${/valid version string not found.*no such file/i} + ${'is invalid'} | ${{ 'backstage.json': '}{' }} | ${/valid version string not found.*not valid json/i} + ${'has missing version'} | ${{ 'backstage.json': '{"a":"b"}' }} | ${/valid version string not found.*version field is missing/i} + ${'has invalid version'} | ${{ 'backstage.json': '{"version":"foobar"}' }} | ${/valid version string not found.*exists but is not valid semver/i} + `('throws if backstage.json $description', ({ content, message }) => { mockDir.addContent(content); - expect(() => getCurrentBackstageVersion()).toThrow( - /valid version string not found/i, - ); + expect(() => getCurrentBackstageVersion()).toThrow(message); }); it('caches repeated calls', () => { diff --git a/packages/yarn-plugin/src/util/getCurrentBackstageVersion.ts b/packages/yarn-plugin/src/util/getCurrentBackstageVersion.ts index 5280854fb3..be80d29376 100644 --- a/packages/yarn-plugin/src/util/getCurrentBackstageVersion.ts +++ b/packages/yarn-plugin/src/util/getCurrentBackstageVersion.ts @@ -18,6 +18,7 @@ import assert from 'assert'; import { valid as semverValid } from 'semver'; import { ppath, xfs } from '@yarnpkg/fslib'; import { BACKSTAGE_JSON } from '@backstage/cli-common'; +import { ForwardedError } from '@backstage/errors'; import { memoize } from './memoize'; import { getWorkspaceRoot } from './getWorkspaceRoot'; @@ -26,11 +27,16 @@ export const getCurrentBackstageVersion = memoize(() => { let backstageVersion: string | null = null; try { - backstageVersion = semverValid(xfs.readJsonSync(backstageJsonPath).version); + const backstageVersionRaw = xfs.readJsonSync(backstageJsonPath).version; + assert(backstageVersionRaw !== undefined, 'Version field is missing'); + backstageVersion = semverValid(backstageVersionRaw); - assert(backstageVersion !== null); - } catch { - throw new Error('Valid version string not found in backstage.json'); + assert(backstageVersion !== null, 'Version exists but is not valid semver'); + } catch (err) { + throw new ForwardedError( + 'Valid version string not found in backstage.json', + err, + ); } return backstageVersion; diff --git a/yarn.lock b/yarn.lock index 5487f23662..0eb01e6648 100644 --- a/yarn.lock +++ b/yarn.lock @@ -49826,6 +49826,7 @@ __metadata: "@backstage/backend-test-utils": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/cli-common": "workspace:^" + "@backstage/errors": "workspace:^" "@backstage/release-manifests": "workspace:^" "@yarnpkg/builder": "npm:^4.2.1" "@yarnpkg/core": "npm:^4.4.1" From 681c726ecc08e443158e0cbbd7786ca1ce7e4c59 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 05:10:36 +0000 Subject: [PATCH 045/107] chore(deps): bump form-data from 2.5.1 to 2.5.5 Bumps [form-data](https://github.com/form-data/form-data) from 2.5.1 to 2.5.5. - [Release notes](https://github.com/form-data/form-data/releases) - [Changelog](https://github.com/form-data/form-data/blob/v2.5.5/CHANGELOG.md) - [Commits](https://github.com/form-data/form-data/compare/v2.5.1...v2.5.5) --- updated-dependencies: - dependency-name: form-data dependency-version: 2.5.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- yarn.lock | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/yarn.lock b/yarn.lock index 853749b99e..cf287c28d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -26265,7 +26265,7 @@ __metadata: languageName: node linkType: hard -"combined-stream@npm:^1.0.6, combined-stream@npm:^1.0.8": +"combined-stream@npm:^1.0.8": version: 1.0.8 resolution: "combined-stream@npm:1.0.8" dependencies: @@ -31054,13 +31054,16 @@ __metadata: linkType: hard "form-data@npm:^2.3.2, form-data@npm:^2.5.0": - version: 2.5.1 - resolution: "form-data@npm:2.5.1" + version: 2.5.5 + resolution: "form-data@npm:2.5.5" dependencies: asynckit: "npm:^0.4.0" - combined-stream: "npm:^1.0.6" - mime-types: "npm:^2.1.12" - checksum: 10/2e2e5e927979ba3623f9b4c4bcc939275fae3f2dea9dafc8db3ca656a3d75476605de2c80f0e6f1487987398e056f0b4c738972d6e1edd83392d5686d0952eed + combined-stream: "npm:^1.0.8" + es-set-tostringtag: "npm:^2.1.0" + hasown: "npm:^2.0.2" + mime-types: "npm:^2.1.35" + safe-buffer: "npm:^5.2.1" + checksum: 10/4b6a8d07bb67089da41048e734215f68317a8e29dd5385a972bf5c458a023313c69d3b5d6b8baafbb7f808fa9881e0e2e030ffe61e096b3ddc894c516401271d languageName: node linkType: hard From 2b208f1963ef4e135233a2b2f0d5a6dfd096b6a0 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Thu, 4 Sep 2025 10:40:22 +0200 Subject: [PATCH 046/107] Adjustments after review Signed-off-by: Andreas Berger --- .../stitcher/markForStitching.test.ts | 10 ++--- .../operations/stitcher/markForStitching.ts | 41 ++++++++++++------- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/plugins/catalog-backend/src/database/operations/stitcher/markForStitching.test.ts b/plugins/catalog-backend/src/database/operations/stitcher/markForStitching.test.ts index 33712b99f4..ff450e9ccf 100644 --- a/plugins/catalog-backend/src/database/operations/stitcher/markForStitching.test.ts +++ b/plugins/catalog-backend/src/database/operations/stitcher/markForStitching.test.ts @@ -436,14 +436,10 @@ describe('markForStitching', () => { }, ); - const deadlockTestDatabases = TestDatabases.create({ - ids: ['POSTGRES_17', 'POSTGRES_16', 'SQLITE_3'], - disableDocker: false, - }); - it.each(deadlockTestDatabases.eachSupportedId())( + it.each(databases.eachSupportedId())( 'reproduces deadlock scenario when concurrent transactions update overlapping entity sets %p', async databaseId => { - const knex = await deadlockTestDatabases.init(databaseId); + const knex = await databases.init(databaseId); await applyDatabaseMigrations(knex); // Setup test data with multiple entities @@ -559,7 +555,7 @@ describe('markForStitching', () => { error?.message?.includes('deadlock detected') || error?.message?.includes('deadlock'), ); - expect(deadlockErrors.length).toEqual(0); + expect(deadlockErrors).toEqual([]); // Verify final state - all entities should have been marked for stitching const finalState = await knex('refresh_state') diff --git a/plugins/catalog-backend/src/database/operations/stitcher/markForStitching.ts b/plugins/catalog-backend/src/database/operations/stitcher/markForStitching.ts index a3d63778c3..913936202f 100644 --- a/plugins/catalog-backend/src/database/operations/stitcher/markForStitching.ts +++ b/plugins/catalog-backend/src/database/operations/stitcher/markForStitching.ts @@ -21,10 +21,25 @@ import { StitchingStrategy } from '../../../stitching/types'; import { DbFinalEntitiesRow, DbRefreshStateRow } from '../../tables'; const UPDATE_CHUNK_SIZE = 100; // Smaller chunks reduce contention -const DEADLOCK_SQLSTATE = '40P01'; const DEADLOCK_RETRY_ATTEMPTS = 3; const DEADLOCK_BASE_DELAY_MS = 25; +// PostgreSQL deadlock error code +const POSTGRES_DEADLOCK_SQLSTATE = '40P01'; + +/** + * Checks if the given error is a deadlock error for the database engine in use. + */ +function isDeadlockError(knex: Knex | Knex.Transaction, e: unknown): boolean { + if (knex.client.config.client.includes('pg')) { + // PostgreSQL deadlock detection + return (e as any)?.code === POSTGRES_DEADLOCK_SQLSTATE; + } + + // Add more database engine checks here as needed + return false; +} + /** * Marks a number of entities for stitching some time in the near * future. @@ -37,9 +52,8 @@ export async function markForStitching(options: { entityRefs?: Iterable; entityIds?: Iterable; }): Promise { - // Sort inputs to ensure consistent lock order across concurrent writers - const entityRefs = split(options.entityRefs, true); - const entityIds = split(options.entityIds, true); + const entityRefs = sortSplit(options.entityRefs); + const entityIds = sortSplit(options.entityIds); const knex = options.knex; const mode = options.strategy.mode; @@ -64,7 +78,7 @@ export async function markForStitching(options: { next_update_at: knex.fn.now(), }) .whereIn('entity_ref', chunk); - }); + }, knex); } for (const chunk of entityIds) { @@ -82,7 +96,7 @@ export async function markForStitching(options: { next_update_at: knex.fn.now(), }) .whereIn('entity_id', chunk); - }); + }, knex); } } else if (mode === 'deferred') { // It's OK that this is shared across refresh state rows; it just needs to @@ -98,12 +112,10 @@ export async function markForStitching(options: { next_stitch_ticket: ticket, }) .whereIn('entity_ref', chunk); - }); + }, knex); } for (const chunk of entityIds) { - // Ensure ids are sorted for deterministic lock order - await retryOnDeadlock(async () => { await knex('refresh_state') .update({ @@ -111,26 +123,25 @@ export async function markForStitching(options: { next_stitch_ticket: ticket, }) .whereIn('entity_id', chunk); - }); + }, knex); } } else { throw new Error(`Unknown stitching strategy mode ${mode}`); } } -function split(input: Iterable | undefined, sort = false): string[][] { +function sortSplit(input: Iterable | undefined): string[][] { if (!input) { return []; } const array = Array.isArray(input) ? input.slice() : [...input]; - if (sort) { - array.sort(); - } + array.sort(); return splitToChunks(array, UPDATE_CHUNK_SIZE); } async function retryOnDeadlock( fn: () => Promise, + knex: Knex | Knex.Transaction, retries = DEADLOCK_RETRY_ATTEMPTS, baseMs = DEADLOCK_BASE_DELAY_MS, ): Promise { @@ -139,7 +150,7 @@ async function retryOnDeadlock( try { return await fn(); } catch (e: any) { - if (e?.code === DEADLOCK_SQLSTATE && attempt < retries) { + if (isDeadlockError(knex, e) && attempt < retries) { await sleep(baseMs * Math.pow(2, attempt)); attempt++; continue; From dd7b6d2f33cbe9a068424fa36ebfe293a03e7ef4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 4 Sep 2025 13:49:04 +0200 Subject: [PATCH 047/107] Fix getDefault for kubernetesFetcherExtensionPoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/heavy-lies-listen.md | 5 +++++ .../kubernetes-backend/src/service/KubernetesInitializer.ts | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 .changeset/heavy-lies-listen.md diff --git a/.changeset/heavy-lies-listen.md b/.changeset/heavy-lies-listen.md new file mode 100644 index 0000000000..9f408b1ea8 --- /dev/null +++ b/.changeset/heavy-lies-listen.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes-backend': patch +--- + +Fix a bug where `getDefault` in the `kubernetesFetcherExtensionPoint` had the wrong `this` value diff --git a/plugins/kubernetes-backend/src/service/KubernetesInitializer.ts b/plugins/kubernetes-backend/src/service/KubernetesInitializer.ts index c32023235f..1b0b9a31e1 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesInitializer.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesInitializer.ts @@ -183,8 +183,9 @@ export class KubernetesInitializer { async init() { const fetcher = - (await this.opts.fetcher?.({ getDefault: this.defaultFetcher })) ?? - (await this.defaultFetcher()); + (await this.opts.fetcher?.({ + getDefault: () => this.defaultFetcher(), + })) ?? (await this.defaultFetcher()); const authStrategyMap = this.opts.authStrategyMap ?? (await this.defaultAuthStrategy()); From 169ae728b8d86364b5deb880300550a36a653b01 Mon Sep 17 00:00:00 2001 From: Owen Shartle Date: Sat, 6 Sep 2025 21:23:00 -0400 Subject: [PATCH 048/107] Moving the TechDocs entity annotation FAQ entry. Signed-off-by: Owen Shartle --- docs/features/techdocs/FAQ.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/features/techdocs/FAQ.md b/docs/features/techdocs/FAQ.md index 323489ddab..24777e5763 100644 --- a/docs/features/techdocs/FAQ.md +++ b/docs/features/techdocs/FAQ.md @@ -46,6 +46,10 @@ annotation should still be present in entity descriptor file (e.g. `catalog-info.yaml`) for Backstage to know that TechDocs is enabled for the entity. +#### What happens when you navigate to a TechDocs URL for an entity uses the `backstage.io/techdocs-entity` annotation? + +If you navigate to a TechDocs URL in the format `docs/{namespace}/{kind}/{name}` for an entity that has the `backstage.io/techdocs-entity` annotation (instead of the `backstage.io/techdocs-ref` annotation), then Backstage will redirect to the TechDocs page of the entity referenced in the value of that annotation. + #### Is it possible for users to suggest changes or provide feedback on a TechDocs page? This is supported for TechDocs sites whose source code is hosted in either @@ -57,7 +61,3 @@ your `mkdocs.yml` files per If the host name of your source code hosting URL does not include `github` or `gitlab`, an `integrations` entry in your `app-config.yaml` pointed at your source code provider is also needed (only the `host` key is necessary). - -#### What happens when you navigate to a TechDocs URL for an entity uses the `backstage.io/techdocs-entity` annotation? - -If you navigate to a TechDocs URL in the format `docs/{namespace}/{kind}/{name}` for an entity that has the `backstage.io/techdocs-entity` annotation (instead of the `backstage.io/techdocs-ref` annotation), then Backstage will redirect to the TechDocs page of the entity referenced in the value of that annotation. From aca3cab2cc57aff6d70c47b336f71b32a005e743 Mon Sep 17 00:00:00 2001 From: JeevaRamanathan <64531160+JeevaRamanathan@users.noreply.github.com> Date: Sun, 7 Sep 2025 19:06:05 +0530 Subject: [PATCH 049/107] Improve readability in getting-started documentation Signed-off-by: JeevaRamanathan <64531160+JeevaRamanathan@users.noreply.github.com> --- docs/getting-started/index.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index cd86aa3343..611002bf02 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -31,7 +31,7 @@ This guide also assumes a basic understanding of working on a Linux based operat [Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/) - A GNU-like build environment available at the command line. For example, on Debian/Ubuntu you will want to have the `make` and `build-essential` packages installed. - On macOS, you will want to have run `xcode-select --install` to get the XCode command line build tooling in place. + On macOS, you will want to run `xcode-select --install` to get the XCode command line build tooling in place. - An account with elevated rights to install the dependencies - `curl` or `wget` installed - Node.js [Active LTS Release](../overview/versioning-policy.md#nodejs-releases) installed using one of these @@ -58,9 +58,9 @@ The Backstage app we'll be creating will only have demo data until we set up int ::: -To install the Backstage Standalone app, we will make use of `npx`. `npx` is a tool that comes preinstalled with Node.js and lets you run commands straight from `npm` or other registries. Before we jump in to running the command, let's chat about what it does. +To install the Backstage Standalone app, we will make use of `npx`. `npx` is a tool that comes preinstalled with Node.js and lets you run commands straight from `npm` or other registries. Before we run the command, let's discuss what it does. -This command will create a new directory with a Backstage app inside. The wizard will ask you for the name of the app. This name will be created as sub directory in your current working directory. +This command will create a new directory with a Backstage app inside. The wizard will ask you for the name of the app. This name will be created as subdirectory in your current working directory. ![create app](../assets/getting-started/create-app-output.png) @@ -90,7 +90,7 @@ app don't add any npm dependencies here as they probably should be installed in the intended workspace rather than in the root._ - **packages/**: Lerna leaf packages or "workspaces". Everything here is going - to be a separate package, managed by lerna. + to be a separate package managed by Lerna. - **packages/app/**: A fully functioning Backstage frontend app that acts as a good starting point for you to get to know Backstage. - **packages/backend/**: We include a backend that helps power features such as From 5798d2566f2d6898863568793de556cbc58c3e2d Mon Sep 17 00:00:00 2001 From: Hellgren Heikki Date: Mon, 8 Sep 2025 08:47:51 +0300 Subject: [PATCH 050/107] chore: change to BACKSTAGE_ENV as per review Signed-off-by: Hellgren Heikki --- .changeset/better-eagles-tickle.md | 2 +- docs/conf/index.md | 8 ++++---- .../config-loader/src/sources/ConfigSources.test.ts | 2 +- packages/config-loader/src/sources/ConfigSources.ts | 11 ++++------- 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/.changeset/better-eagles-tickle.md b/.changeset/better-eagles-tickle.md index b199ae21d7..418f0bcae0 100644 --- a/.changeset/better-eagles-tickle.md +++ b/.changeset/better-eagles-tickle.md @@ -2,4 +2,4 @@ '@backstage/config-loader': patch --- -Allow using `BACKSTAGE_ENVIRONMENT` for loading environment specific config files +Allow using `BACKSTAGE_ENV` for loading environment specific config files diff --git a/docs/conf/index.md b/docs/conf/index.md index 0b2240e2de..029c5b88fd 100644 --- a/docs/conf/index.md +++ b/docs/conf/index.md @@ -17,15 +17,15 @@ allowing for customization. Configuration is stored in YAML files where the defaults are `app-config.yaml` and `app-config.local.yaml` for local overrides. Additionally, it is possible -to define environment based configuration files with `BACKSTAGE_ENVIRONMENT` -environment variable, which will load `app-config..yaml`. +to define environment based configuration files with `BACKSTAGE_ENV` +environment variable, which will load `app-config..yaml`. Loading order of these files is as follows: 1. `app-config.yaml` -2. `app-config..yaml` +2. `app-config..yaml` 3. `app-config.local.yaml` -4. `app-config..local.yaml` +4. `app-config..local.yaml` Other sets of files can by loaded by passing `--config ` flags. Read more about the configuration loading order in the diff --git a/packages/config-loader/src/sources/ConfigSources.test.ts b/packages/config-loader/src/sources/ConfigSources.test.ts index da56f5ccf9..a6b0648889 100644 --- a/packages/config-loader/src/sources/ConfigSources.test.ts +++ b/packages/config-loader/src/sources/ConfigSources.test.ts @@ -90,7 +90,7 @@ describe('ConfigSources', () => { { name: 'FileConfigSource', path: `${root}app-config.local.yaml` }, ]); - process.env = Object.assign(process.env, { BACKSTAGE_ENVIRONMENT: 'test' }); + process.env = Object.assign(process.env, { BACKSTAGE_ENV: 'test' }); expect( mergeSources( ConfigSources.defaultForTargets({ rootDir: '/', targets: [] }), diff --git a/packages/config-loader/src/sources/ConfigSources.ts b/packages/config-loader/src/sources/ConfigSources.ts index 1dacdc4752..18d0443986 100644 --- a/packages/config-loader/src/sources/ConfigSources.ts +++ b/packages/config-loader/src/sources/ConfigSources.ts @@ -184,11 +184,11 @@ export class ConfigSources { const localPath = resolvePath(rootDir, 'app-config.local.yaml'); const envPath = resolvePath( rootDir, - `app-config.${process.env.BACKSTAGE_ENVIRONMENT}.yaml`, + `app-config.${process.env.BACKSTAGE_ENV}.yaml`, ); const envLocalPath = resolvePath( rootDir, - `app-config.${process.env.BACKSTAGE_ENVIRONMENT}.local.yaml`, + `app-config.${process.env.BACKSTAGE_ENV}.local.yaml`, ); const alwaysIncludeDefaultConfigSource = !options.allowMissingDefaultConfig; @@ -203,7 +203,7 @@ export class ConfigSources { ); } - if (process.env.BACKSTAGE_ENVIRONMENT && fs.pathExistsSync(envPath)) { + if (process.env.BACKSTAGE_ENV && fs.pathExistsSync(envPath)) { argSources.push( FileConfigSource.create({ watch: options.watch, @@ -223,10 +223,7 @@ export class ConfigSources { ); } - if ( - process.env.BACKSTAGE_ENVIRONMENT && - fs.pathExistsSync(envLocalPath) - ) { + if (process.env.BACKSTAGE_ENV && fs.pathExistsSync(envLocalPath)) { argSources.push( FileConfigSource.create({ watch: options.watch, From 6c4904102aeb853283cb2de829e84114f4a620af Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Wed, 2 Jul 2025 13:27:00 +0200 Subject: [PATCH 051/107] chore: add migrations file Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- ...20250701120000_oidc_client_registration.js | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 plugins/auth-backend/migrations/20250701120000_oidc_client_registration.js diff --git a/plugins/auth-backend/migrations/20250701120000_oidc_client_registration.js b/plugins/auth-backend/migrations/20250701120000_oidc_client_registration.js new file mode 100644 index 0000000000..9f40831ae4 --- /dev/null +++ b/plugins/auth-backend/migrations/20250701120000_oidc_client_registration.js @@ -0,0 +1,174 @@ +/* + * 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. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex.schema.createTable('oidc_clients', table => { + table.comment( + 'OIDC clients that are registered via dynamic client registration', + ); + + table + .string('client_id') + .primary() + .notNullable() + .comment('The unique client ID of the client'); + + table + .string('client_secret') + .notNullable() + .comment('The client secret of the client'); + + table + .string('client_name') + .notNullable() + .comment('The name of the client, should be human readable'); + + table + .timestamp('created_at', { useTz: false, precision: 0 }) + .notNullable() + .defaultTo(knex.fn.now()) + .comment('Client registration timestamp'); + + table + .timestamp('expires_at', { useTz: false, precision: 0 }) + .nullable() + .comment('Client registration expiration timestamp'); + + table + .text('response_types', 'longtext') + .notNullable() + .comment('JSON array of supported response types'); + + table + .text('grant_types', 'longtext') + .notNullable() + .comment('JSON array of supported grant types'); + + table + .text('scope') + .nullable() + .comment('Space-separated list of allowed scopes'); + + table + .text('metadata', 'longtext') + .nullable() + .comment('Additional client metadata as JSON'); + }); + + await knex.schema.createTable('oidc_authorization_codes', table => { + table.comment('Authorization codes for OIDC authorization code flow'); + + table.string('code').primary().notNullable().comment('Authorization code'); + + table + .string('client_id') + .notNullable() + .comment('Client ID that requested the code'); + + table + .string('user_entity_ref') + .notNullable() + .comment('User entity reference who authorized'); + + table + .text('redirect_uri') + .notNullable() + .comment('Redirect URI used in authorization request'); + + table.text('scope').nullable().comment('Requested scopes'); + + table.string('code_challenge').nullable().comment('PKCE code challenge'); + + table + .string('code_challenge_method') + .nullable() + .comment('PKCE code challenge method'); + + table.string('nonce').nullable().comment('Nonce value for ID token'); + + table + .timestamp('created_at', { useTz: false, precision: 0 }) + .notNullable() + .defaultTo(knex.fn.now()) + .comment('Code creation timestamp'); + + table + .timestamp('expires_at', { useTz: false, precision: 0 }) + .notNullable() + .comment('Code expiration timestamp'); + + table + .boolean('used') + .defaultTo(false) + .comment('Whether the code has been used'); + + table.foreign('client_id').references('client_id').inTable('oidc_clients'); + }); + + await knex.schema.createTable('oidc_access_tokens', table => { + table.comment('Access tokens issued by OIDC server'); + + table + .string('token_id') + .primary() + .notNullable() + .comment('Unique token identifier'); + + table + .string('client_id') + .notNullable() + .comment('Client ID that owns the token'); + + table + .string('user_entity_ref') + .notNullable() + .comment('User entity reference'); + + table.text('scope').nullable().comment('Token scopes'); + + table + .timestamp('created_at', { useTz: false, precision: 0 }) + .notNullable() + .defaultTo(knex.fn.now()) + .comment('Token creation timestamp'); + + table + .timestamp('expires_at', { useTz: false, precision: 0 }) + .notNullable() + .comment('Token expiration timestamp'); + + table + .boolean('revoked') + .defaultTo(false) + .comment('Whether the token has been revoked'); + + table.foreign('client_id').references('client_id').inTable('oidc_clients'); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.dropTable('oidc_access_tokens'); + await knex.schema.dropTable('oidc_authorization_codes'); + await knex.schema.dropTable('oidc_clients'); +}; From 64dc5463ba2671faaae38d8f495bf772981b0cb2 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Wed, 2 Jul 2025 17:45:45 +0200 Subject: [PATCH 052/107] feat: started to add some tests for the oidc database Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- ...20250701120000_oidc_client_registration.js | 5 + .../src/database/OidcDatabase.test.ts | 199 +++++++++++++++ .../auth-backend/src/database/OidcDatabase.ts | 229 ++++++++++++++++++ 3 files changed, 433 insertions(+) create mode 100644 plugins/auth-backend/src/database/OidcDatabase.test.ts create mode 100644 plugins/auth-backend/src/database/OidcDatabase.ts diff --git a/plugins/auth-backend/migrations/20250701120000_oidc_client_registration.js b/plugins/auth-backend/migrations/20250701120000_oidc_client_registration.js index 9f40831ae4..d4863965de 100644 --- a/plugins/auth-backend/migrations/20250701120000_oidc_client_registration.js +++ b/plugins/auth-backend/migrations/20250701120000_oidc_client_registration.js @@ -41,6 +41,11 @@ exports.up = async function up(knex) { .notNullable() .comment('The name of the client, should be human readable'); + table + .text('redirect_uris', 'longtext') + .notNullable() + .comment('JSON array of valid redirect URIs'); + table .timestamp('created_at', { useTz: false, precision: 0 }) .notNullable() diff --git a/plugins/auth-backend/src/database/OidcDatabase.test.ts b/plugins/auth-backend/src/database/OidcDatabase.test.ts new file mode 100644 index 0000000000..bf00159b49 --- /dev/null +++ b/plugins/auth-backend/src/database/OidcDatabase.test.ts @@ -0,0 +1,199 @@ +/* + * Copyright 2025 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 { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; +import { AuthDatabase } from './AuthDatabase'; +import { OidcDatabase } from './OidcDatabase'; +import { resolvePackagePath } from '@backstage/backend-plugin-api'; + +describe('Oidc Database', () => { + const databases = TestDatabases.create(); + + async function createOidcDatabase(databaseId: TestDatabaseId) { + const knex = await databases.init(databaseId); + + await knex.migrate.latest({ + directory: resolvePackagePath( + '@backstage/plugin-auth-backend', + 'migrations', + ), + }); + + return { + oidc: await OidcDatabase.create({ + database: AuthDatabase.create({ + getClient: async () => knex, + }), + }), + }; + } + + describe.each(databases.eachSupportedId())('%p', databaseId => { + describe('Client', () => { + it('should create and return a client', async () => { + const { oidc } = await createOidcDatabase(databaseId); + + await expect( + oidc.createClient({ + clientId: 'test-client', + clientName: 'Test Client', + clientSecret: 'test-secret', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + }), + ).resolves.toEqual({ + clientId: 'test-client', + clientName: 'Test Client', + clientSecret: 'test-secret', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + scope: undefined, + expiresAt: undefined, + metadata: undefined, + createdAt: expect.any(String), + }); + }); + + it('should return the client thats created in a list', async () => { + const { oidc } = await createOidcDatabase(databaseId); + + const client = await oidc.createClient({ + clientId: 'test-client', + clientName: 'Test Client', + clientSecret: 'test-secret', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + }); + + await expect( + oidc.getClient({ clientId: 'test-client' }), + ).resolves.toEqual(client); + }); + + it('should return null if the client does not exist', async () => { + const { oidc } = await createOidcDatabase(databaseId); + + await expect( + oidc.getClient({ clientId: 'test-client' }), + ).resolves.toBeNull(); + }); + }); + + describe('Authorization Code', () => { + it('should create and return an authorization code', async () => { + const { oidc } = await createOidcDatabase(databaseId); + + const mockClient = await oidc.createClient({ + clientId: 'test-client', + clientName: 'Test Client', + clientSecret: 'test-secret', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + }); + + const authorizationCode = await oidc.createAuthorizationCode({ + code: 'test-code', + clientId: mockClient.clientId, + userEntityRef: 'user:default/blam', + redirectUri: 'https://example.com/callback', + scope: undefined, + codeChallenge: 'test-challenge', + codeChallengeMethod: 'S256', + nonce: 'test-nonce', + expiresAt: '2025-01-01', + }); + + await expect( + oidc.getAuthorizationCode({ code: 'test-code' }), + ).resolves.toEqual(authorizationCode); + }); + + it('should return null if the authorization code does not exist', async () => { + const { oidc } = await createOidcDatabase(databaseId); + + await expect( + oidc.getAuthorizationCode({ code: 'test-code' }), + ).resolves.toBeNull(); + }); + + it('should return the authorization code when created', async () => { + const { oidc } = await createOidcDatabase(databaseId); + + const mockClient = await oidc.createClient({ + clientId: 'test-client', + clientName: 'Test Client', + clientSecret: 'test-secret', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + }); + + const authorizationCode = await oidc.createAuthorizationCode({ + code: 'test-code', + clientId: mockClient.clientId, + userEntityRef: 'user:default/blam', + redirectUri: 'https://example.com/callback', + scope: undefined, + codeChallenge: 'test-challenge', + codeChallengeMethod: 'S256', + nonce: 'test-nonce', + expiresAt: '2025-01-01', + }); + + await expect( + oidc.getAuthorizationCode({ code: 'test-code' }), + ).resolves.toEqual(authorizationCode); + }); + + it('should allow updating the authorization code', async () => { + const { oidc } = await createOidcDatabase(databaseId); + + const mockClient = await oidc.createClient({ + clientId: 'test-client', + clientName: 'Test Client', + clientSecret: 'test-secret', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + }); + + const authorizationCode = await oidc.createAuthorizationCode({ + code: 'test-code', + clientId: mockClient.clientId, + userEntityRef: 'user:default/blam', + redirectUri: 'https://example.com/callback', + codeChallenge: 'test-challenge', + codeChallengeMethod: 'S256', + nonce: 'test-nonce', + expiresAt: '2025-01-01', + }); + + await expect( + oidc.updateAuthorizationCode({ + code: 'test-code', + used: true, + }), + ).resolves.toEqual({ + ...authorizationCode, + used: true, + }); + }); + }); + }); +}); diff --git a/plugins/auth-backend/src/database/OidcDatabase.ts b/plugins/auth-backend/src/database/OidcDatabase.ts new file mode 100644 index 0000000000..ae3cfc42a6 --- /dev/null +++ b/plugins/auth-backend/src/database/OidcDatabase.ts @@ -0,0 +1,229 @@ +/* + * Copyright 2025 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 { Knex } from 'knex'; +import { AuthDatabase } from './AuthDatabase'; + +import { DateTime } from 'luxon'; + +type OidcClientRow = { + client_id: string; + client_secret: string; + client_name: string; + created_at: string; + expires_at: string | null; + response_types: string; + grant_types: string; + redirect_uris: string; + scope: string | null; + metadata: string | null; +}; + +type OidcAuthorizationCodeRow = { + code: string; + client_id: string; + user_entity_ref: string; + redirect_uri: string; + scope: string | null; + code_challenge: string | null; + code_challenge_method: string | null; + nonce: string | null; + created_at: string; + expires_at: string; + used?: boolean; +}; + +type Client = { + clientId: string; + clientName: string; + clientSecret: string; + redirectUris: string[]; + responseTypes: string[]; + grantTypes: string[]; + scope?: string; + expiresAt?: string; + metadata?: Record; + createdAt: string; +}; + +type AuthorizationCode = { + code: string; + clientId: string; + userEntityRef: string; + redirectUri: string; + scope?: string; + codeChallenge?: string; + codeChallengeMethod?: string; + nonce?: string; + createdAt: string; + expiresAt: string; + used: boolean; +}; + +export class OidcDatabase { + private constructor(private readonly db: Knex) {} + + static async create(options: { database: AuthDatabase }) { + const client = await options.database.get(); + return new OidcDatabase(client); + } + + async createClient(client: Omit) { + const now = DateTime.now().toString(); + + await this.db('oidc_clients').insert({ + client_id: client.clientId, + client_secret: client.clientSecret, + client_name: client.clientName, + created_at: now, + expires_at: client.expiresAt, + response_types: JSON.stringify(client.responseTypes), + grant_types: JSON.stringify(client.grantTypes), + redirect_uris: JSON.stringify(client.redirectUris), + scope: client.scope, + metadata: JSON.stringify(client.metadata), + }); + + return { + ...client, + createdAt: now, + }; + } + + async getClient({ clientId }: { clientId: string }) { + const client = await this.db('oidc_clients') + .where('client_id', clientId) + .first(); + + if (!client) { + return null; + } + + return this.rowToClient(client) as Client; + } + + async createAuthorizationCode( + authorizationCode: Omit, + ) { + const now = DateTime.now().toString(); + + await this.db('oidc_authorization_codes').insert({ + code: authorizationCode.code, + client_id: authorizationCode.clientId, + user_entity_ref: authorizationCode.userEntityRef, + redirect_uri: authorizationCode.redirectUri, + scope: authorizationCode.scope, + code_challenge: authorizationCode.codeChallenge, + code_challenge_method: authorizationCode.codeChallengeMethod, + nonce: authorizationCode.nonce, + expires_at: authorizationCode.expiresAt, + created_at: now, + used: false, + }); + + return { + ...authorizationCode, + createdAt: now, + used: false, + }; + } + + async getAuthorizationCode({ code }: { code: string }) { + const authorizationCode = await this.db( + 'oidc_authorization_codes', + ) + .where('code', code) + .first(); + + if (!authorizationCode) { + return null; + } + + return this.rowToAuthorizationCode(authorizationCode) as AuthorizationCode; + } + + async updateAuthorizationCode( + authorizationCode: Partial & { code: string }, + ) { + const row = this.authorizationCodeToRow(authorizationCode); + const updatedFields = Object.fromEntries( + Object.entries(row).filter(([_, value]) => value !== undefined), + ); + console.log(updatedFields); + const updated = await this.db( + 'oidc_authorization_codes', + ) + .where('code', authorizationCode.code) + .update(updatedFields) + .returning('*'); + + return this.rowToAuthorizationCode(updated[0]) as AuthorizationCode; + } + + private rowToClient(row: Partial): Partial { + return { + clientId: row.client_id, + clientName: row.client_name, + clientSecret: row.client_secret, + redirectUris: row.redirect_uris + ? JSON.parse(row.redirect_uris) + : undefined, + responseTypes: row.response_types + ? JSON.parse(row.response_types) + : undefined, + grantTypes: row.grant_types ? JSON.parse(row.grant_types) : undefined, + scope: row.scope ?? undefined, + expiresAt: row.expires_at ?? undefined, + metadata: row.metadata ? JSON.parse(row.metadata) : undefined, + createdAt: row.created_at, + }; + } + + private authorizationCodeToRow( + authorizationCode: Partial, + ): Partial { + return { + code: authorizationCode.code, + client_id: authorizationCode.clientId, + user_entity_ref: authorizationCode.userEntityRef, + redirect_uri: authorizationCode.redirectUri, + scope: authorizationCode.scope, + code_challenge: authorizationCode.codeChallenge, + code_challenge_method: authorizationCode.codeChallengeMethod, + nonce: authorizationCode.nonce, + created_at: authorizationCode.createdAt, + expires_at: authorizationCode.expiresAt, + used: authorizationCode.used, + }; + } + + private rowToAuthorizationCode( + row: Partial, + ): Partial { + return { + code: row.code, + clientId: row.client_id, + userEntityRef: row.user_entity_ref, + redirectUri: row.redirect_uri, + scope: row.scope ?? undefined, + codeChallenge: row.code_challenge ?? undefined, + codeChallengeMethod: row.code_challenge_method ?? undefined, + nonce: row.nonce ?? undefined, + createdAt: row.created_at, + expiresAt: row.expires_at, + used: Boolean(row.used), + }; + } +} From ac54ac21d315864d5ed2872a21d45aa97455d36e Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Wed, 2 Jul 2025 19:30:25 +0200 Subject: [PATCH 053/107] chore: implementing access token management Signed-off-by: benjdlambert --- .../src/database/OidcDatabase.test.ts | 95 +++++++++++++++- .../auth-backend/src/database/OidcDatabase.ts | 107 +++++++++++++++++- 2 files changed, 197 insertions(+), 5 deletions(-) diff --git a/plugins/auth-backend/src/database/OidcDatabase.test.ts b/plugins/auth-backend/src/database/OidcDatabase.test.ts index bf00159b49..65f9e9824c 100644 --- a/plugins/auth-backend/src/database/OidcDatabase.test.ts +++ b/plugins/auth-backend/src/database/OidcDatabase.test.ts @@ -41,7 +41,7 @@ describe('Oidc Database', () => { } describe.each(databases.eachSupportedId())('%p', databaseId => { - describe('Client', () => { + describe('Clients', () => { it('should create and return a client', async () => { const { oidc } = await createOidcDatabase(databaseId); @@ -94,7 +94,7 @@ describe('Oidc Database', () => { }); }); - describe('Authorization Code', () => { + describe('Authorization Codes', () => { it('should create and return an authorization code', async () => { const { oidc } = await createOidcDatabase(databaseId); @@ -195,5 +195,96 @@ describe('Oidc Database', () => { }); }); }); + + describe('Access Tokens', () => { + it('should create and return an access token', async () => { + const { oidc } = await createOidcDatabase(databaseId); + + const mockClient = await oidc.createClient({ + clientId: 'test-client', + clientName: 'Test Client', + clientSecret: 'test-secret', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + }); + + const accessToken = await oidc.createAccessToken({ + tokenId: 'test-token', + clientId: mockClient.clientId, + userEntityRef: 'user:default/blam', + expiresAt: '2025-01-01', + revoked: false, + }); + + await expect( + oidc.getAccessToken({ tokenId: 'test-token' }), + ).resolves.toEqual(accessToken); + }); + + it('should return null if the access token does not exist', async () => { + const { oidc } = await createOidcDatabase(databaseId); + + await expect( + oidc.getAccessToken({ tokenId: 'test-token' }), + ).resolves.toBeNull(); + }); + + it('should return the access token when created', async () => { + const { oidc } = await createOidcDatabase(databaseId); + + const mockClient = await oidc.createClient({ + clientId: 'test-client', + clientName: 'Test Client', + clientSecret: 'test-secret', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + }); + + const accessToken = await oidc.createAccessToken({ + tokenId: 'test-token', + clientId: mockClient.clientId, + userEntityRef: 'user:default/blam', + expiresAt: '2025-01-01', + revoked: false, + }); + + await expect( + oidc.getAccessToken({ tokenId: 'test-token' }), + ).resolves.toEqual(accessToken); + }); + + it('should allow updating the access token', async () => { + const { oidc } = await createOidcDatabase(databaseId); + + const mockClient = await oidc.createClient({ + clientId: 'test-client', + clientName: 'Test Client', + clientSecret: 'test-secret', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + }); + + const accessToken = await oidc.createAccessToken({ + tokenId: 'test-token', + clientId: mockClient.clientId, + userEntityRef: 'user:default/blam', + expiresAt: '2025-01-01', + revoked: false, + }); + + await expect( + oidc.updateAccessToken({ + tokenId: 'test-token', + revoked: true, + }), + ).resolves.toEqual({ + ...accessToken, + revoked: true, + }); + }); + }); }); }); diff --git a/plugins/auth-backend/src/database/OidcDatabase.ts b/plugins/auth-backend/src/database/OidcDatabase.ts index ae3cfc42a6..cbaf101ed9 100644 --- a/plugins/auth-backend/src/database/OidcDatabase.ts +++ b/plugins/auth-backend/src/database/OidcDatabase.ts @@ -45,6 +45,16 @@ type OidcAuthorizationCodeRow = { used?: boolean; }; +type OidcAccessTokenRow = { + token_id: string; + client_id: string; + user_entity_ref: string; + scope: string | null; + created_at: string; + expires_at: string; + revoked?: boolean; +}; + type Client = { clientId: string; clientName: string; @@ -72,6 +82,23 @@ type AuthorizationCode = { used: boolean; }; +type AccessToken = { + tokenId: string; + clientId: string; + userEntityRef: string; + scope?: string; + createdAt: string; + expiresAt: string; + revoked?: boolean; +}; + +/** + * This class is an implementation for the Database operations that power the OIDC sign-in flow. + * + * This class provides database operations for OpenID Connect (OIDC) authentication flows. + * It manages OIDC clients, authorization codes, and access tokens in the database, as well as the consent requests + * for the frontend plugin to accept. + */ export class OidcDatabase { private constructor(private readonly db: Knex) {} @@ -161,15 +188,61 @@ export class OidcDatabase { const updatedFields = Object.fromEntries( Object.entries(row).filter(([_, value]) => value !== undefined), ); - console.log(updatedFields); - const updated = await this.db( + + const [updated] = await this.db( 'oidc_authorization_codes', ) .where('code', authorizationCode.code) .update(updatedFields) .returning('*'); - return this.rowToAuthorizationCode(updated[0]) as AuthorizationCode; + return this.rowToAuthorizationCode(updated) as AuthorizationCode; + } + + async createAccessToken(accessToken: Omit) { + const now = DateTime.now().toString(); + + await this.db('oidc_access_tokens').insert({ + token_id: accessToken.tokenId, + client_id: accessToken.clientId, + user_entity_ref: accessToken.userEntityRef, + scope: accessToken.scope, + created_at: now, + expires_at: accessToken.expiresAt, + revoked: accessToken.revoked ?? false, + }); + + return { + ...accessToken, + createdAt: now, + }; + } + + async getAccessToken({ tokenId }: { tokenId: string }) { + const accessToken = await this.db('oidc_access_tokens') + .where('token_id', tokenId) + .first(); + + if (!accessToken) { + return null; + } + + return this.rowToAccessToken(accessToken) as AccessToken; + } + + async updateAccessToken( + accessToken: Partial & { tokenId: string }, + ) { + const row = this.accessTokenToRow(accessToken); + const updatedFields = Object.fromEntries( + Object.entries(row).filter(([_, value]) => value !== undefined), + ); + const [updated] = await this.db('oidc_access_tokens') + .where('token_id', accessToken.tokenId) + .update(updatedFields) + .returning('*'); + + return this.rowToAccessToken(updated) as AccessToken; } private rowToClient(row: Partial): Partial { @@ -226,4 +299,32 @@ export class OidcDatabase { used: Boolean(row.used), }; } + + private accessTokenToRow( + accessToken: Partial, + ): Partial { + return { + token_id: accessToken.tokenId, + client_id: accessToken.clientId, + user_entity_ref: accessToken.userEntityRef, + scope: accessToken.scope, + created_at: accessToken.createdAt, + expires_at: accessToken.expiresAt, + revoked: accessToken.revoked, + }; + } + + private rowToAccessToken( + row: Partial, + ): Partial { + return { + tokenId: row.token_id, + clientId: row.client_id, + userEntityRef: row.user_entity_ref, + scope: row.scope ?? undefined, + createdAt: row.created_at, + expiresAt: row.expires_at, + revoked: Boolean(row.revoked), + }; + } } From bbda7485f6439938d5f5933f5a07fd09a922be18 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 3 Jul 2025 10:24:53 +0200 Subject: [PATCH 054/107] feat: adding client register Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- .../src/service/OidcRouter.test.ts | 2 ++ .../auth-backend/src/service/OidcRouter.ts | 28 ++++++++++++++++++- .../auth-backend/src/service/OidcService.ts | 28 +++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/plugins/auth-backend/src/service/OidcRouter.test.ts b/plugins/auth-backend/src/service/OidcRouter.test.ts index dbc6c88f93..8e53abbcaf 100644 --- a/plugins/auth-backend/src/service/OidcRouter.test.ts +++ b/plugins/auth-backend/src/service/OidcRouter.test.ts @@ -37,6 +37,8 @@ describe('OidcRouter', () => { }), } as unknown as UserInfoDatabase; + const mockOidc = {}; + const { server } = await startTestBackend({ features: [ createBackendPlugin({ diff --git a/plugins/auth-backend/src/service/OidcRouter.ts b/plugins/auth-backend/src/service/OidcRouter.ts index 6ada071c44..219e026a53 100644 --- a/plugins/auth-backend/src/service/OidcRouter.ts +++ b/plugins/auth-backend/src/service/OidcRouter.ts @@ -15,10 +15,12 @@ */ import Router from 'express-promise-router'; import { OidcService } from './OidcService'; -import { AuthenticationError } from '@backstage/errors'; +import { AuthenticationError, isError } from '@backstage/errors'; import { AuthService } from '@backstage/backend-plugin-api'; import { TokenIssuer } from '../identity/types'; import { UserInfoDatabase } from '../database/UserInfoDatabase'; +import { rest } from 'lodash'; +import { OidcDatabase } from '../database/OidcDatabase'; export class OidcRouter { private constructor(private readonly oidc: OidcService) {} @@ -28,6 +30,7 @@ export class OidcRouter { tokenIssuer: TokenIssuer; baseUrl: string; userInfo: UserInfoDatabase; + oidc: OidcDatabase; }) { return new OidcRouter(OidcService.create(options)); } @@ -68,6 +71,29 @@ export class OidcRouter { res.json(userInfo); }); + router.get('/v1/register', async (req, res) => { + // todo(blam): maybe add zod types for validating input + const registrationRequest = req.body; + if (!registrationRequest.redirect_uris?.length) { + res.status(400).json({ + error: 'invalid_request', + error_description: 'redirect_uris is required', + }); + return; + } + + try { + res.json(await this.oidc.registerClient(registrationRequest)); + } catch (e) { + res.status(500).json({ + error: 'server_error', + error_description: `Failed to register client: ${ + isError(e) ? e.message : 'Unknown error' + }`, + }); + } + }); + return router; } } diff --git a/plugins/auth-backend/src/service/OidcService.ts b/plugins/auth-backend/src/service/OidcService.ts index 5024b2cc8c..919c0dac64 100644 --- a/plugins/auth-backend/src/service/OidcService.ts +++ b/plugins/auth-backend/src/service/OidcService.ts @@ -18,6 +18,8 @@ import { TokenIssuer } from '../identity/types'; import { UserInfoDatabase } from '../database/UserInfoDatabase'; import { InputError } from '@backstage/errors'; import { decodeJwt } from 'jose'; +import crypto from 'crypto'; +import { OidcDatabase } from '../database/OidcDatabase'; export class OidcService { private constructor( @@ -25,6 +27,7 @@ export class OidcService { private readonly tokenIssuer: TokenIssuer, private readonly baseUrl: string, private readonly userInfo: UserInfoDatabase, + private readonly oidc: OidcDatabase, ) {} static create(options: { @@ -32,12 +35,14 @@ export class OidcService { tokenIssuer: TokenIssuer; baseUrl: string; userInfo: UserInfoDatabase; + oidc: OidcDatabase; }) { return new OidcService( options.auth, options.tokenIssuer, options.baseUrl, options.userInfo, + options.oidc, ); } @@ -65,6 +70,8 @@ export class OidcService { token_endpoint_auth_methods_supported: [], claims_supported: ['sub', 'ent'], grant_types_supported: [], + authorization_endpoint: `${this.baseUrl}/v1/authorize`, + registration_endpoint: `${this.baseUrl}/v1/register`, }; } @@ -89,4 +96,25 @@ export class OidcService { } return await this.userInfo.getUserInfo(userEntityRef); } + + public async registerClient(opts: { + responseTypes?: string[]; + grantTypes?: string[]; + clientName: string; + redirectUris?: string[]; + scope?: string; + }) { + const generatedClientId = crypto.randomUUID(); + const generatedClientSecret = crypto.randomUUID(); + + return await this.oidc.createClient({ + clientId: generatedClientId, + clientName: opts.clientName, + clientSecret: generatedClientSecret, + redirectUris: opts.redirectUris ?? [], + responseTypes: opts.responseTypes ?? ['code'], + grantTypes: opts.grantTypes ?? ['authorization_code'], + scope: opts.scope, + }); + } } From 628322d19bbd1284195ed6a7cac6743c0192e6cc Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 3 Jul 2025 10:46:06 +0200 Subject: [PATCH 055/107] chore: issue a token for guest entity ref Signed-off-by: benjdlambert --- .../src/service/OidcRouter.test.ts | 17 +- .../auth-backend/src/service/OidcRouter.ts | 116 +++++++++- .../auth-backend/src/service/OidcService.ts | 200 +++++++++++++++++- plugins/auth-backend/src/service/router.ts | 4 + 4 files changed, 327 insertions(+), 10 deletions(-) diff --git a/plugins/auth-backend/src/service/OidcRouter.test.ts b/plugins/auth-backend/src/service/OidcRouter.test.ts index 8e53abbcaf..ccc2426035 100644 --- a/plugins/auth-backend/src/service/OidcRouter.test.ts +++ b/plugins/auth-backend/src/service/OidcRouter.test.ts @@ -23,6 +23,7 @@ import Router from 'express-promise-router'; import request from 'supertest'; import { OidcRouter } from './OidcRouter'; import { UserInfoDatabase } from '../database/UserInfoDatabase'; +import { OidcDatabase } from '../database/OidcDatabase'; describe('OidcRouter', () => { describe('/v1/userinfo', () => { @@ -37,7 +38,12 @@ describe('OidcRouter', () => { }), } as unknown as UserInfoDatabase; - const mockOidc = {}; + const mockOidc = { + createClient: jest.fn().mockResolvedValue({ + clientId: 'test', + clientSecret: 'test', + }), + } as unknown as OidcDatabase; const { server } = await startTestBackend({ features: [ @@ -55,6 +61,7 @@ describe('OidcRouter', () => { tokenIssuer: {} as any, baseUrl: 'http://localhost:7000', userInfo: mockUserInfo, + oidc: mockOidc, }).getRouter(), ); httpRouter.use(router); @@ -101,6 +108,13 @@ describe('OidcRouter', () => { }), } as unknown as UserInfoDatabase; + const mockOidc = { + createClient: jest.fn().mockResolvedValue({ + clientId: 'test', + clientSecret: 'test', + }), + } as unknown as OidcDatabase; + const { server } = await startTestBackend({ features: [ createBackendPlugin({ @@ -117,6 +131,7 @@ describe('OidcRouter', () => { tokenIssuer: {} as any, baseUrl: 'http://localhost:7000', userInfo: mockUserInfo, + oidc: mockOidc, }).getRouter(), ); httpRouter.use(router); diff --git a/plugins/auth-backend/src/service/OidcRouter.ts b/plugins/auth-backend/src/service/OidcRouter.ts index 219e026a53..721dedf8c3 100644 --- a/plugins/auth-backend/src/service/OidcRouter.ts +++ b/plugins/auth-backend/src/service/OidcRouter.ts @@ -19,7 +19,6 @@ import { AuthenticationError, isError } from '@backstage/errors'; import { AuthService } from '@backstage/backend-plugin-api'; import { TokenIssuer } from '../identity/types'; import { UserInfoDatabase } from '../database/UserInfoDatabase'; -import { rest } from 'lodash'; import { OidcDatabase } from '../database/OidcDatabase'; export class OidcRouter { @@ -47,11 +46,118 @@ export class OidcRouter { res.json({ keys }); }); - router.get('/v1/token', (_req, res) => { - res.status(501).send('Not Implemented'); + router.get('/v1/authorize', async (req, res) => { + // todo(blam): maybe add zod types for validating input + const { + client_id: clientId, + redirect_uri: redirectUri, + response_type: responseType, + scope, + state, + nonce, + code_challenge: codeChallenge, + code_challenge_method: codeChallengeMethod, + } = req.query; + + if (!clientId || !redirectUri || !responseType) { + return res.status(400).json({ + error: 'invalid_request', + error_description: + 'Missing required parameters: client_id, redirect_uri, response_type', + }); + } + + try { + // For simplicity, we'll use a default user entity ref for now + // In a real implementation, this should be obtained from the authenticated user + const userEntityRef = 'user:default/guest'; + + const { redirectUrl } = await this.oidc.authorize({ + clientId: clientId as string, + redirectUri: redirectUri as string, + responseType: responseType as string, + scope: scope as string, + state: state as string, + nonce: nonce as string, + codeChallenge: codeChallenge as string, + codeChallengeMethod: codeChallengeMethod as string, + userEntityRef, + }); + + return res.redirect(redirectUrl); + } catch (error) { + const errorParams = new URLSearchParams(); + errorParams.append( + 'error', + isError(error) ? error.name : 'server_error', + ); + errorParams.append( + 'error_description', + isError(error) ? error.message : 'Unknown error', + ); + if (state) { + errorParams.append('state', state as string); + } + + const redirectUrl = new URL(redirectUri as string); + redirectUrl.search = errorParams.toString(); + return res.redirect(redirectUrl.toString()); + } }); - // This endpoint doesn't use the regular HttpAuthoidc, since the contract + router.post('/v1/token', async (req, res) => { + // todo(blam): maybe add zod types for validating input + const { + grant_type: grantType, + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + } = req.body; + + if (!grantType || !code || !clientId || !clientSecret || !redirectUri) { + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Missing required parameters', + }); + } + + try { + const result = await this.oidc.exchangeCodeForToken({ + code, + clientId, + clientSecret, + redirectUri, + codeVerifier, + grantType, + }); + + return res.json(result); + } catch (error) { + if (isError(error)) { + if (error.name === 'AuthenticationError') { + return res.status(401).json({ + error: 'invalid_client', + error_description: error.message, + }); + } + if (error.name === 'InputError') { + return res.status(400).json({ + error: 'invalid_request', + error_description: error.message, + }); + } + } + + return res.status(500).json({ + error: 'server_error', + error_description: isError(error) ? error.message : 'Unknown error', + }); + } + }); + + // This endpoint doesn't use the regular HttpAuth, since the contract // is specifically for the header to be communicated in the Authorization // header, regardless of token type router.get('/v1/userinfo', async (req, res) => { @@ -71,7 +177,7 @@ export class OidcRouter { res.json(userInfo); }); - router.get('/v1/register', async (req, res) => { + router.post('/v1/register', async (req, res) => { // todo(blam): maybe add zod types for validating input const registrationRequest = req.body; if (!registrationRequest.redirect_uris?.length) { diff --git a/plugins/auth-backend/src/service/OidcService.ts b/plugins/auth-backend/src/service/OidcService.ts index 919c0dac64..104c2290ae 100644 --- a/plugins/auth-backend/src/service/OidcService.ts +++ b/plugins/auth-backend/src/service/OidcService.ts @@ -16,10 +16,11 @@ import { AuthService } from '@backstage/backend-plugin-api'; import { TokenIssuer } from '../identity/types'; import { UserInfoDatabase } from '../database/UserInfoDatabase'; -import { InputError } from '@backstage/errors'; +import { InputError, AuthenticationError } from '@backstage/errors'; import { decodeJwt } from 'jose'; import crypto from 'crypto'; import { OidcDatabase } from '../database/OidcDatabase'; +import { DateTime } from 'luxon'; export class OidcService { private constructor( @@ -52,7 +53,7 @@ export class OidcService { token_endpoint: `${this.baseUrl}/v1/token`, userinfo_endpoint: `${this.baseUrl}/v1/userinfo`, jwks_uri: `${this.baseUrl}/.well-known/jwks.json`, - response_types_supported: ['id_token'], + response_types_supported: ['code', 'id_token'], subject_types_supported: ['public'], id_token_signing_alg_values_supported: [ 'RS256', @@ -67,11 +68,15 @@ export class OidcService { 'EdDSA', ], scopes_supported: ['openid'], - token_endpoint_auth_methods_supported: [], + token_endpoint_auth_methods_supported: [ + 'client_secret_basic', + 'client_secret_post', + ], claims_supported: ['sub', 'ent'], - grant_types_supported: [], + grant_types_supported: ['authorization_code'], authorization_endpoint: `${this.baseUrl}/v1/authorize`, registration_endpoint: `${this.baseUrl}/v1/register`, + code_challenge_methods_supported: ['S256', 'plain'], }; } @@ -117,4 +122,191 @@ export class OidcService { scope: opts.scope, }); } + + public async authorize(opts: { + clientId: string; + redirectUri: string; + responseType: string; + scope?: string; + state?: string; + nonce?: string; + codeChallenge?: string; + codeChallengeMethod?: string; + userEntityRef: string; + }) { + const { + clientId, + redirectUri, + responseType, + scope, + state, + nonce, + codeChallenge, + codeChallengeMethod, + userEntityRef, + } = opts; + + if (responseType !== 'code') { + throw new InputError('Only authorization code flow is supported'); + } + + const client = await this.oidc.getClient({ clientId }); + if (!client) { + throw new InputError('Invalid client_id'); + } + + if (!client.redirectUris.includes(redirectUri)) { + throw new InputError('Invalid redirect_uri'); + } + + if (codeChallenge) { + if ( + !codeChallengeMethod || + !['S256', 'plain'].includes(codeChallengeMethod) + ) { + throw new InputError('Invalid code_challenge_method'); + } + } + + const authorizationCode = crypto.randomBytes(32).toString('base64url'); + const expiresAt = DateTime.now().plus({ minutes: 10 }).toISO(); + + await this.oidc.createAuthorizationCode({ + code: authorizationCode, + clientId, + userEntityRef, + redirectUri, + scope, + codeChallenge, + codeChallengeMethod, + nonce, + expiresAt, + }); + + const redirectUrl = new URL(redirectUri); + redirectUrl.searchParams.append('code', authorizationCode); + if (state) { + redirectUrl.searchParams.append('state', state); + } + + return { + redirectUrl: redirectUrl.toString(), + }; + } + + public async exchangeCodeForToken(params: { + code: string; + clientId: string; + clientSecret: string; + redirectUri: string; + codeVerifier?: string; + grantType: string; + }) { + const { + code, + clientId, + clientSecret, + redirectUri, + codeVerifier, + grantType, + } = params; + + if (grantType !== 'authorization_code') { + throw new InputError('Unsupported grant type'); + } + + const client = await this.oidc.getClient({ clientId }); + if (!client) { + throw new AuthenticationError('Invalid client'); + } + + if (client.clientSecret !== clientSecret) { + throw new AuthenticationError('Invalid client credentials'); + } + + const authCode = await this.oidc.getAuthorizationCode({ code }); + if (!authCode) { + throw new AuthenticationError('Invalid authorization code'); + } + + if (DateTime.fromISO(authCode.expiresAt) < DateTime.now()) { + throw new AuthenticationError('Authorization code expired'); + } + + if (authCode.used) { + throw new AuthenticationError('Authorization code already used'); + } + + if (authCode.clientId !== clientId) { + throw new AuthenticationError('Client ID mismatch'); + } + + if (authCode.redirectUri !== redirectUri) { + throw new AuthenticationError('Redirect URI mismatch'); + } + + if (authCode.codeChallenge) { + if (!codeVerifier) { + throw new AuthenticationError('Code verifier required for PKCE'); + } + + if ( + !this.verifyPkce( + authCode.codeChallenge, + codeVerifier, + authCode.codeChallengeMethod, + ) + ) { + throw new AuthenticationError('Invalid code verifier'); + } + } + + await this.oidc.updateAuthorizationCode({ + code, + used: true, + }); + + const accessTokenId = crypto.randomUUID(); + const expiresAt = DateTime.now().plus({ hours: 1 }).toISO(); + + await this.oidc.createAccessToken({ + tokenId: accessTokenId, + clientId, + userEntityRef: authCode.userEntityRef, + scope: authCode.scope, + expiresAt, + }); + + const { token } = await this.tokenIssuer.issueToken({ + claims: { + sub: authCode.userEntityRef, + }, + }); + + return { + access_token: token, + token_type: 'Bearer', + expires_in: 3600, + id_token: token, + scope: authCode.scope || 'openid', + }; + } + + private verifyPkce( + codeChallenge: string, + codeVerifier: string, + method?: string, + ): boolean { + if (!method || method === 'plain') { + return codeChallenge === codeVerifier; + } + + if (method === 'S256') { + const hash = crypto.createHash('sha256').update(codeVerifier).digest(); + const base64urlHash = hash.toString('base64url'); + return codeChallenge === base64urlHash; + } + + return false; + } } diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 5012c90106..f81390daf8 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -40,6 +40,7 @@ import { StaticTokenIssuer } from '../identity/StaticTokenIssuer'; import { StaticKeyStore } from '../identity/StaticKeyStore'; import { bindProviderRouters, ProviderFactories } from '../providers/router'; import { OidcRouter } from './OidcRouter'; +import { OidcDatabase } from '../database/OidcDatabase'; interface RouterOptions { logger: LoggerService; @@ -147,11 +148,14 @@ export async function createRouter( userInfo, }); + const oidc = await OidcDatabase.create({ database }); + const oidcRouter = OidcRouter.create({ auth: options.auth, tokenIssuer, baseUrl: authUrl, userInfo, + oidc, }); router.use(oidcRouter.getRouter()); From 0d142d95ec0e770ca1d255d14ccfe7cfb8770e75 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 3 Jul 2025 11:16:51 +0200 Subject: [PATCH 056/107] chore: implementing the register and code exchange Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- .../auth-backend/src/database/OidcDatabase.ts | 18 +++++-- .../auth-backend/src/service/OidcRouter.ts | 49 +++++++++++++++---- plugins/auth-backend/src/service/router.ts | 1 + plugins/mcp-actions-backend/src/plugin.ts | 27 +++++++++- 4 files changed, 80 insertions(+), 15 deletions(-) diff --git a/plugins/auth-backend/src/database/OidcDatabase.ts b/plugins/auth-backend/src/database/OidcDatabase.ts index cbaf101ed9..2eeacb7ffe 100644 --- a/plugins/auth-backend/src/database/OidcDatabase.ts +++ b/plugins/auth-backend/src/database/OidcDatabase.ts @@ -93,11 +93,8 @@ type AccessToken = { }; /** - * This class is an implementation for the Database operations that power the OIDC sign-in flow. - * * This class provides database operations for OpenID Connect (OIDC) authentication flows. - * It manages OIDC clients, authorization codes, and access tokens in the database, as well as the consent requests - * for the frontend plugin to accept. + * It manages OIDC clients, authorization codes, and access tokens in the database. */ export class OidcDatabase { private constructor(private readonly db: Knex) {} @@ -109,7 +106,18 @@ export class OidcDatabase { async createClient(client: Omit) { const now = DateTime.now().toString(); - + console.log({ + client_id: client.clientId, + client_secret: client.clientSecret, + client_name: client.clientName, + created_at: now, + expires_at: client.expiresAt, + response_types: JSON.stringify(client.responseTypes), + grant_types: JSON.stringify(client.grantTypes), + redirect_uris: JSON.stringify(client.redirectUris), + scope: client.scope, + metadata: JSON.stringify(client.metadata), + }); await this.db('oidc_clients').insert({ client_id: client.clientId, client_secret: client.clientSecret, diff --git a/plugins/auth-backend/src/service/OidcRouter.ts b/plugins/auth-backend/src/service/OidcRouter.ts index 721dedf8c3..9706554ba5 100644 --- a/plugins/auth-backend/src/service/OidcRouter.ts +++ b/plugins/auth-backend/src/service/OidcRouter.ts @@ -16,27 +16,34 @@ import Router from 'express-promise-router'; import { OidcService } from './OidcService'; import { AuthenticationError, isError } from '@backstage/errors'; -import { AuthService } from '@backstage/backend-plugin-api'; +import { AuthService, LoggerService } from '@backstage/backend-plugin-api'; import { TokenIssuer } from '../identity/types'; import { UserInfoDatabase } from '../database/UserInfoDatabase'; import { OidcDatabase } from '../database/OidcDatabase'; +import { json } from 'express'; export class OidcRouter { - private constructor(private readonly oidc: OidcService) {} + private constructor( + private readonly oidc: OidcService, + private readonly logger: LoggerService, + ) {} static create(options: { auth: AuthService; tokenIssuer: TokenIssuer; baseUrl: string; + logger: LoggerService; userInfo: UserInfoDatabase; oidc: OidcDatabase; }) { - return new OidcRouter(OidcService.create(options)); + return new OidcRouter(OidcService.create(options), options.logger); } public getRouter() { const router = Router(); + router.use(json()); + router.get('/.well-known/openid-configuration', (_req, res) => { res.json(this.oidc.getConfiguration()); }); @@ -60,6 +67,7 @@ export class OidcRouter { } = req.query; if (!clientId || !redirectUri || !responseType) { + this.logger.error(`Failed to authorize: Missing required parameters`); return res.status(400).json({ error: 'invalid_request', error_description: @@ -68,8 +76,8 @@ export class OidcRouter { } try { - // For simplicity, we'll use a default user entity ref for now - // In a real implementation, this should be obtained from the authenticated user + // use default user entity ref for now, as we need a redirect to the frontend plugin + // for the consent flow in order to issue the right token for the right user. const userEntityRef = 'user:default/guest'; const { redirectUrl } = await this.oidc.authorize({ @@ -117,6 +125,9 @@ export class OidcRouter { } = req.body; if (!grantType || !code || !clientId || !clientSecret || !redirectUri) { + this.logger.error( + `Failed to exchange code for token: Missing required parameters`, + ); return res.status(400).json({ error: 'invalid_request', error_description: 'Missing required parameters', @@ -135,6 +146,12 @@ export class OidcRouter { return res.json(result); } catch (error) { + const description = isError(error) ? error.message : 'Unknown error'; + this.logger.error( + `Failed to exchange code for token: ${description}`, + error, + ); + if (isError(error)) { if (error.name === 'AuthenticationError') { return res.status(401).json({ @@ -180,6 +197,7 @@ export class OidcRouter { router.post('/v1/register', async (req, res) => { // todo(blam): maybe add zod types for validating input const registrationRequest = req.body; + if (!registrationRequest.redirect_uris?.length) { res.status(400).json({ error: 'invalid_request', @@ -189,13 +207,26 @@ export class OidcRouter { } try { - res.json(await this.oidc.registerClient(registrationRequest)); + const client = await this.oidc.registerClient({ + clientName: registrationRequest.client_name, + redirectUris: registrationRequest.redirect_uris, + responseTypes: registrationRequest.response_types, + grantTypes: registrationRequest.grant_types, + scope: registrationRequest.scope, + }); + + res.status(201).json({ + client_id: client.clientId, + redirect_uris: client.redirectUris, + client_secret: client.clientSecret, + }); } catch (e) { + const description = isError(e) ? e.message : 'Unknown error'; + this.logger.error(`Failed to register client: ${description}`, e); + res.status(500).json({ error: 'server_error', - error_description: `Failed to register client: ${ - isError(e) ? e.message : 'Unknown error' - }`, + error_description: `Failed to register client: ${description}`, }); } }); diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index f81390daf8..5eb3450451 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -156,6 +156,7 @@ export async function createRouter( baseUrl: authUrl, userInfo, oidc, + logger, }); router.use(oidcRouter.getRouter()); diff --git a/plugins/mcp-actions-backend/src/plugin.ts b/plugins/mcp-actions-backend/src/plugin.ts index f1e89417e9..2e29847964 100644 --- a/plugins/mcp-actions-backend/src/plugin.ts +++ b/plugins/mcp-actions-backend/src/plugin.ts @@ -42,8 +42,17 @@ export const mcpPlugin = createBackendPlugin({ httpRouter: coreServices.httpRouter, actions: actionsServiceRef, registry: actionsRegistryServiceRef, + rootRouter: coreServices.rootHttpRouter, + discovery: coreServices.discovery, }, - async init({ actions, logger, httpRouter, httpAuth }) { + async init({ + actions, + logger, + httpRouter, + httpAuth, + rootRouter, + discovery, + }) { const mcpService = await McpService.create({ actions, }); @@ -66,6 +75,22 @@ export const mcpPlugin = createBackendPlugin({ router.use('/v1', streamableRouter); httpRouter.use(router); + + // todo(blam): there's probably a better way to proxy this, but it's required + // for mcp auth spec that it lives on the root of the mcp entrypoint server. + const authRouter = Router(); + authRouter.use('/', async (_, res) => { + const authBaseUrl = await discovery.getBaseUrl('auth'); + + const oidcResponse = await fetch( + `${authBaseUrl}/.well-known/openid-configuration`, + ); + + const oidcResponseJson = await oidcResponse.json(); + + res.json(oidcResponseJson); + }); + rootRouter.use('/.well-known/oauth-authorization-server', authRouter); }, }); }, From e0473b52e9bcbffec466c3fa8bf5be4d36be5d14 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 3 Jul 2025 11:28:11 +0200 Subject: [PATCH 057/107] chore: little cleanup Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- plugins/auth-backend/src/database/OidcDatabase.ts | 13 +------------ plugins/auth-backend/src/service/OidcRouter.ts | 8 +++++++- plugins/auth-backend/src/service/OidcService.ts | 8 ++++---- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/plugins/auth-backend/src/database/OidcDatabase.ts b/plugins/auth-backend/src/database/OidcDatabase.ts index 2eeacb7ffe..12554fba57 100644 --- a/plugins/auth-backend/src/database/OidcDatabase.ts +++ b/plugins/auth-backend/src/database/OidcDatabase.ts @@ -106,18 +106,7 @@ export class OidcDatabase { async createClient(client: Omit) { const now = DateTime.now().toString(); - console.log({ - client_id: client.clientId, - client_secret: client.clientSecret, - client_name: client.clientName, - created_at: now, - expires_at: client.expiresAt, - response_types: JSON.stringify(client.responseTypes), - grant_types: JSON.stringify(client.grantTypes), - redirect_uris: JSON.stringify(client.redirectUris), - scope: client.scope, - metadata: JSON.stringify(client.metadata), - }); + await this.db('oidc_clients').insert({ client_id: client.clientId, client_secret: client.clientSecret, diff --git a/plugins/auth-backend/src/service/OidcRouter.ts b/plugins/auth-backend/src/service/OidcRouter.ts index 9706554ba5..0e14b91d79 100644 --- a/plugins/auth-backend/src/service/OidcRouter.ts +++ b/plugins/auth-backend/src/service/OidcRouter.ts @@ -144,7 +144,13 @@ export class OidcRouter { grantType, }); - return res.json(result); + return res.json({ + access_token: result.accessToken, + token_type: result.tokenType, + expires_in: result.expiresIn, + id_token: result.idToken, + scope: result.scope, + }); } catch (error) { const description = isError(error) ? error.message : 'Unknown error'; this.logger.error( diff --git a/plugins/auth-backend/src/service/OidcService.ts b/plugins/auth-backend/src/service/OidcService.ts index 104c2290ae..cc60b2b432 100644 --- a/plugins/auth-backend/src/service/OidcService.ts +++ b/plugins/auth-backend/src/service/OidcService.ts @@ -284,10 +284,10 @@ export class OidcService { }); return { - access_token: token, - token_type: 'Bearer', - expires_in: 3600, - id_token: token, + accessToken: token, + tokenType: 'Bearer', + expiresIn: 3600, + idToken: token, scope: authCode.scope || 'openid', }; } From b50e18b25fb4b264ac66945ea60f760eb3963fef Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 3 Jul 2025 19:32:37 +0200 Subject: [PATCH 058/107] chore: reworking the migrations to simplify the tables structure Signed-off-by: benjdlambert --- ...20250701120000_oidc_client_registration.js | 158 +++++++++++------- 1 file changed, 97 insertions(+), 61 deletions(-) diff --git a/plugins/auth-backend/migrations/20250701120000_oidc_client_registration.js b/plugins/auth-backend/migrations/20250701120000_oidc_client_registration.js index d4863965de..bf8ee521f0 100644 --- a/plugins/auth-backend/migrations/20250701120000_oidc_client_registration.js +++ b/plugins/auth-backend/migrations/20250701120000_oidc_client_registration.js @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * Copyright 2025 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. @@ -20,6 +20,8 @@ * @param {import('knex').Knex} knex */ exports.up = async function up(knex) { + // These tables make up the OIDC client registration flow. + // Clients are the top of the tree, that are created by the client registration flow. await knex.schema.createTable('oidc_clients', table => { table.comment( 'OIDC clients that are registered via dynamic client registration', @@ -41,36 +43,27 @@ exports.up = async function up(knex) { .notNullable() .comment('The name of the client, should be human readable'); - table - .text('redirect_uris', 'longtext') - .notNullable() - .comment('JSON array of valid redirect URIs'); - - table - .timestamp('created_at', { useTz: false, precision: 0 }) - .notNullable() - .defaultTo(knex.fn.now()) - .comment('Client registration timestamp'); - table .timestamp('expires_at', { useTz: false, precision: 0 }) .nullable() .comment('Client registration expiration timestamp'); table - .text('response_types', 'longtext') + .text('response_types') .notNullable() .comment('JSON array of supported response types'); table - .text('grant_types', 'longtext') + .text('grant_types') .notNullable() .comment('JSON array of supported grant types'); table - .text('scope') - .nullable() - .comment('Space-separated list of allowed scopes'); + .text('redirect_uris', 'longtext') + .notNullable() + .comment('Allowed redirect URIs as JSON array'); + + table.text('scope').nullable().comment('Default scopes for the client'); table .text('metadata', 'longtext') @@ -78,27 +71,29 @@ exports.up = async function up(knex) { .comment('Additional client metadata as JSON'); }); - await knex.schema.createTable('oidc_authorization_codes', table => { - table.comment('Authorization codes for OIDC authorization code flow'); - - table.string('code').primary().notNullable().comment('Authorization code'); + await knex.schema.createTable('oauth_authorization_sessions', table => { + table.comment('Core OAuth authorization sessions with shared context'); table - .string('client_id') + .string('id') + .primary() .notNullable() - .comment('Client ID that requested the code'); + .comment('Unique session identifier'); + + table.string('client_id').notNullable().comment('OIDC client identifier'); table .string('user_entity_ref') - .notNullable() - .comment('User entity reference who authorized'); + .nullable() + .comment('Backstage user entity reference'); - table - .text('redirect_uri') - .notNullable() - .comment('Redirect URI used in authorization request'); + table.text('redirect_uri').notNullable().comment('Client redirect URI'); - table.text('scope').nullable().comment('Requested scopes'); + table.text('scope').nullable().comment('Requested scopes space-separated'); + + table.string('state').nullable().comment('Client state parameter'); + + table.string('response_type').notNullable().comment('OAuth2 response type'); table.string('code_challenge').nullable().comment('PKCE code challenge'); @@ -107,65 +102,104 @@ exports.up = async function up(knex) { .nullable() .comment('PKCE code challenge method'); - table.string('nonce').nullable().comment('Nonce value for ID token'); + table.string('nonce').nullable().comment('OIDC nonce parameter'); table - .timestamp('created_at', { useTz: false, precision: 0 }) - .notNullable() - .defaultTo(knex.fn.now()) - .comment('Code creation timestamp'); + .enum('status', ['pending', 'approved', 'rejected', 'expired']) + .defaultTo('pending') + .comment('Authorization session status'); table .timestamp('expires_at', { useTz: false, precision: 0 }) .notNullable() - .comment('Code expiration timestamp'); + .comment('Session expiration timestamp'); + + table.foreign('client_id').references('client_id').inTable('oidc_clients'); + table.index(['client_id', 'user_entity_ref']); + table.index(['status', 'expires_at']); + }); + + await knex.schema.createTable('oidc_consent_requests', table => { + table.comment('User consent requests for OAuth authorization'); + + table + .string('id') + .primary() + .notNullable() + .comment('Unique consent request identifier'); + + table + .string('session_id') + .notNullable() + .comment('Authorization session identifier'); + + table + .timestamp('expires_at', { useTz: false, precision: 0 }) + .notNullable() + .comment('Consent request expiration timestamp'); + + table + .foreign('session_id') + .references('id') + .inTable('oauth_authorization_sessions') + .onDelete('CASCADE'); + }); + + await knex.schema.createTable('oidc_authorization_codes', table => { + table.comment('OAuth authorization codes for code exchange flow'); + + table + .string('code') + .primary() + .notNullable() + .comment('Unique authorization code'); + + table + .string('session_id') + .notNullable() + .comment('Authorization session identifier'); + + table + .timestamp('expires_at', { useTz: false, precision: 0 }) + .notNullable() + .comment('Authorization code expiration timestamp'); table .boolean('used') .defaultTo(false) - .comment('Whether the code has been used'); + .comment('Whether the authorization code has been used'); - table.foreign('client_id').references('client_id').inTable('oidc_clients'); + table + .foreign('session_id') + .references('id') + .inTable('oauth_authorization_sessions') + .onDelete('CASCADE'); }); await knex.schema.createTable('oidc_access_tokens', table => { - table.comment('Access tokens issued by OIDC server'); + table.comment('OAuth access tokens for API access'); table .string('token_id') .primary() .notNullable() - .comment('Unique token identifier'); + .comment('Unique access token identifier'); table - .string('client_id') + .string('session_id') .notNullable() - .comment('Client ID that owns the token'); - - table - .string('user_entity_ref') - .notNullable() - .comment('User entity reference'); - - table.text('scope').nullable().comment('Token scopes'); - - table - .timestamp('created_at', { useTz: false, precision: 0 }) - .notNullable() - .defaultTo(knex.fn.now()) - .comment('Token creation timestamp'); + .comment('Authorization session identifier'); table .timestamp('expires_at', { useTz: false, precision: 0 }) .notNullable() - .comment('Token expiration timestamp'); + .comment('Access token expiration timestamp'); table - .boolean('revoked') - .defaultTo(false) - .comment('Whether the token has been revoked'); - - table.foreign('client_id').references('client_id').inTable('oidc_clients'); + .foreign('session_id') + .references('id') + .inTable('oauth_authorization_sessions') + .onDelete('CASCADE'); }); }; @@ -175,5 +209,7 @@ exports.up = async function up(knex) { exports.down = async function down(knex) { await knex.schema.dropTable('oidc_access_tokens'); await knex.schema.dropTable('oidc_authorization_codes'); + await knex.schema.dropTable('oidc_consent_requests'); + await knex.schema.dropTable('oauth_authorization_sessions'); await knex.schema.dropTable('oidc_clients'); }; From 5b084e7223fc46df914bec322731ac5b88a45f4d Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 3 Jul 2025 19:33:15 +0200 Subject: [PATCH 059/107] chore: reworking the API for oidc-database Signed-off-by: benjdlambert --- .../src/database/OidcDatabase.test.ts | 375 ++++++++++++------ .../auth-backend/src/database/OidcDatabase.ts | 321 +++++++++------ 2 files changed, 448 insertions(+), 248 deletions(-) diff --git a/plugins/auth-backend/src/database/OidcDatabase.test.ts b/plugins/auth-backend/src/database/OidcDatabase.test.ts index 65f9e9824c..f9c2b83511 100644 --- a/plugins/auth-backend/src/database/OidcDatabase.test.ts +++ b/plugins/auth-backend/src/database/OidcDatabase.test.ts @@ -64,7 +64,6 @@ describe('Oidc Database', () => { scope: undefined, expiresAt: undefined, metadata: undefined, - createdAt: expect.any(String), }); }); @@ -94,11 +93,195 @@ describe('Oidc Database', () => { }); }); + describe('Authorization Sessions', () => { + it('should create and return an authorization session', async () => { + const { oidc } = await createOidcDatabase(databaseId); + + const client = await oidc.createClient({ + clientId: 'test-client', + clientName: 'Test Client', + clientSecret: 'test-secret', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + }); + + const session = await oidc.createAuthorizationSession({ + id: 'test-session', + clientId: client.clientId, + userEntityRef: 'user:default/blam', + redirectUri: 'https://example.com/callback', + responseType: 'code', + scope: 'openid', + state: 'test-state', + codeChallenge: 'test-challenge', + codeChallengeMethod: 'S256', + nonce: 'test-nonce', + expiresAt: '2025-01-01T00:00:00Z', + }); + + expect(session).toEqual( + expect.objectContaining({ + id: 'test-session', + clientId: client.clientId, + userEntityRef: 'user:default/blam', + redirectUri: 'https://example.com/callback', + responseType: 'code', + scope: 'openid', + state: 'test-state', + codeChallenge: 'test-challenge', + codeChallengeMethod: 'S256', + nonce: 'test-nonce', + expiresAt: '2025-01-01T00:00:00Z', + status: 'pending', + }), + ); + }); + + it('should allow updating the authorization session', async () => { + const { oidc } = await createOidcDatabase(databaseId); + + const client = await oidc.createClient({ + clientId: 'test-client', + clientName: 'Test Client', + clientSecret: 'test-secret', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + }); + + const session = await oidc.createAuthorizationSession({ + id: 'test-session', + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + expiresAt: '2025-01-01T00:00:00Z', + }); + + await expect( + oidc.updateAuthorizationSession({ + id: 'test-session', + userEntityRef: 'user:default/blam', + status: 'approved', + }), + ).resolves.toEqual({ + ...session, + userEntityRef: 'user:default/blam', + status: 'approved', + }); + }); + }); + + describe('Consent Requests', () => { + it('should create and return a consent request', async () => { + const { oidc } = await createOidcDatabase(databaseId); + + const client = await oidc.createClient({ + clientId: 'test-client', + clientName: 'Test Client', + clientSecret: 'test-secret', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + }); + + const session = await oidc.createAuthorizationSession({ + id: 'test-session', + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + expiresAt: '2025-01-01T00:00:00Z', + }); + + const consentRequest = await oidc.createConsentRequest({ + id: 'test-consent', + sessionId: session.id, + expiresAt: '2025-01-01T00:00:00Z', + }); + + await expect( + oidc.getConsentRequest({ id: 'test-consent' }), + ).resolves.toEqual(consentRequest); + }); + + it('should return consent request with session data', async () => { + const { oidc } = await createOidcDatabase(databaseId); + + const client = await oidc.createClient({ + clientId: 'test-client', + clientName: 'Test Client', + clientSecret: 'test-secret', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + }); + + const session = await oidc.createAuthorizationSession({ + id: 'test-session', + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + scope: 'openid', + state: 'test-state', + expiresAt: '2025-01-01T00:00:00Z', + }); + + const consentRequest = await oidc.createConsentRequest({ + id: 'test-consent', + sessionId: session.id, + expiresAt: '2025-01-01T00:00:00Z', + }); + + const consentFromDb = await oidc.getConsentRequest({ + id: 'test-consent', + }); + const sessionFromDb = await oidc.getAuthorizationSession({ + id: consentFromDb!.sessionId, + }); + + expect(consentFromDb).toEqual(consentRequest); + expect(sessionFromDb).toEqual(session); + }); + + it('should delete consent request', async () => { + const { oidc } = await createOidcDatabase(databaseId); + + const client = await oidc.createClient({ + clientId: 'test-client', + clientName: 'Test Client', + clientSecret: 'test-secret', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + }); + + const session = await oidc.createAuthorizationSession({ + id: 'test-session', + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + expiresAt: '2025-01-01T00:00:00Z', + }); + + await oidc.createConsentRequest({ + id: 'test-consent', + sessionId: session.id, + expiresAt: '2025-01-01T00:00:00Z', + }); + + await oidc.deleteConsentRequest({ id: 'test-consent' }); + + await expect( + oidc.getConsentRequest({ id: 'test-consent' }), + ).resolves.toBeNull(); + }); + }); + describe('Authorization Codes', () => { it('should create and return an authorization code', async () => { const { oidc } = await createOidcDatabase(databaseId); - const mockClient = await oidc.createClient({ + const client = await oidc.createClient({ clientId: 'test-client', clientName: 'Test Client', clientSecret: 'test-secret', @@ -107,35 +290,33 @@ describe('Oidc Database', () => { grantTypes: ['authorization_code'], }); - const authorizationCode = await oidc.createAuthorizationCode({ - code: 'test-code', - clientId: mockClient.clientId, - userEntityRef: 'user:default/blam', + const session = await oidc.createAuthorizationSession({ + id: 'test-session', + clientId: client.clientId, redirectUri: 'https://example.com/callback', - scope: undefined, - codeChallenge: 'test-challenge', - codeChallengeMethod: 'S256', - nonce: 'test-nonce', - expiresAt: '2025-01-01', + responseType: 'code', + expiresAt: '2025-01-01T00:00:00Z', }); - await expect( - oidc.getAuthorizationCode({ code: 'test-code' }), - ).resolves.toEqual(authorizationCode); + const authCode = await oidc.createAuthorizationCode({ + code: 'test-code', + sessionId: session.id, + expiresAt: '2025-01-01T00:00:00Z', + }); + + expect(authCode).toEqual( + expect.objectContaining({ + code: 'test-code', + sessionId: session.id, + expiresAt: '2025-01-01T00:00:00Z', + }), + ); }); - it('should return null if the authorization code does not exist', async () => { + it('should return authorization code with session data', async () => { const { oidc } = await createOidcDatabase(databaseId); - await expect( - oidc.getAuthorizationCode({ code: 'test-code' }), - ).resolves.toBeNull(); - }); - - it('should return the authorization code when created', async () => { - const { oidc } = await createOidcDatabase(databaseId); - - const mockClient = await oidc.createClient({ + const client = await oidc.createClient({ clientId: 'test-client', clientName: 'Test Client', clientSecret: 'test-secret', @@ -144,27 +325,40 @@ describe('Oidc Database', () => { grantTypes: ['authorization_code'], }); - const authorizationCode = await oidc.createAuthorizationCode({ - code: 'test-code', - clientId: mockClient.clientId, + const session = await oidc.createAuthorizationSession({ + id: 'test-session', + clientId: client.clientId, userEntityRef: 'user:default/blam', redirectUri: 'https://example.com/callback', - scope: undefined, + responseType: 'code', + scope: 'openid', codeChallenge: 'test-challenge', codeChallengeMethod: 'S256', nonce: 'test-nonce', - expiresAt: '2025-01-01', + expiresAt: '2025-01-01T00:00:00Z', }); - await expect( - oidc.getAuthorizationCode({ code: 'test-code' }), - ).resolves.toEqual(authorizationCode); + const authCode = await oidc.createAuthorizationCode({ + code: 'test-code', + sessionId: session.id, + expiresAt: '2025-01-01T00:00:00Z', + }); + + const authCodeFromDb = await oidc.getAuthorizationCode({ + code: 'test-code', + }); + const sessionFromDb = await oidc.getAuthorizationSession({ + id: authCodeFromDb!.sessionId, + }); + + expect(authCodeFromDb).toEqual(authCode); + expect(sessionFromDb).toEqual(session); }); it('should allow updating the authorization code', async () => { const { oidc } = await createOidcDatabase(databaseId); - const mockClient = await oidc.createClient({ + const client = await oidc.createClient({ clientId: 'test-client', clientName: 'Test Client', clientSecret: 'test-secret', @@ -173,24 +367,27 @@ describe('Oidc Database', () => { grantTypes: ['authorization_code'], }); - const authorizationCode = await oidc.createAuthorizationCode({ - code: 'test-code', - clientId: mockClient.clientId, - userEntityRef: 'user:default/blam', + const session = await oidc.createAuthorizationSession({ + id: 'test-session', + clientId: client.clientId, redirectUri: 'https://example.com/callback', - codeChallenge: 'test-challenge', - codeChallengeMethod: 'S256', - nonce: 'test-nonce', - expiresAt: '2025-01-01', + responseType: 'code', + expiresAt: '2025-01-01T00:00:00Z', }); - await expect( - oidc.updateAuthorizationCode({ - code: 'test-code', - used: true, - }), - ).resolves.toEqual({ - ...authorizationCode, + const authCode = await oidc.createAuthorizationCode({ + code: 'test-code', + sessionId: session.id, + expiresAt: '2025-01-01T00:00:00Z', + }); + + const updatedAuthCode = await oidc.updateAuthorizationCode({ + code: 'test-code', + used: true, + }); + + expect(updatedAuthCode).toEqual({ + ...authCode, used: true, }); }); @@ -200,7 +397,7 @@ describe('Oidc Database', () => { it('should create and return an access token', async () => { const { oidc } = await createOidcDatabase(databaseId); - const mockClient = await oidc.createClient({ + const client = await oidc.createClient({ clientId: 'test-client', clientName: 'Test Client', clientSecret: 'test-secret', @@ -209,81 +406,27 @@ describe('Oidc Database', () => { grantTypes: ['authorization_code'], }); - const accessToken = await oidc.createAccessToken({ - tokenId: 'test-token', - clientId: mockClient.clientId, - userEntityRef: 'user:default/blam', - expiresAt: '2025-01-01', - revoked: false, - }); - - await expect( - oidc.getAccessToken({ tokenId: 'test-token' }), - ).resolves.toEqual(accessToken); - }); - - it('should return null if the access token does not exist', async () => { - const { oidc } = await createOidcDatabase(databaseId); - - await expect( - oidc.getAccessToken({ tokenId: 'test-token' }), - ).resolves.toBeNull(); - }); - - it('should return the access token when created', async () => { - const { oidc } = await createOidcDatabase(databaseId); - - const mockClient = await oidc.createClient({ - clientId: 'test-client', - clientName: 'Test Client', - clientSecret: 'test-secret', - redirectUris: ['https://example.com/callback'], - responseTypes: ['code'], - grantTypes: ['authorization_code'], + const session = await oidc.createAuthorizationSession({ + id: 'test-session', + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + expiresAt: '2025-01-01T00:00:00Z', }); const accessToken = await oidc.createAccessToken({ tokenId: 'test-token', - clientId: mockClient.clientId, - userEntityRef: 'user:default/blam', - expiresAt: '2025-01-01', - revoked: false, + sessionId: session.id, + expiresAt: '2025-01-01T00:00:00Z', }); - await expect( - oidc.getAccessToken({ tokenId: 'test-token' }), - ).resolves.toEqual(accessToken); - }); - - it('should allow updating the access token', async () => { - const { oidc } = await createOidcDatabase(databaseId); - - const mockClient = await oidc.createClient({ - clientId: 'test-client', - clientName: 'Test Client', - clientSecret: 'test-secret', - redirectUris: ['https://example.com/callback'], - responseTypes: ['code'], - grantTypes: ['authorization_code'], - }); - - const accessToken = await oidc.createAccessToken({ - tokenId: 'test-token', - clientId: mockClient.clientId, - userEntityRef: 'user:default/blam', - expiresAt: '2025-01-01', - revoked: false, - }); - - await expect( - oidc.updateAccessToken({ + expect(accessToken).toEqual( + expect.objectContaining({ tokenId: 'test-token', - revoked: true, + sessionId: session.id, + expiresAt: '2025-01-01T00:00:00Z', }), - ).resolves.toEqual({ - ...accessToken, - revoked: true, - }); + ); }); }); }); diff --git a/plugins/auth-backend/src/database/OidcDatabase.ts b/plugins/auth-backend/src/database/OidcDatabase.ts index 12554fba57..0c75290f12 100644 --- a/plugins/auth-backend/src/database/OidcDatabase.ts +++ b/plugins/auth-backend/src/database/OidcDatabase.ts @@ -16,13 +16,10 @@ import { Knex } from 'knex'; import { AuthDatabase } from './AuthDatabase'; -import { DateTime } from 'luxon'; - type OidcClientRow = { client_id: string; client_secret: string; client_name: string; - created_at: string; expires_at: string | null; response_types: string; grant_types: string; @@ -31,31 +28,41 @@ type OidcClientRow = { metadata: string | null; }; -type OidcAuthorizationCodeRow = { - code: string; +type OAuthAuthorizationSessionRow = { + id: string; client_id: string; - user_entity_ref: string; + user_entity_ref: string | null; redirect_uri: string; scope: string | null; + state: string | null; + response_type: string; code_challenge: string | null; code_challenge_method: string | null; nonce: string | null; - created_at: string; + status: 'pending' | 'approved' | 'rejected' | 'expired'; expires_at: string; - used?: boolean; +}; + +type OidcConsentRequestRow = { + id: string; + session_id: string; + expires_at: string; +}; + +type OidcAuthorizationCodeRow = { + code: string; + session_id: string; + expires_at: string; + used: boolean; }; type OidcAccessTokenRow = { token_id: string; - client_id: string; - user_entity_ref: string; - scope: string | null; - created_at: string; + session_id: string; expires_at: string; - revoked?: boolean; }; -type Client = { +export type Client = { clientId: string; clientName: string; clientSecret: string; @@ -65,31 +72,40 @@ type Client = { scope?: string; expiresAt?: string; metadata?: Record; - createdAt: string; }; -type AuthorizationCode = { - code: string; +export type AuthorizationSession = { + id: string; clientId: string; - userEntityRef: string; + userEntityRef?: string; redirectUri: string; scope?: string; + state?: string; + responseType: string; codeChallenge?: string; codeChallengeMethod?: string; nonce?: string; - createdAt: string; + status: 'pending' | 'approved' | 'rejected' | 'expired'; + expiresAt: string; +}; + +export type ConsentRequest = { + id: string; + sessionId: string; + expiresAt: string; +}; + +export type AuthorizationCode = { + code: string; + sessionId: string; expiresAt: string; used: boolean; }; -type AccessToken = { +export type AccessToken = { tokenId: string; - clientId: string; - userEntityRef: string; - scope?: string; - createdAt: string; + sessionId: string; expiresAt: string; - revoked?: boolean; }; /** @@ -104,14 +120,11 @@ export class OidcDatabase { return new OidcDatabase(client); } - async createClient(client: Omit) { - const now = DateTime.now().toString(); - + async createClient(client: Client) { await this.db('oidc_clients').insert({ client_id: client.clientId, client_secret: client.clientSecret, client_name: client.clientName, - created_at: now, expires_at: client.expiresAt, response_types: JSON.stringify(client.responseTypes), grant_types: JSON.stringify(client.grantTypes), @@ -120,10 +133,7 @@ export class OidcDatabase { metadata: JSON.stringify(client.metadata), }); - return { - ...client, - createdAt: now, - }; + return client; } async getClient({ clientId }: { clientId: string }) { @@ -138,44 +148,122 @@ export class OidcDatabase { return this.rowToClient(client) as Client; } - async createAuthorizationCode( - authorizationCode: Omit, + async createAuthorizationSession( + session: Omit, ) { - const now = DateTime.now().toString(); + await this.db( + 'oauth_authorization_sessions', + ).insert({ + id: session.id, + client_id: session.clientId, + user_entity_ref: session.userEntityRef, + redirect_uri: session.redirectUri, + scope: session.scope, + state: session.state, + response_type: session.responseType, + code_challenge: session.codeChallenge, + code_challenge_method: session.codeChallengeMethod, + nonce: session.nonce, + status: 'pending', + expires_at: session.expiresAt, + }); + return { + ...session, + status: 'pending', + }; + } + + async updateAuthorizationSession( + session: Partial & { id: string }, + ) { + const row = this.authorizationSessionToRow(session); + const updatedFields = Object.fromEntries( + Object.entries(row).filter(([_, value]) => value !== undefined), + ); + + const [updated] = await this.db( + 'oauth_authorization_sessions', + ) + .where('id', session.id) + .update(updatedFields) + .returning('*'); + + return this.rowToAuthorizationSession(updated) as AuthorizationSession; + } + + async createConsentRequest(consentRequest: ConsentRequest) { + await this.db('oidc_consent_requests').insert({ + id: consentRequest.id, + session_id: consentRequest.sessionId, + expires_at: consentRequest.expiresAt, + }); + + return consentRequest; + } + + async getConsentRequest({ id }: { id: string }) { + const consentRequest = await this.db( + 'oidc_consent_requests', + ) + .where('id', id) + .first(); + + if (!consentRequest) { + return null; + } + + return this.rowToConsentRequest(consentRequest) as ConsentRequest; + } + + async getAuthorizationSession({ id }: { id: string }) { + const session = await this.db( + 'oauth_authorization_sessions', + ) + .where('id', id) + .first(); + + if (!session) { + return null; + } + + return this.rowToAuthorizationSession(session) as AuthorizationSession; + } + + async deleteConsentRequest({ id }: { id: string }) { + await this.db('oidc_consent_requests') + .where('id', id) + .delete(); + } + + async createAuthorizationCode( + authorizationCode: Omit, + ) { await this.db('oidc_authorization_codes').insert({ code: authorizationCode.code, - client_id: authorizationCode.clientId, - user_entity_ref: authorizationCode.userEntityRef, - redirect_uri: authorizationCode.redirectUri, - scope: authorizationCode.scope, - code_challenge: authorizationCode.codeChallenge, - code_challenge_method: authorizationCode.codeChallengeMethod, - nonce: authorizationCode.nonce, + session_id: authorizationCode.sessionId, expires_at: authorizationCode.expiresAt, - created_at: now, used: false, }); return { ...authorizationCode, - createdAt: now, used: false, }; } async getAuthorizationCode({ code }: { code: string }) { - const authorizationCode = await this.db( + const authCode = await this.db( 'oidc_authorization_codes', ) .where('code', code) .first(); - if (!authorizationCode) { + if (!authCode) { return null; } - return this.rowToAuthorizationCode(authorizationCode) as AuthorizationCode; + return this.rowToAuthorizationCode(authCode) as AuthorizationCode; } async updateAuthorizationCode( @@ -196,50 +284,14 @@ export class OidcDatabase { return this.rowToAuthorizationCode(updated) as AuthorizationCode; } - async createAccessToken(accessToken: Omit) { - const now = DateTime.now().toString(); - + async createAccessToken(accessToken: AccessToken) { await this.db('oidc_access_tokens').insert({ token_id: accessToken.tokenId, - client_id: accessToken.clientId, - user_entity_ref: accessToken.userEntityRef, - scope: accessToken.scope, - created_at: now, + session_id: accessToken.sessionId, expires_at: accessToken.expiresAt, - revoked: accessToken.revoked ?? false, }); - return { - ...accessToken, - createdAt: now, - }; - } - - async getAccessToken({ tokenId }: { tokenId: string }) { - const accessToken = await this.db('oidc_access_tokens') - .where('token_id', tokenId) - .first(); - - if (!accessToken) { - return null; - } - - return this.rowToAccessToken(accessToken) as AccessToken; - } - - async updateAccessToken( - accessToken: Partial & { tokenId: string }, - ) { - const row = this.accessTokenToRow(accessToken); - const updatedFields = Object.fromEntries( - Object.entries(row).filter(([_, value]) => value !== undefined), - ); - const [updated] = await this.db('oidc_access_tokens') - .where('token_id', accessToken.tokenId) - .update(updatedFields) - .returning('*'); - - return this.rowToAccessToken(updated) as AccessToken; + return accessToken; } private rowToClient(row: Partial): Partial { @@ -257,7 +309,54 @@ export class OidcDatabase { scope: row.scope ?? undefined, expiresAt: row.expires_at ?? undefined, metadata: row.metadata ? JSON.parse(row.metadata) : undefined, - createdAt: row.created_at, + }; + } + + private authorizationSessionToRow( + session: Partial, + ): Partial { + return { + id: session.id, + client_id: session.clientId, + user_entity_ref: session.userEntityRef, + redirect_uri: session.redirectUri, + scope: session.scope, + state: session.state, + response_type: session.responseType, + code_challenge: session.codeChallenge, + code_challenge_method: session.codeChallengeMethod, + nonce: session.nonce, + status: session.status, + expires_at: session.expiresAt, + }; + } + + private rowToAuthorizationSession( + row: Partial, + ): Partial { + return { + id: row.id, + clientId: row.client_id, + userEntityRef: row.user_entity_ref ?? undefined, + redirectUri: row.redirect_uri, + scope: row.scope ?? undefined, + state: row.state ?? undefined, + responseType: row.response_type, + codeChallenge: row.code_challenge ?? undefined, + codeChallengeMethod: row.code_challenge_method ?? undefined, + nonce: row.nonce ?? undefined, + status: row.status, + expiresAt: row.expires_at, + }; + } + + private rowToConsentRequest( + row: Partial, + ): Partial { + return { + id: row.id, + sessionId: row.session_id, + expiresAt: row.expires_at, }; } @@ -266,14 +365,7 @@ export class OidcDatabase { ): Partial { return { code: authorizationCode.code, - client_id: authorizationCode.clientId, - user_entity_ref: authorizationCode.userEntityRef, - redirect_uri: authorizationCode.redirectUri, - scope: authorizationCode.scope, - code_challenge: authorizationCode.codeChallenge, - code_challenge_method: authorizationCode.codeChallengeMethod, - nonce: authorizationCode.nonce, - created_at: authorizationCode.createdAt, + session_id: authorizationCode.sessionId, expires_at: authorizationCode.expiresAt, used: authorizationCode.used, }; @@ -284,44 +376,9 @@ export class OidcDatabase { ): Partial { return { code: row.code, - clientId: row.client_id, - userEntityRef: row.user_entity_ref, - redirectUri: row.redirect_uri, - scope: row.scope ?? undefined, - codeChallenge: row.code_challenge ?? undefined, - codeChallengeMethod: row.code_challenge_method ?? undefined, - nonce: row.nonce ?? undefined, - createdAt: row.created_at, + sessionId: row.session_id, expiresAt: row.expires_at, used: Boolean(row.used), }; } - - private accessTokenToRow( - accessToken: Partial, - ): Partial { - return { - token_id: accessToken.tokenId, - client_id: accessToken.clientId, - user_entity_ref: accessToken.userEntityRef, - scope: accessToken.scope, - created_at: accessToken.createdAt, - expires_at: accessToken.expiresAt, - revoked: accessToken.revoked, - }; - } - - private rowToAccessToken( - row: Partial, - ): Partial { - return { - tokenId: row.token_id, - clientId: row.client_id, - userEntityRef: row.user_entity_ref, - scope: row.scope ?? undefined, - createdAt: row.created_at, - expiresAt: row.expires_at, - revoked: Boolean(row.revoked), - }; - } } From eb2297fe6d792dd03aa766b97802adee7d1f18b7 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 3 Jul 2025 19:36:00 +0200 Subject: [PATCH 060/107] chore: updating the oidc service to handle consent Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- .../auth-backend/src/service/OidcService.ts | 249 ++++++++++++++++-- 1 file changed, 234 insertions(+), 15 deletions(-) diff --git a/plugins/auth-backend/src/service/OidcService.ts b/plugins/auth-backend/src/service/OidcService.ts index cc60b2b432..4214f53a8c 100644 --- a/plugins/auth-backend/src/service/OidcService.ts +++ b/plugins/auth-backend/src/service/OidcService.ts @@ -112,6 +112,9 @@ export class OidcService { const generatedClientId = crypto.randomUUID(); const generatedClientSecret = crypto.randomUUID(); + // todo(blam): add validation for redirectUris here. + // should be a list of urls and / or allowed schemes or something. + return await this.oidc.createClient({ clientId: generatedClientId, clientName: opts.clientName, @@ -123,6 +126,194 @@ export class OidcService { }); } + public async createConsentRequest(opts: { + clientId: string; + redirectUri: string; + responseType: string; + scope?: string; + state?: string; + nonce?: string; + codeChallenge?: string; + codeChallengeMethod?: string; + }) { + const { + clientId, + redirectUri, + responseType, + scope, + state, + nonce, + codeChallenge, + codeChallengeMethod, + } = opts; + + if (responseType !== 'code') { + throw new InputError('Only authorization code flow is supported'); + } + + const client = await this.oidc.getClient({ clientId }); + if (!client) { + throw new InputError('Invalid client_id'); + } + + if (!client.redirectUris.includes(redirectUri)) { + throw new InputError('Invalid redirect_uri'); + } + + if (codeChallenge) { + if ( + !codeChallengeMethod || + !['S256', 'plain'].includes(codeChallengeMethod) + ) { + throw new InputError('Invalid code_challenge_method'); + } + } + + const sessionId = crypto.randomUUID(); + const sessionExpiresAt = DateTime.now().plus({ hours: 1 }).toISO(); + + await this.oidc.createAuthorizationSession({ + id: sessionId, + clientId, + redirectUri, + responseType, + scope, + state, + codeChallenge, + codeChallengeMethod, + nonce, + expiresAt: sessionExpiresAt, + }); + + const consentRequestId = crypto.randomUUID(); + const consentExpiresAt = DateTime.now().plus({ minutes: 30 }).toISO(); + + await this.oidc.createConsentRequest({ + id: consentRequestId, + sessionId, + expiresAt: consentExpiresAt, + }); + + return { + consentRequestId, + clientName: client.clientName, + scope, + redirectUri, + }; + } + + public async approveConsentRequest(opts: { + consentRequestId: string; + userEntityRef: string; + }) { + const { consentRequestId, userEntityRef } = opts; + + const consentRequest = await this.oidc.getConsentRequest({ + id: consentRequestId, + }); + if (!consentRequest) { + throw new InputError('Invalid consent request'); + } + + if (DateTime.fromISO(consentRequest.expiresAt) < DateTime.now()) { + throw new InputError('Consent request expired'); + } + + const session = await this.oidc.getAuthorizationSession({ + id: consentRequest.sessionId, + }); + if (!session) { + throw new InputError('Invalid authorization session'); + } + + if (DateTime.fromISO(session.expiresAt) < DateTime.now()) { + throw new InputError('Authorization session expired'); + } + + await this.oidc.updateAuthorizationSession({ + id: session.id, + userEntityRef, + status: 'approved', + }); + + const authorizationCode = crypto.randomBytes(32).toString('base64url'); + const codeExpiresAt = DateTime.now().plus({ minutes: 10 }).toISO(); + + await this.oidc.createAuthorizationCode({ + code: authorizationCode, + sessionId: session.id, + expiresAt: codeExpiresAt, + }); + + await this.oidc.deleteConsentRequest({ id: consentRequestId }); + + const redirectUrl = new URL(session.redirectUri); + redirectUrl.searchParams.append('code', authorizationCode); + if (session.state) { + redirectUrl.searchParams.append('state', session.state); + } + + return { + redirectUrl: redirectUrl.toString(), + }; + } + + public async getConsentRequest(opts: { consentRequestId: string }) { + const consentRequest = await this.oidc.getConsentRequest({ + id: opts.consentRequestId, + }); + if (!consentRequest) { + throw new InputError('Invalid consent request'); + } + + if (DateTime.fromISO(consentRequest.expiresAt) < DateTime.now()) { + throw new InputError('Consent request expired'); + } + + const session = await this.oidc.getAuthorizationSession({ + id: consentRequest.sessionId, + }); + + if (!session) { + throw new InputError('Invalid authorization session'); + } + + const client = await this.oidc.getClient({ clientId: session.clientId }); + if (!client) { + throw new InputError('Invalid client_id'); + } + + return { + id: consentRequest.id, + clientId: session.clientId, + clientName: client.clientName, + redirectUri: session.redirectUri, + scope: session.scope, + state: session.state, + responseType: session.responseType, + codeChallenge: session.codeChallenge, + codeChallengeMethod: session.codeChallengeMethod, + nonce: session.nonce, + expiresAt: consentRequest.expiresAt, + }; + } + + public async deleteConsentRequest(opts: { consentRequestId: string }) { + const consentRequest = await this.oidc.getConsentRequest({ + id: opts.consentRequestId, + }); + if (!consentRequest) { + return; + } + + await this.oidc.updateAuthorizationSession({ + id: consentRequest.sessionId, + status: 'rejected', + }); + + await this.oidc.deleteConsentRequest({ id: opts.consentRequestId }); + } + public async authorize(opts: { clientId: string; redirectUri: string; @@ -168,19 +359,35 @@ export class OidcService { } } - const authorizationCode = crypto.randomBytes(32).toString('base64url'); - const expiresAt = DateTime.now().plus({ minutes: 10 }).toISO(); + const sessionId = crypto.randomUUID(); + const sessionExpiresAt = DateTime.now().plus({ hours: 1 }).toISO(); - await this.oidc.createAuthorizationCode({ - code: authorizationCode, + await this.oidc.createAuthorizationSession({ + id: sessionId, clientId, userEntityRef, redirectUri, + responseType, scope, + state, codeChallenge, codeChallengeMethod, nonce, - expiresAt, + expiresAt: sessionExpiresAt, + }); + + await this.oidc.updateAuthorizationSession({ + id: sessionId, + status: 'approved', + }); + + const authorizationCode = crypto.randomBytes(32).toString('base64url'); + const codeExpiresAt = DateTime.now().plus({ minutes: 10 }).toISO(); + + await this.oidc.createAuthorizationCode({ + code: authorizationCode, + sessionId, + expiresAt: codeExpiresAt, }); const redirectUrl = new URL(redirectUri); @@ -237,24 +444,38 @@ export class OidcService { throw new AuthenticationError('Authorization code already used'); } - if (authCode.clientId !== clientId) { + const session = await this.oidc.getAuthorizationSession({ + id: authCode.sessionId, + }); + if (!session) { + throw new AuthenticationError('Invalid authorization session'); + } + if (session.clientId !== clientId) { throw new AuthenticationError('Client ID mismatch'); } - if (authCode.redirectUri !== redirectUri) { + if (session.redirectUri !== redirectUri) { throw new AuthenticationError('Redirect URI mismatch'); } - if (authCode.codeChallenge) { + if (session.status !== 'approved') { + throw new AuthenticationError('Authorization not approved'); + } + + if (!session.userEntityRef) { + throw new AuthenticationError('No user associated with authorization'); + } + + if (session.codeChallenge) { if (!codeVerifier) { throw new AuthenticationError('Code verifier required for PKCE'); } if ( !this.verifyPkce( - authCode.codeChallenge, + session.codeChallenge, codeVerifier, - authCode.codeChallengeMethod, + session.codeChallengeMethod, ) ) { throw new AuthenticationError('Invalid code verifier'); @@ -271,15 +492,13 @@ export class OidcService { await this.oidc.createAccessToken({ tokenId: accessTokenId, - clientId, - userEntityRef: authCode.userEntityRef, - scope: authCode.scope, + sessionId: session.id, expiresAt, }); const { token } = await this.tokenIssuer.issueToken({ claims: { - sub: authCode.userEntityRef, + sub: session.userEntityRef, }, }); @@ -288,7 +507,7 @@ export class OidcService { tokenType: 'Bearer', expiresIn: 3600, idToken: token, - scope: authCode.scope || 'openid', + scope: session.scope || 'openid', }; } From ff251064ae57fce729d3cf7a0164523e28e89ffb Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 3 Jul 2025 19:36:24 +0200 Subject: [PATCH 061/107] chore: implementing the routers Signed-off-by: benjdlambert --- .../src/service/OidcRouter.test.ts | 4 + .../auth-backend/src/service/OidcRouter.ts | 183 ++++++++++++++++-- plugins/auth-backend/src/service/router.ts | 1 + 3 files changed, 177 insertions(+), 11 deletions(-) diff --git a/plugins/auth-backend/src/service/OidcRouter.test.ts b/plugins/auth-backend/src/service/OidcRouter.test.ts index ccc2426035..c0f1543487 100644 --- a/plugins/auth-backend/src/service/OidcRouter.test.ts +++ b/plugins/auth-backend/src/service/OidcRouter.test.ts @@ -60,6 +60,8 @@ describe('OidcRouter', () => { auth, tokenIssuer: {} as any, baseUrl: 'http://localhost:7000', + appUrl: 'http://localhost:3000', + logger: mockServices.logger.mock(), userInfo: mockUserInfo, oidc: mockOidc, }).getRouter(), @@ -130,6 +132,8 @@ describe('OidcRouter', () => { auth, tokenIssuer: {} as any, baseUrl: 'http://localhost:7000', + appUrl: 'http://localhost:3000', + logger: mockServices.logger.mock(), userInfo: mockUserInfo, oidc: mockOidc, }).getRouter(), diff --git a/plugins/auth-backend/src/service/OidcRouter.ts b/plugins/auth-backend/src/service/OidcRouter.ts index 0e14b91d79..ba2f2ccef9 100644 --- a/plugins/auth-backend/src/service/OidcRouter.ts +++ b/plugins/auth-backend/src/service/OidcRouter.ts @@ -26,17 +26,25 @@ export class OidcRouter { private constructor( private readonly oidc: OidcService, private readonly logger: LoggerService, + private readonly auth: AuthService, + private readonly appUrl: string, ) {} static create(options: { auth: AuthService; tokenIssuer: TokenIssuer; baseUrl: string; + appUrl: string; logger: LoggerService; userInfo: UserInfoDatabase; oidc: OidcDatabase; }) { - return new OidcRouter(OidcService.create(options), options.logger); + return new OidcRouter( + OidcService.create(options), + options.logger, + options.auth, + options.appUrl, + ); } public getRouter() { @@ -44,15 +52,25 @@ export class OidcRouter { router.use(json()); + // OpenID Provider Configuration endpoint + // https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig + // Returns the OpenID Provider Configuration document containing metadata about the provider router.get('/.well-known/openid-configuration', (_req, res) => { res.json(this.oidc.getConfiguration()); }); + // JSON Web Key Set endpoint + // https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.10.1.1 + // Returns the public keys used to verify JWTs issued by this provider router.get('/.well-known/jwks.json', async (_req, res) => { const { keys } = await this.oidc.listPublicKeys(); res.json({ keys }); }); + // Authorization endpoint + // https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + // Handles the initial authorization request from the client, validates parameters, + // and redirects to the consent page for user approval router.get('/v1/authorize', async (req, res) => { // todo(blam): maybe add zod types for validating input const { @@ -76,11 +94,7 @@ export class OidcRouter { } try { - // use default user entity ref for now, as we need a redirect to the frontend plugin - // for the consent flow in order to issue the right token for the right user. - const userEntityRef = 'user:default/guest'; - - const { redirectUrl } = await this.oidc.authorize({ + const result = await this.oidc.createConsentRequest({ clientId: clientId as string, redirectUri: redirectUri as string, responseType: responseType as string, @@ -89,10 +103,14 @@ export class OidcRouter { nonce: nonce as string, codeChallenge: codeChallenge as string, codeChallengeMethod: codeChallengeMethod as string, - userEntityRef, }); - return res.redirect(redirectUrl); + // todo(blam): maybe this URL could be overridable by config if + // the plugin is mounted somewhere else? + const consentUrl = new URL('/oidc/consent', this.appUrl); + consentUrl.searchParams.append('consent_id', result.consentRequestId); + + return res.redirect(consentUrl.toString()); } catch (error) { const errorParams = new URLSearchParams(); errorParams.append( @@ -113,6 +131,146 @@ export class OidcRouter { } }); + // Consent request details endpoint + // Returns consent request details for the frontend consent page + router.get('/v1/consent/:consentId', async (req, res) => { + const { consentId } = req.params; + + if (!consentId) { + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Missing consent ID', + }); + } + + try { + const consentRequest = await this.oidc.getConsentRequest({ + consentRequestId: consentId, + }); + + return res.json({ + id: consentRequest.id, + clientName: consentRequest.clientName, + scope: consentRequest.scope, + redirectUri: consentRequest.redirectUri, + }); + } catch (error) { + this.logger.error( + `Failed to get consent request: ${ + isError(error) ? error.message : 'Unknown error' + }`, + error, + ); + return res.status(404).json({ + error: 'not_found', + error_description: 'Consent request not found or expired', + }); + } + }); + + // Consent approval endpoint + // Handles user approval of consent requests and generates authorization codes + router.post('/v1/consent/:consentId/approve', async (req, res) => { + const { consentId } = req.params; + + if (!consentId) { + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Missing consent ID', + }); + } + + try { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + return res.status(401).json({ + error: 'unauthorized', + error_description: 'Bearer token required', + }); + } + + const token = authHeader.substring(7); + const credentials = await this.auth.authenticate(token); + if (!this.auth.isPrincipal(credentials, 'user')) { + return res.status(401).json({ + error: 'unauthorized', + error_description: 'Authentication required', + }); + } + + const userEntityRef = credentials.principal.userEntityRef; + + const result = await this.oidc.approveConsentRequest({ + consentRequestId: consentId, + userEntityRef, + }); + + return res.json({ + redirectUrl: result.redirectUrl, + }); + } catch (error) { + this.logger.error( + `Failed to approve consent: ${ + isError(error) ? error.message : 'Unknown error' + }`, + error, + ); + return res.status(400).json({ + error: 'invalid_request', + error_description: isError(error) ? error.message : 'Unknown error', + }); + } + }); + + // Consent rejection endpoint + // Handles user rejection of consent requests and redirects with error + router.post('/v1/consent/:consentId/reject', async (req, res) => { + const { consentId } = req.params; + + if (!consentId) { + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Missing consent ID', + }); + } + + try { + const consentRequest = await this.oidc.getConsentRequest({ + consentRequestId: consentId, + }); + + await this.oidc.deleteConsentRequest({ consentRequestId: consentId }); + + const errorParams = new URLSearchParams(); + errorParams.append('error', 'access_denied'); + errorParams.append('error_description', 'User denied the request'); + if (consentRequest.state) { + errorParams.append('state', consentRequest.state); + } + + const redirectUrl = new URL(consentRequest.redirectUri); + redirectUrl.search = errorParams.toString(); + + return res.json({ + redirectUrl: redirectUrl.toString(), + }); + } catch (error) { + this.logger.error( + `Failed to reject consent: ${ + isError(error) ? error.message : 'Unknown error' + }`, + error, + ); + return res.status(400).json({ + error: 'invalid_request', + error_description: isError(error) ? error.message : 'Unknown error', + }); + } + }); + + // Token endpoint + // https://openid.net/specs/openid-connect-core-1_0.html#TokenRequest + // Exchanges authorization codes for access tokens and ID tokens router.post('/v1/token', async (req, res) => { // todo(blam): maybe add zod types for validating input const { @@ -180,9 +338,9 @@ export class OidcRouter { } }); - // This endpoint doesn't use the regular HttpAuth, since the contract - // is specifically for the header to be communicated in the Authorization - // header, regardless of token type + // UserInfo endpoint + // https://openid.net/specs/openid-connect-core-1_0.html#UserInfo + // Returns claims about the authenticated user using an access token router.get('/v1/userinfo', async (req, res) => { const matches = req.headers.authorization?.match(/^Bearer[ ]+(\S+)$/i); const token = matches?.[1]; @@ -200,6 +358,9 @@ export class OidcRouter { res.json(userInfo); }); + // Dynamic Client Registration endpoint + // https://openid.net/specs/openid-connect-registration-1_0.html#ClientRegistration + // Allows clients to register themselves dynamically with the provider router.post('/v1/register', async (req, res) => { // todo(blam): maybe add zod types for validating input const registrationRequest = req.body; diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 5eb3450451..28f218c749 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -154,6 +154,7 @@ export async function createRouter( auth: options.auth, tokenIssuer, baseUrl: authUrl, + appUrl, userInfo, oidc, logger, From e31a1e2c0c71dbe5998bff45bd84d30267581094 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 3 Jul 2025 19:47:41 +0200 Subject: [PATCH 062/107] chore: fixing redirect path Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- plugins/auth-backend/src/service/OidcRouter.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/auth-backend/src/service/OidcRouter.ts b/plugins/auth-backend/src/service/OidcRouter.ts index ba2f2ccef9..1db444db59 100644 --- a/plugins/auth-backend/src/service/OidcRouter.ts +++ b/plugins/auth-backend/src/service/OidcRouter.ts @@ -107,8 +107,10 @@ export class OidcRouter { // todo(blam): maybe this URL could be overridable by config if // the plugin is mounted somewhere else? - const consentUrl = new URL('/oidc/consent', this.appUrl); - consentUrl.searchParams.append('consent_id', result.consentRequestId); + const consentUrl = new URL( + `/auth/consent/${result.consentRequestId}`, + this.appUrl, + ); return res.redirect(consentUrl.toString()); } catch (error) { From ebe65724e4f7c404c781320170d18d6cf7f2bfed Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Fri, 4 Jul 2025 08:39:16 +0200 Subject: [PATCH 063/107] chore: added some tests for oidcservice Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- .../auth-backend/src/service/OidcRouter.ts | 13 +- .../src/service/OidcService.test.ts | 649 ++++++++++++++++++ 2 files changed, 654 insertions(+), 8 deletions(-) create mode 100644 plugins/auth-backend/src/service/OidcService.test.ts diff --git a/plugins/auth-backend/src/service/OidcRouter.ts b/plugins/auth-backend/src/service/OidcRouter.ts index 1db444db59..c7bb42e1f5 100644 --- a/plugins/auth-backend/src/service/OidcRouter.ts +++ b/plugins/auth-backend/src/service/OidcRouter.ts @@ -257,15 +257,12 @@ export class OidcRouter { redirectUrl: redirectUrl.toString(), }); } catch (error) { - this.logger.error( - `Failed to reject consent: ${ - isError(error) ? error.message : 'Unknown error' - }`, - error, - ); + const description = isError(error) ? error.message : 'Unknown error'; + this.logger.error(`Failed to reject consent: ${description}`, error); + return res.status(400).json({ error: 'invalid_request', - error_description: isError(error) ? error.message : 'Unknown error', + error_description: description, }); } }); @@ -335,7 +332,7 @@ export class OidcRouter { return res.status(500).json({ error: 'server_error', - error_description: isError(error) ? error.message : 'Unknown error', + error_description: description, }); } }); diff --git a/plugins/auth-backend/src/service/OidcService.test.ts b/plugins/auth-backend/src/service/OidcService.test.ts new file mode 100644 index 0000000000..fa4c0a12ac --- /dev/null +++ b/plugins/auth-backend/src/service/OidcService.test.ts @@ -0,0 +1,649 @@ +/* + * Copyright 2025 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 { + mockServices, + TestDatabaseId, + TestDatabases, +} from '@backstage/backend-test-utils'; +import { OidcService } from './OidcService'; +import { + BackstageCredentials, + BackstageServicePrincipal, + BackstageUserPrincipal, + resolvePackagePath, +} from '@backstage/backend-plugin-api'; +import { AuthDatabase } from '../database/AuthDatabase'; +import { OidcDatabase } from '../database/OidcDatabase'; +import { UserInfoDatabase } from '../database/UserInfoDatabase'; +import { InputError, AuthenticationError } from '@backstage/errors'; +import crypto from 'crypto'; +import { AnyJWK, TokenIssuer } from '../identity/types'; + +describe('OidcService', () => { + const databases = TestDatabases.create(); + + async function createOidcService(databaseId: TestDatabaseId) { + const knex = await databases.init(databaseId); + + await knex.migrate.latest({ + directory: resolvePackagePath( + '@backstage/plugin-auth-backend', + 'migrations', + ), + }); + + const oidcDatabase = await OidcDatabase.create({ + database: AuthDatabase.create({ + getClient: async () => knex, + }), + }); + + const mockAuth = mockServices.auth.mock(); + const mockTokenIssuer = { + issueToken: jest.fn(), + listPublicKeys: jest.fn(), + } as jest.Mocked; + + const mockUserInfo = { + addUserInfo: jest.fn(), + getUserInfo: jest.fn(), + } as unknown as jest.Mocked; + + return { + service: OidcService.create({ + auth: mockAuth, + tokenIssuer: mockTokenIssuer, + baseUrl: 'http://mock-base-url', + userInfo: mockUserInfo, + oidc: oidcDatabase, + }), + mocks: { + auth: mockAuth, + tokenIssuer: mockTokenIssuer, + userInfo: mockUserInfo, + }, + }; + } + + describe.each(databases.eachSupportedId())('%p', databaseId => { + describe('getConfiguration', () => { + it('should return OIDC configuration', async () => { + const { service } = await createOidcService(databaseId); + + const config = service.getConfiguration(); + + expect(config).toEqual({ + issuer: 'http://mock-base-url', + token_endpoint: 'http://mock-base-url/v1/token', + userinfo_endpoint: 'http://mock-base-url/v1/userinfo', + jwks_uri: 'http://mock-base-url/.well-known/jwks.json', + response_types_supported: ['code', 'id_token'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: [ + 'RS256', + 'RS384', + 'RS512', + 'ES256', + 'ES384', + 'ES512', + 'PS256', + 'PS384', + 'PS512', + 'EdDSA', + ], + scopes_supported: ['openid'], + token_endpoint_auth_methods_supported: [ + 'client_secret_basic', + 'client_secret_post', + ], + claims_supported: ['sub', 'ent'], + grant_types_supported: ['authorization_code'], + authorization_endpoint: 'http://mock-base-url/v1/authorize', + registration_endpoint: 'http://mock-base-url/v1/register', + code_challenge_methods_supported: ['S256', 'plain'], + }); + }); + }); + + describe('listPublicKeys', () => { + it('should return public keys from token issuer', async () => { + const { service, mocks } = await createOidcService(databaseId); + const mockKeys = [{ kid: 'key-1', use: 'sig' }] as AnyJWK[]; + mocks.tokenIssuer.listPublicKeys.mockResolvedValue({ keys: mockKeys }); + + const { keys } = await service.listPublicKeys(); + + expect(keys).toEqual(mockKeys); + expect(mocks.tokenIssuer.listPublicKeys).toHaveBeenCalledTimes(1); + }); + }); + + describe('getUserInfo', () => { + it('should return user info for valid token', async () => { + const { service, mocks } = await createOidcService(databaseId); + const mockCredentials: BackstageCredentials = { + principal: { + type: 'user', + userEntityRef: 'user:default/test', + }, + $$type: '@backstage/BackstageCredentials', + }; + const mockUserInfo = { sub: 'user:default/test', name: 'Test User' }; + + mocks.auth.authenticate.mockResolvedValue(mockCredentials); + mocks.auth.isPrincipal.mockReturnValue(true); + mocks.userInfo.getUserInfo.mockResolvedValue({ + claims: mockUserInfo, + }); + + const mockToken = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvdGVzdCJ9.signature'; + + const userInfo = await service.getUserInfo({ token: mockToken }); + + expect(userInfo).toEqual({ + claims: mockUserInfo, + }); + + expect(mocks.auth.authenticate).toHaveBeenCalledWith(mockToken, { + allowLimitedAccess: true, + }); + + expect(mocks.userInfo.getUserInfo).toHaveBeenCalledWith( + 'user:default/test', + ); + }); + + it('should throw error for non-user principal', async () => { + const { service, mocks } = await createOidcService(databaseId); + const mockCredentials: BackstageCredentials = + { + principal: { + type: 'service', + subject: 'test-service', + }, + $$type: '@backstage/BackstageCredentials', + }; + + mocks.auth.authenticate.mockResolvedValue(mockCredentials); + mocks.auth.isPrincipal.mockReturnValue(false); + + const mockToken = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvdGVzdCJ9.signature'; + + await expect(service.getUserInfo({ token: mockToken })).rejects.toThrow( + 'Userinfo endpoint must be called with a token that represents a user principal', + ); + }); + }); + + describe('registerClient', () => { + it('should create a new client with generated credentials', async () => { + const { service } = await createOidcService(databaseId); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + scope: 'openid', + }); + + expect(client).toEqual( + expect.objectContaining({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + scope: 'openid', + }), + ); + expect(client.clientId).toBeDefined(); + expect(client.clientSecret).toBeDefined(); + }); + + it('should create a client with default values', async () => { + const { service } = await createOidcService(databaseId); + + const client = await service.registerClient({ + clientName: 'Test Client', + }); + + expect(client).toEqual( + expect.objectContaining({ + clientName: 'Test Client', + redirectUris: [], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + }), + ); + }); + }); + + describe('createConsentRequest', () => { + it('should create a consent request for valid client', async () => { + const { service } = await createOidcService(databaseId); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + }); + + const consent = await service.createConsentRequest({ + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + scope: 'openid', + state: 'test-state', + }); + + expect(consent).toEqual({ + consentRequestId: expect.any(String), + clientName: 'Test Client', + scope: 'openid', + redirectUri: 'https://example.com/callback', + }); + }); + + it('should throw error for invalid client', async () => { + const { service } = await createOidcService(databaseId); + + await expect( + service.createConsentRequest({ + clientId: 'invalid-client', + redirectUri: 'https://example.com/callback', + responseType: 'code', + }), + ).rejects.toThrow('Invalid client_id'); + }); + + it('should throw error for invalid redirect URI', async () => { + const { service } = await createOidcService(databaseId); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + }); + + await expect( + service.createConsentRequest({ + clientId: client.clientId, + redirectUri: 'https://invalid.com/callback', + responseType: 'code', + }), + ).rejects.toThrow('Invalid redirect_uri'); + }); + + it('should throw error for unsupported response type', async () => { + const { service } = await createOidcService(databaseId); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + }); + + await expect( + service.createConsentRequest({ + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'token', + }), + ).rejects.toThrow('Only authorization code flow is supported'); + }); + + it('should handle PKCE parameters', async () => { + const { service } = await createOidcService(databaseId); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + }); + + const consent = await service.createConsentRequest({ + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + codeChallenge: 'test-challenge', + codeChallengeMethod: 'S256', + }); + + expect(consent.consentRequestId).toBeDefined(); + }); + + it('should throw error for invalid PKCE method', async () => { + const { service } = await createOidcService(databaseId); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + }); + + await expect( + service.createConsentRequest({ + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + codeChallenge: 'test-challenge', + codeChallengeMethod: 'invalid', + }), + ).rejects.toThrow('Invalid code_challenge_method'); + }); + }); + + describe('approveConsentRequest', () => { + it('should approve a valid consent request', async () => { + const { service } = await createOidcService(databaseId); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + }); + + const consent = await service.createConsentRequest({ + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + state: 'test-state', + }); + + const result = await service.approveConsentRequest({ + consentRequestId: consent.consentRequestId, + userEntityRef: 'user:default/test', + }); + + expect(result.redirectUrl).toMatch( + /^https:\/\/example\.com\/callback\?code=.+&state=test-state$/, + ); + }); + + it('should throw error for invalid consent request', async () => { + const { service } = await createOidcService(databaseId); + + await expect( + service.approveConsentRequest({ + consentRequestId: 'invalid-consent', + userEntityRef: 'user:default/test', + }), + ).rejects.toThrow('Invalid consent request'); + }); + }); + + describe('getConsentRequest', () => { + it('should return consent request details', async () => { + const { service } = await createOidcService(databaseId); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + }); + + const consent = await service.createConsentRequest({ + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + scope: 'openid', + state: 'test-state', + }); + + const details = await service.getConsentRequest({ + consentRequestId: consent.consentRequestId, + }); + + expect(details).toEqual( + expect.objectContaining({ + id: consent.consentRequestId, + clientId: client.clientId, + clientName: 'Test Client', + redirectUri: 'https://example.com/callback', + scope: 'openid', + state: 'test-state', + responseType: 'code', + }), + ); + }); + }); + + describe('deleteConsentRequest', () => { + it('should delete a consent request', async () => { + const { service } = await createOidcService(databaseId); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + }); + + const consent = await service.createConsentRequest({ + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + }); + + await service.deleteConsentRequest({ + consentRequestId: consent.consentRequestId, + }); + + await expect( + service.getConsentRequest({ + consentRequestId: consent.consentRequestId, + }), + ).rejects.toThrow('Invalid consent request'); + }); + + it('should handle deleting non-existent consent request', async () => { + const { service } = await createOidcService(databaseId); + + await expect( + service.deleteConsentRequest({ + consentRequestId: 'non-existent', + }), + ).resolves.not.toThrow(); + }); + }); + + describe('authorize', () => { + it('should create direct authorization', async () => { + const { service } = await createOidcService(databaseId); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + }); + + const result = await service.authorize({ + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + userEntityRef: 'user:default/test', + state: 'test-state', + }); + + expect(result.redirectUrl).toMatch( + /^https:\/\/example\.com\/callback\?code=.+&state=test-state$/, + ); + }); + + it('should throw error for invalid client', async () => { + const { service } = await createOidcService(databaseId); + + await expect( + service.authorize({ + clientId: 'invalid-client', + redirectUri: 'https://example.com/callback', + responseType: 'code', + userEntityRef: 'user:default/test', + }), + ).rejects.toThrow('Invalid client_id'); + }); + }); + + describe('exchangeCodeForToken', () => { + it('should exchange valid code for tokens', async () => { + const { service, mocks } = await createOidcService(databaseId); + const mockToken = 'mock-jwt-token'; + mocks.tokenIssuer.issueToken.mockResolvedValue({ token: mockToken }); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + }); + + const authResult = await service.authorize({ + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + userEntityRef: 'user:default/test', + scope: 'openid', + }); + + const code = new URL(authResult.redirectUrl).searchParams.get('code')!; + + const tokenResult = await service.exchangeCodeForToken({ + code, + clientId: client.clientId, + clientSecret: client.clientSecret, + redirectUri: 'https://example.com/callback', + grantType: 'authorization_code', + }); + + expect(tokenResult).toEqual({ + accessToken: mockToken, + tokenType: 'Bearer', + expiresIn: 3600, + idToken: mockToken, + scope: 'openid', + }); + }); + + it('should throw error for invalid grant type', async () => { + const { service } = await createOidcService(databaseId); + + await expect( + service.exchangeCodeForToken({ + code: 'test-code', + clientId: 'test-client', + clientSecret: 'test-secret', + redirectUri: 'https://example.com/callback', + grantType: 'client_credentials', + }), + ).rejects.toThrow('Unsupported grant type'); + }); + + it('should throw error for invalid client', async () => { + const { service } = await createOidcService(databaseId); + + await expect( + service.exchangeCodeForToken({ + code: 'test-code', + clientId: 'invalid-client', + clientSecret: 'test-secret', + redirectUri: 'https://example.com/callback', + grantType: 'authorization_code', + }), + ).rejects.toThrow('Invalid client'); + }); + + it('should throw error for invalid client secret', async () => { + const { service } = await createOidcService(databaseId); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + }); + + await expect( + service.exchangeCodeForToken({ + code: 'test-code', + clientId: client.clientId, + clientSecret: 'invalid-secret', + redirectUri: 'https://example.com/callback', + grantType: 'authorization_code', + }), + ).rejects.toThrow('Invalid client credentials'); + }); + + it('should handle PKCE verification', async () => { + const { service, mocks } = await createOidcService(databaseId); + const mockToken = 'mock-jwt-token'; + mocks.tokenIssuer.issueToken.mockResolvedValue({ token: mockToken }); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + }); + + const codeVerifier = 'test-code-verifier'; + const codeChallenge = crypto + .createHash('sha256') + .update(codeVerifier) + .digest('base64url'); + + const authResult = await service.authorize({ + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + userEntityRef: 'user:default/test', + codeChallenge, + codeChallengeMethod: 'S256', + }); + + const code = new URL(authResult.redirectUrl).searchParams.get('code')!; + + const tokenResult = await service.exchangeCodeForToken({ + code, + clientId: client.clientId, + clientSecret: client.clientSecret, + redirectUri: 'https://example.com/callback', + grantType: 'authorization_code', + codeVerifier, + }); + + expect(tokenResult.accessToken).toBe(mockToken); + }); + + it('should throw error for invalid PKCE verifier', async () => { + const { service } = await createOidcService(databaseId); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + }); + + const codeChallenge = 'test-challenge'; + const authResult = await service.authorize({ + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + userEntityRef: 'user:default/test', + codeChallenge, + codeChallengeMethod: 'S256', + }); + + const code = new URL(authResult.redirectUrl).searchParams.get('code')!; + + await expect( + service.exchangeCodeForToken({ + code, + clientId: client.clientId, + clientSecret: client.clientSecret, + redirectUri: 'https://example.com/callback', + grantType: 'authorization_code', + codeVerifier: 'invalid-verifier', + }), + ).rejects.toThrow('Invalid code verifier'); + }); + }); + }); +}); From 0d320ca888ffcfa20e6ab50273b130d7d199cf89 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Fri, 4 Jul 2025 09:21:22 +0200 Subject: [PATCH 064/107] chore: added some tests for oidcrouter and refactor Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- .../src/service/OidcRouter.test.ts | 1000 ++++++++++++++--- 1 file changed, 867 insertions(+), 133 deletions(-) diff --git a/plugins/auth-backend/src/service/OidcRouter.test.ts b/plugins/auth-backend/src/service/OidcRouter.test.ts index c0f1543487..150065ede5 100644 --- a/plugins/auth-backend/src/service/OidcRouter.test.ts +++ b/plugins/auth-backend/src/service/OidcRouter.test.ts @@ -17,156 +17,890 @@ import { coreServices, createBackendPlugin, + resolvePackagePath, } from '@backstage/backend-plugin-api'; -import { mockServices, startTestBackend } from '@backstage/backend-test-utils'; -import Router from 'express-promise-router'; +import { + mockServices, + startTestBackend, + TestDatabases, + TestDatabaseId, +} from '@backstage/backend-test-utils'; import request from 'supertest'; +import crypto from 'crypto'; import { OidcRouter } from './OidcRouter'; import { UserInfoDatabase } from '../database/UserInfoDatabase'; import { OidcDatabase } from '../database/OidcDatabase'; +import { AuthDatabase } from '../database/AuthDatabase'; +import { OidcService } from '../service/OidcService'; +import { TokenIssuer } from '../identity/types'; describe('OidcRouter', () => { - describe('/v1/userinfo', () => { - it('should return user info for full tokens', async () => { - const auth = mockServices.auth.mock(); - const mockUserInfo = { - getUserInfo: jest.fn().mockResolvedValue({ - claims: { - sub: 'k/ns:n', - ent: ['k/ns:a', 'k/ns:b'], - }, - }), - } as unknown as UserInfoDatabase; + const databases = TestDatabases.create(); - const mockOidc = { - createClient: jest.fn().mockResolvedValue({ - clientId: 'test', - clientSecret: 'test', - }), - } as unknown as OidcDatabase; + async function createRouter(databaseId: TestDatabaseId) { + const knex = await databases.init(databaseId); - const { server } = await startTestBackend({ - features: [ - createBackendPlugin({ - pluginId: 'auth', - register(reg) { - reg.registerInit({ - deps: { httpRouter: coreServices.httpRouter }, - async init({ httpRouter }) { - const router = Router(); - - router.use( - OidcRouter.create({ - auth, - tokenIssuer: {} as any, - baseUrl: 'http://localhost:7000', - appUrl: 'http://localhost:3000', - logger: mockServices.logger.mock(), - userInfo: mockUserInfo, - oidc: mockOidc, - }).getRouter(), - ); - httpRouter.use(router); - httpRouter.addAuthPolicy({ - path: '/', - allow: 'unauthenticated', - }); - }, - }); - }, - }), - ], - }); - - auth.authenticate.mockResolvedValueOnce({} as any); - auth.isPrincipal.mockReturnValueOnce(true); - - await request(server) - .get('/api/auth/v1/userinfo') - .set( - 'Authorization', - `Bearer h.${btoa( - JSON.stringify({ sub: 'k/ns:n', ent: ['k/ns:a', 'k/ns:b'] }), - )}.s`, - ) - .expect(200, { - claims: { - sub: 'k/ns:n', - ent: ['k/ns:a', 'k/ns:b'], - }, - }); - - expect(mockUserInfo.getUserInfo).toHaveBeenCalledWith('k/ns:n'); + await knex.migrate.latest({ + directory: resolvePackagePath( + '@backstage/plugin-auth-backend', + 'migrations', + ), }); - it('should return user info for limited tokens', async () => { - const auth = mockServices.auth.mock(); - const mockUserInfo = { - getUserInfo: jest.fn().mockResolvedValue({ - claims: { - sub: 'k/ns:n', - ent: ['k/ns:a', 'k/ns:b'], - }, - }), - } as unknown as UserInfoDatabase; - - const mockOidc = { - createClient: jest.fn().mockResolvedValue({ - clientId: 'test', - clientSecret: 'test', - }), - } as unknown as OidcDatabase; - - const { server } = await startTestBackend({ - features: [ - createBackendPlugin({ - pluginId: 'auth', - register(reg) { - reg.registerInit({ - deps: { httpRouter: coreServices.httpRouter }, - async init({ httpRouter }) { - const router = Router(); - - router.use( - OidcRouter.create({ - auth, - tokenIssuer: {} as any, - baseUrl: 'http://localhost:7000', - appUrl: 'http://localhost:3000', - logger: mockServices.logger.mock(), - userInfo: mockUserInfo, - oidc: mockOidc, - }).getRouter(), - ); - httpRouter.use(router); - httpRouter.addAuthPolicy({ - path: '/', - allow: 'unauthenticated', - }); - }, - }); - }, - }), - ], - }); - - auth.authenticate.mockResolvedValueOnce({} as any); - auth.isPrincipal.mockReturnValueOnce(true); - - await request(server) - .get('/api/auth/v1/userinfo') - .set( - 'Authorization', - `Bearer h.${btoa(JSON.stringify({ sub: 'k/ns:n' }))}.s`, - ) - .expect(200, { + const authDatabase = AuthDatabase.create({ + getClient: async () => knex, + }); + + const oidcDatabase = await OidcDatabase.create({ + database: authDatabase, + }); + + const userInfoDatabase = await UserInfoDatabase.create({ + database: authDatabase, + }); + + const mockTokenIssuer = { + issueToken: jest.fn(), + listPublicKeys: jest.fn(), + } as unknown as jest.Mocked; + + const mockAuth = mockServices.auth.mock(); + + const oidcService = OidcService.create({ + auth: mockAuth, + tokenIssuer: mockTokenIssuer, + baseUrl: 'http://localhost:7000', + userInfo: userInfoDatabase, + oidc: oidcDatabase, + }); + + const oidcRouter = OidcRouter.create({ + auth: mockAuth, + tokenIssuer: mockTokenIssuer, + baseUrl: 'http://localhost:7000', + appUrl: 'http://localhost:3000', + logger: mockServices.logger.mock(), + userInfo: userInfoDatabase, + oidc: oidcDatabase, + }); + + return { + router: oidcRouter, + mocks: { + auth: mockAuth, + oidc: oidcDatabase, + userInfo: userInfoDatabase, + service: oidcService, + tokenIssuer: mockTokenIssuer, + }, + }; + } + + describe.each(databases.eachSupportedId())('%p', databaseId => { + describe('/v1/userinfo', () => { + it('should return user info for full tokens', async () => { + const { + mocks: { auth, userInfo }, + router, + } = await createRouter(databaseId); + + await userInfo.addUserInfo({ claims: { sub: 'k/ns:n', ent: ['k/ns:a', 'k/ns:b'], + exp: Math.floor(Date.now() / 1000) + 3600, }, }); - expect(mockUserInfo.getUserInfo).toHaveBeenCalledWith('k/ns:n'); + const { server } = await startTestBackend({ + features: [ + createBackendPlugin({ + pluginId: 'auth', + register(reg) { + reg.registerInit({ + deps: { httpRouter: coreServices.httpRouter }, + async init({ httpRouter }) { + httpRouter.use(router.getRouter()); + httpRouter.addAuthPolicy({ + path: '/', + allow: 'unauthenticated', + }); + }, + }); + }, + }), + ], + }); + + auth.authenticate.mockResolvedValueOnce({} as any); + auth.isPrincipal.mockReturnValueOnce(true); + + const response = await request(server) + .get('/api/auth/v1/userinfo') + .set( + 'Authorization', + `Bearer h.${btoa( + JSON.stringify({ sub: 'k/ns:n', ent: ['k/ns:a', 'k/ns:b'] }), + )}.s`, + ) + .expect(200); + + expect(response.body).toEqual({ + claims: { + sub: 'k/ns:n', + ent: ['k/ns:a', 'k/ns:b'], + exp: expect.any(Number), + }, + }); + }); + + it('should return user info for limited tokens', async () => { + const { + mocks: { auth, userInfo }, + router, + } = await createRouter(databaseId); + + await userInfo.addUserInfo({ + claims: { + sub: 'k/ns:n', + ent: ['k/ns:a', 'k/ns:b'], + exp: Math.floor(Date.now() / 1000) + 3600, + }, + }); + + const { server } = await startTestBackend({ + features: [ + createBackendPlugin({ + pluginId: 'auth', + register(reg) { + reg.registerInit({ + deps: { httpRouter: coreServices.httpRouter }, + async init({ httpRouter }) { + httpRouter.use(router.getRouter()); + httpRouter.addAuthPolicy({ + path: '/', + allow: 'unauthenticated', + }); + }, + }); + }, + }), + ], + }); + + auth.authenticate.mockResolvedValueOnce({} as any); + auth.isPrincipal.mockReturnValueOnce(true); + + const response = await request(server) + .get('/api/auth/v1/userinfo') + .set( + 'Authorization', + `Bearer h.${btoa(JSON.stringify({ sub: 'k/ns:n' }))}.s`, + ) + .expect(200); + + expect(response.body).toEqual({ + claims: { + sub: 'k/ns:n', + ent: ['k/ns:a', 'k/ns:b'], + exp: expect.any(Number), + }, + }); + }); + }); + + describe('consent flow', () => { + it('should register a client', async () => { + const { router } = await createRouter(databaseId); + + const { server } = await startTestBackend({ + features: [ + createBackendPlugin({ + pluginId: 'auth', + register(reg) { + reg.registerInit({ + deps: { httpRouter: coreServices.httpRouter }, + async init({ httpRouter }) { + httpRouter.use(router.getRouter()); + httpRouter.addAuthPolicy({ + path: '/', + allow: 'unauthenticated', + }); + }, + }); + }, + }), + ], + }); + + const response = await request(server) + .post('/api/auth/v1/register') + .send({ + client_name: 'Test Client', + redirect_uris: ['https://example.com/callback'], + response_types: ['code'], + grant_types: ['authorization_code'], + scope: 'openid', + }) + .expect(201); + + expect(response.body).toEqual({ + client_id: expect.any(String), + client_secret: expect.any(String), + redirect_uris: ['https://example.com/callback'], + }); + }); + + it('should create a consent request via authorization endpoint', async () => { + const { + mocks: { service }, + router, + } = await createRouter(databaseId); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + scope: 'openid', + }); + + const { server } = await startTestBackend({ + features: [ + createBackendPlugin({ + pluginId: 'auth', + register(reg) { + reg.registerInit({ + deps: { httpRouter: coreServices.httpRouter }, + async init({ httpRouter }) { + httpRouter.use(router.getRouter()); + httpRouter.addAuthPolicy({ + path: '/', + allow: 'unauthenticated', + }); + }, + }); + }, + }), + ], + }); + + const response = await request(server) + .get('/api/auth/v1/authorize') + .query({ + client_id: client.clientId, + redirect_uri: 'https://example.com/callback', + response_type: 'code', + scope: 'openid', + state: 'test-state', + }) + .expect(302); + + expect(response.header.location).toMatch( + /^http:\/\/localhost:3000\/auth\/consent\/[a-f0-9-]+$/, + ); + }); + + it('should get consent request details', async () => { + const { + mocks: { service }, + router, + } = await createRouter(databaseId); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + scope: 'openid', + }); + + const consentRequest = await service.createConsentRequest({ + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + scope: 'openid', + state: 'test-state', + }); + + const { server } = await startTestBackend({ + features: [ + createBackendPlugin({ + pluginId: 'auth', + register(reg) { + reg.registerInit({ + deps: { httpRouter: coreServices.httpRouter }, + async init({ httpRouter }) { + httpRouter.use(router.getRouter()); + httpRouter.addAuthPolicy({ + path: '/', + allow: 'unauthenticated', + }); + }, + }); + }, + }), + ], + }); + + const response = await request(server) + .get(`/api/auth/v1/consent/${consentRequest.consentRequestId}`) + .expect(200); + + expect(response.body).toEqual({ + id: consentRequest.consentRequestId, + clientName: 'Test Client', + scope: 'openid', + redirectUri: 'https://example.com/callback', + }); + }); + + it('should approve consent request', async () => { + const { + mocks: { auth, service }, + router, + } = await createRouter(databaseId); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + scope: 'openid', + }); + + const consentRequest = await service.createConsentRequest({ + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + scope: 'openid', + state: 'test-state', + }); + + const { server } = await startTestBackend({ + features: [ + createBackendPlugin({ + pluginId: 'auth', + register(reg) { + reg.registerInit({ + deps: { httpRouter: coreServices.httpRouter }, + async init({ httpRouter }) { + httpRouter.use(router.getRouter()); + httpRouter.addAuthPolicy({ + path: '/', + allow: 'unauthenticated', + }); + }, + }); + }, + }), + ], + }); + + auth.authenticate.mockResolvedValueOnce({ + principal: { + type: 'user', + userEntityRef: 'user:default/test-user', + }, + $$type: '@backstage/BackstageCredentials', + }); + + auth.isPrincipal.mockReturnValueOnce(true); + + const response = await request(server) + .post( + `/api/auth/v1/consent/${consentRequest.consentRequestId}/approve`, + ) + .set('Authorization', 'Bearer test-token') + .expect(200); + + expect(response.body).toEqual({ + redirectUrl: expect.stringMatching( + /^https:\/\/example\.com\/callback\?code=[\w-]+&state=test-state$/, + ), + }); + }); + + it('should reject consent request', async () => { + const { + mocks: { service }, + router, + } = await createRouter(databaseId); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + scope: 'openid', + }); + + const consentRequest = await service.createConsentRequest({ + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + scope: 'openid', + state: 'test-state', + }); + + const { server } = await startTestBackend({ + features: [ + createBackendPlugin({ + pluginId: 'auth', + register(reg) { + reg.registerInit({ + deps: { httpRouter: coreServices.httpRouter }, + async init({ httpRouter }) { + httpRouter.use(router.getRouter()); + httpRouter.addAuthPolicy({ + path: '/', + allow: 'unauthenticated', + }); + }, + }); + }, + }), + ], + }); + + const response = await request(server) + .post( + `/api/auth/v1/consent/${consentRequest.consentRequestId}/reject`, + ) + .expect(200); + + expect(response.body).toEqual({ + redirectUrl: expect.stringMatching( + /^https:\/\/example\.com\/callback\?error=access_denied&error_description=User\+denied\+the\+request&state=test-state$/, + ), + }); + }); + }); + + describe('token exchange', () => { + it('should exchange authorization code for tokens', async () => { + const { + mocks: { auth, service, tokenIssuer }, + router, + } = await createRouter(databaseId); + + auth.authenticate.mockResolvedValueOnce({ + principal: { + type: 'user', + userEntityRef: 'user:default/test-user', + }, + $$type: '@backstage/BackstageCredentials', + }); + auth.isPrincipal.mockReturnValueOnce(true); + + tokenIssuer.issueToken.mockResolvedValue({ + token: 'mock-access-token', + }); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + scope: 'openid', + }); + + const consentRequest = await service.createConsentRequest({ + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + scope: 'openid', + state: 'test-state', + }); + + const { server } = await startTestBackend({ + features: [ + createBackendPlugin({ + pluginId: 'auth', + register(reg) { + reg.registerInit({ + deps: { httpRouter: coreServices.httpRouter }, + async init({ httpRouter }) { + httpRouter.use(router.getRouter()); + httpRouter.addAuthPolicy({ + path: '/', + allow: 'unauthenticated', + }); + }, + }); + }, + }), + ], + }); + + const approvalResponse = await request(server) + .post( + `/api/auth/v1/consent/${consentRequest.consentRequestId}/approve`, + ) + .set('Authorization', 'Bearer test-token') + .expect(200); + + const redirectUrl = new URL(approvalResponse.body.redirectUrl); + const authorizationCode = redirectUrl.searchParams.get('code'); + + expect(authorizationCode).toBeDefined(); + + const tokenResponse = await request(server) + .post('/api/auth/v1/token') + .send({ + grant_type: 'authorization_code', + code: authorizationCode, + client_id: client.clientId, + client_secret: client.clientSecret, + redirect_uri: 'https://example.com/callback', + }) + .expect(200); + + expect(tokenResponse.body).toEqual({ + access_token: 'mock-access-token', + token_type: 'Bearer', + expires_in: 3600, + id_token: 'mock-access-token', + scope: 'openid', + }); + + expect(tokenIssuer.issueToken).toHaveBeenCalledWith({ + claims: { + sub: 'user:default/test-user', + }, + }); + }); + + it('should exchange authorization code for tokens with PKCE', async () => { + const { + mocks: { auth, service, tokenIssuer }, + router, + } = await createRouter(databaseId); + + tokenIssuer.issueToken.mockResolvedValue({ + token: 'mock-access-token-pkce', + }); + + auth.authenticate.mockResolvedValueOnce({ + principal: { + type: 'user', + userEntityRef: 'user:default/test-user-pkce', + }, + $$type: '@backstage/BackstageCredentials', + }); + auth.isPrincipal.mockReturnValueOnce(true); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + scope: 'openid', + }); + + const codeVerifier = + 'test-code-verifier-123456789012345678901234567890123456789012345'; + const codeChallenge = codeVerifier; + + const consentRequest = await service.createConsentRequest({ + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + scope: 'openid', + state: 'test-state', + codeChallenge, + codeChallengeMethod: 'plain', + }); + + const { server } = await startTestBackend({ + features: [ + createBackendPlugin({ + pluginId: 'auth', + register(reg) { + reg.registerInit({ + deps: { httpRouter: coreServices.httpRouter }, + async init({ httpRouter }) { + httpRouter.use(router.getRouter()); + httpRouter.addAuthPolicy({ + path: '/', + allow: 'unauthenticated', + }); + }, + }); + }, + }), + ], + }); + + const approvalResponse = await request(server) + .post( + `/api/auth/v1/consent/${consentRequest.consentRequestId}/approve`, + ) + .set('Authorization', 'Bearer test-token') + .expect(200); + + const redirectUrl = new URL(approvalResponse.body.redirectUrl); + const authorizationCode = redirectUrl.searchParams.get('code'); + + expect(authorizationCode).toBeDefined(); + + const tokenResponse = await request(server) + .post('/api/auth/v1/token') + .send({ + grant_type: 'authorization_code', + code: authorizationCode, + client_id: client.clientId, + client_secret: client.clientSecret, + redirect_uri: 'https://example.com/callback', + code_verifier: codeVerifier, + }) + .expect(200); + + expect(tokenResponse.body).toEqual({ + access_token: 'mock-access-token-pkce', + token_type: 'Bearer', + expires_in: 3600, + id_token: 'mock-access-token-pkce', + scope: 'openid', + }); + + expect(tokenIssuer.issueToken).toHaveBeenCalledWith({ + claims: { + sub: 'user:default/test-user-pkce', + }, + }); + }); + + it('should reject token exchange with invalid client credentials', async () => { + const { + mocks: { auth, service }, + router, + } = await createRouter(databaseId); + + auth.authenticate.mockResolvedValueOnce({ + principal: { + type: 'user', + userEntityRef: 'user:default/test-user', + }, + $$type: '@backstage/BackstageCredentials', + }); + auth.isPrincipal.mockReturnValueOnce(true); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + scope: 'openid', + }); + + const consentRequest = await service.createConsentRequest({ + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + scope: 'openid', + }); + + const { server } = await startTestBackend({ + features: [ + createBackendPlugin({ + pluginId: 'auth', + register(reg) { + reg.registerInit({ + deps: { httpRouter: coreServices.httpRouter }, + async init({ httpRouter }) { + httpRouter.use(router.getRouter()); + httpRouter.addAuthPolicy({ + path: '/', + allow: 'unauthenticated', + }); + }, + }); + }, + }), + ], + }); + + const approvalResponse = await request(server) + .post( + `/api/auth/v1/consent/${consentRequest.consentRequestId}/approve`, + ) + .set('Authorization', 'Bearer test-token') + .expect(200); + + const redirectUrl = new URL(approvalResponse.body.redirectUrl); + const authorizationCode = redirectUrl.searchParams.get('code'); + + const tokenResponse = await request(server) + .post('/api/auth/v1/token') + .send({ + grant_type: 'authorization_code', + code: authorizationCode, + client_id: client.clientId, + client_secret: 'invalid-secret', + redirect_uri: 'https://example.com/callback', + }) + .expect(401); + + expect(tokenResponse.body).toEqual({ + error: 'invalid_client', + error_description: 'Invalid client credentials', + }); + }); + + it('should reject token exchange with invalid authorization code', async () => { + const { + mocks: { service }, + router, + } = await createRouter(databaseId); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + scope: 'openid', + }); + + const { server } = await startTestBackend({ + features: [ + createBackendPlugin({ + pluginId: 'auth', + register(reg) { + reg.registerInit({ + deps: { httpRouter: coreServices.httpRouter }, + async init({ httpRouter }) { + httpRouter.use(router.getRouter()); + httpRouter.addAuthPolicy({ + path: '/', + allow: 'unauthenticated', + }); + }, + }); + }, + }), + ], + }); + + const tokenResponse = await request(server) + .post('/api/auth/v1/token') + .send({ + grant_type: 'authorization_code', + code: 'invalid-code', + client_id: client.clientId, + client_secret: client.clientSecret, + redirect_uri: 'https://example.com/callback', + }) + .expect(401); + + expect(tokenResponse.body).toEqual({ + error: 'invalid_client', + error_description: 'Invalid authorization code', + }); + }); + + it('should exchange authorization code for tokens with PKCE S256', async () => { + const { + mocks: { auth, service, tokenIssuer }, + router, + } = await createRouter(databaseId); + + tokenIssuer.issueToken.mockResolvedValue({ + token: 'mock-access-token-s256', + }); + + auth.authenticate.mockResolvedValueOnce({ + principal: { + type: 'user', + userEntityRef: 'user:default/test-user-s256', + }, + $$type: '@backstage/BackstageCredentials', + }); + auth.isPrincipal.mockReturnValueOnce(true); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + scope: 'openid', + }); + + const codeVerifier = + 'test-code-verifier-s256-123456789012345678901234567890123456789'; + const codeChallenge = crypto + .createHash('sha256') + .update(codeVerifier) + .digest('base64url'); + + const consentRequest = await service.createConsentRequest({ + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + scope: 'openid', + state: 'test-state', + codeChallenge, + codeChallengeMethod: 'S256', + }); + + const { server } = await startTestBackend({ + features: [ + createBackendPlugin({ + pluginId: 'auth', + register(reg) { + reg.registerInit({ + deps: { httpRouter: coreServices.httpRouter }, + async init({ httpRouter }) { + httpRouter.use(router.getRouter()); + httpRouter.addAuthPolicy({ + path: '/', + allow: 'unauthenticated', + }); + }, + }); + }, + }), + ], + }); + + const approvalResponse = await request(server) + .post( + `/api/auth/v1/consent/${consentRequest.consentRequestId}/approve`, + ) + .set('Authorization', 'Bearer test-token') + .expect(200); + + const redirectUrl = new URL(approvalResponse.body.redirectUrl); + const authorizationCode = redirectUrl.searchParams.get('code'); + + expect(authorizationCode).toBeDefined(); + + const tokenResponse = await request(server) + .post('/api/auth/v1/token') + .send({ + grant_type: 'authorization_code', + code: authorizationCode, + client_id: client.clientId, + client_secret: client.clientSecret, + redirect_uri: 'https://example.com/callback', + code_verifier: codeVerifier, + }) + .expect(200); + + expect(tokenResponse.body).toEqual({ + access_token: 'mock-access-token-s256', + token_type: 'Bearer', + expires_in: 3600, + id_token: 'mock-access-token-s256', + scope: 'openid', + }); + + expect(tokenIssuer.issueToken).toHaveBeenCalledWith({ + claims: { + sub: 'user:default/test-user-s256', + }, + }); + }); }); }); }); From bf372ab53f70b156a9e5cc7c2d4c30e68df53648 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 7 Jul 2025 12:21:57 +0200 Subject: [PATCH 065/107] chore: cleanup and simplify Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- ...20250701120000_oidc_client_registration.js | 63 +------- .../src/database/OidcDatabase.test.ts | 142 ------------------ .../auth-backend/src/database/OidcDatabase.ts | 66 -------- .../src/service/OidcRouter.test.ts | 80 ++++------ .../auth-backend/src/service/OidcRouter.ts | 110 +++++++------- .../src/service/OidcService.test.ts | 80 +++++----- .../auth-backend/src/service/OidcService.ts | 88 ++++------- plugins/auth-backend/src/service/router.ts | 4 + 8 files changed, 166 insertions(+), 467 deletions(-) diff --git a/plugins/auth-backend/migrations/20250701120000_oidc_client_registration.js b/plugins/auth-backend/migrations/20250701120000_oidc_client_registration.js index bf8ee521f0..e175c922c1 100644 --- a/plugins/auth-backend/migrations/20250701120000_oidc_client_registration.js +++ b/plugins/auth-backend/migrations/20250701120000_oidc_client_registration.js @@ -43,11 +43,6 @@ exports.up = async function up(knex) { .notNullable() .comment('The name of the client, should be human readable'); - table - .timestamp('expires_at', { useTz: false, precision: 0 }) - .nullable() - .comment('Client registration expiration timestamp'); - table .text('response_types') .notNullable() @@ -110,7 +105,7 @@ exports.up = async function up(knex) { .comment('Authorization session status'); table - .timestamp('expires_at', { useTz: false, precision: 0 }) + .timestamp('expires_at', { useTz: true, precision: 0 }) .notNullable() .comment('Session expiration timestamp'); @@ -119,32 +114,6 @@ exports.up = async function up(knex) { table.index(['status', 'expires_at']); }); - await knex.schema.createTable('oidc_consent_requests', table => { - table.comment('User consent requests for OAuth authorization'); - - table - .string('id') - .primary() - .notNullable() - .comment('Unique consent request identifier'); - - table - .string('session_id') - .notNullable() - .comment('Authorization session identifier'); - - table - .timestamp('expires_at', { useTz: false, precision: 0 }) - .notNullable() - .comment('Consent request expiration timestamp'); - - table - .foreign('session_id') - .references('id') - .inTable('oauth_authorization_sessions') - .onDelete('CASCADE'); - }); - await knex.schema.createTable('oidc_authorization_codes', table => { table.comment('OAuth authorization codes for code exchange flow'); @@ -160,7 +129,7 @@ exports.up = async function up(knex) { .comment('Authorization session identifier'); table - .timestamp('expires_at', { useTz: false, precision: 0 }) + .timestamp('expires_at', { useTz: true, precision: 0 }) .notNullable() .comment('Authorization code expiration timestamp'); @@ -175,41 +144,13 @@ exports.up = async function up(knex) { .inTable('oauth_authorization_sessions') .onDelete('CASCADE'); }); - - await knex.schema.createTable('oidc_access_tokens', table => { - table.comment('OAuth access tokens for API access'); - - table - .string('token_id') - .primary() - .notNullable() - .comment('Unique access token identifier'); - - table - .string('session_id') - .notNullable() - .comment('Authorization session identifier'); - - table - .timestamp('expires_at', { useTz: false, precision: 0 }) - .notNullable() - .comment('Access token expiration timestamp'); - - table - .foreign('session_id') - .references('id') - .inTable('oauth_authorization_sessions') - .onDelete('CASCADE'); - }); }; /** * @param {import('knex').Knex} knex */ exports.down = async function down(knex) { - await knex.schema.dropTable('oidc_access_tokens'); await knex.schema.dropTable('oidc_authorization_codes'); - await knex.schema.dropTable('oidc_consent_requests'); await knex.schema.dropTable('oauth_authorization_sessions'); await knex.schema.dropTable('oidc_clients'); }; diff --git a/plugins/auth-backend/src/database/OidcDatabase.test.ts b/plugins/auth-backend/src/database/OidcDatabase.test.ts index f9c2b83511..82064361a5 100644 --- a/plugins/auth-backend/src/database/OidcDatabase.test.ts +++ b/plugins/auth-backend/src/database/OidcDatabase.test.ts @@ -172,111 +172,6 @@ describe('Oidc Database', () => { }); }); - describe('Consent Requests', () => { - it('should create and return a consent request', async () => { - const { oidc } = await createOidcDatabase(databaseId); - - const client = await oidc.createClient({ - clientId: 'test-client', - clientName: 'Test Client', - clientSecret: 'test-secret', - redirectUris: ['https://example.com/callback'], - responseTypes: ['code'], - grantTypes: ['authorization_code'], - }); - - const session = await oidc.createAuthorizationSession({ - id: 'test-session', - clientId: client.clientId, - redirectUri: 'https://example.com/callback', - responseType: 'code', - expiresAt: '2025-01-01T00:00:00Z', - }); - - const consentRequest = await oidc.createConsentRequest({ - id: 'test-consent', - sessionId: session.id, - expiresAt: '2025-01-01T00:00:00Z', - }); - - await expect( - oidc.getConsentRequest({ id: 'test-consent' }), - ).resolves.toEqual(consentRequest); - }); - - it('should return consent request with session data', async () => { - const { oidc } = await createOidcDatabase(databaseId); - - const client = await oidc.createClient({ - clientId: 'test-client', - clientName: 'Test Client', - clientSecret: 'test-secret', - redirectUris: ['https://example.com/callback'], - responseTypes: ['code'], - grantTypes: ['authorization_code'], - }); - - const session = await oidc.createAuthorizationSession({ - id: 'test-session', - clientId: client.clientId, - redirectUri: 'https://example.com/callback', - responseType: 'code', - scope: 'openid', - state: 'test-state', - expiresAt: '2025-01-01T00:00:00Z', - }); - - const consentRequest = await oidc.createConsentRequest({ - id: 'test-consent', - sessionId: session.id, - expiresAt: '2025-01-01T00:00:00Z', - }); - - const consentFromDb = await oidc.getConsentRequest({ - id: 'test-consent', - }); - const sessionFromDb = await oidc.getAuthorizationSession({ - id: consentFromDb!.sessionId, - }); - - expect(consentFromDb).toEqual(consentRequest); - expect(sessionFromDb).toEqual(session); - }); - - it('should delete consent request', async () => { - const { oidc } = await createOidcDatabase(databaseId); - - const client = await oidc.createClient({ - clientId: 'test-client', - clientName: 'Test Client', - clientSecret: 'test-secret', - redirectUris: ['https://example.com/callback'], - responseTypes: ['code'], - grantTypes: ['authorization_code'], - }); - - const session = await oidc.createAuthorizationSession({ - id: 'test-session', - clientId: client.clientId, - redirectUri: 'https://example.com/callback', - responseType: 'code', - expiresAt: '2025-01-01T00:00:00Z', - }); - - await oidc.createConsentRequest({ - id: 'test-consent', - sessionId: session.id, - expiresAt: '2025-01-01T00:00:00Z', - }); - - await oidc.deleteConsentRequest({ id: 'test-consent' }); - - await expect( - oidc.getConsentRequest({ id: 'test-consent' }), - ).resolves.toBeNull(); - }); - }); - describe('Authorization Codes', () => { it('should create and return an authorization code', async () => { const { oidc } = await createOidcDatabase(databaseId); @@ -392,42 +287,5 @@ describe('Oidc Database', () => { }); }); }); - - describe('Access Tokens', () => { - it('should create and return an access token', async () => { - const { oidc } = await createOidcDatabase(databaseId); - - const client = await oidc.createClient({ - clientId: 'test-client', - clientName: 'Test Client', - clientSecret: 'test-secret', - redirectUris: ['https://example.com/callback'], - responseTypes: ['code'], - grantTypes: ['authorization_code'], - }); - - const session = await oidc.createAuthorizationSession({ - id: 'test-session', - clientId: client.clientId, - redirectUri: 'https://example.com/callback', - responseType: 'code', - expiresAt: '2025-01-01T00:00:00Z', - }); - - const accessToken = await oidc.createAccessToken({ - tokenId: 'test-token', - sessionId: session.id, - expiresAt: '2025-01-01T00:00:00Z', - }); - - expect(accessToken).toEqual( - expect.objectContaining({ - tokenId: 'test-token', - sessionId: session.id, - expiresAt: '2025-01-01T00:00:00Z', - }), - ); - }); - }); }); }); diff --git a/plugins/auth-backend/src/database/OidcDatabase.ts b/plugins/auth-backend/src/database/OidcDatabase.ts index 0c75290f12..17dd4f2c06 100644 --- a/plugins/auth-backend/src/database/OidcDatabase.ts +++ b/plugins/auth-backend/src/database/OidcDatabase.ts @@ -20,7 +20,6 @@ type OidcClientRow = { client_id: string; client_secret: string; client_name: string; - expires_at: string | null; response_types: string; grant_types: string; redirect_uris: string; @@ -43,12 +42,6 @@ type OAuthAuthorizationSessionRow = { expires_at: string; }; -type OidcConsentRequestRow = { - id: string; - session_id: string; - expires_at: string; -}; - type OidcAuthorizationCodeRow = { code: string; session_id: string; @@ -56,12 +49,6 @@ type OidcAuthorizationCodeRow = { used: boolean; }; -type OidcAccessTokenRow = { - token_id: string; - session_id: string; - expires_at: string; -}; - export type Client = { clientId: string; clientName: string; @@ -70,7 +57,6 @@ export type Client = { responseTypes: string[]; grantTypes: string[]; scope?: string; - expiresAt?: string; metadata?: Record; }; @@ -125,7 +111,6 @@ export class OidcDatabase { client_id: client.clientId, client_secret: client.clientSecret, client_name: client.clientName, - expires_at: client.expiresAt, response_types: JSON.stringify(client.responseTypes), grant_types: JSON.stringify(client.grantTypes), redirect_uris: JSON.stringify(client.redirectUris), @@ -192,30 +177,6 @@ export class OidcDatabase { return this.rowToAuthorizationSession(updated) as AuthorizationSession; } - async createConsentRequest(consentRequest: ConsentRequest) { - await this.db('oidc_consent_requests').insert({ - id: consentRequest.id, - session_id: consentRequest.sessionId, - expires_at: consentRequest.expiresAt, - }); - - return consentRequest; - } - - async getConsentRequest({ id }: { id: string }) { - const consentRequest = await this.db( - 'oidc_consent_requests', - ) - .where('id', id) - .first(); - - if (!consentRequest) { - return null; - } - - return this.rowToConsentRequest(consentRequest) as ConsentRequest; - } - async getAuthorizationSession({ id }: { id: string }) { const session = await this.db( 'oauth_authorization_sessions', @@ -230,12 +191,6 @@ export class OidcDatabase { return this.rowToAuthorizationSession(session) as AuthorizationSession; } - async deleteConsentRequest({ id }: { id: string }) { - await this.db('oidc_consent_requests') - .where('id', id) - .delete(); - } - async createAuthorizationCode( authorizationCode: Omit, ) { @@ -284,16 +239,6 @@ export class OidcDatabase { return this.rowToAuthorizationCode(updated) as AuthorizationCode; } - async createAccessToken(accessToken: AccessToken) { - await this.db('oidc_access_tokens').insert({ - token_id: accessToken.tokenId, - session_id: accessToken.sessionId, - expires_at: accessToken.expiresAt, - }); - - return accessToken; - } - private rowToClient(row: Partial): Partial { return { clientId: row.client_id, @@ -307,7 +252,6 @@ export class OidcDatabase { : undefined, grantTypes: row.grant_types ? JSON.parse(row.grant_types) : undefined, scope: row.scope ?? undefined, - expiresAt: row.expires_at ?? undefined, metadata: row.metadata ? JSON.parse(row.metadata) : undefined, }; } @@ -350,16 +294,6 @@ export class OidcDatabase { }; } - private rowToConsentRequest( - row: Partial, - ): Partial { - return { - id: row.id, - sessionId: row.session_id, - expiresAt: row.expires_at, - }; - } - private authorizationCodeToRow( authorizationCode: Partial, ): Partial { diff --git a/plugins/auth-backend/src/service/OidcRouter.test.ts b/plugins/auth-backend/src/service/OidcRouter.test.ts index 150065ede5..579c96470f 100644 --- a/plugins/auth-backend/src/service/OidcRouter.test.ts +++ b/plugins/auth-backend/src/service/OidcRouter.test.ts @@ -24,6 +24,7 @@ import { startTestBackend, TestDatabases, TestDatabaseId, + mockCredentials, } from '@backstage/backend-test-utils'; import request from 'supertest'; import crypto from 'crypto'; @@ -35,6 +36,8 @@ import { OidcService } from '../service/OidcService'; import { TokenIssuer } from '../identity/types'; describe('OidcRouter', () => { + const MOCK_USER_TOKEN = 'mock-user-token'; + const MOCK_USER_ENTITY_REF = 'user:default/test-user'; const databases = TestDatabases.create(); async function createRouter(databaseId: TestDatabaseId) { @@ -82,6 +85,9 @@ describe('OidcRouter', () => { logger: mockServices.logger.mock(), userInfo: userInfoDatabase, oidc: oidcDatabase, + httpAuth: mockServices.httpAuth({ + defaultCredentials: mockCredentials.user(), + }), }); return { @@ -209,7 +215,7 @@ describe('OidcRouter', () => { }); }); - describe('consent flow', () => { + describe('auth flow', () => { it('should register a client', async () => { const { router } = await createRouter(databaseId); @@ -251,7 +257,7 @@ describe('OidcRouter', () => { }); }); - it('should create a consent request via authorization endpoint', async () => { + it('should create an authorization session via authorization endpoint', async () => { const { mocks: { service }, router, @@ -297,11 +303,11 @@ describe('OidcRouter', () => { .expect(302); expect(response.header.location).toMatch( - /^http:\/\/localhost:3000\/auth\/consent\/[a-f0-9-]+$/, + /^http:\/\/localhost:3000\/auth\/sessions\/[a-f0-9-]+$/, ); }); - it('should get consent request details', async () => { + it('should get auth session details', async () => { const { mocks: { service }, router, @@ -315,7 +321,7 @@ describe('OidcRouter', () => { scope: 'openid', }); - const consentRequest = await service.createConsentRequest({ + const authSession = await service.createAuthorizationSession({ clientId: client.clientId, redirectUri: 'https://example.com/callback', responseType: 'code', @@ -344,11 +350,11 @@ describe('OidcRouter', () => { }); const response = await request(server) - .get(`/api/auth/v1/consent/${consentRequest.consentRequestId}`) + .get(`/api/auth/v1/sessions/${authSession.id}`) .expect(200); expect(response.body).toEqual({ - id: consentRequest.consentRequestId, + id: authSession.id, clientName: 'Test Client', scope: 'openid', redirectUri: 'https://example.com/callback', @@ -369,7 +375,7 @@ describe('OidcRouter', () => { scope: 'openid', }); - const consentRequest = await service.createConsentRequest({ + const authSession = await service.createAuthorizationSession({ clientId: client.clientId, redirectUri: 'https://example.com/callback', responseType: 'code', @@ -397,21 +403,11 @@ describe('OidcRouter', () => { ], }); - auth.authenticate.mockResolvedValueOnce({ - principal: { - type: 'user', - userEntityRef: 'user:default/test-user', - }, - $$type: '@backstage/BackstageCredentials', - }); - auth.isPrincipal.mockReturnValueOnce(true); const response = await request(server) - .post( - `/api/auth/v1/consent/${consentRequest.consentRequestId}/approve`, - ) - .set('Authorization', 'Bearer test-token') + .post(`/api/auth/v1/sessions/${authSession.id}/approve`) + .set('Authorization', `Bearer ${MOCK_USER_TOKEN}`) .expect(200); expect(response.body).toEqual({ @@ -421,7 +417,7 @@ describe('OidcRouter', () => { }); }); - it('should reject consent request', async () => { + it('should reject auth session', async () => { const { mocks: { service }, router, @@ -435,7 +431,7 @@ describe('OidcRouter', () => { scope: 'openid', }); - const consentRequest = await service.createConsentRequest({ + const authSession = await service.createAuthorizationSession({ clientId: client.clientId, redirectUri: 'https://example.com/callback', responseType: 'code', @@ -464,9 +460,7 @@ describe('OidcRouter', () => { }); const response = await request(server) - .post( - `/api/auth/v1/consent/${consentRequest.consentRequestId}/reject`, - ) + .post(`/api/auth/v1/sessions/${authSession.id}/reject`) .expect(200); expect(response.body).toEqual({ @@ -505,7 +499,7 @@ describe('OidcRouter', () => { scope: 'openid', }); - const consentRequest = await service.createConsentRequest({ + const authSession = await service.createAuthorizationSession({ clientId: client.clientId, redirectUri: 'https://example.com/callback', responseType: 'code', @@ -534,10 +528,8 @@ describe('OidcRouter', () => { }); const approvalResponse = await request(server) - .post( - `/api/auth/v1/consent/${consentRequest.consentRequestId}/approve`, - ) - .set('Authorization', 'Bearer test-token') + .post(`/api/auth/v1/sessions/${authSession.id}/approve`) + .set('Authorization', `Bearer ${MOCK_USER_TOKEN}`) .expect(200); const redirectUrl = new URL(approvalResponse.body.redirectUrl); @@ -566,7 +558,7 @@ describe('OidcRouter', () => { expect(tokenIssuer.issueToken).toHaveBeenCalledWith({ claims: { - sub: 'user:default/test-user', + sub: MOCK_USER_ENTITY_REF, }, }); }); @@ -602,7 +594,7 @@ describe('OidcRouter', () => { 'test-code-verifier-123456789012345678901234567890123456789012345'; const codeChallenge = codeVerifier; - const consentRequest = await service.createConsentRequest({ + const authSession = await service.createAuthorizationSession({ clientId: client.clientId, redirectUri: 'https://example.com/callback', responseType: 'code', @@ -633,10 +625,8 @@ describe('OidcRouter', () => { }); const approvalResponse = await request(server) - .post( - `/api/auth/v1/consent/${consentRequest.consentRequestId}/approve`, - ) - .set('Authorization', 'Bearer test-token') + .post(`/api/auth/v1/sessions/${authSession.id}/approve`) + .set('Authorization', `Bearer ${MOCK_USER_TOKEN}`) .expect(200); const redirectUrl = new URL(approvalResponse.body.redirectUrl); @@ -666,7 +656,7 @@ describe('OidcRouter', () => { expect(tokenIssuer.issueToken).toHaveBeenCalledWith({ claims: { - sub: 'user:default/test-user-pkce', + sub: MOCK_USER_ENTITY_REF, }, }); }); @@ -694,7 +684,7 @@ describe('OidcRouter', () => { scope: 'openid', }); - const consentRequest = await service.createConsentRequest({ + const authSession = await service.createAuthorizationSession({ clientId: client.clientId, redirectUri: 'https://example.com/callback', responseType: 'code', @@ -722,10 +712,8 @@ describe('OidcRouter', () => { }); const approvalResponse = await request(server) - .post( - `/api/auth/v1/consent/${consentRequest.consentRequestId}/approve`, - ) - .set('Authorization', 'Bearer test-token') + .post(`/api/auth/v1/sessions/${authSession.id}/approve`) + .set('Authorization', `Bearer ${MOCK_USER_TOKEN}`) .expect(200); const redirectUrl = new URL(approvalResponse.body.redirectUrl); @@ -833,7 +821,7 @@ describe('OidcRouter', () => { .update(codeVerifier) .digest('base64url'); - const consentRequest = await service.createConsentRequest({ + const authSession = await service.createAuthorizationSession({ clientId: client.clientId, redirectUri: 'https://example.com/callback', responseType: 'code', @@ -864,10 +852,8 @@ describe('OidcRouter', () => { }); const approvalResponse = await request(server) - .post( - `/api/auth/v1/consent/${consentRequest.consentRequestId}/approve`, - ) - .set('Authorization', 'Bearer test-token') + .post(`/api/auth/v1/sessions/${authSession.id}/approve`) + .set('Authorization', `Bearer ${MOCK_USER_TOKEN}`) .expect(200); const redirectUrl = new URL(approvalResponse.body.redirectUrl); diff --git a/plugins/auth-backend/src/service/OidcRouter.ts b/plugins/auth-backend/src/service/OidcRouter.ts index c7bb42e1f5..c07ffd5adb 100644 --- a/plugins/auth-backend/src/service/OidcRouter.ts +++ b/plugins/auth-backend/src/service/OidcRouter.ts @@ -16,7 +16,11 @@ import Router from 'express-promise-router'; import { OidcService } from './OidcService'; import { AuthenticationError, isError } from '@backstage/errors'; -import { AuthService, LoggerService } from '@backstage/backend-plugin-api'; +import { + AuthService, + HttpAuthService, + LoggerService, +} from '@backstage/backend-plugin-api'; import { TokenIssuer } from '../identity/types'; import { UserInfoDatabase } from '../database/UserInfoDatabase'; import { OidcDatabase } from '../database/OidcDatabase'; @@ -28,6 +32,7 @@ export class OidcRouter { private readonly logger: LoggerService, private readonly auth: AuthService, private readonly appUrl: string, + private readonly httpAuth: HttpAuthService, ) {} static create(options: { @@ -38,12 +43,14 @@ export class OidcRouter { logger: LoggerService; userInfo: UserInfoDatabase; oidc: OidcDatabase; + httpAuth: HttpAuthService; }) { return new OidcRouter( OidcService.create(options), options.logger, options.auth, options.appUrl, + options.httpAuth, ); } @@ -70,7 +77,7 @@ export class OidcRouter { // Authorization endpoint // https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest // Handles the initial authorization request from the client, validates parameters, - // and redirects to the consent page for user approval + // and redirects to the Authorization Session page for user approval router.get('/v1/authorize', async (req, res) => { // todo(blam): maybe add zod types for validating input const { @@ -94,7 +101,7 @@ export class OidcRouter { } try { - const result = await this.oidc.createConsentRequest({ + const result = await this.oidc.createAuthorizationSession({ clientId: clientId as string, redirectUri: redirectUri as string, responseType: responseType as string, @@ -107,12 +114,13 @@ export class OidcRouter { // todo(blam): maybe this URL could be overridable by config if // the plugin is mounted somewhere else? - const consentUrl = new URL( - `/auth/consent/${result.consentRequestId}`, + // support slashes in baseUrl? + const authSessionRedirectUrl = new URL( + `/auth/sessions/${result.id}`, this.appUrl, ); - return res.redirect(consentUrl.toString()); + return res.redirect(authSessionRedirectUrl.toString()); } catch (error) { const errorParams = new URLSearchParams(); errorParams.append( @@ -133,77 +141,69 @@ export class OidcRouter { } }); - // Consent request details endpoint - // Returns consent request details for the frontend consent page - router.get('/v1/consent/:consentId', async (req, res) => { - const { consentId } = req.params; + // Authorization Session request details endpoint + // Returns Authorization Session request details for the frontned + router.get('/v1/sessions/:sessionId', async (req, res) => { + const { sessionId } = req.params; - if (!consentId) { + if (!sessionId) { return res.status(400).json({ error: 'invalid_request', - error_description: 'Missing consent ID', + error_description: 'Missing Authorization Session ID', }); } try { - const consentRequest = await this.oidc.getConsentRequest({ - consentRequestId: consentId, + const session = await this.oidc.getAuthorizationSession({ + sessionId, }); return res.json({ - id: consentRequest.id, - clientName: consentRequest.clientName, - scope: consentRequest.scope, - redirectUri: consentRequest.redirectUri, + id: session.id, + clientName: session.clientName, + scope: session.scope, + redirectUri: session.redirectUri, }); } catch (error) { this.logger.error( - `Failed to get consent request: ${ + `Failed to get authorization session: ${ isError(error) ? error.message : 'Unknown error' }`, error, ); return res.status(404).json({ error: 'not_found', - error_description: 'Consent request not found or expired', + error_description: 'Authorization session not found or expired', }); } }); - // Consent approval endpoint - // Handles user approval of consent requests and generates authorization codes - router.post('/v1/consent/:consentId/approve', async (req, res) => { - const { consentId } = req.params; + // Authorization Session approval endpoint + // Handles user approval of Authorization Session requests and generates authorization codes + router.post('/v1/sessions/:sessionId/approve', async (req, res) => { + const { sessionId } = req.params; - if (!consentId) { + if (!sessionId) { return res.status(400).json({ error: 'invalid_request', - error_description: 'Missing consent ID', + error_description: 'Missing authorization session ID', }); } try { - const authHeader = req.headers.authorization; - if (!authHeader?.startsWith('Bearer ')) { - return res.status(401).json({ - error: 'unauthorized', - error_description: 'Bearer token required', - }); - } + const httpCredentials = await this.httpAuth.credentials(req); - const token = authHeader.substring(7); - const credentials = await this.auth.authenticate(token); - if (!this.auth.isPrincipal(credentials, 'user')) { + if (!this.auth.isPrincipal(httpCredentials, 'user')) { return res.status(401).json({ error: 'unauthorized', error_description: 'Authentication required', }); } - const userEntityRef = credentials.principal.userEntityRef; + const userEntityRef = httpCredentials.principal.userEntityRef; - const result = await this.oidc.approveConsentRequest({ - consentRequestId: consentId, + const result = await this.oidc.approveAuthorizationSession({ + sessionId, userEntityRef, }); @@ -211,8 +211,9 @@ export class OidcRouter { redirectUrl: result.redirectUrl, }); } catch (error) { + console.log(error); this.logger.error( - `Failed to approve consent: ${ + `Failed to approve authorization session: ${ isError(error) ? error.message : 'Unknown error' }`, error, @@ -224,33 +225,33 @@ export class OidcRouter { } }); - // Consent rejection endpoint - // Handles user rejection of consent requests and redirects with error - router.post('/v1/consent/:consentId/reject', async (req, res) => { - const { consentId } = req.params; + // Authorization Session rejection endpoint + // Handles user rejection of Authorization Session requests and redirects with error + router.post('/v1/sessions/:sessionId/reject', async (req, res) => { + const { sessionId } = req.params; - if (!consentId) { + if (!sessionId) { return res.status(400).json({ error: 'invalid_request', - error_description: 'Missing consent ID', + error_description: 'Missing authorization session ID', }); } try { - const consentRequest = await this.oidc.getConsentRequest({ - consentRequestId: consentId, + const session = await this.oidc.getAuthorizationSession({ + sessionId, }); - await this.oidc.deleteConsentRequest({ consentRequestId: consentId }); + await this.oidc.rejectAuthorizationSession({ sessionId }); const errorParams = new URLSearchParams(); errorParams.append('error', 'access_denied'); errorParams.append('error_description', 'User denied the request'); - if (consentRequest.state) { - errorParams.append('state', consentRequest.state); + if (session.state) { + errorParams.append('state', session.state); } - const redirectUrl = new URL(consentRequest.redirectUri); + const redirectUrl = new URL(session.redirectUri); redirectUrl.search = errorParams.toString(); return res.json({ @@ -258,7 +259,10 @@ export class OidcRouter { }); } catch (error) { const description = isError(error) ? error.message : 'Unknown error'; - this.logger.error(`Failed to reject consent: ${description}`, error); + this.logger.error( + `Failed to reject authorization session: ${description}`, + error, + ); return res.status(400).json({ error: 'invalid_request', diff --git a/plugins/auth-backend/src/service/OidcService.test.ts b/plugins/auth-backend/src/service/OidcService.test.ts index fa4c0a12ac..5067cde458 100644 --- a/plugins/auth-backend/src/service/OidcService.test.ts +++ b/plugins/auth-backend/src/service/OidcService.test.ts @@ -233,8 +233,8 @@ describe('OidcService', () => { }); }); - describe('createConsentRequest', () => { - it('should create a consent request for valid client', async () => { + describe('createAuthorizationSession', () => { + it('should create a authorization session for valid client', async () => { const { service } = await createOidcService(databaseId); const client = await service.registerClient({ @@ -242,7 +242,7 @@ describe('OidcService', () => { redirectUris: ['https://example.com/callback'], }); - const consent = await service.createConsentRequest({ + const authSession = await service.createAuthorizationSession({ clientId: client.clientId, redirectUri: 'https://example.com/callback', responseType: 'code', @@ -250,8 +250,8 @@ describe('OidcService', () => { state: 'test-state', }); - expect(consent).toEqual({ - consentRequestId: expect.any(String), + expect(authSession).toEqual({ + id: expect.any(String), clientName: 'Test Client', scope: 'openid', redirectUri: 'https://example.com/callback', @@ -262,7 +262,7 @@ describe('OidcService', () => { const { service } = await createOidcService(databaseId); await expect( - service.createConsentRequest({ + service.createAuthorizationSession({ clientId: 'invalid-client', redirectUri: 'https://example.com/callback', responseType: 'code', @@ -279,7 +279,7 @@ describe('OidcService', () => { }); await expect( - service.createConsentRequest({ + service.createAuthorizationSession({ clientId: client.clientId, redirectUri: 'https://invalid.com/callback', responseType: 'code', @@ -296,7 +296,7 @@ describe('OidcService', () => { }); await expect( - service.createConsentRequest({ + service.createAuthorizationSession({ clientId: client.clientId, redirectUri: 'https://example.com/callback', responseType: 'token', @@ -312,7 +312,7 @@ describe('OidcService', () => { redirectUris: ['https://example.com/callback'], }); - const consent = await service.createConsentRequest({ + const authSession = await service.createAuthorizationSession({ clientId: client.clientId, redirectUri: 'https://example.com/callback', responseType: 'code', @@ -320,7 +320,7 @@ describe('OidcService', () => { codeChallengeMethod: 'S256', }); - expect(consent.consentRequestId).toBeDefined(); + expect(authSession.id).toBeDefined(); }); it('should throw error for invalid PKCE method', async () => { @@ -332,7 +332,7 @@ describe('OidcService', () => { }); await expect( - service.createConsentRequest({ + service.createAuthorizationSession({ clientId: client.clientId, redirectUri: 'https://example.com/callback', responseType: 'code', @@ -343,8 +343,8 @@ describe('OidcService', () => { }); }); - describe('approveConsentRequest', () => { - it('should approve a valid consent request', async () => { + describe('approveAuthorizationSession', () => { + it('should approve a valid authorization session', async () => { const { service } = await createOidcService(databaseId); const client = await service.registerClient({ @@ -352,15 +352,15 @@ describe('OidcService', () => { redirectUris: ['https://example.com/callback'], }); - const consent = await service.createConsentRequest({ + const authSession = await service.createAuthorizationSession({ clientId: client.clientId, redirectUri: 'https://example.com/callback', responseType: 'code', state: 'test-state', }); - const result = await service.approveConsentRequest({ - consentRequestId: consent.consentRequestId, + const result = await service.approveAuthorizationSession({ + sessionId: authSession.id, userEntityRef: 'user:default/test', }); @@ -369,20 +369,20 @@ describe('OidcService', () => { ); }); - it('should throw error for invalid consent request', async () => { + it('should throw error for invalid authorization session', async () => { const { service } = await createOidcService(databaseId); await expect( - service.approveConsentRequest({ - consentRequestId: 'invalid-consent', + service.approveAuthorizationSession({ + sessionId: 'invalid-session', userEntityRef: 'user:default/test', }), - ).rejects.toThrow('Invalid consent request'); + ).rejects.toThrow('Invalid authorization session'); }); }); - describe('getConsentRequest', () => { - it('should return consent request details', async () => { + describe('getAuthorizationSession', () => { + it('should return authorization session details', async () => { const { service } = await createOidcService(databaseId); const client = await service.registerClient({ @@ -390,7 +390,7 @@ describe('OidcService', () => { redirectUris: ['https://example.com/callback'], }); - const consent = await service.createConsentRequest({ + const authSession = await service.createAuthorizationSession({ clientId: client.clientId, redirectUri: 'https://example.com/callback', responseType: 'code', @@ -398,13 +398,13 @@ describe('OidcService', () => { state: 'test-state', }); - const details = await service.getConsentRequest({ - consentRequestId: consent.consentRequestId, + const details = await service.getAuthorizationSession({ + sessionId: authSession.id, }); expect(details).toEqual( expect.objectContaining({ - id: consent.consentRequestId, + id: authSession.id, clientId: client.clientId, clientName: 'Test Client', redirectUri: 'https://example.com/callback', @@ -416,8 +416,8 @@ describe('OidcService', () => { }); }); - describe('deleteConsentRequest', () => { - it('should delete a consent request', async () => { + describe('rejectAuthorizationSession', () => { + it('should delete a authorization session', async () => { const { service } = await createOidcService(databaseId); const client = await service.registerClient({ @@ -425,31 +425,35 @@ describe('OidcService', () => { redirectUris: ['https://example.com/callback'], }); - const consent = await service.createConsentRequest({ + const authSession = await service.createAuthorizationSession({ clientId: client.clientId, redirectUri: 'https://example.com/callback', responseType: 'code', }); - await service.deleteConsentRequest({ - consentRequestId: consent.consentRequestId, + await service.rejectAuthorizationSession({ + sessionId: authSession.id, }); await expect( - service.getConsentRequest({ - consentRequestId: consent.consentRequestId, + service.getAuthorizationSession({ + sessionId: authSession.id, }), - ).rejects.toThrow('Invalid consent request'); + ).resolves.toEqual( + expect.objectContaining({ + status: 'rejected', + }), + ); }); - it('should handle deleting non-existent consent request', async () => { + it('should throw error for invalid authorization session', async () => { const { service } = await createOidcService(databaseId); await expect( - service.deleteConsentRequest({ - consentRequestId: 'non-existent', + service.rejectAuthorizationSession({ + sessionId: 'invalid-session', }), - ).resolves.not.toThrow(); + ).rejects.toThrow('Invalid authorization session'); }); }); diff --git a/plugins/auth-backend/src/service/OidcService.ts b/plugins/auth-backend/src/service/OidcService.ts index 4214f53a8c..e89dfeac6f 100644 --- a/plugins/auth-backend/src/service/OidcService.ts +++ b/plugins/auth-backend/src/service/OidcService.ts @@ -126,7 +126,7 @@ export class OidcService { }); } - public async createConsentRequest(opts: { + public async createAuthorizationSession(opts: { clientId: string; redirectUri: string; responseType: string; @@ -185,43 +185,24 @@ export class OidcService { expiresAt: sessionExpiresAt, }); - const consentRequestId = crypto.randomUUID(); - const consentExpiresAt = DateTime.now().plus({ minutes: 30 }).toISO(); - - await this.oidc.createConsentRequest({ - id: consentRequestId, - sessionId, - expiresAt: consentExpiresAt, - }); - return { - consentRequestId, + id: sessionId, clientName: client.clientName, scope, redirectUri, }; } - public async approveConsentRequest(opts: { - consentRequestId: string; + public async approveAuthorizationSession(opts: { + sessionId: string; userEntityRef: string; }) { - const { consentRequestId, userEntityRef } = opts; - - const consentRequest = await this.oidc.getConsentRequest({ - id: consentRequestId, - }); - if (!consentRequest) { - throw new InputError('Invalid consent request'); - } - - if (DateTime.fromISO(consentRequest.expiresAt) < DateTime.now()) { - throw new InputError('Consent request expired'); - } + const { sessionId, userEntityRef } = opts; const session = await this.oidc.getAuthorizationSession({ - id: consentRequest.sessionId, + id: sessionId, }); + if (!session) { throw new InputError('Invalid authorization session'); } @@ -245,9 +226,8 @@ export class OidcService { expiresAt: codeExpiresAt, }); - await this.oidc.deleteConsentRequest({ id: consentRequestId }); - const redirectUrl = new URL(session.redirectUri); + redirectUrl.searchParams.append('code', authorizationCode); if (session.state) { redirectUrl.searchParams.append('state', session.state); @@ -258,33 +238,26 @@ export class OidcService { }; } - public async getConsentRequest(opts: { consentRequestId: string }) { - const consentRequest = await this.oidc.getConsentRequest({ - id: opts.consentRequestId, - }); - if (!consentRequest) { - throw new InputError('Invalid consent request'); - } - - if (DateTime.fromISO(consentRequest.expiresAt) < DateTime.now()) { - throw new InputError('Consent request expired'); - } - + public async getAuthorizationSession(opts: { sessionId: string }) { const session = await this.oidc.getAuthorizationSession({ - id: consentRequest.sessionId, + id: opts.sessionId, }); if (!session) { throw new InputError('Invalid authorization session'); } + if (DateTime.fromISO(session.expiresAt) < DateTime.now()) { + throw new InputError('Authorization session expired'); + } + const client = await this.oidc.getClient({ clientId: session.clientId }); if (!client) { throw new InputError('Invalid client_id'); } return { - id: consentRequest.id, + id: session.id, clientId: session.clientId, clientName: client.clientName, redirectUri: session.redirectUri, @@ -294,24 +267,28 @@ export class OidcService { codeChallenge: session.codeChallenge, codeChallengeMethod: session.codeChallengeMethod, nonce: session.nonce, - expiresAt: consentRequest.expiresAt, + expiresAt: session.expiresAt, + status: session.status, }; } - public async deleteConsentRequest(opts: { consentRequestId: string }) { - const consentRequest = await this.oidc.getConsentRequest({ - id: opts.consentRequestId, + public async rejectAuthorizationSession(opts: { sessionId: string }) { + const session = await this.oidc.getAuthorizationSession({ + id: opts.sessionId, }); - if (!consentRequest) { - return; + + if (!session) { + throw new InputError('Invalid authorization session'); + } + + if (DateTime.fromISO(session.expiresAt) < DateTime.now()) { + throw new InputError('Authorization session expired'); } await this.oidc.updateAuthorizationSession({ - id: consentRequest.sessionId, + id: session.id, status: 'rejected', }); - - await this.oidc.deleteConsentRequest({ id: opts.consentRequestId }); } public async authorize(opts: { @@ -487,15 +464,6 @@ export class OidcService { used: true, }); - const accessTokenId = crypto.randomUUID(); - const expiresAt = DateTime.now().plus({ hours: 1 }).toISO(); - - await this.oidc.createAccessToken({ - tokenId: accessTokenId, - sessionId: session.id, - expiresAt, - }); - const { token } = await this.tokenIssuer.issueToken({ claims: { sub: session.userEntityRef, diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 28f218c749..1f5fea7cb5 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -21,6 +21,7 @@ import { AuthService, DatabaseService, DiscoveryService, + HttpAuthService, LoggerService, RootConfigService, } from '@backstage/backend-plugin-api'; @@ -52,6 +53,7 @@ interface RouterOptions { providerFactories?: ProviderFactories; catalog: CatalogService; ownershipResolver?: AuthOwnershipResolver; + httpAuth: HttpAuthService; } export async function createRouter( @@ -64,6 +66,7 @@ export async function createRouter( database: db, tokenFactoryAlgorithm, providerFactories = {}, + httpAuth, } = options; const router = Router(); @@ -158,6 +161,7 @@ export async function createRouter( userInfo, oidc, logger, + httpAuth, }); router.use(oidcRouter.getRouter()); From 1122bb29acd56322a5c7bd51c2175185a3e8e628 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 7 Jul 2025 14:02:02 +0200 Subject: [PATCH 066/107] feat: add sqlreports and fixing up Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- plugins/auth-backend/report.sql.md | 53 +++++++++++++++++++ plugins/auth-backend/src/authPlugin.ts | 3 ++ .../src/service/OidcRouter.test.ts | 43 +++++++++------ .../auth-backend/src/service/OidcRouter.ts | 1 - .../src/service/OidcService.test.ts | 1 - 5 files changed, 82 insertions(+), 19 deletions(-) diff --git a/plugins/auth-backend/report.sql.md b/plugins/auth-backend/report.sql.md index b135414af6..7622a5750e 100644 --- a/plugins/auth-backend/report.sql.md +++ b/plugins/auth-backend/report.sql.md @@ -5,6 +5,59 @@ > [!WARNING] > Failed to migrate down from '20220321100910_timestamptz_again.js' +## Table `oauth_authorization_sessions` + +| Column | Type | Nullable | Max Length | Default | +| ----------------------- | -------------------------- | -------- | ---------- | ----------------- | +| `client_id` | `character varying` | false | 255 | - | +| `code_challenge` | `character varying` | true | 255 | - | +| `code_challenge_method` | `character varying` | true | 255 | - | +| `expires_at` | `timestamp with time zone` | false | - | - | +| `id` | `character varying` | false | 255 | - | +| `nonce` | `character varying` | true | 255 | - | +| `redirect_uri` | `text` | false | - | - | +| `response_type` | `character varying` | false | 255 | - | +| `scope` | `text` | true | - | - | +| `state` | `character varying` | true | 255 | - | +| `status` | `text` | true | - | `'pending'::text` | +| `user_entity_ref` | `character varying` | true | 255 | - | + +### Indices + +- `oauth_authorization_sessions_client_id_user_entity_ref_index` (`client_id`, `user_entity_ref`) +- `oauth_authorization_sessions_pkey` (`id`) unique primary +- `oauth_authorization_sessions_status_expires_at_index` (`status`, `expires_at`) + +## Table `oidc_authorization_codes` + +| Column | Type | Nullable | Max Length | Default | +| ------------ | -------------------------- | -------- | ---------- | ------- | +| `code` | `character varying` | false | 255 | - | +| `expires_at` | `timestamp with time zone` | false | - | - | +| `session_id` | `character varying` | false | 255 | - | +| `used` | `boolean` | true | - | `false` | + +### Indices + +- `oidc_authorization_codes_pkey` (`code`) unique primary + +## Table `oidc_clients` + +| Column | Type | Nullable | Max Length | Default | +| ---------------- | ------------------- | -------- | ---------- | ------- | +| `client_id` | `character varying` | false | 255 | - | +| `client_name` | `character varying` | false | 255 | - | +| `client_secret` | `character varying` | false | 255 | - | +| `grant_types` | `text` | false | - | - | +| `metadata` | `text` | true | - | - | +| `redirect_uris` | `text` | false | - | - | +| `response_types` | `text` | false | - | - | +| `scope` | `text` | true | - | - | + +### Indices + +- `oidc_clients_pkey` (`client_id`) unique primary + ## Table `sessions` | Column | Type | Nullable | Max Length | Default | diff --git a/plugins/auth-backend/src/authPlugin.ts b/plugins/auth-backend/src/authPlugin.ts index f3877d48cd..025d72fcb7 100644 --- a/plugins/auth-backend/src/authPlugin.ts +++ b/plugins/auth-backend/src/authPlugin.ts @@ -66,6 +66,7 @@ export const authPlugin = createBackendPlugin({ database: coreServices.database, discovery: coreServices.discovery, auth: coreServices.auth, + httpAuth: coreServices.httpAuth, catalog: catalogServiceRef, }, async init({ @@ -75,6 +76,7 @@ export const authPlugin = createBackendPlugin({ database, discovery, auth, + httpAuth, catalog, }) { const router = await createRouter({ @@ -86,6 +88,7 @@ export const authPlugin = createBackendPlugin({ catalog, providerFactories: Object.fromEntries(providers), ownershipResolver, + httpAuth, }); httpRouter.addAuthPolicy({ path: '/', diff --git a/plugins/auth-backend/src/service/OidcRouter.test.ts b/plugins/auth-backend/src/service/OidcRouter.test.ts index 579c96470f..fe5182251f 100644 --- a/plugins/auth-backend/src/service/OidcRouter.test.ts +++ b/plugins/auth-backend/src/service/OidcRouter.test.ts @@ -24,7 +24,6 @@ import { startTestBackend, TestDatabases, TestDatabaseId, - mockCredentials, } from '@backstage/backend-test-utils'; import request from 'supertest'; import crypto from 'crypto'; @@ -68,6 +67,7 @@ describe('OidcRouter', () => { } as unknown as jest.Mocked; const mockAuth = mockServices.auth.mock(); + const mockHttpAuth = mockServices.httpAuth.mock(); const oidcService = OidcService.create({ auth: mockAuth, @@ -85,14 +85,13 @@ describe('OidcRouter', () => { logger: mockServices.logger.mock(), userInfo: userInfoDatabase, oidc: oidcDatabase, - httpAuth: mockServices.httpAuth({ - defaultCredentials: mockCredentials.user(), - }), + httpAuth: mockHttpAuth, }); return { router: oidcRouter, mocks: { + httpAuth: mockHttpAuth, auth: mockAuth, oidc: oidcDatabase, userInfo: userInfoDatabase, @@ -138,7 +137,6 @@ describe('OidcRouter', () => { ], }); - auth.authenticate.mockResolvedValueOnce({} as any); auth.isPrincipal.mockReturnValueOnce(true); const response = await request(server) @@ -194,7 +192,6 @@ describe('OidcRouter', () => { ], }); - auth.authenticate.mockResolvedValueOnce({} as any); auth.isPrincipal.mockReturnValueOnce(true); const response = await request(server) @@ -361,9 +358,9 @@ describe('OidcRouter', () => { }); }); - it('should approve consent request', async () => { + it('should approve authorization session', async () => { const { - mocks: { auth, service }, + mocks: { auth, service, httpAuth }, router, } = await createRouter(databaseId); @@ -403,6 +400,14 @@ describe('OidcRouter', () => { ], }); + httpAuth.credentials.mockResolvedValueOnce({ + principal: { + type: 'user', + userEntityRef: 'user:default/test-user', + }, + $$type: '@backstage/BackstageCredentials', + }); + auth.isPrincipal.mockReturnValueOnce(true); const response = await request(server) @@ -474,17 +479,18 @@ describe('OidcRouter', () => { describe('token exchange', () => { it('should exchange authorization code for tokens', async () => { const { - mocks: { auth, service, tokenIssuer }, + mocks: { auth, service, tokenIssuer, httpAuth }, router, } = await createRouter(databaseId); - auth.authenticate.mockResolvedValueOnce({ + httpAuth.credentials.mockResolvedValueOnce({ principal: { type: 'user', userEntityRef: 'user:default/test-user', }, $$type: '@backstage/BackstageCredentials', }); + auth.isPrincipal.mockReturnValueOnce(true); tokenIssuer.issueToken.mockResolvedValue({ @@ -565,7 +571,7 @@ describe('OidcRouter', () => { it('should exchange authorization code for tokens with PKCE', async () => { const { - mocks: { auth, service, tokenIssuer }, + mocks: { auth, service, tokenIssuer, httpAuth }, router, } = await createRouter(databaseId); @@ -573,13 +579,14 @@ describe('OidcRouter', () => { token: 'mock-access-token-pkce', }); - auth.authenticate.mockResolvedValueOnce({ + httpAuth.credentials.mockResolvedValueOnce({ principal: { type: 'user', userEntityRef: 'user:default/test-user-pkce', }, $$type: '@backstage/BackstageCredentials', }); + auth.isPrincipal.mockReturnValueOnce(true); const client = await service.registerClient({ @@ -656,24 +663,25 @@ describe('OidcRouter', () => { expect(tokenIssuer.issueToken).toHaveBeenCalledWith({ claims: { - sub: MOCK_USER_ENTITY_REF, + sub: 'user:default/test-user-pkce', }, }); }); it('should reject token exchange with invalid client credentials', async () => { const { - mocks: { auth, service }, + mocks: { auth, service, httpAuth }, router, } = await createRouter(databaseId); - auth.authenticate.mockResolvedValueOnce({ + httpAuth.credentials.mockResolvedValueOnce({ principal: { type: 'user', userEntityRef: 'user:default/test-user', }, $$type: '@backstage/BackstageCredentials', }); + auth.isPrincipal.mockReturnValueOnce(true); const client = await service.registerClient({ @@ -789,7 +797,7 @@ describe('OidcRouter', () => { it('should exchange authorization code for tokens with PKCE S256', async () => { const { - mocks: { auth, service, tokenIssuer }, + mocks: { auth, service, tokenIssuer, httpAuth }, router, } = await createRouter(databaseId); @@ -797,13 +805,14 @@ describe('OidcRouter', () => { token: 'mock-access-token-s256', }); - auth.authenticate.mockResolvedValueOnce({ + httpAuth.credentials.mockResolvedValueOnce({ principal: { type: 'user', userEntityRef: 'user:default/test-user-s256', }, $$type: '@backstage/BackstageCredentials', }); + auth.isPrincipal.mockReturnValueOnce(true); const client = await service.registerClient({ diff --git a/plugins/auth-backend/src/service/OidcRouter.ts b/plugins/auth-backend/src/service/OidcRouter.ts index c07ffd5adb..a0acc57cec 100644 --- a/plugins/auth-backend/src/service/OidcRouter.ts +++ b/plugins/auth-backend/src/service/OidcRouter.ts @@ -211,7 +211,6 @@ export class OidcRouter { redirectUrl: result.redirectUrl, }); } catch (error) { - console.log(error); this.logger.error( `Failed to approve authorization session: ${ isError(error) ? error.message : 'Unknown error' diff --git a/plugins/auth-backend/src/service/OidcService.test.ts b/plugins/auth-backend/src/service/OidcService.test.ts index 5067cde458..4820559b01 100644 --- a/plugins/auth-backend/src/service/OidcService.test.ts +++ b/plugins/auth-backend/src/service/OidcService.test.ts @@ -28,7 +28,6 @@ import { import { AuthDatabase } from '../database/AuthDatabase'; import { OidcDatabase } from '../database/OidcDatabase'; import { UserInfoDatabase } from '../database/UserInfoDatabase'; -import { InputError, AuthenticationError } from '@backstage/errors'; import crypto from 'crypto'; import { AnyJWK, TokenIssuer } from '../identity/types'; From 75e0cdbc0b9069d7fa7483312fe49338ec36f251 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 10 Jul 2025 07:52:54 +0200 Subject: [PATCH 067/107] chore: when session has been accepted or approved it should return not found from apio Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- .../auth-backend/src/service/OidcRouter.ts | 14 +- .../src/service/OidcService.test.ts | 165 +++++++++++++++++- .../auth-backend/src/service/OidcService.ts | 27 ++- 3 files changed, 186 insertions(+), 20 deletions(-) diff --git a/plugins/auth-backend/src/service/OidcRouter.ts b/plugins/auth-backend/src/service/OidcRouter.ts index a0acc57cec..60c0e42f92 100644 --- a/plugins/auth-backend/src/service/OidcRouter.ts +++ b/plugins/auth-backend/src/service/OidcRouter.ts @@ -165,15 +165,14 @@ export class OidcRouter { redirectUri: session.redirectUri, }); } catch (error) { + const description = isError(error) ? error.message : 'Unknown error'; this.logger.error( - `Failed to get authorization session: ${ - isError(error) ? error.message : 'Unknown error' - }`, + `Failed to get authorization session: ${description}`, error, ); return res.status(404).json({ error: 'not_found', - error_description: 'Authorization session not found or expired', + error_description: description, }); } }); @@ -211,15 +210,14 @@ export class OidcRouter { redirectUrl: result.redirectUrl, }); } catch (error) { + const description = isError(error) ? error.message : 'Unknown error'; this.logger.error( - `Failed to approve authorization session: ${ - isError(error) ? error.message : 'Unknown error' - }`, + `Failed to approve authorization session: ${description}`, error, ); return res.status(400).json({ error: 'invalid_request', - error_description: isError(error) ? error.message : 'Unknown error', + error_description: description, }); } }); diff --git a/plugins/auth-backend/src/service/OidcService.test.ts b/plugins/auth-backend/src/service/OidcService.test.ts index 4820559b01..017d3ab481 100644 --- a/plugins/auth-backend/src/service/OidcService.test.ts +++ b/plugins/auth-backend/src/service/OidcService.test.ts @@ -378,6 +378,59 @@ describe('OidcService', () => { }), ).rejects.toThrow('Invalid authorization session'); }); + + it('should throw error when trying to approve an already approved session', async () => { + const { service } = await createOidcService(databaseId); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + }); + + const authSession = await service.createAuthorizationSession({ + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + }); + + await service.approveAuthorizationSession({ + sessionId: authSession.id, + userEntityRef: 'user:default/test', + }); + + await expect( + service.approveAuthorizationSession({ + sessionId: authSession.id, + userEntityRef: 'user:default/test', + }), + ).rejects.toThrow('Authorization session not found or expired'); + }); + + it('should throw error when trying to approve an already rejected session', async () => { + const { service } = await createOidcService(databaseId); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + }); + + const authSession = await service.createAuthorizationSession({ + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + }); + + await service.rejectAuthorizationSession({ + sessionId: authSession.id, + }); + + await expect( + service.approveAuthorizationSession({ + sessionId: authSession.id, + userEntityRef: 'user:default/test', + }), + ).rejects.toThrow('Authorization session not found or expired'); + }); }); describe('getAuthorizationSession', () => { @@ -413,10 +466,34 @@ describe('OidcService', () => { }), ); }); - }); - describe('rejectAuthorizationSession', () => { - it('should delete a authorization session', async () => { + it('should throw error when trying to get an already approved session', async () => { + const { service } = await createOidcService(databaseId); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + }); + + const authSession = await service.createAuthorizationSession({ + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + }); + + await service.approveAuthorizationSession({ + sessionId: authSession.id, + userEntityRef: 'user:default/test', + }); + + await expect( + service.getAuthorizationSession({ + sessionId: authSession.id, + }), + ).rejects.toThrow('Authorization session not found or expired'); + }); + + it('should throw error when trying to get an already rejected session', async () => { const { service } = await createOidcService(databaseId); const client = await service.registerClient({ @@ -438,11 +515,34 @@ describe('OidcService', () => { service.getAuthorizationSession({ sessionId: authSession.id, }), - ).resolves.toEqual( - expect.objectContaining({ - status: 'rejected', + ).rejects.toThrow('Authorization session not found or expired'); + }); + }); + + describe('rejectAuthorizationSession', () => { + it('should reject a authorization session', async () => { + const { service } = await createOidcService(databaseId); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + }); + + const authSession = await service.createAuthorizationSession({ + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + }); + + await service.rejectAuthorizationSession({ + sessionId: authSession.id, + }); + + await expect( + service.getAuthorizationSession({ + sessionId: authSession.id, }), - ); + ).rejects.toThrow('Authorization session not found or expired'); }); it('should throw error for invalid authorization session', async () => { @@ -454,6 +554,57 @@ describe('OidcService', () => { }), ).rejects.toThrow('Invalid authorization session'); }); + + it('should throw error when trying to reject an already approved session', async () => { + const { service } = await createOidcService(databaseId); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + }); + + const authSession = await service.createAuthorizationSession({ + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + }); + + await service.approveAuthorizationSession({ + sessionId: authSession.id, + userEntityRef: 'user:default/test', + }); + + await expect( + service.rejectAuthorizationSession({ + sessionId: authSession.id, + }), + ).rejects.toThrow('Authorization session not found or expired'); + }); + + it('should throw error when trying to reject an already rejected session', async () => { + const { service } = await createOidcService(databaseId); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://example.com/callback'], + }); + + const authSession = await service.createAuthorizationSession({ + clientId: client.clientId, + redirectUri: 'https://example.com/callback', + responseType: 'code', + }); + + await service.rejectAuthorizationSession({ + sessionId: authSession.id, + }); + + await expect( + service.rejectAuthorizationSession({ + sessionId: authSession.id, + }), + ).rejects.toThrow('Authorization session not found or expired'); + }); }); describe('authorize', () => { diff --git a/plugins/auth-backend/src/service/OidcService.ts b/plugins/auth-backend/src/service/OidcService.ts index e89dfeac6f..7809d2fd00 100644 --- a/plugins/auth-backend/src/service/OidcService.ts +++ b/plugins/auth-backend/src/service/OidcService.ts @@ -16,7 +16,11 @@ import { AuthService } from '@backstage/backend-plugin-api'; import { TokenIssuer } from '../identity/types'; import { UserInfoDatabase } from '../database/UserInfoDatabase'; -import { InputError, AuthenticationError } from '@backstage/errors'; +import { + InputError, + AuthenticationError, + NotFoundError, +} from '@backstage/errors'; import { decodeJwt } from 'jose'; import crypto from 'crypto'; import { OidcDatabase } from '../database/OidcDatabase'; @@ -204,13 +208,17 @@ export class OidcService { }); if (!session) { - throw new InputError('Invalid authorization session'); + throw new NotFoundError('Invalid authorization session'); } if (DateTime.fromISO(session.expiresAt) < DateTime.now()) { throw new InputError('Authorization session expired'); } + if (session.status !== 'pending') { + throw new NotFoundError('Authorization session not found or expired'); + } + await this.oidc.updateAuthorizationSession({ id: session.id, userEntityRef, @@ -244,13 +252,17 @@ export class OidcService { }); if (!session) { - throw new InputError('Invalid authorization session'); + throw new NotFoundError('Invalid authorization session'); } if (DateTime.fromISO(session.expiresAt) < DateTime.now()) { throw new InputError('Authorization session expired'); } + if (session.status !== 'pending') { + throw new NotFoundError('Authorization session not found or expired'); + } + const client = await this.oidc.getClient({ clientId: session.clientId }); if (!client) { throw new InputError('Invalid client_id'); @@ -278,13 +290,17 @@ export class OidcService { }); if (!session) { - throw new InputError('Invalid authorization session'); + throw new NotFoundError('Invalid authorization session'); } if (DateTime.fromISO(session.expiresAt) < DateTime.now()) { throw new InputError('Authorization session expired'); } + if (session.status !== 'pending') { + throw new NotFoundError('Authorization session not found or expired'); + } + await this.oidc.updateAuthorizationSession({ id: session.id, status: 'rejected', @@ -424,8 +440,9 @@ export class OidcService { const session = await this.oidc.getAuthorizationSession({ id: authCode.sessionId, }); + if (!session) { - throw new AuthenticationError('Invalid authorization session'); + throw new NotFoundError('Invalid authorization session'); } if (session.clientId !== clientId) { throw new AuthenticationError('Client ID mismatch'); From 1d47bf37f59dc8927ccb3f103217e4d05f2ce035 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 21 Jul 2025 16:21:28 +0200 Subject: [PATCH 068/107] chore: add changesets Signed-off-by: benjdlambert --- .changeset/eleven-doors-down.md | 5 +++++ .changeset/eleven-doors-own.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/eleven-doors-down.md create mode 100644 .changeset/eleven-doors-own.md diff --git a/.changeset/eleven-doors-down.md b/.changeset/eleven-doors-down.md new file mode 100644 index 0000000000..47cb99dd03 --- /dev/null +++ b/.changeset/eleven-doors-down.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-mcp-actions-backend': patch +--- + +Proxy `/.well-known/oauth-authorization-server` to `/.well-known/openid-configuration` on `auth-backend` diff --git a/.changeset/eleven-doors-own.md b/.changeset/eleven-doors-own.md new file mode 100644 index 0000000000..1308aab3eb --- /dev/null +++ b/.changeset/eleven-doors-own.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Implementing Dynamic Client Registration with the OIDC server From e81f461ed88eac0c8328bb49977615b589cb8b4c Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 8 Sep 2025 10:35:41 +0200 Subject: [PATCH 069/107] chore: fix support for returning Signed-off-by: benjdlambert --- .../auth-backend/src/database/OidcDatabase.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/plugins/auth-backend/src/database/OidcDatabase.ts b/plugins/auth-backend/src/database/OidcDatabase.ts index 17dd4f2c06..f4f99ef6a6 100644 --- a/plugins/auth-backend/src/database/OidcDatabase.ts +++ b/plugins/auth-backend/src/database/OidcDatabase.ts @@ -167,6 +167,32 @@ export class OidcDatabase { Object.entries(row).filter(([_, value]) => value !== undefined), ); + // MySQL and SQLite3 don't support RETURNING + if ( + this.db.client.config.client.includes('sqlite3') || + this.db.client.config.client.includes('mysql') + ) { + return await this.db.transaction(async trx => { + await trx('oauth_authorization_sessions') + .where('id', session.id) + .update(updatedFields); + + const updated = await trx( + 'oauth_authorization_sessions', + ) + .where('id', session.id) + .first(); + + if (!updated) { + throw new Error( + `Failed to retrieve updated authorization session with id ${session.id}`, + ); + } + + return this.rowToAuthorizationSession(updated) as AuthorizationSession; + }); + } + const [updated] = await this.db( 'oauth_authorization_sessions', ) @@ -229,6 +255,32 @@ export class OidcDatabase { Object.entries(row).filter(([_, value]) => value !== undefined), ); + // MySQL and SQLite3 don't support RETURNING + if ( + this.db.client.config.client.includes('sqlite3') || + this.db.client.config.client.includes('mysql') + ) { + return await this.db.transaction(async trx => { + await trx('oidc_authorization_codes') + .where('code', authorizationCode.code) + .update(updatedFields); + + const updated = await trx( + 'oidc_authorization_codes', + ) + .where('code', authorizationCode.code) + .first(); + + if (!updated) { + throw new Error( + `Failed to retrieve updated authorization code with code ${authorizationCode.code}`, + ); + } + + return this.rowToAuthorizationCode(updated) as AuthorizationCode; + }); + } + const [updated] = await this.db( 'oidc_authorization_codes', ) From 838429ac898c9bd67e28a873770b187e8be69ace Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 8 Sep 2025 10:52:05 +0200 Subject: [PATCH 070/107] chore: fix some more typescript errors Signed-off-by: benjdlambert --- .../src/database/TestDatabases.ts | 2 +- .../src/database/OidcDatabase.test.ts | 20 +++++++-------- .../auth-backend/src/database/OidcDatabase.ts | 25 +++++++++++++------ .../src/service/OidcService.test.ts | 2 ++ .../auth-backend/src/service/OidcService.ts | 16 ++++++------ 5 files changed, 38 insertions(+), 27 deletions(-) diff --git a/packages/backend-test-utils/src/database/TestDatabases.ts b/packages/backend-test-utils/src/database/TestDatabases.ts index cd20e2997b..00fae4130a 100644 --- a/packages/backend-test-utils/src/database/TestDatabases.ts +++ b/packages/backend-test-utils/src/database/TestDatabases.ts @@ -104,7 +104,7 @@ export class TestDatabases { if (supportedIds.length > 0) { afterAll(async () => { await databases.shutdown(); - }); + }, 30_000); } return databases; diff --git a/plugins/auth-backend/src/database/OidcDatabase.test.ts b/plugins/auth-backend/src/database/OidcDatabase.test.ts index 82064361a5..9965069acf 100644 --- a/plugins/auth-backend/src/database/OidcDatabase.test.ts +++ b/plugins/auth-backend/src/database/OidcDatabase.test.ts @@ -117,7 +117,7 @@ describe('Oidc Database', () => { codeChallenge: 'test-challenge', codeChallengeMethod: 'S256', nonce: 'test-nonce', - expiresAt: '2025-01-01T00:00:00Z', + expiresAt: new Date('2025-01-01T00:00:00Z'), }); expect(session).toEqual( @@ -132,7 +132,7 @@ describe('Oidc Database', () => { codeChallenge: 'test-challenge', codeChallengeMethod: 'S256', nonce: 'test-nonce', - expiresAt: '2025-01-01T00:00:00Z', + expiresAt: new Date('2025-01-01T00:00:00Z'), status: 'pending', }), ); @@ -155,7 +155,7 @@ describe('Oidc Database', () => { clientId: client.clientId, redirectUri: 'https://example.com/callback', responseType: 'code', - expiresAt: '2025-01-01T00:00:00Z', + expiresAt: new Date('2025-01-01T00:00:00Z'), }); await expect( @@ -190,20 +190,20 @@ describe('Oidc Database', () => { clientId: client.clientId, redirectUri: 'https://example.com/callback', responseType: 'code', - expiresAt: '2025-01-01T00:00:00Z', + expiresAt: new Date('2025-01-01T00:00:00Z'), }); const authCode = await oidc.createAuthorizationCode({ code: 'test-code', sessionId: session.id, - expiresAt: '2025-01-01T00:00:00Z', + expiresAt: new Date('2025-01-01T00:00:00Z'), }); expect(authCode).toEqual( expect.objectContaining({ code: 'test-code', sessionId: session.id, - expiresAt: '2025-01-01T00:00:00Z', + expiresAt: new Date('2025-01-01T00:00:00Z'), }), ); }); @@ -230,13 +230,13 @@ describe('Oidc Database', () => { codeChallenge: 'test-challenge', codeChallengeMethod: 'S256', nonce: 'test-nonce', - expiresAt: '2025-01-01T00:00:00Z', + expiresAt: new Date('2025-01-01T00:00:00Z'), }); const authCode = await oidc.createAuthorizationCode({ code: 'test-code', sessionId: session.id, - expiresAt: '2025-01-01T00:00:00Z', + expiresAt: new Date('2025-01-01T00:00:00Z'), }); const authCodeFromDb = await oidc.getAuthorizationCode({ @@ -267,13 +267,13 @@ describe('Oidc Database', () => { clientId: client.clientId, redirectUri: 'https://example.com/callback', responseType: 'code', - expiresAt: '2025-01-01T00:00:00Z', + expiresAt: new Date('2025-01-01T00:00:00Z'), }); const authCode = await oidc.createAuthorizationCode({ code: 'test-code', sessionId: session.id, - expiresAt: '2025-01-01T00:00:00Z', + expiresAt: new Date('2025-01-01T00:00:00Z'), }); const updatedAuthCode = await oidc.updateAuthorizationCode({ diff --git a/plugins/auth-backend/src/database/OidcDatabase.ts b/plugins/auth-backend/src/database/OidcDatabase.ts index f4f99ef6a6..ec9a879803 100644 --- a/plugins/auth-backend/src/database/OidcDatabase.ts +++ b/plugins/auth-backend/src/database/OidcDatabase.ts @@ -16,6 +16,15 @@ import { Knex } from 'knex'; import { AuthDatabase } from './AuthDatabase'; +function toDate(value?: Date | string | number): Date | undefined { + if (!value) { + return undefined; + } + + return typeof value === 'string' || typeof value === 'number' + ? new Date(value) + : value; +} type OidcClientRow = { client_id: string; client_secret: string; @@ -39,13 +48,13 @@ type OAuthAuthorizationSessionRow = { code_challenge_method: string | null; nonce: string | null; status: 'pending' | 'approved' | 'rejected' | 'expired'; - expires_at: string; + expires_at: Date | string; }; type OidcAuthorizationCodeRow = { code: string; session_id: string; - expires_at: string; + expires_at: Date | string; used: boolean; }; @@ -72,26 +81,26 @@ export type AuthorizationSession = { codeChallengeMethod?: string; nonce?: string; status: 'pending' | 'approved' | 'rejected' | 'expired'; - expiresAt: string; + expiresAt: Date; }; export type ConsentRequest = { id: string; sessionId: string; - expiresAt: string; + expiresAt: Date; }; export type AuthorizationCode = { code: string; sessionId: string; - expiresAt: string; + expiresAt: Date; used: boolean; }; export type AccessToken = { tokenId: string; sessionId: string; - expiresAt: string; + expiresAt: Date; }; /** @@ -342,7 +351,7 @@ export class OidcDatabase { codeChallengeMethod: row.code_challenge_method ?? undefined, nonce: row.nonce ?? undefined, status: row.status, - expiresAt: row.expires_at, + expiresAt: toDate(row.expires_at), }; } @@ -363,7 +372,7 @@ export class OidcDatabase { return { code: row.code, sessionId: row.session_id, - expiresAt: row.expires_at, + expiresAt: toDate(row.expires_at), used: Boolean(row.used), }; } diff --git a/plugins/auth-backend/src/service/OidcService.test.ts b/plugins/auth-backend/src/service/OidcService.test.ts index 017d3ab481..11a37e20bf 100644 --- a/plugins/auth-backend/src/service/OidcService.test.ts +++ b/plugins/auth-backend/src/service/OidcService.test.ts @@ -31,6 +31,8 @@ import { UserInfoDatabase } from '../database/UserInfoDatabase'; import crypto from 'crypto'; import { AnyJWK, TokenIssuer } from '../identity/types'; +jest.setTimeout(60_000); + describe('OidcService', () => { const databases = TestDatabases.create(); diff --git a/plugins/auth-backend/src/service/OidcService.ts b/plugins/auth-backend/src/service/OidcService.ts index 7809d2fd00..4b47d963c9 100644 --- a/plugins/auth-backend/src/service/OidcService.ts +++ b/plugins/auth-backend/src/service/OidcService.ts @@ -174,7 +174,7 @@ export class OidcService { } const sessionId = crypto.randomUUID(); - const sessionExpiresAt = DateTime.now().plus({ hours: 1 }).toISO(); + const sessionExpiresAt = DateTime.now().plus({ hours: 1 }).toJSDate(); await this.oidc.createAuthorizationSession({ id: sessionId, @@ -211,7 +211,7 @@ export class OidcService { throw new NotFoundError('Invalid authorization session'); } - if (DateTime.fromISO(session.expiresAt) < DateTime.now()) { + if (DateTime.fromJSDate(session.expiresAt) < DateTime.now()) { throw new InputError('Authorization session expired'); } @@ -226,7 +226,7 @@ export class OidcService { }); const authorizationCode = crypto.randomBytes(32).toString('base64url'); - const codeExpiresAt = DateTime.now().plus({ minutes: 10 }).toISO(); + const codeExpiresAt = DateTime.now().plus({ minutes: 10 }).toJSDate(); await this.oidc.createAuthorizationCode({ code: authorizationCode, @@ -255,7 +255,7 @@ export class OidcService { throw new NotFoundError('Invalid authorization session'); } - if (DateTime.fromISO(session.expiresAt) < DateTime.now()) { + if (DateTime.fromJSDate(session.expiresAt) < DateTime.now()) { throw new InputError('Authorization session expired'); } @@ -293,7 +293,7 @@ export class OidcService { throw new NotFoundError('Invalid authorization session'); } - if (DateTime.fromISO(session.expiresAt) < DateTime.now()) { + if (DateTime.fromJSDate(session.expiresAt) < DateTime.now()) { throw new InputError('Authorization session expired'); } @@ -353,7 +353,7 @@ export class OidcService { } const sessionId = crypto.randomUUID(); - const sessionExpiresAt = DateTime.now().plus({ hours: 1 }).toISO(); + const sessionExpiresAt = DateTime.now().plus({ hours: 1 }).toJSDate(); await this.oidc.createAuthorizationSession({ id: sessionId, @@ -375,7 +375,7 @@ export class OidcService { }); const authorizationCode = crypto.randomBytes(32).toString('base64url'); - const codeExpiresAt = DateTime.now().plus({ minutes: 10 }).toISO(); + const codeExpiresAt = DateTime.now().plus({ minutes: 10 }).toJSDate(); await this.oidc.createAuthorizationCode({ code: authorizationCode, @@ -429,7 +429,7 @@ export class OidcService { throw new AuthenticationError('Invalid authorization code'); } - if (DateTime.fromISO(authCode.expiresAt) < DateTime.now()) { + if (DateTime.fromJSDate(authCode.expiresAt) < DateTime.now()) { throw new AuthenticationError('Authorization code expired'); } From d23bab525d9380ca43b2e322d546bec5299ed054 Mon Sep 17 00:00:00 2001 From: gyan Date: Mon, 8 Sep 2025 15:29:54 +0530 Subject: [PATCH 071/107] remove extra theme string from the tool-tip Signed-off-by: gyan --- plugins/user-settings/src/translation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/user-settings/src/translation.ts b/plugins/user-settings/src/translation.ts index 0161508d14..7445a415f2 100644 --- a/plugins/user-settings/src/translation.ts +++ b/plugins/user-settings/src/translation.ts @@ -28,7 +28,7 @@ export const userSettingsTranslationRef = createTranslationRef({ themeToggle: { title: 'Theme', description: 'Change the theme mode', - select: 'Select theme {{theme}}', + select: 'Select {{theme}}', selectAuto: 'Select Auto Theme', names: { light: 'Light', From b713b543564445dd18908b5ffa791da55d2a5686 Mon Sep 17 00:00:00 2001 From: gyan Date: Mon, 8 Sep 2025 15:45:56 +0530 Subject: [PATCH 072/107] add changeset configuration Signed-off-by: gyan --- .changeset/red-shrimps-fall.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/red-shrimps-fall.md diff --git a/.changeset/red-shrimps-fall.md b/.changeset/red-shrimps-fall.md new file mode 100644 index 0000000000..1ee3aca7ed --- /dev/null +++ b/.changeset/red-shrimps-fall.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-user-settings': patch +--- + +Tool-tip text correction for the Theme selection in settings page From 4eda590b6a5e0309fcc23a279fc2931471f5b5c0 Mon Sep 17 00:00:00 2001 From: Shijun Wang Date: Tue, 29 Jul 2025 15:53:19 +0300 Subject: [PATCH 073/107] add logic to construct namespace from provided namespace and plugin id Signed-off-by: Shijun Wang --- .changeset/big-cameras-turn.md | 5 + docs/backend-system/core-services/cache.md | 33 ++++ .../entrypoints/cache/CacheManager.test.ts | 143 ++++++++++++++++++ .../src/entrypoints/cache/CacheManager.ts | 23 ++- 4 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 .changeset/big-cameras-turn.md diff --git a/.changeset/big-cameras-turn.md b/.changeset/big-cameras-turn.md new file mode 100644 index 0000000000..6226b17806 --- /dev/null +++ b/.changeset/big-cameras-turn.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': patch +--- + +Fixed cache namespace and key prefix separator configuration to properly use configured values instead of hardcoded plugin ID. The cache manager now correctly combines the configured namespace with plugin IDs using the configured separator for Redis and Valkey. Memcache and memory store continue to use plugin ID as namespace. diff --git a/docs/backend-system/core-services/cache.md b/docs/backend-system/core-services/cache.md index 0e26e8e1f5..ad4d04b50a 100644 --- a/docs/backend-system/core-services/cache.md +++ b/docs/backend-system/core-services/cache.md @@ -7,6 +7,39 @@ description: Documentation for the Cache service This service lets your plugin interact with a cache. It is bound to your plugin too, so that you will only set and get values in your plugin's private namespace. +## Configuration + +The cache service can be configured using the `backend.cache` section in your `app-config.yaml`: + +```yaml +backend: + cache: + store: redis # or 'valkey', 'memcache', 'memory' + connection: redis://localhost:6379 + + # Store-specific configuration (Redis/Valkey only) + redis: + client: + # Optional: Global namespace prefix for all cache keys + namespace: 'my-app' + # Optional: Separator used between namespace and plugin ID (default: ':') + keyPrefixSeparator: ':' + # Other Redis-specific options... + clearBatchSize: 1000 + useUnlink: false +``` + +### Namespace Configuration + +For Redis and Valkey stores, you can configure a global namespace that will be prefixed to all cache keys: + +- **Without namespace**: Cache keys use only the plugin ID (e.g., `catalog:some-key`) +- **With namespace**: Cache keys use the format `namespace:pluginId:key` (e.g., `my-app:catalog:some-key`) + +The `keyPrefixSeparator` controls what character is used between the namespace and plugin ID (defaults to `:`). + +**Note**: Memory and Memcache stores do not support namespace configuration and will always use the plugin ID directly. + ## Using the service The following example shows how to get a cache client in your `example` backend plugin and setting and getting values from the cache. diff --git a/packages/backend-defaults/src/entrypoints/cache/CacheManager.test.ts b/packages/backend-defaults/src/entrypoints/cache/CacheManager.test.ts index 4e9475b66e..a308caa222 100644 --- a/packages/backend-defaults/src/entrypoints/cache/CacheManager.test.ts +++ b/packages/backend-defaults/src/entrypoints/cache/CacheManager.test.ts @@ -333,4 +333,147 @@ describe('CacheManager store options', () => { keyPrefixSeparator: '!', }); }); + + it('correctly applies namespace configuration to redis and valkey stores', () => { + const testCases = [ + { store: 'redis', namespace: 'test1', separator: ':' }, + { store: 'valkey', namespace: 'test2', separator: '!' }, + ]; + + testCases.forEach(({ store, namespace, separator }) => { + const manager = CacheManager.fromConfig( + mockServices.rootConfig({ + data: { + backend: { + cache: { + store, + connection: 'redis://localhost:6379', + [store]: { + client: { + namespace, + keyPrefixSeparator: separator, + }, + }, + }, + }, + }, + }), + ); + + manager.forPlugin('testPlugin'); + + if (store === 'redis') { + // eslint-disable-next-line jest/no-conditional-expect + expect(KeyvRedis).toHaveBeenCalledWith('redis://localhost:6379', { + namespace, + keyPrefixSeparator: separator, + }); + } else if (store === 'valkey') { + // eslint-disable-next-line jest/no-conditional-expect + expect(KeyvValkey).toHaveBeenCalledWith('redis://localhost:6379', { + namespace, + keyPrefixSeparator: separator, + }); + } + }); + }); + + it('falls back to pluginId when no namespace is configured', () => { + const manager = CacheManager.fromConfig( + mockServices.rootConfig({ + data: { + backend: { + cache: { + store: 'redis', + connection: 'redis://localhost:6379', + }, + }, + }, + }), + ); + + manager.forPlugin('testPlugin'); + + expect(KeyvRedis).toHaveBeenCalledWith('redis://localhost:6379', { + keyPrefixSeparator: ':', + }); + }); + + describe('Namespace construction', () => { + it('returns pluginId when no store options are provided', () => { + const result = (CacheManager as any).constructNamespace( + 'testPlugin', + undefined, + ); + expect(result).toBe('testPlugin'); + }); + + it('returns pluginId when store options have no namespace', () => { + const storeOptions = { + client: { + keyPrefixSeparator: ':', + }, + }; + const result = (CacheManager as any).constructNamespace( + 'testPlugin', + storeOptions, + ); + expect(result).toBe('testPlugin'); + }); + + it('combines namespace and pluginId with default separator', () => { + const storeOptions = { + client: { + namespace: 'my-app', + keyPrefixSeparator: ':', + }, + }; + const result = (CacheManager as any).constructNamespace( + 'testPlugin', + storeOptions, + ); + expect(result).toBe('my-app:testPlugin'); + }); + + it('combines namespace and pluginId with custom separator', () => { + const storeOptions = { + client: { + namespace: 'my-app', + keyPrefixSeparator: '-', + }, + }; + const result = (CacheManager as any).constructNamespace( + 'testPlugin', + storeOptions, + ); + expect(result).toBe('my-app-testPlugin'); + }); + + it('uses default separator when keyPrefixSeparator is not provided', () => { + const storeOptions = { + client: { + namespace: 'my-app', + }, + }; + const result = (CacheManager as any).constructNamespace( + 'testPlugin', + storeOptions, + ); + expect(result).toBe('my-app:testPlugin'); + }); + + it('handles empty namespace by falling back to pluginId', () => { + const storeOptions = { + client: { + namespace: '', + keyPrefixSeparator: ':', + }, + }; + const result = (CacheManager as any).constructNamespace( + 'testPlugin', + storeOptions, + ); + expect(result).toBe('testPlugin'); + }); + }); }); diff --git a/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts b/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts index 1c26083525..29bea42273 100644 --- a/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts +++ b/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts @@ -213,6 +213,25 @@ export class CacheManager { return redisOptions; } + /** + * Construct the full namespace based on the options and pluginId. + * + * @param pluginId - The plugin ID to namespace + * @param storeOptions - Optional cache store configuration options + * @returns The constructed namespace string combining the configured namespace with pluginId + */ + private static constructNamespace( + pluginId: string, + storeOptions: CacheStoreOptions | undefined, + ): string { + if (storeOptions?.client?.namespace) { + const separator = storeOptions.client.keyPrefixSeparator ?? ':'; + return `${storeOptions.client.namespace}${separator}${pluginId}`; + } + + return pluginId; + } + /** @internal */ constructor( store: string, @@ -286,7 +305,7 @@ export class CacheManager { }); } return new Keyv({ - namespace: pluginId, + namespace: CacheManager.constructNamespace(pluginId, this.storeOptions), ttl: defaultTtl, store: stores[pluginId], emitErrors: false, @@ -326,7 +345,7 @@ export class CacheManager { }); } return new Keyv({ - namespace: pluginId, + namespace: CacheManager.constructNamespace(pluginId, this.storeOptions), ttl: defaultTtl, store: stores[pluginId], emitErrors: false, From 0b9278c6235374ac5df7f2deed24dcb5e677a080 Mon Sep 17 00:00:00 2001 From: Shijun Wang Date: Mon, 8 Sep 2025 13:32:43 +0300 Subject: [PATCH 074/107] fix type errors Signed-off-by: Shijun Wang --- .../src/entrypoints/cache/CacheManager.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts b/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts index 29bea42273..06b2b0c57b 100644 --- a/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts +++ b/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts @@ -222,14 +222,15 @@ export class CacheManager { */ private static constructNamespace( pluginId: string, - storeOptions: CacheStoreOptions | undefined, + storeOptions: RedisCacheStoreOptions | undefined, ): string { - if (storeOptions?.client?.namespace) { - const separator = storeOptions.client.keyPrefixSeparator ?? ':'; - return `${storeOptions.client.namespace}${separator}${pluginId}`; - } + const prefix = storeOptions?.client?.namespace + ? `${storeOptions.client.namespace}${ + storeOptions.client.keyPrefixSeparator ?? ':' + }` + : ''; - return pluginId; + return `${prefix}${pluginId}`; } /** @internal */ From 025fdd20ea1de2fa14c28bf8ee6c8d921222a593 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 8 Sep 2025 13:12:36 +0200 Subject: [PATCH 075/107] chore: clientId and clientSecret are not to be passed to the token endpoint Signed-off-by: benjdlambert --- .../src/service/OidcRouter.test.ts | 97 +------------------ .../auth-backend/src/service/OidcRouter.ts | 6 +- .../src/service/OidcService.test.ts | 41 -------- .../auth-backend/src/service/OidcService.ts | 23 +---- 4 files changed, 3 insertions(+), 164 deletions(-) diff --git a/plugins/auth-backend/src/service/OidcRouter.test.ts b/plugins/auth-backend/src/service/OidcRouter.test.ts index fe5182251f..6362fd9a02 100644 --- a/plugins/auth-backend/src/service/OidcRouter.test.ts +++ b/plugins/auth-backend/src/service/OidcRouter.test.ts @@ -548,8 +548,6 @@ describe('OidcRouter', () => { .send({ grant_type: 'authorization_code', code: authorizationCode, - client_id: client.clientId, - client_secret: client.clientSecret, redirect_uri: 'https://example.com/callback', }) .expect(200); @@ -646,8 +644,6 @@ describe('OidcRouter', () => { .send({ grant_type: 'authorization_code', code: authorizationCode, - client_id: client.clientId, - client_secret: client.clientSecret, redirect_uri: 'https://example.com/callback', code_verifier: codeVerifier, }) @@ -668,95 +664,8 @@ describe('OidcRouter', () => { }); }); - it('should reject token exchange with invalid client credentials', async () => { - const { - mocks: { auth, service, httpAuth }, - router, - } = await createRouter(databaseId); - - httpAuth.credentials.mockResolvedValueOnce({ - principal: { - type: 'user', - userEntityRef: 'user:default/test-user', - }, - $$type: '@backstage/BackstageCredentials', - }); - - auth.isPrincipal.mockReturnValueOnce(true); - - const client = await service.registerClient({ - clientName: 'Test Client', - redirectUris: ['https://example.com/callback'], - responseTypes: ['code'], - grantTypes: ['authorization_code'], - scope: 'openid', - }); - - const authSession = await service.createAuthorizationSession({ - clientId: client.clientId, - redirectUri: 'https://example.com/callback', - responseType: 'code', - scope: 'openid', - }); - - const { server } = await startTestBackend({ - features: [ - createBackendPlugin({ - pluginId: 'auth', - register(reg) { - reg.registerInit({ - deps: { httpRouter: coreServices.httpRouter }, - async init({ httpRouter }) { - httpRouter.use(router.getRouter()); - httpRouter.addAuthPolicy({ - path: '/', - allow: 'unauthenticated', - }); - }, - }); - }, - }), - ], - }); - - const approvalResponse = await request(server) - .post(`/api/auth/v1/sessions/${authSession.id}/approve`) - .set('Authorization', `Bearer ${MOCK_USER_TOKEN}`) - .expect(200); - - const redirectUrl = new URL(approvalResponse.body.redirectUrl); - const authorizationCode = redirectUrl.searchParams.get('code'); - - const tokenResponse = await request(server) - .post('/api/auth/v1/token') - .send({ - grant_type: 'authorization_code', - code: authorizationCode, - client_id: client.clientId, - client_secret: 'invalid-secret', - redirect_uri: 'https://example.com/callback', - }) - .expect(401); - - expect(tokenResponse.body).toEqual({ - error: 'invalid_client', - error_description: 'Invalid client credentials', - }); - }); - it('should reject token exchange with invalid authorization code', async () => { - const { - mocks: { service }, - router, - } = await createRouter(databaseId); - - const client = await service.registerClient({ - clientName: 'Test Client', - redirectUris: ['https://example.com/callback'], - responseTypes: ['code'], - grantTypes: ['authorization_code'], - scope: 'openid', - }); + const { router } = await createRouter(databaseId); const { server } = await startTestBackend({ features: [ @@ -783,8 +692,6 @@ describe('OidcRouter', () => { .send({ grant_type: 'authorization_code', code: 'invalid-code', - client_id: client.clientId, - client_secret: client.clientSecret, redirect_uri: 'https://example.com/callback', }) .expect(401); @@ -875,8 +782,6 @@ describe('OidcRouter', () => { .send({ grant_type: 'authorization_code', code: authorizationCode, - client_id: client.clientId, - client_secret: client.clientSecret, redirect_uri: 'https://example.com/callback', code_verifier: codeVerifier, }) diff --git a/plugins/auth-backend/src/service/OidcRouter.ts b/plugins/auth-backend/src/service/OidcRouter.ts index 60c0e42f92..c64d41a73e 100644 --- a/plugins/auth-backend/src/service/OidcRouter.ts +++ b/plugins/auth-backend/src/service/OidcRouter.ts @@ -276,13 +276,11 @@ export class OidcRouter { const { grant_type: grantType, code, - client_id: clientId, - client_secret: clientSecret, redirect_uri: redirectUri, code_verifier: codeVerifier, } = req.body; - if (!grantType || !code || !clientId || !clientSecret || !redirectUri) { + if (!grantType || !code || !redirectUri) { this.logger.error( `Failed to exchange code for token: Missing required parameters`, ); @@ -295,8 +293,6 @@ export class OidcRouter { try { const result = await this.oidc.exchangeCodeForToken({ code, - clientId, - clientSecret, redirectUri, codeVerifier, grantType, diff --git a/plugins/auth-backend/src/service/OidcService.test.ts b/plugins/auth-backend/src/service/OidcService.test.ts index 11a37e20bf..b1f03c68ed 100644 --- a/plugins/auth-backend/src/service/OidcService.test.ts +++ b/plugins/auth-backend/src/service/OidcService.test.ts @@ -668,8 +668,6 @@ describe('OidcService', () => { const tokenResult = await service.exchangeCodeForToken({ code, - clientId: client.clientId, - clientSecret: client.clientSecret, redirectUri: 'https://example.com/callback', grantType: 'authorization_code', }); @@ -689,47 +687,12 @@ describe('OidcService', () => { await expect( service.exchangeCodeForToken({ code: 'test-code', - clientId: 'test-client', - clientSecret: 'test-secret', redirectUri: 'https://example.com/callback', grantType: 'client_credentials', }), ).rejects.toThrow('Unsupported grant type'); }); - it('should throw error for invalid client', async () => { - const { service } = await createOidcService(databaseId); - - await expect( - service.exchangeCodeForToken({ - code: 'test-code', - clientId: 'invalid-client', - clientSecret: 'test-secret', - redirectUri: 'https://example.com/callback', - grantType: 'authorization_code', - }), - ).rejects.toThrow('Invalid client'); - }); - - it('should throw error for invalid client secret', async () => { - const { service } = await createOidcService(databaseId); - - const client = await service.registerClient({ - clientName: 'Test Client', - redirectUris: ['https://example.com/callback'], - }); - - await expect( - service.exchangeCodeForToken({ - code: 'test-code', - clientId: client.clientId, - clientSecret: 'invalid-secret', - redirectUri: 'https://example.com/callback', - grantType: 'authorization_code', - }), - ).rejects.toThrow('Invalid client credentials'); - }); - it('should handle PKCE verification', async () => { const { service, mocks } = await createOidcService(databaseId); const mockToken = 'mock-jwt-token'; @@ -759,8 +722,6 @@ describe('OidcService', () => { const tokenResult = await service.exchangeCodeForToken({ code, - clientId: client.clientId, - clientSecret: client.clientSecret, redirectUri: 'https://example.com/callback', grantType: 'authorization_code', codeVerifier, @@ -792,8 +753,6 @@ describe('OidcService', () => { await expect( service.exchangeCodeForToken({ code, - clientId: client.clientId, - clientSecret: client.clientSecret, redirectUri: 'https://example.com/callback', grantType: 'authorization_code', codeVerifier: 'invalid-verifier', diff --git a/plugins/auth-backend/src/service/OidcService.ts b/plugins/auth-backend/src/service/OidcService.ts index 4b47d963c9..2b7eb40bc9 100644 --- a/plugins/auth-backend/src/service/OidcService.ts +++ b/plugins/auth-backend/src/service/OidcService.ts @@ -396,34 +396,16 @@ export class OidcService { public async exchangeCodeForToken(params: { code: string; - clientId: string; - clientSecret: string; redirectUri: string; codeVerifier?: string; grantType: string; }) { - const { - code, - clientId, - clientSecret, - redirectUri, - codeVerifier, - grantType, - } = params; + const { code, redirectUri, codeVerifier, grantType } = params; if (grantType !== 'authorization_code') { throw new InputError('Unsupported grant type'); } - const client = await this.oidc.getClient({ clientId }); - if (!client) { - throw new AuthenticationError('Invalid client'); - } - - if (client.clientSecret !== clientSecret) { - throw new AuthenticationError('Invalid client credentials'); - } - const authCode = await this.oidc.getAuthorizationCode({ code }); if (!authCode) { throw new AuthenticationError('Invalid authorization code'); @@ -444,9 +426,6 @@ export class OidcService { if (!session) { throw new NotFoundError('Invalid authorization session'); } - if (session.clientId !== clientId) { - throw new AuthenticationError('Client ID mismatch'); - } if (session.redirectUri !== redirectUri) { throw new AuthenticationError('Redirect URI mismatch'); From 929c55adbc7095a6431512cbb37a22513b94062c Mon Sep 17 00:00:00 2001 From: Shijun Wang Date: Mon, 8 Sep 2025 15:07:15 +0300 Subject: [PATCH 076/107] wait for storage to become ready Signed-off-by: Shijun Wang --- .changeset/heavy-cats-unite.md | 6 ++++++ plugins/home/report.api.md | 2 +- .../CustomHomepage/CustomHomepageGrid.tsx | 14 +++++++++++--- 3 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 .changeset/heavy-cats-unite.md diff --git a/.changeset/heavy-cats-unite.md b/.changeset/heavy-cats-unite.md new file mode 100644 index 0000000000..4875093b0a --- /dev/null +++ b/.changeset/heavy-cats-unite.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-home': patch +--- + +Fixed race condition in CustomHomepageGrid by waiting for storage to load before rendering custom layout to prevent +rendering of the default content. diff --git a/plugins/home/report.api.md b/plugins/home/report.api.md index dd33b952ee..73c6d035cc 100644 --- a/plugins/home/report.api.md +++ b/plugins/home/report.api.md @@ -84,7 +84,7 @@ export const createCardExtension: typeof createCardExtension_2; // @public export const CustomHomepageGrid: ( props: CustomHomepageGridProps, -) => JSX_2.Element; +) => JSX_2.Element | null; // @public export type CustomHomepageGridProps = { diff --git a/plugins/home/src/components/CustomHomepage/CustomHomepageGrid.tsx b/plugins/home/src/components/CustomHomepage/CustomHomepageGrid.tsx index 5d85e834e8..b4b674be1b 100644 --- a/plugins/home/src/components/CustomHomepage/CustomHomepageGrid.tsx +++ b/plugins/home/src/components/CustomHomepage/CustomHomepageGrid.tsx @@ -90,7 +90,7 @@ const useStyles = makeStyles((theme: Theme) => function useHomeStorage( defaultWidgets: GridWidget[], -): [GridWidget[], (value: GridWidget[]) => void] { +): [GridWidget[], (value: GridWidget[]) => void, boolean] { const key = 'home'; const storageApi = useApi(storageApiRef).forBucket('home.customHomepage'); // TODO: Support multiple home pages @@ -110,6 +110,9 @@ function useHomeStorage( storageApi.observe$(key), storageApi.snapshot(key), ); + + const isStorageLoading = homeSnapshot.presence === 'unknown' || !homeSnapshot; + const widgets: GridWidget[] = useMemo(() => { if (homeSnapshot.presence === 'absent') { return defaultWidgets; @@ -122,7 +125,7 @@ function useHomeStorage( } }, [homeSnapshot, defaultWidgets]); - return [widgets, setWidgets]; + return [widgets, setWidgets, isStorageLoading]; } const convertConfigToDefaultWidgets = ( @@ -213,7 +216,7 @@ export const CustomHomepageGrid = (props: CustomHomepageGridProps) => { ? convertConfigToDefaultWidgets(props.config, availableWidgets) : []; }, [props.config, availableWidgets]); - const [widgets, setWidgets] = useHomeStorage(defaultLayout); + const [widgets, setWidgets, isStorageLoading] = useHomeStorage(defaultLayout); const [addWidgetDialogOpen, setAddWidgetDialogOpen] = useState(false); const editModeOn = widgets.find(w => w.layout.isResizable) !== undefined; const [editMode, setEditMode] = useState(editModeOn); @@ -322,6 +325,11 @@ export const CustomHomepageGrid = (props: CustomHomepageGridProps) => { ); }; + // Don't render anything while storage is loading + if (isStorageLoading) { + return null; + } + return ( <> From 225cdf5bdf05b767947fb59f1468ebdcdb68c0e9 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 8 Sep 2025 14:27:05 +0200 Subject: [PATCH 077/107] chore: wrap up things in a feature flag Signed-off-by: benjdlambert --- app-config.yaml | 2 + .../src/service/OidcRouter.test.ts | 1 + .../auth-backend/src/service/OidcRouter.ts | 595 +++++++++--------- plugins/auth-backend/src/service/router.ts | 4 + plugins/mcp-actions-backend/src/plugin.ts | 35 +- 5 files changed, 328 insertions(+), 309 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index 60842ee61a..e32609c1a1 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -209,6 +209,8 @@ scaffolder: defaultCommitMessage: 'Initial commit' auth: + experimental: + enableDynamicClientRegistration: true ### Add auth.keyStore.provider to more granularly control how to store JWK data when running # the auth-backend. # diff --git a/plugins/auth-backend/src/service/OidcRouter.test.ts b/plugins/auth-backend/src/service/OidcRouter.test.ts index 6362fd9a02..8714eb7f62 100644 --- a/plugins/auth-backend/src/service/OidcRouter.test.ts +++ b/plugins/auth-backend/src/service/OidcRouter.test.ts @@ -86,6 +86,7 @@ describe('OidcRouter', () => { userInfo: userInfoDatabase, oidc: oidcDatabase, httpAuth: mockHttpAuth, + enableDynamicClientRegistration: true, }); return { diff --git a/plugins/auth-backend/src/service/OidcRouter.ts b/plugins/auth-backend/src/service/OidcRouter.ts index c64d41a73e..6c39115528 100644 --- a/plugins/auth-backend/src/service/OidcRouter.ts +++ b/plugins/auth-backend/src/service/OidcRouter.ts @@ -33,6 +33,7 @@ export class OidcRouter { private readonly auth: AuthService, private readonly appUrl: string, private readonly httpAuth: HttpAuthService, + private readonly enableDynamicClientRegistration: boolean, ) {} static create(options: { @@ -44,6 +45,7 @@ export class OidcRouter { userInfo: UserInfoDatabase; oidc: OidcDatabase; httpAuth: HttpAuthService; + enableDynamicClientRegistration: boolean; }) { return new OidcRouter( OidcService.create(options), @@ -51,6 +53,7 @@ export class OidcRouter { options.auth, options.appUrl, options.httpAuth, + options.enableDynamicClientRegistration, ); } @@ -74,266 +77,6 @@ export class OidcRouter { res.json({ keys }); }); - // Authorization endpoint - // https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest - // Handles the initial authorization request from the client, validates parameters, - // and redirects to the Authorization Session page for user approval - router.get('/v1/authorize', async (req, res) => { - // todo(blam): maybe add zod types for validating input - const { - client_id: clientId, - redirect_uri: redirectUri, - response_type: responseType, - scope, - state, - nonce, - code_challenge: codeChallenge, - code_challenge_method: codeChallengeMethod, - } = req.query; - - if (!clientId || !redirectUri || !responseType) { - this.logger.error(`Failed to authorize: Missing required parameters`); - return res.status(400).json({ - error: 'invalid_request', - error_description: - 'Missing required parameters: client_id, redirect_uri, response_type', - }); - } - - try { - const result = await this.oidc.createAuthorizationSession({ - clientId: clientId as string, - redirectUri: redirectUri as string, - responseType: responseType as string, - scope: scope as string, - state: state as string, - nonce: nonce as string, - codeChallenge: codeChallenge as string, - codeChallengeMethod: codeChallengeMethod as string, - }); - - // todo(blam): maybe this URL could be overridable by config if - // the plugin is mounted somewhere else? - // support slashes in baseUrl? - const authSessionRedirectUrl = new URL( - `/auth/sessions/${result.id}`, - this.appUrl, - ); - - return res.redirect(authSessionRedirectUrl.toString()); - } catch (error) { - const errorParams = new URLSearchParams(); - errorParams.append( - 'error', - isError(error) ? error.name : 'server_error', - ); - errorParams.append( - 'error_description', - isError(error) ? error.message : 'Unknown error', - ); - if (state) { - errorParams.append('state', state as string); - } - - const redirectUrl = new URL(redirectUri as string); - redirectUrl.search = errorParams.toString(); - return res.redirect(redirectUrl.toString()); - } - }); - - // Authorization Session request details endpoint - // Returns Authorization Session request details for the frontned - router.get('/v1/sessions/:sessionId', async (req, res) => { - const { sessionId } = req.params; - - if (!sessionId) { - return res.status(400).json({ - error: 'invalid_request', - error_description: 'Missing Authorization Session ID', - }); - } - - try { - const session = await this.oidc.getAuthorizationSession({ - sessionId, - }); - - return res.json({ - id: session.id, - clientName: session.clientName, - scope: session.scope, - redirectUri: session.redirectUri, - }); - } catch (error) { - const description = isError(error) ? error.message : 'Unknown error'; - this.logger.error( - `Failed to get authorization session: ${description}`, - error, - ); - return res.status(404).json({ - error: 'not_found', - error_description: description, - }); - } - }); - - // Authorization Session approval endpoint - // Handles user approval of Authorization Session requests and generates authorization codes - router.post('/v1/sessions/:sessionId/approve', async (req, res) => { - const { sessionId } = req.params; - - if (!sessionId) { - return res.status(400).json({ - error: 'invalid_request', - error_description: 'Missing authorization session ID', - }); - } - - try { - const httpCredentials = await this.httpAuth.credentials(req); - - if (!this.auth.isPrincipal(httpCredentials, 'user')) { - return res.status(401).json({ - error: 'unauthorized', - error_description: 'Authentication required', - }); - } - - const userEntityRef = httpCredentials.principal.userEntityRef; - - const result = await this.oidc.approveAuthorizationSession({ - sessionId, - userEntityRef, - }); - - return res.json({ - redirectUrl: result.redirectUrl, - }); - } catch (error) { - const description = isError(error) ? error.message : 'Unknown error'; - this.logger.error( - `Failed to approve authorization session: ${description}`, - error, - ); - return res.status(400).json({ - error: 'invalid_request', - error_description: description, - }); - } - }); - - // Authorization Session rejection endpoint - // Handles user rejection of Authorization Session requests and redirects with error - router.post('/v1/sessions/:sessionId/reject', async (req, res) => { - const { sessionId } = req.params; - - if (!sessionId) { - return res.status(400).json({ - error: 'invalid_request', - error_description: 'Missing authorization session ID', - }); - } - - try { - const session = await this.oidc.getAuthorizationSession({ - sessionId, - }); - - await this.oidc.rejectAuthorizationSession({ sessionId }); - - const errorParams = new URLSearchParams(); - errorParams.append('error', 'access_denied'); - errorParams.append('error_description', 'User denied the request'); - if (session.state) { - errorParams.append('state', session.state); - } - - const redirectUrl = new URL(session.redirectUri); - redirectUrl.search = errorParams.toString(); - - return res.json({ - redirectUrl: redirectUrl.toString(), - }); - } catch (error) { - const description = isError(error) ? error.message : 'Unknown error'; - this.logger.error( - `Failed to reject authorization session: ${description}`, - error, - ); - - return res.status(400).json({ - error: 'invalid_request', - error_description: description, - }); - } - }); - - // Token endpoint - // https://openid.net/specs/openid-connect-core-1_0.html#TokenRequest - // Exchanges authorization codes for access tokens and ID tokens - router.post('/v1/token', async (req, res) => { - // todo(blam): maybe add zod types for validating input - const { - grant_type: grantType, - code, - redirect_uri: redirectUri, - code_verifier: codeVerifier, - } = req.body; - - if (!grantType || !code || !redirectUri) { - this.logger.error( - `Failed to exchange code for token: Missing required parameters`, - ); - return res.status(400).json({ - error: 'invalid_request', - error_description: 'Missing required parameters', - }); - } - - try { - const result = await this.oidc.exchangeCodeForToken({ - code, - redirectUri, - codeVerifier, - grantType, - }); - - return res.json({ - access_token: result.accessToken, - token_type: result.tokenType, - expires_in: result.expiresIn, - id_token: result.idToken, - scope: result.scope, - }); - } catch (error) { - const description = isError(error) ? error.message : 'Unknown error'; - this.logger.error( - `Failed to exchange code for token: ${description}`, - error, - ); - - if (isError(error)) { - if (error.name === 'AuthenticationError') { - return res.status(401).json({ - error: 'invalid_client', - error_description: error.message, - }); - } - if (error.name === 'InputError') { - return res.status(400).json({ - error: 'invalid_request', - error_description: error.message, - }); - } - } - - return res.status(500).json({ - error: 'server_error', - error_description: description, - }); - } - }); - // UserInfo endpoint // https://openid.net/specs/openid-connect-core-1_0.html#UserInfo // Returns claims about the authenticated user using an access token @@ -354,45 +97,307 @@ export class OidcRouter { res.json(userInfo); }); - // Dynamic Client Registration endpoint - // https://openid.net/specs/openid-connect-registration-1_0.html#ClientRegistration - // Allows clients to register themselves dynamically with the provider - router.post('/v1/register', async (req, res) => { - // todo(blam): maybe add zod types for validating input - const registrationRequest = req.body; + if (this.enableDynamicClientRegistration) { + // Authorization endpoint + // https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + // Handles the initial authorization request from the client, validates parameters, + // and redirects to the Authorization Session page for user approval + router.get('/v1/authorize', async (req, res) => { + // todo(blam): maybe add zod types for validating input + const { + client_id: clientId, + redirect_uri: redirectUri, + response_type: responseType, + scope, + state, + nonce, + code_challenge: codeChallenge, + code_challenge_method: codeChallengeMethod, + } = req.query; - if (!registrationRequest.redirect_uris?.length) { - res.status(400).json({ - error: 'invalid_request', - error_description: 'redirect_uris is required', - }); - return; - } + if (!clientId || !redirectUri || !responseType) { + this.logger.error(`Failed to authorize: Missing required parameters`); + return res.status(400).json({ + error: 'invalid_request', + error_description: + 'Missing required parameters: client_id, redirect_uri, response_type', + }); + } - try { - const client = await this.oidc.registerClient({ - clientName: registrationRequest.client_name, - redirectUris: registrationRequest.redirect_uris, - responseTypes: registrationRequest.response_types, - grantTypes: registrationRequest.grant_types, - scope: registrationRequest.scope, - }); + try { + const result = await this.oidc.createAuthorizationSession({ + clientId: clientId as string, + redirectUri: redirectUri as string, + responseType: responseType as string, + scope: scope as string, + state: state as string, + nonce: nonce as string, + codeChallenge: codeChallenge as string, + codeChallengeMethod: codeChallengeMethod as string, + }); - res.status(201).json({ - client_id: client.clientId, - redirect_uris: client.redirectUris, - client_secret: client.clientSecret, - }); - } catch (e) { - const description = isError(e) ? e.message : 'Unknown error'; - this.logger.error(`Failed to register client: ${description}`, e); + // todo(blam): maybe this URL could be overridable by config if + // the plugin is mounted somewhere else? + // support slashes in baseUrl? + const authSessionRedirectUrl = new URL( + `/auth/sessions/${result.id}`, + this.appUrl, + ); - res.status(500).json({ - error: 'server_error', - error_description: `Failed to register client: ${description}`, - }); - } - }); + return res.redirect(authSessionRedirectUrl.toString()); + } catch (error) { + const errorParams = new URLSearchParams(); + errorParams.append( + 'error', + isError(error) ? error.name : 'server_error', + ); + errorParams.append( + 'error_description', + isError(error) ? error.message : 'Unknown error', + ); + if (state) { + errorParams.append('state', state as string); + } + + const redirectUrl = new URL(redirectUri as string); + redirectUrl.search = errorParams.toString(); + return res.redirect(redirectUrl.toString()); + } + }); + + // Authorization Session request details endpoint + // Returns Authorization Session request details for the frontned + router.get('/v1/sessions/:sessionId', async (req, res) => { + const { sessionId } = req.params; + + if (!sessionId) { + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Missing Authorization Session ID', + }); + } + + try { + const session = await this.oidc.getAuthorizationSession({ + sessionId, + }); + + return res.json({ + id: session.id, + clientName: session.clientName, + scope: session.scope, + redirectUri: session.redirectUri, + }); + } catch (error) { + const description = isError(error) ? error.message : 'Unknown error'; + this.logger.error( + `Failed to get authorization session: ${description}`, + error, + ); + return res.status(404).json({ + error: 'not_found', + error_description: description, + }); + } + }); + + // Authorization Session approval endpoint + // Handles user approval of Authorization Session requests and generates authorization codes + router.post('/v1/sessions/:sessionId/approve', async (req, res) => { + const { sessionId } = req.params; + + if (!sessionId) { + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Missing authorization session ID', + }); + } + + try { + const httpCredentials = await this.httpAuth.credentials(req); + + if (!this.auth.isPrincipal(httpCredentials, 'user')) { + return res.status(401).json({ + error: 'unauthorized', + error_description: 'Authentication required', + }); + } + + const userEntityRef = httpCredentials.principal.userEntityRef; + + const result = await this.oidc.approveAuthorizationSession({ + sessionId, + userEntityRef, + }); + + return res.json({ + redirectUrl: result.redirectUrl, + }); + } catch (error) { + const description = isError(error) ? error.message : 'Unknown error'; + this.logger.error( + `Failed to approve authorization session: ${description}`, + error, + ); + return res.status(400).json({ + error: 'invalid_request', + error_description: description, + }); + } + }); + + // Authorization Session rejection endpoint + // Handles user rejection of Authorization Session requests and redirects with error + router.post('/v1/sessions/:sessionId/reject', async (req, res) => { + const { sessionId } = req.params; + + if (!sessionId) { + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Missing authorization session ID', + }); + } + + try { + const session = await this.oidc.getAuthorizationSession({ + sessionId, + }); + + await this.oidc.rejectAuthorizationSession({ sessionId }); + + const errorParams = new URLSearchParams(); + errorParams.append('error', 'access_denied'); + errorParams.append('error_description', 'User denied the request'); + if (session.state) { + errorParams.append('state', session.state); + } + + const redirectUrl = new URL(session.redirectUri); + redirectUrl.search = errorParams.toString(); + + return res.json({ + redirectUrl: redirectUrl.toString(), + }); + } catch (error) { + const description = isError(error) ? error.message : 'Unknown error'; + this.logger.error( + `Failed to reject authorization session: ${description}`, + error, + ); + + return res.status(400).json({ + error: 'invalid_request', + error_description: description, + }); + } + }); + + // Token endpoint + // https://openid.net/specs/openid-connect-core-1_0.html#TokenRequest + // Exchanges authorization codes for access tokens and ID tokens + router.post('/v1/token', async (req, res) => { + // todo(blam): maybe add zod types for validating input + const { + grant_type: grantType, + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + } = req.body; + + if (!grantType || !code || !redirectUri) { + this.logger.error( + `Failed to exchange code for token: Missing required parameters`, + ); + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Missing required parameters', + }); + } + + try { + const result = await this.oidc.exchangeCodeForToken({ + code, + redirectUri, + codeVerifier, + grantType, + }); + + return res.json({ + access_token: result.accessToken, + token_type: result.tokenType, + expires_in: result.expiresIn, + id_token: result.idToken, + scope: result.scope, + }); + } catch (error) { + const description = isError(error) ? error.message : 'Unknown error'; + this.logger.error( + `Failed to exchange code for token: ${description}`, + error, + ); + + if (isError(error)) { + if (error.name === 'AuthenticationError') { + return res.status(401).json({ + error: 'invalid_client', + error_description: error.message, + }); + } + if (error.name === 'InputError') { + return res.status(400).json({ + error: 'invalid_request', + error_description: error.message, + }); + } + } + + return res.status(500).json({ + error: 'server_error', + error_description: description, + }); + } + }); + + // Dynamic Client Registration endpoint + // https://openid.net/specs/openid-connect-registration-1_0.html#ClientRegistration + // Allows clients to register themselves dynamically with the provider + router.post('/v1/register', async (req, res) => { + // todo(blam): maybe add zod types for validating input + const registrationRequest = req.body; + + if (!registrationRequest.redirect_uris?.length) { + res.status(400).json({ + error: 'invalid_request', + error_description: 'redirect_uris is required', + }); + return; + } + + try { + const client = await this.oidc.registerClient({ + clientName: registrationRequest.client_name, + redirectUris: registrationRequest.redirect_uris, + responseTypes: registrationRequest.response_types, + grantTypes: registrationRequest.grant_types, + scope: registrationRequest.scope, + }); + + res.status(201).json({ + client_id: client.clientId, + redirect_uris: client.redirectUris, + client_secret: client.clientSecret, + }); + } catch (e) { + const description = isError(e) ? e.message : 'Unknown error'; + this.logger.error(`Failed to register client: ${description}`, e); + + res.status(500).json({ + error: 'server_error', + error_description: `Failed to register client: ${description}`, + }); + } + }); + } return router; } diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 1f5fea7cb5..d2790ff35c 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -162,6 +162,10 @@ export async function createRouter( oidc, logger, httpAuth, + enableDynamicClientRegistration: + config.getOptionalBoolean( + 'auth.experimental.enableDynamicClientRegistration', + ) ?? false, }); router.use(oidcRouter.getRouter()); diff --git a/plugins/mcp-actions-backend/src/plugin.ts b/plugins/mcp-actions-backend/src/plugin.ts index 2e29847964..bcac77921c 100644 --- a/plugins/mcp-actions-backend/src/plugin.ts +++ b/plugins/mcp-actions-backend/src/plugin.ts @@ -17,7 +17,8 @@ import { coreServices, createBackendPlugin, } from '@backstage/backend-plugin-api'; -import { json, Router } from 'express'; +import { json } from 'express'; +import Router from 'express-promise-router'; import { McpService } from './services/McpService'; import { createStreamableRouter } from './routers/createStreamableRouter'; import { createSseRouter } from './routers/createSseRouter'; @@ -44,6 +45,7 @@ export const mcpPlugin = createBackendPlugin({ registry: actionsRegistryServiceRef, rootRouter: coreServices.rootHttpRouter, discovery: coreServices.discovery, + config: coreServices.rootConfig, }, async init({ actions, @@ -52,6 +54,7 @@ export const mcpPlugin = createBackendPlugin({ httpAuth, rootRouter, discovery, + config, }) { const mcpService = await McpService.create({ actions, @@ -76,21 +79,25 @@ export const mcpPlugin = createBackendPlugin({ httpRouter.use(router); - // todo(blam): there's probably a better way to proxy this, but it's required - // for mcp auth spec that it lives on the root of the mcp entrypoint server. - const authRouter = Router(); - authRouter.use('/', async (_, res) => { - const authBaseUrl = await discovery.getBaseUrl('auth'); + if ( + config.getOptionalBoolean( + 'auth.experimental.enableDynamicClientRegistration', + ) + ) { + // This should be replaced with throwing a WWW-Authenticate header, but that doesn't seem to be supported by + // many of the MCP client as of yet. So this seems to be the oldest version of the spec thats implemented. + rootRouter.use( + '/.well-known/oauth-authorization-server', + async (_, res) => { + const authBaseUrl = await discovery.getBaseUrl('auth'); + const oidcResponse = await fetch( + `${authBaseUrl}/.well-known/openid-configuration`, + ); - const oidcResponse = await fetch( - `${authBaseUrl}/.well-known/openid-configuration`, + res.json(await oidcResponse.json()); + }, ); - - const oidcResponseJson = await oidcResponse.json(); - - res.json(oidcResponseJson); - }); - rootRouter.use('/.well-known/oauth-authorization-server', authRouter); + } }, }); }, From 75b5880cb790721b5d9af691ff53d0eb593b8f24 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 8 Sep 2025 15:09:30 +0200 Subject: [PATCH 078/107] chore: Fixing changesets ] Signed-off-by: benjdlambert --- .changeset/eleven-doors-down.md | 2 +- .changeset/eleven-doors-own.md | 2 +- packages/backend-test-utils/src/database/TestDatabases.ts | 2 +- plugins/auth-backend/src/database/OidcDatabase.test.ts | 2 ++ plugins/auth-backend/src/service/OidcRouter.test.ts | 2 ++ 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.changeset/eleven-doors-down.md b/.changeset/eleven-doors-down.md index 47cb99dd03..a253828c54 100644 --- a/.changeset/eleven-doors-down.md +++ b/.changeset/eleven-doors-down.md @@ -2,4 +2,4 @@ '@backstage/plugin-mcp-actions-backend': patch --- -Proxy `/.well-known/oauth-authorization-server` to `/.well-known/openid-configuration` on `auth-backend` +Proxy `/.well-known/oauth-authorization-server` to `/.well-known/openid-configuration` on `auth-backend` when `auth.experimental.enableDynamicClientRegistration` is enabled. diff --git a/.changeset/eleven-doors-own.md b/.changeset/eleven-doors-own.md index 1308aab3eb..8c83082b22 100644 --- a/.changeset/eleven-doors-own.md +++ b/.changeset/eleven-doors-own.md @@ -2,4 +2,4 @@ '@backstage/plugin-auth-backend': patch --- -Implementing Dynamic Client Registration with the OIDC server +Implementing Dynamic Client Registration with the OIDC server. You can enable this by setting `auth.experimental.enableDynamicClientRegistration` in `app-config.yaml`. This is highly experimental, but feedback welcome. diff --git a/packages/backend-test-utils/src/database/TestDatabases.ts b/packages/backend-test-utils/src/database/TestDatabases.ts index 00fae4130a..cd20e2997b 100644 --- a/packages/backend-test-utils/src/database/TestDatabases.ts +++ b/packages/backend-test-utils/src/database/TestDatabases.ts @@ -104,7 +104,7 @@ export class TestDatabases { if (supportedIds.length > 0) { afterAll(async () => { await databases.shutdown(); - }, 30_000); + }); } return databases; diff --git a/plugins/auth-backend/src/database/OidcDatabase.test.ts b/plugins/auth-backend/src/database/OidcDatabase.test.ts index 9965069acf..19285aec0a 100644 --- a/plugins/auth-backend/src/database/OidcDatabase.test.ts +++ b/plugins/auth-backend/src/database/OidcDatabase.test.ts @@ -18,6 +18,8 @@ import { AuthDatabase } from './AuthDatabase'; import { OidcDatabase } from './OidcDatabase'; import { resolvePackagePath } from '@backstage/backend-plugin-api'; +jest.setTimeout(60_000); + describe('Oidc Database', () => { const databases = TestDatabases.create(); diff --git a/plugins/auth-backend/src/service/OidcRouter.test.ts b/plugins/auth-backend/src/service/OidcRouter.test.ts index 8714eb7f62..bffe9ee509 100644 --- a/plugins/auth-backend/src/service/OidcRouter.test.ts +++ b/plugins/auth-backend/src/service/OidcRouter.test.ts @@ -34,6 +34,8 @@ import { AuthDatabase } from '../database/AuthDatabase'; import { OidcService } from '../service/OidcService'; import { TokenIssuer } from '../identity/types'; +jest.setTimeout(60_000); + describe('OidcRouter', () => { const MOCK_USER_TOKEN = 'mock-user-token'; const MOCK_USER_ENTITY_REF = 'user:default/test-user'; From a4b9f94d4f4358084f13b47c7931eaec68d13274 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 8 Sep 2025 15:14:05 +0200 Subject: [PATCH 079/107] chore: fix experimental flag Signed-off-by: benjdlambert --- .changeset/eleven-doors-down.md | 2 +- .changeset/eleven-doors-own.md | 2 +- plugins/auth-backend/src/service/router.ts | 2 +- plugins/mcp-actions-backend/src/plugin.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/eleven-doors-down.md b/.changeset/eleven-doors-down.md index a253828c54..0d380c8ddf 100644 --- a/.changeset/eleven-doors-down.md +++ b/.changeset/eleven-doors-down.md @@ -2,4 +2,4 @@ '@backstage/plugin-mcp-actions-backend': patch --- -Proxy `/.well-known/oauth-authorization-server` to `/.well-known/openid-configuration` on `auth-backend` when `auth.experimental.enableDynamicClientRegistration` is enabled. +Proxy `/.well-known/oauth-authorization-server` to `/.well-known/openid-configuration` on `auth-backend` when `auth.experimentalDynamicClientRegistration.enabled` is enabled. diff --git a/.changeset/eleven-doors-own.md b/.changeset/eleven-doors-own.md index 8c83082b22..1da0297e5c 100644 --- a/.changeset/eleven-doors-own.md +++ b/.changeset/eleven-doors-own.md @@ -2,4 +2,4 @@ '@backstage/plugin-auth-backend': patch --- -Implementing Dynamic Client Registration with the OIDC server. You can enable this by setting `auth.experimental.enableDynamicClientRegistration` in `app-config.yaml`. This is highly experimental, but feedback welcome. +Implementing Dynamic Client Registration with the OIDC server. You can enable this by setting `auth.experimentalDynamicClientRegistration.enabled` in `app-config.yaml`. This is highly experimental, but feedback welcome. diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index d2790ff35c..0d5b26078d 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -164,7 +164,7 @@ export async function createRouter( httpAuth, enableDynamicClientRegistration: config.getOptionalBoolean( - 'auth.experimental.enableDynamicClientRegistration', + 'auth.experimentalDynamicClientRegistration.enabled', ) ?? false, }); diff --git a/plugins/mcp-actions-backend/src/plugin.ts b/plugins/mcp-actions-backend/src/plugin.ts index bcac77921c..d04df6cdf5 100644 --- a/plugins/mcp-actions-backend/src/plugin.ts +++ b/plugins/mcp-actions-backend/src/plugin.ts @@ -81,7 +81,7 @@ export const mcpPlugin = createBackendPlugin({ if ( config.getOptionalBoolean( - 'auth.experimental.enableDynamicClientRegistration', + 'auth.experimentalDynamicClientRegistration.enabled', ) ) { // This should be replaced with throwing a WWW-Authenticate header, but that doesn't seem to be supported by From ff15f3032970aa35015ce245f68ba00f03fc3283 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 8 Sep 2025 17:48:33 +0200 Subject: [PATCH 080/107] feat: implementing fixes for wildcard matching for callback URLs Signed-off-by: benjdlambert --- .../src/service/OidcRouter.test.ts | 14 ++++++- .../auth-backend/src/service/OidcRouter.ts | 15 ++++--- .../src/service/OidcService.test.ts | 42 +++++++++++++++++++ .../auth-backend/src/service/OidcService.ts | 22 ++++++++-- plugins/auth-backend/src/service/router.ts | 5 +-- 5 files changed, 84 insertions(+), 14 deletions(-) diff --git a/plugins/auth-backend/src/service/OidcRouter.test.ts b/plugins/auth-backend/src/service/OidcRouter.test.ts index bffe9ee509..81430ef05e 100644 --- a/plugins/auth-backend/src/service/OidcRouter.test.ts +++ b/plugins/auth-backend/src/service/OidcRouter.test.ts @@ -70,6 +70,15 @@ describe('OidcRouter', () => { const mockAuth = mockServices.auth.mock(); const mockHttpAuth = mockServices.httpAuth.mock(); + const mockConfig = mockServices.rootConfig({ + data: { + auth: { + experimentalDynamicClientRegistration: { + enabled: true, + }, + }, + }, + }); const oidcService = OidcService.create({ auth: mockAuth, @@ -77,6 +86,7 @@ describe('OidcRouter', () => { baseUrl: 'http://localhost:7000', userInfo: userInfoDatabase, oidc: oidcDatabase, + config: mockConfig, }); const oidcRouter = OidcRouter.create({ @@ -88,7 +98,7 @@ describe('OidcRouter', () => { userInfo: userInfoDatabase, oidc: oidcDatabase, httpAuth: mockHttpAuth, - enableDynamicClientRegistration: true, + config: mockConfig, }); return { @@ -303,7 +313,7 @@ describe('OidcRouter', () => { .expect(302); expect(response.header.location).toMatch( - /^http:\/\/localhost:3000\/auth\/sessions\/[a-f0-9-]+$/, + /^http:\/\/localhost:3000\/oauth2\/authorize\/[a-f0-9-]+$/, ); }); diff --git a/plugins/auth-backend/src/service/OidcRouter.ts b/plugins/auth-backend/src/service/OidcRouter.ts index 6c39115528..7090fadf0b 100644 --- a/plugins/auth-backend/src/service/OidcRouter.ts +++ b/plugins/auth-backend/src/service/OidcRouter.ts @@ -20,6 +20,7 @@ import { AuthService, HttpAuthService, LoggerService, + RootConfigService, } from '@backstage/backend-plugin-api'; import { TokenIssuer } from '../identity/types'; import { UserInfoDatabase } from '../database/UserInfoDatabase'; @@ -33,7 +34,7 @@ export class OidcRouter { private readonly auth: AuthService, private readonly appUrl: string, private readonly httpAuth: HttpAuthService, - private readonly enableDynamicClientRegistration: boolean, + private readonly config: RootConfigService, ) {} static create(options: { @@ -45,7 +46,7 @@ export class OidcRouter { userInfo: UserInfoDatabase; oidc: OidcDatabase; httpAuth: HttpAuthService; - enableDynamicClientRegistration: boolean; + config: RootConfigService; }) { return new OidcRouter( OidcService.create(options), @@ -53,7 +54,7 @@ export class OidcRouter { options.auth, options.appUrl, options.httpAuth, - options.enableDynamicClientRegistration, + options.config, ); } @@ -97,7 +98,11 @@ export class OidcRouter { res.json(userInfo); }); - if (this.enableDynamicClientRegistration) { + if ( + this.config.getOptionalBoolean( + 'auth.experimentalDynamicClientRegistration.enabled', + ) + ) { // Authorization endpoint // https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest // Handles the initial authorization request from the client, validates parameters, @@ -140,7 +145,7 @@ export class OidcRouter { // the plugin is mounted somewhere else? // support slashes in baseUrl? const authSessionRedirectUrl = new URL( - `/auth/sessions/${result.id}`, + `/oauth2/authorize/${result.id}`, this.appUrl, ); diff --git a/plugins/auth-backend/src/service/OidcService.test.ts b/plugins/auth-backend/src/service/OidcService.test.ts index b1f03c68ed..7829be3841 100644 --- a/plugins/auth-backend/src/service/OidcService.test.ts +++ b/plugins/auth-backend/src/service/OidcService.test.ts @@ -63,6 +63,8 @@ describe('OidcService', () => { getUserInfo: jest.fn(), } as unknown as jest.Mocked; + const mockConfig = mockServices.rootConfig.mock(); + return { service: OidcService.create({ auth: mockAuth, @@ -70,11 +72,13 @@ describe('OidcService', () => { baseUrl: 'http://mock-base-url', userInfo: mockUserInfo, oidc: oidcDatabase, + config: mockConfig, }), mocks: { auth: mockAuth, tokenIssuer: mockTokenIssuer, userInfo: mockUserInfo, + config: mockConfig, }, }; } @@ -216,6 +220,44 @@ describe('OidcService', () => { expect(client.clientSecret).toBeDefined(); }); + it('should throw an error for invalid redirect URI', async () => { + const { + service, + mocks: { config }, + } = await createOidcService(databaseId); + + config.getOptionalStringArray.mockReturnValue([ + 'https://example.com/*', + ]); + + await expect( + service.registerClient({ + clientName: 'Test Client', + redirectUris: ['https://invalid.com/callback'], + }), + ).rejects.toThrow('Invalid redirect_uri'); + }); + + it('should create a new client with valid redirect URI', async () => { + const { + service, + mocks: { config }, + } = await createOidcService(databaseId); + + config.getOptionalStringArray.mockReturnValue(['cursor://*']); + + const client = await service.registerClient({ + clientName: 'Test Client', + redirectUris: ['cursor://callback'], + }); + + expect(client).toEqual( + expect.objectContaining({ + redirectUris: ['cursor://callback'], + }), + ); + }); + it('should create a client with default values', async () => { const { service } = await createOidcService(databaseId); diff --git a/plugins/auth-backend/src/service/OidcService.ts b/plugins/auth-backend/src/service/OidcService.ts index 2b7eb40bc9..3139ccc73d 100644 --- a/plugins/auth-backend/src/service/OidcService.ts +++ b/plugins/auth-backend/src/service/OidcService.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { AuthService } from '@backstage/backend-plugin-api'; +import { AuthService, RootConfigService } from '@backstage/backend-plugin-api'; import { TokenIssuer } from '../identity/types'; import { UserInfoDatabase } from '../database/UserInfoDatabase'; import { @@ -33,6 +33,7 @@ export class OidcService { private readonly baseUrl: string, private readonly userInfo: UserInfoDatabase, private readonly oidc: OidcDatabase, + private readonly config: RootConfigService, ) {} static create(options: { @@ -41,6 +42,7 @@ export class OidcService { baseUrl: string; userInfo: UserInfoDatabase; oidc: OidcDatabase; + config: RootConfigService; }) { return new OidcService( options.auth, @@ -48,6 +50,7 @@ export class OidcService { options.baseUrl, options.userInfo, options.oidc, + options.config, ); } @@ -116,8 +119,21 @@ export class OidcService { const generatedClientId = crypto.randomUUID(); const generatedClientSecret = crypto.randomUUID(); - // todo(blam): add validation for redirectUris here. - // should be a list of urls and / or allowed schemes or something. + const allowedRedirectUriPatterns = this.config.getOptionalStringArray( + 'auth.experimentalDynamicClientRegistration.allowedRedirectUriPatterns', + ); + + if (allowedRedirectUriPatterns) { + for (const redirectUri of opts.redirectUris ?? []) { + if ( + !allowedRedirectUriPatterns.some(pattern => + new RegExp(pattern).test(redirectUri), + ) + ) { + throw new InputError('Invalid redirect_uri'); + } + } + } return await this.oidc.createClient({ clientId: generatedClientId, diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 0d5b26078d..0f0d5b2830 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -162,10 +162,7 @@ export async function createRouter( oidc, logger, httpAuth, - enableDynamicClientRegistration: - config.getOptionalBoolean( - 'auth.experimentalDynamicClientRegistration.enabled', - ) ?? false, + config, }); router.use(oidcRouter.getRouter()); From a2be46e16b8f820fae952e149cb69afe3e20527d Mon Sep 17 00:00:00 2001 From: Shijun Wang Date: Tue, 9 Sep 2025 08:33:25 +0300 Subject: [PATCH 081/107] return progress bar instead of null Signed-off-by: Shijun Wang --- plugins/home/report.api.md | 2 +- .../src/components/CustomHomepage/CustomHomepageGrid.tsx | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/plugins/home/report.api.md b/plugins/home/report.api.md index 73c6d035cc..dd33b952ee 100644 --- a/plugins/home/report.api.md +++ b/plugins/home/report.api.md @@ -84,7 +84,7 @@ export const createCardExtension: typeof createCardExtension_2; // @public export const CustomHomepageGrid: ( props: CustomHomepageGridProps, -) => JSX_2.Element | null; +) => JSX_2.Element; // @public export type CustomHomepageGridProps = { diff --git a/plugins/home/src/components/CustomHomepage/CustomHomepageGrid.tsx b/plugins/home/src/components/CustomHomepage/CustomHomepageGrid.tsx index b4b674be1b..fec7895170 100644 --- a/plugins/home/src/components/CustomHomepage/CustomHomepageGrid.tsx +++ b/plugins/home/src/components/CustomHomepage/CustomHomepageGrid.tsx @@ -34,7 +34,11 @@ import { } from '@material-ui/core/styles'; import { compact } from 'lodash'; import useObservable from 'react-use/esm/useObservable'; -import { ContentHeader, ErrorBoundary } from '@backstage/core-components'; +import { + ContentHeader, + ErrorBoundary, + Progress, +} from '@backstage/core-components'; import Typography from '@material-ui/core/Typography'; import { WidgetSettingsOverlay } from './WidgetSettingsOverlay'; import { AddWidgetDialog } from './AddWidgetDialog'; @@ -325,9 +329,8 @@ export const CustomHomepageGrid = (props: CustomHomepageGridProps) => { ); }; - // Don't render anything while storage is loading if (isStorageLoading) { - return null; + return ; } return ( From 70a332477f5f707f9be89d5263faf2758a3f7e48 Mon Sep 17 00:00:00 2001 From: gyan Date: Tue, 9 Sep 2025 11:09:48 +0530 Subject: [PATCH 082/107] report-alpha.api.md Signed-off-by: gyan --- plugins/user-settings/report-alpha.api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/user-settings/report-alpha.api.md b/plugins/user-settings/report-alpha.api.md index 5a28aacc02..1c8e844d05 100644 --- a/plugins/user-settings/report-alpha.api.md +++ b/plugins/user-settings/report-alpha.api.md @@ -123,7 +123,7 @@ export const userSettingsTranslationRef: TranslationRef< readonly 'languageToggle.select': 'Select language {{language}}'; readonly 'languageToggle.title': 'Language'; readonly 'languageToggle.description': 'Change the language'; - readonly 'themeToggle.select': 'Select theme {{theme}}'; + readonly 'themeToggle.select': 'Select {{theme}}'; readonly 'themeToggle.title': 'Theme'; readonly 'themeToggle.description': 'Change the theme mode'; readonly 'themeToggle.names.auto': 'Auto'; From a79d7cbe36e701d88e024b698ac0b0fe11fcd0f5 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 9 Sep 2025 10:32:05 +0200 Subject: [PATCH 083/107] chore: cleanup a little bit Signed-off-by: benjdlambert --- .../operations/stitcher/markForStitching.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/plugins/catalog-backend/src/database/operations/stitcher/markForStitching.ts b/plugins/catalog-backend/src/database/operations/stitcher/markForStitching.ts index 913936202f..484d6e4ed6 100644 --- a/plugins/catalog-backend/src/database/operations/stitcher/markForStitching.ts +++ b/plugins/catalog-backend/src/database/operations/stitcher/markForStitching.ts @@ -17,7 +17,9 @@ import { Knex } from 'knex'; import splitToChunks from 'lodash/chunk'; import { v4 as uuid } from 'uuid'; +import { ErrorLike, isError } from '@backstage/errors'; import { StitchingStrategy } from '../../../stitching/types'; +import { setTimeout as sleep } from 'timers/promises'; import { DbFinalEntitiesRow, DbRefreshStateRow } from '../../tables'; const UPDATE_CHUNK_SIZE = 100; // Smaller chunks reduce contention @@ -30,10 +32,13 @@ const POSTGRES_DEADLOCK_SQLSTATE = '40P01'; /** * Checks if the given error is a deadlock error for the database engine in use. */ -function isDeadlockError(knex: Knex | Knex.Transaction, e: unknown): boolean { +function isDeadlockError( + knex: Knex | Knex.Transaction, + e: unknown, +): e is ErrorLike { if (knex.client.config.client.includes('pg')) { // PostgreSQL deadlock detection - return (e as any)?.code === POSTGRES_DEADLOCK_SQLSTATE; + return isError(e) && e.code === POSTGRES_DEADLOCK_SQLSTATE; } // Add more database engine checks here as needed @@ -149,7 +154,7 @@ async function retryOnDeadlock( for (;;) { try { return await fn(); - } catch (e: any) { + } catch (e: unknown) { if (isDeadlockError(knex, e) && attempt < retries) { await sleep(baseMs * Math.pow(2, attempt)); attempt++; @@ -159,7 +164,3 @@ async function retryOnDeadlock( } } } - -function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} From c9f1fb203a0105b0747fd6d047c45aa59f607f88 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 9 Sep 2025 10:46:45 +0200 Subject: [PATCH 084/107] chore: cleanup Signed-off-by: benjdlambert --- app-config.yaml | 7 ++++-- .../auth-backend/src/service/OidcRouter.ts | 22 ++++++++++++------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index e32609c1a1..eacf9a96aa 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -209,8 +209,11 @@ scaffolder: defaultCommitMessage: 'Initial commit' auth: - experimental: - enableDynamicClientRegistration: true + experimentalDynamicClientRegistration: + enabled: true + allowedRedirectUriPatterns: + - cursor://* + ### Add auth.keyStore.provider to more granularly control how to store JWK data when running # the auth-backend. # diff --git a/plugins/auth-backend/src/service/OidcRouter.ts b/plugins/auth-backend/src/service/OidcRouter.ts index 7090fadf0b..f20b5f4ede 100644 --- a/plugins/auth-backend/src/service/OidcRouter.ts +++ b/plugins/auth-backend/src/service/OidcRouter.ts @@ -228,7 +228,7 @@ export class OidcRouter { }); } - const userEntityRef = httpCredentials.principal.userEntityRef; + const { userEntityRef } = httpCredentials.principal; const result = await this.oidc.approveAuthorizationSession({ sessionId, @@ -368,9 +368,15 @@ export class OidcRouter { // Allows clients to register themselves dynamically with the provider router.post('/v1/register', async (req, res) => { // todo(blam): maybe add zod types for validating input - const registrationRequest = req.body; + const { + client_name: clientName, + redirect_uris: redirectUris, + response_types: responseTypes, + grant_types: grantTypes, + scope, + } = req.body; - if (!registrationRequest.redirect_uris?.length) { + if (!redirectUris?.length) { res.status(400).json({ error: 'invalid_request', error_description: 'redirect_uris is required', @@ -380,11 +386,11 @@ export class OidcRouter { try { const client = await this.oidc.registerClient({ - clientName: registrationRequest.client_name, - redirectUris: registrationRequest.redirect_uris, - responseTypes: registrationRequest.response_types, - grantTypes: registrationRequest.grant_types, - scope: registrationRequest.scope, + clientName, + redirectUris, + responseTypes, + grantTypes, + scope, }); res.status(201).json({ From 1d15f6b01baaefdd8b0b3c4c047382c7a5f84270 Mon Sep 17 00:00:00 2001 From: Andre Wanlin Date: Thu, 7 Aug 2025 06:20:45 -0500 Subject: [PATCH 085/107] Removed references to Lerna as it's not used Signed-off-by: Andre Wanlin --- docs/contribute/project-structure.md | 4 ---- docs/getting-started/index.md | 4 ++-- docs/references/glossary.md | 2 +- lerna.json | 6 ------ packages/config-loader/src/schema/collect.test.ts | 10 ---------- 5 files changed, 3 insertions(+), 23 deletions(-) delete mode 100644 lerna.json diff --git a/docs/contribute/project-structure.md b/docs/contribute/project-structure.md index 5b86b655b2..f58afedee8 100644 --- a/docs/contribute/project-structure.md +++ b/docs/contribute/project-structure.md @@ -221,7 +221,3 @@ future. - [`catalog-info.yaml`](https://github.com/backstage/backstage/tree/master/catalog-info.yaml) - Description of Backstage in the Backstage Entity format. - -- [`lerna.json`](https://github.com/backstage/backstage/tree/master/lerna.json) - - [Lerna](https://github.com/lerna/lerna) monorepo config. We are using - `yarn workspaces`, so this will only be used for executing scripts. diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index cd86aa3343..9fb29f693e 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -89,8 +89,8 @@ app - **package.json**: Root package.json for the project. _Note: Be sure that you don't add any npm dependencies here as they probably should be installed in the intended workspace rather than in the root._ -- **packages/**: Lerna leaf packages or "workspaces". Everything here is going - to be a separate package, managed by lerna. +- **packages/**: Yarn workspaces, everything here is going + to be a separate package, managed by Yarn. - **packages/app/**: A fully functioning Backstage frontend app that acts as a good starting point for you to get to know Backstage. - **packages/backend/**: We include a backend that helps power features such as diff --git a/docs/references/glossary.md b/docs/references/glossary.md index c955d9a5ae..ad9e2bbee5 100644 --- a/docs/references/glossary.md +++ b/docs/references/glossary.md @@ -208,7 +208,7 @@ One of the [packages](#package) within a [monorepo](#monorepo). A package may or 1. A single repository for a collection of related software projects, such as all projects belonging to an organization. -2. A project layout that consists of multiple [packages](#package) within a single project, where packages are able to have local dependencies on each other. Often enabled through tooling such as [lerna](https://lerna.js.org/) and [yarn workspaces](https://classic.yarnpkg.com/en/docs/workspaces/) +2. A project layout that consists of multiple [packages](#package) within a single project, where packages are able to have local dependencies on each other. Often enabled through tooling such as [yarn workspaces](https://classic.yarnpkg.com/en/docs/workspaces/) ## Name diff --git a/lerna.json b/lerna.json deleted file mode 100644 index 4621689664..0000000000 --- a/lerna.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "packages": ["packages/*", "plugins/*"], - "npmClient": "yarn", - "useWorkspaces": true, - "version": "0.0.0" -} diff --git a/packages/config-loader/src/schema/collect.test.ts b/packages/config-loader/src/schema/collect.test.ts index 53db5c6da7..d48c98fbe4 100644 --- a/packages/config-loader/src/schema/collect.test.ts +++ b/packages/config-loader/src/schema/collect.test.ts @@ -41,16 +41,6 @@ describe('collectConfigSchemas', () => { mockDir.clear(); }); - it('should not find any schemas without packages', async () => { - mockDir.setContent({ - 'lerna.json': JSON.stringify({ - packages: ['packages/*'], - }), - }); - - await expect(collectConfigSchemas([], [])).resolves.toEqual([]); - }); - it('should find schema in a local package', async () => { mockDir.setContent({ node_modules: { From ec6cb6bce220e854674001e54aefecd96f7c0962 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 9 Sep 2025 13:17:00 +0200 Subject: [PATCH 086/107] chore: code review comments Signed-off-by: benjdlambert --- ...0250909120000_oidc_client_registration.js} | 0 .../auth-backend/src/database/OidcDatabase.ts | 36 +++-- plugins/auth-backend/src/migrations.test.ts | 140 ++++++++++++++++++ .../src/service/OidcRouter.test.ts | 49 +++--- .../auth-backend/src/service/OidcRouter.ts | 37 +++-- .../src/service/OidcService.test.ts | 67 ++++----- .../auth-backend/src/service/OidcService.ts | 95 +----------- 7 files changed, 247 insertions(+), 177 deletions(-) rename plugins/auth-backend/migrations/{20250701120000_oidc_client_registration.js => 20250909120000_oidc_client_registration.js} (100%) diff --git a/plugins/auth-backend/migrations/20250701120000_oidc_client_registration.js b/plugins/auth-backend/migrations/20250909120000_oidc_client_registration.js similarity index 100% rename from plugins/auth-backend/migrations/20250701120000_oidc_client_registration.js rename to plugins/auth-backend/migrations/20250909120000_oidc_client_registration.js diff --git a/plugins/auth-backend/src/database/OidcDatabase.ts b/plugins/auth-backend/src/database/OidcDatabase.ts index ec9a879803..ebb6619400 100644 --- a/plugins/auth-backend/src/database/OidcDatabase.ts +++ b/plugins/auth-backend/src/database/OidcDatabase.ts @@ -202,14 +202,24 @@ export class OidcDatabase { }); } - const [updated] = await this.db( + const returnedRows = await this.db( 'oauth_authorization_sessions', ) .where('id', session.id) .update(updatedFields) .returning('*'); - return this.rowToAuthorizationSession(updated) as AuthorizationSession; + if (returnedRows.length !== 1) { + throw new Error( + `Failed to retrieve updated authorization session with id ${session.id}`, + ); + } + + const [returnedSession] = returnedRows; + + return this.rowToAuthorizationSession( + returnedSession, + ) as AuthorizationSession; } async getAuthorizationSession({ id }: { id: string }) { @@ -290,17 +300,25 @@ export class OidcDatabase { }); } - const [updated] = await this.db( + const returnedRows = await this.db( 'oidc_authorization_codes', ) .where('code', authorizationCode.code) .update(updatedFields) .returning('*'); - return this.rowToAuthorizationCode(updated) as AuthorizationCode; + if (returnedRows.length !== 1) { + throw new Error( + `Failed to retrieve updated authorization code with code ${authorizationCode.code}`, + ); + } + + const [returnedCode] = returnedRows; + + return this.rowToAuthorizationCode(returnedCode) as AuthorizationCode; } - private rowToClient(row: Partial): Partial { + private rowToClient(row: OidcClientRow): Client { return { clientId: row.client_id, clientName: row.client_name, @@ -332,12 +350,12 @@ export class OidcDatabase { code_challenge_method: session.codeChallengeMethod, nonce: session.nonce, status: session.status, - expires_at: session.expiresAt, + expires_at: toDate(session.expiresAt), }; } private rowToAuthorizationSession( - row: Partial, + row: OAuthAuthorizationSessionRow, ): Partial { return { id: row.id, @@ -361,13 +379,13 @@ export class OidcDatabase { return { code: authorizationCode.code, session_id: authorizationCode.sessionId, - expires_at: authorizationCode.expiresAt, + expires_at: toDate(authorizationCode.expiresAt), used: authorizationCode.used, }; } private rowToAuthorizationCode( - row: Partial, + row: OidcAuthorizationCodeRow, ): Partial { return { code: row.code, diff --git a/plugins/auth-backend/src/migrations.test.ts b/plugins/auth-backend/src/migrations.test.ts index f9575c70fd..7cb6f982ea 100644 --- a/plugins/auth-backend/src/migrations.test.ts +++ b/plugins/auth-backend/src/migrations.test.ts @@ -186,4 +186,144 @@ describe('migrations', () => { await knex.destroy(); }, ); + + it.each(databases.eachSupportedId())( + '20250909120000_oidc_client_registration.js, %p', + async databaseId => { + const knex = await databases.init(databaseId); + + await migrateUntilBefore( + knex, + '20250909120000_oidc_client_registration.js', + ); + await migrateUpOnce(knex); + + await knex + .insert({ + client_id: 'test-client-id', + client_secret: 'test-client-secret', + client_name: 'Test Client', + response_types: JSON.stringify(['code']), + grant_types: JSON.stringify(['authorization_code']), + redirect_uris: JSON.stringify(['https://example.com/callback']), + scope: 'openid profile', + metadata: JSON.stringify({ description: 'Test client' }), + }) + .into('oidc_clients'); + + await expect( + knex('oidc_clients').where('client_id', 'test-client-id').first(), + ).resolves.toEqual({ + client_id: 'test-client-id', + client_secret: 'test-client-secret', + client_name: 'Test Client', + response_types: JSON.stringify(['code']), + grant_types: JSON.stringify(['authorization_code']), + redirect_uris: JSON.stringify(['https://example.com/callback']), + scope: 'openid profile', + metadata: JSON.stringify({ description: 'Test client' }), + }); + + await knex + .insert({ + id: 'test-session-id', + client_id: 'test-client-id', + user_entity_ref: 'user:default/test-user', + redirect_uri: 'https://example.com/callback', + scope: 'openid', + state: 'test-state', + response_type: 'code', + code_challenge: 'test-challenge', + code_challenge_method: 'S256', + nonce: 'test-nonce', + status: 'pending', + expires_at: new Date(Date.now() + 3600000), + }) + .into('oauth_authorization_sessions'); + + await expect( + knex('oauth_authorization_sessions') + .where('id', 'test-session-id') + .first(), + ).resolves.toEqual( + expect.objectContaining({ + id: 'test-session-id', + client_id: 'test-client-id', + user_entity_ref: 'user:default/test-user', + redirect_uri: 'https://example.com/callback', + scope: 'openid', + state: 'test-state', + response_type: 'code', + code_challenge: 'test-challenge', + code_challenge_method: 'S256', + nonce: 'test-nonce', + status: 'pending', + }), + ); + + await knex + .insert({ + code: 'test-auth-code', + session_id: 'test-session-id', + expires_at: new Date(Date.now() + 600000), + used: false, + }) + .into('oidc_authorization_codes'); + + await expect( + knex('oidc_authorization_codes') + .where('code', 'test-auth-code') + .first(), + ).resolves.toEqual( + expect.objectContaining({ + code: 'test-auth-code', + session_id: 'test-session-id', + }), + ); + + await expect( + knex + .insert({ + id: 'invalid-session', + client_id: 'non-existent-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + expires_at: new Date(), + }) + .into('oauth_authorization_sessions'), + ).rejects.toThrow(); + + await expect( + knex + .insert({ + code: 'invalid-code', + session_id: 'non-existent-session', + expires_at: new Date(), + }) + .into('oidc_authorization_codes'), + ).rejects.toThrow(); + + await knex('oauth_authorization_sessions') + .where('id', 'test-session-id') + .del(); + + await expect( + knex('oidc_authorization_codes').where('session_id', 'test-session-id'), + ).resolves.toHaveLength(0); + + await migrateDownOnce(knex); + + const tables = [ + 'oidc_clients', + 'oauth_authorization_sessions', + 'oidc_authorization_codes', + ]; + + for (const table of tables) { + await expect(knex.schema.hasTable(table)).resolves.toBe(false); + } + + await knex.destroy(); + }, + ); }); diff --git a/plugins/auth-backend/src/service/OidcRouter.test.ts b/plugins/auth-backend/src/service/OidcRouter.test.ts index 81430ef05e..de865a9c43 100644 --- a/plugins/auth-backend/src/service/OidcRouter.test.ts +++ b/plugins/auth-backend/src/service/OidcRouter.test.ts @@ -24,6 +24,7 @@ import { startTestBackend, TestDatabases, TestDatabaseId, + mockCredentials, } from '@backstage/backend-test-utils'; import request from 'supertest'; import crypto from 'crypto'; @@ -413,13 +414,9 @@ describe('OidcRouter', () => { ], }); - httpAuth.credentials.mockResolvedValueOnce({ - principal: { - type: 'user', - userEntityRef: 'user:default/test-user', - }, - $$type: '@backstage/BackstageCredentials', - }); + httpAuth.credentials.mockResolvedValueOnce( + mockCredentials.user('user:default/test-user'), + ); auth.isPrincipal.mockReturnValueOnce(true); @@ -437,7 +434,7 @@ describe('OidcRouter', () => { it('should reject auth session', async () => { const { - mocks: { service }, + mocks: { service, httpAuth, auth }, router, } = await createRouter(databaseId); @@ -457,6 +454,12 @@ describe('OidcRouter', () => { state: 'test-state', }); + httpAuth.credentials.mockResolvedValueOnce( + mockCredentials.user('user:default/test-user'), + ); + + auth.isPrincipal.mockReturnValueOnce(true); + const { server } = await startTestBackend({ features: [ createBackendPlugin({ @@ -496,13 +499,9 @@ describe('OidcRouter', () => { router, } = await createRouter(databaseId); - httpAuth.credentials.mockResolvedValueOnce({ - principal: { - type: 'user', - userEntityRef: 'user:default/test-user', - }, - $$type: '@backstage/BackstageCredentials', - }); + httpAuth.credentials.mockResolvedValueOnce( + mockCredentials.user('user:default/test-user'), + ); auth.isPrincipal.mockReturnValueOnce(true); @@ -590,13 +589,9 @@ describe('OidcRouter', () => { token: 'mock-access-token-pkce', }); - httpAuth.credentials.mockResolvedValueOnce({ - principal: { - type: 'user', - userEntityRef: 'user:default/test-user-pkce', - }, - $$type: '@backstage/BackstageCredentials', - }); + httpAuth.credentials.mockResolvedValueOnce( + mockCredentials.user('user:default/test-user-pkce'), + ); auth.isPrincipal.mockReturnValueOnce(true); @@ -725,13 +720,9 @@ describe('OidcRouter', () => { token: 'mock-access-token-s256', }); - httpAuth.credentials.mockResolvedValueOnce({ - principal: { - type: 'user', - userEntityRef: 'user:default/test-user-s256', - }, - $$type: '@backstage/BackstageCredentials', - }); + httpAuth.credentials.mockResolvedValueOnce( + mockCredentials.user('user:default/test-user-s256'), + ); auth.isPrincipal.mockReturnValueOnce(true); diff --git a/plugins/auth-backend/src/service/OidcRouter.ts b/plugins/auth-backend/src/service/OidcRouter.ts index f20b5f4ede..9a3308b4eb 100644 --- a/plugins/auth-backend/src/service/OidcRouter.ts +++ b/plugins/auth-backend/src/service/OidcRouter.ts @@ -134,19 +134,19 @@ export class OidcRouter { clientId: clientId as string, redirectUri: redirectUri as string, responseType: responseType as string, - scope: scope as string, - state: state as string, - nonce: nonce as string, - codeChallenge: codeChallenge as string, - codeChallengeMethod: codeChallengeMethod as string, + scope: scope as string | undefined, + state: state as string | undefined, + nonce: nonce as string | undefined, + codeChallenge: codeChallenge as string | undefined, + codeChallengeMethod: codeChallengeMethod as string | undefined, }); // todo(blam): maybe this URL could be overridable by config if // the plugin is mounted somewhere else? // support slashes in baseUrl? const authSessionRedirectUrl = new URL( - `/oauth2/authorize/${result.id}`, - this.appUrl, + `./oauth2/authorize/${result.id}`, + ensureTrailingSlash(this.appUrl), ); return res.redirect(authSessionRedirectUrl.toString()); @@ -171,7 +171,7 @@ export class OidcRouter { }); // Authorization Session request details endpoint - // Returns Authorization Session request details for the frontned + // Returns Authorization Session request details for the frontend router.get('/v1/sessions/:sessionId', async (req, res) => { const { sessionId } = req.params; @@ -263,12 +263,25 @@ export class OidcRouter { }); } + const httpCredentials = await this.httpAuth.credentials(req); + + if (!this.auth.isPrincipal(httpCredentials, 'user')) { + return res.status(401).json({ + error: 'unauthorized', + error_description: 'Authentication required', + }); + } + + const { userEntityRef } = httpCredentials.principal; try { const session = await this.oidc.getAuthorizationSession({ sessionId, }); - await this.oidc.rejectAuthorizationSession({ sessionId }); + await this.oidc.rejectAuthorizationSession({ + sessionId, + userEntityRef, + }); const errorParams = new URLSearchParams(); errorParams.append('error', 'access_denied'); @@ -413,3 +426,9 @@ export class OidcRouter { return router; } } +function ensureTrailingSlash(appUrl: string): string | URL | undefined { + if (appUrl.endsWith('/')) { + return appUrl; + } + return `${appUrl}/`; +} diff --git a/plugins/auth-backend/src/service/OidcService.test.ts b/plugins/auth-backend/src/service/OidcService.test.ts index 7829be3841..328a753e4c 100644 --- a/plugins/auth-backend/src/service/OidcService.test.ts +++ b/plugins/auth-backend/src/service/OidcService.test.ts @@ -466,6 +466,7 @@ describe('OidcService', () => { await service.rejectAuthorizationSession({ sessionId: authSession.id, + userEntityRef: 'user:default/test', }); await expect( @@ -553,6 +554,7 @@ describe('OidcService', () => { await service.rejectAuthorizationSession({ sessionId: authSession.id, + userEntityRef: 'user:default/test', }); await expect( @@ -580,6 +582,7 @@ describe('OidcService', () => { await service.rejectAuthorizationSession({ sessionId: authSession.id, + userEntityRef: 'user:default/test', }); await expect( @@ -595,6 +598,7 @@ describe('OidcService', () => { await expect( service.rejectAuthorizationSession({ sessionId: 'invalid-session', + userEntityRef: 'user:default/test', }), ).rejects.toThrow('Invalid authorization session'); }); @@ -621,6 +625,7 @@ describe('OidcService', () => { await expect( service.rejectAuthorizationSession({ sessionId: authSession.id, + userEntityRef: 'user:default/test', }), ).rejects.toThrow('Authorization session not found or expired'); }); @@ -641,49 +646,15 @@ describe('OidcService', () => { await service.rejectAuthorizationSession({ sessionId: authSession.id, + userEntityRef: 'user:default/test', }); await expect( service.rejectAuthorizationSession({ sessionId: authSession.id, - }), - ).rejects.toThrow('Authorization session not found or expired'); - }); - }); - - describe('authorize', () => { - it('should create direct authorization', async () => { - const { service } = await createOidcService(databaseId); - - const client = await service.registerClient({ - clientName: 'Test Client', - redirectUris: ['https://example.com/callback'], - }); - - const result = await service.authorize({ - clientId: client.clientId, - redirectUri: 'https://example.com/callback', - responseType: 'code', - userEntityRef: 'user:default/test', - state: 'test-state', - }); - - expect(result.redirectUrl).toMatch( - /^https:\/\/example\.com\/callback\?code=.+&state=test-state$/, - ); - }); - - it('should throw error for invalid client', async () => { - const { service } = await createOidcService(databaseId); - - await expect( - service.authorize({ - clientId: 'invalid-client', - redirectUri: 'https://example.com/callback', - responseType: 'code', userEntityRef: 'user:default/test', }), - ).rejects.toThrow('Invalid client_id'); + ).rejects.toThrow('Authorization session not found or expired'); }); }); @@ -698,14 +669,18 @@ describe('OidcService', () => { redirectUris: ['https://example.com/callback'], }); - const authResult = await service.authorize({ + const authSession = await service.createAuthorizationSession({ clientId: client.clientId, redirectUri: 'https://example.com/callback', responseType: 'code', - userEntityRef: 'user:default/test', scope: 'openid', }); + const authResult = await service.approveAuthorizationSession({ + sessionId: authSession.id, + userEntityRef: 'user:default/test', + }); + const code = new URL(authResult.redirectUrl).searchParams.get('code')!; const tokenResult = await service.exchangeCodeForToken({ @@ -751,15 +726,19 @@ describe('OidcService', () => { .update(codeVerifier) .digest('base64url'); - const authResult = await service.authorize({ + const authSession = await service.createAuthorizationSession({ clientId: client.clientId, redirectUri: 'https://example.com/callback', responseType: 'code', - userEntityRef: 'user:default/test', codeChallenge, codeChallengeMethod: 'S256', }); + const authResult = await service.approveAuthorizationSession({ + sessionId: authSession.id, + userEntityRef: 'user:default/test', + }); + const code = new URL(authResult.redirectUrl).searchParams.get('code')!; const tokenResult = await service.exchangeCodeForToken({ @@ -781,15 +760,19 @@ describe('OidcService', () => { }); const codeChallenge = 'test-challenge'; - const authResult = await service.authorize({ + const authSession = await service.createAuthorizationSession({ clientId: client.clientId, redirectUri: 'https://example.com/callback', responseType: 'code', - userEntityRef: 'user:default/test', codeChallenge, codeChallengeMethod: 'S256', }); + const authResult = await service.approveAuthorizationSession({ + sessionId: authSession.id, + userEntityRef: 'user:default/test', + }); + const code = new URL(authResult.redirectUrl).searchParams.get('code')!; await expect( diff --git a/plugins/auth-backend/src/service/OidcService.ts b/plugins/auth-backend/src/service/OidcService.ts index 3139ccc73d..e8a308148a 100644 --- a/plugins/auth-backend/src/service/OidcService.ts +++ b/plugins/auth-backend/src/service/OidcService.ts @@ -300,9 +300,14 @@ export class OidcService { }; } - public async rejectAuthorizationSession(opts: { sessionId: string }) { + public async rejectAuthorizationSession(opts: { + sessionId: string; + userEntityRef: string; + }) { + const { sessionId, userEntityRef } = opts; + const session = await this.oidc.getAuthorizationSession({ - id: opts.sessionId, + id: sessionId, }); if (!session) { @@ -320,94 +325,8 @@ export class OidcService { await this.oidc.updateAuthorizationSession({ id: session.id, status: 'rejected', - }); - } - - public async authorize(opts: { - clientId: string; - redirectUri: string; - responseType: string; - scope?: string; - state?: string; - nonce?: string; - codeChallenge?: string; - codeChallengeMethod?: string; - userEntityRef: string; - }) { - const { - clientId, - redirectUri, - responseType, - scope, - state, - nonce, - codeChallenge, - codeChallengeMethod, userEntityRef, - } = opts; - - if (responseType !== 'code') { - throw new InputError('Only authorization code flow is supported'); - } - - const client = await this.oidc.getClient({ clientId }); - if (!client) { - throw new InputError('Invalid client_id'); - } - - if (!client.redirectUris.includes(redirectUri)) { - throw new InputError('Invalid redirect_uri'); - } - - if (codeChallenge) { - if ( - !codeChallengeMethod || - !['S256', 'plain'].includes(codeChallengeMethod) - ) { - throw new InputError('Invalid code_challenge_method'); - } - } - - const sessionId = crypto.randomUUID(); - const sessionExpiresAt = DateTime.now().plus({ hours: 1 }).toJSDate(); - - await this.oidc.createAuthorizationSession({ - id: sessionId, - clientId, - userEntityRef, - redirectUri, - responseType, - scope, - state, - codeChallenge, - codeChallengeMethod, - nonce, - expiresAt: sessionExpiresAt, }); - - await this.oidc.updateAuthorizationSession({ - id: sessionId, - status: 'approved', - }); - - const authorizationCode = crypto.randomBytes(32).toString('base64url'); - const codeExpiresAt = DateTime.now().plus({ minutes: 10 }).toJSDate(); - - await this.oidc.createAuthorizationCode({ - code: authorizationCode, - sessionId, - expiresAt: codeExpiresAt, - }); - - const redirectUrl = new URL(redirectUri); - redirectUrl.searchParams.append('code', authorizationCode); - if (state) { - redirectUrl.searchParams.append('state', state); - } - - return { - redirectUrl: redirectUrl.toString(), - }; } public async exchangeCodeForToken(params: { From 675814c0962df46a37abc30664360bc74c938dd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 9 Sep 2025 13:29:58 +0200 Subject: [PATCH 087/107] Update .changeset/warm-emus-itch.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/warm-emus-itch.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/warm-emus-itch.md b/.changeset/warm-emus-itch.md index 3275098425..a7bd51abdb 100644 --- a/.changeset/warm-emus-itch.md +++ b/.changeset/warm-emus-itch.md @@ -2,4 +2,4 @@ '@backstage/plugin-catalog': patch --- -Fix incorrect defaultTarget on `createComponentRouteRef`. +Fix incorrect `defaultTarget` on `createComponentRouteRef`. From c2afe12dfd479e95b6cb256c64befcee24a42d0f Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 9 Sep 2025 13:50:38 +0200 Subject: [PATCH 088/107] chore: cleanup a little bit more :tada: Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- ...20250909120000_oidc_client_registration.js | 9 +++++--- plugins/auth-backend/package.json | 1 + plugins/auth-backend/src/migrations.test.ts | 22 ------------------- .../src/service/OidcService.test.ts | 6 ++--- .../auth-backend/src/service/OidcService.ts | 19 ++++++++-------- yarn.lock | 10 +++++++++ 6 files changed, 29 insertions(+), 38 deletions(-) diff --git a/plugins/auth-backend/migrations/20250909120000_oidc_client_registration.js b/plugins/auth-backend/migrations/20250909120000_oidc_client_registration.js index e175c922c1..87391c3467 100644 --- a/plugins/auth-backend/migrations/20250909120000_oidc_client_registration.js +++ b/plugins/auth-backend/migrations/20250909120000_oidc_client_registration.js @@ -44,12 +44,12 @@ exports.up = async function up(knex) { .comment('The name of the client, should be human readable'); table - .text('response_types') + .text('response_types', 'longtext') .notNullable() .comment('JSON array of supported response types'); table - .text('grant_types') + .text('grant_types', 'longtext') .notNullable() .comment('JSON array of supported grant types'); @@ -82,7 +82,10 @@ exports.up = async function up(knex) { .nullable() .comment('Backstage user entity reference'); - table.text('redirect_uri').notNullable().comment('Client redirect URI'); + table + .text('redirect_uri', 'longtext') + .notNullable() + .comment('Client redirect URI'); table.text('scope').nullable().comment('Requested scopes space-separated'); diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 0dddd935e6..9507df7fc3 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -60,6 +60,7 @@ "knex": "^3.0.0", "lodash": "^4.17.21", "luxon": "^3.0.0", + "matcher": "^4.0.0", "minimatch": "^9.0.0", "passport": "^0.7.0", "uuid": "^11.0.0" diff --git a/plugins/auth-backend/src/migrations.test.ts b/plugins/auth-backend/src/migrations.test.ts index 7cb6f982ea..df7063351a 100644 --- a/plugins/auth-backend/src/migrations.test.ts +++ b/plugins/auth-backend/src/migrations.test.ts @@ -281,28 +281,6 @@ describe('migrations', () => { }), ); - await expect( - knex - .insert({ - id: 'invalid-session', - client_id: 'non-existent-client', - redirect_uri: 'https://example.com/callback', - response_type: 'code', - expires_at: new Date(), - }) - .into('oauth_authorization_sessions'), - ).rejects.toThrow(); - - await expect( - knex - .insert({ - code: 'invalid-code', - session_id: 'non-existent-session', - expires_at: new Date(), - }) - .into('oidc_authorization_codes'), - ).rejects.toThrow(); - await knex('oauth_authorization_sessions') .where('id', 'test-session-id') .del(); diff --git a/plugins/auth-backend/src/service/OidcService.test.ts b/plugins/auth-backend/src/service/OidcService.test.ts index 328a753e4c..e4e1673397 100644 --- a/plugins/auth-backend/src/service/OidcService.test.ts +++ b/plugins/auth-backend/src/service/OidcService.test.ts @@ -244,16 +244,16 @@ describe('OidcService', () => { mocks: { config }, } = await createOidcService(databaseId); - config.getOptionalStringArray.mockReturnValue(['cursor://*']); + config.getOptionalStringArray.mockReturnValue(['cursor:*']); const client = await service.registerClient({ clientName: 'Test Client', - redirectUris: ['cursor://callback'], + redirectUris: ['cursor://callback/asd?asd=asd'], }); expect(client).toEqual( expect.objectContaining({ - redirectUris: ['cursor://callback'], + redirectUris: ['cursor://callback/asd?asd=asd'], }), ); }); diff --git a/plugins/auth-backend/src/service/OidcService.ts b/plugins/auth-backend/src/service/OidcService.ts index e8a308148a..b4c6bb122b 100644 --- a/plugins/auth-backend/src/service/OidcService.ts +++ b/plugins/auth-backend/src/service/OidcService.ts @@ -25,6 +25,7 @@ import { decodeJwt } from 'jose'; import crypto from 'crypto'; import { OidcDatabase } from '../database/OidcDatabase'; import { DateTime } from 'luxon'; +import matcher from 'matcher'; export class OidcService { private constructor( @@ -121,17 +122,15 @@ export class OidcService { const allowedRedirectUriPatterns = this.config.getOptionalStringArray( 'auth.experimentalDynamicClientRegistration.allowedRedirectUriPatterns', - ); + ) ?? ['*']; - if (allowedRedirectUriPatterns) { - for (const redirectUri of opts.redirectUris ?? []) { - if ( - !allowedRedirectUriPatterns.some(pattern => - new RegExp(pattern).test(redirectUri), - ) - ) { - throw new InputError('Invalid redirect_uri'); - } + for (const redirectUri of opts.redirectUris ?? []) { + if ( + !allowedRedirectUriPatterns.some(pattern => + matcher.isMatch(redirectUri, pattern), + ) + ) { + throw new InputError('Invalid redirect_uri'); } } diff --git a/yarn.lock b/yarn.lock index 853749b99e..5d78326d94 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4196,6 +4196,7 @@ __metadata: knex: "npm:^3.0.0" lodash: "npm:^4.17.21" luxon: "npm:^3.0.0" + matcher: "npm:^4.0.0" minimatch: "npm:^9.0.0" passport: "npm:^0.7.0" supertest: "npm:^7.0.0" @@ -37208,6 +37209,15 @@ __metadata: languageName: node linkType: hard +"matcher@npm:^4.0.0": + version: 4.0.0 + resolution: "matcher@npm:4.0.0" + dependencies: + escape-string-regexp: "npm:^4.0.0" + checksum: 10/d338aff31d8dfd3626873e43777f46b123579734d53bb8d18d64b08a822ba5e8d39f5fe2e23403258e6143aa0cbe20a15662720d825cd0d3af961d5a44230328 + languageName: node + linkType: hard + "material-ui-confirm@npm:^3.0.12": version: 3.0.18 resolution: "material-ui-confirm@npm:3.0.18" From 62e3de764c2aa3daf17533c31466be7693b715f2 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 8 Sep 2025 17:25:52 +0200 Subject: [PATCH 089/107] chore: initial plugin fix Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- plugins/auth-backend/package.json | 1 + plugins/auth-node/package.json | 1 + plugins/auth-react/package.json | 1 + plugins/auth/.eslintrc.js | 1 + plugins/auth/README.md | 12 + plugins/auth/catalog-info.yaml | 9 + plugins/auth/dev/index.tsx | 17 + plugins/auth/package.json | 76 ++++ .../components/ConsentPage/ConsentPage.tsx | 348 ++++++++++++++++++ .../auth/src/components/ConsentPage/index.ts | 16 + plugins/auth/src/components/Router.tsx | 29 ++ plugins/auth/src/index.ts | 17 + plugins/auth/src/plugin.test.ts | 22 ++ plugins/auth/src/plugin.tsx | 38 ++ plugins/auth/src/routes.ts | 18 + plugins/auth/src/setupTests.ts | 16 + yarn.lock | 138 ++++++- 17 files changed, 748 insertions(+), 12 deletions(-) create mode 100644 plugins/auth/.eslintrc.js create mode 100644 plugins/auth/README.md create mode 100644 plugins/auth/catalog-info.yaml create mode 100644 plugins/auth/dev/index.tsx create mode 100644 plugins/auth/package.json create mode 100644 plugins/auth/src/components/ConsentPage/ConsentPage.tsx create mode 100644 plugins/auth/src/components/ConsentPage/index.ts create mode 100644 plugins/auth/src/components/Router.tsx create mode 100644 plugins/auth/src/index.ts create mode 100644 plugins/auth/src/plugin.test.ts create mode 100644 plugins/auth/src/plugin.tsx create mode 100644 plugins/auth/src/routes.ts create mode 100644 plugins/auth/src/setupTests.ts diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 9507df7fc3..bf52739ea4 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -6,6 +6,7 @@ "role": "backend-plugin", "pluginId": "auth", "pluginPackages": [ + "@backstage/plugin-auth", "@backstage/plugin-auth-backend", "@backstage/plugin-auth-node", "@backstage/plugin-auth-react" diff --git a/plugins/auth-node/package.json b/plugins/auth-node/package.json index 23cff9720c..a846a868eb 100644 --- a/plugins/auth-node/package.json +++ b/plugins/auth-node/package.json @@ -5,6 +5,7 @@ "role": "node-library", "pluginId": "auth", "pluginPackages": [ + "@backstage/plugin-auth", "@backstage/plugin-auth-backend", "@backstage/plugin-auth-node", "@backstage/plugin-auth-react" diff --git a/plugins/auth-react/package.json b/plugins/auth-react/package.json index 4335bbc03a..2c6b5d6ff2 100644 --- a/plugins/auth-react/package.json +++ b/plugins/auth-react/package.json @@ -6,6 +6,7 @@ "role": "web-library", "pluginId": "auth", "pluginPackages": [ + "@backstage/plugin-auth", "@backstage/plugin-auth-backend", "@backstage/plugin-auth-node", "@backstage/plugin-auth-react" diff --git a/plugins/auth/.eslintrc.js b/plugins/auth/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/auth/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/auth/README.md b/plugins/auth/README.md new file mode 100644 index 0000000000..789ae670b4 --- /dev/null +++ b/plugins/auth/README.md @@ -0,0 +1,12 @@ +# @backstage/plugin-auth + +A Backstage frontend plugin that provides user interface components for authentication flows, specifically for OpenID Connect (OIDC) consent management. + +## Installation + +This plugin is designed to work with the `@backstage/plugin-auth-backend` package that provides OIDC provider functionality. + +```bash +# From your Backstage app directory +yarn --cwd packages/app add @backstage/plugin-auth +``` diff --git a/plugins/auth/catalog-info.yaml b/plugins/auth/catalog-info.yaml new file mode 100644 index 0000000000..66d835a198 --- /dev/null +++ b/plugins/auth/catalog-info.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-auth + title: '@backstage/plugin-auth' +spec: + lifecycle: experimental + type: backstage-frontend-plugin + owner: auth-maintainers diff --git a/plugins/auth/dev/index.tsx b/plugins/auth/dev/index.tsx new file mode 100644 index 0000000000..04598f0f2e --- /dev/null +++ b/plugins/auth/dev/index.tsx @@ -0,0 +1,17 @@ +/* + * Copyright 2025 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. + */ + +// todo diff --git a/plugins/auth/package.json b/plugins/auth/package.json new file mode 100644 index 0000000000..9d3fe7b197 --- /dev/null +++ b/plugins/auth/package.json @@ -0,0 +1,76 @@ +{ + "name": "@backstage/plugin-auth", + "version": "0.1.0", + "license": "Apache-2.0", + "private": true, + "main": "src/index.ts", + "types": "src/index.ts", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "package.json": [ + "package.json" + ] + } + }, + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/auth" + }, + "backstage": { + "role": "frontend-plugin", + "pluginId": "auth", + "pluginPackages": [ + "@backstage/plugin-auth", + "@backstage/plugin-auth-backend", + "@backstage/plugin-auth-node", + "@backstage/plugin-auth-react" + ] + }, + "sideEffects": false, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/core-compat-api": "workspace:^", + "@backstage/core-components": "workspace:^", + "@backstage/core-plugin-api": "workspace:^", + "@backstage/errors": "workspace:^", + "@backstage/frontend-plugin-api": "workspace:^", + "@backstage/theme": "workspace:^", + "@material-ui/core": "^4.9.13", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.57", + "react-use": "^17.2.4" + }, + "peerDependencies": { + "react": "^17.0.0", + "react-router-dom": "^6.0.0" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@backstage/core-app-api": "workspace:^", + "@backstage/dev-utils": "workspace:^", + "@backstage/test-utils": "workspace:^", + "@testing-library/jest-dom": "^6.0.0", + "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.0.0", + "msw": "^1.0.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/auth/src/components/ConsentPage/ConsentPage.tsx b/plugins/auth/src/components/ConsentPage/ConsentPage.tsx new file mode 100644 index 0000000000..3234450a1d --- /dev/null +++ b/plugins/auth/src/components/ConsentPage/ConsentPage.tsx @@ -0,0 +1,348 @@ +/* + * Copyright 2025 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 { useCallback, useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { + Box, + Button, + Card, + CardContent, + CardActions, + Typography, + makeStyles, + Divider, +} from '@material-ui/core'; +import { Alert } from '@material-ui/lab'; +import CheckCircleIcon from '@material-ui/icons/CheckCircle'; +import CancelIcon from '@material-ui/icons/Cancel'; +import AppsIcon from '@material-ui/icons/Apps'; +import WarningIcon from '@material-ui/icons/Warning'; +import { + Header, + Page, + Content, + Progress, + EmptyState, + ResponseErrorPanel, +} from '@backstage/core-components'; +import { + alertApiRef, + useApi, + fetchApiRef, + discoveryApiRef, +} from '@backstage/core-plugin-api'; +import { isError } from '@backstage/errors'; + +const useStyles = makeStyles(theme => ({ + authCard: { + maxWidth: 600, + margin: '0 auto', + marginTop: theme.spacing(4), + }, + appHeader: { + display: 'flex', + alignItems: 'center', + marginBottom: theme.spacing(2), + }, + appIcon: { + marginRight: theme.spacing(2), + fontSize: 40, + }, + appName: { + fontSize: '1.5rem', + fontWeight: 'bold', + }, + securityWarning: { + margin: theme.spacing(2, 0), + }, + buttonContainer: { + display: 'flex', + justifyContent: 'space-between', + gap: theme.spacing(2), + padding: theme.spacing(2), + }, + callbackUrl: { + fontFamily: 'monospace', + backgroundColor: theme.palette.background.default, + padding: theme.spacing(1), + borderRadius: theme.shape.borderRadius, + wordBreak: 'break-all', + fontSize: '0.875rem', + }, + scopeList: { + backgroundColor: theme.palette.background.default, + borderRadius: theme.shape.borderRadius, + padding: theme.spacing(1), + }, +})); + +interface Session { + id: string; + clientName?: string; + clientId: string; + redirectUri: string; + scopes?: string[]; + responseType?: string; + state?: string; + nonce?: string; + codeChallenge?: string; + codeChallengeMethod?: string; + expiresAt?: string; +} + +export const ConsentPage = () => { + const classes = useStyles(); + const { sessionId } = useParams<{ sessionId: string }>(); + const alertApi = useApi(alertApiRef); + const fetchApi = useApi(fetchApiRef); + const discoveryApi = useApi(discoveryApiRef); + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [completed, setCompleted] = useState< + | { + action: 'approve' | 'reject'; + } + | undefined + >(undefined); + + useEffect(() => { + const fetchSession = async () => { + if (!sessionId) return; + + try { + const baseUrl = await discoveryApi.getBaseUrl('auth'); + const response = await fetchApi.fetch( + `${baseUrl}/v1/sessions/${sessionId}`, + ); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + setSession(data); + } catch (err) { + setError(isError(err) ? err.message : 'Failed to load consent request'); + } finally { + setLoading(false); + } + }; + + fetchSession(); + }, [sessionId, discoveryApi, fetchApi]); + + const handleAction = useCallback( + async (action: 'approve' | 'reject') => { + if (!session) return; + + setSubmitting(true); + try { + const baseUrl = await discoveryApi.getBaseUrl('auth'); + const response = await fetchApi.fetch( + `${baseUrl}/v1/sessions/${session.id}/${action}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const result = await response.json(); + + setCompleted({ + action, + }); + + if (result.redirectUrl) { + window.location.href = result.redirectUrl; + } + } catch (err) { + alertApi.post({ + message: isError(err) ? err.message : `Failed to ${action} consent`, + severity: 'error', + }); + } finally { + setSubmitting(false); + } + }, + [session, discoveryApi, fetchApi, alertApi], + ); + + if (!sessionId) { + return ( + +
+ + + + + ); + } + + if (loading) { + return ( + +
+ + + + + + + ); + } + + if (error ?? !session) { + return ( + +
+ + + + + ); + } + + if (completed) { + return ( + +
+ + + + + {completed.action === 'approve' ? ( + + ) : ( + + )} + + {completed.action === 'approve' + ? 'Authorization Approved' + : 'Authorization Denied'} + + + {completed.action === 'approve' + ? 'You have successfully authorized the application to access your Backstage account.' + : 'You have denied the application access to your Backstage account.'} + + + Redirecting to the application... + + + + + + + ); + } + + const appName = session.clientName ?? session.clientId; + + return ( + +
+ + + + + + + {appName} + + wants to access your Backstage account + + + + + + + } + className={classes.securityWarning} + > + + Security Notice: By authorizing this + application, you are granting it access to your Backstage + account. The application will receive an access token that + allows it to act on your behalf. + + + + Callback URL: + + {session.redirectUri} + + + + + + Make sure you trust this application and recognize the callback + URL above. Only authorize applications you trust. + + + + + + + + + + + + ); +}; diff --git a/plugins/auth/src/components/ConsentPage/index.ts b/plugins/auth/src/components/ConsentPage/index.ts new file mode 100644 index 0000000000..9fdd44c96d --- /dev/null +++ b/plugins/auth/src/components/ConsentPage/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2025 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 { ConsentPage } from './ConsentPage'; diff --git a/plugins/auth/src/components/Router.tsx b/plugins/auth/src/components/Router.tsx new file mode 100644 index 0000000000..fbb5f9aaa9 --- /dev/null +++ b/plugins/auth/src/components/Router.tsx @@ -0,0 +1,29 @@ +/* + * Copyright 2025 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 { Routes, Route } from 'react-router-dom'; +import { ConsentPage } from './ConsentPage'; + +/** + * Router component for the auth plugin + * @public + */ +export const Router = () => { + return ( + + } /> + + ); +}; diff --git a/plugins/auth/src/index.ts b/plugins/auth/src/index.ts new file mode 100644 index 0000000000..d507bac202 --- /dev/null +++ b/plugins/auth/src/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2025 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 { authPlugin, AuthRouter } from './plugin'; +export { rootRouteRef } from './routes'; diff --git a/plugins/auth/src/plugin.test.ts b/plugins/auth/src/plugin.test.ts new file mode 100644 index 0000000000..a50b718e5d --- /dev/null +++ b/plugins/auth/src/plugin.test.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2025 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 { default as authPlugin } from './plugin'; + +describe('auth', () => { + it('should export plugin', () => { + expect(authPlugin).toBeDefined(); + }); +}); diff --git a/plugins/auth/src/plugin.tsx b/plugins/auth/src/plugin.tsx new file mode 100644 index 0000000000..9b52960f4c --- /dev/null +++ b/plugins/auth/src/plugin.tsx @@ -0,0 +1,38 @@ +/* + * Copyright 2025 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 { compatWrapper } from '@backstage/core-compat-api'; +import { + createFrontendPlugin, + PageBlueprint, +} from '@backstage/frontend-plugin-api'; +import { rootRouteRef } from './routes'; + +export const AuthPage = PageBlueprint.make({ + params: { + path: '/oauth2', + routeRef: rootRouteRef, + loader: () => + import('./components/Router').then(m => compatWrapper()), + }, +}); + +export default createFrontendPlugin({ + pluginId: 'auth', + extensions: [AuthPage], + routes: { + root: rootRouteRef, + }, +}); diff --git a/plugins/auth/src/routes.ts b/plugins/auth/src/routes.ts new file mode 100644 index 0000000000..09e6a48da4 --- /dev/null +++ b/plugins/auth/src/routes.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2025 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 { createRouteRef } from '@backstage/frontend-plugin-api'; + +export const rootRouteRef = createRouteRef(); diff --git a/plugins/auth/src/setupTests.ts b/plugins/auth/src/setupTests.ts new file mode 100644 index 0000000000..b57590b525 --- /dev/null +++ b/plugins/auth/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2025 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 '@testing-library/jest-dom'; diff --git a/yarn.lock b/yarn.lock index 81c7b8aa13..fd631a0fe0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4264,6 +4264,34 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-auth@workspace:plugins/auth": + version: 0.0.0-use.local + resolution: "@backstage/plugin-auth@workspace:plugins/auth" + dependencies: + "@backstage/cli": "workspace:^" + "@backstage/core-app-api": "workspace:^" + "@backstage/core-compat-api": "workspace:^" + "@backstage/core-components": "workspace:^" + "@backstage/core-plugin-api": "workspace:^" + "@backstage/dev-utils": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/frontend-plugin-api": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@backstage/theme": "workspace:^" + "@material-ui/core": "npm:^4.9.13" + "@material-ui/icons": "npm:^4.9.1" + "@material-ui/lab": "npm:4.0.0-alpha.57" + "@testing-library/jest-dom": "npm:^6.0.0" + "@testing-library/react": "npm:^14.0.0" + "@testing-library/user-event": "npm:^14.0.0" + msw: "npm:^1.0.0" + react-use: "npm:^17.2.4" + peerDependencies: + react: ^17.0.0 + react-router-dom: ^6.0.0 + languageName: unknown + linkType: soft + "@backstage/plugin-bitbucket-cloud-common@workspace:^, @backstage/plugin-bitbucket-cloud-common@workspace:plugins/bitbucket-cloud-common": version: 0.0.0-use.local resolution: "@backstage/plugin-bitbucket-cloud-common@workspace:plugins/bitbucket-cloud-common" @@ -10466,6 +10494,27 @@ __metadata: languageName: node linkType: hard +"@material-ui/lab@npm:4.0.0-alpha.57": + version: 4.0.0-alpha.57 + resolution: "@material-ui/lab@npm:4.0.0-alpha.57" + dependencies: + "@babel/runtime": "npm:^7.4.4" + "@material-ui/utils": "npm:^4.11.2" + clsx: "npm:^1.0.4" + prop-types: "npm:^15.7.2" + react-is: "npm:^16.8.0 || ^17.0.0" + peerDependencies: + "@material-ui/core": ^4.9.10 + "@types/react": ^16.8.6 || ^17.0.0 + react: ^16.8.0 || ^17.0.0 + react-dom: ^16.8.0 || ^17.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/0142df7864fd8307a577a7e98e5c198bc71225a1abfe186abb3f5bb6d15bfcf99cf64204d43ea1a9be6723907135e1773a63dc41605b84c37596a02015f3e3b4 + languageName: node + linkType: hard + "@material-ui/lab@npm:4.0.0-alpha.61, @material-ui/lab@npm:^4.0.0-alpha.57, @material-ui/lab@npm:^4.0.0-alpha.60, @material-ui/lab@npm:^4.0.0-alpha.61": version: 4.0.0-alpha.61 resolution: "@material-ui/lab@npm:4.0.0-alpha.61" @@ -10601,7 +10650,7 @@ __metadata: languageName: node linkType: hard -"@material-ui/utils@npm:^4.11.3": +"@material-ui/utils@npm:^4.11.2, @material-ui/utils@npm:^4.11.3": version: 4.11.3 resolution: "@material-ui/utils@npm:4.11.3" dependencies: @@ -19487,6 +19536,22 @@ __metadata: languageName: node linkType: hard +"@testing-library/dom@npm:^9.0.0": + version: 9.3.4 + resolution: "@testing-library/dom@npm:9.3.4" + dependencies: + "@babel/code-frame": "npm:^7.10.4" + "@babel/runtime": "npm:^7.12.5" + "@types/aria-query": "npm:^5.0.1" + aria-query: "npm:5.1.3" + chalk: "npm:^4.1.0" + dom-accessibility-api: "npm:^0.5.9" + lz-string: "npm:^1.5.0" + pretty-format: "npm:^27.0.2" + checksum: 10/510da752ea76f4a10a0a4e3a77917b0302cf03effe576cd3534cab7e796533ee2b0e9fb6fb11b911a1ebd7c70a0bb6f235bf4f816c9b82b95b8fe0cddfd10975 + languageName: node + linkType: hard + "@testing-library/jest-dom@npm:6.5.0": version: 6.5.0 resolution: "@testing-library/jest-dom@npm:6.5.0" @@ -19539,6 +19604,20 @@ __metadata: languageName: node linkType: hard +"@testing-library/react@npm:^14.0.0": + version: 14.3.1 + resolution: "@testing-library/react@npm:14.3.1" + dependencies: + "@babel/runtime": "npm:^7.12.5" + "@testing-library/dom": "npm:^9.0.0" + "@types/react-dom": "npm:^18.0.0" + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + checksum: 10/83359dcdf9eaf067839f34604e1a181cbc14fc09f3a07672403700fcc6a900c4b8054ad1114fc24b4b9f89d84e2a09e1b7c9afce2306b1d4b4c9e30eb1cb12de + languageName: node + linkType: hard + "@testing-library/react@npm:^16.0.0": version: 16.3.0 resolution: "@testing-library/react@npm:16.3.0" @@ -23941,6 +24020,15 @@ __metadata: languageName: node linkType: hard +"aria-query@npm:5.1.3": + version: 5.1.3 + resolution: "aria-query@npm:5.1.3" + dependencies: + deep-equal: "npm:^2.0.5" + checksum: 10/e5da608a7c4954bfece2d879342b6c218b6b207e2d9e5af270b5e38ef8418f02d122afdc948b68e32649b849a38377785252059090d66fa8081da95d1609c0d2 + languageName: node + linkType: hard + "aria-query@npm:5.3.0": version: 5.3.0 resolution: "aria-query@npm:5.3.0" @@ -23957,7 +24045,7 @@ __metadata: languageName: node linkType: hard -"array-buffer-byte-length@npm:^1.0.1, array-buffer-byte-length@npm:^1.0.2": +"array-buffer-byte-length@npm:^1.0.0, array-buffer-byte-length@npm:^1.0.1, array-buffer-byte-length@npm:^1.0.2": version: 1.0.2 resolution: "array-buffer-byte-length@npm:1.0.2" dependencies: @@ -27717,6 +27805,32 @@ __metadata: languageName: node linkType: hard +"deep-equal@npm:^2.0.5": + version: 2.2.3 + resolution: "deep-equal@npm:2.2.3" + dependencies: + array-buffer-byte-length: "npm:^1.0.0" + call-bind: "npm:^1.0.5" + es-get-iterator: "npm:^1.1.3" + get-intrinsic: "npm:^1.2.2" + is-arguments: "npm:^1.1.1" + is-array-buffer: "npm:^3.0.2" + is-date-object: "npm:^1.0.5" + is-regex: "npm:^1.1.4" + is-shared-array-buffer: "npm:^1.0.2" + isarray: "npm:^2.0.5" + object-is: "npm:^1.1.5" + object-keys: "npm:^1.1.1" + object.assign: "npm:^4.1.4" + regexp.prototype.flags: "npm:^1.5.1" + side-channel: "npm:^1.0.4" + which-boxed-primitive: "npm:^1.0.2" + which-collection: "npm:^1.0.1" + which-typed-array: "npm:^1.1.13" + checksum: 10/1ce49d0b71d0f14d8ef991a742665eccd488dfc9b3cada069d4d7a86291e591c92d2589c832811dea182b4015736b210acaaebce6184be356c1060d176f5a05f + languageName: node + linkType: hard + "deep-equal@npm:~1.0.1": version: 1.0.1 resolution: "deep-equal@npm:1.0.1" @@ -28925,7 +29039,7 @@ __metadata: languageName: node linkType: hard -"es-get-iterator@npm:^1.0.2": +"es-get-iterator@npm:^1.0.2, es-get-iterator@npm:^1.1.3": version: 1.1.3 resolution: "es-get-iterator@npm:1.1.3" dependencies: @@ -31522,7 +31636,7 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0": +"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.2, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0": version: 1.3.0 resolution: "get-intrinsic@npm:1.3.0" dependencies: @@ -33455,7 +33569,7 @@ __metadata: languageName: node linkType: hard -"is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5": +"is-array-buffer@npm:^3.0.2, is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5": version: 3.0.5 resolution: "is-array-buffer@npm:3.0.5" dependencies: @@ -33928,7 +34042,7 @@ __metadata: languageName: node linkType: hard -"is-regex@npm:^1.2.1": +"is-regex@npm:^1.1.4, is-regex@npm:^1.2.1": version: 1.2.1 resolution: "is-regex@npm:1.2.1" dependencies: @@ -33977,7 +34091,7 @@ __metadata: languageName: node linkType: hard -"is-shared-array-buffer@npm:^1.0.4": +"is-shared-array-buffer@npm:^1.0.2, is-shared-array-buffer@npm:^1.0.4": version: 1.0.4 resolution: "is-shared-array-buffer@npm:1.0.4" dependencies: @@ -43716,7 +43830,7 @@ __metadata: languageName: node linkType: hard -"regexp.prototype.flags@npm:^1.5.3": +"regexp.prototype.flags@npm:^1.5.1, regexp.prototype.flags@npm:^1.5.3": version: 1.5.4 resolution: "regexp.prototype.flags@npm:1.5.4" dependencies: @@ -45220,7 +45334,7 @@ __metadata: languageName: node linkType: hard -"side-channel@npm:^1.0.6, side-channel@npm:^1.1.0": +"side-channel@npm:^1.0.4, side-channel@npm:^1.0.6, side-channel@npm:^1.1.0": version: 1.1.0 resolution: "side-channel@npm:1.1.0" dependencies: @@ -49387,7 +49501,7 @@ __metadata: languageName: node linkType: hard -"which-boxed-primitive@npm:^1.1.0, which-boxed-primitive@npm:^1.1.1": +"which-boxed-primitive@npm:^1.0.2, which-boxed-primitive@npm:^1.1.0, which-boxed-primitive@npm:^1.1.1": version: 1.1.1 resolution: "which-boxed-primitive@npm:1.1.1" dependencies: @@ -49421,7 +49535,7 @@ __metadata: languageName: node linkType: hard -"which-collection@npm:^1.0.2": +"which-collection@npm:^1.0.1, which-collection@npm:^1.0.2": version: 1.0.2 resolution: "which-collection@npm:1.0.2" dependencies: @@ -49443,7 +49557,7 @@ __metadata: languageName: node linkType: hard -"which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.18, which-typed-array@npm:^1.1.2": +"which-typed-array@npm:^1.1.13, which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.18, which-typed-array@npm:^1.1.2": version: 1.1.19 resolution: "which-typed-array@npm:1.1.19" dependencies: From a2f3718c8b2ab882ee54a42e1c351c277a1bd424 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 9 Sep 2025 10:55:13 +0200 Subject: [PATCH 090/107] chore: added tdev app Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- plugins/auth/dev/index.tsx | 13 ++++++++++++- plugins/auth/package.json | 16 +++++++++++----- plugins/auth/src/index.ts | 2 +- yarn.lock | 37 +++++++++++-------------------------- 4 files changed, 35 insertions(+), 33 deletions(-) diff --git a/plugins/auth/dev/index.tsx b/plugins/auth/dev/index.tsx index 04598f0f2e..b0762416c6 100644 --- a/plugins/auth/dev/index.tsx +++ b/plugins/auth/dev/index.tsx @@ -14,4 +14,15 @@ * limitations under the License. */ -// todo +import { createApp } from '@backstage/frontend-defaults'; +import { createRoot } from 'react-dom/client'; + +import plugin from '../src'; + +const app = createApp({ + features: [plugin], +}); + +const container = document.getElementById('root'); +const root = createRoot(container!); +root.render(app.createRoot()); diff --git a/plugins/auth/package.json b/plugins/auth/package.json index 9d3fe7b197..a623185f42 100644 --- a/plugins/auth/package.json +++ b/plugins/auth/package.json @@ -51,24 +51,30 @@ "@backstage/errors": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", "@backstage/theme": "workspace:^", - "@material-ui/core": "^4.9.13", + "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", - "@material-ui/lab": "4.0.0-alpha.57", + "@material-ui/lab": "4.0.0-alpha.61", "react-use": "^17.2.4" }, "peerDependencies": { - "react": "^17.0.0", - "react-router-dom": "^6.0.0" + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0", + "react-router-dom": "^6.3.0" }, "devDependencies": { "@backstage/cli": "workspace:^", "@backstage/core-app-api": "workspace:^", "@backstage/dev-utils": "workspace:^", + "@backstage/frontend-defaults": "workspace:^", "@backstage/test-utils": "workspace:^", "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.0.0", - "msw": "^1.0.0" + "@types/react": "^18.0.0", + "msw": "^1.0.0", + "react": "^18.0.2", + "react-dom": "^18.0.2" }, "files": [ "dist" diff --git a/plugins/auth/src/index.ts b/plugins/auth/src/index.ts index d507bac202..31460b3e9d 100644 --- a/plugins/auth/src/index.ts +++ b/plugins/auth/src/index.ts @@ -13,5 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { authPlugin, AuthRouter } from './plugin'; +export { default } from './plugin'; export { rootRouteRef } from './routes'; diff --git a/yarn.lock b/yarn.lock index fd631a0fe0..4d15a81ad2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4275,20 +4275,26 @@ __metadata: "@backstage/core-plugin-api": "workspace:^" "@backstage/dev-utils": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/frontend-defaults": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/theme": "workspace:^" - "@material-ui/core": "npm:^4.9.13" + "@material-ui/core": "npm:^4.12.2" "@material-ui/icons": "npm:^4.9.1" - "@material-ui/lab": "npm:4.0.0-alpha.57" + "@material-ui/lab": "npm:4.0.0-alpha.61" "@testing-library/jest-dom": "npm:^6.0.0" "@testing-library/react": "npm:^14.0.0" "@testing-library/user-event": "npm:^14.0.0" + "@types/react": "npm:^18.0.0" msw: "npm:^1.0.0" + react: "npm:^18.0.2" + react-dom: "npm:^18.0.2" react-use: "npm:^17.2.4" peerDependencies: - react: ^17.0.0 - react-router-dom: ^6.0.0 + "@types/react": ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + react-router-dom: ^6.3.0 languageName: unknown linkType: soft @@ -10494,27 +10500,6 @@ __metadata: languageName: node linkType: hard -"@material-ui/lab@npm:4.0.0-alpha.57": - version: 4.0.0-alpha.57 - resolution: "@material-ui/lab@npm:4.0.0-alpha.57" - dependencies: - "@babel/runtime": "npm:^7.4.4" - "@material-ui/utils": "npm:^4.11.2" - clsx: "npm:^1.0.4" - prop-types: "npm:^15.7.2" - react-is: "npm:^16.8.0 || ^17.0.0" - peerDependencies: - "@material-ui/core": ^4.9.10 - "@types/react": ^16.8.6 || ^17.0.0 - react: ^16.8.0 || ^17.0.0 - react-dom: ^16.8.0 || ^17.0.0 - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 10/0142df7864fd8307a577a7e98e5c198bc71225a1abfe186abb3f5bb6d15bfcf99cf64204d43ea1a9be6723907135e1773a63dc41605b84c37596a02015f3e3b4 - languageName: node - linkType: hard - "@material-ui/lab@npm:4.0.0-alpha.61, @material-ui/lab@npm:^4.0.0-alpha.57, @material-ui/lab@npm:^4.0.0-alpha.60, @material-ui/lab@npm:^4.0.0-alpha.61": version: 4.0.0-alpha.61 resolution: "@material-ui/lab@npm:4.0.0-alpha.61" @@ -10650,7 +10635,7 @@ __metadata: languageName: node linkType: hard -"@material-ui/utils@npm:^4.11.2, @material-ui/utils@npm:^4.11.3": +"@material-ui/utils@npm:^4.11.3": version: 4.11.3 resolution: "@material-ui/utils@npm:4.11.3" dependencies: From c7be7c1c6ddae17c956b9425121065634105c497 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 9 Sep 2025 10:56:41 +0200 Subject: [PATCH 091/107] chore: added docs Signed-off-by: benjdlambert --- plugins/auth/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/auth/README.md b/plugins/auth/README.md index 789ae670b4..440d7d294a 100644 --- a/plugins/auth/README.md +++ b/plugins/auth/README.md @@ -10,3 +10,7 @@ This plugin is designed to work with the `@backstage/plugin-auth-backend` packag # From your Backstage app directory yarn --cwd packages/app add @backstage/plugin-auth ``` + +## Usage + +The plugin provides the route `/oauth2/authorize/:sessionId` for approving of oauth2 sessions for clients. You should see an approval flow for any sessions created through the `auth-backend`. From 54ddfefee9f9232be3630ddea015215eef6f0388 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 9 Sep 2025 11:01:13 +0200 Subject: [PATCH 092/107] chore: fix package metadata and changeset Signed-off-by: benjdlambert --- .changeset/funny-eagles-try.md | 7 +++ .changeset/giant-buttons-flash.md | 5 +++ plugins/auth/package.json | 73 +++++++++++++++---------------- 3 files changed, 48 insertions(+), 37 deletions(-) create mode 100644 .changeset/funny-eagles-try.md create mode 100644 .changeset/giant-buttons-flash.md diff --git a/.changeset/funny-eagles-try.md b/.changeset/funny-eagles-try.md new file mode 100644 index 0000000000..c5550756e0 --- /dev/null +++ b/.changeset/funny-eagles-try.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-auth-backend': patch +'@backstage/plugin-auth-react': patch +'@backstage/plugin-auth-node': patch +--- + +Updating plugin metadata diff --git a/.changeset/giant-buttons-flash.md b/.changeset/giant-buttons-flash.md new file mode 100644 index 0000000000..97564d37ac --- /dev/null +++ b/.changeset/giant-buttons-flash.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth': minor +--- + +Initial publish of the `auth` frontend package diff --git a/plugins/auth/package.json b/plugins/auth/package.json index a623185f42..c69ffe656f 100644 --- a/plugins/auth/package.json +++ b/plugins/auth/package.json @@ -1,29 +1,6 @@ { "name": "@backstage/plugin-auth", - "version": "0.1.0", - "license": "Apache-2.0", - "private": true, - "main": "src/index.ts", - "types": "src/index.ts", - "publishConfig": { - "access": "public" - }, - "exports": { - ".": "./src/index.ts", - "./package.json": "./package.json" - }, - "typesVersions": { - "*": { - "package.json": [ - "package.json" - ] - } - }, - "repository": { - "type": "git", - "url": "https://github.com/backstage/backstage", - "directory": "plugins/auth" - }, + "version": "0.0.0", "backstage": { "role": "frontend-plugin", "pluginId": "auth", @@ -34,15 +11,40 @@ "@backstage/plugin-auth-react" ] }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/auth" + }, + "license": "Apache-2.0", "sideEffects": false, + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "main": "src/index.ts", + "types": "src/index.ts", + "typesVersions": { + "*": { + "package.json": [ + "package.json" + ] + } + }, + "files": [ + "dist" + ], "scripts": { - "start": "backstage-cli package start", "build": "backstage-cli package build", - "lint": "backstage-cli package lint", - "test": "backstage-cli package test", "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", "prepack": "backstage-cli package prepack", - "postpack": "backstage-cli package postpack" + "postpack": "backstage-cli package postpack", + "start": "backstage-cli package start", + "test": "backstage-cli package test" }, "dependencies": { "@backstage/core-compat-api": "workspace:^", @@ -56,12 +58,6 @@ "@material-ui/lab": "4.0.0-alpha.61", "react-use": "^17.2.4" }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0", - "react-router-dom": "^6.3.0" - }, "devDependencies": { "@backstage/cli": "workspace:^", "@backstage/core-app-api": "workspace:^", @@ -76,7 +72,10 @@ "react": "^18.0.2", "react-dom": "^18.0.2" }, - "files": [ - "dist" - ] + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0", + "react-router-dom": "^6.3.0" + } } From 4fcd282522b141a9d58b446348a73751b86047f2 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 9 Sep 2025 11:32:48 +0200 Subject: [PATCH 093/107] chore: fix linting of peer deps Signed-off-by: benjdlambert --- plugins/auth/package.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/plugins/auth/package.json b/plugins/auth/package.json index c69ffe656f..6dadc78423 100644 --- a/plugins/auth/package.json +++ b/plugins/auth/package.json @@ -70,12 +70,18 @@ "@types/react": "^18.0.0", "msw": "^1.0.0", "react": "^18.0.2", - "react-dom": "^18.0.2" + "react-dom": "^18.0.2", + "react-router-dom": "^6.3.0" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0", "react": "^17.0.0 || ^18.0.0", "react-dom": "^17.0.0 || ^18.0.0", "react-router-dom": "^6.3.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } } From 81dd70aebd994af6b2065a3aa665743d65b2fe27 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 9 Sep 2025 14:21:54 +0200 Subject: [PATCH 094/107] chore: fixing dependencies and code improvements Signed-off-by: benjdlambert --- plugins/auth/package.json | 5 +- .../components/ConsentPage/ConsentPage.tsx | 370 +++++++----------- .../ConsentPage/useConsentSession.ts | 144 +++++++ plugins/auth/src/plugin.tsx | 4 +- yarn.lock | 96 +---- 5 files changed, 296 insertions(+), 323 deletions(-) create mode 100644 plugins/auth/src/components/ConsentPage/useConsentSession.ts diff --git a/plugins/auth/package.json b/plugins/auth/package.json index 6dadc78423..54e87cb7b1 100644 --- a/plugins/auth/package.json +++ b/plugins/auth/package.json @@ -47,9 +47,7 @@ "test": "backstage-cli package test" }, "dependencies": { - "@backstage/core-compat-api": "workspace:^", "@backstage/core-components": "workspace:^", - "@backstage/core-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", "@backstage/theme": "workspace:^", @@ -60,12 +58,11 @@ }, "devDependencies": { "@backstage/cli": "workspace:^", - "@backstage/core-app-api": "workspace:^", "@backstage/dev-utils": "workspace:^", "@backstage/frontend-defaults": "workspace:^", "@backstage/test-utils": "workspace:^", "@testing-library/jest-dom": "^6.0.0", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.0.0", "@types/react": "^18.0.0", "msw": "^1.0.0", diff --git a/plugins/auth/src/components/ConsentPage/ConsentPage.tsx b/plugins/auth/src/components/ConsentPage/ConsentPage.tsx index 3234450a1d..0f071025a2 100644 --- a/plugins/auth/src/components/ConsentPage/ConsentPage.tsx +++ b/plugins/auth/src/components/ConsentPage/ConsentPage.tsx @@ -13,8 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useCallback, useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; + import { Box, Button, @@ -38,13 +38,7 @@ import { EmptyState, ResponseErrorPanel, } from '@backstage/core-components'; -import { - alertApiRef, - useApi, - fetchApiRef, - discoveryApiRef, -} from '@backstage/core-plugin-api'; -import { isError } from '@backstage/errors'; +import { useConsentSession } from './useConsentSession'; const useStyles = makeStyles(theme => ({ authCard: { @@ -89,260 +83,164 @@ const useStyles = makeStyles(theme => ({ }, })); -interface Session { - id: string; - clientName?: string; - clientId: string; - redirectUri: string; - scopes?: string[]; - responseType?: string; - state?: string; - nonce?: string; - codeChallenge?: string; - codeChallengeMethod?: string; - expiresAt?: string; -} +const ConsentPageLayout = ({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) => ( + +
+ {children} + +); export const ConsentPage = () => { const classes = useStyles(); const { sessionId } = useParams<{ sessionId: string }>(); - const alertApi = useApi(alertApiRef); - const fetchApi = useApi(fetchApiRef); - const discoveryApi = useApi(discoveryApiRef); - const [session, setSession] = useState(null); - const [loading, setLoading] = useState(true); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - const [completed, setCompleted] = useState< - | { - action: 'approve' | 'reject'; - } - | undefined - >(undefined); - - useEffect(() => { - const fetchSession = async () => { - if (!sessionId) return; - - try { - const baseUrl = await discoveryApi.getBaseUrl('auth'); - const response = await fetchApi.fetch( - `${baseUrl}/v1/sessions/${sessionId}`, - ); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - setSession(data); - } catch (err) { - setError(isError(err) ? err.message : 'Failed to load consent request'); - } finally { - setLoading(false); - } - }; - - fetchSession(); - }, [sessionId, discoveryApi, fetchApi]); - - const handleAction = useCallback( - async (action: 'approve' | 'reject') => { - if (!session) return; - - setSubmitting(true); - try { - const baseUrl = await discoveryApi.getBaseUrl('auth'); - const response = await fetchApi.fetch( - `${baseUrl}/v1/sessions/${session.id}/${action}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - }, - ); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const result = await response.json(); - - setCompleted({ - action, - }); - - if (result.redirectUrl) { - window.location.href = result.redirectUrl; - } - } catch (err) { - alertApi.post({ - message: isError(err) ? err.message : `Failed to ${action} consent`, - severity: 'error', - }); - } finally { - setSubmitting(false); - } - }, - [session, discoveryApi, fetchApi, alertApi], - ); + const { state, handleAction } = useConsentSession({ sessionId }); if (!sessionId) { return ( - -
- - - - + + + ); } - if (loading) { + if (state.status === 'loading') { return ( - -
- - - - - - + + + + + ); } - if (error ?? !session) { + if (state.status === 'error') { return ( - -
- - - - + + + ); } - if (completed) { + if (state.status === 'completed') { return ( - -
- - - - - {completed.action === 'approve' ? ( - - ) : ( - - )} - - {completed.action === 'approve' - ? 'Authorization Approved' - : 'Authorization Denied'} - - - {completed.action === 'approve' - ? 'You have successfully authorized the application to access your Backstage account.' - : 'You have denied the application access to your Backstage account.'} - - - Redirecting to the application... - - - - - - - ); - } - - const appName = session.clientName ?? session.clientId; - - return ( - -
- + - - - - {appName} - - wants to access your Backstage account - - - - - - - } - className={classes.securityWarning} - > - - Security Notice: By authorizing this - application, you are granting it access to your Backstage - account. The application will receive an access token that - allows it to act on your behalf. + + {state.action === 'approve' ? ( + + ) : ( + + )} + + {state.action === 'approve' + ? 'Authorization Approved' + : 'Authorization Denied'} + + + {state.action === 'approve' + ? 'You have successfully authorized the application to access your Backstage account.' + : 'You have denied the application access to your Backstage account.'} - - - Callback URL: - - {session.redirectUri} - - - - - Make sure you trust this application and recognize the callback - URL above. Only authorize applications you trust. + Redirecting to the application... - - - - - - - + + ); + } + + const session = state.session; + const isSubmitting = state.status === 'submitting'; + const appName = session.clientName ?? session.clientId; + + return ( + + + + + + + {appName} + + wants to access your Backstage account + + + + + + + } + className={classes.securityWarning} + > + + Security Notice: By authorizing this application, + you are granting it access to your Backstage account. The + application will receive an access token that allows it to act on + your behalf. + + + + Callback URL: + + {session.redirectUri} + + + + + + Make sure you trust this application and recognize the callback + URL above. Only authorize applications you trust. + + + + + + + + + + ); }; diff --git a/plugins/auth/src/components/ConsentPage/useConsentSession.ts b/plugins/auth/src/components/ConsentPage/useConsentSession.ts new file mode 100644 index 0000000000..a19faae6fe --- /dev/null +++ b/plugins/auth/src/components/ConsentPage/useConsentSession.ts @@ -0,0 +1,144 @@ +/* + * Copyright 2025 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 { + useApi, + alertApiRef, + fetchApiRef, + discoveryApiRef, +} from '@backstage/frontend-plugin-api'; +import { useCallback } from 'react'; +import useAsync from 'react-use/esm/useAsync'; +import useAsyncFn from 'react-use/esm/useAsyncFn'; +import { isError } from '@backstage/errors'; + +interface Session { + id: string; + clientName?: string; + clientId: string; + redirectUri: string; + scopes?: string[]; + responseType?: string; + state?: string; + nonce?: string; + codeChallenge?: string; + codeChallengeMethod?: string; + expiresAt?: string; +} + +type ConsentState = + | { status: 'loading' } + | { status: 'error'; error: string } + | { status: 'loaded'; session: Session } + | { status: 'submitting'; session: Session; action: 'approve' | 'reject' } + | { status: 'completed'; action: 'approve' | 'reject' }; + +export const useConsentSession = (opts: { sessionId?: string }) => { + const alertApi = useApi(alertApiRef); + const fetchApi = useApi(fetchApiRef); + const discoveryApi = useApi(discoveryApiRef); + const { sessionId } = opts; + + const sessionState = useAsync(async () => { + if (!sessionId) { + throw new Error('Session ID is missing'); + } + + const baseUrl = await discoveryApi.getBaseUrl('auth'); + const response = await fetchApi.fetch( + `${baseUrl}/v1/sessions/${sessionId}`, + ); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return (await response.json()) as Session; + }, [sessionId]); + + const [actionState, handleActionInternal] = useAsyncFn( + async (action: 'approve' | 'reject', session: Session) => { + const baseUrl = await discoveryApi.getBaseUrl('auth'); + const response = await fetchApi.fetch( + `${baseUrl}/v1/sessions/${session.id}/${action}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const result = await response.json(); + + if (result.redirectUrl) { + window.location.href = result.redirectUrl; + } + + return { action, redirectUrl: result.redirectUrl }; + }, + [discoveryApi, fetchApi], + ); + + const getConsentState = (): ConsentState => { + if (actionState.value) { + return { status: 'completed', action: actionState.value.action }; + } + if (actionState.loading && sessionState.value) { + return { + status: 'submitting', + session: sessionState.value, + action: 'approve', // This will be set properly when called + }; + } + if (sessionState.error) { + return { + status: 'error', + error: isError(sessionState.error) + ? sessionState.error.message + : 'Failed to load consent request', + }; + } + if (sessionState.value) { + return { status: 'loaded', session: sessionState.value }; + } + return { status: 'loading' }; + }; + + const state = getConsentState(); + return { + state, + handleAction: useCallback( + async (action: 'approve' | 'reject') => { + if (state.status !== 'loaded') return; + + try { + await handleActionInternal(action, state.session); + } catch (err) { + alertApi.post({ + message: isError(err) ? err.message : `Failed to ${action} consent`, + severity: 'error', + }); + } + }, + [state, handleActionInternal, alertApi], + ), + }; +}; diff --git a/plugins/auth/src/plugin.tsx b/plugins/auth/src/plugin.tsx index 9b52960f4c..4ed5d57223 100644 --- a/plugins/auth/src/plugin.tsx +++ b/plugins/auth/src/plugin.tsx @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { compatWrapper } from '@backstage/core-compat-api'; import { createFrontendPlugin, PageBlueprint, @@ -24,8 +23,7 @@ export const AuthPage = PageBlueprint.make({ params: { path: '/oauth2', routeRef: rootRouteRef, - loader: () => - import('./components/Router').then(m => compatWrapper()), + loader: () => import('./components/Router').then(m => ), }, }); diff --git a/yarn.lock b/yarn.lock index 4d15a81ad2..9d3c3af1f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4269,10 +4269,7 @@ __metadata: resolution: "@backstage/plugin-auth@workspace:plugins/auth" dependencies: "@backstage/cli": "workspace:^" - "@backstage/core-app-api": "workspace:^" - "@backstage/core-compat-api": "workspace:^" "@backstage/core-components": "workspace:^" - "@backstage/core-plugin-api": "workspace:^" "@backstage/dev-utils": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/frontend-defaults": "workspace:^" @@ -4283,18 +4280,22 @@ __metadata: "@material-ui/icons": "npm:^4.9.1" "@material-ui/lab": "npm:4.0.0-alpha.61" "@testing-library/jest-dom": "npm:^6.0.0" - "@testing-library/react": "npm:^14.0.0" + "@testing-library/react": "npm:^16.0.0" "@testing-library/user-event": "npm:^14.0.0" "@types/react": "npm:^18.0.0" msw: "npm:^1.0.0" react: "npm:^18.0.2" react-dom: "npm:^18.0.2" + react-router-dom: "npm:^6.3.0" react-use: "npm:^17.2.4" peerDependencies: "@types/react": ^17.0.0 || ^18.0.0 react: ^17.0.0 || ^18.0.0 react-dom: ^17.0.0 || ^18.0.0 react-router-dom: ^6.3.0 + peerDependenciesMeta: + "@types/react": + optional: true languageName: unknown linkType: soft @@ -19521,22 +19522,6 @@ __metadata: languageName: node linkType: hard -"@testing-library/dom@npm:^9.0.0": - version: 9.3.4 - resolution: "@testing-library/dom@npm:9.3.4" - dependencies: - "@babel/code-frame": "npm:^7.10.4" - "@babel/runtime": "npm:^7.12.5" - "@types/aria-query": "npm:^5.0.1" - aria-query: "npm:5.1.3" - chalk: "npm:^4.1.0" - dom-accessibility-api: "npm:^0.5.9" - lz-string: "npm:^1.5.0" - pretty-format: "npm:^27.0.2" - checksum: 10/510da752ea76f4a10a0a4e3a77917b0302cf03effe576cd3534cab7e796533ee2b0e9fb6fb11b911a1ebd7c70a0bb6f235bf4f816c9b82b95b8fe0cddfd10975 - languageName: node - linkType: hard - "@testing-library/jest-dom@npm:6.5.0": version: 6.5.0 resolution: "@testing-library/jest-dom@npm:6.5.0" @@ -19589,20 +19574,6 @@ __metadata: languageName: node linkType: hard -"@testing-library/react@npm:^14.0.0": - version: 14.3.1 - resolution: "@testing-library/react@npm:14.3.1" - dependencies: - "@babel/runtime": "npm:^7.12.5" - "@testing-library/dom": "npm:^9.0.0" - "@types/react-dom": "npm:^18.0.0" - peerDependencies: - react: ^18.0.0 - react-dom: ^18.0.0 - checksum: 10/83359dcdf9eaf067839f34604e1a181cbc14fc09f3a07672403700fcc6a900c4b8054ad1114fc24b4b9f89d84e2a09e1b7c9afce2306b1d4b4c9e30eb1cb12de - languageName: node - linkType: hard - "@testing-library/react@npm:^16.0.0": version: 16.3.0 resolution: "@testing-library/react@npm:16.3.0" @@ -24005,15 +23976,6 @@ __metadata: languageName: node linkType: hard -"aria-query@npm:5.1.3": - version: 5.1.3 - resolution: "aria-query@npm:5.1.3" - dependencies: - deep-equal: "npm:^2.0.5" - checksum: 10/e5da608a7c4954bfece2d879342b6c218b6b207e2d9e5af270b5e38ef8418f02d122afdc948b68e32649b849a38377785252059090d66fa8081da95d1609c0d2 - languageName: node - linkType: hard - "aria-query@npm:5.3.0": version: 5.3.0 resolution: "aria-query@npm:5.3.0" @@ -24030,7 +23992,7 @@ __metadata: languageName: node linkType: hard -"array-buffer-byte-length@npm:^1.0.0, array-buffer-byte-length@npm:^1.0.1, array-buffer-byte-length@npm:^1.0.2": +"array-buffer-byte-length@npm:^1.0.1, array-buffer-byte-length@npm:^1.0.2": version: 1.0.2 resolution: "array-buffer-byte-length@npm:1.0.2" dependencies: @@ -27790,32 +27752,6 @@ __metadata: languageName: node linkType: hard -"deep-equal@npm:^2.0.5": - version: 2.2.3 - resolution: "deep-equal@npm:2.2.3" - dependencies: - array-buffer-byte-length: "npm:^1.0.0" - call-bind: "npm:^1.0.5" - es-get-iterator: "npm:^1.1.3" - get-intrinsic: "npm:^1.2.2" - is-arguments: "npm:^1.1.1" - is-array-buffer: "npm:^3.0.2" - is-date-object: "npm:^1.0.5" - is-regex: "npm:^1.1.4" - is-shared-array-buffer: "npm:^1.0.2" - isarray: "npm:^2.0.5" - object-is: "npm:^1.1.5" - object-keys: "npm:^1.1.1" - object.assign: "npm:^4.1.4" - regexp.prototype.flags: "npm:^1.5.1" - side-channel: "npm:^1.0.4" - which-boxed-primitive: "npm:^1.0.2" - which-collection: "npm:^1.0.1" - which-typed-array: "npm:^1.1.13" - checksum: 10/1ce49d0b71d0f14d8ef991a742665eccd488dfc9b3cada069d4d7a86291e591c92d2589c832811dea182b4015736b210acaaebce6184be356c1060d176f5a05f - languageName: node - linkType: hard - "deep-equal@npm:~1.0.1": version: 1.0.1 resolution: "deep-equal@npm:1.0.1" @@ -29024,7 +28960,7 @@ __metadata: languageName: node linkType: hard -"es-get-iterator@npm:^1.0.2, es-get-iterator@npm:^1.1.3": +"es-get-iterator@npm:^1.0.2": version: 1.1.3 resolution: "es-get-iterator@npm:1.1.3" dependencies: @@ -31621,7 +31557,7 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.2, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0": +"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0": version: 1.3.0 resolution: "get-intrinsic@npm:1.3.0" dependencies: @@ -33554,7 +33490,7 @@ __metadata: languageName: node linkType: hard -"is-array-buffer@npm:^3.0.2, is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5": +"is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5": version: 3.0.5 resolution: "is-array-buffer@npm:3.0.5" dependencies: @@ -34027,7 +33963,7 @@ __metadata: languageName: node linkType: hard -"is-regex@npm:^1.1.4, is-regex@npm:^1.2.1": +"is-regex@npm:^1.2.1": version: 1.2.1 resolution: "is-regex@npm:1.2.1" dependencies: @@ -34076,7 +34012,7 @@ __metadata: languageName: node linkType: hard -"is-shared-array-buffer@npm:^1.0.2, is-shared-array-buffer@npm:^1.0.4": +"is-shared-array-buffer@npm:^1.0.4": version: 1.0.4 resolution: "is-shared-array-buffer@npm:1.0.4" dependencies: @@ -43815,7 +43751,7 @@ __metadata: languageName: node linkType: hard -"regexp.prototype.flags@npm:^1.5.1, regexp.prototype.flags@npm:^1.5.3": +"regexp.prototype.flags@npm:^1.5.3": version: 1.5.4 resolution: "regexp.prototype.flags@npm:1.5.4" dependencies: @@ -45319,7 +45255,7 @@ __metadata: languageName: node linkType: hard -"side-channel@npm:^1.0.4, side-channel@npm:^1.0.6, side-channel@npm:^1.1.0": +"side-channel@npm:^1.0.6, side-channel@npm:^1.1.0": version: 1.1.0 resolution: "side-channel@npm:1.1.0" dependencies: @@ -49486,7 +49422,7 @@ __metadata: languageName: node linkType: hard -"which-boxed-primitive@npm:^1.0.2, which-boxed-primitive@npm:^1.1.0, which-boxed-primitive@npm:^1.1.1": +"which-boxed-primitive@npm:^1.1.0, which-boxed-primitive@npm:^1.1.1": version: 1.1.1 resolution: "which-boxed-primitive@npm:1.1.1" dependencies: @@ -49520,7 +49456,7 @@ __metadata: languageName: node linkType: hard -"which-collection@npm:^1.0.1, which-collection@npm:^1.0.2": +"which-collection@npm:^1.0.2": version: 1.0.2 resolution: "which-collection@npm:1.0.2" dependencies: @@ -49542,7 +49478,7 @@ __metadata: languageName: node linkType: hard -"which-typed-array@npm:^1.1.13, which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.18, which-typed-array@npm:^1.1.2": +"which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.18, which-typed-array@npm:^1.1.2": version: 1.1.19 resolution: "which-typed-array@npm:1.1.19" dependencies: From 0f5b2d6489bc0cc16b216b3d79fd811ee6b5fa47 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 9 Sep 2025 16:12:06 +0200 Subject: [PATCH 095/107] chore: updating api-report Signed-off-by: benjdlambert --- plugins/auth/report.api.md | 52 ++++++++++++++++++++++++++++++++++++++ plugins/auth/src/index.ts | 1 - 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 plugins/auth/report.api.md diff --git a/plugins/auth/report.api.md b/plugins/auth/report.api.md new file mode 100644 index 0000000000..4c7d2d4a11 --- /dev/null +++ b/plugins/auth/report.api.md @@ -0,0 +1,52 @@ +## API Report File for "@backstage/plugin-auth" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; +import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; +import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; +import { JSX as JSX_2 } from 'react'; +import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api'; +import { RouteRef } from '@backstage/frontend-plugin-api'; + +// @public (undocumented) +const _default: OverridableFrontendPlugin< + { + root: RouteRef; + }, + {}, + { + 'page:auth': ExtensionDefinition<{ + kind: 'page'; + name: undefined; + config: { + path: string | undefined; + }; + configInput: { + path?: string | undefined; + }; + output: + | ExtensionDataRef + | ExtensionDataRef + | ExtensionDataRef< + RouteRef, + 'core.routing.ref', + { + optional: true; + } + >; + inputs: {}; + params: { + defaultPath?: [Error: `Use the 'path' param instead`]; + path: string; + loader: () => Promise; + routeRef?: RouteRef; + }; + }>; + } +>; +export default _default; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/auth/src/index.ts b/plugins/auth/src/index.ts index 31460b3e9d..717cdd4672 100644 --- a/plugins/auth/src/index.ts +++ b/plugins/auth/src/index.ts @@ -14,4 +14,3 @@ * limitations under the License. */ export { default } from './plugin'; -export { rootRouteRef } from './routes'; From 14b5ccfbbea1aa22016d0f182006b6e562a79077 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 9 Sep 2025 16:28:05 +0200 Subject: [PATCH 096/107] chore: add auth to app next Signed-off-by: benjdlambert --- packages/app-next/package.json | 1 + yarn.lock | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/app-next/package.json b/packages/app-next/package.json index 44602b2438..8e54830cac 100644 --- a/packages/app-next/package.json +++ b/packages/app-next/package.json @@ -49,6 +49,7 @@ "@backstage/plugin-api-docs": "workspace:^", "@backstage/plugin-app": "workspace:^", "@backstage/plugin-app-visualizer": "workspace:^", + "@backstage/plugin-auth": "workspace:^", "@backstage/plugin-auth-react": "workspace:^", "@backstage/plugin-catalog": "workspace:^", "@backstage/plugin-catalog-common": "workspace:^", diff --git a/yarn.lock b/yarn.lock index 9d3c3af1f7..88e4061d10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4264,7 +4264,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-auth@workspace:plugins/auth": +"@backstage/plugin-auth@workspace:^, @backstage/plugin-auth@workspace:plugins/auth": version: 0.0.0-use.local resolution: "@backstage/plugin-auth@workspace:plugins/auth" dependencies: @@ -29954,6 +29954,7 @@ __metadata: "@backstage/plugin-api-docs": "workspace:^" "@backstage/plugin-app": "workspace:^" "@backstage/plugin-app-visualizer": "workspace:^" + "@backstage/plugin-auth": "workspace:^" "@backstage/plugin-auth-react": "workspace:^" "@backstage/plugin-catalog": "workspace:^" "@backstage/plugin-catalog-common": "workspace:^" From 020d484ac424b9d48668fd1e82f505b381101d72 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Sep 2025 15:00:36 +0000 Subject: [PATCH 097/107] Version Packages (next) --- .changeset/create-app-1757429965.md | 5 + .changeset/pre.json | 26 +- docs/releases/v1.43.0-next.2-changelog.md | 695 ++++++++++++++++++ package.json | 2 +- packages/app-next/CHANGELOG.md | 27 + packages/app-next/package.json | 2 +- packages/app/CHANGELOG.md | 25 + packages/app/package.json | 2 +- packages/backend-defaults/CHANGELOG.md | 11 + packages/backend-defaults/package.json | 2 +- .../CHANGELOG.md | 11 + .../package.json | 2 +- packages/catalog-client/CHANGELOG.md | 23 + packages/catalog-client/package.json | 2 +- packages/cli/CHANGELOG.md | 8 + packages/cli/package.json | 2 +- packages/config-loader/CHANGELOG.md | 6 + packages/config-loader/package.json | 2 +- packages/core-compat-api/CHANGELOG.md | 7 + packages/core-compat-api/package.json | 2 +- packages/core-components/CHANGELOG.md | 7 + packages/core-components/package.json | 2 +- packages/create-app/CHANGELOG.md | 6 + packages/create-app/package.json | 2 +- packages/dev-utils/CHANGELOG.md | 8 + packages/dev-utils/package.json | 2 +- packages/repo-tools/CHANGELOG.md | 7 + packages/repo-tools/package.json | 2 +- packages/ui/CHANGELOG.md | 7 + packages/ui/package.json | 2 +- plugins/api-docs/CHANGELOG.md | 10 + plugins/api-docs/package.json | 2 +- plugins/app-backend/CHANGELOG.md | 10 + plugins/app-backend/package.json | 2 +- plugins/app-node/CHANGELOG.md | 7 + plugins/app-node/package.json | 2 +- plugins/auth-backend/CHANGELOG.md | 10 + plugins/auth-backend/package.json | 2 +- plugins/auth-node/CHANGELOG.md | 8 + plugins/auth-node/package.json | 2 +- plugins/auth-react/CHANGELOG.md | 8 + plugins/auth-react/package.json | 2 +- plugins/auth/CHANGELOG.md | 12 + plugins/auth/package.json | 2 +- .../catalog-backend-module-aws/CHANGELOG.md | 10 + .../catalog-backend-module-aws/package.json | 2 +- .../catalog-backend-module-azure/CHANGELOG.md | 8 + .../catalog-backend-module-azure/package.json | 2 +- .../CHANGELOG.md | 7 + .../package.json | 2 +- .../CHANGELOG.md | 9 + .../package.json | 2 +- .../CHANGELOG.md | 9 + .../package.json | 2 +- .../catalog-backend-module-gcp/CHANGELOG.md | 7 + .../catalog-backend-module-gcp/package.json | 2 +- .../CHANGELOG.md | 8 + .../package.json | 2 +- .../catalog-backend-module-gitea/CHANGELOG.md | 8 + .../catalog-backend-module-gitea/package.json | 2 +- .../CHANGELOG.md | 8 + .../package.json | 2 +- .../CHANGELOG.md | 10 + .../package.json | 2 +- .../CHANGELOG.md | 8 + .../package.json | 2 +- .../CHANGELOG.md | 9 + .../package.json | 2 +- .../CHANGELOG.md | 9 + .../package.json | 2 +- .../catalog-backend-module-ldap/CHANGELOG.md | 7 + .../catalog-backend-module-ldap/package.json | 2 +- .../CHANGELOG.md | 7 + .../package.json | 2 +- .../CHANGELOG.md | 8 + .../package.json | 2 +- .../CHANGELOG.md | 8 + .../package.json | 2 +- .../CHANGELOG.md | 7 + .../package.json | 2 +- .../CHANGELOG.md | 8 + .../package.json | 2 +- plugins/catalog-backend/CHANGELOG.md | 10 + plugins/catalog-backend/package.json | 2 +- plugins/catalog-graph/CHANGELOG.md | 10 + plugins/catalog-graph/package.json | 2 +- plugins/catalog-import/CHANGELOG.md | 11 + plugins/catalog-import/package.json | 2 +- plugins/catalog-node/CHANGELOG.md | 28 + plugins/catalog-node/package.json | 2 +- plugins/catalog-react/CHANGELOG.md | 30 + plugins/catalog-react/package.json | 2 +- plugins/catalog/CHANGELOG.md | 11 + plugins/catalog/package.json | 2 +- plugins/devtools-backend/CHANGELOG.md | 8 + plugins/devtools-backend/package.json | 2 +- plugins/home/CHANGELOG.md | 12 + plugins/home/package.json | 2 +- plugins/kubernetes-backend/CHANGELOG.md | 11 + plugins/kubernetes-backend/package.json | 2 +- plugins/kubernetes-cluster/CHANGELOG.md | 8 + plugins/kubernetes-cluster/package.json | 2 +- plugins/kubernetes/CHANGELOG.md | 9 + plugins/kubernetes/package.json | 2 +- plugins/mcp-actions-backend/CHANGELOG.md | 10 + plugins/mcp-actions-backend/package.json | 2 +- .../CHANGELOG.md | 10 + .../package.json | 2 +- .../CHANGELOG.md | 8 + .../package.json | 2 +- plugins/notifications-backend/CHANGELOG.md | 9 + plugins/notifications-backend/package.json | 2 +- plugins/notifications-node/CHANGELOG.md | 7 + plugins/notifications-node/package.json | 2 +- plugins/org-react/CHANGELOG.md | 9 + plugins/org-react/package.json | 2 +- plugins/org/CHANGELOG.md | 9 + plugins/org/package.json | 2 +- .../CHANGELOG.md | 8 + .../package.json | 2 +- plugins/scaffolder-backend/CHANGELOG.md | 12 + plugins/scaffolder-backend/package.json | 2 +- plugins/scaffolder-react/CHANGELOG.md | 10 + plugins/scaffolder-react/package.json | 2 +- plugins/scaffolder/CHANGELOG.md | 13 + plugins/scaffolder/package.json | 2 +- .../CHANGELOG.md | 8 + .../package.json | 2 +- .../CHANGELOG.md | 8 + .../package.json | 2 +- plugins/search/CHANGELOG.md | 9 + plugins/search/package.json | 2 +- .../techdocs-addons-test-utils/CHANGELOG.md | 9 + .../techdocs-addons-test-utils/package.json | 2 +- plugins/techdocs-backend/CHANGELOG.md | 11 + plugins/techdocs-backend/package.json | 2 +- plugins/techdocs/CHANGELOG.md | 12 + plugins/techdocs/package.json | 2 +- plugins/user-settings/CHANGELOG.md | 10 + plugins/user-settings/package.json | 2 +- 140 files changed, 1486 insertions(+), 71 deletions(-) create mode 100644 .changeset/create-app-1757429965.md create mode 100644 docs/releases/v1.43.0-next.2-changelog.md create mode 100644 plugins/auth/CHANGELOG.md diff --git a/.changeset/create-app-1757429965.md b/.changeset/create-app-1757429965.md new file mode 100644 index 0000000000..b50d431d4b --- /dev/null +++ b/.changeset/create-app-1757429965.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': patch +--- + +Bumped create-app version. diff --git a/.changeset/pre.json b/.changeset/pre.json index dcb0a48960..b1a840849d 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -201,23 +201,42 @@ "@backstage/plugin-techdocs-react": "1.3.2", "@backstage/plugin-user-settings": "0.8.25", "@backstage/plugin-user-settings-backend": "0.3.5", - "@backstage/plugin-user-settings-common": "0.0.1" + "@backstage/plugin-user-settings-common": "0.0.1", + "@backstage/plugin-auth": "0.0.0" }, "changesets": [ + "better-eagles-tickle", + "big-cameras-turn", "brave-bugs-know", + "brave-jars-speak", + "busy-chairs-itch", "cold-donuts-train", "cool-games-rescue", + "create-app-1757429965", "curvy-sites-rhyme", "easy-wings-turn", + "eleven-doors-down", + "eleven-doors-own", "fine-hands-think", + "fix-select-aria-props", "flat-colts-know", + "funny-eagles-try", + "giant-buttons-flash", "giant-zebras-peel", + "gold-words-smoke", + "heavy-cats-unite", + "heavy-lies-listen", "icy-camels-throw", + "itchy-moons-start", + "late-swans-press", "legal-lemons-attend", "lemon-terms-cheer", "lucky-glasses-slide", "mean-sites-cheer", + "olive-moons-burn", "puny-books-fetch", + "quiet-papayas-mate", + "red-shrimps-fall", "ripe-plants-pump", "sharp-carrots-spend", "sixty-pans-prove", @@ -226,9 +245,12 @@ "slick-worms-drum", "social-beers-unite", "sweet-lemons-wonder", + "tangy-squids-film", "tricky-buses-lead", + "warm-emus-itch", "wet-kiwis-strive", "whole-dingos-lay", - "yellow-dragons-float" + "yellow-dragons-float", + "young-doodles-enter" ] } diff --git a/docs/releases/v1.43.0-next.2-changelog.md b/docs/releases/v1.43.0-next.2-changelog.md new file mode 100644 index 0000000000..ef7ddb1c1f --- /dev/null +++ b/docs/releases/v1.43.0-next.2-changelog.md @@ -0,0 +1,695 @@ +# Release v1.43.0-next.2 + +Upgrade Helper: [https://backstage.github.io/upgrade-helper/?to=1.43.0-next.2](https://backstage.github.io/upgrade-helper/?to=1.43.0-next.2) + +## @backstage/catalog-client@1.12.0-next.0 + +### Minor Changes + +- 0e9ec44: Introduced new `streamEntities` async generator method for the catalog. + + Catalog API and Catalog Service now includes a `streamEntities` method that allows for streaming entities from the catalog. + This method is designed to handle large datasets efficiently by processing entities in a stream rather than loading them + all into memory at once. This is useful when you need to fetch a large number of entities but do not want to use pagination + or fetch all entities at once. + + Example usage: + + ```ts + const pageStream = catalogClient.streamEntities({ pageSize: 100 }, { token }); + for await (const page of pageStream) { + // Handle page of entities + for (const entity of page) { + console.log(entity); + } + } + ``` + +## @backstage/plugin-auth@0.1.0-next.0 + +### Minor Changes + +- 54ddfef: Initial publish of the `auth` frontend package + +### Patch Changes + +- Updated dependencies + - @backstage/core-components@0.17.6-next.1 + +## @backstage/plugin-catalog-node@1.19.0-next.1 + +### Minor Changes + +- 0e9ec44: Introduced new `streamEntities` async generator method for the catalog. + + Catalog API and Catalog Service now includes a `streamEntities` method that allows for streaming entities from the catalog. + This method is designed to handle large datasets efficiently by processing entities in a stream rather than loading them + all into memory at once. This is useful when you need to fetch a large number of entities but do not want to use pagination + or fetch all entities at once. + + Example usage: + + ```ts + const pageStream = catalogClient.streamEntities({ pageSize: 100 }, { token }); + for await (const page of pageStream) { + // Handle page of entities + for (const entity of page) { + console.log(entity); + } + } + ``` + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + +## @backstage/plugin-catalog-react@1.21.0-next.2 + +### Minor Changes + +- 0e9ec44: Introduced new `streamEntities` async generator method for the catalog. + + Catalog API and Catalog Service now includes a `streamEntities` method that allows for streaming entities from the catalog. + This method is designed to handle large datasets efficiently by processing entities in a stream rather than loading them + all into memory at once. This is useful when you need to fetch a large number of entities but do not want to use pagination + or fetch all entities at once. + + Example usage: + + ```ts + const pageStream = catalogClient.streamEntities({ pageSize: 100 }, { token }); + for await (const page of pageStream) { + // Handle page of entities + for (const entity of page) { + console.log(entity); + } + } + ``` + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/core-components@0.17.6-next.1 + - @backstage/core-compat-api@0.5.2-next.2 + +## @backstage/backend-defaults@0.12.1-next.1 + +### Patch Changes + +- 4eda590: Fixed cache namespace and key prefix separator configuration to properly use configured values instead of hardcoded plugin ID. The cache manager now correctly combines the configured namespace with plugin IDs using the configured separator for Redis and Valkey. Memcache and memory store continue to use plugin ID as namespace. +- Updated dependencies + - @backstage/config-loader@1.10.3-next.0 + - @backstage/plugin-auth-node@0.6.7-next.1 + - @backstage/integration@1.18.0-next.0 + - @backstage/integration-aws-node@0.1.17 + +## @backstage/backend-dynamic-feature-service@0.7.4-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/config-loader@1.10.3-next.0 + - @backstage/backend-defaults@0.12.1-next.1 + - @backstage/plugin-auth-node@0.6.7-next.1 + - @backstage/plugin-catalog-backend@3.0.2-next.1 + - @backstage/plugin-app-node@0.1.37-next.1 + +## @backstage/cli@0.34.2-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/config-loader@1.10.3-next.0 + - @backstage/integration@1.18.0-next.0 + +## @backstage/config-loader@1.10.3-next.0 + +### Patch Changes + +- a73f495: Allow using `BACKSTAGE_ENV` for loading environment specific config files + +## @backstage/core-compat-api@0.5.2-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-react@1.21.0-next.2 + +## @backstage/core-components@0.17.6-next.1 + +### Patch Changes + +- 1ad3d94: Dependency graph can now be opened in full screen mode +- ae7d426: update about card links style for pretty display with other language + +## @backstage/create-app@0.7.4-next.2 + +### Patch Changes + +- Bumped create-app version. + +## @backstage/dev-utils@1.1.14-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + +## @backstage/repo-tools@0.15.2-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/config-loader@1.10.3-next.0 + +## @backstage/ui@0.7.1-next.0 + +### Patch Changes + +- 7307930: Add missing class for flex: baseline +- 89da341: Fix Select component to properly attach aria-label and aria-labelledby props to the rendered element for improved accessibility. + +## @backstage/plugin-api-docs@0.12.11-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + - @backstage/plugin-catalog@1.31.3-next.2 + - @backstage/core-compat-api@0.5.2-next.2 + +## @backstage/plugin-app-backend@0.5.6-next.1 + +### Patch Changes + +- afd368e: Internal update to not expose the old `createRouter`. +- Updated dependencies + - @backstage/config-loader@1.10.3-next.0 + - @backstage/plugin-auth-node@0.6.7-next.1 + - @backstage/plugin-app-node@0.1.37-next.1 + +## @backstage/plugin-app-node@0.1.37-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/config-loader@1.10.3-next.0 + +## @backstage/plugin-auth-backend@0.25.4-next.1 + +### Patch Changes + +- 1d47bf3: Implementing Dynamic Client Registration with the OIDC server. You can enable this by setting `auth.experimentalDynamicClientRegistration.enabled` in `app-config.yaml`. This is highly experimental, but feedback welcome. +- 54ddfef: Updating plugin metadata +- Updated dependencies + - @backstage/plugin-auth-node@0.6.7-next.1 + - @backstage/plugin-catalog-node@1.19.0-next.1 + +## @backstage/plugin-auth-node@0.6.7-next.1 + +### Patch Changes + +- 54ddfef: Updating plugin metadata +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + +## @backstage/plugin-auth-react@0.1.19-next.1 + +### Patch Changes + +- 54ddfef: Updating plugin metadata +- Updated dependencies + - @backstage/core-components@0.17.6-next.1 + +## @backstage/plugin-catalog@1.31.3-next.2 + +### Patch Changes + +- 85c5e04: Fix incorrect `defaultTarget` on `createComponentRouteRef`. +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + - @backstage/core-compat-api@0.5.2-next.2 + +## @backstage/plugin-catalog-backend@3.0.2-next.1 + +### Patch Changes + +- 2204f5b: Prevent deadlock in catalog deferred stitching +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration@1.18.0-next.0 + +## @backstage/plugin-catalog-backend-module-aws@0.4.15-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/backend-defaults@0.12.1-next.1 + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration@1.18.0-next.0 + - @backstage/integration-aws-node@0.1.17 + +## @backstage/plugin-catalog-backend-module-azure@0.3.9-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration@1.18.0-next.0 + +## @backstage/plugin-catalog-backend-module-backstage-openapi@0.5.6-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + +## @backstage/plugin-catalog-backend-module-bitbucket-cloud@0.5.3-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration@1.18.0-next.0 + +## @backstage/plugin-catalog-backend-module-bitbucket-server@0.5.3-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration@1.18.0-next.0 + +## @backstage/plugin-catalog-backend-module-gcp@0.3.12-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + +## @backstage/plugin-catalog-backend-module-gerrit@0.3.6-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration@1.18.0-next.0 + +## @backstage/plugin-catalog-backend-module-gitea@0.1.4-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration@1.18.0-next.0 + +## @backstage/plugin-catalog-backend-module-github@0.11.0-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-backend@3.0.2-next.1 + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration@1.18.0-next.0 + +## @backstage/plugin-catalog-backend-module-github-org@0.3.14-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/plugin-catalog-backend-module-github@0.11.0-next.1 + +## @backstage/plugin-catalog-backend-module-gitlab@0.7.3-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/backend-defaults@0.12.1-next.1 + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration@1.18.0-next.0 + +## @backstage/plugin-catalog-backend-module-gitlab-org@0.2.13-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/plugin-catalog-backend-module-gitlab@0.7.3-next.1 + +## @backstage/plugin-catalog-backend-module-incremental-ingestion@0.7.4-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/backend-defaults@0.12.1-next.1 + - @backstage/plugin-catalog-backend@3.0.2-next.1 + - @backstage/plugin-catalog-node@1.19.0-next.1 + +## @backstage/plugin-catalog-backend-module-ldap@0.11.9-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + +## @backstage/plugin-catalog-backend-module-msgraph@0.8.0-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + +## @backstage/plugin-catalog-backend-module-openapi@0.2.14-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration@1.18.0-next.0 + +## @backstage/plugin-catalog-backend-module-puppetdb@0.2.14-next.1 + +### Patch Changes + +- afd368e: **BREAKING ALPHA**: The module has been moved from the `/alpha` export to the root of the package. +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + +## @backstage/plugin-catalog-backend-module-scaffolder-entity-model@0.2.12-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + +## @backstage/plugin-catalog-backend-module-unprocessed@0.6.4-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-auth-node@0.6.7-next.1 + - @backstage/plugin-catalog-node@1.19.0-next.1 + +## @backstage/plugin-catalog-graph@0.4.23-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + - @backstage/core-compat-api@0.5.2-next.2 + +## @backstage/plugin-catalog-import@0.13.5-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + - @backstage/integration@1.18.0-next.0 + - @backstage/core-compat-api@0.5.2-next.2 + +## @backstage/plugin-devtools-backend@0.5.9-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/config-loader@1.10.3-next.0 + - @backstage/backend-defaults@0.12.1-next.1 + +## @backstage/plugin-home@0.8.12-next.2 + +### Patch Changes + +- 929c55a: Fixed race condition in CustomHomepageGrid by waiting for storage to load before rendering custom layout to prevent + rendering of the default content. +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + - @backstage/core-compat-api@0.5.2-next.2 + +## @backstage/plugin-kubernetes@0.12.11-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + - @backstage/core-compat-api@0.5.2-next.2 + +## @backstage/plugin-kubernetes-backend@0.20.2-next.2 + +### Patch Changes + +- dd7b6d2: Fix a bug where `getDefault` in the `kubernetesFetcherExtensionPoint` had the wrong `this` value +- Updated dependencies + - @backstage/plugin-auth-node@0.6.7-next.1 + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration-aws-node@0.1.17 + +## @backstage/plugin-kubernetes-cluster@0.0.29-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + +## @backstage/plugin-mcp-actions-backend@0.1.3-next.1 + +### Patch Changes + +- 1d47bf3: Proxy `/.well-known/oauth-authorization-server` to `/.well-known/openid-configuration` on `auth-backend` when `auth.experimentalDynamicClientRegistration.enabled` is enabled. +- Updated dependencies + - @backstage/backend-defaults@0.12.1-next.1 + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-node@1.19.0-next.1 + +## @backstage/plugin-notifications-backend@0.5.10-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-auth-node@0.6.7-next.1 + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/plugin-notifications-node@0.2.19-next.1 + +## @backstage/plugin-notifications-backend-module-email@0.3.13-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration-aws-node@0.1.17 + - @backstage/plugin-notifications-node@0.2.19-next.1 + +## @backstage/plugin-notifications-backend-module-slack@0.1.5-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/plugin-notifications-node@0.2.19-next.1 + +## @backstage/plugin-notifications-node@0.2.19-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + +## @backstage/plugin-org@0.6.44-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + - @backstage/core-compat-api@0.5.2-next.2 + +## @backstage/plugin-org-react@0.1.42-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + +## @backstage/plugin-scaffolder@1.34.1-next.2 + +### Patch Changes + +- 0d415ae: Render a TechDocs link on the Scaffolder Template List page when templates include either `backstage.io/techdocs-ref` or `backstage.io/techdocs-entity` annotations, using the shared `buildTechDocsURL` helper. Also adds tests to verify both annotations and optional `backstage.io/techdocs-entity-path` are respected. +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + - @backstage/plugin-scaffolder-react@1.19.1-next.2 + - @backstage/integration@1.18.0-next.0 + - @backstage/core-compat-api@0.5.2-next.2 + +## @backstage/plugin-scaffolder-backend@2.2.1-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/backend-defaults@0.12.1-next.1 + - @backstage/plugin-auth-node@0.6.7-next.1 + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration@1.18.0-next.0 + - @backstage/plugin-catalog-backend-module-scaffolder-entity-model@0.2.12-next.1 + - @backstage/plugin-scaffolder-backend-module-github@0.8.3-next.1 + +## @backstage/plugin-scaffolder-backend-module-github@0.8.3-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration@1.18.0-next.0 + +## @backstage/plugin-scaffolder-react@1.19.1-next.2 + +### Patch Changes + +- 58fc108: Fix scaffolder task log stream not having a minimum height +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + +## @backstage/plugin-search@1.4.30-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + - @backstage/core-compat-api@0.5.2-next.2 + +## @backstage/plugin-search-backend-module-catalog@0.3.8-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-node@1.19.0-next.1 + +## @backstage/plugin-search-backend-module-techdocs@0.4.6-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-node@1.19.0-next.1 + +## @backstage/plugin-techdocs@1.14.2-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-auth-react@0.1.19-next.1 + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + - @backstage/integration@1.18.0-next.0 + - @backstage/core-compat-api@0.5.2-next.2 + +## @backstage/plugin-techdocs-addons-test-utils@1.0.53-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/plugin-catalog@1.31.3-next.2 + - @backstage/plugin-techdocs@1.14.2-next.2 + +## @backstage/plugin-techdocs-backend@2.1.0-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/backend-defaults@0.12.1-next.1 + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration@1.18.0-next.0 + - @backstage/plugin-search-backend-module-techdocs@0.4.6-next.1 + +## @backstage/plugin-user-settings@0.8.26-next.2 + +### Patch Changes + +- b713b54: Tool-tip text correction for the Theme selection in settings page +- Updated dependencies + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + - @backstage/core-compat-api@0.5.2-next.2 + +## example-app@0.2.113-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/ui@0.7.1-next.0 + - @backstage/plugin-auth-react@0.1.19-next.1 + - @backstage/plugin-home@0.8.12-next.2 + - @backstage/plugin-scaffolder@1.34.1-next.2 + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + - @backstage/plugin-user-settings@0.8.26-next.2 + - @backstage/plugin-scaffolder-react@1.19.1-next.2 + - @backstage/plugin-catalog@1.31.3-next.2 + - @backstage/cli@0.34.2-next.2 + - @backstage/plugin-catalog-graph@0.4.23-next.2 + - @backstage/plugin-catalog-import@0.13.5-next.2 + - @backstage/plugin-org@0.6.44-next.2 + - @backstage/plugin-techdocs@1.14.2-next.2 + - @backstage/plugin-api-docs@0.12.11-next.2 + - @backstage/plugin-kubernetes@0.12.11-next.2 + - @backstage/plugin-kubernetes-cluster@0.0.29-next.2 + - @backstage/plugin-search@1.4.30-next.2 + - @backstage/plugin-techdocs-module-addons-contrib@1.1.28-next.0 + +## example-app-next@0.0.27-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/ui@0.7.1-next.0 + - @backstage/plugin-auth-react@0.1.19-next.1 + - @backstage/plugin-auth@0.1.0-next.0 + - @backstage/plugin-home@0.8.12-next.2 + - @backstage/plugin-scaffolder@1.34.1-next.2 + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + - @backstage/plugin-user-settings@0.8.26-next.2 + - @backstage/plugin-scaffolder-react@1.19.1-next.2 + - @backstage/plugin-catalog@1.31.3-next.2 + - @backstage/cli@0.34.2-next.2 + - @backstage/plugin-catalog-graph@0.4.23-next.2 + - @backstage/plugin-catalog-import@0.13.5-next.2 + - @backstage/plugin-org@0.6.44-next.2 + - @backstage/plugin-techdocs@1.14.2-next.2 + - @backstage/core-compat-api@0.5.2-next.2 + - @backstage/plugin-api-docs@0.12.11-next.2 + - @backstage/plugin-kubernetes@0.12.11-next.2 + - @backstage/plugin-kubernetes-cluster@0.0.29-next.2 + - @backstage/plugin-search@1.4.30-next.2 + - @backstage/plugin-techdocs-module-addons-contrib@1.1.28-next.0 diff --git a/package.json b/package.json index d4ebc22bc1..7aed18ab74 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "root", - "version": "1.43.0-next.1", + "version": "1.43.0-next.2", "backstage": { "cli": { "new": { diff --git a/packages/app-next/CHANGELOG.md b/packages/app-next/CHANGELOG.md index 5fc353c09d..a4e2386525 100644 --- a/packages/app-next/CHANGELOG.md +++ b/packages/app-next/CHANGELOG.md @@ -1,5 +1,32 @@ # example-app-next +## 0.0.27-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/ui@0.7.1-next.0 + - @backstage/plugin-auth-react@0.1.19-next.1 + - @backstage/plugin-auth@0.1.0-next.0 + - @backstage/plugin-home@0.8.12-next.2 + - @backstage/plugin-scaffolder@1.34.1-next.2 + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + - @backstage/plugin-user-settings@0.8.26-next.2 + - @backstage/plugin-scaffolder-react@1.19.1-next.2 + - @backstage/plugin-catalog@1.31.3-next.2 + - @backstage/cli@0.34.2-next.2 + - @backstage/plugin-catalog-graph@0.4.23-next.2 + - @backstage/plugin-catalog-import@0.13.5-next.2 + - @backstage/plugin-org@0.6.44-next.2 + - @backstage/plugin-techdocs@1.14.2-next.2 + - @backstage/core-compat-api@0.5.2-next.2 + - @backstage/plugin-api-docs@0.12.11-next.2 + - @backstage/plugin-kubernetes@0.12.11-next.2 + - @backstage/plugin-kubernetes-cluster@0.0.29-next.2 + - @backstage/plugin-search@1.4.30-next.2 + - @backstage/plugin-techdocs-module-addons-contrib@1.1.28-next.0 + ## 0.0.27-next.1 ### Patch Changes diff --git a/packages/app-next/package.json b/packages/app-next/package.json index 8e54830cac..49ce264f06 100644 --- a/packages/app-next/package.json +++ b/packages/app-next/package.json @@ -1,6 +1,6 @@ { "name": "example-app-next", - "version": "0.0.27-next.1", + "version": "0.0.27-next.2", "backstage": { "role": "frontend" }, diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index 93dd1db16a..f3b911accf 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,30 @@ # example-app +## 0.2.113-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/ui@0.7.1-next.0 + - @backstage/plugin-auth-react@0.1.19-next.1 + - @backstage/plugin-home@0.8.12-next.2 + - @backstage/plugin-scaffolder@1.34.1-next.2 + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + - @backstage/plugin-user-settings@0.8.26-next.2 + - @backstage/plugin-scaffolder-react@1.19.1-next.2 + - @backstage/plugin-catalog@1.31.3-next.2 + - @backstage/cli@0.34.2-next.2 + - @backstage/plugin-catalog-graph@0.4.23-next.2 + - @backstage/plugin-catalog-import@0.13.5-next.2 + - @backstage/plugin-org@0.6.44-next.2 + - @backstage/plugin-techdocs@1.14.2-next.2 + - @backstage/plugin-api-docs@0.12.11-next.2 + - @backstage/plugin-kubernetes@0.12.11-next.2 + - @backstage/plugin-kubernetes-cluster@0.0.29-next.2 + - @backstage/plugin-search@1.4.30-next.2 + - @backstage/plugin-techdocs-module-addons-contrib@1.1.28-next.0 + ## 0.2.113-next.1 ### Patch Changes diff --git a/packages/app/package.json b/packages/app/package.json index 580c401a1b..5e38aa6256 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "example-app", - "version": "0.2.113-next.1", + "version": "0.2.113-next.2", "backstage": { "role": "frontend" }, diff --git a/packages/backend-defaults/CHANGELOG.md b/packages/backend-defaults/CHANGELOG.md index 0375e54232..b4c7393455 100644 --- a/packages/backend-defaults/CHANGELOG.md +++ b/packages/backend-defaults/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/backend-defaults +## 0.12.1-next.1 + +### Patch Changes + +- 4eda590: Fixed cache namespace and key prefix separator configuration to properly use configured values instead of hardcoded plugin ID. The cache manager now correctly combines the configured namespace with plugin IDs using the configured separator for Redis and Valkey. Memcache and memory store continue to use plugin ID as namespace. +- Updated dependencies + - @backstage/config-loader@1.10.3-next.0 + - @backstage/plugin-auth-node@0.6.7-next.1 + - @backstage/integration@1.18.0-next.0 + - @backstage/integration-aws-node@0.1.17 + ## 0.12.1-next.0 ### Patch Changes diff --git a/packages/backend-defaults/package.json b/packages/backend-defaults/package.json index 09bb4dc85d..1b1f0101c7 100644 --- a/packages/backend-defaults/package.json +++ b/packages/backend-defaults/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/backend-defaults", - "version": "0.12.1-next.0", + "version": "0.12.1-next.1", "description": "Backend defaults used by Backstage backend apps", "backstage": { "role": "node-library" diff --git a/packages/backend-dynamic-feature-service/CHANGELOG.md b/packages/backend-dynamic-feature-service/CHANGELOG.md index 6bd6a7a781..d2e46404ef 100644 --- a/packages/backend-dynamic-feature-service/CHANGELOG.md +++ b/packages/backend-dynamic-feature-service/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/backend-dynamic-feature-service +## 0.7.4-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/config-loader@1.10.3-next.0 + - @backstage/backend-defaults@0.12.1-next.1 + - @backstage/plugin-auth-node@0.6.7-next.1 + - @backstage/plugin-catalog-backend@3.0.2-next.1 + - @backstage/plugin-app-node@0.1.37-next.1 + ## 0.7.4-next.0 ### Patch Changes diff --git a/packages/backend-dynamic-feature-service/package.json b/packages/backend-dynamic-feature-service/package.json index ff8fc29639..6a7cc2315f 100644 --- a/packages/backend-dynamic-feature-service/package.json +++ b/packages/backend-dynamic-feature-service/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/backend-dynamic-feature-service", - "version": "0.7.4-next.0", + "version": "0.7.4-next.1", "description": "Backstage dynamic feature service", "backstage": { "role": "node-library" diff --git a/packages/catalog-client/CHANGELOG.md b/packages/catalog-client/CHANGELOG.md index 52e2ed04a4..254f37bd69 100644 --- a/packages/catalog-client/CHANGELOG.md +++ b/packages/catalog-client/CHANGELOG.md @@ -1,5 +1,28 @@ # @backstage/catalog-client +## 1.12.0-next.0 + +### Minor Changes + +- 0e9ec44: Introduced new `streamEntities` async generator method for the catalog. + + Catalog API and Catalog Service now includes a `streamEntities` method that allows for streaming entities from the catalog. + This method is designed to handle large datasets efficiently by processing entities in a stream rather than loading them + all into memory at once. This is useful when you need to fetch a large number of entities but do not want to use pagination + or fetch all entities at once. + + Example usage: + + ```ts + const pageStream = catalogClient.streamEntities({ pageSize: 100 }, { token }); + for await (const page of pageStream) { + // Handle page of entities + for (const entity of page) { + console.log(entity); + } + } + ``` + ## 1.11.0 ### Minor Changes diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json index a87ed29302..3b9d1c603a 100644 --- a/packages/catalog-client/package.json +++ b/packages/catalog-client/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/catalog-client", - "version": "1.11.0", + "version": "1.12.0-next.0", "description": "An isomorphic client for the catalog backend", "backstage": { "role": "common-library" diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 87be49efce..bdaf3a05f6 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/cli +## 0.34.2-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/config-loader@1.10.3-next.0 + - @backstage/integration@1.18.0-next.0 + ## 0.34.2-next.1 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 51090239d8..8956b50713 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/cli", - "version": "0.34.2-next.1", + "version": "0.34.2-next.2", "description": "CLI for developing Backstage plugins and apps", "backstage": { "role": "cli" diff --git a/packages/config-loader/CHANGELOG.md b/packages/config-loader/CHANGELOG.md index 6fe939f3e6..a7b38c24b9 100644 --- a/packages/config-loader/CHANGELOG.md +++ b/packages/config-loader/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/config-loader +## 1.10.3-next.0 + +### Patch Changes + +- a73f495: Allow using `BACKSTAGE_ENV` for loading environment specific config files + ## 1.10.2 ### Patch Changes diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index f347af8034..b0e7af3b5e 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/config-loader", - "version": "1.10.2", + "version": "1.10.3-next.0", "description": "Config loading functionality used by Backstage backend, and CLI", "backstage": { "role": "node-library" diff --git a/packages/core-compat-api/CHANGELOG.md b/packages/core-compat-api/CHANGELOG.md index a966469313..ea40b301bb 100644 --- a/packages/core-compat-api/CHANGELOG.md +++ b/packages/core-compat-api/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/core-compat-api +## 0.5.2-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-react@1.21.0-next.2 + ## 0.5.2-next.1 ### Patch Changes diff --git a/packages/core-compat-api/package.json b/packages/core-compat-api/package.json index 1430973106..db51762180 100644 --- a/packages/core-compat-api/package.json +++ b/packages/core-compat-api/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/core-compat-api", - "version": "0.5.2-next.1", + "version": "0.5.2-next.2", "backstage": { "role": "web-library" }, diff --git a/packages/core-components/CHANGELOG.md b/packages/core-components/CHANGELOG.md index 515e6eac5b..10e04ae0c6 100644 --- a/packages/core-components/CHANGELOG.md +++ b/packages/core-components/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/core-components +## 0.17.6-next.1 + +### Patch Changes + +- 1ad3d94: Dependency graph can now be opened in full screen mode +- ae7d426: update about card links style for pretty display with other language + ## 0.17.6-next.0 ### Patch Changes diff --git a/packages/core-components/package.json b/packages/core-components/package.json index d93ff26333..5cd6b9f270 100644 --- a/packages/core-components/package.json +++ b/packages/core-components/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/core-components", - "version": "0.17.6-next.0", + "version": "0.17.6-next.1", "description": "Core components used by Backstage plugins and apps", "backstage": { "role": "web-library" diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index 261e595b30..0e622c2490 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/create-app +## 0.7.4-next.2 + +### Patch Changes + +- Bumped create-app version. + ## 0.7.4-next.1 ### Patch Changes diff --git a/packages/create-app/package.json b/packages/create-app/package.json index 7f2f9e4cd5..59f8498c54 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/create-app", - "version": "0.7.4-next.1", + "version": "0.7.4-next.2", "description": "A CLI that helps you create your own Backstage app", "backstage": { "role": "cli" diff --git a/packages/dev-utils/CHANGELOG.md b/packages/dev-utils/CHANGELOG.md index 503ae406ae..45aaf0a814 100644 --- a/packages/dev-utils/CHANGELOG.md +++ b/packages/dev-utils/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/dev-utils +## 1.1.14-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + ## 1.1.14-next.1 ### Patch Changes diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index 55b4c3fdfe..fd5881af44 100644 --- a/packages/dev-utils/package.json +++ b/packages/dev-utils/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/dev-utils", - "version": "1.1.14-next.1", + "version": "1.1.14-next.2", "description": "Utilities for developing Backstage plugins.", "backstage": { "role": "web-library" diff --git a/packages/repo-tools/CHANGELOG.md b/packages/repo-tools/CHANGELOG.md index 569444b4b8..a5a9bb0505 100644 --- a/packages/repo-tools/CHANGELOG.md +++ b/packages/repo-tools/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/repo-tools +## 0.15.2-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/config-loader@1.10.3-next.0 + ## 0.15.2-next.0 ### Patch Changes diff --git a/packages/repo-tools/package.json b/packages/repo-tools/package.json index 9b7c875279..d93785f25e 100644 --- a/packages/repo-tools/package.json +++ b/packages/repo-tools/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/repo-tools", - "version": "0.15.2-next.0", + "version": "0.15.2-next.1", "description": "CLI for Backstage repo tooling ", "backstage": { "role": "cli" diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index a88c4593e1..d251516dcd 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/ui +## 0.7.1-next.0 + +### Patch Changes + +- 7307930: Add missing class for flex: baseline +- 89da341: Fix Select component to properly attach aria-label and aria-labelledby props to the rendered element for improved accessibility. + ## 0.7.0 ### Minor Changes diff --git a/packages/ui/package.json b/packages/ui/package.json index 2ac399fcee..36b51f0bc8 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/ui", - "version": "0.7.0", + "version": "0.7.1-next.0", "backstage": { "role": "web-library" }, diff --git a/plugins/api-docs/CHANGELOG.md b/plugins/api-docs/CHANGELOG.md index 25671d8050..c57a56754c 100644 --- a/plugins/api-docs/CHANGELOG.md +++ b/plugins/api-docs/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-api-docs +## 0.12.11-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + - @backstage/plugin-catalog@1.31.3-next.2 + - @backstage/core-compat-api@0.5.2-next.2 + ## 0.12.11-next.1 ### Patch Changes diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index 4fcae79fba..17cf78a69a 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-api-docs", - "version": "0.12.11-next.1", + "version": "0.12.11-next.2", "description": "A Backstage plugin that helps represent API entities in the frontend", "backstage": { "role": "frontend-plugin", diff --git a/plugins/app-backend/CHANGELOG.md b/plugins/app-backend/CHANGELOG.md index bd097b0005..ffe2bd1b71 100644 --- a/plugins/app-backend/CHANGELOG.md +++ b/plugins/app-backend/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-app-backend +## 0.5.6-next.1 + +### Patch Changes + +- afd368e: Internal update to not expose the old `createRouter`. +- Updated dependencies + - @backstage/config-loader@1.10.3-next.0 + - @backstage/plugin-auth-node@0.6.7-next.1 + - @backstage/plugin-app-node@0.1.37-next.1 + ## 0.5.6-next.0 ### Patch Changes diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index b81e709926..4640a6459b 100644 --- a/plugins/app-backend/package.json +++ b/plugins/app-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-app-backend", - "version": "0.5.6-next.0", + "version": "0.5.6-next.1", "description": "A Backstage backend plugin that serves the Backstage frontend app", "backstage": { "role": "backend-plugin", diff --git a/plugins/app-node/CHANGELOG.md b/plugins/app-node/CHANGELOG.md index 2063c2db48..14b3a215ee 100644 --- a/plugins/app-node/CHANGELOG.md +++ b/plugins/app-node/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/plugin-app-node +## 0.1.37-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/config-loader@1.10.3-next.0 + ## 0.1.37-next.0 ### Patch Changes diff --git a/plugins/app-node/package.json b/plugins/app-node/package.json index 07340379e9..f28b28f461 100644 --- a/plugins/app-node/package.json +++ b/plugins/app-node/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-app-node", - "version": "0.1.37-next.0", + "version": "0.1.37-next.1", "description": "Node.js library for the app plugin", "backstage": { "role": "node-library", diff --git a/plugins/auth-backend/CHANGELOG.md b/plugins/auth-backend/CHANGELOG.md index 54dee8262d..7aa33c315a 100644 --- a/plugins/auth-backend/CHANGELOG.md +++ b/plugins/auth-backend/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-auth-backend +## 0.25.4-next.1 + +### Patch Changes + +- 1d47bf3: Implementing Dynamic Client Registration with the OIDC server. You can enable this by setting `auth.experimentalDynamicClientRegistration.enabled` in `app-config.yaml`. This is highly experimental, but feedback welcome. +- 54ddfef: Updating plugin metadata +- Updated dependencies + - @backstage/plugin-auth-node@0.6.7-next.1 + - @backstage/plugin-catalog-node@1.19.0-next.1 + ## 0.25.4-next.0 ### Patch Changes diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index bf52739ea4..e5f048ccae 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-auth-backend", - "version": "0.25.4-next.0", + "version": "0.25.4-next.1", "description": "A Backstage backend plugin that handles authentication", "backstage": { "role": "backend-plugin", diff --git a/plugins/auth-node/CHANGELOG.md b/plugins/auth-node/CHANGELOG.md index 48554f6932..3c2ceea7c0 100644 --- a/plugins/auth-node/CHANGELOG.md +++ b/plugins/auth-node/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-auth-node +## 0.6.7-next.1 + +### Patch Changes + +- 54ddfef: Updating plugin metadata +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + ## 0.6.7-next.0 ### Patch Changes diff --git a/plugins/auth-node/package.json b/plugins/auth-node/package.json index a846a868eb..64c12ae977 100644 --- a/plugins/auth-node/package.json +++ b/plugins/auth-node/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-auth-node", - "version": "0.6.7-next.0", + "version": "0.6.7-next.1", "backstage": { "role": "node-library", "pluginId": "auth", diff --git a/plugins/auth-react/CHANGELOG.md b/plugins/auth-react/CHANGELOG.md index 1dc720ab67..9b314d3e4f 100644 --- a/plugins/auth-react/CHANGELOG.md +++ b/plugins/auth-react/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-auth-react +## 0.1.19-next.1 + +### Patch Changes + +- 54ddfef: Updating plugin metadata +- Updated dependencies + - @backstage/core-components@0.17.6-next.1 + ## 0.1.19-next.0 ### Patch Changes diff --git a/plugins/auth-react/package.json b/plugins/auth-react/package.json index 2c6b5d6ff2..2bfee0dd44 100644 --- a/plugins/auth-react/package.json +++ b/plugins/auth-react/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-auth-react", - "version": "0.1.19-next.0", + "version": "0.1.19-next.1", "description": "Web library for the auth plugin", "backstage": { "role": "web-library", diff --git a/plugins/auth/CHANGELOG.md b/plugins/auth/CHANGELOG.md new file mode 100644 index 0000000000..d28fef1c9e --- /dev/null +++ b/plugins/auth/CHANGELOG.md @@ -0,0 +1,12 @@ +# @backstage/plugin-auth + +## 0.1.0-next.0 + +### Minor Changes + +- 54ddfef: Initial publish of the `auth` frontend package + +### Patch Changes + +- Updated dependencies + - @backstage/core-components@0.17.6-next.1 diff --git a/plugins/auth/package.json b/plugins/auth/package.json index 54e87cb7b1..6c8184db35 100644 --- a/plugins/auth/package.json +++ b/plugins/auth/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-auth", - "version": "0.0.0", + "version": "0.1.0-next.0", "backstage": { "role": "frontend-plugin", "pluginId": "auth", diff --git a/plugins/catalog-backend-module-aws/CHANGELOG.md b/plugins/catalog-backend-module-aws/CHANGELOG.md index 3ba5538fc5..7ceff47a80 100644 --- a/plugins/catalog-backend-module-aws/CHANGELOG.md +++ b/plugins/catalog-backend-module-aws/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-catalog-backend-module-aws +## 0.4.15-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/backend-defaults@0.12.1-next.1 + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration@1.18.0-next.0 + - @backstage/integration-aws-node@0.1.17 + ## 0.4.15-next.0 ### Patch Changes diff --git a/plugins/catalog-backend-module-aws/package.json b/plugins/catalog-backend-module-aws/package.json index 9f98b788a7..4dfe5b990d 100644 --- a/plugins/catalog-backend-module-aws/package.json +++ b/plugins/catalog-backend-module-aws/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend-module-aws", - "version": "0.4.15-next.0", + "version": "0.4.15-next.1", "description": "A Backstage catalog backend module that helps integrate towards AWS", "backstage": { "role": "backend-plugin-module", diff --git a/plugins/catalog-backend-module-azure/CHANGELOG.md b/plugins/catalog-backend-module-azure/CHANGELOG.md index 31364b56b3..0962c2aa28 100644 --- a/plugins/catalog-backend-module-azure/CHANGELOG.md +++ b/plugins/catalog-backend-module-azure/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-catalog-backend-module-azure +## 0.3.9-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration@1.18.0-next.0 + ## 0.3.9-next.0 ### Patch Changes diff --git a/plugins/catalog-backend-module-azure/package.json b/plugins/catalog-backend-module-azure/package.json index 51956ca1a5..dad34fec2a 100644 --- a/plugins/catalog-backend-module-azure/package.json +++ b/plugins/catalog-backend-module-azure/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend-module-azure", - "version": "0.3.9-next.0", + "version": "0.3.9-next.1", "description": "A Backstage catalog backend module that helps integrate towards Azure", "backstage": { "role": "backend-plugin-module", diff --git a/plugins/catalog-backend-module-backstage-openapi/CHANGELOG.md b/plugins/catalog-backend-module-backstage-openapi/CHANGELOG.md index 7f31323ddd..d40c4e03e4 100644 --- a/plugins/catalog-backend-module-backstage-openapi/CHANGELOG.md +++ b/plugins/catalog-backend-module-backstage-openapi/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/plugin-catalog-backend-module-backstage-openapi +## 0.5.6-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + ## 0.5.6-next.0 ### Patch Changes diff --git a/plugins/catalog-backend-module-backstage-openapi/package.json b/plugins/catalog-backend-module-backstage-openapi/package.json index db55d563de..f004e1bb16 100644 --- a/plugins/catalog-backend-module-backstage-openapi/package.json +++ b/plugins/catalog-backend-module-backstage-openapi/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend-module-backstage-openapi", - "version": "0.5.6-next.0", + "version": "0.5.6-next.1", "backstage": { "role": "backend-plugin-module", "pluginId": "catalog", diff --git a/plugins/catalog-backend-module-bitbucket-cloud/CHANGELOG.md b/plugins/catalog-backend-module-bitbucket-cloud/CHANGELOG.md index edb0a14cf3..c462210097 100644 --- a/plugins/catalog-backend-module-bitbucket-cloud/CHANGELOG.md +++ b/plugins/catalog-backend-module-bitbucket-cloud/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-catalog-backend-module-bitbucket-cloud +## 0.5.3-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration@1.18.0-next.0 + ## 0.5.3-next.0 ### Patch Changes diff --git a/plugins/catalog-backend-module-bitbucket-cloud/package.json b/plugins/catalog-backend-module-bitbucket-cloud/package.json index 56ab813052..97eea0897f 100644 --- a/plugins/catalog-backend-module-bitbucket-cloud/package.json +++ b/plugins/catalog-backend-module-bitbucket-cloud/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend-module-bitbucket-cloud", - "version": "0.5.3-next.0", + "version": "0.5.3-next.1", "description": "A Backstage catalog backend module that helps integrate towards Bitbucket Cloud", "backstage": { "role": "backend-plugin-module", diff --git a/plugins/catalog-backend-module-bitbucket-server/CHANGELOG.md b/plugins/catalog-backend-module-bitbucket-server/CHANGELOG.md index 42964bad70..66965f3009 100644 --- a/plugins/catalog-backend-module-bitbucket-server/CHANGELOG.md +++ b/plugins/catalog-backend-module-bitbucket-server/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-catalog-backend-module-bitbucket-server +## 0.5.3-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration@1.18.0-next.0 + ## 0.5.3-next.0 ### Patch Changes diff --git a/plugins/catalog-backend-module-bitbucket-server/package.json b/plugins/catalog-backend-module-bitbucket-server/package.json index a3cf31ee9a..98d28131a9 100644 --- a/plugins/catalog-backend-module-bitbucket-server/package.json +++ b/plugins/catalog-backend-module-bitbucket-server/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend-module-bitbucket-server", - "version": "0.5.3-next.0", + "version": "0.5.3-next.1", "backstage": { "role": "backend-plugin-module", "pluginId": "catalog", diff --git a/plugins/catalog-backend-module-gcp/CHANGELOG.md b/plugins/catalog-backend-module-gcp/CHANGELOG.md index 9cf3bcb98f..fa30682d37 100644 --- a/plugins/catalog-backend-module-gcp/CHANGELOG.md +++ b/plugins/catalog-backend-module-gcp/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/plugin-catalog-backend-module-gcp +## 0.3.12-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + ## 0.3.12-next.0 ### Patch Changes diff --git a/plugins/catalog-backend-module-gcp/package.json b/plugins/catalog-backend-module-gcp/package.json index ac095bfc4a..5459221795 100644 --- a/plugins/catalog-backend-module-gcp/package.json +++ b/plugins/catalog-backend-module-gcp/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend-module-gcp", - "version": "0.3.12-next.0", + "version": "0.3.12-next.1", "description": "A Backstage catalog backend module that helps integrate towards GCP", "backstage": { "role": "backend-plugin-module", diff --git a/plugins/catalog-backend-module-gerrit/CHANGELOG.md b/plugins/catalog-backend-module-gerrit/CHANGELOG.md index eaf94c852a..ea0a8a0bbb 100644 --- a/plugins/catalog-backend-module-gerrit/CHANGELOG.md +++ b/plugins/catalog-backend-module-gerrit/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-catalog-backend-module-gerrit +## 0.3.6-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration@1.18.0-next.0 + ## 0.3.6-next.0 ### Patch Changes diff --git a/plugins/catalog-backend-module-gerrit/package.json b/plugins/catalog-backend-module-gerrit/package.json index 62f17f1b26..9dac6c9526 100644 --- a/plugins/catalog-backend-module-gerrit/package.json +++ b/plugins/catalog-backend-module-gerrit/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend-module-gerrit", - "version": "0.3.6-next.0", + "version": "0.3.6-next.1", "backstage": { "role": "backend-plugin-module", "pluginId": "catalog", diff --git a/plugins/catalog-backend-module-gitea/CHANGELOG.md b/plugins/catalog-backend-module-gitea/CHANGELOG.md index 036dd6b477..f8b03e496c 100644 --- a/plugins/catalog-backend-module-gitea/CHANGELOG.md +++ b/plugins/catalog-backend-module-gitea/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-catalog-backend-module-gitea +## 0.1.4-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration@1.18.0-next.0 + ## 0.1.4-next.0 ### Patch Changes diff --git a/plugins/catalog-backend-module-gitea/package.json b/plugins/catalog-backend-module-gitea/package.json index 3aaf419c82..521179be95 100644 --- a/plugins/catalog-backend-module-gitea/package.json +++ b/plugins/catalog-backend-module-gitea/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend-module-gitea", - "version": "0.1.4-next.0", + "version": "0.1.4-next.1", "license": "Apache-2.0", "description": "The gitea backend module for the catalog plugin.", "main": "src/index.ts", diff --git a/plugins/catalog-backend-module-github-org/CHANGELOG.md b/plugins/catalog-backend-module-github-org/CHANGELOG.md index d6110b1f5a..9007c5ab97 100644 --- a/plugins/catalog-backend-module-github-org/CHANGELOG.md +++ b/plugins/catalog-backend-module-github-org/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-catalog-backend-module-github-org +## 0.3.14-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/plugin-catalog-backend-module-github@0.11.0-next.1 + ## 0.3.14-next.0 ### Patch Changes diff --git a/plugins/catalog-backend-module-github-org/package.json b/plugins/catalog-backend-module-github-org/package.json index 04029f57b9..ff2b78abc1 100644 --- a/plugins/catalog-backend-module-github-org/package.json +++ b/plugins/catalog-backend-module-github-org/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend-module-github-org", - "version": "0.3.14-next.0", + "version": "0.3.14-next.1", "description": "The github-org backend module for the catalog plugin.", "backstage": { "role": "backend-plugin-module", diff --git a/plugins/catalog-backend-module-github/CHANGELOG.md b/plugins/catalog-backend-module-github/CHANGELOG.md index b412ef8780..444d602783 100644 --- a/plugins/catalog-backend-module-github/CHANGELOG.md +++ b/plugins/catalog-backend-module-github/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-catalog-backend-module-github +## 0.11.0-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-backend@3.0.2-next.1 + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration@1.18.0-next.0 + ## 0.11.0-next.0 ### Minor Changes diff --git a/plugins/catalog-backend-module-github/package.json b/plugins/catalog-backend-module-github/package.json index a61deeb214..42c8446a75 100644 --- a/plugins/catalog-backend-module-github/package.json +++ b/plugins/catalog-backend-module-github/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend-module-github", - "version": "0.11.0-next.0", + "version": "0.11.0-next.1", "description": "A Backstage catalog backend module that helps integrate towards GitHub", "backstage": { "role": "backend-plugin-module", diff --git a/plugins/catalog-backend-module-gitlab-org/CHANGELOG.md b/plugins/catalog-backend-module-gitlab-org/CHANGELOG.md index afd3317620..88d11fb806 100644 --- a/plugins/catalog-backend-module-gitlab-org/CHANGELOG.md +++ b/plugins/catalog-backend-module-gitlab-org/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-catalog-backend-module-gitlab-org +## 0.2.13-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/plugin-catalog-backend-module-gitlab@0.7.3-next.1 + ## 0.2.13-next.0 ### Patch Changes diff --git a/plugins/catalog-backend-module-gitlab-org/package.json b/plugins/catalog-backend-module-gitlab-org/package.json index 05b9e75a0c..98d45ebe61 100644 --- a/plugins/catalog-backend-module-gitlab-org/package.json +++ b/plugins/catalog-backend-module-gitlab-org/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend-module-gitlab-org", - "version": "0.2.13-next.0", + "version": "0.2.13-next.1", "description": "The gitlab-org backend module for the catalog plugin.", "backstage": { "role": "backend-plugin-module", diff --git a/plugins/catalog-backend-module-gitlab/CHANGELOG.md b/plugins/catalog-backend-module-gitlab/CHANGELOG.md index 958c3b364a..adbc95ee65 100644 --- a/plugins/catalog-backend-module-gitlab/CHANGELOG.md +++ b/plugins/catalog-backend-module-gitlab/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-catalog-backend-module-gitlab +## 0.7.3-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/backend-defaults@0.12.1-next.1 + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration@1.18.0-next.0 + ## 0.7.3-next.0 ### Patch Changes diff --git a/plugins/catalog-backend-module-gitlab/package.json b/plugins/catalog-backend-module-gitlab/package.json index 7d8fe59327..14d62a2ad8 100644 --- a/plugins/catalog-backend-module-gitlab/package.json +++ b/plugins/catalog-backend-module-gitlab/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend-module-gitlab", - "version": "0.7.3-next.0", + "version": "0.7.3-next.1", "description": "A Backstage catalog backend module that helps integrate towards GitLab", "backstage": { "role": "backend-plugin-module", diff --git a/plugins/catalog-backend-module-incremental-ingestion/CHANGELOG.md b/plugins/catalog-backend-module-incremental-ingestion/CHANGELOG.md index 9139c3e3f1..1d93406c49 100644 --- a/plugins/catalog-backend-module-incremental-ingestion/CHANGELOG.md +++ b/plugins/catalog-backend-module-incremental-ingestion/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-catalog-backend-module-incremental-ingestion +## 0.7.4-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/backend-defaults@0.12.1-next.1 + - @backstage/plugin-catalog-backend@3.0.2-next.1 + - @backstage/plugin-catalog-node@1.19.0-next.1 + ## 0.7.4-next.0 ### Patch Changes diff --git a/plugins/catalog-backend-module-incremental-ingestion/package.json b/plugins/catalog-backend-module-incremental-ingestion/package.json index 5a2acc53fc..7a83dd50af 100644 --- a/plugins/catalog-backend-module-incremental-ingestion/package.json +++ b/plugins/catalog-backend-module-incremental-ingestion/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend-module-incremental-ingestion", - "version": "0.7.4-next.0", + "version": "0.7.4-next.1", "description": "An entity provider for streaming large asset sources into the catalog", "backstage": { "role": "backend-plugin-module", diff --git a/plugins/catalog-backend-module-ldap/CHANGELOG.md b/plugins/catalog-backend-module-ldap/CHANGELOG.md index 83b25b4f4c..343a87c514 100644 --- a/plugins/catalog-backend-module-ldap/CHANGELOG.md +++ b/plugins/catalog-backend-module-ldap/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/plugin-catalog-backend-module-ldap +## 0.11.9-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + ## 0.11.9-next.0 ### Patch Changes diff --git a/plugins/catalog-backend-module-ldap/package.json b/plugins/catalog-backend-module-ldap/package.json index 9e32b21bb3..02e2216d09 100644 --- a/plugins/catalog-backend-module-ldap/package.json +++ b/plugins/catalog-backend-module-ldap/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend-module-ldap", - "version": "0.11.9-next.0", + "version": "0.11.9-next.1", "description": "A Backstage catalog backend module that helps integrate towards LDAP", "backstage": { "role": "backend-plugin-module", diff --git a/plugins/catalog-backend-module-msgraph/CHANGELOG.md b/plugins/catalog-backend-module-msgraph/CHANGELOG.md index b48d58fbd2..0ecb111c7a 100644 --- a/plugins/catalog-backend-module-msgraph/CHANGELOG.md +++ b/plugins/catalog-backend-module-msgraph/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/plugin-catalog-backend-module-msgraph +## 0.8.0-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + ## 0.8.0-next.1 ### Patch Changes diff --git a/plugins/catalog-backend-module-msgraph/package.json b/plugins/catalog-backend-module-msgraph/package.json index facfd228a3..12fcfa0585 100644 --- a/plugins/catalog-backend-module-msgraph/package.json +++ b/plugins/catalog-backend-module-msgraph/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend-module-msgraph", - "version": "0.8.0-next.1", + "version": "0.8.0-next.2", "description": "A Backstage catalog backend module that helps integrate towards Microsoft Graph", "backstage": { "role": "backend-plugin-module", diff --git a/plugins/catalog-backend-module-openapi/CHANGELOG.md b/plugins/catalog-backend-module-openapi/CHANGELOG.md index 6c82466060..4a1c9eca0a 100644 --- a/plugins/catalog-backend-module-openapi/CHANGELOG.md +++ b/plugins/catalog-backend-module-openapi/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-catalog-backend-module-openapi +## 0.2.14-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration@1.18.0-next.0 + ## 0.2.14-next.0 ### Patch Changes diff --git a/plugins/catalog-backend-module-openapi/package.json b/plugins/catalog-backend-module-openapi/package.json index 3902aafcb7..fec08415cd 100644 --- a/plugins/catalog-backend-module-openapi/package.json +++ b/plugins/catalog-backend-module-openapi/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend-module-openapi", - "version": "0.2.14-next.0", + "version": "0.2.14-next.1", "description": "A Backstage catalog backend module that helps with OpenAPI specifications", "backstage": { "role": "backend-plugin-module", diff --git a/plugins/catalog-backend-module-puppetdb/CHANGELOG.md b/plugins/catalog-backend-module-puppetdb/CHANGELOG.md index 141e2d52ad..dee1e885fc 100644 --- a/plugins/catalog-backend-module-puppetdb/CHANGELOG.md +++ b/plugins/catalog-backend-module-puppetdb/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-catalog-backend-module-puppetdb +## 0.2.14-next.1 + +### Patch Changes + +- afd368e: **BREAKING ALPHA**: The module has been moved from the `/alpha` export to the root of the package. +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + ## 0.2.14-next.0 ### Patch Changes diff --git a/plugins/catalog-backend-module-puppetdb/package.json b/plugins/catalog-backend-module-puppetdb/package.json index 91e45a63f7..51c43d475f 100644 --- a/plugins/catalog-backend-module-puppetdb/package.json +++ b/plugins/catalog-backend-module-puppetdb/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend-module-puppetdb", - "version": "0.2.14-next.0", + "version": "0.2.14-next.1", "description": "A Backstage catalog backend module that helps integrate towards PuppetDB", "backstage": { "role": "backend-plugin-module", diff --git a/plugins/catalog-backend-module-scaffolder-entity-model/CHANGELOG.md b/plugins/catalog-backend-module-scaffolder-entity-model/CHANGELOG.md index 4a6c195039..9c17f17eaa 100644 --- a/plugins/catalog-backend-module-scaffolder-entity-model/CHANGELOG.md +++ b/plugins/catalog-backend-module-scaffolder-entity-model/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/plugin-catalog-backend-module-scaffolder-entity-model +## 0.2.12-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + ## 0.2.12-next.0 ### Patch Changes diff --git a/plugins/catalog-backend-module-scaffolder-entity-model/package.json b/plugins/catalog-backend-module-scaffolder-entity-model/package.json index 6e040f8078..7019619a6c 100644 --- a/plugins/catalog-backend-module-scaffolder-entity-model/package.json +++ b/plugins/catalog-backend-module-scaffolder-entity-model/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend-module-scaffolder-entity-model", - "version": "0.2.12-next.0", + "version": "0.2.12-next.1", "description": "Adds support for the scaffolder specific entity model (e.g. the Template kind) to the catalog backend plugin.", "backstage": { "role": "backend-plugin-module", diff --git a/plugins/catalog-backend-module-unprocessed/CHANGELOG.md b/plugins/catalog-backend-module-unprocessed/CHANGELOG.md index 28436537b0..ede26e78d1 100644 --- a/plugins/catalog-backend-module-unprocessed/CHANGELOG.md +++ b/plugins/catalog-backend-module-unprocessed/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-catalog-backend-module-unprocessed +## 0.6.4-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-auth-node@0.6.7-next.1 + - @backstage/plugin-catalog-node@1.19.0-next.1 + ## 0.6.4-next.0 ### Patch Changes diff --git a/plugins/catalog-backend-module-unprocessed/package.json b/plugins/catalog-backend-module-unprocessed/package.json index a3de0c75a4..e949240c64 100644 --- a/plugins/catalog-backend-module-unprocessed/package.json +++ b/plugins/catalog-backend-module-unprocessed/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend-module-unprocessed", - "version": "0.6.4-next.0", + "version": "0.6.4-next.1", "description": "Backstage Catalog module to view unprocessed entities", "backstage": { "role": "backend-plugin-module", diff --git a/plugins/catalog-backend/CHANGELOG.md b/plugins/catalog-backend/CHANGELOG.md index ad535f16fd..52ebffab31 100644 --- a/plugins/catalog-backend/CHANGELOG.md +++ b/plugins/catalog-backend/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-catalog-backend +## 3.0.2-next.1 + +### Patch Changes + +- 2204f5b: Prevent deadlock in catalog deferred stitching +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration@1.18.0-next.0 + ## 3.0.2-next.0 ### Patch Changes diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index d825f29356..7a2df294fd 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend", - "version": "3.0.2-next.0", + "version": "3.0.2-next.1", "description": "The Backstage backend plugin that provides the Backstage catalog", "backstage": { "role": "backend-plugin", diff --git a/plugins/catalog-graph/CHANGELOG.md b/plugins/catalog-graph/CHANGELOG.md index a756916c38..5fa578c1ad 100644 --- a/plugins/catalog-graph/CHANGELOG.md +++ b/plugins/catalog-graph/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-catalog-graph +## 0.4.23-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + - @backstage/core-compat-api@0.5.2-next.2 + ## 0.4.23-next.1 ### Patch Changes diff --git a/plugins/catalog-graph/package.json b/plugins/catalog-graph/package.json index dd3271f468..777359e693 100644 --- a/plugins/catalog-graph/package.json +++ b/plugins/catalog-graph/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-graph", - "version": "0.4.23-next.1", + "version": "0.4.23-next.2", "backstage": { "role": "frontend-plugin", "pluginId": "catalog-graph", diff --git a/plugins/catalog-import/CHANGELOG.md b/plugins/catalog-import/CHANGELOG.md index 77ad27ced9..ecd0d8bc46 100644 --- a/plugins/catalog-import/CHANGELOG.md +++ b/plugins/catalog-import/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-catalog-import +## 0.13.5-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + - @backstage/integration@1.18.0-next.0 + - @backstage/core-compat-api@0.5.2-next.2 + ## 0.13.5-next.1 ### Patch Changes diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index 050e8be908..39ed5bcef0 100644 --- a/plugins/catalog-import/package.json +++ b/plugins/catalog-import/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-import", - "version": "0.13.5-next.1", + "version": "0.13.5-next.2", "description": "A Backstage plugin the helps you import entities into your catalog", "backstage": { "role": "frontend-plugin", diff --git a/plugins/catalog-node/CHANGELOG.md b/plugins/catalog-node/CHANGELOG.md index 18f44ba88f..14ac349b6d 100644 --- a/plugins/catalog-node/CHANGELOG.md +++ b/plugins/catalog-node/CHANGELOG.md @@ -1,5 +1,33 @@ # @backstage/plugin-catalog-node +## 1.19.0-next.1 + +### Minor Changes + +- 0e9ec44: Introduced new `streamEntities` async generator method for the catalog. + + Catalog API and Catalog Service now includes a `streamEntities` method that allows for streaming entities from the catalog. + This method is designed to handle large datasets efficiently by processing entities in a stream rather than loading them + all into memory at once. This is useful when you need to fetch a large number of entities but do not want to use pagination + or fetch all entities at once. + + Example usage: + + ```ts + const pageStream = catalogClient.streamEntities({ pageSize: 100 }, { token }); + for await (const page of pageStream) { + // Handle page of entities + for (const entity of page) { + console.log(entity); + } + } + ``` + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + ## 1.18.1-next.0 ### Patch Changes diff --git a/plugins/catalog-node/package.json b/plugins/catalog-node/package.json index a61c4b801b..3111765897 100644 --- a/plugins/catalog-node/package.json +++ b/plugins/catalog-node/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-node", - "version": "1.18.1-next.0", + "version": "1.19.0-next.1", "description": "The plugin-catalog-node module for @backstage/plugin-catalog-backend", "backstage": { "role": "node-library", diff --git a/plugins/catalog-react/CHANGELOG.md b/plugins/catalog-react/CHANGELOG.md index d6eb0dc5fe..7ab8dcd967 100644 --- a/plugins/catalog-react/CHANGELOG.md +++ b/plugins/catalog-react/CHANGELOG.md @@ -1,5 +1,35 @@ # @backstage/plugin-catalog-react +## 1.21.0-next.2 + +### Minor Changes + +- 0e9ec44: Introduced new `streamEntities` async generator method for the catalog. + + Catalog API and Catalog Service now includes a `streamEntities` method that allows for streaming entities from the catalog. + This method is designed to handle large datasets efficiently by processing entities in a stream rather than loading them + all into memory at once. This is useful when you need to fetch a large number of entities but do not want to use pagination + or fetch all entities at once. + + Example usage: + + ```ts + const pageStream = catalogClient.streamEntities({ pageSize: 100 }, { token }); + for await (const page of pageStream) { + // Handle page of entities + for (const entity of page) { + console.log(entity); + } + } + ``` + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/core-components@0.17.6-next.1 + - @backstage/core-compat-api@0.5.2-next.2 + ## 1.20.2-next.1 ### Patch Changes diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index 3912d9ce9d..0b072f51ce 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-react", - "version": "1.20.2-next.1", + "version": "1.21.0-next.2", "description": "A frontend library that helps other Backstage plugins interact with the catalog", "backstage": { "role": "web-library", diff --git a/plugins/catalog/CHANGELOG.md b/plugins/catalog/CHANGELOG.md index 19c35e2137..cddfdf18bc 100644 --- a/plugins/catalog/CHANGELOG.md +++ b/plugins/catalog/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-catalog +## 1.31.3-next.2 + +### Patch Changes + +- 85c5e04: Fix incorrect `defaultTarget` on `createComponentRouteRef`. +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + - @backstage/core-compat-api@0.5.2-next.2 + ## 1.31.3-next.1 ### Patch Changes diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 9e1b0fcc05..fd0c1c4375 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog", - "version": "1.31.3-next.1", + "version": "1.31.3-next.2", "description": "The Backstage plugin for browsing the Backstage catalog", "backstage": { "role": "frontend-plugin", diff --git a/plugins/devtools-backend/CHANGELOG.md b/plugins/devtools-backend/CHANGELOG.md index 4da746e10a..dbd27d37d9 100644 --- a/plugins/devtools-backend/CHANGELOG.md +++ b/plugins/devtools-backend/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-devtools-backend +## 0.5.9-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/config-loader@1.10.3-next.0 + - @backstage/backend-defaults@0.12.1-next.1 + ## 0.5.9-next.0 ### Patch Changes diff --git a/plugins/devtools-backend/package.json b/plugins/devtools-backend/package.json index 038fcbce82..5bd90bdd41 100644 --- a/plugins/devtools-backend/package.json +++ b/plugins/devtools-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-devtools-backend", - "version": "0.5.9-next.0", + "version": "0.5.9-next.1", "backstage": { "role": "backend-plugin", "pluginId": "devtools", diff --git a/plugins/home/CHANGELOG.md b/plugins/home/CHANGELOG.md index ea7326d9b3..1d2709648f 100644 --- a/plugins/home/CHANGELOG.md +++ b/plugins/home/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-home +## 0.8.12-next.2 + +### Patch Changes + +- 929c55a: Fixed race condition in CustomHomepageGrid by waiting for storage to load before rendering custom layout to prevent + rendering of the default content. +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + - @backstage/core-compat-api@0.5.2-next.2 + ## 0.8.12-next.1 ### Patch Changes diff --git a/plugins/home/package.json b/plugins/home/package.json index 21469df7cd..bc8d71d23b 100644 --- a/plugins/home/package.json +++ b/plugins/home/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-home", - "version": "0.8.12-next.1", + "version": "0.8.12-next.2", "description": "A Backstage plugin that helps you build a home page", "backstage": { "role": "frontend-plugin", diff --git a/plugins/kubernetes-backend/CHANGELOG.md b/plugins/kubernetes-backend/CHANGELOG.md index 761e87e5e0..0986e613f8 100644 --- a/plugins/kubernetes-backend/CHANGELOG.md +++ b/plugins/kubernetes-backend/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-kubernetes-backend +## 0.20.2-next.2 + +### Patch Changes + +- dd7b6d2: Fix a bug where `getDefault` in the `kubernetesFetcherExtensionPoint` had the wrong `this` value +- Updated dependencies + - @backstage/plugin-auth-node@0.6.7-next.1 + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration-aws-node@0.1.17 + ## 0.20.2-next.1 ### Patch Changes diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index bae94407b5..15722c373d 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kubernetes-backend", - "version": "0.20.2-next.1", + "version": "0.20.2-next.2", "description": "A Backstage backend plugin that integrates towards Kubernetes", "backstage": { "role": "backend-plugin", diff --git a/plugins/kubernetes-cluster/CHANGELOG.md b/plugins/kubernetes-cluster/CHANGELOG.md index 63ee864a46..af3a23232d 100644 --- a/plugins/kubernetes-cluster/CHANGELOG.md +++ b/plugins/kubernetes-cluster/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-kubernetes-cluster +## 0.0.29-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + ## 0.0.29-next.1 ### Patch Changes diff --git a/plugins/kubernetes-cluster/package.json b/plugins/kubernetes-cluster/package.json index 6488b959f6..0f9ab60401 100644 --- a/plugins/kubernetes-cluster/package.json +++ b/plugins/kubernetes-cluster/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kubernetes-cluster", - "version": "0.0.29-next.1", + "version": "0.0.29-next.2", "description": "A Backstage plugin that shows details of Kubernetes clusters", "backstage": { "role": "frontend-plugin", diff --git a/plugins/kubernetes/CHANGELOG.md b/plugins/kubernetes/CHANGELOG.md index 6f76f6bc2a..5a1643287e 100644 --- a/plugins/kubernetes/CHANGELOG.md +++ b/plugins/kubernetes/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-kubernetes +## 0.12.11-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + - @backstage/core-compat-api@0.5.2-next.2 + ## 0.12.11-next.1 ### Patch Changes diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 3fd141319c..d0b553a4eb 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kubernetes", - "version": "0.12.11-next.1", + "version": "0.12.11-next.2", "description": "A Backstage plugin that integrates towards Kubernetes", "backstage": { "role": "frontend-plugin", diff --git a/plugins/mcp-actions-backend/CHANGELOG.md b/plugins/mcp-actions-backend/CHANGELOG.md index 0ca964fbd1..e7578dff07 100644 --- a/plugins/mcp-actions-backend/CHANGELOG.md +++ b/plugins/mcp-actions-backend/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-mcp-actions-backend +## 0.1.3-next.1 + +### Patch Changes + +- 1d47bf3: Proxy `/.well-known/oauth-authorization-server` to `/.well-known/openid-configuration` on `auth-backend` when `auth.experimentalDynamicClientRegistration.enabled` is enabled. +- Updated dependencies + - @backstage/backend-defaults@0.12.1-next.1 + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-node@1.19.0-next.1 + ## 0.1.3-next.0 ### Patch Changes diff --git a/plugins/mcp-actions-backend/package.json b/plugins/mcp-actions-backend/package.json index 2a7fbdf8a3..4ed2250925 100644 --- a/plugins/mcp-actions-backend/package.json +++ b/plugins/mcp-actions-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-mcp-actions-backend", - "version": "0.1.3-next.0", + "version": "0.1.3-next.1", "backstage": { "role": "backend-plugin", "pluginId": "mcp-actions", diff --git a/plugins/notifications-backend-module-email/CHANGELOG.md b/plugins/notifications-backend-module-email/CHANGELOG.md index 9a46dad0bc..8619edd6f4 100644 --- a/plugins/notifications-backend-module-email/CHANGELOG.md +++ b/plugins/notifications-backend-module-email/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-notifications-backend-module-email +## 0.3.13-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration-aws-node@0.1.17 + - @backstage/plugin-notifications-node@0.2.19-next.1 + ## 0.3.13-next.0 ### Patch Changes diff --git a/plugins/notifications-backend-module-email/package.json b/plugins/notifications-backend-module-email/package.json index 602edf0e66..6844a77d3c 100644 --- a/plugins/notifications-backend-module-email/package.json +++ b/plugins/notifications-backend-module-email/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-notifications-backend-module-email", - "version": "0.3.13-next.0", + "version": "0.3.13-next.1", "description": "The email backend module for the notifications plugin.", "backstage": { "role": "backend-plugin-module", diff --git a/plugins/notifications-backend-module-slack/CHANGELOG.md b/plugins/notifications-backend-module-slack/CHANGELOG.md index e38e8f9d07..a9d8074e5f 100644 --- a/plugins/notifications-backend-module-slack/CHANGELOG.md +++ b/plugins/notifications-backend-module-slack/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-notifications-backend-module-slack +## 0.1.5-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/plugin-notifications-node@0.2.19-next.1 + ## 0.1.5-next.0 ### Patch Changes diff --git a/plugins/notifications-backend-module-slack/package.json b/plugins/notifications-backend-module-slack/package.json index 227201984b..6b32e1b31d 100644 --- a/plugins/notifications-backend-module-slack/package.json +++ b/plugins/notifications-backend-module-slack/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-notifications-backend-module-slack", - "version": "0.1.5-next.0", + "version": "0.1.5-next.1", "description": "The slack backend module for the notifications plugin.", "backstage": { "role": "backend-plugin-module", diff --git a/plugins/notifications-backend/CHANGELOG.md b/plugins/notifications-backend/CHANGELOG.md index 41905ec918..3391980f44 100644 --- a/plugins/notifications-backend/CHANGELOG.md +++ b/plugins/notifications-backend/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-notifications-backend +## 0.5.10-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-auth-node@0.6.7-next.1 + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/plugin-notifications-node@0.2.19-next.1 + ## 0.5.10-next.0 ### Patch Changes diff --git a/plugins/notifications-backend/package.json b/plugins/notifications-backend/package.json index 94c42005c0..a7933b0fc8 100644 --- a/plugins/notifications-backend/package.json +++ b/plugins/notifications-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-notifications-backend", - "version": "0.5.10-next.0", + "version": "0.5.10-next.1", "backstage": { "role": "backend-plugin", "pluginId": "notifications", diff --git a/plugins/notifications-node/CHANGELOG.md b/plugins/notifications-node/CHANGELOG.md index af3c020fd3..77e35d0996 100644 --- a/plugins/notifications-node/CHANGELOG.md +++ b/plugins/notifications-node/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/plugin-notifications-node +## 0.2.19-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + ## 0.2.19-next.0 ### Patch Changes diff --git a/plugins/notifications-node/package.json b/plugins/notifications-node/package.json index c20cf4c820..234d9d342f 100644 --- a/plugins/notifications-node/package.json +++ b/plugins/notifications-node/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-notifications-node", - "version": "0.2.19-next.0", + "version": "0.2.19-next.1", "description": "Node.js library for the notifications plugin", "backstage": { "role": "node-library", diff --git a/plugins/org-react/CHANGELOG.md b/plugins/org-react/CHANGELOG.md index 410b9da982..6cf5609da0 100644 --- a/plugins/org-react/CHANGELOG.md +++ b/plugins/org-react/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-org-react +## 0.1.42-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + ## 0.1.42-next.1 ### Patch Changes diff --git a/plugins/org-react/package.json b/plugins/org-react/package.json index f8410a3f77..06c4135396 100644 --- a/plugins/org-react/package.json +++ b/plugins/org-react/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-org-react", - "version": "0.1.42-next.1", + "version": "0.1.42-next.2", "backstage": { "role": "web-library", "pluginId": "org", diff --git a/plugins/org/CHANGELOG.md b/plugins/org/CHANGELOG.md index ee5def4fcf..9df2d654e3 100644 --- a/plugins/org/CHANGELOG.md +++ b/plugins/org/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-org +## 0.6.44-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + - @backstage/core-compat-api@0.5.2-next.2 + ## 0.6.44-next.1 ### Patch Changes diff --git a/plugins/org/package.json b/plugins/org/package.json index df5b58446c..96a4ab9e62 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-org", - "version": "0.6.44-next.1", + "version": "0.6.44-next.2", "description": "A Backstage plugin that helps you create entity pages for your organization", "backstage": { "role": "frontend-plugin", diff --git a/plugins/scaffolder-backend-module-github/CHANGELOG.md b/plugins/scaffolder-backend-module-github/CHANGELOG.md index 9e99278912..2c396607d5 100644 --- a/plugins/scaffolder-backend-module-github/CHANGELOG.md +++ b/plugins/scaffolder-backend-module-github/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-scaffolder-backend-module-github +## 0.8.3-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration@1.18.0-next.0 + ## 0.8.3-next.0 ### Patch Changes diff --git a/plugins/scaffolder-backend-module-github/package.json b/plugins/scaffolder-backend-module-github/package.json index 15dedf5ac8..2d28caf5c6 100644 --- a/plugins/scaffolder-backend-module-github/package.json +++ b/plugins/scaffolder-backend-module-github/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-backend-module-github", - "version": "0.8.3-next.0", + "version": "0.8.3-next.1", "description": "The github module for @backstage/plugin-scaffolder-backend", "backstage": { "role": "backend-plugin-module", diff --git a/plugins/scaffolder-backend/CHANGELOG.md b/plugins/scaffolder-backend/CHANGELOG.md index 5fbb8edcc2..305afa351c 100644 --- a/plugins/scaffolder-backend/CHANGELOG.md +++ b/plugins/scaffolder-backend/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-scaffolder-backend +## 2.2.1-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/backend-defaults@0.12.1-next.1 + - @backstage/plugin-auth-node@0.6.7-next.1 + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration@1.18.0-next.0 + - @backstage/plugin-catalog-backend-module-scaffolder-entity-model@0.2.12-next.1 + - @backstage/plugin-scaffolder-backend-module-github@0.8.3-next.1 + ## 2.2.1-next.0 ### Patch Changes diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 6b2f9ef0cb..2e77b90177 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-backend", - "version": "2.2.1-next.0", + "version": "2.2.1-next.1", "description": "The Backstage backend plugin that helps you create new things", "backstage": { "role": "backend-plugin", diff --git a/plugins/scaffolder-react/CHANGELOG.md b/plugins/scaffolder-react/CHANGELOG.md index 78d40108f4..00c948a51b 100644 --- a/plugins/scaffolder-react/CHANGELOG.md +++ b/plugins/scaffolder-react/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-scaffolder-react +## 1.19.1-next.2 + +### Patch Changes + +- 58fc108: Fix scaffolder task log stream not having a minimum height +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + ## 1.19.1-next.1 ### Patch Changes diff --git a/plugins/scaffolder-react/package.json b/plugins/scaffolder-react/package.json index 1d8737db72..25c8ea0ec6 100644 --- a/plugins/scaffolder-react/package.json +++ b/plugins/scaffolder-react/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-react", - "version": "1.19.1-next.1", + "version": "1.19.1-next.2", "description": "A frontend library that helps other Backstage plugins interact with the Scaffolder", "backstage": { "role": "web-library", diff --git a/plugins/scaffolder/CHANGELOG.md b/plugins/scaffolder/CHANGELOG.md index bbb826965c..420938d6fd 100644 --- a/plugins/scaffolder/CHANGELOG.md +++ b/plugins/scaffolder/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/plugin-scaffolder +## 1.34.1-next.2 + +### Patch Changes + +- 0d415ae: Render a TechDocs link on the Scaffolder Template List page when templates include either `backstage.io/techdocs-ref` or `backstage.io/techdocs-entity` annotations, using the shared `buildTechDocsURL` helper. Also adds tests to verify both annotations and optional `backstage.io/techdocs-entity-path` are respected. +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + - @backstage/plugin-scaffolder-react@1.19.1-next.2 + - @backstage/integration@1.18.0-next.0 + - @backstage/core-compat-api@0.5.2-next.2 + ## 1.34.1-next.1 ### Patch Changes diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index d9a63c7661..695afd2244 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder", - "version": "1.34.1-next.1", + "version": "1.34.1-next.2", "description": "The Backstage plugin that helps you create new things", "backstage": { "role": "frontend-plugin", diff --git a/plugins/search-backend-module-catalog/CHANGELOG.md b/plugins/search-backend-module-catalog/CHANGELOG.md index e9a56fa5f9..8f60690872 100644 --- a/plugins/search-backend-module-catalog/CHANGELOG.md +++ b/plugins/search-backend-module-catalog/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-search-backend-module-catalog +## 0.3.8-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-node@1.19.0-next.1 + ## 0.3.8-next.0 ### Patch Changes diff --git a/plugins/search-backend-module-catalog/package.json b/plugins/search-backend-module-catalog/package.json index 9bcc2287ff..0ebd03caa6 100644 --- a/plugins/search-backend-module-catalog/package.json +++ b/plugins/search-backend-module-catalog/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-search-backend-module-catalog", - "version": "0.3.8-next.0", + "version": "0.3.8-next.1", "description": "A module for the search backend that exports catalog modules", "backstage": { "role": "backend-plugin-module", diff --git a/plugins/search-backend-module-techdocs/CHANGELOG.md b/plugins/search-backend-module-techdocs/CHANGELOG.md index 1938883b9e..ead9c04a7b 100644 --- a/plugins/search-backend-module-techdocs/CHANGELOG.md +++ b/plugins/search-backend-module-techdocs/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-search-backend-module-techdocs +## 0.4.6-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-node@1.19.0-next.1 + ## 0.4.6-next.0 ### Patch Changes diff --git a/plugins/search-backend-module-techdocs/package.json b/plugins/search-backend-module-techdocs/package.json index 1faffa2b90..d1b784a4d8 100644 --- a/plugins/search-backend-module-techdocs/package.json +++ b/plugins/search-backend-module-techdocs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-search-backend-module-techdocs", - "version": "0.4.6-next.0", + "version": "0.4.6-next.1", "description": "A module for the search backend that exports techdocs modules", "backstage": { "role": "backend-plugin-module", diff --git a/plugins/search/CHANGELOG.md b/plugins/search/CHANGELOG.md index 8449247680..8e9ca88787 100644 --- a/plugins/search/CHANGELOG.md +++ b/plugins/search/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-search +## 1.4.30-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + - @backstage/core-compat-api@0.5.2-next.2 + ## 1.4.30-next.1 ### Patch Changes diff --git a/plugins/search/package.json b/plugins/search/package.json index 7600d61b86..b58993b65c 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-search", - "version": "1.4.30-next.1", + "version": "1.4.30-next.2", "description": "The Backstage plugin that provides your backstage app with search", "backstage": { "role": "frontend-plugin", diff --git a/plugins/techdocs-addons-test-utils/CHANGELOG.md b/plugins/techdocs-addons-test-utils/CHANGELOG.md index a6b33f9837..714fd56c8f 100644 --- a/plugins/techdocs-addons-test-utils/CHANGELOG.md +++ b/plugins/techdocs-addons-test-utils/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-techdocs-addons-test-utils +## 1.0.53-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/plugin-catalog@1.31.3-next.2 + - @backstage/plugin-techdocs@1.14.2-next.2 + ## 1.0.53-next.1 ### Patch Changes diff --git a/plugins/techdocs-addons-test-utils/package.json b/plugins/techdocs-addons-test-utils/package.json index eebe45a88a..94fd865e02 100644 --- a/plugins/techdocs-addons-test-utils/package.json +++ b/plugins/techdocs-addons-test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs-addons-test-utils", - "version": "1.0.53-next.1", + "version": "1.0.53-next.2", "backstage": { "role": "web-library", "pluginId": "techdocs-addons", diff --git a/plugins/techdocs-backend/CHANGELOG.md b/plugins/techdocs-backend/CHANGELOG.md index 036ba4387c..98b42c0b84 100644 --- a/plugins/techdocs-backend/CHANGELOG.md +++ b/plugins/techdocs-backend/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-techdocs-backend +## 2.1.0-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/backend-defaults@0.12.1-next.1 + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-node@1.19.0-next.1 + - @backstage/integration@1.18.0-next.0 + - @backstage/plugin-search-backend-module-techdocs@0.4.6-next.1 + ## 2.1.0-next.0 ### Minor Changes diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json index bc6bfd6175..fd1ed4cadf 100644 --- a/plugins/techdocs-backend/package.json +++ b/plugins/techdocs-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs-backend", - "version": "2.1.0-next.0", + "version": "2.1.0-next.1", "description": "The Backstage backend plugin that renders technical documentation for your components", "backstage": { "role": "backend-plugin", diff --git a/plugins/techdocs/CHANGELOG.md b/plugins/techdocs/CHANGELOG.md index c4f6ed3cef..02593351ee 100644 --- a/plugins/techdocs/CHANGELOG.md +++ b/plugins/techdocs/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-techdocs +## 1.14.2-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-auth-react@0.1.19-next.1 + - @backstage/catalog-client@1.12.0-next.0 + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + - @backstage/integration@1.18.0-next.0 + - @backstage/core-compat-api@0.5.2-next.2 + ## 1.14.2-next.1 ### Patch Changes diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 7ecec3cd0a..ca867db802 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs", - "version": "1.14.2-next.1", + "version": "1.14.2-next.2", "description": "The Backstage plugin that renders technical documentation for your components", "backstage": { "role": "frontend-plugin", diff --git a/plugins/user-settings/CHANGELOG.md b/plugins/user-settings/CHANGELOG.md index 5d4f80079f..cfb4c0b712 100644 --- a/plugins/user-settings/CHANGELOG.md +++ b/plugins/user-settings/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-user-settings +## 0.8.26-next.2 + +### Patch Changes + +- b713b54: Tool-tip text correction for the Theme selection in settings page +- Updated dependencies + - @backstage/plugin-catalog-react@1.21.0-next.2 + - @backstage/core-components@0.17.6-next.1 + - @backstage/core-compat-api@0.5.2-next.2 + ## 0.8.26-next.1 ### Patch Changes diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index d5224ac8b6..3ce96f9146 100644 --- a/plugins/user-settings/package.json +++ b/plugins/user-settings/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-user-settings", - "version": "0.8.26-next.1", + "version": "0.8.26-next.2", "description": "A Backstage plugin that provides a settings page", "backstage": { "role": "frontend-plugin", From 33bb3c2305f1d72a45c13ef3dab3008a2cecc8e3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 2 Jul 2025 19:22:45 +0000 Subject: [PATCH 098/107] fix(deps): update dependency dompurify to v3.2.4 [security] Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/yarn.lock b/yarn.lock index 50a73bb657..42cf3d4a3d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -21537,10 +21537,10 @@ __metadata: languageName: node linkType: hard -"@types/trusted-types@npm:*": - version: 2.0.3 - resolution: "@types/trusted-types@npm:2.0.3" - checksum: 10/4794804bc4a4a173d589841b6d26cf455ff5dc4f3e704e847de7d65d215f2e7043d8757e4741ce3a823af3f08260a8d04a1a6e9c5ec9b20b7b04586956a6b005 +"@types/trusted-types@npm:*, @types/trusted-types@npm:^2.0.7": + version: 2.0.7 + resolution: "@types/trusted-types@npm:2.0.7" + checksum: 10/8e4202766a65877efcf5d5a41b7dd458480b36195e580a3b1085ad21e948bc417d55d6f8af1fd2a7ad008015d4117d5fdfe432731157da3c68678487174e4ba3 languageName: node linkType: hard @@ -28454,9 +28454,14 @@ __metadata: linkType: hard "dompurify@npm:^3.0.0, dompurify@npm:^3.1.7": - version: 3.1.7 - resolution: "dompurify@npm:3.1.7" - checksum: 10/dc637a064306f83cf911caa267ffe1f973552047602020e3b6723c90f67962813edf8a65a0b62e8c9bc13fcd173a2691212a3719bc116226967f46bcd6181277 + version: 3.2.6 + resolution: "dompurify@npm:3.2.6" + dependencies: + "@types/trusted-types": "npm:^2.0.7" + dependenciesMeta: + "@types/trusted-types": + optional: true + checksum: 10/b91631ed0e4d17fae950ef53613cc009ed7e73adc43ac94a41dd52f35483f7538d13caebdafa7626e0da145fc8184e7ac7935f14f25b7e841b32fda777e40447 languageName: node linkType: hard From d821c01c5ecca6873c6c2a33e3e205dd9c27c4c0 Mon Sep 17 00:00:00 2001 From: Jackson Chen Date: Tue, 9 Sep 2025 17:31:19 -0400 Subject: [PATCH 099/107] refactor and fix dompurify tsc errors Signed-off-by: Jackson Chen --- .../transformers/html/hooks/attributes.ts | 32 +++++++++++++++ .../reader/transformers/html/hooks/iframes.ts | 7 +++- .../reader/transformers/html/hooks/index.ts | 2 + .../reader/transformers/html/hooks/links.ts | 8 ++-- .../transformers/html/hooks/metatags.ts | 41 +++++++++++++++++++ .../reader/transformers/html/transformer.ts | 30 ++++---------- .../src/reader/transformers/html/utils.ts | 19 +++++++++ 7 files changed, 113 insertions(+), 26 deletions(-) create mode 100644 plugins/techdocs/src/reader/transformers/html/hooks/attributes.ts create mode 100644 plugins/techdocs/src/reader/transformers/html/hooks/metatags.ts create mode 100644 plugins/techdocs/src/reader/transformers/html/utils.ts diff --git a/plugins/techdocs/src/reader/transformers/html/hooks/attributes.ts b/plugins/techdocs/src/reader/transformers/html/hooks/attributes.ts new file mode 100644 index 0000000000..ae0b4e2ce9 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/html/hooks/attributes.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2025 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 { UponSanitizeAttributeHook } from 'dompurify'; + +/** + * Removes attributes that should only be present on meta tags from other elements. + * This ensures that http-equiv and content attributes are only allowed on meta tags + * where they are required for the redirect feature. + */ +export const removeRestrictedAttributes: UponSanitizeAttributeHook = ( + node, + data, +) => { + if (node.tagName !== 'META') { + if (data.attrName === 'http-equiv' || data.attrName === 'content') { + node.removeAttribute(data.attrName); + } + } +}; diff --git a/plugins/techdocs/src/reader/transformers/html/hooks/iframes.ts b/plugins/techdocs/src/reader/transformers/html/hooks/iframes.ts index 25259dbc43..23711b52b7 100644 --- a/plugins/techdocs/src/reader/transformers/html/hooks/iframes.ts +++ b/plugins/techdocs/src/reader/transformers/html/hooks/iframes.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { isElement } from '../utils'; + /** * Checks whether a node is iframe or not. * @param node - can be any element. @@ -42,9 +44,10 @@ const isSafe = (node: Element, hosts: string[]) => { * @param node - can be any element. * @param hosts - list of allowed hosts. */ -export const removeUnsafeIframes = (hosts: string[]) => (node: Element) => { +export const removeUnsafeIframes = (hosts: string[]) => (node: Node) => { + if (!isElement(node)) return; + if (isIframe(node) && !isSafe(node, hosts)) { node.remove(); } - return node; }; diff --git a/plugins/techdocs/src/reader/transformers/html/hooks/index.ts b/plugins/techdocs/src/reader/transformers/html/hooks/index.ts index a4356db2e9..ce237ec6d2 100644 --- a/plugins/techdocs/src/reader/transformers/html/hooks/index.ts +++ b/plugins/techdocs/src/reader/transformers/html/hooks/index.ts @@ -16,3 +16,5 @@ export { removeUnsafeLinks } from './links'; export { removeUnsafeIframes } from './iframes'; +export { removeUnsafeMetaTags } from './metatags'; +export { removeRestrictedAttributes } from './attributes'; diff --git a/plugins/techdocs/src/reader/transformers/html/hooks/links.ts b/plugins/techdocs/src/reader/transformers/html/hooks/links.ts index 38f775cfbe..eb2cbfc6de 100644 --- a/plugins/techdocs/src/reader/transformers/html/hooks/links.ts +++ b/plugins/techdocs/src/reader/transformers/html/hooks/links.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { isElement } from '../utils'; + const MKDOCS_CSS = /main\.[A-Fa-f0-9]{8}\.min\.css$/; const GOOGLE_FONTS = /^https:\/\/fonts\.googleapis\.com/; const GSTATIC_FONTS = /^https:\/\/fonts\.gstatic\.com/; @@ -41,11 +43,11 @@ const isSafe = (node: Element) => { /** * Function that removes unsafe link nodes. * @param node - can be any element. - * @param hosts - list of allowed hosts. */ -export const removeUnsafeLinks = (node: Element) => { +export const removeUnsafeLinks = (node: Node) => { + if (!isElement(node)) return; + if (isLink(node) && !isSafe(node)) { node.remove(); } - return node; }; diff --git a/plugins/techdocs/src/reader/transformers/html/hooks/metatags.ts b/plugins/techdocs/src/reader/transformers/html/hooks/metatags.ts new file mode 100644 index 0000000000..a207a5bca2 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/html/hooks/metatags.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2025 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 { UponSanitizeElementHook } from 'dompurify'; +import { isElement } from '../utils'; + +/** + * Checks if a meta tag is a refresh redirect tag that should be allowed. + * These tags are required for the TechDocs redirect feature. + */ +const isAllowedMetaRefreshTag = (element: Element): boolean => { + const httpEquiv = element.getAttribute('http-equiv'); + const content = element.getAttribute('content'); + + return httpEquiv === 'refresh' && content?.includes('url=') === true; +}; + +/** + * Removes unsafe meta tags from the DOM while preserving allowed refresh redirect tags. + * Only meta tags used for page refreshing/redirects are allowed as they are required + * for the TechDocs redirect feature. + */ +export const removeUnsafeMetaTags: UponSanitizeElementHook = (node, data) => { + if (!isElement(node)) return; + + if (data.tagName === 'meta' && !isAllowedMetaRefreshTag(node)) { + node.parentNode?.removeChild(node); + } +}; diff --git a/plugins/techdocs/src/reader/transformers/html/transformer.ts b/plugins/techdocs/src/reader/transformers/html/transformer.ts index d4b31a34e5..6ebf53f26e 100644 --- a/plugins/techdocs/src/reader/transformers/html/transformer.ts +++ b/plugins/techdocs/src/reader/transformers/html/transformer.ts @@ -20,7 +20,12 @@ import { useCallback, useMemo } from 'react'; import { configApiRef, useApi } from '@backstage/core-plugin-api'; import { Transformer } from '../transformer'; -import { removeUnsafeIframes, removeUnsafeLinks } from './hooks'; +import { + removeRestrictedAttributes, + removeUnsafeIframes, + removeUnsafeLinks, + removeUnsafeMetaTags, +} from './hooks'; /** * Returns html sanitizer configuration @@ -51,26 +56,9 @@ export const useSanitizerTransformer = (): Transformer => { DOMPurify.addHook('beforeSanitizeElements', removeUnsafeIframes(hosts)); } - // Only allow meta tags if they are used for refreshing the page. They are required for the redirect feature. - DOMPurify.addHook('uponSanitizeElement', (currNode, data) => { - if (data.tagName === 'meta') { - const isMetaRefreshTag = - currNode.getAttribute('http-equiv') === 'refresh' && - currNode.getAttribute('content')?.includes('url='); - if (!isMetaRefreshTag) { - currNode.parentNode?.removeChild(currNode); - } - } - }); + DOMPurify.addHook('uponSanitizeElement', removeUnsafeMetaTags); - // Only allow http-equiv and content attributes on meta tags. They are required for the redirect feature. - DOMPurify.addHook('uponSanitizeAttribute', (currNode, data) => { - if (currNode.tagName !== 'META') { - if (data.attrName === 'http-equiv' || data.attrName === 'content') { - currNode.removeAttribute(data.attrName); - } - } - }); + DOMPurify.addHook('uponSanitizeAttribute', removeRestrictedAttributes); const tagNameCheck = config?.getOptionalString( 'allowedCustomElementTagNameRegExp', @@ -122,7 +110,7 @@ export const useSanitizerTransformer = (): Transformer => { ? new RegExp(attributeNameCheck) : undefined, }, - }); + }) as Element; }, [config], ); diff --git a/plugins/techdocs/src/reader/transformers/html/utils.ts b/plugins/techdocs/src/reader/transformers/html/utils.ts new file mode 100644 index 0000000000..b97b63743c --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/html/utils.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2025 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 const isElement = (node: Node): node is Element => { + return node.nodeType === Node.ELEMENT_NODE; +}; From 313cec7bed81039ea9473227c035f57396fa7a97 Mon Sep 17 00:00:00 2001 From: Jackson Chen Date: Tue, 9 Sep 2025 17:47:35 -0400 Subject: [PATCH 100/107] add changeset Signed-off-by: Jackson Chen --- .changeset/chilly-llamas-attend.md | 5 +++++ plugins/techdocs/package.json | 2 +- yarn.lock | 4 ++-- 3 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 .changeset/chilly-llamas-attend.md diff --git a/.changeset/chilly-llamas-attend.md b/.changeset/chilly-llamas-attend.md new file mode 100644 index 0000000000..0a0876ccac --- /dev/null +++ b/.changeset/chilly-llamas-attend.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Updated dependency `dompurify` to `^3.2.4`. diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index ca867db802..7777133862 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -81,7 +81,7 @@ "@material-ui/lab": "4.0.0-alpha.61", "@material-ui/styles": "^4.10.0", "@microsoft/fetch-event-source": "^2.0.1", - "dompurify": "^3.0.0", + "dompurify": "^3.2.4", "git-url-parse": "^15.0.0", "jss": "~10.10.0", "lodash": "^4.17.21", diff --git a/yarn.lock b/yarn.lock index 42cf3d4a3d..0e5078d824 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7289,7 +7289,7 @@ __metadata: "@testing-library/user-event": "npm:^14.0.0" "@types/dompurify": "npm:^3.0.0" "@types/react": "npm:^18.0.0" - dompurify: "npm:^3.0.0" + dompurify: "npm:^3.2.4" git-url-parse: "npm:^15.0.0" jss: "npm:~10.10.0" lodash: "npm:^4.17.21" @@ -28453,7 +28453,7 @@ __metadata: languageName: node linkType: hard -"dompurify@npm:^3.0.0, dompurify@npm:^3.1.7": +"dompurify@npm:^3.1.7, dompurify@npm:^3.2.4": version: 3.2.6 resolution: "dompurify@npm:3.2.6" dependencies: From fffd4347fa133ec286bb650e6b01d5fa76638056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Erik=20Bergstr=C3=B6m?= Date: Tue, 9 Sep 2025 19:43:23 +0200 Subject: [PATCH 101/107] fix(module-federation): disallow imported fallback modules in mf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Carl-Erik Bergström --- .changeset/angry-heads-design.md | 5 +++++ packages/cli/src/modules/build/lib/bundler/config.ts | 8 ++++++++ 2 files changed, 13 insertions(+) create mode 100644 .changeset/angry-heads-design.md diff --git a/.changeset/angry-heads-design.md b/.changeset/angry-heads-design.md new file mode 100644 index 0000000000..0b4a866fb1 --- /dev/null +++ b/.changeset/angry-heads-design.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Disallow import fallback of critical shared dependencies in module federation. diff --git a/packages/cli/src/modules/build/lib/bundler/config.ts b/packages/cli/src/modules/build/lib/bundler/config.ts index c4011ede48..bf78d04b4a 100644 --- a/packages/cli/src/modules/build/lib/bundler/config.ts +++ b/packages/cli/src/modules/build/lib/bundler/config.ts @@ -264,24 +264,30 @@ export async function createConfig( singleton: true, requiredVersion: '*', eager: !isRemote, + import: false, }, 'react-dom': { singleton: true, requiredVersion: '*', eager: !isRemote, + import: false, }, // React Router 'react-router': { singleton: true, requiredVersion: '*', eager: !isRemote, + import: false, }, 'react-router-dom': { singleton: true, requiredVersion: '*', eager: !isRemote, + import: false, }, // MUI v4 + // not setting import: false for MUI packages as this + // will break once Backstage moves to BUI '@material-ui/core/styles': { singleton: true, requiredVersion: '*', @@ -293,6 +299,8 @@ export async function createConfig( eager: !isRemote, }, // MUI v5 + // not setting import: false for MUI packages as this + // will break once Backstage moves to BUI '@mui/material/styles/': { singleton: true, requiredVersion: '*', From d03010ec9be71f0f756cfbc860de3120ff87f0e7 Mon Sep 17 00:00:00 2001 From: Hayato Kihara <14058454+gumimin@users.noreply.github.com> Date: Thu, 11 Sep 2025 03:23:23 +0900 Subject: [PATCH 102/107] docs: Add missing TOC entries to TechDocs FAQ (#30900) Signed-off-by: Hayato Kihara --- .github/vale/config/vocabularies/Backstage/accept.txt | 1 + docs/features/techdocs/FAQ.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index ec43e1fbbc..516eec516f 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -146,6 +146,7 @@ Expedia facto failover Fargate +faqs featureful Figma firehydrant diff --git a/docs/features/techdocs/FAQ.md b/docs/features/techdocs/FAQ.md index 24777e5763..9c78e3e123 100644 --- a/docs/features/techdocs/FAQ.md +++ b/docs/features/techdocs/FAQ.md @@ -12,6 +12,8 @@ This page answers frequently asked questions about [TechDocs](README.md). - [What static site generator is TechDocs using?](#what-static-site-generator-is-techdocs-using) - [What is the mkdocs-techdocs-core plugin?](#what-is-the-mkdocs-techdocs-core-plugin) - [Does TechDocs support file formats other than Markdown (e.g. RST, AsciiDoc)?](#does-techdocs-support-file-formats-other-than-markdown-eg-rst-asciidoc-) +- [What should be the value of `backstage.io/techdocs-ref` when using external build and storage?](#what-should-be-the-value-of-backstageiotechdocs-ref-when-using-external-build-and-storage) +- [Is it possible for users to suggest changes or provide feedback on a TechDocs page?](#is-it-possible-for-users-to-suggest-changes-or-provide-feedback-on-a-techdocs-page) #### What static site generator is TechDocs using? From 8d18d23e347ef24ca30c26fe4fb4efe3e870227f Mon Sep 17 00:00:00 2001 From: Luna Stadler Date: Wed, 10 Sep 2025 20:34:58 +0200 Subject: [PATCH 103/107] Improve TechDocs page titles (especially for nested pages) (#31054) * Replace underscores in techdocs titles Signed-off-by: Luna Stadler * Make techdocs titles similar to component titles The pattern for components is entity name, page/tab and then app title. This ordering makes it easier to distinguish tabs at a glance. Signed-off-by: Luna Stadler * Abbreviate nested pages in techdocs A deeply nested page like `/really/very/deeply/nested/page`, will now become "Really | ... | Nested | Page". This should preserve some of the context and support docs whith deeply nested pages. Signed-off-by: Luna Stadler * Add changeset for TechDocs page title improvements Signed-off-by: Luna Stadler * Display the full title based on all parts of the path Signed-off-by: Luna Stadler --------- Signed-off-by: Luna Stadler --- .changeset/clear-houses-wonder.md | 5 +++ .../TechDocsReaderPageHeader.test.tsx | 33 ++++++++++++++++--- .../TechDocsReaderPageHeader.tsx | 5 ++- 3 files changed, 35 insertions(+), 8 deletions(-) create mode 100644 .changeset/clear-houses-wonder.md diff --git a/.changeset/clear-houses-wonder.md b/.changeset/clear-houses-wonder.md new file mode 100644 index 0000000000..46ada62fbe --- /dev/null +++ b/.changeset/clear-houses-wonder.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +TechDocs page titles have been improved, especially for deeply nested pages. diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.test.tsx index 6f6ee81c8b..3c10b26f7f 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.test.tsx @@ -52,13 +52,11 @@ const mockTechDocsMetadata = { site_description: 'test-site-desc', }; -const mockUseParams = jest.fn(); -mockUseParams.mockReturnValue({ '*': 'foo/bar/baz/' }); - +let useParamsPath = '/'; jest.mock('react-router-dom', () => { return { ...(jest.requireActual('react-router-dom') as any), - useParams: () => mockUseParams(), + useParams: () => ({ '*': useParamsPath }), }; }); @@ -189,6 +187,7 @@ describe('', () => { getEntityMetadata.mockResolvedValue(mockEntityMetadata); getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); + useParamsPath = 'foo/bar/baz/'; await renderInTestApp( @@ -203,7 +202,31 @@ describe('', () => { await waitFor(() => { expect(document.title).toEqual( - 'Backstage | Test Entity | Foo | Bar | Baz', + 'Test Entity | Foo | Bar | Baz | Backstage', + ); + }); + }); + + it('The header title is abbreviated if path is too long', async () => { + getEntityMetadata.mockResolvedValue(mockEntityMetadata); + getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); + + useParamsPath = 'foo/bar/baz/qux/quux/'; + await renderInTestApp( + + + , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name/*': entityRouteRef, + '/docs': rootRouteRef, + }, + }, + ); + + await waitFor(() => { + expect(document.title).toEqual( + 'Test Entity | Foo | Bar | Baz | Qux | Quux | Backstage', ); }); }); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx index bafcdd270c..6328becc73 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx @@ -172,17 +172,16 @@ export const TechDocsReaderPageHeader = ( const removeTrailingSlash = (str: string) => str.replace(/\/$/, ''); const normalizeAndSpace = (str: string) => - str.replace(/-/g, ' ').split(' ').map(capitalize).join(' '); + str.replace(/[-_]/g, ' ').split(' ').map(capitalize).join(' '); let techdocsTabTitleItems: string[] = []; if (path !== '') techdocsTabTitleItems = removeTrailingSlash(path) .split('/') - .slice(0, 3) .map(normalizeAndSpace); - const tabTitleItems = [appTitle, entityDisplayName, ...techdocsTabTitleItems]; + const tabTitleItems = [entityDisplayName, ...techdocsTabTitleItems, appTitle]; const tabTitle = tabTitleItems.join(' | '); return ( From 56897d717e9445eb7aacfceba3f17bfe782f4809 Mon Sep 17 00:00:00 2001 From: Lee Standen Date: Wed, 10 Sep 2025 15:57:16 -0700 Subject: [PATCH 104/107] Fixes issue with organization name case sensitivity when using allowedInstallationOwners Signed-off-by: Lee Standen --- .changeset/thin-phones-press.md | 5 +++ ...eInstanceGithubCredentialsProvider.test.ts | 36 +++++++++++++++++++ ...SingleInstanceGithubCredentialsProvider.ts | 10 ++++-- 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 .changeset/thin-phones-press.md diff --git a/.changeset/thin-phones-press.md b/.changeset/thin-phones-press.md new file mode 100644 index 0000000000..8b3f985772 --- /dev/null +++ b/.changeset/thin-phones-press.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': patch +--- + +Fixes issue with Github credentials provider which fails to match organization name if using allowedInstallationOwners diff --git a/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.test.ts b/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.test.ts index ec07d1b465..0468e96e14 100644 --- a/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.test.ts +++ b/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.test.ts @@ -231,6 +231,42 @@ describe('SingleInstanceGithubCredentialsProvider tests', () => { expect(token).toEqual(undefined); }); + it('should not fail to issue tokens for an organization when there is a case mismatch in the organization name', async () => { + octokit.apps.listInstallations.mockResolvedValue({ + headers: { + etag: '123', + }, + data: [ + { + id: 1, + repository_selection: 'selected', + account: { + login: 'backstage', + }, + }, + ], + } as RestEndpointMethodTypes['apps']['listInstallations']['response']); + + octokit.apps.createInstallationAccessToken.mockResolvedValueOnce({ + data: { + expires_at: DateTime.local().plus({ hours: 1 }).toString(), + token: 'secret_token', + repository_selection: 'selected', + }, + } as RestEndpointMethodTypes['apps']['createInstallationAccessToken']['response']); + + octokit.apps.listReposAccessibleToInstallation.mockReturnValue({ + data: [{ name: 'some-repo' }], + } as unknown as RestEndpointMethodTypes['apps']['listReposAccessibleToInstallation']['response']); + + const { token, headers } = await github.getCredentials({ + url: 'https://github.com/Backstage', + }); + const expectedToken = 'secret_token'; + expect(headers).toEqual({ Authorization: `Bearer ${expectedToken}` }); + expect(token).toEqual('secret_token'); + }); + it('should not fail to issue tokens for an organization when the app is installed for a single repo', async () => { octokit.apps.listInstallations.mockResolvedValue({ headers: { diff --git a/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.ts b/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.ts index fd3c02486f..eaaabe8b0f 100644 --- a/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.ts +++ b/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.ts @@ -102,7 +102,9 @@ class GithubAppManager { private readonly allowedInstallationOwners: string[] | undefined; // undefined allows all installations constructor(config: GithubAppConfig, baseUrl?: string) { - this.allowedInstallationOwners = config.allowedInstallationOwners; + this.allowedInstallationOwners = config.allowedInstallationOwners?.map( + owner => owner.toLocaleLowerCase('en-US'), + ); this.baseUrl = baseUrl; this.baseAuthConfig = { appId: config.appId, @@ -121,7 +123,11 @@ class GithubAppManager { repo?: string, ): Promise<{ accessToken: string | undefined }> { if (this.allowedInstallationOwners) { - if (!this.allowedInstallationOwners?.includes(owner)) { + if ( + !this.allowedInstallationOwners?.includes( + owner.toLocaleLowerCase('en-US'), + ) + ) { return { accessToken: undefined }; // An empty token allows anonymous access to public repos } } From 8f9d0f947cdf710c629ae647c46c2ce63ece8ebd Mon Sep 17 00:00:00 2001 From: Hellgren Heikki Date: Thu, 11 Sep 2025 08:54:18 +0300 Subject: [PATCH 105/107] feat(microsite): add search for plugins Signed-off-by: Hellgren Heikki --- .../pluginsSearch/pluginsSearch.tsx | 19 +++++ microsite/src/pages/plugins/index.tsx | 84 ++++++++++++------- .../src/pages/plugins/plugins.module.scss | 14 ++++ 3 files changed, 88 insertions(+), 29 deletions(-) create mode 100644 microsite/src/components/pluginsSearch/pluginsSearch.tsx diff --git a/microsite/src/components/pluginsSearch/pluginsSearch.tsx b/microsite/src/components/pluginsSearch/pluginsSearch.tsx new file mode 100644 index 0000000000..68f9a2831a --- /dev/null +++ b/microsite/src/components/pluginsSearch/pluginsSearch.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +type Props = { + searchTerm: string; + onSearchTermChange: (newTerm: string) => void; +}; + +export const PluginsSearch = (props: Props) => { + const { searchTerm, onSearchTermChange } = props; + return ( + onSearchTermChange((e.target as HTMLInputElement).value)} + value={searchTerm} + placeholder="Search plugins..." + className="DocSearch-Input search" + /> + ); +}; diff --git a/microsite/src/pages/plugins/index.tsx b/microsite/src/pages/plugins/index.tsx index 2abdbabf4b..1a87f9c0af 100644 --- a/microsite/src/pages/plugins/index.tsx +++ b/microsite/src/pages/plugins/index.tsx @@ -5,10 +5,11 @@ import { truncateDescription } from '@site/src/util/truncateDescription'; import { ChipCategory } from '@site/src/util/types'; import Layout from '@theme/Layout'; import clsx from 'clsx'; -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { IPluginData, PluginCard } from './_pluginCard'; import pluginsStyles from './plugins.module.scss'; +import { PluginsSearch } from '@site/src/components/pluginsSearch/pluginsSearch'; interface IPluginsList { corePlugins: IPluginData[]; @@ -55,6 +56,7 @@ const Plugins = () => { const [selectedCategories, setSelectedCategories] = useState([]); const [showCoreFeatures, setShowCoreFeatures] = useState(true); const [showOtherPlugins, setShowOtherPlugins] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); const handleChipClick = (categoryName: string) => { const isSelected = @@ -97,6 +99,34 @@ const Plugins = () => { } }; + const matchesSearch = (pluginData: IPluginData, term: string) => { + if (!term) return true; + const lowerTerm = term.toLowerCase(); + return ( + pluginData.title.toLowerCase().includes(lowerTerm) || + pluginData.description.toLowerCase().includes(lowerTerm) || + pluginData.category.toLowerCase().includes(lowerTerm) || + (pluginData.author && pluginData.author.toLowerCase().includes(lowerTerm)) + ); + }; + + const matchesCategory = (pluginData: IPluginData, categories: string[]) => { + if (categories.length === 0) return true; + return categories.includes(pluginData.category); + }; + + const corePlugins = useMemo(() => { + return plugins.corePlugins + .filter(pluginData => matchesCategory(pluginData, selectedCategories)) + .filter(pluginData => matchesSearch(pluginData, searchTerm)); + }, [selectedCategories, searchTerm]); + + const otherPlugins = useMemo(() => { + return plugins.otherPlugins + .filter(pluginData => matchesCategory(pluginData, selectedCategories)) + .filter(pluginData => matchesSearch(pluginData, searchTerm)); + }, [selectedCategories, searchTerm]); + return (
{ categories={categories} handleChipClick={handleChipClick} /> +
- {showCoreFeatures && ( + {corePlugins.length === 0 && otherPlugins.length === 0 && ( +
+

No plugins found

+

+ We couldn't find any plugins matching your criteria. Please try + adjusting your search or filter settings. +

+
+ )} + + {showCoreFeatures && corePlugins.length > 0 && (
-

Core Features

+

Core Features ({corePlugins.length})

- {plugins.corePlugins - .filter( - pluginData => - !selectedCategories.length || - selectedCategories.includes(pluginData.category), - ) - .map(pluginData => ( - - ))} + {corePlugins.map(pluginData => ( + + ))}
)} - {showOtherPlugins && ( + {showOtherPlugins && otherPlugins.length > 0 && (
-

All Plugins

+

All Plugins ({otherPlugins.length})

Friendly reminder: While we love the variety and contributions of our open source plugins, they haven't been fully vetted by the @@ -158,18 +193,9 @@ const Plugins = () => { your due diligence before installing. Happy exploring!

- {plugins.otherPlugins - .filter( - pluginData => - !selectedCategories.length || - selectedCategories.includes(pluginData.category), - ) - .map(pluginData => ( - - ))} + {otherPlugins.map(pluginData => ( + + ))}
)} diff --git a/microsite/src/pages/plugins/plugins.module.scss b/microsite/src/pages/plugins/plugins.module.scss index fb17e90625..a17e4bc5c0 100644 --- a/microsite/src/pages/plugins/plugins.module.scss +++ b/microsite/src/pages/plugins/plugins.module.scss @@ -76,6 +76,20 @@ height: 1rem; } + :global(.search) { + float: right; + margin-bottom: 1rem; + margin-right: 1rem; + font-size: calc(0.875rem * var(--ifm-button-size-multiplier)); + font-weight: var(--ifm-button-font-weight); + background-color: transparent; + border: var(--ifm-button-border-width) solid var(--ifm-color-primary); + border-radius: var(--ifm-button-border-radius); + color: var(--ifm-color-primary); + width: 220px; + height: 2.2rem; + } + :global(.dropdown) { float: right; margin-bottom: 1rem; From 8c51c700202c573e6ce50b2b0e26c0295babb22e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 11 Sep 2025 13:16:16 +0200 Subject: [PATCH 106/107] fix a non-working example template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../sample-templates/notifications-demo/template.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/scaffolder-backend/sample-templates/notifications-demo/template.yaml b/plugins/scaffolder-backend/sample-templates/notifications-demo/template.yaml index abd18b1d42..56a7e7f5c6 100644 --- a/plugins/scaffolder-backend/sample-templates/notifications-demo/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/notifications-demo/template.yaml @@ -32,7 +32,7 @@ spec: title: Title type: string description: Notification title - description: + info: title: Description type: string description: Notification longer description @@ -67,7 +67,7 @@ spec: recipients: ${{ parameters.recipients }} entityRefs: ${{ parameters.entityRefs }} title: ${{ parameters.title }} - description: ${{ parameters.description }} + info: ${{ parameters.info }} link: ${{ parameters.link }} severity: ${{ parameters.severity }} topic: ${{ parameters.topic }} From 4815b120f8d45ae0741419ef227d79e6c8893046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 11 Sep 2025 13:49:15 +0200 Subject: [PATCH 107/107] fix notifications rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/cold-garlics-care.md | 5 +++++ plugins/notifications/src/alpha.tsx | 7 ++++--- 2 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 .changeset/cold-garlics-care.md diff --git a/.changeset/cold-garlics-care.md b/.changeset/cold-garlics-care.md new file mode 100644 index 0000000000..960ebe6bd4 --- /dev/null +++ b/.changeset/cold-garlics-care.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-notifications': patch +--- + +Fixed missing app context when rendering the notifications view diff --git a/plugins/notifications/src/alpha.tsx b/plugins/notifications/src/alpha.tsx index 6d32150c35..ca4be58209 100644 --- a/plugins/notifications/src/alpha.tsx +++ b/plugins/notifications/src/alpha.tsx @@ -23,6 +23,7 @@ import { } from '@backstage/frontend-plugin-api'; import { rootRouteRef } from './routes'; import { + compatWrapper, convertLegacyRouteRef, convertLegacyRouteRefs, } from '@backstage/core-compat-api'; @@ -33,9 +34,9 @@ const page = PageBlueprint.make({ path: '/notifications', routeRef: convertLegacyRouteRef(rootRouteRef), loader: () => - import('./components/NotificationsPage').then(m => ( - - )), + import('./components/NotificationsPage').then(m => + compatWrapper(), + ), }, });