Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit eb19112

Browse filesBrowse files
feat: Add OAuth2 authentication flow for Coder (#151)
feat: implement oauth2 support for Coder --------- Co-authored-by: Garrett Delfosse <19379394+f0ssel@users.noreply.github.com> Co-authored-by: Parkreiner <throwawayclover@gmail.com> Co-authored-by: Michael Smith <michaelsmith@coder.com>
1 parent 9da52e6 commit eb19112
Copy full SHA for eb19112

21 files changed

+619-145Lines changed: 619 additions & 145 deletions

File tree

Expand file treeCollapse file tree
Open diff view settings
Filter options
Expand file treeCollapse file tree
Open diff view settings
Collapse file

‎.prettierignore‎

Copy file name to clipboardExpand all lines: .prettierignore
+2Lines 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
Collapse file

‎app-config.yaml‎

Copy file name to clipboardExpand all lines: app-config.yaml
+8-2Lines 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
Collapse file

‎package.json‎

Copy file name to clipboardExpand all lines: package.json
+4-3Lines 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": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
5960
}
Collapse file

‎packages/backend/package.json‎

Copy file name to clipboardExpand all lines: packages/backend/package.json
+1Lines 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",
Collapse file

‎packages/backend/src/index.ts‎

Copy file name to clipboardExpand all lines: packages/backend/src/index.ts
+9Lines 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));
Collapse file
+1Lines 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);
Collapse file
+49Lines changed: 49 additions & 0 deletions
  • Display the source diff
  • Display the rich diff
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.
Collapse file
+49Lines 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+
}
Collapse file
+7Lines 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';
Collapse file
+136Lines 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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.