Merge branch 'master' into gcp-ua-header
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend-module-okta-provider': patch
|
||||
---
|
||||
|
||||
Added missing `additionalScopes` option to configuration schema.
|
||||
@@ -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'));
|
||||
```
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/app-defaults': patch
|
||||
'@backstage/plugin-catalog': patch
|
||||
---
|
||||
|
||||
Change default icon for `kind:resource` to the storage icon.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend': patch
|
||||
---
|
||||
|
||||
Use the externalized `auth-backend-module-microsoft-provider` again.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-react': patch
|
||||
---
|
||||
|
||||
Overflowing labels in OwnerPicker (Catalog) are now truncated. Hovering over them shows the full label
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-react': minor
|
||||
'@backstage/plugin-scaffolder': minor
|
||||
---
|
||||
|
||||
Remove the old legacy exports from `/alpha`
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-openapi-utils': patch
|
||||
---
|
||||
|
||||
Internal updates due to `json-schema-to-ts`
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend': patch
|
||||
---
|
||||
|
||||
Migrated oidc auth provider to new `@backstage/plugin-auth-backend-module-oidc-provider` module package.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog': patch
|
||||
---
|
||||
|
||||
Fixed inconsistencies in icons used for System and Template
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
---
|
||||
|
||||
Removed alpha symbol from Task List header
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-compat-api': patch
|
||||
---
|
||||
|
||||
Added `convertLegacyRouteRefs` for bulk conversion of plugin routes.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/frontend-app-api': patch
|
||||
---
|
||||
|
||||
Improved the error message when data input/output shapes do not match
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-home': patch
|
||||
---
|
||||
|
||||
Fix typo in VisitsStorageApi
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Updated dependencies in frontend plugin templates
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Updated the backend module template to make the module instance the package default export.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-devtools': patch
|
||||
---
|
||||
|
||||
Added alpha support for the New Frontend System (Declarative Integration)
|
||||
@@ -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
@@ -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/**/*
|
||||
|
||||
@@ -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] }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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",
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -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'
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
+22
-14
@@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
+9
-2
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
+10
-6
@@ -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;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export interface Config {
|
||||
authServerId?: string;
|
||||
idp?: string;
|
||||
callbackUrl?: string;
|
||||
additionalScopes?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Vendored
-28
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user