Merge remote-tracking branch 'origin/master' into rec-tasks-version-1
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/repo-tools': minor
|
||||
---
|
||||
|
||||
Add support for `oneOf` in client generated by `schema openapi generate-client`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend-module-okta-provider': patch
|
||||
---
|
||||
|
||||
Added missing `additionalScopes` option to configuration schema.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend-module-aws': patch
|
||||
---
|
||||
|
||||
The default EKS cluster entity transformer now sets the new
|
||||
`kubernetes.io/x-k8s-aws-id` annotation.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend': patch
|
||||
---
|
||||
|
||||
Use the externalized `auth-backend-module-microsoft-provider` again.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-devtools': patch
|
||||
---
|
||||
|
||||
Refactored code to improve accessibility by moving elements outside the `ul` tag and placing them appropriately. Also adjusted theme to offer better contrast.
|
||||
@@ -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,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-scaffolder-node': minor
|
||||
---
|
||||
|
||||
Added functions to clone a repo, create a branch, add files and push and commit to the branch. This allows for files to be added to the a PR for use in the bitbucket pull request action for issue #21762
|
||||
@@ -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,11 @@
|
||||
---
|
||||
'@backstage/plugin-home-react': patch
|
||||
'@backstage/plugin-home': patch
|
||||
'@backstage/plugin-scaffolder-react': patch
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
---
|
||||
|
||||
Updated dependency `@rjsf/utils` to `5.16.1`.
|
||||
Updated dependency `@rjsf/core` to `5.16.1`.
|
||||
Updated dependency `@rjsf/material-ui` to `5.16.1`.
|
||||
Updated dependency `@rjsf/validator-ajv8` to `5.16.1`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend-module-oidc-provider': patch
|
||||
---
|
||||
|
||||
Updated dependency `passport` to `^0.7.0`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-bitbucket-cloud-common': patch
|
||||
---
|
||||
|
||||
Updated dependency `ts-morph` to `^21.0.0`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-api-docs': patch
|
||||
---
|
||||
|
||||
Updated dependency `graphiql` to `3.1.0`.
|
||||
@@ -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,14 @@
|
||||
---
|
||||
'@backstage/plugin-kubernetes-backend': patch
|
||||
'@backstage/plugin-kubernetes-common': patch
|
||||
---
|
||||
|
||||
Clusters configured with the `aws` authentication strategy can now customize the
|
||||
`x-k8s-aws-id` header value used to generate tokens. This value can be specified
|
||||
specified via the `kubernetes.io/x-k8s-aws-id` parameter (in
|
||||
`metadata.annotations` for clusters in the catalog, or the `authMetadata` block
|
||||
on clusters in the app-config). This is particularly helpful when a Backstage
|
||||
instance contains multiple AWS clusters with the same name in different regions
|
||||
-- using this new parameter, the clusters can be given different logical names
|
||||
to distinguish them but still use the same ID for the purposes of generating
|
||||
tokens.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-app-backend': patch
|
||||
---
|
||||
|
||||
Do not force caching of the Javascript asset that contains the injected config.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-cloudbuild': patch
|
||||
---
|
||||
|
||||
Add telemetry HTTP header Google Cloud Platform
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-home': patch
|
||||
---
|
||||
|
||||
Fix typo in VisitsStorageApi
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend-module-bitbucket': patch
|
||||
---
|
||||
|
||||
Enhanced the pull request action to allow for adding new content to the PR as described in this issue #21762
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-compat-api': patch
|
||||
---
|
||||
|
||||
collectLegacyRoutes throws in case invalid <Route /> element is found
|
||||
+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/**/*
|
||||
|
||||
@@ -330,6 +330,7 @@ Rollbar
|
||||
Rollup
|
||||
routable
|
||||
Routable
|
||||
Rspack
|
||||
rst
|
||||
rsync
|
||||
ruleset
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/stale@v8.0.0
|
||||
- uses: actions/stale@v9.0.0
|
||||
id: stale
|
||||
with:
|
||||
stale-issue-message: >
|
||||
|
||||
@@ -66,6 +66,6 @@ jobs:
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: 'Upload to code-scanning'
|
||||
uses: github/codeql-action/upload-sarif@4759df8df70c5ebe7042c3029bbace20eee13edd # v2.23.1
|
||||
uses: github/codeql-action/upload-sarif@0b21cf2492b6b02c465a3e5d7c473717ad7721ba # v3.23.1
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -58,6 +58,6 @@ jobs:
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=7168
|
||||
- name: Upload Snyk report
|
||||
uses: github/codeql-action/upload-sarif@v2.23.1
|
||||
uses: github/codeql-action/upload-sarif@v3.23.1
|
||||
with:
|
||||
sarif_file: snyk.sarif
|
||||
|
||||
@@ -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:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2.23.1
|
||||
uses: github/codeql-action/init@v3.23.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2.23.1
|
||||
uses: github/codeql-action/autobuild@v3.23.1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -80,4 +80,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2.23.1
|
||||
uses: github/codeql-action/analyze@v3.23.1
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@v4.1.1
|
||||
- uses: actions/setup-python@v4.8.0
|
||||
- uses: actions/setup-python@v5.0.0
|
||||
with:
|
||||
python-version: '3.9'
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
registry-url: https://registry.npmjs.org/ # Needed for auth
|
||||
|
||||
- name: setup python
|
||||
uses: actions/setup-python@v4.8.0
|
||||
uses: actions/setup-python@v5.0.0
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- run: yarn build-storybook
|
||||
|
||||
- uses: chromaui/action@7fb6b0407c69171ce521d08355a825958a5ef81a # v1
|
||||
- uses: chromaui/action@7fb6b0407c69171ce521d08355a825958a5ef81a # v10
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# projectToken intentionally shared to allow collaborators to run Chromatic on forks
|
||||
|
||||
@@ -2,4 +2,6 @@ Backstage
|
||||
Copyright 2020 The Backstage Authors
|
||||
|
||||
Portions of this software were developed by third-party software vendors:
|
||||
|
||||
- Tech Radar Plugin (https://opensource.zalando.com/tech-radar/), Copyright (c) 2017 Zalando SE
|
||||
- [OpenAPI Generator Templates](./packages/repo-tools/templates), Copyright 2018 OpenAPI-Generator Contributors (https://openapi-generator.tech) Copyright 2018 SmartBear Software
|
||||
|
||||
@@ -19,7 +19,7 @@ When editing BEPs, aim for tightly-scoped, single-topic PRs to keep discussions
|
||||
|
||||
<!-- Before merging the initial BEP PR, create a feature issue and update the below link. You can wait with this step until the BEP is ready to be merged. -->
|
||||
|
||||
[**Discussion Issue**](https://github.com/backstage/backstage/issues/NNNNN)
|
||||
[**Discussion Issue**](https://github.com/backstage/backstage/issues/22423)
|
||||
|
||||
- [Summary](#summary)
|
||||
- [Motivation](#motivation)
|
||||
@@ -143,10 +143,16 @@ Plugin discovery is a pre-requisite for Plugin registry. This should be responsi
|
||||
|
||||
### Module federation implementation experiments
|
||||
|
||||
> NOTE Share outcome of testing mixing multiple tools for module federation.
|
||||
|
||||
Test should consist of trying to run permutations of webpack/Rspack/vite based shell apps/plugins and discover if we can freely choose any tool, or if we should restrict the tooling to just a subset of the available options.
|
||||
|
||||
The outcome of initial testing is positive and it is possible to mix and match different build tools and consume different remote modules in a single shell application.
|
||||
|
||||
The experimental code can be found in [this repository](https://github.com/scalprum/mf-mixing-experiments).
|
||||
|
||||
**The testing so far was done only on very simple modules**. Although core React features are working (Context API and hooks), more testings needs to be done in order to declare this approach 100% reliable.
|
||||
|
||||
So far a lot of custom code needs to be written to bridge Webpack, Rspack, @module-federation/enhanced with Vite. The first three are compatible out of the box, but Vite requires extra bridge to be able to consume/provide modules with/to other builds.
|
||||
|
||||
### Plugin manifest
|
||||
|
||||
Each plugin should have a manifest file with important metadata. This metadata is used to load the remote assets to browser. The plugin manifest should be part of a build output.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -4,23 +4,26 @@ title: Kubernetes Authentication
|
||||
description: Authentication in Kubernetes plugin
|
||||
---
|
||||
|
||||
The authentication process in Kubernetes relies on `KubernetesAuthProviders`, which are
|
||||
not the same as the application's auth providers, the default providers are defined in
|
||||
`plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts`, you can
|
||||
add custom providers there if needed.
|
||||
The authentication process in Kubernetes relies on `KubernetesAuthProviders` --
|
||||
which are not the same as the application's auth providers. the default
|
||||
providers are defined in
|
||||
`plugins/kubernetes-react/src/kubernetes-auth-provider/KubernetesAuthProviders.ts`;
|
||||
you can add custom providers there if needed.
|
||||
|
||||
These providers are configured so your Kubernetes plugin can locate and access the
|
||||
clusters you have access to, some of them have special requirements in the third party in
|
||||
question, like Microsoft Entra ID (formerly Azure Active Directory) subscription or Azure RBAC support active on the cluster.
|
||||
These providers are configured so your Kubernetes plugin can locate and access
|
||||
the clusters you have access to, some of them have special requirements in the
|
||||
third party in question, like Microsoft Entra ID (formerly Azure Active
|
||||
Directory) subscription or Azure RBAC support active on the cluster.
|
||||
|
||||
The providers currently available are divided into server side and client side.
|
||||
The providers currently available are summarized below:
|
||||
|
||||
## Server Side Providers
|
||||
|
||||
These providers authenticate your _application_ with the cluster, meaning anyone that is
|
||||
logged in into your backstage app will be granted the same access to Kubernetes objects, including guest users.
|
||||
These providers authenticate your _application_ with the cluster, meaning anyone
|
||||
that is logged in into your Backstage app will be granted the same access to
|
||||
Kubernetes objects, including guest users.
|
||||
|
||||
The providers available as server side are:
|
||||
The server side providers are:
|
||||
|
||||
- `aws`
|
||||
- `azure`
|
||||
@@ -30,11 +33,19 @@ The providers available as server side are:
|
||||
|
||||
### AWS
|
||||
|
||||
For AWS, in addition to the "kubernetes" configuration, you will have to set up AWS authentication. The AWS server-side authentication provider uses [AWS Identity and Access Management (IAM)][3] to authenticate to the target Account(s), you can read more about it on the page for the [Integration AWS node][4].
|
||||
For AWS, in addition to Kubernetes configuration, you will have to set up
|
||||
AWS authentication. The AWS server-side authentication provider uses [AWS
|
||||
Identity and Access Management (IAM)][3] to authenticate to the target
|
||||
Account(s); you can read more about it on the page for the [Integration AWS
|
||||
node][4].
|
||||
|
||||
Using the plugin, you can authenticate to several AWS accounts using either [static AWS Access keys][5] or short-lived Access keys generated by [assuming a role][6], for either case you will need to install the [AWS CLI utility][7] and set it up following the steps in the linked documentation.
|
||||
Using the plugin, you can authenticate to several AWS accounts using either
|
||||
[static AWS Access keys][5] or short-lived Access keys generated by [assuming a
|
||||
role][6], for either case you will need to install the [AWS CLI utility][7] and
|
||||
set it up following the steps in the linked documentation.
|
||||
|
||||
If you have generated static AWS security credentials, the configuration block for AWS will look like this:
|
||||
If you have generated static AWS security credentials, the configuration block
|
||||
for AWS will look like this:
|
||||
|
||||
```yaml
|
||||
aws:
|
||||
@@ -47,7 +58,8 @@ aws:
|
||||
accountDefaults:
|
||||
```
|
||||
|
||||
If your environment is set up to assume a role, the configuration would instead look like this:
|
||||
If your environment is set up to assume a role, the configuration would instead
|
||||
look like this:
|
||||
|
||||
```yaml
|
||||
aws:
|
||||
@@ -58,7 +70,8 @@ aws:
|
||||
accountDefaults:
|
||||
```
|
||||
|
||||
Either of these sections needs to be present for the Kubernetes configuration to use the `aws` `authProvider`. The Kubernetes configuration looks like this:
|
||||
Either of these sections needs to be present for the Kubernetes configuration to
|
||||
use the `aws` `authProvider`. The Kubernetes configuration looks like this:
|
||||
|
||||
```yaml
|
||||
kubernetes:
|
||||
@@ -68,23 +81,37 @@ kubernetes:
|
||||
- type: 'config'
|
||||
clusters:
|
||||
- url: https://<unique-identifier>.<region>.eks.amazonaws.com
|
||||
name: <cluster-name-to-use>
|
||||
name: ${CLUSTER_NAME_TO_DISPLAY}
|
||||
authProvider: 'aws'
|
||||
caData: ${EKS_CA_DATA}
|
||||
authMetadata:
|
||||
kubernetes.io/aws-assume-role: ${ROLE_ARN_TO_ASSUME}
|
||||
kubernetes.io/aws-external-id: ${ID_FROM_AWS_ADMIN}
|
||||
kubernetes.io/x-k8s-aws-id: ${CLUSTER_NAME_IN_AWS_CONSOLE}
|
||||
```
|
||||
|
||||
You get both, the cluster `url` and `caData` directly from the AWS console by going to `EKS` > `Your cluster` > `Overview` > `Details`. You will find them under 'API server endpoint' and 'Certificate authority' respectively.
|
||||
You get both the cluster URL and CA directly from the AWS console by going to
|
||||
`EKS` > `Your cluster` > `Overview` > `Details`. You will find them under 'API
|
||||
server endpoint' and 'Certificate authority' respectively.
|
||||
|
||||
If Backstage needs to assume a role when authenticating with EKS clusters, the
|
||||
`kubernetes.io/aws-assume-role` parameter can be set to the ARN of the desired
|
||||
role. the `kubernetes.io/aws-external-id` parameter in the config corresponds to
|
||||
the `ExternalId` parameter of the [`AssumeRole` API in STS][8].
|
||||
|
||||
### Azure
|
||||
|
||||
The Azure server side authentication provider works by authenticating on the server with
|
||||
the Azure CLI, please note that [Microsoft Entra authentication][1] is a requirement and has to
|
||||
The Azure provider works by authenticating on the server with the Azure CLI,
|
||||
please note that [Microsoft Entra authentication][1] is a requirement and has to
|
||||
be enabled in your AKS cluster, then follow these steps:
|
||||
|
||||
- [Install the Azure CLI][2] in the environment where the backstage application will run.
|
||||
- Login with your Azure/Microsoft account with `az login` in the server's terminal.
|
||||
- Go to your AKS cluster's resource page in Azure Console and follow the steps in the
|
||||
`Connect` tab to set the subscription and get your credentials for `kubectl` integration.
|
||||
- [Install the Azure CLI][2] in the environment where the backstage application
|
||||
will run.
|
||||
- Login with your Azure/Microsoft account with `az login` in the server's
|
||||
terminal.
|
||||
- Go to your AKS cluster's resource page in Azure Console and follow the steps
|
||||
in the `Connect` tab to set the subscription and get your credentials for
|
||||
`kubectl` integration.
|
||||
- Configure your cluster to use the `azure` auth provider like this:
|
||||
|
||||
```yaml
|
||||
@@ -98,18 +125,19 @@ kubernetes:
|
||||
skipTLSVerify: true
|
||||
```
|
||||
|
||||
To get the API server address for your Azure cluster, go to the Azure console page for the
|
||||
cluster resource, go to `Overview` > `Properties` tab > `Networking` section and copy paste
|
||||
the API server address directly in that `url` field.
|
||||
To get the API server address for your Azure cluster, go to the Azure console
|
||||
page for the cluster resource, go to `Overview` > `Properties` tab >
|
||||
`Networking` section and copy paste the API server address directly in that
|
||||
`url` field.
|
||||
|
||||
## Client Side Providers
|
||||
|
||||
These providers authenticate your _user_ with the cluster. Each Backstage user will be
|
||||
prompted for credentials and will have access to the clusters as long as the user has been
|
||||
authorized to access said cluster. If the cluster is listed in the `clusterLocatorMethods`,
|
||||
but the user hasn't been authorized to access, the user will see the cluster listed but
|
||||
will not see any resources in the plugin page for that cluster, and the error will show
|
||||
as `401` or similar.
|
||||
These providers authenticate a _user_ with the cluster. Each Backstage user will
|
||||
be prompted for credentials and will have access to the clusters as long as the
|
||||
user has been authorized to access said cluster. If Backstage is configured to
|
||||
communicate with a cluster but the user isn't authorized to access it, they will
|
||||
see the cluster listed but will not see any resources in the plugin page for
|
||||
that cluster. The error will show as `401` or similar.
|
||||
|
||||
The providers available as client side are:
|
||||
|
||||
@@ -124,3 +152,4 @@ The providers available as client side are:
|
||||
[5]: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html
|
||||
[6]: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use.html
|
||||
[7]: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html
|
||||
[8]: https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html#API_AssumeRole_RequestParameters
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -40,7 +40,7 @@ import { createRouteRef } from '@backstage/core-plugin-api';
|
||||
|
||||
// Note: This route ref is for internal use only, don't export it from the plugin
|
||||
export const rootRouteRef = createRouteRef({
|
||||
title: 'Example Page',
|
||||
id: 'Example Page',
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
@@ -478,7 +478,7 @@
|
||||
"architecture-decisions/adrs-adr012",
|
||||
"architecture-decisions/adrs-adr013"
|
||||
],
|
||||
"FAQ": ["FAQ"],
|
||||
"FAQ": ["faq/index", "faq/product", "faq/technical"],
|
||||
"Accessibility": ["accessibility/index"]
|
||||
}
|
||||
}
|
||||
|
||||
+46
-46
@@ -2701,90 +2701,90 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-darwin-arm64@npm:1.3.104":
|
||||
version: 1.3.104
|
||||
resolution: "@swc/core-darwin-arm64@npm:1.3.104"
|
||||
"@swc/core-darwin-arm64@npm:1.3.105":
|
||||
version: 1.3.105
|
||||
resolution: "@swc/core-darwin-arm64@npm:1.3.105"
|
||||
conditions: os=darwin & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-darwin-x64@npm:1.3.104":
|
||||
version: 1.3.104
|
||||
resolution: "@swc/core-darwin-x64@npm:1.3.104"
|
||||
"@swc/core-darwin-x64@npm:1.3.105":
|
||||
version: 1.3.105
|
||||
resolution: "@swc/core-darwin-x64@npm:1.3.105"
|
||||
conditions: os=darwin & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-linux-arm-gnueabihf@npm:1.3.104":
|
||||
version: 1.3.104
|
||||
resolution: "@swc/core-linux-arm-gnueabihf@npm:1.3.104"
|
||||
"@swc/core-linux-arm-gnueabihf@npm:1.3.105":
|
||||
version: 1.3.105
|
||||
resolution: "@swc/core-linux-arm-gnueabihf@npm:1.3.105"
|
||||
conditions: os=linux & cpu=arm
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-linux-arm64-gnu@npm:1.3.104":
|
||||
version: 1.3.104
|
||||
resolution: "@swc/core-linux-arm64-gnu@npm:1.3.104"
|
||||
"@swc/core-linux-arm64-gnu@npm:1.3.105":
|
||||
version: 1.3.105
|
||||
resolution: "@swc/core-linux-arm64-gnu@npm:1.3.105"
|
||||
conditions: os=linux & cpu=arm64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-linux-arm64-musl@npm:1.3.104":
|
||||
version: 1.3.104
|
||||
resolution: "@swc/core-linux-arm64-musl@npm:1.3.104"
|
||||
"@swc/core-linux-arm64-musl@npm:1.3.105":
|
||||
version: 1.3.105
|
||||
resolution: "@swc/core-linux-arm64-musl@npm:1.3.105"
|
||||
conditions: os=linux & cpu=arm64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-linux-x64-gnu@npm:1.3.104":
|
||||
version: 1.3.104
|
||||
resolution: "@swc/core-linux-x64-gnu@npm:1.3.104"
|
||||
"@swc/core-linux-x64-gnu@npm:1.3.105":
|
||||
version: 1.3.105
|
||||
resolution: "@swc/core-linux-x64-gnu@npm:1.3.105"
|
||||
conditions: os=linux & cpu=x64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-linux-x64-musl@npm:1.3.104":
|
||||
version: 1.3.104
|
||||
resolution: "@swc/core-linux-x64-musl@npm:1.3.104"
|
||||
"@swc/core-linux-x64-musl@npm:1.3.105":
|
||||
version: 1.3.105
|
||||
resolution: "@swc/core-linux-x64-musl@npm:1.3.105"
|
||||
conditions: os=linux & cpu=x64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-win32-arm64-msvc@npm:1.3.104":
|
||||
version: 1.3.104
|
||||
resolution: "@swc/core-win32-arm64-msvc@npm:1.3.104"
|
||||
"@swc/core-win32-arm64-msvc@npm:1.3.105":
|
||||
version: 1.3.105
|
||||
resolution: "@swc/core-win32-arm64-msvc@npm:1.3.105"
|
||||
conditions: os=win32 & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-win32-ia32-msvc@npm:1.3.104":
|
||||
version: 1.3.104
|
||||
resolution: "@swc/core-win32-ia32-msvc@npm:1.3.104"
|
||||
"@swc/core-win32-ia32-msvc@npm:1.3.105":
|
||||
version: 1.3.105
|
||||
resolution: "@swc/core-win32-ia32-msvc@npm:1.3.105"
|
||||
conditions: os=win32 & cpu=ia32
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-win32-x64-msvc@npm:1.3.104":
|
||||
version: 1.3.104
|
||||
resolution: "@swc/core-win32-x64-msvc@npm:1.3.104"
|
||||
"@swc/core-win32-x64-msvc@npm:1.3.105":
|
||||
version: 1.3.105
|
||||
resolution: "@swc/core-win32-x64-msvc@npm:1.3.105"
|
||||
conditions: os=win32 & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core@npm:^1.3.46":
|
||||
version: 1.3.104
|
||||
resolution: "@swc/core@npm:1.3.104"
|
||||
version: 1.3.105
|
||||
resolution: "@swc/core@npm:1.3.105"
|
||||
dependencies:
|
||||
"@swc/core-darwin-arm64": 1.3.104
|
||||
"@swc/core-darwin-x64": 1.3.104
|
||||
"@swc/core-linux-arm-gnueabihf": 1.3.104
|
||||
"@swc/core-linux-arm64-gnu": 1.3.104
|
||||
"@swc/core-linux-arm64-musl": 1.3.104
|
||||
"@swc/core-linux-x64-gnu": 1.3.104
|
||||
"@swc/core-linux-x64-musl": 1.3.104
|
||||
"@swc/core-win32-arm64-msvc": 1.3.104
|
||||
"@swc/core-win32-ia32-msvc": 1.3.104
|
||||
"@swc/core-win32-x64-msvc": 1.3.104
|
||||
"@swc/core-darwin-arm64": 1.3.105
|
||||
"@swc/core-darwin-x64": 1.3.105
|
||||
"@swc/core-linux-arm-gnueabihf": 1.3.105
|
||||
"@swc/core-linux-arm64-gnu": 1.3.105
|
||||
"@swc/core-linux-arm64-musl": 1.3.105
|
||||
"@swc/core-linux-x64-gnu": 1.3.105
|
||||
"@swc/core-linux-x64-musl": 1.3.105
|
||||
"@swc/core-win32-arm64-msvc": 1.3.105
|
||||
"@swc/core-win32-ia32-msvc": 1.3.105
|
||||
"@swc/core-win32-x64-msvc": 1.3.105
|
||||
"@swc/counter": ^0.1.1
|
||||
"@swc/types": ^0.1.5
|
||||
peerDependencies:
|
||||
@@ -2813,7 +2813,7 @@ __metadata:
|
||||
peerDependenciesMeta:
|
||||
"@swc/helpers":
|
||||
optional: true
|
||||
checksum: 95fbf1412c8685d311cf2d7efbfa43e082d2d9e84ece48c4d8d96d6c67c5923569bfb26352451eb3e4d98adcb556dcfac65271c0fba77f078bb755fe2f64b295
|
||||
checksum: 5baa880bc92748ef4845d9c65eba5d6dd01adaa673854e20a5116f5e267c12180db50e563cf3c34a415772b9742d021176a9d9a91065c190ef6f54fefe85728c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -2855,9 +2855,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"@tsconfig/docusaurus@npm:^2.0.0":
|
||||
version: 2.0.1
|
||||
resolution: "@tsconfig/docusaurus@npm:2.0.1"
|
||||
checksum: 63bebda70d83c56f95a90176d2e188e1ea9c08c23b499e5e7b292ebfae0ce7117f712809828ed21ae3b8440daf22191d6bf71bf2575f9bd474a51b1770ca30cc
|
||||
version: 2.0.2
|
||||
resolution: "@tsconfig/docusaurus@npm:2.0.2"
|
||||
checksum: 129f2532172496c108f53a15082e410b418e664c1761902664558059541a6cf7faff2b3d2953e2d507a7e3688afacf2479e17adcb35b89cae04ddb0415d0397a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
+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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"react-use": "{{versionQuery 'react-use' '17.2.4'}}"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "{{versionQuery 'react' '^16.13.1 || ^17.0.0'}}"
|
||||
"react": "{{versionQuery 'react' '^16.13.1 || ^17.0.0 || ^18.0.0'}}"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "{{versionQuery '@backstage/cli'}}",
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"@material-ui/core": "{{versionQuery '@material-ui/core' '4.12.2'}}"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "{{versionQuery 'react' '^16.13.1 || ^17.0.0'}}"
|
||||
"react": "{{versionQuery 'react' '^16.13.1 || ^17.0.0 || ^18.0.0'}}"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "{{versionQuery '@backstage/cli'}}",
|
||||
|
||||
@@ -27,7 +27,7 @@ import { PuppetDbPage } from '@backstage/plugin-puppetdb';
|
||||
import { StackstormPage } from '@backstage/plugin-stackstorm';
|
||||
import { ScoreBoardPage } from '@oriflame/backstage-plugin-score-card';
|
||||
import React, { Fragment } from 'react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { collectLegacyRoutes } from './collectLegacyRoutes';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
@@ -279,4 +279,98 @@ describe('collectLegacyRoutes', () => {
|
||||
screen.findByText('plugins: test'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should throw if invalid Route has been detected', async () => {
|
||||
const plugin = createPlugin({
|
||||
id: 'test',
|
||||
});
|
||||
const routeRef = createRouteRef({ id: 'test' });
|
||||
const Page = plugin.provide(
|
||||
createRoutableExtension({
|
||||
name: 'Test',
|
||||
mountPoint: routeRef,
|
||||
component: async () => () => {
|
||||
const app = useApp();
|
||||
return <div>plugins: {app.getPlugins().map(p => p.getId())}</div>;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
collectLegacyRoutes(
|
||||
<FlatRoutes>
|
||||
<Route path="/" element={<Page />} />
|
||||
<Route path="/" element={<Page />} />
|
||||
<div />
|
||||
</FlatRoutes>,
|
||||
),
|
||||
).toThrow(
|
||||
/Invalid element inside FlatRoutes, expected Route but found div./,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw if invalid element has been detected', async () => {
|
||||
const plugin = createPlugin({
|
||||
id: 'test',
|
||||
});
|
||||
const routeRef = createRouteRef({ id: 'test' });
|
||||
const Page = plugin.provide(
|
||||
createRoutableExtension({
|
||||
name: 'Test',
|
||||
mountPoint: routeRef,
|
||||
component: async () => () => {
|
||||
const app = useApp();
|
||||
return <div>plugins: {app.getPlugins().map(p => p.getId())}</div>;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
collectLegacyRoutes(
|
||||
<FlatRoutes>
|
||||
<Route path="/" element={<Page />} />a string
|
||||
</FlatRoutes>,
|
||||
),
|
||||
).toThrow(
|
||||
/Invalid element inside FlatRoutes, expected Route but found element of type string./,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw if <Route /> has no path', async () => {
|
||||
const plugin = createPlugin({
|
||||
id: 'test',
|
||||
});
|
||||
const routeRef = createRouteRef({ id: 'test' });
|
||||
const Page = plugin.provide(
|
||||
createRoutableExtension({
|
||||
name: 'Test',
|
||||
mountPoint: routeRef,
|
||||
component: () =>
|
||||
Promise.resolve(() => {
|
||||
const app = useApp();
|
||||
return <div>plugins: {app.getPlugins().map(p => p.getId())}</div>;
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
collectLegacyRoutes(
|
||||
<FlatRoutes>
|
||||
<Route element={<Page />} />
|
||||
</FlatRoutes>,
|
||||
),
|
||||
).toThrow(/Route element inside FlatRoutes had no path prop value given/);
|
||||
});
|
||||
|
||||
it('should throw if element cannot be converted', async () => {
|
||||
expect(() =>
|
||||
collectLegacyRoutes(
|
||||
<FlatRoutes>
|
||||
<Route element={<Navigate to="/somewhere" />} />
|
||||
</FlatRoutes>,
|
||||
),
|
||||
).toThrow(
|
||||
/Route with path undefined has en element that can not be converted/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -175,10 +175,16 @@ export function collectLegacyRoutes(
|
||||
flatRoutesElement.props.children,
|
||||
(route: ReactNode) => {
|
||||
// TODO(freben): Handle feature flag and permissions framework wrapper elements
|
||||
if (!React.isValidElement(route) || route.type !== Route) {
|
||||
return;
|
||||
if (!React.isValidElement(route)) {
|
||||
throw new Error(
|
||||
`Invalid element inside FlatRoutes, expected Route but found element of type ${typeof route}.`,
|
||||
);
|
||||
}
|
||||
if (route.type !== Route) {
|
||||
throw new Error(
|
||||
`Invalid element inside FlatRoutes, expected Route but found ${route.type}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const routeElement = route.props.element;
|
||||
const path: string | undefined = route.props.path;
|
||||
const plugin = getComponentData<LegacyBackstagePlugin>(
|
||||
@@ -189,8 +195,16 @@ export function collectLegacyRoutes(
|
||||
routeElement,
|
||||
'core.mountPoint',
|
||||
);
|
||||
if (!plugin || !path) {
|
||||
return;
|
||||
if (!plugin) {
|
||||
throw new Error(
|
||||
// TODO(vinzscam): add See <link-to-app-migration-docs> for more info
|
||||
`Route with path ${path} has en element that can not be converted as it does not belong to a plugin. Make sure that the top-level React element of the element prop is an extension from a Backstage plugin, or remove the Route completely.`,
|
||||
);
|
||||
}
|
||||
if (path === undefined) {
|
||||
throw new Error(
|
||||
`Route element inside FlatRoutes had no path prop value given`,
|
||||
);
|
||||
}
|
||||
|
||||
const extensions = getPluginExtensions(plugin);
|
||||
|
||||
@@ -29,6 +29,7 @@ export const OUTPUT_PATH = 'src/generated';
|
||||
export const OPENAPI_IGNORE_FILES = [
|
||||
// Get rid of the default files.
|
||||
'*.md',
|
||||
'*.mustache',
|
||||
// The rest of these have to be explicit, otherwise they get added if this was a *.*
|
||||
'apis/baseapi.ts',
|
||||
'apis/exception.ts',
|
||||
|
||||
@@ -8,6 +8,20 @@ files:
|
||||
model.mustache:
|
||||
templateType: Model
|
||||
destinationFilename: .model.ts
|
||||
modelGeneric.mustache:
|
||||
templateType: SupportingFiles
|
||||
modelOneOf.mustache:
|
||||
templateType: SupportingFiles
|
||||
modelGenericAdditionalProperties.mustache:
|
||||
templateType: SupportingFiles
|
||||
modelGenericEnums.mustache:
|
||||
templateType: SupportingFiles
|
||||
modelAlias.mustache:
|
||||
templateType: SupportingFiles
|
||||
modelEnum.mustache:
|
||||
templateType: SupportingFiles
|
||||
modelTaggedUnion.mustache:
|
||||
templateType: SupportingFiles
|
||||
models/models_all.mustache:
|
||||
templateType: SupportingFiles
|
||||
destinationFilename: models/index.ts
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//
|
||||
|
||||
/**
|
||||
* {{{appName}}}{{#version}}@{{{.}}}{{/version}}
|
||||
*
|
||||
* NOTE: This class is auto generated, do not edit the class manually.
|
||||
*/
|
||||
@@ -1,43 +1,17 @@
|
||||
//
|
||||
|
||||
{{! Sourced from https://github.com/OpenAPITools/openapi-generator/blob/7347daec61b2cb8d3d28e1ed06fe8b5e682090f8/modules/openapi-generator/src/main/resources/typescript-angular/model.mustache#L17. }}
|
||||
{{>licenseInfo}}
|
||||
{{#models}}
|
||||
{{#model}}
|
||||
{{#tsImports}}
|
||||
import { {{classname}} } from '{{filename}}.model{{importFileExtension}}';
|
||||
import { {{classname}} } from '{{filename}}.model';
|
||||
{{/tsImports}}
|
||||
|
||||
|
||||
{{#description}}
|
||||
/**
|
||||
* {{{.}}}
|
||||
*/
|
||||
* {{{.}}}
|
||||
*/
|
||||
{{/description}}
|
||||
{{^isEnum}}
|
||||
export interface {{classname}} {
|
||||
{{#additionalPropertiesType}}
|
||||
[key: string]: {{{additionalPropertiesType}}};
|
||||
{{/additionalPropertiesType}}
|
||||
{{#vars}}
|
||||
{{#description}}
|
||||
/**
|
||||
* {{{.}}}
|
||||
*/
|
||||
{{/description}}
|
||||
'{{name}}'{{^required}}?{{/required}}: {{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}{{#isNullable}} | null{{/isNullable}};
|
||||
{{/vars}}
|
||||
}
|
||||
|
||||
{{#hasEnums}}
|
||||
|
||||
{{#vars}}
|
||||
{{#isEnum}}
|
||||
export type {{classname}}{{enumName}} ={{#allowableValues}}{{#values}} "{{.}}" {{^-last}}|{{/-last}}{{/values}}{{/allowableValues}};
|
||||
{{/isEnum}}
|
||||
{{/vars}}
|
||||
|
||||
{{/hasEnums}}
|
||||
{{/isEnum}}
|
||||
{{#isEnum}}
|
||||
export type {{classname}} ={{#allowableValues}}{{#values}} "{{.}}" {{^-last}}|{{/-last}}{{/values}}{{/allowableValues}};
|
||||
{{/isEnum}}
|
||||
{{#isEnum}}{{>modelEnum}}{{/isEnum}}{{^isEnum}}{{#isAlias}}{{>modelAlias}}{{/isAlias}}{{^isAlias}}{{#taggedUnions}}{{>modelTaggedUnion}}{{/taggedUnions}}{{^taggedUnions}}{{#oneOf}}{{#-first}}{{>modelOneOf}}{{/-first}}{{/oneOf}}{{^oneOf}}{{>modelGeneric}}{{/oneOf}}{{/taggedUnions}}{{/isAlias}}{{/isEnum}}
|
||||
{{/model}}
|
||||
{{/models}}
|
||||
{{/models}}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{{! Sourced from https://github.com/OpenAPITools/openapi-generator/blob/7347daec61b2cb8d3d28e1ed06fe8b5e682090f8/modules/openapi-generator/src/main/resources/typescript-angular/modelAlias.mustache }}
|
||||
|
||||
export type {{classname}} = {{dataType}};
|
||||
@@ -0,0 +1,22 @@
|
||||
{{! Sourced from https://github.com/OpenAPITools/openapi-generator/blob/7347daec61b2cb8d3d28e1ed06fe8b5e682090f8/modules/openapi-generator/src/main/resources/typescript-angular/modelEnum.mustache }}
|
||||
|
||||
{{#stringEnums}}
|
||||
export enum {{classname}} {
|
||||
{{#allowableValues}}
|
||||
{{#enumVars}}
|
||||
{{name}} = {{{value}}}{{^-last}},{{/-last}}
|
||||
{{/enumVars}}
|
||||
{{/allowableValues}}
|
||||
}
|
||||
{{/stringEnums}}
|
||||
{{^stringEnums}}
|
||||
export type {{classname}} = {{#allowableValues}}{{#enumVars}}{{{value}}}{{^-last}} | {{/-last}}{{/enumVars}}{{/allowableValues}};
|
||||
|
||||
export const {{classname}} = {
|
||||
{{#allowableValues}}
|
||||
{{#enumVars}}
|
||||
{{name}}: {{{value}}} as {{classname}}{{^-last}},{{/-last}}
|
||||
{{/enumVars}}
|
||||
{{/allowableValues}}
|
||||
};
|
||||
{{/stringEnums}}
|
||||
@@ -0,0 +1,38 @@
|
||||
{{! Sourced from https://github.com/OpenAPITools/openapi-generator/blob/7347daec61b2cb8d3d28e1ed06fe8b5e682090f8/modules/openapi-generator/src/main/resources/typescript-angular/modelGeneric.mustache }}
|
||||
|
||||
{{#models}}
|
||||
{{#model}}
|
||||
|
||||
{{#description}}
|
||||
/**
|
||||
* {{{.}}}
|
||||
*/
|
||||
{{/description}}
|
||||
{{^isEnum}}
|
||||
export interface {{classname}} {
|
||||
{{>modelGenericAdditionalProperties}}
|
||||
{{#vars}}
|
||||
{{#description}}
|
||||
/**
|
||||
* {{{.}}}
|
||||
*/
|
||||
{{/description}}
|
||||
'{{name}}'{{^required}}?{{/required}}: {{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}{{#isNullable}} | null{{/isNullable}};
|
||||
{{/vars}}
|
||||
}
|
||||
|
||||
{{#hasEnums}}
|
||||
|
||||
{{#vars}}
|
||||
{{#isEnum}}
|
||||
export type {{classname}}{{enumName}} ={{#allowableValues}}{{#values}} "{{.}}" {{^-last}}|{{/-last}}{{/values}}{{/allowableValues}};
|
||||
{{/isEnum}}
|
||||
{{/vars}}
|
||||
|
||||
{{/hasEnums}}
|
||||
{{/isEnum}}
|
||||
{{#isEnum}}
|
||||
export type {{classname}} ={{#allowableValues}}{{#values}} "{{.}}" {{^-last}}|{{/-last}}{{/values}}{{/allowableValues}};
|
||||
{{/isEnum}}
|
||||
{{/model}}
|
||||
{{/models}}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{{! Sourced from https://github.com/OpenAPITools/openapi-generator/blob/7347daec61b2cb8d3d28e1ed06fe8b5e682090f8/modules/openapi-generator/src/main/resources/typescript-angular/modelGenericAdditionalProperties.mustache }}
|
||||
|
||||
{{#additionalPropertiesType}}
|
||||
|
||||
[key: string]: {{{additionalPropertiesType}}}{{#hasVars}} | any{{/hasVars}};
|
||||
|
||||
{{/additionalPropertiesType}}
|
||||
@@ -0,0 +1,32 @@
|
||||
{{! Sourced from https://github.com/OpenAPITools/openapi-generator/blob/7347daec61b2cb8d3d28e1ed06fe8b5e682090f8/modules/openapi-generator/src/main/resources/typescript-angular/modelGenericEnums.mustache }}
|
||||
|
||||
{{#hasEnums}}
|
||||
|
||||
{{^stringEnums}}
|
||||
export namespace {{classname}} {
|
||||
{{/stringEnums}}
|
||||
{{#vars}}
|
||||
{{#isEnum}}
|
||||
{{#stringEnums}}
|
||||
export enum {{classname}}{{enumName}} {
|
||||
{{#allowableValues}}
|
||||
{{#enumVars}}
|
||||
{{name}} = {{{value}}}{{^-last}},{{/-last}}
|
||||
{{/enumVars}}
|
||||
{{/allowableValues}}
|
||||
};
|
||||
{{/stringEnums}}
|
||||
{{^stringEnums}}
|
||||
export type {{enumName}} = {{#allowableValues}}{{#enumVars}}{{{value}}}{{^-last}} | {{/-last}}{{/enumVars}}{{/allowableValues}};
|
||||
export const {{enumName}} = {
|
||||
{{#allowableValues}}
|
||||
{{#enumVars}}
|
||||
{{name}}: {{{value}}} as {{enumName}}{{^-last}},{{/-last}}
|
||||
{{/enumVars}}
|
||||
{{/allowableValues}}
|
||||
};
|
||||
{{/stringEnums}}
|
||||
{{/isEnum}}
|
||||
{{/vars}}
|
||||
{{^stringEnums}}}{{/stringEnums}}
|
||||
{{/hasEnums}}
|
||||
@@ -0,0 +1,16 @@
|
||||
{{! Sourced from https://github.com/OpenAPITools/openapi-generator/blob/7347daec61b2cb8d3d28e1ed06fe8b5e682090f8/modules/openapi-generator/src/main/resources/typescript-angular/modelOneOf.mustache }}
|
||||
|
||||
{{#hasImports}}
|
||||
import {
|
||||
{{#imports}}
|
||||
{{{.}}},
|
||||
{{/imports}}
|
||||
} from './';
|
||||
|
||||
{{/hasImports}}
|
||||
{{#description}}
|
||||
/**
|
||||
* {{{.}}}
|
||||
*/
|
||||
{{/description}}
|
||||
export type {{classname}} = {{#oneOf}}{{{.}}}{{^-last}} | {{/-last}}{{/oneOf}};
|
||||
@@ -0,0 +1,23 @@
|
||||
{{! Sourced from https://github.com/OpenAPITools/openapi-generator/blob/7347daec61b2cb8d3d28e1ed06fe8b5e682090f8/modules/openapi-generator/src/main/resources/typescript-angular/modelTaggedUnion.mustache }}
|
||||
|
||||
{{#discriminator}}
|
||||
export type {{classname}} = {{#children}}{{^-first}} | {{/-first}}{{classname}}{{/children}};
|
||||
{{/discriminator}}
|
||||
{{^discriminator}}
|
||||
{{#parent}}
|
||||
export interface {{classname}} { {{>modelGenericAdditionalProperties}}
|
||||
{{#allVars}}
|
||||
{{#description}}
|
||||
/**
|
||||
* {{{.}}}
|
||||
*/
|
||||
{{/description}}
|
||||
{{name}}{{^required}}?{{/required}}: {{#discriminatorValue}}'{{.}}'{{/discriminatorValue}}{{^discriminatorValue}}{{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}{{/discriminatorValue}}{{#isNullable}} | null{{/isNullable}};
|
||||
{{/allVars}}
|
||||
}
|
||||
{{>modelGenericEnums}}
|
||||
{{/parent}}
|
||||
{{^parent}}
|
||||
{{>modelGeneric}}
|
||||
{{/parent}}
|
||||
{{/discriminator}}
|
||||
@@ -1,6 +1,2 @@
|
||||
|
||||
{{#servers}}
|
||||
{{#-last}}
|
||||
export const pluginId = "{{url}}";
|
||||
{{/-last}}
|
||||
{{/servers}}
|
||||
export const pluginId = "{{appName}}";
|
||||
@@ -46,7 +46,7 @@
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.61",
|
||||
"@types/react": "^16.13.1 || ^17.0.0",
|
||||
"graphiql": "3.0.10",
|
||||
"graphiql": "3.1.0",
|
||||
"graphql": "^16.0.0",
|
||||
"graphql-config": "^5.0.2",
|
||||
"graphql-ws": "^5.4.1",
|
||||
|
||||
@@ -31,7 +31,9 @@ type InjectOptions = {
|
||||
/**
|
||||
* Injects configs into the app bundle, replacing any existing injected config.
|
||||
*/
|
||||
export async function injectConfig(options: InjectOptions) {
|
||||
export async function injectConfig(
|
||||
options: InjectOptions,
|
||||
): Promise<string | undefined> {
|
||||
const { staticDir, logger, appConfigs } = options;
|
||||
|
||||
const files = await fs.readdir(staticDir);
|
||||
@@ -52,7 +54,7 @@ export async function injectConfig(options: InjectOptions) {
|
||||
injected,
|
||||
);
|
||||
await fs.writeFile(path, newContent, 'utf8');
|
||||
return;
|
||||
return path;
|
||||
} else if (content.includes('__APP_INJECTED_CONFIG_MARKER__')) {
|
||||
logger.info(`Replacing injected env config in ${jsFile}`);
|
||||
|
||||
@@ -61,10 +63,11 @@ export async function injectConfig(options: InjectOptions) {
|
||||
injected,
|
||||
);
|
||||
await fs.writeFile(path, newContent, 'utf8');
|
||||
return;
|
||||
return path;
|
||||
}
|
||||
}
|
||||
logger.info('Env config not injected');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type ReadOptions = {
|
||||
|
||||
@@ -16,3 +16,4 @@
|
||||
|
||||
export const CACHE_CONTROL_NO_CACHE = 'no-store, max-age=0';
|
||||
export const CACHE_CONTROL_MAX_CACHE = 'public, max-age=1209600'; // 14 days
|
||||
export const CACHE_CONTROL_REVALIDATE_CACHE = 'no-cache'; // require revalidating cached responses before reuse them.
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import {
|
||||
CACHE_CONTROL_MAX_CACHE,
|
||||
CACHE_CONTROL_NO_CACHE,
|
||||
CACHE_CONTROL_REVALIDATE_CACHE,
|
||||
} from '../lib/headers';
|
||||
|
||||
// express uses mime v1 while we only have types for mime v2
|
||||
@@ -114,6 +115,7 @@ export async function createRouter(
|
||||
|
||||
logger.info(`Serving static app content from ${appDistDir}`);
|
||||
|
||||
let injectedConfigPath: string | undefined;
|
||||
if (!disableConfigInjection) {
|
||||
const appConfigs = await readConfigs({
|
||||
config,
|
||||
@@ -121,7 +123,7 @@ export async function createRouter(
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
await injectConfig({ appConfigs, logger, staticDir });
|
||||
injectedConfigPath = await injectConfig({ appConfigs, logger, staticDir });
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
@@ -132,8 +134,12 @@ export async function createRouter(
|
||||
const staticRouter = Router();
|
||||
staticRouter.use(
|
||||
express.static(resolvePath(appDistDir, 'static'), {
|
||||
setHeaders: res => {
|
||||
res.setHeader('Cache-Control', CACHE_CONTROL_MAX_CACHE);
|
||||
setHeaders: (res, path) => {
|
||||
if (path === injectedConfigPath) {
|
||||
res.setHeader('Cache-Control', CACHE_CONTROL_REVALIDATE_CACHE);
|
||||
} else {
|
||||
res.setHeader('Cache-Control', CACHE_CONTROL_MAX_CACHE);
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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 { 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.7.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,
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The oidc-provider backend module for the auth plugin.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
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(),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -15,4 +15,11 @@
|
||||
*/
|
||||
|
||||
export { oidc } from './provider';
|
||||
export type { OidcAuthResult } from './provider';
|
||||
|
||||
import { OidcAuthResult as OidcAuthResult_ } from '@backstage/plugin-auth-backend-module-oidc-provider';
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use OidcAuthResult from `@backstage/plugin-auth-backend-module-oidc-provider` instead
|
||||
*/
|
||||
export type OidcAuthResult = OidcAuthResult_;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -13,177 +13,157 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Config, ConfigReader } from '@backstage/config';
|
||||
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { Config, ConfigReader } from '@backstage/config';
|
||||
import {
|
||||
AuthProviderConfig,
|
||||
AuthResolverContext,
|
||||
CookieConfigurer,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import express from 'express';
|
||||
import { Session } from 'express-session';
|
||||
import { UnsecuredJWT } from 'jose';
|
||||
import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { ClientMetadata, IssuerMetadata } from 'openid-client';
|
||||
import { OAuthAdapter } from '../../lib/oauth';
|
||||
import { oidc, OidcAuthProvider, Options } from './provider';
|
||||
import { AuthResolverContext } from '../types';
|
||||
import { oidc } from './provider';
|
||||
|
||||
const issuerMetadata = {
|
||||
issuer: 'https://oidc.test',
|
||||
authorization_endpoint: 'https://oidc.test/as/authorization.oauth2',
|
||||
token_endpoint: 'https://oidc.test/as/token.oauth2',
|
||||
revocation_endpoint: 'https://oidc.test/as/revoke_token.oauth2',
|
||||
userinfo_endpoint: 'https://oidc.test/idp/userinfo.openid',
|
||||
introspection_endpoint: 'https://oidc.test/as/introspect.oauth2',
|
||||
jwks_uri: 'https://oidc.test/pf/JWKS',
|
||||
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'],
|
||||
};
|
||||
describe('oidc.create', () => {
|
||||
const userinfo = {
|
||||
sub: 'test',
|
||||
iss: 'https://oidc.test',
|
||||
aud: 'clientId',
|
||||
nonce: 'foo',
|
||||
};
|
||||
const server = setupServer();
|
||||
setupRequestMockHandlers(server);
|
||||
|
||||
const clientMetadata: Options = {
|
||||
authHandler: async input => ({
|
||||
profile: {
|
||||
displayName: input.userinfo.email,
|
||||
},
|
||||
}),
|
||||
resolverContext: {} as AuthResolverContext,
|
||||
callbackUrl: 'https://oidc.test/callback',
|
||||
clientId: 'testclientid',
|
||||
clientSecret: 'testclientsecret',
|
||||
metadataUrl: 'https://oidc.test/.well-known/openid-configuration',
|
||||
tokenEndpointAuthMethod: 'none',
|
||||
tokenSignedResponseAlg: 'none',
|
||||
};
|
||||
let publicKey: JWK;
|
||||
let tokenset: object;
|
||||
let providerFactoryOptions: {
|
||||
providerId: string;
|
||||
globalConfig: AuthProviderConfig;
|
||||
config: Config;
|
||||
logger: LoggerService;
|
||||
resolverContext: AuthResolverContext;
|
||||
baseUrl: string;
|
||||
appUrl: string;
|
||||
isOriginAllowed: (origin: string) => boolean;
|
||||
cookieConfigurer?: CookieConfigurer;
|
||||
};
|
||||
|
||||
describe('OidcAuthProvider', () => {
|
||||
const worker = setupServer();
|
||||
setupRequestMockHandlers(worker);
|
||||
beforeAll(async () => {
|
||||
const keyPair = await generateKeyPair('RS256');
|
||||
const privateKey = await exportJWK(keyPair.privateKey);
|
||||
publicKey = await exportJWK(keyPair.publicKey);
|
||||
publicKey.alg = privateKey.alg = 'RS256';
|
||||
|
||||
tokenset = {
|
||||
id_token: await new SignJWT({
|
||||
iat: Date.now(),
|
||||
exp: Date.now() + 10000,
|
||||
...userinfo,
|
||||
})
|
||||
.setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid })
|
||||
.sign(keyPair.privateKey),
|
||||
access_token: 'accessToken',
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('hit the metadata url', async () => {
|
||||
const handler = jest.fn((_req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.json(issuerMetadata),
|
||||
);
|
||||
});
|
||||
worker.use(
|
||||
rest.get('https://oidc.test/.well-known/openid-configuration', handler),
|
||||
server.use(
|
||||
rest.get(
|
||||
'https://oidc.test/.well-known/openid-configuration',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
issuer: 'https://oidc.test',
|
||||
token_endpoint: 'https://oidc.test/oauth2/token',
|
||||
userinfo_endpoint: 'https://oidc.test/idp/userinfo.openid',
|
||||
jwks_uri: 'https://oidc.test/jwks.json',
|
||||
}),
|
||||
),
|
||||
),
|
||||
rest.post('https://oidc.test/oauth2/token', (_req, res, ctx) =>
|
||||
res(ctx.json(tokenset)),
|
||||
),
|
||||
rest.get('https://oidc.test/jwks.json', async (_req, res, ctx) =>
|
||||
res(ctx.json({ keys: [{ ...publicKey }] })),
|
||||
),
|
||||
rest.get(
|
||||
'https://oidc.test/idp/userinfo.openid',
|
||||
async (_req, res, ctx) => res(ctx.json(userinfo)),
|
||||
),
|
||||
);
|
||||
const provider = new OidcAuthProvider(clientMetadata);
|
||||
const { strategy } = (await (provider as any).implementation) as any as {
|
||||
strategy: {
|
||||
_client: ClientMetadata;
|
||||
_issuer: IssuerMetadata;
|
||||
};
|
||||
};
|
||||
// Assert that the expected request to the metadaurl was made.
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
const { _client, _issuer } = strategy;
|
||||
expect(_client.client_id).toBe(clientMetadata.clientId);
|
||||
expect(_issuer.token_endpoint).toBe(issuerMetadata.token_endpoint);
|
||||
});
|
||||
|
||||
it('OidcAuthProvider#handler successfully invokes the oidc endpoints', async () => {
|
||||
const sub = 'alice';
|
||||
const iss = 'https://oidc.test';
|
||||
const iat = Date.now();
|
||||
const aud = clientMetadata.clientId;
|
||||
const exp = Date.now() + 10000;
|
||||
const jwt = await new UnsecuredJWT({ iss, sub, aud, iat, exp })
|
||||
.setIssuer(iss)
|
||||
.setAudience(aud)
|
||||
.setSubject(sub)
|
||||
.setIssuedAt(iat)
|
||||
.setExpirationTime(exp)
|
||||
.encode();
|
||||
const requestSequence: Array<string> = [];
|
||||
|
||||
// The array of expected requests executed by the provider handler
|
||||
const requests: Array<{
|
||||
method: 'get' | 'post';
|
||||
url: string;
|
||||
payload: object;
|
||||
}> = [
|
||||
{
|
||||
method: 'get',
|
||||
url: 'https://oidc.test/.well-known/openid-configuration',
|
||||
payload: issuerMetadata,
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
url: 'https://oidc.test/as/token.oauth2',
|
||||
payload: {
|
||||
id_token: jwt,
|
||||
access_token: 'test',
|
||||
authorization_signed_response_alg: 'HS256',
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
url: 'https://oidc.test/idp/userinfo.openid',
|
||||
payload: {
|
||||
sub: 'alice',
|
||||
email: 'alice@oidc.test',
|
||||
},
|
||||
},
|
||||
];
|
||||
worker.use(
|
||||
...requests.map(r => {
|
||||
return rest[r.method](r.url, (_req, res, ctx) => {
|
||||
requestSequence.push(r.url);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.json(r.payload),
|
||||
);
|
||||
});
|
||||
}),
|
||||
);
|
||||
const provider = new OidcAuthProvider(clientMetadata);
|
||||
const req = {
|
||||
method: 'GET',
|
||||
url: 'https://oidc.test/?code=test2',
|
||||
session: { 'oidc:oidc.test': 'test' } as any as Session,
|
||||
} as express.Request;
|
||||
await provider.handler(req);
|
||||
expect(requestSequence).toEqual([0, 1, 2].map(i => requests[i].url));
|
||||
});
|
||||
|
||||
it('oidc.create', async () => {
|
||||
const handler = jest.fn((_req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.json(issuerMetadata),
|
||||
);
|
||||
});
|
||||
worker.use(
|
||||
rest.get('https://oidc.test/.well-known/openid-configuration', handler),
|
||||
);
|
||||
const config: Config = new ConfigReader({
|
||||
testEnv: {
|
||||
...clientMetadata,
|
||||
metadataUrl: 'https://oidc.test/.well-known/openid-configuration',
|
||||
},
|
||||
} as any);
|
||||
const provider = oidc.create()({
|
||||
providerFactoryOptions = {
|
||||
providerId: 'myoidc',
|
||||
baseUrl: 'http://backstage.test/api/auth',
|
||||
appUrl: 'http://backstage.test',
|
||||
isOriginAllowed: _ => true,
|
||||
globalConfig: {
|
||||
appUrl: 'https://oidc.test',
|
||||
baseUrl: 'https://oidc.test',
|
||||
baseUrl: 'http://backstage.test/api/auth',
|
||||
appUrl: 'http://backstage.test',
|
||||
isOriginAllowed: _ => true,
|
||||
},
|
||||
config,
|
||||
} as any) as OAuthAdapter;
|
||||
expect(provider.start).toBeDefined();
|
||||
// Cast provider as any here to be able to inspect private members
|
||||
await (provider as any).handlers.get('testEnv').handlers.implementation;
|
||||
// Assert that the expected request to the metadaurl was made.
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
config: new ConfigReader({
|
||||
development: {
|
||||
metadataUrl: 'https://oidc.test/.well-known/openid-configuration',
|
||||
clientId: 'clientId',
|
||||
clientSecret: 'clientSecret',
|
||||
},
|
||||
}),
|
||||
logger: getVoidLogger(),
|
||||
resolverContext: {
|
||||
issueToken: jest.fn(),
|
||||
findCatalogUser: jest.fn(),
|
||||
signInWithCatalogUser: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('invokes authHandler with tokenset and userinfo response', async () => {
|
||||
const authHandler = jest.fn();
|
||||
const provider = oidc.create({ authHandler })(providerFactoryOptions);
|
||||
const state = Buffer.from('nonce=foo&env=development').toString('hex');
|
||||
|
||||
await provider.frameHandler(
|
||||
{
|
||||
method: 'GET',
|
||||
url: `http://backstage.test/api/auth/myoidc/handler/frame?code=blahblah&state=${state}`,
|
||||
query: { state },
|
||||
cookies: { 'myoidc-nonce': 'foo' },
|
||||
session: { 'oidc:oidc.test': { state, nonce: 'foo' } },
|
||||
} as unknown as express.Request,
|
||||
{ setHeader: jest.fn(), end: jest.fn() } as unknown as express.Response,
|
||||
);
|
||||
|
||||
expect(authHandler).toHaveBeenCalledWith(
|
||||
{ tokenset, userinfo },
|
||||
providerFactoryOptions.resolverContext,
|
||||
);
|
||||
});
|
||||
|
||||
it('invokes sign-in resolver with tokenset and userinfo response', async () => {
|
||||
const resolver = jest.fn();
|
||||
const provider = oidc.create({ signIn: { resolver } })(
|
||||
providerFactoryOptions,
|
||||
);
|
||||
const state = Buffer.from('nonce=foo&env=development').toString('hex');
|
||||
|
||||
await provider.frameHandler(
|
||||
{
|
||||
method: 'GET',
|
||||
url: `http://backstage.test/api/auth/myoidc/handler/frame?code=blahblah&state=${state}`,
|
||||
query: { state },
|
||||
cookies: { 'myoidc-nonce': 'foo' },
|
||||
session: { 'oidc:oidc.test': { state, nonce: 'foo' } },
|
||||
} as unknown as express.Request,
|
||||
{ setHeader: jest.fn(), end: jest.fn() } as unknown as express.Response,
|
||||
);
|
||||
|
||||
expect(resolver).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ result: { tokenset, userinfo } }),
|
||||
providerFactoryOptions.resolverContext,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,208 +14,23 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import {
|
||||
Client,
|
||||
ClientAuthMethod,
|
||||
Issuer,
|
||||
Strategy as OidcStrategy,
|
||||
TokenSet,
|
||||
UserinfoResponse,
|
||||
} from 'openid-client';
|
||||
import {
|
||||
encodeState,
|
||||
OAuthAdapter,
|
||||
OAuthEnvironmentHandler,
|
||||
OAuthHandlers,
|
||||
OAuthProviderOptions,
|
||||
OAuthRefreshRequest,
|
||||
OAuthResponse,
|
||||
OAuthStartRequest,
|
||||
} from '../../lib/oauth';
|
||||
import {
|
||||
executeFrameHandlerStrategy,
|
||||
executeRedirectStrategy,
|
||||
PassportDoneCallback,
|
||||
} from '../../lib/passport';
|
||||
import {
|
||||
AuthHandler,
|
||||
AuthResolverContext,
|
||||
OAuthStartResponse,
|
||||
SignInResolver,
|
||||
} from '../types';
|
||||
import { AuthHandler, SignInResolver } from '../types';
|
||||
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
|
||||
import {
|
||||
createOAuthProviderFactory,
|
||||
AuthResolverContext,
|
||||
BackstageSignInResult,
|
||||
OAuthAuthenticatorResult,
|
||||
SignInInfo,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import {
|
||||
oidcAuthenticator,
|
||||
OidcAuthResult,
|
||||
} from '@backstage/plugin-auth-backend-module-oidc-provider';
|
||||
import {
|
||||
commonByEmailLocalPartResolver,
|
||||
commonByEmailResolver,
|
||||
} from '../resolvers';
|
||||
import { BACKSTAGE_SESSION_EXPIRATION } from '../../lib/session';
|
||||
|
||||
type PrivateInfo = {
|
||||
refreshToken?: string;
|
||||
};
|
||||
|
||||
type OidcImpl = {
|
||||
strategy: OidcStrategy<UserinfoResponse, Client>;
|
||||
client: Client;
|
||||
};
|
||||
|
||||
/**
|
||||
* authentication result for the OIDC which includes the token set and user information (a profile response sent by OIDC server)
|
||||
* @public
|
||||
*/
|
||||
export type OidcAuthResult = {
|
||||
tokenset: TokenSet;
|
||||
userinfo: UserinfoResponse;
|
||||
};
|
||||
|
||||
export type Options = OAuthProviderOptions & {
|
||||
metadataUrl: string;
|
||||
scope?: string;
|
||||
prompt?: string;
|
||||
tokenEndpointAuthMethod?: ClientAuthMethod;
|
||||
tokenSignedResponseAlg?: string;
|
||||
signInResolver?: SignInResolver<OidcAuthResult>;
|
||||
authHandler: AuthHandler<OidcAuthResult>;
|
||||
resolverContext: AuthResolverContext;
|
||||
};
|
||||
|
||||
export class OidcAuthProvider implements OAuthHandlers {
|
||||
private readonly implementation: Promise<OidcImpl>;
|
||||
private readonly scope?: string;
|
||||
private readonly prompt?: string;
|
||||
|
||||
private readonly signInResolver?: SignInResolver<OidcAuthResult>;
|
||||
private readonly authHandler: AuthHandler<OidcAuthResult>;
|
||||
private readonly resolverContext: AuthResolverContext;
|
||||
|
||||
constructor(options: Options) {
|
||||
this.implementation = this.setupStrategy(options);
|
||||
this.scope = options.scope;
|
||||
this.prompt = options.prompt;
|
||||
this.signInResolver = options.signInResolver;
|
||||
this.authHandler = options.authHandler;
|
||||
this.resolverContext = options.resolverContext;
|
||||
}
|
||||
|
||||
async start(req: OAuthStartRequest): Promise<OAuthStartResponse> {
|
||||
const { strategy } = await this.implementation;
|
||||
const options: Record<string, string> = {
|
||||
scope: req.scope || this.scope || 'openid profile email',
|
||||
state: encodeState(req.state),
|
||||
};
|
||||
const prompt = this.prompt || 'none';
|
||||
if (prompt !== 'auto') {
|
||||
options.prompt = prompt;
|
||||
}
|
||||
return await executeRedirectStrategy(req, strategy, options);
|
||||
}
|
||||
|
||||
async handler(req: express.Request) {
|
||||
const { strategy } = await this.implementation;
|
||||
const { result, privateInfo } = await executeFrameHandlerStrategy<
|
||||
OidcAuthResult,
|
||||
PrivateInfo
|
||||
>(req, strategy);
|
||||
|
||||
return {
|
||||
response: await this.handleResult(result),
|
||||
refreshToken: privateInfo.refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(req: OAuthRefreshRequest) {
|
||||
const { client } = await this.implementation;
|
||||
const tokenset = await client.refresh(req.refreshToken);
|
||||
if (!tokenset.access_token) {
|
||||
throw new Error('Refresh failed');
|
||||
}
|
||||
if (!tokenset.scope) {
|
||||
tokenset.scope = req.scope;
|
||||
}
|
||||
const userinfo = await client.userinfo(tokenset.access_token);
|
||||
|
||||
return {
|
||||
response: await this.handleResult({ tokenset, userinfo }),
|
||||
refreshToken: tokenset.refresh_token,
|
||||
};
|
||||
}
|
||||
|
||||
private async setupStrategy(options: Options): Promise<OidcImpl> {
|
||||
const issuer = await Issuer.discover(options.metadataUrl);
|
||||
const client = new issuer.Client({
|
||||
access_type: 'offline', // this option must be passed to provider to receive a refresh token
|
||||
client_id: options.clientId,
|
||||
client_secret: options.clientSecret,
|
||||
redirect_uris: [options.callbackUrl],
|
||||
response_types: ['code'],
|
||||
token_endpoint_auth_method:
|
||||
options.tokenEndpointAuthMethod || 'client_secret_basic',
|
||||
id_token_signed_response_alg: options.tokenSignedResponseAlg || 'RS256',
|
||||
scope: options.scope || '',
|
||||
});
|
||||
|
||||
const strategy = new OidcStrategy(
|
||||
{
|
||||
client,
|
||||
passReqToCallback: false,
|
||||
},
|
||||
(
|
||||
tokenset: TokenSet,
|
||||
userinfo: UserinfoResponse,
|
||||
done: PassportDoneCallback<OidcAuthResult, PrivateInfo>,
|
||||
) => {
|
||||
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,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
strategy.error = console.error;
|
||||
return { strategy, client };
|
||||
}
|
||||
|
||||
// Use this function to grab the user profile info from the token
|
||||
// Then populate the profile with it
|
||||
private async handleResult(result: OidcAuthResult): Promise<OAuthResponse> {
|
||||
const { profile } = await this.authHandler(result, this.resolverContext);
|
||||
|
||||
const expiresInSeconds =
|
||||
result.tokenset.expires_in === undefined
|
||||
? BACKSTAGE_SESSION_EXPIRATION
|
||||
: Math.min(result.tokenset.expires_in, BACKSTAGE_SESSION_EXPIRATION);
|
||||
|
||||
let backstageIdentity = undefined;
|
||||
if (this.signInResolver) {
|
||||
backstageIdentity = await this.signInResolver(
|
||||
{
|
||||
result,
|
||||
profile,
|
||||
},
|
||||
this.resolverContext,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
backstageIdentity,
|
||||
providerInfo: {
|
||||
idToken: result.tokenset.id_token,
|
||||
accessToken: result.tokenset.access_token!,
|
||||
scope: result.tokenset.scope!,
|
||||
expiresInSeconds,
|
||||
},
|
||||
profile,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth provider integration for generic OpenID Connect auth
|
||||
@@ -224,59 +39,44 @@ export class OidcAuthProvider implements OAuthHandlers {
|
||||
*/
|
||||
export const oidc = createAuthProviderIntegration({
|
||||
create(options?: {
|
||||
/**
|
||||
* The profile transformation function used to verify and convert the auth response
|
||||
* into the profile that will be presented to the user.
|
||||
*/
|
||||
authHandler?: AuthHandler<OidcAuthResult>;
|
||||
|
||||
/**
|
||||
* Configure sign-in for this provider; convert user profile respones into
|
||||
* Backstage identities.
|
||||
*/
|
||||
signIn?: {
|
||||
resolver: SignInResolver<OidcAuthResult>;
|
||||
};
|
||||
}) {
|
||||
return ({ providerId, globalConfig, config, resolverContext }) =>
|
||||
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
|
||||
const clientId = envConfig.getString('clientId');
|
||||
const clientSecret = envConfig.getString('clientSecret');
|
||||
const customCallbackUrl = envConfig.getOptionalString('callbackUrl');
|
||||
const callbackUrl =
|
||||
customCallbackUrl ||
|
||||
`${globalConfig.baseUrl}/${providerId}/handler/frame`;
|
||||
const metadataUrl = envConfig.getString('metadataUrl');
|
||||
const tokenEndpointAuthMethod = envConfig.getOptionalString(
|
||||
'tokenEndpointAuthMethod',
|
||||
) as ClientAuthMethod;
|
||||
const tokenSignedResponseAlg = envConfig.getOptionalString(
|
||||
'tokenSignedResponseAlg',
|
||||
);
|
||||
const scope = envConfig.getOptionalString('scope');
|
||||
const prompt = envConfig.getOptionalString('prompt');
|
||||
|
||||
const authHandler: AuthHandler<OidcAuthResult> = options?.authHandler
|
||||
? options.authHandler
|
||||
: async ({ userinfo }) => ({
|
||||
profile: {
|
||||
displayName: userinfo.name,
|
||||
email: userinfo.email,
|
||||
picture: userinfo.picture,
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new OidcAuthProvider({
|
||||
clientId,
|
||||
clientSecret,
|
||||
callbackUrl,
|
||||
tokenEndpointAuthMethod,
|
||||
tokenSignedResponseAlg,
|
||||
metadataUrl,
|
||||
scope,
|
||||
prompt,
|
||||
signInResolver: options?.signIn?.resolver,
|
||||
authHandler,
|
||||
resolverContext,
|
||||
});
|
||||
|
||||
return OAuthAdapter.fromConfig(globalConfig, provider, {
|
||||
providerId,
|
||||
callbackUrl,
|
||||
});
|
||||
});
|
||||
const authHandler = options?.authHandler;
|
||||
const signInResolver = options?.signIn?.resolver;
|
||||
return createOAuthProviderFactory({
|
||||
authenticator: oidcAuthenticator,
|
||||
profileTransform:
|
||||
authHandler &&
|
||||
((
|
||||
result: OAuthAuthenticatorResult<OidcAuthResult>,
|
||||
context: AuthResolverContext,
|
||||
) => authHandler(result.fullProfile, context)),
|
||||
signInResolver:
|
||||
signInResolver &&
|
||||
((
|
||||
info: SignInInfo<OAuthAuthenticatorResult<OidcAuthResult>>,
|
||||
context: AuthResolverContext,
|
||||
): Promise<BackstageSignInResult> =>
|
||||
signInResolver(
|
||||
{
|
||||
result: info.result.fullProfile,
|
||||
profile: info.profile,
|
||||
},
|
||||
context,
|
||||
)),
|
||||
});
|
||||
},
|
||||
resolvers: {
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user