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.
This commit is contained in:
Bilal Godil 2026-06-10 16:53:39 -07:00
parent 4479758a68
commit 3c60b6923e
2 changed files with 106 additions and 2 deletions

View File

@ -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",

View File

@ -718,8 +718,12 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
}
if (isBrowserLike()) {
// The OAuth callback resolution scheduled above synchronously strips `code` and `state`
// from the URL before its token exchange, so the nested handler must decide based on the
// URL the page was loaded with, not whatever is in the address bar when it runs.
const urlAtConstructionTime = new URL(window.location.href);
this._trackPendingAuthResolution(async () => {
await this._maybeHandleNestedCrossDomainAuth();
await this._maybeHandleNestedCrossDomainAuth(urlAtConstructionTime);
});
}
@ -890,11 +894,15 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
return targetUrl.toString();
}
protected async _maybeHandleNestedCrossDomainAuth(): Promise<boolean> {
protected async _maybeHandleNestedCrossDomainAuth(urlAtConstructionTime?: URL): Promise<boolean> {
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;