mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Fix handler sign out
This commit is contained in:
parent
1d8babb0e1
commit
8b78587dac
@ -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 () => {
|
||||
|
||||
@ -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); }
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user