From b75669ebf20828dbd3261d1d54da0ad903392301 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Fri, 12 Sep 2025 18:48:35 +0000 Subject: [PATCH 01/12] chore: fix dev server config --- app-config.yaml | 3 +-- package.json | 7 ++--- scripts/dev-init.sh | 7 +++-- yarn.lock | 63 ++++++++++++++++++++++++++------------------- 4 files changed, 44 insertions(+), 36 deletions(-) mode change 100644 => 100755 scripts/dev-init.sh diff --git a/app-config.yaml b/app-config.yaml index 3dc58953..286b634f 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -15,8 +15,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/scripts/dev-init.sh b/scripts/dev-init.sh old mode 100644 new mode 100755 index c505e619..774291b2 --- a/scripts/dev-init.sh +++ b/scripts/dev-init.sh @@ -1,6 +1,6 @@ #!/bin/bash -# Run a basic postgreSQL container on localhost:5555 with basic username and password +# Run a basic postgreSQL container on localhost:5432 with basic username and password CONTAINER_NAME="backstage_db" # Check if the container is already running @@ -11,10 +11,9 @@ else docker run --name $CONTAINER_NAME \ -e POSTGRES_PASSWORD=postgres \ -e POSTGRES_USER=postgres \ - -p 5555:5432 \ - -v backstage_data:/var/lib/postgresql/data \ + -p 5432:5432 \ -d postgres echo "Container $CONTAINER_NAME started." fi -echo "Running backend on http://localhost:7007" +echo "Running postgres db on localhost:5432" diff --git a/yarn.lock b/yarn.lock index c287f84a..abbe5a5c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8718,12 +8718,10 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== -"@types/react-dom@*", "@types/react-dom@^18.0.0": - version "18.3.0" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.0.tgz#0cbc818755d87066ab6ca74fbedb2547d74a82b0" - integrity sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg== - dependencies: - "@types/react" "*" +"@types/react-dom@*", "@types/react-dom@^18", "@types/react-dom@^18.0.0": + version "18.3.7" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.7.tgz#b89ddf2cd83b4feafcc4e2ea41afdfb95a0d194f" + integrity sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ== "@types/react-redux@^7.1.20": version "7.1.33" @@ -8756,21 +8754,12 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^16.13.1 || ^17.0.0 || ^18.0.0": - version "18.3.2" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.2.tgz#462ae4904973bc212fa910424d901e3d137dbfcd" - integrity sha512-Btgg89dAnqD4vV7R3hlwOxgqobUQKgx3MmrQRi0yYbs/P0ym8XozIAlkqVilPqHQwXs4e9Tf63rrCgl58BcO4w== - dependencies: - "@types/prop-types" "*" - csstype "^3.0.2" - -"@types/react@^16.13.1 || ^17.0.0": - version "17.0.80" - resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.80.tgz#a5dfc351d6a41257eb592d73d3a85d3b7dbcbb41" - integrity sha512-LrgHIu2lEtIo8M7d1FcI3BdwXWoRQwMoXOZ7+dPTW0lYREjmlHl3P0U1VD0i/9tppOuv8/sam7sOjx34TxSFbA== +"@types/react@*", "@types/react@^16.13.1 || ^17.0.0", "@types/react@^16.13.1 || ^17.0.0 || ^18.0.0", "@types/react@^18": + version "18.3.24" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.24.tgz#f6a5a4c613242dfe3af0dcee2b4ec47b92d9b6bd" + integrity sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A== dependencies: "@types/prop-types" "*" - "@types/scheduler" "^0.16" csstype "^3.0.2" "@types/request@^2.47.1", "@types/request@^2.48.8": @@ -8800,11 +8789,6 @@ resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== -"@types/scheduler@^0.16": - version "0.16.8" - resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.8.tgz#ce5ace04cfeabe7ef87c0091e50752e36707deff" - integrity sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A== - "@types/semver@^7.3.12", "@types/semver@^7.5.0": version "7.5.7" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.7.tgz#326f5fdda70d13580777bcaa1bc6fa772a5aef0e" @@ -21927,7 +21911,16 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -22001,7 +21994,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -22015,6 +22008,13 @@ strip-ansi@5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -23828,7 +23828,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -23846,6 +23846,15 @@ wrap-ansi@^6.0.1: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From 04d9ab2aaff4ce26a93994411d1968f2eda44578 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse <19379394+f0ssel@users.noreply.github.com> Date: Mon, 13 Oct 2025 13:52:35 +0000 Subject: [PATCH 02/12] feat: Add OAuth2 authentication flow for Coder Added OAuth2 sign-in capability to the Coder Backstage plugin, allowing users to authenticate via Coder's OAuth2 provider instead of manually entering tokens. Frontend changes: - Added 'Sign in with Coder' OAuth2 button to CoderAuthInputForm - OAuth button opens popup window for better UX - PostMessage-based token exchange between popup and parent window - Configurable via appConfig.oauth.clientId and appConfig.oauth.backendUrl - Falls back to manual token entry if needed Backend changes: - Created new backend plugin (@coder/backstage-plugin-coder-backend) - Handles OAuth2 callback at /api/auth/coder/oauth/callback - Exchanges authorization codes for access tokens - Returns tokens to frontend via postMessage - Configurable OAuth credentials via app-config (coder.oauth.*) Key features: - Clean separation between frontend and backend concerns - Secure token exchange with configurable backend URL validation - Maintains existing manual token entry as fallback option --- .prettierignore | 2 + app-config.yaml | 7 + .../app/src/components/catalog/EntityPage.tsx | 5 + packages/backend/package.json | 1 + packages/backend/src/index.ts | 9 ++ .../.eslintrc.js | 1 + .../package.json | 49 +++++++ .../src/index.ts | 7 + .../src/service/router.ts | 121 +++++++++++++++++ plugins/backstage-plugin-coder/README.md | 6 + .../backstage-plugin-coder/dev/DevPage.tsx | 5 + .../CoderAuthForm/CoderAuthInputForm.tsx | 122 ++++++++++++++++-- .../CoderProvider/CoderAppConfigProvider.tsx | 5 + plugins/backstage-plugin-coder/src/plugin.ts | 7 +- 14 files changed, 332 insertions(+), 15 deletions(-) create mode 100644 plugins/backstage-plugin-coder-backend/.eslintrc.js create mode 100644 plugins/backstage-plugin-coder-backend/package.json create mode 100644 plugins/backstage-plugin-coder-backend/src/index.ts create mode 100644 plugins/backstage-plugin-coder-backend/src/service/router.ts 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 286b634f..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 diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 6c4f9df1..8cf92ca5 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -136,6 +136,11 @@ const coderAppConfig: CoderAppConfig = { accessUrl: 'https://dev.coder.com', }, + oauth: { + clientId: '09cd00cf-9517-401c-9601-3712f187b53c', + backendUrl: 'http://localhost:7007', + }, + workspaces: { defaultTemplateName: 'devcontainers', defaultMode: 'manual', 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/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..1dceec87 --- /dev/null +++ b/plugins/backstage-plugin-coder-backend/src/service/router.ts @@ -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 { + const { logger, config } = options; + + const router = Router(); + router.use(express.json()); + + // OAuth callback endpoint + router.get('/oauth/callback', async (req, res) => { + const { code, state } = 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(` + + + + Authentication Successful + + +

Authentication successful! This window will close automatically...

+ + + + `); + + logger.info('OAuth authentication successful'); + return; + } catch (error) { + logger.error('OAuth token exchange failed', error); + res + .status(500) + .send( + `

Authentication failed

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

`, + ); + return; + } + }); + + 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..84382507 100644 --- a/plugins/backstage-plugin-coder/README.md +++ b/plugins/backstage-plugin-coder/README.md @@ -59,6 +59,12 @@ the Dev Container. accessUrl: 'https://coder.example.com', }, + // Optional: OAuth configuration for "Sign in with Coder" button + // Get the clientId from your Coder OAuth2 apps settings + oauth: { + clientId: 'your-oauth-client-id', + }, + // Set the default template (and parameters) for // catalog items. Individual properties can be overridden // by a repo's catalog-info.yaml file diff --git a/plugins/backstage-plugin-coder/dev/DevPage.tsx b/plugins/backstage-plugin-coder/dev/DevPage.tsx index abc24008..7a8843f9 100644 --- a/plugins/backstage-plugin-coder/dev/DevPage.tsx +++ b/plugins/backstage-plugin-coder/dev/DevPage.tsx @@ -23,6 +23,11 @@ const appConfig: CoderAppConfig = { accessUrl: 'https://dev.coder.com', }, + oauth: { + clientId: '09cd00cf-9517-401c-9601-3712f187b53c', + backendUrl: 'http://localhost:7007', + }, + workspaces: { defaultTemplateName: 'devcontainers', defaultMode: 'manual', diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthInputForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthInputForm.tsx index ae527e28..afdeca40 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthInputForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthInputForm.tsx @@ -1,4 +1,4 @@ -import React, { type FormEvent, useState } from 'react'; +import React, { type FormEvent, useState, useEffect } from 'react'; import { useId } from '../../hooks/hookPolyfills'; import { type CoderAuthStatus, @@ -43,6 +43,37 @@ const useStyles = makeStyles(theme => ({ marginLeft: 'auto', marginRight: 'auto', }, + + oauthSection: { + marginTop: theme.spacing(3), + marginBottom: theme.spacing(2), + }, + + oauthButton: { + display: 'block', + width: '100%', + maxWidth: '100%', + }, + + divider: { + display: 'flex', + alignItems: 'center', + textAlign: 'center', + marginTop: theme.spacing(2), + marginBottom: theme.spacing(2), + + '&::before, &::after': { + content: '""', + flex: 1, + borderBottom: `1px solid ${theme.palette.divider}`, + }, + }, + + dividerText: { + padding: `0 ${theme.spacing(1)}px`, + color: theme.palette.text.secondary, + fontSize: '0.875rem', + }, })); export const CoderAuthInputForm = () => { @@ -51,6 +82,28 @@ export const CoderAuthInputForm = () => { const appConfig = useCoderAppConfig(); const { status, registerNewToken } = useInternalCoderAuth(); + useEffect(() => { + const handleOAuthMessage = (event: MessageEvent) => { + // Verify the message is from our OAuth callback backend + const backendUrl = appConfig.oauth?.backendUrl; + + // If backendUrl is configured, verify the origin matches + if (backendUrl) { + const backendOrigin = new URL(backendUrl).origin; + if (event.origin !== backendOrigin) { + return; + } + } + + if (event.data?.type === 'coder-oauth-success' && event.data?.token) { + registerNewToken(event.data.token); + } + }; + + window.addEventListener('message', handleOAuthMessage); + return () => window.removeEventListener('message', handleOAuthMessage); + }, [registerNewToken, appConfig.oauth?.backendUrl]); + const onSubmit = (event: FormEvent) => { event.preventDefault(); const formData = Object.fromEntries(new FormData(event.currentTarget)); @@ -60,6 +113,32 @@ export const CoderAuthInputForm = () => { registerNewToken(newToken); }; + const handleOAuthLogin = () => { + const authUrl = `${appConfig.deployment.accessUrl}/oauth2/authorize`; + const clientId = appConfig.oauth?.clientId || 'backstage'; + const backendUrl = + appConfig.oauth?.backendUrl || + `${window.location.protocol}//${window.location.hostname}:7007`; + const redirectUri = `${backendUrl}/api/auth/coder/oauth/callback`; + const state = btoa(JSON.stringify({ returnTo: window.location.pathname })); + + const oauthUrl = `${authUrl}?client_id=${clientId}&redirect_uri=${encodeURIComponent( + redirectUri, + )}&response_type=code&state=${state}`; + + // Open OAuth flow in popup window + const width = 600; + const height = 700; + 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`; const legendId = `${hookId}-legend`; const authTokenInputId = `${hookId}-auth-token`; @@ -77,20 +156,37 @@ export const CoderAuthInputForm = () => {
-

- Link your Coder account to create remote workspaces. Please enter a - new token from your{' '} - - Coder deployment's token page - (link opens in new tab) - - . -

+

Link your Coder account to create remote workspaces.

+
+ + Sign in with Coder + +
+ +
+ OR +
+ +

+ Alternatively, enter a token from your{' '} + + Coder deployment's token page + (link opens in new tab) + + . +

+