Compare commits

...
Author SHA1 Message Date
james.maes c66f586834 Add post_logout_redirect_uri and error handling for Authentik logout
Authentik has a known bug in their RAC provider that causes database errors
during logout (bind message supplies 1 parameters, but prepared statement
requires 4). This change:

- Adds post_logout_redirect_uri to signoutRedirect() to ensure we redirect
  back to our app even if Authentik has errors
- Adds try-catch around signoutRedirect() to handle any frontend errors
- Ensures local state is cleaned up before attempting Authentik logout
- Falls back to manual redirect if Authentik logout fails

This ensures users are redirected back to the app even when Authentik's
backend has errors during logout.
2025-11-15 06:41:16 -06:00
james.maes 3972f4bf96 Fix OAuth2: Handle cookie value type checking to prevent object being passed as UUID
The react-cookie library can sometimes return objects instead of strings
when cookies are set with different paths or have parsing issues. This
fix ensures we only use string values for the session UUID cookie,
preventing errors where {path: '/admin'} was being passed to manageSession
instead of the actual UUID string.

- Add type check to ensure sessionUuid is a string before using it
- Treat non-string cookie values as null (no cookie exists)
- Add comment explaining why this check is necessary

This handles the case where cookies set with path: '/' are not accessible
from sub-paths like /admin, or when react-cookie returns unexpected values.
2025-11-15 06:39:12 -06:00
james.maes 09c720f18c Fix ESLint: Use double quotes instead of single quotes in PathUtils
- Change 'script[src]' to "script[src]" on line 88
- Change '/static/' to "/static/" on line 92

Fixes ESLint errors requiring double quotes for string literals.
2025-11-15 06:11:00 -06:00
james.maes 3208704053 Add .vscode to .gitignore 2025-11-15 06:04:41 -06:00
james.maes 3da3c96edd Fix cookie paths in App.tsx and HandleAuthorizationError to use base path
- App.tsx: Fix cookie removal on 401 error to use detectBasePath()
- HandleAuthorizationError: Fix cookie removal to use detectBasePath()

Ensures cookies are properly scoped to the SPA's base path when hosted
at sub-paths, preventing cookie conflicts between multiple SPAs.
2025-11-15 06:04:41 -06:00
james.maes 858999119c Fix authentication modules to use detected base path instead of hardcoded root
OAuth2 Module:
- Fix /token path check to use basePath + /token
- Fix redirect_uri to include base path (e.g., /admin/token)
- Fix cookie path to use basePath instead of '/'
- Fix navigation fallback to use basePath instead of '/'

Auth0 Module:
- Fix redirectUri to include base path
- Fix cookie path to use basePath instead of '/'

Anonymous Module:
- Fix cookie paths (setCookie and removeCookie) to use basePath

These changes ensure authentication works correctly when the SPA is
hosted at sub-paths like /admin or /dashboard, not just at root (/).
2025-11-15 06:04:41 -06:00
james.maes a181e30eef Improve base path detection to handle relative paths and edge cases
- Add normalizeBasePath() helper to ensure paths always start with '/'
- Reorder detection strategies: script detection first (most reliable)
- Add fallback to check all script tags if currentScript unavailable
- Add documentation explaining compatibility with homepage: '.' in package.json
- Improve detection reliability for edge cases like './' or empty paths

This ensures base path detection works correctly even when HTML uses
relative paths (homepage: '.') because browsers resolve them to absolute
URLs before the script executes.
2025-11-15 06:04:39 -06:00
7 changed files with 124 additions and 36 deletions

No files matched your search

+1
View File
@@ -3,6 +3,7 @@
src/main/resources/material-dashboard/
**/.idea*
.vscode
*.iml
.yalc*
yalc.lock
+3 -2
View File
@@ -48,7 +48,7 @@ import ProcessRun from "qqq/pages/processes/ProcessRun";
import ReportRun from "qqq/pages/processes/ReportRun";
import EntityCreate from "qqq/pages/records/create/RecordCreate";
import TableDeveloperView from "qqq/pages/records/developer/TableDeveloperView";
import {resolveAssetUrl} from "qqq/utils/PathUtils";
import {detectBasePath, resolveAssetUrl} from "qqq/utils/PathUtils";
import EntityEdit from "qqq/pages/records/edit/RecordEdit";
import RecordQuery from "qqq/pages/records/query/RecordQuery";
import RecordDeveloperView from "qqq/pages/records/view/RecordDeveloperView";
@@ -558,7 +558,8 @@ export default function App({authenticationMetaData}: Props)
console.log("Exception is a QException with status = 401. Clearing some of localStorage & cookies");
qController.clearAuthenticationMetaDataLocalStorage();
localStorage.removeItem("accessToken");
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
const basePath = detectBasePath();
removeCookie(SESSION_UUID_COOKIE_NAME, {path: basePath});
doLogout();
return;
+3 -1
View File
@@ -23,6 +23,7 @@ import {useAuth0} from "@auth0/auth0-react";
import React, {useEffect} from "react";
import {useCookies} from "react-cookie";
import {SESSION_UUID_COOKIE_NAME} from "App";
import {detectBasePath} from "qqq/utils/PathUtils";
interface Props
{
@@ -39,7 +40,8 @@ function HandleAuthorizationError({errorMessage}: Props)
useEffect(() =>
{
logout();
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
const basePath = detectBasePath();
removeCookie(SESSION_UUID_COOKIE_NAME, {path: basePath});
});
return (
@@ -22,6 +22,7 @@
import {QAuthenticationMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
import {SESSION_UUID_COOKIE_NAME} from "App";
import Client from "qqq/utils/qqq/Client";
import {detectBasePath} from "qqq/utils/PathUtils";
import {useCookies} from "react-cookie";
import {Md5} from "ts-md5/dist/md5";
@@ -49,7 +50,8 @@ export default function useAnonymousAuthenticationModule({setIsFullyAuthenticate
console.log("Generating random token...");
setIsFullyAuthenticated(true);
Client.setGotAuthenticationInAllControllers();
setCookie(SESSION_UUID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"});
const basePath = detectBasePath();
setCookie(SESSION_UUID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: basePath});
console.log("Token generation complete.");
};
@@ -60,7 +62,8 @@ export default function useAnonymousAuthenticationModule({setIsFullyAuthenticate
const logout = () =>
{
qController.clearAuthenticationMetaDataLocalStorage();
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
const basePath = detectBasePath();
removeCookie(SESSION_UUID_COOKIE_NAME, {path: basePath});
};
@@ -26,6 +26,7 @@ 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 {detectBasePath} from "qqq/utils/PathUtils";
import {useCookies} from "react-cookie";
import {useNavigate, useSearchParams} from "react-router-dom";
@@ -142,7 +143,8 @@ export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, s
console.log(`Error loading token: ${JSON.stringify(e)}`);
qController.clearAuthenticationMetaDataLocalStorage();
localStorage.removeItem("accessToken");
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
const basePath = detectBasePath();
removeCookie(SESSION_UUID_COOKIE_NAME, {path: basePath});
useAuth0Logout();
return;
}
@@ -232,12 +234,14 @@ export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, s
return <App authenticationMetaData={authenticationMetaData} />
}
const basePath = detectBasePath();
const redirectUri = `${window.location.origin}${basePath}`;
return (
<Auth0ProviderWithRedirectCallback
domain={domain}
clientId={clientId}
audience={audience}
redirectUri={`${window.location.origin}/`}>
redirectUri={redirectUri}>
<MaterialUIControllerProvider>
<ProtectedRoute component={WrappedApp} />
</MaterialUIControllerProvider>
@@ -22,6 +22,7 @@
import {QAuthenticationMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
import {SESSION_UUID_COOKIE_NAME} from "App";
import Client from "qqq/utils/qqq/Client";
import {detectBasePath} from "qqq/utils/PathUtils";
import {useCookies} from "react-cookie";
import {AuthContextProps, AuthProvider, useAuth} from "react-oidc-context";
import {useNavigate, useSearchParams} from "react-router-dom";
@@ -59,8 +60,10 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
{
try
{
const basePath = detectBasePath();
const preSigninRedirectPathnameKey = "oauth2.preSigninRedirect.pathname";
if (window.location.pathname == "/token")
const tokenPath = basePath === "/" ? "/token" : `${basePath}/token`;
if (window.location.pathname === tokenPath)
{
///////////////////////////////////////////////////////////////////////////
// if we're at a path of /token, get code & state params, look up values //
@@ -87,7 +90,7 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
const preSigninRedirectPathname = localStorage.getItem(preSigninRedirectPathnameKey);
localStorage.removeItem(preSigninRedirectPathname);
navigate(preSigninRedirectPathname ?? "/", {replace: true});
navigate(preSigninRedirectPathname ?? basePath, {replace: true});
}
else
{
@@ -102,7 +105,10 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
//////////////////////////////////////////////////////////////////////////
// if we have a sessionUUID cookie, try to validate it with the backend //
//////////////////////////////////////////////////////////////////////////
const sessionUuid = cookies[SESSION_UUID_COOKIE_NAME];
const sessionUuidCookie = cookies[SESSION_UUID_COOKIE_NAME];
// Ensure we have a string value, not an object (react-cookie can return objects in some cases)
// This can happen if the cookie was set with a different path or if there's a cookie parsing issue
const sessionUuid = typeof sessionUuidCookie === "string" ? sessionUuidCookie : null;
if (sessionUuid)
{
console.log(`we have session UUID: ${sessionUuid} - validating it...`);
@@ -142,9 +148,24 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
***************************************************************************/
const logout = () =>
{
// Clean up local state first, before attempting Authentik logout
qController.clearAuthenticationMetaDataLocalStorage();
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
authOidc?.signoutRedirect();
const basePath = detectBasePath();
removeCookie(SESSION_UUID_COOKIE_NAME, {path: basePath});
// Attempt Authentik logout, but handle errors gracefully
// If Authentik has an error (like the RAC provider bug), we still want to redirect back to our app
try
{
const postLogoutRedirectUri = `${window.location.origin}${basePath}`;
authOidc?.signoutRedirect({post_logout_redirect_uri: postLogoutRedirectUri});
}
catch (e)
{
// If Authentik logout fails, redirect manually to our app
console.warn("Authentik logout error (this may be an Authentik backend issue):", e);
window.location.href = `${window.location.origin}${basePath}`;
}
};
@@ -163,11 +184,13 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
);
}
const basePath = detectBasePath();
const tokenPath = basePath === "/" ? "/token" : `${basePath}/token`;
const oidcConfig =
{
authority: authority,
client_id: clientId,
redirect_uri: `${window.location.origin}/token`,
redirect_uri: `${window.location.origin}${tokenPath}`,
response_type: "code",
scope: "openid profile email offline_access",
};
+77 -23
View File
@@ -19,36 +19,58 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Normalizes a path to always be an absolute path starting with "/".
* Handles edge cases like "./", "", or paths without leading slash.
*
* @param path The path to normalize
* @returns An absolute path starting with "/"
*/
function normalizeBasePath(path: string): string
{
if (!path || path === "" || path === "./" || path === ".")
{
return "/";
}
// Ensure it starts with "/"
if (!path.startsWith("/"))
{
return "/" + path.replace(/^\.\//, "").replace(/\/$/, "");
}
// Ensure it doesn't end with "/" (except for root)
if (path !== "/" && path.endsWith("/"))
{
return path.slice(0, -1);
}
return path;
}
/**
* Detects the base path where the SPA is running.
* Works for both root and sub-paths like /admin, /dashboard, etc.
*
* This function works correctly with `homepage: "."` in package.json because:
* - Even though HTML may reference assets relatively (./static/js/main.js),
* - The browser resolves these to absolute URLs when loading
* - document.currentScript.src will always contain the full absolute URL
*
* Detection strategy (in order of reliability):
* 1. Extract from current script location (most reliable)
* 2. Extract from known path patterns in current pathname
* 3. Default to / (root)
* 1. Extract from current script location (most reliable - works even with relative paths in HTML)
* 2. Extract from all script tags as fallback
* 3. Extract from known path patterns in current pathname
* 4. Default to / (root)
*
* @returns The detected base path (e.g., "/admin", "/", "/dashboard")
* Always returns an absolute path starting with "/"
*/
export function detectBasePath(): string
{
// Strategy 1: Check current URL path first (most reliable for dev and prod)
const currentPath = window.location.pathname;
const parts = currentPath.split("/").filter(p => p);
// If first part is a known SPA path like "admin", "dashboard", etc., use it
if (parts.length > 0)
{
const firstPart = parts[0];
// You can add more known paths here as needed
if (["admin", "dashboard", "app", "manager", "console"].includes(firstPart))
{
return "/" + firstPart;
}
}
// Strategy 2: Try to extract from the current script's source
// When served from /admin/static/js/main.abc123.js, extract /admin
// Strategy 1: Try to extract from the current script's source (most reliable)
// Works even when homepage: "." uses relative paths because browser resolves them to absolute URLs
// Example: HTML has "./static/js/main.js" but browser resolves to "http://localhost:8000/admin/static/js/main.js"
if (document.currentScript && document.currentScript instanceof HTMLScriptElement)
{
const src = document.currentScript.src;
@@ -57,12 +79,44 @@ export function detectBasePath(): string
const match = src.match(/^https?:\/\/[^\/]+([\/\w-]*?)\/static\//);
if (match && match[1])
{
return match[1];
return normalizeBasePath(match[1]);
}
}
// Default to root
return "/";
// Strategy 2: Fallback - try to extract from any script tag with /static/ in the path
// This handles cases where currentScript might not be available
const scripts = document.querySelectorAll("script[src]");
for (let i = 0; i < scripts.length; i++)
{
const script = scripts[i] as HTMLScriptElement;
if (script.src && script.src.includes("/static/"))
{
const match = script.src.match(/^https?:\/\/[^\/]+([\/\w-]*?)\/static\//);
if (match && match[1])
{
return normalizeBasePath(match[1]);
}
}
}
// Strategy 3: Extract from current URL pathname
// Check if first part of pathname matches known SPA paths
const currentPath = window.location.pathname;
const parts = currentPath.split("/").filter(p => p);
if (parts.length > 0)
{
const firstPart = parts[0];
// You can add more known paths here as needed
if (["admin", "dashboard", "app", "manager", "console"].includes(firstPart))
{
return normalizeBasePath("/" + firstPart);
}
}
// Strategy 4: Default to root
// This handles the case where SPA is served from root (/)
return normalizeBasePath("/");
}
/**