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 567fbf8ad..2186c4760 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 @@ -166,20 +166,35 @@ describe("StackClientApp cross-domain auth", () => { expect(redirectUri.searchParams.get("hexclave_cross_domain_after_callback_redirect_url")).toBe("https://demo.stack-auth.com/"); }); - it("routes hosted sign-out back through the source-domain sign-out handler", async () => { + it("clears a stale target-domain session before deferring to the source-domain session", async () => { + const projectId = "00000000-0000-4000-8000-000000000006"; + const hostedAccessToken = createAccessTokenString("hosted-old-refresh-token-id"); const clientApp = new StackClientApp({ baseUrl: "http://localhost:12345", - projectId: "00000000-0000-4000-8000-000000000003", + projectId, publishableClientKey: "stack-pk-test", tokenStore: "memory", redirectMethod: "window", urls: { - handler: "/handler", - signOut: "https://hosted.example.test/handler/sign-out", + default: { type: "hosted" }, }, noAutomaticPrefetch: true, }); - const currentHref = "https://demo.stack-auth.com/signed-in-page?foo=bar"; + const tokenStore = Reflect.get(clientApp, "_memoryTokenStore"); + if (!(tokenStore instanceof Store)) { + throw new Error("Expected StackClientApp to use a memory token store in this test."); + } + tokenStore.set({ + refreshToken: "hosted-old-refresh-token", + accessToken: hostedAccessToken, + }); + + const currentUrl = new URL(`https://${projectId}.example-stack-hosted.test/handler/sign-in`); + currentUrl.searchParams.set("stack_nested_cross_domain_auth_refresh_token_id", "source-anonymous-refresh-token-id"); + currentUrl.searchParams.set("stack_nested_cross_domain_auth_callback_url", "https://demo.stack-auth.com/handler/oauth-callback"); + currentUrl.searchParams.set("hexclave_cross_domain_state", "outer-state"); + currentUrl.searchParams.set("hexclave_cross_domain_code_challenge", "outer-code-challenge"); + currentUrl.searchParams.set("hexclave_cross_domain_after_callback_redirect_url", "https://demo.stack-auth.com/app"); const previousWindow = globalThis.window; const previousDocument = globalThis.document; @@ -189,8 +204,8 @@ describe("StackClientApp cross-domain auth", () => { globalThis.document = createMockDocument(); globalThis.window = { location: { - href: currentHref, - assign: (url: string) => { + href: currentUrl.toString(), + replace: (url: string) => { redirectedUrl = url; throw new Error("INTENTIONAL_TEST_ABORT"); }, @@ -198,19 +213,40 @@ describe("StackClientApp cross-domain auth", () => { } as any; try { - await expect(clientApp.redirectToSignOut()).rejects.toThrowError("INTENTIONAL_TEST_ABORT"); + await expect((clientApp as any)._maybeHandleNestedCrossDomainAuth()).rejects.toThrowError("INTENTIONAL_TEST_ABORT"); } finally { globalThis.window = previousWindow; globalThis.document = previousDocument; } - const hostedSignOutUrl = new URL(redirectedUrl); - expect(hostedSignOutUrl.origin).toBe("https://hosted.example.test"); - expect(hostedSignOutUrl.pathname).toBe("/handler/sign-out"); - const sourceSignOutUrl = new URL(hostedSignOutUrl.searchParams.get("after_auth_return_to") ?? ""); - expect(sourceSignOutUrl.origin).toBe("https://demo.stack-auth.com"); - expect(sourceSignOutUrl.pathname).toBe("/handler/sign-out"); - expect(sourceSignOutUrl.searchParams.get("after_auth_return_to")).toBe(currentHref); + expect(tokenStore.get()).toEqual({ + refreshToken: null, + accessToken: null, + }); + expect(new URL(redirectedUrl).origin).toBe("https://demo.stack-auth.com"); + }); + + it("uses direct sign-out instead of hosted sign-out redirects when code execution is available", async () => { + const clientApp = new StackClientApp({ + baseUrl: "http://localhost:12345", + projectId: "00000000-0000-4000-8000-000000000003", + publishableClientKey: "stack-pk-test", + tokenStore: "memory", + redirectMethod: "window", + urls: { + handler: "/handler", + signOut: { type: "hosted" }, + }, + noAutomaticPrefetch: true, + }); + const signOutSpy = vi.spyOn(clientApp, "signOut").mockRejectedValue(new Error("INTENTIONAL_TEST_ABORT")); + + try { + await expect(clientApp.redirectToSignOut()).rejects.toThrowError("INTENTIONAL_TEST_ABORT"); + expect(signOutSpy).toHaveBeenCalledWith(); + } finally { + signOutSpy.mockRestore(); + } }); it("keeps default hosted signOut() on the source domain when afterSignOut is not configured", async () => { 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 2bb43f937..72e66e0f2 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 @@ -946,6 +946,10 @@ export class _HexclaveClientAppImplIncomplete await this._getCrossDomainHandoffParamsForRedirect(href), }); @@ -3022,7 +3014,13 @@ export class _HexclaveClientAppImplIncomplete Promise, }): Promise { const initial = buildRedirectBackAwareHandlerUrl({ @@ -186,7 +182,6 @@ async function resolveRedirectBackAwareHandlerUrlForRedirect(options: { currentUrl: options.currentUrl, crossDomainHandoffParams: null, localOAuthCallbackUrl: options.localOAuthCallbackUrl, - localSignOutHandlerUrl: options.localSignOutHandlerUrl, }); if (options.handlerName === "signOut") { return initial; @@ -205,7 +200,6 @@ async function resolveRedirectBackAwareHandlerUrlForRedirect(options: { currentUrl: options.currentUrl, crossDomainHandoffParams, localOAuthCallbackUrl: options.localOAuthCallbackUrl, - localSignOutHandlerUrl: options.localSignOutHandlerUrl, }); } @@ -215,7 +209,6 @@ export async function planRedirectToHandler(options: { noRedirectBack: boolean, currentUrl: URL | null, localOAuthCallbackUrl: string, - localSignOutHandlerUrl: string, getCrossDomainHandoffParams: (currentUrl: URL) => Promise, }): Promise { if (options.noRedirectBack || options.currentUrl == null) { @@ -277,7 +270,6 @@ export async function planRedirectToHandler(options: { rawHandlerUrl: options.rawHandlerUrl, currentUrl: options.currentUrl, localOAuthCallbackUrl: options.localOAuthCallbackUrl, - localSignOutHandlerUrl: options.localSignOutHandlerUrl, getCrossDomainHandoffParams: options.getCrossDomainHandoffParams, }), }; diff --git a/packages/template/src/lib/hexclave-app/url-targets.test.ts b/packages/template/src/lib/hexclave-app/url-targets.test.ts index 8a116cfea..219ea21e8 100644 --- a/packages/template/src/lib/hexclave-app/url-targets.test.ts +++ b/packages/template/src/lib/hexclave-app/url-targets.test.ts @@ -96,6 +96,23 @@ describe("handler URL targets", () => { expect(urls.cliAuthConfirm).toBe("https://project-id.example-stack-hosted.test/handler/cli-auth-confirm"); }); + it("keeps redirect-only post-auth targets local even when the default target is hosted", () => { + vi.stubEnv("NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX", ".example-stack-hosted.test"); + + const urls = resolveHandlerUrls({ + projectId: "project-id", + urls: { + default: { type: "hosted" }, + }, + }); + + expect(urls.signIn).toBe("https://project-id.example-stack-hosted.test/handler/sign-in"); + expect(urls.signOut).toBe("https://project-id.example-stack-hosted.test/handler/sign-out"); + expect(urls.afterSignIn).toBe("/"); + expect(urls.afterSignUp).toBe("/"); + expect(urls.afterSignOut).toBe("/"); + }); + it("rejects absolute OAuth callback string targets", () => { expect(() => resolveHandlerUrls({ projectId: "project-id", diff --git a/packages/template/src/lib/hexclave-app/url-targets.ts b/packages/template/src/lib/hexclave-app/url-targets.ts index 3c38791aa..4d197a6a2 100644 --- a/packages/template/src/lib/hexclave-app/url-targets.ts +++ b/packages/template/src/lib/hexclave-app/url-targets.ts @@ -120,6 +120,12 @@ const isRelativeUrlString = (url: string): boolean => { return !schemePrefixRegex.test(url); }; +const nonHostedHandlerNames = new Set([ + "afterSignIn", + "afterSignUp", + "afterSignOut", +]); + export const isLocalHandlerUrlTarget = (options: { targetUrl: string, handlerPath: string, @@ -155,6 +161,9 @@ const resolveUrlTarget = (options: { return options.fallbackPath; } case "hosted": { + if (nonHostedHandlerNames.has(options.handlerName)) { + return options.fallbackPath; + } return getHostedHandlerUrl({ projectId: options.projectId, pagePath: getHostedPagePathForHandlerName(options.handlerName), @@ -202,15 +211,24 @@ export const resolveHandlerUrls = (options: { urls: HandlerUrlOptions | undefine }); } + const homeTarget = configuredUrls?.home ?? defaultTarget; + const localHome = resolveUrlTarget({ + target: typeof homeTarget !== "string" && homeTarget.type === "hosted" + ? { type: "handler-component" } + : homeTarget, + fallbackPath: "/", + handlerName: "home", + projectId: options.projectId, + }); const home = resolveUrlTarget({ - target: configuredUrls?.home ?? defaultTarget, + target: homeTarget, fallbackPath: "/", handlerName: "home", projectId: options.projectId, }); const afterSignIn = resolveUrlTarget({ target: configuredUrls?.afterSignIn ?? defaultTarget, - fallbackPath: home, + fallbackPath: localHome, handlerName: "afterSignIn", projectId: options.projectId, }); @@ -249,7 +267,7 @@ export const resolveHandlerUrls = (options: { urls: HandlerUrlOptions | undefine }), afterSignOut: resolveUrlTarget({ target: configuredUrls?.afterSignOut ?? defaultTarget, - fallbackPath: home, + fallbackPath: localHome, handlerName: "afterSignOut", projectId: options.projectId, }),