mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Hosted component hacks
This commit is contained in:
parent
09e5a97d77
commit
31dc84075a
186
packages/template/src/components-page/sign-out.test.tsx
Normal file
186
packages/template/src/components-page/sign-out.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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} />;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -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());
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
@ -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/);
|
||||
});
|
||||
});
|
||||
@ -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));
|
||||
}
|
||||
@ -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>
|
||||
|
||||
41
packages/template/src/utils/session-storage.ts
Normal file
41
packages/template/src/utils/session-storage.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
@ -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).
|
||||
|
||||
Loading…
Reference in New Issue
Block a user