Skip to content
Open
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 }}
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