Merge branch 'master' of github.com:spotify/backstage into shmidt-i/backend-hmr-2

This commit is contained in:
Ivan Shmidt
2020-06-15 15:46:23 +02:00
211 changed files with 4781 additions and 1562 deletions
+1 -1
View File
@@ -61,5 +61,5 @@ jobs:
run: yarn test:e2e:ci
working-directory: ${{ runner.temp }}/test-app/packages/app
env:
PORT: 3001
APP_CONFIG_app_baseUrl: '"http://localhost:3001"'
BACKSTAGE_E2E_CLI_TEST: true
+1 -1
View File
@@ -64,5 +64,5 @@ jobs:
run: yarn test:e2e:ci
working-directory: ${{ runner.temp }}/test-app/packages/app
env:
PORT: 3001
APP_CONFIG_app_baseUrl: '"http://localhost:3001"'
BACKSTAGE_E2E_CLI_TEST: true
+1 -1
View File
@@ -68,4 +68,4 @@ This project adheres to the [Spotify FOSS Code of Conduct][code-of-conduct]. By
# Security Issues?
Please report sensitive security issues via Spotify's [bug-bounty program](https://hackerone.com/spotify) rather than GitHub.
See [SECURITY](SECURITY.md).
+11
View File
@@ -0,0 +1,11 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 0.x | :white_check_mark: |
## Reporting a Vulnerability
Please report sensitive security issues via Spotify's [bug-bounty program](https://hackerone.com/spotify) rather than GitHub.
+1 -1
View File
@@ -22,7 +22,7 @@ function inject_config() {
>&2 echo "Runtime app config: $config"
local main_js
main_js="$(ls /usr/share/nginx/html/main.*.chunk.js)"
main_js="$(grep -l __APP_INJECTED_RUNTIME_CONFIG__ /usr/share/nginx/html/*.chunk.js)"
echo "Writing runtime config to ${main_js}"
# escape ' and " twice, for both sed and json
+4 -4
View File
@@ -1,10 +1,10 @@
# Documentation
Check out <https://backstage.io> or see the table of content below.
Check out <https://backstage.io> or see the table of contents below.
- [Architecture and terminology](architecture-terminology.md)
- [Getting started](getting-started/README.md)
- [Architecture and Terminology](architecture-terminology.md)
- [Getting Started](getting-started/README.md)
- [References](reference/README.md)
- [Publishing](publishing.md)
- [Designing for Backstage](design.md)
- [How to add an auth provider](auth/add-auth-provider.md)
- [How to Add an Auth Provider](auth/add-auth-provider.md)
+57 -2
View File
@@ -1,5 +1,60 @@
# Getting started with Backstage
## Running Backstage Locally
To get up and running with a local Backstage to evaluate it, let's clone it off
of GitHub and run an initial build. First make sure that you have at least node
version 12 installed locally.
```bash
# Start from your local development folder
git clone git@github.com:spotify/backstage.git
cd backstage
# Fetch our dependencies and run an initial build
yarn install
yarn tsc
yarn build
```
Phew! Now you have a local repository that's ready to run and to add any open
source contributions into.
We are now going to launch two things: an example Backstage frontend app, and an
example Backstage backend that the frontend talks to. You are going to need two
terminal windows, both starting from the Backstage project root.
In the first window, run
```bash
cd packages/backend
yarn start
```
That starts up a backend instance on port 7000.
In the other window, we will first populate the catalog with some nice mock data
to look at, and then launch the frontend. These commands are run from the
project root, not inside the backend directory.
```bash
yarn lerna run mock-catalog-data
yarn start
```
That starts up the frontend on port 3000, and should automatically open a
browser window showing it.
Congratulations! That should be it. Let us know how it went
[on discord](https://discord.gg/EBHEGzX), file issues for any
[feature](https://github.com/spotify/backstage/issues/new?labels=help+wanted&template=feature_template.md)
or
[plugin suggestions](https://github.com/spotify/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME),
or
[bugs](https://github.com/spotify/backstage/issues/new?labels=bug&template=bug_template.md)
you have, and feel free to
[contribute](https://github.com/spotify/backstage/blob/master/CONTRIBUTING.md)!
## Creating a Plugin
The value of Backstage grows with every new plugin that gets added. Here is a
@@ -7,8 +62,8 @@ collection of tutorials that will guide you through setting up and extending an
instance of Backstage with your own plugins.
- [Development Environment](development-environment.md)
- [Create a Backstage plugin](create-a-plugin.md)
- [Structure of a plugin](structure-of-a-plugin.md)
- [Create a Backstage Plugin](create-a-plugin.md)
- [Structure of a Plugin](structure-of-a-plugin.md)
- [Utility APIs](utility-apis.md)
- Using Backstage components (TODO)
+2 -2
View File
@@ -1,8 +1,8 @@
# Backstage Plugins
# Create a Backstage Plugin
A Backstage Plugin adds functionality to Backstage.
## Create a plugin
## Create a Plugin
To create a new plugin, make sure you've run `yarn install` and installed
dependencies, then run the following on your command line (invoking the
@@ -1,4 +1,4 @@
# Structure of a newly created plugin
# Structure of a Plugin
Nice, you have a new plugin! We'll soon see how we can develop it into doing
great things. But first off, let's look at what we get out of the box.
+273
View File
@@ -0,0 +1,273 @@
# Purpose
This RFC describes a possible journey of a future Backstage plugin developer as
they build a plugin that touches many different aspects of a Backstage. The
story invents many new things that are not part of Backstage today, but are
things that I'm suggesting we should add as long term or north star goals. The
idea is to discuss what parts of the story makes sense to aim for, and what we'd
want to do differently or not at all. The "chapters" are numbered to make it a
bit easier to comment on parts of the story.
# The Protagonist
Sam is an experienced developer that has worked with Backstage for a while, and
knows the best practices and tools available to build plugins. Sam also likes
music and wants to have a theme tune for every service in Backstage.
# The End
Sam built a Spotify plugin for Backstage that allows service owners to define a
theme tune for their service. The theme tune plays whenever a user visits the
service page in Backstage. The plugin is published to NPM and available for any
organization to easily install and add to their Backstage installation.
# 1. A New Plugin
Sam chooses to develop this plugin in a standalone project and creates a new
plugin using `npx @backstage/cli create-plugin`, which detects that it's not
being run in an existing project and therefore creates a separate plugin repo.
Spinning up the frontend with `yarn start`, Sam goes to work with getting the
base functionality of a Spotify web player going. By installing a couple of
dependencies and whipping together a nice UI, the player is pretty much done.
# 2. The Auth Menace
Sam realizes users need to be authenticated towards the Spotify API to be able
to play music, and Backstage doesn't support Spotify login yet. Sam adds the
`@backstage/plugin-auth-backend` as local development middleware in the project,
and provides the necessary wrapping logic and configuration for the
`passport-spotify` strategy. The Spotify auth provider is now available in the
local development backend, and by adding a frontend `SpotifyAuth` Utility API
that implements the `OAuthApi` type, it's now working in the frontend too.
```ts
const spotifyAuthApiRef = createApiRef<OAuthApi>({
id: 'core.auth.spotify',
description: 'Provides authentication towards Spotify APIs',
});
```
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.
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
list of required APIs, as well as listing it in the requirements section in the
README.
```md
## Requirements
This plugin requires the following APIs to function:
- `spotifyAuthApiRef` from `@backstage/core@^1.1.0`
```
# 3. The Catalog Awakens
Sam now has a working player and a method for users to log in to listen to
music, but the goal is to provide theme songs for services. Sam adds this
functionality by defining a new metadata annotation called
`sam.wise/spotify-track-id`. The annotation's value is a Spotify track ID and
can be defined in a component like this:
```yaml
apiVersion: backstage.io/v1
kind: Component
metadata:
name: my-component
annotations:
sam.wise/spotify-track-id: '4uLU6hMCjMI75M1A2tKUQC'
spec:
type: service
```
Sam creates a JSON schema that documents the annotation and allows it to be used
in validation and documentation for organizations that choose to adopt the
plugin.
```json
{
"sam.wise/spotify-track-id": {
"$id": "https://raw.githubusercontent.com/sam/backstage-spotify-theme/master/annotation.json#/sam.wise/spotify-track-id",
"type": "string",
"title": "Spotify Track Annotation",
"description": "Spotify track ID to associated with the entity",
"examples": ["4uLU6hMCjMI75M1A2tKUQC"]
}
}
```
# 4. The Rise of Widgets
Sam also wraps the music player in an entity page widget. This allows anyone
that wants to use the plugin to add the player to any of their entity layout
templates, which will make it show up for every entity of that kind.
```tsx
export const PlayerWidget = plugin.createEntityWidget({
component: WebPlayer,
locations: ['header', 'card', 'footer'],
cardSize: [2, 4],
});
```
The widget receives information about the entity in whose page it's being
embedded, which makes it simple to grab the track id from the annotations and
hook up the player.
Sam also modifies the standalone plugin development setup to include this new
widget inside a basic entity page, adding it to a couple of different places
where users of the plugin may want to put the player, just to make sure they all
work.
# 5. The First User
At this point the only things that anyone that wants to use Sam's plugin needs
to do is to add
https://raw.githubusercontent.com/sam/backstage-spotify-theme/master/annotation.json#/sam.wise/spotify-track-id
to their catalog schema, import and add the `PlayerWidget` on the desired entity
template pages, and make sure they're providing Spotify auth.
Sam soon sees the first "Used by" show up on GitHub, and feedback starts rolling
in. Users really like the plugin, and some a requesting the possibility to
select a theme tune when creating a new component. Sam jumps on the idea and
adds a new creation hook that is exported by the plugin. The hook can be
installed either in a single, all, or component templates that match a label. It
adds a field as a part of the component creation process with a nice search box
that allow users to search for a track that they want to use as the theme tune.
# 6. Return to the Repo
Sam is pretty content at this point, but would like to make it easier for users
to change the track after creation, preferable using the same search box that
was made for the creation form. By adding an edit button to the `PlayerWidget`,
and a nice empty state, Sam is able to provide the appropriate hooks in the GUI
to open up a search dialog.
To save the selected track, Sam uses the `RepoApi` to suggest a change to the
entity definition file. This will create a Pull Request for organizations that
use GitHub, a Merge Request for users of GitLab, and so on.
```ts
const repoApi = useApi(repoApiRef);
const alertApi = useApi(alertApiRef);
const onSave = async () => {
const { url } = await repoApi.createChangeRequest({
title: `Change theme tune to ${track.title} by ${track.artist}`,
changes: [
{
path: entityYamlPath,
content: newEntityYamlContent,
},
],
});
alertApi.post({ message: `Requested change, ${url}` });
};
```
Now it's much simpler for users to change the theme tune, as they no longer need
to go look up a track ID and edit a yaml file. Instead, they can now stay inside
Backstage and search for the track and request the change from there. In
addition, the requested change can be reviewed by the regular process of each
organization.
# 7. The User Strikes Back
Sam's plugin is pretty popular at this point, and has been picked up and used by
many organizations. But some users start voicing concerns that they have too
many different hand-crafted annotations in their entity descriptions, and would
like to be able to avoid some of them. They really like the theme tunes though,
and wish they could keep them without having to put them in the entity
description, even if that means it won't go through the regular source control
review process.
One day Sam receives a Pull Request for the plugin. It adds an option to use the
Database provided by `@backstage/backend-common` to store the track ID. It's all
packaged into a new backend plugin that will also extend the catalog backend
with schema and functionality to automatically load the value of the
`sam.wise/spotify-track-id` annotation from the backend plugin and database. The
backend plugin also extends the common GraphQL schema with a mutation that
updates the track ID in the database.
On the frontend the Pull Request doesn't change much. It defines the save action
the was previously using the `RepoApi` in it's own API.
```ts
type ThemeTuneStorageApi = {
save(entity: Entity, trackId: string): Promise<void>;
};
```
The plugin also provides two different implementations of the API, one that uses
the old behavior of the `RepoApi`, and a new one that calls the `GraphQL` API.
The new API relies on the `IdentityApi` as a mechanism for authorizing changes,
instead of source control reviews. The `IdentityApi` provides a token that is
included in the request to the backend, which then must match the owner of the
component for which the user is trying to change the theme tune.
> Author breaking the 4th wall here. I actually think every GraphQL request
> should include the ID token of the user, but invented a reason to include it
> here anyway.
The API is selected based on a configuration parameter for the plugin, but
defaults to the original `RepoApi` behavior.
```ts
if (config.getBoolean('storeTrackInDatabase')) {
return new GraphQLThemeTuneStore(graphqlClient, identityApi, alertApi);
} else {
return new RepoThemeTuneStore(repoApi, alertApi);
}
```
Sam is amazed by the pure awesomeness of this change, replies with a "👍" and
hits merge.
# 8. Attack of the Clones
Sam just released v1.8.4 of the plugin, and at this point it's so popular that a
couple of other plugins has started depending on the `sam.wise/spotify-track-id`
annotation. One such plugin being the `spotify-album-art` plugin that can
display the album art of the theme tune as the background of the entity header.
Sam thinks it's all pretty cool, but doesn't like that the annotation that was
once an internal concern of the plugin is now becoming a standard in the
community.
In order to standardize the annotation in Backstage, Sam submits a Pull Request
to the Backstage Core repo. The request suggest a new well-known metadata
annotation called `spotify.com/track-id`, with the same schema definition as
Sam's label, and refers to Sam's own plugin and the `spotify-album-art` plugin
as existing usages. The Backstage maintainers merge the Pull Request, after
checking with the folks over at Spotify that they're cool with the annotation,
and faffing about over some minor grammar mistake in the annotation description.
With the annotation now available inside Backstage Core, Sam releases v2 of the
plugin, which uses the new annotation. It can still consume the old annotation
for backwards compatibility, but new users of the plugin no longer need to add
the
https://raw.githubusercontent.com/sam/backstage-spotify-theme/master/annotation.json#/sam.wise/spotify-track-id
extension to their catalog schema, as it's now part of the core schema. The new
release of Sam's plugin specifies a dependency on Backstage with a minimum
version set to the same release as the one were the annotation was added to the
core schema.
<details>
<summary></summary>
# 9. Revenge of the Sam
Sam, now in full control of all theme tunes in Backstage, releases v2.0.1, which
switches all tracks to 4uLU6hMCjMI75M1A2tKUQC. Sam wanted to do something more
nefarious, but since Backstage sandboxes sensitive actions and is mostly
read-only with strict CSP, Sam's hands were tied.
</details>
+1 -1
View File
@@ -2,5 +2,5 @@
"packages": ["packages/*", "plugins/*"],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.1.1-alpha.7"
"version": "0.1.1-alpha.8"
}
+16 -16
View File
@@ -1,28 +1,29 @@
{
"name": "example-app",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"private": true,
"dependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/core": "^0.1.1-alpha.7",
"@backstage/plugin-catalog": "^0.1.1-alpha.7",
"@backstage/plugin-circleci": "^0.1.1-alpha.7",
"@backstage/plugin-explore": "^0.1.1-alpha.7",
"@backstage/plugin-home-page": "^0.1.1-alpha.7",
"@backstage/plugin-lighthouse": "^0.1.1-alpha.7",
"@backstage/plugin-register-component": "^0.1.1-alpha.7",
"@backstage/plugin-scaffolder": "^0.1.1-alpha.7",
"@backstage/plugin-sentry": "^0.1.1-alpha.7",
"@backstage/plugin-tech-radar": "^0.1.1-alpha.7",
"@backstage/plugin-welcome": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/plugin-catalog": "^0.1.1-alpha.8",
"@backstage/plugin-circleci": "^0.1.1-alpha.8",
"@backstage/plugin-explore": "^0.1.1-alpha.8",
"@backstage/plugin-gitops-profiles": "^0.1.1-alpha.8",
"@backstage/plugin-home-page": "^0.1.1-alpha.8",
"@backstage/plugin-lighthouse": "^0.1.1-alpha.8",
"@backstage/plugin-register-component": "^0.1.1-alpha.8",
"@backstage/plugin-scaffolder": "^0.1.1-alpha.8",
"@backstage/plugin-sentry": "^0.1.1-alpha.8",
"@backstage/plugin-tech-radar": "^0.1.1-alpha.8",
"@backstage/plugin-welcome": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-hot-loader": "^4.12.21",
"react-router-dom": "^5.2.0",
"react-router-dom": "6.0.0-alpha.5",
"react-use": "^14.2.0",
"zen-observable": "^0.8.15"
},
@@ -34,7 +35,6 @@
"@types/jest": "^25.2.2",
"@types/jquery": "^3.3.34",
"@types/node": "^12.0.0",
"@types/react-router-dom": "^5.1.3",
"@types/zen-observable": "^0.8.0",
"cross-env": "^7.0.0",
"cypress": "^4.2.0",
+1 -1
View File
@@ -18,7 +18,7 @@ import { createApp, AlertDisplay, OAuthRequestDialog } from '@backstage/core';
import React, { FC } from 'react';
import Root from './components/Root';
import * as plugins from './plugins';
import apis from './apis';
import { apis } from './apis';
import { hot } from 'react-hot-loader/root';
const app = createApp({
+55 -46
View File
@@ -15,11 +15,11 @@
*/
import {
ApiHolder,
ApiRegistry,
alertApiRef,
errorApiRef,
AlertApiForwarder,
ConfigApi,
ErrorApiForwarder,
ErrorAlerter,
featureFlagsApiRef,
@@ -44,57 +44,66 @@ import { techRadarApiRef, TechRadar } from '@backstage/plugin-tech-radar';
import { CircleCIApi, circleCIApiRef } from '@backstage/plugin-circleci';
import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog';
const builder = ApiRegistry.builder();
import { gitOpsApiRef, GitOpsRestApi } from '@backstage/plugin-gitops-profiles';
const alertApi = builder.add(alertApiRef, new AlertApiForwarder());
const errorApi = builder.add(
errorApiRef,
new ErrorAlerter(alertApi, new ErrorApiForwarder()),
);
export const apis = (config: ConfigApi) => {
// eslint-disable-next-line no-console
console.log(`Creating APIs for ${config.getString('app.title')}`);
builder.add(storageApiRef, WebStorage.create({ errorApi }));
builder.add(circleCIApiRef, new CircleCIApi());
builder.add(featureFlagsApiRef, new FeatureFlags());
const builder = ApiRegistry.builder();
builder.add(lighthouseApiRef, new LighthouseRestApi('http://localhost:3003'));
const alertApi = builder.add(alertApiRef, new AlertApiForwarder());
const errorApi = builder.add(
errorApiRef,
new ErrorAlerter(alertApi, new ErrorApiForwarder()),
);
const oauthRequestApi = builder.add(
oauthRequestApiRef,
new OAuthRequestManager(),
);
builder.add(storageApiRef, WebStorage.create({ errorApi }));
builder.add(circleCIApiRef, new CircleCIApi());
builder.add(featureFlagsApiRef, new FeatureFlags());
builder.add(
googleAuthApiRef,
GoogleAuth.create({
apiOrigin: 'http://localhost:7000',
basePath: '/auth/',
oauthRequestApi,
}),
);
builder.add(lighthouseApiRef, new LighthouseRestApi('http://localhost:3003'));
builder.add(
githubAuthApiRef,
GithubAuth.create({
apiOrigin: 'http://localhost:7000',
basePath: '/auth/',
oauthRequestApi,
}),
);
const oauthRequestApi = builder.add(
oauthRequestApiRef,
new OAuthRequestManager(),
);
builder.add(
techRadarApiRef,
new TechRadar({
width: 1500,
height: 800,
}),
);
builder.add(
googleAuthApiRef,
GoogleAuth.create({
apiOrigin: 'http://localhost:7000',
basePath: '/auth/',
oauthRequestApi,
}),
);
builder.add(
catalogApiRef,
new CatalogClient({
apiOrigin: 'http://localhost:3000',
basePath: '/catalog/api',
}),
);
builder.add(
githubAuthApiRef,
GithubAuth.create({
apiOrigin: 'http://localhost:7000',
basePath: '/auth/',
oauthRequestApi,
}),
);
export default builder.build() as ApiHolder;
builder.add(
techRadarApiRef,
new TechRadar({
width: 1500,
height: 800,
}),
);
builder.add(
catalogApiRef,
new CatalogClient({
apiOrigin: 'http://localhost:3000',
basePath: '/catalog/api',
}),
);
builder.add(gitOpsApiRef, new GitOpsRestApi('http://localhost:3008'));
return builder.build();
};
@@ -19,6 +19,9 @@ import PropTypes from 'prop-types';
import { Link, makeStyles } from '@material-ui/core';
import HomeIcon from '@material-ui/icons/Home';
import ExploreIcon from '@material-ui/icons/Explore';
import BuildIcon from '@material-ui/icons/BuildRounded';
import RuleIcon from '@material-ui/icons/AssignmentTurnedIn';
import MapIcon from '@material-ui/icons/MyLocation';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import LogoFull from './LogoFull';
import LogoIcon from './LogoIcon';
@@ -88,6 +91,9 @@ const Root: FC<{}> = ({ children }) => (
<SidebarItem icon={CreateComponentIcon} to="/create" text="Create..." />
{/* End global nav */}
<SidebarDivider />
<SidebarItem icon={MapIcon} to="/tech-radar" text="Tech Radar" />
<SidebarItem icon={RuleIcon} to="/lighthouse" text="Lighthouse" />
<SidebarItem icon={BuildIcon} to="/circleci" text="CircleCI" />
<SidebarSpace />
<SidebarDivider />
<SidebarThemeToggle />
+1
View File
@@ -23,3 +23,4 @@ export { plugin as Explore } from '@backstage/plugin-explore';
export { plugin as Circleci } from '@backstage/plugin-circleci';
export { plugin as RegisterComponent } from '@backstage/plugin-register-component';
export { plugin as Sentry } from '@backstage/plugin-sentry';
export { plugin as GitopsProfiles } from '@backstage/plugin-gitops-profiles';
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/backend-common",
"description": "Common functionality library for Backstage backends",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist",
"types": "src/index.ts",
"private": false,
@@ -37,7 +37,7 @@
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@types/compression": "^1.7.0",
"@types/cors": "^2.8.6",
"@types/express": "^4.17.6",
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import winston from 'winston';
import * as winston from 'winston';
import { getRootLogger, setRootLogger } from './rootLogger';
describe('rootLogger', () => {
@@ -14,9 +14,9 @@
* limitations under the License.
*/
import winston, { Logger } from 'winston';
import * as winston from 'winston';
let rootLogger: Logger = winston.createLogger({
let rootLogger: winston.Logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format:
process.env.NODE_ENV === 'production'
@@ -35,10 +35,10 @@ let rootLogger: Logger = winston.createLogger({
],
});
export function getRootLogger(): Logger {
export function getRootLogger(): winston.Logger {
return rootLogger;
}
export function setRootLogger(newLogger: Logger) {
export function setRootLogger(newLogger: winston.Logger) {
rootLogger = newLogger;
}
@@ -15,12 +15,12 @@
*/
import { PassThrough } from 'stream';
import winston, { Logger } from 'winston';
import * as winston from 'winston';
/**
* A logger that just throws away all messages.
*/
export function getVoidLogger(): Logger {
export function getVoidLogger(): winston.Logger {
return winston.createLogger({
transports: [new winston.transports.Stream({ stream: new PassThrough() })],
});
@@ -16,7 +16,7 @@
import express from 'express';
import request from 'supertest';
import winston from 'winston';
import * as winston from 'winston';
import { requestLoggingHandler } from './requestLoggingHandler';
describe('requestLoggingHandler', () => {
+4 -9
View File
@@ -39,19 +39,14 @@ If you want to use the catalog functionality, you need to add so called location
to the backend. These are places where the backend can find some entity descriptor
data to consume and serve.
To get started, you can issue the following after starting the backend:
To get started, you can issue the following after starting the backend, from inside
the `plugins/catalog-backend` directory:
```bash
curl -i \
-H "Content-Type: application/json" \
-d '{"type":"github","target":"https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/fixtures/two_components.yaml"}' \
localhost:7000/catalog/locations
yarn mock-catalog-data
```
After a short while, you should start seeing data on `localhost:7000/catalog/entities`.
If you changed the `type` to `file` in the command above, and set the `target`
to the absolute path of a YAML file on disk, you could consume your own experimental data.
You should then start seeing data on `localhost:7000/catalog/entities`.
The catalog currently runs in-memory only, so feel free to try it out, but it will
need to be re-populated on next startup.
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "example-backend",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist",
"types": "src/index.ts",
"private": true,
@@ -17,13 +17,13 @@
"migrate:create": "knex migrate:make -x ts"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.7",
"@backstage/catalog-model": "^0.1.1-alpha.7",
"@backstage/plugin-auth-backend": "^0.1.1-alpha.7",
"@backstage/plugin-catalog-backend": "^0.1.1-alpha.7",
"@backstage/plugin-identity-backend": "^0.1.1-alpha.7",
"@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.7",
"@backstage/plugin-sentry-backend": "^0.1.1-alpha.7",
"@backstage/backend-common": "^0.1.1-alpha.8",
"@backstage/catalog-model": "^0.1.1-alpha.8",
"@backstage/plugin-auth-backend": "^0.1.1-alpha.8",
"@backstage/plugin-catalog-backend": "^0.1.1-alpha.8",
"@backstage/plugin-identity-backend": "^0.1.1-alpha.8",
"@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.8",
"@backstage/plugin-sentry-backend": "^0.1.1-alpha.8",
"esm": "^3.2.25",
"express": "^4.17.1",
"knex": "^0.21.1",
@@ -31,7 +31,7 @@
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@types/express": "^4.17.6",
"@types/express-serve-static-core": "^4.17.5",
"@types/helmet": "^0.0.47",
+4 -2
View File
@@ -74,6 +74,8 @@ async function main() {
});
}
main();
module.hot?.accept();
main().catch(error => {
console.error(`Backend failed to start up, ${error}`);
process.exit(1);
});
+1 -1
View File
@@ -32,7 +32,7 @@ export default async function createPlugin({
}: PluginEnvironment) {
const locationReader = new LocationReaders(logger);
const db = await DatabaseManager.createDatabase(database, logger);
const db = await DatabaseManager.createDatabase(database, { logger });
const entitiesCatalog = new DatabaseEntitiesCatalog(db);
const locationsCatalog = new DatabaseLocationsCatalog(db);
const higherOrderOperation = new HigherOrderOperations(
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/catalog-model",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"main:src": "src/index.ts",
@@ -26,7 +26,7 @@
"yup": "^0.28.5"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@types/jest": "^25.2.2",
"@types/lodash": "^4.14.151",
"@types/yup": "^0.28.2",
+9 -3
View File
@@ -15,15 +15,18 @@
*/
import {
DefaultNamespaceEntityPolicy,
Entity,
FieldFormatEntityPolicy,
NoForeignRootFieldsEntityPolicy,
ReservedFieldsEntityPolicy,
SchemaValidEntityPolicy,
} from './entity';
import { ComponentV1beta1Policy } from './kinds';
import {
ComponentEntityV1beta1Policy,
LocationEntityV1beta1Policy,
} from './kinds';
import { EntityPolicy } from './types';
import { DefaultNamespaceEntityPolicy } from './entity/policies/DefaultNamespaceEntityPolicy';
// Helper that requires that all of a set of policies can be successfully
// applied
@@ -68,7 +71,10 @@ export class EntityPolicies implements EntityPolicy {
new FieldFormatEntityPolicy(),
new ReservedFieldsEntityPolicy(),
]),
EntityPolicies.anyOf([new ComponentV1beta1Policy()]),
EntityPolicies.anyOf([
new ComponentEntityV1beta1Policy(),
new LocationEntityV1beta1Policy(),
]),
]);
}
@@ -14,6 +14,7 @@
* limitations under the License.
*/
export { DefaultNamespaceEntityPolicy } from './DefaultNamespaceEntityPolicy';
export { FieldFormatEntityPolicy } from './FieldFormatEntityPolicy';
export { NoForeignRootFieldsEntityPolicy } from './NoForeignRootFieldsEntityPolicy';
export { ReservedFieldsEntityPolicy } from './ReservedFieldsEntityPolicy';
@@ -15,33 +15,25 @@
*/
import * as yup from 'yup';
import type { Entity, EntityMeta } from '../entity/Entity';
import type { Entity } from '../entity/Entity';
import type { EntityPolicy } from '../types';
const API_VERSION = 'backstage.io/v1beta1';
const KIND = 'Component';
export interface ComponentV1beta1 extends Entity {
export interface ComponentEntityV1beta1 extends Entity {
apiVersion: typeof API_VERSION;
kind: typeof KIND;
metadata: EntityMeta & {
name: string;
};
spec: {
type: string;
};
}
export class ComponentV1beta1Policy implements EntityPolicy {
export class ComponentEntityV1beta1Policy implements EntityPolicy {
private schema: yup.Schema<any>;
constructor() {
this.schema = yup.object<Partial<ComponentV1beta1>>({
metadata: yup
.object({
name: yup.string().required(),
})
.required(),
this.schema = yup.object<Partial<ComponentEntityV1beta1>>({
spec: yup
.object({
type: yup.string().required(),
@@ -51,10 +43,7 @@ export class ComponentV1beta1Policy implements EntityPolicy {
}
async enforce(envelope: Entity): Promise<Entity> {
if (
envelope.apiVersion !== 'backstage.io/v1beta1' ||
envelope.kind !== 'Component'
) {
if (envelope.apiVersion !== API_VERSION || envelope.kind !== KIND) {
throw new Error('Unsupported apiVersion / kind');
}
@@ -0,0 +1,56 @@
/*
* Copyright 2020 Spotify AB
*
* 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 * as yup from 'yup';
import type { Entity } from '../entity/Entity';
import type { EntityPolicy } from '../types';
const API_VERSION = 'backstage.io/v1beta1';
const KIND = 'Location';
export interface LocationEntityV1beta1 extends Entity {
apiVersion: typeof API_VERSION;
kind: typeof KIND;
spec: {
type: string;
target?: string;
targets?: string[];
};
}
export class LocationEntityV1beta1Policy implements EntityPolicy {
private schema: yup.Schema<any>;
constructor() {
this.schema = yup.object<Partial<LocationEntityV1beta1>>({
spec: yup
.object({
type: yup.string().required(),
target: yup.string().notRequired(),
targets: yup.array(yup.string()).notRequired(),
})
.required(),
});
}
async enforce(envelope: Entity): Promise<Entity> {
if (envelope.apiVersion !== API_VERSION || envelope.kind !== KIND) {
throw new Error('Unsupported apiVersion / kind');
}
return await this.schema.validate(envelope, { strict: true });
}
}
+9 -4
View File
@@ -14,8 +14,13 @@
* limitations under the License.
*/
export { ComponentEntityV1beta1Policy } from './ComponentEntityV1beta1';
export type {
ComponentV1beta1,
ComponentV1beta1 as Component,
} from './ComponentV1beta1';
export { ComponentV1beta1Policy } from './ComponentV1beta1';
ComponentEntityV1beta1 as ComponentEntity,
ComponentEntityV1beta1,
} from './ComponentEntityV1beta1';
export { LocationEntityV1beta1Policy } from './LocationEntityV1beta1';
export type {
LocationEntityV1beta1 as LocationEntity,
LocationEntityV1beta1,
} from './LocationEntityV1beta1';
+2
View File
@@ -52,6 +52,8 @@ module.exports = {
'warn',
{ vars: 'all', args: 'after-used', ignoreRestSiblings: true },
],
// Avoid cross-package imports
'no-restricted-imports': [2, { patterns: ['**/../../**/*/src/**'] }],
},
overrides: [
{
+3 -2
View File
@@ -57,18 +57,19 @@ module.exports = {
'warn',
{ vars: 'all', args: 'after-used', ignoreRestSiblings: true },
],
// Importing the entire MUI icons packages kills build performance as the list of icons is huge.
'no-restricted-imports': [
2,
{
paths: [
{
// Importing the entire MUI icons packages kills build performance as the list of icons is huge.
name: '@material-ui/icons',
message: "Please import '@material-ui/icons/<Icon>' instead.",
},
...require('module').builtinModules,
],
// Avoid cross-package imports
patterns: ['**/../../**/*/src/**'],
},
],
},
+1 -1
View File
@@ -41,7 +41,7 @@ async function getConfig() {
for (const pkg of packages) {
const mainSrc = pkg.get('main:src');
if (mainSrc) {
moduleNameMapper[pkg.name] = path.resolve(pkg.location, mainSrc);
moduleNameMapper[`^${pkg.name}$`] = path.resolve(pkg.location, mainSrc);
}
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/cli",
"description": "CLI for developing Backstage plugins and apps",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"private": false,
"publishConfig": {
"access": "public"
@@ -69,7 +69,7 @@
"replace-in-file": "^6.0.0",
"rollup": "2.10.x",
"rollup-plugin-dts": "^1.4.6",
"rollup-plugin-esbuild": "^1.4.1",
"rollup-plugin-esbuild": "^2.0.0",
"rollup-plugin-image-files": "^1.4.2",
"rollup-plugin-peer-deps-external": "^2.2.2",
"rollup-plugin-postcss": "^3.1.1",
+2 -1
View File
@@ -23,7 +23,7 @@ import {
printFileSizesAfterBuild,
} from 'react-dev-utils/FileSizeReporter';
import formatWebpackMessages from 'react-dev-utils/formatWebpackMessages';
import { createConfig } from './config';
import { createConfig, resolveBaseUrl } from './config';
import { BuildOptions } from './types';
import { resolveBundlingPaths } from './paths';
import chalk from 'chalk';
@@ -41,6 +41,7 @@ export async function buildBundle(options: BuildOptions) {
checksEnabled: false,
isDev: false,
isBackend: false,
baseUrl: resolveBaseUrl(options.config),
});
const compiler = webpack(config);
+13
View File
@@ -21,6 +21,7 @@ import StartServerPlugin from 'start-server-webpack-plugin';
import webpack from 'webpack';
import nodeExternals from 'webpack-node-externals';
import { optimization } from './optimization';
import { Config } from '@backstage/config';
import { BundlingPaths } from './paths';
import { transforms } from './transforms';
import { BundlingOptions } from './types';
@@ -30,6 +31,18 @@ import { BundlingOptions } from './types';
// import evalSourceMapMiddleware from 'react-dev-utils/evalSourceMapMiddleware';
// import WatchMissingNodeModulesPlugin from 'react-dev-utils/WatchMissingNodeModulesPlugin';
export function resolveBaseUrl(config: Config): URL {
const baseUrl = config.getString('app.baseUrl');
if (!baseUrl) {
throw new Error('app.baseUrl must be set in config');
}
try {
return new URL(baseUrl, 'http://localhost:3000');
} catch (error) {
throw new Error(`Invalid app.baseUrl, ${error}`);
}
}
export function createConfig(
paths: BundlingPaths,
options: BundlingOptions,
+8 -28
View File
@@ -15,25 +15,17 @@
*/
import fs from 'fs-extra';
import yn from 'yn';
import webpack from 'webpack';
import WebpackDevServer from 'webpack-dev-server';
import openBrowser from 'react-dev-utils/openBrowser';
import { choosePort, prepareUrls } from 'react-dev-utils/WebpackDevServerUtils';
import { createConfig } from './config';
import { createConfig, resolveBaseUrl } from './config';
import { ServeOptions } from './types';
import { resolveBundlingPaths } from './paths';
export async function serveBundle(options: ServeOptions) {
const host = process.env.HOST ?? '0.0.0.0';
const defaultPort = parseInt(process.env.PORT ?? '', 10) || 3000;
const url = resolveBaseUrl(options.config);
const port = await choosePort(host, defaultPort);
if (!port) {
throw new Error(`Invalid or no port set: '${port}'`);
}
const protocol = yn(process.env.HTTPS, { default: false }) ? 'https' : 'http';
const port = Number(url.port) || (url.protocol === 'https:' ? 443 : 80);
const paths = resolveBundlingPaths(options);
const pkgPath = paths.targetPackageJson;
@@ -42,6 +34,7 @@ export async function serveBundle(options: ServeOptions) {
...options,
isDev: true,
isBackend: false,
baseUrl: url,
});
const compiler = webpack(config);
@@ -53,33 +46,20 @@ export async function serveBundle(options: ServeOptions) {
historyApiFallback: true,
clientLogLevel: 'warning',
stats: 'errors-warnings',
https: protocol === 'https',
host,
https: url.protocol === 'https:',
host: url.hostname,
port,
proxy: pkg.proxy,
});
await new Promise((resolve, reject) => {
server.listen(port, host, (err?: Error) => {
server.listen(port, url.hostname, (err?: Error) => {
if (err) {
reject(err);
return;
}
// TODO: This signature is available in 10.2.1 but doesn't have types published yet
const latestPrepareUrls = prepareUrls as (
protocol: string,
host: string,
port: number,
path?: string,
) => ReturnType<typeof prepareUrls>;
const urls = latestPrepareUrls(
protocol,
host,
port,
config.output?.publicPath,
);
openBrowser(urls.localUrlForBrowser);
openBrowser(url.href);
resolve();
});
});
+1
View File
@@ -23,6 +23,7 @@ export type BundlingOptions = {
config: Config;
appConfigs: AppConfig[];
isBackend: boolean;
baseUrl: URL;
};
export type ServeOptions = BundlingPathsOptions & {
+8 -4
View File
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import chalk from 'chalk';
import fs from 'fs-extra';
import { relative as relativePath } from 'path';
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
@@ -42,7 +43,9 @@ export const makeConfigs = async (
if (!declarationsExist) {
const path = relativePath(paths.targetDir, typesInput);
throw new Error(
`No declaration files found at ${path}, be sure to run tsc to generate .d.ts files before packaging`,
`No declaration files found at ${path}, be sure to run ${chalk.bgRed.white(
'yarn tsc',
)} to generate .d.ts files before packaging`,
);
}
@@ -50,6 +53,7 @@ export const makeConfigs = async (
if (options.outputs.has(Output.cjs) || options.outputs.has(Output.esm)) {
const output = new Array<OutputOptions>();
const mainFields = ['module', 'main'];
if (options.outputs.has(Output.cjs)) {
output.push({
@@ -66,6 +70,8 @@ export const makeConfigs = async (
chunkFileNames: 'esm/[name]-[hash].js',
format: 'module',
});
// Assume we're building for the browser if ESM output is included
mainFields.unshift('browser');
}
configs.push({
@@ -77,9 +83,7 @@ export const makeConfigs = async (
peerDepsExternal({
includeDependencies: true,
}),
resolve({
mainFields: ['browser', 'module', 'main'],
}),
resolve({ mainFields }),
commonjs({
include: ['node_modules/**', '../../node_modules/**'],
exclude: ['**/*.stories.*', '**/*.test.*'],
@@ -12,7 +12,7 @@
"plugin-welcome": "0.0.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0",
"react-router-dom": "6.0.0-alpha.5",
"react-use": "^14.2.0"
},
"devDependencies": {
@@ -21,7 +21,6 @@
"@testing-library/user-event": "^10.2.4",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"@types/react-router-dom": "^5.1.3",
"@types/testing-library__jest-dom": "^5.0.4",
"cross-env": "^7.0.0",
"cypress": "^4.2.0",
@@ -27,7 +27,7 @@
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-use": "^14.2.0",
"react-router-dom": "^5.2.0"
"react-router-dom": "6.0.0-alpha.5"
},
"devDependencies": {
"@backstage/cli": "^{{version}}",
+4 -2
View File
@@ -32,11 +32,13 @@
"dependencies": {
"@backstage/config": "^0.1.1-alpha.7",
"fs-extra": "^9.0.0",
"yaml": "^1.9.2"
"yaml": "^1.9.2",
"yup": "^0.28.5"
},
"devDependencies": {
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0"
"@types/node": "^12.0.0",
"@types/yup": "^0.28.2"
},
"files": [
"dist/**/*.{js,d.ts}"
+1 -1
View File
@@ -15,4 +15,4 @@
*/
export { loadConfig } from './loader';
export type { LoadConfigOptions } from './types';
export type { LoadConfigOptions } from './loader';
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { readEnv } from './loader';
import { readEnv } from './env';
describe('readEnv', () => {
it('should return empty config for empty env', () => {
+91
View File
@@ -0,0 +1,91 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { AppConfig, JsonObject } from '@backstage/config';
const ENV_PREFIX = 'APP_CONFIG_';
// Update the same pattern in config package if this is changed
const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i;
/**
* Read runtime configuration from the environment.
*
* Only environment variables prefixed with APP_CONFIG_ will be considered.
*
* For each variable, the prefix will be removed, and rest of the key will
* be split by '_'. Each part will then be used as keys to build up a nested
* config object structure. The treatment of the entire environment variable
* is case-sensitive.
*
* The value of the variable should be JSON serialized, as it will be parsed
* and the type will be kept intact. For example "true" and true are treated
* differently, as well as "42" and 42.
*
* For example, to set the config app.title to "My Title", use the following:
*
* APP_CONFIG_app_title='"My Title"'
*/
export function readEnv(env: {
[name: string]: string | undefined;
}): AppConfig[] {
let config: JsonObject | undefined = undefined;
for (const [name, value] of Object.entries(env)) {
if (!value) {
continue;
}
if (name.startsWith(ENV_PREFIX)) {
const key = name.replace(ENV_PREFIX, '');
const keyParts = key.split('_');
let obj = (config = config ?? {});
for (const [index, part] of keyParts.entries()) {
if (!CONFIG_KEY_PART_PATTERN.test(part)) {
throw new TypeError(`Invalid env config key '${key}'`);
}
if (index < keyParts.length - 1) {
obj = (obj[part] = obj[part] ?? {}) as JsonObject;
if (typeof obj !== 'object' || Array.isArray(obj)) {
const subKey = keyParts.slice(0, index + 1).join('_');
throw new TypeError(
`Could not nest config for key '${key}' under existing value '${subKey}'`,
);
}
} else {
if (part in obj) {
throw new TypeError(
`Refusing to override existing config at key '${key}'`,
);
}
try {
const parsedValue = JSON.parse(value);
if (parsedValue === null) {
throw new Error('value may not be null');
}
obj[part] = parsedValue;
} catch (error) {
throw new TypeError(
`Failed to parse JSON-serialized config value for key '${key}', ${error}`,
);
}
}
}
}
}
return config ? [config] : [];
}
@@ -13,13 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EntityMeta } from '@backstage/catalog-model';
import { ReactNode } from 'react';
export type Component = {
name: string;
namespace?: string;
kind: string;
metadata: EntityMeta;
description: ReactNode;
};
export { resolveStaticConfig } from './resolver';
export { readConfigFile } from './reader';
export { readEnv } from './env';
export { readSecret } from './secrets';
@@ -0,0 +1,123 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { readConfigFile } from './reader';
import { ReaderContext, ReadSecretFunc } from './types';
function memoryFiles(files: { [path: string]: string }) {
return async (path: string) => {
if (path in files) {
return files[path];
}
throw new Error(`File not found, ${path}`);
};
}
describe('readConfigFile', () => {
it('should read a plain config file', async () => {
const readFile = memoryFiles({
'./app-config.yaml':
'app: { title: "Test", x: 1, y: [null, true], z: null }',
});
const config = readConfigFile('./app-config.yaml', {
readFile,
} as ReaderContext);
await expect(config).resolves.toEqual({
app: {
title: 'Test',
x: 1,
y: [true],
},
});
});
it('should error out if the config file has invalid syntax', async () => {
const readFile = memoryFiles({
'./app-config.yaml': 'app: { title: ]',
});
const config = readConfigFile('./app-config.yaml', {
readFile,
} as ReaderContext);
await expect(config).rejects.toThrow('Flow map contains an unexpected ]');
});
it('should error out if config is not an object', async () => {
const readFile = memoryFiles({
'./app-config.yaml': '[]',
});
const config = readConfigFile('./app-config.yaml', {
readFile,
} as ReaderContext);
await expect(config).rejects.toThrow('Expected object at config root');
});
it('should read secrets', async () => {
const readFile = memoryFiles({
'./app-config.yaml': 'app: { $secret: { file: "./my-secret" } }',
});
const readSecret = jest.fn().mockResolvedValue('secret');
const config = readConfigFile('./app-config.yaml', {
env: {},
readFile,
readSecret: readSecret as ReadSecretFunc,
});
await expect(config).resolves.toEqual({
app: 'secret',
});
expect(readSecret).toHaveBeenCalledWith({ file: './my-secret' });
});
it('should require secrets to be objects', async () => {
const readFile = memoryFiles({
'./app-config.yaml': 'app: { $secret: ["wrong-type"] }',
});
const readSecret = jest.fn().mockResolvedValue('secret');
const config = readConfigFile('./app-config.yaml', {
env: {},
readFile,
readSecret: readSecret as ReadSecretFunc,
});
expect(readSecret).not.toHaveBeenCalled();
await expect(config).rejects.toThrow(
'Expected object at secret .app.$secret',
);
});
it('should forward secret reading errors', async () => {
const readFile = memoryFiles({
'./app-config.yaml': 'app: { $secret: {} }',
});
const readSecret = jest.fn().mockRejectedValue(new Error('NOPE'));
const config = readConfigFile('./app-config.yaml', {
env: {},
readFile,
readSecret: readSecret as ReadSecretFunc,
});
await expect(config).rejects.toThrow('Invalid secret at .app: NOPE');
});
});
+80
View File
@@ -0,0 +1,80 @@
/*
* Copyright 2020 Spotify AB
*
* 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 yaml from 'yaml';
import { isObject } from './utils';
import { JsonValue, JsonObject } from '@backstage/config';
import { ReaderContext } from './types';
/**
* Reads and parses, and validates, and transforms a single config file.
* The transformation rewrites any special values, like the $secret key.
*/
export async function readConfigFile(filePath: string, ctx: ReaderContext) {
const configYaml = await ctx.readFile(filePath);
const config = yaml.parse(configYaml);
async function transform(
obj: JsonValue,
path: string,
): Promise<JsonValue | undefined> {
if (typeof obj !== 'object') {
return obj;
} else if (obj === null) {
return undefined;
} else if (Array.isArray(obj)) {
const arr = new Array<JsonValue>();
for (const [index, value] of obj.entries()) {
const out = await transform(value, `${path}[${index}]`);
if (out !== undefined) {
arr.push(out);
}
}
return arr;
}
if ('$secret' in obj) {
if (!isObject(obj.$secret)) {
throw TypeError(`Expected object at secret ${path}.$secret`);
}
try {
return await ctx.readSecret(obj.$secret);
} catch (error) {
throw new Error(`Invalid secret at ${path}: ${error.message}`);
}
}
const out: JsonObject = {};
for (const [key, value] of Object.entries(obj)) {
const result = await transform(value, `${path}.${key}`);
if (result !== undefined) {
out[key] = result;
}
}
return out;
}
const finalConfig = await transform(config, '');
if (!isObject(finalConfig)) {
throw new TypeError('Expected object at config root');
}
return finalConfig;
}
@@ -0,0 +1,43 @@
/*
* Copyright 2020 Spotify AB
*
* 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 fs from 'fs-extra';
import { resolve as resolvePath } from 'path';
import { findRootPath } from './paths';
type ResolveOptions = {
// Same as configPath in LoadConfigOptions
configPath?: string;
};
/**
* Resolves all configuration files that should be loaded in the given environment.
*/
export async function resolveStaticConfig(
options: ResolveOptions,
): Promise<string[]> {
// TODO: We'll want this to be a bit more elaborate, probably adding configs for
// specific env, and maybe local config for plugins.
let { configPath } = options;
if (!configPath) {
configPath = resolvePath(
findRootPath(fs.realpathSync(process.cwd())),
'app-config.yaml',
);
}
return [configPath];
}
@@ -0,0 +1,114 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { readSecret } from './secrets';
import { ReaderContext } from './types';
const ctx: ReaderContext = {
env: {
SECRET: 'my-secret',
},
readSecret: jest.fn(),
async readFile(path) {
const content = ({
'my-secret': 'secret',
'my-data.json': '{"a":{"b":{"c":42}}}',
'my-data.yaml': 'some:\n yaml:\n key: 7',
'my-data.yml': 'different: { key: hello }',
} as { [key: string]: string })[path];
if (!content) {
throw new Error('File not found!');
}
return content;
},
};
describe('readSecret', () => {
it('should read file secrets', async () => {
await expect(readSecret({ file: 'my-secret' }, ctx)).resolves.toBe(
'secret',
);
await expect(readSecret({ file: 'no-secret' }, ctx)).rejects.toThrow(
'File not found!',
);
});
it('should read present env secrets', async () => {
await expect(readSecret({ env: 'SECRET' }, ctx)).resolves.toBe('my-secret');
await expect(readSecret({ env: 'NO_SECRET' }, ctx)).resolves.toBe(
undefined,
);
});
it('should read data secrets', async () => {
await expect(
readSecret({ data: 'my-data.json', path: 'a.b.c' }, ctx),
).resolves.toBe('42');
await expect(
readSecret({ data: 'my-data.yaml', path: 'some.yaml.key' }, ctx),
).resolves.toBe('7');
await expect(
readSecret({ data: 'my-data.yml', path: 'different.key' }, ctx),
).resolves.toBe('hello');
await expect(
readSecret({ data: 'no-data.yml', path: 'different.key' }, ctx),
).rejects.toThrow('File not found!');
});
it('should reject invalid secrets', async () => {
await expect(readSecret('hello' as any, ctx)).rejects.toThrow(
'secret must be a `object` type, but the final value was: `"hello"`.',
);
await expect(readSecret({}, ctx)).rejects.toThrow(
"Secret must contain one of 'file', 'env', 'data'",
);
await expect(readSecret({ unknown: 'derp' }, ctx)).rejects.toThrow(
"Secret must contain one of 'file', 'env', 'data'",
);
await expect(readSecret({ data: 'no-data.yml' }, ctx)).rejects.toThrow(
'path is a required field',
);
await expect(
readSecret({ data: 'no-parser.js', path: '.' }, ctx),
).rejects.toThrow('No data secret parser available for extension .js');
await expect(
readSecret({ data: 'my-data.yaml', path: 'some.wrong.yaml.key' }, ctx),
).rejects.toThrow('Value is not an object at some.wrong in my-data.yaml');
});
it('should have 100% test coverage', async () => {
let firstVisit = true;
const secret = {};
const proto = {
get file() {
if (!firstVisit) {
Object.setPrototypeOf(secret, {});
}
firstVisit = false;
return 'a-file';
},
};
Object.setPrototypeOf(secret, proto);
await expect(readSecret(secret, ctx)).rejects.toThrow(
'Secret was left unhandled',
);
});
});
+140
View File
@@ -0,0 +1,140 @@
/*
* Copyright 2020 Spotify AB
*
* 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 * as yup from 'yup';
import yaml from 'yaml';
import { extname } from 'path';
import { JsonObject, JsonValue } from '@backstage/config';
import { isObject, isNever } from './utils';
import { ReaderContext } from './types';
// Reads a file and forwards the contents as is, assuming ut8 encoding
type FileSecret = {
// Path to the secret file, relative to the config file.
file: string;
};
// Reads the secret from an environment variable.
type EnvSecret = {
// The name of the environment file.
env: string;
};
// Reads a secret from a json-like file and extracts a value at a path.
// The supported extensions are define in dataSecretParser below.
type DataSecret = {
// Path to the data secret file, relative to the config file.
data: string;
// The path to the value inside the data file.
// Either a '.' separated list, or an array of path segments.
path: string | string[];
};
type Secret = FileSecret | EnvSecret | DataSecret;
// Schema for each type of secret description
const secretLoaderSchemas = {
file: yup.object({
file: yup.string().required(),
}),
env: yup.object({
env: yup.string().required(),
}),
data: yup.object({
data: yup.string().required(),
path: yup.lazy(value => {
if (typeof value === 'string') {
return yup.string().required();
}
return yup.array().of(yup.string().required()).required();
}),
}),
};
// The top-level secret schema, which figures out what type of secret it is.
const secretSchema = yup.lazy<object>(value => {
if (typeof value !== 'object' || value === null) {
return yup.object().required().label('secret');
}
const loaderTypes = Object.keys(
secretLoaderSchemas,
) as (keyof typeof secretLoaderSchemas)[];
for (const key of loaderTypes) {
if (key in value) {
return secretLoaderSchemas[key];
}
}
throw new yup.ValidationError(
`Secret must contain one of '${loaderTypes.join("', '")}'`,
value,
'$secret',
);
});
// Parsers for each type of data secret file.
const dataSecretParser: {
[ext in string]: (content: string) => Promise<JsonObject>;
} = {
'.json': async content => JSON.parse(content),
'.yaml': async content => yaml.parse(content),
'.yml': async content => yaml.parse(content),
};
/**
* Transforms a secret description into the actual secret value.
*/
export async function readSecret(
data: JsonObject,
ctx: ReaderContext,
): Promise<string | undefined> {
const secret = secretSchema.validateSync(data, { strict: true }) as Secret;
if ('file' in secret) {
return ctx.readFile(secret.file);
}
if ('env' in secret) {
return ctx.env[secret.env];
}
if ('data' in secret) {
const ext = extname(secret.data);
const parser = dataSecretParser[ext];
if (!parser) {
throw new Error(`No data secret parser available for extension ${ext}`);
}
const content = await ctx.readFile(secret.data);
const { path } = secret;
const parts = typeof path === 'string' ? path.split('.') : path;
let value: JsonValue = await parser(content);
for (const [index, part] of parts.entries()) {
if (!isObject(value)) {
const errPath = parts.slice(0, index).join('.');
throw new Error(
`Value is not an object at ${errPath} in ${secret.data}`,
);
}
value = value[part];
}
return String(value);
}
isNever<typeof secret>();
throw new Error('Secret was left unhandled');
}
+29
View File
@@ -0,0 +1,29 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { JsonObject } from '@backstage/config';
export type ReadFileFunc = (path: string) => Promise<string>;
export type ReadSecretFunc = (desc: JsonObject) => Promise<string | undefined>;
/**
* Common context that provides all the necessary hooks for reading configuration files.
*/
export type ReaderContext = {
env: { [name in string]?: string };
readFile: ReadFileFunc;
readSecret: ReadSecretFunc;
};
+31
View File
@@ -0,0 +1,31 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { JsonValue, JsonObject } from '@backstage/config';
export function isObject(obj: JsonValue | undefined): obj is JsonObject {
if (typeof obj !== 'object') {
return false;
} else if (Array.isArray(obj)) {
return false;
}
return obj !== null;
}
// A thing to make sure we've narrowed the type down to never
export function isNever<T extends never>() {
return void 0 as T;
}
+52 -73
View File
@@ -15,87 +15,46 @@
*/
import fs from 'fs-extra';
import yaml from 'yaml';
import { resolve as resolvePath } from 'path';
import { resolve as resolvePath, dirname } from 'path';
import { AppConfig, JsonObject } from '@backstage/config';
import { findRootPath } from './paths';
import { LoadConfigOptions } from './types';
import {
resolveStaticConfig,
readConfigFile,
readEnv,
readSecret,
} from './lib';
const ENV_PREFIX = 'APP_CONFIG_';
export type LoadConfigOptions = {
// Config path, defaults to app-config.yaml in project root
configPath?: string;
// Update the same pattern in config package if this is changed
const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i;
// Whether to read secrets or omit them, defaults to false.
shouldReadSecrets?: boolean;
};
export function readEnv(env: {
[name: string]: string | undefined;
}): AppConfig[] {
let config: JsonObject | undefined = undefined;
class Context {
constructor(
private readonly options: {
env: { [name in string]?: string };
rootPath: string;
shouldReadSecrets: boolean;
},
) {}
for (const [name, value] of Object.entries(env)) {
if (!value) {
continue;
}
if (name.startsWith(ENV_PREFIX)) {
const key = name.replace(ENV_PREFIX, '');
const keyParts = key.split('_');
let obj = (config = config ?? {});
for (const [index, part] of keyParts.entries()) {
if (!CONFIG_KEY_PART_PATTERN.test(part)) {
throw new TypeError(`Invalid env config key '${key}'`);
}
if (index < keyParts.length - 1) {
obj = (obj[part] = obj[part] ?? {}) as JsonObject;
if (typeof obj !== 'object' || Array.isArray(obj)) {
const subKey = keyParts.slice(0, index + 1).join('_');
throw new TypeError(
`Could not nest config for key '${key}' under existing value '${subKey}'`,
);
}
} else {
if (part in obj) {
throw new TypeError(
`Refusing to override existing config at key '${key}'`,
);
}
try {
const parsedValue = JSON.parse(value);
if (parsedValue === null) {
throw new Error('value may not be null');
}
obj[part] = parsedValue;
} catch (error) {
throw new TypeError(
`Failed to parse JSON-serialized config value for key '${key}', ${error}`,
);
}
}
}
}
get env() {
return this.options.env;
}
return config ? [config] : [];
}
export async function readStaticConfig(
options: LoadConfigOptions,
): Promise<AppConfig[]> {
// TODO: We'll want this to be a bit more elaborate, probably adding configs for
// specific env, and maybe local config for plugins.
let { configPath } = options;
if (!configPath) {
configPath = resolvePath(
findRootPath(fs.realpathSync(process.cwd())),
'app-config.yaml',
);
async readFile(path: string): Promise<string> {
return fs.readFile(resolvePath(this.options.rootPath, path), 'utf8');
}
try {
const configYaml = await fs.readFile(configPath, 'utf8');
const config = yaml.parse(configYaml);
return [config];
} catch (error) {
throw new Error(`Failed to read static configuration file, ${error}`);
async readSecret(desc: JsonObject): Promise<string | undefined> {
if (!this.options.shouldReadSecrets) {
return undefined;
}
return readSecret(desc, this);
}
}
@@ -105,7 +64,27 @@ export async function loadConfig(
const configs = [];
configs.push(...readEnv(process.env));
configs.push(...(await readStaticConfig(options)));
const configPaths = await resolveStaticConfig(options);
try {
for (const configPath of configPaths) {
const config = await readConfigFile(
configPath,
new Context({
env: process.env,
rootPath: dirname(configPath),
shouldReadSecrets: Boolean(options.shouldReadSecrets),
}),
);
configs.push(config);
}
} catch (error) {
throw new Error(
`Failed to read static configuration file: ${error.message}`,
);
}
return configs;
}
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/core-api",
"description": "Internal Core API used by Backstage plugins and apps",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"private": false,
"publishConfig": {
"access": "public",
@@ -31,18 +31,18 @@
},
"dependencies": {
"@backstage/config": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@types/react": "^16.9",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-router-dom": "^5.2.0",
"react-router-dom": "6.0.0-alpha.5",
"react-use": "^14.2.0",
"zen-observable": "^0.8.15"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/test-utils-core": "^0.1.1-alpha.7",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
@@ -0,0 +1,46 @@
/*
* Copyright 2020 Spotify AB
*
* 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 } from '../ApiRef';
/**
* 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;
/**
* 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(): string | undefined;
// TODO: getProfile(): Promise<Profile> - We want this to be async when added, but needs more work.
};
export const identifyApiRef = createApiRef<IdentityApi>({
id: 'core.identity',
description: 'Provides access to the identity of the signed in user',
});
+11 -2
View File
@@ -15,6 +15,7 @@
*/
import { createApiRef } from '../ApiRef';
import { Observable } from '../..';
/**
* This file contains declarations for common interfaces of auth-related APIs.
@@ -167,6 +168,14 @@ export type ProfileInfo = {
picture?: string;
};
export enum SessionState {
SignedIn = 'SignedIn',
SignedOut = 'SignedOut',
}
export type SessionStateApi = {
sessionState$(): Observable<SessionState>;
};
/**
* Provides authentication towards Google APIs and identities.
*
@@ -176,7 +185,7 @@ export type ProfileInfo = {
* email and expiration information. Do not rely on any other fields, as they might not be present.
*/
export const googleAuthApiRef = createApiRef<
OAuthApi & OpenIdConnectApi & ProfileInfoApi
OAuthApi & OpenIdConnectApi & ProfileInfoApi & SessionStateApi
>({
id: 'core.auth.google',
description: 'Provides authentication towards Google APIs and identities',
@@ -188,7 +197,7 @@ export const googleAuthApiRef = createApiRef<
* See https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/
* for a full list of supported scopes.
*/
export const githubAuthApiRef = createApiRef<OAuthApi>({
export const githubAuthApiRef = createApiRef<OAuthApi & SessionStateApi>({
id: 'core.auth.github',
description: 'Provides authentication towards Github APIs',
});
@@ -27,5 +27,6 @@ export * from './AppThemeApi';
export * from './ConfigApi';
export * from './ErrorApi';
export * from './FeatureFlagsApi';
export * from './IdentityApi';
export * from './OAuthRequestApi';
export * from './StorageApi';
@@ -17,10 +17,17 @@
import GithubIcon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { GithubSession } from './types';
import { OAuthApi, AccessTokenOptions } from '../../../definitions/auth';
import {
OAuthApi,
AccessTokenOptions,
SessionStateApi,
SessionState,
} from '../../../definitions/auth';
import { OAuthRequestApi, AuthProvider } from '../../../definitions';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { StaticAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { Observable } from '../../../../types';
import { SessionStateTracker } from '../../../../lib/AuthSessionManager/SessionStateTracker';
type CreateOptions = {
// TODO(Rugvip): These two should be grabbed from global config when available, they're not unique to GithubAuth
@@ -46,11 +53,11 @@ const DEFAULT_PROVIDER = {
icon: GithubIcon,
};
class GithubAuth implements OAuthApi {
class GithubAuth implements OAuthApi, SessionStateApi {
static create({
apiOrigin,
basePath,
environment = 'dev',
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
}: CreateOptions) {
@@ -78,6 +85,12 @@ class GithubAuth implements OAuthApi {
return new GithubAuth(sessionManager);
}
private readonly sessionStateTracker = new SessionStateTracker();
sessionState$(): Observable<SessionState> {
return this.sessionStateTracker.observable;
}
constructor(private readonly sessionManager: SessionManager<GithubSession>) {}
async getAccessToken(scope?: string, options?: AccessTokenOptions) {
@@ -86,6 +99,7 @@ class GithubAuth implements OAuthApi {
...options,
scopes: normalizedScopes,
});
this.sessionStateTracker.setIsSignedId(!!session);
if (session) {
return session.accessToken;
}
@@ -94,6 +108,7 @@ class GithubAuth implements OAuthApi {
async logout() {
await this.sessionManager.removeSession();
this.sessionStateTracker.setIsSignedId(false);
}
static normalizeScope(scope?: string): Set<string> {
@@ -25,10 +25,14 @@ import {
ProfileInfoApi,
ProfileInfoOptions,
ProfileInfo,
SessionStateApi,
SessionState,
} from '../../../definitions/auth';
import { OAuthRequestApi, AuthProvider } from '../../../definitions';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { Observable } from '../../../../types';
import { SessionStateTracker } from '../../../../lib/AuthSessionManager/SessionStateTracker';
type CreateOptions = {
// TODO(Rugvip): These two should be grabbed from global config when available, they're not unique to GoogleAuth
@@ -57,11 +61,12 @@ const DEFAULT_PROVIDER = {
const SCOPE_PREFIX = 'https://www.googleapis.com/auth/';
class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi {
class GoogleAuth
implements OAuthApi, OpenIdConnectApi, ProfileInfoApi, SessionStateApi {
static create({
apiOrigin,
basePath,
environment = 'dev',
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
}: CreateOptions) {
@@ -99,6 +104,12 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi {
return new GoogleAuth(sessionManager);
}
private readonly sessionStateTracker = new SessionStateTracker();
sessionState$(): Observable<SessionState> {
return this.sessionStateTracker.observable;
}
constructor(private readonly sessionManager: SessionManager<GoogleSession>) {}
async getAccessToken(
@@ -110,6 +121,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi {
...options,
scopes: normalizedScopes,
});
this.sessionStateTracker.setIsSignedId(!!session);
if (session) {
return session.accessToken;
}
@@ -118,6 +130,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi {
async getIdToken(options: IdTokenOptions = {}) {
const session = await this.sessionManager.getSession(options);
this.sessionStateTracker.setIsSignedId(!!session);
if (session) {
return session.idToken;
}
@@ -126,10 +139,12 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi {
async logout() {
await this.sessionManager.removeSession();
this.sessionStateTracker.setIsSignedId(false);
}
async getProfile(options: ProfileInfoOptions = {}) {
const session = await this.sessionManager.getSession(options);
this.sessionStateTracker.setIsSignedId(!!session);
if (!session) {
return undefined;
}
+31 -37
View File
@@ -13,11 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { ComponentType, FC, useMemo } from 'react';
import { Route, Switch, Redirect } from 'react-router-dom';
import { Route, Routes, Navigate } from 'react-router-dom';
import { AppContextProvider } from './AppContext';
import { BackstageApp, AppComponents, AppConfigLoader } from './types';
import { BackstageApp, AppComponents, AppConfigLoader, Apis } from './types';
import { BackstagePlugin } from '../plugin';
import { FeatureFlagsRegistryItem } from './FeatureFlags';
import { featureFlagsApiRef } from '../apis/definitions';
@@ -38,7 +37,7 @@ import { ApiAggregator } from '../apis/ApiAggregator';
import { useAsync } from 'react-use';
type FullAppOptions = {
apis: ApiHolder;
apis: Apis;
icons: SystemIcons;
plugins: BackstagePlugin[];
components: AppComponents;
@@ -47,15 +46,17 @@ type FullAppOptions = {
};
export class PrivateAppImpl implements BackstageApp {
private readonly apis: ApiHolder;
private apis?: ApiHolder = undefined;
private readonly icons: SystemIcons;
private readonly plugins: BackstagePlugin[];
private readonly components: AppComponents;
private readonly themes: AppTheme[];
private readonly configLoader?: AppConfigLoader;
private apisOrFactory: Apis;
constructor(options: FullAppOptions) {
this.apis = options.apis;
this.apisOrFactory = options.apis;
this.icons = options.icons;
this.plugins = options.plugins;
this.components = options.components;
@@ -64,6 +65,9 @@ export class PrivateAppImpl implements BackstageApp {
}
getApis(): ApiHolder {
if (!this.apis) {
throw new Error('Tried to access APIs before app was loaded');
}
return this.apis;
}
@@ -85,50 +89,31 @@ export class PrivateAppImpl implements BackstageApp {
for (const output of plugin.output()) {
switch (output.type) {
case 'legacy-route': {
const { path, component, options = {} } = output;
const { exact = true } = options;
const { path, component: Component } = output;
routes.push(
<Route
key={path}
path={path}
component={component}
exact={exact}
/>,
<Route key={path} path={path} element={<Component />} />,
);
break;
}
case 'route': {
const { target, component, options = {} } = output;
const { exact = true } = options;
const { target, component: Component } = output;
routes.push(
<Route
key={`${plugin.getId()}-${target.path}`}
path={target.path}
component={component}
exact={exact}
element={<Component />}
/>,
);
break;
}
case 'legacy-redirect-route': {
const { path, target, options = {} } = output;
const { exact = true } = options;
routes.push(
<Redirect key={path} path={path} to={target} exact={exact} />,
);
const { path, target } = output;
routes.push(<Navigate key={path} to={target} />);
break;
}
case 'redirect-route': {
const { from, to, options = {} } = output;
const { exact = true } = options;
routes.push(
<Redirect
key={from.path}
path={from.path}
to={to.path}
exact={exact}
/>,
);
const { from, to } = output;
routes.push(<Navigate key={from.path} to={to.path} />);
break;
}
case 'feature-flag': {
@@ -150,10 +135,10 @@ export class PrivateAppImpl implements BackstageApp {
}
const rendered = (
<Switch>
<Routes>
{routes}
<Route component={NotFoundErrorPage} />
</Switch>
<Route element={<NotFoundErrorPage />} />
</Routes>
);
return () => rendered;
@@ -196,6 +181,15 @@ export class PrivateAppImpl implements BackstageApp {
[appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)],
[configApiRef, configReader],
]);
if (!this.apis) {
if ('get' in this.apisOrFactory) {
this.apis = this.apisOrFactory;
} else {
this.apis = this.apisOrFactory(configReader);
}
}
const apis = new ApiAggregator(this.apis, appApis);
const { Router } = this.components;
@@ -211,7 +205,7 @@ export class PrivateAppImpl implements BackstageApp {
<ApiProvider apis={apis}>
<AppContextProvider app={this}>
<AppThemeProvider>
<Router basename={pathname}>{children}</Router>
<Router>{children}</Router>
</AppThemeProvider>
</AppContextProvider>
</ApiProvider>
+6 -4
View File
@@ -18,19 +18,18 @@ import { ComponentType } from 'react';
import { IconComponent, SystemIconKey, SystemIcons } from '../icons';
import { BackstagePlugin } from '../plugin';
import { ApiHolder } from '../apis';
import { AppTheme } from '../apis/definitions';
import { AppTheme, ConfigApi } from '../apis/definitions';
import { AppConfig } from '@backstage/config';
export type BootErrorPageProps = {
step: 'load-config';
error: Error;
};
export type AppComponents = {
NotFoundErrorPage: ComponentType<{}>;
BootErrorPage: ComponentType<BootErrorPageProps>;
Progress: ComponentType<{}>;
Router: ComponentType<{ basename?: string }>;
Router: ComponentType<{}>;
};
/**
@@ -41,13 +40,16 @@ export type AppComponents = {
*/
export type AppConfigLoader = () => Promise<AppConfig[]>;
// TODO(Rugvip): Temporary workaround for accessing config when instantiating APIs, we might want to do this differently
export type Apis = ApiHolder | ((config: ConfigApi) => ApiHolder);
export type AppOptions = {
/**
* A holder of all APIs available in the app.
*
* Use for example ApiRegistry or ApiTestRegistry.
*/
apis?: ApiHolder;
apis?: Apis;
/**
* Supply icons to override the default ones.
@@ -131,15 +131,6 @@ describe('RefreshingAuthSessionManager', () => {
});
it('should remove session and reload', async () => {
// This is a workaround that is used by Facebook and the Jest core team
// It is a limitation with the newest versions of JSDOM, and newer browser standards
// where window.location and all of its properties are read-only. So we re-construct it!
// See https://github.com/facebook/jest/issues/890#issuecomment-209698782
const location = { ...window.location };
delete window.location;
window.location = location;
jest.spyOn(window.location, 'reload').mockImplementation();
const removeSession = jest.fn();
const manager = new RefreshingAuthSessionManager({
connector: { removeSession },
@@ -147,7 +138,7 @@ describe('RefreshingAuthSessionManager', () => {
} as any);
await manager.removeSession();
expect(window.location.reload).toHaveBeenCalled();
expect(removeSession).toHaveBeenCalled();
expect(await manager.getSession({ optional: true })).toBe(undefined);
});
});
@@ -113,8 +113,8 @@ export class RefreshingAuthSessionManager<T> implements SessionManager<T> {
}
async removeSession() {
this.currentSession = undefined;
await this.connector.removeSession();
window.location.reload(); // TODO(Rugvip): make this work without reload?
}
async getCurrentSession() {
@@ -0,0 +1,32 @@
/*
* Copyright 2020 Spotify AB
*
* 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 '..';
import { SessionState } from '../../apis';
export class SessionStateTracker {
private signedIn: boolean = false;
observable = new BehaviorSubject<SessionState>(SessionState.SignedOut);
setIsSignedId(isSignedIn: boolean) {
if (this.signedIn !== isSignedIn) {
this.signedIn = isSignedIn;
this.observable.next(
this.signedIn ? SessionState.SignedIn : SessionState.SignedOut,
);
}
}
}
@@ -84,11 +84,6 @@ describe('StaticAuthSessionManager', () => {
});
it('should remove session and reload', async () => {
const location = { ...window.location };
delete window.location;
window.location = location;
jest.spyOn(window.location, 'reload').mockImplementation();
const removeSession = jest.fn();
const manager = new StaticAuthSessionManager({
connector: { removeSession },
@@ -96,7 +91,7 @@ describe('StaticAuthSessionManager', () => {
} as any);
await manager.removeSession();
expect(window.location.reload).toHaveBeenCalled();
expect(removeSession).toHaveBeenCalled();
expect(await manager.getSession({ optional: true })).toBe(undefined);
});
});
@@ -64,7 +64,7 @@ export class StaticAuthSessionManager<T> implements SessionManager<T> {
}
async removeSession() {
this.currentSession = undefined;
await this.connector.removeSession();
window.location.reload(); // TODO(Rugvip): make this work without reload?
}
}
+7 -8
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/core",
"description": "Core API used by Backstage plugins and apps",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"private": false,
"publishConfig": {
"access": "public",
@@ -31,13 +31,12 @@
},
"dependencies": {
"@backstage/config": "^0.1.1-alpha.7",
"@backstage/core-api": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/core-api": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@types/react": "^16.9",
"@types/react-router-dom": "^5.1.5",
"@types/react-sparklines": "^1.7.0",
"classnames": "^2.2.6",
"clsx": "^1.1.0",
@@ -48,15 +47,15 @@
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-helmet": "6.0.0",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"react-router": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-alpha.5",
"react-sparklines": "^1.7.0",
"react-syntax-highlighter": "^12.2.1",
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/test-utils": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/test-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
@@ -15,12 +15,7 @@
*/
import React, { FunctionComponentFactory } from 'react';
import { Button } from './Button';
import {
MemoryRouter,
Route,
useLocation,
Link as RouterLink,
} from 'react-router-dom';
import { MemoryRouter, Route, useLocation } from 'react-router-dom';
import { createRouteRef } from '@backstage/core-api';
const Location = () => {
@@ -70,14 +65,7 @@ export const PassProps = () => {
return (
<>
<Button
to={routeRef.path}
/** react-router-dom related prop */
component={RouterLink}
/** material-ui related prop */
color="secondary"
variant="outlined"
>
<Button to={routeRef.path} color="secondary" variant="outlined">
This link
</Button>
&nbsp;has props for both material-ui's component as well as for
@@ -15,11 +15,10 @@
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { render, fireEvent, act } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { Button } from './Button';
import { MemoryRouter, Route } from 'react-router';
import { act } from 'react-dom/test-utils';
import { Route, Routes } from 'react-router';
describe('<Button />', () => {
it('navigates using react-router', async () => {
@@ -27,12 +26,13 @@ describe('<Button />', () => {
const buttonLabel = 'Navigate!';
const { getByText } = render(
wrapInTestApp(
<MemoryRouter>
<Routes>
<Route path="/test" element={<p>{testString}</p>} />
<Button to="/test">{buttonLabel}</Button>
<Route path="/test">{testString}</Route>{' '}
</MemoryRouter>,
</Routes>,
),
);
expect(() => getByText(testString)).toThrow();
await act(async () => fireEvent.click(getByText(buttonLabel)));
expect(getByText(testString)).toBeInTheDocument();
@@ -18,7 +18,7 @@ import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { Link } from './Link';
import { MemoryRouter, Route } from 'react-router';
import { Route, Routes } from 'react-router';
import { act } from 'react-dom/test-utils';
describe('<Link />', () => {
@@ -27,10 +27,10 @@ describe('<Link />', () => {
const linkText = 'Navigate!';
const { getByText } = render(
wrapInTestApp(
<MemoryRouter>
<Routes>
<Link to="/test">{linkText}</Link>
<Route path="/test">{testString}</Route>
</MemoryRouter>,
<Route path="/test" element={<p>{testString}</p>} />
</Routes>,
),
);
expect(() => getByText(testString)).toThrow();
+1 -1
View File
@@ -19,7 +19,7 @@ import { Link as MaterialLink } from '@material-ui/core';
import { Link as RouterLink } from 'react-router-dom';
type Props = ComponentProps<typeof MaterialLink> &
ComponentProps<typeof RouterLink>;
ComponentProps<typeof RouterLink> & { component?: React.FC<any> };
/**
* Thin wrapper on top of material-ui's Link component
@@ -19,7 +19,7 @@ import { Typography, Link, Grid } from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import { BackstageTheme } from '@backstage/theme';
import { MicDrop } from './MicDrop';
import { useHistory } from 'react-router';
import { useNavigate } from 'react-router';
interface IErrorPageProps {
status: string;
@@ -40,7 +40,7 @@ const useStyles = makeStyles<BackstageTheme>(theme => ({
export const ErrorPage = ({ status, statusMessage }: IErrorPageProps) => {
const classes = useStyles();
const history = useHistory();
const navigate = useNavigate();
return (
<Grid container className={classes.container}>
@@ -53,7 +53,7 @@ export const ErrorPage = ({ status, statusMessage }: IErrorPageProps) => {
Looks like someone dropped the mic!
</Typography>
<Typography variant="h6">
<Link data-testid="go-back-link" onClick={history.goBack}>
<Link data-testid="go-back-link" onClick={() => navigate(-1)}>
Go back
</Link>
... or if you think this is a bug, please file an{' '}
+8 -6
View File
@@ -58,8 +58,11 @@ const useStyles = makeStyles<Theme>(theme => {
// XXX (@koroeskohr): I can't seem to achieve the desired font-weight from the designs
fontWeight: 'bold',
whiteSpace: 'nowrap',
lineHeight: 1.0,
lineHeight: 'auto',
flex: '3 1 auto',
width: '110px',
overflow: 'hidden',
'text-overflow': 'ellipsis',
},
iconContainer: {
boxSizing: 'border-box',
@@ -118,7 +121,8 @@ export const SidebarItem: FC<SidebarItemProps> = ({
icon: Icon,
text,
to = '#',
disableSelected = false,
// TODO: isActive is not in v6
// disableSelected = false,
hasNotifications = false,
onClick,
children,
@@ -145,8 +149,7 @@ export const SidebarItem: FC<SidebarItemProps> = ({
<NavLink
className={clsx(classes.root, classes.closed)}
activeClassName={classes.selected}
isActive={match => Boolean(match && !disableSelected)}
exact
end
to={to}
onClick={onClick}
>
@@ -158,8 +161,7 @@ export const SidebarItem: FC<SidebarItemProps> = ({
<NavLink
className={clsx(classes.root, classes.open)}
activeClassName={classes.selected}
isActive={match => Boolean(match && !disableSelected)}
exact
end
to={to}
onClick={onClick}
>
@@ -0,0 +1,73 @@
/*
* Copyright 2020 Spotify AB
*
* 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,
OAuthApi,
SessionStateApi,
useApi,
Subscription,
IconComponent,
SessionState,
} from '@backstage/core-api';
import React, { FC, useState, useEffect } from 'react';
import { ProviderSettingsItem } from './ProviderSettingsItem';
type OAuthProviderSidebarProps = {
title: string;
icon: IconComponent;
apiRef: ApiRef<OAuthApi & SessionStateApi>;
};
export const OAuthProviderSettings: FC<OAuthProviderSidebarProps> = ({
title,
icon,
apiRef,
}) => {
const api = useApi(apiRef);
const [signedIn, setSignedIn] = useState(false);
useEffect(() => {
const checkSession = async () => {
const session = await api.getAccessToken('', { optional: true });
setSignedIn(!!session);
};
let subscription: Subscription;
const observeSession = () => {
subscription = api
.sessionState$()
.subscribe((sessionState: SessionState) => {
setSignedIn(sessionState === SessionState.SignedIn);
});
};
checkSession();
observeSession();
return () => {
subscription.unsubscribe();
};
}, [api]);
return (
<ProviderSettingsItem
title={title}
icon={icon}
signedIn={signedIn}
api={api}
signInHandler={() => api.getAccessToken()}
/>
);
};
@@ -0,0 +1,74 @@
/*
* Copyright 2020 Spotify AB
*
* 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,
OpenIdConnectApi,
SessionStateApi,
useApi,
Subscription,
IconComponent,
SessionState,
} from '@backstage/core-api';
import React, { FC, useState, useEffect } from 'react';
import { ProviderSettingsItem } from './ProviderSettingsItem';
export type OIDCProviderSidebarProps = {
title: string;
icon: IconComponent;
apiRef: ApiRef<OpenIdConnectApi & SessionStateApi>;
};
export const OIDCProviderSettings: FC<OIDCProviderSidebarProps> = ({
title,
icon,
apiRef,
}) => {
const api = useApi(apiRef);
const [signedIn, setSignedIn] = useState(false);
useEffect(() => {
const checkSession = async () => {
const session = await api.getIdToken({ optional: true });
setSignedIn(!!session);
};
let subscription: Subscription;
const observeSession = () => {
subscription = api
.sessionState$()
.subscribe((sessionState: SessionState) => {
setSignedIn(sessionState === SessionState.SignedIn);
});
};
checkSession();
observeSession();
return () => {
subscription.unsubscribe();
};
}, [api]);
return (
<ProviderSettingsItem
title={title}
icon={icon}
signedIn={signedIn}
api={api}
signInHandler={() => api.getIdToken()}
/>
);
};
@@ -0,0 +1,49 @@
/*
* Copyright 2020 Spotify AB
*
* 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 React, { FC } from 'react';
import { OAuthApi, OpenIdConnectApi, IconComponent } from '@backstage/core-api';
import { SidebarItem } from '../Items';
import { IconButton, Tooltip } from '@material-ui/core';
import StarBorder from '@material-ui/icons/StarBorder';
import PowerButton from '@material-ui/icons/PowerSettingsNew';
export const ProviderSettingsItem: FC<{
title: string;
icon: IconComponent;
signedIn: boolean;
api: OAuthApi | OpenIdConnectApi;
signInHandler: Function;
}> = ({ title, icon, signedIn, api, signInHandler }) => {
return (
<SidebarItem
key={title}
text={title}
icon={icon ?? StarBorder}
disableSelected
>
<IconButton onClick={() => (signedIn ? api.logout() : signInHandler())}>
<Tooltip
placement="top"
arrow
title={signedIn ? `Sign out from ${title}` : `Sign in to ${title}`}
>
<PowerButton color={signedIn ? 'secondary' : 'primary'} />
</Tooltip>
</IconButton>
</SidebarItem>
);
};
@@ -0,0 +1,112 @@
/*
* Copyright 2020 Spotify AB
*
* 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 React, { FC, useState, useRef, useEffect } from 'react';
import { makeStyles, Avatar, Divider } from '@material-ui/core';
import {
ProfileInfo,
useApi,
googleAuthApiRef,
Subscription,
SessionState,
} from '@backstage/core-api';
import { SidebarItem } from '../Items';
import ExpandLess from '@material-ui/icons/ExpandLess';
import ExpandMore from '@material-ui/icons/ExpandMore';
import AccountCircleIcon from '@material-ui/icons/AccountCircle';
const useStyles = makeStyles({
avatar: {
width: 24,
height: 24,
},
});
export const UserProfile: FC<{ open: boolean; setOpen: Function }> = ({
open,
setOpen,
}) => {
const [profile, setProfile] = useState<ProfileInfo>();
const ref = useRef<Element>(); // for scrolling down when collapse item opens
const googleAuth = useApi(googleAuthApiRef);
const classes = useStyles();
const handleClick = () => {
setOpen(!open);
setTimeout(() => ref.current?.scrollIntoView({ behavior: 'smooth' }), 300);
};
useEffect(() => {
const fetchProfile = async () => {
await googleAuth
.getProfile({ optional: true })
.then((userProfile?: ProfileInfo) => {
setProfile(userProfile);
});
};
let subscription: Subscription;
const observeSession = () => {
subscription = googleAuth
.sessionState$()
.subscribe(async (sessionState: SessionState) => {
if (sessionState === SessionState.SignedIn) {
await fetchProfile();
} else {
setProfile(undefined);
}
});
};
fetchProfile();
observeSession();
return () => {
subscription.unsubscribe();
};
}, [googleAuth]);
// Handle main auth info that is shown on the collapsible SidebarItem
let avatar;
let displayName = 'Guest';
if (profile) {
const email = profile.email;
const name = profile.name;
const imageUrl = profile.picture;
const emailTrimmed = email.split('@')[0];
const displayEmail =
emailTrimmed.charAt(0).toUpperCase() + emailTrimmed.slice(1);
displayName = name ?? displayEmail;
avatar = imageUrl
? () => (
<Avatar alt={displayName} src={imageUrl} className={classes.avatar} />
)
: () => <Avatar alt={displayName} className={classes.avatar} />;
}
return (
<>
<Divider innerRef={ref} />
<SidebarItem
text={displayName}
onClick={handleClick}
icon={avatar || AccountCircleIcon}
disableSelected
>
{open ? <ExpandLess /> : <ExpandMore />}
</SidebarItem>
</>
);
};
@@ -0,0 +1,20 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { ProviderSettingsItem } from './ProviderSettingsItem';
export { OAuthProviderSettings } from './OAuthProviderSettings';
export { OIDCProviderSettings } from './OIDCProviderSettings';
export { UserProfile } from './UserProfile';
@@ -22,6 +22,7 @@ import {
SidebarDivider,
SidebarSearchField,
SidebarSpace,
SidebarUserSettings,
} from '.';
import HomeOutlinedIcon from '@material-ui/icons/HomeOutlined';
import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline';
@@ -54,5 +55,6 @@ export const SampleSidebar = () => (
<SidebarIntro />
<SidebarSpace />
<SidebarDivider />
<SidebarUserSettings />
</Sidebar>
);
+18 -152
View File
@@ -14,174 +14,40 @@
* limitations under the License.
*/
import React, { useState, useContext, useEffect, useRef } from 'react';
import React, { useContext, useEffect } from 'react';
import Collapse from '@material-ui/core/Collapse';
import ExpandLess from '@material-ui/icons/ExpandLess';
import ExpandMore from '@material-ui/icons/ExpandMore';
import StarBorder from '@material-ui/icons/StarBorder';
import Star from '@material-ui/icons/Star';
import { SidebarContext } from './config';
import { SidebarItem } from './Items';
import AccountCircleIcon from '@material-ui/icons/AccountCircle';
import Divider from '@material-ui/core/Divider';
import { googleAuthApiRef, githubAuthApiRef } from '@backstage/core-api';
import {
useApi,
googleAuthApiRef,
githubAuthApiRef,
ProfileInfo,
} from '@backstage/core-api';
import { Avatar, IconButton, makeStyles, Tooltip } from '@material-ui/core';
import PowerButton from '@material-ui/icons/PowerSettingsNew';
type Provider = {
title: string;
api: any;
identity?: boolean;
isSignedIn: boolean;
icon: any;
};
const useProviders = () => {
const googleAuth = useApi(googleAuthApiRef);
const githubAuth = useApi(githubAuthApiRef);
const [providers, setProviders] = useState<Provider[]>([
{
title: 'Google',
api: googleAuth,
identity: true,
isSignedIn: false,
icon: Star,
},
{
title: 'Github',
api: githubAuth,
isSignedIn: false,
icon: StarBorder,
},
]);
const setIsSignedIn = async () => {
const signInChecks = await Promise.all(
providers.map(provider =>
provider.identity
? provider.api.getIdToken({ optional: true })
: provider.api.getAccessToken('', { optional: true }),
),
);
signInChecks.map((result, i) => {
providers[i].isSignedIn = !!result;
});
setProviders(providers);
};
setIsSignedIn();
return providers;
};
const useStyles = makeStyles({
avatar: {
width: 24,
height: 24,
},
});
OAuthProviderSettings,
OIDCProviderSettings,
UserProfile as SidebarUserProfile,
} from './Settings';
export function SidebarUserSettings() {
const { isOpen: sidebarOpen } = useContext(SidebarContext);
const [open, setOpen] = React.useState(false);
const ref = useRef<Element>(); // for scrolling down when collapse item opens
const providers = useProviders();
const [profile, setProfile] = useState<ProfileInfo>();
const classes = useStyles();
// TODO(soapraj): List all the providers supported by the app and let user log in from here
// TODO(soapraj): How to observe if the user is logged in
useEffect(() => {
const identityProvider = providers.find(
(provider: Provider) => provider.identity,
);
identityProvider?.api
.getProfile({ optional: true })
.then((userProfile: ProfileInfo) => {
setProfile(userProfile);
});
}, [providers, open]);
const handleClick = () => {
setOpen(!open);
setTimeout(() => ref.current?.scrollIntoView({ behavior: 'smooth' }), 300);
};
// Close the provider list when sidebar collapse
useEffect(() => {
if (!sidebarOpen && open) setOpen(false);
}, [open, sidebarOpen]);
// Handle main auth info that is shown on the collapsible SidebarItem
let avatar;
let displayName;
if (profile) {
const email = profile.email;
const name = profile.name;
const imageUrl = profile.picture;
const avatarFallback = email.charAt(0).toUpperCase() + email.slice(1);
const emailTrimmed = email.split('@')[0];
const displayEmail =
emailTrimmed.charAt(0).toUpperCase() + emailTrimmed.slice(1);
displayName = name ?? displayEmail;
avatar = imageUrl
? () => <Avatar alt={name} src={imageUrl} className={classes.avatar} />
: () => (
<Avatar alt={name} className={classes.avatar}>
{avatarFallback[0]}
</Avatar>
);
}
return (
<>
<Divider innerRef={ref} />
<SidebarItem
text={displayName || 'Guest'}
onClick={handleClick}
icon={avatar || AccountCircleIcon}
disableSelected
>
{open ? <ExpandLess /> : <ExpandMore />}
</SidebarItem>
<Collapse in={open} timeout="auto" unmountOnExit>
{providers.map((provider: Provider) => (
<SidebarItem
key={provider.title}
text={provider.title}
icon={provider.icon ?? StarBorder}
disableSelected
>
<IconButton
onClick={() =>
provider.isSignedIn
? provider.api.logout()
: provider.api.getAccessToken()
}
>
<Tooltip
placement="top"
arrow
title={
provider.isSignedIn
? `Logout from ${provider.title}`
: `Sign in to ${provider.title}`
}
>
<PowerButton
color={provider.isSignedIn ? 'secondary' : 'primary'}
/>
</Tooltip>
</IconButton>
</SidebarItem>
))}
<SidebarUserProfile open={open} setOpen={setOpen} />
<Collapse in={open} timeout="auto">
<OIDCProviderSettings
title="Google"
apiRef={googleAuthApiRef}
icon={Star}
/>
<OAuthProviderSettings
title="Github"
apiRef={githubAuthApiRef}
icon={Star}
/>
</Collapse>
</>
);
@@ -34,3 +34,4 @@ export {
export type { SidebarContextType } from './config';
export { SidebarThemeToggle } from './SidebarThemeToggle';
export { SidebarUserSettings } from './UserSettings';
export * from './Settings';
+7 -7
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/dev-utils",
"description": "Utilities for developing Backstage plugins.",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"private": false,
"publishConfig": {
"access": "public",
@@ -30,10 +30,10 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/core": "^0.1.1-alpha.7",
"@backstage/test-utils": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/test-utils": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@testing-library/jest-dom": "^5.7.0",
@@ -43,8 +43,8 @@
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-hot-loader": "^4.12.21",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0"
"react-router": "^6.0.0-alpha.5",
"react-router-dom": "^6.0.0-alpha.5"
},
"devDependencies": {
"@types/jest": "^25.2.2",
+11
View File
@@ -5,10 +5,12 @@ import {
oauthRequestApiRef,
OAuthRequestManager,
googleAuthApiRef,
githubAuthApiRef,
AlertApiForwarder,
ErrorApiForwarder,
ErrorAlerter,
GoogleAuth,
GithubAuth,
} from '@backstage/core';
const builder = ApiRegistry.builder();
@@ -31,4 +33,13 @@ builder.add(
}),
);
builder.add(
githubAuthApiRef,
GithubAuth.create({
apiOrigin: 'http://localhost:7000',
basePath: '/auth/',
oauthRequestApi,
}),
);
export const apis = builder.build();
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "storybook",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"description": "Storybook build for core package",
"private": true,
"scripts": {
@@ -14,7 +14,7 @@
]
},
"dependencies": {
"@backstage/theme": "^0.1.1-alpha.7"
"@backstage/theme": "^0.1.1-alpha.8"
},
"devDependencies": {
"@storybook/addon-actions": "^5.3.17",
+6 -6
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/test-utils",
"description": "Utilities to test Backstage plugins and apps.",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"private": false,
"publishConfig": {
"access": "public",
@@ -30,10 +30,10 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/core-api": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/core-api": "^0.1.1-alpha.8",
"@backstage/test-utils-core": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
@@ -41,8 +41,8 @@
"@types/react": "^16.9",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0"
"react-router": "^6.0.0-alpha.5",
"react-router-dom": "^6.0.0-alpha.5"
},
"devDependencies": {
"@types/jest": "^25.2.2",
@@ -17,7 +17,7 @@
import React, { FC, useEffect } from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp, renderInTestApp } from './appWrappers';
import { Route } from 'react-router';
import { Route, Routes } from 'react-router';
import { withLogCollector } from '@backstage/test-utils-core';
import {
useApi,
@@ -32,15 +32,15 @@ describe('wrapInTestApp', () => {
const { error } = await withLogCollector(['error'], async () => {
const rendered = render(
wrapInTestApp(
<>
<Route path="/route1">Route 1</Route>
<Route path="/route2">Route 2</Route>
</>,
<Routes>
<Route path="/route1" element={<p>Route 1</p>} />
<Route path="/route2" element={<p>Route 2</p>} />
</Routes>,
{ routeEntries: ['/route2'] },
),
);
expect(rendered.getByText('Route 2')).toBeInTheDocument();
expect(rendered.getByText('Route 2')).toBeInTheDocument();
// Wait for async actions to trigger the act() warnings that we assert below
await Promise.resolve();
});
@@ -92,7 +92,9 @@ export function wrapInTestApp(
return (
<AppProvider>
<Route component={Wrapper} />
{/* The path of * here is needed to be set as a catch all, so it will render the wrapper element
* and work with nested routes if they exist too */}
<Route path="*" element={<Wrapper />} />
</AppProvider>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/theme",
"description": "material-ui theme for use with Backstage.",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"private": false,
"publishConfig": {
"access": "public",
@@ -32,7 +32,7 @@
"@material-ui/core": "^4.9.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7"
"@backstage/cli": "^0.1.1-alpha.8"
},
"files": [
"dist/**/*.{js,d.ts}"
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -15,7 +15,7 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.7",
"@backstage/backend-common": "^0.1.1-alpha.8",
"@types/cookie-parser": "^1.4.2",
"@types/jwt-decode": "2.2.1",
"@types/passport": "^1.0.3",
@@ -39,7 +39,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@types/body-parser": "^1.19.0",
"@types/passport-saml": "^1.1.2",
"jest-fetch-mock": "^3.0.3",
@@ -0,0 +1,62 @@
/*
* Copyright 2020 Spotify AB
*
* 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 express from 'express';
import { AuthProviderRouteHandlers } from '../providers/types';
import { NotFoundError } from '@backstage/backend-common';
export type EnvironmentHandlers = {
[key: string]: AuthProviderRouteHandlers;
};
export class EnvironmentHandler implements AuthProviderRouteHandlers {
constructor(private readonly providers: EnvironmentHandlers) {}
private getProviderForEnv(req: express.Request): AuthProviderRouteHandlers {
const env = req.query.env?.toString();
if (!this.providers.hasOwnProperty(env)) {
throw new NotFoundError(
`No environment for ${env} found in this provider`,
);
}
return this.providers[env];
}
async start(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req);
provider.start(req, res);
}
async frameHandler(
req: express.Request,
res: express.Response,
): Promise<void> {
const provider = this.getProviderForEnv(req);
provider.frameHandler(req, res);
}
async refresh(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req);
if (provider.refresh) {
provider.refresh(req, res);
}
}
async logout(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req);
provider.logout(req, res);
}
}
@@ -18,15 +18,12 @@ import express from 'express';
import {
ensuresXRequestedWith,
postMessageResponse,
removeRefreshTokenCookie,
setRefreshTokenCookie,
THOUSAND_DAYS_MS,
setNonceCookie,
TEN_MINUTES_MS,
verifyNonce,
OAuthProvider,
} from './OAuthProvider';
import { AuthResponse, OAuthProviderHandlers } from './types';
import { AuthResponse, OAuthProviderHandlers } from '../providers/types';
describe('OAuthProvider Utils', () => {
describe('verifyNonce', () => {
@@ -80,52 +77,8 @@ describe('OAuthProvider Utils', () => {
});
});
describe('setNonceCookie', () => {
it('should set nonce cookie', () => {
const mockResponse = ({
cookie: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
setNonceCookie(mockResponse, 'providera');
expect(mockResponse.cookie).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledWith(
'providera-nonce',
expect.any(String),
expect.objectContaining({ maxAge: TEN_MINUTES_MS }),
);
});
});
describe('setRefreshTokenCookie', () => {
it('should set refresh token cookie', () => {
const mockResponse = ({
cookie: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
setRefreshTokenCookie(mockResponse, 'providera', 'REFRESH_TOKEN');
expect(mockResponse.cookie).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledWith(
'providera-refresh-token',
'REFRESH_TOKEN',
expect.objectContaining({ maxAge: THOUSAND_DAYS_MS }),
);
});
});
describe('removeRefreshTokenCookie', () => {
it('should remove refresh token cookie', () => {
const mockResponse = ({
cookie: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
removeRefreshTokenCookie(mockResponse, 'providera');
expect(mockResponse.cookie).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledWith(
'providera-refresh-token',
'',
expect.objectContaining({ maxAge: 0 }),
);
});
});
describe('postMessageResponse', () => {
const appOrigin = 'http://localhost:3000';
it('should post a message back with payload success', () => {
const mockResponse = ({
end: jest.fn().mockReturnThis(),
@@ -144,7 +97,7 @@ describe('OAuthProvider Utils', () => {
const jsonData = JSON.stringify(data);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
postMessageResponse(mockResponse, data);
postMessageResponse(mockResponse, appOrigin, data);
expect(mockResponse.setHeader).toBeCalledTimes(2);
expect(mockResponse.end).toBeCalledTimes(1);
expect(mockResponse.end).toBeCalledWith(
@@ -165,7 +118,7 @@ describe('OAuthProvider Utils', () => {
const jsonData = JSON.stringify(data);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
postMessageResponse(mockResponse, data);
postMessageResponse(mockResponse, appOrigin, data);
expect(mockResponse.setHeader).toBeCalledTimes(2);
expect(mockResponse.end).toBeCalledTimes(1);
expect(mockResponse.end).toBeCalledWith(
@@ -221,10 +174,19 @@ describe('OAuthProvider', () => {
}
}
const providerInstance = new MyAuthProvider();
const providerId = 'test-provider';
const oAuthProviderOptions = {
providerId: 'test-provider',
secure: false,
disableRefresh: true,
baseUrl: 'http://localhost:7000/auth',
appOrigin: 'http://localhost:3000',
};
it('sets the correct headers in start', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId);
const oauthProvider = new OAuthProvider(
providerInstance,
oAuthProviderOptions,
);
const mockRequest = ({
query: {
scope: 'user',
@@ -239,6 +201,14 @@ describe('OAuthProvider', () => {
} as unknown) as express.Response;
await oauthProvider.start(mockRequest, mockResponse);
// nonce cookie checks
expect(mockResponse.cookie).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledWith(
`${oAuthProviderOptions.providerId}-nonce`,
expect.any(String),
expect.objectContaining({ maxAge: TEN_MINUTES_MS }),
);
// redirect checks
expect(mockResponse.setHeader).toHaveBeenCalledTimes(2);
expect(mockResponse.setHeader).toHaveBeenCalledWith('Location', '/url');
expect(mockResponse.setHeader).toHaveBeenCalledWith('Content-Length', '0');
@@ -247,7 +217,10 @@ describe('OAuthProvider', () => {
});
it('sets the refresh cookie if refresh is enabled', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId);
const oauthProvider = new OAuthProvider(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
});
const mockRequest = ({
cookies: {
@@ -269,12 +242,18 @@ describe('OAuthProvider', () => {
expect(mockResponse.cookie).toHaveBeenCalledWith(
expect.stringContaining('test-provider-refresh-token'),
expect.stringContaining('token'),
expect.objectContaining({ path: '/auth/test-provider' }),
expect.objectContaining({
path: '/auth/test-provider',
maxAge: THOUSAND_DAYS_MS,
}),
);
});
it('does no set the refresh cookie if refresh is disabled', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId, true);
it('does not set the refresh cookie if refresh is disabled', async () => {
const oauthProvider = new OAuthProvider(providerInstance, {
...oAuthProviderOptions,
disableRefresh: true,
});
const mockRequest = ({
cookies: {
@@ -296,7 +275,10 @@ describe('OAuthProvider', () => {
});
it('removes refresh cookie when logging out', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId);
const oauthProvider = new OAuthProvider(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
});
const mockRequest = ({
header: () => 'XMLHttpRequest',
@@ -317,7 +299,11 @@ describe('OAuthProvider', () => {
});
it('gets new access-token when refreshing', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId);
oAuthProviderOptions.disableRefresh = false;
const oauthProvider = new OAuthProvider(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
});
const mockRequest = ({
header: () => 'XMLHttpRequest',
@@ -341,7 +327,10 @@ describe('OAuthProvider', () => {
});
it('handles refresh without capabilities', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId, true);
const oauthProvider = new OAuthProvider(providerInstance, {
...oAuthProviderOptions,
disableRefresh: true,
});
const mockRequest = ({
header: () => 'XMLHttpRequest',
@@ -14,20 +14,29 @@
* limitations under the License.
*/
import express, { CookieOptions } from 'express';
import express from 'express';
import crypto from 'crypto';
import { URL } from 'url';
import {
AuthResponse,
AuthProviderRouteHandlers,
OAuthProviderHandlers,
} from './types';
} from '../providers/types';
import { InputError } from '@backstage/backend-common';
export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
export const TEN_MINUTES_MS = 600 * 1000;
export const verifyNonce = (req: express.Request, provider: string) => {
const cookieNonce = req.cookies[`${provider}-nonce`];
export type Options = {
providerId: string;
secure: boolean;
disableRefresh?: boolean;
baseUrl: string;
appOrigin: string;
};
export const verifyNonce = (req: express.Request, providerId: string) => {
const cookieNonce = req.cookies[`${providerId}-nonce`];
const stateNonce = req.query.state;
if (!cookieNonce || !stateNonce) {
@@ -39,58 +48,9 @@ export const verifyNonce = (req: express.Request, provider: string) => {
}
};
export const setNonceCookie = (res: express.Response, provider: string) => {
const nonce = crypto.randomBytes(16).toString('base64');
const options: CookieOptions = {
maxAge: TEN_MINUTES_MS,
secure: false,
sameSite: 'none',
domain: 'localhost',
path: `/auth/${provider}/handler`,
httpOnly: true,
};
res.cookie(`${provider}-nonce`, nonce, options);
return nonce;
};
export const setRefreshTokenCookie = (
res: express.Response,
provider: string,
refreshToken: string,
) => {
const options: CookieOptions = {
maxAge: THOUSAND_DAYS_MS,
secure: false,
sameSite: 'none',
domain: 'localhost',
path: `/auth/${provider}`,
httpOnly: true,
};
res.cookie(`${provider}-refresh-token`, refreshToken, options);
};
export const removeRefreshTokenCookie = (
res: express.Response,
provider: string,
) => {
const options: CookieOptions = {
maxAge: 0,
secure: false,
sameSite: 'none',
domain: 'localhost',
path: `/auth/${provider}`,
httpOnly: true,
};
res.cookie(`${provider}-refresh-token`, '', options);
};
export const postMessageResponse = (
res: express.Response,
appOrigin: string,
data: AuthResponse,
) => {
const jsonData = JSON.stringify(data);
@@ -104,7 +64,7 @@ export const postMessageResponse = (
<html>
<body>
<script>
(window.opener || window.parent).postMessage(JSON.parse(atob('${base64Data}')), 'http://localhost:3000')
(window.opener || window.parent).postMessage(JSON.parse(atob('${base64Data}')), '${appOrigin}')
window.close()
</script>
</body>
@@ -122,17 +82,16 @@ export const ensuresXRequestedWith = (req: express.Request) => {
};
export class OAuthProvider implements AuthProviderRouteHandlers {
private readonly provider: string;
private readonly providerHandlers: OAuthProviderHandlers;
private readonly disableRefresh: boolean;
private readonly domain: string;
private readonly basePath: string;
constructor(
providerHandlers: OAuthProviderHandlers,
provider: string,
disableRefresh?: boolean,
private readonly providerHandlers: OAuthProviderHandlers,
private readonly options: Options,
) {
this.provider = provider;
this.providerHandlers = providerHandlers;
this.disableRefresh = disableRefresh ?? false;
const url = new URL(options.baseUrl);
this.domain = url.hostname;
this.basePath = url.pathname;
}
async start(req: express.Request, res: express.Response): Promise<any> {
@@ -143,8 +102,9 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
throw new InputError('missing scope parameter');
}
const nonce = crypto.randomBytes(16).toString('base64');
// set a nonce cookie before redirecting to oauth provider
const nonce = setNonceCookie(res, this.provider);
this.setNonceCookie(res, nonce);
const options = {
scope,
@@ -152,6 +112,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
prompt: 'consent',
state: nonce,
};
const { url, status } = await this.providerHandlers.start(req, options);
res.statusCode = status || 302;
@@ -166,11 +127,11 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
): Promise<any> {
try {
// verify nonce cookie and state cookie on callback
verifyNonce(req, this.provider);
verifyNonce(req, this.options.providerId);
const { user, info } = await this.providerHandlers.handler(req);
if (!this.disableRefresh) {
if (!this.options.disableRefresh) {
// throw error if missing refresh token
const { refreshToken } = info;
if (!refreshToken) {
@@ -178,17 +139,17 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
}
// set new refresh token
setRefreshTokenCookie(res, this.provider, refreshToken);
this.setRefreshTokenCookie(res, refreshToken);
}
// post message back to popup if successful
return postMessageResponse(res, {
return postMessageResponse(res, this.options.appOrigin, {
type: 'auth-result',
payload: user,
});
} catch (error) {
// post error message back to popup if failure
return postMessageResponse(res, {
return postMessageResponse(res, this.options.appOrigin, {
type: 'auth-result',
error: {
name: error.name,
@@ -203,9 +164,9 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
return res.status(401).send('Invalid X-Requested-With header');
}
if (!this.disableRefresh) {
if (!this.options.disableRefresh) {
// remove refresh token cookie before logout
removeRefreshTokenCookie(res, this.provider);
this.removeRefreshTokenCookie(res);
}
return res.send('logout!');
}
@@ -215,14 +176,15 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
return res.status(401).send('Invalid X-Requested-With header');
}
if (!this.providerHandlers.refresh || this.disableRefresh) {
if (!this.providerHandlers.refresh || this.options.disableRefresh) {
return res.send(
`Refresh token not supported for provider: ${this.provider}`,
`Refresh token not supported for provider: ${this.options.providerId}`,
);
}
try {
const refreshToken = req.cookies[`${this.provider}-refresh-token`];
const refreshToken =
req.cookies[`${this.options.providerId}-refresh-token`];
// throw error if refresh token is missing in the request
if (!refreshToken) {
@@ -241,4 +203,40 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
return res.status(401).send(`${error.message}`);
}
}
private setNonceCookie = (res: express.Response, nonce: string) => {
res.cookie(`${this.options.providerId}-nonce`, nonce, {
maxAge: TEN_MINUTES_MS,
secure: this.options.secure,
sameSite: 'none',
domain: this.domain,
path: `${this.basePath}/${this.options.providerId}/handler`,
httpOnly: true,
});
};
private setRefreshTokenCookie = (
res: express.Response,
refreshToken: string,
) => {
res.cookie(`${this.options.providerId}-refresh-token`, refreshToken, {
maxAge: THOUSAND_DAYS_MS,
secure: this.options.secure,
sameSite: 'none',
domain: this.domain,
path: `${this.basePath}/${this.options.providerId}`,
httpOnly: true,
});
};
private removeRefreshTokenCookie = (res: express.Response) => {
res.cookie(`${this.options.providerId}-refresh-token`, '', {
maxAge: 0,
secure: false,
sameSite: 'none',
domain: `${this.domain}`,
path: `${this.basePath}/${this.options.providerId}`,
httpOnly: true,
});
};
}
@@ -17,7 +17,11 @@
import express from 'express';
import passport from 'passport';
import jwtDecoder from 'jwt-decode';
import { RedirectInfo, RefreshTokenResponse, ProfileInfo } from './types';
import {
RedirectInfo,
RefreshTokenResponse,
ProfileInfo,
} from '../providers/types';
export const makeProfileInfo = (
profile: passport.Profile,
@@ -1,43 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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 const providers = [
{
provider: 'google',
options: {
clientID: process.env.AUTH_GOOGLE_CLIENT_ID!,
clientSecret: process.env.AUTH_GOOGLE_CLIENT_SECRET!,
callbackURL: 'http://localhost:7000/auth/google/handler/frame',
},
},
{
provider: 'github',
options: {
clientID: process.env.AUTH_GITHUB_CLIENT_ID!,
clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET!,
callbackURL: 'http://localhost:7000/auth/github/handler/frame',
},
disableRefresh: true,
},
{
provider: 'saml',
options: {
path: '/auth/saml/handler/frame',
entryPoint: 'http://localhost:7001/',
issuer: 'passport-saml',
},
},
];
@@ -19,6 +19,7 @@ import { createGithubProvider } from './github';
import { createGoogleProvider } from './google';
import { createSamlProvider } from './saml';
import { AuthProviderFactory, AuthProviderConfig } from './types';
import { Logger } from 'winston';
const factories: { [providerId: string]: AuthProviderFactory } = {
google: createGoogleProvider,
@@ -26,17 +27,18 @@ const factories: { [providerId: string]: AuthProviderFactory } = {
saml: createSamlProvider,
};
export function createAuthProvider(providerId: string, config: any) {
export const createAuthProviderRouter = (
providerId: string,
globalConfig: AuthProviderConfig,
providerConfig: any, // TODO: make this a config reader object of sorts
logger: Logger,
) => {
const factory = factories[providerId];
if (!factory) {
throw Error(`No auth provider available for '${providerId}'`);
}
return factory(config);
}
export const createAuthProviderRouter = (config: AuthProviderConfig) => {
const providerId = config.provider;
const provider = createAuthProvider(providerId, config);
const provider = factory(globalConfig, providerConfig, logger);
const router = Router();
router.get('/start', provider.start.bind(provider));
@@ -46,5 +48,6 @@ export const createAuthProviderRouter = (config: AuthProviderConfig) => {
if (provider.refresh) {
router.get('/refresh', provider.refresh.bind(provider));
}
return router;
};

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