From 3c60b6923eb7e4c3b89d9d760e2467ca735824de Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 10 Jun 2026 16:53:39 -0700 Subject: [PATCH] fix(sdk): stop nested cross-domain auth from re-bouncing after the OAuth callback consumed code+state On the code-return hop of the nested cross-domain handshake, the constructor schedules callOAuthCallback before _maybeHandleNestedCrossDomainAuth. The former synchronously strips code+state from the URL (history.replaceState) before starting its token exchange, so the latter's 'a real OAuth callback wins' guard read an already-stripped URL, decided no callback was happening, and bounced back to the source domain with fresh handoff params - cancelling the in-flight exchange and restarting the whole redirect chain. Users saw 5-8+ redirects ping-ponging between their app and the hosted components site before the race happened to resolve. Capture the URL at construction time and let the nested handler consult it in addition to the live URL, so a stripped callback still counts as a callback. --- .../client-app-impl.cross-domain.test.ts | 96 +++++++++++++++++++ .../apps/implementations/client-app-impl.ts | 12 ++- 2 files changed, 106 insertions(+), 2 deletions(-) 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..294dd559f 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,102 @@ 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"); + + globalThis.document = createMockDocument(); + globalThis.window = { + location: { + href: strippedUrl.toString(), + replace: () => { + throw new Error("INTENTIONAL_TEST_ABORT"); + }, + }, + } as any; + + const clientApp = new StackClientApp({ + baseUrl: "http://localhost:12345", + projectId, + publishableClientKey: "stack-pk-test", + tokenStore: "memory", + redirectMethod: "window", + noAutomaticPrefetch: true, + }); + 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); + } 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;