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:
maziggy
2026-06-28 12:25:05 +02:00
4 changed files with 523 additions and 466 deletions
+5 -3
View File
@@ -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])) {
+59 -4
View File
@@ -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
View File
@@ -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>