Skip to content

Commit eb19112

Browse files
f0sselParkreiner
andauthored
feat: Add OAuth2 authentication flow for Coder (#151)
feat: implement oauth2 support for Coder --------- Co-authored-by: Garrett Delfosse <[email protected]> Co-authored-by: Parkreiner <[email protected]> Co-authored-by: Michael Smith <[email protected]>
1 parent 9da52e6 commit eb19112

File tree

21 files changed

+619
-145
lines changed

21 files changed

+619
-145
lines changed

.prettierignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ dist
22
dist-types
33
coverage
44
.vscode
5+
.coder.yaml
6+
app-config.local.yaml

app-config.yaml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ app:
55
organization:
66
name: Coder
77

8+
coder:
9+
deployment:
10+
accessUrl: https://dev.coder.com
11+
oauth:
12+
clientId: ${CODER_OAUTH_CLIENT_ID:-backstage}
13+
clientSecret: ${CODER_OAUTH_CLIENT_SECRET:-change-me}
14+
815
backend:
916
# Used for enabling authentication, secret is shared by all backend plugins
1017
# See https://backstage.io/docs/auth/service-to-service-auth for
@@ -15,8 +22,7 @@ backend:
1522
baseUrl: http://localhost:7007
1623
listen:
1724
port: 7007
18-
# Uncomment the following host directive to bind to specific interfaces
19-
# host: 127.0.0.1
25+
host: localhost
2026
csp:
2127
connect-src: ["'self'", 'http:', 'https:']
2228
# Content-Security-Policy directives follow the Helmet format: https://helmetjs.github.io/#reference

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
"node": "18 || 20"
77
},
88
"scripts": {
9-
"dev": "concurrently \"yarn dev-init\" \"yarn start\" \"yarn start-backend\"",
10-
"dev-init": "/bin/bash ./scripts/dev-init.sh",
9+
"dev": "yarn dev-init && concurrently --names \"react,backend\" -c \"green,blue\" \"yarn start\" \"yarn start-backend\"",
10+
"dev-init": "./scripts/dev-init.sh",
1111
"start": "yarn workspace app start",
1212
"start-backend": "yarn workspace backend start",
1313
"build:backend": "yarn workspace backend build",
@@ -55,5 +55,6 @@
5555
"*.{json,md}": [
5656
"prettier --write"
5757
]
58-
}
58+
},
59+
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
5960
}

packages/backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"@backstage/plugin-search-backend-module-techdocs": "^0.1.13",
3838
"@backstage/plugin-search-backend-node": "^1.2.13",
3939
"@backstage/plugin-techdocs-backend": "^1.9.2",
40+
"@coder/backstage-plugin-coder-backend": "0.0.0",
4041
"@coder/backstage-plugin-devcontainers-backend": "0.0.0",
4142
"app": "link:../app",
4243
"better-sqlite3": "^9.0.0",

packages/backend/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import search from './plugins/search';
3131
import { PluginEnvironment } from './types';
3232
import { ServerPermissionClient } from '@backstage/plugin-permission-node';
3333
import { DefaultIdentityClient } from '@backstage/plugin-auth-node';
34+
import { createRouter as createCoderRouter } from '@coder/backstage-plugin-coder-backend';
3435

3536
function makeCreateEnv(config: Config) {
3637
const root = getRootLogger();
@@ -85,10 +86,18 @@ async function main() {
8586
const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs'));
8687
const searchEnv = useHotMemoize(module, () => createEnv('search'));
8788
const appEnv = useHotMemoize(module, () => createEnv('app'));
89+
const coderEnv = useHotMemoize(module, () => createEnv('coder'));
8890

8991
const apiRouter = Router();
9092
apiRouter.use('/catalog', await catalog(catalogEnv));
9193
apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv));
94+
apiRouter.use(
95+
'/auth/coder',
96+
await createCoderRouter({
97+
logger: coderEnv.logger,
98+
config: coderEnv.config,
99+
}),
100+
);
92101
apiRouter.use('/auth', await auth(authEnv));
93102
apiRouter.use('/techdocs', await techdocs(techdocsEnv));
94103
apiRouter.use('/proxy', await proxy(proxyEnv));
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# `backstage-plugin-coder-backend`
2+
3+
> [!NOTE]
4+
> 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.
5+
6+
## Features
7+
8+
- Management of OAuth2 state for requests sent from the Backstage backend.
9+
10+
## Installing the plugin to support oauth2
11+
12+
1. Run the following command from your Backstage app to install the plugin:
13+
```bash
14+
yarn --cwd packages/app add @coder/backstage-plugin-coder
15+
```
16+
2. Import the `createRouter` function from the `@coder/backstage-plugin-coder` package:
17+
```ts
18+
// Imports can be renamed if there would be a name conflict
19+
import { createRouter as createCoderRouter } from '@coder/backstage-plugin-coder-backend';
20+
```
21+
3. Add support for Coder hot module reloading to `main` function in your deployment's `backend/src/index.ts` file:
22+
```ts
23+
const coderEnv = useHotMemoize(module, () => createEnv('coder'));
24+
```
25+
4. Register the plugin's oauth route with Backstage from inside the same `main` function:
26+
```ts
27+
apiRouter.use(
28+
'/auth/coder',
29+
await createCoderRouter({
30+
logger: coderEnv.logger,
31+
config: coderEnv.config,
32+
}),
33+
);
34+
```
35+
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).
36+
6. Add the following values to one of your `app-config.yaml` files:
37+
```yaml
38+
coder:
39+
deployment:
40+
# Change the value to match your Coder deployment
41+
accessUrl: https://dev.coder.com
42+
oauth:
43+
clientId: oauth2-client-id-goes-here
44+
# The client secret isn't used by the frontend plugin, but the backend
45+
# plugin needs it for oauth functionality to work
46+
clientSecret: oauth2-secret-goes-here
47+
```
48+
49+
Note that the `clientSecret` value is given `secret`-level visibility, and will never be logged anywhere by Backstage.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{
2+
"name": "@coder/backstage-plugin-coder-backend",
3+
"description": "Backend plugin for Coder OAuth2 authentication flow",
4+
"version": "0.0.0",
5+
"main": "src/index.ts",
6+
"types": "src/index.ts",
7+
"license": "Apache-2.0",
8+
"publishConfig": {
9+
"access": "public",
10+
"main": "dist/index.cjs.js",
11+
"types": "dist/index.d.ts"
12+
},
13+
"backstage": {
14+
"role": "backend-plugin"
15+
},
16+
"scripts": {
17+
"start": "backstage-cli package start",
18+
"build": "backstage-cli package build",
19+
"lint": "backstage-cli package lint",
20+
"test": "backstage-cli package test",
21+
"clean": "backstage-cli package clean",
22+
"prepack": "backstage-cli package prepack",
23+
"postpack": "backstage-cli package postpack"
24+
},
25+
"dependencies": {
26+
"@backstage/backend-common": "^0.20.1",
27+
"@backstage/config": "^1.1.1",
28+
"@backstage/errors": "^1.2.3",
29+
"@types/express": "*",
30+
"express": "^4.17.1",
31+
"express-promise-router": "^4.1.0",
32+
"winston": "^3.2.1",
33+
"axios": "^1.6.8"
34+
},
35+
"devDependencies": {
36+
"@backstage/cli": "^0.25.1",
37+
"@types/supertest": "^2.0.12",
38+
"supertest": "^6.2.4"
39+
},
40+
"files": [
41+
"dist"
42+
],
43+
"keywords": [
44+
"backstage",
45+
"coder",
46+
"oauth2",
47+
"authentication"
48+
]
49+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* Backend plugin for Coder OAuth2 authentication
3+
*
4+
* @packageDocumentation
5+
*/
6+
7+
export { createRouter } from './service/router';
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { errorHandler } from '@backstage/backend-common';
2+
import { Config } from '@backstage/config';
3+
import express from 'express';
4+
import Router from 'express-promise-router';
5+
import { Logger } from 'winston';
6+
import axios, { type AxiosResponse } from 'axios';
7+
8+
export interface RouterOptions {
9+
logger: Logger;
10+
config: Config;
11+
}
12+
13+
export async function createRouter(
14+
options: RouterOptions,
15+
): Promise<express.Router> {
16+
const { logger, config } = options;
17+
18+
const router = Router();
19+
router.use(express.json());
20+
21+
// OAuth callback endpoint
22+
router.get('/oauth/callback', async (req, res) => {
23+
const { code } = req.query;
24+
25+
if (!code || typeof code !== 'string') {
26+
logger.error('OAuth callback missing authorization code');
27+
res.status(400).send('Missing authorization code');
28+
return;
29+
}
30+
31+
const coderConfig = config.getOptionalConfig('coder');
32+
const accessUrl = coderConfig?.getString('deployment.accessUrl') || '';
33+
const clientId = coderConfig?.getString('oauth.clientId') || '';
34+
const clientSecret = coderConfig?.getString('oauth.clientSecret') || '';
35+
const redirectUri = `${req.protocol}://${req.get(
36+
'host',
37+
)}/api/auth/coder/oauth/callback`;
38+
39+
let tokenResponse: AxiosResponse<{ access_token?: string }, unknown>;
40+
try {
41+
// Exchange authorization code for access token
42+
tokenResponse = await axios.post(
43+
`${accessUrl}/oauth2/tokens`,
44+
new URLSearchParams({
45+
grant_type: 'authorization_code',
46+
code,
47+
redirect_uri: redirectUri,
48+
client_id: clientId,
49+
client_secret: clientSecret,
50+
}),
51+
{
52+
headers: {
53+
'Content-Type': 'application/x-www-form-urlencoded',
54+
},
55+
},
56+
);
57+
} catch (error) {
58+
logger.error('OAuth token exchange failed', error);
59+
res
60+
.status(500)
61+
.send(
62+
`<html><body><h1>Authentication failed</h1><p>${
63+
error instanceof Error ? error.message : 'Unknown error'
64+
}</p></body></html>`,
65+
);
66+
return;
67+
}
68+
69+
const { access_token } = tokenResponse.data;
70+
if (!access_token) {
71+
const message = 'Coder deployment did not respond with access token';
72+
logger.error(message);
73+
res.status(502).send(
74+
`<!DOCTYPE html>
75+
<html>
76+
<head>
77+
<title>Authentication Failed</title>
78+
</head>
79+
<body>
80+
<h1>Authentication failed</h1>
81+
<p>${message}</p>
82+
</body>
83+
</html>`,
84+
);
85+
return;
86+
}
87+
88+
// Return HTML that sends the token to the opener window via postMessage
89+
res.setHeader('Content-Security-Policy', "script-src 'unsafe-inline'");
90+
res.send(`
91+
<!DOCTYPE html>
92+
<html>
93+
<head>
94+
<title>Authentication Successful</title>
95+
</head>
96+
<body>
97+
<p>Authentication successful! This window will close automatically...</p>
98+
<script>
99+
(function() {
100+
// Send token to opener window via postMessage
101+
if (window.opener) {
102+
var targetOrigin;
103+
try {
104+
// Try to get the opener's origin
105+
targetOrigin = window.opener.location.origin;
106+
} catch (e) {
107+
// If we can't access it due to cross-origin, use wildcard
108+
// This is safe since we're only sending to our own opener
109+
targetOrigin = '*';
110+
}
111+
112+
window.opener.postMessage(
113+
{ type: 'coder-oauth-success', token: '${access_token}' },
114+
targetOrigin
115+
);
116+
setTimeout(function() { window.close(); }, 500);
117+
} else {
118+
document.body.innerHTML = '<p>Authentication successful! You can close this window.</p>';
119+
}
120+
})();
121+
</script>
122+
</body>
123+
</html>
124+
`);
125+
126+
logger.info('OAuth authentication successful');
127+
});
128+
129+
router.get('/health', (_, response) => {
130+
logger.info('Health check');
131+
response.json({ status: 'ok' });
132+
});
133+
134+
router.use(errorHandler());
135+
return router;
136+
}

0 commit comments

Comments
 (0)