Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ dist
dist-types
coverage
.vscode
.coder.yaml
app-config.local.yaml
10 changes: 8 additions & 2 deletions app-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ app:
organization:
name: Coder

coder:
deployment:
accessUrl: https://dev.coder.com
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe unused

oauth:
clientId: ${CODER_OAUTH_CLIENT_ID:-backstage}
clientSecret: ${CODER_OAUTH_CLIENT_SECRET:-change-me}
Comment on lines +8 to +13
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this should be here or somewhere else, but is useful for development.


backend:
# Used for enabling authentication, secret is shared by all backend plugins
# See https://backstage.io/docs/auth/service-to-service-auth for
Expand All @@ -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
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -55,5 +55,6 @@
"*.{json,md}": [
"prettier --write"
]
}
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
1 change: 1 addition & 0 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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));
Expand Down
1 change: 1 addition & 0 deletions plugins/backstage-plugin-coder-backend/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
49 changes: 49 additions & 0 deletions plugins/backstage-plugin-coder-backend/README.md
Original file line number Diff line number Diff line change
@@ -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.
49 changes: 49 additions & 0 deletions plugins/backstage-plugin-coder-backend/package.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
7 changes: 7 additions & 0 deletions plugins/backstage-plugin-coder-backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Backend plugin for Coder OAuth2 authentication
*
* @packageDocumentation
*/

export { createRouter } from './service/router';
136 changes: 136 additions & 0 deletions plugins/backstage-plugin-coder-backend/src/service/router.ts
Original file line number Diff line number Diff line change
@@ -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<express.Router> {
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(
`<html><body><h1>Authentication failed</h1><p>${
error instanceof Error ? error.message : 'Unknown error'
}</p></body></html>`,
);
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(
`<!DOCTYPE html>
<html>
<head>
<title>Authentication Failed</title>
</head>
<body>
<h1>Authentication failed</h1>
<p>${message}</p>
</body>
</html>`,
);
return;
}

// Return HTML that sends the token to the opener window via postMessage
res.setHeader('Content-Security-Policy', "script-src 'unsafe-inline'");
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Authentication Successful</title>
</head>
<body>
<p>Authentication successful! This window will close automatically...</p>
<script>
(function() {
// Send token to opener window via postMessage
if (window.opener) {
var targetOrigin;
try {
// Try to get the opener's origin
targetOrigin = window.opener.location.origin;
} catch (e) {
// If we can't access it due to cross-origin, use wildcard
// This is safe since we're only sending to our own opener
targetOrigin = '*';
}

window.opener.postMessage(
{ type: 'coder-oauth-success', token: '${access_token}' },
targetOrigin
);
setTimeout(function() { window.close(); }, 500);
} else {
document.body.innerHTML = '<p>Authentication successful! You can close this window.</p>';
}
})();
</script>
</body>
</html>
`);

logger.info('OAuth authentication successful');
});

router.get('/health', (_, response) => {
logger.info('Health check');
response.json({ status: 'ok' });
});

router.use(errorHandler());
return router;
}
26 changes: 25 additions & 1 deletion plugins/backstage-plugin-coder/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading