mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
Merge branch 'dev' into rename-env-vars-hexclave
This commit is contained in:
commit
9d3ee6a0d6
File diff suppressed because it is too large
Load Diff
@ -300,8 +300,8 @@ it("does not await pending auth resolutions when post-callback redirect adds nes
|
||||
await withHostedDomainSuffix(async () => {
|
||||
const projectId = "13131313-1313-4313-8313-131313131313";
|
||||
const clientApp = createClientApp(projectId);
|
||||
const getCurrentRefreshTokenIdIfSignedInSpy = vi
|
||||
.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn")
|
||||
const fetchCurrentRefreshTokenIdIfSignedInSpy = vi
|
||||
.spyOn(clientApp as any, "_fetchCurrentRefreshTokenIdIfSignedIn")
|
||||
.mockResolvedValue(null);
|
||||
|
||||
const previousWindow = globalThis.window;
|
||||
@ -317,8 +317,10 @@ it("does not await pending auth resolutions when post-callback redirect adds nes
|
||||
} as any;
|
||||
|
||||
try {
|
||||
// accountSettings (unlike afterSignIn & co, which resolve to local URLs) still lives on the
|
||||
// hosted domain, so it exercises the nested cross-domain auth params path.
|
||||
await expect((clientApp as any)._redirectToHandler(
|
||||
"afterSignIn",
|
||||
"accountSettings",
|
||||
{ replace: true },
|
||||
{ awaitPendingAuthResolutions: false },
|
||||
)).rejects.toThrowError("INTENTIONAL_TEST_ABORT");
|
||||
@ -327,9 +329,9 @@ it("does not await pending auth resolutions when post-callback redirect adds nes
|
||||
globalThis.document = previousDocument;
|
||||
}
|
||||
|
||||
expect(getCurrentRefreshTokenIdIfSignedInSpy).toHaveBeenCalledWith({
|
||||
expect(fetchCurrentRefreshTokenIdIfSignedInSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
awaitPendingAuthResolutions: false,
|
||||
});
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@ -453,7 +455,7 @@ it("adds nested cross-domain auth params when redirecting signed-in users to hos
|
||||
const currentHref = `${localRedirectUrl}/dashboard?tab=settings`;
|
||||
const clientApp = createClientApp(projectId);
|
||||
|
||||
vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(refreshTokenId);
|
||||
vi.spyOn(clientApp as any, "_fetchCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(refreshTokenId);
|
||||
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
@ -491,7 +493,7 @@ it("adds nested cross-domain auth params for other cross-domain handler redirect
|
||||
const currentHref = `${localRedirectUrl}/private-page`;
|
||||
const clientApp = createClientApp(projectId);
|
||||
|
||||
vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(refreshTokenId);
|
||||
vi.spyOn(clientApp as any, "_fetchCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(refreshTokenId);
|
||||
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
@ -531,7 +533,7 @@ it("starts nested cross-domain auth from the target domain", async ({ expect })
|
||||
const previousDocument = globalThis.document;
|
||||
let redirectedUrl = "";
|
||||
|
||||
vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(null);
|
||||
vi.spyOn(clientApp as any, "_fetchCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(null);
|
||||
vi.spyOn(clientApp as any, "_getCrossDomainHandoffParamsForRedirect").mockResolvedValue({
|
||||
state: "nested-state",
|
||||
codeChallenge: "nested-code-challenge",
|
||||
@ -595,7 +597,7 @@ it("carries hosted sign-in return state on the nested OAuth redirect URI", async
|
||||
const previousDocument = globalThis.document;
|
||||
let redirectedUrl = "";
|
||||
|
||||
vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(null);
|
||||
vi.spyOn(clientApp as any, "_fetchCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(null);
|
||||
vi.spyOn(clientApp as any, "_getCrossDomainHandoffParamsForRedirect").mockResolvedValue({
|
||||
state: "nested-state",
|
||||
codeChallenge: "nested-code-challenge",
|
||||
@ -656,7 +658,7 @@ it("continues nested cross-domain auth on the source domain", async ({ expect })
|
||||
const createCrossDomainAuthRedirectUrlSpy = vi
|
||||
.spyOn(clientApp as any, "_createCrossDomainAuthRedirectUrl")
|
||||
.mockResolvedValue(crossDomainRedirect);
|
||||
vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(sourceRefreshTokenId);
|
||||
vi.spyOn(clientApp as any, "_fetchCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(sourceRefreshTokenId);
|
||||
|
||||
globalThis.document = createMockDocument();
|
||||
globalThis.window = {
|
||||
@ -728,7 +730,7 @@ it("rejects nested cross-domain auth when the callback URL is untrusted", async
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
|
||||
vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(null);
|
||||
vi.spyOn(clientApp as any, "_fetchCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(null);
|
||||
vi.spyOn(clientApp as any, "_isTrusted").mockResolvedValue(false);
|
||||
|
||||
globalThis.document = createMockDocument();
|
||||
@ -760,7 +762,7 @@ it("rejects nested cross-domain auth when the source session does not match", as
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
const createCrossDomainAuthRedirectUrlSpy = vi.spyOn(clientApp as any, "_createCrossDomainAuthRedirectUrl");
|
||||
vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue("different-source-session");
|
||||
vi.spyOn(clientApp as any, "_fetchCurrentRefreshTokenIdIfSignedIn").mockResolvedValue("different-source-session");
|
||||
|
||||
globalThis.document = createMockDocument();
|
||||
globalThis.window = {
|
||||
|
||||
@ -283,6 +283,12 @@ describe("StackClientApp cross-domain auth", () => {
|
||||
const originalFetchNewAccessToken = Reflect.get(clientInterface, "fetchNewAccessToken");
|
||||
const refreshedRawRefreshTokens: string[] = [];
|
||||
|
||||
// Cookie-store writes queue a background trusted-parent-domain lookup. Without this stub, that
|
||||
// lookup fetches the (unreachable) baseUrl with retries while holding the global store lock,
|
||||
// which starves any later test that needs the write lock (e.g. signOut). Not restored on
|
||||
// purpose: queued tasks can still run after this test body finishes.
|
||||
vi.spyOn(clientApp as any, "_getTrustedParentDomain").mockResolvedValue(null);
|
||||
|
||||
try {
|
||||
const getBrowserCookieTokenStore = Reflect.get(clientApp, "_getBrowserCookieTokenStore");
|
||||
if (typeof getBrowserCookieTokenStore !== "function") {
|
||||
@ -325,6 +331,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",
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user