) => {
+ 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) => (
+
+ )}
+
+ ) : (
+ 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 (
+
+ );
+};
+
+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}`;