mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Use redirectToHandler in StackHandler and disallow string default URL target (#1472)
This commit is contained in:
parent
1effedbc42
commit
05e22e10a3
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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());
|
||||
},
|
||||
|
||||
@ -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>,
|
||||
},
|
||||
}
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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": {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user