diff --git a/docs/redesign/phase-5/p5-15/p5-15-01/analytics-auth-repair/README.md b/docs/redesign/phase-5/p5-15/p5-15-01/analytics-auth-repair/README.md index b6765f0..a1c8f3b 100644 --- a/docs/redesign/phase-5/p5-15/p5-15-01/analytics-auth-repair/README.md +++ b/docs/redesign/phase-5/p5-15/p5-15-01/analytics-auth-repair/README.md @@ -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. diff --git a/job-tracker-ui/e2e/analytics-auth-repair.spec.ts b/job-tracker-ui/e2e/analytics-auth-repair.spec.ts new file mode 100644 index 0000000..27a30a5 --- /dev/null +++ b/job-tracker-ui/e2e/analytics-auth-repair.spec.ts @@ -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(); } +}); diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index ab9f3df..baf2963 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -290,7 +290,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo }, []); if (requireAuth === null || !authResolved) return {t("loading")}; - if (requireAuth && !me) return ; + if (requireAuth && !me) return ; const pageTitle = titleFor(path, t); const pageSubtitle = subtitleFor(path, t); diff --git a/job-tracker-ui/src/auth.ts b/job-tracker-ui/src/auth.ts index 29de756..348c9af 100644 --- a/job-tracker-ui/src/auth.ts +++ b/job-tracker-ui/src/auth.ts @@ -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); diff --git a/job-tracker-ui/src/login-page.test.tsx b/job-tracker-ui/src/login-page.test.tsx index 90a7063..5159da6 100644 --- a/job-tracker-ui/src/login-page.test.tsx +++ b/job-tracker-ui/src/login-page.test.tsx @@ -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 () => { diff --git a/job-tracker-ui/src/views/LoginPage.tsx b/job-tracker-ui/src/views/LoginPage.tsx index 29cdc62..e6ca6d0 100644 --- a/job-tracker-ui/src/views/LoginPage.tsx +++ b/job-tracker-ui/src/views/LoginPage.tsx @@ -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 {t("or")} - {cfg?.googleEnabled ? { navigate(nextPath, { replace: true }); }} /> : null} - {cfg?.microsoftEnabled ? { navigate(nextPath, { replace: true }); }} /> : null} + {cfg?.googleEnabled ? { navigate(nextPath, nextOptions); }} /> : null} + {cfg?.microsoftEnabled ? { navigate(nextPath, nextOptions); }} /> : null} ) : null}