mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user