Use redirectToHandler in StackHandler and disallow string default URL target (#1472)

This commit is contained in:
Konsti Wohlwend 2026-05-22 13:48:01 -07:00 committed by GitHub
parent 1effedbc42
commit 05e22e10a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 76 additions and 47 deletions

View File

@ -32,7 +32,33 @@ export type HandlerRedirectUrls = Record<
export type HandlerUrls = HandlerPageUrls & HandlerRedirectUrls;
export type HandlerUrlTarget = HandlerUrls[keyof HandlerUrls];
export type DefaultHandlerUrlTarget = string | { type: "hosted" | "handler-component" };
/**
* The default handler URL target, applied to any key not explicitly set.
*
* - `{ type: "handler-component" }` render the page inside the local `StackHandler` component (current default, may change in the next breaking version).
* - `{ type: "hosted" }` redirect to Stack's hosted auth pages.
*/
export type DefaultHandlerUrlTarget = { type: "hosted" | "handler-component" };
/**
* Configuration for where each auth page/redirect lives.
*
* **`default`** fallback target for every key not set individually:
* - `{ type: "handler-component" }` use the local `StackHandler` (current default, may change in the next breaking version).
* - `{ type: "hosted" }` use Stack's hosted auth pages.
*
* **Page keys** (`signIn`, `signUp`, `signOut`, `emailVerification`, `passwordReset`,
* `forgotPassword`, `oauthCallback`, `magicLinkCallback`, `accountSettings`,
* `teamInvitation`, `cliAuthConfirm`, `mfa`, `error`, `onboarding`, `handler`):
* - A URL string (e.g. `"/my-sign-in"`) custom path.
* - `{ type: "custom", url: "...", version: 0 }` custom URL with version tracking.
* - `{ type: "hosted" }` Stack's hosted page.
* - `{ type: "handler-component" }` local `StackHandler`.
*
* **Redirect keys** (`afterSignIn`, `afterSignUp`, `afterSignOut`, `home`):
* - A URL string (e.g. `"/dashboard"`) where to redirect after the action.
*/
export type HandlerUrlOptions = Partial<HandlerUrls> & { default?: DefaultHandlerUrlTarget };
export type ResolvedHandlerUrls = {
[K in keyof HandlerUrls]: string;

View File

@ -2,11 +2,12 @@
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { FilterUndefined, filterUndefined } from "@stackframe/stack-shared/dist/utils/objects";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { getRelativePart } from "@stackframe/stack-shared/dist/utils/urls";
import { notFound, redirect, RedirectType, usePathname, useSearchParams } from 'next/navigation'; // THIS_LINE_PLATFORM next
import { useMemo } from 'react';
import { useEffect, useMemo } from 'react';
/* IF_PLATFORM react
import { useEffect, useRef } from 'react';
import { useRef } from 'react';
// END_PLATFORM */
import { SignIn, SignUp, StackServerApp } from "..";
import { useStackApp } from "../lib/hooks";
@ -25,9 +26,7 @@ import { PasswordReset } from "./password-reset";
import { SignOut } from "./sign-out";
import { TeamInvitation } from "./team-invitation";
/* IF_PLATFORM react
import { MessageCard } from "../components/message-cards/message-card";
// END_PLATFORM react */
type Components = {
SignIn: typeof SignIn,
@ -89,16 +88,16 @@ function renderComponent(props: {
searchParams: Record<string, string>,
fullPage: boolean,
componentProps?: BaseHandlerProps['componentProps'],
redirectIfNotHandler?: (name: keyof HandlerUrls) => void,
shouldRedirectToPage?: (name: keyof HandlerUrls) => boolean,
getDefaultUnknownPathUrl?: (path: string) => string | null,
onNotFound: () => any,
app: StackClientApp<any> | StackServerApp<any>,
}) {
const { path, searchParams, fullPage, componentProps, redirectIfNotHandler, getDefaultUnknownPathUrl, onNotFound, app } = props;
const { path, searchParams, fullPage, componentProps, shouldRedirectToPage, getDefaultUnknownPathUrl, onNotFound, app } = props;
switch (path) {
case availablePaths.signIn: {
redirectIfNotHandler?.('signIn');
if (shouldRedirectToPage?.('signIn')) return { redirectToPage: 'signIn' as const };
return <SignIn
fullPage={fullPage}
automaticRedirect
@ -106,7 +105,7 @@ function renderComponent(props: {
/>;
}
case availablePaths.signUp: {
redirectIfNotHandler?.('signUp');
if (shouldRedirectToPage?.('signUp')) return { redirectToPage: 'signUp' as const };
return <SignUp
fullPage={fullPage}
automaticRedirect
@ -114,7 +113,7 @@ function renderComponent(props: {
/>;
}
case availablePaths.emailVerification: {
redirectIfNotHandler?.('emailVerification');
if (shouldRedirectToPage?.('emailVerification')) return { redirectToPage: 'emailVerification' as const };
return <EmailVerification
searchParams={searchParams}
fullPage={fullPage}
@ -122,7 +121,7 @@ function renderComponent(props: {
/>;
}
case availablePaths.passwordReset: {
redirectIfNotHandler?.('passwordReset');
if (shouldRedirectToPage?.('passwordReset')) return { redirectToPage: 'passwordReset' as const };
return <PasswordReset
searchParams={searchParams}
fullPage={fullPage}
@ -130,28 +129,28 @@ function renderComponent(props: {
/>;
}
case availablePaths.forgotPassword: {
redirectIfNotHandler?.('forgotPassword');
if (shouldRedirectToPage?.('forgotPassword')) return { redirectToPage: 'forgotPassword' as const };
return <ForgotPassword
fullPage={fullPage}
{...filterUndefinedINU(componentProps?.ForgotPassword)}
/>;
}
case availablePaths.signOut: {
redirectIfNotHandler?.('signOut');
if (shouldRedirectToPage?.('signOut')) return { redirectToPage: 'signOut' as const };
return <SignOut
fullPage={fullPage}
{...filterUndefinedINU(componentProps?.SignOut)}
/>;
}
case availablePaths.oauthCallback: {
redirectIfNotHandler?.('oauthCallback');
if (shouldRedirectToPage?.('oauthCallback')) return { redirectToPage: 'oauthCallback' as const };
return <OAuthCallback
fullPage={fullPage}
{...filterUndefinedINU(componentProps?.OAuthCallback)}
/>;
}
case availablePaths.magicLinkCallback: {
redirectIfNotHandler?.('magicLinkCallback');
if (shouldRedirectToPage?.('magicLinkCallback')) return { redirectToPage: 'magicLinkCallback' as const };
return <MagicLinkCallback
searchParams={searchParams}
fullPage={fullPage}
@ -159,7 +158,7 @@ function renderComponent(props: {
/>;
}
case availablePaths.teamInvitation: {
redirectIfNotHandler?.('teamInvitation');
if (shouldRedirectToPage?.('teamInvitation')) return { redirectToPage: 'teamInvitation' as const };
return <TeamInvitation
searchParams={searchParams}
fullPage={fullPage}
@ -180,21 +179,21 @@ function renderComponent(props: {
/>;
}
case availablePaths.cliAuthConfirm: {
redirectIfNotHandler?.('cliAuthConfirm');
if (shouldRedirectToPage?.('cliAuthConfirm')) return { redirectToPage: 'cliAuthConfirm' as const };
return <CliAuthConfirmation
fullPage={fullPage}
{...filterUndefinedINU(componentProps?.CliAuthConfirmation)}
/>;
}
case availablePaths.mfa: {
redirectIfNotHandler?.('mfa');
if (shouldRedirectToPage?.('mfa')) return { redirectToPage: 'mfa' as const };
return <MFA
fullPage={fullPage}
{...filterUndefinedINU(componentProps?.MFA)}
/>;
}
case availablePaths.onboarding: {
redirectIfNotHandler?.('onboarding');
if (shouldRedirectToPage?.('onboarding')) return { redirectToPage: 'onboarding' as const };
return <Onboarding
fullPage={fullPage}
{...filterUndefinedINU(componentProps?.Onboarding)}
@ -262,31 +261,17 @@ export function StackHandlerClient(props: BaseHandlerProps & Partial<RouteProps>
});
};
const redirectIfNotHandler = (name: keyof HandlerUrls) => {
const shouldRedirectToPage = (name: keyof HandlerUrls): boolean => {
const url = stackApp.urls[name];
const isCrossDomainLocalOauthCallback = name === "oauthCallback" && searchParams.stack_cross_domain_auth === "1";
if (isCrossDomainLocalOauthCallback) {
return;
return false;
}
const isLocalHandlerTarget = isLocalHandlerUrlTarget({
return !isLocalHandlerUrlTarget({
targetUrl: url,
handlerPath,
currentOrigin: typeof window === "undefined" ? undefined : window.location.origin,
});
if (isLocalHandlerTarget) {
return;
}
const urlObj = new URL(url, placeholderOrigin);
for (const [key, value] of Object.entries(searchParams)) {
urlObj.searchParams.set(key, value);
}
// IF_PLATFORM next
redirect(toAbsoluteOrRelativeRedirectTarget(urlObj), RedirectType.replace);
/* ELSE_IF_PLATFORM react
redirectTargets.push(toAbsoluteOrRelativeRedirectTarget(urlObj));
END_PLATFORM */
};
const result = renderComponent({
@ -294,7 +279,7 @@ export function StackHandlerClient(props: BaseHandlerProps & Partial<RouteProps>
searchParams,
fullPage: props.fullPage,
componentProps: props.componentProps,
redirectIfNotHandler,
shouldRedirectToPage,
getDefaultUnknownPathUrl,
onNotFound: () =>
// IF_PLATFORM next
@ -315,6 +300,21 @@ export function StackHandlerClient(props: BaseHandlerProps & Partial<RouteProps>
app: stackApp,
});
const redirectToPage = (result != null && typeof result === 'object' && 'redirectToPage' in result) ? result.redirectToPage : undefined;
useEffect(() => {
if (redirectToPage == null) return;
runAsynchronouslyWithAlert(
stackApp[stackAppInternalsSymbol].redirectToHandler(redirectToPage, { replace: true })
);
}, [redirectToPage, stackApp]);
if (redirectToPage != null) {
return (
<MessageCard title="Redirecting..." fullPage={props.fullPage} />
);
}
if (result && 'redirect' in result) {
// IF_PLATFORM next
redirect(result.redirect, RedirectType.replace);

View File

@ -3977,6 +3977,9 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
redirectToUrl: async (url: string | URL, options?: { replace?: boolean }) => {
await this._redirectTo({ url, ...options });
},
redirectToHandler: async (handlerName: keyof HandlerUrls, options?: RedirectToOptions) => {
await this._redirectToHandler(handlerName, options);
},
refreshOwnedProjects: async () => {
await this._refreshOwnedProjects(await this._getSession());
},

View File

@ -127,6 +127,7 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
sendRequest(path: string, requestOptions: RequestInit, requestType?: "client" | "server" | "admin"): Promise<Response>,
getRedirectMethod(): RedirectMethod,
redirectToUrl(url: string | URL, options?: { replace?: boolean }): Promise<void>,
redirectToHandler(handlerName: keyof HandlerUrls, options?: RedirectToOptions): Promise<void>,
signInWithTokens(tokens: { accessToken: string, refreshToken: string }): Promise<void>,
},
}

View File

@ -122,16 +122,18 @@ describe("handler URL targets", () => {
`);
});
it("does not inherit an absolute default target for the OAuth callback", () => {
it("inherits a hosted default target for the OAuth callback", () => {
vi.stubEnv("NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX", ".example-stack-hosted.test");
const urls = resolveHandlerUrls({
projectId: "project-id",
urls: {
default: "https://app.example.test/handler",
default: { type: "hosted" },
},
});
expect(urls.signIn).toBe("https://app.example.test/handler");
expect(urls.oauthCallback).toBe("/handler/oauth-callback");
expect(urls.signIn).toBe("https://project-id.example-stack-hosted.test/handler/sign-in");
expect(urls.oauthCallback).toBe("https://project-id.example-stack-hosted.test/handler/oauth-callback");
});
it("supports custom CLI auth confirmation targets", () => {

View File

@ -185,9 +185,9 @@ const assertOAuthCallbackTargetIsRelative = (target: HandlerUrlTarget): void =>
export const resolveHandlerUrls = (options: { urls: HandlerUrlOptions | undefined, projectId: string }): ResolvedHandlerUrls => {
const configuredUrls = options.urls;
const defaultTarget: HandlerUrlTarget = configuredUrls?.default ?? { type: "handler-component" };
const defaultTarget = configuredUrls?.default ?? { type: "handler-component" } as const;
const oauthCallbackTarget: HandlerUrlTarget = configuredUrls?.oauthCallback ?? (
typeof defaultTarget !== "string" && defaultTarget.type === "hosted"
defaultTarget.type === "hosted"
? defaultTarget
: { type: "handler-component" }
);
@ -339,10 +339,7 @@ export const resolveUnknownHandlerPathFallbackUrl = (options: {
projectId: string,
unknownPath: string,
}): string | null => {
const defaultTarget = options.defaultTarget ?? { type: "handler-component" } satisfies HandlerUrlTarget;
if (typeof defaultTarget === "string") {
return defaultTarget;
}
const defaultTarget = options.defaultTarget ?? { type: "handler-component" } satisfies DefaultHandlerUrlTarget;
switch (defaultTarget.type) {
case "handler-component": {