Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ func main() {

mux := http.NewServeMux()
mux.HandleFunc("POST /api/function/create", handleFuncCreate)
mux.HandleFunc("GET /api/oauth/config", handleOAuthConfig)
mux.HandleFunc("POST /api/oauth/callback", handleOAuthCallback)
mux.Handle("/", http.FileServer(http.FS(static)))

handler := loggingMiddleware(mux)
Expand Down
112 changes: 112 additions & 0 deletions backend/oauth_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package main

import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strings"
)

var (
githubClientID = os.Getenv("GITHUB_CLIENT_ID")
githubClientSecret = os.Getenv("GITHUB_CLIENT_SECRET")
)

func handleOAuthConfig(w http.ResponseWriter, _ *http.Request) {
enabled := githubClientID != "" && githubClientSecret != ""
cid := ""
if enabled {
cid = githubClientID
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"client_id": cid,
"enabled": enabled,
})
}

type oauthCallbackRequest struct {
Code string `json:"code"`
CodeVerifier string `json:"code_verifier"`
}

var validOAuthParam = regexp.MustCompile(`^[a-zA-Z0-9_\-]+$`)

func handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
if githubClientID == "" || githubClientSecret == "" {
jsonError(w, "OAuth is not configured on this server", http.StatusServiceUnavailable)
return
}

var req oauthCallbackRequest
r.Body = http.MaxBytesReader(w, r.Body, 4096)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
return
}
if !validOAuthParam.MatchString(req.Code) {
jsonError(w, "invalid authorization code", http.StatusBadRequest)
return
}
if !validOAuthParam.MatchString(req.CodeVerifier) {
jsonError(w, "invalid code verifier", http.StatusBadRequest)
return
}

form := url.Values{
"client_id": {githubClientID},
"client_secret": {githubClientSecret},
"code": {req.Code},
"code_verifier": {req.CodeVerifier},
}
ghReq, err := http.NewRequestWithContext(r.Context(), "POST", "https://github.com/login/oauth/access_token", strings.NewReader(form.Encode()))
if err != nil {
jsonError(w, "failed to build token request", http.StatusInternalServerError)
return
}
ghReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
ghReq.Header.Set("Accept", "application/json")

resp, err := http.DefaultClient.Do(ghReq)
if err != nil {
jsonError(w, "failed to exchange token with GitHub: "+err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
jsonError(w, fmt.Sprintf("GitHub returned HTTP %d", resp.StatusCode), http.StatusBadGateway)
return
}

body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
if err != nil {
jsonError(w, "failed to read GitHub response", http.StatusBadGateway)
return
}

var ghResp map[string]any
if err := json.Unmarshal(body, &ghResp); err != nil {
jsonError(w, "invalid response from GitHub", http.StatusBadGateway)
return
}

if errMsg, ok := ghResp["error"]; ok {
desc, _ := ghResp["error_description"].(string)
jsonError(w, fmt.Sprintf("%v: %s", errMsg, desc), http.StatusUnauthorized)
return
}

token, _ := ghResp["access_token"].(string)
if token == "" {
jsonError(w, "no access_token in GitHub response", http.StatusBadGateway)
return
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"access_token": token})
}
10 changes: 10 additions & 0 deletions charts/openshift-console-plugin/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ spec:
protocol: TCP
args:
- "--https-port={{ .Values.plugin.port }}"
{{- if .Values.plugin.oauth.enabled }}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this all hard-wired during installation? Could we make it configurable via some ConfigMap? So admin can enable it post-installation by modifying some configmap and then doing deployment roll-out?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

diff --git a/charts/openshift-console-plugin/templates/configmap-oauth.yaml b/charts/openshift-console-plugin/templates/configmap-oauth.yaml
new file mode 100644
index 0000000..ceb4a4b
--- /dev/null
+++ b/charts/openshift-console-plugin/templates/configmap-oauth.yaml
@@ -0,0 +1,9 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: {{ template "openshift-console-plugin.name" . }}-oauth
+  namespace: {{ .Release.Namespace }}
+  labels:
+    {{- include "openshift-console-plugin.labels" . | nindent 4 }}
+data:
+  GITHUB_CLIENT_ID: {{ .Values.plugin.oauth.githubClientId | quote }}
diff --git a/charts/openshift-console-plugin/templates/deployment.yaml b/charts/openshift-console-plugin/templates/deployment.yaml
index 37a79fe..8eaa70b 100644
--- a/charts/openshift-console-plugin/templates/deployment.yaml
+++ b/charts/openshift-console-plugin/templates/deployment.yaml
@@ -13,6 +13,8 @@ spec:
       {{- include "openshift-console-plugin.selectorLabels" . | nindent 6 }}
   template:
     metadata:
+      annotations:
+        checksum/oauth-config: {{ include (print $.Template.BasePath "/configmap-oauth.yaml") . | sha256sum }}
       labels:
             {{- include "openshift-console-plugin.labels" . | nindent 8 }}
     spec:
@@ -28,16 +30,19 @@ spec:
               protocol: TCP
           args:
             - "--https-port={{ .Values.plugin.port }}"
-          {{- if .Values.plugin.oauth.enabled }}
           env:
             - name: GITHUB_CLIENT_ID
-              value: {{ .Values.plugin.oauth.githubClientId | quote }}
+              valueFrom:
+                configMapKeyRef:
+                  name: {{ template "openshift-console-plugin.name" . }}-oauth
+                  key: GITHUB_CLIENT_ID
+                  optional: true
             - name: GITHUB_CLIENT_SECRET
               valueFrom:
                 secretKeyRef:
                   name: {{ .Values.plugin.oauth.githubSecretName }}
                   key: {{ .Values.plugin.oauth.githubSecretKey }}
-          {{- end }}
+                  optional: true
           imagePullPolicy: {{ .Values.plugin.imagePullPolicy }}
           {{- if and (.Values.plugin.securityContext.enabled) (.Values.plugin.containerSecurityContext) }}
           securityContext: {{ tpl (toYaml (omit .Values.plugin.containerSecurityContext "enabled")) $ | nindent 12 }}
diff --git a/charts/openshift-console-plugin/values.yaml b/charts/openshift-console-plugin/values.yaml
index 229bf23..e0e1c57 100644
--- a/charts/openshift-console-plugin/values.yaml
+++ b/charts/openshift-console-plugin/values.yaml
@@ -26,9 +26,8 @@ plugin:
       memory: 50Mi
   basePath: /
   oauth:
-    enabled: false
     githubClientId: ""
-    githubSecretName: ""
+    githubSecretName: "github-oauth"
     githubSecretKey: "client-secret"
   certificateSecretName: ""
   serviceAccount:

@matejvasek matejvasek Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enabling GitHub OAuth (post-installation)

OAuth is disabled by default. The plugin determines whether OAuth is active
based on the presence of a non-empty GITHUB_CLIENT_ID and
GITHUB_CLIENT_SECRET. To enable it after installation, follow the steps
below.

Replace console-functions-plugin with the namespace where the plugin is installed and
console-functions-plugin with the Helm release name (e.g. console-functions-plugin).

1. Create a GitHub OAuth App

  1. Go to GitHub > Settings > Developer settings > OAuth Apps > New OAuth App
    (direct link: https://github.com/settings/applications/new).
  2. Fill in the form:
    • Application name -- any descriptive name (e.g. "OpenShift Functions Console").
    • Homepage URL -- the URL of your OpenShift Console
      (e.g. https://console-openshift-console.apps.<cluster-domain>).
    • Authorization callback URL -- the console URL with the plugin's
      callback path appended, e.g.
      https://console-openshift-console.apps.<cluster-domain>/faas/oauth/callback.
  3. Click Register application.
  4. On the next page, copy the Client ID.
  5. Click Generate a new client secret, then copy the generated value.
    Store it securely; GitHub will not show it again.

2. Create the Secret

oc create secret generic github-oauth \
  --from-literal=client-secret=<YOUR_CLIENT_SECRET> \
  -n console-functions-plugin

If you used a custom secret name or key during installation
(plugin.oauth.githubSecretName / plugin.oauth.githubSecretKey),
use those values instead of github-oauth and client-secret.

3. Set the Client ID in the ConfigMap

oc patch configmap console-functions-plugin-oauth \
  -n console-functions-plugin \
  --type merge \
  -p '{"data":{"GITHUB_CLIENT_ID":"<YOUR_CLIENT_ID>"}}'

4. Restart the pods

The deployment has a checksum annotation that triggers a rollout
automatically on helm upgrade. After a manual ConfigMap edit you need
to restart the pods yourself:

oc rollout restart deployment/console-functions-plugin -n console-functions-plugin

5. Verify

oc rollout status deployment/console-functions-plugin -n console-functions-plugin

Check the plugin UI. The "Sign in with GitHub" button should now be active.

Disabling OAuth

To disable OAuth again, clear the Client ID:

oc patch configmap console-functions-plugin-oauth \
  -n console-functions-plugin \
  --type merge \
  -p '{"data":{"GITHUB_CLIENT_ID":""}}'

oc rollout restart deployment/console-functions-plugin -n console-functions-plugin

You can optionally delete the Secret as well:

oc delete secret github-oauth -n console-functions-plugin

env:
- name: GITHUB_CLIENT_ID
value: {{ .Values.plugin.oauth.githubClientId | quote }}
- name: GITHUB_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ .Values.plugin.oauth.githubSecretName }}
key: {{ .Values.plugin.oauth.githubSecretKey }}
{{- end }}
imagePullPolicy: {{ .Values.plugin.imagePullPolicy }}
{{- if and (.Values.plugin.securityContext.enabled) (.Values.plugin.containerSecurityContext) }}
securityContext: {{ tpl (toYaml (omit .Values.plugin.containerSecurityContext "enabled")) $ | nindent 12 }}
Expand Down
5 changes: 5 additions & 0 deletions charts/openshift-console-plugin/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ plugin:
cpu: 10m
memory: 50Mi
basePath: /
oauth:
enabled: false
githubClientId: ""
githubSecretName: ""
githubSecretKey: "client-secret"
certificateSecretName: ""
serviceAccount:
create: true
Expand Down
8 changes: 8 additions & 0 deletions console-extensions.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,13 @@
"path": "/faas/edit/:name",
"component": { "$codeRef": "FunctionEditPage" }
}
},
{
"type": "console.page/route",
"properties": {
"path": "/faas/oauth/callback",
"component": { "$codeRef": "OAuthCallbackPage" },
"exact": true
}
}
]
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@
"exposedModules": {
"FunctionsListPage": "./pages/function-list/FunctionsListPage",
"FunctionCreatePage": "./pages/function-create/FunctionCreatePage",
"FunctionEditPage": "./pages/function-edit/FunctionEditPage"
"FunctionEditPage": "./pages/function-edit/FunctionEditPage",
"OAuthCallbackPage": "./pages/oauth-callback/OAuthCallbackPage"
},
"dependencies": {
"@console/pluginAPI": ">=4.19.0"
Expand Down
63 changes: 63 additions & 0 deletions src/common/components/DisconnectConfirmModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DisconnectConfirmModal } from './DisconnectConfirmModal';

vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));

describe('DisconnectConfirmModal', () => {
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
onConfirm: vi.fn(),
};

afterEach(() => {
vi.restoreAllMocks();
});

it('renders title and confirmation text', () => {
render(<DisconnectConfirmModal {...defaultProps} />);

expect(screen.getByText('Disconnect from GitHub')).toBeInTheDocument();
expect(
screen.getByText('Are you sure you want to disconnect from GitHub?'),
).toBeInTheDocument();
});

it('renders Disconnect and Cancel buttons', () => {
render(<DisconnectConfirmModal {...defaultProps} />);

expect(screen.getByRole('button', { name: 'Disconnect' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
});

it('calls onConfirm when Disconnect is clicked', async () => {
const onConfirm = vi.fn();
const user = userEvent.setup();

render(<DisconnectConfirmModal {...defaultProps} onConfirm={onConfirm} />);

await user.click(screen.getByRole('button', { name: 'Disconnect' }));

expect(onConfirm).toHaveBeenCalledOnce();
});

it('calls onClose when Cancel is clicked', async () => {
const onClose = vi.fn();
const user = userEvent.setup();

render(<DisconnectConfirmModal {...defaultProps} onClose={onClose} />);

await user.click(screen.getByRole('button', { name: 'Cancel' }));

expect(onClose).toHaveBeenCalledOnce();
});

it('does not render content when closed', () => {
render(<DisconnectConfirmModal {...defaultProps} isOpen={false} />);

expect(screen.queryByText('Disconnect from GitHub')).not.toBeInTheDocument();
});
});
31 changes: 31 additions & 0 deletions src/common/components/DisconnectConfirmModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from '@patternfly/react-core';
import { useTranslation } from 'react-i18next';

interface DisconnectConfirmModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
}

export function DisconnectConfirmModal({
isOpen,
onClose,
onConfirm,
}: DisconnectConfirmModalProps) {
const { t } = useTranslation('plugin__console-functions-plugin');

return (
<Modal isOpen={isOpen} onClose={onClose} variant="small">
<ModalHeader title={t('Disconnect from GitHub')} />
<ModalBody>{t('Are you sure you want to disconnect from GitHub?')}</ModalBody>
<ModalFooter>
<Button variant="danger" onClick={onConfirm}>
{t('Disconnect')}
</Button>
<Button variant="link" onClick={onClose}>
{t('Cancel')}
</Button>
</ModalFooter>
</Modal>
);
}
Loading
Loading