Hosted component hacks

This commit is contained in:
Konstantin Wohlwend 2026-07-10 10:45:22 -07:00
parent 09e5a97d77
commit 31dc84075a
11 changed files with 958 additions and 5 deletions

View File

@ -0,0 +1,186 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CurrentUser } from "../lib/hexclave-app";
import type { StackClientApp } from "../lib/hexclave-app/apps/interfaces/client-app";
import { hexclaveAppInternalsSymbol } from "../lib/hexclave-app/common";
import { TranslationProviderClient } from "../providers/translation-provider-client";
import { SignOut } from "./sign-out";
const appMockState = vi.hoisted(() => ({
app: null as unknown,
user: null as unknown,
}));
vi.mock("..", () => ({
useStackApp: () => {
if (appMockState.app == null) {
throw new Error("Expected test app to be set before rendering.");
}
return appMockState.app;
},
useUser: () => appMockState.user,
}));
// The real `use` delegates to React.use, which breaks in this test setup because the shared
// package and react-dom resolve different React instances. Replace it with an equivalent
// Suspense-compatible implementation that doesn't depend on the React dispatcher.
vi.mock("@hexclave/shared/dist/utils/react", async (importOriginal) => {
const original = await importOriginal<typeof import("@hexclave/shared/dist/utils/react")>();
type PromiseState = { status: "pending" } | { status: "ok", value: unknown } | { status: "error", error: unknown };
const promiseStates = new WeakMap<Promise<unknown>, PromiseState>();
return {
...original,
use: <T,>(promise: Promise<T>): T => {
const state = promiseStates.get(promise);
if (state == null) {
promiseStates.set(promise, { status: "pending" });
promise.then(
(value) => promiseStates.set(promise, { status: "ok", value }),
(error) => promiseStates.set(promise, { status: "error", error }),
);
throw promise;
}
switch (state.status) {
case "pending": {
throw promise;
}
case "error": {
throw state.error;
}
case "ok": {
// This cast is safe because the value was stored from this exact promise's resolution;
// the WeakMap just can't express the per-key type relationship.
return state.value as T;
}
}
},
};
});
vi.mock("@hexclave/ui", async () => {
const React = await import("react");
return {
Button: (props: { children: React.ReactNode, onClick?: () => void }) => (
<button type="button" onClick={props.onClick}>{props.children}</button>
),
Typography: (props: { children: React.ReactNode }) => <div>{props.children}</div>,
cn: (...classes: (string | false | null | undefined)[]) => classes.filter(Boolean).join(" "),
};
});
const previousActEnvironment = Reflect.get(globalThis, "IS_REACT_ACT_ENVIRONMENT");
function createAppTestDouble(options: {
trustedUrls: string[],
}) {
const app = {
redirectToSignIn: vi.fn(async () => {}),
[hexclaveAppInternalsSymbol]: {
isTrustedRedirectUrl: vi.fn(async (url: string) => options.trustedUrls.includes(url)),
},
};
// This test double intentionally implements only the StackClientApp surface that SignOut and
// the rendered signed-out card touch.
return app as unknown as StackClientApp<true>;
}
function createUserTestDouble() {
const user = {
signOut: vi.fn(async () => {}),
};
// Same as the app test double: only the surface SignOut touches.
return user as unknown as CurrentUser & { signOut: ReturnType<typeof vi.fn> };
}
// jsdom's window.location is not configurable, so it can't be spied on; replace it wholesale.
const originalLocation = window.location;
let locationReplaceMock = vi.fn();
let root: Root | null = null;
let container: HTMLDivElement | null = null;
async function renderSignOut(options: {
app: StackClientApp<true>,
user: unknown,
searchParams?: Record<string, string>,
}) {
appMockState.app = options.app;
appMockState.user = options.user;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(
<TranslationProviderClient quetzalKeys={new Map()} quetzalLocale={new Map()}>
<React.Suspense fallback={<div data-testid="suspense-fallback" />}>
<SignOut searchParams={options.searchParams} />
</React.Suspense>
</TranslationProviderClient>
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
describe("SignOut", () => {
beforeEach(() => {
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
locationReplaceMock = vi.fn();
Reflect.deleteProperty(window, "location");
Reflect.set(window, "location", { href: "https://demo.example.test/handler/sign-out", replace: locationReplaceMock });
});
afterEach(() => {
act(() => {
root?.unmount();
});
container?.remove();
root = null;
container = null;
appMockState.app = null;
appMockState.user = null;
Reflect.set(window, "location", originalLocation);
vi.restoreAllMocks();
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousActEnvironment);
});
it("signs out with a trusted after_auth_return_to redirect URL", async () => {
const app = createAppTestDouble({ trustedUrls: ["/dashboard"] });
const user = createUserTestDouble();
await renderSignOut({ app, user, searchParams: { after_auth_return_to: "/dashboard" } });
expect(user.signOut).toHaveBeenCalledWith({ redirectUrl: "/dashboard" });
});
it("ignores an untrusted after_auth_return_to and falls back to the default sign-out redirect", async () => {
const app = createAppTestDouble({ trustedUrls: [] });
const user = createUserTestDouble();
await renderSignOut({ app, user, searchParams: { after_auth_return_to: "https://evil.example.test/phishing" } });
// The untrusted URL is dropped entirely; sign-out proceeds with the default destination.
expect(user.signOut).toHaveBeenCalledWith({ redirectUrl: undefined });
});
it("does not follow an untrusted after_auth_return_to when already signed out", async () => {
const app = createAppTestDouble({ trustedUrls: [] });
await renderSignOut({ app, user: null, searchParams: { after_auth_return_to: "https://evil.example.test/phishing-signed-out" } });
expect(locationReplaceMock).not.toHaveBeenCalled();
});
it("follows a trusted after_auth_return_to when already signed out", async () => {
const app = createAppTestDouble({ trustedUrls: ["/trusted-dashboard"] });
await renderSignOut({ app, user: null, searchParams: { after_auth_return_to: "/trusted-dashboard" } });
expect(locationReplaceMock).toHaveBeenCalledWith("/trusted-dashboard");
});
});

View File

@ -1,9 +1,26 @@
'use client';
import { cacheFunction } from "@hexclave/shared/dist/utils/caches";
import { captureError } from "@hexclave/shared/dist/utils/errors";
import { use } from "@hexclave/shared/dist/utils/react";
import { CurrentUser, useUser } from "..";
import { CurrentUser, useStackApp, useUser } from "..";
import { PredefinedMessageCard } from "../components/message-cards/predefined-message-card";
import { StackClientApp, hexclaveAppInternalsSymbol } from "../lib/hexclave-app";
// The sign-out page's `after_auth_return_to` comes straight from the query string, so it is
// attacker-craftable (open-redirect shaped). Only follow it if it passes the same trust validation
// as every other SDK redirect; otherwise ignore it and fall back to the default after-sign-out
// destination.
const cacheGetTrustedRedirectUrl = cacheFunction(async (app: StackClientApp<true, string>, redirectUrl: string | undefined): Promise<string | undefined> => {
if (redirectUrl == null) {
return undefined;
}
if (await app[hexclaveAppInternalsSymbol].isTrustedRedirectUrl(redirectUrl)) {
return redirectUrl;
}
captureError("sign-out-untrusted-redirect-url", new Error(`Ignoring untrusted after_auth_return_to query parameter on the sign-out page: ${redirectUrl}`));
return undefined;
});
const cacheSignOut = cacheFunction(async (user: CurrentUser, redirectUrl: string | undefined) => {
return await user.signOut({ redirectUrl });
@ -19,13 +36,15 @@ const cacheRedirectIfAlreadySignedOut = cacheFunction(async (redirectUrl: string
});
export function SignOut(props: { fullPage?: boolean, searchParams?: Record<string, string> }) {
const app = useStackApp();
const user = useUser({ or: "return-null" });
const redirectUrl = props.searchParams?.after_auth_return_to;
const trustedRedirectUrl = use(cacheGetTrustedRedirectUrl(app, redirectUrl));
if (user) {
use(cacheSignOut(user, redirectUrl));
use(cacheSignOut(user, trustedRedirectUrl));
} else {
use(cacheRedirectIfAlreadySignedOut(redirectUrl));
use(cacheRedirectIfAlreadySignedOut(trustedRedirectUrl));
}
return <PredefinedMessageCard type='signedOut' fullPage={props.fullPage} />;

View File

@ -644,6 +644,133 @@ describe("StackClientApp cross-domain auth", () => {
expect(redirectedUrl).toBe("/settings?tab=profile");
});
it("restores a dropped after_auth_return_to from the sessionStorage mirror when redirecting after sign-in", async () => {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const hadPreviousWindow = Reflect.has(globalThis, "window");
const sessionStorageMap = new Map<string, string>();
const windowMock = {
location: {
href: "https://demo.example.test/handler/sign-in?after_auth_return_to=%2Fmusic%3Ftrack%3D1",
},
sessionStorage: {
getItem: (key: string) => sessionStorageMap.get(key) ?? null,
setItem: (key: string, value: string) => {
sessionStorageMap.set(key, value);
},
removeItem: (key: string) => {
sessionStorageMap.delete(key);
},
},
};
Reflect.set(globalThis, "window", windowMock);
globalThis.document = createMockDocument();
try {
// The constructor mirrors the redirect-back state from the arrival URL into sessionStorage.
const clientApp = new StackClientApp({
baseUrl: "http://localhost:12345",
projectId: "00000000-0000-4000-8000-000000000011",
publishableClientKey: "stack-pk-test",
tokenStore: "memory",
redirectMethod: "window",
noAutomaticPrefetch: true,
});
// Intermediate hops (MFA, magic link, OAuth round-trips, ...) dropped the query params.
windowMock.location.href = "https://demo.example.test/handler/mfa";
const redirectUrl = await clientApp[hexclaveAppInternalsSymbol].getRedirectToHandlerUrl("afterSignIn");
expect(redirectUrl).toBe("/music?track=1");
// noRedirectBack opts out of the mirror just like it opts out of the query param.
const noRedirectBackUrl = await clientApp[hexclaveAppInternalsSymbol].getRedirectToHandlerUrl("afterSignIn", { noRedirectBack: true });
expect(new URL(noRedirectBackUrl, "https://demo.example.test").pathname).toBe("/");
} finally {
globalThis.document = previousDocument;
if (hadPreviousWindow) {
Reflect.set(globalThis, "window", previousWindow);
} else {
Reflect.deleteProperty(globalThis, "window");
}
}
});
it("restores dropped cross-domain handoff state from the sessionStorage mirror when redirecting after sign-in", async () => {
const projectId = "00000000-0000-4000-8000-000000000012";
const previousWindow = globalThis.window;
const hadPreviousWindow = Reflect.has(globalThis, "window");
const sessionStorageMap = new Map<string, string>();
const handoffState = "mirror-handoff-state";
const handoffCodeChallenge = "abcdefghijklmnopqrstuvwxyzABCDEFG_0123456789-._~";
const handoffAfterCallbackRedirect = "https://demo.example.test/dashboard";
const redirectBackUrl = new URL("https://demo.example.test/handler/oauth-callback");
redirectBackUrl.searchParams.set("hexclave_cross_domain_auth", "1");
redirectBackUrl.searchParams.set("hexclave_cross_domain_state", handoffState);
redirectBackUrl.searchParams.set("hexclave_cross_domain_code_challenge", handoffCodeChallenge);
redirectBackUrl.searchParams.set("hexclave_cross_domain_after_callback_redirect_url", handoffAfterCallbackRedirect);
const arrivalUrl = new URL(`https://${projectId}.built-with-stack-auth.com/handler/sign-in`);
arrivalUrl.searchParams.set("after_auth_return_to", redirectBackUrl.toString());
arrivalUrl.searchParams.set("hexclave_cross_domain_state", handoffState);
arrivalUrl.searchParams.set("hexclave_cross_domain_code_challenge", handoffCodeChallenge);
arrivalUrl.searchParams.set("hexclave_cross_domain_after_callback_redirect_url", handoffAfterCallbackRedirect);
const previousDocument = globalThis.document;
const windowMock = {
location: {
href: arrivalUrl.toString(),
},
sessionStorage: {
getItem: (key: string) => sessionStorageMap.get(key) ?? null,
setItem: (key: string, value: string) => {
sessionStorageMap.set(key, value);
},
removeItem: (key: string) => {
sessionStorageMap.delete(key);
},
},
};
Reflect.set(globalThis, "window", windowMock);
globalThis.document = createMockDocument();
try {
const clientApp = new StackClientApp({
baseUrl: "http://localhost:12345",
projectId,
publishableClientKey: "stack-pk-test",
tokenStore: "memory",
redirectMethod: "window",
noAutomaticPrefetch: true,
});
const crossDomainAuthorizeRedirect = "https://demo.example.test/handler/oauth-callback?code=minted-code&state=mirror-handoff-state";
const createCrossDomainAuthRedirectUrlSpy = vi
.spyOn(clientApp as any, "_createCrossDomainAuthRedirectUrl")
.mockResolvedValue(crossDomainAuthorizeRedirect);
// All redirect-back query params were dropped before the after-sign-in redirect.
windowMock.location.href = `https://${projectId}.built-with-stack-auth.com/handler/sign-in`;
const redirectUrl = await clientApp[hexclaveAppInternalsSymbol].getRedirectToHandlerUrl("afterSignIn");
expect(createCrossDomainAuthRedirectUrlSpy).toHaveBeenCalledWith(expect.objectContaining({
redirectUri: redirectBackUrl.toString(),
state: handoffState,
codeChallenge: handoffCodeChallenge,
afterCallbackRedirectUrl: handoffAfterCallbackRedirect,
}));
expect(redirectUrl).toBe(crossDomainAuthorizeRedirect);
} finally {
globalThis.document = previousDocument;
if (hadPreviousWindow) {
Reflect.set(globalThis, "window", previousWindow);
} else {
Reflect.deleteProperty(globalThis, "window");
}
}
});
it("ignores stale session callbacks after a newer refresh token owns the token store", async () => {
const clientApp = new StackClientApp({
baseUrl: "http://localhost:12345",

View File

@ -57,6 +57,8 @@ import { TeamPermission } from "../../permissions";
import { AdminOwnedProject, AdminProjectUpdateOptions, Project, adminProjectCreateOptionsToCrud } from "../../projects";
import { EditableTeamMemberProfile, ReceivedTeamInvitation, SentTeamInvitation, Team, TeamCreateOptions, TeamUpdateOptions, TeamUser, teamCreateOptionsToCrud, teamUpdateOptionsToCrud } from "../../teams";
import { buildCliAuthConfirmUrl, getHostedHandlerUrl, isHostedHandlerUrlForProject, resolveHandlerUrls } from "../../url-targets";
import { augmentUrlWithPersistedRedirectBackState, saveRedirectBackStateFromUrl } from "./redirect-back-state";
import { recordRedirectAndThrowIfLoopDetected } from "./redirect-loop-breaker";
import { ActiveSession, Auth, BaseUser, CurrentUser, InternalUserExtra, OAuthProvider, ProjectCurrentUser, SyncedPartialUser, TokenPartialUser, UserExtra, UserUpdateOptions, userUpdateOptionsToCrud, withUserDestructureGuard } from "../../users";
import { StackClientApp, StackClientAppConstructorOptions, StackClientAppJson } from "../interfaces/client-app";
import { _HexclaveAdminAppImplIncomplete } from "./admin-app-impl";
@ -760,6 +762,11 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
this._trackPendingAuthResolution(async () => {
await this._maybeHandleNestedCrossDomainAuth(urlAtConstructionTime);
});
// Mirror any redirect-back state from the page URL into sessionStorage, so the return trip
// survives later hops that drop the query params (OAuth provider round-trips, MFA, magic
// links, ...). See redirect-back-state.ts for details.
saveRedirectBackStateFromUrl({ url: urlAtConstructionTime, projectId: this.projectId });
}
// IF_PLATFORM js-like
@ -3011,6 +3018,17 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
}
protected async _redirectTo(options: { url: URL | string, replace?: boolean }) {
if (this._redirectMethod !== "none" && isBrowserLike()) {
const currentUrl = new URL(window.location.href);
const targetUrl = new URL(options.url, currentUrl);
// Circuit breaker: if this exact redirect keeps repeating, throw so the initiating page
// renders its error state instead of bouncing the user between pages forever.
recordRedirectAndThrowIfLoopDetected({ currentUrl, targetUrl });
// Client-side navigations don't re-run the app constructor, so mirror redirect-back state
// on every SDK-driven hop too (see redirect-back-state.ts).
saveRedirectBackStateFromUrl({ url: targetUrl, projectId: this.projectId });
}
if (this._redirectMethod === "none") {
return;
// IF_PLATFORM next
@ -3089,9 +3107,21 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
throw new Error(`No URL for handler name ${handlerName}`);
}
const currentUrl = isReactServer || typeof window === "undefined"
let currentUrl = isReactServer || typeof window === "undefined"
? null
: new URL(window.location.href);
if (
currentUrl != null
&& options?.noRedirectBack !== true
&& (handlerName === "afterSignIn" || handlerName === "afterSignUp")
) {
// If intermediate hops dropped the redirect-back query params, restore them from the
// sessionStorage mirror so the user still returns to where they came from instead of
// stranding on the default post-auth page (on hosted components, that would be the hosted
// welcome page). The restored URL goes through the same _isTrusted / cross-domain-authorize
// validation below as one read from query params.
currentUrl = augmentUrlWithPersistedRedirectBackState({ currentUrl, projectId: this.projectId });
}
const plan = await planRedirectToHandler({
handlerName,
rawHandlerUrl,
@ -4221,6 +4251,9 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
awaitPendingAuthResolutions: async () => {
await this._awaitPendingAuthResolutions();
},
isTrustedRedirectUrl: async (url: string) => {
return await this._isTrusted(url);
},
};
};

View File

@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { augmentUrlWithPersistedRedirectBackState, readRedirectBackState, saveRedirectBackStateFromUrl } from "./redirect-back-state";
function createMockSessionStorage() {
const map = new Map<string, string>();
return {
getItem: (key: string) => map.get(key) ?? null,
setItem: (key: string, value: string) => {
map.set(key, value);
},
removeItem: (key: string) => {
map.delete(key);
},
};
}
const previousWindow = Reflect.get(globalThis, "window");
const hadPreviousWindow = Reflect.has(globalThis, "window");
const projectId = "00000000-0000-4000-8000-000000000000";
describe("redirect-back state mirror", () => {
beforeEach(() => {
Reflect.set(globalThis, "window", { sessionStorage: createMockSessionStorage() });
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
if (hadPreviousWindow) {
Reflect.set(globalThis, "window", previousWindow);
} else {
Reflect.deleteProperty(globalThis, "window");
}
});
it("round-trips redirect-back state including the cross-domain handoff params", () => {
const url = new URL("https://hosted.example.test/handler/sign-in");
url.searchParams.set("after_auth_return_to", "https://app.example.test/handler/oauth-callback?hexclave_cross_domain_auth=1");
url.searchParams.set("hexclave_cross_domain_state", "the-state");
url.searchParams.set("hexclave_cross_domain_code_challenge", "the-code-challenge");
url.searchParams.set("hexclave_cross_domain_after_callback_redirect_url", "https://app.example.test/dashboard");
saveRedirectBackStateFromUrl({ url, projectId });
expect(readRedirectBackState({ projectId })).toEqual({
afterAuthReturnTo: "https://app.example.test/handler/oauth-callback?hexclave_cross_domain_auth=1",
crossDomainState: "the-state",
crossDomainCodeChallenge: "the-code-challenge",
crossDomainAfterCallbackRedirectUrl: "https://app.example.test/dashboard",
savedAtMillis: Date.now(),
});
});
it("returns null when nothing was saved and does not save URLs without after_auth_return_to", () => {
expect(readRedirectBackState({ projectId })).toBeNull();
saveRedirectBackStateFromUrl({ url: new URL("https://hosted.example.test/handler/sign-in?foo=bar"), projectId });
expect(readRedirectBackState({ projectId })).toBeNull();
});
it("expires the state after the TTL", () => {
const url = new URL("https://hosted.example.test/handler/sign-in?after_auth_return_to=/music");
saveRedirectBackStateFromUrl({ url, projectId });
vi.advanceTimersByTime(29 * 60 * 1000);
expect(readRedirectBackState({ projectId })).not.toBeNull();
vi.advanceTimersByTime(2 * 60 * 1000);
expect(readRedirectBackState({ projectId })).toBeNull();
});
it("scopes the state per project", () => {
const url = new URL("https://hosted.example.test/handler/sign-in?after_auth_return_to=/music");
saveRedirectBackStateFromUrl({ url, projectId });
expect(readRedirectBackState({ projectId: "11111111-1111-4111-8111-111111111111" })).toBeNull();
expect(readRedirectBackState({ projectId })).not.toBeNull();
});
it("discards malformed stored values", () => {
const storage = createMockSessionStorage();
storage.setItem(`hexclave-redirect-back-state-${projectId}`, "{not json");
Reflect.set(globalThis, "window", { sessionStorage: storage });
expect(readRedirectBackState({ projectId })).toBeNull();
storage.setItem(`hexclave-redirect-back-state-${projectId}`, JSON.stringify({ some: "other-shape" }));
expect(readRedirectBackState({ projectId })).toBeNull();
});
it("restores dropped params onto a URL, but never overrides an explicit after_auth_return_to", () => {
const arrivalUrl = new URL("https://hosted.example.test/handler/sign-in");
arrivalUrl.searchParams.set("after_auth_return_to", "https://app.example.test/handler/oauth-callback");
arrivalUrl.searchParams.set("hexclave_cross_domain_state", "the-state");
arrivalUrl.searchParams.set("hexclave_cross_domain_code_challenge", "the-code-challenge");
arrivalUrl.searchParams.set("hexclave_cross_domain_after_callback_redirect_url", "https://app.example.test/dashboard");
saveRedirectBackStateFromUrl({ url: arrivalUrl, projectId });
// Params were dropped along the way: restore all of them.
const strippedUrl = new URL("https://hosted.example.test/handler/mfa");
const augmented = augmentUrlWithPersistedRedirectBackState({ currentUrl: strippedUrl, projectId });
expect(augmented.searchParams.get("after_auth_return_to")).toBe("https://app.example.test/handler/oauth-callback");
expect(augmented.searchParams.get("hexclave_cross_domain_state")).toBe("the-state");
expect(augmented.searchParams.get("hexclave_cross_domain_code_challenge")).toBe("the-code-challenge");
expect(augmented.searchParams.get("hexclave_cross_domain_after_callback_redirect_url")).toBe("https://app.example.test/dashboard");
// The input URL is not mutated.
expect(strippedUrl.searchParams.has("after_auth_return_to")).toBe(false);
// Explicit query params always win over the mirror.
const explicitUrl = new URL("https://hosted.example.test/handler/sign-in?after_auth_return_to=/somewhere-else");
const unchanged = augmentUrlWithPersistedRedirectBackState({ currentUrl: explicitUrl, projectId });
expect(unchanged.toString()).toBe(explicitUrl.toString());
});
it("leaves the URL unchanged when there is no mirrored state", () => {
const url = new URL("https://hosted.example.test/handler/sign-in");
const augmented = augmentUrlWithPersistedRedirectBackState({ currentUrl: url, projectId });
expect(augmented.toString()).toBe(url.toString());
});
it("degrades to a no-op when sessionStorage is unavailable", () => {
Reflect.set(globalThis, "window", {
get sessionStorage(): Storage {
throw new Error("Storage is disabled");
},
});
const url = new URL("https://hosted.example.test/handler/sign-in?after_auth_return_to=/music");
saveRedirectBackStateFromUrl({ url, projectId });
expect(readRedirectBackState({ projectId })).toBeNull();
expect(augmentUrlWithPersistedRedirectBackState({ currentUrl: url, projectId }).toString()).toBe(url.toString());
});
});

View File

@ -0,0 +1,136 @@
import { readSessionStorageItem, removeSessionStorageItem, writeSessionStorageItem } from "../../../../utils/session-storage";
import { crossDomainAuthQueryParams } from "./redirect-page-urls";
/**
* Resilience layer for the redirect-back ("return to where you came from") state of auth flows.
*
* The primary transport for this state is query params (`after_auth_return_to` +
* `hexclave_cross_domain_*`), which must survive every hop of the auth flow (sign-in page, OAuth
* provider round-trips, MFA, magic links, ...). Historically, any hop that dropped these params
* stranded the user on the wrong page after auth on hosted components, that meant the hosted
* welcome page instead of the customer's app.
*
* This module mirrors the state into sessionStorage whenever it appears in a URL, so
* `redirectToAfterSignIn`/`redirectToAfterSignUp` can restore it if the params were dropped along
* the way. Query params always take precedence; the mirror is only a fallback. It is scoped
* per-tab (sessionStorage), per-origin (sessionStorage again), and per-project, and it expires
* after a TTL. Restored URLs still go through the exact same trust validation as URLs read from
* query params (the `_isTrusted` gate client-side and the cross-domain authorize endpoint
* server-side), so the mirror never widens the set of allowed destinations.
*/
// Deliberately NOT consumed/cleared on use: React can invoke the after-sign-in redirect twice
// (strict mode, remounts), and consuming on first read would make the second invocation fall back
// to the default post-auth page and race the correct redirect. Staleness is bounded by the TTL
// instead, and a stale entry merely returns the user to the last page that started an auth flow
// in this tab — which is also what they'd expect.
//
// The TTL must stay below the 1h lifetime of the outer PKCE verifier cookie (see
// saveVerifierAndState in cookie.ts): the mirrored cross-domain params are useless once their
// verifier cookie on the app domain has expired.
const redirectBackStateTtlMillis = 30 * 60 * 1000;
export type PersistedRedirectBackState = {
/**
* The `after_auth_return_to` value exactly as it appeared in the URL (possibly relative).
* sessionStorage is per-origin, so restoring it into a URL later always resolves it against the
* same origin it was saved from.
*/
afterAuthReturnTo: string,
crossDomainState: string | null,
crossDomainCodeChallenge: string | null,
crossDomainAfterCallbackRedirectUrl: string | null,
/** Wall-clock ms (Date.now()); must survive page loads, so performance.now() is not usable. */
savedAtMillis: number,
};
function getStorageKey(projectId: string): string {
return `hexclave-redirect-back-state-${projectId}`;
}
function isPersistedRedirectBackState(value: unknown): value is PersistedRedirectBackState {
return (
typeof value === "object"
&& value !== null
&& "afterAuthReturnTo" in value && typeof value.afterAuthReturnTo === "string"
&& "crossDomainState" in value && (value.crossDomainState === null || typeof value.crossDomainState === "string")
&& "crossDomainCodeChallenge" in value && (value.crossDomainCodeChallenge === null || typeof value.crossDomainCodeChallenge === "string")
&& "crossDomainAfterCallbackRedirectUrl" in value && (value.crossDomainAfterCallbackRedirectUrl === null || typeof value.crossDomainAfterCallbackRedirectUrl === "string")
&& "savedAtMillis" in value && typeof value.savedAtMillis === "number"
);
}
/**
* Mirrors the redirect-back state from the given URL into sessionStorage, if it has any. Called
* with the page URL at app construction time (full page loads) and with the target URL of every
* SDK-driven redirect (client-side navigations, which don't re-run the constructor).
*/
export function saveRedirectBackStateFromUrl(options: { url: URL, projectId: string }): void {
const afterAuthReturnTo = options.url.searchParams.get("after_auth_return_to");
if (afterAuthReturnTo == null) {
return;
}
const state: PersistedRedirectBackState = {
afterAuthReturnTo,
crossDomainState: options.url.searchParams.get(crossDomainAuthQueryParams.state),
crossDomainCodeChallenge: options.url.searchParams.get(crossDomainAuthQueryParams.codeChallenge),
crossDomainAfterCallbackRedirectUrl: options.url.searchParams.get(crossDomainAuthQueryParams.afterCallbackRedirectUrl),
savedAtMillis: Date.now(),
};
writeSessionStorageItem(getStorageKey(options.projectId), JSON.stringify(state));
}
export function readRedirectBackState(options: { projectId: string }): PersistedRedirectBackState | null {
const storageKey = getStorageKey(options.projectId);
const raw = readSessionStorageItem(storageKey);
if (raw == null) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
// Corrupted value (eg. written by a different SDK version); losing the fallback for this flow
// is the pre-existing behavior, so just discard it.
removeSessionStorageItem(storageKey);
return null;
}
if (!isPersistedRedirectBackState(parsed)) {
removeSessionStorageItem(storageKey);
return null;
}
const ageMillis = Date.now() - parsed.savedAtMillis;
if (ageMillis < 0 || ageMillis > redirectBackStateTtlMillis) {
removeSessionStorageItem(storageKey);
return null;
}
return parsed;
}
/**
* Returns a copy of `currentUrl` with any redirect-back query params that were dropped along the
* way restored from the sessionStorage mirror. If `currentUrl` already carries
* `after_auth_return_to`, it is returned unchanged explicit query params always win.
*/
export function augmentUrlWithPersistedRedirectBackState(options: { currentUrl: URL, projectId: string }): URL {
if (options.currentUrl.searchParams.has("after_auth_return_to")) {
return options.currentUrl;
}
const persisted = readRedirectBackState({ projectId: options.projectId });
if (persisted == null) {
return options.currentUrl;
}
const augmented = new URL(options.currentUrl.toString());
augmented.searchParams.set("after_auth_return_to", persisted.afterAuthReturnTo);
const crossDomainParamValues = [
[crossDomainAuthQueryParams.state, persisted.crossDomainState],
[crossDomainAuthQueryParams.codeChallenge, persisted.crossDomainCodeChallenge],
[crossDomainAuthQueryParams.afterCallbackRedirectUrl, persisted.crossDomainAfterCallbackRedirectUrl],
] as const;
for (const [paramName, persistedValue] of crossDomainParamValues) {
if (persistedValue != null && !augmented.searchParams.has(paramName)) {
augmented.searchParams.set(paramName, persistedValue);
}
}
return augmented;
}

View File

@ -0,0 +1,123 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { recordRedirectAndThrowIfLoopDetected } from "./redirect-loop-breaker";
function createMockSessionStorage() {
const map = new Map<string, string>();
return {
getItem: (key: string) => map.get(key) ?? null,
setItem: (key: string, value: string) => {
map.set(key, value);
},
removeItem: (key: string) => {
map.delete(key);
},
};
}
const previousWindow = Reflect.get(globalThis, "window");
const hadPreviousWindow = Reflect.has(globalThis, "window");
function record(from: string, to: string) {
recordRedirectAndThrowIfLoopDetected({
currentUrl: new URL(from),
targetUrl: new URL(to),
});
}
describe("recordRedirectAndThrowIfLoopDetected", () => {
beforeEach(() => {
Reflect.set(globalThis, "window", { sessionStorage: createMockSessionStorage() });
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
if (hadPreviousWindow) {
Reflect.set(globalThis, "window", previousWindow);
} else {
Reflect.deleteProperty(globalThis, "window");
}
});
it("allows distinct redirects in quick succession", () => {
for (let i = 0; i < 20; i++) {
record(`https://app.example.test/page-${i}`, `https://app.example.test/page-${i + 1}`);
}
});
it("throws once the same redirect repeats 5 times within the window", () => {
for (let i = 0; i < 4; i++) {
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
vi.advanceTimersByTime(1000);
}
expect(() => {
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
}).toThrowError(/Redirect loop detected/);
});
it("treats redirects that differ only in query params or hash as identical", () => {
// Real loops usually rotate nonce-style params (code, state, after_auth_return_to) each cycle.
for (let i = 0; i < 4; i++) {
record(
`https://app.example.test/dashboard?code=code-${i}`,
`https://hosted.example.test/handler/sign-in?state=state-${i}#frag-${i}`,
);
}
expect(() => {
record("https://app.example.test/dashboard?code=final", "https://hosted.example.test/handler/sign-in?state=final");
}).toThrowError(/Redirect loop detected/);
});
it("does not throw when the identical redirects are spread out beyond the window", () => {
for (let i = 0; i < 20; i++) {
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
vi.advanceTimersByTime(31_000);
}
});
it("allows the same redirect again after a loop was broken", () => {
for (let i = 0; i < 4; i++) {
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
}
expect(() => {
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
}).toThrowError(/Redirect loop detected/);
// The breadcrumbs for the broken loop are dropped, so a manual retry isn't instantly blocked.
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
});
it("counts self-redirects (same origin and path) towards loops", () => {
for (let i = 0; i < 4; i++) {
record(`https://hosted.example.test/handler/sign-in?attempt=${i}`, "https://hosted.example.test/handler/sign-in");
}
expect(() => {
record("https://hosted.example.test/handler/sign-in?attempt=final", "https://hosted.example.test/handler/sign-in");
}).toThrowError(/Redirect loop detected/);
});
it("degrades to a no-op when sessionStorage is unavailable", () => {
Reflect.set(globalThis, "window", {
get sessionStorage(): Storage {
throw new Error("Storage is disabled");
},
});
for (let i = 0; i < 20; i++) {
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
}
});
it("recovers from corrupted breadcrumb storage", () => {
const storage = createMockSessionStorage();
storage.setItem("hexclave-redirect-loop-breadcrumbs", "{not json");
Reflect.set(globalThis, "window", { sessionStorage: storage });
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
// After recovery, loop detection works again.
for (let i = 0; i < 3; i++) {
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
}
expect(() => {
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
}).toThrowError(/Redirect loop detected/);
});
});

View File

@ -0,0 +1,113 @@
import { HexclaveAssertionError, captureError } from "@hexclave/shared/dist/utils/errors";
import { readSessionStorageItem, removeSessionStorageItem, writeSessionStorageItem } from "../../../../utils/session-storage";
/**
* Last line of defense against redirect loops (most importantly in hosted components flows, where
* redirects bounce between the app domain and the hosted domain and a bug anywhere in the chain
* can bounce the user forever).
*
* Every SDK-driven redirect records a breadcrumb, and when the exact same redirect keeps repeating
* within a short window, the redirect throws instead of navigating. The page that initiated the
* redirect then renders its error state (eg. the auth page's error card), which both stops the
* loop and surfaces the bug loudly.
*/
// sessionStorage (not module state) because most redirect loops involve full page loads, so each
// loop iteration runs in a fresh JS context. sessionStorage is also per-tab, which is exactly the
// scope of a redirect loop.
const breadcrumbsStorageKey = "hexclave-redirect-loop-breadcrumbs";
// A redirect loop cycles within a few seconds per iteration even with full cross-domain page
// loads, so a real loop reaches 5 identical redirects well within 30 seconds. A human bouncing
// between pages (eg. toggling sign-in <-> sign-up) produces the same (from, to) pair far less
// often, which keeps false positives implausible.
const loopWindowMillis = 30_000;
const maxIdenticalRedirectsPerWindow = 5;
const maxStoredBreadcrumbs = 50;
type RedirectBreadcrumb = {
from: string,
to: string,
/**
* Wall-clock milliseconds (Date.now()). Although this is used to measure elapsed time (where we
* would normally use performance.now()), breadcrumbs must be comparable across full page loads,
* and performance.now() restarts at zero on every navigation.
*/
at: number,
};
function isRedirectBreadcrumb(value: unknown): value is RedirectBreadcrumb {
return (
typeof value === "object"
&& value !== null
&& "from" in value && typeof value.from === "string"
&& "to" in value && typeof value.to === "string"
&& "at" in value && typeof value.at === "number"
);
}
function readBreadcrumbs(): RedirectBreadcrumb[] {
const raw = readSessionStorageItem(breadcrumbsStorageKey);
if (raw == null) {
return [];
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
// A corrupted value (eg. written by an older SDK version) just means we lose loop protection
// for this one redirect; start over with a fresh list.
removeSessionStorageItem(breadcrumbsStorageKey);
return [];
}
if (!Array.isArray(parsed)) {
removeSessionStorageItem(breadcrumbsStorageKey);
return [];
}
return parsed.filter(isRedirectBreadcrumb);
}
function writeBreadcrumbs(breadcrumbs: RedirectBreadcrumb[]): void {
writeSessionStorageItem(breadcrumbsStorageKey, JSON.stringify(breadcrumbs));
}
/**
* Loop iterations usually differ only in nonce-style query params (`code`, `state`,
* `after_auth_return_to`, ...), so redirects are compared by origin + pathname only.
*/
function getComparableRedirectLocation(url: URL): string {
return `${url.origin}${url.pathname}`;
}
export function recordRedirectAndThrowIfLoopDetected(options: { currentUrl: URL, targetUrl: URL }): void {
const from = getComparableRedirectLocation(options.currentUrl);
const to = getComparableRedirectLocation(options.targetUrl);
const now = Date.now();
// Clock jumps backwards (eg. NTP sync) would make breadcrumbs appear to be from the future;
// treat those as expired rather than counting them towards a loop.
const recentBreadcrumbs = readBreadcrumbs().filter((b) => now - b.at >= 0 && now - b.at < loopWindowMillis);
const identicalRedirectCount = recentBreadcrumbs.filter((b) => b.from === from && b.to === to).length + 1;
if (identicalRedirectCount >= maxIdenticalRedirectsPerWindow) {
// Drop the matching breadcrumbs so a manual retry (eg. the user reloading and clicking sign-in
// again) isn't instantly blocked by the history of the loop we just broke.
writeBreadcrumbs(recentBreadcrumbs.filter((b) => !(b.from === from && b.to === to)));
const error = new HexclaveAssertionError(
`Redirect loop detected: the redirect ${from} -> ${to} was attempted ${identicalRedirectCount} times within ${loopWindowMillis / 1000}s, so it was blocked. This is a bug in the auth flow; the recent redirect chain is included in the error details.`,
{
from,
to,
currentUrl: options.currentUrl.toString(),
targetUrl: options.targetUrl.toString(),
recentRedirects: recentBreadcrumbs.map((b) => `${b.from} -> ${b.to}`),
},
);
// Also captured (not just thrown) so the loop is reported even if a caller maps the thrown
// error to UI without reporting it.
captureError("redirect-loop-detected", error);
throw error;
}
writeBreadcrumbs([...recentBreadcrumbs, { from, to, at: now }].slice(-maxStoredBreadcrumbs));
}

View File

@ -138,6 +138,7 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
redirectToHandler(handlerName: keyof HandlerUrls, options?: RedirectToOptions): Promise<void>,
signInWithTokens(tokens: { accessToken: string, refreshToken: string }): Promise<void>,
awaitPendingAuthResolutions(): Promise<void>,
isTrustedRedirectUrl(url: string): Promise<boolean>,
},
}
& AsyncStoreProperty<"project", [], Project, false>

View File

@ -0,0 +1,41 @@
/**
* Guarded sessionStorage access for resilience layers (redirect loop breaking, redirect-back state
* mirroring). These layers must degrade to "no stored value" when storage is unavailable, instead
* of breaking the auth flow they are trying to protect.
*/
export function readSessionStorageItem(key: string): string | null {
if (typeof window === "undefined") {
return null;
}
try {
return window.sessionStorage.getItem(key);
} catch {
// Storage can be blocked entirely (private browsing, sandboxed iframes, disabled cookies), in
// which case accessing window.sessionStorage throws. Treat that as "nothing stored".
return null;
}
}
export function writeSessionStorageItem(key: string, value: string): void {
if (typeof window === "undefined") {
return;
}
try {
window.sessionStorage.setItem(key, value);
} catch {
// Storage can be blocked or full; see readSessionStorageItem. Values written here are only
// used as a fallback, so failing to persist must not fail the caller.
}
}
export function removeSessionStorageItem(key: string): void {
if (typeof window === "undefined") {
return;
}
try {
window.sessionStorage.removeItem(key);
} catch {
// See readSessionStorageItem.
}
}

View File

@ -1020,10 +1020,54 @@ Implementation:
3. For afterSignIn/afterSignUp:
- Check current URL for after_auth_return_to query param
- If present: redirect to that URL instead of the default
- If absent (unless noRedirectBack=true): fall back to the sessionStorage mirror of the
redirect-back state (see "Redirect-back state mirror" below) before using the default
4. Perform redirect based on redirectMethod config:
- "browser": window.location.assign() or .replace()
- "nextjs": Next.js redirect() function [JS-ONLY]
- "none": don't redirect (for headless/API use)
- Custom navigate function: call it with the URL
Do not error.
Do not error, except when the redirect loop breaker triggers (see below).
### Redirect-back state mirror [BROWSER-ONLY]
The `after_auth_return_to` and cross-domain handoff query params must survive every hop of an
auth flow (OAuth provider round-trips, MFA, magic links, ...). As a resilience layer, the SDK
mirrors them into sessionStorage so a hop that drops them doesn't strand the user on the default
post-auth page:
1. On app construction (browser only) and on every SDK-driven redirect, if the (target) URL
contains after_auth_return_to, store it together with the hexclave_cross_domain_state,
hexclave_cross_domain_code_challenge, and hexclave_cross_domain_after_callback_redirect_url
params (if present) in sessionStorage, keyed by project ID.
2. When redirectToAfterSignIn/redirectToAfterSignUp run without an after_auth_return_to query
param, restore the mirrored params (query params always take precedence over the mirror).
3. The mirror expires after 30 minutes (must stay below the 1h lifetime of the outer PKCE
verifier cookie) and is NOT consumed on read (post-auth redirects may legitimately run more
than once, eg. React re-renders).
4. Restored URLs go through the exact same trust validation as URLs read from query params, so
the mirror never widens the set of allowed destinations.
5. If sessionStorage is unavailable (private browsing, sandboxed iframes), degrade silently to
the previous behavior (no mirror).
### Redirect loop breaker [BROWSER-ONLY]
As a last line of defense against redirect loops (particularly in hosted components flows), every
SDK-driven redirect records a breadcrumb (origin + pathname of the current and target URL; query
params are ignored because loops usually rotate nonce-style params each cycle) in sessionStorage:
1. Before navigating, count how often the exact same (from, to) redirect occurred within the last
30 seconds (wall clock; must survive full page loads).
2. If this is the 5th identical redirect in that window, do NOT navigate: drop the matching
breadcrumbs (so a manual retry isn't instantly blocked), report the error, and throw. The page
that initiated the redirect renders its error state, which stops the loop and surfaces the bug.
3. If sessionStorage is unavailable, degrade silently (no loop breaking).
### Sign-out redirect validation
The sign-out page follows the after_auth_return_to query param only if it passes the same trust
validation as every other redirect (relative URLs, same-origin URLs, the project's hosted
components origin, and the project's trusted domains). Untrusted values are ignored (the error is
reported, and sign-out proceeds with the default after-sign-out destination); they must never be
followed, since the query param is attacker-craftable (open redirect).