feat(login): redirect configuration options in multi-domain setups (#11527)

# Which Problems Are Solved

This PR introduces robust redirect configuration options and enhances
security for Server Actions in multi-domain setups.

# How the Problems Are Solved

- Redirect Fallback Logic: Implemented a unified `resolveRedirectUri`
utility that prioritizes redirect targets as follows:
  - Environment override (`DEFAULT_REDIRECT_URI`)
  - Organization settings (`defaultRedirectUri`).
  - Relative fallback (`/signedin?...`).
- Host Reflection: `DEFAULT_REDIRECT_URI` supports absolute URLs or
relative paths via host reflection.
- Server Action Security: Configured `serverActions.allowedOrigins` to
be dynamically manageable via the `SERVER_ACTION_ALLOWED_ORIGINS`
environment variable (comma-separated list). It defaults to an empty
list for maximum security.
- UI Integration: Updated the /signedin page to use the unified redirect
logic, ensuring consistent behavior for the "Continue" button.
- Documentation: Added type definitions and detailed descriptions for
new environment variables in
`next-env-vars.d.ts`

---------

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Max Peintner
2026-02-24 11:02:29 +03:00
committed by GitHub
co-authored by Copilot
parent 87aae1b5fc
commit d27833e1bc
22 changed files with 267 additions and 53 deletions
+15
View File
@@ -45,6 +45,21 @@ declare namespace NodeJS {
*/
NEXT_PUBLIC_APPLICATION_NAME?: string;
/**
* Optional: override the redirect URI after successful login.
* If the value starts with "/", it will be used as a relative path and prepended with the host of the request (useful for rewrites).
* Otherwise, it will use the value as an absolute redirect URI.
* Takes precedence over organization settings.
*/
DEFAULT_REDIRECT_URI?: string;
/**
* Optional: Comma-separated list of additional allowed origins for Server Actions.
* Origins should include the protocol, e.g., 'https://zitadel.com,http://localhost:3000'.
* If not set, it defaults to an empty list, allowing only same-origin requests.
*/
SERVER_ACTION_ALLOWED_ORIGINS?: string;
/**
* Optional: Enable automatic code submission on page load.
* Set to "true" to auto-submit verification codes (e.g. email verification, Email OTP).
+7 -1
View File
@@ -34,9 +34,15 @@ const nextConfig = {
reactStrictMode: true,
experimental: {
// Add React 19 compatibility optimizations
optimizePackageImports: ['@radix-ui/react-tooltip', '@heroicons/react'],
optimizePackageImports: ["@radix-ui/react-tooltip", "@heroicons/react"],
useCache: true,
serverActions: {
...(process.env.SERVER_ACTION_ALLOWED_ORIGINS
? { allowedOrigins: process.env.SERVER_ACTION_ALLOWED_ORIGINS.split(",").map((o) => o.trim()) }
: {}),
},
},
// Improve SSR stability - not actually needed for React 19 SSR issues
// onDemandEntries: {
// maxInactiveAge: 25 * 1000,
+12 -2
View File
@@ -3,6 +3,7 @@ import { Button, ButtonVariants } from "@/components/button";
import { DynamicTheme } from "@/components/dynamic-theme";
import { Translated } from "@/components/translated";
import { UserAvatar } from "@/components/user-avatar";
import { resolveRedirectUri } from "@/lib/client";
import { getMostRecentCookieWithLoginname, getSessionCookieById } from "@/lib/cookies";
import { completeDeviceAuthorization } from "@/lib/server/device";
import { getServiceConfig } from "@/lib/service-url";
@@ -83,6 +84,15 @@ export default async function Page(props: { searchParams: Promise<any> }) {
loginSettings = await getLoginSettings({ serviceConfig, organization });
}
const redirectUri = await resolveRedirectUri(
requestId && sessionId
? { sessionId, requestId }
: { loginName: loginName ?? sessionFactors?.factors?.user?.loginName },
loginSettings?.defaultRedirectUri,
);
const isSamePage = redirectUri?.startsWith("/signedin") ?? false;
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col space-y-4">
@@ -108,11 +118,11 @@ export default async function Page(props: { searchParams: Promise<any> }) {
</Alert>
)}
{loginSettings?.defaultRedirectUri && (
{redirectUri && !isSamePage && (
<div className="mt-8 flex w-full flex-row items-center">
<span className="flex-grow"></span>
<Link href={loginSettings?.defaultRedirectUri}>
<Link href={redirectUri}>
<Button type="submit" className="self-end" variant={ButtonVariants.Primary}>
<Translated i18nKey="continue" namespace="signedin" />
</Button>
@@ -16,7 +16,7 @@ import { TextInput } from "./input";
import { PasswordComplexity } from "./password-complexity";
import { Spinner } from "./spinner";
import { Translated } from "./translated";
import { handleServerActionResponse } from "@/lib/client";
import { handleServerActionResponse } from "@/lib/client-utils";
import { AutoSubmitForm } from "./auto-submit-form";
type Inputs =
@@ -7,7 +7,7 @@ import { useRouter } from "next/navigation";
import { EMAIL, SMS, TOTP, U2F } from "./auth-methods";
import { Translated } from "./translated";
import { useState } from "react";
import { handleServerActionResponse } from "@/lib/client";
import { handleServerActionResponse } from "@/lib/client-utils";
import { AutoSubmitForm } from "./auto-submit-form";
import { Alert } from "./alert";
+2 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { handleServerActionResponse, completeFlowOrGetUrl } from "@/lib/client";
import { handleServerActionResponse } from "@/lib/client-utils";
import { updateOrCreateSession } from "@/lib/server/session";
import { create } from "@zitadel/client";
import { RequestChallengesSchema } from "@zitadel/proto/zitadel/session/v2/challenge_pb";
@@ -17,6 +17,7 @@ import { TextInput } from "./input";
import { Spinner } from "./spinner";
import { Translated } from "./translated";
import { AutoSubmitForm } from "./auto-submit-form";
import { completeFlowOrGetUrl } from "@/lib/client";
// either loginName or sessionId must be provided
type Props = {
+1 -1
View File
@@ -9,7 +9,7 @@ import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { handleServerActionResponse } from "@/lib/client";
import { handleServerActionResponse } from "@/lib/client-utils";
import { Alert } from "./alert";
import { BackButton } from "./back-button";
import { Button, ButtonVariants } from "./button";
+1 -1
View File
@@ -14,7 +14,7 @@ import { Button, ButtonVariants } from "./button";
import { TextInput } from "./input";
import { Spinner } from "./spinner";
import { Translated } from "./translated";
import { handleServerActionResponse } from "@/lib/client";
import { handleServerActionResponse } from "@/lib/client-utils";
import { AutoSubmitForm } from "./auto-submit-form";
type Inputs = {
@@ -1,7 +1,7 @@
"use client";
import { registerUserAndLinkToIDP } from "@/lib/server/register";
import { handleServerActionResponse } from "@/lib/client";
import { handleServerActionResponse } from "@/lib/client-utils";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { FieldValues, useForm } from "react-hook-form";
+1 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { registerUser } from "@/lib/server/register";
import { handleServerActionResponse } from "@/lib/client";
import { handleServerActionResponse } from "@/lib/client-utils";
import { LegalAndSupportSettings } from "@zitadel/proto/zitadel/settings/v2/legal_settings_pb";
import { LoginSettings, PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { useRouter } from "next/navigation";
+2 -1
View File
@@ -1,7 +1,6 @@
"use client";
import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64";
import { completeFlowOrGetUrl, handleServerActionResponse } from "@/lib/client";
import { addU2F, verifyU2F } from "@/lib/server/u2f";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { RegisterU2FResponse } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
@@ -13,6 +12,8 @@ import { Button, ButtonVariants } from "./button";
import { Spinner } from "./spinner";
import { Translated } from "./translated";
import { AutoSubmitForm } from "./auto-submit-form";
import { handleServerActionResponse } from "@/lib/client-utils";
import { completeFlowOrGetUrl } from "@/lib/client";
type Props = {
loginName?: string;
+1 -1
View File
@@ -12,7 +12,7 @@ import { useRouter } from "next/navigation";
import React, { useState } from "react";
import { Avatar } from "./avatar";
import { Translated } from "./translated";
import { handleServerActionResponse } from "@/lib/client";
import { handleServerActionResponse } from "@/lib/client-utils";
import { AutoSubmitForm } from "./auto-submit-form";
export function isSessionValid(session: Partial<Session>): {
@@ -16,7 +16,7 @@ import { TextInput } from "./input";
import { PasswordComplexity } from "./password-complexity";
import { Spinner } from "./spinner";
import { Translated } from "./translated";
import { handleServerActionResponse } from "@/lib/client";
import { handleServerActionResponse } from "@/lib/client-utils";
import { AutoSubmitForm } from "./auto-submit-form";
type Inputs =
@@ -2,7 +2,7 @@
import { lowerCaseValidator, numberValidator, symbolValidator, upperCaseValidator } from "@/helpers/validators";
import { registerUser } from "@/lib/server/register";
import { handleServerActionResponse } from "@/lib/client";
import { handleServerActionResponse } from "@/lib/client-utils";
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
import { useRouter } from "next/navigation";
import { useState } from "react";
+2 -1
View File
@@ -1,6 +1,5 @@
"use client";
import { completeFlowOrGetUrl, handleServerActionResponse } from "@/lib/client";
import { verifyTOTP } from "@/lib/server/verify";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import Link from "next/link";
@@ -16,6 +15,8 @@ import { TextInput } from "./input";
import { Spinner } from "./spinner";
import { Translated } from "./translated";
import { AutoSubmitForm } from "./auto-submit-form";
import { handleServerActionResponse } from "@/lib/client-utils";
import { completeFlowOrGetUrl } from "@/lib/client";
type Inputs = {
code: string;
+1 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { sendLoginname } from "@/lib/server/loginname";
import { handleServerActionResponse } from "@/lib/client";
import { handleServerActionResponse } from "@/lib/client-utils";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
+1 -1
View File
@@ -2,7 +2,7 @@
import { Alert, AlertType } from "@/components/alert";
import { resendVerification, sendVerification } from "@/lib/server/verify";
import { handleServerActionResponse } from "@/lib/client";
import { handleServerActionResponse } from "@/lib/client-utils";
import { UNKNOWN_USER_ID } from "@/lib/constants";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, test, vi, beforeEach } from "vitest";
import { isSafeRedirectUri } from "./client-utils";
describe("isSafeRedirectUri", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("should reject empty uri", async () => {
expect(await isSafeRedirectUri("")).toBe(false);
expect(await isSafeRedirectUri(undefined as unknown as string)).toBe(false);
});
test("should allow relative paths", async () => {
expect(await isSafeRedirectUri("/dashboard")).toBe(true);
expect(await isSafeRedirectUri("/ui/console")).toBe(true);
expect(await isSafeRedirectUri("/")).toBe(true);
});
test("should reject protocol-relative paths", async () => {
expect(await isSafeRedirectUri("//evil.com")).toBe(false);
expect(await isSafeRedirectUri("\\\\evil.com")).toBe(false);
});
test("should allow absolute URLs matching the current host", async () => {
expect(await isSafeRedirectUri("https://my-zitadel.com/console")).toBe(true);
});
test("should allow absolute URLs to external domains", async () => {
expect(await isSafeRedirectUri("https://evil.com")).toBe(true);
expect(await isSafeRedirectUri("https://settings.com/dashboard")).toBe(true);
});
test("should reject non-http(s) protocols", async () => {
expect(await isSafeRedirectUri("javascript:alert(1)")).toBe(false);
expect(await isSafeRedirectUri("data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==")).toBe(false);
expect(await isSafeRedirectUri("file:///etc/passwd")).toBe(false);
});
test("should gracefully reject invalid, unparsable URLs that aren't relative", async () => {
expect(await isSafeRedirectUri("not-a-valid-url")).toBe(false);
expect(await isSafeRedirectUri("http://%")).toBe(false);
});
});
+64
View File
@@ -0,0 +1,64 @@
export type ServerActionResponse =
| { redirect: string }
| { error: string }
| { samlData: { url: string; fields: Record<string, string> } }
| undefined
| null;
export function handleServerActionResponse(
response: ServerActionResponse,
router: { push: (url: string) => void },
setSamlData: (data: { url: string; fields: Record<string, string> }) => void,
setError: (error: string) => void,
): boolean {
if (!response) {
return false;
}
if ("redirect" in response && response.redirect) {
router.push(response.redirect);
return true;
}
if ("samlData" in response && response.samlData) {
setSamlData(response.samlData);
return true;
}
if ("error" in response && response.error) {
setError(response.error);
return true;
}
return false;
}
/**
* Validates whether a given redirect URI is safe.
* Safe URIs are either relative paths or absolute URLs matching the current host.
* This prevents open redirect vulnerabilities and XSS via javascript:/data: URIs.
*/
export function isSafeRedirectUri(uri: string): boolean {
if (!uri) return false;
// 1. Relative paths are generally safe
if (uri.startsWith("/") && !uri.startsWith("//")) {
return true;
}
// 2. Check absolute URLs for safe protocols (http/https)
// We allow external domains, but strictly forbid javascript:/data: etc.
try {
const parsedUri = new URL(uri);
// Only allow http(s) protocols
if (parsedUri.protocol !== "http:" && parsedUri.protocol !== "https:") {
return false;
}
return true;
} catch {
// If it can't be parsed as a URL and didn't start with /, it's unsafe
return false;
}
}
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, test, vi, beforeEach } from "vitest";
import { getNextUrl } from "./client";
// Mock next/headers
vi.mock("next/headers", () => ({
headers: vi.fn(),
}));
// Mock host helper
vi.mock("./server/host", () => ({
getPublicHostWithProtocol: vi.fn(),
}));
describe("getNextUrl", () => {
const command = { loginName: "test-user" };
beforeEach(() => {
vi.clearAllMocks();
delete (process.env as any).DEFAULT_REDIRECT_URI;
delete (process.env as any).NEXT_PUBLIC_BASE_PATH;
});
test("should use DEFAULT_REDIRECT_URI if set", async () => {
process.env.DEFAULT_REDIRECT_URI = "https://env-override.com";
const result = await getNextUrl(command);
expect(result).toBe("https://env-override.com");
});
test("should use host-based redirect if DEFAULT_REDIRECT_URI is set to a path (starting with '/')", async () => {
const { headers } = await import("next/headers");
const { getPublicHostWithProtocol } = await import("./server/host");
process.env.DEFAULT_REDIRECT_URI = "/dashboard";
vi.mocked(headers).mockResolvedValue({} as any);
vi.mocked(getPublicHostWithProtocol).mockReturnValue("https://my-host.com");
process.env.NEXT_PUBLIC_BASE_PATH = "/ui/v2/login";
const result = await getNextUrl(command);
expect(result).toBe("https://my-host.com/dashboard");
});
test("should use defaultRedirectUri if env is NOT set", async () => {
const result = await getNextUrl(command, "https://settings.com");
expect(result).toBe("https://settings.com");
});
test("should fallback to relative signedin page if everything else fails (the new default)", async () => {
const { headers } = await import("next/headers");
vi.mocked(headers).mockRejectedValue(new Error("No headers"));
const result = await getNextUrl(command);
expect(result).toBe("/signedin?loginName=test-user");
});
});
+46 -36
View File
@@ -1,4 +1,9 @@
"use server";
import { headers } from "next/headers";
import { completeAuthFlow } from "./server/auth-flow";
import { getPublicHostWithProtocol } from "./server/host";
import { isSafeRedirectUri } from "./client-utils";
type FinishFlowCommand =
| {
@@ -100,47 +105,52 @@ export async function getNextUrl(
// OIDC/SAML flows are now handled by completeAuthFlowAction() server action
// This function only handles device flows and fallback navigation
if (defaultRedirectUri) {
console.log("getNextUrl: Using defaultRedirectUri:", defaultRedirectUri);
return defaultRedirectUri;
}
const result = goToSignedInPage(command);
console.log("getNextUrl: got goToSignedInPage result");
const result = await resolveRedirectUri(command, defaultRedirectUri);
console.log("getNextUrl: Resolved redirect URI:", result);
return result;
}
export type ServerActionResponse =
| { redirect: string }
| { error: string }
| { samlData: { url: string; fields: Record<string, string> } }
| undefined
| null;
export function handleServerActionResponse(
response: ServerActionResponse,
router: { push: (url: string) => void },
setSamlData: (data: { url: string; fields: Record<string, string> }) => void,
setError: (error: string) => void,
): boolean {
if (!response) {
return false;
/**
* Resolves the redirect URI based on the following priority:
* 1. DEFAULT_REDIRECT_URI environment variable
* 2. defaultRedirectUri from organization settings
* 3. Relative signed-in page fallback
* 4. Reserved for future extensions
*/
export async function resolveRedirectUri(command: FinishFlowCommand, defaultRedirectUri?: string): Promise<string> {
// 1. Environment variable override
const envOverride = process.env.DEFAULT_REDIRECT_URI;
if (envOverride) {
if (envOverride.startsWith("/")) {
// Special state: trigger absolute host-based redirect with provided path
try {
const _headers = await headers();
const host = getPublicHostWithProtocol(_headers);
const result = `${host}${envOverride}`;
console.log("resolveRedirectUri: Using host-based redirect from override:", result);
return result;
} catch (error) {
console.warn("resolveRedirectUri: Could not determine host for override, falling back", error);
}
} else {
console.log("resolveRedirectUri: Using DEFAULT_REDIRECT_URI override:", envOverride);
return envOverride;
}
}
if ("redirect" in response && response.redirect) {
router.push(response.redirect);
return true;
// 2. Default redirect URI from settings
if (defaultRedirectUri) {
if (isSafeRedirectUri(defaultRedirectUri)) {
console.log("resolveRedirectUri: Using defaultRedirectUri from settings:", defaultRedirectUri);
return defaultRedirectUri;
} else {
console.warn("resolveRedirectUri: Unsafe defaultRedirectUri prevented:", defaultRedirectUri);
}
}
if ("samlData" in response && response.samlData) {
setSamlData(response.samlData);
return true;
}
if ("error" in response && response.error) {
setError(response.error);
return true;
}
return false;
// 3. Default signed-in page (relative)
const result = goToSignedInPage(command);
console.log("resolveRedirectUri: Using relative goToSignedInPage result:", result);
return result;
}
+6
View File
@@ -29,6 +29,12 @@ export function getServiceConfig(headers: ReadonlyHeaders): { serviceConfig: Ser
const instanceHost = getInstanceHost(headers);
const publicHost = getPublicHost(headers);
console.log("serviceConfig", {
baseUrl: process.env.ZITADEL_API_URL,
...(instanceHost && { instanceHost: stripProtocol(instanceHost) }),
...(publicHost && { publicHost: stripProtocol(publicHost) }),
});
return {
serviceConfig: {
baseUrl: process.env.ZITADEL_API_URL,