fix(sdk): stop nested cross-domain auth from restarting the redirect chain on the hosted domain (#1581)

## What users see

Setting up a new project with hosted components, clicking **Sign in**
sometimes throws the browser into a redirect ping-pong between the app
and the hosted components site — anywhere from 5 to 9+ cross-domain
redirects — before the sign-in page finally renders. Reproduced live
against production:

![redirect loop
demo](https://gist.githubusercontent.com/BilalG1/888feed849ef0bc1f73c4609bfd71662/raw/redirect-loop.gif)

Captured redirect chain from that recording (one line per navigation, ~1
per second):

```
localhost:3000/                                      ← click "Sign in"
HOSTED  /handler/sign-in?...nested_refresh_token_id  ← start session handoff
localhost:3000/?redirect_uri=...&state=S1            ← bounce: "prove the session"
HOSTED  /handler/sign-in?...&code=...                ← code delivered... then RESTART ↩
localhost:3000/?redirect_uri=...&state=S2            ← bounce again (fresh state!)
HOSTED  /handler/sign-in?...&code=...                ← code delivered... RESTART ↩
localhost:3000/?redirect_uri=...&state=S3            ← again
HOSTED  /handler/sign-in?...&code=...                ← again
localhost:3000/?redirect_uri=...&state=S4            ← again
HOSTED  /handler/sign-in?...&code=...                ← exchange finally wins the race
HOSTED  /handler/sign-in (clean URL)                 ← sign-in form renders
```

The designed handshake is only 3 cross-domain redirects. Everything past
that is one bug restarting the chain over and over.

## The bug

When a page on the hosted domain loads with a one-time `code`, the
`StackClientApp` constructor schedules **two** async startup flows
back-to-back:

1. `callOAuthCallback` — which **synchronously strips `code` + `state`
from the URL** (`history.replaceState`) before starting its network
token exchange, and
2. `_maybeHandleNestedCrossDomainAuth` — which has a guard for exactly
this situation ("a real OAuth callback wins"), implemented as *"is
`code`+`state` in the URL?"*

Flow 1 runs first. By the time flow 2 reads `window.location`, the
params it's guarding on are already gone — so it concludes no OAuth
callback is happening, sees the (un-stripped) nested handoff marker, and
bounces back to the app domain to request a *new* code, cancelling the
in-flight exchange:

```mermaid
sequenceDiagram
    participant A as Your app (a.com)
    participant B as Hosted sign-in (b.com)
    A->>B: 1. go to sign-in ("I have session X")
    B->>A: 2. "prove it" (state, code_challenge)
    A->>B: 3. one-time code for session X
    Note over B: callOAuthCallback strips code+state from URL,<br/>starts token exchange (network)
    Note over B: nested handler runs next, checks URL for code+state…<br/>already gone → guard defeated 
    B->>A: 2'. "prove it" AGAIN (fresh state) — exchange cancelled
    A->>B: 3'. another one-time code
    Note over B: …loop repeats until the exchange happens to<br/>finish before the re-bounce navigation commits
```

Whether each cycle escapes is a coin flip between two competing
navigations (the exchange's success redirect vs. the handler's
re-bounce), which is why the loop count varies run to run and the issue
reproduces so inconsistently.

## The fix

Capture the URL once, at construction time — before anything can mutate
it — and let the nested handler consult that snapshot in addition to the
live URL:

- The constructor now captures `new URL(window.location.href)` when
scheduling the nested-auth resolution and passes it in.
- `_maybeHandleNestedCrossDomainAuth(urlAtConstructionTime?)` stands
down if **either** the live URL **or** the construction-time URL carries
`code` + `state`.

A stripped callback still counts as a callback, so the handler no longer
re-bounces while the exchange is in flight. Every other path is
unchanged: the handler still reads all of its working params from the
live URL (the strip never touches the nested params), hop-1/hop-2 pages
have no `code` in either snapshot, and ordinary social-login callbacks
never had this race (the component-driven flow strips long after the
handler has run).

Note this fix removes the *restarts*. The remaining 3-redirect baseline
for signed-out users is a separate design issue (the analytics-created
anonymous session triggering the handoff at all) and is intentionally
out of scope here.

## Tests

- New: `does not re-bounce nested cross-domain auth after the OAuth
callback consumed code+state from the URL` — pins both guards
(mutation-tested: reverting either fix line fails it).
- New: `passes the construction-time URL to the nested cross-domain auth
handler` — pins the eager capture; fails if the URL is read lazily at
handler run time.
- Full cross-domain suite passes (the `signOut` timeout in that file is
a pre-existing flake on `dev`, reproducible without this change).


<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Fixes a race in nested cross-domain auth that caused repeated redirects
between the app and the hosted sign-in. We now snapshot the URL at
construction so OAuth callbacks are respected even after `code` and
`state` are stripped.

- **Bug Fixes**
- Capture `window.location` at construction and pass it to
`_maybeHandleNestedCrossDomainAuth`.
- Handler stands down if `code` and `state` exist in the live or
captured URL.
- Stops the redirect ping‑pong; the 3‑redirect baseline remains
unchanged.
- Keeps reading nested params from the live URL; no other paths changed.
  - Adds tests to pin the race and the construction‑time URL behavior.

<sup>Written for commit f312baa54c.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1581?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved cross-domain OAuth authentication handling to prevent
unnecessary redirects after OAuth callback processing.

* **Tests**
* Added test coverage for nested cross-domain OAuth authentication
scenarios.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
BilalG1 2026-06-11 17:37:05 -07:00 committed by GitHub
parent a092be6dc3
commit f4c13db079
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 112 additions and 2 deletions

View File

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

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;