diff --git a/package.json b/package.json index 25d9393..aeab4ca 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,8 @@ "html-to-text": "^9.0.5", "http-proxy-middleware": "2.0.6", "jwt-decode": "3.1.2", + "oidc-client-ts": "2.4.1", + "react-oidc-context": "2.3.1", "rapidoc": "9.3.4", "react": "18.0.0", "react-ace": "10.1.0", diff --git a/src/App.tsx b/src/App.tsx index 62ca7a2..ad58422 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,7 +19,6 @@ * along with this program. If not, see . */ -import {useAuth0} from "@auth0/auth0-react"; import {QException} from "@kingsrook/qqq-frontend-core/lib/exceptions/QException"; import {QAppNodeType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAppNodeType"; import {QAppTreeNode} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAppTreeNode"; @@ -35,12 +34,14 @@ import Icon from "@mui/material/Icon"; import {ThemeProvider} from "@mui/material/styles"; import {LicenseInfo} from "@mui/x-license-pro"; import CommandMenu from "CommandMenu"; -import jwt_decode from "jwt-decode"; import QContext from "QContext"; +import useAnonymousAuthenticationModule from "qqq/authorization/anonymous/useAnonymousAuthenticationModule"; +import useAuth0AuthenticationModule from "qqq/authorization/auth0/useAuth0AuthenticationModule"; +import useOAuth2AuthenticationModule from "qqq/authorization/oauth2/useOAuth2AuthenticationModule"; import Sidenav from "qqq/components/horseshoe/sidenav/SideNav"; import theme from "qqq/components/legacy/Theme"; import {getBannerClassName, getBannerStyles, getBanner, makeBannerContent} from "qqq/components/misc/Banners"; -import {setMiniSidenav, setOpenConfigurator, useMaterialUIController} from "qqq/context"; +import {setMiniSidenav, useMaterialUIController} from "qqq/context"; import AppHome from "qqq/pages/apps/Home"; import NoApps from "qqq/pages/apps/NoApps"; import ProcessRun from "qqq/pages/processes/ProcessRun"; @@ -64,10 +65,14 @@ import {Md5} from "ts-md5/dist/md5"; const qController = Client.getInstance(); export const SESSION_UUID_COOKIE_NAME = "sessionUUID"; -export default function App() +interface Props { - const [cookies, setCookie, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]); - const {user, getAccessTokenSilently, logout} = useAuth0(); + authenticationMetaData: QAuthenticationMetaData; +} + +export default function App({authenticationMetaData}: Props) +{ + const [, , removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]); const [loadingToken, setLoadingToken] = useState(false); const [isFullyAuthenticated, setIsFullyAuthenticated] = useState(false); const [profileRoutes, setProfileRoutes] = useState({}); @@ -76,68 +81,20 @@ export default function App() const [needLicenseKey, setNeedLicenseKey] = useState(true); const [loggedInUser, setLoggedInUser] = useState({} as { name?: string, email?: string }); const [defaultRoute, setDefaultRoute] = useState("/no-apps"); + const [earlyReturnForAuth, setEarlyReturnForAuth] = useState(null as JSX.Element); + + const {setupSession: auth0SetupSession, logout: auth0Logout} = useAuth0AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth}); + const {setupSession: oauth2SetupSession, logout: oauth2Logout} = useOAuth2AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth, inOAuthContext: authenticationMetaData.type === "OAUTH2"}); + const {setupSession: anonymousSetupSession, logout: anonymousLogout} = useAnonymousAuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth}); ///////////////////////////////////////////////////////// // tell the client how to do a logout if it sees a 401 // ///////////////////////////////////////////////////////// - Client.setUnauthorizedCallback(() => - { - logout(); - }); - - const shouldStoreNewToken = (newToken: string, oldToken: string): boolean => - { - if (!cookies[SESSION_UUID_COOKIE_NAME]) - { - console.log("No session uuid cookie - so we should store a new one."); - return (true); - } - - if (!oldToken) - { - console.log("No accessToken in localStorage - so we should store a new one."); - return (true); - } - - try - { - 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 // - //////////////////////////////////////////////////////////////////////////////////// - const oldExp = oldJSON["exp"]; - if (oldExp * 1000 < (new Date().getTime())) - { - console.log("Access token in local storage was expired - so we should store a new one."); - 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); - if (different) - { - console.log("Latest access token from auth0 has changed vs localStorage - so we should store a new one."); - } - return (different); - } - catch (e) - { - console.log("Caught in shouldStoreNewToken: " + e); - } - - return (true); - }; + Client.setUnauthorizedCallback(() => doLogout()); + ///////////////////////////////////////////////// + // deal with making sure user is authenticated // + ///////////////////////////////////////////////// useEffect(() => { if (loadingToken) @@ -148,65 +105,17 @@ export default function App() (async () => { - const authenticationMetaData: QAuthenticationMetaData = await qController.getAuthenticationMetaData(); - if (authenticationMetaData.type === "AUTH_0") { - ///////////////////////////////////////// - // use auth0 if auth type is ... auth0 // - ///////////////////////////////////////// - try - { - console.log("Loading token from auth0..."); - const accessToken = await getAccessTokenSilently(); - - const lsAccessToken = localStorage.getItem("accessToken"); - if (shouldStoreNewToken(accessToken, lsAccessToken)) - { - console.log("Sending accessToken to backend, requesting a sessionUUID..."); - const {uuid: newSessionUuid, values} = await qController.manageSession(accessToken, null); - - ///////////////////////////////////////////////////////////////////////////////////////////////////////////// - // the request to the backend should send a header to set the cookie, so we don't need to do it ourselves. // - ///////////////////////////////////////////////////////////////////////////////////////////////////////////// - // setCookie(SESSION_UUID_COOKIE_NAME, newSessionUuid, {path: "/"}); - - localStorage.setItem("accessToken", accessToken); - localStorage.setItem("sessionValues", JSON.stringify(values)); - console.log("Got new sessionUUID from backend, and stored new accessToken"); - } - else - { - console.log("Using existing sessionUUID cookie"); - } - - setIsFullyAuthenticated(true); - qController.setGotAuthentication(); - - setLoggedInUser(user); - console.log("Token load complete."); - } - catch (e) - { - console.log(`Error loading token: ${JSON.stringify(e)}`); - qController.clearAuthenticationMetaDataLocalStorage(); - localStorage.removeItem("accessToken"); - removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"}); - logout(); - return; - } + await auth0SetupSession(); + } + else if (authenticationMetaData.type === "OAUTH2") + { + await oauth2SetupSession(); } else if (authenticationMetaData.type === "FULLY_ANONYMOUS" || authenticationMetaData.type === "MOCK") { - ///////////////////////////////////////////// - // use a random token if anonymous or mock // - ///////////////////////////////////////////// - console.log("Generating random token..."); - setIsFullyAuthenticated(true); - qController.setGotAuthentication(); - setCookie(SESSION_UUID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"}); - console.log("Token generation complete."); - return; + await anonymousSetupSession(); } else { @@ -222,13 +131,36 @@ export default function App() (async () => { const metaData: QInstance = await qController.loadMetaData(); - LicenseInfo.setLicenseKey(metaData.environmentValues.get("MATERIAL_UI_LICENSE_KEY") || process.env.REACT_APP_MATERIAL_UI_LICENSE_KEY); + LicenseInfo.setLicenseKey(metaData.environmentValues?.get("MATERIAL_UI_LICENSE_KEY") || process.env.REACT_APP_MATERIAL_UI_LICENSE_KEY); setNeedLicenseKey(false); })(); } + /*************************************************************************** + ** call appropriate logout function based on authentication meta data type + ***************************************************************************/ + function doLogout() + { + if (authenticationMetaData?.type === "AUTH_0") + { + auth0Logout(); + } + else if (authenticationMetaData?.type === "OAUTH2") + { + oauth2Logout(); + } + else if (authenticationMetaData?.type === "FULLY_ANONYMOUS" || authenticationMetaData?.type === "MOCK") + { + anonymousLogout(); + } + else + { + console.log(`No logout callback for authentication type [${authenticationMetaData?.type}].`); + } + } + const [controller, dispatch] = useMaterialUIController(); - const {miniSidenav, direction, layout, openConfigurator, sidenavColor} = controller; + const {miniSidenav, direction, sidenavColor} = controller; const [onMouseEnter, setOnMouseEnter] = useState(false); const {pathname} = useLocation(); const [queryParams] = useSearchParams(); @@ -521,11 +453,10 @@ export default function App() } } - let profileRoutes = {}; const gravatarBase = "https://www.gravatar.com/avatar/"; const hash = Md5.hashStr(loggedInUser?.email || "user"); const profilePicture = `${gravatarBase}${hash}`; - profileRoutes = { + const profileRoutes = { type: "collapse", name: loggedInUser?.name ?? "Anonymous", key: "username", @@ -594,10 +525,7 @@ export default function App() localStorage.removeItem("accessToken"); removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"}); - ////////////////////////////////////////////////////// - // todo - this is auth0 logout... make more generic // - ////////////////////////////////////////////////////// - logout(); + doLogout(); return; } } @@ -605,7 +533,9 @@ export default function App() })(); }, [needToLoadRoutes, isFullyAuthenticated]); - // Open sidenav when mouse enter on mini sidenav + /////////////////////////////////////////////////// + // Open sidenav when mouse enter on mini sidenav // + /////////////////////////////////////////////////// const handleOnMouseEnter = () => { if (miniSidenav && !onMouseEnter) @@ -615,7 +545,9 @@ export default function App() } }; - // Close sidenav when mouse leave mini sidenav + ///////////////////////////////////////////////// + // Close sidenav when mouse leave mini sidenav // + ///////////////////////////////////////////////// const handleOnMouseLeave = () => { if (onMouseEnter) @@ -625,16 +557,14 @@ export default function App() } }; - // Change the openConfigurator state - const handleConfiguratorOpen = () => setOpenConfigurator(dispatch, !openConfigurator); - - // Setting the dir attribute for the body element useEffect(() => { document.body.setAttribute("dir", direction); }, [direction]); - // Setting page scroll to 0 when changing the route + ////////////////////////////////////////////////////// + // Setting page scroll to 0 when changing the route // + ////////////////////////////////////////////////////// useEffect(() => { document.documentElement.scrollTop = 0; @@ -674,14 +604,14 @@ export default function App() const [dotMenuOpen, setDotMenuOpen] = useState(false); const [keyboardHelpOpen, setKeyboardHelpOpen] = useState(false); const [helpHelpActive] = useState(queryParams.has("helpHelp")); - const [userId, setUserId] = useState(user?.email); + const [userId, setUserId] = useState(loggedInUser?.email); useEffect(() => { - setUserId(user?.email) - }, [user]); + setUserId(loggedInUser?.email); + }, [loggedInUser]); + - const [googleAnalyticsUtils] = useState(new GoogleAnalyticsUtils()); /******************************************************************************* @@ -689,7 +619,16 @@ export default function App() *******************************************************************************/ function recordAnalytics(model: AnalyticsModel) { - googleAnalyticsUtils.recordAnalytics(model) + googleAnalyticsUtils.recordAnalytics(model); + } + + /////////////////////////////////////////////////////////////////// + // if any of the auth/session setup code determined that we need // + // to render something and return early - then do so here. // + /////////////////////////////////////////////////////////////////// + if (earlyReturnForAuth) + { + return (earlyReturnForAuth); } @@ -747,6 +686,7 @@ export default function App() routes={sideNavRoutes} onMouseEnter={handleOnMouseEnter} onMouseLeave={handleOnMouseLeave} + logout={doLogout} /> } /> diff --git a/src/index.tsx b/src/index.tsx index fb7929a..4299bf7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -19,116 +19,97 @@ * along with this program. If not, see . */ -import {Auth0Provider} from "@auth0/auth0-react"; import {QAuthenticationMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData"; -import React from "react"; -import {createRoot} from "react-dom/client"; -import {BrowserRouter, useNavigate, useSearchParams} from "react-router-dom"; import App from "App"; import "qqq/styles/qqq-override-styles.css"; import "qqq/styles/globals.scss"; import "qqq/styles/raycast.scss"; -import HandleAuthorizationError from "HandleAuthorizationError"; -import ProtectedRoute from "qqq/authorization/auth0/ProtectedRoute"; +import useAnonymousAuthenticationModule from "qqq/authorization/anonymous/useAnonymousAuthenticationModule"; +import useAuth0AuthenticationModule from "qqq/authorization/auth0/useAuth0AuthenticationModule"; +import useOAuth2AuthenticationModule from "qqq/authorization/oauth2/useOAuth2AuthenticationModule"; import {MaterialUIControllerProvider} from "qqq/context"; import Client from "qqq/utils/qqq/Client"; +import React from "react"; +import {createRoot} from "react-dom/client"; +import {BrowserRouter} from "react-router-dom"; + const qController = Client.getInstance(); -if(document.location.search && document.location.search.indexOf("clearAuthenticationMetaDataLocalStorage") > -1) +if (document.location.search && document.location.search.indexOf("clearAuthenticationMetaDataLocalStorage") > -1) { - qController.clearAuthenticationMetaDataLocalStorage() + qController.clearAuthenticationMetaDataLocalStorage(); } -const authenticationMetaDataPromise: Promise = qController.getAuthenticationMetaData() +const authenticationMetaDataPromise: Promise = qController.getAuthenticationMetaData(); authenticationMetaDataPromise.then((authenticationMetaData) => { - // @ts-ignore - function Auth0ProviderWithRedirectCallback({children, ...props}) - { - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - // @ts-ignore - const onRedirectCallback = (appState) => - { - navigate((appState && appState.returnTo) || window.location.pathname); - }; - if (searchParams.get("error")) - { - return ( - // @ts-ignore - - - - ); - } - else - { - return ( - // @ts-ignore - - {children} - - ); - } + /*************************************************************************** + ** + ***************************************************************************/ + function Auth0RouterBody() + { + const {renderAppWrapper} = useAuth0AuthenticationModule({}); + return (renderAppWrapper(authenticationMetaData)); } + + /*************************************************************************** + ** + ***************************************************************************/ + function OAuth2RouterBody() + { + const {renderAppWrapper} = useOAuth2AuthenticationModule({inOAuthContext: false}); + return (renderAppWrapper(authenticationMetaData, ( + + + + ))); + } + + + /*************************************************************************** + ** + ***************************************************************************/ + function AnonymousRouterBody() + { + const {renderAppWrapper} = useAnonymousAuthenticationModule({}); + return (renderAppWrapper(authenticationMetaData, ( + + + + ))); + } + + const container = document.getElementById("root"); const root = createRoot(container); if (authenticationMetaData.type === "AUTH_0") { - // @ts-ignore - let domain: string = authenticationMetaData.data.baseUrl; - - // @ts-ignore - const clientId = authenticationMetaData.data.clientId; - - // @ts-ignore - const audience = authenticationMetaData.data.audience; - - if(!domain || !clientId) - { - root.render( -
Error: AUTH0 authenticationMetaData is missing domain [{domain}] and/or clientId [{clientId}].
- ); - return; - } - - if(domain.endsWith("/")) - { - ///////////////////////////////////////////////////////////////////////////////////// - // auth0 lib fails if we have a trailing slash. be a bit more graceful than that. // - ///////////////////////////////////////////////////////////////////////////////////// - domain = domain.replace(/\/$/, ""); - } - - root.render( - - - - - - - - ); + root.render( + + ); + } + else if (authenticationMetaData.type === "OAUTH2") + { + root.render( + + ); + } + else if (authenticationMetaData.type === "FULLY_ANONYMOUS" || authenticationMetaData.type === "MOCK") + { + root.render( + + ); } else { - root.render( - - - - - - ); + root.render(
+ Error: Unknown authenticationMetaData type: [{authenticationMetaData.type}]. +
); } -}) +}); diff --git a/src/qqq/authorization/anonymous/useAnonymousAuthenticationModule.tsx b/src/qqq/authorization/anonymous/useAnonymousAuthenticationModule.tsx new file mode 100644 index 0000000..9511ece --- /dev/null +++ b/src/qqq/authorization/anonymous/useAnonymousAuthenticationModule.tsx @@ -0,0 +1,82 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {QAuthenticationMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData"; +import {SESSION_UUID_COOKIE_NAME} from "App"; +import Client from "qqq/utils/qqq/Client"; +import {useCookies} from "react-cookie"; +import {Md5} from "ts-md5/dist/md5"; + +const qController = Client.getInstance(); + +interface Props +{ + setIsFullyAuthenticated?: (is: boolean) => void; + setLoggedInUser?: (user: any) => void; + setEarlyReturnForAuth?: (element: JSX.Element | null) => void; +} + +/*************************************************************************** + ** hook for working with the anonymous authentication module + ***************************************************************************/ +export default function useAnonymousAuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth}: Props) +{ + const [cookies, setCookie, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]); + + /*************************************************************************** + ** + ***************************************************************************/ + const setupSession = async () => + { + console.log("Generating random token..."); + setIsFullyAuthenticated(true); + qController.setGotAuthentication(); + setCookie(SESSION_UUID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"}); + console.log("Token generation complete."); + }; + + + /*************************************************************************** + ** + ***************************************************************************/ + const logout = () => + { + qController.clearAuthenticationMetaDataLocalStorage(); + removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"}); + }; + + + /*************************************************************************** + ** + ***************************************************************************/ + const renderAppWrapper = (authenticationMetaData: QAuthenticationMetaData, children: JSX.Element): JSX.Element => + { + return children; + }; + + + return { + setupSession, + logout, + renderAppWrapper + }; + +} \ No newline at end of file diff --git a/src/qqq/authorization/auth0/useAuth0AuthenticationModule.tsx b/src/qqq/authorization/auth0/useAuth0AuthenticationModule.tsx new file mode 100644 index 0000000..aa1c256 --- /dev/null +++ b/src/qqq/authorization/auth0/useAuth0AuthenticationModule.tsx @@ -0,0 +1,252 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import {Auth0Provider, useAuth0} from "@auth0/auth0-react"; +import {QAuthenticationMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData"; +import App, {SESSION_UUID_COOKIE_NAME} from "App"; +import HandleAuthorizationError from "HandleAuthorizationError"; +import jwt_decode from "jwt-decode"; +import ProtectedRoute from "qqq/authorization/auth0/ProtectedRoute"; +import {MaterialUIControllerProvider} from "qqq/context"; +import Client from "qqq/utils/qqq/Client"; +import {useCookies} from "react-cookie"; +import {useNavigate, useSearchParams} from "react-router-dom"; + +const qController = Client.getInstance(); + +interface Props +{ + setIsFullyAuthenticated?: (is: boolean) => void; + setLoggedInUser?: (user: any) => void; + setEarlyReturnForAuth?: (element: JSX.Element | null) => void; +} + +/*************************************************************************** + ** hook for working with the Auth0 authentication module + ***************************************************************************/ +export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser}: Props) +{ + const {user: auth0User, getAccessTokenSilently: auth0GetAccessTokenSilently, logout: useAuth0Logout} = useAuth0(); + + const [cookies, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]); + + + /*************************************************************************** + ** + ***************************************************************************/ + const shouldStoreNewToken = (newToken: string, oldToken: string): boolean => + { + if (!cookies[SESSION_UUID_COOKIE_NAME]) + { + console.log("No session uuid cookie - so we should store a new one."); + return (true); + } + + if (!oldToken) + { + console.log("No accessToken in localStorage - so we should store a new one."); + return (true); + } + + try + { + 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 // + //////////////////////////////////////////////////////////////////////////////////// + const oldExp = oldJSON["exp"]; + if (oldExp * 1000 < (new Date().getTime())) + { + console.log("Access token in local storage was expired - so we should store a new one."); + 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); + if (different) + { + console.log("Latest access token from auth0 has changed vs localStorage - so we should store a new one."); + } + return (different); + } + catch (e) + { + console.log("Caught in shouldStoreNewToken: " + e); + } + + return (true); + }; + + + /*************************************************************************** + ** + ***************************************************************************/ + const setupSession = async () => + { + try + { + console.log("Loading token from auth0..."); + const accessToken = await auth0GetAccessTokenSilently(); + + const lsAccessToken = localStorage.getItem("accessToken"); + if (shouldStoreNewToken(accessToken, lsAccessToken)) + { + console.log("Sending accessToken to backend, requesting a sessionUUID..."); + const {uuid: values} = await qController.manageSession(accessToken, null); + + localStorage.setItem("accessToken", accessToken); + localStorage.setItem("sessionValues", JSON.stringify(values)); + console.log("Got new sessionUUID from backend, and stored new accessToken"); + } + else + { + console.log("Using existing sessionUUID cookie"); + } + + setIsFullyAuthenticated(true); + qController.setGotAuthentication(); + + setLoggedInUser(auth0User); + console.log("Token load complete."); + } + catch (e) + { + console.log(`Error loading token: ${JSON.stringify(e)}`); + qController.clearAuthenticationMetaDataLocalStorage(); + localStorage.removeItem("accessToken"); + removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"}); + useAuth0Logout(); + return; + } + }; + + + /*************************************************************************** + ** + ***************************************************************************/ + const logout = () => + { + useAuth0Logout({returnTo: window.location.origin}); + }; + + /*************************************************************************** + ** + ***************************************************************************/ + // @ts-ignore + function Auth0ProviderWithRedirectCallback({children, ...props}) + { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + // @ts-ignore + const onRedirectCallback = (appState) => + { + navigate((appState && appState.returnTo) || window.location.pathname); + }; + if (searchParams.get("error")) + { + return ( + // @ts-ignore + + + + ); + } + else + { + return ( + // @ts-ignore + + {children} + + ); + } + } + + + /*************************************************************************** + ** + ***************************************************************************/ + const renderAppWrapper = (authenticationMetaData: QAuthenticationMetaData): JSX.Element => + { + // @ts-ignore + let domain: string = authenticationMetaData.data.baseUrl; + + // @ts-ignore + const clientId = authenticationMetaData.data.clientId; + + // @ts-ignore + const audience = authenticationMetaData.data.audience; + + if (!domain || !clientId) + { + return ( +
Error: AUTH0 authenticationMetaData is missing baseUrl [{domain}] and/or clientId [{clientId}].
+ ); + } + + if (domain.endsWith("/")) + { + ///////////////////////////////////////////////////////////////////////////////////// + // auth0 lib fails if we have a trailing slash. be a bit more graceful than that. // + ///////////////////////////////////////////////////////////////////////////////////// + domain = domain.replace(/\/$/, ""); + } + + /*************************************************************************** + ** simple Functional Component to wrap the and pass the authentication- + ** MetaData prop in, so a simple Component can be passed into ProtectedRoute + ***************************************************************************/ + function WrappedApp() + { + return + } + + return ( + + + + + + ); + }; + + + return { + setupSession, + logout, + renderAppWrapper + }; + +} \ No newline at end of file diff --git a/src/qqq/authorization/oauth2/useOAuth2AuthenticationModule.tsx b/src/qqq/authorization/oauth2/useOAuth2AuthenticationModule.tsx new file mode 100644 index 0000000..0bd58c7 --- /dev/null +++ b/src/qqq/authorization/oauth2/useOAuth2AuthenticationModule.tsx @@ -0,0 +1,188 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {QAuthenticationMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData"; +import {SESSION_UUID_COOKIE_NAME} from "App"; +import Client from "qqq/utils/qqq/Client"; +import {useCookies} from "react-cookie"; +import {AuthContextProps, AuthProvider, useAuth} from "react-oidc-context"; +import {useNavigate, useSearchParams} from "react-router-dom"; + +const qController = Client.getInstance(); + +interface Props +{ + setIsFullyAuthenticated?: (is: boolean) => void; + setLoggedInUser?: (user: any) => void; + setEarlyReturnForAuth?: (element: JSX.Element | null) => void; + inOAuthContext: boolean; +} + +/*************************************************************************** + ** hook for working with the OAuth2 authentication module + ***************************************************************************/ +export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth, inOAuthContext}: Props) +{ + /////////////////////////////////////////////////////////////////////////////////////// + // the useAuth hook should only be called if we're inside the element // + // so on the page that uses this hook to call renderAppWrapper, we aren't in that // + // element/context, thus, don't call that hook. // + /////////////////////////////////////////////////////////////////////////////////////// + const authOidc: AuthContextProps | null = inOAuthContext ? useAuth() : null; + + const [cookies, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + + /*************************************************************************** + ** + ***************************************************************************/ + const setupSession = async () => + { + try + { + const preSigninRedirectPathnameKey = "oauth2.preSigninRedirect.pathname"; + if (window.location.pathname == "/token") + { + /////////////////////////////////////////////////////////////////////////// + // if we're at a path of /token, get code & state params, look up values // + // from that state in local storage, and make a post to the backend to // + // with these values - which will itself talk to the identity provider // + // to get an access token, and ultimately a session. // + /////////////////////////////////////////////////////////////////////////// + const code = searchParams.get("code"); + const state = searchParams.get("state"); + const oidcString = localStorage.getItem(`oidc.${state}`); + if (oidcString) + { + const oidcObject = JSON.parse(oidcString) as { [name: string]: any }; + console.log(oidcObject); + const manageSessionRequestBody = {code: code, codeVerifier: oidcObject.code_verifier, redirectUri: oidcObject.redirect_uri}; + const {uuid: newSessionUuid, values} = await qController.manageSession(null, null, manageSessionRequestBody); + console.log(`we have new session UUID: ${newSessionUuid}`); + + setIsFullyAuthenticated(true); + qController.setGotAuthentication(); + + setLoggedInUser(values?.user); + console.log("Token load complete."); + + const preSigninRedirectPathname = localStorage.getItem(preSigninRedirectPathnameKey); + localStorage.removeItem(preSigninRedirectPathname); + navigate(preSigninRedirectPathname ?? "/", {replace: true}); + } + else + { + //////////////////////////////////////////// + // if unrecognized state, render an error // + //////////////////////////////////////////// + setEarlyReturnForAuth(
Login error: Unrecognized state. Refresh to try again.
); + } + } + else + { + ////////////////////////////////////////////////////////////////////////// + // if we have a sessionUUID cookie, try to validate it with the backend // + ////////////////////////////////////////////////////////////////////////// + const sessionUuid = cookies[SESSION_UUID_COOKIE_NAME]; + if (sessionUuid) + { + console.log(`we have session UUID: ${sessionUuid} - validating it...`); + const {values} = await qController.manageSession(null, sessionUuid, null); + + setIsFullyAuthenticated(true); + qController.setGotAuthentication(); + + setLoggedInUser(values?.user); + console.log("Token load complete."); + } + else + { + ///////////////////////////////////////////////////////////////////////////////////////////////// + // else no cookie, and not a token url, we need to redirect to the provider's login page // + // capture the path the user was trying to access in local storage, to redirect back to later. // + ///////////////////////////////////////////////////////////////////////////////////////////////// + console.log("Loading token from OAuth2 provider..."); + console.log(authOidc); + localStorage.setItem(preSigninRedirectPathnameKey, window.location.pathname); + setEarlyReturnForAuth(
Signing in...
); + authOidc?.signinRedirect(); + } + } + } + catch (e) + { + console.log(`Error loading token: ${JSON.stringify(e)}`); + logout(); + return; + } + }; + + + /*************************************************************************** + ** + ***************************************************************************/ + const logout = () => + { + qController.clearAuthenticationMetaDataLocalStorage(); + removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"}); + authOidc?.signoutRedirect(); + }; + + + /*************************************************************************** + ** + ***************************************************************************/ + const renderAppWrapper = (authenticationMetaData: QAuthenticationMetaData, children: JSX.Element): JSX.Element => + { + const authority: string = authenticationMetaData.data.baseUrl; + const clientId = authenticationMetaData.data.clientId; + + if (!authority || !clientId) + { + return ( +
Error: OAuth2 authenticationMetaData is missing baseUrl [{authority}] and/or clientId [{clientId}].
+ ); + } + + const oidcConfig = + { + authority: authority, + client_id: clientId, + redirect_uri: `${window.location.origin}/token`, + response_type: "code", + scope: "openid profile email offline_access", + }; + + return ( + {children} + + ); + }; + + + return { + setupSession, + logout, + renderAppWrapper + }; + +} \ No newline at end of file diff --git a/src/qqq/components/buttons/AuthenticationButton.tsx b/src/qqq/components/buttons/AuthenticationButton.tsx deleted file mode 100644 index d7b27c4..0000000 --- a/src/qqq/components/buttons/AuthenticationButton.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import {useAuth0} from "@auth0/auth0-react"; -import {Button} from "@mui/material"; -import React from "react"; - -function AuthenticationButton() -{ - const {loginWithRedirect, logout, isAuthenticated} = useAuth0(); - - if (isAuthenticated) - { - return ; - } - - return ; -} - -export default AuthenticationButton; diff --git a/src/qqq/components/horseshoe/sidenav/SideNav.tsx b/src/qqq/components/horseshoe/sidenav/SideNav.tsx index da14699..a9f3f36 100644 --- a/src/qqq/components/horseshoe/sidenav/SideNav.tsx +++ b/src/qqq/components/horseshoe/sidenav/SideNav.tsx @@ -20,14 +20,12 @@ */ import {QBrandingMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QBrandingMetaData"; +import {Button} from "@mui/material"; import Box from "@mui/material/Box"; import Divider from "@mui/material/Divider"; import Icon from "@mui/material/Icon"; import Link from "@mui/material/Link"; import List from "@mui/material/List"; -import {ReactNode, useEffect, useReducer, useState} from "react"; -import {NavLink, useLocation} from "react-router-dom"; -import AuthenticationButton from "qqq/components/buttons/AuthenticationButton"; import SideNavCollapse from "qqq/components/horseshoe/sidenav/SideNavCollapse"; import SideNavItem from "qqq/components/horseshoe/sidenav/SideNavItem"; import SideNavList from "qqq/components/horseshoe/sidenav/SideNavList"; @@ -36,6 +34,8 @@ import sidenavLogoLabel from "qqq/components/horseshoe/sidenav/styles/SideNav"; import MDTypography from "qqq/components/legacy/MDTypography"; import {getBannerClassName, getBannerStyles, getBanner, makeBannerContent} from "qqq/components/misc/Banners"; import {setMiniSidenav, setTransparentSidenav, setWhiteSidenav, useMaterialUIController,} from "qqq/context"; +import {ReactNode, useEffect, useReducer, useState} from "react"; +import {NavLink, useLocation} from "react-router-dom"; interface Props @@ -45,6 +45,7 @@ interface Props logo?: string; appName?: string; branding?: QBrandingMetaData; + logout: () => void; routes: { [key: string]: | ReactNode @@ -67,7 +68,7 @@ interface Props [key: string]: any; } -function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props): JSX.Element +function Sidenav({color, icon, logo, appName, branding, routes, logout, ...rest}: Props): JSX.Element { const [openCollapse, setOpenCollapse] = useState(false); const [openNestedCollapse, setOpenNestedCollapse] = useState(false); @@ -258,7 +259,7 @@ function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props) active={key === collapseName} open={openCollapse === key} noCollapse={noCollapse} - onClick={() => (! noCollapse ? (openCollapse === key ? setOpenCollapse(false) : setOpenCollapse(key)) : null) } + onClick={() => (!noCollapse ? (openCollapse === key ? setOpenCollapse(false) : setOpenCollapse(key)) : null)} > {collapse ? renderCollapse(collapse) : null} @@ -370,7 +371,7 @@ function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props) (darkMode && !transparentSidenav && whiteSidenav) } /> - + ); } diff --git a/src/qqq/utils/GoogleAnalyticsUtils.ts b/src/qqq/utils/GoogleAnalyticsUtils.ts index bbcc6e9..240620c 100644 --- a/src/qqq/utils/GoogleAnalyticsUtils.ts +++ b/src/qqq/utils/GoogleAnalyticsUtils.ts @@ -102,7 +102,7 @@ export default class GoogleAnalyticsUtils console.log("Error reading session values from localStorage: " + e); } - if (this.metaData.environmentValues.get("GOOGLE_ANALYTICS_ENABLED") == "true" && this.metaData.environmentValues.get("GOOGLE_ANALYTICS_TRACKING_ID")) + if (this.metaData.environmentValues?.get("GOOGLE_ANALYTICS_ENABLED") == "true" && this.metaData.environmentValues?.get("GOOGLE_ANALYTICS_TRACKING_ID")) { this.active = true;