From fb5866a9fee0b922e00e472ef08cbae24ef8287d Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Wed, 10 Jun 2026 01:11:31 +0200 Subject: [PATCH] fix(web): restore deep link after re-auth redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MSAL runs with navigateToLoginRequestUrl=false and redirectUri=origin, so after an interactive login the user lands on the bare root and AuthGate routes them role-based to /dashboard — dropping the deep link they opened (e.g. a /sessions/[id] link in a new tab, which has no shared sessionStorage token and must re-auth). Stash the intended in-app path before login() in ProtectedRoute and restore it in AuthGate after auth settles. New lib/postLoginReturn.ts persists it in sessionStorage (survives the same-tab redirect round-trip) and guards against open redirects (same-origin "/..." paths only). Do NOT flip navigateToLoginRequestUrl — it is intentional for the www->portal cross-origin flow. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/ProtectedRoute.tsx | 5 ++ .../components/landing/AuthGate.tsx | 7 ++ .../lib/__tests__/postLoginReturn.test.ts | 71 +++++++++++++++++++ .../lib/postLoginReturn.ts | 48 +++++++++++++ 4 files changed, 131 insertions(+) create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/postLoginReturn.test.ts create mode 100644 src/Web/autopilot-monitor-web/lib/postLoginReturn.ts diff --git a/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx b/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx index 0e83ae8c..26e7cbad 100644 --- a/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx +++ b/src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx @@ -3,6 +3,7 @@ import { useAuth } from "../contexts/AuthContext"; import { useRouter } from "next/navigation"; import { useEffect, useRef } from "react"; +import { savePostLoginReturnUrl } from "../lib/postLoginReturn"; interface ProtectedRouteProps { children: React.ReactNode; @@ -34,6 +35,10 @@ export function ProtectedRoute({ children, requireGlobalAdmin = false }: Protect // entry-point flow for users who arrived via a www → portal // cross-origin sign-in nav with no portal-side session yet. reloginAttempted.current = true; + // Stash the deep link so AuthGate can restore it after re-auth. MSAL has + // navigateToLoginRequestUrl=false, so without this the user lands on the + // role-default route (e.g. /dashboard) instead of the page they opened. + savePostLoginReturnUrl(window.location.pathname + window.location.search); login().catch((err) => { console.warn('[ProtectedRoute] Login redirect failed, navigating to landing:', err); router.push("/"); diff --git a/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx b/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx index d156c414..25634148 100644 --- a/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx @@ -3,6 +3,7 @@ import { useAuth } from "../../contexts/AuthContext"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; +import { consumePostLoginReturnUrl } from "../../lib/postLoginReturn"; /** * Invisible client component that handles auth redirect logic. @@ -15,8 +16,14 @@ export function AuthGate() { useEffect(() => { if (isAuthenticated && !isLoading && user) { + // Always consume (read + clear) so a stale deep link can't misroute a later + // sign-in; only honor it when the user isn't preview-gated. + const returnUrl = consumePostLoginReturnUrl(); if (isPreviewBlocked) { router.push("/preview"); + } else if (returnUrl) { + // Restore the deep link the user originally opened before re-auth. + router.replace(returnUrl); } else if (user.isTenantAdmin || user.isGlobalAdmin || user.role === 'Operator') { router.push("/dashboard"); } else { diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/postLoginReturn.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/postLoginReturn.test.ts new file mode 100644 index 00000000..8f258cc4 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/postLoginReturn.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { savePostLoginReturnUrl, consumePostLoginReturnUrl } from "../postLoginReturn"; + +// Guards the deep-link-after-reauth contract: ProtectedRoute stashes the page the +// user opened (e.g. a /sessions/[id] link in a new tab) before the MSAL login +// redirect, and AuthGate restores it afterwards. MSAL runs with +// navigateToLoginRequestUrl=false, so a regression here silently drops the user +// on /dashboard instead of where they were headed — and the open-redirect guard +// is the only thing stopping a crafted path from bouncing them off-origin. + +function installSessionStorageStub() { + const store = new Map(); + const sessionStorage = { + getItem: (k: string) => (store.has(k) ? store.get(k)! : null), + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), + clear: () => store.clear(), + }; + (globalThis as unknown as { window: unknown }).window = { sessionStorage }; +} + +describe("postLoginReturn", () => { + beforeEach(() => { + installSessionStorageStub(); + }); + + afterEach(() => { + delete (globalThis as unknown as { window?: unknown }).window; + }); + + it("round-trips a safe in-app path and clears it on consume", () => { + savePostLoginReturnUrl("/sessions/abc-123?tab=events"); + expect(consumePostLoginReturnUrl()).toBe("/sessions/abc-123?tab=events"); + // Second consume returns null — the value is single-use. + expect(consumePostLoginReturnUrl()).toBeNull(); + }); + + it("returns null when nothing was saved", () => { + expect(consumePostLoginReturnUrl()).toBeNull(); + }); + + it("ignores the bare root path", () => { + savePostLoginReturnUrl("/"); + expect(consumePostLoginReturnUrl()).toBeNull(); + }); + + it("rejects open-redirect shapes", () => { + for (const evil of ["//evil.com", "/\\evil.com", "https://evil.com", "javascript:alert(1)", ""]) { + savePostLoginReturnUrl(evil); + expect(consumePostLoginReturnUrl()).toBeNull(); + } + }); + + // Belt-and-suspenders: percent-encoded slash/backslash do NOT act as authority + // separators at the URL level (the leading single "/" already fixed this as a + // path), so these stay same-origin and are intentionally accepted as-is. This + // locks that contract against a future change that decodes before validating — + // which would turn "/%2F%2Fevil.com" into the "//evil.com" open redirect above. + it("accepts percent-encoded slash/backslash as literal same-origin paths", () => { + for (const encoded of ["/%2F%2Fevil.com", "/%5Cevil.com", "/%2f%2fevil.com"]) { + savePostLoginReturnUrl(encoded); + expect(consumePostLoginReturnUrl()).toBe(encoded); + } + }); + + it("does not throw when sessionStorage is unavailable", () => { + delete (globalThis as unknown as { window?: unknown }).window; + expect(() => savePostLoginReturnUrl("/sessions/x")).not.toThrow(); + expect(consumePostLoginReturnUrl()).toBeNull(); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/postLoginReturn.ts b/src/Web/autopilot-monitor-web/lib/postLoginReturn.ts new file mode 100644 index 00000000..a2b5ba01 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/postLoginReturn.ts @@ -0,0 +1,48 @@ +/** + * Post-login return-URL helper. + * + * MSAL is configured with `navigateToLoginRequestUrl: false` (see lib/msalConfig.ts), + * so after an interactive login redirect the browser lands on the bare origin (`/`) + * and AuthGate routes the user to a role-based default (e.g. /dashboard) — losing the + * deep link they originally tried to open (e.g. a /sessions/[id] link in a new tab). + * + * To preserve the intended destination we stash it just before triggering the login + * redirect and restore it once auth settles back on the landing page. sessionStorage + * survives the same-tab redirect round-trip and auto-clears when the tab closes. + */ + +const RETURN_URL_KEY = "apm:postLoginReturnUrl"; + +/** + * Only same-origin absolute in-app paths (e.g. "/sessions/abc") are eligible. + * Rejects protocol-relative ("//evil.com"), backslash tricks ("/\\evil.com"), + * absolute URLs, and the bare root (nothing meaningful to restore) to avoid + * open-redirect abuse. + */ +function isSafeReturnPath(path: string | null | undefined): path is string { + if (!path || !path.startsWith("/")) return false; + if (path.startsWith("//") || path.startsWith("/\\")) return false; + if (path === "/") return false; + return true; +} + +/** Persist the in-app location to return to after the login redirect completes. */ +export function savePostLoginReturnUrl(path: string): void { + try { + if (!isSafeReturnPath(path)) return; + window.sessionStorage.setItem(RETURN_URL_KEY, path); + } catch { + // sessionStorage unavailable (private mode / ITP) — fall back to default routing. + } +} + +/** Read and clear the saved return URL. Returns null if absent or unsafe. */ +export function consumePostLoginReturnUrl(): string | null { + try { + const value = window.sessionStorage.getItem(RETURN_URL_KEY); + if (value !== null) window.sessionStorage.removeItem(RETURN_URL_KEY); + return isSafeReturnPath(value) ? value : null; + } catch { + return null; + } +}