-
Notifications
You must be signed in to change notification settings - Fork 7
feat: Add OAuth2 authentication flow for Coder #151
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
b75669e
04d9ab2
9e09c3e
f7c5cec
6e50b24
90695ae
76bec6a
c77b45f
5d45128
e6a2bd9
216b4e5
993fb2b
dc8d0de
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,3 +2,5 @@ dist | |
| dist-types | ||
| coverage | ||
| .vscode | ||
| .coder.yaml | ||
| app-config.local.yaml | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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} | ||
|
Comment on lines
+8
to
+13
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
@@ -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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -136,6 +136,11 @@ const coderAppConfig: CoderAppConfig = { | |
| accessUrl: 'https://dev.coder.com', | ||
| }, | ||
|
|
||
| oauth: { | ||
| clientId: '09cd00cf-9517-401c-9601-3712f187b53c', | ||
| backendUrl: 'http://localhost:7007', | ||
| }, | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've been having trouble piping config from appConfig data into the frontend apps, so this is still hard coded to get things working for now. |
||
|
|
||
| workspaces: { | ||
| defaultTemplateName: 'devcontainers', | ||
| defaultMode: 'manual', | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); | ||
|
f0ssel marked this conversation as resolved.
|
||
| 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" | ||
| ] | ||
| } |
| 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'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| 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 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; | ||
| } | ||
|
|
||
| try { | ||
| // Get Coder configuration from coder.oauth | ||
| const coderConfig = config.getOptionalConfig('coder'); | ||
|
|
||
| const accessUrl = coderConfig?.getString('deployment.accessUrl') || ''; | ||
| const clientId = coderConfig?.getString('oauth.clientId') || 'backstage'; | ||
| const clientSecret = coderConfig?.getString('oauth.clientSecret') || ''; | ||
| const redirectUri = `${req.protocol}://${req.get( | ||
| 'host', | ||
| )}/api/auth/coder/oauth/callback`; | ||
|
|
||
| // Exchange authorization code for access token | ||
| const 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', | ||
| }, | ||
| }, | ||
| ); | ||
|
|
||
| const { access_token } = tokenResponse.data; | ||
|
|
||
| // 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'); | ||
| return; | ||
| } 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; | ||
| } | ||
| }); | ||
|
|
||
| router.get('/health', (_, response) => { | ||
| logger.info('Health check'); | ||
| response.json({ status: 'ok' }); | ||
| }); | ||
|
|
||
| router.use(errorHandler()); | ||
| return router; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| }; | ||
| }; | ||
| } | ||
|
Comment on lines
+1
to
+28
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Very cool I had no clue how to do this lol |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,6 +23,11 @@ const appConfig: CoderAppConfig = { | |
| accessUrl: 'https://dev.coder.com', | ||
| }, | ||
|
|
||
| oauth: { | ||
| clientId: '09cd00cf-9517-401c-9601-3712f187b53c', | ||
| backendUrl: 'http://localhost:7007', | ||
| }, | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same appConfig issue here |
||
|
|
||
| workspaces: { | ||
| defaultTemplateName: 'devcontainers', | ||
| defaultMode: 'manual', | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe unused