Merge remote-tracking branch 'origin/master' into rec-tasks-version-1

This commit is contained in:
bnechyporenko
2024-01-23 13:01:39 +01:00
135 changed files with 10460 additions and 3338 deletions
+15
View File
@@ -0,0 +1,15 @@
---
'@backstage/repo-tools': minor
---
Updates the OpenAPI client template to support the new format for identifying plugin ID. You should now use `info.title` like so,
```diff
info:
+ title: yourPluginId
- title: @internal/plugin-*-backend
servers:
- /
- - yourPluginId
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/repo-tools': minor
---
Add support for `oneOf` in client generated by `schema openapi generate-client`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-okta-provider': patch
---
Added missing `additionalScopes` option to configuration schema.
+6
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Use the externalized `auth-backend-module-microsoft-provider` again.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-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.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-openapi-utils': patch
---
Internal updates due to `json-schema-to-ts`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Migrated oidc auth provider to new `@backstage/plugin-auth-backend-module-oidc-provider` module package.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-catalog-backend': minor
'@backstage/plugin-search-backend': minor
'@backstage/plugin-todo-backend': patch
---
Updates the OpenAPI spec to use plugin as `info.title` instead of package name.
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/catalog-client': minor
'@backstage/plugin-catalog-backend': minor
'@backstage/plugin-search-backend': minor
'@backstage/plugin-todo-backend': patch
---
Updates the OpenAPI specification title to plugin ID instead of package name.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-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
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-oidc-provider': minor
---
Created new `@backstage/plugin-auth-backend-module-oidc-provider` module package to house oidc auth provider migration.
+11
View File
@@ -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`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-oidc-provider': patch
---
Updated dependency `passport` to `^0.7.0`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-bitbucket-cloud-common': patch
---
Updated dependency `ts-morph` to `^21.0.0`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-api-docs': patch
---
Updated dependency `graphiql` to `3.1.0`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-microsoft-provider': patch
---
Added the possibility to use custom scopes for performing login with Microsoft EntraID.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': minor
---
Exported `CatalogTable.defaultColumnsFunc` for defining the columns in `<CatalogTable />` of some Kinds while using the default columns for the others.
+14
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-app-backend': patch
---
Do not force caching of the Javascript asset that contains the injected config.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-cloudbuild': patch
---
Add telemetry HTTP header Google Cloud Platform
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-home': patch
---
Fix typo in VisitsStorageApi
+5
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-compat-api': patch
---
collectLegacyRoutes throws in case invalid <Route /> element is found
+21 -18
View File
@@ -2,34 +2,37 @@ area:catalog:
- plugins/catalog/**/*
- plugins/catalog-*/**/*
- packages/catalog-*/**/*
area:scaffolder:
- plugins/scaffolder/**/*
- plugins/scaffolder-*/**/*
search:
- plugins/search/**/*
- plugins/search-*/**/*
- packages/search-*/**/*
homepage:
- plugins/home/**/*
area:discoverability: # search + home
- plugins/search/**/*
- plugins/search-*/**/*
- packages/search-*/**/*
- plugins/home/**/*
area:kubernetes:
- plugins/kubernetes/**/*
- plugins/kubernetes-*/**/*
area:permission:
- plugins/permission-*/**/*
area:scaffolder:
- plugins/scaffolder/**/*
- plugins/scaffolder-*/**/*
area:techdocs:
- plugins/techdocs/**/*
- plugins/techdocs-*/**/*
- packages/techdocs-*/**/*
documentation:
- docs/**/*
microsite:
- microsite/**/*
storybook:
- storybook/**/*
auth:
- plugins/auth-backend/**/*
- plugins/auth-*/**/*
- packages/core-app-api/src/apis/implementations/auth/**/*
- packages/core-app-api/src/lib/Auth*/**/*
- packages/core-plugin-api/src/apis/definitions/auth.ts
area:permission:
- plugins/permission-*/**/*
documentation:
- docs/**/*
homepage:
- plugins/home/**/*
microsite:
- microsite/**/*
search:
- plugins/search/**/*
- plugins/search-*/**/*
- packages/search-*/**/*
storybook:
- storybook/**/*
@@ -330,6 +330,7 @@ Rollbar
Rollup
routable
Routable
Rspack
rst
rsync
ruleset
+1 -1
View File
@@ -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: >
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -76,7 +76,7 @@ jobs:
- name: Cache Manifests File
if: ${{ steps.event.outputs.ACTION != 'closed' }}
uses: actions/cache@v3.3.3
uses: actions/cache@v4.0.0
with:
path: manifests.rendered.yml
key: ${{ steps.hash.outputs.MANIFESTS_FILE_HASH }}
@@ -140,7 +140,7 @@ jobs:
- name: Fetch cached Manifests File
id: cache
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: manifests.rendered.yml
key: ${{ needs.cache-manifests-file.outputs.manifests-cache-key }}
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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
View File
@@ -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
+9 -3
View File
@@ -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.
+4
View File
@@ -28,6 +28,7 @@ On the **API permissions** tab, click on `Add Permission`, then add the followin
- `openid`
- `profile`
- `User.Read`
- Optional custom scopes of the `Microsoft Graph` API defined in the app-config.yaml file.
Your company may require you to grant [admin consent](https://learn.microsoft.com/en-us/azure/active-directory/manage-apps/user-admin-consent-overview) for these permissions.
Even if your company doesn't require admin consent, you may wish to do so as it means users don't need to individually consent the first time they access backstage.
@@ -54,6 +55,8 @@ auth:
clientSecret: ${AZURE_CLIENT_SECRET}
tenantId: ${AZURE_TENANT_ID}
domainHint: ${AZURE_TENANT_ID}
additionalScopes:
- Mail.Send
```
The Microsoft provider is a structure with three mandatory configuration keys:
@@ -65,6 +68,7 @@ The Microsoft provider is a structure with three mandatory configuration keys:
Leave blank if your app registration is multi tenant.
When specified, this reduces login friction for users with accounts in multiple tenants by automatically filtering away accounts from other tenants.
For more details, see [Home Realm Discovery](https://learn.microsoft.com/en-us/azure/active-directory/manage-apps/home-realm-discovery-policy)
- `additionalScopes` (optional): List of scopes for the App Registration. The default and mandatory value is ['user.read'].
## Adding the provider to the Backstage frontend
+15
View File
@@ -0,0 +1,15 @@
---
id: index
title: Overview
description: FAQ Overview
---
This section contains answers to frequently asked questions about Backstage.
### [Product FAQ](../faq/product.md)
Questions related to product and design.
### [Technical FAQ](../faq/technical.md)
General technical questions about Backstage.
+66
View File
@@ -0,0 +1,66 @@
---
id: product
title: Product FAQ
description: Questions related to product and design.
---
### Can we call Backstage something different? So that it fits our company better?
Yes, Backstage is just a platform for building your own developer portal. We
happen to call our internal version Backstage, as well, as a reference to our
music roots. You can call your version whatever suits your team, company, or
brand.
### Is Backstage a monitoring platform?
No, but it can be! Backstage is designed to be a developer portal for all your
infrastructure tooling, services, and documentation. So, it's not a monitoring
platform — but that doesn't mean you can't integrate a monitoring tool into
Backstage by writing [a plugin](#what-is-a-plugin-in-backstage).
### How is Backstage licensed?
Backstage was released as open source software by Spotify and is licensed under
[Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0).
### Why did we open source Backstage?
We hope to see Backstage become the infrastructure standard everywhere. When we
saw how much Backstage improved developer experience and productivity
internally, we wanted to share those gains. After all, if Backstage can create
order in an engineering environment as open and diverse as ours, then we're
pretty sure it can create order (and boost productivity) anywhere. To learn
more, read our blog post,
"[What the heck is Backstage anyway?](https://backstage.io/blog/2020/03/18/what-is-backstage)"
### Will Spotify's internal plugins be open sourced, too?
Yes, we've already started releasing open source versions of some of the plugins
we use here, and we'll continue to do so.
[Plugins](#what-is-a-plugin-in-backstage) are the building blocks of
functionality in Backstage. We have over 120 plugins inside Spotify — many of
those are specialized for our use, so will remain internal and proprietary to
us. But we estimate that about a third of our existing plugins make good open
source candidates. (And we'll probably end up writing some brand new ones, too.)
### What's the roadmap for Backstage?
We envision three phases, which you can learn about in
[our project roadmap](../overview/roadmap.md). Even though the open source version
of Backstage is relatively new compared to our internal version, we have already
begun work on various aspects of all three phases. Looking at the
[milestones for active issues](https://github.com/backstage/backstage/milestones)
will also give you a sense of our progress.
### My company doesn't have thousands of developers or services. Is using Backstage excessive for our needs?
Not at all! A core reason to adopt Backstage is to standardize how software is
built at your company. It's easier to decide on those standards as a small
company, and grows in importance as the company grows. Backstage sets a
foundation, and an early investment in your infrastructure becomes even more
valuable as you grow.
### Our company has a strong design language system/brand that we want to incorporate. Does Backstage support this?
Yes! The Backstage UI is built using Material UI. With the theming capabilities
of Material UI, you are able to adapt the interface to your brand guidelines.
+5 -70
View File
@@ -1,74 +1,9 @@
---
id: FAQ
title: FAQ
description: All FAQ related to Backstage
id: technical
title: Technical FAQ
description: General technical questions about Backstage.
---
## Product FAQ
### Can we call Backstage something different? So that it fits our company better?
Yes, Backstage is just a platform for building your own developer portal. We
happen to call our internal version Backstage, as well, as a reference to our
music roots. You can call your version whatever suits your team, company, or
brand.
### Is Backstage a monitoring platform?
No, but it can be! Backstage is designed to be a developer portal for all your
infrastructure tooling, services, and documentation. So, it's not a monitoring
platform — but that doesn't mean you can't integrate a monitoring tool into
Backstage by writing [a plugin](#what-is-a-plugin-in-backstage).
### How is Backstage licensed?
Backstage was released as open source software by Spotify and is licensed under
[Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0).
### Why did we open source Backstage?
We hope to see Backstage become the infrastructure standard everywhere. When we
saw how much Backstage improved developer experience and productivity
internally, we wanted to share those gains. After all, if Backstage can create
order in an engineering environment as open and diverse as ours, then we're
pretty sure it can create order (and boost productivity) anywhere. To learn
more, read our blog post,
"[What the heck is Backstage anyway?](https://backstage.io/blog/2020/03/18/what-is-backstage)"
### Will Spotify's internal plugins be open sourced, too?
Yes, we've already started releasing open source versions of some of the plugins
we use here, and we'll continue to do so.
[Plugins](#what-is-a-plugin-in-backstage) are the building blocks of
functionality in Backstage. We have over 120 plugins inside Spotify — many of
those are specialized for our use, so will remain internal and proprietary to
us. But we estimate that about a third of our existing plugins make good open
source candidates. (And we'll probably end up writing some brand new ones, too.)
### What's the roadmap for Backstage?
We envision three phases, which you can learn about in
[our project roadmap](overview/roadmap.md). Even though the open source version
of Backstage is relatively new compared to our internal version, we have already
begun work on various aspects of all three phases. Looking at the
[milestones for active issues](https://github.com/backstage/backstage/milestones)
will also give you a sense of our progress.
### My company doesn't have thousands of developers or services. Is using Backstage excessive for our needs?
Not at all! A core reason to adopt Backstage is to standardize how software is
built at your company. It's easier to decide on those standards as a small
company, and grows in importance as the company grows. Backstage sets a
foundation, and an early investment in your infrastructure becomes even more
valuable as you grow.
### Our company has a strong design language system/brand that we want to incorporate. Does Backstage support this?
Yes! The Backstage UI is built using Material UI. With the theming capabilities
of Material UI, you are able to adapt the interface to your brand guidelines.
## Technical FAQ
### What technology does Backstage use?
Backstage is a large scale [TypeScript](https://www.typescriptlang.org/)
@@ -116,7 +51,7 @@ type of content. Plugins all use a common set of platform APIs and reusable UI
components. Plugins can fetch data either from the backend or an API exposed
through the proxy.
Learn more about [the different components](overview/what-is-backstage.md) that
Learn more about [the different components](../overview/what-is-backstage.md) that
make up Backstage.
### Why can't I dynamically install plugins without modifications to the app?
@@ -253,7 +188,7 @@ data is shared with.
Yes. The core frontend framework could be used for building any large-scale web
application where (1) multiple teams are building separate parts of the app, and
(2) you want the overall experience to be consistent. That being said, in
[Phase 2](overview/roadmap.md) of the project we will add features that are
[Phase 2](../overview/roadmap.md) of the project we will add features that are
needed for developer portals and systems for managing software ecosystems. Our
ambition will be to keep Backstage modular.
+62 -33
View File
@@ -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
+10
View File
@@ -139,3 +139,13 @@ integration:
- `Variables`: `Read & write` (if templates include GitHub Action Repository Variables)
- `Secrets`: `Read & write` (if templates include GitHub Action Repository Secrets)
- `Environments`: `Read & write` (if templates include GitHub Environments)
### Troubleshooting
`HttpError: This endpoint requires you to be authenticated.`
This message tends to wrap a `NotFoundError: No app installation found` under the hood, which
is the result of not installing the app in your organization. Even if created via the `backstage-cli`
as a member and app manager of your organization, the app will not automatically install. You
must possess the `Owner` role in the organization to see the `Install` menu under your
app settings, then manually press `Install` to authorize the application.
+4 -7
View File
@@ -8,15 +8,12 @@ description: Documentation on how to create a client for a given OpenAPI spec
### Prerequisites
1. Add your plugin ID as the last `servers` item, like this,
1. Set your OpenAPI file's `info.title` to your pluginID like so,
```yaml
servers:
# first value, used for OpenAPI router validation.
- url: /
# final value, pluginId.
- url: catalog
info:
# your pluginId
title: catalog
```
2. Find or create a new plugin to house your new generated client. Currently, we do not support generating an entirely new plugin and instead just generate client files.
+1 -1
View File
@@ -11,7 +11,7 @@ description: Support and Community Details and Links
here if you want to contribute.
- [RFCs](https://github.com/backstage/backstage/labels/rfc) - Help shape the
technical direction by reviewing _Request for Comments_ issues.
- [FAQ](../FAQ.md) - Frequently Asked Questions.
- [FAQ](../faq/index.md) - Frequently Asked Questions.
- [Code of Conduct](https://github.com/backstage/backstage/blob/master/CODE_OF_CONDUCT.md) -
This is how we roll.
- [Blog](https://backstage.io/blog/) - Announcements and updates.
+1 -1
View File
@@ -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',
});
```
+1 -1
View File
@@ -478,7 +478,7 @@
"architecture-decisions/adrs-adr012",
"architecture-decisions/adrs-adr013"
],
"FAQ": ["FAQ"],
"FAQ": ["faq/index", "faq/product", "faq/technical"],
"Accessibility": ["accessibility/index"]
}
}
+46 -46
View File
@@ -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
View File
@@ -214,4 +214,7 @@ nav:
- ADR011 - Plugin Package Structure: 'architecture-decisions/adr011-plugin-package-structure.md'
- ADR012 - Plugin Package Structure: 'architecture-decisions/adr012-use-luxon-locale-and-date-presets.md'
- ADR013 - Plugin Package Structure: 'architecture-decisions/adr013-use-node-fetch.md'
- FAQ: FAQ.md
- FAQ:
- Overview: 'faq/index.md'
- Product FAQ: 'faq/product.md'
- Technical FAQ: 'faq/technical.md'
+3 -3
View File
@@ -7,7 +7,7 @@ import type { ContentObject } from 'openapi3-ts';
import type core from 'express-serve-static-core';
import { Express as Express_2 } from 'express';
import { FromSchema } from 'json-schema-to-ts';
import { JSONSchema7 } from 'json-schema-to-ts';
import { JSONSchema } from 'json-schema-to-ts';
import { middleware } from 'express-openapi-validator';
import type { OpenAPIObject } from 'openapi3-ts';
import type { ParameterObject } from 'openapi3-ts';
@@ -62,7 +62,7 @@ type ComponentTypes<Doc extends RequiredDoc> = Extract<
// @public (undocumented)
type ConvertAll<T extends ReadonlyArray<unknown>> = {
[Index in keyof T]: T[Index] extends JSONSchema7
[Index in keyof T]: T[Index] extends JSONSchema
? FromSchema<T[Index]>
: T[Index];
} & {
@@ -463,7 +463,7 @@ type ParameterSchema<
Schema extends ImmutableParameterObject['schema'],
> = SchemaRef<Doc, Schema> extends infer R
? R extends ImmutableSchemaObject
? R extends JSONSchema7
? R extends JSONSchema
? FromSchema<R>
: never
: never
@@ -18,7 +18,7 @@
* Pulled from https://github.com/varanauskas/oatx.
*/
import { FromSchema, JSONSchema7 } from 'json-schema-to-ts';
import { FromSchema, JSONSchema } from 'json-schema-to-ts';
import {
ImmutableContentObject,
ImmutableOpenAPIObject,
@@ -221,7 +221,7 @@ export type TuplifyUnion<
* @public
*/
export type ConvertAll<T extends ReadonlyArray<unknown>> = {
[Index in keyof T]: T[Index] extends JSONSchema7
[Index in keyof T]: T[Index] extends JSONSchema
? FromSchema<T[Index]>
: T[Index];
} & { length: T['length'] };
@@ -36,7 +36,7 @@ import {
SchemaRef,
ValueOf,
} from './common';
import { FromSchema, JSONSchema7 } from 'json-schema-to-ts';
import { FromSchema, JSONSchema } from 'json-schema-to-ts';
/**
* @public
@@ -96,7 +96,7 @@ export type ParameterSchema<
Schema extends ImmutableParameterObject['schema'],
> = SchemaRef<Doc, Schema> extends infer R
? R extends ImmutableSchemaObject
? R extends JSONSchema7
? R extends JSONSchema
? FromSchema<R>
: never
: never
@@ -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}}
@@ -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}}";
+1 -1
View File
@@ -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",
+6 -3
View File
@@ -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 = {
+1
View File
@@ -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.
+9 -3
View File
@@ -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
+38
View File
@@ -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;
}
+1
View File
@@ -29,6 +29,7 @@ export interface Config {
authServerId?: string;
idp?: string;
callbackUrl?: string;
additionalScopes?: string;
};
};
};
+8 -12
View File
@@ -25,6 +25,7 @@ import { LoggerService } from '@backstage/backend-plugin-api';
import { OAuth2ProxyResult as OAuth2ProxyResult_2 } from '@backstage/plugin-auth-backend-module-oauth2-proxy-provider';
import { OAuthEnvironmentHandler as OAuthEnvironmentHandler_2 } from '@backstage/plugin-auth-node';
import { OAuthState as OAuthState_2 } from '@backstage/plugin-auth-node';
import { OidcAuthResult as OidcAuthResult_2 } from '@backstage/plugin-auth-backend-module-oidc-provider';
import { PluginDatabaseManager } from '@backstage/backend-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { prepareBackstageIdentityResponse as prepareBackstageIdentityResponse_2 } from '@backstage/plugin-auth-node';
@@ -34,9 +35,7 @@ import { SignInInfo as SignInInfo_2 } from '@backstage/plugin-auth-node';
import { SignInResolver as SignInResolver_2 } from '@backstage/plugin-auth-node';
import { TokenManager } from '@backstage/backend-common';
import { TokenParams as TokenParams_2 } from '@backstage/plugin-auth-node';
import { TokenSet } from 'openid-client';
import { UserEntity } from '@backstage/catalog-model';
import { UserinfoResponse } from 'openid-client';
import { WebMessageResponse as WebMessageResponse_2 } from '@backstage/plugin-auth-node';
// @public @deprecated
@@ -340,11 +339,8 @@ export type OAuthStartResponse = {
// @public @deprecated (undocumented)
export type OAuthState = OAuthState_2;
// @public
export type OidcAuthResult = {
tokenset: TokenSet;
userinfo: UserinfoResponse;
};
// @public @deprecated (undocumented)
export type OidcAuthResult = OidcAuthResult_2;
// @public @deprecated (undocumented)
export const postMessageResponse: (
@@ -531,9 +527,9 @@ export const providers: Readonly<{
| undefined,
) => AuthProviderFactory_2;
resolvers: Readonly<{
emailLocalPartMatchingUserEntityName: () => SignInResolver<unknown>;
emailMatchingUserEntityProfileEmail: () => SignInResolver<unknown>;
emailMatchingUserEntityAnnotation(): SignInResolver<OAuthResult>;
emailMatchingUserEntityProfileEmail: () => SignInResolver_2<OAuthResult>;
emailLocalPartMatchingUserEntityName: () => SignInResolver_2<OAuthResult>;
emailMatchingUserEntityAnnotation: () => SignInResolver_2<OAuthResult>;
}>;
}>;
oauth2: Readonly<{
@@ -564,10 +560,10 @@ export const providers: Readonly<{
create: (
options?:
| {
authHandler?: AuthHandler<OidcAuthResult> | undefined;
authHandler?: AuthHandler<OidcAuthResult_2> | undefined;
signIn?:
| {
resolver: SignInResolver<OidcAuthResult>;
resolver: SignInResolver<OidcAuthResult_2>;
}
| undefined;
}
-28
View File
@@ -149,22 +149,6 @@ export interface Config {
};
};
/** @visibility frontend */
oidc?: {
[authEnv: string]: {
clientId: string;
/**
* @visibility secret
*/
clientSecret: string;
callbackUrl?: string;
metadataUrl: string;
tokenEndpointAuthMethod?: string;
tokenSignedResponseAlg?: string;
scope?: string;
prompt?: string;
};
};
/** @visibility frontend */
auth0?: {
[authEnv: string]: {
clientId: string;
@@ -180,18 +164,6 @@ export interface Config {
};
};
/** @visibility frontend */
microsoft?: {
[authEnv: string]: {
clientId: string;
/**
* @visibility secret
*/
clientSecret: string;
tenantId: string;
callbackUrl?: string;
};
};
/** @visibility frontend */
onelogin?: {
[authEnv: string]: {
clientId: string;
+2 -1
View File
@@ -43,8 +43,10 @@
"@backstage/plugin-auth-backend-module-github-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-gitlab-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-google-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-microsoft-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-oauth2-proxy-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-oidc-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-okta-provider": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
@@ -74,7 +76,6 @@
"passport-auth0": "^1.4.3",
"passport-bitbucket-oauth2": "^0.1.2",
"passport-github2": "^0.1.12",
"passport-gitlab2": "^5.0.0",
"passport-google-oauth20": "^2.0.0",
"passport-microsoft": "^1.0.0",
"passport-oauth2": "^1.6.1",
@@ -1,90 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FakeMicrosoftAPI } from './fake';
describe('FakeMicrosoftAPI', () => {
const api = new FakeMicrosoftAPI();
describe('#token', () => {
it('exchanges auth codes', () => {
const { access_token } = api.token(
new URLSearchParams({
grant_type: 'authorization_code',
code: api.generateAuthCode('User.Read'),
}),
);
expect(api.tokenHasScope(access_token, 'User.Read')).toBe(true);
});
it('supports scopes for the first requested audience only', () => {
const { access_token } = api.token(
new URLSearchParams({
grant_type: 'authorization_code',
code: api.generateAuthCode('someaudience/somescope User.Read'),
}),
);
expect(api.tokenHasScope(access_token, 'User.Read')).toBe(false);
});
it('special openid scopes do not count towards the 1-audience limit', () => {
const { access_token } = api.token(
new URLSearchParams({
grant_type: 'authorization_code',
code: api.generateAuthCode('openid offline_access User.Read'),
}),
);
expect(api.tokenHasScope(access_token, 'User.Read')).toBe(true);
});
it('refreshes tokens', () => {
const { access_token } = api.token(
new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: api.generateRefreshToken(
'email openid profile User.Read',
),
}),
);
expect(
api.tokenHasScope(access_token, 'email openid profile User.Read'),
).toBe(true);
});
it('requires `openid` scope for ID token', () => {
const { id_token } = api.token(
new URLSearchParams({
grant_type: 'authorization_code',
code: api.generateAuthCode('User.Read'),
}),
);
expect(id_token).toBeUndefined();
});
it('requires `offline_access` scope for refresh token', () => {
const { refresh_token } = api.token(
new URLSearchParams({
grant_type: 'authorization_code',
code: api.generateAuthCode('User.Read'),
}),
);
expect(refresh_token).toBeUndefined();
});
});
});
@@ -1,126 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { decodeJwt } from 'jose';
type Claims = { aud: string; scp: string };
export class FakeMicrosoftAPI {
generateAccessToken(scope: string): string {
return this.tokenWithClaims(this.allClaimsForScope(scope)).access_token;
}
generateAuthCode(scope: string): string {
return this.encodeClaims(this.allClaimsForScope(scope));
}
generateRefreshToken(scope: string): string {
return this.encodeClaims(this.allClaimsForScope(scope));
}
token(formData: URLSearchParams): {
access_token: string;
scope: string;
refresh_token?: string;
id_token?: string;
} {
const scopeParameter = formData.get('scope');
const claims =
(scopeParameter && this.allClaimsForScope(scopeParameter)) ??
formData.get('grant_type') === 'refresh_token'
? this.decodeClaims(formData.get('refresh_token')!)
: this.decodeClaims(formData.get('code')!);
return {
...this.tokenWithClaims(claims),
...(this.hasScope(claims, 'offline_access') && {
refresh_token: this.encodeClaims(claims),
}),
...(this.hasScope(claims, 'openid') && {
id_token: 'header.e30K.microsoft',
}),
};
}
tokenHasScope(token: string, scope: string): boolean {
const { aud, scp } = decodeJwt(token);
return this.hasScope({ aud: aud as string, scp: scp as string }, scope);
}
private tokenWithClaims(claims: Claims): {
access_token: string;
scope: string;
} {
const filteredClaims = {
...claims,
scp: claims.scp
.split(' ')
.filter(s => s !== 'offline_access')
.join(' '),
};
return {
access_token: `header.${Buffer.from(
JSON.stringify(filteredClaims),
).toString('base64')}.signature`,
scope: this.scopeFromClaims(filteredClaims),
};
}
private allClaimsForScope(scope: string): Claims {
const scopes = scope.split(' ').map(this.parseScope);
const firstAudience = scopes
.map(({ aud }) => aud)
.find(aud => aud !== 'openid');
return {
aud: firstAudience ?? '00000003-0000-0000-c000-000000000000',
scp: scopes
.filter(({ aud }) => aud === 'openid' || aud === firstAudience)
.map(({ scp }) => scp)
.join(' '),
};
}
// auth codes and refresh tokens in this fake system are base64-encoded JSON
// strings of claims
private encodeClaims(claims: Claims): string {
return Buffer.from(JSON.stringify(claims)).toString('base64');
}
private decodeClaims(encoded: string): Claims {
return JSON.parse(Buffer.from(encoded, 'base64').toString());
}
private hasScope(claims: Claims, scope: string): boolean {
return this.scopeFromClaims(claims).includes(scope);
}
private parseScope(s: string): Claims {
if (s.includes('/')) {
const [aud, scp] = s.split('/');
return { aud, scp };
}
switch (s) {
case 'email':
case 'openid':
case 'offline_access':
case 'profile': {
return { aud: 'openid', scp: s };
}
default:
return { aud: '00000003-0000-0000-c000-000000000000', scp: s };
}
}
private scopeFromClaims(claims: Claims): string {
return claims.scp
.split(' ')
.map(this.parseScope)
.map(({ aud, scp }) =>
aud === 'openid' ||
claims.aud === '00000003-0000-0000-c000-000000000000'
? scp
: `${claims.aud}/${scp}`,
)
.join(' ');
}
}
@@ -1,450 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { microsoft } from './provider';
import { getVoidLogger } from '@backstage/backend-common';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { ConfigReader } from '@backstage/config';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { AuthProviderRouteHandlers, AuthResolverContext } from '../types';
import express from 'express';
import crypto from 'crypto';
import { FakeMicrosoftAPI } from './__testUtils__/fake';
describe('MicrosoftAuthProvider', () => {
const nonce = 'AAAAAAAAAAAAAAAAAAAAAA=='; // 16 bytes of zeros in base64
const state = Buffer.from(
`nonce=${encodeURIComponent(nonce)}&env=development`,
).toString('hex');
const mockBackstageToken = `header.${Buffer.from(
JSON.stringify({ sub: 'user:default/mock' }),
'utf8',
).toString('base64')}.backstage`;
const server = setupServer();
const microsoftApi = new FakeMicrosoftAPI();
let provider: AuthProviderRouteHandlers;
let response: jest.Mocked<express.Response>;
setupRequestMockHandlers(server);
beforeEach(() => {
provider = microsoft.create({
signIn: {
resolver: microsoft.resolvers.emailMatchingUserEntityAnnotation(),
},
})({
providerId: 'microsoft',
baseUrl: 'http://backstage.test/api/auth',
appUrl: 'http://backstage.test',
isOriginAllowed: _ => true,
globalConfig: {
baseUrl: 'http://backstage.test/api/auth',
appUrl: 'http://backstage.test',
isOriginAllowed: _ => true,
},
config: new ConfigReader({
development: {
tenantId: 'tenantId',
clientId: 'clientId',
clientSecret: 'clientSecret',
},
}),
logger: getVoidLogger(),
resolverContext: {
issueToken: jest.fn(),
findCatalogUser: jest.fn(),
signInWithCatalogUser: async _ => ({
token: mockBackstageToken,
}),
} as AuthResolverContext,
}) as AuthProviderRouteHandlers;
server.use(
rest.post(
'https://login.microsoftonline.com/tenantId/oauth2/v2.0/token',
async (req, res, ctx) => {
return res(
ctx.json({
...microsoftApi.token(new URLSearchParams(await req.text())),
token_type: 'Bearer',
expires_in: 123,
ext_expires_in: 123,
}),
);
},
),
rest.get('https://graph.microsoft.com/v1.0/me/', (req, res, ctx) => {
if (
!microsoftApi.tokenHasScope(
req.headers.get('authorization')!.replace(/^Bearer /, ''),
'User.Read',
)
) {
return res(ctx.status(403));
}
return res(
ctx.json({
id: 'conrad',
displayName: 'Conrad',
surname: 'Ribas',
givenName: 'Francisco',
mail: 'conrad@example.com',
}),
);
}),
rest.get(
'https://graph.microsoft.com/v1.0/me/photos/*',
async (req, res, ctx) => {
if (
!microsoftApi.tokenHasScope(
req.headers.get('authorization')!.replace(/^Bearer /, ''),
'User.Read',
)
) {
return res(ctx.status(403));
}
const imageBuffer = new Uint8Array([104, 111, 119, 100, 121]).buffer;
return res(
ctx.set('Content-Length', imageBuffer.byteLength.toString()),
ctx.set('Content-Type', 'image/jpeg'),
ctx.body(imageBuffer),
);
},
),
);
response = {
cookie: jest.fn(),
end: jest.fn(),
json: jest.fn(),
setHeader: jest.fn(),
status: jest.fn(),
} as unknown as jest.Mocked<express.Response>;
response.status.mockReturnValue(response);
});
describe('#start', () => {
const randomBytes = jest.spyOn(
crypto,
'randomBytes',
) as unknown as jest.MockedFunction<(size: number) => Buffer>;
afterEach(() => {
randomBytes.mockRestore();
});
it('redirects to authorize URL', async () => {
randomBytes.mockReturnValue(Buffer.from(nonce, 'base64'));
await provider.start(
{
query: {
env: 'development',
scope: 'email openid profile User.Read',
},
} as unknown as express.Request,
response,
);
expect(response.setHeader).toHaveBeenCalledWith(
'Location',
'https://login.microsoftonline.com/tenantId/oauth2/v2.0/authorize' +
'?response_type=code' +
`&redirect_uri=${encodeURIComponent(
'http://backstage.test/api/auth/microsoft/handler/frame',
)}` +
`&scope=${encodeURIComponent('email openid profile User.Read')}` +
`&state=${state}` +
'&client_id=clientId',
);
});
});
describe('#handle', () => {
it('returns provider info and profile with photo data', async () => {
await provider.frameHandler(
{
query: {
env: 'development',
code: microsoftApi.generateAuthCode(
'email openid profile User.Read',
),
state,
},
cookies: {
'microsoft-nonce': nonce,
},
} as unknown as express.Request,
response,
);
expect(response.end).toHaveBeenCalledWith(
expect.stringContaining(
encodeURIComponent(
JSON.stringify({
type: 'authorization_response',
response: {
providerInfo: {
accessToken: microsoftApi.generateAccessToken(
'email openid profile User.Read',
),
scope: 'email openid profile User.Read',
expiresInSeconds: 123,
idToken: 'header.e30K.microsoft',
},
profile: {
email: 'conrad@example.com',
picture: 'data:image/jpeg;base64,aG93ZHk=',
displayName: 'Conrad',
},
backstageIdentity: {
token: mockBackstageToken,
identity: {
type: 'user',
userEntityRef: 'user:default/mock',
ownershipEntityRefs: [],
},
},
},
}),
),
),
);
});
it('returns access token for non-microsoft graph scope', async () => {
await provider.frameHandler(
{
query: {
env: 'development',
code: microsoftApi.generateAuthCode('aks-audience/user.read'),
state,
},
cookies: {
'microsoft-nonce': nonce,
},
} as unknown as express.Request,
response,
);
expect(response.end).toHaveBeenCalledWith(
expect.stringContaining(
encodeURIComponent(
JSON.stringify({
type: 'authorization_response',
response: {
providerInfo: {
accessToken: microsoftApi.generateAccessToken(
'aks-audience/user.read',
),
scope: 'aks-audience/user.read',
expiresInSeconds: 123,
},
profile: {},
},
}),
),
),
);
});
it('sets refresh token', async () => {
await provider.frameHandler(
{
query: {
env: 'development',
code: microsoftApi.generateAuthCode(
'email offline_access openid profile User.Read',
),
state,
},
cookies: {
'microsoft-nonce': nonce,
},
} as unknown as express.Request,
response,
);
expect(response.cookie).toHaveBeenCalledWith(
'microsoft-refresh-token',
microsoftApi.generateRefreshToken(
'email offline_access openid profile User.Read',
),
{
domain: 'backstage.test',
httpOnly: true,
maxAge: 86400000000,
path: '/api/auth/microsoft',
sameSite: 'lax',
secure: false,
},
);
});
it('omits photo data when fetching it fails', async () => {
server.use(
rest.get('https://graph.microsoft.com/v1.0/me/photos/*', (_, res) =>
res.networkError('remote hung up'),
),
);
await provider.frameHandler(
{
query: {
env: 'development',
code: microsoftApi.generateAuthCode(
'email openid profile User.Read',
),
state,
},
cookies: {
'microsoft-nonce': nonce,
},
} as unknown as express.Request,
response,
);
expect(response.end).toHaveBeenCalledWith(
expect.stringContaining(
encodeURIComponent(
JSON.stringify({
type: 'authorization_response',
response: {
providerInfo: {
accessToken: microsoftApi.generateAccessToken(
'email openid profile User.Read',
),
scope: 'email openid profile User.Read',
expiresInSeconds: 123,
idToken: 'header.e30K.microsoft',
},
profile: {
email: 'conrad@example.com',
displayName: 'Conrad',
},
backstageIdentity: {
token: mockBackstageToken,
identity: {
type: 'user',
userEntityRef: 'user:default/mock',
ownershipEntityRefs: [],
},
},
},
}),
),
),
);
});
});
describe('#refresh', () => {
it('returns provider info and profile with photo data', async () => {
await provider.refresh!(
{
query: {
env: 'development',
scope: 'email openid profile User.Read',
},
header: jest.fn(_ => 'XMLHttpRequest'),
cookies: {
'microsoft-refresh-token': microsoftApi.generateRefreshToken(
'email openid profile User.Read',
),
},
get: jest.fn(),
} as unknown as express.Request,
response,
);
expect(response.json).toHaveBeenCalledWith(
expect.objectContaining({
providerInfo: {
accessToken: microsoftApi.generateAccessToken(
'email openid profile User.Read',
),
scope: 'email openid profile User.Read',
expiresInSeconds: 123,
idToken: 'header.e30K.microsoft',
},
profile: {
email: 'conrad@example.com',
picture: 'data:image/jpeg;base64,aG93ZHk=',
displayName: 'Conrad',
},
}),
);
});
it('returns access token for non-microsoft graph scope', async () => {
await provider.refresh!(
{
query: {
env: 'development',
scope: 'aks-audience/user.read',
},
header: jest.fn(_ => 'XMLHttpRequest'),
cookies: {
'microsoft-refresh-token': microsoftApi.generateRefreshToken(
'aks-audience/user.read',
),
},
get: jest.fn(),
} as unknown as express.Request,
response,
);
expect(response.json).toHaveBeenCalledWith({
providerInfo: {
accessToken: microsoftApi.generateAccessToken(
'aks-audience/user.read',
),
expiresInSeconds: 123,
scope: 'aks-audience/user.read',
},
profile: {},
});
});
it('returns backstage identity', async () => {
await provider.refresh!(
{
query: {
env: 'development',
scope: 'email openid profile User.Read',
},
header: jest.fn(_ => 'XMLHttpRequest'),
cookies: {
'microsoft-refresh-token': microsoftApi.generateRefreshToken(
'email openid profile User.Read',
),
},
get: jest.fn(),
} as unknown as express.Request,
response,
);
expect(response.json).toHaveBeenCalledWith(
expect.objectContaining({
backstageIdentity: expect.objectContaining({
token: mockBackstageToken,
}),
}),
);
});
});
});
@@ -14,215 +14,22 @@
* limitations under the License.
*/
import express from 'express';
import passport from 'passport';
import { Strategy as MicrosoftStrategy } from 'passport-microsoft';
import {
encodeState,
OAuthAdapter,
OAuthEnvironmentHandler,
OAuthHandlers,
OAuthProviderOptions,
OAuthRefreshRequest,
OAuthResponse,
OAuthResult,
OAuthStartRequest,
} from '../../lib/oauth';
import {
executeFetchUserProfileStrategy,
executeFrameHandlerStrategy,
executeRedirectStrategy,
executeRefreshTokenStrategy,
makeProfileInfo,
PassportDoneCallback,
} from '../../lib/passport';
import {
AuthHandler,
OAuthStartResponse,
SignInResolver,
AuthResolverContext,
} from '../types';
import { SignInResolver, AuthHandler } from '../types';
import { OAuthResult } from '../../lib/oauth';
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
import {
commonByEmailLocalPartResolver,
commonByEmailResolver,
} from '../resolvers';
import { LoggerService } from '@backstage/backend-plugin-api';
import fetch from 'node-fetch';
import { decodeJwt } from 'jose';
import { Profile as PassportProfile } from 'passport';
import { BACKSTAGE_SESSION_EXPIRATION } from '../../lib/session';
type PrivateInfo = {
refreshToken: string;
};
type Options = OAuthProviderOptions & {
signInResolver?: SignInResolver<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
logger: LoggerService;
resolverContext: AuthResolverContext;
authorizationUrl?: string;
tokenUrl?: string;
};
export class MicrosoftAuthProvider implements OAuthHandlers {
private readonly _strategy: MicrosoftStrategy;
private readonly signInResolver?: SignInResolver<OAuthResult>;
private readonly authHandler: AuthHandler<OAuthResult>;
private readonly logger: LoggerService;
private readonly resolverContext: AuthResolverContext;
constructor(options: Options) {
this.signInResolver = options.signInResolver;
this.authHandler = options.authHandler;
this.logger = options.logger;
this.resolverContext = options.resolverContext;
this._strategy = new MicrosoftStrategy(
{
clientID: options.clientId,
clientSecret: options.clientSecret,
callbackURL: options.callbackUrl,
authorizationURL: options.authorizationUrl,
tokenURL: options.tokenUrl,
passReqToCallback: false,
skipUserProfile: (
accessToken: string,
done: (err: unknown, skip: boolean) => void,
) => {
done(null, this.skipUserProfile(accessToken));
},
},
(
accessToken: any,
refreshToken: any,
params: any,
fullProfile: passport.Profile,
done: PassportDoneCallback<OAuthResult, PrivateInfo>,
) => {
done(undefined, { fullProfile, accessToken, params }, { refreshToken });
},
);
}
private skipUserProfile = (accessToken: string): boolean => {
const { aud, scp } = decodeJwt(accessToken);
const hasGraphReadScope =
aud === '00000003-0000-0000-c000-000000000000' &&
(scp as string)
.split(' ')
.map(s => s.toLowerCase())
.includes('user.read');
return !hasGraphReadScope;
};
async start(req: OAuthStartRequest): Promise<OAuthStartResponse> {
return await executeRedirectStrategy(req, this._strategy, {
scope: req.scope,
state: encodeState(req.state),
});
}
async handler(req: express.Request) {
const { result, privateInfo } = await executeFrameHandlerStrategy<
OAuthResult,
PrivateInfo
>(req, this._strategy);
return {
response: await this.handleResult(result),
refreshToken: privateInfo.refreshToken,
};
}
async refresh(req: OAuthRefreshRequest) {
const { accessToken, refreshToken, params } =
await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
return {
response: await this.handleResult({
params,
accessToken,
...(!this.skipUserProfile(accessToken) && {
fullProfile: await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
),
}),
}),
refreshToken,
};
}
private async handleResult(result: {
fullProfile?: PassportProfile;
params: {
id_token?: string;
scope: string;
expires_in: number;
};
accessToken: string;
refreshToken?: string;
}): Promise<OAuthResponse> {
let profile = {};
if (result.fullProfile) {
const photo = await this.getUserPhoto(result.accessToken);
result.fullProfile.photos = photo ? [{ value: photo }] : undefined;
({ profile } = await this.authHandler(
result as OAuthResult,
this.resolverContext,
));
}
const expiresInSeconds =
result.params.expires_in === undefined
? BACKSTAGE_SESSION_EXPIRATION
: Math.min(result.params.expires_in, BACKSTAGE_SESSION_EXPIRATION);
return {
providerInfo: {
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds,
...{ idToken: result.params.id_token },
},
profile,
...(result.fullProfile &&
this.signInResolver && {
backstageIdentity: await this.signInResolver(
{ result: result as OAuthResult, profile },
this.resolverContext,
),
}),
};
}
private async getUserPhoto(accessToken: string): Promise<string | undefined> {
try {
const res = await fetch(
'https://graph.microsoft.com/v1.0/me/photos/48x48/$value',
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
const data = await res.buffer();
return `data:image/jpeg;base64,${data.toString('base64')}`;
} catch (error) {
this.logger.warn(
`Could not retrieve user profile photo from Microsoft Graph API: ${error}`,
);
return undefined;
}
}
}
commonSignInResolvers,
createOAuthProviderFactory,
} from '@backstage/plugin-auth-node';
import {
adaptLegacyOAuthHandler,
adaptLegacyOAuthSignInResolver,
adaptOAuthSignInResolverToLegacy,
} from '../../lib/legacy';
import {
microsoftAuthenticator,
microsoftSignInResolvers,
} from '@backstage/plugin-auth-backend-module-microsoft-provider';
/**
* Auth provider integration for Microsoft auth
@@ -241,75 +48,21 @@ export const microsoft = createAuthProviderIntegration({
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
signIn?: {
/**
* Maps an auth result to a Backstage identity for the user.
*/
resolver: SignInResolver<OAuthResult>;
};
}) {
return ({ providerId, globalConfig, config, logger, resolverContext }) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const tenantId = envConfig.getString('tenantId');
const customCallbackUrl = envConfig.getOptionalString('callbackUrl');
const callbackUrl =
customCallbackUrl ||
`${globalConfig.baseUrl}/${providerId}/handler/frame`;
const authorizationUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`;
const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
const authHandler: AuthHandler<OAuthResult> = options?.authHandler
? options.authHandler
: async ({ fullProfile, params }) => ({
profile: makeProfileInfo(fullProfile ?? {}, params.id_token),
});
const provider = new MicrosoftAuthProvider({
clientId,
clientSecret,
callbackUrl,
authorizationUrl,
tokenUrl,
authHandler,
signInResolver: options?.signIn?.resolver,
logger,
resolverContext,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
providerId,
callbackUrl,
});
});
},
resolvers: {
/**
* Looks up the user by matching their email local part to the entity name.
*/
emailLocalPartMatchingUserEntityName: () => commonByEmailLocalPartResolver,
/**
* Looks up the user by matching their email to the entity email.
*/
emailMatchingUserEntityProfileEmail: () => commonByEmailResolver,
/**
* Looks up the user by matching their email to the `microsoft.com/email` annotation.
*/
emailMatchingUserEntityAnnotation(): SignInResolver<OAuthResult> {
return async (info, ctx) => {
const { profile } = info;
if (!profile.email) {
throw new Error('Microsoft profile contained no email');
}
return ctx.signInWithCatalogUser({
annotations: {
'microsoft.com/email': profile.email,
},
});
};
},
return createOAuthProviderFactory({
authenticator: microsoftAuthenticator,
profileTransform: adaptLegacyOAuthHandler(options?.authHandler),
signInResolver: adaptLegacyOAuthSignInResolver(options?.signIn?.resolver),
});
},
resolvers: adaptOAuthSignInResolverToLegacy({
emailLocalPartMatchingUserEntityName:
commonSignInResolvers.emailLocalPartMatchingUserEntityName(),
emailMatchingUserEntityProfileEmail:
commonSignInResolvers.emailMatchingUserEntityProfileEmail(),
emailMatchingUserEntityAnnotation:
microsoftSignInResolvers.emailMatchingUserEntityAnnotation(),
}),
});
@@ -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