fix(auth): retain protected deep-link state through sign-in
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user