fix(web): restore deep link after re-auth redirect

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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-10 01:11:31 +02:00
parent dca4da50ed
commit fb5866a9fe
4 changed files with 131 additions and 0 deletions
@@ -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("/");
@@ -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 {
@@ -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<string, string>();
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();
});
});
@@ -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;
}
}