From 95a56c6545cb766a258d218cae0cefb8c69d24b9 Mon Sep 17 00:00:00 2001 From: Ilya Buziuk Date: Thu, 5 Nov 2026 12:59:33 +0100 Subject: [PATCH 1/4] feat: secure devfile endpoint with OpenShift OAuth Proxy Signed-off-by: Ilya Buziuk --- devfile.yaml | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/devfile.yaml b/devfile.yaml index fb5b5b8..78a0474 100644 --- a/devfile.yaml +++ b/devfile.yaml @@ -16,6 +16,18 @@ metadata: website: https://devfile.io attributes: controller.devfile.io/storage-type: ephemeral + # The oauth-proxy sidecar authenticates against OpenShift as a service account + # OAuth client, so the workspace pod has to run as the ServiceAccount declared by + # the `oauth-proxy-sa` component instead of the one generated per workspace. + pod-overrides: + spec: + serviceAccountName: wizard-oauth +variables: + # Namespace the workspace runs in. Used for the access check performed by + # oauth-proxy: only users who can `get pods` in this namespace are let through. + namespace: CHANGE_ME + # Seed for the session cookie: openssl rand -base64 32 + cookie-secret: CHANGE_ME components: - name: nodejs-dev container: @@ -23,15 +35,81 @@ components: mountSources: true sourceMapping: /projects endpoints: + # Not exposed directly: the dev server is only reachable through the + # oauth-proxy sidecar below. Set `exposure: public` to also get the + # usual unauthenticated (or gateway-authenticated) workspace URL back. - name: dev-server targetPort: 5173 protocol: http - exposure: public + exposure: none env: - name: NODE_ENV value: development memoryLimit: 2Gi cpuLimit: 1000m + # OpenShift OAuth in front of the Vite dev server. The proxy shares the pod + # network with nodejs-dev, so it reaches Vite over 127.0.0.1 and Vite itself + # never has to listen on a routable address. + - name: oauth-proxy + container: + image: quay.io/openshift/origin-oauth-proxy:4.18 + mountSources: false + command: + - /usr/bin/oauth-proxy + args: + - --provider=openshift + - --http-address=0.0.0.0:4180 + - --https-address= + - --upstream=http://127.0.0.1:5173 + - --openshift-service-account=wizard-oauth + - '--openshift-sar={"namespace":"{{namespace}}","resource":"pods","verb":"get"}' + - --cookie-secret={{cookie-secret}} + - --cookie-secure=true + - --pass-basic-auth=false + - --skip-provider-button=true + endpoints: + # `discoverable` makes the operator create a Service named after the + # endpoint, which gives the Route below a stable target to point at. + - name: wizard-oauth + targetPort: 4180 + protocol: http + exposure: internal + attributes: + discoverable: true + memoryLimit: 256Mi + memoryRequest: 64Mi + cpuLimit: 200m + cpuRequest: 50m + - name: oauth-proxy-sa + openshift: + deployByDefault: true + inlined: | + apiVersion: v1 + kind: ServiceAccount + metadata: + name: wizard-oauth + annotations: + # Registers the Route below as a valid OAuth redirect target for this + # service account, so the callback URL does not have to be hardcoded. + serviceaccounts.openshift.io/oauth-redirectreference.primary: '{"kind":"OAuthRedirectReference","apiVersion":"v1","reference":{"kind":"Route","name":"wizard-oauth"}}' + - name: oauth-proxy-route + openshift: + deployByDefault: true + inlined: | + apiVersion: route.openshift.io/v1 + kind: Route + metadata: + name: wizard-oauth + spec: + to: + kind: Service + name: wizard-oauth + weight: 100 + port: + targetPort: 4180 + tls: + termination: edge + insecureEdgeTerminationPolicy: Redirect - name: node-modules volume: size: 1Gi From 16c3ddf59899ad3f4953a6eb9359ef1fd5a22786 Mon Sep 17 00:00:00 2001 From: Ilya Buziuk Date: Thu, 5 Nov 2026 13:18:07 +0100 Subject: [PATCH 2/4] fix: pre-create SA for OAuth proxy Signed-off-by: Ilya Buziuk --- README.md | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++ devfile.yaml | 51 ++++++++++++++++++----------- 2 files changed, 124 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 59138ba..f30854d 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,98 @@ To remove all resources: kubectl delete -k openshift ``` +## OAuth-protected dev server + +`devfile.yaml` runs the Vite dev server behind an [openshift/oauth-proxy](https://github.com/openshift/oauth-proxy) +sidecar, on a dedicated OpenShift Route. Only users who can `get pods` in the workspace +namespace can reach it; the dev server itself is not exposed (`exposure: none`), so it is +reachable only over the pod loopback. + +This is meant for internal development on a cluster, not as a general-purpose hardening +recipe. A dedicated Route is needed because both the DevWorkspace basic solver and the Che +gateway put every endpoint on the shared workspace hostname under a rewritten path prefix, +where the `/oauth/callback` path would not route back to the proxy. + +### How it is wired + +| Piece | Where it lives | +|-------|----------------| +| `oauth-proxy` sidecar, listening on 4180, upstream `127.0.0.1:5173` | `oauth-proxy` container component | +| Service `wizard-oauth` | generated by the operator from the endpoint's `discoverable: true` attribute | +| Route `wizard-oauth` (edge TLS, redirect) | `oauth-proxy-route` OpenShift component, `deployByDefault: true` | +| OAuth client identity | `wizard-oauth` ServiceAccount, created once in the namespace | + +The workspace pod keeps the ServiceAccount the DevWorkspace Operator generates for it, so it +keeps its SCC and role bindings. The proxy authenticates as the `wizard-oauth` OAuth client +using that ServiceAccount's token, mounted into the workspace as an automounted secret. + +### One-time namespace setup + +Run once per namespace, before starting the workspace: + +```bash +oc create sa wizard-oauth + +# Registers the Route as a valid redirect target, so the callback URL is resolved +# dynamically and never has to be hardcoded. +oc annotate sa wizard-oauth \ + serviceaccounts.openshift.io/oauth-redirectreference.primary='{"kind":"OAuthRedirectReference","apiVersion":"v1","reference":{"kind":"Route","name":"wizard-oauth"}}' + +# Token for that OAuth client, automounted into the workspace at /var/run/oauth-client. +oc apply -f - <<'EOF' +apiVersion: v1 +kind: Secret +metadata: + name: wizard-oauth-client + labels: + controller.devfile.io/mount-to-devworkspace: "true" + controller.devfile.io/watch-secret: "true" + annotations: + kubernetes.io/service-account.name: wizard-oauth + controller.devfile.io/mount-as: file + controller.devfile.io/mount-path: /var/run/oauth-client +type: kubernetes.io/service-account-token +EOF +``` + +Both labels are required: `mount-to-devworkspace` is what the operator selects on, and +without `watch-secret` the controller's cache never sees the secret. The secret has to exist +before the workspace starts, since automounting happens before inlined OpenShift components +are applied. + +Then set the two variables at the top of `devfile.yaml`: + +| Variable | Value | +|----------|-------| +| `namespace` | the namespace the workspace runs in — used for `--client-id` and the access check | +| `cookie-secret` | `openssl rand -base64 32` | + +Once the workspace is running, the URL is: + +```bash +oc get route wizard-oauth -o jsonpath='{.spec.host}{"\n"}' +``` + +### Notes and troubleshooting + +- **`unable to validate against any security context constraint`** — something overrode the + pod's ServiceAccount. The SCC is bound to the generated `-sa`; a pod running + as any other ServiceAccount loses it. +- **`invalid client`** on login — the `namespace` variable does not match the real namespace, + so `--client-id` names a ServiceAccount that does not exist. +- **403 after logging in** — expected when the user cannot `get pods` in the namespace. Adjust + the `--openshift-sar` argument to match who should have access. +- Do not pre-create the `wizard-oauth` Route by hand: the operator refuses to adopt objects it + does not own and the workspace will fail to start. +- The ServiceAccount token is mounted into every workspace container, including `nodejs-dev`. +- Names are fixed, so two workspaces running this devfile in the same namespace collide on the + Service and Route. +- Termination is `edge`, so router-to-pod traffic is plaintext inside the cluster. Reencrypt + would need the serving-cert secret mounted into the sidecar, which devfile container + components cannot do directly. +- To get the usual unauthenticated (or gateway-authenticated) dev server URL back, set the + `dev-server` endpoint to `exposure: public`. + ## Technology Stack - **Frontend Framework:** React 18.3.1 diff --git a/devfile.yaml b/devfile.yaml index 78a0474..9b44f38 100644 --- a/devfile.yaml +++ b/devfile.yaml @@ -16,12 +16,6 @@ metadata: website: https://devfile.io attributes: controller.devfile.io/storage-type: ephemeral - # The oauth-proxy sidecar authenticates against OpenShift as a service account - # OAuth client, so the workspace pod has to run as the ServiceAccount declared by - # the `oauth-proxy-sa` component instead of the one generated per workspace. - pod-overrides: - spec: - serviceAccountName: wizard-oauth variables: # Namespace the workspace runs in. Used for the access check performed by # oauth-proxy: only users who can `get pods` in this namespace are let through. @@ -50,6 +44,30 @@ components: # OpenShift OAuth in front of the Vite dev server. The proxy shares the pod # network with nodejs-dev, so it reaches Vite over 127.0.0.1 and Vite itself # never has to listen on a routable address. + # + # One-time setup in the workspace namespace (see README section "OAuth-protected + # dev server"). The pod keeps the ServiceAccount the operator generates for it - + # the proxy authenticates as the `wizard-oauth` OAuth client using a token that + # is mounted into the workspace, so no pod ServiceAccount override (and no SCC + # grant) is needed: + # + # oc create sa wizard-oauth + # oc annotate sa wizard-oauth \ + # serviceaccounts.openshift.io/oauth-redirectreference.primary='{"kind":"OAuthRedirectReference","apiVersion":"v1","reference":{"kind":"Route","name":"wizard-oauth"}}' + # oc apply -f - <<'EOF' + # apiVersion: v1 + # kind: Secret + # metadata: + # name: wizard-oauth-client + # labels: + # controller.devfile.io/mount-to-devworkspace: "true" + # controller.devfile.io/watch-secret: "true" + # annotations: + # kubernetes.io/service-account.name: wizard-oauth + # controller.devfile.io/mount-as: file + # controller.devfile.io/mount-path: /var/run/oauth-client + # type: kubernetes.io/service-account-token + # EOF - name: oauth-proxy container: image: quay.io/openshift/origin-oauth-proxy:4.18 @@ -61,7 +79,14 @@ components: - --http-address=0.0.0.0:4180 - --https-address= - --upstream=http://127.0.0.1:5173 - - --openshift-service-account=wizard-oauth + # OAuth client is the wizard-oauth ServiceAccount; its token is automounted + # from the wizard-oauth-client secret created in the one-time setup above. + - --client-id=system:serviceaccount:{{namespace}}:wizard-oauth + - --client-secret-file=/var/run/oauth-client/token + - --openshift-ca=/etc/pki/tls/cert.pem + - --openshift-ca=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt + # Access check, run with the end user's token: only users who can get pods + # in this namespace are let through. - '--openshift-sar={"namespace":"{{namespace}}","resource":"pods","verb":"get"}' - --cookie-secret={{cookie-secret}} - --cookie-secure=true @@ -80,18 +105,6 @@ components: memoryRequest: 64Mi cpuLimit: 200m cpuRequest: 50m - - name: oauth-proxy-sa - openshift: - deployByDefault: true - inlined: | - apiVersion: v1 - kind: ServiceAccount - metadata: - name: wizard-oauth - annotations: - # Registers the Route below as a valid OAuth redirect target for this - # service account, so the callback URL does not have to be hardcoded. - serviceaccounts.openshift.io/oauth-redirectreference.primary: '{"kind":"OAuthRedirectReference","apiVersion":"v1","reference":{"kind":"Route","name":"wizard-oauth"}}' - name: oauth-proxy-route openshift: deployByDefault: true From a3ce7f8dbd0602b1ebfc5fb4e871e6c2f84ea31a Mon Sep 17 00:00:00 2001 From: Ilya Buziuk Date: Thu, 5 Nov 2026 13:28:04 +0100 Subject: [PATCH 3/4] fix: Modify namespace and cookie-secret in devfile.yaml for testing Updated namespace and cookie-secret values in devfile.yaml. --- devfile.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/devfile.yaml b/devfile.yaml index 9b44f38..211d771 100644 --- a/devfile.yaml +++ b/devfile.yaml @@ -17,11 +17,11 @@ metadata: attributes: controller.devfile.io/storage-type: ephemeral variables: - # Namespace the workspace runs in. Used for the access check performed by + # CHANGE_ME - Namespace the workspace runs in. Used for the access check performed by # oauth-proxy: only users who can `get pods` in this namespace are let through. - namespace: CHANGE_ME - # Seed for the session cookie: openssl rand -base64 32 - cookie-secret: CHANGE_ME + namespace: ibuziuk-dev + # CHANGE_ME - Seed for the session cookie: 'openssl rand -base64 32' + cookie-secret: k0uiU7MQGwKY4BqHa22cjOZsmfI2FZzO7SioUUtf4e0= components: - name: nodejs-dev container: From 128cfe8100224ca161b464938d65b57a779cf5bf Mon Sep 17 00:00:00 2001 From: Ilya Buziuk Date: Thu, 5 Nov 2026 13:41:09 +0100 Subject: [PATCH 4/4] fix: add host to the devfile 'run' command Signed-off-by: Ilya Buziuk --- README.md | 6 ++++++ devfile.yaml | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f30854d..cb07db0 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,12 @@ oc get route wizard-oauth -o jsonpath='{.spec.host}{"\n"}' so `--client-id` names a ServiceAccount that does not exist. - **403 after logging in** — expected when the user cannot `get pods` in the namespace. Adjust the `--openshift-sar` argument to match who should have access. +- **502 with an empty page** — that is oauth-proxy failing to reach the upstream, not the + router (router errors come with an HTML page). Either the dev server is not running, so run + the `start-dev-server` command, or it is not on the loopback address the proxy dials. Check + with `oc logs -c oauth-proxy`, which logs the dial error. The + `start-dev-server` command passes `--host 127.0.0.1` for this reason: Vite's default host is + `localhost`, which Node may resolve to `::1` only. - Do not pre-create the `wizard-oauth` Route by hand: the operator refuses to adopt objects it does not own and the workspace will fail to start. - The ServiceAccount token is mounted into every workspace container, including `nodejs-dev`. diff --git a/devfile.yaml b/devfile.yaml index 211d771..3f91fb3 100644 --- a/devfile.yaml +++ b/devfile.yaml @@ -137,7 +137,9 @@ commands: - id: start-dev-server exec: component: nodejs-dev - commandLine: npm run dev + # Pinned to the IPv4 loopback: vite's default host is `localhost`, which Node + # may resolve to ::1 only, while the oauth-proxy sidecar dials 127.0.0.1. + commandLine: npm run dev -- --host 127.0.0.1 workingDir: ${PROJECT_SOURCE} group: kind: run