From d27833e1bc2765ae3f5a0fcc692f45a1ec7614a9 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 24 Feb 2026 09:02:29 +0100 Subject: [PATCH] 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 <175728472+Copilot@users.noreply.github.com> --- apps/login/next-env-vars.d.ts | 15 ++++ apps/login/next.config.mjs | 8 +- apps/login/src/app/(login)/signedin/page.tsx | 14 +++- .../src/components/change-password-form.tsx | 2 +- .../choose-second-factor-to-setup.tsx | 2 +- apps/login/src/components/login-otp.tsx | 3 +- apps/login/src/components/login-passkey.tsx | 2 +- apps/login/src/components/password-form.tsx | 2 +- .../register-form-idp-incomplete.tsx | 2 +- apps/login/src/components/register-form.tsx | 2 +- apps/login/src/components/register-u2f.tsx | 3 +- apps/login/src/components/session-item.tsx | 2 +- .../src/components/set-password-form.tsx | 2 +- .../components/set-register-password-form.tsx | 2 +- apps/login/src/components/totp-register.tsx | 3 +- apps/login/src/components/username-form.tsx | 2 +- apps/login/src/components/verify-form.tsx | 2 +- apps/login/src/lib/client-utils.test.ts | 44 ++++++++++ apps/login/src/lib/client-utils.ts | 64 +++++++++++++++ apps/login/src/lib/client.test.ts | 56 +++++++++++++ apps/login/src/lib/client.ts | 82 +++++++++++-------- apps/login/src/lib/service-url.ts | 6 ++ 22 files changed, 267 insertions(+), 53 deletions(-) create mode 100644 apps/login/src/lib/client-utils.test.ts create mode 100644 apps/login/src/lib/client-utils.ts create mode 100644 apps/login/src/lib/client.test.ts diff --git a/apps/login/next-env-vars.d.ts b/apps/login/next-env-vars.d.ts index 2a5ec1c05..041e3a955 100644 --- a/apps/login/next-env-vars.d.ts +++ b/apps/login/next-env-vars.d.ts @@ -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). diff --git a/apps/login/next.config.mjs b/apps/login/next.config.mjs index 0ee67d5af..6e280d615 100755 --- a/apps/login/next.config.mjs +++ b/apps/login/next.config.mjs @@ -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, diff --git a/apps/login/src/app/(login)/signedin/page.tsx b/apps/login/src/app/(login)/signedin/page.tsx index bee49a06f..36da7e0c6 100644 --- a/apps/login/src/app/(login)/signedin/page.tsx +++ b/apps/login/src/app/(login)/signedin/page.tsx @@ -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 }) { 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 (
@@ -108,11 +118,11 @@ export default async function Page(props: { searchParams: Promise }) { )} - {loginSettings?.defaultRedirectUri && ( + {redirectUri && !isSamePage && (
- + diff --git a/apps/login/src/components/change-password-form.tsx b/apps/login/src/components/change-password-form.tsx index cdcec0142..1eb6b2f10 100644 --- a/apps/login/src/components/change-password-form.tsx +++ b/apps/login/src/components/change-password-form.tsx @@ -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 = diff --git a/apps/login/src/components/choose-second-factor-to-setup.tsx b/apps/login/src/components/choose-second-factor-to-setup.tsx index 8b164adfb..b0fdaa603 100644 --- a/apps/login/src/components/choose-second-factor-to-setup.tsx +++ b/apps/login/src/components/choose-second-factor-to-setup.tsx @@ -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"; diff --git a/apps/login/src/components/login-otp.tsx b/apps/login/src/components/login-otp.tsx index 4c4f0fa94..1454882f8 100644 --- a/apps/login/src/components/login-otp.tsx +++ b/apps/login/src/components/login-otp.tsx @@ -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 = { diff --git a/apps/login/src/components/login-passkey.tsx b/apps/login/src/components/login-passkey.tsx index fd56c71a8..fc80ce4e5 100644 --- a/apps/login/src/components/login-passkey.tsx +++ b/apps/login/src/components/login-passkey.tsx @@ -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"; diff --git a/apps/login/src/components/password-form.tsx b/apps/login/src/components/password-form.tsx index 23f66e91a..ee4d1a959 100644 --- a/apps/login/src/components/password-form.tsx +++ b/apps/login/src/components/password-form.tsx @@ -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 = { diff --git a/apps/login/src/components/register-form-idp-incomplete.tsx b/apps/login/src/components/register-form-idp-incomplete.tsx index 318869532..0701272dc 100644 --- a/apps/login/src/components/register-form-idp-incomplete.tsx +++ b/apps/login/src/components/register-form-idp-incomplete.tsx @@ -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"; diff --git a/apps/login/src/components/register-form.tsx b/apps/login/src/components/register-form.tsx index cd02c6b9d..d6f4fdb21 100644 --- a/apps/login/src/components/register-form.tsx +++ b/apps/login/src/components/register-form.tsx @@ -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"; diff --git a/apps/login/src/components/register-u2f.tsx b/apps/login/src/components/register-u2f.tsx index 7c0abf1b9..383716d02 100644 --- a/apps/login/src/components/register-u2f.tsx +++ b/apps/login/src/components/register-u2f.tsx @@ -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; diff --git a/apps/login/src/components/session-item.tsx b/apps/login/src/components/session-item.tsx index f0b2ad415..141c19acf 100644 --- a/apps/login/src/components/session-item.tsx +++ b/apps/login/src/components/session-item.tsx @@ -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): { diff --git a/apps/login/src/components/set-password-form.tsx b/apps/login/src/components/set-password-form.tsx index 25742106b..8d5c372ca 100644 --- a/apps/login/src/components/set-password-form.tsx +++ b/apps/login/src/components/set-password-form.tsx @@ -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 = diff --git a/apps/login/src/components/set-register-password-form.tsx b/apps/login/src/components/set-register-password-form.tsx index debcc4f2d..32bae9dc7 100644 --- a/apps/login/src/components/set-register-password-form.tsx +++ b/apps/login/src/components/set-register-password-form.tsx @@ -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"; diff --git a/apps/login/src/components/totp-register.tsx b/apps/login/src/components/totp-register.tsx index 3a268ec6b..67c5dc05b 100644 --- a/apps/login/src/components/totp-register.tsx +++ b/apps/login/src/components/totp-register.tsx @@ -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; diff --git a/apps/login/src/components/username-form.tsx b/apps/login/src/components/username-form.tsx index 08ed561ec..77dad6577 100644 --- a/apps/login/src/components/username-form.tsx +++ b/apps/login/src/components/username-form.tsx @@ -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"; diff --git a/apps/login/src/components/verify-form.tsx b/apps/login/src/components/verify-form.tsx index 3fd53cb07..c72c3c68b 100644 --- a/apps/login/src/components/verify-form.tsx +++ b/apps/login/src/components/verify-form.tsx @@ -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"; diff --git a/apps/login/src/lib/client-utils.test.ts b/apps/login/src/lib/client-utils.test.ts new file mode 100644 index 000000000..d6a2604b2 --- /dev/null +++ b/apps/login/src/lib/client-utils.test.ts @@ -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); + }); +}); diff --git a/apps/login/src/lib/client-utils.ts b/apps/login/src/lib/client-utils.ts new file mode 100644 index 000000000..4f99790b2 --- /dev/null +++ b/apps/login/src/lib/client-utils.ts @@ -0,0 +1,64 @@ +export type ServerActionResponse = + | { redirect: string } + | { error: string } + | { samlData: { url: string; fields: Record } } + | undefined + | null; + +export function handleServerActionResponse( + response: ServerActionResponse, + router: { push: (url: string) => void }, + setSamlData: (data: { url: string; fields: Record }) => 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; + } +} \ No newline at end of file diff --git a/apps/login/src/lib/client.test.ts b/apps/login/src/lib/client.test.ts new file mode 100644 index 000000000..5e604e216 --- /dev/null +++ b/apps/login/src/lib/client.test.ts @@ -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"); + }); +}); + + diff --git a/apps/login/src/lib/client.ts b/apps/login/src/lib/client.ts index f63a55df2..9ce70fdea 100644 --- a/apps/login/src/lib/client.ts +++ b/apps/login/src/lib/client.ts @@ -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 } } - | undefined - | null; - -export function handleServerActionResponse( - response: ServerActionResponse, - router: { push: (url: string) => void }, - setSamlData: (data: { url: string; fields: Record }) => 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 { + // 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; } + diff --git a/apps/login/src/lib/service-url.ts b/apps/login/src/lib/service-url.ts index 02a2160de..ed9aa059c 100644 --- a/apps/login/src/lib/service-url.ts +++ b/apps/login/src/lib/service-url.ts @@ -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,