From 31dc84075ab147e457173e442077b4d14c036937 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 10 Jul 2026 10:45:22 -0700 Subject: [PATCH] Hosted component hacks --- .../src/components-page/sign-out.test.tsx | 186 ++++++++++++++++++ .../template/src/components-page/sign-out.tsx | 25 ++- .../client-app-impl.cross-domain.test.ts | 127 ++++++++++++ .../apps/implementations/client-app-impl.ts | 35 +++- .../redirect-back-state.test.ts | 130 ++++++++++++ .../implementations/redirect-back-state.ts | 136 +++++++++++++ .../redirect-loop-breaker.test.ts | 123 ++++++++++++ .../implementations/redirect-loop-breaker.ts | 113 +++++++++++ .../apps/interfaces/client-app.ts | 1 + .../template/src/utils/session-storage.ts | 41 ++++ sdks/spec/src/apps/client-app.spec.md | 46 ++++- 11 files changed, 958 insertions(+), 5 deletions(-) create mode 100644 packages/template/src/components-page/sign-out.test.tsx create mode 100644 packages/template/src/lib/hexclave-app/apps/implementations/redirect-back-state.test.ts create mode 100644 packages/template/src/lib/hexclave-app/apps/implementations/redirect-back-state.ts create mode 100644 packages/template/src/lib/hexclave-app/apps/implementations/redirect-loop-breaker.test.ts create mode 100644 packages/template/src/lib/hexclave-app/apps/implementations/redirect-loop-breaker.ts create mode 100644 packages/template/src/utils/session-storage.ts diff --git a/packages/template/src/components-page/sign-out.test.tsx b/packages/template/src/components-page/sign-out.test.tsx new file mode 100644 index 000000000..fc7fd624a --- /dev/null +++ b/packages/template/src/components-page/sign-out.test.tsx @@ -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(); + type PromiseState = { status: "pending" } | { status: "ok", value: unknown } | { status: "error", error: unknown }; + const promiseStates = new WeakMap, PromiseState>(); + return { + ...original, + use: (promise: Promise): 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 }) => ( + + ), + Typography: (props: { children: React.ReactNode }) =>
{props.children}
, + 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; +} + +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 }; +} + +// 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, + user: unknown, + searchParams?: Record, +}) { + appMockState.app = options.app; + appMockState.user = options.user; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => { + root?.render( + + }> + + + + ); + }); + 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"); + }); +}); diff --git a/packages/template/src/components-page/sign-out.tsx b/packages/template/src/components-page/sign-out.tsx index 4d578e135..067bcf6f1 100644 --- a/packages/template/src/components-page/sign-out.tsx +++ b/packages/template/src/components-page/sign-out.tsx @@ -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, redirectUrl: string | undefined): Promise => { + 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 }) { + 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 ; diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts b/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts index ff31d2821..5a51c0214 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts @@ -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(); + 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(); + + 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", diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts index b7f1b5da8..c3ca80d6c 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts @@ -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 { 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 { await this._awaitPendingAuthResolutions(); }, + isTrustedRedirectUrl: async (url: string) => { + return await this._isTrusted(url); + }, }; }; diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/redirect-back-state.test.ts b/packages/template/src/lib/hexclave-app/apps/implementations/redirect-back-state.test.ts new file mode 100644 index 000000000..fcb308f40 --- /dev/null +++ b/packages/template/src/lib/hexclave-app/apps/implementations/redirect-back-state.test.ts @@ -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(); + 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()); + }); +}); diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/redirect-back-state.ts b/packages/template/src/lib/hexclave-app/apps/implementations/redirect-back-state.ts new file mode 100644 index 000000000..51bde0262 --- /dev/null +++ b/packages/template/src/lib/hexclave-app/apps/implementations/redirect-back-state.ts @@ -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; +} diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/redirect-loop-breaker.test.ts b/packages/template/src/lib/hexclave-app/apps/implementations/redirect-loop-breaker.test.ts new file mode 100644 index 000000000..ef062a315 --- /dev/null +++ b/packages/template/src/lib/hexclave-app/apps/implementations/redirect-loop-breaker.test.ts @@ -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(); + 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/); + }); +}); diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/redirect-loop-breaker.ts b/packages/template/src/lib/hexclave-app/apps/implementations/redirect-loop-breaker.ts new file mode 100644 index 000000000..ba247284e --- /dev/null +++ b/packages/template/src/lib/hexclave-app/apps/implementations/redirect-loop-breaker.ts @@ -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)); +} diff --git a/packages/template/src/lib/hexclave-app/apps/interfaces/client-app.ts b/packages/template/src/lib/hexclave-app/apps/interfaces/client-app.ts index a47b61315..bb6324277 100644 --- a/packages/template/src/lib/hexclave-app/apps/interfaces/client-app.ts +++ b/packages/template/src/lib/hexclave-app/apps/interfaces/client-app.ts @@ -138,6 +138,7 @@ export type StackClientApp, signInWithTokens(tokens: { accessToken: string, refreshToken: string }): Promise, awaitPendingAuthResolutions(): Promise, + isTrustedRedirectUrl(url: string): Promise, }, } & AsyncStoreProperty<"project", [], Project, false> diff --git a/packages/template/src/utils/session-storage.ts b/packages/template/src/utils/session-storage.ts new file mode 100644 index 000000000..f8b9a8f45 --- /dev/null +++ b/packages/template/src/utils/session-storage.ts @@ -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. + } +} diff --git a/sdks/spec/src/apps/client-app.spec.md b/sdks/spec/src/apps/client-app.spec.md index 4328b9568..30f3899f7 100644 --- a/sdks/spec/src/apps/client-app.spec.md +++ b/sdks/spec/src/apps/client-app.spec.md @@ -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).