From 7fa42a6eb5152ec7158cb05bb4a1f87c4e27de30 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 1 Aug 2023 09:01:07 -0500 Subject: [PATCH 1/4] Initial WIP Checkpoint of auth0 userSessions --- src/App.tsx | 100 +++++++++++++++++++++++++------ src/HandleAuthorizationError.tsx | 6 +- src/setupProxy.js | 1 + 3 files changed, 86 insertions(+), 21 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index c9995f6..17aa959 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,7 +33,7 @@ import CssBaseline from "@mui/material/CssBaseline"; import Icon from "@mui/material/Icon"; import {ThemeProvider} from "@mui/material/styles"; import {LicenseInfo} from "@mui/x-license-pro"; -import React, {JSXElementConstructor, Key, ReactElement, useContext, useEffect, useState,} from "react"; +import React, {JSXElementConstructor, Key, ReactElement, useEffect, useState,} from "react"; import {useCookies} from "react-cookie"; import {Navigate, Route, Routes, useLocation,} from "react-router-dom"; import {Md5} from "ts-md5/dist/md5"; @@ -57,11 +57,11 @@ import ProcessUtils from "qqq/utils/qqq/ProcessUtils"; const qController = Client.getInstance(); -export const SESSION_ID_COOKIE_NAME = "sessionId"; +export const SESSION_UUID_COOKIE_NAME = "sessionUUID"; export default function App() { - const [, setCookie, removeCookie] = useCookies([SESSION_ID_COOKIE_NAME]); + const [, setCookie, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]); const {user, getAccessTokenSilently, logout} = useAuth0(); const [loadingToken, setLoadingToken] = useState(false); const [isFullyAuthenticated, setIsFullyAuthenticated] = useState(false); @@ -69,8 +69,62 @@ export default function App() const [branding, setBranding] = useState({} as QBrandingMetaData); const [metaData, setMetaData] = useState({} as QInstance); const [needLicenseKey, setNeedLicenseKey] = useState(true); + const [loggedInUser, setLoggedInUser] = useState({} as { name?: string, email?: string }); const [defaultRoute, setDefaultRoute] = useState("/no-apps"); + const decodeJWT = (jwt: string): any => + { + const base64Url = jwt.split(".")[1]; + const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); + const jsonPayload = decodeURIComponent(window.atob(base64).split("").map(function (c) + { + return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2); + }).join("")); + + return JSON.parse(jsonPayload); + }; + + const shouldStoreNewToken = (newToken: string, oldToken: string): boolean => + { + if (!oldToken) + { + return (true); + } + + try + { + const oldJSON = decodeJWT(oldToken); + const newJSON = decodeJWT(newToken); + + //////////////////////////////////////////////////////////////////////////////////// + // if the old (local storage) token is expired, then we need to store the new one // + //////////////////////////////////////////////////////////////////////////////////// + const oldExp = oldJSON["exp"]; + if(oldExp * 1000 < (new Date().getTime())) + { + return (true); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // remove the exp & iat values from what we compare - as they are always different from auth0 // + // note, this is only deleting them from what we compare, not from what we'd store. // + //////////////////////////////////////////////////////////////////////////////////////////////// + delete newJSON["exp"] + delete newJSON["iat"] + delete oldJSON["exp"] + delete oldJSON["iat"] + + const different = JSON.stringify(newJSON) !== JSON.stringify(oldJSON); + return (different); + } + catch(e) + { + console.log("Caught in shouldStoreNewToken: " + e) + } + + return (true); + }; + useEffect(() => { if (loadingToken) @@ -92,20 +146,28 @@ export default function App() { console.log("Loading token from auth0..."); const accessToken = await getAccessTokenSilently(); - qController.setAuthorizationHeaderValue("Bearer " + accessToken); + // qController.setAuthorizationHeaderValue("Bearer " + accessToken); - ///////////////////////////////////////////////////////////////////////////////// - // we've stopped using session id cook with auth0, so make sure it is not set. // - ///////////////////////////////////////////////////////////////////////////////// - removeCookie(SESSION_ID_COOKIE_NAME); + const lsAccessToken = localStorage.getItem("accessToken"); + if (shouldStoreNewToken(accessToken, lsAccessToken)) + { + const newSessionUuid = await qController.manageSession(accessToken, null); + setCookie(SESSION_UUID_COOKIE_NAME, newSessionUuid, {path: "/"}); + localStorage.setItem("accessToken", accessToken); + } setIsFullyAuthenticated(true); + qController.setGotAuthentication(); + + setLoggedInUser(user); console.log("Token load complete."); } catch (e) { console.log(`Error loading token: ${JSON.stringify(e)}`); - qController.clearAuthenticationMetaDataLocalStorage(); + // qController.clearAuthenticationMetaDataLocalStorage(); + localStorage.removeItem("accessToken") + removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"}); logout(); return; } @@ -116,9 +178,9 @@ export default function App() // use a random token if anonymous or mock // ///////////////////////////////////////////// console.log("Generating random token..."); - qController.setAuthorizationHeaderValue(null); + // qController.setAuthorizationHeaderValue(null); setIsFullyAuthenticated(true); - setCookie(SESSION_ID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"}); + setCookie(SESSION_UUID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"}); console.log("Token generation complete."); return; } @@ -149,7 +211,7 @@ export default function App() const [needToLoadRoutes, setNeedToLoadRoutes] = useState(true); const [sideNavRoutes, setSideNavRoutes] = useState([]); const [appRoutes, setAppRoutes] = useState(null as any); - const [pathToLabelMap, setPathToLabelMap] = useState({} as {[path: string]: string}); + const [pathToLabelMap, setPathToLabelMap] = useState({} as { [path: string]: string }); //////////////////////////////////////////// // load qqq meta data to make more routes // @@ -267,14 +329,14 @@ export default function App() name: `${app.label}`, key: app.name, route: path, - component: , + component: , }); routeList.push({ name: `${app.label}`, key: app.name, route: `${path}/savedFilter/:id`, - component: , + component: , }); routeList.push({ @@ -429,11 +491,11 @@ export default function App() let profileRoutes = {}; const gravatarBase = "https://www.gravatar.com/avatar/"; - const hash = Md5.hashStr(user?.email || "user"); + const hash = Md5.hashStr(loggedInUser?.email || "user"); const profilePicture = `${gravatarBase}${hash}`; profileRoutes = { type: "collapse", - name: user?.name, + name: loggedInUser?.name ?? "Anonymous", key: "username", noCollapse: true, icon: , @@ -495,11 +557,13 @@ export default function App() { if ((e as QException).status === "401") { - qController.clearAuthenticationMetaDataLocalStorage(); + // todo revisit qController.clearAuthenticationMetaDataLocalStorage(); ////////////////////////////////////////////////////// // todo - this is auth0 logout... make more generic // ////////////////////////////////////////////////////// + localStorage.removeItem("accessToken") + removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"}); logout(); return; } @@ -596,7 +660,7 @@ export default function App() }}> - + { logout(); - removeCookie(SESSION_ID_COOKIE_NAME, {path: "/"}); + removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"}); }); return ( diff --git a/src/setupProxy.js b/src/setupProxy.js index d693159..dfe7bdd 100644 --- a/src/setupProxy.js +++ b/src/setupProxy.js @@ -49,6 +49,7 @@ module.exports = function (app) app.use("/data/*", getRequestHandler()); app.use("/widget/*", getRequestHandler()); app.use("/serverInfo", getRequestHandler()); + app.use("/manageSession", getRequestHandler()); app.use("/processes", getRequestHandler()); app.use("/reports", getRequestHandler()); app.use("/images", getRequestHandler()); From 7bf515554df5273dea7feba11a5d8c3fe9baae12 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 15 Aug 2023 09:08:44 -0500 Subject: [PATCH 2/4] CE-609 - staged-rollout-ready - keeping the auth header, but also setting sessionUUID cookie; placeholder for quick-rollback; added todo#authHeader comments to mark where follow-up needs to happen after happy with new code --- package.json | 2 +- src/App.tsx | 31 ++++++++++++++++----- src/qqq/pages/processes/ProcessRun.tsx | 11 ++++++-- src/qqq/pages/records/query/RecordQuery.tsx | 1 + src/qqq/utils/HtmlUtils.ts | 10 +++++++ 5 files changed, 45 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index bf637c9..dc95524 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "@auth0/auth0-react": "1.10.2", "@emotion/react": "11.7.1", "@emotion/styled": "11.6.0", - "@kingsrook/qqq-frontend-core": "1.0.79", + "@kingsrook/qqq-frontend-core": "1.0.81", "@mui/icons-material": "5.4.1", "@mui/material": "5.11.1", "@mui/styles": "5.11.1", diff --git a/src/App.tsx b/src/App.tsx index 17aa959..8727c18 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -38,6 +38,7 @@ import {useCookies} from "react-cookie"; import {Navigate, Route, Routes, useLocation,} from "react-router-dom"; import {Md5} from "ts-md5/dist/md5"; import CommandMenu from "CommandMenu"; +import DNDTest from "DNDTest"; import QContext from "QContext"; import Sidenav from "qqq/components/horseshoe/sidenav/SideNav"; import theme from "qqq/components/legacy/Theme"; @@ -102,6 +103,7 @@ export default function App() const oldExp = oldJSON["exp"]; if(oldExp * 1000 < (new Date().getTime())) { + console.log("Access token in local storage was expired."); return (true); } @@ -115,6 +117,10 @@ export default function App() delete oldJSON["iat"] const different = JSON.stringify(newJSON) !== JSON.stringify(oldJSON); + if(different) + { + console.log("Latest access token from auth0 has changed vs localStorage."); + } return (different); } catch(e) @@ -146,18 +152,28 @@ export default function App() { console.log("Loading token from auth0..."); const accessToken = await getAccessTokenSilently(); - // qController.setAuthorizationHeaderValue("Bearer " + accessToken); const lsAccessToken = localStorage.getItem("accessToken"); if (shouldStoreNewToken(accessToken, lsAccessToken)) { + console.log("Sending accessToken to backend, requesting a sessionUUID..."); const newSessionUuid = await qController.manageSession(accessToken, null); setCookie(SESSION_UUID_COOKIE_NAME, newSessionUuid, {path: "/"}); localStorage.setItem("accessToken", accessToken); } + /* + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // todo#authHeader - this is our quick rollback plan - if we feel the need to stop using the cookie approach. // + // we turn off the shouldStoreNewToken block above, and turn on these 2 lines. // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"}); + localStorage.removeItem("accessToken"); + */ + setIsFullyAuthenticated(true); qController.setGotAuthentication(); + qController.setAuthorizationHeaderValue("Bearer " + accessToken); setLoggedInUser(user); console.log("Token load complete."); @@ -165,7 +181,7 @@ export default function App() catch (e) { console.log(`Error loading token: ${JSON.stringify(e)}`); - // qController.clearAuthenticationMetaDataLocalStorage(); + qController.clearAuthenticationMetaDataLocalStorage(); localStorage.removeItem("accessToken") removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"}); logout(); @@ -178,7 +194,7 @@ export default function App() // use a random token if anonymous or mock // ///////////////////////////////////////////// console.log("Generating random token..."); - // qController.setAuthorizationHeaderValue(null); + qController.setAuthorizationHeaderValue(Md5.hashStr(`${new Date()}`)); setIsFullyAuthenticated(true); setCookie(SESSION_UUID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"}); console.log("Token generation complete."); @@ -531,7 +547,7 @@ export default function App() } const pathToLabelMap: {[path: string]: string} = {} - for(let i =0; i { - const qController = Client.getInstance(); - + ///////////////////////////////////////////////////////////////////////////////////////////// + // todo - this could be simplified. // + // it was originally built like this when we had to submit full access token to backend... // + ///////////////////////////////////////////////////////////////////////////////////////////// let xhr = new XMLHttpRequest(); xhr.open("POST", url); xhr.responseType = "blob"; let formData = new FormData(); + + //////////////////////////////////// + // todo#authHeader - delete this. // + //////////////////////////////////// + const qController = Client.getInstance(); formData.append("Authorization", qController.getAuthorizationHeaderValue()); // @ts-ignore diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index 9ebef6d..35c9cc6 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -1145,6 +1145,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element Generating file ${filename}${totalRecords ? " with " + totalRecords.toLocaleString() + " record" + (totalRecords == 1 ? "" : "s") : ""}...
+ diff --git a/src/qqq/utils/HtmlUtils.ts b/src/qqq/utils/HtmlUtils.ts index 602b3a8..3d69e50 100644 --- a/src/qqq/utils/HtmlUtils.ts +++ b/src/qqq/utils/HtmlUtils.ts @@ -95,6 +95,11 @@ export default class HtmlUtils form.setAttribute("target", "downloadIframe"); iframe.appendChild(form); + ///////////////////////////////////////////////////////////////////////////////////////////// + // todo#authHeader - remove after comfortable with sessionUUID // + // todo - this could be simplified (i think?) // + // it was originally built like this when we had to submit full access token to backend... // + ///////////////////////////////////////////////////////////////////////////////////////////// const authorizationInput = document.createElement("input"); authorizationInput.setAttribute("type", "hidden"); authorizationInput.setAttribute("id", "authorizationInput"); @@ -118,6 +123,11 @@ export default class HtmlUtils { if(url.startsWith("data:")) { + ///////////////////////////////////////////////////////////////////////////////////////////// + // todo#authHeader - remove the Authorization input after comfortable with sessionUUID // + // todo - this could be simplified (i think?) // + // it was originally built like this when we had to submit full access token to backend... // + ///////////////////////////////////////////////////////////////////////////////////////////// const openInWindow = window.open("", "_blank"); openInWindow.document.write(` From b6b7d8d8b3b1afc99717532daa1ea3455217e3b4 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 15 Aug 2023 09:25:45 -0500 Subject: [PATCH 3/4] CE-609 - Removed DNDTest WIP module --- src/App.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 8727c18..8b2d4cb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -38,7 +38,6 @@ import {useCookies} from "react-cookie"; import {Navigate, Route, Routes, useLocation,} from "react-router-dom"; import {Md5} from "ts-md5/dist/md5"; import CommandMenu from "CommandMenu"; -import DNDTest from "DNDTest"; import QContext from "QContext"; import Sidenav from "qqq/components/horseshoe/sidenav/SideNav"; import theme from "qqq/components/legacy/Theme"; From 1f343abbb552e12edcbf2d884eb9e8386172a4d6 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 25 Sep 2023 16:27:46 -0500 Subject: [PATCH 4/4] Switch to use jwt_decode library (from auth0) rather than S/O decodeJWT function --- package.json | 1 + src/App.tsx | 17 +++-------------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index dc95524..f963e83 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "html-react-parser": "1.4.8", "html-to-text": "^9.0.5", "http-proxy-middleware": "2.0.6", + "jwt-decode": "3.1.2", "rapidoc": "9.3.4", "react": "18.0.0", "react-ace": "10.1.0", diff --git a/src/App.tsx b/src/App.tsx index 8b2d4cb..edf1372 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,7 @@ import CssBaseline from "@mui/material/CssBaseline"; import Icon from "@mui/material/Icon"; import {ThemeProvider} from "@mui/material/styles"; import {LicenseInfo} from "@mui/x-license-pro"; +import jwt_decode from "jwt-decode"; import React, {JSXElementConstructor, Key, ReactElement, useEffect, useState,} from "react"; import {useCookies} from "react-cookie"; import {Navigate, Route, Routes, useLocation,} from "react-router-dom"; @@ -72,18 +73,6 @@ export default function App() const [loggedInUser, setLoggedInUser] = useState({} as { name?: string, email?: string }); const [defaultRoute, setDefaultRoute] = useState("/no-apps"); - const decodeJWT = (jwt: string): any => - { - const base64Url = jwt.split(".")[1]; - const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); - const jsonPayload = decodeURIComponent(window.atob(base64).split("").map(function (c) - { - return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2); - }).join("")); - - return JSON.parse(jsonPayload); - }; - const shouldStoreNewToken = (newToken: string, oldToken: string): boolean => { if (!oldToken) @@ -93,8 +82,8 @@ export default function App() try { - const oldJSON = decodeJWT(oldToken); - const newJSON = decodeJWT(newToken); + const oldJSON: any = jwt_decode(oldToken); + const newJSON: any = jwt_decode(newToken); //////////////////////////////////////////////////////////////////////////////////// // if the old (local storage) token is expired, then we need to store the new one //