fix(web): re-home hop peeks the post-login deep link instead of re-saving it

CodeQL flagged the re-save in AuthGate (js/clear-text-storage-of-sensitive-data,
alert #82): the value flowed back into sessionStorage from the consumed
return URL. The link only has to survive one more sign-in round trip, so the
re-home path now reads it without clearing and never writes it again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-02 17:26:28 +02:00
parent 6c6d746532
commit 5494fd1862
3 changed files with 39 additions and 14 deletions
@@ -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 {
@@ -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();
@@ -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 {