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
@@ -21,4 +21,10 @@ Before application changes, the Login component regression run has **17passing/1
Existing session/account invalidation intentionally disposes the previous page/providers and replaces with `/login?sessionChanged=1`; previous-account private context must remain discarded. Backend ownership and role checks remain authoritative. Synthetic accounts/disposable SQLite only; no real external identity provider or production data.
Current repair status: **IN PROGRESS**. Original AUTH-01 remains open until GREEN and full validation. P5-15-02 and production are not cleared. Broader human/device/font qualification, historical preference500 causes, the CV Unsaved intermittent observation and P5-13 deferral remain open.
## Bounded implementation checkpoint
`App.Shell` now carries pathname/search/hash plus the existing navigation context through the same landing/sign-in state. `safeAuthReturnPath` rejects external/protocol-relative targets, controls/backslashes, malformed encoding, recursively encoded unsafe paths and normalized authentication-entry loops, including case variants. It returns valid internal strings unchanged. Login uses that validated destination across local, two-factor, Google and Microsoft completion, replaying only validated existing Jobs return context. Account-disposal entries explicitly abandon old destinations. No URL storage, Analytics aggregation, authentication API or provider changes.
Final application changes have fresh **1,033/1,033 backend**, **490/490 frontend**, **31/31 focused Login**, the unchanged original native AUTH-01 **1/1**, and new native return/security journeys **5/5** passing. TypeScript, lint and both builds pass. The case-insensitive login-loop gap also has separate RED evidence before correction: two failures, two selected passes and27 not selected; final31 cases pass. External identity callbacks/two-factor are component fixtures, not live provider acceptance.
The complete browser/security/fidelity reconciliation is still running. Current checkpoint status: **IN PROGRESS**, with the demonstrated AUTH-01 repaired but no final acceptance claim yet. P5-15-02 and production are not cleared. Broader human/device/font qualification, historical preference500 causes, the CV Unsaved intermittent observation and P5-13 deferral remain open.
@@ -0,0 +1,203 @@
import { expect, test as base, type APIRequestContext, type Page } from '@playwright/test';
import { translations } from '../src/i18n/translations';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
async function login(api: APIRequestContext, email: string) {
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await (await api.get(`${apiUrl}/auth/me`)).json();
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value;
return { 'X-CSRF-TOKEN': csrf, 'X-Session-Context': me.sessionContext as string };
}
const test = base.extend<{ email: string }>({
email: async ({ playwright, context }, provide) => {
const admin = await playwright.request.newContext();
try {
const headers = await login(admin, 'e2e@example.test');
const email = `analytics-auth-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers, data: { email, password, roles: [] } })).status()).toBe(200);
await context.addInitScript(() => { if (!localStorage.getItem('uiLanguage')) localStorage.setItem('uiLanguage', 'en'); });
await context.route('**/*', route => {
const url = new URL(route.request().url());
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol)
? route.continue() : route.abort();
});
await provide(email);
} finally { await admin.dispose(); }
},
});
async function submitLogin(page: Page, email: string, language: 'en' | 'nb' = 'en', value = password) {
const t = translations[language];
if (new URL(page.url()).pathname !== '/login') await page.getByRole('button', { name: t.signInTitle, exact: true }).first().click();
await page.getByLabel(t.usernameOrEmail, { exact: true }).fill(email);
await page.getByLabel(t.profileCurrentPassword, { exact: true }).fill(value);
await page.getByRole('button', { name: t.signInTitle, exact: true }).click();
}
test('[P5-15-01R-03 AUTH-01] native sign-in restores the non-default Analytics period', async ({ page, email }, info) => {
await page.goto('/analytics?months=6');
await expect(page).toHaveURL(/\/$/);
await submitLogin(page, email);
await expect(page.getByRole('heading', { name: translations.en.analytics, exact: true, level: 1 })).toBeVisible();
await info.attach('analytics-native-auth-return', { body: JSON.stringify({ requested: '/analytics?months=6', returned: new URL(page.url()).pathname + new URL(page.url()).search }), contentType: 'application/json' });
await expect(page).toHaveURL(/\/analytics\?months=6$/);
await expect(page.getByRole('button', { name: '6 mo', exact: true })).toHaveAttribute('aria-pressed', 'true');
});
function periodLabel(language: 'en' | 'nb', months: number) {
return translations[language].dashboardMonthsShort.replace('{count}', String(months));
}
function analyticsResponse(page: Page, months: number) {
return page.waitForResponse(response => {
const url = new URL(response.url());
return url.pathname === '/api/jobapplications/analytics' && url.searchParams.get('months') === String(months);
});
}
test('[P5-15-01R-03] Analytics encoded period, fragment and existing defaults survive native sign-in across locales/themes', async ({ page, context, email }, info) => {
test.setTimeout(120_000);
const results: { requested: string; returned: string; effectiveMonths: number; language: string; mode: string; width: number }[] = [];
for (const variant of [
{ language: 'en', mode: 'light', width: 1440, path: '/analytics?months=24#activity', months: 24 },
{ language: 'nb', mode: 'dark', width: 390, path: '/analytics?months=%36#rapport', months: 6 },
{ language: 'en', mode: 'dark', width: 768, path: '/analytics?months=12', months: 12 },
{ language: 'nb', mode: 'light', width: 320, path: '/analytics?months=3#rapport', months: 12 },
] as const) {
const headers = await login(page.request, email);
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: variant.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
await page.goto('about:blank');
await context.clearCookies();
await page.setViewportSize({ width: variant.width, height: 900 });
await page.goto('/');
await page.evaluate(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, variant);
await page.goto(variant.path);
await expect(page).toHaveURL(/\/$/);
const report = analyticsResponse(page, variant.months);
await submitLogin(page, email, variant.language);
await expect(page).toHaveURL(`http://localhost:3300${variant.path}`);
expect((await report).status()).toBe(200);
await expect(page.getByRole('heading', { name: translations[variant.language].analytics, exact: true, level: 1 })).toBeVisible();
await expect(page.getByRole('button', { name: periodLabel(variant.language, variant.months), exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', variant.mode);
const returned = new URL(page.url());
results.push({ requested: variant.path, returned: returned.pathname + returned.search + returned.hash, effectiveMonths: variant.months, language: variant.language, mode: variant.mode, width: variant.width });
const path = info.outputPath(`analytics-auth-${variant.language}-${variant.mode}-${variant.width}.png`);
await page.screenshot({ path, fullPage: true, animations: 'disabled' });
await info.attach(`analytics-auth-${variant.language}-${variant.mode}-${variant.width}`, { path, contentType: 'image/png' });
}
await info.attach('analytics-auth-period-matrix', { body: JSON.stringify(results), contentType: 'application/json' });
});
test('[P5-15-01R-03] failed local sign-in preserves the destination; default, history, refresh and authenticated links stay correct', async ({ page, email }, info) => {
await page.goto('/analytics');
await expect(page).toHaveURL(/\/$/);
const failedLogin = page.waitForResponse(response => response.url() === `${apiUrl}/auth/login` && response.request().method() === 'POST');
await submitLogin(page, email, 'en', 'IncorrectPassword123!');
expect((await failedLogin).status()).toBe(401);
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole('alert').filter({ hasText: 'Unauthorized' })).toBeVisible();
const report = analyticsResponse(page, 12);
await submitLogin(page, email);
await expect(page).toHaveURL(/\/analytics$/);
expect((await report).status()).toBe(200);
await expect(page.getByRole('button', { name: '12 mo', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.getByRole('button', { name: '6 mo', exact: true }).click();
await expect(page).toHaveURL(/\/analytics\?months=6$/);
await page.getByRole('button', { name: '24 mo', exact: true }).click();
await expect(page).toHaveURL(/\/analytics\?months=24$/);
await page.goBack();
await expect(page).toHaveURL(/\/analytics\?months=6$/);
await expect(page.getByRole('button', { name: '6 mo', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.goBack();
await expect(page).toHaveURL(/\/analytics$/);
await expect(page.getByRole('button', { name: '12 mo', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.goForward();
await expect(page).toHaveURL(/\/analytics\?months=6$/);
await page.reload();
await expect(page.getByRole('button', { name: '6 mo', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.goto('/analytics?months=24#activity');
await expect(page).toHaveURL(/\/analytics\?months=24#activity$/);
await expect(page.getByRole('button', { name: '24 mo', exact: true })).toHaveAttribute('aria-pressed', 'true');
await info.attach('analytics-native-history-and-authentication', { body: JSON.stringify({ failedLogin: 401, retryDefault: 12, backPeriods: [6, 12], forwardAndRefresh: 6, authenticatedDirect: 24 }), contentType: 'application/json' });
});
test('[P5-15-01R-03] expired browser cookie retains the report return path; deliberate logout clears it', async ({ page, context, email }, info) => {
await login(page.request, email);
await page.goto('/analytics?months=24#activity');
await expect(page.getByRole('button', { name: '24 mo', exact: true })).toHaveAttribute('aria-pressed', 'true');
const authCookie = (await context.cookies()).find(cookie => cookie.name === 'jobtracker_auth');
expect(authCookie).toBeDefined();
await context.addCookies([{ ...authCookie!, expires: 1 }]);
expect((await context.cookies()).some(cookie => cookie.name === 'jobtracker_auth')).toBe(false);
expect((await page.request.get(`${apiUrl}/auth/me`)).status()).toBe(401);
await page.reload();
await expect(page).toHaveURL(/\/$/);
await submitLogin(page, email);
await expect(page).toHaveURL(/\/analytics\?months=24#activity$/);
await expect(page.getByRole('button', { name: '24 mo', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.getByRole('button', { name: translations.en.user, exact: true }).click();
const loggedOut = page.waitForResponse(response => response.url() === `${apiUrl}/auth/logout`);
await page.getByRole('menuitem', { name: translations.en.signOut, exact: true }).click();
expect((await loggedOut).status()).toBe(204);
await expect(page).toHaveURL(/\/login\?sessionChanged=1$/);
await submitLogin(page, email);
await expect(page).toHaveURL(/\/dashboard$/);
await info.attach('native-expiry-versus-deliberate-logout', { body: JSON.stringify({ expiredCookieMe: 401, expiryReturn: '/analytics?months=24#activity', logout: 204, logoutSubsequentLogin: '/dashboard' }), contentType: 'application/json' });
});
test('[P5-15-01R-03 PRES-E09] another protected section keeps its destination without bypassing ownership or unsafe-target validation', async ({ page, playwright, email }, info) => {
test.setTimeout(90_000);
const owner = await playwright.request.newContext();
const admin = await playwright.request.newContext();
try {
const ownerHeaders = await login(owner, email);
const company = await owner.post(`${apiUrl}/companies`, { headers: ownerHeaders, data: { name: 'Private Analytics-auth employer' } });
expect(company.status()).toBe(201);
const job = await owner.post(`${apiUrl}/jobapplications`, { headers: ownerHeaders, data: { companyId: (await company.json()).id, jobTitle: 'Private Analytics-auth role', status: 'Saved' } });
expect(job.status()).toBe(201);
const jobId = (await job.json()).id;
const destination = `/jobs/${jobId}?section=documents#documents`;
await page.goto(destination);
await expect(page).toHaveURL(/\/$/);
await submitLogin(page, email);
await expect(page).toHaveURL(`http://localhost:3300${destination}`);
await expect(page.getByRole('region', { name: translations.en.workspaceCurrentDocuments, exact: true })).toBeVisible();
await page.reload();
await expect(page.getByRole('region', { name: translations.en.workspaceCurrentDocuments, exact: true })).toBeVisible();
await page.getByRole('button', { name: translations.en.user, exact: true }).click();
await page.getByRole('menuitem', { name: translations.en.signOut, exact: true }).click();
await expect(page).toHaveURL(/\/login\?sessionChanged=1$/);
const adminHeaders = await login(admin, 'e2e@example.test');
const emailB = `analytics-auth-other-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email: emailB, password, roles: [] } })).status()).toBe(200);
await page.goto(destination);
await expect(page).toHaveURL(/\/$/);
const denied = page.waitForResponse(response => new URL(response.url()).pathname === `/api/jobapplications/${jobId}/workspace`);
await submitLogin(page, emailB);
await expect(page).toHaveURL(`http://localhost:3300${destination}`);
expect((await denied).status()).toBe(404);
await expect(page.getByText(translations.en.workspaceLoadFailed, { exact: true })).toBeVisible();
await expect(page.getByText('Private Analytics-auth role', { exact: true })).toHaveCount(0);
expect((await page.request.get(`${apiUrl}/jobapplications/${jobId}`)).status()).toBe(404);
expect((await (await owner.get(`${apiUrl}/jobapplications/${jobId}`)).json()).jobTitle).toBe('Private Analytics-auth role');
const rejected: string[] = [];
for (const target of ['//outside.example.test/analytics', '/%2f%2foutside.example.test/analytics']) {
await page.getByRole('button', { name: translations.en.user, exact: true }).click();
await page.getByRole('menuitem', { name: translations.en.signOut, exact: true }).click();
await expect(page).toHaveURL(/\/login\?sessionChanged=1$/);
await page.goto('/login');
await page.evaluate(from => { history.replaceState({ ...history.state, usr: { from } }, '', location.href); }, target);
await page.reload();
await submitLogin(page, emailB);
await expect(page).toHaveURL(/\/dashboard$/);
rejected.push(target);
}
await info.attach('native-return-target-and-ownership', { body: JSON.stringify({ ownedDestination: destination, foreignResource: 404, foreignWorkspace: 404, ownerReadbackUnchanged: true, rejectedTargets: rejected, fallback: '/dashboard' }), contentType: 'application/json' });
} finally { await owner.dispose(); await admin.dispose(); }
});
+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}