diff --git a/.prettierignore b/.prettierignore index dfb0f1cd..3b5fc9c8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,5 @@ dist dist-types coverage .vscode +.coder.yaml +app-config.local.yaml \ No newline at end of file diff --git a/app-config.yaml b/app-config.yaml index 3dc58953..6d11a74e 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -5,6 +5,13 @@ app: organization: name: Coder +coder: + deployment: + accessUrl: https://dev.coder.com + oauth: + clientId: ${CODER_OAUTH_CLIENT_ID:-backstage} + clientSecret: ${CODER_OAUTH_CLIENT_SECRET:-change-me} + backend: # Used for enabling authentication, secret is shared by all backend plugins # See https://backstage.io/docs/auth/service-to-service-auth for @@ -15,8 +22,7 @@ backend: baseUrl: http://localhost:7007 listen: port: 7007 - # Uncomment the following host directive to bind to specific interfaces - # host: 127.0.0.1 + host: localhost csp: connect-src: ["'self'", 'http:', 'https:'] # Content-Security-Policy directives follow the Helmet format: https://helmetjs.github.io/#reference diff --git a/package.json b/package.json index 74a5bfa8..cef9c62a 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,8 @@ "node": "18 || 20" }, "scripts": { - "dev": "concurrently \"yarn dev-init\" \"yarn start\" \"yarn start-backend\"", - "dev-init": "/bin/bash ./scripts/dev-init.sh", + "dev": "yarn dev-init && concurrently --names \"react,backend\" -c \"green,blue\" \"yarn start\" \"yarn start-backend\"", + "dev-init": "./scripts/dev-init.sh", "start": "yarn workspace app start", "start-backend": "yarn workspace backend start", "build:backend": "yarn workspace backend build", @@ -55,5 +55,6 @@ "*.{json,md}": [ "prettier --write" ] - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/packages/backend/package.json b/packages/backend/package.json index c021564d..e0d8f3aa 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -37,6 +37,7 @@ "@backstage/plugin-search-backend-module-techdocs": "^0.1.13", "@backstage/plugin-search-backend-node": "^1.2.13", "@backstage/plugin-techdocs-backend": "^1.9.2", + "@coder/backstage-plugin-coder-backend": "0.0.0", "@coder/backstage-plugin-devcontainers-backend": "0.0.0", "app": "link:../app", "better-sqlite3": "^9.0.0", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 04c4ff93..57e39d2d 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -31,6 +31,7 @@ import search from './plugins/search'; import { PluginEnvironment } from './types'; import { ServerPermissionClient } from '@backstage/plugin-permission-node'; import { DefaultIdentityClient } from '@backstage/plugin-auth-node'; +import { createRouter as createCoderRouter } from '@coder/backstage-plugin-coder-backend'; function makeCreateEnv(config: Config) { const root = getRootLogger(); @@ -85,10 +86,18 @@ async function main() { const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs')); const searchEnv = useHotMemoize(module, () => createEnv('search')); const appEnv = useHotMemoize(module, () => createEnv('app')); + const coderEnv = useHotMemoize(module, () => createEnv('coder')); const apiRouter = Router(); apiRouter.use('/catalog', await catalog(catalogEnv)); apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv)); + apiRouter.use( + '/auth/coder', + await createCoderRouter({ + logger: coderEnv.logger, + config: coderEnv.config, + }), + ); apiRouter.use('/auth', await auth(authEnv)); apiRouter.use('/techdocs', await techdocs(techdocsEnv)); apiRouter.use('/proxy', await proxy(proxyEnv)); diff --git a/plugins/backstage-plugin-coder-backend/.eslintrc.js b/plugins/backstage-plugin-coder-backend/.eslintrc.js new file mode 100644 index 00000000..e2a53a6a --- /dev/null +++ b/plugins/backstage-plugin-coder-backend/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/backstage-plugin-coder-backend/README.md b/plugins/backstage-plugin-coder-backend/README.md new file mode 100644 index 00000000..0c6efe34 --- /dev/null +++ b/plugins/backstage-plugin-coder-backend/README.md @@ -0,0 +1,49 @@ +# `backstage-plugin-coder-backend` + +> [!NOTE] +> This plugin is designed to be the backend counterpart of `backstage-plugin-coder`. In the future, this plugin may become more standalone, but for now, all functionality requires that you also have `backstage-plugin-coder` installed. [See that plugin's setup instructions](../backstage-plugin-coder/README.md#setup) for more information. + +## Features + +- Management of OAuth2 state for requests sent from the Backstage backend. + +## Installing the plugin to support oauth2 + +1. Run the following command from your Backstage app to install the plugin: + ```bash + yarn --cwd packages/app add @coder/backstage-plugin-coder + ``` +2. Import the `createRouter` function from the `@coder/backstage-plugin-coder` package: + ```ts + // Imports can be renamed if there would be a name conflict + import { createRouter as createCoderRouter } from '@coder/backstage-plugin-coder-backend'; + ``` +3. Add support for Coder hot module reloading to `main` function in your deployment's `backend/src/index.ts` file: + ```ts + const coderEnv = useHotMemoize(module, () => createEnv('coder')); + ``` +4. Register the plugin's oauth route with Backstage from inside the same `main` function: + ```ts + apiRouter.use( + '/auth/coder', + await createCoderRouter({ + logger: coderEnv.logger, + config: coderEnv.config, + }), + ); + ``` +5. [If you haven't already, be sure to register Backstage as an oauth app through Coder](https://coder.com/docs/admin/integrations/oauth2-provider). +6. Add the following values to one of your `app-config.yaml` files: + ```yaml + coder: + deployment: + # Change the value to match your Coder deployment + accessUrl: https://dev.coder.com + oauth: + clientId: oauth2-client-id-goes-here + # The client secret isn't used by the frontend plugin, but the backend + # plugin needs it for oauth functionality to work + clientSecret: oauth2-secret-goes-here + ``` + +Note that the `clientSecret` value is given `secret`-level visibility, and will never be logged anywhere by Backstage. diff --git a/plugins/backstage-plugin-coder-backend/package.json b/plugins/backstage-plugin-coder-backend/package.json new file mode 100644 index 00000000..361d4468 --- /dev/null +++ b/plugins/backstage-plugin-coder-backend/package.json @@ -0,0 +1,49 @@ +{ + "name": "@coder/backstage-plugin-coder-backend", + "description": "Backend plugin for Coder OAuth2 authentication flow", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "backend-plugin" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/backend-common": "^0.20.1", + "@backstage/config": "^1.1.1", + "@backstage/errors": "^1.2.3", + "@types/express": "*", + "express": "^4.17.1", + "express-promise-router": "^4.1.0", + "winston": "^3.2.1", + "axios": "^1.6.8" + }, + "devDependencies": { + "@backstage/cli": "^0.25.1", + "@types/supertest": "^2.0.12", + "supertest": "^6.2.4" + }, + "files": [ + "dist" + ], + "keywords": [ + "backstage", + "coder", + "oauth2", + "authentication" + ] +} diff --git a/plugins/backstage-plugin-coder-backend/src/index.ts b/plugins/backstage-plugin-coder-backend/src/index.ts new file mode 100644 index 00000000..9f158c79 --- /dev/null +++ b/plugins/backstage-plugin-coder-backend/src/index.ts @@ -0,0 +1,7 @@ +/** + * Backend plugin for Coder OAuth2 authentication + * + * @packageDocumentation + */ + +export { createRouter } from './service/router'; diff --git a/plugins/backstage-plugin-coder-backend/src/service/router.ts b/plugins/backstage-plugin-coder-backend/src/service/router.ts new file mode 100644 index 00000000..afdbdde8 --- /dev/null +++ b/plugins/backstage-plugin-coder-backend/src/service/router.ts @@ -0,0 +1,136 @@ +import { errorHandler } from '@backstage/backend-common'; +import { Config } from '@backstage/config'; +import express from 'express'; +import Router from 'express-promise-router'; +import { Logger } from 'winston'; +import axios, { type AxiosResponse } from 'axios'; + +export interface RouterOptions { + logger: Logger; + config: Config; +} + +export async function createRouter( + options: RouterOptions, +): Promise { + const { logger, config } = options; + + const router = Router(); + router.use(express.json()); + + // OAuth callback endpoint + router.get('/oauth/callback', async (req, res) => { + const { code } = req.query; + + if (!code || typeof code !== 'string') { + logger.error('OAuth callback missing authorization code'); + res.status(400).send('Missing authorization code'); + return; + } + + const coderConfig = config.getOptionalConfig('coder'); + const accessUrl = coderConfig?.getString('deployment.accessUrl') || ''; + const clientId = coderConfig?.getString('oauth.clientId') || ''; + const clientSecret = coderConfig?.getString('oauth.clientSecret') || ''; + const redirectUri = `${req.protocol}://${req.get( + 'host', + )}/api/auth/coder/oauth/callback`; + + let tokenResponse: AxiosResponse<{ access_token?: string }, unknown>; + try { + // Exchange authorization code for access token + tokenResponse = await axios.post( + `${accessUrl}/oauth2/tokens`, + new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: clientId, + client_secret: clientSecret, + }), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + ); + } catch (error) { + logger.error('OAuth token exchange failed', error); + res + .status(500) + .send( + `

Authentication failed

${ + error instanceof Error ? error.message : 'Unknown error' + }

`, + ); + return; + } + + const { access_token } = tokenResponse.data; + if (!access_token) { + const message = 'Coder deployment did not respond with access token'; + logger.error(message); + res.status(502).send( + ` + + + Authentication Failed + + +

Authentication failed

+

${message}

+ + `, + ); + return; + } + + // Return HTML that sends the token to the opener window via postMessage + res.setHeader('Content-Security-Policy', "script-src 'unsafe-inline'"); + res.send(` + + + + Authentication Successful + + +

Authentication successful! This window will close automatically...

+ + + + `); + + logger.info('OAuth authentication successful'); + }); + + router.get('/health', (_, response) => { + logger.info('Health check'); + response.json({ status: 'ok' }); + }); + + router.use(errorHandler()); + return router; +} diff --git a/plugins/backstage-plugin-coder/README.md b/plugins/backstage-plugin-coder/README.md index 5ccc64a5..96523ec3 100644 --- a/plugins/backstage-plugin-coder/README.md +++ b/plugins/backstage-plugin-coder/README.md @@ -38,7 +38,8 @@ the Dev Container. target: 'https://coder.example.com/' changeOrigin: true - allowedMethods: ['GET'] # Additional methods will be supported soon! + # Add methods based on what API calls you need + allowedMethods: ['GET', 'POST'] allowedHeaders: ['Authorization', 'Coder-Session-Token'] headers: X-Custom-Source: backstage @@ -117,6 +118,29 @@ the Dev Container. ); ``` +### Adding support for OAuth2 + +> [!IMPORTANT] +> Support for OAuth2 requires that you also install the `backstage-plugin-coder-backend` package through NPM. [You can find its README here](../backstage-plugin-coder-backend/README.md). These instrutions assume that you will be installing this plugin before that one. + +1. Register Backstage as an OAuth application. [See the instructions in Coder's documentation for more information](https://coder.com/docs/admin/integrations/oauth2-provider). +2. Add the following values to one of your `app-config.yaml` files: + ```yaml + coder: + deployment: + # Change the value to match your Coder deployment + accessUrl: https://dev.coder.com + oauth: + clientId: oauth2-client-id-goes-here + # The client secret isn't used by the frontend plugin, but the backend + # plugin needs it for oauth functionality to work + clientSecret: oauth2-secret-goes-here + ``` + +(Once the values have been added, you may need to restart the Backstage server for the new values to be recognized.) + +Only the Client ID is exposed in the frontend application (and is used to generate OAuth2 consent links). The client secret stays exclusively on the server. + ### `catalog-info.yaml` files In addition to the above, you can define additional properties on your specific repo's `catalog-info.yaml` file. diff --git a/plugins/backstage-plugin-coder/config.d.ts b/plugins/backstage-plugin-coder/config.d.ts new file mode 100644 index 00000000..90a396eb --- /dev/null +++ b/plugins/backstage-plugin-coder/config.d.ts @@ -0,0 +1,28 @@ +export interface Config { + /** + * @visibility frontend + */ + coder: { + /** + * @deepVisibility frontend + */ + deployment: { + accessUrl: string; + }; + + /** + * @visibility frontend + */ + oauth: { + /** + * @visibility frontend + */ + clientId: string; + + /** + * @visibility secret + */ + clientSecret: string; + }; + }; +} diff --git a/plugins/backstage-plugin-coder/package.json b/plugins/backstage-plugin-coder/package.json index 1d21b960..9dc60dec 100644 --- a/plugins/backstage-plugin-coder/package.json +++ b/plugins/backstage-plugin-coder/package.json @@ -63,7 +63,8 @@ "msw": "^1.0.0" }, "files": [ - "dist" + "dist", + "config.d.ts" ], "keywords": [ "backstage", @@ -73,5 +74,6 @@ "ide", "vscode", "jetbrains" - ] + ], + "configSchema": "config.d.ts" } diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx index 79b263ca..bac58181 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx @@ -1,15 +1,13 @@ import React from 'react'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { CoderProviderWithMockAuth } from '../../testHelpers/setup'; +import { renderInCoderEnvironment } from '../../testHelpers/setup'; import type { CoderAuth, CoderAuthStatus } from '../CoderProvider'; import { - mockAppConfig, mockAuthStates, mockCoderAuthToken, } from '../../testHelpers/mockBackstageData'; import { CoderAuthForm } from './CoderAuthForm'; -import { renderInTestApp } from '@backstage/test-utils'; type RenderInputs = Readonly<{ authStatus: CoderAuthStatus; @@ -34,11 +32,10 @@ async function renderAuthWrapper({ authStatus }: RenderInputs) { * migration to React 18. Need to figure out where this issue is coming from, * and open an issue upstream if necessary */ - const renderOutput = await renderInTestApp( - - - , - ); + const renderOutput = await renderInCoderEnvironment({ + children: , + auth: auth, + }); return { ...renderOutput, unlinkToken, registerNewToken }; } @@ -103,7 +100,7 @@ describe(`${CoderAuthForm.name}`, () => { } }); - it('Lets the user submit a new token', async () => { + it('Lets the user submit a new access token', async () => { const { registerNewToken } = await renderAuthWrapper({ authStatus: 'tokenMissing', }); @@ -117,7 +114,7 @@ describe(`${CoderAuthForm.name}`, () => { * have to use a regex selector */ const inputField = screen.getByLabelText(/Auth token/); - const submitButton = screen.getByRole('button', { name: 'Authenticate' }); + const submitButton = screen.getByRole('button', { name: 'Use token' }); const user = userEvent.setup(); await user.click(inputField); diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthInputForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthInputForm.tsx index ae527e28..32722eb3 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthInputForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthInputForm.tsx @@ -1,11 +1,10 @@ -import React, { type FormEvent, useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useId } from '../../hooks/hookPolyfills'; import { type CoderAuthStatus, useCoderAppConfig, useInternalCoderAuth, } from '../CoderProvider'; - import { CoderLogo } from '../CoderLogo'; import { Link, LinkButton } from '@backstage/core-components'; import { VisuallyHidden } from '../VisuallyHidden'; @@ -13,6 +12,8 @@ import { makeStyles } from '@material-ui/core'; import TextField from '@material-ui/core/TextField'; import ErrorIcon from '@material-ui/icons/ErrorOutline'; import SyncIcon from '@material-ui/icons/Sync'; +import { configApiRef, errorApiRef, useApi } from '@backstage/core-plugin-api'; +import { useUrlSync } from '../../hooks/useUrlSync'; const useStyles = makeStyles(theme => ({ formContainer: { @@ -43,21 +44,138 @@ const useStyles = makeStyles(theme => ({ marginLeft: 'auto', marginRight: 'auto', }, + + oauthSection: { + paddingTop: theme.spacing(0.5), + paddingBottom: theme.spacing(0.5), + }, + + oauthButton: { + display: 'block', + // Deliberately making this button bigger than the token button, because we + // want to start pushing users to use oauth as the default. The old token + // approach may end up getting deprecated + width: '100%', + }, + + divider: { + display: 'flex', + alignItems: 'center', + textAlign: 'center', + paddingTop: theme.spacing(2), + paddingBottom: theme.spacing(0.5), + + '&::before, &::after': { + content: '""', + flexGrow: 1, + borderBottom: `1px solid ${theme.palette.divider}`, + }, + }, + + dividerText: { + textTransform: 'uppercase', + padding: `0 ${theme.spacing(1)}px`, + color: theme.palette.text.secondary, + fontSize: '0.75rem', + fontWeight: 500, + }, + + tokenSection: { + paddingTop: `${theme.spacing(1.5)}px`, + }, + + tokenInstructions: { + margin: 0, + marginBottom: `-${theme.spacing(0.5)}px`, + }, })); export const CoderAuthInputForm = () => { const hookId = useId(); const styles = useStyles(); const appConfig = useCoderAppConfig(); + const urlSync = useUrlSync(); + const configApi = useApi(configApiRef); + const errorApi = useApi(errorApiRef); const { status, registerNewToken } = useInternalCoderAuth(); - const onSubmit = (event: FormEvent) => { - event.preventDefault(); - const formData = Object.fromEntries(new FormData(event.currentTarget)); - const newToken = - typeof formData.authToken === 'string' ? formData.authToken : ''; + const backendUrl = urlSync.state.baseUrl; + useEffect(() => { + if (!backendUrl) { + return undefined; + } + + const onOauthMessage = (event: MessageEvent): void => { + // Even though we're going to add the event listener to the window object + // directly, we still want to make sure that the event originated on the + // window, and wasn't received from a DOM node via event bubbling + if (event.target !== window) { + return; + } + + const backendOrigin = new URL(backendUrl).origin; + const originMismatch = event.origin !== backendOrigin; + if (originMismatch) { + return; + } + + const { data } = event; + const messageIsOauthPayload = + typeof data === 'object' && data !== null && 'token' in data; + if (!messageIsOauthPayload) { + return; + } + // For some reason, TypeScript won't narrow properly if you move this + // check to the messageIsOauthPayload boolean + if (typeof data.token === 'string') { + registerNewToken(data.token); + } + }; + + window.addEventListener('message', onOauthMessage); + return () => window.removeEventListener('message', onOauthMessage); + }, [registerNewToken, backendUrl]); + + const handleOAuthLogin = () => { + const clientId = configApi.getOptionalString('coder.oauth.clientId'); + if (!clientId) { + errorApi.post( + { + name: 'Coder oauth clientId is missing', + message: + 'Please see plugin documentation for how to add clientId to your Backstage deployment', + }, + { hidden: false }, + ); + return; + } + + const params = new URLSearchParams({ + /** + * @todo See what we can do to move the state calculations to the backend. + * The state should actually be cryptographically generated and should + * have a high number of bits of entropy, too. + */ + state: btoa(JSON.stringify({ returnTo: window.location.pathname })), + response_type: 'code', + client_id: clientId, + redirect_uri: `${backendUrl}/api/auth/coder/oauth/callback`, + }); + + const oauthUrl = `${ + appConfig.deployment.accessUrl + }/oauth2/authorize?${params.toString()}`; - registerNewToken(newToken); + const width = 800; + const height = 800; + const left = window.screen.width / 2 - width / 2; + const top = window.screen.height / 2 - height / 2; + + window.open( + oauthUrl, + 'Coder OAuth', + `width=${width},height=${height},left=${left},top=${top},popup=yes`, + ); }; const formHeaderId = `${hookId}-form-header`; @@ -69,7 +187,14 @@ export const CoderAuthInputForm = () => {
{ + event.preventDefault(); + const formData = Object.fromEntries(new FormData(event.currentTarget)); + const newToken = + typeof formData.authToken === 'string' ? formData.authToken : ''; + + registerNewToken(newToken); + }} >