Merge branch 'master' into gcp-ua-header

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-01-22 00:49:01 +01:00
committed by GitHub
202 changed files with 5099 additions and 9304 deletions
+15
View File
@@ -0,0 +1,15 @@
---
'@backstage/repo-tools': minor
---
Updates the OpenAPI client template to support the new format for identifying plugin ID. You should now use `info.title` like so,
```diff
info:
+ title: yourPluginId
- title: @internal/plugin-*-backend
servers:
- /
- - yourPluginId
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-okta-provider': patch
---
Added missing `additionalScopes` option to configuration schema.
+14
View File
@@ -0,0 +1,14 @@
---
'@backstage/plugin-explore-backend': patch
---
Add support for the new backend system.
A new backend plugin for the explore backend
was added and exported as `default`.
You can use it with the new backend system like
```ts title="packages/backend/src/index.ts"
backend.add(import('@backstage/plugin-explore-backend'));
```
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/app-defaults': patch
'@backstage/plugin-catalog': patch
---
Change default icon for `kind:resource` to the storage icon.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Use the externalized `auth-backend-module-microsoft-provider` again.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
Overflowing labels in OwnerPicker (Catalog) are now truncated. Hovering over them shows the full label
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-scaffolder-react': minor
'@backstage/plugin-scaffolder': minor
---
Remove the old legacy exports from `/alpha`
+11
View File
@@ -0,0 +1,11 @@
---
'@backstage/plugin-user-settings': patch
'@backstage/plugin-scaffolder': patch
'@backstage/plugin-tech-radar': patch
'@backstage/plugin-graphiql': patch
'@backstage/plugin-techdocs': patch
'@backstage/plugin-catalog': patch
'@backstage/plugin-search': patch
---
Use `convertLegacyRouteRefs` to define routes in `/alpha` export plugin.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-openapi-utils': patch
---
Internal updates due to `json-schema-to-ts`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Migrated oidc auth provider to new `@backstage/plugin-auth-backend-module-oidc-provider` module package.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-catalog-backend': minor
'@backstage/plugin-search-backend': minor
'@backstage/plugin-todo-backend': patch
---
Updates the OpenAPI spec to use plugin as `info.title` instead of package name.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': patch
---
Fixed inconsistencies in icons used for System and Template
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': patch
---
Removed alpha symbol from Task List header
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/catalog-client': minor
'@backstage/plugin-catalog-backend': minor
'@backstage/plugin-search-backend': minor
'@backstage/plugin-todo-backend': patch
---
Updates the OpenAPI specification title to plugin ID instead of package name.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-oidc-provider': minor
---
Created new `@backstage/plugin-auth-backend-module-oidc-provider` module package to house oidc auth provider migration.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-compat-api': patch
---
Added `convertLegacyRouteRefs` for bulk conversion of plugin routes.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-microsoft-provider': patch
---
Added the possibility to use custom scopes for performing login with Microsoft EntraID.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': minor
---
Exported `CatalogTable.defaultColumnsFunc` for defining the columns in `<CatalogTable />` of some Kinds while using the default columns for the others.
+25
View File
@@ -0,0 +1,25 @@
---
'@backstage/frontend-app-api': minor
---
**BREAKING**: The `app.routes.bindings` app-config mapping has been simplified. You now only need to specify the plugin ID and route ID on both sides of the mapping.
Old form:
```yaml
app:
routes:
bindings:
plugin.catalog.externalRoutes.viewTechDoc: plugin.techdocs.routes.docRoot
plugin.catalog.externalRoutes.createComponent: plugin.catalog-import.routes.importPage
```
New form:
```yaml
app:
routes:
bindings:
catalog.viewTechDoc: techdocs.docRoot
catalog.createComponent: catalog-import.importPage
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-app-api': patch
---
Improved the error message when data input/output shapes do not match
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs-module-addons-contrib': patch
---
Fix position of the ReportIssue component when is displaying at the top of the container.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-home': patch
---
Fix typo in VisitsStorageApi
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Updated dependencies in frontend plugin templates
+12
View File
@@ -0,0 +1,12 @@
---
'@backstage/plugin-cloudbuild': minor
---
Changed build list view to automatically filter builds based on repository name matching component-info's metadata.name.
Added optional `google.com/cloudbuild-repo-name` annotation which allows you to specify a different repository to filter on.
Added optional `google.com/cloudbuild-trigger-name` annotation which allows you to filter based on a trigger name instead of a repo name.
Updated the ReadMe with information about the filtering and some other minor verbiage updates.
Changed `substitutions.BRANCH_NAME` to `substitutions.REF_NAME` so that the Ref field is populated properly.
Added optional `google.com/cloudbuild-location` annotation which allows you to specify the Cloud Build location of your builds. Default is global scope.
Changed build list view to show builds in a specific location if the location annotation is used.
Updated ReadMe with information about the use of the location filtering.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-compat-api': patch
---
Plugins converted by `convertLegacyApp` now have their `routes` and `externalRoutes` included as well, allowing them to be used to bind external routes in configuration.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Updated the backend module template to make the module instance the package default export.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-devtools': patch
---
Added alpha support for the New Frontend System (Declarative Integration)
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-azure-devops': patch
---
Prefer `dev.azure.com/build-definition` annotation when it is provided, as it is more specific than `dev.azure.com/project-repo`. This can also be used as a filter for mono-repos.
+21 -18
View File
@@ -2,34 +2,37 @@ area:catalog:
- plugins/catalog/**/*
- plugins/catalog-*/**/*
- packages/catalog-*/**/*
area:scaffolder:
- plugins/scaffolder/**/*
- plugins/scaffolder-*/**/*
search:
- plugins/search/**/*
- plugins/search-*/**/*
- packages/search-*/**/*
homepage:
- plugins/home/**/*
area:discoverability: # search + home
- plugins/search/**/*
- plugins/search-*/**/*
- packages/search-*/**/*
- plugins/home/**/*
area:kubernetes:
- plugins/kubernetes/**/*
- plugins/kubernetes-*/**/*
area:permission:
- plugins/permission-*/**/*
area:scaffolder:
- plugins/scaffolder/**/*
- plugins/scaffolder-*/**/*
area:techdocs:
- plugins/techdocs/**/*
- plugins/techdocs-*/**/*
- packages/techdocs-*/**/*
documentation:
- docs/**/*
microsite:
- microsite/**/*
storybook:
- storybook/**/*
auth:
- plugins/auth-backend/**/*
- plugins/auth-*/**/*
- packages/core-app-api/src/apis/implementations/auth/**/*
- packages/core-app-api/src/lib/Auth*/**/*
- packages/core-plugin-api/src/apis/definitions/auth.ts
area:permission:
- plugins/permission-*/**/*
documentation:
- docs/**/*
homepage:
- plugins/home/**/*
microsite:
- microsite/**/*
search:
- plugins/search/**/*
- plugins/search-*/**/*
- packages/search-*/**/*
storybook:
- storybook/**/*
+1 -1
View File
@@ -216,7 +216,7 @@ jobs:
run: yarn backstage-cli repo lint --since origin/master
- name: test changed packages
run: yarn backstage-cli repo test --maxWorkers=4 --workerIdleMemoryLimit=1300M --since origin/master
run: yarn backstage-cli repo test --maxWorkers=3 --workerIdleMemoryLimit=1300M --since origin/master
env:
BACKSTAGE_TEST_DISABLE_DOCKER: 1
BACKSTAGE_TEST_DATABASE_POSTGRES16_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres16.ports[5432] }}
+1 -1
View File
@@ -98,7 +98,7 @@ jobs:
- name: test (and upload coverage)
run: |
yarn backstage-cli repo test --maxWorkers=4 --workerIdleMemoryLimit=1300M --coverage
yarn backstage-cli repo test --maxWorkers=3 --workerIdleMemoryLimit=1300M --coverage
bash <(curl -s https://codecov.io/bash)
# Upload code coverage for some specific flags. Also see .codecov.yml
bash <(curl -s https://codecov.io/bash) -f packages/core-app-api/coverage/* -F core-app-api
+2 -2
View File
@@ -76,7 +76,7 @@ jobs:
- name: Cache Manifests File
if: ${{ steps.event.outputs.ACTION != 'closed' }}
uses: actions/cache@v3.3.3
uses: actions/cache@v4.0.0
with:
path: manifests.rendered.yml
key: ${{ steps.hash.outputs.MANIFESTS_FILE_HASH }}
@@ -140,7 +140,7 @@ jobs:
- name: Fetch cached Manifests File
id: cache
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: manifests.rendered.yml
key: ${{ needs.cache-manifests-file.outputs.manifests-cache-key }}
+1 -1
View File
@@ -55,7 +55,7 @@ jobs:
run: yarn lint:type-deps
- name: test
run: yarn backstage-cli repo test --maxWorkers=4 --workerIdleMemoryLimit=1300M
run: yarn backstage-cli repo test --maxWorkers=3 --workerIdleMemoryLimit=1300M
env:
BACKSTAGE_TEST_DISABLE_DOCKER: 1
@@ -1 +1 @@
This terraform file should create a S3 bucket and setup IAM with a user with an inline policy which gives the user access to the bucket. After you have created the bucket, user and policy you should go to the user in the AWS console and create an access key. This access key should be used as the env variables in step 3a [here](https://backstage.io/docs/features/techdocs/using-cloud-storage#configuring-aws-s3-bucket-with-techdocs).
This Terraform file should create an S3 bucket and set up IAM with a user and an inline policy that authorises access to the bucket. After you've set up the bucket, user, and policy, go to the user's AWS dashboard and create an access key. This access key should be used to configure the environment variables in step 3a [here](https://backstage.io/docs/features/techdocs/using-cloud-storage#configuring-aws-s3-bucket-with-techdocs).
+4
View File
@@ -28,6 +28,7 @@ On the **API permissions** tab, click on `Add Permission`, then add the followin
- `openid`
- `profile`
- `User.Read`
- Optional custom scopes of the `Microsoft Graph` API defined in the app-config.yaml file.
Your company may require you to grant [admin consent](https://learn.microsoft.com/en-us/azure/active-directory/manage-apps/user-admin-consent-overview) for these permissions.
Even if your company doesn't require admin consent, you may wish to do so as it means users don't need to individually consent the first time they access backstage.
@@ -54,6 +55,8 @@ auth:
clientSecret: ${AZURE_CLIENT_SECRET}
tenantId: ${AZURE_TENANT_ID}
domainHint: ${AZURE_TENANT_ID}
additionalScopes:
- Mail.Send
```
The Microsoft provider is a structure with three mandatory configuration keys:
@@ -65,6 +68,7 @@ The Microsoft provider is a structure with three mandatory configuration keys:
Leave blank if your app registration is multi tenant.
When specified, this reduces login friction for users with accounts in multiple tenants by automatically filtering away accounts from other tenants.
For more details, see [Home Realm Discovery](https://learn.microsoft.com/en-us/azure/active-directory/manage-apps/home-realm-discovery-policy)
- `additionalScopes` (optional): List of scopes for the App Registration. The default and mandatory value is ['user.read'].
## Adding the provider to the Backstage frontend
+15
View File
@@ -0,0 +1,15 @@
---
id: index
title: Overview
description: FAQ Overview
---
This section contains answers to frequently asked questions about Backstage.
### [Product FAQ](../faq/product.md)
Questions related to product and design.
### [Technical FAQ](../faq/technical.md)
General technical questions about Backstage.
+66
View File
@@ -0,0 +1,66 @@
---
id: product
title: Product FAQ
description: Questions related to product and design.
---
### Can we call Backstage something different? So that it fits our company better?
Yes, Backstage is just a platform for building your own developer portal. We
happen to call our internal version Backstage, as well, as a reference to our
music roots. You can call your version whatever suits your team, company, or
brand.
### Is Backstage a monitoring platform?
No, but it can be! Backstage is designed to be a developer portal for all your
infrastructure tooling, services, and documentation. So, it's not a monitoring
platform — but that doesn't mean you can't integrate a monitoring tool into
Backstage by writing [a plugin](#what-is-a-plugin-in-backstage).
### How is Backstage licensed?
Backstage was released as open source software by Spotify and is licensed under
[Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0).
### Why did we open source Backstage?
We hope to see Backstage become the infrastructure standard everywhere. When we
saw how much Backstage improved developer experience and productivity
internally, we wanted to share those gains. After all, if Backstage can create
order in an engineering environment as open and diverse as ours, then we're
pretty sure it can create order (and boost productivity) anywhere. To learn
more, read our blog post,
"[What the heck is Backstage anyway?](https://backstage.io/blog/2020/03/18/what-is-backstage)"
### Will Spotify's internal plugins be open sourced, too?
Yes, we've already started releasing open source versions of some of the plugins
we use here, and we'll continue to do so.
[Plugins](#what-is-a-plugin-in-backstage) are the building blocks of
functionality in Backstage. We have over 120 plugins inside Spotify — many of
those are specialized for our use, so will remain internal and proprietary to
us. But we estimate that about a third of our existing plugins make good open
source candidates. (And we'll probably end up writing some brand new ones, too.)
### What's the roadmap for Backstage?
We envision three phases, which you can learn about in
[our project roadmap](../overview/roadmap.md). Even though the open source version
of Backstage is relatively new compared to our internal version, we have already
begun work on various aspects of all three phases. Looking at the
[milestones for active issues](https://github.com/backstage/backstage/milestones)
will also give you a sense of our progress.
### My company doesn't have thousands of developers or services. Is using Backstage excessive for our needs?
Not at all! A core reason to adopt Backstage is to standardize how software is
built at your company. It's easier to decide on those standards as a small
company, and grows in importance as the company grows. Backstage sets a
foundation, and an early investment in your infrastructure becomes even more
valuable as you grow.
### Our company has a strong design language system/brand that we want to incorporate. Does Backstage support this?
Yes! The Backstage UI is built using Material UI. With the theming capabilities
of Material UI, you are able to adapt the interface to your brand guidelines.
+5 -70
View File
@@ -1,74 +1,9 @@
---
id: FAQ
title: FAQ
description: All FAQ related to Backstage
id: technical
title: Technical FAQ
description: General technical questions about Backstage.
---
## Product FAQ
### Can we call Backstage something different? So that it fits our company better?
Yes, Backstage is just a platform for building your own developer portal. We
happen to call our internal version Backstage, as well, as a reference to our
music roots. You can call your version whatever suits your team, company, or
brand.
### Is Backstage a monitoring platform?
No, but it can be! Backstage is designed to be a developer portal for all your
infrastructure tooling, services, and documentation. So, it's not a monitoring
platform — but that doesn't mean you can't integrate a monitoring tool into
Backstage by writing [a plugin](#what-is-a-plugin-in-backstage).
### How is Backstage licensed?
Backstage was released as open source software by Spotify and is licensed under
[Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0).
### Why did we open source Backstage?
We hope to see Backstage become the infrastructure standard everywhere. When we
saw how much Backstage improved developer experience and productivity
internally, we wanted to share those gains. After all, if Backstage can create
order in an engineering environment as open and diverse as ours, then we're
pretty sure it can create order (and boost productivity) anywhere. To learn
more, read our blog post,
"[What the heck is Backstage anyway?](https://backstage.io/blog/2020/03/18/what-is-backstage)"
### Will Spotify's internal plugins be open sourced, too?
Yes, we've already started releasing open source versions of some of the plugins
we use here, and we'll continue to do so.
[Plugins](#what-is-a-plugin-in-backstage) are the building blocks of
functionality in Backstage. We have over 120 plugins inside Spotify — many of
those are specialized for our use, so will remain internal and proprietary to
us. But we estimate that about a third of our existing plugins make good open
source candidates. (And we'll probably end up writing some brand new ones, too.)
### What's the roadmap for Backstage?
We envision three phases, which you can learn about in
[our project roadmap](overview/roadmap.md). Even though the open source version
of Backstage is relatively new compared to our internal version, we have already
begun work on various aspects of all three phases. Looking at the
[milestones for active issues](https://github.com/backstage/backstage/milestones)
will also give you a sense of our progress.
### My company doesn't have thousands of developers or services. Is using Backstage excessive for our needs?
Not at all! A core reason to adopt Backstage is to standardize how software is
built at your company. It's easier to decide on those standards as a small
company, and grows in importance as the company grows. Backstage sets a
foundation, and an early investment in your infrastructure becomes even more
valuable as you grow.
### Our company has a strong design language system/brand that we want to incorporate. Does Backstage support this?
Yes! The Backstage UI is built using Material UI. With the theming capabilities
of Material UI, you are able to adapt the interface to your brand guidelines.
## Technical FAQ
### What technology does Backstage use?
Backstage is a large scale [TypeScript](https://www.typescriptlang.org/)
@@ -116,7 +51,7 @@ type of content. Plugins all use a common set of platform APIs and reusable UI
components. Plugins can fetch data either from the backend or an API exposed
through the proxy.
Learn more about [the different components](overview/what-is-backstage.md) that
Learn more about [the different components](../overview/what-is-backstage.md) that
make up Backstage.
### Why can't I dynamically install plugins without modifications to the app?
@@ -253,7 +188,7 @@ data is shared with.
Yes. The core frontend framework could be used for building any large-scale web
application where (1) multiple teams are building separate parts of the app, and
(2) you want the overall experience to be consistent. That being said, in
[Phase 2](overview/roadmap.md) of the project we will add features that are
[Phase 2](../overview/roadmap.md) of the project we will add features that are
needed for developer portals and systems for managing software ecosystems. Our
ambition will be to keep Backstage modular.
@@ -241,10 +241,10 @@ app:
bindings:
# point to the Scaffolder create component page when the Catalog create component ref is used
# highlight-next-line
plugin.catalog.externalRoutes.createComponent: plugin.scaffolder.routes.index
catalog.createComponent: scaffolder.index
# point to the Catalog details page when the Scaffolder component details ref is used
# highlight-next-line
plugin.scaffolder.externalRoutes.componentDetails: plugin.catalog.routes.details
scaffolder.componentDetails: catalog.details
```
We also have the ability to express this in code as an option to `createApp`, but you of course only need to use one of these two methods:
+10
View File
@@ -139,3 +139,13 @@ integration:
- `Variables`: `Read & write` (if templates include GitHub Action Repository Variables)
- `Secrets`: `Read & write` (if templates include GitHub Action Repository Secrets)
- `Environments`: `Read & write` (if templates include GitHub Environments)
### Troubleshooting
`HttpError: This endpoint requires you to be authenticated.`
This message tends to wrap a `NotFoundError: No app installation found` under the hood, which
is the result of not installing the app in your organization. Even if created via the `backstage-cli`
as a member and app manager of your organization, the app will not automatically install. You
must possess the `Owner` role in the organization to see the `Install` menu under your
app settings, then manually press `Install` to authorize the application.
+4 -7
View File
@@ -8,15 +8,12 @@ description: Documentation on how to create a client for a given OpenAPI spec
### Prerequisites
1. Add your plugin ID as the last `servers` item, like this,
1. Set your OpenAPI file's `info.title` to your pluginID like so,
```yaml
servers:
# first value, used for OpenAPI router validation.
- url: /
# final value, pluginId.
- url: catalog
info:
# your pluginId
title: catalog
```
2. Find or create a new plugin to house your new generated client. Currently, we do not support generating an entirely new plugin and instead just generate client files.
+1 -1
View File
@@ -11,7 +11,7 @@ description: Support and Community Details and Links
here if you want to contribute.
- [RFCs](https://github.com/backstage/backstage/labels/rfc) - Help shape the
technical direction by reviewing _Request for Comments_ issues.
- [FAQ](../FAQ.md) - Frequently Asked Questions.
- [FAQ](../faq/index.md) - Frequently Asked Questions.
- [Code of Conduct](https://github.com/backstage/backstage/blob/master/CODE_OF_CONDUCT.md) -
This is how we roll.
- [Blog](https://backstage.io/blog/) - Announcements and updates.
@@ -1,10 +1,10 @@
---
title: AWS App Developer Tools
title: OPA on AWS
author: Amazon Web Services
authorUrl: https://aws.amazon.com/
category: Infrastructure
description: Create and manage AWS Apps within Backstage
documentation: https://github.com/awslabs/app-development-for-backstage-io-on-aws#readme
iconUrl: https://github.com/awslabs/app-development-for-backstage-io-on-aws/blob/main/docs/images/AWS_logo.png?raw=true
description: Orchestrate Platforms and Applications (OPA) allows customers to build and manage AWS Apps & Environments within Backstage
documentation: https://opaonaws.io
iconUrl: https://github.com/awslabs/app-development-for-backstage-io-on-aws/blob/main/website/static/img/white_OPA_text02.png?raw=true
npmPackageName: '@aws/plugin-aws-apps-for-backstage'
addedDate: '2023-05-10'
+10
View File
@@ -0,0 +1,10 @@
---
title: Backchat GenAI
author: benwilcock
authorUrl: https://github.com/benwilcock
category: Services
description: Access your favorite open source GenAI GUIs privately from Backstage. Chat wth large language models in your portal. Choose from hundreds of LLMs. Run inferencing wherever you like - local or remote, CPU or GPU - it's up to you!
documentation: https://github.com/benwilcock/backstage-plugin-backchat
iconUrl: /img/backchat-logo.png
npmPackageName: '@benbravo73/backstage-plugin-backchat'
addedDate: '2024-01-12'
+10
View File
@@ -0,0 +1,10 @@
---
title: End of life
author: dweber019
authorUrl: https://github.com/dweber019
category: Quality
description: Display end of life data for entities from endoflife.data
documentation: https://github.com/dweber019/backstage-plugin-endoflife
iconUrl: https://raw.githubusercontent.com/dweber019/backstage-plugin-endoflife/main/plugins/endoflife/docs/pluginIcon.png
npmPackageName: '@dweber019/backstage-plugin-endoflife'
addedDate: '2024-01-18'
@@ -0,0 +1,15 @@
---
title: GitHub Codespaces
author: Aditya Singhal
authorUrl: https://github.com/adityasinghal26
category: Development
description: Integrates GitHub Codespaces for a Backstage component with the Authenticated User.
documentation: https://github.com/adityasinghal26/backstage-plugins/tree/main/plugins/github-codespaces
iconUrl: https://github.com/adityasinghal26/backstage-plugins/blob/00c9c00ba9acc3135014d6454ccf04f573195eef/plugins/github-codespaces/images/GitHubLogo.png
npmPackageName: '@adityasinghal26/plugin-github-codespaces'
tags:
- github
- codespaces
- development
- devcontainers
addedDate: '2023-12-30'
+4 -4
View File
@@ -18,7 +18,7 @@
"docusaurus": "docusaurus"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "0.0.0-5703",
"@docusaurus/module-type-aliases": "0.0.0-5809",
"@spotify/prettier-config": "^14.0.0",
"@tsconfig/docusaurus": "^2.0.0",
"@types/luxon": "^3.0.0",
@@ -30,9 +30,9 @@
},
"prettier": "@spotify/prettier-config",
"dependencies": {
"@docusaurus/core": "0.0.0-5703",
"@docusaurus/plugin-client-redirects": "0.0.0-5703",
"@docusaurus/preset-classic": "0.0.0-5703",
"@docusaurus/core": "0.0.0-5809",
"@docusaurus/plugin-client-redirects": "0.0.0-5809",
"@docusaurus/preset-classic": "0.0.0-5809",
"@swc/core": "^1.3.46",
"clsx": "^2.0.0",
"docusaurus-plugin-sass": "^0.2.3",
+1 -1
View File
@@ -478,7 +478,7 @@
"architecture-decisions/adrs-adr012",
"architecture-decisions/adrs-adr013"
],
"FAQ": ["FAQ"],
"FAQ": ["faq/index", "faq/product", "faq/technical"],
"Accessibility": ["accessibility/index"]
}
}
@@ -1,9 +1,13 @@
$page-header-height: 44px;
.hubSpotNewAdopterFormContainer {
top: 50%;
right: 0;
display: flex;
position: fixed;
transform: translateY(-50%);
margin-top: $page-header-height;
max-height: calc(100% - $page-header-height);
transition: all 250ms ease-in-out;
@@ -22,9 +26,7 @@
}
.hubSpotNewAdopterFormContent {
height: 600px;
max-width: 500px;
overflow-y: hidden;
border-radius: 8px 0 0 8px;
background-color: var(--ifm-color-white);
Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

+945 -1560
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -214,4 +214,7 @@ nav:
- ADR011 - Plugin Package Structure: 'architecture-decisions/adr011-plugin-package-structure.md'
- ADR012 - Plugin Package Structure: 'architecture-decisions/adr012-use-luxon-locale-and-date-presets.md'
- ADR013 - Plugin Package Structure: 'architecture-decisions/adr013-use-node-fetch.md'
- FAQ: FAQ.md
- FAQ:
- Overview: 'faq/index.md'
- Product FAQ: 'faq/product.md'
- Technical FAQ: 'faq/technical.md'
+2 -2
View File
@@ -34,7 +34,7 @@ import MuiMenuBookIcon from '@material-ui/icons/MenuBook';
import MuiPeopleIcon from '@material-ui/icons/People';
import MuiPersonIcon from '@material-ui/icons/Person';
import MuiWarningIcon from '@material-ui/icons/Warning';
import MuiWorkIcon from '@material-ui/icons/Work';
import MuiStorageIcon from '@material-ui/icons/Storage';
import MuiFeaturedPlayListIcon from '@material-ui/icons/FeaturedPlayList';
export const icons = {
@@ -58,7 +58,7 @@ export const icons = {
'kind:location': MuiLocationOnIcon as IconComponent,
'kind:system': MuiCategoryIcon as IconComponent,
'kind:user': MuiPersonIcon as IconComponent,
'kind:resource': MuiWorkIcon as IconComponent,
'kind:resource': MuiStorageIcon as IconComponent,
'kind:template': MuiFeaturedPlayListIcon as IconComponent,
user: MuiPersonIcon as IconComponent,
warning: MuiWarningIcon as IconComponent,
+3 -2
View File
@@ -3,8 +3,9 @@ app:
packages: 'all' # ✨
routes:
bindings:
plugin.pages.externalRoutes.pageX: plugin.pages.routes.pageX
plugin.catalog.externalRoutes.viewTechDoc: plugin.techdocs.routes.docRoot
pages.pageX: pages.pageX
catalog.viewTechDoc: techdocs.docRoot
catalog.createComponent: catalog-import.importPage
extensions:
# - apis.plugin.graphiql.browse.gitlab: true
-3
View File
@@ -51,8 +51,6 @@ import {
import { createSignInPageExtension } from '@backstage/frontend-plugin-api';
import { SignInPage } from '@backstage/core-components';
import linguistPlugin from '@backstage/plugin-linguist/alpha';
/*
# Notes
@@ -127,7 +125,6 @@ const app = createApp({
userSettingsPlugin,
homePlugin,
appVisualizerPlugin,
linguistPlugin,
...collectedLegacyPlugins,
createExtensionOverrides({
extensions: [
+23 -53
View File
@@ -31,7 +31,6 @@ import {
AppRouter,
ConfigReader,
defaultConfigLoader,
FeatureFlagged,
FlatRoutes,
} from '@backstage/core-app-api';
import {
@@ -64,7 +63,6 @@ import { GcpProjectsPage } from '@backstage/plugin-gcp-projects';
import { HomepageCompositionRoot, VisitListener } from '@backstage/plugin-home';
import { LighthousePage } from '@backstage/plugin-lighthouse';
import { NewRelicPage } from '@backstage/plugin-newrelic';
import { LegacyScaffolderPage } from '@backstage/plugin-scaffolder/alpha';
import { ScaffolderPage, scaffolderPlugin } from '@backstage/plugin-scaffolder';
import {
ScaffolderFieldExtensions,
@@ -96,10 +94,7 @@ import { apis } from './apis';
import { entityPage } from './components/catalog/EntityPage';
import { homePage } from './components/home/HomePage';
import { Root } from './components/Root';
import {
DelayingComponentFieldExtension,
LowerCaseValuePickerFieldExtension,
} from './components/scaffolder/customScaffolderExtensions';
import { DelayingComponentFieldExtension } from './components/scaffolder/customScaffolderExtensions';
import { defaultPreviewTemplate } from './components/scaffolder/defaultPreviewTemplate';
import { searchPage } from './components/search/SearchPage';
import { providers } from './identityProviders';
@@ -236,53 +231,28 @@ const routes = (
<LightBox />
</TechDocsAddons>
</Route>
<FeatureFlagged with="scaffolder-legacy">
<Route
path="/create"
element={
<LegacyScaffolderPage
groups={[
{
title: 'Recommended',
filter: entity =>
entity?.metadata?.tags?.includes('recommended') ?? false,
},
]}
/>
}
>
<ScaffolderFieldExtensions>
<LowerCaseValuePickerFieldExtension />
</ScaffolderFieldExtensions>
<ScaffolderLayouts>
<TwoColumnLayout />
</ScaffolderLayouts>
</Route>
</FeatureFlagged>
<FeatureFlagged without="scaffolder-legacy">
<Route
path="/create"
element={
<ScaffolderPage
defaultPreviewTemplate={defaultPreviewTemplate}
groups={[
{
title: 'Recommended',
filter: entity =>
entity?.metadata?.tags?.includes('recommended') ?? false,
},
]}
/>
}
>
<ScaffolderFieldExtensions>
<DelayingComponentFieldExtension />
</ScaffolderFieldExtensions>
<ScaffolderLayouts>
<TwoColumnLayout />
</ScaffolderLayouts>
</Route>
</FeatureFlagged>
<Route
path="/create"
element={
<ScaffolderPage
defaultPreviewTemplate={defaultPreviewTemplate}
groups={[
{
title: 'Recommended',
filter: entity =>
entity?.metadata?.tags?.includes('recommended') ?? false,
},
]}
/>
}
>
<ScaffolderFieldExtensions>
<DelayingComponentFieldExtension />
</ScaffolderFieldExtensions>
<ScaffolderLayouts>
<TwoColumnLayout />
</ScaffolderLayouts>
</Route>
<Route path="/explore" element={<ExplorePage />} />
<Route
path="/tech-radar"
+3 -3
View File
@@ -7,7 +7,7 @@ import type { ContentObject } from 'openapi3-ts';
import type core from 'express-serve-static-core';
import { Express as Express_2 } from 'express';
import { FromSchema } from 'json-schema-to-ts';
import { JSONSchema7 } from 'json-schema-to-ts';
import { JSONSchema } from 'json-schema-to-ts';
import { middleware } from 'express-openapi-validator';
import type { OpenAPIObject } from 'openapi3-ts';
import type { ParameterObject } from 'openapi3-ts';
@@ -62,7 +62,7 @@ type ComponentTypes<Doc extends RequiredDoc> = Extract<
// @public (undocumented)
type ConvertAll<T extends ReadonlyArray<unknown>> = {
[Index in keyof T]: T[Index] extends JSONSchema7
[Index in keyof T]: T[Index] extends JSONSchema
? FromSchema<T[Index]>
: T[Index];
} & {
@@ -463,7 +463,7 @@ type ParameterSchema<
Schema extends ImmutableParameterObject['schema'],
> = SchemaRef<Doc, Schema> extends infer R
? R extends ImmutableSchemaObject
? R extends JSONSchema7
? R extends JSONSchema
? FromSchema<R>
: never
: never
@@ -18,7 +18,7 @@
* Pulled from https://github.com/varanauskas/oatx.
*/
import { FromSchema, JSONSchema7 } from 'json-schema-to-ts';
import { FromSchema, JSONSchema } from 'json-schema-to-ts';
import {
ImmutableContentObject,
ImmutableOpenAPIObject,
@@ -221,7 +221,7 @@ export type TuplifyUnion<
* @public
*/
export type ConvertAll<T extends ReadonlyArray<unknown>> = {
[Index in keyof T]: T[Index] extends JSONSchema7
[Index in keyof T]: T[Index] extends JSONSchema
? FromSchema<T[Index]>
: T[Index];
} & { length: T['length'] };
@@ -36,7 +36,7 @@ import {
SchemaRef,
ValueOf,
} from './common';
import { FromSchema, JSONSchema7 } from 'json-schema-to-ts';
import { FromSchema, JSONSchema } from 'json-schema-to-ts';
/**
* @public
@@ -96,7 +96,7 @@ export type ParameterSchema<
Schema extends ImmutableParameterObject['schema'],
> = SchemaRef<Doc, Schema> extends infer R
? R extends ImmutableSchemaObject
? R extends JSONSchema7
? R extends JSONSchema
? FromSchema<R>
: never
: never
+1 -1
View File
@@ -82,7 +82,7 @@
"@gitbeaker/node": "^35.1.0",
"@octokit/rest": "^19.0.3",
"@opentelemetry/api": "^1.4.1",
"@opentelemetry/exporter-prometheus": "^0.45.0",
"@opentelemetry/exporter-prometheus": "^0.47.0",
"@opentelemetry/sdk-metrics": "^1.13.0",
"azure-devops-node-api": "^12.0.0",
"better-sqlite3": "^9.0.0",
@@ -5,4 +5,4 @@
* @packageDocumentation
*/
export { {{moduleVar}} } from './module';
export { {{moduleVar}} as default } from './module';
@@ -45,8 +45,8 @@
"@backstage/core-app-api": "{{versionQuery '@backstage/core-app-api'}}",
"@backstage/dev-utils": "{{versionQuery '@backstage/dev-utils'}}",
"@backstage/test-utils": "{{versionQuery '@backstage/test-utils'}}",
"@testing-library/jest-dom": "{{versionQuery '@testing-library/jest-dom' '5.10.1'}}",
"@testing-library/react": "{{versionQuery '@testing-library/react' '12.1.3'}}",
"@testing-library/jest-dom": "{{versionQuery '@testing-library/jest-dom' '6.0.0'}}",
"@testing-library/react": "{{versionQuery '@testing-library/react' '14.0.0'}}",
"@testing-library/user-event": "{{versionQuery '@testing-library/user-event' '14.0.0'}}",
"msw": "{{versionQuery 'msw' '1.0.0'}}"
},
@@ -39,8 +39,8 @@
"devDependencies": {
"@backstage/cli": "{{versionQuery '@backstage/cli'}}",
"@backstage/test-utils": "{{versionQuery '@backstage/test-utils'}}",
"@testing-library/jest-dom": "{{versionQuery '@testing-library/jest-dom' '5.10.1'}}",
"@testing-library/react": "{{versionQuery '@testing-library/react' '12.1.3'}}"
"@testing-library/jest-dom": "{{versionQuery '@testing-library/jest-dom' '6.0.0'}}",
"@testing-library/react": "{{versionQuery '@testing-library/react' '14.0.0'}}"
},
"files": [
"dist"
@@ -30,7 +30,7 @@
},
"devDependencies": {
"@backstage/cli": "{{versionQuery '@backstage/cli'}}",
"@testing-library/jest-dom": "{{versionQuery '@testing-library/jest-dom' '5.10.1'}}"
"@testing-library/jest-dom": "{{versionQuery '@testing-library/jest-dom' '6.0.0'}}"
},
"files": [
"dist"
+21
View File
@@ -40,5 +40,26 @@ export function convertLegacyRouteRef<
ref: ExternalRouteRef<TParams, TOptional>,
): ExternalRouteRef_2<TParams, TOptional>;
// @public
export function convertLegacyRouteRefs<
TRefs extends {
[name in string]: RouteRef | SubRouteRef | ExternalRouteRef;
},
>(
refs: TRefs,
): {
[KName in keyof TRefs]: ToNewRouteRef<TRefs[KName]>;
};
// @public
export type ToNewRouteRef<T extends RouteRef | SubRouteRef | ExternalRouteRef> =
T extends RouteRef<infer IParams>
? RouteRef_2<IParams>
: T extends SubRouteRef<infer IParams>
? SubRouteRef_2<IParams>
: T extends ExternalRouteRef<infer IParams, infer IOptional>
? ExternalRouteRef_2<IParams, IOptional>
: never;
// (No @packageDocumentation comment for this package)
```
@@ -32,7 +32,10 @@ import {
} from '@backstage/frontend-plugin-api';
import React, { Children, ReactNode, isValidElement } from 'react';
import { Route, Routes } from 'react-router-dom';
import { convertLegacyRouteRef } from './convertLegacyRouteRef';
import {
convertLegacyRouteRef,
convertLegacyRouteRefs,
} from './convertLegacyRouteRef';
import { compatWrapper } from './compatWrapper';
/*
@@ -244,6 +247,8 @@ export function collectLegacyRoutes(
createApiExtension({ factory }),
),
],
routes: convertLegacyRouteRefs(plugin.routes ?? {}),
externalRoutes: convertLegacyRouteRefs(plugin.externalRoutes ?? {}),
}),
);
}
@@ -40,6 +40,43 @@ import { toInternalSubRouteRef } from '../../frontend-plugin-api/src/routing/Sub
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalExternalRouteRef } from '../../frontend-plugin-api/src/routing/ExternalRouteRef';
/**
* Converts a legacy route ref type to the new system.
*
* @public
*/
export type ToNewRouteRef<
T extends LegacyRouteRef | LegacySubRouteRef | LegacyExternalRouteRef,
> = T extends LegacyRouteRef<infer IParams>
? RouteRef<IParams>
: T extends LegacySubRouteRef<infer IParams>
? SubRouteRef<IParams>
: T extends LegacyExternalRouteRef<infer IParams, infer IOptional>
? ExternalRouteRef<IParams, IOptional>
: never;
/**
* Converts a collection of legacy route refs to the new system.
* This is particularly useful when defining plugin `routes` and `externalRoutes`.
*
* @public
*/
export function convertLegacyRouteRefs<
TRefs extends {
[name in string]:
| LegacyRouteRef
| LegacySubRouteRef
| LegacyExternalRouteRef;
},
>(refs: TRefs): { [KName in keyof TRefs]: ToNewRouteRef<TRefs[KName]> } {
return Object.fromEntries(
Object.entries(refs).map(([name, ref]) => [
name,
convertLegacyRouteRef(ref as LegacyRouteRef),
]),
) as { [KName in keyof TRefs]: ToNewRouteRef<TRefs[KName]> };
}
/**
* A temporary helper to convert a legacy route ref to the new system.
*
+5 -1
View File
@@ -16,4 +16,8 @@
export * from './compatWrapper';
export { convertLegacyApp } from './convertLegacyApp';
export { convertLegacyRouteRef } from './convertLegacyRouteRef';
export {
convertLegacyRouteRef,
convertLegacyRouteRefs,
type ToNewRouteRef,
} from './convertLegacyRouteRef';
@@ -37,15 +37,13 @@ describe('collectRouteIds', () => {
createPlugin({ id: 'test', routes: { ref }, externalRoutes: { extRef } }),
]);
expect(Object.fromEntries(collected.routes)).toEqual({
'plugin.test.routes.ref': ref,
'test.ref': ref,
});
expect(Object.fromEntries(collected.externalRoutes)).toEqual({
'plugin.test.externalRoutes.extRef': extRef,
'test.extRef': extRef,
});
expect(String(ref)).toBe('RouteRef{plugin.test.routes.ref}');
expect(String(extRef)).toBe(
'ExternalRouteRef{plugin.test.externalRoutes.extRef}',
);
expect(String(ref)).toBe('RouteRef{test.ref}');
expect(String(extRef)).toBe('ExternalRouteRef{test.extRef}');
});
});
@@ -47,7 +47,7 @@ export function collectRouteIds(features: FrontendFeature[]): RouteRefsById {
}
for (const [name, ref] of Object.entries(feature.routes)) {
const refId = `plugin.${feature.id}.routes.${name}`;
const refId = `${feature.id}.${name}`;
if (routesById.has(refId)) {
throw new Error(`Unexpected duplicate route '${refId}'`);
}
@@ -62,7 +62,7 @@ export function collectRouteIds(features: FrontendFeature[]): RouteRefsById {
}
}
for (const [name, ref] of Object.entries(feature.externalRoutes)) {
const refId = `plugin.${feature.id}.externalRoutes.${name}`;
const refId = `${feature.id}.${name}`;
if (externalRoutesById.has(refId)) {
throw new Error(`Unexpected duplicate external route '${refId}'`);
}
@@ -615,8 +615,8 @@ describe('createAppNodeInstance', () => {
),
),
}),
).toThrow(
"Failed to instantiate extension 'app/test', input 'singleton' did not receive required extension data 'other' from extension 'app/test'",
).toThrowErrorMatchingInlineSnapshot(
`"Failed to instantiate extension 'app/test', extension 'app/test' could not be attached because its output data ('test', 'other') does not match what the input 'singleton' requires ('other')"`,
);
});
});
@@ -37,8 +37,17 @@ function resolveInputData(
return mapValues(dataMap, ref => {
const value = attachment.instance?.getData(ref);
if (value === undefined && !ref.config.optional) {
const expected = Object.values(dataMap)
.filter(r => !r.config.optional)
.map(r => `'${r.id}'`)
.join(', ');
const provided = [...(attachment.instance?.getDataRefs() ?? [])]
.map(r => `'${r.id}'`)
.join(', ');
throw new Error(
`input '${inputName}' did not receive required extension data '${ref.id}' from extension '${attachment.spec.id}'`,
`extension '${attachment.spec.id}' could not be attached because its output data (${provided}) does not match what the input '${inputName}' requires (${expected})`,
);
}
return value;
@@ -63,8 +63,8 @@ describe('createExtensionTester', () => {
factory: () => ({ path: '/foo' }),
});
const tester = createExtensionTester(extension);
expect(() => tester.render()).toThrow(
"Failed to instantiate extension 'app/routes', input 'routes' did not receive required extension data 'core.reactElement' from extension 'test'",
expect(() => tester.render()).toThrowErrorMatchingInlineSnapshot(
`"Failed to instantiate extension 'app/routes', extension 'test' could not be attached because its output data ('core.routing.path') does not match what the input 'routes' requires ('core.routing.path', 'core.reactElement')"`,
);
});
@@ -1,6 +1,2 @@
{{#servers}}
{{#-last}}
export const pluginId = "{{url}}";
{{/-last}}
{{/servers}}
export const pluginId = "{{appName}}";
@@ -28,6 +28,7 @@ export interface Config {
clientSecret: string;
domainHint?: string;
callbackUrl?: string;
additionalScopes?: string[];
};
};
};
@@ -27,6 +27,7 @@
"@backstage/plugin-auth-node": "workspace:^",
"express": "^4.18.2",
"jose": "^4.6.0",
"lodash": "^4.17.21",
"node-fetch": "^2.6.7",
"passport": "^0.7.0",
"passport-microsoft": "^1.0.0"
@@ -113,6 +113,7 @@ describe('microsoftAuthenticator', () => {
tenantId: 'tenantId',
clientId: 'clientId',
clientSecret: 'clientSecret',
additionalScopes: ['User.Read.All'],
}),
});
});
@@ -21,6 +21,7 @@ import {
PassportProfile,
} from '@backstage/plugin-auth-node';
import { ExtendedMicrosoftStrategy } from './strategy';
import { union } from 'lodash';
/** @public */
export const microsoftAuthenticator = createOAuthAuthenticator({
@@ -31,6 +32,10 @@ export const microsoftAuthenticator = createOAuthAuthenticator({
const clientSecret = config.getString('clientSecret');
const tenantId = config.getString('tenantId');
const domainHint = config.getOptionalString('domainHint');
const scope = union(
['user.read'],
config.getOptionalStringArray('additionalScopes'),
);
const helper = PassportOAuthAuthenticatorHelper.from(
new ExtendedMicrosoftStrategy(
@@ -39,7 +44,7 @@ export const microsoftAuthenticator = createOAuthAuthenticator({
clientSecret: clientSecret,
callbackURL: callbackUrl,
tenant: tenantId,
scope: ['user.read'],
scope: scope,
},
(
accessToken: string,
@@ -38,6 +38,7 @@ describe('authModuleMicrosoftProvider', () => {
clientId: 'my-client-id',
clientSecret: 'my-client-secret',
tenantId: 'my-tenant-id',
additionalScopes: ['User.Read.All'],
},
},
},
@@ -66,7 +67,7 @@ describe('authModuleMicrosoftProvider', () => {
expect(startUrl.pathname).toBe('/my-tenant-id/oauth2/v2.0/authorize');
expect(Object.fromEntries(startUrl.searchParams)).toEqual({
response_type: 'code',
scope: 'user.read',
scope: 'user.read User.Read.All',
client_id: 'my-client-id',
redirect_uri: `http://localhost:${server.port()}/api/auth/microsoft/handler/frame`,
state: expect.any(String),
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,8 @@
# Auth Module: Oidc Provider
This module provides an Oidc auth provider implementation for `@backstage/plugin-auth-backend`.
## Links
- [Repository](https://oidc.com/backstage/backstage/tree/master/plugins/auth-backend-module-oidc-provider)
- [Backstage Project Homepage](https://backstage.io)
@@ -0,0 +1,50 @@
## API Report File for "@backstage/plugin-auth-backend-module-oidc-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 { BaseClient } from 'openid-client';
import { OAuthAuthenticator } from '@backstage/plugin-auth-node';
import { PassportOAuthAuthenticatorHelper } from '@backstage/plugin-auth-node';
import { SignInResolverFactory } from '@backstage/plugin-auth-node';
import { Strategy } from 'openid-client';
import { TokenSet } from 'openid-client';
import { UserinfoResponse } from 'openid-client';
// @public (undocumented)
const authModuleOidcProvider: () => BackendFeature;
export default authModuleOidcProvider;
// @public (undocumented)
export const oidcAuthenticator: OAuthAuthenticator<
{
initializedScope: string | undefined;
initializedPrompt: string | undefined;
promise: Promise<{
helper: PassportOAuthAuthenticatorHelper;
client: BaseClient;
strategy: Strategy<OidcAuthResult, BaseClient>;
}>;
},
OidcAuthResult
>;
// @public
export type OidcAuthResult = {
tokenset: TokenSet;
userinfo: UserinfoResponse;
};
// @public
export namespace oidcSignInResolvers {
const emailLocalPartMatchingUserEntityName: SignInResolverFactory<
unknown,
unknown
>;
const emailMatchingUserEntityProfileEmail: SignInResolverFactory<
unknown,
unknown
>;
}
```
@@ -0,0 +1,10 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-plugin-auth-backend-module-oidc-provider
title: '@backstage/plugin-auth-backend-module-oidc-provider'
description: The oidc-provider backend module for the auth plugin.
spec:
lifecycle: experimental
type: backstage-backend-plugin-module
owner: maintainers
@@ -14,17 +14,25 @@
* limitations under the License.
*/
import { renderInTestApp } from '@backstage/test-utils';
import CloudIcon from '@material-ui/icons/Cloud';
import React from 'react';
import { IconLink } from './IconLink';
describe('IconLink', () => {
it('should render an icon link', async () => {
const rendered = await renderInTestApp(
<IconLink href="https://example.com" text="I am Link" Icon={CloudIcon} />,
);
expect(rendered.getByText('I am Link')).toBeInTheDocument();
});
});
export interface Config {
auth?: {
providers?: {
/** @visibility frontend */
oidc?: {
[authEnv: string]: {
clientId: string;
/**
* @visibility secret
*/
clientSecret: string;
metadataUrl: string;
callbackUrl?: string;
tokenEndpointAuthMethod?: string;
tokenSignedResponseAlg?: string;
scope?: string;
prompt?: string;
};
};
};
};
}
@@ -13,5 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { LegacyRouter, type LegacyRouterProps } from './Router';
export { LegacyScaffolderPage } from '../plugin';
import { createBackend } from '@backstage/backend-defaults';
const backend = createBackend();
backend.add(import('@backstage/plugin-auth-backend'));
backend.add(import('../src'));
backend.start();
@@ -0,0 +1,51 @@
{
"name": "@backstage/plugin-auth-backend-module-oidc-provider",
"description": "The oidc-provider backend module for the auth plugin.",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "backend-plugin-module"
},
"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/backend-common": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/plugin-auth-backend": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"express": "^4.18.2",
"openid-client": "^5.5.0",
"passport": "^0.6.0"
},
"devDependencies": {
"@backstage/backend-defaults": "workspace:^",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@backstage/config": "workspace:^",
"cookie-parser": "^1.4.6",
"express-promise-router": "^4.1.1",
"express-session": "^1.17.3",
"jose": "^4.14.6",
"msw": "^1.3.1",
"supertest": "^6.3.3"
},
"configSchema": "config.d.ts",
"files": [
"dist",
"config.d.ts"
]
}
@@ -0,0 +1,437 @@
/*
* 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.
*/
import {
OAuthAuthenticatorAuthenticateInput,
OAuthAuthenticatorRefreshInput,
OAuthAuthenticatorStartInput,
OAuthState,
decodeOAuthState,
encodeOAuthState,
} from '@backstage/plugin-auth-node';
import { oidcAuthenticator } from './authenticator';
import { setupServer } from 'msw/node';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { ConfigReader } from '@backstage/config';
import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose';
import { rest } from 'msw';
import express from 'express';
describe('oidcAuthenticator', () => {
let implementation: any;
let oauthState: OAuthState;
let idToken: string;
let publicKey: JWK;
const mswServer = setupServer();
setupRequestMockHandlers(mswServer);
const issuerMetadata = {
issuer: 'https://oidc.test',
authorization_endpoint: 'https://oidc.test/oauth2/authorize',
token_endpoint: 'https://oidc.test/oauth2/token',
revocation_endpoint: 'https://oidc.test/oauth2/revoke_token',
userinfo_endpoint: 'https://oidc.test/idp/userinfo.openid',
introspection_endpoint: 'https://oidc.test/introspect.oauth2',
jwks_uri: 'https://oidc.test/jwks.json',
scopes_supported: [
'openid',
'offline_access',
'oidc:request-audience',
'username',
'groups',
],
claims_supported: ['email', 'username', 'groups', 'additionalClaims'],
response_types_supported: ['code'],
id_token_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'],
token_endpoint_auth_signing_alg_values_supported: [
'RS256',
'RS512',
'HS256',
],
request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'],
};
beforeAll(async () => {
const keyPair = await generateKeyPair('RS256');
const privateKey = await exportJWK(keyPair.privateKey);
publicKey = await exportJWK(keyPair.publicKey);
publicKey.alg = privateKey.alg = 'RS256';
idToken = await new SignJWT({
sub: 'test',
iss: 'https://oidc.test',
iat: Date.now(),
aud: 'clientId',
exp: Date.now() + 10000,
})
.setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid })
.sign(keyPair.privateKey);
});
beforeEach(() => {
mswServer.use(
rest.get(
'https://oidc.test/.well-known/openid-configuration',
(_req, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(issuerMetadata),
),
),
rest.get('https://oidc.test/jwks.json', async (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })),
),
rest.post('https://oidc.test/oauth2/token', async (req, res, ctx) => {
return res(
req.headers.get('Authorization')
? ctx.json({
access_token: 'accessToken',
id_token: idToken,
refresh_token: 'refreshToken',
scope: 'testScope',
expires_in: 3600,
})
: ctx.status(401),
);
}),
rest.get(
'https://oidc.test/idp/userinfo.openid',
async (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
sub: 'test',
name: 'Alice Adams',
given_name: 'Alice',
family_name: 'Adams',
email: 'alice@test.com',
picture: 'http://testPictureUrl/photo.jpg',
}),
),
),
);
implementation = oidcAuthenticator.initialize({
callbackUrl: 'https://backstage.test/callback',
config: new ConfigReader({
metadataUrl: 'https://oidc.test/.well-known/openid-configuration',
clientId: 'clientId',
clientSecret: 'clientSecret',
}),
});
oauthState = {
nonce: 'nonce',
env: 'env',
};
});
afterEach(() => {
jest.clearAllMocks();
});
describe('#start', () => {
let fakeSession: Record<string, any>;
let startRequest: OAuthAuthenticatorStartInput;
beforeEach(() => {
fakeSession = {};
startRequest = {
state: encodeOAuthState(oauthState),
req: {
method: 'GET',
url: 'test',
session: fakeSession,
},
} as unknown as OAuthAuthenticatorStartInput;
});
it('redirects to authorization endpoint returned from OIDC metadata endpoint', async () => {
const startResponse = await oidcAuthenticator.start(
startRequest,
implementation,
);
const url = new URL(startResponse.url);
expect(url.protocol).toBe('https:');
expect(url.hostname).toBe('oidc.test');
expect(url.pathname).toBe('/oauth2/authorize');
});
it('initiates authorization code grant', async () => {
const startResponse = await oidcAuthenticator.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 oidcAuthenticator.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 oidcAuthenticator.start(
startRequest,
implementation,
);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('redirect_uri')).toBe(
'https://backstage.test/callback',
);
});
it('generates PKCE challenge', async () => {
const startResponse = await oidcAuthenticator.start(
startRequest,
implementation,
);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('code_challenge_method')).toBe('S256');
expect(searchParams.get('code_challenge')).not.toBeNull();
});
it('stores PKCE verifier in session', async () => {
await oidcAuthenticator.start(startRequest, implementation);
expect(fakeSession['oidc:oidc.test'].code_verifier).toBeDefined();
});
it('requests default scopes if none are provided in config', async () => {
const startResponse = await oidcAuthenticator.start(
startRequest,
implementation,
);
const { searchParams } = new URL(startResponse.url);
const scopes = searchParams.get('scope')?.split(' ') ?? [];
expect(scopes).toEqual(
expect.arrayContaining(['openid', 'profile', 'email']),
);
});
it('encodes OAuth state in query param', async () => {
const startResponse = await oidcAuthenticator.start(
startRequest,
implementation,
);
const { searchParams } = new URL(startResponse.url);
const stateParam = searchParams.get('state');
const decodedState = decodeOAuthState(stateParam!);
expect(decodedState).toMatchObject(oauthState);
});
it('fails when request has no session', async () => {
return expect(
oidcAuthenticator.start(
{
state: encodeOAuthState(oauthState),
req: {
method: 'GET',
url: 'test',
},
} as unknown as OAuthAuthenticatorStartInput,
implementation,
),
).rejects.toThrow('authentication requires session support');
});
});
describe('#authenticate', () => {
let handlerRequest: OAuthAuthenticatorAuthenticateInput;
beforeEach(() => {
handlerRequest = {
req: {
method: 'GET',
url: `https://test?code=authorization_code&state=${encodeOAuthState(
oauthState,
)}`,
session: {
'oidc:oidc.test': {
state: encodeOAuthState(oauthState),
},
},
} as unknown as express.Request,
};
});
it('exchanges authorization code for access token', async () => {
const authenticatorResult = await oidcAuthenticator.authenticate(
handlerRequest,
implementation,
);
const accessToken = authenticatorResult.session.accessToken;
expect(accessToken).toEqual('accessToken');
});
it('exchanges authorization code for refresh token', async () => {
const authenticatorResult = await oidcAuthenticator.authenticate(
handlerRequest,
implementation,
);
const refreshToken = authenticatorResult.session.refreshToken;
expect(refreshToken).toEqual('refreshToken');
});
it('returns granted scope', async () => {
const authenticatorResult = await oidcAuthenticator.authenticate(
handlerRequest,
implementation,
);
const responseScope = authenticatorResult.session.scope;
expect(responseScope).toEqual('testScope');
});
it('returns a default session.tokentype field', async () => {
const authenticatorResult = await oidcAuthenticator.authenticate(
handlerRequest,
implementation,
);
const tokenType = authenticatorResult.session.tokenType;
expect(tokenType).toEqual('bearer');
});
it('returns picture and email', async () => {
const authenticatorResult = await oidcAuthenticator.authenticate(
handlerRequest,
implementation,
);
expect(authenticatorResult).toMatchObject({
fullProfile: {
userinfo: {
email: 'alice@test.com',
picture: 'http://testPictureUrl/photo.jpg',
name: 'Alice Adams',
},
},
});
});
it('returns idToken', async () => {
const authenticatorResult = await oidcAuthenticator.authenticate(
handlerRequest,
implementation,
);
expect(authenticatorResult).toMatchObject({
session: {
idToken,
},
});
expect(
Math.abs(authenticatorResult.session.expiresInSeconds! - 3600),
).toBeLessThan(5);
});
it('fails without authorization code', async () => {
handlerRequest.req.url = 'https://test.com';
return expect(
oidcAuthenticator.authenticate(handlerRequest, implementation),
).rejects.toThrow('Unexpected redirect');
});
it('fails without oauth state', async () => {
return expect(
oidcAuthenticator.authenticate(
{
req: {
method: 'GET',
url: `https://test?code=authorization_code}`,
session: {
['oidc:pinniped.test']: {
state: { handle: 'sessionid', code_verifier: 'foo' },
},
},
} as unknown as express.Request,
},
implementation,
),
).rejects.toThrow(
'Authentication failed, did not find expected authorization request details in session, req.session["oidc:oidc.test"] is undefined',
);
});
it('fails when request has no session', async () => {
return expect(
oidcAuthenticator.authenticate(
{
req: {
method: 'GET',
url: 'https://test.com',
} as unknown as express.Request,
},
implementation,
),
).rejects.toThrow('authentication requires session support');
});
});
describe('#refresh', () => {
let refreshRequest: OAuthAuthenticatorRefreshInput;
beforeEach(() => {
refreshRequest = {
scope: '',
refreshToken: 'otherRefreshToken',
req: {} as express.Request,
};
});
it('gets new refresh token', async () => {
const refreshResponse = await oidcAuthenticator.refresh(
refreshRequest,
implementation,
);
expect(refreshResponse.session.refreshToken).toBe('refreshToken');
});
it('gets access token', async () => {
const refreshResponse = await oidcAuthenticator.refresh(
refreshRequest,
implementation,
);
expect(refreshResponse.session.accessToken).toBe('accessToken');
});
it('gets id token', async () => {
const refreshResponse = await oidcAuthenticator.refresh(
refreshRequest,
implementation,
);
expect(refreshResponse.session.idToken).toBe(idToken);
});
});
});
@@ -0,0 +1,187 @@
/*
* 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.
*/
import {
Issuer,
ClientAuthMethod,
TokenSet,
UserinfoResponse,
Strategy as OidcStrategy,
} from 'openid-client';
import {
createOAuthAuthenticator,
OAuthAuthenticatorResult,
PassportDoneCallback,
PassportHelpers,
PassportOAuthAuthenticatorHelper,
PassportOAuthPrivateInfo,
} from '@backstage/plugin-auth-node';
/**
* authentication result for the OIDC which includes the token set and user
* profile response
* @public
*/
export type OidcAuthResult = {
tokenset: TokenSet;
userinfo: UserinfoResponse;
};
/** @public */
export const oidcAuthenticator = createOAuthAuthenticator({
defaultProfileTransform: async (
input: OAuthAuthenticatorResult<OidcAuthResult>,
) => ({
profile: {
email: input.fullProfile.userinfo.email,
picture: input.fullProfile.userinfo.picture,
displayName: input.fullProfile.userinfo.name,
},
}),
initialize({ callbackUrl, config }) {
const clientId = config.getString('clientId');
const clientSecret = config.getString('clientSecret');
const metadataUrl = config.getString('metadataUrl');
const customCallbackUrl = config.getOptionalString('callbackUrl');
const tokenEndpointAuthMethod = config.getOptionalString(
'tokenEndpointAuthMethod',
) as ClientAuthMethod;
const tokenSignedResponseAlg = config.getOptionalString(
'tokenSignedResponseAlg',
);
const initializedScope = config.getOptionalString('scope');
const initializedPrompt = config.getOptionalString('prompt');
const promise = Issuer.discover(metadataUrl).then(issuer => {
const client = new issuer.Client({
access_type: 'offline', // this option must be passed to provider to receive a refresh token
client_id: clientId,
client_secret: clientSecret,
redirect_uris: [customCallbackUrl || callbackUrl],
response_types: ['code'],
token_endpoint_auth_method:
tokenEndpointAuthMethod || 'client_secret_basic',
id_token_signed_response_alg: tokenSignedResponseAlg || 'RS256',
scope: initializedScope || '',
});
const strategy = new OidcStrategy(
{
client,
passReqToCallback: false,
},
(
tokenset: TokenSet,
userinfo: UserinfoResponse,
done: PassportDoneCallback<OidcAuthResult, PassportOAuthPrivateInfo>,
) => {
if (typeof done !== 'function') {
throw new Error(
'OIDC IdP must provide a userinfo_endpoint in the metadata response',
);
}
done(
undefined,
{ tokenset, userinfo },
{ refreshToken: tokenset.refresh_token },
);
},
);
const helper = PassportOAuthAuthenticatorHelper.from(strategy);
return { helper, client, strategy };
});
return { initializedScope, initializedPrompt, promise };
},
async start(input, ctx) {
const { initializedScope, initializedPrompt, promise } = ctx;
const { helper, strategy } = await promise;
const options: Record<string, string> = {
scope: input.scope || initializedScope || 'openid profile email',
state: input.state,
};
const prompt = initializedPrompt || 'none';
if (prompt !== 'auto') {
options.prompt = prompt;
}
return new Promise((resolve, reject) => {
strategy.error = reject;
return helper
.start(input, {
...options,
})
.then(resolve);
});
},
async authenticate(
input,
ctx,
): Promise<OAuthAuthenticatorResult<OidcAuthResult>> {
const { strategy } = await ctx.promise;
const { result, privateInfo } =
await PassportHelpers.executeFrameHandlerStrategy<
OidcAuthResult,
PassportOAuthPrivateInfo
>(input.req, strategy);
return {
fullProfile: result,
session: {
accessToken: result.tokenset.access_token!,
tokenType: result.tokenset.token_type ?? 'bearer',
scope: result.tokenset.scope!,
expiresInSeconds: result.tokenset.expires_in,
idToken: result.tokenset.id_token,
refreshToken: privateInfo.refreshToken,
},
};
},
async refresh(input, ctx) {
const { client } = await ctx.promise;
const tokenset = await client.refresh(input.refreshToken);
if (!tokenset.access_token) {
throw new Error('Refresh failed');
}
if (!tokenset.scope) {
tokenset.scope = input.scope;
}
const userinfo = await client.userinfo(tokenset.access_token);
return new Promise((resolve, reject) => {
if (!tokenset.access_token) {
reject(new Error('Refresh Failed'));
}
resolve({
fullProfile: { userinfo, tokenset },
session: {
accessToken: tokenset.access_token!,
tokenType: tokenset.token_type ?? 'bearer',
scope: tokenset.scope!,
expiresInSeconds: tokenset.expires_in,
idToken: tokenset.id_token,
refreshToken: tokenset.refresh_token,
},
});
});
},
});
@@ -1,5 +1,5 @@
/*
* Copyright 2021 The Backstage Authors
* 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.
@@ -14,9 +14,13 @@
* limitations under the License.
*/
import React from 'react';
import { MarkdownContent } from '@backstage/core-components';
import { FieldProps } from '@rjsf/utils';
/**
* The oidc-provider backend module for the auth plugin.
*
* @packageDocumentation
*/
export const DescriptionField = ({ description }: FieldProps) =>
description && <MarkdownContent content={description} linkTarget="_blank" />;
export { oidcAuthenticator } from './authenticator';
export type { OidcAuthResult } from './authenticator';
export { authModuleOidcProvider as default } from './module';
export { oidcSignInResolvers } from './resolvers';
@@ -0,0 +1,223 @@
/*
* 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.
*/
import request from 'supertest';
import { decodeOAuthState } from '@backstage/plugin-auth-node';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
import {
mockServices,
setupRequestMockHandlers,
startTestBackend,
} from '@backstage/backend-test-utils';
import { Server } from 'http';
import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose';
import { authModuleOidcProvider } from './module';
describe('authModuleOidcProvider', () => {
let backstageServer: Server;
let appUrl: string;
let idToken: string;
let publicKey: JWK;
const mswServer = setupServer();
setupRequestMockHandlers(mswServer);
const issuerMetadata = {
issuer: 'https://oidc.test',
authorization_endpoint: 'https://oidc.test/oauth2/authorize',
token_endpoint: 'https://oidc.test/oauth2/token',
revocation_endpoint: 'https://oidc.test/oauth2/revoke_token',
userinfo_endpoint: 'https://oidc.test/idp/userinfo.openid',
introspection_endpoint: 'https://oidc.test/as/introspect.oauth2',
jwks_uri: 'https://oidc.test/jwks.json',
scopes_supported: ['openid'],
claims_supported: ['email'],
response_types_supported: ['code'],
id_token_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'],
token_endpoint_auth_signing_alg_values_supported: [
'RS256',
'RS512',
'HS256',
],
request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'],
};
beforeAll(async () => {
const keyPair = await generateKeyPair('RS256');
const privateKey = await exportJWK(keyPair.privateKey);
publicKey = await exportJWK(keyPair.publicKey);
publicKey.alg = privateKey.alg = 'RS256';
idToken = await new SignJWT({
sub: 'test',
iss: 'https://oidc.test',
iat: Date.now(),
aud: 'clientId',
exp: Date.now() + 10000,
})
.setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid })
.sign(keyPair.privateKey);
});
beforeEach(async () => {
jest.clearAllMocks();
mswServer.use(
rest.get(
'https://oidc.test/.well-known/openid-configuration',
(_req, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(issuerMetadata),
),
),
rest.get('https://oidc.test/oauth2/authorize', async (req, res, ctx) => {
const callbackUrl = new URL(req.url.searchParams.get('redirect_uri')!);
callbackUrl.searchParams.set('code', 'authorization_code');
callbackUrl.searchParams.set(
'state',
req.url.searchParams.get('state')!,
);
callbackUrl.searchParams.set('scope', 'test-scope');
return res(
ctx.status(302),
ctx.set('Location', callbackUrl.toString()),
);
}),
rest.get('https://oidc.test/jwks.json', async (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })),
),
rest.post('https://oidc.test/oauth2/token', async (req, res, ctx) => {
return res(
req.headers.get('Authorization')
? ctx.json({
access_token: 'accessToken',
id_token: idToken,
refresh_token: 'refreshToken',
scope: 'testScope',
token_type: '',
expires_in: 3600,
})
: ctx.status(401),
);
}),
rest.get(
'https://oidc.test/idp/userinfo.openid',
async (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
sub: 'test',
name: 'Alice Adams',
given_name: 'Alice',
family_name: 'Adams',
email: 'alice@test.com',
picture: 'http://testPictureUrl/photo.jpg',
}),
),
),
);
const backend = await startTestBackend({
features: [
authModuleOidcProvider,
import('@backstage/plugin-auth-backend'),
mockServices.rootConfig.factory({
data: {
app: { baseUrl: 'http://localhost' },
auth: {
session: { secret: 'test' },
providers: {
oidc: {
development: {
metadataUrl:
'https://oidc.test/.well-known/openid-configuration',
clientId: 'clientId',
clientSecret: 'clientSecret',
},
},
},
},
},
}),
],
});
backstageServer = backend.server;
const port = backend.server.port();
appUrl = `http://localhost:${port}`;
mswServer.use(rest.all(`http://*:${port}/*`, req => req.passthrough()));
});
afterEach(() => {
backstageServer.close();
});
it('should start', async () => {
const agent = request.agent(backstageServer);
const startResponse = await agent.get(
`/api/auth/oidc/start?env=development`,
);
expect(startResponse.status).toEqual(302);
const nonceCookie = agent.jar.getCookie('oidc-nonce', {
domain: 'localhost',
path: '/api/auth/oidc/handler',
script: false,
secure: false,
});
expect(nonceCookie).toBeDefined();
const startUrl = new URL(startResponse.get('location'));
expect(startUrl.origin).toBe('https://oidc.test');
expect(startUrl.pathname).toBe('/oauth2/authorize');
expect(Object.fromEntries(startUrl.searchParams)).toEqual({
response_type: 'code',
scope: 'openid profile email',
client_id: 'clientId',
redirect_uri: `${appUrl}/api/auth/oidc/handler/frame`,
state: expect.any(String),
prompt: 'none',
code_challenge: expect.any(String),
code_challenge_method: `S256`,
});
expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({
env: 'development',
nonce: decodeURIComponent(nonceCookie.value),
});
});
it('#authenticate exchanges authorization code for a access_token', async () => {
const agent = request.agent('');
const startResponse = await agent.get(
`${appUrl}/api/auth/oidc/start?env=development`,
);
const authorizationResponse = await agent.get(
startResponse.header.location,
);
const handlerResponse = await agent.get(
authorizationResponse.header.location,
);
expect(handlerResponse.text).toContain(
encodeURIComponent(`"accessToken":"accessToken"`),
);
});
});
@@ -0,0 +1,48 @@
/*
* 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.
*/
import { createBackendModule } from '@backstage/backend-plugin-api';
import {
authProvidersExtensionPoint,
commonSignInResolvers,
createOAuthProviderFactory,
} from '@backstage/plugin-auth-node';
import { oidcAuthenticator } from './authenticator';
import { oidcSignInResolvers } from './resolvers';
/** @public */
export const authModuleOidcProvider = createBackendModule({
pluginId: 'auth',
moduleId: 'oidc-provider',
register(reg) {
reg.registerInit({
deps: {
providers: authProvidersExtensionPoint,
},
async init({ providers }) {
providers.registerProvider({
providerId: 'oidc',
factory: createOAuthProviderFactory({
authenticator: oidcAuthenticator,
signInResolverFactories: {
...oidcSignInResolvers,
...commonSignInResolvers,
},
}),
});
},
});
},
});
@@ -0,0 +1,38 @@
/*
* 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.
*/
import { commonSignInResolvers } from '@backstage/plugin-auth-node';
/**
* Available sign-in resolvers for the Oidc auth provider.
*
* @public
*/
export namespace oidcSignInResolvers {
/**
* A oidc resolver that looks up the user using the local part of
* their email address as the entity name.
*/
export const emailLocalPartMatchingUserEntityName =
commonSignInResolvers.emailLocalPartMatchingUserEntityName;
/**
* A oidc resolver that looks up the user using their email address
* as email of the entity.
*/
export const emailMatchingUserEntityProfileEmail =
commonSignInResolvers.emailMatchingUserEntityProfileEmail;
}
+1
View File
@@ -29,6 +29,7 @@ export interface Config {
authServerId?: string;
idp?: string;
callbackUrl?: string;
additionalScopes?: string;
};
};
};
+8 -12
View File
@@ -25,6 +25,7 @@ import { LoggerService } from '@backstage/backend-plugin-api';
import { OAuth2ProxyResult as OAuth2ProxyResult_2 } from '@backstage/plugin-auth-backend-module-oauth2-proxy-provider';
import { OAuthEnvironmentHandler as OAuthEnvironmentHandler_2 } from '@backstage/plugin-auth-node';
import { OAuthState as OAuthState_2 } from '@backstage/plugin-auth-node';
import { OidcAuthResult as OidcAuthResult_2 } from '@backstage/plugin-auth-backend-module-oidc-provider';
import { PluginDatabaseManager } from '@backstage/backend-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { prepareBackstageIdentityResponse as prepareBackstageIdentityResponse_2 } from '@backstage/plugin-auth-node';
@@ -34,9 +35,7 @@ import { SignInInfo as SignInInfo_2 } from '@backstage/plugin-auth-node';
import { SignInResolver as SignInResolver_2 } from '@backstage/plugin-auth-node';
import { TokenManager } from '@backstage/backend-common';
import { TokenParams as TokenParams_2 } from '@backstage/plugin-auth-node';
import { TokenSet } from 'openid-client';
import { UserEntity } from '@backstage/catalog-model';
import { UserinfoResponse } from 'openid-client';
import { WebMessageResponse as WebMessageResponse_2 } from '@backstage/plugin-auth-node';
// @public @deprecated
@@ -340,11 +339,8 @@ export type OAuthStartResponse = {
// @public @deprecated (undocumented)
export type OAuthState = OAuthState_2;
// @public
export type OidcAuthResult = {
tokenset: TokenSet;
userinfo: UserinfoResponse;
};
// @public @deprecated (undocumented)
export type OidcAuthResult = OidcAuthResult_2;
// @public @deprecated (undocumented)
export const postMessageResponse: (
@@ -531,9 +527,9 @@ export const providers: Readonly<{
| undefined,
) => AuthProviderFactory_2;
resolvers: Readonly<{
emailLocalPartMatchingUserEntityName: () => SignInResolver<unknown>;
emailMatchingUserEntityProfileEmail: () => SignInResolver<unknown>;
emailMatchingUserEntityAnnotation(): SignInResolver<OAuthResult>;
emailMatchingUserEntityProfileEmail: () => SignInResolver_2<OAuthResult>;
emailLocalPartMatchingUserEntityName: () => SignInResolver_2<OAuthResult>;
emailMatchingUserEntityAnnotation: () => SignInResolver_2<OAuthResult>;
}>;
}>;
oauth2: Readonly<{
@@ -564,10 +560,10 @@ export const providers: Readonly<{
create: (
options?:
| {
authHandler?: AuthHandler<OidcAuthResult> | undefined;
authHandler?: AuthHandler<OidcAuthResult_2> | undefined;
signIn?:
| {
resolver: SignInResolver<OidcAuthResult>;
resolver: SignInResolver<OidcAuthResult_2>;
}
| undefined;
}
-28
View File
@@ -149,22 +149,6 @@ export interface Config {
};
};
/** @visibility frontend */
oidc?: {
[authEnv: string]: {
clientId: string;
/**
* @visibility secret
*/
clientSecret: string;
callbackUrl?: string;
metadataUrl: string;
tokenEndpointAuthMethod?: string;
tokenSignedResponseAlg?: string;
scope?: string;
prompt?: string;
};
};
/** @visibility frontend */
auth0?: {
[authEnv: string]: {
clientId: string;
@@ -180,18 +164,6 @@ export interface Config {
};
};
/** @visibility frontend */
microsoft?: {
[authEnv: string]: {
clientId: string;
/**
* @visibility secret
*/
clientSecret: string;
tenantId: string;
callbackUrl?: string;
};
};
/** @visibility frontend */
onelogin?: {
[authEnv: string]: {
clientId: string;
+2 -1
View File
@@ -43,8 +43,10 @@
"@backstage/plugin-auth-backend-module-github-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-gitlab-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-google-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-microsoft-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-oauth2-proxy-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-oidc-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-okta-provider": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
@@ -74,7 +76,6 @@
"passport-auth0": "^1.4.3",
"passport-bitbucket-oauth2": "^0.1.2",
"passport-github2": "^0.1.12",
"passport-gitlab2": "^5.0.0",
"passport-google-oauth20": "^2.0.0",
"passport-microsoft": "^1.0.0",
"passport-oauth2": "^1.6.1",
@@ -1,90 +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.
*/
import { FakeMicrosoftAPI } from './fake';
describe('FakeMicrosoftAPI', () => {
const api = new FakeMicrosoftAPI();
describe('#token', () => {
it('exchanges auth codes', () => {
const { access_token } = api.token(
new URLSearchParams({
grant_type: 'authorization_code',
code: api.generateAuthCode('User.Read'),
}),
);
expect(api.tokenHasScope(access_token, 'User.Read')).toBe(true);
});
it('supports scopes for the first requested audience only', () => {
const { access_token } = api.token(
new URLSearchParams({
grant_type: 'authorization_code',
code: api.generateAuthCode('someaudience/somescope User.Read'),
}),
);
expect(api.tokenHasScope(access_token, 'User.Read')).toBe(false);
});
it('special openid scopes do not count towards the 1-audience limit', () => {
const { access_token } = api.token(
new URLSearchParams({
grant_type: 'authorization_code',
code: api.generateAuthCode('openid offline_access User.Read'),
}),
);
expect(api.tokenHasScope(access_token, 'User.Read')).toBe(true);
});
it('refreshes tokens', () => {
const { access_token } = api.token(
new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: api.generateRefreshToken(
'email openid profile User.Read',
),
}),
);
expect(
api.tokenHasScope(access_token, 'email openid profile User.Read'),
).toBe(true);
});
it('requires `openid` scope for ID token', () => {
const { id_token } = api.token(
new URLSearchParams({
grant_type: 'authorization_code',
code: api.generateAuthCode('User.Read'),
}),
);
expect(id_token).toBeUndefined();
});
it('requires `offline_access` scope for refresh token', () => {
const { refresh_token } = api.token(
new URLSearchParams({
grant_type: 'authorization_code',
code: api.generateAuthCode('User.Read'),
}),
);
expect(refresh_token).toBeUndefined();
});
});
});
@@ -1,126 +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.
*/
import { decodeJwt } from 'jose';
type Claims = { aud: string; scp: string };
export class FakeMicrosoftAPI {
generateAccessToken(scope: string): string {
return this.tokenWithClaims(this.allClaimsForScope(scope)).access_token;
}
generateAuthCode(scope: string): string {
return this.encodeClaims(this.allClaimsForScope(scope));
}
generateRefreshToken(scope: string): string {
return this.encodeClaims(this.allClaimsForScope(scope));
}
token(formData: URLSearchParams): {
access_token: string;
scope: string;
refresh_token?: string;
id_token?: string;
} {
const scopeParameter = formData.get('scope');
const claims =
(scopeParameter && this.allClaimsForScope(scopeParameter)) ??
formData.get('grant_type') === 'refresh_token'
? this.decodeClaims(formData.get('refresh_token')!)
: this.decodeClaims(formData.get('code')!);
return {
...this.tokenWithClaims(claims),
...(this.hasScope(claims, 'offline_access') && {
refresh_token: this.encodeClaims(claims),
}),
...(this.hasScope(claims, 'openid') && {
id_token: 'header.e30K.microsoft',
}),
};
}
tokenHasScope(token: string, scope: string): boolean {
const { aud, scp } = decodeJwt(token);
return this.hasScope({ aud: aud as string, scp: scp as string }, scope);
}
private tokenWithClaims(claims: Claims): {
access_token: string;
scope: string;
} {
const filteredClaims = {
...claims,
scp: claims.scp
.split(' ')
.filter(s => s !== 'offline_access')
.join(' '),
};
return {
access_token: `header.${Buffer.from(
JSON.stringify(filteredClaims),
).toString('base64')}.signature`,
scope: this.scopeFromClaims(filteredClaims),
};
}
private allClaimsForScope(scope: string): Claims {
const scopes = scope.split(' ').map(this.parseScope);
const firstAudience = scopes
.map(({ aud }) => aud)
.find(aud => aud !== 'openid');
return {
aud: firstAudience ?? '00000003-0000-0000-c000-000000000000',
scp: scopes
.filter(({ aud }) => aud === 'openid' || aud === firstAudience)
.map(({ scp }) => scp)
.join(' '),
};
}
// auth codes and refresh tokens in this fake system are base64-encoded JSON
// strings of claims
private encodeClaims(claims: Claims): string {
return Buffer.from(JSON.stringify(claims)).toString('base64');
}
private decodeClaims(encoded: string): Claims {
return JSON.parse(Buffer.from(encoded, 'base64').toString());
}
private hasScope(claims: Claims, scope: string): boolean {
return this.scopeFromClaims(claims).includes(scope);
}
private parseScope(s: string): Claims {
if (s.includes('/')) {
const [aud, scp] = s.split('/');
return { aud, scp };
}
switch (s) {
case 'email':
case 'openid':
case 'offline_access':
case 'profile': {
return { aud: 'openid', scp: s };
}
default:
return { aud: '00000003-0000-0000-c000-000000000000', scp: s };
}
}
private scopeFromClaims(claims: Claims): string {
return claims.scp
.split(' ')
.map(this.parseScope)
.map(({ aud, scp }) =>
aud === 'openid' ||
claims.aud === '00000003-0000-0000-c000-000000000000'
? scp
: `${claims.aud}/${scp}`,
)
.join(' ');
}
}
@@ -1,450 +0,0 @@
/*
* Copyright 2020 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 { microsoft } from './provider';
import { getVoidLogger } from '@backstage/backend-common';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { ConfigReader } from '@backstage/config';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { AuthProviderRouteHandlers, AuthResolverContext } from '../types';
import express from 'express';
import crypto from 'crypto';
import { FakeMicrosoftAPI } from './__testUtils__/fake';
describe('MicrosoftAuthProvider', () => {
const nonce = 'AAAAAAAAAAAAAAAAAAAAAA=='; // 16 bytes of zeros in base64
const state = Buffer.from(
`nonce=${encodeURIComponent(nonce)}&env=development`,
).toString('hex');
const mockBackstageToken = `header.${Buffer.from(
JSON.stringify({ sub: 'user:default/mock' }),
'utf8',
).toString('base64')}.backstage`;
const server = setupServer();
const microsoftApi = new FakeMicrosoftAPI();
let provider: AuthProviderRouteHandlers;
let response: jest.Mocked<express.Response>;
setupRequestMockHandlers(server);
beforeEach(() => {
provider = microsoft.create({
signIn: {
resolver: microsoft.resolvers.emailMatchingUserEntityAnnotation(),
},
})({
providerId: 'microsoft',
baseUrl: 'http://backstage.test/api/auth',
appUrl: 'http://backstage.test',
isOriginAllowed: _ => true,
globalConfig: {
baseUrl: 'http://backstage.test/api/auth',
appUrl: 'http://backstage.test',
isOriginAllowed: _ => true,
},
config: new ConfigReader({
development: {
tenantId: 'tenantId',
clientId: 'clientId',
clientSecret: 'clientSecret',
},
}),
logger: getVoidLogger(),
resolverContext: {
issueToken: jest.fn(),
findCatalogUser: jest.fn(),
signInWithCatalogUser: async _ => ({
token: mockBackstageToken,
}),
} as AuthResolverContext,
}) as AuthProviderRouteHandlers;
server.use(
rest.post(
'https://login.microsoftonline.com/tenantId/oauth2/v2.0/token',
async (req, res, ctx) => {
return res(
ctx.json({
...microsoftApi.token(new URLSearchParams(await req.text())),
token_type: 'Bearer',
expires_in: 123,
ext_expires_in: 123,
}),
);
},
),
rest.get('https://graph.microsoft.com/v1.0/me/', (req, res, ctx) => {
if (
!microsoftApi.tokenHasScope(
req.headers.get('authorization')!.replace(/^Bearer /, ''),
'User.Read',
)
) {
return res(ctx.status(403));
}
return res(
ctx.json({
id: 'conrad',
displayName: 'Conrad',
surname: 'Ribas',
givenName: 'Francisco',
mail: 'conrad@example.com',
}),
);
}),
rest.get(
'https://graph.microsoft.com/v1.0/me/photos/*',
async (req, res, ctx) => {
if (
!microsoftApi.tokenHasScope(
req.headers.get('authorization')!.replace(/^Bearer /, ''),
'User.Read',
)
) {
return res(ctx.status(403));
}
const imageBuffer = new Uint8Array([104, 111, 119, 100, 121]).buffer;
return res(
ctx.set('Content-Length', imageBuffer.byteLength.toString()),
ctx.set('Content-Type', 'image/jpeg'),
ctx.body(imageBuffer),
);
},
),
);
response = {
cookie: jest.fn(),
end: jest.fn(),
json: jest.fn(),
setHeader: jest.fn(),
status: jest.fn(),
} as unknown as jest.Mocked<express.Response>;
response.status.mockReturnValue(response);
});
describe('#start', () => {
const randomBytes = jest.spyOn(
crypto,
'randomBytes',
) as unknown as jest.MockedFunction<(size: number) => Buffer>;
afterEach(() => {
randomBytes.mockRestore();
});
it('redirects to authorize URL', async () => {
randomBytes.mockReturnValue(Buffer.from(nonce, 'base64'));
await provider.start(
{
query: {
env: 'development',
scope: 'email openid profile User.Read',
},
} as unknown as express.Request,
response,
);
expect(response.setHeader).toHaveBeenCalledWith(
'Location',
'https://login.microsoftonline.com/tenantId/oauth2/v2.0/authorize' +
'?response_type=code' +
`&redirect_uri=${encodeURIComponent(
'http://backstage.test/api/auth/microsoft/handler/frame',
)}` +
`&scope=${encodeURIComponent('email openid profile User.Read')}` +
`&state=${state}` +
'&client_id=clientId',
);
});
});
describe('#handle', () => {
it('returns provider info and profile with photo data', async () => {
await provider.frameHandler(
{
query: {
env: 'development',
code: microsoftApi.generateAuthCode(
'email openid profile User.Read',
),
state,
},
cookies: {
'microsoft-nonce': nonce,
},
} as unknown as express.Request,
response,
);
expect(response.end).toHaveBeenCalledWith(
expect.stringContaining(
encodeURIComponent(
JSON.stringify({
type: 'authorization_response',
response: {
providerInfo: {
accessToken: microsoftApi.generateAccessToken(
'email openid profile User.Read',
),
scope: 'email openid profile User.Read',
expiresInSeconds: 123,
idToken: 'header.e30K.microsoft',
},
profile: {
email: 'conrad@example.com',
picture: 'data:image/jpeg;base64,aG93ZHk=',
displayName: 'Conrad',
},
backstageIdentity: {
token: mockBackstageToken,
identity: {
type: 'user',
userEntityRef: 'user:default/mock',
ownershipEntityRefs: [],
},
},
},
}),
),
),
);
});
it('returns access token for non-microsoft graph scope', async () => {
await provider.frameHandler(
{
query: {
env: 'development',
code: microsoftApi.generateAuthCode('aks-audience/user.read'),
state,
},
cookies: {
'microsoft-nonce': nonce,
},
} as unknown as express.Request,
response,
);
expect(response.end).toHaveBeenCalledWith(
expect.stringContaining(
encodeURIComponent(
JSON.stringify({
type: 'authorization_response',
response: {
providerInfo: {
accessToken: microsoftApi.generateAccessToken(
'aks-audience/user.read',
),
scope: 'aks-audience/user.read',
expiresInSeconds: 123,
},
profile: {},
},
}),
),
),
);
});
it('sets refresh token', async () => {
await provider.frameHandler(
{
query: {
env: 'development',
code: microsoftApi.generateAuthCode(
'email offline_access openid profile User.Read',
),
state,
},
cookies: {
'microsoft-nonce': nonce,
},
} as unknown as express.Request,
response,
);
expect(response.cookie).toHaveBeenCalledWith(
'microsoft-refresh-token',
microsoftApi.generateRefreshToken(
'email offline_access openid profile User.Read',
),
{
domain: 'backstage.test',
httpOnly: true,
maxAge: 86400000000,
path: '/api/auth/microsoft',
sameSite: 'lax',
secure: false,
},
);
});
it('omits photo data when fetching it fails', async () => {
server.use(
rest.get('https://graph.microsoft.com/v1.0/me/photos/*', (_, res) =>
res.networkError('remote hung up'),
),
);
await provider.frameHandler(
{
query: {
env: 'development',
code: microsoftApi.generateAuthCode(
'email openid profile User.Read',
),
state,
},
cookies: {
'microsoft-nonce': nonce,
},
} as unknown as express.Request,
response,
);
expect(response.end).toHaveBeenCalledWith(
expect.stringContaining(
encodeURIComponent(
JSON.stringify({
type: 'authorization_response',
response: {
providerInfo: {
accessToken: microsoftApi.generateAccessToken(
'email openid profile User.Read',
),
scope: 'email openid profile User.Read',
expiresInSeconds: 123,
idToken: 'header.e30K.microsoft',
},
profile: {
email: 'conrad@example.com',
displayName: 'Conrad',
},
backstageIdentity: {
token: mockBackstageToken,
identity: {
type: 'user',
userEntityRef: 'user:default/mock',
ownershipEntityRefs: [],
},
},
},
}),
),
),
);
});
});
describe('#refresh', () => {
it('returns provider info and profile with photo data', async () => {
await provider.refresh!(
{
query: {
env: 'development',
scope: 'email openid profile User.Read',
},
header: jest.fn(_ => 'XMLHttpRequest'),
cookies: {
'microsoft-refresh-token': microsoftApi.generateRefreshToken(
'email openid profile User.Read',
),
},
get: jest.fn(),
} as unknown as express.Request,
response,
);
expect(response.json).toHaveBeenCalledWith(
expect.objectContaining({
providerInfo: {
accessToken: microsoftApi.generateAccessToken(
'email openid profile User.Read',
),
scope: 'email openid profile User.Read',
expiresInSeconds: 123,
idToken: 'header.e30K.microsoft',
},
profile: {
email: 'conrad@example.com',
picture: 'data:image/jpeg;base64,aG93ZHk=',
displayName: 'Conrad',
},
}),
);
});
it('returns access token for non-microsoft graph scope', async () => {
await provider.refresh!(
{
query: {
env: 'development',
scope: 'aks-audience/user.read',
},
header: jest.fn(_ => 'XMLHttpRequest'),
cookies: {
'microsoft-refresh-token': microsoftApi.generateRefreshToken(
'aks-audience/user.read',
),
},
get: jest.fn(),
} as unknown as express.Request,
response,
);
expect(response.json).toHaveBeenCalledWith({
providerInfo: {
accessToken: microsoftApi.generateAccessToken(
'aks-audience/user.read',
),
expiresInSeconds: 123,
scope: 'aks-audience/user.read',
},
profile: {},
});
});
it('returns backstage identity', async () => {
await provider.refresh!(
{
query: {
env: 'development',
scope: 'email openid profile User.Read',
},
header: jest.fn(_ => 'XMLHttpRequest'),
cookies: {
'microsoft-refresh-token': microsoftApi.generateRefreshToken(
'email openid profile User.Read',
),
},
get: jest.fn(),
} as unknown as express.Request,
response,
);
expect(response.json).toHaveBeenCalledWith(
expect.objectContaining({
backstageIdentity: expect.objectContaining({
token: mockBackstageToken,
}),
}),
);
});
});
});
@@ -14,215 +14,22 @@
* limitations under the License.
*/
import express from 'express';
import passport from 'passport';
import { Strategy as MicrosoftStrategy } from 'passport-microsoft';
import {
encodeState,
OAuthAdapter,
OAuthEnvironmentHandler,
OAuthHandlers,
OAuthProviderOptions,
OAuthRefreshRequest,
OAuthResponse,
OAuthResult,
OAuthStartRequest,
} from '../../lib/oauth';
import {
executeFetchUserProfileStrategy,
executeFrameHandlerStrategy,
executeRedirectStrategy,
executeRefreshTokenStrategy,
makeProfileInfo,
PassportDoneCallback,
} from '../../lib/passport';
import {
AuthHandler,
OAuthStartResponse,
SignInResolver,
AuthResolverContext,
} from '../types';
import { SignInResolver, AuthHandler } from '../types';
import { OAuthResult } from '../../lib/oauth';
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
import {
commonByEmailLocalPartResolver,
commonByEmailResolver,
} from '../resolvers';
import { LoggerService } from '@backstage/backend-plugin-api';
import fetch from 'node-fetch';
import { decodeJwt } from 'jose';
import { Profile as PassportProfile } from 'passport';
import { BACKSTAGE_SESSION_EXPIRATION } from '../../lib/session';
type PrivateInfo = {
refreshToken: string;
};
type Options = OAuthProviderOptions & {
signInResolver?: SignInResolver<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
logger: LoggerService;
resolverContext: AuthResolverContext;
authorizationUrl?: string;
tokenUrl?: string;
};
export class MicrosoftAuthProvider implements OAuthHandlers {
private readonly _strategy: MicrosoftStrategy;
private readonly signInResolver?: SignInResolver<OAuthResult>;
private readonly authHandler: AuthHandler<OAuthResult>;
private readonly logger: LoggerService;
private readonly resolverContext: AuthResolverContext;
constructor(options: Options) {
this.signInResolver = options.signInResolver;
this.authHandler = options.authHandler;
this.logger = options.logger;
this.resolverContext = options.resolverContext;
this._strategy = new MicrosoftStrategy(
{
clientID: options.clientId,
clientSecret: options.clientSecret,
callbackURL: options.callbackUrl,
authorizationURL: options.authorizationUrl,
tokenURL: options.tokenUrl,
passReqToCallback: false,
skipUserProfile: (
accessToken: string,
done: (err: unknown, skip: boolean) => void,
) => {
done(null, this.skipUserProfile(accessToken));
},
},
(
accessToken: any,
refreshToken: any,
params: any,
fullProfile: passport.Profile,
done: PassportDoneCallback<OAuthResult, PrivateInfo>,
) => {
done(undefined, { fullProfile, accessToken, params }, { refreshToken });
},
);
}
private skipUserProfile = (accessToken: string): boolean => {
const { aud, scp } = decodeJwt(accessToken);
const hasGraphReadScope =
aud === '00000003-0000-0000-c000-000000000000' &&
(scp as string)
.split(' ')
.map(s => s.toLowerCase())
.includes('user.read');
return !hasGraphReadScope;
};
async start(req: OAuthStartRequest): Promise<OAuthStartResponse> {
return await executeRedirectStrategy(req, this._strategy, {
scope: req.scope,
state: encodeState(req.state),
});
}
async handler(req: express.Request) {
const { result, privateInfo } = await executeFrameHandlerStrategy<
OAuthResult,
PrivateInfo
>(req, this._strategy);
return {
response: await this.handleResult(result),
refreshToken: privateInfo.refreshToken,
};
}
async refresh(req: OAuthRefreshRequest) {
const { accessToken, refreshToken, params } =
await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
return {
response: await this.handleResult({
params,
accessToken,
...(!this.skipUserProfile(accessToken) && {
fullProfile: await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
),
}),
}),
refreshToken,
};
}
private async handleResult(result: {
fullProfile?: PassportProfile;
params: {
id_token?: string;
scope: string;
expires_in: number;
};
accessToken: string;
refreshToken?: string;
}): Promise<OAuthResponse> {
let profile = {};
if (result.fullProfile) {
const photo = await this.getUserPhoto(result.accessToken);
result.fullProfile.photos = photo ? [{ value: photo }] : undefined;
({ profile } = await this.authHandler(
result as OAuthResult,
this.resolverContext,
));
}
const expiresInSeconds =
result.params.expires_in === undefined
? BACKSTAGE_SESSION_EXPIRATION
: Math.min(result.params.expires_in, BACKSTAGE_SESSION_EXPIRATION);
return {
providerInfo: {
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds,
...{ idToken: result.params.id_token },
},
profile,
...(result.fullProfile &&
this.signInResolver && {
backstageIdentity: await this.signInResolver(
{ result: result as OAuthResult, profile },
this.resolverContext,
),
}),
};
}
private async getUserPhoto(accessToken: string): Promise<string | undefined> {
try {
const res = await fetch(
'https://graph.microsoft.com/v1.0/me/photos/48x48/$value',
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
const data = await res.buffer();
return `data:image/jpeg;base64,${data.toString('base64')}`;
} catch (error) {
this.logger.warn(
`Could not retrieve user profile photo from Microsoft Graph API: ${error}`,
);
return undefined;
}
}
}
commonSignInResolvers,
createOAuthProviderFactory,
} from '@backstage/plugin-auth-node';
import {
adaptLegacyOAuthHandler,
adaptLegacyOAuthSignInResolver,
adaptOAuthSignInResolverToLegacy,
} from '../../lib/legacy';
import {
microsoftAuthenticator,
microsoftSignInResolvers,
} from '@backstage/plugin-auth-backend-module-microsoft-provider';
/**
* Auth provider integration for Microsoft auth
@@ -241,75 +48,21 @@ export const microsoft = createAuthProviderIntegration({
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
signIn?: {
/**
* Maps an auth result to a Backstage identity for the user.
*/
resolver: SignInResolver<OAuthResult>;
};
}) {
return ({ providerId, globalConfig, config, logger, resolverContext }) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const tenantId = envConfig.getString('tenantId');
const customCallbackUrl = envConfig.getOptionalString('callbackUrl');
const callbackUrl =
customCallbackUrl ||
`${globalConfig.baseUrl}/${providerId}/handler/frame`;
const authorizationUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`;
const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
const authHandler: AuthHandler<OAuthResult> = options?.authHandler
? options.authHandler
: async ({ fullProfile, params }) => ({
profile: makeProfileInfo(fullProfile ?? {}, params.id_token),
});
const provider = new MicrosoftAuthProvider({
clientId,
clientSecret,
callbackUrl,
authorizationUrl,
tokenUrl,
authHandler,
signInResolver: options?.signIn?.resolver,
logger,
resolverContext,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
providerId,
callbackUrl,
});
});
},
resolvers: {
/**
* Looks up the user by matching their email local part to the entity name.
*/
emailLocalPartMatchingUserEntityName: () => commonByEmailLocalPartResolver,
/**
* Looks up the user by matching their email to the entity email.
*/
emailMatchingUserEntityProfileEmail: () => commonByEmailResolver,
/**
* Looks up the user by matching their email to the `microsoft.com/email` annotation.
*/
emailMatchingUserEntityAnnotation(): SignInResolver<OAuthResult> {
return async (info, ctx) => {
const { profile } = info;
if (!profile.email) {
throw new Error('Microsoft profile contained no email');
}
return ctx.signInWithCatalogUser({
annotations: {
'microsoft.com/email': profile.email,
},
});
};
},
return createOAuthProviderFactory({
authenticator: microsoftAuthenticator,
profileTransform: adaptLegacyOAuthHandler(options?.authHandler),
signInResolver: adaptLegacyOAuthSignInResolver(options?.signIn?.resolver),
});
},
resolvers: adaptOAuthSignInResolverToLegacy({
emailLocalPartMatchingUserEntityName:
commonSignInResolvers.emailLocalPartMatchingUserEntityName(),
emailMatchingUserEntityProfileEmail:
commonSignInResolvers.emailMatchingUserEntityProfileEmail(),
emailMatchingUserEntityAnnotation:
microsoftSignInResolvers.emailMatchingUserEntityAnnotation(),
}),
});

Some files were not shown because too many files have changed in this diff Show More