Fix handler sign out

This commit is contained in:
Konstantin Wohlwend 2026-06-04 17:59:00 -07:00
parent 1d8babb0e1
commit 8b78587dac
5 changed files with 101 additions and 40 deletions

View File

@ -166,20 +166,35 @@ describe("StackClientApp cross-domain auth", () => {
expect(redirectUri.searchParams.get("hexclave_cross_domain_after_callback_redirect_url")).toBe("https://demo.stack-auth.com/");
});
it("routes hosted sign-out back through the source-domain sign-out handler", async () => {
it("clears a stale target-domain session before deferring to the source-domain session", async () => {
const projectId = "00000000-0000-4000-8000-000000000006";
const hostedAccessToken = createAccessTokenString("hosted-old-refresh-token-id");
const clientApp = new StackClientApp({
baseUrl: "http://localhost:12345",
projectId: "00000000-0000-4000-8000-000000000003",
projectId,
publishableClientKey: "stack-pk-test",
tokenStore: "memory",
redirectMethod: "window",
urls: {
handler: "/handler",
signOut: "https://hosted.example.test/handler/sign-out",
default: { type: "hosted" },
},
noAutomaticPrefetch: true,
});
const currentHref = "https://demo.stack-auth.com/signed-in-page?foo=bar";
const tokenStore = Reflect.get(clientApp, "_memoryTokenStore");
if (!(tokenStore instanceof Store)) {
throw new Error("Expected StackClientApp to use a memory token store in this test.");
}
tokenStore.set({
refreshToken: "hosted-old-refresh-token",
accessToken: hostedAccessToken,
});
const currentUrl = new URL(`https://${projectId}.example-stack-hosted.test/handler/sign-in`);
currentUrl.searchParams.set("stack_nested_cross_domain_auth_refresh_token_id", "source-anonymous-refresh-token-id");
currentUrl.searchParams.set("stack_nested_cross_domain_auth_callback_url", "https://demo.stack-auth.com/handler/oauth-callback");
currentUrl.searchParams.set("hexclave_cross_domain_state", "outer-state");
currentUrl.searchParams.set("hexclave_cross_domain_code_challenge", "outer-code-challenge");
currentUrl.searchParams.set("hexclave_cross_domain_after_callback_redirect_url", "https://demo.stack-auth.com/app");
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
@ -189,8 +204,8 @@ describe("StackClientApp cross-domain auth", () => {
globalThis.document = createMockDocument();
globalThis.window = {
location: {
href: currentHref,
assign: (url: string) => {
href: currentUrl.toString(),
replace: (url: string) => {
redirectedUrl = url;
throw new Error("INTENTIONAL_TEST_ABORT");
},
@ -198,19 +213,40 @@ describe("StackClientApp cross-domain auth", () => {
} as any;
try {
await expect(clientApp.redirectToSignOut()).rejects.toThrowError("INTENTIONAL_TEST_ABORT");
await expect((clientApp as any)._maybeHandleNestedCrossDomainAuth()).rejects.toThrowError("INTENTIONAL_TEST_ABORT");
} finally {
globalThis.window = previousWindow;
globalThis.document = previousDocument;
}
const hostedSignOutUrl = new URL(redirectedUrl);
expect(hostedSignOutUrl.origin).toBe("https://hosted.example.test");
expect(hostedSignOutUrl.pathname).toBe("/handler/sign-out");
const sourceSignOutUrl = new URL(hostedSignOutUrl.searchParams.get("after_auth_return_to") ?? "");
expect(sourceSignOutUrl.origin).toBe("https://demo.stack-auth.com");
expect(sourceSignOutUrl.pathname).toBe("/handler/sign-out");
expect(sourceSignOutUrl.searchParams.get("after_auth_return_to")).toBe(currentHref);
expect(tokenStore.get()).toEqual({
refreshToken: null,
accessToken: null,
});
expect(new URL(redirectedUrl).origin).toBe("https://demo.stack-auth.com");
});
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",
projectId: "00000000-0000-4000-8000-000000000003",
publishableClientKey: "stack-pk-test",
tokenStore: "memory",
redirectMethod: "window",
urls: {
handler: "/handler",
signOut: { type: "hosted" },
},
noAutomaticPrefetch: true,
});
const signOutSpy = vi.spyOn(clientApp, "signOut").mockRejectedValue(new Error("INTENTIONAL_TEST_ABORT"));
try {
await expect(clientApp.redirectToSignOut()).rejects.toThrowError("INTENTIONAL_TEST_ABORT");
expect(signOutSpy).toHaveBeenCalledWith();
} finally {
signOutSpy.mockRestore();
}
});
it("keeps default hosted signOut() on the source domain when afterSignOut is not configured", async () => {

View File

@ -946,6 +946,10 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
// shape; a.com will verify the source session and issue the one-time code.
const currentRefreshTokenId = await this._getCurrentRefreshTokenIdIfSignedIn({ awaitPendingAuthResolutions: false });
if (currentRefreshTokenId === refreshTokenId) return false;
if (currentRefreshTokenId != null) {
const session = await this._getSession(undefined, { awaitPendingAuthResolutions: false });
session.markInvalid();
}
const callbackUrlString = currentUrl.searchParams.get(nestedCrossDomainAuthQueryParams.callbackUrl);
if (callbackUrlString == null) {
throw new HexclaveAssertionError("Nested cross-domain auth URL is missing callback URL");
@ -2837,17 +2841,6 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
}).oauthCallback;
}
protected _getLocalSignOutHandlerUrl(): string {
return resolveHandlerUrls({
urls: {
...this._urlOptions,
default: { type: "handler-component" },
signOut: { type: "handler-component" },
},
projectId: this.projectId,
}).signOut;
}
protected async _createCrossDomainAuthRedirectUrl(options: {
redirectUri: string,
state: string,
@ -2978,7 +2971,6 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
noRedirectBack: options?.noRedirectBack === true,
currentUrl,
localOAuthCallbackUrl: this._getLocalOAuthCallbackHandlerUrl(),
localSignOutHandlerUrl: this._getLocalSignOutHandlerUrl(),
getCrossDomainHandoffParams: async (href) => await this._getCrossDomainHandoffParamsForRedirect(href),
});
@ -3022,7 +3014,13 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
async redirectToSignIn(options?: RedirectToOptions) { return await this._redirectToHandler("signIn", options); }
async redirectToSignUp(options?: RedirectToOptions) { return await this._redirectToHandler("signUp", options); }
async redirectToSignOut(options?: RedirectToOptions) { return await this._redirectToHandler("signOut", options); }
async redirectToSignOut(options?: RedirectToOptions) {
const configuredSignOutTarget = this._urlOptions.signOut ?? this._urlOptions.default;
if (typeof configuredSignOutTarget !== "string" && configuredSignOutTarget?.type === "hosted") {
return await this.signOut();
}
return await this._redirectToHandler("signOut", options);
}
async redirectToEmailVerification(options?: RedirectToOptions) { return await this._redirectToHandler("emailVerification", options); }
async redirectToPasswordReset(options?: RedirectToOptions) { return await this._redirectToHandler("passwordReset", options); }
async redirectToForgotPassword(options?: RedirectToOptions) { return await this._redirectToHandler("forgotPassword", options); }

View File

@ -94,7 +94,6 @@ function buildRedirectBackAwareHandlerUrl(options: {
currentUrl: URL,
crossDomainHandoffParams: CrossDomainHandoffParams | null,
localOAuthCallbackUrl: string,
localSignOutHandlerUrl: string,
}): string {
const nextUrl = new URL(options.rawHandlerUrl, options.currentUrl);
// Preserve after_auth_return_to verbatim (not a rebranded param).
@ -114,9 +113,7 @@ function buildRedirectBackAwareHandlerUrl(options: {
if (options.currentUrl.protocol === nextUrl.protocol && options.currentUrl.host === nextUrl.host) {
nextUrl.searchParams.set("after_auth_return_to", getRelativePart(options.currentUrl));
} else {
const sourceSignOutUrl = new URL(options.localSignOutHandlerUrl, options.currentUrl);
sourceSignOutUrl.searchParams.set("after_auth_return_to", options.currentUrl.toString());
nextUrl.searchParams.set("after_auth_return_to", sourceSignOutUrl.toString());
nextUrl.searchParams.set("after_auth_return_to", options.currentUrl.toString());
}
}
return nextUrl.origin === options.currentUrl.origin ? getRelativePart(nextUrl) : nextUrl.toString();
@ -177,7 +174,6 @@ async function resolveRedirectBackAwareHandlerUrlForRedirect(options: {
rawHandlerUrl: string,
currentUrl: URL,
localOAuthCallbackUrl: string,
localSignOutHandlerUrl: string,
getCrossDomainHandoffParams: (currentUrl: URL) => Promise<CrossDomainHandoffParams>,
}): Promise<string> {
const initial = buildRedirectBackAwareHandlerUrl({
@ -186,7 +182,6 @@ async function resolveRedirectBackAwareHandlerUrlForRedirect(options: {
currentUrl: options.currentUrl,
crossDomainHandoffParams: null,
localOAuthCallbackUrl: options.localOAuthCallbackUrl,
localSignOutHandlerUrl: options.localSignOutHandlerUrl,
});
if (options.handlerName === "signOut") {
return initial;
@ -205,7 +200,6 @@ async function resolveRedirectBackAwareHandlerUrlForRedirect(options: {
currentUrl: options.currentUrl,
crossDomainHandoffParams,
localOAuthCallbackUrl: options.localOAuthCallbackUrl,
localSignOutHandlerUrl: options.localSignOutHandlerUrl,
});
}
@ -215,7 +209,6 @@ export async function planRedirectToHandler(options: {
noRedirectBack: boolean,
currentUrl: URL | null,
localOAuthCallbackUrl: string,
localSignOutHandlerUrl: string,
getCrossDomainHandoffParams: (currentUrl: URL) => Promise<CrossDomainHandoffParams>,
}): Promise<RedirectToHandlerPlan> {
if (options.noRedirectBack || options.currentUrl == null) {
@ -277,7 +270,6 @@ export async function planRedirectToHandler(options: {
rawHandlerUrl: options.rawHandlerUrl,
currentUrl: options.currentUrl,
localOAuthCallbackUrl: options.localOAuthCallbackUrl,
localSignOutHandlerUrl: options.localSignOutHandlerUrl,
getCrossDomainHandoffParams: options.getCrossDomainHandoffParams,
}),
};

View File

@ -96,6 +96,23 @@ describe("handler URL targets", () => {
expect(urls.cliAuthConfirm).toBe("https://project-id.example-stack-hosted.test/handler/cli-auth-confirm");
});
it("keeps redirect-only post-auth targets local even when the default target is hosted", () => {
vi.stubEnv("NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX", ".example-stack-hosted.test");
const urls = resolveHandlerUrls({
projectId: "project-id",
urls: {
default: { type: "hosted" },
},
});
expect(urls.signIn).toBe("https://project-id.example-stack-hosted.test/handler/sign-in");
expect(urls.signOut).toBe("https://project-id.example-stack-hosted.test/handler/sign-out");
expect(urls.afterSignIn).toBe("/");
expect(urls.afterSignUp).toBe("/");
expect(urls.afterSignOut).toBe("/");
});
it("rejects absolute OAuth callback string targets", () => {
expect(() => resolveHandlerUrls({
projectId: "project-id",

View File

@ -120,6 +120,12 @@ const isRelativeUrlString = (url: string): boolean => {
return !schemePrefixRegex.test(url);
};
const nonHostedHandlerNames = new Set<keyof HandlerUrls>([
"afterSignIn",
"afterSignUp",
"afterSignOut",
]);
export const isLocalHandlerUrlTarget = (options: {
targetUrl: string,
handlerPath: string,
@ -155,6 +161,9 @@ const resolveUrlTarget = (options: {
return options.fallbackPath;
}
case "hosted": {
if (nonHostedHandlerNames.has(options.handlerName)) {
return options.fallbackPath;
}
return getHostedHandlerUrl({
projectId: options.projectId,
pagePath: getHostedPagePathForHandlerName(options.handlerName),
@ -202,15 +211,24 @@ export const resolveHandlerUrls = (options: { urls: HandlerUrlOptions | undefine
});
}
const homeTarget = configuredUrls?.home ?? defaultTarget;
const localHome = resolveUrlTarget({
target: typeof homeTarget !== "string" && homeTarget.type === "hosted"
? { type: "handler-component" }
: homeTarget,
fallbackPath: "/",
handlerName: "home",
projectId: options.projectId,
});
const home = resolveUrlTarget({
target: configuredUrls?.home ?? defaultTarget,
target: homeTarget,
fallbackPath: "/",
handlerName: "home",
projectId: options.projectId,
});
const afterSignIn = resolveUrlTarget({
target: configuredUrls?.afterSignIn ?? defaultTarget,
fallbackPath: home,
fallbackPath: localHome,
handlerName: "afterSignIn",
projectId: options.projectId,
});
@ -249,7 +267,7 @@ export const resolveHandlerUrls = (options: { urls: HandlerUrlOptions | undefine
}),
afterSignOut: resolveUrlTarget({
target: configuredUrls?.afterSignOut ?? defaultTarget,
fallbackPath: home,
fallbackPath: localHome,
handlerName: "afterSignOut",
projectId: options.projectId,
}),