fix(auth): retain protected deep-link state through sign-in

This commit is contained in:
cesnimda
2026-10-10 14:32:24 +02:00
parent c37cab560a
commit 6843767176
6 changed files with 250 additions and 18 deletions
+1 -1
View File
@@ -290,7 +290,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
}, []);
if (requireAuth === null || !authResolved) return <Box sx={{ p: 4 }}><Typography variant="h6">{t("loading")}</Typography></Box>;
if (requireAuth && !me) return <Navigate to="/" replace state={{ from: path }} />;
if (requireAuth && !me) return <Navigate to="/" replace state={{ from: `${path}${location.search}${location.hash}`, fromState: location.state }} />;
const pageTitle = titleFor(path, t);
const pageSubtitle = subtitleFor(path, t);
+22
View File
@@ -6,6 +6,28 @@ export const AUTH_USER_CHANGED_EVENT = "auth-user-changed";
export type AuthPersistence = "local" | "session";
export function safeAuthReturnPath(value: unknown): string | undefined {
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//") || /[\u0000-\u001f\u007f\\]/.test(value)) return undefined;
try {
decodeURIComponent(value);
const target = new URL(value, window.location.origin);
let path = target.pathname;
// Validate decoded path meaning without changing encoded query/fragment navigation.
for (;;) {
const decoded = decodeURIComponent(path);
if (decoded === path) break;
path = decoded;
}
if (path.startsWith("//") || /[\u0000-\u001f\u007f\\]/.test(path)) return undefined;
const normalized = new URL(path, window.location.origin);
if (target.origin !== window.location.origin || normalized.origin !== window.location.origin
|| ["/", "/login", "/register"].includes(normalized.pathname.replace(/\/+$/, "").toLowerCase() || "/")) return undefined;
return value;
} catch {
return undefined;
}
}
function safeGet(storage: Storage, key: string): string | null {
try {
return storage.getItem(key);
+5 -5
View File
@@ -145,11 +145,11 @@ describe('LoginPage', () => {
mockMsalLoginPopup.mockResolvedValueOnce({ idToken: 'synthetic-microsoft-token' });
mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'microsoft' } } as any);
renderLoginPage('login', '/jobs');
renderLoginPage('login', '/jobs?status=Applied#rows');
await click(await screen.findByRole('button', { name: 'Continue with Microsoft' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/microsoft/exchange', { token: 'synthetic-microsoft-token', rememberMe: true }));
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/jobs', { replace: true }));
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/jobs?status=Applied#rows', { replace: true }));
});
it('completes a Google credential return without account-linking copy', async () => {
@@ -231,7 +231,7 @@ describe('LoginPage', () => {
'https://evil.example/analytics', '//evil.example/analytics', '/\t/evil.example',
'/%2f%2fevil.example', '/%252f%252fevil.example', '/%5cevil.example',
'/analytics?months=%ZZ', '/jobs/../login', '/%6cogin', '/register',
'/analytics%0a', '/analytics?months=6\n',
'/analytics%0a', '/analytics?months=6\n', '/LOGIN', '/%4cOGIN',
])('uses the safe fallback for the untrusted return target %j', async (from) => {
mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'local' } } as any);
renderLoginPage('login', from);
@@ -302,7 +302,7 @@ describe('LoginPage', () => {
});
mockedApi.get.mockResolvedValueOnce({ data: { roles: [], email: 'person@example.com', userName: 'person' } } as any);
renderLoginPage();
renderLoginPage('login', '/analytics?months=24#activity');
await screen.findByLabelText('Username or email');
await userEvent.type(screen.getByLabelText('Username or email'), 'person@example.com');
@@ -317,7 +317,7 @@ describe('LoginPage', () => {
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/2fa/challenge', { pendingToken: 'pending-abc', code: '123456', trustDevice: false }));
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/auth/me'));
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }));
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/analytics?months=24#activity', { replace: true }));
});
it('shows a clear message when the 2FA challenge is rate-limited', async () => {
+12 -11
View File
@@ -5,7 +5,7 @@ import { Alert, Box, Button, Checkbox, Divider, FormControlLabel, Paper, TextFie
import { useLocation, useNavigate } from "react-router-dom";
import { api, getApiErrorMessage } from "../api";
import { getRememberMePref, setAuthPersistencePreference } from "../auth";
import { getRememberMePref, safeAuthReturnPath, setAuthPersistencePreference } from "../auth";
import GoogleAuthCard from "../components/GoogleAuthCard";
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
import TwoFactorChallenge from "../components/TwoFactorChallenge";
@@ -47,13 +47,14 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const requestedNextPath = location?.state?.from;
const nextPath = typeof requestedNextPath === "string"
&& requestedNextPath.startsWith("/")
&& !requestedNextPath.startsWith("//")
&& !requestedNextPath.includes("\\")
? requestedNextPath
: "/dashboard";
const returnPath = new URLSearchParams(location.search).has("sessionChanged") ? undefined : safeAuthReturnPath(location.state?.from);
const nextPath = returnPath ?? "/dashboard";
const fromState = location.state?.fromState;
const contextPath = returnPath && safeAuthReturnPath(fromState?.from);
const nextOptions = contextPath ? {
replace: true,
state: { from: contextPath, ...(Number.isSafeInteger(fromState.focusJobId) && fromState.focusJobId > 0 ? { focusJobId: fromState.focusJobId } : {}) },
} : { replace: true };
useEffect(() => {
api
@@ -66,7 +67,7 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
setAuthPersistencePreference(rememberMe ? "local" : "session");
await api.get("/auth/me");
toast(t("signedIn"), "success");
navigate(nextPath, { replace: true });
navigate(nextPath, nextOptions);
}
function validate(mode: "login" | "register") {
@@ -265,8 +266,8 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
<Box sx={{ mt: 2.5 }}>
<Divider sx={{ mb: 2 }}>{t("or")}</Divider>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
{cfg?.googleEnabled ? <GoogleAuthCard presentation="sign-in" onSignedIn={() => { navigate(nextPath, { replace: true }); }} /> : null}
{cfg?.microsoftEnabled ? <MicrosoftAuthCard presentation="sign-in" onSignedIn={() => { navigate(nextPath, { replace: true }); }} /> : null}
{cfg?.googleEnabled ? <GoogleAuthCard presentation="sign-in" onSignedIn={() => { navigate(nextPath, nextOptions); }} /> : null}
{cfg?.microsoftEnabled ? <MicrosoftAuthCard presentation="sign-in" onSignedIn={() => { navigate(nextPath, nextOptions); }} /> : null}
</Box>
</Box>
) : null}