diff --git a/.github/workflows/chromatic-storybook-test.yml b/.github/workflows/chromatic-storybook-test.yml index e38b494a73..0bcaf400d0 100644 --- a/.github/workflows/chromatic-storybook-test.yml +++ b/.github/workflows/chromatic-storybook-test.yml @@ -14,7 +14,37 @@ jobs: - uses: actions/checkout@v2 with: fetch-depth: 0 # Required to retrieve git history - - run: yarn install && yarn build-storybook + + # Beginning of yarn setup, keep in sync between all workflows, see ci.yml + - name: use node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + registry-url: https://registry.npmjs.org/ # Needed for auth + - name: cache all node_modules + id: cache-modules + uses: actions/cache@v2 + with: + path: '**/node_modules' + key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + - name: find location of global yarn cache + id: yarn-cache + if: steps.cache-modules.outputs.cache-hit != 'true' + run: echo "::set-output name=dir::$(yarn cache dir)" + - name: cache global yarn cache + uses: actions/cache@v2 + if: steps.cache-modules.outputs.cache-hit != 'true' + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + - name: yarn install + run: yarn install --frozen-lockfile + # End of yarn setup + + - run: yarn build-storybook + - uses: chromaui/action@v1 with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 105642e284..492b008431 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,34 +20,52 @@ jobs: - uses: actions/checkout@v2 - name: fetch branch master run: git fetch origin master - - name: find location of global yarn cache - id: yarn-cache - run: echo "::set-output name=dir::$(yarn cache dir)" - - name: cache global yarn cache - uses: actions/cache@v2 - with: - path: ${{ steps.yarn-cache.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - name: cache node_modules - uses: actions/cache@v2 - with: - path: node_modules - key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }} + + # Beginning of yarn setup, keep in sync between all workflows. + # TODO(Rugvip): move this to composite action once all features we use are supported - name: use node.js ${{ matrix.node-version }} uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + registry-url: https://registry.npmjs.org/ # Needed for auth + + # Cache every node_modules folder inside the monorepo + - name: cache all node_modules + id: cache-modules + uses: actions/cache@v2 + with: + path: '**/node_modules' + # We use both yarn.lock and package.json as cache keys to ensure that + # changes to local monorepo packages bust the cache. + key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + + # If we get a cache hit for node_modules, there's no need to bring in the global + # yarn cache or run yarn install, as all dependencies will be installed already. + + - name: find location of global yarn cache + id: yarn-cache + if: steps.cache-modules.outputs.cache-hit != 'true' + run: echo "::set-output name=dir::$(yarn cache dir)" + + - name: cache global yarn cache + uses: actions/cache@v2 + if: steps.cache-modules.outputs.cache-hit != 'true' + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: yarn install + if: steps.cache-modules.outputs.cache-hit != 'true' + run: yarn install --frozen-lockfile + # End of yarn setup - name: check for yarn.lock changes id: yarn-lock run: git diff --quiet origin/master HEAD -- yarn.lock continue-on-error: true - - name: yarn install - run: yarn install --frozen-lockfile - - name: verify doc links run: node docs/verify-links.js @@ -59,8 +77,7 @@ jobs: - name: build changed packages if: ${{ steps.yarn-lock.outcome == 'success' }} - # Need to build all dependencies as well to be able to run tests later - run: yarn lerna -- run build --since origin/master --include-dependencies + run: yarn lerna -- run build --since origin/master - name: build all packages if: ${{ steps.yarn-lock.outcome == 'failure' }} @@ -79,6 +96,3 @@ jobs: - name: verify plugin template run: yarn lerna -- run diff -- --check - - - name: verify storybook - run: yarn workspace storybook build-storybook diff --git a/.github/workflows/e2e-win.yml b/.github/workflows/e2e-win.yml index 5f75f3d9c8..37cb13ffbe 100644 --- a/.github/workflows/e2e-win.yml +++ b/.github/workflows/e2e-win.yml @@ -26,22 +26,35 @@ jobs: name: Node ${{ matrix.node-version }} on ${{ matrix.os }} steps: - uses: actions/checkout@v2 - - name: find location of global yarn cache - id: yarn-cache - run: echo "::set-output name=dir::$(yarn cache dir)" - - name: cache global yarn cache - uses: actions/cache@v2 - with: - path: ${{ steps.yarn-cache.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- + + # Beginning of yarn setup, keep in sync between all workflows, see ci.yml - name: use node.js ${{ matrix.node-version }} uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + registry-url: https://registry.npmjs.org/ # Needed for auth + - name: cache all node_modules + id: cache-modules + uses: actions/cache@v2 + with: + path: '**/node_modules' + key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + - name: find location of global yarn cache + id: yarn-cache + if: steps.cache-modules.outputs.cache-hit != 'true' + run: echo "::set-output name=dir::$(yarn cache dir)" + - name: cache global yarn cache + uses: actions/cache@v2 + if: steps.cache-modules.outputs.cache-hit != 'true' + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- - name: yarn install run: yarn install --frozen-lockfile + # End of yarn setup + - run: yarn tsc - name: yarn build run: yarn build --ignore example-app --ignore example-backend --ignore @techdocs/cli --ignore backstage-microsite diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 45b9d7e2d8..401fe7297a 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -33,22 +33,35 @@ jobs: name: Node ${{ matrix.node-version }} on ${{ matrix.os }} steps: - uses: actions/checkout@v2 - - name: find location of global yarn cache - id: yarn-cache - run: echo "::set-output name=dir::$(yarn cache dir)" - - name: cache global yarn cache - uses: actions/cache@v2 - with: - path: ${{ steps.yarn-cache.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- + + # Beginning of yarn setup, keep in sync between all workflows, see ci.yml - name: use node.js ${{ matrix.node-version }} uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + registry-url: https://registry.npmjs.org/ # Needed for auth + - name: cache all node_modules + id: cache-modules + uses: actions/cache@v2 + with: + path: '**/node_modules' + key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + - name: find location of global yarn cache + id: yarn-cache + if: steps.cache-modules.outputs.cache-hit != 'true' + run: echo "::set-output name=dir::$(yarn cache dir)" + - name: cache global yarn cache + uses: actions/cache@v2 + if: steps.cache-modules.outputs.cache-hit != 'true' + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- - name: yarn install run: yarn install --frozen-lockfile + # End of yarn setup + - run: yarn tsc - name: yarn build run: yarn build --ignore example-app --ignore example-backend --ignore @techdocs/cli --ignore backstage-microsite diff --git a/.github/workflows/master-win.yml b/.github/workflows/master-win.yml index 074f944250..b5a042a236 100644 --- a/.github/workflows/master-win.yml +++ b/.github/workflows/master-win.yml @@ -18,29 +18,35 @@ jobs: steps: - uses: actions/checkout@v2 - - name: find location of global yarn cache - id: yarn-cache - run: echo "::set-output name=dir::$(yarn cache dir)" - - name: cache global yarn cache - uses: actions/cache@v2 - with: - path: ${{ steps.yarn-cache.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - name: cache node_modules - uses: actions/cache@v2 - with: - path: node_modules - key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }} + + # Beginning of yarn setup, keep in sync between all workflows, see ci.yml - name: use node.js ${{ matrix.node-version }} uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} registry-url: https://registry.npmjs.org/ # Needed for auth - + - name: cache all node_modules + id: cache-modules + uses: actions/cache@v2 + with: + path: '**/node_modules' + key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + - name: find location of global yarn cache + id: yarn-cache + if: steps.cache-modules.outputs.cache-hit != 'true' + run: echo "::set-output name=dir::$(yarn cache dir)" + - name: cache global yarn cache + uses: actions/cache@v2 + if: steps.cache-modules.outputs.cache-hit != 'true' + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- - name: yarn install run: yarn install --frozen-lockfile + # End of yarn setup + # Tests are broken on Windows, disabled for now # - name: test diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 805095ec53..04b50c3e60 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -18,29 +18,34 @@ jobs: steps: - uses: actions/checkout@v2 - - name: find location of global yarn cache - id: yarn-cache - run: echo "::set-output name=dir::$(yarn cache dir)" - - name: cache global yarn cache - uses: actions/cache@v2 - with: - path: ${{ steps.yarn-cache.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - name: cache node_modules - uses: actions/cache@v2 - with: - path: node_modules - key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }} + + # Beginning of yarn setup, keep in sync between all workflows, see ci.yml - name: use node.js ${{ matrix.node-version }} uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} registry-url: https://registry.npmjs.org/ # Needed for auth - + - name: cache all node_modules + id: cache-modules + uses: actions/cache@v2 + with: + path: '**/node_modules' + key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + - name: find location of global yarn cache + id: yarn-cache + if: steps.cache-modules.outputs.cache-hit != 'true' + run: echo "::set-output name=dir::$(yarn cache dir)" + - name: cache global yarn cache + uses: actions/cache@v2 + if: steps.cache-modules.outputs.cache-hit != 'true' + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- - name: yarn install run: yarn install --frozen-lockfile + # End of yarn setup - name: lint run: yarn lerna -- run lint diff --git a/.github/workflows/microsite-build-check.yml b/.github/workflows/microsite-build-check.yml index 41f43399ca..dc86ca2630 100644 --- a/.github/workflows/microsite-build-check.yml +++ b/.github/workflows/microsite-build-check.yml @@ -21,32 +21,34 @@ jobs: steps: - uses: actions/checkout@v2 - - name: find location of global yarn cache - id: yarn-cache - run: echo "::set-output name=dir::$(yarn cache dir)" - - - name: cache global yarn cache - uses: actions/cache@v2 - with: - path: ${{ steps.yarn-cache.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: cache node_modules - uses: actions/cache@v2 - with: - path: node_modules - key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }} + # Beginning of yarn setup, keep in sync between all workflows, see ci.yml - name: use node.js ${{ matrix.node-version }} uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} registry-url: https://registry.npmjs.org/ # Needed for auth - + - name: cache all node_modules + id: cache-modules + uses: actions/cache@v2 + with: + path: '**/node_modules' + key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + - name: find location of global yarn cache + id: yarn-cache + if: steps.cache-modules.outputs.cache-hit != 'true' + run: echo "::set-output name=dir::$(yarn cache dir)" + - name: cache global yarn cache + uses: actions/cache@v2 + if: steps.cache-modules.outputs.cache-hit != 'true' + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- - name: yarn install run: yarn install --frozen-lockfile + # End of yarn setup - name: build microsite run: yarn workspace backstage-microsite build diff --git a/.github/workflows/microsite-with-storybook-deploy.yml b/.github/workflows/microsite-with-storybook-deploy.yml index 84ac6f9490..2c8b8af40c 100644 --- a/.github/workflows/microsite-with-storybook-deploy.yml +++ b/.github/workflows/microsite-with-storybook-deploy.yml @@ -25,32 +25,34 @@ jobs: steps: - uses: actions/checkout@v2 - - name: find location of global yarn cache - id: yarn-cache - run: echo "::set-output name=dir::$(yarn cache dir)" - - - name: cache global yarn cache - uses: actions/cache@v2 - with: - path: ${{ steps.yarn-cache.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: cache node_modules - uses: actions/cache@v2 - with: - path: node_modules - key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }} + # Beginning of yarn setup, keep in sync between all workflows, see ci.yml - name: use node.js ${{ matrix.node-version }} uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} registry-url: https://registry.npmjs.org/ # Needed for auth - + - name: cache all node_modules + id: cache-modules + uses: actions/cache@v2 + with: + path: '**/node_modules' + key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + - name: find location of global yarn cache + id: yarn-cache + if: steps.cache-modules.outputs.cache-hit != 'true' + run: echo "::set-output name=dir::$(yarn cache dir)" + - name: cache global yarn cache + uses: actions/cache@v2 + if: steps.cache-modules.outputs.cache-hit != 'true' + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- - name: yarn install run: yarn install --frozen-lockfile + # End of yarn setup - name: build microsite run: yarn workspace backstage-microsite build diff --git a/app-config.yaml b/app-config.yaml index 01e2ff33fe..8a0199e30a 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -122,3 +122,16 @@ auth: domain: $secret: env: AUTH_AUTH0_DOMAIN + microsoft: + development: + appOrigin: "http://localhost:3000/" + secure: false + clientId: + $secret: + env: AUTH_MICROSOFT_CLIENT_ID + clientSecret: + $secret: + env: AUTH_MICROSOFT_CLIENT_SECRET + tenantId: + $secret: + env: AUTH_MICROSOFT_TENANT_ID \ No newline at end of file diff --git a/docs/assets/techdocs/documentation-template.png b/docs/assets/techdocs/documentation-template.png new file mode 100644 index 0000000000..1f44ad27c6 Binary files /dev/null and b/docs/assets/techdocs/documentation-template.png differ diff --git a/docs/features/techdocs/concepts.md b/docs/features/techdocs/concepts.md index fd87543994..7d5b5325cf 100644 --- a/docs/features/techdocs/concepts.md +++ b/docs/features/techdocs/concepts.md @@ -42,7 +42,7 @@ sites with the Backstage UI. The TechDocs Reader purpose is also to open up the opportunity to integrate TechDocs widgets for a customized full-featured TechDocs experience. -([coming soon V.2](https://github.com/spotify/backstage/milestone/17)) +([coming soon V.3](./README.md#project-roadmap)) [TechDocs Reader](https://github.com/spotify/backstage/blob/master/plugins/techdocs/src/reader/README.md) diff --git a/docs/features/techdocs/creating-and-publishing.md b/docs/features/techdocs/creating-and-publishing.md index 342b846dcc..abb56b348a 100644 --- a/docs/features/techdocs/creating-and-publishing.md +++ b/docs/features/techdocs/creating-and-publishing.md @@ -6,26 +6,38 @@ sidebar_label: Creating and Publishing Documentation This section will guide you through: -- Creating a basic setup for your documentation -- Writing and previewing your documentation in a local Backstage environment -- Creating a build ready for publication -- Publishing your documentation and making your Backstage instance read your - published docs. +- [Create a basic documentation setup](#create-a-basic-documentation-setup) + - [Use the documentation template](#use-the-documentation-template) + - [Manually add documentation setup to already existing repository](#manually-add-documentation-setup-to-already-existing-repository) +- [Writing and previewing your documentation](#writing-and-previewing-your-documentation) ## Prerequisities -- [Docker](https://docs.docker.com/get-docker/) -- Static file hosting - A working Backstage instance with TechDocs installed (see [TechDocs getting started](getting-started.md)) ## Create a basic documentation setup -In your home directory (also known as `~`), create a directory that contains -your documentation (for example, `hello-docs`). Inside this directory, create a -file called `mkdocs.yml`. Below is a basic example of how it could look. +### Use the documentation template -The `~/hello-docs/mkdocs.yml` file should have the following content: +Your working Backstage instance should by default have a documentation template +added. If not, follow these +[instructions](../software-templates/installation.md#adding-templates) to add +the documentation template. + +![Documentation Template](../../assets/techdocs/documentation-template.png) + +Create an entity from the documentation template and you will get the needed +setup for free. + +### Manually add documentation setup to already existing repository + +Prerequisities: + +- `catalog-info.yml` file registered to Backstage. + +Create a `mkdocs.yml` file in the root of the repository with the following +content: ```yaml site_name: 'example-docs' @@ -37,7 +49,20 @@ plugins: - techdocs-core ``` -The `~/hello-docs/docs/index.md` should have the following content: +Update your `catalog-info.yaml` file in the root of the repository with the +following content: + +```yaml +metadata: + annotations: + backstage.io/techdocs-ref: dir:./ +``` + +Create a `/docs` folder in the root of the project with at least a `index.md` +file. _(If you add more markdown files, make sure to update the nav in the +mkdocs.yml file to get a proper navigation for your documentation.)_ + +The `docs/index.md` can for example have the following content: ```md # example docs @@ -45,6 +70,9 @@ The `~/hello-docs/docs/index.md` should have the following content: This is a basic example of documentation. ``` +Commit your changes, open a pull request and merge. You will now get your +updated documentation next time you run Backstage! + ## Writing and previewing your documentation Using the `techdocs-cli` you can preview your docs inside a local Backstage @@ -54,83 +82,6 @@ want to write your documentation. To do this you can run: ```bash -cd ~/hello-docs/ +cd ~// npx techdocs-cli serve ``` - -## Build production ready documentation - -To get a build suitable for publication you can build your docs using the -`spotify/techdocs` container: - -```bash -cd ~/hello-docs/ -docker run -it -w /content -v $(pwd):/content spotify/techdocs build -``` - -You should now have a folder called `~/hello-docs/site/`. - -## Deploy to a file server - -In order to serve documentation to TechDocs, our Backstage plugin needs to -download the HTML rendered from the previous step. This will likely exist on an -external file server, or a storage solution such as Google Cloud Storage. - -When deploying documentation, it should be deployed on that file server/storage -solution with the following convention: `{id}/{file}`. For example, if you want -to upload the `getting-started/index.html` file for the `backstage` -documentation site, we would upload it to our file server as -`backstage/getting-started/index.html`. - -To explain further what this would look like for multiple documentation sites, -take a look at this example file tree that would be represented on your file -server: - -```md -/backstage/index.html /backstage/getting-started/index.html -/backstage/contributing/index.html /mkdocs/index.html -/mkdocs/plugin-development/index.html -/mkdocs/plugin-development/debugging/index.html -``` - -In this file tree, we have two documentation sites available: `backstage` and -`mkdocs`. Each of them expose several pages. Let's say both of these are hosted -on `http://example.com` as the server URL. - -When you configure the TechDocs plugin in Backstage to use `http://example.com` -as the file server/storage solution, it will translate the following URLs to the -file server: - -| Backstage URL | File Server URL | -| --------------------------------------------------------- | ------------------------------------------------------- | -| https://demo.backstage.io/docs/backstage/ | http://example.com/backstage/index.html | -| https://demo.backstage.io/docs/mkdocs/plugin-development/ | http://example.com/mkdocs/plugin-development/index.html | - -Then deploying new sites is easy: simply copy over the `site/` folder produced -in the [Create documentation](#build-production-ready-documentation) step above -to the file server/storage solution under the ID of the documentation site. It -will then become immediately available in Backstage under the same ID as you can -see in the table above. - -So, if the URL to your file server is `http://example.com/`, your -`~/hello-docs/site` folder containing the documentation should be accessible at -`http://example.com/hello-docs/`. - -## Configure TechDocs to read from file server - -In order for Backstage to show your documentation, it needs to know where you -uploaded it. - -Make sure you have Backstage set up using -[TechDocs getting started](getting-started.md). - -To point Backstage to your docs storage, add or change the following lines in -your Backstage `app-config.yaml`: - -```yaml -techdocs: - storageUrl: http://example.com -``` - -You can now start Backstage using `yarn start` and open up your browser at -`http://localhost:3000/docs/hello-docs` to view your docs. diff --git a/docs/features/techdocs/getting-started.md b/docs/features/techdocs/getting-started.md index 094f9a8378..414b3baae8 100644 --- a/docs/features/techdocs/getting-started.md +++ b/docs/features/techdocs/getting-started.md @@ -3,16 +3,6 @@ id: getting-started title: Getting Started --- -> TechDocs is not yet feature complete - currently you can't set up a complete -> end-to-end working TechDocs plugin without customizing the plugin itself. - -> What you can expect from TechDocs V.0 is a demonstration of how to integrate -> docs into Backstage. TechDocs can create docs using -> [mkdocs](https://www.mkdocs.org/), as well as read published docs. If you -> publish generated docs and pass in a `storageUrl` in your `app-config.yaml`, -> you can view them in Backstage by going to -> `http://localhost:3000/docs/`. - TechDocs functions as a plugin to Backstage, so you will need to use Backstage to use TechDocs. @@ -48,8 +38,8 @@ containing your new Backstage application. ## Installing TechDocs -TechDocs is not provided with the Backstage application by default, so you will -now need to set up TechDocs manually. It should take less than a minute. +TechDocs is provided with the Backstage application by default. If you want to +set up TechDocs manually, keep follow the instructions below. ### Adding the package @@ -84,19 +74,29 @@ export { plugin as TechDocs } from '@backstage/plugin-techdocs'; ### Setting the configuration TechDocs allows for configuration of the docs storage URL through your -`app-config` file. The URL provided here is for demo docs to use for testing -purposes. +`app-config` file. -To use the demo docs, add the following lines to `app-config.yaml`: +The default storage URL: ```yaml techdocs: - storageUrl: https://techdocs-mock-sites.storage.googleapis.com + storageUrl: http://localhost:7000/techdocs/static/docs ``` +If you want to configure this to point to another storage URL, change the value +of `storageUrl`. + ## Run Backstage locally -Change folder to your Backstage application root and run the following command: +Change folder to `/packages/backend` and run the +following command: + +```bash +yarn start +``` + +Open a new command line window. Change directory to your Backstage application +root and run the following command: ```bash yarn start diff --git a/microsite/pages/en/docs.js b/microsite/pages/en/docs.js new file mode 100644 index 0000000000..a8bda18f88 --- /dev/null +++ b/microsite/pages/en/docs.js @@ -0,0 +1,12 @@ +const React = require('react'); +const Redirect = require('../../core/Redirect.js'); + +const siteConfig = require(process.cwd() + '/siteConfig.js'); + +function Docs() { + return ( + + ); +} + +module.exports = Docs; diff --git a/microsite/siteConfig.js b/microsite/siteConfig.js index 73a25cd931..64e1af4d86 100644 --- a/microsite/siteConfig.js +++ b/microsite/siteConfig.js @@ -40,6 +40,7 @@ const siteConfig = { }, { doc: 'overview/what-is-backstage', + href: '/docs', label: 'Docs', }, { diff --git a/mkdocs.yml b/mkdocs.yml index e227ab8f9d..74f9815483 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -42,11 +42,7 @@ nav: - Overview: 'features/techdocs/README.md' - Getting Started: 'features/techdocs/getting-started.md' - Concepts: 'features/techdocs/concepts.md' - - Reading Documentation: 'features/techdocs/reading-documentation.md' - - Writing Documentation: 'features/techdocs/writing-documentation.md' - - Publishing Documentation: 'features/techdocs/publishing-documentation.md' - - Contributing: 'features/techdocs/contributing.md' - - Debugging: 'features/techdocs/debugging.md' + - Creating and Publishing Documentation: 'features/techdocs/creating-and-publishing.md' - FAQ: 'features/techdocs/FAQ.md' - Plugins: - Overview: 'plugins/index.md' diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index df023b80cb..cb2a65b64a 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -30,6 +30,7 @@ import { OktaAuth, GitlabAuth, Auth0Auth, + MicrosoftAuth, oauthRequestApiRef, OAuthRequestManager, googleAuthApiRef, @@ -38,6 +39,7 @@ import { oktaAuthApiRef, gitlabAuthApiRef, auth0AuthApiRef, + microsoftAuthApiRef, storageApiRef, WebStorage, } from '@backstage/core'; @@ -74,7 +76,10 @@ import { TravisCIApi, travisCIApiRef, } from '@roadiehq/backstage-plugin-travis-ci'; -import { GithubPullRequestsClient, githubPullRequestsApiRef } from '@roadiehq/backstage-plugin-github-pull-requests'; +import { + GithubPullRequestsClient, + githubPullRequestsApiRef, +} from '@roadiehq/backstage-plugin-github-pull-requests'; export const apis = (config: ConfigApi) => { // eslint-disable-next-line no-console @@ -122,6 +127,15 @@ export const apis = (config: ConfigApi) => { }), ); + builder.add( + microsoftAuthApiRef, + MicrosoftAuth.create({ + backendUrl, + basePath: '/auth/', + oauthRequestApi, + }), + ); + const githubAuthApi = builder.add( githubAuthApiRef, GithubAuth.create({ diff --git a/packages/app/src/identityProviders.ts b/packages/app/src/identityProviders.ts index ea5af8b505..fab0e92577 100644 --- a/packages/app/src/identityProviders.ts +++ b/packages/app/src/identityProviders.ts @@ -19,6 +19,7 @@ import { gitlabAuthApiRef, oktaAuthApiRef, githubAuthApiRef, + microsoftAuthApiRef, } from '@backstage/core'; export const providers = [ @@ -28,6 +29,12 @@ export const providers = [ message: 'Sign In using Google', apiRef: googleAuthApiRef, }, + { + id: 'microsoft-auth-provider', + title: 'Microsoft', + message: 'Sign In using Microsoft Azure AD', + apiRef: microsoftAuthApiRef, + }, { id: 'gitlab-auth-provider', title: 'GitLab', diff --git a/packages/cli-common/package.json b/packages/cli-common/package.json index 80cc665426..5e050210da 100644 --- a/packages/cli-common/package.json +++ b/packages/cli-common/package.json @@ -29,7 +29,6 @@ "clean": "backstage-cli clean" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.19", "@types/jest": "^26.0.7", "@types/node": "^12.0.0" }, diff --git a/packages/cli/src/commands/app/build.ts b/packages/cli/src/commands/app/build.ts index 21d6924b15..5d499cbc2d 100644 --- a/packages/cli/src/commands/app/build.ts +++ b/packages/cli/src/commands/app/build.ts @@ -22,7 +22,7 @@ import { buildBundle } from '../../lib/bundler'; export default async (cmd: Command) => { const appConfigs = await loadConfig({ - env: 'production', + env: process.env.NODE_ENV ?? 'production', rootPaths: [paths.targetRoot, paths.targetDir], }); await buildBundle({ diff --git a/packages/cli/src/commands/app/serve.ts b/packages/cli/src/commands/app/serve.ts index a04e73dceb..c748d12a75 100644 --- a/packages/cli/src/commands/app/serve.ts +++ b/packages/cli/src/commands/app/serve.ts @@ -22,7 +22,7 @@ import { serveBundle } from '../../lib/bundler'; export default async (cmd: Command) => { const appConfigs = await loadConfig({ - env: 'development', + env: process.env.NODE_ENV ?? 'development', rootPaths: [paths.targetRoot, paths.targetDir], }); const waitForExit = await serveBundle({ diff --git a/packages/cli/src/commands/backend/dev.ts b/packages/cli/src/commands/backend/dev.ts index 91c08af201..8b2ff9285a 100644 --- a/packages/cli/src/commands/backend/dev.ts +++ b/packages/cli/src/commands/backend/dev.ts @@ -22,12 +22,14 @@ import { serveBackend } from '../../lib/bundler/backend'; export default async (cmd: Command) => { const appConfigs = await loadConfig({ - env: 'development', + env: process.env.NODE_ENV ?? 'development', rootPaths: [paths.targetRoot, paths.targetDir], }); + const waitForExit = await serveBackend({ entry: 'src/index', checksEnabled: cmd.check, + inspectEnabled: cmd.inspect, config: ConfigReader.fromConfigs(appConfigs), appConfigs, }); diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index b355939e55..7b4532ffa7 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -22,7 +22,7 @@ export default async (cmd: Command, cmdArgs: string[]) => { const args = [ '--ext=js,jsx,ts,tsx', '--max-warnings=0', - '--format=eslint-formatter-friendly', + `--format=${cmd.format}`, ...(cmdArgs ?? [paths.targetDir]), ]; if (cmd.fix) { diff --git a/packages/cli/src/commands/plugin/export.ts b/packages/cli/src/commands/plugin/export.ts index 8f3c132285..8cbbe5b3ee 100644 --- a/packages/cli/src/commands/plugin/export.ts +++ b/packages/cli/src/commands/plugin/export.ts @@ -22,7 +22,7 @@ import { buildBundle } from '../../lib/bundler'; export default async (cmd: Command) => { const appConfigs = await loadConfig({ - env: 'production', + env: process.env.NODE_ENV ?? 'production', rootPaths: [paths.targetRoot, paths.targetDir], }); await buildBundle({ diff --git a/packages/cli/src/commands/plugin/serve.ts b/packages/cli/src/commands/plugin/serve.ts index a677bec918..8a04df8837 100644 --- a/packages/cli/src/commands/plugin/serve.ts +++ b/packages/cli/src/commands/plugin/serve.ts @@ -22,7 +22,7 @@ import { serveBundle } from '../../lib/bundler'; export default async (cmd: Command) => { const appConfigs = await loadConfig({ - env: 'development', + env: process.env.NODE_ENV ?? 'development', rootPaths: [paths.targetRoot, paths.targetDir], }); const waitForExit = await serveBundle({ diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 4b9dc4bab7..9fb909592b 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -52,6 +52,7 @@ const main = (argv: string[]) => { .command('backend:dev') .description('Start local development server with HMR for the backend') .option('--check', 'Enable type checking and linting') + .option('--inspect', 'Enable debugger') .action(lazyAction(() => import('./commands/backend/dev'), 'default')); program @@ -113,6 +114,11 @@ const main = (argv: string[]) => { program .command('lint') + .option( + '--format ', + 'Lint report output format', + 'eslint-formatter-friendly', + ) .option('--fix', 'Attempt to automatically fix violations') .description('Lint a package') .action(lazyAction(() => import('./commands/lint'), 'default')); diff --git a/packages/cli/src/lib/bundler/backend.ts b/packages/cli/src/lib/bundler/backend.ts index ce821a585b..f5f233854f 100644 --- a/packages/cli/src/lib/bundler/backend.ts +++ b/packages/cli/src/lib/bundler/backend.ts @@ -19,7 +19,11 @@ import { createBackendConfig } from './config'; import { resolveBundlingPaths } from './paths'; import { ServeOptions } from './types'; -export async function serveBackend(options: ServeOptions) { +export async function serveBackend( + options: ServeOptions & { + inspectEnabled: boolean; + }, +) { const paths = resolveBundlingPaths(options); const config = createBackendConfig(paths, { ...options, diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index be81b4bb59..d432d6b64e 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -205,7 +205,10 @@ export function createBackendConfig( : {}), }, plugins: [ - new StartServerPlugin('main.js'), + new StartServerPlugin({ + name: 'main.js', + nodeArgs: options.inspectEnabled ? ['--inspect'] : undefined, + }), new webpack.HotModuleReplacementPlugin(), ...(checksEnabled ? [ diff --git a/packages/cli/src/lib/bundler/types.ts b/packages/cli/src/lib/bundler/types.ts index 5d0ba66a51..03d68d9bb8 100644 --- a/packages/cli/src/lib/bundler/types.ts +++ b/packages/cli/src/lib/bundler/types.ts @@ -25,7 +25,9 @@ export type BundlingOptions = { baseUrl: URL; }; -export type BackendBundlingOptions = Omit; +export type BackendBundlingOptions = Omit & { + inspectEnabled: boolean; +}; export type ServeOptions = BundlingPathsOptions & { checksEnabled: boolean; diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 3e46874c1d..840cc36173 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -30,10 +30,12 @@ export type AppConfig = { }; export type Config = { + has(key: string): boolean; + keys(): string[]; - get(key: string): JsonValue; - getOptional(key: string): JsonValue | undefined; + get(key?: string): JsonValue; + getOptional(key?: string): JsonValue | undefined; getConfig(key: string): Config; getOptionalConfig(key: string): Config | undefined; diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index ffe44afb8a..469414d3dc 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -277,6 +277,24 @@ export const auth0AuthApiRef = createApiRef< description: 'Provides authentication towards Auth0 APIs', }); +/** + * Provides authentication towards Microsoft APIs and identities. + * + * For more info and a full list of supported scopes, see: + * - https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent + * - https://docs.microsoft.com/en-us/graph/permissions-reference + */ +export const microsoftAuthApiRef = createApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionStateApi +>({ + id: 'core.auth.microsoft', + description: 'Provides authentication towards Microsoft APIs and identities', +}); + /** * Provides authentication for custom identity providers. */ diff --git a/packages/core-api/src/apis/implementations/auth/index.ts b/packages/core-api/src/apis/implementations/auth/index.ts index ce6e0d8570..a6d7e2c989 100644 --- a/packages/core-api/src/apis/implementations/auth/index.ts +++ b/packages/core-api/src/apis/implementations/auth/index.ts @@ -20,3 +20,4 @@ export * from './google'; export * from './oauth2'; export * from './okta'; export * from './auth0'; +export * from './microsoft'; diff --git a/packages/core-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts b/packages/core-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts new file mode 100644 index 0000000000..d4a70995e3 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts @@ -0,0 +1,172 @@ +/* + * 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 MicrosoftIcon from '@material-ui/icons/AcUnit'; +import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; +import { MicrosoftSession } from './types'; + +import { + OAuthApi, + OpenIdConnectApi, + ProfileInfoApi, + ProfileInfo, + SessionStateApi, + SessionState, + BackstageIdentityApi, + AuthRequestOptions, + BackstageIdentity, +} from '../../../definitions/auth'; + +import { OAuthRequestApi, AuthProvider } from '../../../definitions'; +import { SessionManager } from '../../../../lib/AuthSessionManager/types'; +import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager'; +import { Observable } from '../../../../types'; + +type CreateOptions = { + backendUrl: string; + basePath: string; + + oauthRequestApi: OAuthRequestApi; + + environment?: string; + provider?: AuthProvider & { id: string }; +}; + +export type MicrosoftAuthResponse = { + providerInfo: { + accessToken: string; + idToken: string; + scope: string; + expiresInSeconds: number; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + +const DEFAULT_PROVIDER = { + id: 'microsoft', + title: 'Microsoft', + icon: MicrosoftIcon, +}; + +class MicrosoftAuth + implements + OAuthApi, + OpenIdConnectApi, + ProfileInfoApi, + BackstageIdentityApi, + SessionStateApi { + static create({ + backendUrl, + basePath, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + }: CreateOptions) { + const connector = new DefaultAuthConnector({ + backendUrl, + basePath, + environment, + provider, + oauthRequestApi: oauthRequestApi, + sessionTransform(res: MicrosoftAuthResponse): MicrosoftSession { + return { + ...res, + providerInfo: { + idToken: res.providerInfo.idToken, + accessToken: res.providerInfo.accessToken, + scopes: MicrosoftAuth.normalizeScopes(res.providerInfo.scope), + expiresAt: new Date( + Date.now() + res.providerInfo.expiresInSeconds * 1000, + ), + }, + }; + }, + }); + + const sessionManager = new RefreshingAuthSessionManager({ + connector, + defaultScopes: new Set([ + 'openid', + 'offline_access', + 'profile', + 'email', + 'User.Read', + ]), + sessionScopes: (session: MicrosoftSession) => session.providerInfo.scopes, + sessionShouldRefresh: (session: MicrosoftSession) => { + const expiresInSec = + (session.providerInfo.expiresAt.getTime() - Date.now()) / 1000; + return expiresInSec < 60 * 5; + }, + }); + + return new MicrosoftAuth(sessionManager); + } + + sessionState$(): Observable { + return this.sessionManager.sessionState$(); + } + + constructor( + private readonly sessionManager: SessionManager, + ) {} + + async getAccessToken( + scope?: string | string[], + options?: AuthRequestOptions, + ) { + const session = await this.sessionManager.getSession({ + ...options, + scopes: MicrosoftAuth.normalizeScopes(scope), + }); + return session?.providerInfo.accessToken ?? ''; + } + + async getIdToken(options: AuthRequestOptions = {}) { + const session = await this.sessionManager.getSession(options); + return session?.providerInfo.idToken ?? ''; + } + + async logout() { + await this.sessionManager.removeSession(); + } + + async getBackstageIdentity( + options: AuthRequestOptions = {}, + ): Promise { + const session = await this.sessionManager.getSession(options); + return session?.backstageIdentity; + } + + async getProfile(options: AuthRequestOptions = {}) { + const session = await this.sessionManager.getSession(options); + return session?.profile; + } + + static normalizeScopes(scopes?: string | string[]): Set { + if (!scopes) { + return new Set(); + } + + const scopeList = Array.isArray(scopes) + ? scopes + : scopes.split(/[\s|,]/).filter(Boolean); + + return new Set(scopeList); + } +} +export default MicrosoftAuth; diff --git a/packages/core-api/src/apis/implementations/auth/microsoft/index.ts b/packages/core-api/src/apis/implementations/auth/microsoft/index.ts new file mode 100644 index 0000000000..e3ae4ee4f1 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/microsoft/index.ts @@ -0,0 +1,18 @@ +/* + * 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 * from './types'; +export { default as MicrosoftAuth } from './MicrosoftAuth'; diff --git a/packages/core-api/src/apis/implementations/auth/microsoft/types.ts b/packages/core-api/src/apis/implementations/auth/microsoft/types.ts new file mode 100644 index 0000000000..6eaf92808a --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/microsoft/types.ts @@ -0,0 +1,28 @@ +/* + * 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 { ProfileInfo, BackstageIdentity } from '../../../definitions'; + +export type MicrosoftSession = { + providerInfo: { + idToken: string; + accessToken: string; + scopes: Set; + expiresAt: Date; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; diff --git a/packages/core/src/layout/Sidebar/UserSettings.tsx b/packages/core/src/layout/Sidebar/UserSettings.tsx index 74fa5e173a..5ea7d2b359 100644 --- a/packages/core/src/layout/Sidebar/UserSettings.tsx +++ b/packages/core/src/layout/Sidebar/UserSettings.tsx @@ -21,6 +21,7 @@ import { identityApiRef, oauth2ApiRef, oktaAuthApiRef, + microsoftAuthApiRef, useApi, configApiRef, } from '@backstage/core-api'; @@ -60,6 +61,13 @@ export function SidebarUserSettings() { icon={Star} /> )} + {providers.includes('microsoft') && ( + + )} {providers.includes('github') && ( [] = [ .filter(Boolean) .join(':'), kind: entity.kind, + selectedTabId: 'overview', })} > {entity.metadata.name} diff --git a/plugins/auth-backend/README.md b/plugins/auth-backend/README.md index 29bec3e694..e1d144c1e0 100644 --- a/plugins/auth-backend/README.md +++ b/plugins/auth-backend/README.md @@ -74,6 +74,41 @@ export AUTH_AUTH0_CLIENT_ID=x export AUTH_AUTH0_CLIENT_SECRET=x ``` +### Microsoft + +#### Creating an Azure AD App Registration + +An Azure AD App Registration is required to be able to sign in using Azure AD and the Microsoft Graph API. +Click [here](https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps) to create a new one. + +- Click on the `New Registration` button. +- Give the app a name. e.g. `backstage-dev` +- Select `Accounts in this organizational directory only` under supported account types. +- Enter the callback URL for your backstage backend instance: + - For local development, this is likely `http://localhost:7000/auth/microsoft/handler/frame` + - For non-local deployments, this will be `https://{APP_FQDN}:{APP_BACKEND_PORT}/auth/microsoft/handler/frame` +- Click `Register`. + +We also need to generate a client secret so Backstage can authenticate as this app. + +- Click on the `Certificates & secrets` menu item. +- Under `Client secrets`, click on `New client secret`. +- Add a description for the new secret. e.g. `auth-backend-plugin` +- Select an expiry time; `1 Year`, `2 Years` or `Never`. +- Click `Add`. + +The secret value will then be displayed on the screen. **You will not be able to retrieve it again after leaving the page**. + +#### Starting the Auth Backend + +```bash +cd packages/backend +export AUTH_MICROSOFT_CLIENT_ID=x +export AUTH_MICROSOFT_CLIENT_SECRET=x +export AUTH_MICROSOFT_TENANT_ID=x +yarn start +``` + ### SAML To try out SAML, you can use the mock identity provider: diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 7771434750..966a1fbb98 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -30,6 +30,7 @@ "express": "^4.17.1", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.0", + "got": "^11.5.2", "helmet": "^4.0.0", "jose": "^1.27.1", "jwt-decode": "2.2.0", @@ -40,6 +41,7 @@ "passport-github2": "^0.1.12", "passport-gitlab2": "^5.0.0", "passport-google-oauth20": "^2.0.0", + "passport-microsoft": "^0.1.0", "passport-oauth2": "^1.5.0", "passport-okta-oauth": "^0.0.1", "passport-saml": "^1.3.3", @@ -55,6 +57,7 @@ "@types/passport": "^1.0.3", "@types/passport-github2": "^1.2.4", "@types/passport-google-oauth20": "^2.0.3", + "@types/passport-microsoft": "^0.0.0", "@types/passport-saml": "^1.1.2", "jest-fetch-mock": "^3.0.3" }, diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index bae5bd5d43..26989d8759 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -24,6 +24,7 @@ import { createOAuth2Provider } from './oauth2'; import { createOktaProvider } from './okta'; import { createSamlProvider } from './saml'; import { createAuth0Provider } from './auth0'; +import { createMicrosoftProvider } from './microsoft'; import { AuthProviderConfig, AuthProviderFactory, @@ -42,6 +43,7 @@ const factories: { [providerId: string]: AuthProviderFactory } = { saml: createSamlProvider, okta: createOktaProvider, auth0: createAuth0Provider, + microsoft: createMicrosoftProvider, oauth2: createOAuth2Provider, }; diff --git a/plugins/auth-backend/src/providers/microsoft/index.ts b/plugins/auth-backend/src/providers/microsoft/index.ts new file mode 100644 index 0000000000..2e4abd2d2c --- /dev/null +++ b/plugins/auth-backend/src/providers/microsoft/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { createMicrosoftProvider } from './provider'; diff --git a/plugins/auth-backend/src/providers/microsoft/provider.ts b/plugins/auth-backend/src/providers/microsoft/provider.ts new file mode 100644 index 0000000000..5997d8e1a8 --- /dev/null +++ b/plugins/auth-backend/src/providers/microsoft/provider.ts @@ -0,0 +1,238 @@ +/* + * 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 passport from 'passport'; +import { Strategy as MicrosoftStrategy } from 'passport-microsoft'; + +import { + executeFrameHandlerStrategy, + executeRedirectStrategy, + executeRefreshTokenStrategy, + makeProfileInfo, + executeFetchUserProfileStrategy, +} from '../../lib/PassportStrategyHelper'; + +import { + OAuthProviderHandlers, + RedirectInfo, + AuthProviderConfig, + OAuthProviderOptions, + OAuthResponse, + PassportDoneCallback, +} from '../types'; + +import { OAuthProvider } from '../../lib/OAuthProvider'; +import { Logger } from 'winston'; +import { TokenIssuer } from '../../identity'; +import { Config } from '@backstage/config'; + +import got from 'got'; + +type PrivateInfo = { + refreshToken: string; +}; + +export type MicrosoftAuthProviderOptions = OAuthProviderOptions & { + authorizationUrl?: string; + tokenUrl?: string; +}; + +export class MicrosoftAuthProvider implements OAuthProviderHandlers { + private readonly _strategy: MicrosoftStrategy; + + static transformAuthResponse( + accessToken: string, + params: any, + rawProfile: any, + photoURL: any, + ): OAuthResponse { + let passportProfile: passport.Profile = rawProfile; + passportProfile = { + ...passportProfile, + photos: [{ value: photoURL }], + }; + + const profile = makeProfileInfo(passportProfile, params.id_token); + const providerInfo = { + idToken: params.id_token, + accessToken, + scope: params.scope, + expiresInSeconds: params.expires_in, + }; + + return { + providerInfo, + profile, + }; + } + + constructor(options: MicrosoftAuthProviderOptions) { + this._strategy = new MicrosoftStrategy( + { + clientID: options.clientId, + clientSecret: options.clientSecret, + callbackURL: options.callbackUrl, + authorizationURL: options.authorizationUrl, + tokenURL: options.tokenUrl, + passReqToCallback: false as true, + }, + ( + accessToken: any, + refreshToken: any, + params: any, + rawProfile: passport.Profile, + done: PassportDoneCallback, + ) => { + this.getUserPhoto(accessToken) + .then(photoURL => { + const authResponse = MicrosoftAuthProvider.transformAuthResponse( + accessToken, + params, + rawProfile, + photoURL, + ); + done(undefined, authResponse, { refreshToken }); + }) + .catch(error => { + throw new Error(`Error processing auth response: ${error}`); + }); + }, + ); + } + + async start( + req: express.Request, + options: Record, + ): Promise { + return await executeRedirectStrategy(req, this._strategy, options); + } + + async handler( + req: express.Request, + ): Promise<{ response: OAuthResponse; refreshToken: string }> { + const { response, privateInfo } = await executeFrameHandlerStrategy< + OAuthResponse, + PrivateInfo + >(req, this._strategy); + + return { + response: await this.populateIdentity(response), + refreshToken: privateInfo.refreshToken, + }; + } + + async refresh(refreshToken: string, scope: string): Promise { + const { accessToken, params } = await executeRefreshTokenStrategy( + this._strategy, + refreshToken, + scope, + ); + + const profile = await executeFetchUserProfileStrategy( + this._strategy, + accessToken, + params.id_token, + ); + const photo = await this.getUserPhoto(accessToken); + if (photo) { + profile.picture = photo; + } + + return this.populateIdentity({ + providerInfo: { + accessToken, + idToken: params.id_token, + expiresInSeconds: params.expires_in, + scope: params.scope, + }, + profile, + }); + } + + private getUserPhoto(accessToken: string): Promise { + return new Promise(resolve => { + got + .get('https://graph.microsoft.com/v1.0/me/photos/48x48/$value', { + encoding: 'binary', + responseType: 'buffer', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + .then(photoData => { + const photoURL = `data:image/jpeg;base64,${Buffer.from( + photoData.body, + ).toString('base64')}`; + resolve(photoURL); + }) + .catch(error => { + console.log( + `Could not retrieve user profile photo from Microsoft Graph API: ${error}`, + ); + // User profile photo is optional, ignore errors and resolve undefined + resolve(); + }); + }); + } + + private async populateIdentity( + response: OAuthResponse, + ): Promise { + const { profile } = response; + + if (!profile.email) { + throw new Error('Microsoft profile contained no email'); + } + + // Like Google implementation, setting this to local part of email for now + const id = profile.email.split('@')[0]; + + return { ...response, backstageIdentity: { id } }; + } +} + +export function createMicrosoftProvider( + config: AuthProviderConfig, + _: string, + envConfig: Config, + _logger: Logger, + tokenIssuer: TokenIssuer, +) { + const providerId = 'microsoft'; + + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const tenantID = envConfig.getString('tenantId'); + + const callbackUrl = `${config.baseUrl}/${providerId}/handler/frame`; + const authorizationUrl = `https://login.microsoftonline.com/${tenantID}/oauth2/v2.0/authorize`; + const tokenUrl = `https://login.microsoftonline.com/${tenantID}/oauth2/v2.0/token`; + + const provider = new MicrosoftAuthProvider({ + clientId, + clientSecret, + callbackUrl, + authorizationUrl, + tokenUrl, + }); + + return OAuthProvider.fromConfig(config, provider, { + disableRefresh: false, + providerId, + tokenIssuer, + }); +} diff --git a/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml b/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml index 01ebdadbce..da2152280d 100644 --- a/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml @@ -5,9 +5,9 @@ metadata: title: Documentation Template description: Create a new standalone documentation project tags: - - Experimental - - TechDocs - - MkDocs + - experimental + - techdocs + - mkdocs spec: owner: spotify/techdocs-core templater: cookiecutter @@ -26,4 +26,4 @@ spec: title: Description type: string description: Description of the component - \ No newline at end of file + diff --git a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx b/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx index c6ed55c92c..f5a592b7b7 100644 --- a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx +++ b/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx @@ -26,7 +26,7 @@ import { useJobPolling } from './useJobPolling'; import { Job } from '../../types'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { Button } from '@backstage/core'; -import { entityRoute } from '@backstage/plugin-catalog'; +import { entityRouteDefault } from '@backstage/plugin-catalog'; import { generatePath } from 'react-router-dom'; type Props = { @@ -72,7 +72,7 @@ export const JobStatusModal = ({ {entity && (