diff --git a/backend/main.go b/backend/main.go
index ac09335..287afcf 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -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)
diff --git a/backend/oauth_handler.go b/backend/oauth_handler.go
new file mode 100644
index 0000000..203e0ee
--- /dev/null
+++ b/backend/oauth_handler.go
@@ -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})
+}
diff --git a/charts/openshift-console-plugin/templates/deployment.yaml b/charts/openshift-console-plugin/templates/deployment.yaml
index 591eb71..37a79fe 100644
--- a/charts/openshift-console-plugin/templates/deployment.yaml
+++ b/charts/openshift-console-plugin/templates/deployment.yaml
@@ -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 }}
diff --git a/charts/openshift-console-plugin/values.yaml b/charts/openshift-console-plugin/values.yaml
index 0a36f9f..229bf23 100644
--- a/charts/openshift-console-plugin/values.yaml
+++ b/charts/openshift-console-plugin/values.yaml
@@ -25,6 +25,11 @@ plugin:
cpu: 10m
memory: 50Mi
basePath: /
+ oauth:
+ enabled: false
+ githubClientId: ""
+ githubSecretName: ""
+ githubSecretKey: "client-secret"
certificateSecretName: ""
serviceAccount:
create: true
diff --git a/console-extensions.json b/console-extensions.json
index 333aee8..8fc473e 100644
--- a/console-extensions.json
+++ b/console-extensions.json
@@ -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
+ }
}
]
diff --git a/package.json b/package.json
index 3a1012e..ab3c187 100644
--- a/package.json
+++ b/package.json
@@ -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"
diff --git a/src/common/components/DisconnectConfirmModal.test.tsx b/src/common/components/DisconnectConfirmModal.test.tsx
new file mode 100644
index 0000000..d4de9cf
--- /dev/null
+++ b/src/common/components/DisconnectConfirmModal.test.tsx
@@ -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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ await user.click(screen.getByRole('button', { name: 'Cancel' }));
+
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+
+ it('does not render content when closed', () => {
+ render();
+
+ expect(screen.queryByText('Disconnect from GitHub')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/common/components/DisconnectConfirmModal.tsx b/src/common/components/DisconnectConfirmModal.tsx
new file mode 100644
index 0000000..6d57808
--- /dev/null
+++ b/src/common/components/DisconnectConfirmModal.tsx
@@ -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 (
+
+
+ {t('Are you sure you want to disconnect from GitHub?')}
+
+
+
+
+
+ );
+}
diff --git a/src/common/components/PatModal.test.tsx b/src/common/components/PatModal.test.tsx
new file mode 100644
index 0000000..61d4397
--- /dev/null
+++ b/src/common/components/PatModal.test.tsx
@@ -0,0 +1,217 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { PatModal } from './PatModal';
+import { OAuthConfig } from '../services/oauth/OAuthService';
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+describe('PatModal', () => {
+ const defaultProps = {
+ isOpen: true,
+ oauthConfig: null as OAuthConfig | null,
+ onClose: vi.fn(),
+ onConnect: vi.fn().mockResolvedValue(undefined),
+ onOAuth: vi.fn().mockResolvedValue(undefined),
+ };
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('rendering', () => {
+ it('renders modal title and form elements', () => {
+ render();
+
+ expect(screen.getByText('Connect to GitHub')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Sign in with GitHub/ })).toBeInTheDocument();
+ expect(screen.getByText('or')).toBeInTheDocument();
+ expect(screen.getByLabelText('Personal Access Token')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
+ });
+
+ it('does not render content when closed', () => {
+ render();
+
+ expect(screen.queryByText('Connect to GitHub')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('OAuth button', () => {
+ it('is aria-disabled when oauthConfig is null', () => {
+ render();
+
+ expect(screen.getByRole('button', { name: /Sign in with GitHub/ })).toHaveAttribute(
+ 'aria-disabled',
+ 'true',
+ );
+ });
+
+ it('is aria-disabled when oauth is not enabled', () => {
+ render();
+
+ expect(screen.getByRole('button', { name: /Sign in with GitHub/ })).toHaveAttribute(
+ 'aria-disabled',
+ 'true',
+ );
+ });
+
+ it('is active when oauth is configured', () => {
+ render();
+
+ expect(screen.getByRole('button', { name: /Sign in with GitHub/ })).not.toHaveAttribute(
+ 'aria-disabled',
+ );
+ });
+
+ it('calls onOAuth when clicked and enabled', async () => {
+ const onOAuth = vi.fn().mockResolvedValue(undefined);
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: /Sign in with GitHub/ }));
+
+ expect(onOAuth).toHaveBeenCalledOnce();
+ });
+
+ it('shows error when OAuth flow fails', async () => {
+ const onOAuth = vi.fn().mockRejectedValue(new Error('Popup blocked'));
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: /Sign in with GitHub/ }));
+
+ expect(await screen.findByText('Popup blocked')).toBeInTheDocument();
+ });
+ });
+
+ describe('PAT input', () => {
+ it('Connect button is disabled when input is empty', () => {
+ render();
+
+ expect(screen.getByRole('button', { name: 'Connect' })).toBeDisabled();
+ });
+
+ it('Connect button is enabled when input has value', async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ await user.type(screen.getByLabelText('Personal Access Token'), 'ghp_test');
+
+ expect(screen.getByRole('button', { name: 'Connect' })).toBeEnabled();
+ });
+
+ it('calls onConnect with PAT value', async () => {
+ const onConnect = vi.fn().mockResolvedValue(undefined);
+ const user = userEvent.setup();
+
+ render();
+
+ await user.type(screen.getByLabelText('Personal Access Token'), 'ghp_valid');
+ await user.click(screen.getByRole('button', { name: 'Connect' }));
+
+ await waitFor(() => {
+ expect(onConnect).toHaveBeenCalledWith('ghp_valid');
+ });
+ });
+
+ it('clears PAT input after successful connect', async () => {
+ const onConnect = vi.fn().mockResolvedValue(undefined);
+ const user = userEvent.setup();
+
+ render();
+
+ await user.type(screen.getByLabelText('Personal Access Token'), 'ghp_valid');
+ await user.click(screen.getByRole('button', { name: 'Connect' }));
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('Personal Access Token')).toHaveValue('');
+ });
+ });
+
+ it('shows error when connect fails', async () => {
+ const onConnect = vi.fn().mockRejectedValue(new Error('Bad credentials'));
+ const user = userEvent.setup();
+
+ render();
+
+ await user.type(screen.getByLabelText('Personal Access Token'), 'ghp_bad');
+ await user.click(screen.getByRole('button', { name: 'Connect' }));
+
+ expect(await screen.findByText('Bad credentials')).toBeInTheDocument();
+ });
+
+ it('disables Cancel while validating', async () => {
+ const user = userEvent.setup();
+ let resolveConnect: () => void;
+ const onConnect = vi.fn().mockReturnValue(
+ new Promise((resolve) => {
+ resolveConnect = resolve;
+ }),
+ );
+
+ render();
+
+ await user.type(screen.getByLabelText('Personal Access Token'), 'ghp_slow');
+ await user.click(screen.getByRole('button', { name: 'Connect' }));
+
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
+
+ resolveConnect!();
+ });
+ });
+
+ describe('close', () => {
+ it('calls onClose when Cancel is clicked', async () => {
+ const onClose = vi.fn();
+ const user = userEvent.setup();
+
+ render();
+
+ await user.click(screen.getByRole('button', { name: 'Cancel' }));
+
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+
+ it('clears PAT and error on cancel', async () => {
+ const onConnect = vi.fn().mockRejectedValue(new Error('Bad credentials'));
+ const onClose = vi.fn();
+ const user = userEvent.setup();
+
+ const { rerender } = render(
+ ,
+ );
+
+ await user.type(screen.getByLabelText('Personal Access Token'), 'ghp_bad');
+ await user.click(screen.getByRole('button', { name: 'Connect' }));
+
+ expect(await screen.findByText('Bad credentials')).toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: 'Cancel' }));
+
+ rerender(
+ ,
+ );
+
+ expect(screen.getByLabelText('Personal Access Token')).toHaveValue('');
+ expect(screen.queryByText('Bad credentials')).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/common/components/PatModal.tsx b/src/common/components/PatModal.tsx
new file mode 100644
index 0000000..00d882f
--- /dev/null
+++ b/src/common/components/PatModal.tsx
@@ -0,0 +1,176 @@
+import {
+ Alert,
+ Button,
+ Divider,
+ Flex,
+ FlexItem,
+ Form,
+ FormGroup,
+ FormHelperText,
+ HelperText,
+ HelperTextItem,
+ Modal,
+ ModalBody,
+ ModalFooter,
+ ModalHeader,
+ TextInput,
+ Tooltip,
+} from '@patternfly/react-core';
+import { GithubIcon } from '@patternfly/react-icons';
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { OAuthConfig } from '../services/oauth/OAuthService';
+import { errorMessage } from '../utils/utils';
+
+interface PatModalProps {
+ isOpen: boolean;
+ oauthConfig: OAuthConfig | null;
+ onClose: () => void;
+ onConnect: (pat: string) => Promise;
+ onOAuth: () => Promise;
+}
+
+export function PatModal({ isOpen, oauthConfig, onClose, onConnect, onOAuth }: PatModalProps) {
+ const { t } = useTranslation('plugin__console-functions-plugin');
+ const {
+ pat,
+ isValidating,
+ isOAuthInProgress,
+ error,
+ setPat,
+ handleConnect,
+ handleOAuth,
+ handleClose,
+ } = usePatModal(onClose, onConnect, onOAuth);
+
+ const oauthEnabled = oauthConfig?.enabled ?? false;
+ const isBusy = isValidating || isOAuthInProgress;
+
+ const oauthButton = (
+ }
+ isAriaDisabled={!oauthEnabled || isBusy}
+ isLoading={isOAuthInProgress}
+ isBlock
+ onClick={oauthEnabled && !isBusy ? handleOAuth : undefined}
+ data-test="oauth-button"
+ >
+ {t('Sign in with GitHub')}
+
+ );
+
+ return (
+
+
+
+ {error && }
+ {oauthEnabled ? (
+ oauthButton
+ ) : (
+
+ {oauthButton}
+
+ )}
+
+
+
+
+ {t('or')}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function usePatModal(
+ onClose: () => void,
+ onConnect: (pat: string) => Promise,
+ onOAuth: () => Promise,
+) {
+ const [pat, setPat] = useState('');
+ const [isValidating, setIsValidating] = useState(false);
+ const [isOAuthInProgress, setIsOAuthInProgress] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleConnect = async () => {
+ setIsValidating(true);
+ setError(null);
+ try {
+ await onConnect(pat);
+ setPat('');
+ } catch (err) {
+ setError(errorMessage(err));
+ } finally {
+ setIsValidating(false);
+ }
+ };
+
+ const handleOAuth = async () => {
+ setIsOAuthInProgress(true);
+ setError(null);
+ try {
+ await onOAuth();
+ } catch (err) {
+ setError(errorMessage(err));
+ } finally {
+ setIsOAuthInProgress(false);
+ }
+ };
+
+ const handleClose = () => {
+ setPat('');
+ setError(null);
+ onClose();
+ };
+
+ return {
+ pat,
+ isValidating,
+ isOAuthInProgress,
+ error,
+ setPat,
+ handleConnect,
+ handleOAuth,
+ handleClose,
+ };
+}
diff --git a/src/common/components/UserAvatar.test.tsx b/src/common/components/UserAvatar.test.tsx
index 49ff48f..f8b570c 100644
--- a/src/common/components/UserAvatar.test.tsx
+++ b/src/common/components/UserAvatar.test.tsx
@@ -1,7 +1,7 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserAvatar } from './UserAvatar';
-import { PAT_KEY, USER_KEY } from '../services/types';
+import { TOKEN_KEY, USER_KEY } from '../services/types';
import { ForgeConnectionContext } from '../context/ForgeConnectionProvider';
import { ReactNode } from 'react';
@@ -16,11 +16,26 @@ vi.mock('../services/source-control/useSourceControlService', () => ({
}),
}));
+const mockFetchConfig = vi.fn().mockResolvedValue({ enabled: false, client_id: '' });
+const mockStartFlow = vi.fn();
+vi.mock('../services/oauth/useOAuthService', () => ({
+ useOAuthService: () => ({
+ fetchConfig: mockFetchConfig,
+ startFlow: mockStartFlow,
+ }),
+}));
+
const testUser = { name: 'twoGiants' };
function renderWithContext(
ui: ReactNode,
- contextValue = { isActive: false, user: testUser, connectionId: 0, connectToForge: vi.fn() },
+ contextValue = {
+ isActive: false,
+ user: testUser,
+ connectionId: 0,
+ connectToForge: vi.fn(),
+ disconnectFromForge: vi.fn(),
+ },
) {
return render(
{ui},
@@ -48,7 +63,7 @@ describe('UserAvatar', () => {
});
it('renders username when user is stored in sessionStorage', () => {
- sessionStorage.setItem(PAT_KEY, 'ghp_test');
+ sessionStorage.setItem(TOKEN_KEY, 'ghp_test');
sessionStorage.setItem(USER_KEY, JSON.stringify(testUser));
renderWithContext();
@@ -56,17 +71,16 @@ describe('UserAvatar', () => {
expect(screen.getByText('twoGiants')).toBeInTheDocument();
});
- it('button is clickable when enableReconnect is true', async () => {
+ it('shows dropdown when logged-in user is clicked', async () => {
const user = userEvent.setup();
- sessionStorage.setItem(PAT_KEY, 'ghp_test');
+ sessionStorage.setItem(TOKEN_KEY, 'ghp_test');
sessionStorage.setItem(USER_KEY, JSON.stringify(testUser));
renderWithContext();
- const button = screen.getByRole('button', { name: 'twoGiants' });
- await user.click(button);
+ await user.click(screen.getByRole('button', { name: 'twoGiants' }));
- expect(screen.getByText('Personal Access Token')).toBeInTheDocument();
+ expect(screen.getByText('Disconnect')).toBeInTheDocument();
});
it('button is disabled when enableReconnect is false', async () => {
@@ -90,7 +104,7 @@ describe('UserAvatar', () => {
});
it('does not auto-open modal when PAT is already stored', () => {
- sessionStorage.setItem(PAT_KEY, 'ghp_test');
+ sessionStorage.setItem(TOKEN_KEY, 'ghp_test');
sessionStorage.setItem(USER_KEY, JSON.stringify(testUser));
renderWithContext();
@@ -122,6 +136,7 @@ describe('UserAvatar', () => {
user: testUser,
connectionId: 0,
connectToForge,
+ disconnectFromForge: vi.fn(),
});
await user.type(screen.getByLabelText('Personal Access Token'), 'ghp_valid');
@@ -132,7 +147,7 @@ describe('UserAvatar', () => {
});
expect(screen.getByText('twoGiants')).toBeInTheDocument();
- expect(sessionStorage.getItem(PAT_KEY)).toBe('ghp_valid');
+ expect(sessionStorage.getItem(TOKEN_KEY)).toBe('ghp_valid');
expect(JSON.parse(sessionStorage.getItem(USER_KEY)!)).toEqual(testUser);
expect(connectToForge).toHaveBeenCalled();
});
@@ -161,7 +176,7 @@ describe('UserAvatar', () => {
expect(screen.queryByText('Personal Access Token')).not.toBeInTheDocument();
});
- it('clears PAT input after successful connect', async () => {
+ it('shows dropdown after successful connect', async () => {
const user = userEvent.setup();
const connectToForge = vi.fn();
mockFetchUserInfo.mockResolvedValue(testUser);
@@ -171,6 +186,7 @@ describe('UserAvatar', () => {
user: testUser,
connectionId: 0,
connectToForge,
+ disconnectFromForge: vi.fn(),
});
await user.type(screen.getByLabelText('Personal Access Token'), 'ghp_valid');
@@ -182,7 +198,7 @@ describe('UserAvatar', () => {
await user.click(screen.getByRole('button', { name: 'twoGiants' }));
- expect(screen.getByLabelText('Personal Access Token')).toHaveValue('');
+ expect(screen.getByText('Disconnect')).toBeInTheDocument();
});
it('clears PAT input and error on cancel', async () => {
@@ -222,4 +238,25 @@ describe('UserAvatar', () => {
resolveConnect!();
});
});
+
+ describe('disconnect', () => {
+ it('shows confirmation modal and disconnects on confirm', async () => {
+ const user = userEvent.setup();
+ sessionStorage.setItem(TOKEN_KEY, 'ghp_test');
+ sessionStorage.setItem(USER_KEY, JSON.stringify(testUser));
+
+ renderWithContext();
+
+ await user.click(screen.getByRole('button', { name: 'twoGiants' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Disconnect' }));
+
+ expect(screen.getByText('Disconnect from GitHub')).toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: 'Disconnect' }));
+
+ expect(sessionStorage.getItem(TOKEN_KEY)).toBeNull();
+ expect(sessionStorage.getItem(USER_KEY)).toBeNull();
+ expect(screen.getByText('Connect to GitHub')).toBeInTheDocument();
+ });
+ });
});
diff --git a/src/common/components/UserAvatar.tsx b/src/common/components/UserAvatar.tsx
index c21c55d..a62594b 100644
--- a/src/common/components/UserAvatar.tsx
+++ b/src/common/components/UserAvatar.tsx
@@ -1,21 +1,14 @@
-import {
- Alert,
- Button,
- Form,
- FormGroup,
- Modal,
- ModalBody,
- ModalFooter,
- ModalHeader,
- TextInput,
-} from '@patternfly/react-core';
+import { Button, Dropdown, DropdownItem, DropdownList, MenuToggle } from '@patternfly/react-core';
import { KeyIcon, UserIcon } from '@patternfly/react-icons';
+import { useContext, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { ForgeUser, PAT_KEY, USER_KEY } from '../services/types';
-import { useContext, useState } from 'react';
import { ForgeConnectionContext } from '../context/ForgeConnectionProvider';
+import { OAuthConfig } from '../services/oauth/OAuthService';
+import { useOAuthService } from '../services/oauth/useOAuthService';
import { useSourceControlService } from '../services/source-control/useSourceControlService';
-import { errorMessage } from '../utils/utils';
+import { AUTH_METHOD_KEY, ForgeUser, TOKEN_KEY, USER_KEY } from '../services/types';
+import { DisconnectConfirmModal } from './DisconnectConfirmModal';
+import { PatModal } from './PatModal';
interface UserAvatarProps {
enableReconnect: boolean;
@@ -23,55 +16,156 @@ interface UserAvatarProps {
export function UserAvatar({ enableReconnect }: UserAvatarProps) {
const { t } = useTranslation('plugin__console-functions-plugin');
- const { user, isModalOpen, openModal, closeModal, login } = useUserAvatar(enableReconnect);
-
- const icon = user ? : ;
- const label = user ? user.name : t('Connect to GitHub');
+ const {
+ user,
+ isModalOpen,
+ isDropdownOpen,
+ isConfirmOpen,
+ oauthConfig,
+ openModal,
+ closeModal,
+ loginWithPat,
+ loginWithOAuth,
+ toggleDropdown,
+ closeDropdown,
+ openConfirm,
+ closeConfirm,
+ disconnect,
+ } = useUserAvatar(enableReconnect);
+
+ if (user) {
+ return (
+ <>
+ !open && closeDropdown()}
+ toggle={(toggleRef) => (
+ }
+ >
+ {user.name}
+
+ )}
+ popperProps={{ position: 'end' }}
+ >
+
+
+ {t('Disconnect')}
+
+
+
+
+ >
+ );
+ }
return (
<>
}
onClick={enableReconnect ? openModal : undefined}
isDisabled={!enableReconnect}
- style={!enableReconnect ? { cursor: 'default' } : undefined}
>
- {label}
+ {t('Connect to GitHub')}
-
+
>
);
}
function useUserAvatar(enableReconnect: boolean) {
const sourceControlService = useSourceControlService();
- const connectToForge = useContext(ForgeConnectionContext).connectToForge;
+ const oauthService = useOAuthService();
+ const { connectToForge, disconnectFromForge } = useContext(ForgeConnectionContext);
const [user, setUser] = useState(() => readStoredUser());
const [isModalOpen, setIsModalOpen] = useState(
- () => enableReconnect && !sessionStorage.getItem(PAT_KEY),
+ () => enableReconnect && !sessionStorage.getItem(TOKEN_KEY),
);
-
- const login = async (pat: string) => {
- const forgeUser = await sourceControlService.fetchUserInfo(pat);
- sessionStorage.setItem(PAT_KEY, pat);
+ const [oauthConfig, setOAuthConfig] = useState(null);
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const [isConfirmOpen, setIsConfirmOpen] = useState(false);
+
+ useEffect(() => {
+ oauthService
+ .fetchConfig()
+ .then(setOAuthConfig)
+ .catch(() => {});
+ }, [oauthService]);
+
+ const finishLogin = (token: string, forgeUser: ForgeUser, method: 'pat' | 'oauth') => {
+ sessionStorage.setItem(TOKEN_KEY, token);
sessionStorage.setItem(USER_KEY, JSON.stringify(forgeUser));
+ sessionStorage.setItem(AUTH_METHOD_KEY, method);
setUser(forgeUser);
setIsModalOpen(false);
connectToForge(forgeUser);
};
+ const loginWithPat = async (pat: string) => {
+ const forgeUser = await sourceControlService.fetchUserInfo(pat);
+ finishLogin(pat, forgeUser, 'pat');
+ };
+
+ const loginWithOAuth = async () => {
+ const token = await oauthService.startFlow();
+ const forgeUser = await sourceControlService.fetchUserInfo(token);
+ finishLogin(token, forgeUser, 'oauth');
+ };
+
+ const disconnect = () => {
+ sessionStorage.removeItem(TOKEN_KEY);
+ sessionStorage.removeItem(USER_KEY);
+ sessionStorage.removeItem(AUTH_METHOD_KEY);
+ setUser(null);
+ setIsConfirmOpen(false);
+ disconnectFromForge();
+ };
+
const openModal = () => setIsModalOpen(true);
const closeModal = () => setIsModalOpen(false);
-
- return { user, isModalOpen, openModal, closeModal, login };
+ const toggleDropdown = () => setIsDropdownOpen((prev) => !prev);
+ const closeDropdown = () => setIsDropdownOpen(false);
+ const openConfirm = () => setIsConfirmOpen(true);
+ const closeConfirm = () => setIsConfirmOpen(false);
+
+ return {
+ user,
+ isModalOpen,
+ isDropdownOpen,
+ isConfirmOpen,
+ oauthConfig,
+ openModal,
+ closeModal,
+ loginWithPat,
+ loginWithOAuth,
+ toggleDropdown,
+ closeDropdown,
+ openConfirm,
+ closeConfirm,
+ disconnect,
+ };
}
function readStoredUser(): ForgeUser | null {
- const pat = sessionStorage.getItem(PAT_KEY);
+ const token = sessionStorage.getItem(TOKEN_KEY);
const userJson = sessionStorage.getItem(USER_KEY);
- if (!pat || !userJson) {
+ if (!token || !userJson) {
return null;
}
@@ -81,79 +175,3 @@ function readStoredUser(): ForgeUser | null {
return null;
}
}
-
-interface PatModalProps {
- isOpen: boolean;
- onClose: () => void;
- onConnect: (pat: string) => Promise;
-}
-
-function PatModal({ isOpen, onClose, onConnect }: PatModalProps) {
- const { t } = useTranslation('plugin__console-functions-plugin');
- const { pat, isValidating, error, setPat, handleConnect, handleClose } = usePatModal(
- onClose,
- onConnect,
- );
-
- return (
-
-
-
- {error && (
-
- )}
-
- {t('Enter your GitHub Personal Access Token to connect your repositories.')}
-
-
-
-
-
-
- );
-}
-
-function usePatModal(onClose: () => void, onConnect: (pat: string) => Promise) {
- const [pat, setPat] = useState('');
- const [isValidating, setIsValidating] = useState(false);
- const [error, setError] = useState(null);
-
- const handleConnect = async () => {
- setIsValidating(true);
- setError(null);
- try {
- await onConnect(pat);
- setPat('');
- } catch (err) {
- setError(errorMessage(err));
- } finally {
- setIsValidating(false);
- }
- };
-
- const handleClose = () => {
- setPat('');
- setError(null);
- onClose();
- };
-
- return { pat, isValidating, error, setPat, handleConnect, handleClose };
-}
diff --git a/src/common/context/ForgeConnectionProvider.tsx b/src/common/context/ForgeConnectionProvider.tsx
index 6dd87ce..99951b8 100644
--- a/src/common/context/ForgeConnectionProvider.tsx
+++ b/src/common/context/ForgeConnectionProvider.tsx
@@ -1,11 +1,12 @@
import { createContext, ReactNode, useState } from 'react';
-import { ForgeUser, PAT_KEY, USER_KEY } from '../services/types';
+import { ForgeUser, TOKEN_KEY, USER_KEY } from '../services/types';
interface ForgeConnection {
isActive: boolean;
user: ForgeUser;
connectionId: number;
connectToForge: (user: ForgeUser) => void;
+ disconnectFromForge: () => void;
}
export const ForgeConnectionContext = createContext({
@@ -13,10 +14,11 @@ export const ForgeConnectionContext = createContext({
user: { name: '' },
connectionId: 0,
connectToForge: () => {},
+ disconnectFromForge: () => {},
});
export function ForgeConnectionProvider({ children }: Readonly<{ children: ReactNode }>) {
- const [isActive, setIsActive] = useState(() => !!sessionStorage.getItem(PAT_KEY));
+ const [isActive, setIsActive] = useState(() => !!sessionStorage.getItem(TOKEN_KEY));
const [user, setUser] = useState(readStoredUser);
const [connectionId, setConnectionId] = useState(0);
@@ -26,8 +28,15 @@ export function ForgeConnectionProvider({ children }: Readonly<{ children: React
setConnectionId((id) => id + 1);
};
+ const disconnectFromForge = () => {
+ setUser({ name: '' });
+ setIsActive(false);
+ };
+
return (
-
+
{children}
);
diff --git a/src/common/services/oauth/OAuthService.test.ts b/src/common/services/oauth/OAuthService.test.ts
new file mode 100644
index 0000000..fbb8a65
--- /dev/null
+++ b/src/common/services/oauth/OAuthService.test.ts
@@ -0,0 +1,288 @@
+import { consoleFetchJSON } from '@openshift-console/dynamic-plugin-sdk';
+import { OAuthService, OAuthConfig } from './OAuthService';
+
+vi.mock('@openshift-console/dynamic-plugin-sdk', () => ({
+ consoleFetchJSON: Object.assign(vi.fn(), {
+ post: vi.fn(),
+ }),
+}));
+
+const enabledConfig: OAuthConfig = { client_id: 'test-client-id', enabled: true };
+const disabledConfig: OAuthConfig = { client_id: '', enabled: false };
+
+const STATE_KEY = 'func-console-oauth-state';
+const VERIFIER_KEY = 'func-console-oauth-verifier';
+
+function flushMicrotasks(): Promise {
+ return new Promise((resolve) => queueMicrotask(resolve));
+}
+
+async function waitForSetup(): Promise {
+ for (let i = 0; i < 10; i++) await flushMicrotasks();
+}
+
+describe('OAuthService', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.clearAllTimers();
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+ sessionStorage.clear();
+ });
+
+ describe('fetchConfig', () => {
+ it('fetches and returns OAuth config from the backend', async () => {
+ vi.mocked(consoleFetchJSON).mockResolvedValue(enabledConfig);
+
+ const service = new OAuthService();
+ const result = await service.fetchConfig();
+
+ expect(consoleFetchJSON).toHaveBeenCalledWith(
+ '/api/proxy/plugin/console-functions-plugin/backend/api/oauth/config',
+ );
+ expect(result).toEqual(enabledConfig);
+ });
+
+ it('caches the config after the first fetch', async () => {
+ vi.mocked(consoleFetchJSON).mockResolvedValue(enabledConfig);
+
+ const service = new OAuthService();
+ await service.fetchConfig();
+ await service.fetchConfig();
+
+ expect(consoleFetchJSON).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('startFlow', () => {
+ beforeEach(() => {
+ vi.spyOn(crypto.subtle, 'digest').mockResolvedValue(new ArrayBuffer(32));
+ });
+
+ it('throws when OAuth is not enabled', async () => {
+ vi.mocked(consoleFetchJSON).mockResolvedValue(disabledConfig);
+
+ const service = new OAuthService();
+
+ await expect(service.startFlow()).rejects.toThrow('OAuth is not configured on this cluster');
+ });
+
+ it('throws when popup is blocked', async () => {
+ vi.mocked(consoleFetchJSON).mockResolvedValue(enabledConfig);
+ vi.spyOn(window, 'open').mockReturnValue(null);
+
+ const service = new OAuthService();
+
+ await expect(service.startFlow()).rejects.toThrow('Popup was blocked by the browser');
+ expect(sessionStorage.getItem(STATE_KEY)).toBeNull();
+ expect(sessionStorage.getItem(VERIFIER_KEY)).toBeNull();
+ });
+
+ it('opens popup with correct OAuth params', async () => {
+ vi.mocked(consoleFetchJSON).mockResolvedValue(enabledConfig);
+ const popup = { closed: true, close: vi.fn() };
+ vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window);
+
+ const service = new OAuthService();
+ try {
+ await service.startFlow();
+ } catch {
+ // expected - popup is immediately "closed"
+ }
+
+ const url = vi.mocked(window.open).mock.calls[0][0] as string;
+ expect(url).toContain('client_id=test-client-id');
+ expect(url).toContain('scope=repo');
+ expect(url).toContain('code_challenge_method=S256');
+ expect(url).toContain('redirect_uri=');
+ expect(url).toContain('state=');
+ expect(url).toContain('code_challenge=');
+ });
+
+ it('rejects when popup is closed without completing auth', async () => {
+ vi.useFakeTimers();
+ vi.mocked(consoleFetchJSON).mockResolvedValue(enabledConfig);
+ const popup = { closed: false, close: vi.fn() };
+ vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window);
+
+ const service = new OAuthService();
+ const flowPromise = service.startFlow();
+ flowPromise.catch(() => {});
+
+ await waitForSetup();
+
+ popup.closed = true;
+ await vi.advanceTimersByTimeAsync(500);
+
+ await expect(flowPromise).rejects.toThrow('Authorization window was closed');
+ expect(sessionStorage.getItem(STATE_KEY)).toBeNull();
+ });
+
+ it('rejects on oauth-error message', async () => {
+ vi.useFakeTimers();
+ vi.mocked(consoleFetchJSON).mockResolvedValue(enabledConfig);
+ const popup = { closed: false, close: vi.fn() };
+ vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window);
+
+ const service = new OAuthService();
+ const flowPromise = service.startFlow();
+ flowPromise.catch(() => {});
+
+ await waitForSetup();
+
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ origin: window.location.origin,
+ data: { type: 'oauth-error', error: 'access_denied' },
+ }),
+ );
+
+ await expect(flowPromise).rejects.toThrow('access_denied');
+ expect(popup.close).toHaveBeenCalled();
+ expect(sessionStorage.getItem(STATE_KEY)).toBeNull();
+ });
+
+ it('rejects on state mismatch', async () => {
+ vi.useFakeTimers();
+ vi.mocked(consoleFetchJSON).mockResolvedValue(enabledConfig);
+ const popup = { closed: false, close: vi.fn() };
+ vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window);
+
+ const service = new OAuthService();
+ const flowPromise = service.startFlow();
+ flowPromise.catch(() => {});
+
+ await waitForSetup();
+
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ origin: window.location.origin,
+ data: { type: 'oauth-callback', code: 'test-code', state: 'wrong-state' },
+ }),
+ );
+
+ await expect(flowPromise).rejects.toThrow('State mismatch');
+ });
+
+ it('exchanges code for token and resolves with access_token', async () => {
+ vi.useFakeTimers();
+ vi.mocked(consoleFetchJSON).mockResolvedValue(enabledConfig);
+ vi.mocked(consoleFetchJSON.post).mockResolvedValue({ access_token: 'gho_abc123' });
+ const popup = { closed: false, close: vi.fn() };
+ vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window);
+
+ const service = new OAuthService();
+ const flowPromise = service.startFlow();
+
+ await waitForSetup();
+
+ const storedState = sessionStorage.getItem(STATE_KEY)!;
+ const storedVerifier = sessionStorage.getItem(VERIFIER_KEY)!;
+ expect(storedState).toBeTruthy();
+ expect(storedVerifier).toBeTruthy();
+
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ origin: window.location.origin,
+ data: { type: 'oauth-callback', code: 'auth-code', state: storedState },
+ }),
+ );
+
+ await vi.advanceTimersByTimeAsync(0);
+
+ const token = await flowPromise;
+
+ expect(token).toBe('gho_abc123');
+ expect(consoleFetchJSON.post).toHaveBeenCalledWith(
+ '/api/proxy/plugin/console-functions-plugin/backend/api/oauth/callback',
+ { code: 'auth-code', code_verifier: storedVerifier },
+ );
+ expect(popup.close).toHaveBeenCalled();
+ expect(sessionStorage.getItem(STATE_KEY)).toBeNull();
+ expect(sessionStorage.getItem(VERIFIER_KEY)).toBeNull();
+ });
+
+ it('rejects when token exchange fails', async () => {
+ vi.useFakeTimers();
+ vi.mocked(consoleFetchJSON).mockResolvedValue(enabledConfig);
+ vi.mocked(consoleFetchJSON.post).mockRejectedValue(new Error('exchange failed'));
+ const popup = { closed: false, close: vi.fn() };
+ vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window);
+
+ const service = new OAuthService();
+ const flowPromise = service.startFlow();
+ flowPromise.catch(() => {});
+
+ await waitForSetup();
+
+ const storedState = sessionStorage.getItem(STATE_KEY)!;
+
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ origin: window.location.origin,
+ data: { type: 'oauth-callback', code: 'auth-code', state: storedState },
+ }),
+ );
+
+ await vi.advanceTimersByTimeAsync(0);
+
+ await expect(flowPromise).rejects.toThrow('exchange failed');
+ expect(sessionStorage.getItem(STATE_KEY)).toBeNull();
+ });
+
+ it('ignores messages from different origins', async () => {
+ vi.useFakeTimers();
+ vi.mocked(consoleFetchJSON).mockResolvedValue(enabledConfig);
+ const popup = { closed: false, close: vi.fn() };
+ vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window);
+
+ const service = new OAuthService();
+ const flowPromise = service.startFlow();
+ flowPromise.catch(() => {});
+
+ await waitForSetup();
+
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ origin: 'https://evil.com',
+ data: { type: 'oauth-callback', code: 'stolen-code', state: 'x' },
+ }),
+ );
+
+ popup.closed = true;
+ await vi.advanceTimersByTimeAsync(500);
+
+ await expect(flowPromise).rejects.toThrow('Authorization window was closed');
+ expect(consoleFetchJSON.post).not.toHaveBeenCalled();
+ });
+
+ it('ignores messages with non-oauth type', async () => {
+ vi.useFakeTimers();
+ vi.mocked(consoleFetchJSON).mockResolvedValue(enabledConfig);
+ const popup = { closed: false, close: vi.fn() };
+ vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window);
+
+ const service = new OAuthService();
+ const flowPromise = service.startFlow();
+ flowPromise.catch(() => {});
+
+ await waitForSetup();
+
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ origin: window.location.origin,
+ data: { type: 'unrelated-message' },
+ }),
+ );
+
+ popup.closed = true;
+ await vi.advanceTimersByTimeAsync(500);
+
+ await expect(flowPromise).rejects.toThrow('Authorization window was closed');
+ expect(consoleFetchJSON.post).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/common/services/oauth/OAuthService.ts b/src/common/services/oauth/OAuthService.ts
new file mode 100644
index 0000000..e1a7ae9
--- /dev/null
+++ b/src/common/services/oauth/OAuthService.ts
@@ -0,0 +1,129 @@
+import { consoleFetchJSON } from '@openshift-console/dynamic-plugin-sdk';
+
+const PROXY_BASE = '/api/proxy/plugin/console-functions-plugin/backend';
+const STATE_KEY = 'func-console-oauth-state';
+const VERIFIER_KEY = 'func-console-oauth-verifier';
+
+export interface OAuthConfig {
+ client_id: string;
+ enabled: boolean;
+}
+
+interface OAuthMessage {
+ type: 'oauth-callback' | 'oauth-error';
+ code?: string;
+ state?: string;
+ error?: string;
+}
+
+export class OAuthService {
+ #configCache: OAuthConfig | null = null;
+
+ async fetchConfig(): Promise {
+ if (this.#configCache) return this.#configCache;
+ this.#configCache = await consoleFetchJSON(`${PROXY_BASE}/api/oauth/config`);
+ return this.#configCache!;
+ }
+
+ async startFlow(): Promise {
+ const config = await this.fetchConfig();
+ if (!config.enabled) throw new Error('OAuth is not configured on this cluster');
+
+ const state = generateRandomString(32);
+ const codeVerifier = generateRandomString(64);
+ const codeChallenge = await generateCodeChallenge(codeVerifier);
+
+ sessionStorage.setItem(STATE_KEY, state);
+ sessionStorage.setItem(VERIFIER_KEY, codeVerifier);
+
+ const params = new URLSearchParams({
+ client_id: config.client_id,
+ redirect_uri: `${window.location.origin}/faas/oauth/callback`,
+ scope: 'repo',
+ state,
+ code_challenge: codeChallenge,
+ code_challenge_method: 'S256',
+ });
+
+ const popup = window.open(
+ `https://github.com/login/oauth/authorize?${params}`,
+ 'github-oauth',
+ 'width=600,height=700',
+ );
+ if (!popup) {
+ cleanup();
+ throw new Error('Popup was blocked by the browser. Please allow popups for this site.');
+ }
+
+ return new Promise((resolve, reject) => {
+ const closedCheck = setInterval(() => {
+ if (popup.closed) {
+ clearInterval(closedCheck);
+ window.removeEventListener('message', onMessage);
+ cleanup();
+ reject(new Error('Authorization window was closed'));
+ }
+ }, 500);
+
+ const onMessage = async (event: MessageEvent) => {
+ if (event.origin !== window.location.origin) return;
+ if (!event.data?.type?.startsWith('oauth-')) return;
+
+ clearInterval(closedCheck);
+ window.removeEventListener('message', onMessage);
+ popup.close();
+
+ if (event.data.type === 'oauth-error') {
+ cleanup();
+ reject(new Error(event.data.error ?? 'Authorization denied'));
+ return;
+ }
+
+ const storedState = sessionStorage.getItem(STATE_KEY);
+ if (event.data.state !== storedState) {
+ cleanup();
+ reject(new Error('State mismatch, possible CSRF attack'));
+ return;
+ }
+
+ try {
+ const storedVerifier = sessionStorage.getItem(VERIFIER_KEY)!;
+ const tokenResponse = await consoleFetchJSON.post(`${PROXY_BASE}/api/oauth/callback`, {
+ code: event.data.code,
+ code_verifier: storedVerifier,
+ });
+ cleanup();
+ resolve(tokenResponse.access_token);
+ } catch (err) {
+ cleanup();
+ reject(err);
+ }
+ };
+
+ window.addEventListener('message', onMessage);
+ });
+ }
+}
+
+function cleanup() {
+ sessionStorage.removeItem(STATE_KEY);
+ sessionStorage.removeItem(VERIFIER_KEY);
+}
+
+function generateRandomString(length: number): string {
+ const bytes = crypto.getRandomValues(new Uint8Array(length));
+ return base64UrlEncode(bytes.buffer);
+}
+
+async function generateCodeChallenge(verifier: string): Promise {
+ const encoded = new TextEncoder().encode(verifier);
+ const digest = await crypto.subtle.digest('SHA-256', encoded);
+ return base64UrlEncode(digest);
+}
+
+function base64UrlEncode(buffer: ArrayBuffer): string {
+ const bytes = new Uint8Array(buffer);
+ let binary = '';
+ for (const b of bytes) binary += String.fromCharCode(b);
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
diff --git a/src/common/services/oauth/useOAuthService.ts b/src/common/services/oauth/useOAuthService.ts
new file mode 100644
index 0000000..20a8d62
--- /dev/null
+++ b/src/common/services/oauth/useOAuthService.ts
@@ -0,0 +1,7 @@
+import { OAuthService } from './OAuthService';
+
+const instance = new OAuthService();
+
+export function useOAuthService(): OAuthService {
+ return instance;
+}
diff --git a/src/common/services/source-control/useSourceControlService.ts b/src/common/services/source-control/useSourceControlService.ts
index 4f484f2..8ff69fa 100644
--- a/src/common/services/source-control/useSourceControlService.ts
+++ b/src/common/services/source-control/useSourceControlService.ts
@@ -1,7 +1,8 @@
+import { TOKEN_KEY } from '../types';
import { GithubService } from './GithubService';
import { SourceControlService } from './SourceControlService';
-const instance = new GithubService(() => sessionStorage.getItem('func-console-pat') || '');
+const instance = new GithubService(() => sessionStorage.getItem(TOKEN_KEY) || '');
export function useSourceControlService(): SourceControlService {
return instance;
diff --git a/src/common/services/types.ts b/src/common/services/types.ts
index e4e783f..3c35a5a 100644
--- a/src/common/services/types.ts
+++ b/src/common/services/types.ts
@@ -1,5 +1,6 @@
-export const PAT_KEY = 'func-console-pat';
+export const TOKEN_KEY = 'func-console-token';
export const USER_KEY = 'func-console-user';
+export const AUTH_METHOD_KEY = 'func-console-auth-method';
export interface FileEntry {
path: string;
diff --git a/src/pages/function-create/FunctionCreatePage.test.tsx b/src/pages/function-create/FunctionCreatePage.test.tsx
index 227da35..f2f8a1c 100644
--- a/src/pages/function-create/FunctionCreatePage.test.tsx
+++ b/src/pages/function-create/FunctionCreatePage.test.tsx
@@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import FunctionCreatePage from './FunctionCreatePage';
-import { PAT_KEY, USER_KEY } from '../../common/services/types';
+import { TOKEN_KEY, USER_KEY } from '../../common/services/types';
const mockGenerateFunction = vi.fn();
const mockCreateRepoWithSecret = vi.fn();
@@ -83,7 +83,7 @@ describe('FunctionCreatePage', () => {
});
it('renders CreateFunctionForm', () => {
- sessionStorage.setItem(PAT_KEY, 'ghp_test');
+ sessionStorage.setItem(TOKEN_KEY, 'ghp_test');
renderPage();
@@ -92,7 +92,7 @@ describe('FunctionCreatePage', () => {
});
it('calls generateFunction, creates repo with secrets, then navigates on submit', async () => {
- sessionStorage.setItem(PAT_KEY, 'ghp_test');
+ sessionStorage.setItem(TOKEN_KEY, 'ghp_test');
sessionStorage.setItem(USER_KEY, JSON.stringify({ name: 'testuser' }));
const user = userEvent.setup();
const files = [{ path: 'func.yaml', mode: '100644', content: 'name: f', type: 'blob' }];
@@ -134,7 +134,7 @@ describe('FunctionCreatePage', () => {
});
it('shows an alert on error', async () => {
- sessionStorage.setItem(PAT_KEY, 'ghp_test');
+ sessionStorage.setItem(TOKEN_KEY, 'ghp_test');
sessionStorage.setItem(USER_KEY, JSON.stringify({ name: 'testuser' }));
const user = userEvent.setup();
mockGenerateFunction.mockRejectedValue(new Error('Backend error'));
diff --git a/src/pages/function-create/components/CreateFunctionForm.test.tsx b/src/pages/function-create/components/CreateFunctionForm.test.tsx
index 228491c..07ef18c 100644
--- a/src/pages/function-create/components/CreateFunctionForm.test.tsx
+++ b/src/pages/function-create/components/CreateFunctionForm.test.tsx
@@ -10,6 +10,7 @@ const forgeContext = {
user: testUser,
connectionId: 0,
connectToForge: vi.fn(),
+ disconnectFromForge: vi.fn(),
};
function renderWithContext(ui: React.ReactElement) {
diff --git a/src/pages/function-edit/FunctionEditPage.test.tsx b/src/pages/function-edit/FunctionEditPage.test.tsx
index 7a65709..f07de8e 100644
--- a/src/pages/function-edit/FunctionEditPage.test.tsx
+++ b/src/pages/function-edit/FunctionEditPage.test.tsx
@@ -4,6 +4,7 @@ import { http, HttpResponse, delay } from 'msw';
import { server } from '../../../testing/msw/server';
import { MemoryRouter, Route, Routes } from 'react-router-dom-v5-compat';
import FunctionEditPage from './FunctionEditPage';
+import { TOKEN_KEY } from '../../common/services/types';
const GITHUB_API = 'https://api.github.com';
@@ -79,7 +80,7 @@ function setupFetchHandlers() {
describe('FunctionEditPage', () => {
beforeAll(() => {
- sessionStorage.setItem('func-console-pat', 'test-pat');
+ sessionStorage.setItem(TOKEN_KEY, 'test-pat');
});
afterAll(() => {
diff --git a/src/pages/function-list/FunctionsListPage.test.tsx b/src/pages/function-list/FunctionsListPage.test.tsx
index c6a1f83..b3b713f 100644
--- a/src/pages/function-list/FunctionsListPage.test.tsx
+++ b/src/pages/function-list/FunctionsListPage.test.tsx
@@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom-v5-compat';
import FunctionsListPage from './FunctionsListPage';
-import { PAT_KEY } from '../../common/services/types';
+import { TOKEN_KEY } from '../../common/services/types';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
@@ -68,7 +68,7 @@ function clusterData(
}
function renderAuthenticated() {
- sessionStorage.setItem(PAT_KEY, 'ghp_test');
+ sessionStorage.setItem(TOKEN_KEY, 'ghp_test');
}
function repoFixture(name: string) {
diff --git a/src/pages/oauth-callback/OAuthCallbackPage.tsx b/src/pages/oauth-callback/OAuthCallbackPage.tsx
new file mode 100644
index 0000000..5e7a9e0
--- /dev/null
+++ b/src/pages/oauth-callback/OAuthCallbackPage.tsx
@@ -0,0 +1,56 @@
+import { EmptyState, EmptyStateBody, Spinner } from '@patternfly/react-core';
+import { useEffect, useRef } from 'react';
+import { useTranslation } from 'react-i18next';
+
+export default function OAuthCallbackPage() {
+ const { t } = useTranslation('plugin__console-functions-plugin');
+ const sent = useRef(false);
+
+ const params = new URLSearchParams(window.location.search);
+ const code = params.get('code');
+ const state = params.get('state');
+ const error = params.get('error');
+ const errorDescription = params.get('error_description');
+
+ useEffect(() => {
+ if (sent.current) return;
+ sent.current = true;
+
+ if (!window.opener) return;
+
+ if (error) {
+ window.opener.postMessage(
+ { type: 'oauth-error', error: errorDescription || error },
+ window.location.origin,
+ );
+ } else if (code && state) {
+ window.opener.postMessage({ type: 'oauth-callback', code, state }, window.location.origin);
+ }
+
+ setTimeout(() => window.close(), 300);
+ }, [code, state, error, errorDescription]);
+
+ if (!window.opener) {
+ return (
+
+
+ {t('This page should be opened via the GitHub sign-in flow.')}
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {errorDescription || error}
+
+ );
+ }
+
+ return (
+
+ {t('This window will close automatically.')}
+
+ );
+}