Merge pull request #6167 from backstage/rugvip/remove

packages: remove core and core-api
This commit is contained in:
Patrik Oldsberg
2021-06-24 13:16:30 +02:00
committed by GitHub
438 changed files with 104 additions and 32939 deletions
+8 -4
View File
@@ -14,11 +14,15 @@ coverage:
# Since Backstage is a mono repo, flags here help in getting the code coverage of individual packages.
# Documentation: https://docs.codecov.io/docs/flags
flags:
core:
core-app-api:
paths:
- packages/core/
- packages/core-app-api/
carryforward: true
core-api:
core-components:
paths:
- packages/core-api/
- packages/core-components/
carryforward: true
core-plugin-api:
paths:
- packages/core-plugin-api/
carryforward: true
@@ -4,8 +4,7 @@ on:
paths:
- '.github/workflows/chromatic-storybook-test.yml'
- 'packages/storybook/**'
- 'packages/core/src/components/**'
- 'packages/core/src/layout/**'
- 'packages/core-components/src/**'
jobs:
chromatic:
+3 -2
View File
@@ -119,8 +119,9 @@ jobs:
yarn lerna -- run test -- --coverage
bash <(curl -s https://codecov.io/bash)
# Upload code coverage for some specific flags. Also see .codecov.yml
bash <(curl -s https://codecov.io/bash) -f packages/core/coverage/* -F core
bash <(curl -s https://codecov.io/bash) -f packages/core-api/coverage/* -F core-api
bash <(curl -s https://codecov.io/bash) -f packages/core-app-api/coverage/* -F core-app-api
bash <(curl -s https://codecov.io/bash) -f packages/core-components/coverage/* -F core-components
bash <(curl -s https://codecov.io/bash) -f packages/core-plugin-api/coverage/* -F core-plugin-api
env:
BACKSTAGE_TEST_DATABASE_POSTGRES13_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres13.ports[5432] }}
BACKSTAGE_TEST_DATABASE_POSTGRES9_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres9.ports[5432] }}
@@ -7,7 +7,7 @@ on:
paths:
- '.github/workflows/microsite-with-storybook-deploy.yml'
- 'packages/storybook/**'
- 'packages/core/src/**'
- 'packages/core-components/src/**'
- 'microsite/**'
- 'docs/**'
@@ -99,7 +99,7 @@ async function main() {
```typescript
// packages/app/src/App.tsx from a create-app deployment
import { discoveryApiRef, useApi } from '@backstage/core';
import { discoveryApiRef, useApi } from '@backstage/core-plugin-api';
// ...
@@ -51,7 +51,7 @@ The Backstage App needs a SignInPage when authentication is required.
When using ALB authentication Backstage will only be loaded once the user has successfully authenticated; we won't need to display a SignIn page, however we will need to create a dummy SignIn component that can refresh the token.
- edit `packages/app/src/App.tsx`
- import the following two additional definitions from `@backstage/core`: `useApi`, `configApiRef`; these will be used to check whether Backstage is running locally or behind an ALB
- import the following two additional definitions from `@backstage/core-plugin-api`: `useApi`, `configApiRef`; these will be used to check whether Backstage is running locally or behind an ALB
- add the following definition just before the app is created (`const app = createApp`):
```ts
@@ -5,6 +5,7 @@ ExampleComponent.tsx reference
```tsx
import React from 'react';
import { Typography, Grid } from '@material-ui/core';
import { identityApiRef, useApi } from '@backstage/core-plugin-api';
import {
InfoCard,
Header,
@@ -13,9 +14,7 @@ import {
ContentHeader,
HeaderLabel,
SupportButton,
identityApiRef,
useApi,
} from '@backstage/core';
} from '@backstage/core-components';
import { ExampleFetchComponent } from '../ExampleFetchComponent';
export const ExampleComponent = () => {
@@ -6,13 +6,8 @@ ExampleFetchComponent.tsx reference
import React from 'react';
import { useAsync } from 'react-use';
import Alert from '@material-ui/lab/Alert';
import {
Table,
TableColumn,
Progress,
githubAuthApiRef,
useApi,
} from '@backstage/core';
import { githubAuthApiRef, useApi } from '@backstage/core-plugin-api';
import { Table, TableColumn, Progress } from '@backstage/core-components';
import { graphql } from '@octokit/graphql';
const query = `{
+17 -16
View File
@@ -23,18 +23,18 @@ during their entire life cycle.
Each Utility API is tied to an `ApiRef` instance, which is a global singleton
object without any additional state or functionality, its only purpose is to
reference Utility APIs. `ApiRef`s are created using `createApiRef`, which is
exported by `@backstage/core`. There are many
exported by `@backstage/core-plugin-api`. There are many
[predefined Utility APIs](../reference/utility-apis/README.md) defined in
`@backstage/core`, and they're all exported with a name of the pattern
`*ApiRef`, for example `errorApiRef`.
`@backstage/core-plugin-api`, and they're all exported with a name of the
pattern `*ApiRef`, for example `errorApiRef`.
To access one of the Utility APIs inside a React component, use the `useApi`
hook exported by `@backstage/core`, or the `withApis` HOC if you prefer class
components. For example, the `ErrorApi` can be accessed like this:
hook exported by `@backstage/core-plugin-api`, or the `withApis` HOC if you
prefer class components. For example, the `ErrorApi` can be accessed like this:
```tsx
import React from 'react';
import { useApi, errorApiRef } from '@backstage/core';
import { useApi, errorApiRef } from '@backstage/core-plugin-api';
export const MyComponent = () => {
const errorApi = useApi(errorApiRef);
@@ -52,9 +52,9 @@ Note that there is no explicit type given for `ErrorApi`. This is because the
`errorApiRef` has the type embedded, and `useApi` is able to infer the type.
Also note that consuming Utility APIs is not limited to plugins, it can be done
from any component inside Backstage, including the ones in `@backstage/core`.
The only requirement is that they are beneath the `AppProvider` in the react
tree.
from any component inside Backstage, including the ones in
`@backstage/core-plugin-api`. The only requirement is that they are beneath the
`AppProvider` in the react tree.
## Supplying APIs
@@ -101,13 +101,13 @@ app, and the app itself.
### Core APIs
Starting with the Backstage core library, it provides implementations for all of
the core APIs. The core APIs are the ones exported by `@backstage/core`, such as
the `errorApiRef` and `configApiRef`. You can find a full list of them
[here](../reference/utility-apis/README.md).
the core APIs. The core APIs are the ones exported by
`@backstage/core-plugin-api`, such as the `errorApiRef` and `configApiRef`. You
can find a full list of them [here](../reference/utility-apis/README.md).
The core APIs are loaded for any app created with `createApp` from
`@backstage/core`, which means that there is no step that needs to be taken to
include these APIs in an app.
`@backstage/core-plugin-api`, which means that there is no step that needs to be
taken to include these APIs in an app.
### Plugin APIs
@@ -213,8 +213,9 @@ implement the `ErrorApi`, as it is checked by the type embedded in the
Plugins are free to define their own Utility APIs. Simply define the TypeScript
interface for the API, and create an `ApiRef` using `createApiRef` exported from
`@backstage/core`. Also be sure to provide at least one implementation of the
API, and to declare a default factory for the API in `createPlugin`.
`@backstage/core-plugin-api`. Also be sure to provide at least one
implementation of the API, and to declare a default factory for the API in
`createPlugin`.
Custom Utility APIs can be either public or private, which is up to the plugin
to choose. Private APIs do not expose an external API surface, and it's
@@ -6,8 +6,8 @@ description: Architecture Decision Record (ADR) log on Module Export Structure
## Context
With a growing number of exports of packages like `@backstage/core`, it is
becoming more and more difficult to answer questions such as
With a growing number of exports of packages like `@backstage/core-components`,
it is becoming more and more difficult to answer questions such as
> Is the export in this module also exported by the package?
@@ -86,7 +86,7 @@ import { helperFunc } from '../../lib/UtilityX/helper';
## Consequences
We will actively work to rework the export structure in our codebase,
prioritizing the library packages such as `@backstage/core` and
prioritizing the library packages such as `@backstage/core-components` and
`@backstage/backend-common`.
If possible, we will add tools, such as lint rules, to help enforce the export
+2 -1
View File
@@ -66,7 +66,8 @@ built-in providers:
```diff
# packages/app/src/App.tsx
+ import { githubAuthApiRef, SignInProviderConfig, SignInPage } from '@backstage/core';
+ import { githubAuthApiRef } from '@backstage/core-plugin-api';
+ import { SignInProviderConfig, SignInPage } from '@backstage/core-components';
+ const githubProvider: SignInProviderConfig = {
+ id: 'github-auth-provider',
+4 -4
View File
@@ -14,7 +14,7 @@ to various third party APIs.
There are occasions when the user wants to perform actions towards third party
services that require authorization via OAuth. Backstage provides standardized
[Utility APIs](../api/utility-apis.md) such as the
[GoogleAuthApi](https://github.com/backstage/backstage/blob/master/packages/core-api/src/apis/definitions/auth.ts)
[GoogleAuthApi](https://github.com/backstage/backstage/blob/master/packages/core-plugin-api/src/apis/definitions/auth.ts)
for that use-case. Backstage also includes a set of implementations of these
APIs that integrate with the
[auth-backend](https://github.com/backstage/backstage/tree/master/plugins/auth-backend)
@@ -38,7 +38,7 @@ choose an account to log in with, and accept or reject the request. If the user
accepts the login request, a token is issued, and any holder of the token can
use it to make authenticated requests towards the third party service.
## OAuth in @backstage/core-api and auth-backend
## OAuth in @backstage/core-app-api and auth-backend
The default OAuth implementation in Backstage is based on an OAuth server-side
offline access flow, which means that it uses the backend as a helper in order
@@ -59,8 +59,8 @@ easier to make authenticated requests inside a plugin.
The following describes the OAuth flow implemented by the
[auth-backend](https://github.com/backstage/backstage/tree/master/plugins/auth-backend)
and
[DefaultAuthConnector](https://github.com/backstage/backstage/blob/master/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts)
in `@backstage/core-api`.
[DefaultAuthConnector](https://github.com/backstage/backstage/blob/master/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts)
in `@backstage/core-app-api`.
Component and APIs can request Access or ID Tokens from any available Auth
provider. If there already exists a cached fresh token that covers (at least)
+9 -8
View File
@@ -45,8 +45,8 @@ pieces in place that can be used.
#### Identity for Plugin Developers
As a plugin developer, there are two main touchpoints for identities: the
`IdentityApi` exported by `@backstage/core` via the `identityApiRef`, and a not
yet existing middleware exported by `@backstage/backend-common`.
`IdentityApi` exported by `@backstage/core-plugin-api` via the `identityApiRef`,
and a not yet existing middleware exported by `@backstage/backend-common`.
The `IdentityApi` gives access to the signed-in user's identity in the frontend.
It provides access to the user's ID, lightweight profile information, and an ID
@@ -61,8 +61,9 @@ https://github.com/backstage/backstage/issues/1435.
If you're setting up your own Backstage app, or want to add a new identity
provider, there are three touchpoints: the frontend auth APIs in
`@backstage/core-api`, the backend auth providers in `auth-backend`, and the
`SignInPage` component configured in the Backstage app via `createApp`.
`@backstage/core-app-api` and `@backstage/core-plugin-api`, the backend auth
providers in `auth-backend`, and the `SignInPage` component configured in the
Backstage app via `createApp`.
The frontend APIs and backend providers are tightly coupled together for each
auth provider, and together they implement an e2e auth flow. Only some auth
@@ -81,10 +82,10 @@ The final piece of the puzzle is the `SignInPage` component that can be
configured as part of the app. Without a sign-in page, Backstage will fall back
to a `guest` identity for all users, without any ID token. To enable sign-in, a
`SignInPage` needs to be configured, which in turn has to supply a user to the
app. The `@backstage/core` package provides a basic sign-in page that allows
both the user and the app developer to choose between a couple of different
sign-in methods, or to designate a single provider that may also be logged in to
automatically.
app. The `@backstage/core-components` package provides a basic sign-in page that
allows both the user and the app developer to choose between a couple of
different sign-in methods, or to designate a single provider that may also be
logged in to automatically.
## Further Reading
+1 -3
View File
@@ -554,9 +554,7 @@ Options:
Scope: `root`
Validate `@backstage` dependencies within the repo, making sure that there are
no duplicates of packages that might lead to breakages. For example,
`@backstage/core` must not be loaded in twice, so having two different versions
of it installed will cause this command to exit with an error.
no duplicates of packages that might lead to breakages.
By supplying the `--fix` flag the command will attempt to fix any conflict that
can be resolved by editing `yarn.lock`, but will not attempt to search for
+2 -2
View File
@@ -112,7 +112,7 @@ example `getString`. These will throw an error if there is no value available.
The [ConfigApi](../reference/utility-apis/Config.md) in the frontend is a
[UtilityApi](../api/utility-apis.md). It's accessible as usual via the
`configApiRef` exported from `@backstage/core`.
`configApiRef` exported from `@backstage/core-plugin-api`.
Depending on the config api in another API is slightly different though, as the
`ConfigApi` implementation is supplied via the App itself and not instantiated
@@ -123,7 +123,7 @@ for an example of how this wiring is done.
For standalone plugin setups in `dev/index.ts`, register a factory with a
statically mocked implementation of the config API. Use the `ConfigReader` from
`@backstage/config` to create an instance and register it for the `configApiRef`
from `@backstage/core`.
from `@backstage/core-plugin-api`.
## Accessing ConfigApi in Backend Plugins
+1 -1
View File
@@ -25,7 +25,7 @@ component, which are then displayed both visually and with sample code to be
copied.
When custom Backstage components are created, they are placed in the
`@backstage/core` package and added to the Storybook.
`@backstage/core-components` package and added to the Storybook.
There may be times where an existing Material-UI component (in
`@material-ui/core`) is sufficient and doesn't need to be wrapped or duplicated.
+1 -1
View File
@@ -29,7 +29,7 @@ Backstage app with the following contents:
```tsx
import React from 'react';
import { Content, Header, Page } from '@backstage/core';
import { Content, Header, Page } from '@backstage/core-components';
import { Grid, List, Card, CardContent } from '@material-ui/core';
import {
SearchBar,
+1 -1
View File
@@ -56,7 +56,7 @@ For example, adding the theme that we created in the previous section can be
done like this:
```ts
import { createApp } from '@backstage/core';
import { createApp } from '@backstage/core-app-api';
const app = createApp({
apis: ...,
+1 -1
View File
@@ -94,7 +94,7 @@ here are some useful ones:
```python
yarn start # Start serving the example app, use --check to include type checks and linting
yarn storybook # Start local storybook, useful for working on components in @backstage/core
yarn storybook # Start local storybook, useful for working on components in @backstage/core-components
yarn workspace @backstage/plugin-welcome start # Serve welcome plugin only, also supports --check
+6 -5
View File
@@ -137,7 +137,7 @@ frontend with `yarn start` in one window, and the backend with
It can often be useful to try out changes to the packages in the main Backstage
repo within your own app. For example if you want to make modifications to
`@backstage/core` and try them out in your app.
`@backstage/core-plugin-api` and try them out in your app.
To link in external packages, add them to your `package.json` and `lerna.json`
workspace paths. These can be either relative or absolute paths with or without
@@ -147,7 +147,7 @@ globs. For example:
"packages": [
"packages/*",
"plugins/*",
"../backstage/packages/core", // New path added to work on @backstage/core
"../backstage/packages/core-plugin-api", // New path added to work on @backstage/core-plugin-api
],
```
@@ -157,9 +157,10 @@ Then reinstall packages to make yarn set up symlinks:
yarn install
```
With this in place you can now modify the `@backstage/core` package within the
main repo, and have those changes be reflected and tested in your app. Simply
run your app using `yarn dev` (or `yarn start` for just frontend) as normal.
With this in place you can now modify the `@backstage/core-plugin-api` package
within the main repo, and have those changes be reflected and tested in your
app. Simply run your app using `yarn dev` (or `yarn start` for just frontend) as
normal.
Note that for backend packages you need to make sure that linked packages are
not dependencies of any non-linked package. If you for example want to work on
+7 -8
View File
@@ -127,18 +127,17 @@ are separated out into their own folder, see further down.
used by the backend, we chose to separate `config` and `config-loader` into
two different packages.
- [`core/`](https://github.com/backstage/backstage/tree/master/packages/core) -
- [`core-app-api/`](https://github.com/backstage/backstage/tree/master/packages/core-app-api) -
This package contains the core APIs that are used to wire together Backstage
apps.
- [`core-components/`](https://github.com/backstage/backstage/tree/master/packages/core-components) -
This package contains our visual React components, some of which you can find
in
[plugin examples](https://backstage.io/storybook/?path=/story/plugins-examples--plugin-with-data).
Apart from that it re-exports everything from [`core-api`] so that users only
need to rely on one package.
- [`core-api/`](https://github.com/backstage/backstage/tree/master/packages/core-api) -
This package contains APIs and definitions of such. It is it's own package
because we needed to split our `test-utils` package. It's an implementation
detail that we try to hide from our users, and no one should have to depend on
it directly.
- [`core-plugin-api/`](https://github.com/backstage/backstage/tree/master/packages/core-plugin-api) -
This package contains the core APIs that are used to build Backstage plugins.
- [`create-app/`](https://github.com/backstage/backstage/tree/master/packages/create-app) -
An CLI to specifically scaffold a new Backstage App. It does so by using a
+3 -15
View File
@@ -106,16 +106,6 @@ Used to load in static configuration, mainly for use by the CLI and
Stability: `1`. Mainly intended for internal use.
### `core` [GitHub](https://github.com/backstage/backstage/tree/master/packages/core/)
The `@backstage/core` and `@backstage/core-api` packages are being phased out
and replaced by other `@backstage/core-*` packages. They are still in use but
will not receive any breaking changes.
### `core-api` [GitHub](https://github.com/backstage/backstage/tree/master/packages/core-api/)
Stability: See `@backstage/core` above
### `core-app-api` [GitHub](https://github.com/backstage/backstage/tree/master/packages/core-app-api/)
The APIs used exclusively in the app, such as `createApp` and the system icons.
@@ -194,8 +184,9 @@ Stability: `2`
### `test-utils-core` [GitHub](https://github.com/backstage/backstage/tree/master/packages/test-utils-core/)
Internal testing utilities that are separated out for usage in
@backstage/core-api. All exports are re-exported by @backstage/test-utils. This
package should not be depended on directly.
@backstage/core-app-api and @backstage/core-plugin-api. All exports are
re-exported by @backstage/test-utils. This package should not be depended on
directly.
Stability: See @backstage/test-utils
@@ -218,9 +209,6 @@ Stability: `1`
## Plugins
Plugins are rarely marked as stable as the `@backstage/core` plugin API is under
heavy development.
Many backend plugins are split into "REST API" and "TypeScript Interface"
sections. The "TypeScript Interface" refers to the API used to integrate the
plugin into the backend.
+5 -4
View File
@@ -511,10 +511,11 @@ clarify intent. Refer to the following table to formulate the new name:
## Porting Existing Apps
The first step of porting any app is to replace the root `Routes` component with
`FlatRoutes` from `@backstage/core`. As opposed to the `Routes` component,
`FlatRoutes` only considers the first level of `Route` components in its
children, and provides any additional children to the outlet of the route. It
also removes the need to append `"/*"` to paths, as it is added automatically.
`FlatRoutes` from `@backstage/core-app-api`. As opposed to the `Routes`
component, `FlatRoutes` only considers the first level of `Route` components in
its children, and provides any additional children to the outlet of the route.
It also removes the need to append `"/*"` to paths, as it is added
automatically.
```diff
const AppRoutes = () => (
+3 -3
View File
@@ -36,7 +36,7 @@ to avoid import cycles, for example like this:
```tsx
/* src/routes.ts */
import { createRouteRef } from '@backstage/core';
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({
@@ -46,11 +46,11 @@ export const rootRouteRef = createRouteRef({
Now that we have a `RouteRef`, we import it into `src/plugin.ts`, create our
plugin instance with `createPlugin`, as well as create and wrap our routable
extension using `createRoutableExtension` from `@backstage/core`:
extension using `createRoutableExtension` from `@backstage/core-plugin-api`:
```tsx
/* src/plugin.ts */
import { createPlugin, createRouteRef } from '@backstage/core';
import { createPlugin, createRouteRef } from '@backstage/core-plugin-api';
import ExampleComponent from './components/ExampleComponent';
// Create a plugin instance and export this from your plugin package
+4 -1
View File
@@ -57,7 +57,10 @@ package.json to declare the plugin dependencies, metadata and scripts.
In the `src` folder we get to the interesting bits. Check out the `plugin.ts`:
```jsx
import { createPlugin, createRoutableExtension } from '@backstage/core';
import {
createPlugin,
createRoutableExtension,
} from '@backstage/core-plugin-api';
import { rootRouteRef } from './routes';
+2 -2
View File
@@ -11,7 +11,7 @@ can use this to split out logic in your code for manual A/B testing, etc.
Here's a code sample:
```typescript
import { createPlugin } from '@backstage/core';
import { createPlugin } from '@backstage/core-plugin-api';
export default createPlugin({
id: 'plugin-name',
@@ -29,7 +29,7 @@ To inspect the state of a feature flag inside your plugin, you can use the
```tsx
import React from 'react';
import { Button } from '@material-ui/core';
import { featureFlagsApiRef, useApi } from '@backstage/core';
import { featureFlagsApiRef, useApi } from '@backstage/core-plugin-api';
const ExamplePage = () => {
const featureFlags = useApi(featureFlagsApiRef);
+1 -1
View File
@@ -30,7 +30,7 @@ type PluginHooks = {
Showcasing adding a feature flag.
```jsx
import { createPlugin } from '@backstage/core';
import { createPlugin } from '@backstage/core-plugin-api';
export default createPlugin({
id: 'new-plugin',
+5 -5
View File
@@ -55,10 +55,10 @@ const spotifyAuthApiRef = createApiRef<OAuthApi>({
Sam realizes that Spotify auth might be useful to others, and that it would be
more convenient if it was a part of the Backstage Core. After submitting and
merging a Pull Request with the additions to the
`@backstage/plugin-auth-backend` and `@backstage/core` packages, Spotify auth is
now available for everyone to use. Since the Backstage Core team also adds it to
the public demo server, Sam can now get rid of it in the local setup and rely on
the shared development auth providers instead.
`@backstage/plugin-auth-backend` and `@backstage/core-plugin-api` packages,
Spotify auth is now available for everyone to use. Since the Backstage Core team
also adds it to the public demo server, Sam can now get rid of it in the local
setup and rely on the shared development auth providers instead.
The only thing left now is making sure that users of the plugin provide Spotify
auth in the app. Sam ensures this by adding `spotifyAuthApiRef` to the plugin's
@@ -70,7 +70,7 @@ README.
This plugin requires the following APIs to function:
- `spotifyAuthApiRef` from `@backstage/core@^1.1.0`
- `spotifyAuthApiRef` from `@@backstage/core-plugin-api@^1.1.0`
```
# 3. The Catalog Awakens
+3 -8
View File
@@ -72,7 +72,7 @@ Our first modification will be to extract information from the Identity API.
```tsx
// Add identityApiRef to the list of imported from core
import { identityApiRef, useApi } from '@backstage/core';
import { identityApiRef, useApi } from '@backstage/core-plugin-api';
```
3. Adjust the ExampleComponent from inline to block
@@ -137,13 +137,8 @@ changes, let's start by wiping this component clean.
import React from 'react';
import { useAsync } from 'react-use';
import Alert from '@material-ui/lab/Alert';
import {
Table,
TableColumn,
Progress,
githubAuthApiRef,
useApi,
} from '@backstage/core';
import { Table, TableColumn, Progress } from '@backstage/core-components';
import { githubAuthApiRef, useApi } from '@backstage/core-plugin-api';
import { graphql } from '@octokit/graphql';
export const ExampleFetchComponent = () => {
-8
View File
@@ -1,8 +0,0 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
rules: {
// TODO: add prop types to JS and remove
'react/prop-types': 0,
'jest/expect-expect': 0,
},
};
-390
View File
@@ -1,390 +0,0 @@
# @backstage/core-api
## 0.2.23
### Patch Changes
- a1c30d7ea: Add deprecation warning to package README.
- Updated dependencies
- @backstage/core-plugin-api@0.1.3
## 0.2.22
### Patch Changes
- 9bca2a252: Improve forwards compatibility with `@backstage/core-app-api` and `@backstage/core-plugin-api` by re-using route reference types and factory methods from `@backstage/core-plugin-api`.
- Updated dependencies [75b8537ce]
- Updated dependencies [da8cba44f]
- @backstage/core-plugin-api@0.1.2
## 0.2.21
### Patch Changes
- 0160678b1: Made the `RouteRef*` types compatible with the ones exported from `@backstage/core-plugin-api`.
- Updated dependencies [031ccd45f]
- Updated dependencies [e7c5e4b30]
- @backstage/core-plugin-api@0.1.1
- @backstage/theme@0.2.8
## 0.2.20
### Patch Changes
- d597a50c6: Add a global type definition for `Symbol.observable`, fix type checking in projects that didn't already have it defined.
## 0.2.19
### Patch Changes
- 61c3f927c: Updated the `Observable` type to provide interoperability with `Symbol.observable`, making it compatible with at least `zen-observable` and `RxJS 7`.
In cases where this change breaks tests that mocked the `Observable` type, the following addition to the mock should fix the breakage:
```ts
[Symbol.observable]() {
return this;
},
```
- 65e6c4541: Remove circular dependencies
## 0.2.18
### Patch Changes
- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8
- 675a569a9: chore: bump `react-use` dependency in all packages
## 0.2.17
### Patch Changes
- ab07d77f6: Add support for discovering plugins through the app element tree, removing the need to register them explicitly.
- 50ce875a0: Fixed a potentially confusing error being thrown about misuse of routable extensions where the error was actually something different.
- Updated dependencies [931b21a12]
- @backstage/theme@0.2.6
## 0.2.16
### Patch Changes
- 1279a3325: Introduce a `load-chunk` step in the `BootErrorPage` to show make chunk loading
errors visible to the user.
- 4a4681b1b: Improved error messaging for routable extension errors, making it easier to identify the component and mount point that caused the error.
- b051e770c: Fixed a bug with `useRouteRef` where navigating from routes beneath a mount point would often fail.
## 0.2.15
### Patch Changes
- 76deafd31: Changed the signature of `createRoutableExtension` to include null
- 01ccef4c7: Introduce `useRouteRefParams` to `core-api` to retrieve typed route parameters.
- Updated dependencies [4618774ff]
- @backstage/theme@0.2.5
## 0.2.14
### Patch Changes
- a51dc0006: Export `SubRouteRef` type, and allow `SubRouteRef`s to be assigned to `plugin.routes`.
- e7f9b9435: Allow elements to be used multiple times in the app element tree.
- 34ff49b0f: Allow extension components to also return `null` in addition to a `JSX.Element`.
- d88dd219e: Internal refactor to allow for future package splits. As part of this `ApiRef`s are now identified by their ID rather than their reference.
- c8b54c370: Added new Docs Icon to Core Icons
- Updated dependencies [0434853a5]
- @backstage/config@0.1.4
## 0.2.13
### Patch Changes
- 13524b80b: Fully deprecate `title` option of `RouteRef`s and introduce `id` instead.
- e74b07578: Fixed a bug where FlatRoutes didn't handle React Fragments properly.
- 6fb4258a8: Add `SubRouteRef`s, which can be used to create a route ref with a fixed path relative to an absolute `RouteRef`. They are useful if you for example have a page that is mounted at a sub route of a routable extension component, and you want other plugins to be able to route to that page.
For example:
```tsx
// routes.ts
const rootRouteRef = createRouteRef({ id: 'root' });
const detailsRouteRef = createSubRouteRef({
id: 'root-sub',
parent: rootRouteRef,
path: '/details',
});
// plugin.ts
export const myPlugin = createPlugin({
routes: {
root: rootRouteRef,
details: detailsRouteRef,
}
})
export const MyPage = plugin.provide(createRoutableExtension({
component: () => import('./components/MyPage').then(m => m.MyPage),
mountPoint: rootRouteRef,
}))
// components/MyPage.tsx
const MyPage = () => (
<Routes>
{/* myPlugin.routes.root will take the user to this page */}
<Route path='/' element={<IndexPage />}>
{/* myPlugin.routes.details will take the user to this page */}
<Route path='/details' element={<DetailsPage />}>
</Routes>
)
```
- 395885905: Wait for `configApi` to be ready before using `featureFlagsApi`
- Updated dependencies [2089de76b]
- @backstage/theme@0.2.4
## 0.2.12
### Patch Changes
- 40c0fdbaa: Added support for optional external route references. By setting `optional: true` when creating an `ExternalRouteRef` it is no longer a requirement to bind the route in the app. If the app isn't bound `useRouteRef` will return `undefined`.
- 2a271d89e: Internal refactor of how component data is access to avoid polluting components and make it possible to bridge across versions.
## 0.2.11
### Patch Changes
- 3a58084b6: The `FlatRoutes` components now renders the not found page of the app if no routes are matched.
- 1407b34c6: More informative error message for missing ApiContext.
- b6c4f485d: Fix error when querying Backstage Identity with SAML authentication
- 3a58084b6: Created separate `AppContext` type to be returned from `useApp` rather than the `BackstageApp` itself. The `AppContext` type includes but deprecates `getPlugins`, `getProvider`, `getRouter`, and `getRoutes`. In addition, the `AppContext` adds a new `getComponents` method which providers access to the app components.
- Updated dependencies [a1f5e6545]
- @backstage/config@0.1.3
## 0.2.10
### Patch Changes
- f10950bd2: Minor refactoring of BackstageApp.getSystemIcons to support custom registered
icons. Custom Icons can be added using:
```tsx
import AlarmIcon from '@material-ui/icons/Alarm';
import MyPersonIcon from './MyPerson';
const app = createApp({
icons: {
user: MyPersonIcon // override system icon
alert: AlarmIcon, // Custom icon
},
});
```
- fd3f2a8c0: Export `createExternalRouteRef`, as well as give it an `id` for easier debugging, and fix parameter requirements when used with `useRouteRef`.
## 0.2.9
### Patch Changes
- ab0892358: Remove test dependencies from production package list
## 0.2.8
### Patch Changes
- a08c32ced: Add `FlatRoutes` component to replace the top-level `Routes` component from `react-router` within apps, removing the need for manually appending `/*` to paths or sorting routes.
- 86c3c652a: Deprecate `RouteRef` path parameter and member, and remove deprecated `routeRef.createSubRouteRef`.
- 27f2af935: Delay auth loginPopup close to avoid race condition with callers of authFlowHelpers.
## 0.2.7
### Patch Changes
- d681db2b5: Fix for GitHub and SAML auth not properly updating session state when already logged in.
- 1dc445e89: Introduce new plugin extension API
- Updated dependencies [1dc445e89]
- @backstage/test-utils@0.1.6
## 0.2.6
### Patch Changes
- 7dd2ef7d1: Use auth provider ID to create unique session storage keys for GitHub and SAML Auth.
## 0.2.5
### Patch Changes
- b6557c098: Update ApiFactory type to correctly infer API type and disallow mismatched implementations.
This fixes for example the following code:
```ts
interface MyApi {
myMethod(): void
}
const myApiRef = createApiRef<MyApi>({...});
createApiFactory({
api: myApiRef,
deps: {},
// This should've caused an error, since the empty object does not fully implement MyApi
factory: () => ({}),
})
```
- d8d5a17da: Deprecated the `ConcreteRoute`, `MutableRouteRef`, `AbsoluteRouteRef` types and added a new `RouteRef` type as replacement.
Deprecated and disabled the `createSubRoute` method of `AbsoluteRouteRef`.
Add an as of yet unused `params` option to `createRouteRef`.
- Updated dependencies [e3bd9fc2f]
- Updated dependencies [e1f4e24ef]
- Updated dependencies [1665ae8bb]
- Updated dependencies [e3bd9fc2f]
- @backstage/config@0.1.2
- @backstage/test-utils@0.1.5
- @backstage/theme@0.2.2
## 0.2.4
### Patch Changes
- b4488ddb0: Added a type alias for PositionError = GeolocationPositionError
- @backstage/test-utils@0.1.4
## 0.2.3
### Patch Changes
- 700a212b4: bug fix: issue 3223 - detect mismatching origin and indicate it in the message at auth failure
## 0.2.2
### Patch Changes
- 9b9e86f8a: export oidc provider
## 0.2.1
### Patch Changes
- c5bab94ab: Updated the AuthApi `.create` methods to configure the default scope of the corresponding Auth Api. As a result the
default scope is configurable when overwriting the Core Api in the app.
```
GithubAuth.create({
discoveryApi,
oauthRequestApi,
defaultScopes: ['read:user', 'repo'],
}),
```
Replaced redundant CreateOptions of each Auth Api with the OAuthApiCreateOptions type.
```
export type OAuthApiCreateOptions = AuthApiCreateOptions & {
oauthRequestApi: OAuthRequestApi;
defaultScopes?: string[];
};
export type AuthApiCreateOptions = {
discoveryApi: DiscoveryApi;
environment?: string;
provider?: AuthProvider & { id: string };
};
```
- Updated dependencies [4577e377b]
- @backstage/theme@0.2.1
## 0.2.0
### Minor Changes
- 819a70229: Add SAML login to backstage
![](https://user-images.githubusercontent.com/872486/92251660-bb9e3400-eeff-11ea-86fe-1f2a0262cd31.png)
![](https://user-images.githubusercontent.com/872486/93851658-1a76f200-fce3-11ea-990b-26ca1a327a15.png)
- b79017fd3: Updated the `GithubAuth.create` method to configure the default scope of the GitHub Auth Api. As a result the
default scope is configurable when overwriting the Core Api in the app.
```
GithubAuth.create({
discoveryApi,
oauthRequestApi,
defaultScopes: ['read:user', 'repo'],
}),
```
- cbab5bbf8: Refactored the FeatureFlagsApi to make it easier to re-implement. Existing usage of particularly getUserFlags can be replaced with isActive() or save().
### Patch Changes
- cbbd271c4: Add initial RouteRefRegistry
Starting out some work to bring routing back and working as part of the work towards finalizing #1536
This is some of the groundwork of an experiment we're working on to enable routing via RouteRefs, while letting the app itself look something like this:
```jsx
const App = () => (
<BackstageRoutes>
<Navigate key="/" to="/catalog" />
<CatalogRoute path="/catalog">
{' '}
// catalogRouteRef
<EntityPage type="service">
<OverviewContent path="/">
<WidgetA />
<WidgetB />
</OverviewContent>
<CICDSwitcher path="/ci-cd" />
<StatusRoute path="/api-status" /> // statusRouteRef
<ApiDocsRoute path="/api" />
<DocsRoute path="/docs" />
</EntityPage>
<EntityPage type="website">
<OverviewContent path="/">
<WidgetA />
<WidgetB />
</OverviewContent>
<CICDSwitcher path="/ci-cd" />
<SentryRoute path="/sentry" /> // sentryRouteRef
<DocsRoute path="/docs" />
</EntityPage>
<EntityPage>
<OverviewContent path="/">
<WidgetA />
<WidgetB />
</OverviewContent>
<DocsRoute path="/docs" />
</EntityPage>
</CatalogRoute>
<DocsRoute path="/docs" />
<TechRadarRoute path="/tech-radar" width={1500} height={800} />
<GraphiQLRoute path="/graphiql" />
<LighthouseRoute path="/lighthouse" />
</BackstageRoutes>
);
```
As part of inverting the composition of the app, route refs and routing in general was somewhat broken, intentionally. Right now it's not really possible to easily route to different parts of the app from a plugin, or even different parts of the plugin that are not within the same router.
The core part of the experiment is to construct a map of ApiRef[] -> path overrides. Each key in the map is the list of route refs to traversed to reach a leaf in the routing tree, and the value is the path override at that point. For example, the above tree would add entries like [techDocsRouteRef] -> '/docs', and [entityRouteRef, apiDocsRouteRef] -> '/api'. By mapping out the entire app in this structure, the idea is that we can navigate to any point in the app using RouteRefs.
The RouteRefRegistry is an implementation of such a map, and the idea is to add it in master to make it a bit easier to experiment and iterate. This is not an exposed API at this point.
We've explored a couple of alternatives for how to enable routing, but it's boiled down to either a solution centred around the route map mentioned above, or treating all routes as static and globally unique, with no room for flexibility, customization or conflicts between different plugins. We're starting out pursuing this options 😁. We also expect that a the app-wide routing table will make things like dynamic loading a lot cleaner, as there would be a much more clear handoff between the main chunk and dynamic chunks.
- 26e69ab1a: Remove cost insights example client from demo app and export from plugin
Create cost insights dev plugin using example client
Make PluginConfig and dependent types public
- Updated dependencies [ae5983387]
- Updated dependencies [0d4459c08]
- @backstage/theme@0.2.0
- @backstage/test-utils@0.1.2
-12
View File
@@ -1,12 +0,0 @@
# @backstage/core-api
**NOTE: This package is deprecated**
See the [migration documentation](https://backstage.io/docs/tutorials/migrating-away-from-core) for details on how to move to using the newer packages, [`@backstage/core-app-api`](https://www.npmjs.com/package/@backstage/core-app-api), [`@backstage/core-components`](https://www.npmjs.com/package/@backstage/core-components), and [`@backstage/core-plugin-api`](https://www.npmjs.com/package/@backstage/core-plugin-api)
This package used to provide the core API used by Backstage plugins and apps.
## Documentation
- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md)
- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md)
-62
View File
@@ -1,62 +0,0 @@
{
"name": "@backstage/core-api",
"description": "Internal Core API used by Backstage plugins and apps",
"version": "0.2.23",
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "packages/core-api"
},
"keywords": [
"backstage"
],
"license": "Apache-2.0",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "backstage-cli build --outputs types,esm",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/config": "^0.1.4",
"@backstage/core-plugin-api": "^0.1.3",
"@backstage/theme": "^0.2.8",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@types/react": "^16.9",
"@types/prop-types": "^15.7.3",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^17.2.4",
"zen-observable": "^0.8.15"
},
"devDependencies": {
"@backstage/cli": "^0.7.2",
"@backstage/test-utils": "^0.1.14",
"@backstage/test-utils-core": "^0.1.1",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/react-hooks": "^3.3.0",
"@testing-library/user-event": "^13.1.8",
"@types/jest": "^26.0.7",
"@types/node": "^14.14.32",
"@types/zen-observable": "^0.8.0",
"cross-fetch": "^3.0.6",
"msw": "^0.29.0"
},
"files": [
"dist"
]
}
@@ -1,44 +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 { createApiRef, ApiRef } from '../system';
import { Observable } from '../../types';
export type AlertMessage = {
message: string;
// Severity will default to success since that is what material ui defaults the value to.
severity?: 'success' | 'info' | 'warning' | 'error';
};
/**
* The alert API is used to report alerts to the app, and display them to the user.
*/
export type AlertApi = {
/**
* Post an alert for handling by the application.
*/
post(alert: AlertMessage): void;
/**
* Observe alerts posted by other parts of the application.
*/
alert$(): Observable<AlertMessage>;
};
export const alertApiRef: ApiRef<AlertApi> = createApiRef({
id: 'core.alert',
description: 'Used to report alerts and forward them to the app',
});
@@ -1,83 +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 { ApiRef, createApiRef } from '../system';
import { BackstageTheme } from '@backstage/theme';
import { Observable } from '../../types';
import { SvgIconProps } from '@material-ui/core';
/**
* Describes a theme provided by the app.
*/
export type AppTheme = {
/**
* ID used to remember theme selections.
*/
id: string;
/**
* Title of the theme
*/
title: string;
/**
* Theme variant
*/
variant: 'light' | 'dark';
/**
* The specialized MaterialUI theme instance.
*/
theme: BackstageTheme;
/**
* An Icon for the theme mode setting.
*/
icon?: React.ReactElement<SvgIconProps>;
};
/**
* The AppThemeApi gives access to the current app theme, and allows switching
* to other options that have been registered as a part of the App.
*/
export type AppThemeApi = {
/**
* Get a list of available themes.
*/
getInstalledThemes(): AppTheme[];
/**
* Observe the currently selected theme. A value of undefined means no specific theme has been selected.
*/
activeThemeId$(): Observable<string | undefined>;
/**
* Get the current theme ID. Returns undefined if no specific theme is selected.
*/
getActiveThemeId(): string | undefined;
/**
* Set a specific theme to use in the app, overriding the default theme selection.
*
* Clear the selection by passing in undefined.
*/
setActiveThemeId(themeId?: string): void;
};
export const appThemeApiRef: ApiRef<AppThemeApi> = createApiRef({
id: 'core.apptheme',
description: 'API Used to configure the app theme, and enumerate options',
});
@@ -1,28 +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 { ApiRef, createApiRef } from '../system';
import { Config } from '@backstage/config';
/**
* The Config API is used to provide a mechanism to access the
* runtime configuration of the system.
*/
export type ConfigApi = Config;
export const configApiRef: ApiRef<ConfigApi> = createApiRef({
id: 'core.config',
description: 'Used to access runtime configuration',
});
@@ -1,47 +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 { ApiRef, createApiRef } from '../system';
/**
* The discovery API is used to provide a mechanism for plugins to
* discover the endpoint to use to talk to their backend counterpart.
*
* The purpose of the discovery API is to allow for many different deployment
* setups and routing methods through a central configuration, instead
* of letting each individual plugin manage that configuration.
*
* Implementations of the discovery API can be a simple as a URL pattern
* using the pluginId, but could also have overrides for individual plugins,
* or query a separate discovery service.
*/
export type DiscoveryApi = {
/**
* Returns the HTTP base backend URL for a given plugin, without a trailing slash.
*
* This method must always be called just before making a request, as opposed to
* fetching the URL when constructing an API client. That is to ensure that more
* flexible routing patterns can be supported.
*
* For example, asking for the URL for `auth` may return something
* like `https://backstage.example.com/api/auth`
*/
getBaseUrl(pluginId: string): Promise<string>;
};
export const discoveryApiRef: ApiRef<DiscoveryApi> = createApiRef({
id: 'core.discovery',
description: 'Provides service discovery of backend plugins',
});
@@ -1,68 +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 { ApiRef, createApiRef } from '../system';
import { Observable } from '../../types';
/**
* Mirrors the JavaScript Error class, for the purpose of
* providing documentation and optional fields.
*/
type Error = {
name: string;
message: string;
stack?: string;
};
/**
* Provides additional information about an error that was posted to the application.
*/
export type ErrorContext = {
// If set to true, this error should not be displayed to the user. Defaults to false.
hidden?: boolean;
};
/**
* The error API is used to report errors to the app, and display them to the user.
*
* Plugins can use this API as a method of displaying errors to the user, but also
* to report errors for collection by error reporting services.
*
* If an error can be displayed inline, e.g. as feedback in a form, that should be
* preferred over relying on this API to display the error. The main use of this API
* for displaying errors should be for asynchronous errors, such as a failing background process.
*
* Even if an error is displayed inline, it should still be reported through this API
* if it would be useful to collect or log it for debugging purposes, but with
* the hidden flag set. For example, an error arising from form field validation
* should probably not be reported, while a failed REST call would be useful to report.
*/
export type ErrorApi = {
/**
* Post an error for handling by the application.
*/
post(error: Error, context?: ErrorContext): void;
/**
* Observe errors posted by other parts of the application.
*/
error$(): Observable<{ error: Error; context?: ErrorContext }>;
};
export const errorApiRef: ApiRef<ErrorApi> = createApiRef({
id: 'core.error',
description: 'Used to report errors and forward them to the app',
});
@@ -1,86 +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 { ApiRef, createApiRef } from '../system';
/**
* The feature flags API is used to toggle functionality to users across plugins and Backstage.
*
* Plugins can use this API to register feature flags that they have available
* for users to enable/disable, and this API will centralize the current user's
* state of which feature flags they would like to enable.
*
* This is ideal for Backstage plugins, as well as your own App, to trial incomplete
* or unstable upcoming features. Although there will be a common interface for users
* to enable and disable feature flags, this API acts as another way to enable/disable.
*/
export type FeatureFlag = {
name: string;
pluginId: string;
};
export enum FeatureFlagState {
None = 0,
Active = 1,
}
/**
* Options to use when saving feature flags.
*/
export type FeatureFlagsSaveOptions = {
/**
* The new feature flag states to save.
*/
states: Record<string, FeatureFlagState>;
/**
* Whether the saves states should be merged into the existing ones, or replace them.
*
* Defaults to false.
*/
merge?: boolean;
};
export type UserFlags = {};
export interface FeatureFlagsApi {
/**
* Registers a new feature flag. Once a feature flag has been registered it
* can be toggled by users, and read back to enable or disable features.
*/
registerFlag(flag: FeatureFlag): void;
/**
* Get a list of all registered flags.
*/
getRegisteredFlags(): FeatureFlag[];
/**
* Whether the feature flag with the given name is currently activated for the user.
*/
isActive(name: string): boolean;
/**
* Save the user's choice of feature flag states.
*/
save(options: FeatureFlagsSaveOptions): void;
}
export const featureFlagsApiRef: ApiRef<FeatureFlagsApi> = createApiRef({
id: 'core.featureflags',
description: 'Used to toggle functionality in features across Backstage',
});
@@ -1,56 +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 { ApiRef, createApiRef } from '../system';
import { ProfileInfo } from './auth';
/**
* The Identity API used to identify and get information about the signed in user.
*/
export type IdentityApi = {
/**
* The ID of the signed in user. This ID is not meant to be presented to the user, but used
* as an opaque string to pass on to backends or use in frontend logic.
*
* TODO: The intention of the user ID is to be able to tie the user to an identity
* that is known by the catalog and/or identity backend. It should for example
* be possible to fetch all owned components using this ID.
*/
getUserId(): string;
// TODO: getProfile(): Promise<Profile> - We want this to be async when added, but needs more work.
/**
* The profile of the signed in user.
*/
getProfile(): ProfileInfo;
/**
* An OpenID Connect ID Token which proves the identity of the signed in user.
*
* The ID token will be undefined if the signed in user does not have a verified
* identity, such as a demo user or mocked user for e2e tests.
*/
getIdToken(): Promise<string | undefined>;
/**
* Sign out the current user
*/
signOut(): Promise<void>;
};
export const identityApiRef: ApiRef<IdentityApi> = createApiRef({
id: 'core.identity',
description: 'Provides access to the identity of the signed in user',
});
@@ -1,133 +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 { IconComponent } from '../../icons/types';
import { Observable } from '../../types';
import { ApiRef, createApiRef } from '../system';
/**
* Information about the auth provider that we're requesting a login towards.
*
* This should be shown to the user so that they can be informed about what login is being requested
* before a popup is shown.
*/
export type AuthProvider = {
/**
* Title for the auth provider, for example "GitHub"
*/
title: string;
/**
* Icon for the auth provider.
*/
icon: IconComponent;
};
/**
* Describes how to handle auth requests. Both how to show them to the user, and what to do when
* the user accesses the auth request.
*/
export type AuthRequesterOptions<AuthResponse> = {
/**
* Information about the auth provider, which will be forwarded to auth requests.
*/
provider: AuthProvider;
/**
* Implementation of the auth flow, which will be called synchronously when
* trigger() is called on an auth requests.
*/
onAuthRequest(scopes: Set<string>): Promise<AuthResponse>;
};
/**
* Function used to trigger new auth requests for a set of scopes.
*
* The returned promise will resolve to the same value returned by the onAuthRequest in the
* AuthRequesterOptions. Or rejected, if the request is rejected.
*
* This function can be called multiple times before the promise resolves. All calls
* will be merged into one request, and the scopes forwarded to the onAuthRequest will be the
* union of all requested scopes.
*/
export type AuthRequester<AuthResponse> = (
scopes: Set<string>,
) => Promise<AuthResponse>;
/**
* An pending auth request for a single auth provider. The request will remain in this pending
* state until either reject() or trigger() is called.
*
* Any new requests for the same provider are merged into the existing pending request, meaning
* there will only ever be a single pending request for a given provider.
*/
export type PendingAuthRequest = {
/**
* Information about the auth provider, as given in the AuthRequesterOptions
*/
provider: AuthProvider;
/**
* Rejects the request, causing all pending AuthRequester calls to fail with "RejectedError".
*/
reject: () => void;
/**
* Trigger the auth request to continue the auth flow, by for example showing a popup.
*
* Synchronously calls onAuthRequest with all scope currently in the request.
*/
trigger(): Promise<void>;
};
/**
* Provides helpers for implemented OAuth login flows within Backstage.
*/
export type OAuthRequestApi = {
/**
* A utility for showing login popups or similar things, and merging together multiple requests for
* different scopes into one request that includes all scopes.
*
* The passed in options provide information about the login provider, and how to handle auth requests.
*
* The returned AuthRequester function is used to request login with new scopes. These requests
* are merged together and forwarded to the auth handler, as soon as a consumer of auth requests
* triggers an auth flow.
*
* See AuthRequesterOptions, AuthRequester, and handleAuthRequests for more info.
*/
createAuthRequester<AuthResponse>(
options: AuthRequesterOptions<AuthResponse>,
): AuthRequester<AuthResponse>;
/**
* Observers pending auth requests. The returned observable will emit all
* current active auth request, at most one for each created auth requester.
*
* Each request has its own info about the login provider, forwarded from the auth requester options.
*
* Depending on user interaction, the request should either be rejected, or used to trigger the auth handler.
* If the request is rejected, all pending AuthRequester calls will fail with a "RejectedError".
* If a auth is triggered, and the auth handler resolves successfully, then all currently pending
* AuthRequester calls will resolve to the value returned by the onAuthRequest call.
*/
authRequest$(): Observable<PendingAuthRequest[]>;
};
export const oauthRequestApiRef: ApiRef<OAuthRequestApi> = createApiRef({
id: 'core.oauthrequest',
description: 'An API for implementing unified OAuth flows in Backstage',
});
@@ -1,71 +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 { ApiRef, createApiRef } from '../system';
import { Observable } from '../../types';
import { ErrorApi } from './ErrorApi';
export type StorageValueChange<T = any> = {
key: string;
newValue?: T;
};
export type CreateStorageApiOptions = {
errorApi: ErrorApi;
namespace?: string;
};
export interface StorageApi {
/**
* Create a bucket to store data in.
* @param {String} name Namespace for the storage to be stored under,
* will inherit previous namespaces too
*/
forBucket(name: string): StorageApi;
/**
* Get the current value for persistent data, use observe$ to be notified of updates.
*
* @param {String} key Unique key associated with the data.
* @return {Object} data The data that should is stored.
*/
get<T>(key: string): T | undefined;
/**
* Remove persistent data.
*
* @param {String} key Unique key associated with the data.
*/
remove(key: string): Promise<void>;
/**
* Save persistent data, and emit messages to anyone that is using observe$ for this key
*
* @param {String} key Unique key associated with the data.
*/
set(key: string, data: any): Promise<void>;
/**
* Observe changes on a particular key in the bucket
* @param {String} key Unique key associated with the data
*/
observe$<T>(key: string): Observable<StorageValueChange<T>>;
}
export const storageApiRef: ApiRef<StorageApi> = createApiRef({
id: 'core.storage',
description: 'Provides the ability to store data which is unique to the user',
});
@@ -1,347 +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 { ApiRef, createApiRef } from '../system';
import { Observable } from '../../types';
/**
* This file contains declarations for common interfaces of auth-related APIs.
* The declarations should be used to signal which type of authentication and
* authorization methods each separate auth provider supports.
*
* For example, a Google OAuth provider that supports OAuth 2 and OpenID Connect,
* would be declared as follows:
*
* const googleAuthApiRef = createApiRef<OAuthApi & OpenIDConnectApi>({ ... })
*/
/**
* An array of scopes, or a scope string formatted according to the
* auth provider, which is typically a space separated list.
*
* See the documentation for each auth provider for the list of scopes
* supported by each provider.
*/
export type OAuthScope = string | string[];
export type AuthRequestOptions = {
/**
* If this is set to true, the user will not be prompted to log in,
* and an empty response will be returned if there is no existing session.
*
* This can be used to perform a check whether the user is logged in, or if you don't
* want to force a user to be logged in, but provide functionality if they already are.
*
* @default false
*/
optional?: boolean;
/**
* If this is set to true, the request will bypass the regular oauth login modal
* and open the login popup directly.
*
* The method must be called synchronously from a user action for this to work in all browsers.
*
* @default false
*/
instantPopup?: boolean;
};
/**
* This API provides access to OAuth 2 credentials. It lets you request access tokens,
* which can be used to act on behalf of the user when talking to APIs.
*/
export type OAuthApi = {
/**
* Requests an OAuth 2 Access Token, optionally with a set of scopes. The access token allows
* you to make requests on behalf of the user, and the copes may grant you broader access, depending
* on the auth provider.
*
* Each auth provider has separate handling of scope, so you need to look at the documentation
* for each one to know what scope you need to request.
*
* This method is cheap and should be called each time an access token is used. Do not for example
* store the access token in React component state, as that could cause the token to expire. Instead
* fetch a new access token for each request.
*
* Be sure to include all required scopes when requesting an access token. When testing your implementation
* it is best to log out the Backstage session and then visit your plugin page directly, as
* you might already have some required scopes in your existing session. Not requesting the correct
* scopes can lead to 403 or other authorization errors, which can be tricky to debug.
*
* If the user has not yet granted access to the provider and the set of requested scopes, the user
* will be prompted to log in. The returned promise will not resolve until the user has
* successfully logged in. The returned promise can be rejected, but only if the user rejects the login request.
*/
getAccessToken(
scope?: OAuthScope,
options?: AuthRequestOptions,
): Promise<string>;
};
/**
* This API provides access to OpenID Connect credentials. It lets you request ID tokens,
* which can be passed to backend services to prove the user's identity.
*/
export type OpenIdConnectApi = {
/**
* Requests an OpenID Connect ID Token.
*
* This method is cheap and should be called each time an ID token is used. Do not for example
* store the id token in React component state, as that could cause the token to expire. Instead
* fetch a new id token for each request.
*
* If the user has not yet logged in to Google inside Backstage, the user will be prompted
* to log in. The returned promise will not resolve until the user has successfully logged in.
* The returned promise can be rejected, but only if the user rejects the login request.
*/
getIdToken(options?: AuthRequestOptions): Promise<string>;
};
/**
* This API provides access to profile information of the user from an auth provider.
*/
export type ProfileInfoApi = {
/**
* Get profile information for the user as supplied by this auth provider.
*
* If the optional flag is not set, a session is guaranteed to be returned, while if
* the optional flag is set, the session may be undefined. See @AuthRequestOptions for more details.
*/
getProfile(options?: AuthRequestOptions): Promise<ProfileInfo | undefined>;
};
/**
* This API provides access to the user's identity within Backstage.
*
* An auth provider that implements this interface can be used to sign-in to backstage. It is
* not intended to be used directly from a plugin, but instead serves as a connection between
* this authentication method and the app's @IdentityApi
*/
export type BackstageIdentityApi = {
/**
* Get the user's identity within Backstage. This should normally not be called directly,
* use the @IdentityApi instead.
*
* If the optional flag is not set, a session is guaranteed to be returned, while if
* the optional flag is set, the session may be undefined. See @AuthRequestOptions for more details.
*/
getBackstageIdentity(
options?: AuthRequestOptions,
): Promise<BackstageIdentity | undefined>;
};
export type BackstageIdentity = {
/**
* The backstage user ID.
*/
id: string;
/**
* An ID token that can be used to authenticate the user within Backstage.
*/
idToken: string;
};
/**
* Profile information of the user.
*/
export type ProfileInfo = {
/**
* Email ID.
*/
email?: string;
/**
* Display name that can be presented to the user.
*/
displayName?: string;
/**
* URL to an avatar image of the user.
*/
picture?: string;
};
/**
* Session state values passed to subscribers of the SessionApi.
*/
export enum SessionState {
SignedIn = 'SignedIn',
SignedOut = 'SignedOut',
}
/**
* The SessionApi provides basic controls for any auth provider that is tied to a persistent session.
*/
export type SessionApi = {
/**
* Sign in with a minimum set of permissions.
*/
signIn(): Promise<void>;
/**
* Sign out from the current session. This will reload the page.
*/
signOut(): Promise<void>;
/**
* Observe the current state of the auth session. Emits the current state on subscription.
*/
sessionState$(): Observable<SessionState>;
};
/**
* Provides authentication towards Google APIs and identities.
*
* See https://developers.google.com/identity/protocols/googlescopes for a full list of supported scopes.
*
* Note that the ID token payload is only guaranteed to contain the user's numerical Google ID,
* email and expiration information. Do not rely on any other fields, as they might not be present.
*/
export const googleAuthApiRef: ApiRef<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
> = createApiRef({
id: 'core.auth.google',
description: 'Provides authentication towards Google APIs and identities',
});
/**
* Provides authentication towards GitHub APIs.
*
* See https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/
* for a full list of supported scopes.
*/
export const githubAuthApiRef: ApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
> = createApiRef({
id: 'core.auth.github',
description: 'Provides authentication towards GitHub APIs',
});
/**
* Provides authentication towards Okta APIs.
*
* See https://developer.okta.com/docs/guides/implement-oauth-for-okta/scopes/
* for a full list of supported scopes.
*/
export const oktaAuthApiRef: ApiRef<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
> = createApiRef({
id: 'core.auth.okta',
description: 'Provides authentication towards Okta APIs',
});
/**
* Provides authentication towards GitLab APIs.
*
* See https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#limiting-scopes-of-a-personal-access-token
* for a full list of supported scopes.
*/
export const gitlabAuthApiRef: ApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
> = createApiRef({
id: 'core.auth.gitlab',
description: 'Provides authentication towards GitLab APIs',
});
/**
* Provides authentication towards Auth0 APIs.
*
* See https://auth0.com/docs/scopes/current/oidc-scopes
* for a full list of supported scopes.
*/
export const auth0AuthApiRef: ApiRef<
OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
> = createApiRef({
id: 'core.auth.auth0',
description: 'Provides authentication towards Auth0 APIs',
});
/**
* Provides authentication towards Microsoft APIs and identities.
*
* For more info and a full list of supported scopes, see:
* - https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent
* - https://docs.microsoft.com/en-us/graph/permissions-reference
*/
export const microsoftAuthApiRef: ApiRef<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
> = createApiRef({
id: 'core.auth.microsoft',
description: 'Provides authentication towards Microsoft APIs and identities',
});
/**
* Provides authentication for custom identity providers.
*/
export const oauth2ApiRef: ApiRef<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
> = createApiRef({
id: 'core.auth.oauth2',
description: 'Example of how to use oauth2 custom provider',
});
/**
* Provides authentication for custom OpenID Connect identity providers.
*/
export const oidcAuthApiRef: ApiRef<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
> = createApiRef({
id: 'core.auth.oidc',
description: 'Example of how to use oidc custom provider',
});
/**
* Provides authentication for saml based identity providers
*/
export const samlAuthApiRef: ApiRef<
ProfileInfoApi & BackstageIdentityApi & SessionApi
> = createApiRef({
id: 'core.auth.saml',
description: 'Example of how to use SAML custom provider',
});
export const oneloginAuthApiRef: ApiRef<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
> = createApiRef({
id: 'core.auth.onelogin',
description: 'Provides authentication towards OneLogin APIs and identities',
});
@@ -1,33 +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.
*/
// This folder contains definitions for all core APIs.
//
// Plugins should rely on these APIs for functionality as much as possible.
//
// If you think some API definition is missing, please open an Issue or send a PR!
export * from './auth';
export * from './AlertApi';
export * from './AppThemeApi';
export * from './ConfigApi';
export * from './DiscoveryApi';
export * from './ErrorApi';
export * from './FeatureFlagsApi';
export * from './IdentityApi';
export * from './OAuthRequestApi';
export * from './StorageApi';
@@ -1,33 +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 { PublishSubject } from '../../../lib/subjects';
import { Observable } from '../../../types';
import { AlertApi, AlertMessage } from '../../definitions';
/**
* Base implementation for the AlertApi that simply forwards alerts to consumers.
*/
export class AlertApiForwarder implements AlertApi {
private readonly subject = new PublishSubject<AlertMessage>();
post(alert: AlertMessage) {
this.subject.next(alert);
}
alert$(): Observable<AlertMessage> {
return this.subject;
}
}
@@ -1,17 +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.
*/
export { AlertApiForwarder } from './AlertApiForwarder';
@@ -1,85 +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 { AppTheme } from '../../definitions';
import { AppThemeSelector } from './AppThemeSelector';
describe('AppThemeSelector', () => {
it('should should select new themes', async () => {
const selector = new AppThemeSelector([]);
expect(selector.getInstalledThemes()).toEqual([]);
const subFn = jest.fn();
selector.activeThemeId$().subscribe(subFn);
expect(selector.getActiveThemeId()).toBe(undefined);
await 'wait a tick';
expect(subFn).toHaveBeenLastCalledWith(undefined);
selector.setActiveThemeId('x');
expect(subFn).toHaveBeenLastCalledWith('x');
expect(selector.getActiveThemeId()).toBe('x');
selector.setActiveThemeId(undefined);
expect(subFn).toHaveBeenLastCalledWith(undefined);
expect(selector.getActiveThemeId()).toBe(undefined);
});
it('should return a new array of themes', () => {
const themes = new Array<AppTheme>();
const selector = new AppThemeSelector(themes);
expect(selector.getInstalledThemes()).toEqual(themes);
expect(selector.getInstalledThemes()).not.toBe(themes);
});
it('should store theme in local storage', async () => {
expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe(
undefined,
);
localStorage.setItem('theme', 'x');
expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe('x');
localStorage.removeItem('theme');
expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe(
undefined,
);
const addListenerSpy = jest.spyOn(window, 'addEventListener');
const selector = AppThemeSelector.createWithStorage([]);
expect(addListenerSpy).toHaveBeenCalledTimes(1);
expect(addListenerSpy).toHaveBeenCalledWith(
'storage',
expect.any(Function),
);
selector.setActiveThemeId('y');
await 'wait a tick';
expect(localStorage.getItem('theme')).toBe('y');
selector.setActiveThemeId(undefined);
await 'wait a tick';
expect(localStorage.getItem('theme')).toBe(null);
localStorage.setItem('theme', 'z');
expect(selector.getActiveThemeId()).toBe(undefined);
const listener = addListenerSpy.mock.calls[0][1] as EventListener;
listener({ key: 'theme' } as StorageEvent);
expect(selector.getActiveThemeId()).toBe('z');
});
});
@@ -1,75 +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 { AppThemeApi, AppTheme } from '../../definitions';
import { BehaviorSubject } from '../../../lib/subjects';
import { Observable } from '../../../types';
const STORAGE_KEY = 'theme';
export class AppThemeSelector implements AppThemeApi {
static createWithStorage(themes: AppTheme[]) {
const selector = new AppThemeSelector(themes);
if (!window.localStorage) {
return selector;
}
const initialThemeId =
window.localStorage.getItem(STORAGE_KEY) ?? undefined;
selector.setActiveThemeId(initialThemeId);
selector.activeThemeId$().subscribe(themeId => {
if (themeId) {
window.localStorage.setItem(STORAGE_KEY, themeId);
} else {
window.localStorage.removeItem(STORAGE_KEY);
}
});
window.addEventListener('storage', event => {
if (event.key === STORAGE_KEY) {
const themeId = localStorage.getItem(STORAGE_KEY) ?? undefined;
selector.setActiveThemeId(themeId);
}
});
return selector;
}
private activeThemeId: string | undefined;
private readonly subject = new BehaviorSubject<string | undefined>(undefined);
constructor(private readonly themes: AppTheme[]) {}
getInstalledThemes(): AppTheme[] {
return this.themes.slice();
}
activeThemeId$(): Observable<string | undefined> {
return this.subject;
}
getActiveThemeId(): string | undefined {
return this.activeThemeId;
}
setActiveThemeId(themeId?: string): void {
this.activeThemeId = themeId;
this.subject.next(themeId);
}
}
@@ -1,17 +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.
*/
export * from './AppThemeSelector';
@@ -1,17 +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.
*/
export { ConfigReader } from '@backstage/config';
@@ -1,84 +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 { UrlPatternDiscovery } from './UrlPatternDiscovery';
describe('UrlPatternDiscovery', () => {
it('should not require interpolation', async () => {
const discoveryApi = UrlPatternDiscovery.compile('http://example.com');
await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe(
'http://example.com',
);
});
it('should use a plain pattern', async () => {
const discoveryApi = UrlPatternDiscovery.compile(
'http://localhost:7000/{{ pluginId }}',
);
await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe(
'http://localhost:7000/my-plugin',
);
});
it('should allow for multiple interpolation points', async () => {
const discoveryApi = UrlPatternDiscovery.compile(
'https://{{pluginId }}.example.com/api/{{ pluginId}}',
);
await expect(discoveryApi.getBaseUrl('my-plugin')).resolves.toBe(
'https://my-plugin.example.com/api/my-plugin',
);
});
it('should validate that the pattern is a valid URL', () => {
expect(() => {
UrlPatternDiscovery.compile('example.com');
}).toThrow('Invalid discovery URL pattern, Invalid URL: example.com');
expect(() => {
UrlPatternDiscovery.compile('http://');
}).toThrow('Invalid discovery URL pattern, Invalid URL: http://');
expect(() => {
UrlPatternDiscovery.compile('abc123');
}).toThrow('Invalid discovery URL pattern, Invalid URL: abc123');
expect(() => {
UrlPatternDiscovery.compile('http://example.com:{{pluginId}}');
}).toThrow(
'Invalid discovery URL pattern, Invalid URL: http://example.com:pluginId',
);
expect(() => {
UrlPatternDiscovery.compile('/{{pluginId}}');
}).toThrow('Invalid discovery URL pattern, Invalid URL: /pluginId');
expect(() => {
UrlPatternDiscovery.compile('http://localhost/{{pluginId}}?forbidden');
}).toThrow('Invalid discovery URL pattern, URL must not have a query');
expect(() => {
UrlPatternDiscovery.compile('http://localhost/{{pluginId}}#forbidden');
}).toThrow('Invalid discovery URL pattern, URL must not have a hash');
expect(() => {
UrlPatternDiscovery.compile('http://localhost/{{pluginId}}/');
}).toThrow('Invalid discovery URL pattern, URL must not end with a slash');
expect(() => {
UrlPatternDiscovery.compile('http://localhost/');
}).toThrow('Invalid discovery URL pattern, URL must not end with a slash');
});
});
@@ -1,58 +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 { DiscoveryApi } from '../../definitions/DiscoveryApi';
/**
* UrlPatternDiscovery is a lightweight DiscoveryApi implementation.
* It uses a single template string to construct URLs for each plugin.
*/
export class UrlPatternDiscovery implements DiscoveryApi {
/**
* Creates a new UrlPatternDiscovery given a template. The the only
* interpolation done for the template is to replace instances of `{{pluginId}}`
* with the ID of the plugin being requested.
*
* Example pattern: `http://localhost:7000/api/{{ pluginId }}`
*/
static compile(pattern: string): UrlPatternDiscovery {
const parts = pattern.split(/\{\{\s*pluginId\s*\}\}/);
try {
const urlStr = parts.join('pluginId');
const url = new URL(urlStr);
if (url.hash) {
throw new Error('URL must not have a hash');
}
if (url.search) {
throw new Error('URL must not have a query');
}
if (urlStr.endsWith('/')) {
throw new Error('URL must not end with a slash');
}
} catch (error) {
throw new Error(`Invalid discovery URL pattern, ${error.message}`);
}
return new UrlPatternDiscovery(parts);
}
private constructor(private readonly parts: string[]) {}
async getBaseUrl(pluginId: string): Promise<string> {
return this.parts.join(pluginId);
}
}
@@ -1,17 +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.
*/
export { UrlPatternDiscovery } from './UrlPatternDiscovery';
@@ -1,39 +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 { AlertApi, ErrorApi, ErrorContext } from '../../definitions';
/**
* Decorates an ErrorApi by also forwarding error messages
* to the alertApi with an 'error' severity.
*/
export class ErrorAlerter implements ErrorApi {
constructor(
private readonly alertApi: AlertApi,
private readonly errorApi: ErrorApi,
) {}
post(error: Error, context?: ErrorContext) {
if (!context?.hidden) {
this.alertApi.post({ message: error.message, severity: 'error' });
}
return this.errorApi.post(error, context);
}
error$() {
return this.errorApi.error$();
}
}
@@ -1,36 +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 { PublishSubject } from '../../../lib/subjects';
import { Observable } from '../../../types';
import { ErrorApi, ErrorContext } from '../../definitions';
/**
* Base implementation for the ErrorApi that simply forwards errors to consumers.
*/
export class ErrorApiForwarder implements ErrorApi {
private readonly subject = new PublishSubject<{
error: Error;
context?: ErrorContext;
}>();
post(error: Error, context?: ErrorContext) {
this.subject.next({ error, context });
}
error$(): Observable<{ error: Error; context?: ErrorContext }> {
return this.subject;
}
}
@@ -1,18 +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.
*/
export { ErrorAlerter } from './ErrorAlerter';
export { ErrorApiForwarder } from './ErrorApiForwarder';
@@ -1,222 +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 { LocalStorageFeatureFlags } from './LocalStorageFeatureFlags';
import { FeatureFlagState, FeatureFlagsApi } from '../../definitions';
describe('FeatureFlags', () => {
beforeEach(() => {
window.localStorage.clear();
});
describe('getFlags', () => {
let featureFlags: FeatureFlagsApi;
beforeEach(() => {
featureFlags = new LocalStorageFeatureFlags();
});
it('returns no flags', () => {
expect(featureFlags.getRegisteredFlags()).toEqual([]);
});
it('loads flags from local storage', () => {
window.localStorage.setItem(
'featureFlags',
JSON.stringify({
'feature-flag-one': 1,
'feature-flag-two': 1,
'feature-flag-three': 0,
'feature-flag-four': 2,
'feature-flag-five': 'not-valid',
}),
);
expect(featureFlags.isActive('feature-flag-one')).toBe(true);
expect(featureFlags.isActive('feature-flag-two')).toBe(true);
expect(featureFlags.isActive('feature-flag-three')).toBe(false);
expect(featureFlags.isActive('feature-flag-four')).toBe(false);
expect(featureFlags.isActive('feature-flag-five')).toBe(false);
});
it('sets the correct values', () => {
featureFlags.save({
states: {
'feature-flag-zero': FeatureFlagState.Active,
},
});
expect(featureFlags.isActive('feature-flag-zero')).toBe(true);
expect(window.localStorage.getItem('featureFlags')).toEqual(
'{"feature-flag-zero":1}',
);
});
it('deletes the correct values', () => {
window.localStorage.setItem(
'featureFlags',
JSON.stringify({
'feature-flag-one': 1,
'feature-flag-two': 0,
'feature-flag-tree': 1,
'feature-flag-four': 0,
}),
);
featureFlags.save({
states: {
'feature-flag-one': FeatureFlagState.None,
'feature-flag-two': FeatureFlagState.Active,
},
});
expect(window.localStorage.getItem('featureFlags')).toEqual(
'{"feature-flag-two":1}',
);
});
it('clears all values', () => {
window.localStorage.setItem(
'featureFlags',
JSON.stringify({
'feature-flag-one': 1,
'feature-flag-two': 1,
'feature-flag-three': 0,
}),
);
expect(featureFlags.isActive('feature-flag-one')).toBe(true);
expect(featureFlags.isActive('feature-flag-two')).toBe(true);
expect(featureFlags.isActive('feature-flag-three')).toBe(false);
featureFlags.save({ states: {} });
expect(featureFlags.isActive('feature-flag-one')).toBe(false);
expect(featureFlags.isActive('feature-flag-two')).toBe(false);
expect(featureFlags.isActive('feature-flag-three')).toBe(false);
expect(window.localStorage.getItem('featureFlags')).toEqual('{}');
});
});
describe('getRegisteredFlags', () => {
let featureFlags: FeatureFlagsApi;
beforeEach(() => {
featureFlags = new LocalStorageFeatureFlags();
featureFlags.registerFlag({
name: 'registered-flag-1',
pluginId: 'plugin-one',
});
featureFlags.registerFlag({
name: 'registered-flag-2',
pluginId: 'plugin-one',
});
featureFlags.registerFlag({
name: 'registered-flag-3',
pluginId: 'plugin-two',
});
});
it('should return an empty list', () => {
featureFlags = new LocalStorageFeatureFlags();
expect(featureFlags.getRegisteredFlags()).toEqual([]);
});
it('should return an valid list', () => {
expect(featureFlags.getRegisteredFlags()).toEqual([
{ name: 'registered-flag-1', pluginId: 'plugin-one' },
{ name: 'registered-flag-2', pluginId: 'plugin-one' },
{ name: 'registered-flag-3', pluginId: 'plugin-two' },
]);
});
it('should provide a copy of the list of flags', () => {
const flags = featureFlags.getRegisteredFlags();
expect(flags).toEqual([
{ name: 'registered-flag-1', pluginId: 'plugin-one' },
{ name: 'registered-flag-2', pluginId: 'plugin-one' },
{ name: 'registered-flag-3', pluginId: 'plugin-two' },
]);
flags.splice(2, 1);
expect(flags).toEqual([
{ name: 'registered-flag-1', pluginId: 'plugin-one' },
{ name: 'registered-flag-2', pluginId: 'plugin-one' },
]);
expect(featureFlags.getRegisteredFlags()).toEqual([
{ name: 'registered-flag-1', pluginId: 'plugin-one' },
{ name: 'registered-flag-2', pluginId: 'plugin-one' },
{ name: 'registered-flag-3', pluginId: 'plugin-two' },
]);
});
it('should get the correct values', () => {
const getByName = (name: string) =>
featureFlags.getRegisteredFlags().find(flag => flag.name === name);
expect(getByName('registered-flag-0')).toBeUndefined();
expect(getByName('registered-flag-1')).toEqual({
name: 'registered-flag-1',
pluginId: 'plugin-one',
});
expect(getByName('registered-flag-2')).toEqual({
name: 'registered-flag-2',
pluginId: 'plugin-one',
});
expect(getByName('registered-flag-3')).toEqual({
name: 'registered-flag-3',
pluginId: 'plugin-two',
});
});
it('throws an error if length is less than three characters', () => {
expect(() =>
featureFlags.registerFlag({
name: 'ab',
pluginId: 'plugin-three',
}),
).toThrow(/minimum length of three characters/i);
});
it('throws an error if length is greater than 150 characters', () => {
expect(() =>
featureFlags.registerFlag({
name:
'loremipsumdolorsitametconsecteturadipiscingelitnuncvitaeportaexaullamcorperturpismaurisutmattisnequemorbisediaculisauguevivamuspulvinarcursuseratblandithendreritquisqueuttinciduntmagnavestibulumblanditaugueat',
pluginId: 'plugin-three',
}),
).toThrow(/not exceed 150 characters/i);
});
it('throws an error if name does not start with a lowercase letter', () => {
expect(() =>
featureFlags.registerFlag({
name: '123456789',
pluginId: 'plugin-three',
}),
).toThrow(/start with a lowercase letter/i);
});
it('throws an error if name contains characters other than lowercase letters, numbers and hyphens', () => {
expect(() =>
featureFlags.registerFlag({
name: 'Invalid_Feature_Flag',
pluginId: 'plugin-three',
}),
).toThrow(/only contain lowercase letters, numbers and hyphens/i);
});
});
});
@@ -1,109 +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 {
FeatureFlagState,
FeatureFlagsApi,
FeatureFlag,
FeatureFlagsSaveOptions,
} from '../../definitions';
export function validateFlagName(name: string): void {
if (name.length < 3) {
throw new Error(
`The '${name}' feature flag must have a minimum length of three characters.`,
);
}
if (name.length > 150) {
throw new Error(
`The '${name}' feature flag must not exceed 150 characters.`,
);
}
if (!name.match(/^[a-z]+[a-z0-9-]+$/)) {
throw new Error(
`The '${name}' feature flag must start with a lowercase letter and only contain lowercase letters, numbers and hyphens. ` +
'Examples: feature-flag-one, alpha, release-2020',
);
}
}
/**
* Create the FeatureFlags implementation based on the API.
*/
export class LocalStorageFeatureFlags implements FeatureFlagsApi {
private registeredFeatureFlags: FeatureFlag[] = [];
private flags?: Map<string, FeatureFlagState>;
registerFlag(flag: FeatureFlag) {
validateFlagName(flag.name);
this.registeredFeatureFlags.push(flag);
}
getRegisteredFlags(): FeatureFlag[] {
return this.registeredFeatureFlags.slice();
}
isActive(name: string): boolean {
if (!this.flags) {
this.flags = this.load();
}
return this.flags.get(name) === FeatureFlagState.Active;
}
save(options: FeatureFlagsSaveOptions): void {
if (!this.flags) {
this.flags = this.load();
}
if (!options.merge) {
this.flags.clear();
}
for (const [name, state] of Object.entries(options.states)) {
this.flags.set(name, state);
}
const enabled = Array.from(this.flags.entries()).filter(
([, state]) => state === FeatureFlagState.Active,
);
window.localStorage.setItem(
'featureFlags',
JSON.stringify(Object.fromEntries(enabled)),
);
}
private load(): Map<string, FeatureFlagState> {
try {
const jsonStr = window.localStorage.getItem('featureFlags');
if (!jsonStr) {
return new Map();
}
const json = JSON.parse(jsonStr) as unknown;
if (typeof json !== 'object' || json === null || Array.isArray(json)) {
return new Map();
}
const entries = Object.entries(json).filter(([name, value]) => {
validateFlagName(name);
return value === FeatureFlagState.Active;
});
return new Map(entries);
} catch {
return new Map();
}
}
}
@@ -1,17 +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.
*/
export { LocalStorageFeatureFlags } from './LocalStorageFeatureFlags';
@@ -1,101 +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 MockOAuthApi from './MockOAuthApi';
import PowerIcon from '@material-ui/icons/Power';
describe('MockOAuthApi', () => {
it('should trigger all requests', async () => {
const authResult = { is: 'done' };
const mock = new MockOAuthApi();
const authHandler1 = jest.fn().mockImplementation(() => authResult);
const requester1 = mock.createAuthRequester({
provider: { icon: PowerIcon, title: 'Test' },
onAuthRequest: authHandler1,
});
const authHandler2 = jest.fn().mockResolvedValue('other');
const requester2 = mock.createAuthRequester({
provider: { icon: PowerIcon, title: 'Test' },
onAuthRequest: authHandler2,
});
const promises = [
requester1(new Set(['a'])),
requester1(new Set(['b'])),
requester2(new Set(['a', 'b'])),
requester2(new Set(['b', 'c'])),
requester2(new Set(['c', 'a'])),
];
await expect(
Promise.race([Promise.all(promises), 'waiting']),
).resolves.toBe('waiting');
await mock.triggerAll();
await expect(Promise.all(promises)).resolves.toEqual([
authResult,
authResult,
'other',
'other',
'other',
]);
expect(authHandler1).toHaveBeenCalledTimes(1);
expect(authHandler1).toHaveBeenCalledWith(new Set(['a', 'b']));
expect(authHandler2).toHaveBeenCalledTimes(1);
expect(authHandler2).toHaveBeenCalledWith(new Set(['a', 'b', 'c']));
});
it('should reject all requests', async () => {
const mock = new MockOAuthApi();
const authHandler1 = jest.fn();
const requester1 = mock.createAuthRequester({
provider: { icon: PowerIcon, title: 'Test' },
onAuthRequest: authHandler1,
});
const authHandler2 = jest.fn();
const requester2 = mock.createAuthRequester({
provider: { icon: PowerIcon, title: 'Test' },
onAuthRequest: authHandler2,
});
const promises = [
requester1(new Set(['a'])),
requester1(new Set(['b'])),
requester2(new Set(['a', 'b'])),
requester2(new Set(['b', 'c'])),
requester2(new Set(['c', 'a'])),
];
await expect(
Promise.race([Promise.all(promises), 'waiting']),
).resolves.toBe('waiting');
await mock.rejectAll();
for (const promise of promises) {
await expect(promise).rejects.toMatchObject({ name: 'RejectedError' });
}
expect(authHandler1).not.toHaveBeenCalled();
expect(authHandler2).not.toHaveBeenCalled();
});
});
@@ -1,55 +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 { OAuthRequestApi, AuthRequesterOptions } from '../../definitions';
import { OAuthRequestManager } from './OAuthRequestManager';
export default class MockOAuthApi implements OAuthRequestApi {
private readonly real = new OAuthRequestManager();
createAuthRequester<T>(options: AuthRequesterOptions<T>) {
return this.real.createAuthRequester(options);
}
authRequest$() {
return this.real.authRequest$();
}
async triggerAll() {
await Promise.resolve(); // Wait a tick to allow new requests to get forwarded
return new Promise<void>(resolve => {
const subscription = this.authRequest$().subscribe(requests => {
subscription.unsubscribe();
Promise.all(requests.map(request => request.trigger())).then(() =>
resolve(),
);
});
});
}
async rejectAll() {
await Promise.resolve(); // Wait a tick to allow new requests to get forwarded
return new Promise<void>(resolve => {
const subscription = this.authRequest$().subscribe(requests => {
subscription.unsubscribe();
requests.map(request => request.reject());
resolve();
});
});
}
}
@@ -1,96 +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 { waitFor } from '@testing-library/react';
import { OAuthPendingRequests } from './OAuthPendingRequests';
describe('OAuthPendingRequests', () => {
it('notifies new observers about current state', async () => {
const target = new OAuthPendingRequests<string>();
const next = jest.fn();
const error = jest.fn();
const input = new Set(['a', 'b']);
target.pending().subscribe({ next, error });
target.request(input);
await waitFor(() => expect(next).toBeCalledTimes(2));
expect(next.mock.calls[0][0].scopes).toBeUndefined();
expect(next.mock.calls[1][0].scopes.toString()).toBe(input.toString());
expect(error.mock.calls.length).toBe(0);
});
it('resolves requests and notifies observers', async () => {
const target = new OAuthPendingRequests<string>();
const next = jest.fn();
const error = jest.fn();
const request1 = target.request(new Set(['a']));
const request2 = target.request(new Set(['a']));
target.pending().subscribe({ next, error });
target.resolve(new Set(['a']), 'session1');
target.resolve(new Set(['a']), 'session2');
await expect(request1).resolves.toBe('session1');
await expect(request2).resolves.toBe('session1');
expect(next).toBeCalledTimes(3); // once on subscription, twice on resolve
expect(error).toBeCalledTimes(0);
});
it('can resolve through the observable', async () => {
const target = new OAuthPendingRequests<string>();
const next = jest.fn(pendingRequest => pendingRequest.resolve('done'));
const error = jest.fn();
const request1 = target.request(new Set(['a']));
target.pending().subscribe({ next, error });
await expect(request1).resolves.toBe('done');
expect(next).toBeCalledTimes(2); // once with data on subscription, once empty after resolution
expect(error).toBeCalledTimes(0);
});
it('rejects requests and notifies observers only once', async () => {
const target = new OAuthPendingRequests<string>();
const next = jest.fn();
const error = jest.fn();
const rejection = new Error('eek');
const request1 = target.request(new Set(['a']));
const request2 = target.request(new Set(['a']));
target.pending().subscribe({ next, error });
target.reject(rejection);
target.resolve(new Set(['a']), 'session');
await expect(request1).rejects.toBe(rejection);
await expect(request2).rejects.toBe(rejection);
expect(next).toBeCalledTimes(3); // once on subscription, once or reject, once on resolve
expect(error).toBeCalledTimes(0);
});
it('can reject through the observable', async () => {
const target = new OAuthPendingRequests<string>();
const rejection = new Error('nope');
const next = jest.fn(pendingRequest => pendingRequest.reject(rejection));
const error = jest.fn();
const request1 = target.request(new Set(['a']));
target.pending().subscribe({ next, error });
await expect(request1).rejects.toBe(rejection);
expect(next).toBeCalledTimes(2);
});
});
@@ -1,126 +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 { BehaviorSubject } from '../../../lib/subjects';
import { Observable } from '../../../types';
type RequestQueueEntry<ResultType> = {
scopes: Set<string>;
resolve: (value: ResultType | PromiseLike<ResultType>) => void;
reject: (reason: Error) => void;
};
export type PendingRequest<ResultType> = {
scopes: Set<string> | undefined;
resolve: (value: ResultType) => void;
reject: (reason: Error) => void;
};
export function hasScopes(
searched: Set<string>,
searchFor: Set<string>,
): boolean {
for (const scope of searchFor) {
if (!searched.has(scope)) {
return false;
}
}
return true;
}
export function joinScopes(
scopes: Set<string>,
...moreScopess: Set<string>[]
): Set<string> {
const result = new Set(scopes);
for (const moreScopes of moreScopess) {
for (const scope of moreScopes) {
result.add(scope);
}
}
return result;
}
/**
* The OAuthPendingRequests class is a utility for managing and observing
* a stream of requests for oauth scopes for a single provider, and resolving
* them correctly once requests are fulfilled.
*/
export class OAuthPendingRequests<ResultType> {
private requests: RequestQueueEntry<ResultType>[] = [];
private subject = new BehaviorSubject<PendingRequest<ResultType>>(
this.getCurrentPending(),
);
request(scopes: Set<string>): Promise<ResultType> {
return new Promise((resolve, reject) => {
this.requests.push({ scopes, resolve, reject });
this.subject.next(this.getCurrentPending());
});
}
resolve(scopes: Set<string>, result: ResultType): void {
this.requests = this.requests.filter(request => {
if (hasScopes(scopes, request.scopes)) {
request.resolve(result);
return false;
}
return true;
});
this.subject.next(this.getCurrentPending());
}
reject(error: Error) {
this.requests.forEach(request => request.reject(error));
this.requests = [];
this.subject.next(this.getCurrentPending());
}
pending(): Observable<PendingRequest<ResultType>> {
return this.subject;
}
private getCurrentPending(): PendingRequest<ResultType> {
const currentScopes =
this.requests.length === 0
? undefined
: this.requests
.slice(1)
.reduce(
(acc, current) => joinScopes(acc, current.scopes),
this.requests[0].scopes,
);
return {
scopes: currentScopes,
resolve: (value: ResultType) => {
if (currentScopes) {
this.resolve(currentScopes, value);
}
},
reject: (reason: Error) => {
if (currentScopes) {
this.reject(reason);
}
},
};
}
}
@@ -1,59 +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 ProviderIcon from '@material-ui/icons/AcUnit';
import { OAuthRequestManager } from './OAuthRequestManager';
describe('OAuthRequestManager', () => {
it('should forward a requests', async () => {
const manager = new OAuthRequestManager();
const reqSpy = jest.fn();
manager.authRequest$().subscribe(reqSpy);
const requester = manager.createAuthRequester({
provider: {
title: 'My Provider',
icon: ProviderIcon,
},
onAuthRequest: async () => 'hello',
});
expect(reqSpy).toHaveBeenCalledTimes(0);
await 'a tick';
expect(reqSpy).toHaveBeenCalledTimes(2);
expect(reqSpy).toHaveBeenLastCalledWith([]);
const req = requester(new Set(['my-scope']));
expect(reqSpy).toHaveBeenCalledTimes(3);
expect(reqSpy).toHaveBeenLastCalledWith([
expect.objectContaining({
reject: expect.any(Function),
trigger: expect.any(Function),
}),
]);
await expect(Promise.race([req, Promise.resolve('not yet')])).resolves.toBe(
'not yet',
);
const [request] = reqSpy.mock.calls[2][0];
request.trigger();
await expect(req).resolves.toBe('hello');
});
});
@@ -1,92 +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 {
OAuthRequestApi,
PendingAuthRequest,
AuthRequester,
AuthRequesterOptions,
} from '../../definitions';
import { OAuthPendingRequests, PendingRequest } from './OAuthPendingRequests';
import { BehaviorSubject } from '../../../lib/subjects';
import { Observable } from '../../../types';
/**
* The OAuthRequestManager is an implementation of the OAuthRequestApi.
*
* The purpose of this class and the API is to read a stream of incoming requests
* of OAuth access tokens from different providers with varying scope, and funnel
* them all together into a single request for each OAuth provider.
*/
export class OAuthRequestManager implements OAuthRequestApi {
private readonly subject = new BehaviorSubject<PendingAuthRequest[]>([]);
private currentRequests: PendingAuthRequest[] = [];
private handlerCount = 0;
createAuthRequester<T>(options: AuthRequesterOptions<T>): AuthRequester<T> {
const handler = new OAuthPendingRequests<T>();
const index = this.handlerCount;
this.handlerCount++;
handler.pending().subscribe({
next: scopeRequest => {
const newRequests = this.currentRequests.slice();
const request = this.makeAuthRequest(scopeRequest, options);
if (!request) {
delete newRequests[index];
} else {
newRequests[index] = request;
}
this.currentRequests = newRequests;
// Convert from sparse array to array of present items only
this.subject.next(newRequests.filter(Boolean));
},
});
return scopes => {
return handler.request(scopes);
};
}
// Converts the pending request and popup options into a popup request that we can forward to subscribers.
private makeAuthRequest(
request: PendingRequest<any>,
options: AuthRequesterOptions<any>,
): PendingAuthRequest | undefined {
const { scopes } = request;
if (!scopes) {
return undefined;
}
return {
provider: options.provider,
trigger: async () => {
const result = await options.onAuthRequest(scopes);
request.resolve(result);
},
reject: () => {
const error = new Error('Login failed, rejected by user');
error.name = 'RejectedError';
request.reject(error);
},
};
}
authRequest$(): Observable<PendingAuthRequest[]> {
return this.subject;
}
}
@@ -1,17 +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.
*/
export { OAuthRequestManager } from './OAuthRequestManager';
@@ -1,173 +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 { WebStorage } from './WebStorage';
import { CreateStorageApiOptions, StorageApi } from '../../definitions';
describe('WebStorage Storage API', () => {
const mockErrorApi = { post: jest.fn(), error$: jest.fn() };
const createWebStorage = (
args?: Partial<CreateStorageApiOptions>,
): StorageApi => {
return WebStorage.create({
errorApi: mockErrorApi,
...args,
});
};
it('should return undefined for values which are unset', async () => {
const storage = createWebStorage();
expect(storage.get('myfakekey')).toBeUndefined();
});
it('should allow the setting and getting of the simple data structures', async () => {
const storage = createWebStorage();
await storage.set('myfakekey', 'helloimastring');
await storage.set('mysecondfakekey', 1234);
await storage.set('mythirdfakekey', true);
expect(storage.get('myfakekey')).toBe('helloimastring');
expect(storage.get('mysecondfakekey')).toBe(1234);
expect(storage.get('mythirdfakekey')).toBe(true);
});
it('should allow setting of complex datastructures', async () => {
const storage = createWebStorage();
const mockData = {
something: 'here',
is: [{ super: { complex: [{ but: 'something', why: true }] } }],
};
await storage.set('myfakekey', mockData);
expect(storage.get('myfakekey')).toEqual(mockData);
});
it('should subscribe to key changes when setting a new value', async () => {
const storage = createWebStorage();
const wrongKeyNextHandler = jest.fn();
const selectedKeyNextHandler = jest.fn();
const mockData = { hello: 'im a great new value' };
await new Promise<void>(resolve => {
storage.observe$<String>('correctKey').subscribe({
next: (...args) => {
selectedKeyNextHandler(...args);
resolve();
},
});
storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler });
storage.set('correctKey', mockData);
});
expect(wrongKeyNextHandler).not.toHaveBeenCalled();
expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1);
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
key: 'correctKey',
newValue: mockData,
});
});
it('should subscribe to key changes when deleting a value', async () => {
const storage = createWebStorage();
const wrongKeyNextHandler = jest.fn();
const selectedKeyNextHandler = jest.fn();
const mockData = { hello: 'im a great new value' };
storage.set('correctKey', mockData);
await new Promise<void>(resolve => {
storage.observe$('correctKey').subscribe({
next: (...args) => {
selectedKeyNextHandler(...args);
resolve();
},
});
storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler });
storage.remove('correctKey');
});
expect(wrongKeyNextHandler).not.toHaveBeenCalled();
expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1);
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
key: 'correctKey',
newValue: undefined,
});
});
it('should be able to create different buckets for different uses', async () => {
const rootStorage = createWebStorage();
const firstStorage = rootStorage.forBucket('userSettings');
const secondStorage = rootStorage.forBucket('profileSettings');
const keyName = 'blobby';
await firstStorage.set(keyName, 'boop');
await secondStorage.set(keyName, 'deerp');
expect(firstStorage.get(keyName)).not.toBe(secondStorage.get(keyName));
expect(firstStorage.get(keyName)).toBe('boop');
expect(secondStorage.get(keyName)).toBe('deerp');
});
it('should not clash with other namesapces when creating buckets', async () => {
const rootStorage = createWebStorage();
// when getting key test2 it will translate to /profile/something/deep/test2
const firstStorage = rootStorage
.forBucket('profile')
.forBucket('something')
.forBucket('deep');
// when getting key deep/test2 it will translate to /profile/something/deep/test2
const secondStorage = rootStorage.forBucket('profile/something');
await firstStorage.set('test2', { error: true });
expect(secondStorage.get('deep/test2')).toBe(undefined);
});
it('should call the error api when the json can not be parsed in local storage', async () => {
const rootStorage = createWebStorage({
namespace: '/Test/Mock/Thing',
});
localStorage.setItem('/Test/Mock/Thing/key', '{smd: asdouindA}');
const value = rootStorage.get('key');
expect(value).toBe(undefined);
expect(mockErrorApi.post).toHaveBeenCalledWith(expect.any(Error));
expect(mockErrorApi.post).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Error when parsing JSON config from storage for: key',
}),
);
});
it('should return a singleton for the same namespace and same bucket', async () => {
const rootStorage = createWebStorage({
namespace: '/Test/Mock/Thing/Thing ',
});
expect(rootStorage.forBucket('test')).toBe(rootStorage.forBucket('test'));
});
});
@@ -1,94 +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 {
StorageApi,
StorageValueChange,
ErrorApi,
CreateStorageApiOptions,
} from '../../definitions';
import { Observable } from '../../../types';
import ObservableImpl from 'zen-observable';
const buckets = new Map<string, WebStorage>();
export class WebStorage implements StorageApi {
constructor(
private readonly namespace: string,
private readonly errorApi: ErrorApi,
) {}
static create(options: CreateStorageApiOptions): WebStorage {
return new WebStorage(options.namespace ?? '', options.errorApi);
}
get<T>(key: string): T | undefined {
try {
const storage = JSON.parse(localStorage.getItem(this.getKeyName(key))!);
return storage ?? undefined;
} catch (e) {
this.errorApi.post(
new Error(`Error when parsing JSON config from storage for: ${key}`),
);
}
return undefined;
}
forBucket(name: string): WebStorage {
const bucketPath = `${this.namespace}/${name}`;
if (!buckets.has(bucketPath)) {
buckets.set(bucketPath, new WebStorage(bucketPath, this.errorApi));
}
return buckets.get(bucketPath)!;
}
async set<T>(key: string, data: T): Promise<void> {
localStorage.setItem(this.getKeyName(key), JSON.stringify(data, null, 2));
this.notifyChanges({ key, newValue: data });
}
async remove(key: string): Promise<void> {
localStorage.removeItem(this.getKeyName(key));
this.notifyChanges({ key, newValue: undefined });
}
observe$<T>(key: string): Observable<StorageValueChange<T>> {
return this.observable.filter(({ key: messageKey }) => messageKey === key);
}
private getKeyName(key: string) {
return `${this.namespace}/${encodeURIComponent(key)}`;
}
private notifyChanges<T>(message: StorageValueChange<T>) {
for (const subscription of this.subscribers) {
subscription.next(message);
}
}
private subscribers = new Set<
ZenObservable.SubscriptionObserver<StorageValueChange>
>();
private readonly observable = new ObservableImpl<StorageValueChange>(
subscriber => {
this.subscribers.add(subscriber);
return () => {
this.subscribers.delete(subscriber);
};
},
);
}
@@ -1,17 +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.
*/
export { WebStorage } from './WebStorage';
@@ -1,46 +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 Auth0Icon from '@material-ui/icons/AcUnit';
import { auth0AuthApiRef } from '../../../definitions/auth';
import { OAuth2 } from '../oauth2';
import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'auth0',
title: 'Auth0',
icon: Auth0Icon,
};
class Auth0Auth {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = ['openid', `email`, `profile`],
}: OAuthApiCreateOptions): typeof auth0AuthApiRef.T {
return OAuth2.create({
discoveryApi,
oauthRequestApi,
provider,
environment,
defaultScopes,
});
}
}
export default Auth0Auth;
@@ -1,17 +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.
*/
export { default as Auth0Auth } from './Auth0Auth';
@@ -1,29 +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 GithubAuth from './GithubAuth';
describe('GithubAuth', () => {
it('should get access token', async () => {
const getSession = jest
.fn()
.mockResolvedValue({ providerInfo: { accessToken: 'access-token' } });
const githubAuth = new GithubAuth({ getSession } as any);
expect(await githubAuth.getAccessToken()).toBe('access-token');
expect(getSession).toBeCalledTimes(1);
});
});
@@ -1,140 +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 GithubIcon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { GithubSession } from './types';
import {
OAuthApi,
SessionApi,
SessionState,
ProfileInfo,
BackstageIdentity,
AuthRequestOptions,
} from '../../../definitions/auth';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import {
AuthSessionStore,
StaticAuthSessionManager,
} from '../../../../lib/AuthSessionManager';
import { Observable } from '../../../../types';
import { OAuthApiCreateOptions } from '../types';
export type GithubAuthResponse = {
providerInfo: {
accessToken: string;
scope: string;
expiresInSeconds: number;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
id: 'github',
title: 'GitHub',
icon: GithubIcon,
};
class GithubAuth implements OAuthApi, SessionApi {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = ['read:user'],
}: OAuthApiCreateOptions) {
const connector = new DefaultAuthConnector({
discoveryApi,
environment,
provider,
oauthRequestApi: oauthRequestApi,
sessionTransform(res: GithubAuthResponse): GithubSession {
return {
...res,
providerInfo: {
accessToken: res.providerInfo.accessToken,
scopes: GithubAuth.normalizeScope(res.providerInfo.scope),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
},
};
},
});
const sessionManager = new StaticAuthSessionManager({
connector,
defaultScopes: new Set(defaultScopes),
sessionScopes: (session: GithubSession) => session.providerInfo.scopes,
});
const authSessionStore = new AuthSessionStore<GithubSession>({
manager: sessionManager,
storageKey: `${provider.id}Session`,
sessionScopes: (session: GithubSession) => session.providerInfo.scopes,
});
return new GithubAuth(authSessionStore);
}
constructor(private readonly sessionManager: SessionManager<GithubSession>) {}
async signIn() {
await this.getAccessToken();
}
async signOut() {
await this.sessionManager.removeSession();
}
sessionState$(): Observable<SessionState> {
return this.sessionManager.sessionState$();
}
async getAccessToken(scope?: string, options?: AuthRequestOptions) {
const session = await this.sessionManager.getSession({
...options,
scopes: GithubAuth.normalizeScope(scope),
});
return session?.providerInfo.accessToken ?? '';
}
async getBackstageIdentity(
options: AuthRequestOptions = {},
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
static normalizeScope(scope?: string): Set<string> {
if (!scope) {
return new Set();
}
const scopeList = Array.isArray(scope)
? scope
: scope.split(/[\s|,]/).filter(Boolean);
return new Set(scopeList);
}
}
export default GithubAuth;
@@ -1,18 +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.
*/
export * from './types';
export { default as GithubAuth } from './GithubAuth';
@@ -1,27 +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 { BackstageIdentity, ProfileInfo } from '../../../definitions';
export type GithubSession = {
providerInfo: {
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
@@ -1,50 +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 MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi';
import { UrlPatternDiscovery } from '../../DiscoveryApi';
import GitlabAuth from './GitlabAuth';
const getSession = jest.fn();
jest.mock('../../../../lib/AuthSessionManager', () => ({
...(jest.requireActual('../../../../lib/AuthSessionManager') as any),
RefreshingAuthSessionManager: class {
getSession = getSession;
},
}));
describe('GitlabAuth', () => {
afterEach(() => {
jest.resetAllMocks();
});
it.each([
[
'read_user api write_repository',
['read_user', 'api', 'write_repository'],
],
['read_repository sudo', ['read_repository', 'sudo']],
])(`should normalize scopes correctly - %p`, (scope, scopes) => {
const gitlabAuth = GitlabAuth.create({
oauthRequestApi: new MockOAuthApi(),
discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
});
gitlabAuth.getAccessToken(scope);
expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) });
});
});
@@ -1,45 +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 GitlabIcon from '@material-ui/icons/AcUnit';
import { gitlabAuthApiRef } from '../../../definitions/auth';
import { OAuth2 } from '../oauth2';
import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'gitlab',
title: 'GitLab',
icon: GitlabIcon,
};
class GitlabAuth {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = ['read_user'],
}: OAuthApiCreateOptions): typeof gitlabAuthApiRef.T {
return OAuth2.create({
discoveryApi,
oauthRequestApi,
provider,
environment,
defaultScopes,
});
}
}
export default GitlabAuth;
@@ -1,17 +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.
*/
export { default as GitlabAuth } from './GitlabAuth';
@@ -1,69 +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 GoogleAuth from './GoogleAuth';
import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi';
import { UrlPatternDiscovery } from '../../DiscoveryApi';
const PREFIX = 'https://www.googleapis.com/auth/';
const getSession = jest.fn();
jest.mock('../../../../lib/AuthSessionManager', () => ({
...(jest.requireActual('../../../../lib/AuthSessionManager') as any),
RefreshingAuthSessionManager: class {
getSession = getSession;
},
}));
describe('GoogleAuth', () => {
afterEach(() => {
jest.resetAllMocks();
});
it.each([
['email', [`${PREFIX}userinfo.email`]],
['profile', [`${PREFIX}userinfo.profile`]],
['openid', ['openid']],
['userinfo.email', [`${PREFIX}userinfo.email`]],
[
'userinfo.profile email',
[`${PREFIX}userinfo.profile`, `${PREFIX}userinfo.email`],
],
[
`profile ${PREFIX}userinfo.email`,
[`${PREFIX}userinfo.profile`, `${PREFIX}userinfo.email`],
],
[`${PREFIX}userinfo.profile`, [`${PREFIX}userinfo.profile`]],
['a', [`${PREFIX}a`]],
['a b\tc', [`${PREFIX}a`, `${PREFIX}b`, `${PREFIX}c`]],
[`${PREFIX}a b`, [`${PREFIX}a`, `${PREFIX}b`]],
[`${PREFIX}a`, [`${PREFIX}a`]],
// Some incorrect scopes that we don't try to fix
[`${PREFIX}email`, [`${PREFIX}email`]],
[`${PREFIX}profile`, [`${PREFIX}profile`]],
[`${PREFIX}openid`, [`${PREFIX}openid`]],
])(`should normalize scopes correctly - %p`, (scope, scopes) => {
const googleAuth = GoogleAuth.create({
oauthRequestApi: new MockOAuthApi(),
discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
});
googleAuth.getAccessToken(scope);
expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) });
});
});
@@ -1,68 +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 GoogleIcon from '@material-ui/icons/AcUnit';
import { googleAuthApiRef } from '../../../definitions/auth';
import { OAuth2 } from '../oauth2';
import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'google',
title: 'Google',
icon: GoogleIcon,
};
const SCOPE_PREFIX = 'https://www.googleapis.com/auth/';
class GoogleAuth {
static create({
discoveryApi,
oauthRequestApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
defaultScopes = [
'openid',
`${SCOPE_PREFIX}userinfo.email`,
`${SCOPE_PREFIX}userinfo.profile`,
],
}: OAuthApiCreateOptions): typeof googleAuthApiRef.T {
return OAuth2.create({
discoveryApi,
oauthRequestApi,
provider,
environment,
defaultScopes,
scopeTransform(scopes: string[]) {
return scopes.map(scope => {
if (scope === 'openid') {
return scope;
}
if (scope === 'profile' || scope === 'email') {
return `${SCOPE_PREFIX}userinfo.${scope}`;
}
if (scope.startsWith(SCOPE_PREFIX)) {
return scope;
}
return `${SCOPE_PREFIX}${scope}`;
});
},
});
}
}
export default GoogleAuth;
@@ -1,17 +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.
*/
export { default as GoogleAuth } from './GoogleAuth';
@@ -1,25 +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.
*/
export * from './github';
export * from './gitlab';
export * from './google';
export * from './oauth2';
export * from './okta';
export * from './saml';
export * from './auth0';
export * from './microsoft';
export * from './onelogin';
@@ -1,52 +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 MicrosoftIcon from '@material-ui/icons/AcUnit';
import { microsoftAuthApiRef } from '../../../definitions/auth';
import { OAuth2 } from '../oauth2';
import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'microsoft',
title: 'Microsoft',
icon: MicrosoftIcon,
};
class MicrosoftAuth {
static create({
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
discoveryApi,
defaultScopes = [
'openid',
'offline_access',
'profile',
'email',
'User.Read',
],
}: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T {
return OAuth2.create({
discoveryApi,
oauthRequestApi,
provider,
environment,
defaultScopes,
});
}
}
export default MicrosoftAuth;
@@ -1,17 +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.
*/
export { default as MicrosoftAuth } from './MicrosoftAuth';
@@ -1,152 +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 OAuth2 from './OAuth2';
const theFuture = new Date(Date.now() + 3600000);
const thePast = new Date(Date.now() - 10);
const PREFIX = 'https://www.googleapis.com/auth/';
const scopeTransform = (x: string[]) => x;
describe('OAuth2', () => {
it('should get refreshed access token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { accessToken: 'access-token', expiresAt: theFuture },
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
});
expect(await oauth2.getAccessToken('my-scope my-scope2')).toBe(
'access-token',
);
expect(getSession).toBeCalledTimes(1);
expect(getSession.mock.calls[0][0].scopes).toEqual(
new Set(['my-scope', 'my-scope2']),
);
});
it('should transform scopes', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { accessToken: 'access-token', expiresAt: theFuture },
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform: scopes => scopes.map(scope => `my-prefix/${scope}`),
});
expect(await oauth2.getAccessToken('my-scope')).toBe('access-token');
expect(getSession).toBeCalledTimes(1);
expect(getSession.mock.calls[0][0].scopes).toEqual(
new Set(['my-prefix/my-scope']),
);
});
it('should get refreshed id token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
});
expect(await oauth2.getIdToken()).toBe('id-token');
expect(getSession).toBeCalledTimes(1);
});
it('should get optional id token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
});
expect(await oauth2.getIdToken({ optional: true })).toBe('id-token');
expect(getSession).toBeCalledTimes(1);
});
it('should share popup closed errors', async () => {
const error = new Error('NOPE');
error.name = 'RejectedError';
const getSession = jest
.fn()
.mockResolvedValueOnce({
providerInfo: {
accessToken: 'access-token',
expiresAt: theFuture,
scopes: new Set([`${PREFIX}not-enough`]),
},
})
.mockRejectedValue(error);
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
});
// Make sure we have a session before we do the double request, so that we get past the !this.currentSession check
await expect(oauth2.getAccessToken()).resolves.toBe('access-token');
const promise1 = oauth2.getAccessToken('more');
const promise2 = oauth2.getAccessToken('more');
await expect(promise1).rejects.toBe(error);
await expect(promise2).rejects.toBe(error);
expect(getSession).toBeCalledTimes(3);
});
it('should wait for all session refreshes', async () => {
const initialSession = {
providerInfo: {
idToken: 'token1',
expiresAt: theFuture,
scopes: new Set(),
},
};
const getSession = jest
.fn()
.mockResolvedValueOnce(initialSession)
.mockResolvedValue({
providerInfo: {
idToken: 'token2',
expiresAt: theFuture,
scopes: new Set(),
},
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
});
// Grab the expired session first
await expect(oauth2.getIdToken()).resolves.toBe('token1');
expect(getSession).toBeCalledTimes(1);
initialSession.providerInfo.expiresAt = thePast;
const promise1 = oauth2.getIdToken();
const promise2 = oauth2.getIdToken();
const promise3 = oauth2.getIdToken();
await expect(promise1).resolves.toBe('token2');
await expect(promise2).resolves.toBe('token2');
await expect(promise3).resolves.toBe('token2');
expect(getSession).toBeCalledTimes(4); // De-duping of session requests happens in client
});
});
@@ -1,179 +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 OAuth2Icon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { Observable } from '../../../../types';
import {
AuthRequestOptions,
BackstageIdentity,
OAuthApi,
OpenIdConnectApi,
ProfileInfo,
ProfileInfoApi,
SessionState,
SessionApi,
BackstageIdentityApi,
} from '../../../definitions/auth';
import { OAuth2Session } from './types';
import { OAuthApiCreateOptions } from '../types';
type Options = {
sessionManager: SessionManager<OAuth2Session>;
scopeTransform: (scopes: string[]) => string[];
};
type CreateOptions = OAuthApiCreateOptions & {
scopeTransform?: (scopes: string[]) => string[];
};
export type OAuth2Response = {
providerInfo: {
accessToken: string;
idToken: string;
scope: string;
expiresInSeconds: number;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
id: 'oauth2',
title: 'Your Identity Provider',
icon: OAuth2Icon,
};
class OAuth2
implements
OAuthApi,
OpenIdConnectApi,
ProfileInfoApi,
BackstageIdentityApi,
SessionApi {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = [],
scopeTransform = x => x,
}: CreateOptions) {
const connector = new DefaultAuthConnector({
discoveryApi,
environment,
provider,
oauthRequestApi: oauthRequestApi,
sessionTransform(res: OAuth2Response): OAuth2Session {
return {
...res,
providerInfo: {
idToken: res.providerInfo.idToken,
accessToken: res.providerInfo.accessToken,
scopes: OAuth2.normalizeScopes(
scopeTransform,
res.providerInfo.scope,
),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
},
};
},
});
const sessionManager = new RefreshingAuthSessionManager({
connector,
defaultScopes: new Set(defaultScopes),
sessionScopes: (session: OAuth2Session) => session.providerInfo.scopes,
sessionShouldRefresh: (session: OAuth2Session) => {
const expiresInSec =
(session.providerInfo.expiresAt.getTime() - Date.now()) / 1000;
return expiresInSec < 60 * 5;
},
});
return new OAuth2({ sessionManager, scopeTransform });
}
private readonly sessionManager: SessionManager<OAuth2Session>;
private readonly scopeTransform: (scopes: string[]) => string[];
constructor(options: Options) {
this.sessionManager = options.sessionManager;
this.scopeTransform = options.scopeTransform;
}
async signIn() {
await this.getAccessToken();
}
async signOut() {
await this.sessionManager.removeSession();
}
sessionState$(): Observable<SessionState> {
return this.sessionManager.sessionState$();
}
async getAccessToken(
scope?: string | string[],
options?: AuthRequestOptions,
) {
const normalizedScopes = OAuth2.normalizeScopes(this.scopeTransform, scope);
const session = await this.sessionManager.getSession({
...options,
scopes: normalizedScopes,
});
return session?.providerInfo.accessToken ?? '';
}
async getIdToken(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.providerInfo.idToken ?? '';
}
async getBackstageIdentity(
options: AuthRequestOptions = {},
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
private static normalizeScopes(
scopeTransform: (scopes: string[]) => string[],
scopes?: string | string[],
): Set<string> {
if (!scopes) {
return new Set();
}
const scopeList = Array.isArray(scopes)
? scopes
: scopes.split(/[\s|,]/).filter(Boolean);
return new Set(scopeTransform(scopeList));
}
}
export default OAuth2;
@@ -1,18 +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.
*/
export { default as OAuth2 } from './OAuth2';
export * from './types';
@@ -1,28 +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 { ProfileInfo, BackstageIdentity } from '../../../definitions';
export type OAuth2Session = {
providerInfo: {
idToken: string;
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
@@ -1,61 +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 OktaAuth from './OktaAuth';
import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi';
import { UrlPatternDiscovery } from '../../DiscoveryApi';
const PREFIX = 'okta.';
const getSession = jest.fn();
jest.mock('../../../../lib/AuthSessionManager', () => ({
...(jest.requireActual('../../../../lib/AuthSessionManager') as any),
RefreshingAuthSessionManager: class {
getSession = getSession;
},
}));
describe('OktaAuth', () => {
afterEach(() => {
jest.resetAllMocks();
});
it.each([
['openid', ['openid']],
['profile email', ['profile', 'email']],
[`${PREFIX}groups.manage`, [`${PREFIX}groups.manage`]],
['groups.read', [`${PREFIX}groups.read`]],
[
`${PREFIX}groups.manage groups.read, openid`,
[`${PREFIX}groups.manage`, `${PREFIX}groups.read`, 'openid'],
],
[`email\t ${PREFIX}groups.read`, ['email', `${PREFIX}groups.read`]],
// Some incorrect scopes that we don't try to fix
[`${PREFIX}email`, [`${PREFIX}email`]],
[`${PREFIX}profile`, [`${PREFIX}profile`]],
[`${PREFIX}openid`, [`${PREFIX}openid`]],
])(`should normalize scopes correctly - %p`, (scope, scopes) => {
const auth = OktaAuth.create({
oauthRequestApi: new MockOAuthApi(),
discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
});
auth.getAccessToken(scope);
expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) });
});
});
@@ -1,71 +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 OktaIcon from '@material-ui/icons/AcUnit';
import { oktaAuthApiRef } from '../../../definitions/auth';
import { OAuth2 } from '../oauth2';
import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'okta',
title: 'Okta',
icon: OktaIcon,
};
const OKTA_OIDC_SCOPES: Set<String> = new Set([
'openid',
'profile',
'email',
'phone',
'address',
'groups',
'offline_access',
]);
const OKTA_SCOPE_PREFIX: string = 'okta.';
class OktaAuth {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = ['openid', 'email', 'profile', 'offline_access'],
}: OAuthApiCreateOptions): typeof oktaAuthApiRef.T {
return OAuth2.create({
discoveryApi,
oauthRequestApi,
provider,
environment,
defaultScopes,
scopeTransform(scopes) {
return scopes.map(scope => {
if (OKTA_OIDC_SCOPES.has(scope)) {
return scope;
}
if (scope.startsWith(OKTA_SCOPE_PREFIX)) {
return scope;
}
return `${OKTA_SCOPE_PREFIX}${scope}`;
});
},
});
}
}
export default OktaAuth;
@@ -1,17 +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.
*/
export { default as OktaAuth } from './OktaAuth';
@@ -1,82 +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 OneLoginIcon from '@material-ui/icons/AcUnit';
import { oneloginAuthApiRef } from '../../../definitions/auth';
import {
OAuthRequestApi,
AuthProvider,
DiscoveryApi,
} from '../../../definitions';
import { OAuth2 } from '../oauth2';
type CreateOptions = {
discoveryApi: DiscoveryApi;
oauthRequestApi: OAuthRequestApi;
environment?: string;
provider?: AuthProvider & { id: string };
};
const DEFAULT_PROVIDER = {
id: 'onelogin',
title: 'onelogin',
icon: OneLoginIcon,
};
const OIDC_SCOPES: Set<String> = new Set([
'openid',
'profile',
'email',
'phone',
'address',
'groups',
'offline_access',
]);
const SCOPE_PREFIX: string = 'onelogin.';
class OneLoginAuth {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
}: CreateOptions): typeof oneloginAuthApiRef.T {
return OAuth2.create({
discoveryApi,
oauthRequestApi,
provider,
environment,
defaultScopes: ['openid', 'email', 'profile', 'offline_access'],
scopeTransform(scopes) {
return scopes.map(scope => {
if (OIDC_SCOPES.has(scope)) {
return scope;
}
if (scope.startsWith(SCOPE_PREFIX)) {
return scope;
}
return `${SCOPE_PREFIX}${scope}`;
});
},
});
}
}
export default OneLoginAuth;
@@ -1,17 +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.
*/
export { default as OneLoginAuth } from './OneLoginAuth';
@@ -1,98 +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 SamlIcon from '@material-ui/icons/AcUnit';
import { DirectAuthConnector } from '../../../../lib/AuthConnector';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { Observable } from '../../../../types';
import {
ProfileInfo,
BackstageIdentity,
SessionState,
AuthRequestOptions,
ProfileInfoApi,
BackstageIdentityApi,
SessionApi,
} from '../../../definitions/auth';
import { SamlSession } from './types';
import {
AuthSessionStore,
StaticAuthSessionManager,
} from '../../../../lib/AuthSessionManager';
import { AuthApiCreateOptions } from '../types';
export type SamlAuthResponse = {
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
id: 'saml',
title: 'SAML',
icon: SamlIcon,
};
class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
}: AuthApiCreateOptions) {
const connector = new DirectAuthConnector<SamlSession>({
discoveryApi,
environment,
provider,
});
const sessionManager = new StaticAuthSessionManager<SamlSession>({
connector,
});
const authSessionStore = new AuthSessionStore<SamlSession>({
manager: sessionManager,
storageKey: `${provider.id}Session`,
});
return new SamlAuth(authSessionStore);
}
sessionState$(): Observable<SessionState> {
return this.sessionManager.sessionState$();
}
constructor(private readonly sessionManager: SessionManager<SamlSession>) {}
async signIn() {
await this.getBackstageIdentity({});
}
async signOut() {
await this.sessionManager.removeSession();
}
async getBackstageIdentity(
options: AuthRequestOptions = {},
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
}
export default SamlAuth;
@@ -1,16 +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.
*/
export { default as SamlAuth } from './SamlAuth';
@@ -1,22 +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 { ProfileInfo, BackstageIdentity } from '../../../definitions';
export type SamlSession = {
userId: string;
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
@@ -1,28 +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 { AuthProvider, DiscoveryApi, OAuthRequestApi } from '../../definitions';
export type OAuthApiCreateOptions = AuthApiCreateOptions & {
oauthRequestApi: OAuthRequestApi;
defaultScopes?: string[];
};
export type AuthApiCreateOptions = {
discoveryApi: DiscoveryApi;
environment?: string;
provider?: AuthProvider & { id: string };
};
@@ -1,30 +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.
*/
// This folder contains implementations for all core APIs.
//
// Plugins should rely on these APIs for functionality as much as possible.
export * from './auth';
export * from './AlertApi';
export * from './AppThemeApi';
export * from './ConfigApi';
export * from './DiscoveryApi';
export * from './ErrorApi';
export * from './FeatureFlagsApi';
export * from './OAuthRequestApi';
export * from './StorageApi';
-19
View File
@@ -1,19 +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.
*/
export * from './system';
export * from './definitions';
export * from './implementations';
@@ -1,46 +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 { ApiAggregator } from './ApiAggregator';
import { createApiRef } from './ApiRef';
import { ApiRegistry } from './ApiRegistry';
describe('ApiAggregator', () => {
const apiARef = createApiRef<number>({ id: 'a', description: '' });
const apiBRef = createApiRef<number>({ id: 'b', description: '' });
it('should forward implementations', () => {
const agg = new ApiAggregator(
ApiRegistry.from([
[apiARef, 5],
[apiBRef, 10],
]),
);
expect(agg.get(apiARef)).toBe(5);
expect(agg.get(apiBRef)).toBe(10);
});
it('should return the first implementation', () => {
const agg = new ApiAggregator(
ApiRegistry.from([
[apiARef, 1],
[apiARef, 2],
]),
);
expect(agg.get(apiARef)).toBe(2);
expect(agg.get(apiBRef)).toBe(undefined);
});
});

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