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 86566be44..57fb05809 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 @@ -325,6 +325,108 @@ describe("StackClientApp cross-domain auth", () => { expect(refreshedRawRefreshTokens).toEqual(["new-refresh-token"]); }); + it("does not re-bounce nested cross-domain auth after the OAuth callback consumed code+state from the URL", async () => { + const projectId = "00000000-0000-4000-8000-000000000008"; + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + + const strippedUrl = new URL(`https://${projectId}.example-stack-hosted.test/handler/sign-in`); + strippedUrl.searchParams.set("stack_nested_cross_domain_auth_refresh_token_id", "source-refresh-token-id"); + strippedUrl.searchParams.set("stack_nested_cross_domain_auth_callback_url", "https://demo.stack-auth.com/"); + const urlAtConstructionTime = new URL(strippedUrl); + urlAtConstructionTime.searchParams.set("code", "one-time-code"); + urlAtConstructionTime.searchParams.set("state", "nested-oauth-state"); + + // Construct before installing the window mock so the constructor does not schedule its own + // nested-auth resolution; the assertions below drive the handler explicitly. + const clientApp = new StackClientApp({ + baseUrl: "http://localhost:12345", + projectId, + publishableClientKey: "stack-pk-test", + tokenStore: "memory", + redirectMethod: "window", + noAutomaticPrefetch: true, + }); + + globalThis.document = createMockDocument(); + globalThis.window = { + location: { + href: strippedUrl.toString(), + replace: () => { + throw new Error("INTENTIONAL_TEST_ABORT"); + }, + }, + } as any; + + vi.spyOn(clientApp as any, "_fetchCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(null); + vi.spyOn(clientApp as any, "_getCrossDomainHandoffParamsForRedirect").mockResolvedValue({ + state: "fresh-nested-state", + codeChallenge: "fresh-nested-code-challenge", + }); + vi.spyOn(clientApp as any, "_isTrusted").mockResolvedValue(true); + + try { + // Without the construction-time URL, the handler re-bounces (location.replace aborts). + await expect((clientApp as any)._maybeHandleNestedCrossDomainAuth()).rejects.toThrowError("INTENTIONAL_TEST_ABORT"); + // With it, the in-flight OAuth callback wins and the handler stands down. + await expect((clientApp as any)._maybeHandleNestedCrossDomainAuth(urlAtConstructionTime)).resolves.toBe(false); + // The live-URL guard must also stand down on its own when code+state are still present. + (globalThis.window as any).location.href = urlAtConstructionTime.toString(); + await expect((clientApp as any)._maybeHandleNestedCrossDomainAuth()).resolves.toBe(false); + } finally { + globalThis.window = previousWindow; + globalThis.document = previousDocument; + } + }); + + it("passes the construction-time URL to the nested cross-domain auth handler", async () => { + const projectId = "00000000-0000-4000-8000-000000000009"; + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + + const callbackUrl = new URL(`https://${projectId}.example-stack-hosted.test/handler/sign-in`); + callbackUrl.searchParams.set("stack_nested_cross_domain_auth_refresh_token_id", "source-refresh-token-id"); + callbackUrl.searchParams.set("code", "one-time-code"); + callbackUrl.searchParams.set("state", "nested-oauth-state"); + const strippedUrl = new URL(callbackUrl); + strippedUrl.searchParams.delete("code"); + strippedUrl.searchParams.delete("state"); + + globalThis.document = createMockDocument(); + globalThis.window = { + location: { + href: callbackUrl.toString(), + }, + } as any; + + const nestedAuthSpy = vi.spyOn(StackClientApp.prototype as any, "_maybeHandleNestedCrossDomainAuth").mockResolvedValue(false); + + try { + new StackClientApp({ + baseUrl: "http://localhost:12345", + projectId, + publishableClientKey: "stack-pk-test", + tokenStore: "memory", + redirectMethod: "window", + noAutomaticPrefetch: true, + }); + + // Simulate consumeOAuthCallbackQueryParams stripping code+state before microtasks run. + (globalThis.window as any).location.href = strippedUrl.toString(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(nestedAuthSpy).toHaveBeenCalledTimes(1); + const urlArgument = nestedAuthSpy.mock.calls[0][0] as URL; + expect(urlArgument).toBeInstanceOf(URL); + expect(urlArgument.searchParams.get("code")).toBe("one-time-code"); + expect(urlArgument.searchParams.get("state")).toBe("nested-oauth-state"); + } finally { + nestedAuthSpy.mockRestore(); + globalThis.window = previousWindow; + globalThis.document = previousDocument; + } + }); + 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", 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 b26d68011..2154336f4 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 @@ -718,8 +718,12 @@ export class _HexclaveClientAppImplIncomplete { - await this._maybeHandleNestedCrossDomainAuth(); + await this._maybeHandleNestedCrossDomainAuth(urlAtConstructionTime); }); } @@ -890,11 +894,15 @@ export class _HexclaveClientAppImplIncomplete { + protected async _maybeHandleNestedCrossDomainAuth(urlAtConstructionTime?: URL): Promise { if (typeof window === "undefined") return false; const currentUrl = new URL(window.location.href); // A real OAuth callback wins over nested handoff detection on the final return to b.com. + // The OAuth callback resolution strips `code` and `state` from the live URL before this + // runs, so the check must also consult the URL captured at construction time — otherwise + // we'd re-bounce to the source domain while the token exchange is still in flight. if (currentUrl.searchParams.has("code") && currentUrl.searchParams.has("state")) return false; + if (urlAtConstructionTime != null && urlAtConstructionTime.searchParams.has("code") && urlAtConstructionTime.searchParams.has("state")) return false; const refreshTokenId = currentUrl.searchParams.get(nestedCrossDomainAuthQueryParams.refreshTokenId); if (refreshTokenId == null) return false;