diff --git a/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx b/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx index 463956b8..ce41893d 100644 --- a/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/navigation"; import { useEffect } from "react"; import type { Route } from "next"; import { trustedRoute } from "../../lib/routes"; -import { consumePostLoginReturnUrl, savePostLoginReturnUrl } from "../../lib/postLoginReturn"; +import { consumePostLoginReturnUrl, peekPostLoginReturnUrl } from "../../lib/postLoginReturn"; import { hasOwnTenantOrPlatformRole, hasTenantReadScope } from "../../lib/tenantScope"; import { portalHandoverUrl, shouldCrossOriginToPortal } from "../../lib/hostRouting"; import { consumePendingRehome, getSelectedAuthApp, legacyConfigured, switchAuthApp, tryBeginRehome } from "../../lib/authApp"; @@ -23,9 +23,19 @@ 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's tenant is activated. - const returnUrl = consumePostLoginReturnUrl(); + // Dual app-reg window: the sign-in that just completed ran on the other app than the + // tenant is homed on (fresh browser, wrong default). Same-origin: re-home NOW — switch + // the browser to the homed app and land on the target, where ProtectedRoute completes + // the sign-in silently via the Entra session and returns here with the deep link intact. + // One hop per tab; the pending request is consumed either way. + const rehome = consumePendingRehome(); + const crossOrigin = shouldCrossOriginToPortal(); + const rehomeNow = rehome !== null && !crossOrigin && tryBeginRehome(); + // Normally consume (read + clear) so a stale deep link can't misroute a later + // sign-in; only honor it when the user's tenant is activated. On the re-home hop only + // PEEK: the link has to survive the extra sign-in round trip for the AuthGate pass that + // follows, and leaving it in place beats re-saving it. + const returnUrl = rehomeNow ? peekPostLoginReturnUrl() : consumePostLoginReturnUrl(); let target: Route; if (isActivationPending) { target = "/activation"; @@ -40,20 +50,13 @@ export function AuthGate() { } else { target = "/progress"; } - // Dual app-reg window: the sign-in that just completed ran on the other app than the - // tenant is homed on (fresh browser, wrong default). Same-origin: re-home NOW — switch - // the browser to the homed app and land on the target, where ProtectedRoute completes - // the sign-in silently via the Entra session and returns here with the deep link intact. - // One hop per tab; the pending request is consumed either way. - const rehome = consumePendingRehome(); // On the public host, hand over to the portal origin in ONE full-page // navigation instead of router.push + HostRoutingGuard bounce. Auth state // is per-origin — the portal side runs its own (silent) MSAL sign-in, on // the app this browser just learned (passed along as ?authapp=). - if (shouldCrossOriginToPortal()) { + if (crossOrigin) { window.location.href = portalHandoverUrl(target, legacyConfigured() ? getSelectedAuthApp() : null); - } else if (rehome && tryBeginRehome()) { - savePostLoginReturnUrl(target); + } else if (rehomeNow) { trackEvent("auth_app_rehomed", { from: activeAuthApp, to: rehome }); switchAuthApp(rehome, target); } 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 index c9868058..c6399be5 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/postLoginReturn.test.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/postLoginReturn.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { savePostLoginReturnUrl, consumePostLoginReturnUrl } from "../postLoginReturn"; +import { savePostLoginReturnUrl, consumePostLoginReturnUrl, peekPostLoginReturnUrl } 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 @@ -39,6 +39,14 @@ describe("postLoginReturn", () => { expect(consumePostLoginReturnUrl()).toBeNull(); }); + it("peek reads without clearing — the re-home hop leaves the deep link for the next pass", () => { + savePostLoginReturnUrl("/sessions?id=abc-123"); + expect(peekPostLoginReturnUrl()).toBe("/sessions?id=abc-123"); + expect(peekPostLoginReturnUrl()).toBe("/sessions?id=abc-123"); + expect(consumePostLoginReturnUrl()).toBe("/sessions?id=abc-123"); + expect(peekPostLoginReturnUrl()).toBeNull(); + }); + it("ignores the bare root path", () => { savePostLoginReturnUrl("/"); expect(consumePostLoginReturnUrl()).toBeNull(); diff --git a/src/Web/autopilot-monitor-web/lib/postLoginReturn.ts b/src/Web/autopilot-monitor-web/lib/postLoginReturn.ts index 79687851..af30086c 100644 --- a/src/Web/autopilot-monitor-web/lib/postLoginReturn.ts +++ b/src/Web/autopilot-monitor-web/lib/postLoginReturn.ts @@ -36,6 +36,20 @@ export function savePostLoginReturnUrl(path: string): void { } } +/** + * Read the saved return URL WITHOUT clearing it. For the post-login re-home hop (AuthGate): + * the deep link must survive one more sign-in round trip on the other app registration, so it + * is left in place for the AuthGate pass that follows instead of being consumed and re-saved. + */ +export function peekPostLoginReturnUrl(): string | null { + try { + const value = window.sessionStorage.getItem(RETURN_URL_KEY); + return isSafeReturnPath(value) ? value : null; + } catch { + return null; + } +} + /** Read and clear the saved return URL. Returns null if absent or unsafe. */ export function consumePostLoginReturnUrl(): string | null { try {