Skip to content

Commit

Permalink
chore: wip consolidate and improve authentication flow
Browse files Browse the repository at this point in the history
  • Loading branch information
fpigeonjr committed Feb 28, 2025
1 parent a684d4b commit f0629df
Show file tree
Hide file tree
Showing 15 changed files with 751 additions and 184 deletions.
146 changes: 146 additions & 0 deletions frontend/README-AUTH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# Authentication Implementation

This document outlines the authentication implementation in the OPRE-OPS frontend application.

## Core Concepts

### Protected Routes

We use a wrapper component (`ProtectedRoute`) to check authentication status and redirect if needed. This component leverages React Router's `Navigate` component to handle redirects while preserving the intended destination.

### Navigation Handling

We use React Router's `Navigate` component for redirects and the `useLocation` hook to preserve redirect-after-login behavior. This ensures users are redirected to their intended destination after successful login.

### State Management

Authentication state is managed with Redux Toolkit and enhanced with RTK Query for API interactions. This provides a clean separation of concerns between state management and API calls.

### Token Management

JWT tokens are stored in localStorage and managed through Redux. Token refresh logic is handled by RTK Query middleware.

### Security Considerations

- We use state tokens to prevent CSRF attacks during the OAuth flow
- We validate tokens before using them
- We use the `replace` option in `Navigate` to avoid polluting browser history with redirects
- We implement proper error handling for authentication failures

## Key Components

### MultiAuthSection

Handles the login process, including:

- OAuth flow with multiple providers
- Token storage and validation
- Redirect after successful login
- Error handling

### ProtectedRoute

Protects routes that require authentication:

- Checks if the user is authenticated
- Redirects to login if not authenticated
- Preserves the intended destination for redirect after login

### RTK Query Authentication API

Provides a clean interface for authentication-related API calls:

- Login
- Logout
- Token refresh
- User profile retrieval

## Testing

We use Mock Service Worker (MSW) with Vitest to mock authentication endpoints for testing. This allows us to:

- Test authentication flows without a real backend
- Simulate different authentication scenarios (success, failure, etc.)
- Test protected routes and redirects

## Usage Examples

### Protecting a Route

```jsx
<Routes>
<Route
path="/"
element={<Layout />}
>
<Route
index
element={<Home />}
/>
<Route
path="login"
element={<MultiAuthSection />}
/>
<Route element={<ProtectedRoute />}>
<Route
path="dashboard"
element={<Dashboard />}
/>
<Route
path="profile"
element={<Profile />}
/>
</Route>
</Route>
</Routes>
```

### Making Authenticated API Calls

```jsx
import { useGetUserProfileQuery } from "../../api/opsAuthAPI";

const UserProfile = () => {
const { data, isLoading, error } = useGetUserProfileQuery();

if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error loading profile</div>;

return <div>Welcome, {data.name}</div>;
};
```

### Logging Out

```jsx
import { useLogoutMutation } from "../../api/opsAuthAPI";

const LogoutButton = () => {
const [logout, { isLoading }] = useLogoutMutation();

const handleLogout = async () => {
try {
await logout().unwrap();
// Redirect happens automatically via the RTK Query middleware
} catch (error) {
console.error("Logout failed:", error);
}
};

return (
<button
onClick={handleLogout}
disabled={isLoading}
>
{isLoading ? "Logging out..." : "Logout"}
</button>
);
};
```

## Future Improvements

- Move tokens to secure HTTP-only cookies for better security
- Implement token rotation for enhanced security
- Add more granular role-based access control
- Implement silent refresh for better user experience
12 changes: 0 additions & 12 deletions frontend/src/api/apiLogin.js

This file was deleted.

16 changes: 0 additions & 16 deletions frontend/src/api/apiLogin.test.js

This file was deleted.

45 changes: 43 additions & 2 deletions frontend/src/api/opsAuthAPI.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import { getAccessToken } from "../components/Auth/auth";
import { logout } from "../components/Auth/authSlice";

const BACKEND_DOMAIN =
window.__RUNTIME_CONFIG__?.REACT_APP_BACKEND_DOMAIN ||
Expand All @@ -8,7 +9,7 @@ const BACKEND_DOMAIN =

export const opsAuthApi = createApi({
reducerPath: "opsAuthApi",
tagTypes: ["Roles"],
tagTypes: ["Roles", "Auth"],
baseQuery: fetchBaseQuery({
baseUrl: `${BACKEND_DOMAIN}/auth/`,
prepareHeaders: (headers) => {
Expand All @@ -27,8 +28,48 @@ export const opsAuthApi = createApi({
getRoles: builder.query({
query: () => `/roles/`,
providesTags: ["Roles"]
}),
login: builder.mutation({
query: ({ provider, code }) => ({
url: "/login/",
method: "POST",
body: { provider, code }
}),
invalidatesTags: ["Auth"]
}),
logout: builder.mutation({
query: () => ({
url: "/logout/",
method: "POST"
}),
async onQueryStarted(_, { dispatch, queryFulfilled }) {
try {
await queryFulfilled;
// Dispatch logout action to clear auth state
dispatch(logout());
} catch (err) {
console.error("Error during logout:", err);
}
}
}),
refreshToken: builder.mutation({
query: () => ({
url: "/refresh/",
method: "POST"
}),
invalidatesTags: ["Auth"]
}),
getUserProfile: builder.query({
query: () => "/profile/",
providesTags: ["Auth"]
})
})
});

export const { useGetRolesQuery } = opsAuthApi;
export const {
useGetRolesQuery,
useLoginMutation,
useLogoutMutation,
useRefreshTokenMutation,
useGetUserProfileQuery
} = opsAuthApi;
72 changes: 53 additions & 19 deletions frontend/src/components/Auth/AuthSection.jsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,43 @@
import { login, logout } from "./authSlice";
import { useDispatch, useSelector } from "react-redux";
import { faArrowRightToBracket } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import cryptoRandomString from "crypto-random-string";
import { useCallback, useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import cryptoRandomString from "crypto-random-string";
import { getAccessToken, getAuthorizationCode } from "./auth";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowRightToBracket } from "@fortawesome/free-solid-svg-icons";
import { useLoginMutation, useLogoutMutation } from "../../api/opsAuthAPI";
import User from "../UI/Header/User";
import { apiLogin, apiLogout } from "../../api/apiLogin";
import NotificationCenter from "../UI/NotificationCenter/NotificationCenter";
import { setActiveUser } from "./auth";
import { getAccessToken, getAuthorizationCode, setActiveUser } from "./auth";
import { login, logout } from "./authSlice";

/**
* Authentication section component that handles login/logout functionality
* @returns {React.ReactElement} The AuthSection component
*/
const AuthSection = () => {
const isLoggedIn = useSelector((state) => state.auth.isLoggedIn);
const activeUser = useSelector((state) => state.auth.activeUser);
// Use type assertion in the selector to fix TypeScript errors
/** @type {boolean} */
const isLoggedIn = useSelector((state) => state.auth?.isLoggedIn);
/** @type {Object|null} */
const activeUser = useSelector((state) => state.auth?.activeUser);
const dispatch = useDispatch();
const navigate = useNavigate();
const [loginMutation] = useLoginMutation();
const [logoutMutation] = useLogoutMutation();

/**
* Handles the authentication code callback from the provider
* @param {string} authCode - The authorization code from the provider
*/
const callBackend = useCallback(
async (authCode) => {
try {
const response = await apiLogin(authCode);
const activeProvider = localStorage.getItem("activeProvider") || "logingov";
const response = await loginMutation({
provider: activeProvider,
code: authCode
}).unwrap();

if (response.access_token) {
localStorage.setItem("access_token", response.access_token);
localStorage.setItem("refresh_token", response.refresh_token);
Expand All @@ -35,7 +52,7 @@ const AuthSection = () => {
navigate("/login");
}
},
[activeUser, dispatch, navigate]
[activeUser, dispatch, navigate, loginMutation]
);

useEffect(() => {
Expand Down Expand Up @@ -68,7 +85,9 @@ const AuthSection = () => {
} else {
const authCode = queryParams.get("code");
console.log(`Received Authentication Code = ${authCode}`);
callBackend(authCode).catch(console.error);
if (authCode) {
callBackend(authCode).catch(console.error);
}
}
}
} else {
Expand All @@ -77,10 +96,22 @@ const AuthSection = () => {
}
}, [activeUser, callBackend, dispatch, navigate]);

/**
* Handles user logout
*/
const logoutHandler = async () => {
await apiLogout();
await dispatch(logout());
navigate("/login");
try {
// Use the RTK Query logout mutation
await logoutMutation().unwrap();
// The logout action is already dispatched in the onQueryStarted callback in opsAuthAPI.js
await dispatch(logout());
navigate("/login");
} catch (error) {
console.error("Error during logout:", error);
// Still attempt to logout locally even if the API call fails
await dispatch(logout());
navigate("/login");
}
// TODO: ⬇ Logout from Auth Provider ⬇
// const output = await logoutUser(localStorage.getItem("ops-state-key"));
// console.log(output);
Expand All @@ -93,9 +124,12 @@ const AuthSection = () => {
<div>
<button
className="usa-button fa-solid fa-arrow-right-to-bracket margin-1"
onClick={() =>
(window.location.href = getAuthorizationCode(localStorage.getItem("ops-state-key")))
}
onClick={() => {
const stateKey = localStorage.getItem("ops-state-key");
if (stateKey) {
window.location.href = getAuthorizationCode("logingov", stateKey).toString();
}
}}
>
<span className="margin-1">Sign-in</span>
<FontAwesomeIcon icon={faArrowRightToBracket} />
Expand Down
Loading

0 comments on commit f0629df

Please sign in to comment.