diff --git a/src/App.tsx b/src/App.tsx index 59c76165..3c058692 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import ViewScores from "./pages/Assignments/ViewScores"; import ViewSubmissions from "./pages/Assignments/ViewSubmissions"; import SubmittedContent from "./pages/Assignments/SubmittedContent"; import Login from "./pages/Authentication/Login"; +import OidcCallback from "./pages/OidcCallback/OidcCallback"; import Logout from "./pages/Authentication/Logout"; import Courses from "./pages/Courses/Course"; import CourseEditor from "./pages/Courses/CourseEditor"; @@ -63,6 +64,7 @@ function App() { children: [ { index: true, element: } /> }, { path: "login", element: }, + { path: "auth/callback", element: }, { path: "logout", element: } /> }, { @@ -85,7 +87,7 @@ function App() { loader: loadAssignment, }, - // Assign Reviewer: no route loader (component handles localStorage/URL id) + // Assign Reviewer: no route loader (component handles localStorage/URL id) { path: "assignments/edit/:id/responsemappings", element: , @@ -350,7 +352,7 @@ function App() { path: "new", element: , }, - { + { id: "edit-role", path: "edit/:id", element: , @@ -363,11 +365,11 @@ function App() { element: , loader: loadInstitutions, children: [ - { + { path: "new", element: , }, - { + { path: "edit/:id", element: , loader: loadInstitution, @@ -379,7 +381,7 @@ function App() { element: , loader: loadUsers, children: [ - { + { path: "new", element: , }, @@ -390,29 +392,42 @@ function App() { }, ], }, - { - path: "questionnaire", - element: , - loader: loadQuestionnaire, }, - ], + { + path: "questionnaire", + element: , + loader: loadQuestionnaire, + }, + ], }, - { path: "*", element: }, + { path: "*", element: }, { path: "questionnaire", element: , loader: loadQuestionnaire }, { path: "questionnaires", - element: } leastPrivilegeRole={ROLE.INSTRUCTOR} />, + element: ( + } leastPrivilegeRole={ROLE.INSTRUCTOR} /> + ), loader: loadQuestionnaire, }, { path: "questionnaires/new", - element: } leastPrivilegeRole={ROLE.INSTRUCTOR} />, + element: ( + } + leastPrivilegeRole={ROLE.INSTRUCTOR} + /> + ), loader: loadQuestionnaire, }, { path: "questionnaires/edit/:id", - element: } leastPrivilegeRole={ROLE.INSTRUCTOR} />, + element: ( + } + leastPrivilegeRole={ROLE.INSTRUCTOR} + /> + ), loader: loadQuestionnaire, }, ], diff --git a/src/components/Modals/OidcModal.test.tsx b/src/components/Modals/OidcModal.test.tsx new file mode 100644 index 00000000..f76759c2 --- /dev/null +++ b/src/components/Modals/OidcModal.test.tsx @@ -0,0 +1,183 @@ +// src/components/Modals/OidcModal.test.tsx +import React from "react"; +import { + render, + screen, + act, + waitFor, +} from "@testing-library/react"; +import "@testing-library/jest-dom"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; + +import OidcModal from "./OidcModal"; +import axiosClient from "../../utils/axios_client"; + +vi.mock("../../utils/axios_client"); + +// Test data constants +const MOCK_PROVIDERS = [ + { id: "google", name: "Google" }, + { id: "github", name: "GitHub" }, +]; + +const MOCK_GOOGLE_PROVIDER = [{ id: "google", name: "Google" }]; + +const MOCK_OAUTH_RESPONSE = { + data: { redirect_uri: "https://oauth.example.com/callback" } +}; + +describe("OidcModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Mock window.location.href to prevent navigation errors in tests + delete (window as any).location; + window.location = { href: "" } as any; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + const renderModal = async (providers: any = MOCK_PROVIDERS) => { + vi.mocked(axiosClient).get.mockResolvedValueOnce({ data: providers }); + + await act(async () => { + render(); + }); + }; + + it("fetches providers on mount and displays the SSO button", async () => { + await renderModal(MOCK_PROVIDERS); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /Sign in with SSO/i })).toBeInTheDocument(); + }); + + expect(vi.mocked(axiosClient).get).toHaveBeenCalledWith( + "/auth/providers", + { skipAuth: true } + ); + }); + + it("displays providers in the modal when opened", async () => { + const user = userEvent.setup(); + + await renderModal(MOCK_PROVIDERS); + + // Click the SSO button to open the modal + await user.click(screen.getByRole("button", { name: /Sign in with SSO/i })); + + await waitFor(() => { + expect(screen.getByText("Google")).toBeInTheDocument(); + expect(screen.getByText("GitHub")).toBeInTheDocument(); + }); + }); + + it("handles provider selection and form submission", async () => { + const user = userEvent.setup(); + + vi.mocked(axiosClient).get.mockResolvedValueOnce({ data: MOCK_GOOGLE_PROVIDER }); + vi.mocked(axiosClient).post.mockResolvedValueOnce(MOCK_OAUTH_RESPONSE); + + await renderModal(MOCK_GOOGLE_PROVIDER); + + // Click the SSO button to open the modal + await user.click(screen.getByRole("button", { name: /Sign in with SSO/i })); + + await waitFor(() => { + expect(screen.getByText("Google")).toBeInTheDocument(); + }); + + // Fill in the form + await user.type(screen.getByLabelText(/User Name/i), "testuser"); + await user.selectOptions(screen.getByLabelText(/Select a provider/i), "google"); + + // Submit the form + await user.click(screen.getByRole("button", { name: /Continue with SSO/i })); + + await waitFor(() => { + expect(vi.mocked(axiosClient).post).toHaveBeenCalledWith( + "/auth/client-select", + { username: "testuser", provider: "google" }, + { skipAuth: true } + ); + // Verify that window.location.href was set to the redirect URI + expect(window.location.href).toBe("https://oauth.example.com/callback"); + }); + }); + + it("shows loading state when no providers are available", async () => { + // Ensure fresh mock setup for this test + vi.mocked(axiosClient).get.mockReset(); + const networkError = new Error("Network error"); + vi.mocked(axiosClient).get.mockRejectedValueOnce(networkError); + + await act(async () => { + render(); + }); + + // Wait a bit for the useEffect to execute and state to update + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + + // The button should not appear if fetch failed + expect(screen.queryByRole("button", { name: /Sign in with SSO/i })).not.toBeInTheDocument(); + }); + + it("handles network timeout gracefully", async () => { + const timeoutError = new Error("Request timeout"); + timeoutError.name = "TimeoutError"; + vi.mocked(axiosClient).get.mockRejectedValueOnce(timeoutError); + + await act(async () => { + render(); + }); + + expect(screen.queryByRole("button", { name: /Sign in with SSO/i })).not.toBeInTheDocument(); + }); + + it("handles network unavailable (no internet connection)", async () => { + const networkError = new Error("Network request failed"); + networkError.name = "NetworkError"; + vi.mocked(axiosClient).get.mockRejectedValueOnce(networkError); + + await act(async () => { + render(); + }); + + expect(screen.queryByRole("button", { name: /Sign in with SSO/i })).not.toBeInTheDocument(); + }); + + it("handles 503 Service Unavailable error", async () => { + const error = new Error("Service Unavailable"); + (error as any).response = { status: 503 }; + vi.mocked(axiosClient).get.mockRejectedValueOnce(error); + + await act(async () => { + render(); + }); + + expect(screen.queryByRole("button", { name: /Sign in with SSO/i })).not.toBeInTheDocument(); + }); + + it("handles 500 Internal Server Error", async () => { + const error = new Error("Internal Server Error"); + (error as any).response = { status: 500 }; + vi.mocked(axiosClient).get.mockRejectedValueOnce(error); + + await act(async () => { + render(); + }); + + expect(screen.queryByRole("button", { name: /Sign in with SSO/i })).not.toBeInTheDocument(); + }); + + it("does not render SSO button when empty providers array is returned", async () => { + await renderModal([]); + + expect(screen.queryByRole("button", { name: /Sign in with SSO/i })).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/Modals/OidcModal.tsx b/src/components/Modals/OidcModal.tsx new file mode 100644 index 00000000..dbef3203 --- /dev/null +++ b/src/components/Modals/OidcModal.tsx @@ -0,0 +1,126 @@ +import React, { useEffect, useState } from "react"; +import { Form, Formik, FormikHelpers } from "formik"; +import FormSelect from "components/Form/FormSelect"; +import FormInput from "../../components/Form/FormInput"; +import { Modal, InputGroup, Button } from "react-bootstrap"; +import * as Yup from "yup"; +import axiosClient from "../../utils/axios_client"; + +interface OidcProvider { + id: string; + name: string; +} + +interface OidcFormValues { + oidc_provider: string; +} + +const OidcModal: React.FC = () => { + const [show, setShow] = useState(false); + const [modalProviders, setModalProviders] = useState([]); + const [hasProviders, setHasProviders] = useState(false); + + useEffect(() => { + // Fetch providers on component mount or when modal visibility changes + axiosClient + .get("/auth/providers", { skipAuth: true }) + .then((response) => { + const data = response.data || []; + setModalProviders(data); + setHasProviders(data.length > 0); + }) + .catch(() => { + setModalProviders([]); + setHasProviders(false); + }); + }, []); + + const validationSchema = Yup.object({ + user_name: Yup.string().required("Required"), + oidc_provider: Yup.string().required("Please select a provider"), + }); + + const handleSubmit = (values: OidcFormValues, submitProps: FormikHelpers) => { + axiosClient + .post("/auth/client-select", { username: values.user_name, provider: values.oidc_provider }, { skipAuth: true }) + .then((response) => { + console.log("OIDC login initiated, redirecting to:", response.data.redirect_uri); + window.location.href = response.data.redirect_uri; + }) + .catch((error) => { + console.error("Failed to initiate OIDC login:", error); + }) + .finally(() => { + submitProps.setSubmitting(false); + }); + }; + + return ( + <> + {hasProviders && ( +
+
+ +
+ )} + setShow(false)} centered> + + Sign in with SSO + + + {modalProviders.length > 0 ? ( + + {(formik) => ( +
+ @} + /> + ({ + label: provider.name, + value: provider.id, + })), + ]} + onChange={(e) => { + formik.setFieldValue("oidc_provider", e.target.value); + }} + /> + + + )} +
+ ) : ( +

Loading providers...

+ )} +
+
+ + ); +}; + +export default OidcModal; diff --git a/src/pages/Authentication/Login.tsx b/src/pages/Authentication/Login.tsx index 7c02cad6..7cc11279 100644 --- a/src/pages/Authentication/Login.tsx +++ b/src/pages/Authentication/Login.tsx @@ -8,7 +8,8 @@ import { authenticationActions } from "../../store/slices/authenticationSlice"; import { alertActions } from "../../store/slices/alertSlice"; import { setAuthToken } from "../../utils/auth"; import * as Yup from "yup"; -import axios from "axios"; +import axiosClient from "utils/axios_client"; +import OidcModal from "../../components/Modals/OidcModal"; /** * @author Ankur Mundra on June, 2023 @@ -29,8 +30,8 @@ const Login: React.FC = () => { const location = useLocation(); const onSubmit = (values: ILoginFormValues, submitProps: FormikHelpers) => { - axios - .post("http://localhost:3002/login", values) + axiosClient + .post("/login", values, { skipAuth: true }) .then((response) => { const payload = setAuthToken(response.data.token); @@ -98,6 +99,7 @@ const Login: React.FC = () => { ); }} + ); diff --git a/src/pages/OidcCallback/OidcCallback.test.tsx b/src/pages/OidcCallback/OidcCallback.test.tsx new file mode 100644 index 00000000..f98ecd41 --- /dev/null +++ b/src/pages/OidcCallback/OidcCallback.test.tsx @@ -0,0 +1,196 @@ +// src/pages/OidcCallback/OidcCallback.test.tsx +import React from "react"; +import { + render, + screen, + waitFor, +} from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { MemoryRouter, Routes, Route } from "react-router-dom"; +import { Provider } from "react-redux"; +import { configureStore } from "@reduxjs/toolkit"; + +import OidcCallback from "./OidcCallback"; +import axiosClient from "../../utils/axios_client"; +import * as authUtils from "../../utils/auth"; +import authenticationReducer from "../../store/slices/authenticationSlice"; +import alertReducer from "../../store/slices/alertSlice"; + +vi.mock("../../utils/axios_client"); +vi.mock("../../utils/auth"); + +// Test data constants +const MOCK_JWT_TOKEN = "fake-jwt-token"; +const MOCK_SESSION_TOKEN = "fake-session-token"; +const MOCK_USER_ID = "123"; +const CALLBACK_ROUTE = "/auth/callback"; +const CALLBACK_URL = `${CALLBACK_ROUTE}?code=test-code&state=test-state`; + +const MOCK_AUTH_PAYLOAD = { + exp: Math.floor(Date.now() / 1000) + 3600, + user_id: MOCK_USER_ID, +}; + +const MOCK_SUCCESS_RESPONSE = { + data: { + token: MOCK_JWT_TOKEN, + session_token: MOCK_SESSION_TOKEN, + }, +}; + +const MOCK_ERROR_RESPONSE = { + error: "access_denied", +}; + +describe("OidcCallback", () => { + let store: any; + + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + store = configureStore({ + reducer: { + authentication: authenticationReducer, + alert: alertReducer, + }, + }); + }); + + afterEach(() => { + localStorage.clear(); + }); + + /** + * Renders the OidcCallback component wrapped in Router and Redux Provider + * @param initialEntry - URL to render at (default: successful callback) + * @param shouldMockSuccess - Whether to mock successful auth response (default: true) + */ + const renderCallback = ( + initialEntry: string = CALLBACK_URL, + shouldMockSuccess: boolean = true + ) => { + if (shouldMockSuccess) { + vi.mocked(authUtils.setAuthToken).mockReturnValueOnce(MOCK_AUTH_PAYLOAD as any); + vi.mocked(axiosClient).post.mockResolvedValueOnce(MOCK_SUCCESS_RESPONSE); + } + + render( + + + + } /> + Home} /> + Login} /> + + + + ); + }; + + it("displays loading message while processing callback", async () => { + renderCallback(); + + expect(screen.getByText("Completing login...")).toBeInTheDocument(); + + await waitFor(() => { + expect(vi.mocked(axiosClient).post).toHaveBeenCalled(); + }); + }); + + it("attempts to exchange code and state for token", async () => { + renderCallback(CALLBACK_URL); + + await waitFor(() => { + expect(vi.mocked(axiosClient).post).toHaveBeenCalledWith( + CALLBACK_ROUTE, + { code: "test-code", state: "test-state" }, + { skipAuth: true } + ); + }, { timeout: 3000 }); + }); + + it("sets auth token when callback succeeds", async () => { + renderCallback(); + + await waitFor(() => { + expect(vi.mocked(authUtils.setAuthToken)).toHaveBeenCalledWith(MOCK_JWT_TOKEN); + }, { timeout: 3000 }); + + // localStorage session was persisted + const session = JSON.parse(localStorage.getItem("session") || "{}"); + expect(session.user).toBeDefined(); + + // Redux received the correct token + expect(store.getState().authentication.authToken).toBe(MOCK_JWT_TOKEN); + expect(store.getState().authentication.user).toEqual(MOCK_AUTH_PAYLOAD); + + // Navigated to the dashboard + await waitFor(() => { + expect(screen.getByText("Home")).toBeInTheDocument(); + }); + }); + + it("does not make API call when missing code", async () => { + renderCallback(`${CALLBACK_ROUTE}?state=test-state`, false); + + await waitFor(() => { + expect(vi.mocked(axiosClient).post).not.toHaveBeenCalled(); + }); + }); + + it("does not make API call when missing state", async () => { + renderCallback(`${CALLBACK_ROUTE}?code=test-code`, false); + + await waitFor(() => { + expect(vi.mocked(axiosClient).post).not.toHaveBeenCalled(); + }); + }); + + it("handles OIDC callback error gracefully", async () => { + const errorResponse = new Error("Authentication failed"); + (errorResponse as any).response = { data: MOCK_ERROR_RESPONSE }; + vi.mocked(axiosClient).post.mockRejectedValueOnce(errorResponse); + + renderCallback(CALLBACK_URL, false); + + // Wait for the redirect — confirms the catch block fully ran + await waitFor(() => { + expect(screen.getByText("Login")).toBeInTheDocument(); + }); + + // Alert was dispatched with the backend error message + expect(store.getState().alert.show).toBe(true); + expect(store.getState().alert.variant).toBe("danger"); + expect(store.getState().alert.message).toBe(MOCK_ERROR_RESPONSE.error); + }); + + it("handles network timeout during callback", async () => { + const timeoutError = new Error("Request timeout"); + timeoutError.name = "TimeoutError"; + vi.mocked(axiosClient).post.mockRejectedValueOnce(timeoutError); + + renderCallback(CALLBACK_URL, false); + + await waitFor(() => { + expect(vi.mocked(axiosClient).post).toHaveBeenCalled(); + }); + }); + + it("shows error parameter in URL query and does not make API call", async () => { + renderCallback(`${CALLBACK_ROUTE}?error=access_denied`, false); + + // Wait for the redirect — the real observable side effect in this branch + await waitFor(() => { + expect(screen.getByText("Login")).toBeInTheDocument(); + }); + + // After the full useEffect has run, the backend should never have been called + expect(vi.mocked(axiosClient).post).not.toHaveBeenCalled(); + + // Alert was dispatched with the provider error value + expect(store.getState().alert.show).toBe(true); + expect(store.getState().alert.variant).toBe("danger"); + expect(store.getState().alert.message).toContain("access_denied"); + }); +}); \ No newline at end of file diff --git a/src/pages/OidcCallback/OidcCallback.tsx b/src/pages/OidcCallback/OidcCallback.tsx new file mode 100644 index 00000000..d3f938b7 --- /dev/null +++ b/src/pages/OidcCallback/OidcCallback.tsx @@ -0,0 +1,71 @@ +import React, { useEffect } from "react"; +import { useNavigate, useSearchParams, useLocation } from "react-router-dom"; +import { useDispatch } from "react-redux"; +import { authenticationActions } from "../../store/slices/authenticationSlice"; +import { alertActions } from "../../store/slices/alertSlice"; +import { setAuthToken } from "../../utils/auth"; +import axiosClient from "../../utils/axios_client"; + +const OidcCallback: React.FC = () => { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const dispatch = useDispatch(); + const location = useLocation(); + + useEffect(() => { + const code = searchParams.get("code"); + const state = searchParams.get("state"); + const error = searchParams.get("error"); + + if (error) { + dispatch( + alertActions.showAlert({ + variant: "danger", + message: `Authentication was denied: ${error}`, + title: "OIDC login failed", + }) + ); + navigate("/login"); + return; + } + + if (!code || !state) { + navigate("/login"); + return; + } + + axiosClient + .post("/auth/callback", { code, state }, { skipAuth: true }) + .then((response) => { + const payload = setAuthToken(response.data.token); + + localStorage.setItem("session", JSON.stringify({ user: payload })); + + dispatch( + authenticationActions.setAuthentication({ + authToken: response.data.token, + user: payload, + }) + ); + navigate(location.state?.from ? location.state.from : "/"); + }) + .catch((error) => { + dispatch( + alertActions.showAlert({ + variant: "danger", + message: error.response?.data?.error || error.message, + title: "OIDC login failed", + }) + ); + navigate("/login"); + }); + }, []); + + return ( +
+

Completing login...

+
+ ); +}; + +export default OidcCallback; diff --git a/src/types/axiosAuth.d.ts b/src/types/axiosAuth.d.ts new file mode 100644 index 00000000..2da9452b --- /dev/null +++ b/src/types/axiosAuth.d.ts @@ -0,0 +1,10 @@ +import 'axios'; + +declare module 'axios' { + export interface AxiosRequestConfig { + skipAuth?: boolean; + } + export interface InternalAxiosRequestConfig { + skipAuth?: boolean; + } +} \ No newline at end of file diff --git a/src/utils/axios_client.ts b/src/utils/axios_client.ts index 77a4ba16..20a35256 100644 --- a/src/utils/axios_client.ts +++ b/src/utils/axios_client.ts @@ -6,7 +6,7 @@ import { getAuthToken } from "./auth"; */ const axiosClient = axios.create({ - baseURL: "http://localhost:3002", + baseURL: import.meta.env.VITE_API_BASE_URL || "http://localhost:3002", timeout: 10000, // Increased from 1000ms to 10 seconds headers: { "Content-Type": "application/json", @@ -15,6 +15,11 @@ const axiosClient = axios.create({ }); axiosClient.interceptors.request.use((config) => { + // Check if this request should skip authentication (for login / OIDC) + if (config.skipAuth) { + return config; + } + const token = getAuthToken(); if (token && token !== "EXPIRED") { config.headers["Authorization"] = `Bearer ${token}`;