Skip to content

Commit

Permalink
CMS-586: Store Keycloak roles in a global context (#69)
Browse files Browse the repository at this point in the history
  • Loading branch information
duncan-oxd authored Jan 15, 2025
1 parent fb9fb2e commit 69201b0
Show file tree
Hide file tree
Showing 10 changed files with 171 additions and 27 deletions.
8 changes: 2 additions & 6 deletions backend/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import RateLimit from "express-rate-limit";
import checkJwt from "./middleware/checkJwt.js";
import { admin, adminRouter, sessionMiddleware } from "./middleware/adminJs.js";
import homeRoutes from "./routes/home.js";
import helloRoute from "./routes/nested-path-example/hello.js";
import parkRoutes from "./routes/api/parks.js";
import seasonRoutes from "./routes/api/seasons.js";
import exportRoutes from "./routes/api/export.js";
Expand Down Expand Up @@ -51,10 +50,7 @@ app.use(limiter);
app.use(sessionMiddleware);

// Public routes
app.use("/", homeRoutes); // example stuff for testing

// Routes with JWT check middleware
app.use("/nested-path-example/", checkJwt, helloRoute); // example stuff for testing
app.use("/", homeRoutes); // Health check route(s)

// API routes
const apiRouter = express.Router();
Expand All @@ -63,7 +59,7 @@ apiRouter.use("/parks", parkRoutes);
apiRouter.use("/seasons", seasonRoutes);
apiRouter.use("/export", exportRoutes);

app.use("/api", apiRouter);
app.use("/api", checkJwt, apiRouter);

// AdminJS routes
app.use(admin.options.rootPath, adminRouter);
Expand Down
2 changes: 1 addition & 1 deletion backend/routes/home.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const router = Router();

// http://0.0.0.0:8100/
router.get("/", (req, res) => {
res.json({ msg: "this is the home route" });
res.json({ msg: "Dates of Operation Tool" });
});

// http://0.0.0.0:8100/time
Expand Down
12 changes: 0 additions & 12 deletions backend/routes/nested-path-example/hello.js

This file was deleted.

9 changes: 9 additions & 0 deletions frontend/src/config/permissions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// @TODO: map role names to specific permissions

// Export constants for role names
export const ROLES = {
SUPER_ADMIN: "doot-super-admin",
APPROVER: "doot-approver",
RSO: "doot-rso",
PO: "doot-po",
};
9 changes: 9 additions & 0 deletions frontend/src/hooks/useAccess.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { useContext } from "react";
import { AccessContext } from "@/router/AccessContext";
import { ROLES } from "@/config/permissions";

export function useAccess() {
const { roles, checkAccess } = useContext(AccessContext);

return { roles, checkAccess, ROLES };
}
41 changes: 36 additions & 5 deletions frontend/src/hooks/useApi.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef, useCallback } from "react";
import axios from "axios";
import { useAuth } from "react-oidc-context";
import getEnv from "../config/getEnv";

// Create an Axios instance for the API server
Expand All @@ -12,11 +13,15 @@ export function useApiGet(endpoint, options = {}) {
const [error, setError] = useState(null);
const requestSent = useRef(false);

const auth = useAuth();

// Parse options:
// URL parameters
const params = options.params ?? {};
// Instantly start the request. Set false to call fetchData manually
const instant = options.instant ?? true;
// Include Keycloak access token in the request
const includeToken = options.includeToken ?? true;

// If instant is true, the request will be sent immediately
const [loading, setLoading] = useState(instant);
Expand All @@ -32,7 +37,17 @@ export function useApiGet(endpoint, options = {}) {
try {
requestSent.current = true;

const response = await axiosInstance.get(endpoint, { params });
// Build request configuration object
const config = { params };

// Add the Keycloak token to the request headers
if (includeToken) {
config.headers = {
Authorization: `Bearer ${auth?.user?.access_token}`,
};
}

const response = await axiosInstance.get(endpoint, config);

setData(response.data);
} catch (err) {
Expand All @@ -42,7 +57,7 @@ export function useApiGet(endpoint, options = {}) {
}

// eslint-disable-next-line react-hooks/exhaustive-deps -- paramsString is a stringified object
}, [endpoint, paramsString]);
}, [endpoint, paramsString, includeToken]);

useEffect(() => {
// Prevent sending multiple requests
Expand All @@ -55,18 +70,34 @@ export function useApiGet(endpoint, options = {}) {
return { data, loading, error, fetchData };
}

export function useApiPost(endpoint) {
export function useApiPost(endpoint, options = {}) {
const [responseData, setResponseData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);

const auth = useAuth();

// Parse options:
// Include Keycloak access token in the request
const includeToken = options.includeToken ?? true;

const sendData = useCallback(
async (payload) => {
setLoading(true);
setError(null);

try {
const response = await axiosInstance.post(endpoint, payload);
// Build request configuration object
const config = {};

// Add the Keycloak token to the request headers
if (includeToken) {
config.headers = {
Authorization: `Bearer ${auth?.user?.access_token}`,
};
}

const response = await axiosInstance.post(endpoint, payload, config);

setResponseData(response.data);
return response.data;
Expand All @@ -77,7 +108,7 @@ export function useApiPost(endpoint) {
setLoading(false);
}
},
[endpoint],
[endpoint, includeToken, auth?.user?.access_token],
);

return { responseData, loading, error, sendData };
Expand Down
43 changes: 43 additions & 0 deletions frontend/src/router/AccessContext.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import PropTypes from "prop-types";
import { createContext } from "react";
import getEnv from "@/config/getEnv";
import { ROLES } from "@/config/permissions";

export const AccessContext = createContext();

export function AccessProvider({ children, auth }) {
// Decode the token to get the user's roles
const roles = [];
const accessToken = auth?.user?.access_token;
const clientId = getEnv("VITE_OIDC_CLIENT_ID"); // "staff-portal"

if (accessToken) {
const payload = accessToken.split(".").at(1);
const decodedPayload = atob(payload);
const parsedPayload = JSON.parse(decodedPayload);
const payloadRoles = parsedPayload?.resource_access?.[clientId].roles ?? [];

roles.push(...payloadRoles);
}

// @TODO: implement fine-grained permission checks here
function checkAccess(requiredRole) {
// Super admin can access everything
if (roles.includes(ROLES.SUPER_ADMIN)) return true;

return roles.includes(requiredRole);
}

// Provide the context value to child components
return (
<AccessContext.Provider value={{ roles, checkAccess }}>
{children}
</AccessContext.Provider>
);
}

// prop validation
AccessProvider.propTypes = {
children: PropTypes.node,
auth: PropTypes.object,
};
13 changes: 10 additions & 3 deletions frontend/src/router/ProtectedRoute.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { withAuthenticationRequired } from "react-oidc-context";
import { useAuth, withAuthenticationRequired } from "react-oidc-context";
import PropTypes from "prop-types";
import { AccessProvider } from "@/router/AccessContext";

// Higher-order component that wraps a route component for authentication
// Wrap a "layout" component in this component to protect all of its children
Expand All @@ -11,11 +12,17 @@ export default function ProtectedRoute({
throw new Error("Component prop is required");
}

const auth = useAuth();

const ComponentWithAuth = withAuthenticationRequired(Component, {
onRedirecting: () => <div>Redirecting you to log in...</div>,
onRedirecting: () => <div>Redirecting...</div>,
});

return <ComponentWithAuth {...otherProps} />;
return (
<AccessProvider auth={auth}>
<ComponentWithAuth {...otherProps} />
</AccessProvider>
);
}

// Define prop types for ProtectedRoute
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/router/index.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createBrowserRouter } from "react-router-dom";
import ApiTest from "./pages/ApiTest";
import LogOut from "./pages/LogOut";
import EditAndReview from "./pages/EditAndReview";
import PublishPage from "./pages/PublishPage";
import ExportPage from "./pages/ExportPage";
Expand Down Expand Up @@ -48,6 +49,12 @@ const RouterConfig = createBrowserRouter(
element: <ApiTest />,
},

// Log out of Keycloak (and show the OIDC login page again)
{
path: "/logout",
element: <LogOut />,
},

// view park details
{
path: "/park/:parkId",
Expand Down
54 changes: 54 additions & 0 deletions frontend/src/router/pages/LogOut.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// src/App.jsx
import { useAuth } from "react-oidc-context";
// import getEnv from "@/config/getEnv";

function App() {
const auth = useAuth();

function logOut() {
auth.stopSilentRenew();
auth.signoutRedirect();
}

// function logOutManual() {
// // Manually clear cookies
// const cookiesToClear = [
// "AUTH_SESSION_ID",
// "AUTH_SESSION_ID_LEGACY",
// "FAILREASON",
// "KEYCLOAK_IDENTITY",
// "KEYCLOAK_IDENTITY_LEGACY",
// "KEYCLOAK_SESSION",
// "KEYCLOAK_SESSION_LEGACY",
// "SMSESSION",
// ];

// cookiesToClear.forEach((cookieName) => {
// document.cookie = `${cookieName}=; path=/; domain=${window.location.hostname}; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
// });

// // Manually clear session storage
// sessionStorage.clear();

// // Manually go to the logout page
// window.location.href = `${getEnv("VITE_OIDC_AUTHORITY")}/protocol/openid-connect/logout?redirect_uri=${window.location.origin}`;
// }

if (auth.isLoading) {
return <div>Loading...</div>;
}

if (auth.error) {
return <div>Auth error: {auth.error.message}</div>;
}

return (
<div>
<button type="button" onClick={logOut}>
Log out {auth.user?.profile.sub}
</button>
</div>
);
}

export default App;

0 comments on commit 69201b0

Please sign in to comment.