mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-02 12:15:36 +02:00
fix(auth): preserve original URL across login + OIDC round-trip (#1750)
ProtectedRoute and PermissionRoute now pass the requested location as
router state when redirecting to /login. LoginPage stashes it in
sessionStorage before the OIDC provider redirect (since window.location
kills React state) and consumes it on all three post-login navigations
(credentials, 2FA, OIDC token exchange). Targets are sanitized to
same-origin internal paths only — protocol-relative and /login itself
are rejected to prevent open-redirect.
QR labels (https://host/inventory?spool=N) now land on the scanned
spool instead of the printer page after authentik / any OIDC SSO login.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Component, type ReactNode, type ErrorInfo } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Layout } from './components/Layout';
|
||||
import { PrintersPage } from './pages/PrintersPage';
|
||||
@@ -93,13 +93,14 @@ function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { authEnabled, loading, user } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
|
||||
}
|
||||
|
||||
if (authEnabled && !user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
@@ -112,6 +113,7 @@ function PermissionRoute({ permission, children }: { permission: string; childre
|
||||
// (e.g. settings:read grants read-only access to Settings; specific tabs
|
||||
// require their own permissions like users:read, groups:update, etc.).
|
||||
const { authEnabled, loading, user, hasPermission } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
|
||||
@@ -123,7 +125,7 @@ function PermissionRoute({ permission, children }: { permission: string; childre
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||
}
|
||||
|
||||
if (!hasPermission(permission as Parameters<typeof hasPermission>[0])) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
@@ -16,6 +16,7 @@ type LoginStep = 'credentials' | '2fa' | 'reset-password';
|
||||
// Read + remove in one try so all branches in the OIDC useEffect see the same
|
||||
// value and a subsequent page load does not replay the flag.
|
||||
const REMEMBER_ME_KEY = 'auth_remember_me';
|
||||
const POST_LOGIN_REDIRECT_KEY = 'auth_post_login_redirect';
|
||||
|
||||
function toPersistence(remember: boolean): TokenPersistence {
|
||||
return remember ? 'persistent' : 'session';
|
||||
@@ -32,6 +33,38 @@ function consumeSavedRememberMe(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Only accept same-origin internal paths. Rejects protocol-relative (`//evil.com`),
|
||||
// absolute URLs, and the login page itself (would loop). Anything else falls
|
||||
// back to `/` so a tampered sessionStorage entry can't open-redirect.
|
||||
function sanitizeRedirectTarget(target: string | null | undefined): string | null {
|
||||
if (!target) return null;
|
||||
if (!target.startsWith('/')) return null;
|
||||
if (target.startsWith('//')) return null;
|
||||
if (target.startsWith('/login')) return null;
|
||||
return target;
|
||||
}
|
||||
|
||||
function stashPostLoginRedirect(target: string): void {
|
||||
const safe = sanitizeRedirectTarget(target);
|
||||
if (!safe) return;
|
||||
try {
|
||||
sessionStorage.setItem(POST_LOGIN_REDIRECT_KEY, safe);
|
||||
} catch (err) {
|
||||
console.warn('stashPostLoginRedirect: sessionStorage unavailable, post-login target will be lost across OIDC redirect', err);
|
||||
}
|
||||
}
|
||||
|
||||
function consumePostLoginRedirect(): string | null {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(POST_LOGIN_REDIRECT_KEY);
|
||||
sessionStorage.removeItem(POST_LOGIN_REDIRECT_KEY);
|
||||
return sanitizeRedirectTarget(saved);
|
||||
} catch (err) {
|
||||
console.warn('consumePostLoginRedirect: sessionStorage unavailable', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single OIDC-provider login button. Extracted from the `.map()` body
|
||||
* because hooks can't be used inside a loop callback — the `iconFailed`
|
||||
@@ -77,12 +110,27 @@ function OIDCProviderButton({
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
const { login, loginWithToken } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const { mode } = useTheme();
|
||||
|
||||
// Resolve the post-login destination, preferring router state (set by
|
||||
// ProtectedRoute when it redirects an unauthed visit) over the sessionStorage
|
||||
// stash (used to survive the OIDC provider round-trip, which kills React
|
||||
// state). Falls back to `/` and rejects unsafe targets via sanitize.
|
||||
function resolvePostLoginRedirect(): string {
|
||||
const fromState = (location.state as { from?: { pathname?: string; search?: string } } | null)?.from;
|
||||
if (fromState?.pathname) {
|
||||
const target = `${fromState.pathname}${fromState.search ?? ''}`;
|
||||
const safe = sanitizeRedirectTarget(target);
|
||||
if (safe) return safe;
|
||||
}
|
||||
return consumePostLoginRedirect() ?? '/';
|
||||
}
|
||||
|
||||
// Credentials step state
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -190,7 +238,7 @@ export function LoginPage() {
|
||||
} else if (resp.access_token && resp.user) {
|
||||
loginWithToken(resp.access_token, resp.user, toPersistence(savedRememberMe));
|
||||
showToast(t('login.loginSuccess'));
|
||||
navigate('/', { replace: true });
|
||||
navigate(resolvePostLoginRedirect(), { replace: true });
|
||||
} else {
|
||||
showToast(t('login.oidcLoginFailed'), 'error');
|
||||
navigate('/login', { replace: true });
|
||||
@@ -219,7 +267,7 @@ export function LoginPage() {
|
||||
setStep('2fa');
|
||||
} else if (resp.access_token && resp.user) {
|
||||
showToast(t('login.loginSuccess'));
|
||||
navigate('/');
|
||||
navigate(resolvePostLoginRedirect(), { replace: true });
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
@@ -275,7 +323,7 @@ export function LoginPage() {
|
||||
if (resp.access_token && resp.user) {
|
||||
loginWithToken(resp.access_token, resp.user, toPersistence(rememberMe));
|
||||
showToast(t('login.loginSuccess'));
|
||||
navigate('/');
|
||||
navigate(resolvePostLoginRedirect(), { replace: true });
|
||||
} else {
|
||||
console.error('2FA verify: unexpected response shape', resp);
|
||||
showToast(t('login.loginFailed'), 'error');
|
||||
@@ -298,6 +346,13 @@ export function LoginPage() {
|
||||
console.warn('setItem auth_remember_me failed, Remember Me will not carry through OIDC redirect', err);
|
||||
}
|
||||
}
|
||||
// Stash the post-login destination from router state so it survives the
|
||||
// provider round-trip (window.location.href kills React state). If the
|
||||
// user landed on /login directly, fromState is absent and we don't stash.
|
||||
const fromState = (location.state as { from?: { pathname?: string; search?: string } } | null)?.from;
|
||||
if (fromState?.pathname) {
|
||||
stashPostLoginRedirect(`${fromState.pathname}${fromState.search ?? ''}`);
|
||||
}
|
||||
window.location.href = data.auth_url;
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-D0SWpeco.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DiPrOrl_.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-45eedLWT.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user