High severityNVD Advisory· Published Mar 7, 2026· Updated Mar 9, 2026
ZITADEL: Account Takeover Due to Improper Instance Validation in V2 Login
CVE-2026-29067
Description
ZITADEL is an open source identity management platform. From version 4.0.0-rc.1 to 4.7.0, a potential vulnerability exists in ZITADEL's password reset mechanism in login V2. ZITADEL utilizes the Forwarded or X-Forwarded-Host header from incoming requests to construct the URL for the password reset confirmation link. This link, containing a secret code, is then emailed to the user. This issue has been patched in version 4.7.1.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/zitadel/zitadelGo | < 1.80.0-v2.20.0.20251208091519-4c879b47334e | 1.80.0-v2.20.0.20251208091519-4c879b47334e |
github.com/zitadel/zitadelGo | >= 1.83.4, <= 1.87.5 | — |
github.com/zitadel/zitadelGo | >= 4.0.0-rc.1, < 4.7.1 | 4.7.1 |
github.com/zitadel/zitadel/v2Go | < 1.80.0-v2.20.0.20251208091519-4c879b47334e | 1.80.0-v2.20.0.20251208091519-4c879b47334e |
Affected products
1Patches
14c879b47334efix(login): Centralize host header resolution and forward headers to APIs
75 files changed · +1631 −1639
apps/login/next.config.mjs+1 −4 modified@@ -37,12 +37,9 @@ const nextConfig = { output: process.env.NEXT_OUTPUT_MODE || undefined, reactStrictMode: true, experimental: { - dynamicIO: true, // Add React 19 compatibility optimizations optimizePackageImports: ['@radix-ui/react-tooltip', '@heroicons/react'], - }, - eslint: { - ignoreDuringBuilds: true, + useCache: true, }, // Improve SSR stability - not actually needed for React 19 SSR issues // onDemandEntries: {
apps/login/next-env.d.ts+1 −0 modified@@ -1,5 +1,6 @@ /// <reference types="next" /> /// <reference types="next/image-types/global" /> +/// <reference path="./.next/types/routes.d.ts" /> // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
apps/login/package.json+2 −2 modified@@ -36,7 +36,7 @@ "deepmerge": "^4.3.1", "lucide-react": "0.469.0", "moment": "^2.29.4", - "next": "15.4.0-canary.86", + "next": "15.5.7", "next-intl": "^3.25.1", "next-themes": "^0.2.1", "nice-grpc": "2.0.1", @@ -75,7 +75,7 @@ "dotenv-cli": "^8.0.0", "env-cmd": "^10.1.0", "eslint": "^8.57.0", - "eslint-config-next": "15.4.0-canary.86", + "eslint-config-next": "15.5.7", "eslint-config-prettier": "^9.1.0", "gaxios": "^7.1.0", "grpc-tools": "1.13.0",
apps/login/src/app/(login)/accounts/page.tsx+8 −14 modified@@ -2,8 +2,8 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { SessionsList } from "@/components/sessions-list"; import { Translated } from "@/components/translated"; import { getAllSessionCookieIds } from "@/lib/cookies"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; -import { getBrandingSettings, getDefaultOrg, listSessions } from "@/lib/zitadel"; +import { getServiceConfig } from "@/lib/service-url"; +import { getBrandingSettings, getDefaultOrg, listSessions, ServiceConfig } from "@/lib/zitadel"; import { UserPlusIcon } from "@heroicons/react/24/outline"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; import { Metadata } from "next"; @@ -17,13 +17,11 @@ export async function generateMetadata(): Promise<Metadata> { return { title: t("title") }; } -async function loadSessions({ serviceUrl }: { serviceUrl: string }) { +async function loadSessions({ serviceConfig }: { serviceConfig: ServiceConfig }) { const cookieIds = await getAllSessionCookieIds(); if (cookieIds && cookieIds.length) { - const response = await listSessions({ - serviceUrl, - ids: cookieIds.filter((id) => !!id) as string[], + const response = await listSessions({ serviceConfig, ids: cookieIds.filter((id) => !!id) as string[], }); return response?.sessions ?? []; } else { @@ -39,23 +37,19 @@ export default async function Page(props: { searchParams: Promise<Record<string const organization = searchParams?.organization; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); let defaultOrganization; if (!organization) { - const org: Organization | null = await getDefaultOrg({ - serviceUrl, - }); + const org: Organization | null = await getDefaultOrg({ serviceConfig, }); if (org) { defaultOrganization = org.id; } } - let sessions = await loadSessions({ serviceUrl }); + let sessions = await loadSessions({ serviceConfig }); - const branding = await getBrandingSettings({ - serviceUrl, - organization: organization ?? defaultOrganization, + const branding = await getBrandingSettings({ serviceConfig, organization: organization ?? defaultOrganization, }); const params = new URLSearchParams();
apps/login/src/app/(login)/authenticator/set/page.tsx+11 −23 modified@@ -6,7 +6,7 @@ import { SignInWithIdp } from "@/components/sign-in-with-idp"; import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; import { getSessionCookieById } from "@/lib/cookies"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; import { checkUserVerification } from "@/lib/verify-helper"; import { @@ -35,7 +35,7 @@ export default async function Page(props: { searchParams: Promise<Record<string const { loginName, requestId, organization, sessionId } = searchParams; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); const sessionWithData = sessionId ? await loadSessionById(sessionId, organization) @@ -52,11 +52,9 @@ export default async function Page(props: { searchParams: Promise<Record<string throw Error("Could not get user id from session"); } - return listAuthenticationMethodTypes({ - serviceUrl, - userId, + return listAuthenticationMethodTypes({ serviceConfig, userId, }).then((methods) => { - return getUserByID({ serviceUrl, userId }).then((user) => { + return getUserByID({ serviceConfig, userId }).then((user) => { const humanUser = user.user?.type.case === "human" ? user.user?.type.value : undefined; return { @@ -71,25 +69,21 @@ export default async function Page(props: { searchParams: Promise<Record<string } async function loadSessionByLoginname(loginName?: string, organization?: string) { - return loadMostRecentSession({ - serviceUrl, - sessionParams: { + return loadMostRecentSession({ serviceConfig, sessionParams: { loginName, organization, }, }).then((session) => { - return getAuthMethodsAndUser(serviceUrl, session); + return getAuthMethodsAndUser(serviceConfig.baseUrl, session); }); } async function loadSessionById(sessionId: string, organization?: string) { const recent = await getSessionCookieById({ sessionId, organization }); - return getSession({ - serviceUrl, - sessionId: recent.id, + return getSession({ serviceConfig, sessionId: recent.id, sessionToken: recent.token, }).then((sessionResponse) => { - return getAuthMethodsAndUser(serviceUrl, sessionResponse.session); + return getAuthMethodsAndUser(serviceConfig.baseUrl, sessionResponse.session); }); } @@ -101,14 +95,10 @@ export default async function Page(props: { searchParams: Promise<Record<string ); } - const branding = await getBrandingSettings({ - serviceUrl, - organization: sessionWithData.factors.user?.organizationId, + const branding = await getBrandingSettings({ serviceConfig, organization: sessionWithData.factors.user?.organizationId, }); - const loginSettings = await getLoginSettings({ - serviceUrl, - organization: sessionWithData.factors.user?.organizationId, + const loginSettings = await getLoginSettings({ serviceConfig, organization: sessionWithData.factors.user?.organizationId, }); // check if user was verified recently @@ -132,9 +122,7 @@ export default async function Page(props: { searchParams: Promise<Record<string redirect(`/verify?` + params); } - const identityProviders = await getActiveIdentityProviders({ - serviceUrl, - orgId: sessionWithData.factors?.user?.organizationId, + const identityProviders = await getActiveIdentityProviders({ serviceConfig, orgId: sessionWithData.factors?.user?.organizationId, linking_allowed: true, }).then((resp) => { return resp.identityProviders;
apps/login/src/app/(login)/device/consent/page.tsx+5 −11 modified@@ -1,7 +1,7 @@ import { ConsentScreen } from "@/components/consent"; import { DynamicTheme } from "@/components/dynamic-theme"; import { Translated } from "@/components/translated"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { getBrandingSettings, getDefaultOrg, getDeviceAuthorizationRequest } from "@/lib/zitadel"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; import { headers } from "next/headers"; @@ -22,11 +22,9 @@ export default async function Page(props: { searchParams: Promise<Record<string } const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); - const { deviceAuthorizationRequest } = await getDeviceAuthorizationRequest({ - serviceUrl, - userCode, + const { deviceAuthorizationRequest } = await getDeviceAuthorizationRequest({ serviceConfig, userCode, }); if (!deviceAuthorizationRequest) { @@ -39,17 +37,13 @@ export default async function Page(props: { searchParams: Promise<Record<string let defaultOrganization; if (!organization) { - const org: Organization | null = await getDefaultOrg({ - serviceUrl, - }); + const org: Organization | null = await getDefaultOrg({ serviceConfig, }); if (org) { defaultOrganization = org.id; } } - const branding = await getBrandingSettings({ - serviceUrl, - organization: organization ?? defaultOrganization, + const branding = await getBrandingSettings({ serviceConfig, organization: organization ?? defaultOrganization, }); const params = new URLSearchParams();
apps/login/src/app/(login)/device/page.tsx+4 −8 modified@@ -1,7 +1,7 @@ import { DeviceCodeForm } from "@/components/device-code-form"; import { DynamicTheme } from "@/components/dynamic-theme"; import { Translated } from "@/components/translated"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { getBrandingSettings, getDefaultOrg } from "@/lib/zitadel"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; import { Metadata } from "next"; @@ -20,21 +20,17 @@ export default async function Page(props: { searchParams: Promise<Record<string const organization = searchParams?.organization; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); let defaultOrganization; if (!organization) { - const org: Organization | null = await getDefaultOrg({ - serviceUrl, - }); + const org: Organization | null = await getDefaultOrg({ serviceConfig, }); if (org) { defaultOrganization = org.id; } } - const branding = await getBrandingSettings({ - serviceUrl, - organization: organization ?? defaultOrganization, + const branding = await getBrandingSettings({ serviceConfig, organization: organization ?? defaultOrganization, }); return (
apps/login/src/app/(login)/idp/ldap/page.tsx+4 −8 modified@@ -1,7 +1,7 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { LDAPUsernamePasswordForm } from "@/components/ldap-username-password-form"; import { Translated } from "@/components/translated"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { getBrandingSettings, getDefaultOrg } from "@/lib/zitadel"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; import { headers } from "next/headers"; @@ -18,21 +18,17 @@ export default async function Page(props: { } const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); let defaultOrganization; if (!organization) { - const org: Organization | null = await getDefaultOrg({ - serviceUrl, - }); + const org: Organization | null = await getDefaultOrg({ serviceConfig, }); if (org) { defaultOrganization = org.id; } } - const branding = await getBrandingSettings({ - serviceUrl, - organization: organization ?? defaultOrganization, + const branding = await getBrandingSettings({ serviceConfig, organization: organization ?? defaultOrganization, }); // return login failed if no linking or creation is allowed and no user was found
apps/login/src/app/(login)/idp/page.tsx+4 −8 modified@@ -1,7 +1,7 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { SignInWithIdp } from "@/components/sign-in-with-idp"; import { Translated } from "@/components/translated"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { getActiveIdentityProviders, getBrandingSettings } from "@/lib/zitadel"; import { Metadata } from "next"; import { getTranslations } from "next-intl/server"; @@ -19,18 +19,14 @@ export default async function Page(props: { searchParams: Promise<Record<string const organization = searchParams?.organization; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); - const identityProviders = await getActiveIdentityProviders({ - serviceUrl, - orgId: organization, + const identityProviders = await getActiveIdentityProviders({ serviceConfig, orgId: organization, }).then((resp) => { return resp.identityProviders; }); - const branding = await getBrandingSettings({ - serviceUrl, - organization, + const branding = await getBrandingSettings({ serviceConfig, organization, }); return (
apps/login/src/app/(login)/idp/[provider]/account-not-found/page.tsx+4 −8 modified@@ -2,7 +2,7 @@ import { Alert, AlertType } from "@/components/alert"; import { Button } from "@/components/button"; import { DynamicTheme } from "@/components/dynamic-theme"; import { Translated } from "@/components/translated"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { getBrandingSettings, getDefaultOrg } from "@/lib/zitadel"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; import { Metadata } from "next"; @@ -20,21 +20,17 @@ export default async function Page(props: { searchParams: Promise<Record<string const { organization, postErrorRedirectUrl } = searchParams; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); let defaultOrganization; if (!organization) { - const org: Organization | null = await getDefaultOrg({ - serviceUrl, - }); + const org: Organization | null = await getDefaultOrg({ serviceConfig, }); if (org) { defaultOrganization = org.id; } } - const branding = await getBrandingSettings({ - serviceUrl, - organization: organization ?? defaultOrganization, + const branding = await getBrandingSettings({ serviceConfig, organization: organization ?? defaultOrganization, }); return (
apps/login/src/app/(login)/idp/[provider]/complete-registration/page.tsx+3 −5 modified@@ -1,7 +1,7 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { RegisterFormIDPIncomplete } from "@/components/register-form-idp-incomplete"; import { Translated } from "@/components/translated"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { getBrandingSettings } from "@/lib/zitadel"; import { headers } from "next/headers"; @@ -16,11 +16,9 @@ export default async function CompleteRegistrationPage(props: { const { id, token, requestId, organization, idpId, idpUserId, idpUserName, givenName, familyName, email } = searchParams; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); - const branding = await getBrandingSettings({ - serviceUrl, - organization, + const branding = await getBrandingSettings({ serviceConfig, organization, }); if (!id || !token || !idpId || !organization || !idpUserId || !idpUserName) {
apps/login/src/app/(login)/idp/[provider]/failure/page.tsx+6 −14 modified@@ -3,7 +3,7 @@ import { ChooseAuthenticatorToLogin } from "@/components/choose-authenticator-to import { DynamicTheme } from "@/components/dynamic-theme"; import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { getBrandingSettings, getLoginSettings, getUserByID, listAuthenticationMethodTypes } from "@/lib/zitadel"; import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; @@ -18,16 +18,12 @@ export default async function Page(props: { const { organization, userId } = searchParams; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); - const branding = await getBrandingSettings({ - serviceUrl, - organization, + const branding = await getBrandingSettings({ serviceConfig, organization, }); - const loginSettings = await getLoginSettings({ - serviceUrl, - organization, + const loginSettings = await getLoginSettings({ serviceConfig, organization, }); let authMethods: AuthenticationMethodType[] = []; @@ -43,9 +39,7 @@ export default async function Page(props: { } if (userId) { - const userResponse = await getUserByID({ - serviceUrl, - userId, + const userResponse = await getUserByID({ serviceConfig, userId, }); if (userResponse) { user = userResponse.user; @@ -58,9 +52,7 @@ export default async function Page(props: { } } - const authMethodsResponse = await listAuthenticationMethodTypes({ - serviceUrl, - userId, + const authMethodsResponse = await listAuthenticationMethodTypes({ serviceConfig, userId, }); if (authMethodsResponse.authMethodTypes) { authMethods = authMethodsResponse.authMethodTypes;
apps/login/src/app/(login)/idp/[provider]/linking-failed/page.tsx+3 −5 modified@@ -1,6 +1,6 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { Translated } from "@/components/translated"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { getBrandingSettings } from "@/lib/zitadel"; import { headers } from "next/headers"; @@ -15,11 +15,9 @@ export default async function LinkingFailedPage(props: { const { organization, error } = searchParams; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); - const branding = await getBrandingSettings({ - serviceUrl, - organization, + const branding = await getBrandingSettings({ serviceConfig, organization, }); return (
apps/login/src/app/(login)/idp/[provider]/registration-failed/page.tsx+4 −8 modified@@ -2,7 +2,7 @@ import { Alert, AlertType } from "@/components/alert"; import { Button } from "@/components/button"; import { DynamicTheme } from "@/components/dynamic-theme"; import { Translated } from "@/components/translated"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { getBrandingSettings, getDefaultOrg } from "@/lib/zitadel"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; import { Metadata } from "next"; @@ -20,21 +20,17 @@ export default async function Page(props: { searchParams: Promise<Record<string const { organization, postErrorRedirectUrl } = searchParams; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); let defaultOrganization; if (!organization) { - const org: Organization | null = await getDefaultOrg({ - serviceUrl, - }); + const org: Organization | null = await getDefaultOrg({ serviceConfig, }); if (org) { defaultOrganization = org.id; } } - const branding = await getBrandingSettings({ - serviceUrl, - organization: organization ?? defaultOrganization, + const branding = await getBrandingSettings({ serviceConfig, organization: organization ?? defaultOrganization, }); return (
apps/login/src/app/(login)/loginname/page.tsx+7 −17 modified@@ -2,7 +2,7 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { SignInWithIdp } from "@/components/sign-in-with-idp"; import { Translated } from "@/components/translated"; import { UsernameForm } from "@/components/username-form"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { getActiveIdentityProviders, getBrandingSettings, getDefaultOrg, getLoginSettings } from "@/lib/zitadel"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; import { Metadata } from "next"; @@ -24,38 +24,28 @@ export default async function Page(props: { searchParams: Promise<Record<string const submit: boolean = searchParams?.submit === "true"; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); let defaultOrganization; if (!organization) { - const org: Organization | null = await getDefaultOrg({ - serviceUrl, - }); + const org: Organization | null = await getDefaultOrg({ serviceConfig, }); if (org) { defaultOrganization = org.id; } } - const loginSettings = await getLoginSettings({ - serviceUrl, - organization: organization ?? defaultOrganization, + const loginSettings = await getLoginSettings({ serviceConfig, organization: organization ?? defaultOrganization, }); - const contextLoginSettings = await getLoginSettings({ - serviceUrl, - organization, + const contextLoginSettings = await getLoginSettings({ serviceConfig, organization, }); - const identityProviders = await getActiveIdentityProviders({ - serviceUrl, - orgId: organization ?? defaultOrganization, + const identityProviders = await getActiveIdentityProviders({ serviceConfig, orgId: organization ?? defaultOrganization, }).then((resp) => { return resp.identityProviders; }); - const branding = await getBrandingSettings({ - serviceUrl, - organization: organization ?? defaultOrganization, + const branding = await getBrandingSettings({ serviceConfig, organization: organization ?? defaultOrganization, }); return (
apps/login/src/app/(login)/logout/done/page.tsx+3 −5 modified@@ -1,20 +1,18 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { Translated } from "@/components/translated"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { getBrandingSettings } from "@/lib/zitadel"; import { headers } from "next/headers"; export default async function Page(props: { searchParams: Promise<any> }) { const searchParams = await props.searchParams; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); const { organization } = searchParams; - const branding = await getBrandingSettings({ - serviceUrl, - organization, + const branding = await getBrandingSettings({ serviceConfig, organization, }); return (
apps/login/src/app/(login)/logout/page.tsx+35 −21 modified@@ -2,26 +2,24 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { SessionsClearList } from "@/components/sessions-clear-list"; import { Translated } from "@/components/translated"; import { getAllSessionCookieIds } from "@/lib/cookies"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; -import { getBrandingSettings, getDefaultOrg, listSessions } from "@/lib/zitadel"; +import { getServiceConfig } from "@/lib/service-url"; +import { getBrandingSettings, getDefaultOrg, listSessions, ServiceConfig } from "@/lib/zitadel"; +import { verifyJwt } from "@zitadel/client/node"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; import { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { headers } from "next/headers"; export async function generateMetadata(): Promise<Metadata> { const t = await getTranslations("logout"); - return { title: t('title')}; + return { title: t("title") }; } -async function loadSessions({ serviceUrl }: { serviceUrl: string }) { +async function loadSessions({ serviceConfig }: { serviceConfig: ServiceConfig }) { const cookieIds = await getAllSessionCookieIds(); if (cookieIds && cookieIds.length) { - const response = await listSessions({ - serviceUrl, - ids: cookieIds.filter((id) => !!id) as string[], - }); + const response = await listSessions({ serviceConfig, ids: cookieIds.filter((id) => !!id) as string[] }); return response?.sessions ?? []; } else { console.info("No session cookie found."); @@ -33,30 +31,46 @@ export default async function Page(props: { searchParams: Promise<Record<string const searchParams = await props.searchParams; const organization = searchParams?.organization; - const postLogoutRedirectUri = searchParams?.post_logout_redirect || searchParams?.post_logout_redirect_uri; - const logoutHint = searchParams?.logout_hint; - // TODO implement with new translation service - // const UILocales = searchParams?.ui_locales; + const logoutToken = searchParams?.logout_token; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); + + let postLogoutRedirectUri, logoutHint; + if (logoutToken) { + try { + const payload = await verifyJwt<{ post_logout_redirect_uri?: string; logoutHint?: string }>( + logoutToken, + `${serviceConfig.baseUrl}/oauth/v2/keys`, + { + instanceHost: serviceConfig.instanceHost, + publicHost: serviceConfig.publicHost, + }, + ); + console.log("logout token payload", payload); + + if (payload.post_logout_redirect_uri && typeof payload.post_logout_redirect_uri === "string") { + postLogoutRedirectUri = payload.post_logout_redirect_uri; + } + if (payload.logout_hint && typeof payload.logout_hint === "string") { + logoutHint = payload.logout_hint; + } + } catch (error) { + console.error("Failed to verify logout token", error); + } + } let defaultOrganization; if (!organization) { - const org: Organization | null = await getDefaultOrg({ - serviceUrl, - }); + const org: Organization | null = await getDefaultOrg({ serviceConfig }); if (org) { defaultOrganization = org.id; } } - let sessions = await loadSessions({ serviceUrl }); + let sessions = await loadSessions({ serviceConfig }); - const branding = await getBrandingSettings({ - serviceUrl, - organization: organization ?? defaultOrganization, - }); + const branding = await getBrandingSettings({ serviceConfig, organization: organization ?? defaultOrganization }); const params = new URLSearchParams();
apps/login/src/app/(login)/mfa/page.tsx+9 −19 modified@@ -5,7 +5,7 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; import { getSessionCookieById } from "@/lib/cookies"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; import { getBrandingSettings, getSession, listAuthenticationMethodTypes } from "@/lib/zitadel"; import { Metadata } from "next"; @@ -23,24 +23,20 @@ export default async function Page(props: { searchParams: Promise<Record<string const { loginName, requestId, organization, sessionId } = searchParams; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); const sessionFactors = sessionId - ? await loadSessionById(serviceUrl, sessionId, organization) - : await loadSessionByLoginname(serviceUrl, loginName, organization); + ? await loadSessionById(serviceConfig.baseUrl, sessionId, organization) + : await loadSessionByLoginname(serviceConfig.baseUrl, loginName, organization); async function loadSessionByLoginname(serviceUrl: string, loginName?: string, organization?: string) { - return loadMostRecentSession({ - serviceUrl, - sessionParams: { + return loadMostRecentSession({ serviceConfig, sessionParams: { loginName, organization, }, }).then((session) => { if (session && session.factors?.user?.id) { - return listAuthenticationMethodTypes({ - serviceUrl, - userId: session.factors.user.id, + return listAuthenticationMethodTypes({ serviceConfig, userId: session.factors.user.id, }).then((methods) => { return { factors: session?.factors, @@ -53,15 +49,11 @@ export default async function Page(props: { searchParams: Promise<Record<string async function loadSessionById(host: string, sessionId: string, organization?: string) { const recent = await getSessionCookieById({ sessionId, organization }); - return getSession({ - serviceUrl, - sessionId: recent.id, + return getSession({ serviceConfig, sessionId: recent.id, sessionToken: recent.token, }).then((response) => { if (response?.session && response.session.factors?.user?.id) { - return listAuthenticationMethodTypes({ - serviceUrl, - userId: response.session.factors.user.id, + return listAuthenticationMethodTypes({ serviceConfig, userId: response.session.factors.user.id, }).then((methods) => { return { factors: response.session?.factors, @@ -72,9 +64,7 @@ export default async function Page(props: { searchParams: Promise<Record<string }); } - const branding = await getBrandingSettings({ - serviceUrl, - organization, + const branding = await getBrandingSettings({ serviceConfig, organization, }); return (
apps/login/src/app/(login)/mfa/set/page.tsx+8 −18 modified@@ -5,7 +5,7 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; import { getSessionCookieById } from "@/lib/cookies"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; import { getBrandingSettings, @@ -46,7 +46,7 @@ export default async function Page(props: { searchParams: Promise<Record<string const { loginName, checkAfter, force, requestId, organization, sessionId } = searchParams; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); const sessionWithData = sessionId ? await loadSessionById(sessionId, organization) @@ -59,11 +59,9 @@ export default async function Page(props: { searchParams: Promise<Record<string throw Error("Could not get user id from session"); } - return listAuthenticationMethodTypes({ - serviceUrl, - userId, + return listAuthenticationMethodTypes({ serviceConfig, userId, }).then((methods) => { - return getUserByID({ serviceUrl, userId }).then((user) => { + return getUserByID({ serviceConfig, userId }).then((user) => { const humanUser = user.user?.type.case === "human" ? user.user?.type.value : undefined; return { @@ -79,9 +77,7 @@ export default async function Page(props: { searchParams: Promise<Record<string } async function loadSessionByLoginname(loginName?: string, organization?: string) { - return loadMostRecentSession({ - serviceUrl, - sessionParams: { + return loadMostRecentSession({ serviceConfig, sessionParams: { loginName, organization, }, @@ -92,22 +88,16 @@ export default async function Page(props: { searchParams: Promise<Record<string async function loadSessionById(sessionId: string, organization?: string) { const recent = await getSessionCookieById({ sessionId, organization }); - return getSession({ - serviceUrl, - sessionId: recent.id, + return getSession({ serviceConfig, sessionId: recent.id, sessionToken: recent.token, }).then((sessionResponse) => { return getAuthMethodsAndUser(sessionResponse.session); }); } - const branding = await getBrandingSettings({ - serviceUrl, - organization, + const branding = await getBrandingSettings({ serviceConfig, organization, }); - const loginSettings = await getLoginSettings({ - serviceUrl, - organization: sessionWithData.factors?.user?.organizationId, + const loginSettings = await getLoginSettings({ serviceConfig, organization: sessionWithData.factors?.user?.organizationId, }); const { valid } = isSessionValid(sessionWithData);
apps/login/src/app/(login)/otp/[method]/page.tsx+8 −15 modified@@ -4,8 +4,8 @@ import { LoginOTP } from "@/components/login-otp"; import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; import { getSessionCookieById } from "@/lib/cookies"; -import { getOriginalHost } from "@/lib/server/host"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getPublicHost } from "@/lib/server/host"; +import { getServiceConfig } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; import { getBrandingSettings, getLoginSettings, getSession } from "@/lib/zitadel"; import { Metadata } from "next"; @@ -25,8 +25,8 @@ export default async function Page(props: { const searchParams = await props.searchParams; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); - const host = await getOriginalHost(); + const { serviceConfig } = getServiceConfig(_headers); + const host = getPublicHost(_headers); const { loginName, // send from password page @@ -40,18 +40,11 @@ export default async function Page(props: { const session = sessionId ? await loadSessionById(sessionId, organization) - : await loadMostRecentSession({ - serviceUrl, - sessionParams: { loginName, organization }, - }); + : await loadMostRecentSession({ serviceConfig, sessionParams: { loginName, organization } }); async function loadSessionById(sessionId: string, organization?: string) { const recent = await getSessionCookieById({ sessionId, organization }); - return getSession({ - serviceUrl, - sessionId: recent.id, - sessionToken: recent.token, - }).then((response) => { + return getSession({ serviceConfig, sessionId: recent.id, sessionToken: recent.token }).then((response) => { if (response?.session) { return response.session; } @@ -60,12 +53,12 @@ export default async function Page(props: { // email links do not come with organization, thus we need to use the session's organization const branding = await getBrandingSettings({ - serviceUrl, + serviceConfig, organization: organization ?? session?.factors?.user?.organizationId, }); const loginSettings = await getLoginSettings({ - serviceUrl, + serviceConfig, organization: organization ?? session?.factors?.user?.organizationId, });
apps/login/src/app/(login)/otp/[method]/set/page.tsx+8 −20 modified@@ -5,7 +5,7 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { TotpRegister } from "@/components/totp-register"; import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; import { addOTPEmail, addOTPSMS, getBrandingSettings, getLoginSettings, registerTOTP } from "@/lib/zitadel"; import { RegisterTOTPResponse } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; @@ -24,20 +24,14 @@ export default async function Page(props: { const { method } = params; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); - const branding = await getBrandingSettings({ - serviceUrl, - organization, + const branding = await getBrandingSettings({ serviceConfig, organization, }); - const loginSettings = await getLoginSettings({ - serviceUrl, - organization, + const loginSettings = await getLoginSettings({ serviceConfig, organization, }); - const session = await loadMostRecentSession({ - serviceUrl, - sessionParams: { + const session = await loadMostRecentSession({ serviceConfig, sessionParams: { loginName, organization, }, @@ -46,9 +40,7 @@ export default async function Page(props: { let totpResponse: RegisterTOTPResponse | undefined, error: Error | undefined; if (session && session.factors?.user?.id) { if (method === "time-based") { - await registerTOTP({ - serviceUrl, - userId: session.factors.user.id, + await registerTOTP({ serviceConfig, userId: session.factors.user.id, }) .then((resp) => { if (resp) { @@ -59,17 +51,13 @@ export default async function Page(props: { error = err; }); } else if (method === "sms") { - await addOTPSMS({ - serviceUrl, - userId: session.factors.user.id, + await addOTPSMS({ serviceConfig, userId: session.factors.user.id, }).catch((_error) => { // TODO: Throw this error? new Error("Could not add OTP via SMS"); }); } else if (method === "email") { - await addOTPEmail({ - serviceUrl, - userId: session.factors.user.id, + await addOTPEmail({ serviceConfig, userId: session.factors.user.id, }).catch((_error) => { // TODO: Throw this error? new Error("Could not add OTP via Email");
apps/login/src/app/(login)/passkey/page.tsx+6 −12 modified@@ -4,7 +4,7 @@ import { LoginPasskey } from "@/components/login-passkey"; import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; import { getSessionCookieById } from "@/lib/cookies"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; import { getBrandingSettings, getSession } from "@/lib/zitadel"; import { Metadata } from "next"; @@ -22,20 +22,16 @@ export default async function Page(props: { searchParams: Promise<Record<string const { loginName, altPassword, requestId, organization, sessionId } = searchParams; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); const sessionFactors = sessionId - ? await loadSessionById(serviceUrl, sessionId, organization) - : await loadMostRecentSession({ - serviceUrl, - sessionParams: { loginName, organization }, + ? await loadSessionById(serviceConfig.baseUrl, sessionId, organization) + : await loadMostRecentSession({ serviceConfig, sessionParams: { loginName, organization }, }); async function loadSessionById(serviceUrl: string, sessionId: string, organization?: string) { const recent = await getSessionCookieById({ sessionId, organization }); - return getSession({ - serviceUrl, - sessionId: recent.id, + return getSession({ serviceConfig, sessionId: recent.id, sessionToken: recent.token, }).then((response) => { if (response?.session) { @@ -44,9 +40,7 @@ export default async function Page(props: { searchParams: Promise<Record<string }); } - const branding = await getBrandingSettings({ - serviceUrl, - organization, + const branding = await getBrandingSettings({ serviceConfig, organization, }); return (
apps/login/src/app/(login)/passkey/set/page.tsx+5 −11 modified@@ -3,7 +3,7 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { RegisterPasskey } from "@/components/register-passkey"; import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; import { getBrandingSettings, getUserByID } from "@/lib/zitadel"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; @@ -23,31 +23,25 @@ export default async function Page(props: { searchParams: Promise<Record<string const { userId, loginName, prompt, organization, requestId, code, codeId } = searchParams; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); // also allow no session to be found for userId-based flows let session: Session | undefined; if (loginName) { - session = await loadMostRecentSession({ - serviceUrl, - sessionParams: { + session = await loadMostRecentSession({ serviceConfig, sessionParams: { loginName, organization, }, }); } - const branding = await getBrandingSettings({ - serviceUrl, - organization, + const branding = await getBrandingSettings({ serviceConfig, organization, }); let user: User | undefined; let displayName: string | undefined; if (userId) { - const userResponse = await getUserByID({ - serviceUrl, - userId, + const userResponse = await getUserByID({ serviceConfig, userId, }); user = userResponse.user;
apps/login/src/app/(login)/password/change/page.tsx+6 −14 modified@@ -3,7 +3,7 @@ import { ChangePasswordForm } from "@/components/change-password-form"; import { DynamicTheme } from "@/components/dynamic-theme"; import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; import { getBrandingSettings, getLoginSettings, getPasswordComplexitySettings } from "@/lib/zitadel"; import { Metadata } from "next"; @@ -17,34 +17,26 @@ export async function generateMetadata(): Promise<Metadata> { export default async function Page(props: { searchParams: Promise<Record<string | number | symbol, string | undefined>> }) { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); const searchParams = await props.searchParams; const { loginName, organization, requestId } = searchParams; // also allow no session to be found (ignoreUnkownUsername) - const sessionFactors = await loadMostRecentSession({ - serviceUrl, - sessionParams: { + const sessionFactors = await loadMostRecentSession({ serviceConfig, sessionParams: { loginName, organization, }, }); - const branding = await getBrandingSettings({ - serviceUrl, - organization, + const branding = await getBrandingSettings({ serviceConfig, organization, }); - const passwordComplexity = await getPasswordComplexitySettings({ - serviceUrl, - organization: sessionFactors?.factors?.user?.organizationId, + const passwordComplexity = await getPasswordComplexitySettings({ serviceConfig, organization: sessionFactors?.factors?.user?.organizationId, }); - const loginSettings = await getLoginSettings({ - serviceUrl, - organization: sessionFactors?.factors?.user?.organizationId, + const loginSettings = await getLoginSettings({ serviceConfig, organization: sessionFactors?.factors?.user?.organizationId, }); return (
apps/login/src/app/(login)/password/page.tsx+6 −14 modified@@ -3,7 +3,7 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { PasswordForm } from "@/components/password-form"; import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; import { getBrandingSettings, getDefaultOrg, getLoginSettings } from "@/lib/zitadel"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; @@ -21,13 +21,11 @@ export default async function Page(props: { searchParams: Promise<Record<string let { loginName, organization, requestId } = searchParams; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); let defaultOrganization; if (!organization) { - const org: Organization | null = await getDefaultOrg({ - serviceUrl, - }); + const org: Organization | null = await getDefaultOrg({ serviceConfig, }); if (org) { defaultOrganization = org.id; @@ -37,9 +35,7 @@ export default async function Page(props: { searchParams: Promise<Record<string // also allow no session to be found (ignoreUnkownUsername) let sessionFactors; try { - sessionFactors = await loadMostRecentSession({ - serviceUrl, - sessionParams: { + sessionFactors = await loadMostRecentSession({ serviceConfig, sessionParams: { loginName, organization, }, @@ -49,13 +45,9 @@ export default async function Page(props: { searchParams: Promise<Record<string console.warn(error); } - const branding = await getBrandingSettings({ - serviceUrl, - organization: organization ?? defaultOrganization, + const branding = await getBrandingSettings({ serviceConfig, organization: organization ?? defaultOrganization, }); - const loginSettings = await getLoginSettings({ - serviceUrl, - organization: organization ?? defaultOrganization, + const loginSettings = await getLoginSettings({ serviceConfig, organization: organization ?? defaultOrganization, }); return (
apps/login/src/app/(login)/password/set/page.tsx+7 −17 modified@@ -3,7 +3,7 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { SetPasswordForm } from "@/components/set-password-form"; import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; import { getBrandingSettings, getLoginSettings, getPasswordComplexitySettings, getUserByID } from "@/lib/zitadel"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; @@ -23,41 +23,31 @@ export default async function Page(props: { searchParams: Promise<Record<string const { userId, loginName, organization, requestId, code, initial } = searchParams; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); // also allow no session to be found (ignoreUnkownUsername) let session: Session | undefined; if (loginName) { - session = await loadMostRecentSession({ - serviceUrl, - sessionParams: { + session = await loadMostRecentSession({ serviceConfig, sessionParams: { loginName, organization, }, }); } - const branding = await getBrandingSettings({ - serviceUrl, - organization, + const branding = await getBrandingSettings({ serviceConfig, organization, }); - const passwordComplexity = await getPasswordComplexitySettings({ - serviceUrl, - organization: session?.factors?.user?.organizationId, + const passwordComplexity = await getPasswordComplexitySettings({ serviceConfig, organization: session?.factors?.user?.organizationId, }); - const loginSettings = await getLoginSettings({ - serviceUrl, - organization, + const loginSettings = await getLoginSettings({ serviceConfig, organization, }); let user: User | undefined; let displayName: string | undefined; if (userId) { - const userResponse = await getUserByID({ - serviceUrl, - userId, + const userResponse = await getUserByID({ serviceConfig, userId, }); user = userResponse.user;
apps/login/src/app/(login)/register/page.tsx+8 −20 modified@@ -3,7 +3,7 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { RegisterForm } from "@/components/register-form"; import { SignInWithIdp } from "@/components/sign-in-with-idp"; import { Translated } from "@/components/translated"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { getActiveIdentityProviders, getBrandingSettings, @@ -29,39 +29,27 @@ export default async function Page(props: { searchParams: Promise<Record<string let { firstname, lastname, email, organization, requestId } = searchParams; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); if (!organization) { - const org: Organization | null = await getDefaultOrg({ - serviceUrl, - }); + const org: Organization | null = await getDefaultOrg({ serviceConfig, }); if (org) { organization = org.id; } } - const legal = await getLegalAndSupportSettings({ - serviceUrl, - organization, + const legal = await getLegalAndSupportSettings({ serviceConfig, organization, }); - const passwordComplexitySettings = await getPasswordComplexitySettings({ - serviceUrl, - organization, + const passwordComplexitySettings = await getPasswordComplexitySettings({ serviceConfig, organization, }); - const branding = await getBrandingSettings({ - serviceUrl, - organization, + const branding = await getBrandingSettings({ serviceConfig, organization, }); - const loginSettings = await getLoginSettings({ - serviceUrl, - organization, + const loginSettings = await getLoginSettings({ serviceConfig, organization, }); - const identityProviders = await getActiveIdentityProviders({ - serviceUrl, - orgId: organization, + const identityProviders = await getActiveIdentityProviders({ serviceConfig, orgId: organization, }).then((resp) => { return resp.identityProviders.filter((idp) => { return idp.options?.isAutoCreation || idp.options?.isCreationAllowed; // check if IDP allows to create account automatically or manual creation is allowed
apps/login/src/app/(login)/register/password/page.tsx+7 −17 modified@@ -1,7 +1,7 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { SetRegisterPasswordForm } from "@/components/set-register-password-form"; import { Translated } from "@/components/translated"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { getBrandingSettings, getDefaultOrg, @@ -18,36 +18,26 @@ export default async function Page(props: { searchParams: Promise<Record<string let { firstname, lastname, email, organization, requestId } = searchParams; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); if (!organization) { - const org: Organization | null = await getDefaultOrg({ - serviceUrl, - }); + const org: Organization | null = await getDefaultOrg({ serviceConfig, }); if (org) { organization = org.id; } } const missingData = !firstname || !lastname || !email || !organization; - const legal = await getLegalAndSupportSettings({ - serviceUrl, - organization, + const legal = await getLegalAndSupportSettings({ serviceConfig, organization, }); - const passwordComplexitySettings = await getPasswordComplexitySettings({ - serviceUrl, - organization, + const passwordComplexitySettings = await getPasswordComplexitySettings({ serviceConfig, organization, }); - const branding = await getBrandingSettings({ - serviceUrl, - organization, + const branding = await getBrandingSettings({ serviceConfig, organization, }); - const loginSettings = await getLoginSettings({ - serviceUrl, - organization, + const loginSettings = await getLoginSettings({ serviceConfig, organization, }); return missingData ? (
apps/login/src/app/login/route.ts+7 −16 modified@@ -1,8 +1,8 @@ import { getAllSessions } from "@/lib/cookies"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { validateAuthRequest, isRSCRequest } from "@/lib/auth-utils"; import { handleOIDCFlowInitiation, handleSAMLFlowInitiation, FlowInitiationParams } from "@/lib/server/flow-initiation"; -import { listSessions } from "@/lib/zitadel"; +import { listSessions, ServiceConfig } from "@/lib/zitadel"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { headers } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; @@ -13,18 +13,15 @@ export const fetchCache = "default-no-store"; // Add this to prevent RSC requests export const runtime = "nodejs"; -async function loadSessions({ serviceUrl, ids }: { serviceUrl: string; ids: string[] }): Promise<Session[]> { - const response = await listSessions({ - serviceUrl, - ids: ids.filter((id: string | undefined) => !!id), - }); +async function loadSessions({ serviceConfig, ids }: { serviceConfig: ServiceConfig; ids: string[] }): Promise<Session[]> { + const response = await listSessions({ serviceConfig, ids: ids.filter((id: string | undefined) => !!id) }); return response?.sessions ?? []; } export async function GET(request: NextRequest) { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); const searchParams = request.nextUrl.searchParams; @@ -43,17 +40,11 @@ export async function GET(request: NextRequest) { const ids = sessionCookies.map((s) => s.id); let sessions: Session[] = []; if (ids && ids.length) { - sessions = await loadSessions({ serviceUrl, ids }); + sessions = await loadSessions({ serviceConfig, ids }); } // Flow initiation - delegate to appropriate handler - const flowParams: FlowInitiationParams = { - serviceUrl, - requestId, - sessions, - sessionCookies, - request, - }; + const flowParams: FlowInitiationParams = { serviceConfig, requestId, sessions, sessionCookies, request }; if (requestId.startsWith("oidc_")) { return handleOIDCFlowInitiation(flowParams);
apps/login/src/app/(login)/signedin/page.tsx+9 −17 modified@@ -5,9 +5,9 @@ import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; import { getMostRecentCookieWithLoginname, getSessionCookieById } from "@/lib/cookies"; import { completeDeviceAuthorization } from "@/lib/server/device"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; -import { getBrandingSettings, getLoginSettings, getSession } from "@/lib/zitadel"; +import { getBrandingSettings, getLoginSettings, getSession, ServiceConfig } from "@/lib/zitadel"; import { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { headers } from "next/headers"; @@ -18,11 +18,9 @@ export async function generateMetadata(): Promise<Metadata> { return { title: t("title", { user: "" }) }; } -async function loadSessionById(serviceUrl: string, sessionId: string, organization?: string) { +async function loadSessionById(serviceConfig: ServiceConfig, sessionId: string, organization?: string) { const recent = await getSessionCookieById({ sessionId, organization }); - return getSession({ - serviceUrl, - sessionId: recent.id, + return getSession({ serviceConfig, sessionId: recent.id, sessionToken: recent.token, }).then((response) => { if (response?.session) { @@ -35,13 +33,11 @@ export default async function Page(props: { searchParams: Promise<any> }) { const searchParams = await props.searchParams; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); const { loginName, requestId, organization, sessionId } = searchParams; - const branding = await getBrandingSettings({ - serviceUrl, - organization, + const branding = await getBrandingSettings({ serviceConfig, organization, }); // complete device authorization flow if device requestId is present @@ -75,17 +71,13 @@ export default async function Page(props: { searchParams: Promise<any> }) { } const sessionFactors = sessionId - ? await loadSessionById(serviceUrl, sessionId, organization) - : await loadMostRecentSession({ - serviceUrl, - sessionParams: { loginName, organization }, + ? await loadSessionById(serviceConfig, sessionId, organization) + : await loadMostRecentSession({ serviceConfig, sessionParams: { loginName, organization }, }); let loginSettings; if (!requestId) { - loginSettings = await getLoginSettings({ - serviceUrl, - organization, + loginSettings = await getLoginSettings({ serviceConfig, organization, }); }
apps/login/src/app/(login)/u2f/page.tsx+5 −11 modified@@ -4,7 +4,7 @@ import { LoginPasskey } from "@/components/login-passkey"; import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; import { getSessionCookieById } from "@/lib/cookies"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; import { getBrandingSettings, getSession } from "@/lib/zitadel"; import { Metadata } from "next"; @@ -22,25 +22,19 @@ export default async function Page(props: { searchParams: Promise<Record<string const { loginName, requestId, sessionId, organization } = searchParams; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); - const branding = await getBrandingSettings({ - serviceUrl, - organization, + const branding = await getBrandingSettings({ serviceConfig, organization, }); const sessionFactors = sessionId ? await loadSessionById(sessionId, organization) - : await loadMostRecentSession({ - serviceUrl, - sessionParams: { loginName, organization }, + : await loadMostRecentSession({ serviceConfig, sessionParams: { loginName, organization }, }); async function loadSessionById(sessionId: string, organization?: string) { const recent = await getSessionCookieById({ sessionId, organization }); - return getSession({ - serviceUrl, - sessionId: recent.id, + return getSession({ serviceConfig, sessionId: recent.id, sessionToken: recent.token, }).then((response) => { if (response?.session) {
apps/login/src/app/(login)/u2f/set/page.tsx+4 −8 modified@@ -3,7 +3,7 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { RegisterU2f } from "@/components/register-u2f"; import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; import { getBrandingSettings } from "@/lib/zitadel"; import { Metadata } from "next"; @@ -21,19 +21,15 @@ export default async function Page(props: { searchParams: Promise<Record<string const { loginName, organization, requestId, checkAfter } = searchParams; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); - const sessionFactors = await loadMostRecentSession({ - serviceUrl, - sessionParams: { + const sessionFactors = await loadMostRecentSession({ serviceConfig, sessionParams: { loginName, organization, }, }); - const branding = await getBrandingSettings({ - serviceUrl, - organization, + const branding = await getBrandingSettings({ serviceConfig, organization, }); return (
apps/login/src/app/(login)/verify/page.tsx+7 −13 modified@@ -3,9 +3,9 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; import { VerifyForm } from "@/components/verify-form"; +import { getPublicHostWithProtocol } from "@/lib/server/host"; import { sendEmailCode, sendInviteEmailCode } from "@/lib/server/verify"; -import { getOriginalHostWithProtocol } from "@/lib/server/host"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; import { getBrandingSettings, getUserByID } from "@/lib/zitadel"; import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb"; @@ -24,12 +24,9 @@ export default async function Page(props: { searchParams: Promise<any> }) { const { userId, loginName, code, organization, requestId, invite, send } = searchParams; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); - const branding = await getBrandingSettings({ - serviceUrl, - organization, - }); + const branding = await getBrandingSettings({ serviceConfig, organization }); let sessionFactors; let user: User | undefined; @@ -43,7 +40,7 @@ export default async function Page(props: { searchParams: Promise<any> }) { const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; async function sendEmail(userId: string) { - const hostWithProtocol = await getOriginalHostWithProtocol(); + const hostWithProtocol = await getPublicHostWithProtocol(_headers); if (invite === "true") { await sendInviteEmailCode({ @@ -70,7 +67,7 @@ export default async function Page(props: { searchParams: Promise<any> }) { if ("loginName" in searchParams) { sessionFactors = await loadMostRecentSession({ - serviceUrl, + serviceConfig, sessionParams: { loginName, organization, @@ -85,10 +82,7 @@ export default async function Page(props: { searchParams: Promise<any> }) { await sendEmail(userId); } - const userResponse = await getUserByID({ - serviceUrl, - userId, - }); + const userResponse = await getUserByID({ serviceConfig, userId }); if (userResponse) { user = userResponse.user; if (user?.type.case === "human") {
apps/login/src/app/(login)/verify/success/page.tsx+5 −11 modified@@ -1,7 +1,7 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; import { getBrandingSettings, getUserByID } from "@/lib/zitadel"; import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb"; @@ -11,18 +11,14 @@ export default async function Page(props: { searchParams: Promise<any> }) { const searchParams = await props.searchParams; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); const { loginName, organization, userId } = searchParams; - const branding = await getBrandingSettings({ - serviceUrl, - organization, + const branding = await getBrandingSettings({ serviceConfig, organization, }); - const sessionFactors = await loadMostRecentSession({ - serviceUrl, - sessionParams: { loginName, organization }, + const sessionFactors = await loadMostRecentSession({ serviceConfig, sessionParams: { loginName, organization }, }).catch((error) => { console.warn("Error loading session:", error); }); @@ -33,9 +29,7 @@ export default async function Page(props: { searchParams: Promise<any> }) { throw Error("Failed to get user id"); } - const userResponse = await getUserByID({ - serviceUrl, - userId: id, + const userResponse = await getUserByID({ serviceConfig, userId: id, }); let user: User | undefined;
apps/login/src/app/security/route.ts+3 −3 modified@@ -1,16 +1,16 @@ import { createServiceForHost } from "@/lib/service"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { Client } from "@zitadel/client"; import { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; export async function GET() { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); const settingsService: Client<typeof SettingsService> = - await createServiceForHost(SettingsService, serviceUrl); + await createServiceForHost(SettingsService, serviceConfig); const settings = await settingsService .getSecuritySettings({})
apps/login/src/i18n/request.ts+3 −5 modified@@ -1,5 +1,5 @@ import { LANGS, LANGUAGE_COOKIE_NAME, LANGUAGE_HEADER_NAME } from "@/lib/i18n"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { getHostedLoginTranslation } from "@/lib/zitadel"; import { JsonObject } from "@zitadel/client"; import deepmerge from "deepmerge"; @@ -13,7 +13,7 @@ export default getRequestConfig(async () => { let locale: string = fallback; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); const languageHeader = await (await headers()).get(LANGUAGE_HEADER_NAME); if (languageHeader) { @@ -34,9 +34,7 @@ export default getRequestConfig(async () => { let translations: JsonObject | {} = {}; try { - const i18nJSON = await getHostedLoginTranslation({ - serviceUrl, - locale, + const i18nJSON = await getHostedLoginTranslation({ serviceConfig, locale, organization: i18nOrganization, });
apps/login/src/lib/deployment.test.ts+77 −0 added@@ -0,0 +1,77 @@ +import { describe, expect, test, beforeEach, afterEach } from "vitest"; +import { hasSystemUserCredentials, hasServiceUserToken } from "./deployment"; + +describe("Deployment utilities", () => { + const originalEnv = process.env; + + beforeEach(() => { + // Reset environment before each test + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe("hasSystemUserCredentials", () => { + test("should return true when all system user credentials are present", () => { + process.env.AUDIENCE = "https://api.zitadel.cloud"; + process.env.SYSTEM_USER_ID = "12345"; + process.env.SYSTEM_USER_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----\n..."; + + expect(hasSystemUserCredentials()).toBe(true); + }); + + test("should return false when AUDIENCE is missing", () => { + process.env.AUDIENCE = undefined as any; + process.env.SYSTEM_USER_ID = "12345"; + process.env.SYSTEM_USER_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----\n..."; + + expect(hasSystemUserCredentials()).toBe(false); + }); + + test("should return false when SYSTEM_USER_ID is missing", () => { + process.env.AUDIENCE = "https://api.zitadel.cloud"; + process.env.SYSTEM_USER_ID = undefined as any; + process.env.SYSTEM_USER_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----\n..."; + + expect(hasSystemUserCredentials()).toBe(false); + }); + + test("should return false when SYSTEM_USER_PRIVATE_KEY is missing", () => { + process.env.AUDIENCE = "https://api.zitadel.cloud"; + process.env.SYSTEM_USER_ID = "12345"; + process.env.SYSTEM_USER_PRIVATE_KEY = undefined as any; + + expect(hasSystemUserCredentials()).toBe(false); + }); + + test("should return false when all credentials are missing", () => { + process.env.AUDIENCE = undefined as any; + process.env.SYSTEM_USER_ID = undefined as any; + process.env.SYSTEM_USER_PRIVATE_KEY = undefined as any; + + expect(hasSystemUserCredentials()).toBe(false); + }); + }); + + describe("hasServiceUserToken", () => { + test("should return true when ZITADEL_SERVICE_USER_TOKEN is present", () => { + process.env.ZITADEL_SERVICE_USER_TOKEN = "token123"; + + expect(hasServiceUserToken()).toBe(true); + }); + + test("should return false when ZITADEL_SERVICE_USER_TOKEN is not set", () => { + process.env.ZITADEL_SERVICE_USER_TOKEN = undefined as any; + + expect(hasServiceUserToken()).toBe(false); + }); + + test("should return false when ZITADEL_SERVICE_USER_TOKEN is empty string", () => { + process.env.ZITADEL_SERVICE_USER_TOKEN = ""; + + expect(hasServiceUserToken()).toBe(false); + }); + }); +});
apps/login/src/lib/deployment.ts+24 −0 added@@ -0,0 +1,24 @@ +/** + * Checks if system user credentials are available for JWT authentication. + * + * System user authentication requires: + * - AUDIENCE: The API audience for JWT authentication + * - SYSTEM_USER_ID: The system user's ID + * - SYSTEM_USER_PRIVATE_KEY: The private key for JWT signing + * + * Both multi-tenant and self-hosted deployments can use system user authentication. + * + * @returns true if system user credentials are present, false otherwise + */ +export function hasSystemUserCredentials(): boolean { + return !!process.env.AUDIENCE && !!process.env.SYSTEM_USER_ID && !!process.env.SYSTEM_USER_PRIVATE_KEY; +} + +/** + * Checks if service user token is available for authentication. + * + * @returns true if ZITADEL_SERVICE_USER_TOKEN is present, false otherwise + */ +export function hasServiceUserToken(): boolean { + return !!process.env.ZITADEL_SERVICE_USER_TOKEN; +}
apps/login/src/lib/oidc.ts+6 −20 modified@@ -1,36 +1,27 @@ import { Cookie } from "@/lib/cookies"; import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname"; -import { createCallback, getLoginSettings } from "@/lib/zitadel"; +import { createCallback, getLoginSettings, ServiceConfig } from "@/lib/zitadel"; import { create } from "@zitadel/client"; import { CreateCallbackRequestSchema, SessionSchema } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { isSessionValid } from "./session"; type LoginWithOIDCAndSession = { - serviceUrl: string; + serviceConfig: ServiceConfig; authRequest: string; sessionId: string; sessions: Session[]; sessionCookies: Cookie[]; }; -export async function loginWithOIDCAndSession({ - serviceUrl, - authRequest, - sessionId, - sessions, - sessionCookies, -}: LoginWithOIDCAndSession): Promise<{ error: string } | { redirect: string }> { +export async function loginWithOIDCAndSession({ serviceConfig, authRequest, sessionId, sessions, sessionCookies }: LoginWithOIDCAndSession): Promise<{ error: string } | { redirect: string }> { console.log(`Login with session: ${sessionId} and authRequest: ${authRequest}`); const selectedSession = sessions.find((s) => s.id === sessionId); if (selectedSession && selectedSession.id) { console.log(`Found session ${selectedSession.id}`); - const isValid = await isSessionValid({ - serviceUrl, - session: selectedSession, - }); + const isValid = await isSessionValid({ serviceConfig, session: selectedSession }); console.log("Session is valid:", isValid); @@ -61,9 +52,7 @@ export async function loginWithOIDCAndSession({ }; try { - const { callbackUrl } = await createCallback({ - serviceUrl, - req: create(CreateCallbackRequestSchema, { + const { callbackUrl } = await createCallback({ serviceConfig, req: create(CreateCallbackRequestSchema, { authRequestId: authRequest, callbackKind: { case: "session", @@ -81,10 +70,7 @@ export async function loginWithOIDCAndSession({ // handle already handled gracefully as these could come up if old emails with requestId are used (reset password, register emails etc.) console.error(error); if (error && typeof error === "object" && "code" in error && error?.code === 9) { - const loginSettings = await getLoginSettings({ - serviceUrl, - organization: selectedSession.factors?.user?.organizationId, - }); + const loginSettings = await getLoginSettings({ serviceConfig, organization: selectedSession.factors?.user?.organizationId }); if (loginSettings?.defaultRedirectUri) { return { redirect: loginSettings.defaultRedirectUri };
apps/login/src/lib/saml.ts+6 −20 modified@@ -1,6 +1,6 @@ import { Cookie } from "@/lib/cookies"; import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname"; -import { createResponse, getLoginSettings } from "@/lib/zitadel"; +import { createResponse, getLoginSettings, ServiceConfig } from "@/lib/zitadel"; import { create } from "@zitadel/client"; import { CreateResponseRequestSchema } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; @@ -9,7 +9,7 @@ import { v4 as uuidv4 } from "uuid"; import { isSessionValid } from "./session"; type LoginWithSAMLAndSession = { - serviceUrl: string; + serviceConfig: ServiceConfig; samlRequest: string; sessionId: string; sessions: Session[]; @@ -82,24 +82,15 @@ export async function getSAMLFormCookie(uid: string): Promise<string | null> { } } -export async function loginWithSAMLAndSession({ - serviceUrl, - samlRequest, - sessionId, - sessions, - sessionCookies, -}: LoginWithSAMLAndSession): Promise<{ error: string } | { redirect: string }> { +export async function loginWithSAMLAndSession({ serviceConfig, samlRequest, sessionId, sessions, sessionCookies }: LoginWithSAMLAndSession): Promise<{ error: string } | { redirect: string }> { console.log(`Login with session: ${sessionId} and samlRequest: ${samlRequest}`); const selectedSession = sessions.find((s) => s.id === sessionId); if (selectedSession && selectedSession.id) { console.log(`Found session ${selectedSession.id}`); - const isValid = await isSessionValid({ - serviceUrl, - session: selectedSession, - }); + const isValid = await isSessionValid({ serviceConfig, session: selectedSession }); console.log("Session is valid:", isValid); @@ -129,9 +120,7 @@ export async function loginWithSAMLAndSession({ // works not with _rsc request try { - const { url } = await createResponse({ - serviceUrl, - req: create(CreateResponseRequestSchema, { + const { url } = await createResponse({ serviceConfig, req: create(CreateResponseRequestSchema, { samlRequestId: samlRequest, responseKind: { case: "session", @@ -149,10 +138,7 @@ export async function loginWithSAMLAndSession({ console.error(error); if (error && typeof error === "object" && "code" in error && error?.code === 9) { - const loginSettings = await getLoginSettings({ - serviceUrl, - organization: selectedSession.factors?.user?.organizationId, - }); + const loginSettings = await getLoginSettings({ serviceConfig, organization: selectedSession.factors?.user?.organizationId }); if (loginSettings?.defaultRedirectUri) { return { redirect: loginSettings.defaultRedirectUri };
apps/login/src/lib/self.ts+8 −13 modified@@ -3,14 +3,11 @@ import { createUserServiceClient } from "@zitadel/client/v2"; import { headers } from "next/headers"; import { getSessionCookieById } from "./cookies"; -import { getServiceUrlFromHeaders } from "./service-url"; -import { createServerTransport, getSession } from "./zitadel"; - -const myUserService = async (serviceUrl: string, sessionToken: string) => { - const transportPromise = await createServerTransport( - sessionToken, - serviceUrl, - ); +import { getServiceConfig } from "./service-url"; +import { createServerTransport, getSession, ServiceConfig } from "./zitadel"; + +const myUserService = async (serviceConfig: ServiceConfig, sessionToken: string) => { + const transportPromise = await createServerTransport(sessionToken, serviceConfig); return createUserServiceClient(transportPromise); }; @@ -22,21 +19,19 @@ export async function setMyPassword({ password: string; }) { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); const sessionCookie = await getSessionCookieById({ sessionId }); - const { session } = await getSession({ - serviceUrl, - sessionId: sessionCookie.id, + const { session } = await getSession({ serviceConfig, sessionId: sessionCookie.id, sessionToken: sessionCookie.token, }); if (!session) { return { error: "Could not load session" }; } - const service = await myUserService(serviceUrl, `${sessionCookie.token}`); + const service = await myUserService(serviceConfig, `${sessionCookie.token}`); if (!session?.factors?.user?.id) { return { error: "No user id found in session" };
apps/login/src/lib/server/auth-flow.ts+8 −14 modified@@ -3,8 +3,8 @@ import { getAllSessions } from "@/lib/cookies"; import { loginWithOIDCAndSession } from "@/lib/oidc"; import { loginWithSAMLAndSession } from "@/lib/saml"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; -import { listSessions } from "@/lib/zitadel"; +import { getServiceConfig } from "@/lib/service-url"; +import { listSessions, ServiceConfig } from "@/lib/zitadel"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { headers } from "next/headers"; @@ -14,10 +14,8 @@ export interface AuthFlowParams { organization?: string; } -async function loadSessions({ serviceUrl, ids }: { serviceUrl: string; ids: string[] }): Promise<Session[]> { - const response = await listSessions({ - serviceUrl, - ids: ids.filter((id: string | undefined) => !!id), +async function loadSessions({ serviceConfig, ids }: { serviceConfig: ServiceConfig; ids: string[] }): Promise<Session[]> { + const response = await listSessions({ serviceConfig, ids: ids.filter((id: string | undefined) => !!id), }); return response?.sessions ?? []; @@ -33,21 +31,19 @@ export async function completeAuthFlow(command: AuthFlowParams): Promise<{ error const { sessionId, requestId } = command; const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); const sessionCookies = await getAllSessions(); const ids = sessionCookies.map((s) => s.id); let sessions: Session[] = []; if (ids && ids.length) { - sessions = await loadSessions({ serviceUrl, ids }); + sessions = await loadSessions({ serviceConfig, ids }); } if (requestId.startsWith("oidc_")) { // Complete OIDC flow - const result = await loginWithOIDCAndSession({ - serviceUrl, - authRequest: requestId.replace("oidc_", ""), + const result = await loginWithOIDCAndSession({ serviceConfig, authRequest: requestId.replace("oidc_", ""), sessionId, sessions, sessionCookies, @@ -62,9 +58,7 @@ export async function completeAuthFlow(command: AuthFlowParams): Promise<{ error return result; } else if (requestId.startsWith("saml_")) { // Complete SAML flow - const result = await loginWithSAMLAndSession({ - serviceUrl, - samlRequest: requestId.replace("saml_", ""), + const result = await loginWithSAMLAndSession({ serviceConfig, samlRequest: requestId.replace("saml_", ""), sessionId, sessions, sessionCookies,
apps/login/src/lib/server/cookie.ts+13 −25 modified@@ -14,7 +14,7 @@ import { Challenges, RequestChallenges } from "@zitadel/proto/zitadel/session/v2 import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { headers } from "next/headers"; -import { getServiceUrlFromHeaders } from "../service-url"; +import { getServiceConfig } from "../service-url"; type CustomCookieData = { id: string; @@ -46,7 +46,7 @@ export async function createSessionAndUpdateCookie(command: { lifetime?: Duration; }): Promise<Session> { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); let sessionLifetime = command.lifetime; @@ -59,16 +59,12 @@ export async function createSessionAndUpdateCookie(command: { } as Duration; // for usecases where the lifetime is not specified (user discovery) } - const createdSession = await createSessionFromChecks({ - serviceUrl, - checks: command.checks, + const createdSession = await createSessionFromChecks({ serviceConfig, checks: command.checks, lifetime: sessionLifetime, }); if (createdSession) { - return getSession({ - serviceUrl, - sessionId: createdSession.sessionId, + return getSession({ serviceConfig, sessionId: createdSession.sessionId, sessionToken: createdSession.sessionToken, }).then(async (response) => { if (response?.session && response.session?.factors?.user?.loginName) { @@ -89,7 +85,7 @@ export async function createSessionAndUpdateCookie(command: { sessionCookie.organization = response.session.factors.user.organizationId; } - const securitySettings = await getSecuritySettings({ serviceUrl }); + const securitySettings = await getSecuritySettings({ serviceConfig }); const iFrameEnabled = !!securitySettings?.embeddedIframe?.enabled; await addSessionToCookie({ session: sessionCookie, iFrameEnabled }); @@ -119,7 +115,7 @@ export async function createSessionForIdpAndUpdateCookie({ lifetime?: Duration; }): Promise<Session> { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); let sessionLifetime = lifetime; @@ -132,9 +128,7 @@ export async function createSessionForIdpAndUpdateCookie({ } as Duration; } - const createdSession = await createSessionForUserIdAndIdpIntent({ - serviceUrl, - userId, + const createdSession = await createSessionForUserIdAndIdpIntent({ serviceConfig, userId, idpIntent, lifetime: sessionLifetime, }).catch((error: ErrorDetail | CredentialsCheckError) => { @@ -152,9 +146,7 @@ export async function createSessionForIdpAndUpdateCookie({ throw "Could not create session"; } - const { session } = await getSession({ - serviceUrl, - sessionId: createdSession.sessionId, + const { session } = await getSession({ serviceConfig, sessionId: createdSession.sessionId, sessionToken: createdSession.sessionToken, }); @@ -180,7 +172,7 @@ export async function createSessionForIdpAndUpdateCookie({ sessionCookie.organization = session.factors.user.organizationId; } - const securitySettings = await getSecuritySettings({ serviceUrl }); + const securitySettings = await getSecuritySettings({ serviceConfig }); const iFrameEnabled = !!securitySettings?.embeddedIframe?.enabled; return addSessionToCookie({ session: sessionCookie, iFrameEnabled }).then(() => { @@ -200,11 +192,9 @@ export async function setSessionAndUpdateCookie(command: { lifetime: Duration; }) { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); - return setSession({ - serviceUrl, - sessionId: command.recentCookie.id, + return setSession({ serviceConfig, sessionId: command.recentCookie.id, sessionToken: command.recentCookie.token, challenges: command.challenges, checks: command.checks, @@ -227,9 +217,7 @@ export async function setSessionAndUpdateCookie(command: { sessionCookie.requestId = command.requestId; } - return getSession({ - serviceUrl, - sessionId: sessionCookie.id, + return getSession({ serviceConfig, sessionId: sessionCookie.id, sessionToken: sessionCookie.token, }).then(async (response) => { if (!response?.session || !response.session.factors?.user?.loginName) { @@ -252,7 +240,7 @@ export async function setSessionAndUpdateCookie(command: { newCookie.requestId = sessionCookie.requestId; } - const securitySettings = await getSecuritySettings({ serviceUrl }); + const securitySettings = await getSecuritySettings({ serviceConfig }); const iFrameEnabled = !!securitySettings?.embeddedIframe?.enabled; return updateSessionCookie({
apps/login/src/lib/server/device.ts+3 −5 modified@@ -2,19 +2,17 @@ import { authorizeOrDenyDeviceAuthorization } from "@/lib/zitadel"; import { headers } from "next/headers"; -import { getServiceUrlFromHeaders } from "../service-url"; +import { getServiceConfig } from "../service-url"; export async function completeDeviceAuthorization( deviceAuthorizationId: string, session?: { sessionId: string; sessionToken: string }, ) { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); // without the session, device auth request is denied - return authorizeOrDenyDeviceAuthorization({ - serviceUrl, - deviceAuthorizationId, + return authorizeOrDenyDeviceAuthorization({ serviceConfig, deviceAuthorizationId, session, }); }
apps/login/src/lib/server/flow-initiation.ts+16 −39 modified@@ -9,6 +9,7 @@ import { getSAMLRequest, getSecuritySettings, startIdentityProviderFlow, + ServiceConfig, } from "@/lib/zitadel"; import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname"; import { idpTypeToSlug } from "@/lib/idp"; @@ -47,7 +48,7 @@ const gotoAccounts = ({ }; export interface FlowInitiationParams { - serviceUrl: string; + serviceConfig: ServiceConfig; requestId: string; sessions: Session[]; sessionCookies: any[]; @@ -58,11 +59,9 @@ export interface FlowInitiationParams { * Handle OIDC flow initiation */ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Promise<NextResponse> { - const { serviceUrl, requestId, sessions, sessionCookies, request } = params; + const { serviceConfig, requestId, sessions, sessionCookies, request } = params; - const { authRequest } = await getAuthRequest({ - serviceUrl, - authRequestId: requestId.replace("oidc_", ""), + const { authRequest } = await getAuthRequest({ serviceConfig, authRequestId: requestId.replace("oidc_", ""), }); let organization = ""; @@ -85,9 +84,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr console.log("Extracted org domain:", orgDomain); if (orgDomain) { - const orgs = await getOrgsByDomain({ - serviceUrl, - domain: orgDomain, + const orgs = await getOrgsByDomain({ serviceConfig, domain: orgDomain, }); if (orgs.result && orgs.result.length === 1) { @@ -102,9 +99,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr const matched = IDP_SCOPE_REGEX.exec(idpScope); idpId = matched?.[1] ?? ""; - const identityProviders = await getActiveIdentityProviders({ - serviceUrl, - orgId: organization ? organization : undefined, + const identityProviders = await getActiveIdentityProviders({ serviceConfig, orgId: organization ? organization : undefined, }).then((resp) => { return resp.identityProviders; }); @@ -137,9 +132,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr params.set("organization", organization); } - let url: string | null = await startIdentityProviderFlow({ - serviceUrl, - idpId, + let url: string | null = await startIdentityProviderFlow({ serviceConfig, idpId, urls: { successUrl: `${origin}/idp/${provider}/process?` + new URLSearchParams(params), failureUrl: `${origin}/idp/${provider}/failure?` + new URLSearchParams(params), @@ -215,13 +208,9 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr } return NextResponse.redirect(loginNameUrl); } else if (authRequest.prompt.includes(Prompt.NONE)) { - const securitySettings = await getSecuritySettings({ - serviceUrl, - }); + const securitySettings = await getSecuritySettings({ serviceConfig, }); - const selectedSession = await findValidSession({ - serviceUrl, - sessions, + const selectedSession = await findValidSession({ serviceConfig, sessions, authRequest, }); @@ -251,9 +240,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr sessionToken: cookie.token, }; - const { callbackUrl } = await createCallback({ - serviceUrl, - req: create(CreateCallbackRequestSchema, { + const { callbackUrl } = await createCallback({ serviceConfig, req: create(CreateCallbackRequestSchema, { authRequestId: requestId.replace("oidc_", ""), callbackKind: { case: "session", @@ -275,9 +262,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr return callbackResponse; } else { - let selectedSession = await findValidSession({ - serviceUrl, - sessions, + let selectedSession = await findValidSession({ serviceConfig, sessions, authRequest, }); @@ -305,9 +290,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr }; try { - const { callbackUrl } = await createCallback({ - serviceUrl, - req: create(CreateCallbackRequestSchema, { + const { callbackUrl } = await createCallback({ serviceConfig, req: create(CreateCallbackRequestSchema, { authRequestId: requestId.replace("oidc_", ""), callbackKind: { case: "session", @@ -359,11 +342,9 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr * Handle SAML flow initiation */ export async function handleSAMLFlowInitiation(params: FlowInitiationParams): Promise<NextResponse> { - const { serviceUrl, requestId, sessions, sessionCookies, request } = params; + const { serviceConfig, requestId, sessions, sessionCookies, request } = params; - const { samlRequest } = await getSAMLRequest({ - serviceUrl, - samlRequestId: requestId.replace("saml_", ""), + const { samlRequest } = await getSAMLRequest({ serviceConfig, samlRequestId: requestId.replace("saml_", ""), }); if (!samlRequest) { @@ -378,9 +359,7 @@ export async function handleSAMLFlowInitiation(params: FlowInitiationParams): Pr } // Try to find a valid session - let selectedSession = await findValidSession({ - serviceUrl, - sessions, + let selectedSession = await findValidSession({ serviceConfig, sessions, samlRequest, }); @@ -410,9 +389,7 @@ export async function handleSAMLFlowInitiation(params: FlowInitiationParams): Pr }; try { - const { url, binding } = await createResponse({ - serviceUrl, - req: create(CreateResponseRequestSchema, { + const { url, binding } = await createResponse({ serviceConfig, req: create(CreateResponseRequestSchema, { samlRequestId: requestId.replace("saml_", ""), responseKind: { case: "session",
apps/login/src/lib/server/host.test.ts+162 −161 modified@@ -1,10 +1,5 @@ import { describe, expect, test, vi, beforeEach, afterEach } from "vitest"; -import { getOriginalHost, getOriginalHostWithProtocol } from "./host"; - -// Mock the Next.js headers function -vi.mock("next/headers", () => ({ - headers: vi.fn(), -})); +import { getInstanceHost, getPublicHostWithProtocol, getPublicHost } from "./host"; describe("Host utility functions", () => { beforeEach(() => { @@ -15,283 +10,289 @@ describe("Host utility functions", () => { vi.restoreAllMocks(); }); - describe("getOriginalHost", () => { - test("should return x-forwarded-host when available", async () => { - const { headers } = await import("next/headers"); + describe("getInstanceHost", () => { + test("should use x-zitadel-instance-host when available", () => { const mockHeaders = { get: vi.fn((key: string) => { - if (key === "x-forwarded-host") return "zitadel.com"; - if (key === "x-original-host") return "backup.com"; - if (key === "host") return "internal.vercel.app"; + if (key === "x-zitadel-instance-host") return "instance.zitadel.cloud"; + if (key === "x-zitadel-forward-host") return "forward.zitadel.cloud"; return null; }), - }; + } as any; - vi.mocked(headers).mockResolvedValue(mockHeaders as any); - - const result = await getOriginalHost(); - expect(result).toBe("zitadel.com"); - expect(mockHeaders.get).toHaveBeenCalledWith("x-forwarded-host"); + const result = getInstanceHost(mockHeaders); + expect(result).toBe("instance.zitadel.cloud"); + expect(mockHeaders.get).toHaveBeenCalledWith("x-zitadel-instance-host"); }); - test("should fall back to x-original-host when x-forwarded-host is not available", async () => { - const { headers } = await import("next/headers"); + test("should use x-zitadel-forward-host when x-zitadel-instance-host is not available", () => { const mockHeaders = { get: vi.fn((key: string) => { - if (key === "x-forwarded-host") return null; - if (key === "x-original-host") return "original.com"; - if (key === "host") return "internal.vercel.app"; + if (key === "x-zitadel-instance-host") return null; + if (key === "x-zitadel-forward-host") return "forward.zitadel.cloud"; return null; }), - }; - - vi.mocked(headers).mockResolvedValue(mockHeaders as any); + } as any; - const result = await getOriginalHost(); - expect(result).toBe("original.com"); - expect(mockHeaders.get).toHaveBeenCalledWith("x-forwarded-host"); - expect(mockHeaders.get).toHaveBeenCalledWith("x-original-host"); + const result = getInstanceHost(mockHeaders); + expect(result).toBe("forward.zitadel.cloud"); + expect(mockHeaders.get).toHaveBeenCalledWith("x-zitadel-instance-host"); + expect(mockHeaders.get).toHaveBeenCalledWith("x-zitadel-forward-host"); }); - test("should fall back to host when forwarded headers are not available", async () => { - const { headers } = await import("next/headers"); + test("should return null when neither x-zitadel-instance-host nor x-zitadel-forward-host are available", () => { const mockHeaders = { get: vi.fn((key: string) => { - if (key === "x-forwarded-host") return null; - if (key === "x-original-host") return null; - if (key === "host") return "fallback.com"; + if (key === "x-zitadel-instance-host") return null; + if (key === "x-zitadel-forward-host") return null; + if (key === "x-forwarded-host") return "accounts.mycompany.com"; + if (key === "host") return "internal.server"; return null; }), - }; - - vi.mocked(headers).mockResolvedValue(mockHeaders as any); - - const result = await getOriginalHost(); - expect(result).toBe("fallback.com"); - expect(mockHeaders.get).toHaveBeenCalledWith("x-forwarded-host"); - expect(mockHeaders.get).toHaveBeenCalledWith("x-original-host"); - expect(mockHeaders.get).toHaveBeenCalledWith("host"); - }); - - test("should throw error when no host is found", async () => { - const { headers } = await import("next/headers"); - const mockHeaders = { - get: vi.fn(() => null), - }; - - vi.mocked(headers).mockResolvedValue(mockHeaders as any); - - await expect(getOriginalHost()).rejects.toThrow("No host found in headers"); - }); + } as any; - test("should throw error when host is empty string", async () => { - const { headers } = await import("next/headers"); - const mockHeaders = { - get: vi.fn(() => ""), - }; - - vi.mocked(headers).mockResolvedValue(mockHeaders as any); - - await expect(getOriginalHost()).rejects.toThrow("No host found in headers"); - }); - - test("should throw error when host is not a string", async () => { - const { headers } = await import("next/headers"); - const mockHeaders = { - get: vi.fn(() => 123), - }; - - vi.mocked(headers).mockResolvedValue(mockHeaders as any); - - await expect(getOriginalHost()).rejects.toThrow("No host found in headers"); + const result = getInstanceHost(mockHeaders); + expect(result).toBeNull(); + expect(mockHeaders.get).toHaveBeenCalledWith("x-zitadel-instance-host"); + expect(mockHeaders.get).toHaveBeenCalledWith("x-zitadel-forward-host"); }); }); - describe("getOriginalHostWithProtocol", () => { - test("should return https for production domain", async () => { - const { headers } = await import("next/headers"); + describe("getPublicHostWithProtocol", () => { + test("should return https for production domain", () => { const mockHeaders = { get: vi.fn(() => "zitadel.com"), - }; - - vi.mocked(headers).mockResolvedValue(mockHeaders as any); + } as any; - const result = await getOriginalHostWithProtocol(); + const result = getPublicHostWithProtocol(mockHeaders); expect(result).toBe("https://zitadel.com"); }); - test("should return http for localhost", async () => { - const { headers } = await import("next/headers"); + test("should return http for localhost", () => { const mockHeaders = { get: vi.fn(() => "localhost:3000"), - }; + } as any; - vi.mocked(headers).mockResolvedValue(mockHeaders as any); - - const result = await getOriginalHostWithProtocol(); + const result = getPublicHostWithProtocol(mockHeaders); expect(result).toBe("http://localhost:3000"); }); - test("should return http for localhost without port", async () => { - const { headers } = await import("next/headers"); + test("should return http for localhost without port", () => { const mockHeaders = { get: vi.fn(() => "localhost"), - }; - - vi.mocked(headers).mockResolvedValue(mockHeaders as any); + } as any; - const result = await getOriginalHostWithProtocol(); + const result = getPublicHostWithProtocol(mockHeaders); expect(result).toBe("http://localhost"); }); - test("should return https for custom domain", async () => { - const { headers } = await import("next/headers"); + test("should return https for custom domain", () => { const mockHeaders = { get: vi.fn(() => "auth.company.com"), - }; + } as any; - vi.mocked(headers).mockResolvedValue(mockHeaders as any); - - const result = await getOriginalHostWithProtocol(); + const result = getPublicHostWithProtocol(mockHeaders); expect(result).toBe("https://auth.company.com"); }); }); describe("Real-world scenarios", () => { - test("should handle Vercel rewrite scenario", async () => { - const { headers } = await import("next/headers"); + test("should handle Vercel rewrite scenario", () => { const mockHeaders = { get: vi.fn((key: string) => { // Simulate Vercel rewrite: zitadel.com/login -> login-zitadel-qa.vercel.app if (key === "x-forwarded-host") return "zitadel.com"; if (key === "host") return "login-zitadel-qa.vercel.app"; return null; }), - }; - - vi.mocked(headers).mockResolvedValue(mockHeaders as any); + } as any; - const result = await getOriginalHostWithProtocol(); + const result = getPublicHostWithProtocol(mockHeaders); expect(result).toBe("https://zitadel.com"); }); - test("should handle CloudFlare proxy scenario", async () => { - const { headers } = await import("next/headers"); + test("should handle CloudFlare proxy scenario", () => { const mockHeaders = { get: vi.fn((key: string) => { if (key === "x-forwarded-host") return "auth.company.com"; if (key === "x-original-host") return null; if (key === "host") return "cloudflare-worker.workers.dev"; return null; }), - }; - - vi.mocked(headers).mockResolvedValue(mockHeaders as any); + } as any; - const result = await getOriginalHost(); + const result = getPublicHost(mockHeaders); expect(result).toBe("auth.company.com"); }); - test("should handle development environment", async () => { - const { headers } = await import("next/headers"); + test("should handle development environment", () => { const mockHeaders = { get: vi.fn((key: string) => { if (key === "host") return "localhost:3000"; return null; }), - }; + } as any; - vi.mocked(headers).mockResolvedValue(mockHeaders as any); - - const result = await getOriginalHostWithProtocol(); + const result = getPublicHostWithProtocol(mockHeaders); expect(result).toBe("http://localhost:3000"); }); - test("should handle staging environment with subdomain", async () => { - const { headers } = await import("next/headers"); + test("should handle staging environment with subdomain", () => { const mockHeaders = { get: vi.fn((key: string) => { if (key === "x-forwarded-host") return "staging-auth.company.com"; if (key === "host") return "staging-internal.vercel.app"; return null; }), - }; - - vi.mocked(headers).mockResolvedValue(mockHeaders as any); + } as any; - const result = await getOriginalHostWithProtocol(); + const result = getPublicHostWithProtocol(mockHeaders); expect(result).toBe("https://staging-auth.company.com"); }); + + test("should prioritize x-zitadel-instance-host in multi-tenant scenario", () => { + const mockHeaders = { + get: vi.fn((key: string) => { + if (key === "x-zitadel-instance-host") return "customer.zitadel.cloud"; + if (key === "x-forwarded-host") return "accounts.company.com"; + if (key === "host") return "internal.vercel.app"; + return null; + }), + } as any; + + const result = getInstanceHost(mockHeaders); + expect(result).toBe("customer.zitadel.cloud"); + }); }); describe("Edge cases", () => { - test("should handle IPv4 addresses", async () => { - const { headers } = await import("next/headers"); + test("should handle IPv4 addresses", () => { const mockHeaders = { get: vi.fn(() => "192.168.1.100:3000"), - }; - - vi.mocked(headers).mockResolvedValue(mockHeaders as any); + } as any; - const result = await getOriginalHostWithProtocol(); + const result = getPublicHostWithProtocol(mockHeaders); expect(result).toBe("https://192.168.1.100:3000"); }); - test("should handle IPv6 addresses", async () => { - const { headers } = await import("next/headers"); + test("should handle IPv6 addresses", () => { const mockHeaders = { get: vi.fn(() => "[::1]:3000"), - }; + } as any; - vi.mocked(headers).mockResolvedValue(mockHeaders as any); - - const result = await getOriginalHostWithProtocol(); + const result = getPublicHostWithProtocol(mockHeaders); expect(result).toBe("https://[::1]:3000"); }); - test("should handle hosts with ports", async () => { - const { headers } = await import("next/headers"); + test("should handle hosts with ports", () => { const mockHeaders = { get: vi.fn(() => "zitadel.com:8080"), - }; - - vi.mocked(headers).mockResolvedValue(mockHeaders as any); + } as any; - const result = await getOriginalHostWithProtocol(); + const result = getPublicHostWithProtocol(mockHeaders); expect(result).toBe("https://zitadel.com:8080"); }); - test("should handle localhost with different ports", async () => { - const { headers } = await import("next/headers"); + test("should handle localhost with different ports", () => { const mockHeaders = { get: vi.fn(() => "localhost:8080"), - }; - - vi.mocked(headers).mockResolvedValue(mockHeaders as any); + } as any; - const result = await getOriginalHostWithProtocol(); + const result = getPublicHostWithProtocol(mockHeaders); expect(result).toBe("http://localhost:8080"); }); + }); + + describe("getPublicHost", () => { + test("should use x-zitadel-public-host when available", () => { + const mockHeaders = { + get: vi.fn((key: string) => { + if (key === "x-zitadel-public-host") return "public.zitadel.cloud"; + if (key === "x-forwarded-host") return "accounts.company.com"; + return null; + }), + } as any; + + const result = getPublicHost(mockHeaders); + expect(result).toBe("public.zitadel.cloud"); + expect(mockHeaders.get).toHaveBeenCalledWith("x-zitadel-public-host"); + }); - test("should handle priority order correctly", async () => { - const { headers } = await import("next/headers"); + test("should use x-zitadel-forward-host when x-zitadel-public-host is not available", () => { const mockHeaders = { get: vi.fn((key: string) => { - // All headers are present, should return x-forwarded-host (highest priority) - if (key === "x-forwarded-host") return "priority1.com"; - if (key === "x-original-host") return "priority2.com"; - if (key === "host") return "priority3.com"; + if (key === "x-zitadel-public-host") return null; + if (key === "x-zitadel-forward-host") return "forward.zitadel.cloud"; + if (key === "x-forwarded-host") return "accounts.company.com"; return null; }), - }; + } as any; + + const result = getPublicHost(mockHeaders); + expect(result).toBe("forward.zitadel.cloud"); + expect(mockHeaders.get).toHaveBeenCalledWith("x-zitadel-public-host"); + expect(mockHeaders.get).toHaveBeenCalledWith("x-zitadel-forward-host"); + }); + + test("should use x-forwarded-host when neither x-zitadel-public-host nor x-zitadel-forward-host is available", () => { + const mockHeaders = { + get: vi.fn((key: string) => { + if (key === "x-zitadel-public-host") return null; + if (key === "x-zitadel-forward-host") return null; + if (key === "x-forwarded-host") return "accounts.company.com"; + if (key === "host") return "internal.server"; + return null; + }), + } as any; + + const result = getPublicHost(mockHeaders); + expect(result).toBe("accounts.company.com"); + expect(mockHeaders.get).toHaveBeenCalledWith("x-zitadel-public-host"); + expect(mockHeaders.get).toHaveBeenCalledWith("x-zitadel-forward-host"); + expect(mockHeaders.get).toHaveBeenCalledWith("x-forwarded-host"); + }); - vi.mocked(headers).mockResolvedValue(mockHeaders as any); + test("should fall back to host when x-forwarded-host is not available", () => { + const mockHeaders = { + get: vi.fn((key: string) => { + if (key === "x-zitadel-public-host") return null; + if (key === "x-zitadel-forward-host") return null; + if (key === "x-forwarded-host") return null; + if (key === "host") return "localhost:3000"; + return null; + }), + } as any; - const result = await getOriginalHost(); - expect(result).toBe("priority1.com"); - // Should only call x-forwarded-host since it's available + const result = getPublicHost(mockHeaders); + expect(result).toBe("localhost:3000"); expect(mockHeaders.get).toHaveBeenCalledWith("x-forwarded-host"); - expect(mockHeaders.get).toHaveBeenCalledTimes(1); + expect(mockHeaders.get).toHaveBeenCalledWith("host"); + }); + + test("should throw error when no host is found", () => { + const mockHeaders = { + get: vi.fn(() => null), + } as any; + + expect(() => getPublicHost(mockHeaders)).toThrow("No host found in headers"); + }); + + test("should differ from getInstanceHost when x-zitadel-instance-host is present", () => { + const mockHeaders = { + get: vi.fn((key: string) => { + if (key === "x-zitadel-instance-host") return "instance.zitadel.cloud"; + if (key === "x-forwarded-host") return "accounts.company.com"; + if (key === "host") return "internal.server"; + return null; + }), + } as any; + + const instanceHost = getInstanceHost(mockHeaders); + const publicHost = getPublicHost(mockHeaders); + + expect(instanceHost).toBe("instance.zitadel.cloud"); + expect(publicHost).toBe("accounts.company.com"); + expect(instanceHost).not.toBe(publicHost); }); }); });
apps/login/src/lib/server/host.ts+30 −30 modified@@ -1,48 +1,48 @@ -import { headers } from "next/headers"; +import { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers"; /** * Gets the original host that the user sees in their browser URL. * When using rewrites this function prioritizes forwarded headers that preserve the original host. * - * ⚠️ SERVER-SIDE ONLY: This function can only be used in: - * - Server Actions (functions with "use server") - * - Server Components (React components that run on the server) - * - Route Handlers (API routes) - * - Middleware - * * @returns The host string (e.g., "zitadel.com") * @throws Error if no host is found */ -export async function getOriginalHost(): Promise<string> { - const _headers = await headers(); - - // Priority order: - // 1. x-forwarded-host - Set by proxies/CDNs with the original host - // 2. x-original-host - Alternative header sometimes used - // 3. host - Fallback to the current host header - const host = _headers.get("x-forwarded-host") || _headers.get("x-original-host") || _headers.get("host"); - - if (!host || typeof host !== "string") { - throw new Error("No host found in headers"); - } +export function getInstanceHost(headers: ReadonlyHeaders): string | null { + // use standard proxy headers (x-forwarded-host → host) for both multi-tenant and self-hosted, do not use x-zitadel-instance-host + const instanceHost = headers.get("x-zitadel-instance-host") || headers.get("x-zitadel-forward-host"); - return host; + return instanceHost; } /** - * Gets the original host with protocol prefix. - * Automatically detects if localhost should use http:// or https:// + * Gets the public host that the user sees in their browser URL. + * Only considers standard proxy headers (x-forwarded-host and host). + * Does NOT include x-zitadel-instance-host. * - * ⚠️ SERVER-SIDE ONLY: This function can only be used in: - * - Server Actions (functions with "use server") - * - Server Components (React components that run on the server) - * - Route Handlers (API routes) - * - Middleware + * Use this when you need the public-facing host that the user actually sees, + * not the internal instance host used for API routing. * - * @returns The full URL prefix (e.g., "https://zitadel.com") + * @returns The public host string (e.g., "accounts.company.com") + * @throws Error if no host is found */ -export async function getOriginalHostWithProtocol(): Promise<string> { - const host = await getOriginalHost(); +export function getPublicHost(headers: ReadonlyHeaders): string { + // Only use standard proxy headers (x-zitadel-public-host → x-zitadel-forward-host → x-forwarded-host → host) + // Do NOT use x-zitadel-instance-host as it may differ from what the user sees + const publicHost = + headers.get("x-zitadel-public-host") || + headers.get("x-zitadel-forward-host") || + headers.get("x-forwarded-host") || + headers.get("host"); + + if (!publicHost || typeof publicHost !== "string") { + throw new Error("No host found in headers"); + } + + return publicHost; +} + +export function getPublicHostWithProtocol(headers: ReadonlyHeaders): string { + const host = getPublicHost(headers); const protocol = host.includes("localhost") ? "http://" : "https://"; return `${protocol}${host}`; }
apps/login/src/lib/server/idp-intent.test.ts+30 −30 modified@@ -12,7 +12,7 @@ vi.mock("@zitadel/client", () => ({ })); vi.mock("../service-url", () => ({ - getServiceUrlFromHeaders: vi.fn(), + getServiceConfig: vi.fn(), })); vi.mock("../zitadel", () => ({ @@ -111,7 +111,7 @@ describe("processIDPCallback", () => { // Import mocked modules const { headers } = await import("next/headers"); - const { getServiceUrlFromHeaders } = await import("../service-url"); + const { getServiceConfig } = await import("../service-url"); const { retrieveIDPIntent, getIDPByID, @@ -129,7 +129,7 @@ describe("processIDPCallback", () => { // Setup mocks mockHeaders = vi.mocked(headers); - mockGetServiceUrlFromHeaders = vi.mocked(getServiceUrlFromHeaders); + mockGetServiceUrlFromHeaders = vi.mocked(getServiceConfig); mockRetrieveIDPIntent = vi.mocked(retrieveIDPIntent); mockGetIDPByID = vi.mocked(getIDPByID); mockUpdateHuman = vi.mocked(updateHuman); @@ -146,7 +146,7 @@ describe("processIDPCallback", () => { // Default mock implementations mockHeaders.mockResolvedValue({} as any); mockGetServiceUrlFromHeaders.mockReturnValue({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, }); mockRetrieveIDPIntent.mockResolvedValue(defaultIntent); mockGetIDPByID.mockResolvedValue(defaultIdp); @@ -257,7 +257,7 @@ describe("processIDPCallback", () => { const result = await processIDPCallback(defaultParams); expect(mockRetrieveIDPIntent).toHaveBeenCalledWith({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, id: "intent123", token: "token123", }); @@ -287,7 +287,7 @@ describe("processIDPCallback", () => { await processIDPCallback(defaultParams); expect(mockUpdateHuman).toHaveBeenCalledWith({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, request: expect.objectContaining({ userId: "user123", profile: defaultIntent.updateHumanUser.profile, @@ -353,7 +353,7 @@ describe("processIDPCallback", () => { const result = await processIDPCallback(linkParams); expect(mockAddIDPLink).toHaveBeenCalledWith({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, idp: { id: "idp123", userId: "user123", @@ -442,12 +442,12 @@ describe("processIDPCallback", () => { const result = await processIDPCallback(defaultParams); expect(mockListUsers).toHaveBeenCalledWith({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, email: "test@example.com", organizationId: "org123", }); expect(mockAddIDPLink).toHaveBeenCalledWith({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, idp: { id: "idp123", userId: "user123", @@ -523,7 +523,7 @@ describe("processIDPCallback", () => { const result = await processIDPCallback(defaultParams); expect(mockListUsers).toHaveBeenCalledWith({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, userName: "testuser", organizationId: "org123", }); @@ -557,7 +557,7 @@ describe("processIDPCallback", () => { const result = await processIDPCallback(defaultParams); expect(mockAddHuman).toHaveBeenCalledWith({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, request: expect.objectContaining({ username: "testuser", profile: defaultIntent.addHumanUser.profile, @@ -602,11 +602,11 @@ describe("processIDPCallback", () => { }); expect(mockGetOrgsByDomain).toHaveBeenCalledWith({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, domain: "example.com", }); expect(mockAddHuman).toHaveBeenCalledWith({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, request: expect.objectContaining({ organization: expect.objectContaining({ org: { case: "orgId", value: "org-from-domain" }, @@ -625,10 +625,10 @@ describe("processIDPCallback", () => { }); expect(mockGetDefaultOrg).toHaveBeenCalledWith({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, }); expect(mockAddHuman).toHaveBeenCalledWith({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, request: expect.objectContaining({ organization: expect.objectContaining({ org: { case: "orgId", value: "default-org" }, @@ -901,14 +901,14 @@ describe("validateIDPLinkingPermissions", () => { }); const result = await validateIDPLinkingPermissions({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, userOrganizationId: "org123", idpId: "idp123", }); expect(result).toBe(false); expect(mockGetLoginSettings).toHaveBeenCalledWith({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, organization: "org123", }); expect(mockGetActiveIdentityProviders).not.toHaveBeenCalled(); @@ -918,7 +918,7 @@ describe("validateIDPLinkingPermissions", () => { mockGetLoginSettings.mockResolvedValue(undefined); const result = await validateIDPLinkingPermissions({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, userOrganizationId: "org123", idpId: "idp123", }); @@ -930,7 +930,7 @@ describe("validateIDPLinkingPermissions", () => { mockGetLoginSettings.mockResolvedValue({}); const result = await validateIDPLinkingPermissions({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, userOrganizationId: "org123", idpId: "idp123", }); @@ -952,14 +952,14 @@ describe("validateIDPLinkingPermissions", () => { }); const result = await validateIDPLinkingPermissions({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, userOrganizationId: "org123", idpId: "idp123", }); expect(result).toBe(false); expect(mockGetActiveIdentityProviders).toHaveBeenCalledWith({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, orgId: "org123", linking_allowed: true, }); @@ -974,7 +974,7 @@ describe("validateIDPLinkingPermissions", () => { }); const result = await validateIDPLinkingPermissions({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, userOrganizationId: "org123", idpId: "idp123", }); @@ -989,7 +989,7 @@ describe("validateIDPLinkingPermissions", () => { mockGetActiveIdentityProviders.mockResolvedValue({}); const result = await validateIDPLinkingPermissions({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, userOrganizationId: "org123", idpId: "idp123", }); @@ -1011,18 +1011,18 @@ describe("validateIDPLinkingPermissions", () => { }); const result = await validateIDPLinkingPermissions({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, userOrganizationId: "org123", idpId: "idp123", }); expect(result).toBe(true); expect(mockGetLoginSettings).toHaveBeenCalledWith({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, organization: "org123", }); expect(mockGetActiveIdentityProviders).toHaveBeenCalledWith({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, orgId: "org123", linking_allowed: true, }); @@ -1042,7 +1042,7 @@ describe("validateIDPLinkingPermissions", () => { }); const result = await validateIDPLinkingPermissions({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, userOrganizationId: "org123", idpId: "idp123", }); @@ -1057,7 +1057,7 @@ describe("validateIDPLinkingPermissions", () => { await expect( validateIDPLinkingPermissions({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, userOrganizationId: "org123", idpId: "idp123", }), @@ -1072,7 +1072,7 @@ describe("validateIDPLinkingPermissions", () => { await expect( validateIDPLinkingPermissions({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, userOrganizationId: "org123", idpId: "idp123", }), @@ -1088,7 +1088,7 @@ describe("validateIDPLinkingPermissions", () => { }); const result = await validateIDPLinkingPermissions({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, userOrganizationId: "org123", idpId: "idp123", });
apps/login/src/lib/server/idp-intent.ts+26 −55 modified@@ -1,6 +1,6 @@ "use server"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getServiceConfig } from "@/lib/service-url"; import { retrieveIDPIntent, getIDPByID, @@ -13,6 +13,7 @@ import { getActiveIdentityProviders, getUserByID, getDefaultOrg, + ServiceConfig, } from "@/lib/zitadel"; import { headers } from "next/headers"; import { create } from "@zitadel/client"; @@ -31,28 +32,24 @@ const ORG_SUFFIX_REGEX = /(?<=@)(.+)/; async function resolveOrganizationForUser({ organization, addHumanUser, - serviceUrl, + serviceConfig, }: { organization?: string; addHumanUser?: AddHumanUserRequest; - serviceUrl: string; + serviceConfig: ServiceConfig; }): Promise<string | undefined> { if (organization) return organization; if (addHumanUser?.username && ORG_SUFFIX_REGEX.test(addHumanUser.username)) { const matched = ORG_SUFFIX_REGEX.exec(addHumanUser.username); const suffix = matched?.[1] ?? ""; - const orgs = await getOrgsByDomain({ - serviceUrl, - domain: suffix, + const orgs = await getOrgsByDomain({ serviceConfig, domain: suffix, }); const orgToCheckForDiscovery = orgs.result && orgs.result.length === 1 ? orgs.result[0].id : undefined; if (orgToCheckForDiscovery) { - const orgLoginSettings = await getLoginSettings({ - serviceUrl, - organization: orgToCheckForDiscovery, + const orgLoginSettings = await getLoginSettings({ serviceConfig, organization: orgToCheckForDiscovery, }); if (orgLoginSettings?.allowDomainDiscovery) { return orgToCheckForDiscovery; @@ -61,7 +58,7 @@ async function resolveOrganizationForUser({ } // Fallback to default organization if no org was resolved through discovery - const defaultOrg = await getDefaultOrg({ serviceUrl }); + const defaultOrg = await getDefaultOrg({ serviceConfig }); return defaultOrg?.id; } @@ -72,29 +69,23 @@ async function resolveOrganizationForUser({ * 2. The specific IDP is activated for the organization * */ -export async function validateIDPLinkingPermissions({ - serviceUrl, - userOrganizationId, +export async function validateIDPLinkingPermissions({ serviceConfig, userOrganizationId, idpId, }: { - serviceUrl: string; + serviceConfig: ServiceConfig; userOrganizationId: string; idpId: string; }): Promise<boolean> { // Check organization login settings - const loginSettings = await getLoginSettings({ - serviceUrl, - organization: userOrganizationId, + const loginSettings = await getLoginSettings({ serviceConfig, organization: userOrganizationId, }); if (!loginSettings?.allowExternalIdp) { return false; } // Check if the IDP is activated for the organization and allows linking - const activeIDPs = await getActiveIdentityProviders({ - serviceUrl, - orgId: userOrganizationId, + const activeIDPs = await getActiveIdentityProviders({ serviceConfig, orgId: userOrganizationId, linking_allowed: true, }); @@ -132,7 +123,7 @@ export async function processIDPCallback({ postErrorRedirectUrl?: string; }): Promise<{ redirect?: string; error?: string }> { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); const t = await getTranslations("idp"); @@ -155,9 +146,7 @@ export async function processIDPCallback({ }); // Consume the single-use token ONCE - const intent = await retrieveIDPIntent({ - serviceUrl, - id, + const intent = await retrieveIDPIntent({ serviceConfig, id, token, }); @@ -171,9 +160,7 @@ export async function processIDPCallback({ } // Get IDP configuration - const idp = await getIDPByID({ - serviceUrl, - id: idpInformation.idpId, + const idp = await getIDPByID({ serviceConfig, id: idpInformation.idpId, }); if (!idp) { @@ -207,9 +194,7 @@ export async function processIDPCallback({ // Auto-update user if enabled if (options?.isAutoUpdate && updateHumanUser) { try { - await updateHuman({ - serviceUrl, - request: create(UpdateHumanUserRequestSchema, { + await updateHuman({ serviceConfig, request: create(UpdateHumanUserRequestSchema, { userId: userId, profile: updateHumanUser.profile, email: updateHumanUser.email, @@ -259,7 +244,7 @@ export async function processIDPCallback({ try { // Get user to retrieve their organization - const targetUser = await getUserByID({ serviceUrl, userId }); + const targetUser = await getUserByID({ serviceConfig, userId }); if (!targetUser || !targetUser.details?.resourceOwner) { console.error("[IDP Process] User not found or missing organization"); @@ -268,9 +253,7 @@ export async function processIDPCallback({ } // Validate IDP linking permissions - const isAllowed = await validateIDPLinkingPermissions({ - serviceUrl, - userOrganizationId: targetUser.details.resourceOwner, + const isAllowed = await validateIDPLinkingPermissions({ serviceConfig, userOrganizationId: targetUser.details.resourceOwner, idpId: idpInformation.idpId, }); @@ -280,9 +263,7 @@ export async function processIDPCallback({ return { redirect: `/idp/${provider}/linking-failed?${params}&error=validation_failed` }; } - await addIDPLink({ - serviceUrl, - idp: { + await addIDPLink({ serviceConfig, idp: { id: idpInformation.idpId, userId: idpInformation.userId, userName: idpInformation.userName, @@ -328,21 +309,17 @@ export async function processIDPCallback({ const email = addHumanUser?.email?.email; if (options.autoLinking === AutoLinkingOption.EMAIL && email) { - foundUser = await listUsers({ serviceUrl, email, organizationId: organization }).then((response) => { + foundUser = await listUsers({ serviceConfig, email, organizationId: organization }).then((response) => { return response.result ? response.result[0] : null; }); } else if (options.autoLinking === AutoLinkingOption.USERNAME) { - foundUser = await listUsers({ - serviceUrl, - userName: idpInformation.userName, + foundUser = await listUsers({ serviceConfig, userName: idpInformation.userName, organizationId: organization, }).then((response) => { return response.result ? response.result[0] : null; }); } else { - foundUser = await listUsers({ - serviceUrl, - userName: idpInformation.userName, + foundUser = await listUsers({ serviceConfig, userName: idpInformation.userName, email, organizationId: organization, }).then((response) => { @@ -359,9 +336,7 @@ export async function processIDPCallback({ } // Validate IDP linking permissions - const isAllowed = await validateIDPLinkingPermissions({ - serviceUrl, - userOrganizationId: foundUser.details.resourceOwner, + const isAllowed = await validateIDPLinkingPermissions({ serviceConfig, userOrganizationId: foundUser.details.resourceOwner, idpId: idpInformation.idpId, }); @@ -371,9 +346,7 @@ export async function processIDPCallback({ return { redirect: `/idp/${provider}/linking-failed?${params}&error=validation_failed` }; } - await addIDPLink({ - serviceUrl, - idp: { + await addIDPLink({ serviceConfig, idp: { id: idpInformation.idpId, userId: idpInformation.userId, userName: idpInformation.userName, @@ -419,7 +392,7 @@ export async function processIDPCallback({ const orgToRegisterOn = await resolveOrganizationForUser({ organization, addHumanUser, - serviceUrl, + serviceConfig, }); if (!orgToRegisterOn) { @@ -438,9 +411,7 @@ export async function processIDPCallback({ }); try { - const newUser = await addHuman({ - serviceUrl, - request: addHumanUserWithOrganization, + const newUser = await addHuman({ serviceConfig, request: addHumanUserWithOrganization, }); console.log("[IDP Process] User auto-created successfully, creating session"); @@ -480,7 +451,7 @@ export async function processIDPCallback({ const orgToRegisterOn = await resolveOrganizationForUser({ organization, addHumanUser, - serviceUrl, + serviceConfig, }); if (!orgToRegisterOn) {
apps/login/src/lib/server/idp.test.ts+15 −11 modified@@ -13,11 +13,12 @@ vi.mock("next/navigation", () => ({ })); vi.mock("../service-url", () => ({ - getServiceUrlFromHeaders: vi.fn(), + getServiceConfig: vi.fn(), })); vi.mock("./host", () => ({ - getOriginalHost: vi.fn(), + getInstanceHost: vi.fn(), + getPublicHost: vi.fn(), })); vi.mock("../zitadel", () => ({ @@ -27,30 +28,33 @@ vi.mock("../zitadel", () => ({ describe("redirectToIdp", () => { let mockHeaders: any; let mockGetServiceUrlFromHeaders: any; - let mockGetOriginalHost: any; + let mockGetInstanceHost: any; + let mockGetPublicHost: any; let mockStartIdentityProviderFlow: any; beforeEach(async () => { vi.clearAllMocks(); // Import mocked modules const { headers } = await import("next/headers"); - const { getServiceUrlFromHeaders } = await import("../service-url"); - const { getOriginalHost } = await import("./host"); + const { getServiceConfig } = await import("../service-url"); + const { getInstanceHost, getPublicHost } = await import("./host"); const { startIdentityProviderFlow } = await import("../zitadel"); // Setup mocks mockHeaders = vi.mocked(headers); - mockGetServiceUrlFromHeaders = vi.mocked(getServiceUrlFromHeaders); - mockGetOriginalHost = vi.mocked(getOriginalHost); + mockGetServiceUrlFromHeaders = vi.mocked(getServiceConfig); + mockGetInstanceHost = vi.mocked(getInstanceHost); + mockGetPublicHost = vi.mocked(getPublicHost); mockStartIdentityProviderFlow = vi.mocked(startIdentityProviderFlow); // Default mock implementations mockHeaders.mockResolvedValue({} as any); mockGetServiceUrlFromHeaders.mockReturnValue({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, }); - mockGetOriginalHost.mockResolvedValue("example.com"); + mockGetInstanceHost.mockReturnValue("example.com"); + mockGetPublicHost.mockReturnValue("example.com"); }); afterEach(() => { @@ -76,7 +80,7 @@ describe("redirectToIdp", () => { } expect(mockStartIdentityProviderFlow).toHaveBeenCalledWith({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, idpId: "idp123", urls: { successUrl: expect.stringContaining("postErrorRedirectUrl=https%3A%2F%2Fapp.example.com%2Ferror"), @@ -115,7 +119,7 @@ describe("redirectToIdp", () => { } expect(mockStartIdentityProviderFlow).toHaveBeenCalledWith({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, idpId: "idp123", urls: { successUrl: expect.not.stringContaining("postErrorRedirectUrl"),
apps/login/src/lib/server/idp.ts+15 −23 modified@@ -6,21 +6,22 @@ import { listAuthenticationMethodTypes, startIdentityProviderFlow, startLDAPIdentityProviderFlow, + ServiceConfig, } from "@/lib/zitadel"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { completeFlowOrGetUrl } from "../client"; -import { getServiceUrlFromHeaders } from "../service-url"; +import { getServiceConfig } from "../service-url"; import { checkEmailVerification, checkMFAFactors } from "../verify-helper"; import { createSessionForIdpAndUpdateCookie } from "./cookie"; -import { getOriginalHost } from "./host"; +import { getPublicHost } from "./host"; export type RedirectToIdpState = { error?: string | null } | undefined; export async function redirectToIdp(prevState: RedirectToIdpState, formData: FormData): Promise<RedirectToIdpState> { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); - const host = await getOriginalHost(); + const { serviceConfig } = getServiceConfig(_headers); + const host = getPublicHost(_headers); const params = new URLSearchParams(); @@ -43,7 +44,7 @@ export async function redirectToIdp(prevState: RedirectToIdpState, formData: For } const response = await startIDPFlow({ - serviceUrl, + serviceConfig, host, idpId, successUrl: `/idp/${provider}/process?` + params.toString(), @@ -62,7 +63,7 @@ export async function redirectToIdp(prevState: RedirectToIdpState, formData: For } export type StartIDPFlowCommand = { - serviceUrl: string; + serviceConfig: ServiceConfig; host: string; idpId: string; successUrl: string; @@ -73,7 +74,7 @@ async function startIDPFlow(command: StartIDPFlowCommand) { const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; const url = await startIdentityProviderFlow({ - serviceUrl: command.serviceUrl, + serviceConfig: command.serviceConfig, idpId: command.idpId, urls: { successUrl: `${command.host.includes("localhost") ? "http://" : "https://"}${command.host}${basePath}${command.successUrl}`, @@ -103,25 +104,19 @@ export type CreateNewSessionCommand = { export async function createNewSessionFromIdpIntent(command: CreateNewSessionCommand) { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); if (!command.userId || !command.idpIntent) { throw new Error("No userId or loginName provided"); } - const userResponse = await getUserByID({ - serviceUrl, - userId: command.userId, - }); + const userResponse = await getUserByID({ serviceConfig, userId: command.userId }); if (!userResponse || !userResponse.user) { return { error: "User not found in the system" }; } - const loginSettings = await getLoginSettings({ - serviceUrl, - organization: userResponse.user.details?.resourceOwner, - }); + const loginSettings = await getLoginSettings({ serviceConfig, organization: userResponse.user.details?.resourceOwner }); const session = await createSessionForIdpAndUpdateCookie({ userId: command.userId, @@ -146,17 +141,14 @@ export async function createNewSessionFromIdpIntent(command: CreateNewSessionCom // check if user has MFA methods let authMethods; if (session.factors?.user?.id) { - const response = await listAuthenticationMethodTypes({ - serviceUrl, - userId: session.factors.user.id, - }); + const response = await listAuthenticationMethodTypes({ serviceConfig, userId: session.factors.user.id }); if (response.authMethodTypes && response.authMethodTypes.length) { authMethods = response.authMethodTypes; } } const mfaFactorCheck = await checkMFAFactors( - serviceUrl, + serviceConfig, session, loginSettings, authMethods || [], // Pass empty array if no auth methods @@ -193,14 +185,14 @@ type createNewSessionForLDAPCommand = { export async function createNewSessionForLDAP(command: createNewSessionForLDAPCommand) { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); if (!command.username || !command.password) { return { error: "No username or password provided" }; } const response = await startLDAPIdentityProviderFlow({ - serviceUrl, + serviceConfig, idpId: command.idpId, username: command.username, password: command.password,
apps/login/src/lib/server/loginname.test.ts+18 −14 modified@@ -15,7 +15,7 @@ vi.mock("@zitadel/client", () => ({ })); vi.mock("../service-url", () => ({ - getServiceUrlFromHeaders: vi.fn(), + getServiceConfig: vi.fn(), })); vi.mock("../idp", () => ({ @@ -39,7 +39,8 @@ vi.mock("./cookie", () => ({ })); vi.mock("./host", () => ({ - getOriginalHost: vi.fn(), + getInstanceHost: vi.fn(), + getPublicHost: vi.fn(), })); // this returns the key itself that can be checked not the translated value @@ -57,7 +58,8 @@ describe("sendLoginname", () => { let mockCreateSessionAndUpdateCookie: any; let mockListAuthenticationMethodTypes: any; let mockListIDPLinks: any; - let mockGetOriginalHost: any; + let mockGetInstanceHost: any; + let mockGetPublicHost: any; let mockStartIdentityProviderFlow: any; let mockGetActiveIdentityProviders: any; let mockGetIDPByID: any; @@ -70,7 +72,7 @@ describe("sendLoginname", () => { // Import mocked modules const { headers } = await import("next/headers"); const { create } = await import("@zitadel/client"); - const { getServiceUrlFromHeaders } = await import("../service-url"); + const { getServiceConfig } = await import("../service-url"); const { getLoginSettings, searchUsers, @@ -81,19 +83,20 @@ describe("sendLoginname", () => { getOrgsByDomain, } = await import("../zitadel"); const { createSessionAndUpdateCookie } = await import("./cookie"); - const { getOriginalHost } = await import("./host"); + const { getInstanceHost, getPublicHost } = await import("./host"); const { idpTypeToSlug } = await import("../idp"); // Setup mocks mockHeaders = vi.mocked(headers); mockCreate = vi.mocked(create); - mockGetServiceUrlFromHeaders = vi.mocked(getServiceUrlFromHeaders); + mockGetServiceUrlFromHeaders = vi.mocked(getServiceConfig); mockGetLoginSettings = vi.mocked(getLoginSettings); mockSearchUsers = vi.mocked(searchUsers); mockCreateSessionAndUpdateCookie = vi.mocked(createSessionAndUpdateCookie); mockListAuthenticationMethodTypes = vi.mocked(listAuthenticationMethodTypes); mockListIDPLinks = vi.mocked(listIDPLinks); - mockGetOriginalHost = vi.mocked(getOriginalHost); + mockGetInstanceHost = vi.mocked(getInstanceHost); + mockGetPublicHost = vi.mocked(getPublicHost); mockStartIdentityProviderFlow = vi.mocked(startIdentityProviderFlow); mockGetActiveIdentityProviders = vi.mocked(getActiveIdentityProviders); mockGetIDPByID = vi.mocked(getIDPByID); @@ -102,8 +105,9 @@ describe("sendLoginname", () => { // Default mock implementations mockHeaders.mockResolvedValue({} as any); - mockGetServiceUrlFromHeaders.mockReturnValue({ serviceUrl: "https://api.example.com" }); - mockGetOriginalHost.mockResolvedValue("example.com"); + mockGetServiceUrlFromHeaders.mockReturnValue({ serviceConfig: { baseUrl: "https://api.example.com" } }); + mockGetInstanceHost.mockReturnValue("example.com"); + mockGetPublicHost.mockReturnValue("example.com"); mockIdpTypeToSlug.mockReturnValue("google"); mockGetIDPByID.mockResolvedValue({ id: "idp123", @@ -243,7 +247,7 @@ describe("sendLoginname", () => { expect(result).toEqual({ redirect: "https://idp.example.com/auth" }); expect(mockListIDPLinks).toHaveBeenCalledWith({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, userId: "user123", }); }); @@ -283,7 +287,7 @@ describe("sendLoginname", () => { expect(result).toEqual({ redirect: "https://org-idp.example.com/auth" }); expect(mockGetActiveIdentityProviders).toHaveBeenCalledWith({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, orgId: "org123", // User's organization from resourceOwner }); }); @@ -523,7 +527,7 @@ describe("sendLoginname", () => { // Verify org discovery was called with correct domain expect(mockGetOrgsByDomain).toHaveBeenCalledWith({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, domain: "example.com", }); }); @@ -562,13 +566,13 @@ describe("sendLoginname", () => { // Verify org discovery was called expect(mockGetOrgsByDomain).toHaveBeenCalledWith({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, domain: "company.com", }); // Verify IDP redirect was called with discovered org expect(mockGetActiveIdentityProviders).toHaveBeenCalledWith({ - serviceUrl: "https://api.example.com", + serviceConfig: { baseUrl: "https://api.example.com" }, orgId: "discovered-org-456", }); });
apps/login/src/lib/server/loginname.ts+18 −42 modified@@ -9,7 +9,7 @@ import { idpTypeToIdentityProviderType, idpTypeToSlug } from "../idp"; import { PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import { UserState } from "@zitadel/proto/zitadel/user/v2/user_pb"; -import { getServiceUrlFromHeaders } from "../service-url"; +import { getServiceConfig } from "../service-url"; import { getActiveIdentityProviders, getIDPByID, @@ -22,7 +22,7 @@ import { startIdentityProviderFlow, } from "../zitadel"; import { createSessionAndUpdateCookie } from "./cookie"; -import { getOriginalHost } from "./host"; +import { getPublicHost } from "./host"; import { IDPLink } from "@zitadel/proto/zitadel/user/v2/idp_pb"; export type SendLoginnameCommand = { @@ -36,21 +36,18 @@ const ORG_SUFFIX_REGEX = /(?<=@)(.+)/; export async function sendLoginname(command: SendLoginnameCommand) { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); const t = await getTranslations("loginname"); - const loginSettingsByContext = await getLoginSettings({ - serviceUrl, - organization: command.organization, - }); + const loginSettingsByContext = await getLoginSettings({ serviceConfig, organization: command.organization }); if (!loginSettingsByContext) { return { error: t("errors.couldNotGetLoginSettings") }; } let searchUsersRequest: SearchUsersCommand = { - serviceUrl, + serviceConfig, searchValue: command.loginName, organizationId: command.organization, loginSettings: loginSettingsByContext, @@ -88,28 +85,22 @@ export async function sendLoginname(command: SendLoginnameCommand) { // If userId is provided, check for user-specific IDP links first let identityProviders: IDPLink[] = []; if (userId) { - identityProviders = await listIDPLinks({ - serviceUrl, - userId, - }).then((resp) => { + identityProviders = await listIDPLinks({ serviceConfig, userId }).then((resp) => { return resp.result; }); } // If no IDP links exist for the user (or no userId provided), try to get active IDPs from the organization if (identityProviders.length === 0) { - const activeIdps = await getActiveIdentityProviders({ - serviceUrl, - orgId: organization, - }).then((resp) => { + const activeIdps = await getActiveIdentityProviders({ serviceConfig, orgId: organization }).then((resp) => { return resp.identityProviders; }); // If exactly one active IDP exists in the organization, redirect to it if (activeIdps.length === 1) { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); - const host = await getOriginalHost(); + const { serviceConfig } = getServiceConfig(_headers); + const host = getPublicHost(_headers); const identityProviderType = activeIdps[0].type; const provider = idpTypeToSlug(identityProviderType); @@ -131,7 +122,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; const url = await startIdentityProviderFlow({ - serviceUrl, + serviceConfig, idpId: activeIdps[0].id, urls: { successUrl: @@ -153,15 +144,12 @@ export async function sendLoginname(command: SendLoginnameCommand) { if (identityProviders.length === 1) { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); - const host = await getOriginalHost(); + const { serviceConfig } = getServiceConfig(_headers); + const host = getPublicHost(_headers); const identityProviderId = identityProviders[0].idpId; - const idp = await getIDPByID({ - serviceUrl, - id: identityProviderId, - }); + const idp = await getIDPByID({ serviceConfig, id: identityProviderId }); const idpType = idp?.type; @@ -189,7 +177,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; const url = await startIdentityProviderFlow({ - serviceUrl, + serviceConfig, idpId: idp.id, urls: { successUrl: @@ -216,10 +204,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { const user = users[0]; const userId = users[0].userId; - const userLoginSettings = await getLoginSettings({ - serviceUrl, - organization: user.details?.resourceOwner, - }); + const userLoginSettings = await getLoginSettings({ serviceConfig, organization: user.details?.resourceOwner }); // compare with the concatenated suffix when set const concatLoginname = command.suffix ? `${command.loginName}@${command.suffix}` : command.loginName; @@ -273,10 +258,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { // Resolve organization from command or session const organization = command.organization ?? session.factors?.user?.organizationId; - const methods = await listAuthenticationMethodTypes({ - serviceUrl, - userId: session.factors?.user?.id, - }); + const methods = await listAuthenticationMethodTypes({ serviceConfig, userId: session.factors?.user?.id }); // always resend invite if user has no auth method set if (!methods.authMethodTypes || !methods.authMethodTypes.length) { @@ -418,18 +400,12 @@ export async function sendLoginname(command: SendLoginnameCommand) { const suffix = matched?.[1] ?? ""; // this just returns orgs where the suffix is set as primary domain - const orgs = await getOrgsByDomain({ - serviceUrl, - domain: suffix, - }); + const orgs = await getOrgsByDomain({ serviceConfig, domain: suffix }); const orgToCheckForDiscovery = orgs.result && orgs.result.length === 1 ? orgs.result[0].id : undefined; if (orgToCheckForDiscovery) { - const orgLoginSettings = await getLoginSettings({ - serviceUrl, - organization: orgToCheckForDiscovery, - }); + const orgLoginSettings = await getLoginSettings({ serviceConfig, organization: orgToCheckForDiscovery }); if (orgLoginSettings?.allowDomainDiscovery) { console.log("org discovery successful, using org:", orgToCheckForDiscovery);
apps/login/src/lib/server/oidc.ts+3 −5 modified@@ -2,14 +2,12 @@ import { getDeviceAuthorizationRequest as zitadelGetDeviceAuthorizationRequest } from "@/lib/zitadel"; import { headers } from "next/headers"; -import { getServiceUrlFromHeaders } from "../service-url"; +import { getServiceConfig } from "../service-url"; export async function getDeviceAuthorizationRequest(userCode: string) { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); - return zitadelGetDeviceAuthorizationRequest({ - serviceUrl, - userCode, + return zitadelGetDeviceAuthorizationRequest({ serviceConfig, userCode, }); }
apps/login/src/lib/server/passkeys.test.ts+3 −3 modified@@ -14,7 +14,7 @@ vi.mock("@zitadel/client", () => ({ })); vi.mock("../service-url", () => ({ - getServiceUrlFromHeaders: vi.fn(), + getServiceConfig: vi.fn(), })); vi.mock("../zitadel", () => ({ @@ -62,7 +62,7 @@ describe("sendPasskey", () => { // Import mocked modules const { headers } = await import("next/headers"); - const { getServiceUrlFromHeaders } = await import("../service-url"); + const { getServiceConfig } = await import("../service-url"); const { getLoginSettings, getUserByID } = await import("../zitadel"); const { setSessionAndUpdateCookie } = await import("./cookie"); const { getSessionCookieById, getSessionCookieByLoginName, getMostRecentSessionCookie } = await import("../cookies"); @@ -71,7 +71,7 @@ describe("sendPasskey", () => { // Setup mocks mockHeaders = vi.mocked(headers); - mockGetServiceUrlFromHeaders = vi.mocked(getServiceUrlFromHeaders); + mockGetServiceUrlFromHeaders = vi.mocked(getServiceConfig); mockGetLoginSettings = vi.mocked(getLoginSettings); mockGetUserByID = vi.mocked(getUserByID); mockSetSessionAndUpdateCookie = vi.mocked(setSessionAndUpdateCookie);
apps/login/src/lib/server/passkeys.ts+16 −47 modified@@ -20,10 +20,10 @@ import { headers } from "next/headers"; import { userAgent } from "next/server"; import { getTranslations } from "next-intl/server"; import { getMostRecentSessionCookie, getSessionCookieById, getSessionCookieByLoginName } from "../cookies"; -import { getServiceUrlFromHeaders } from "../service-url"; +import { getServiceConfig } from "../service-url"; import { checkEmailVerification, checkUserVerification } from "../verify-helper"; import { createSessionAndUpdateCookie, setSessionAndUpdateCookie } from "./cookie"; -import { getOriginalHost } from "./host"; +import { getPublicHost } from "./host"; import { completeFlowOrGetUrl } from "../client"; type VerifyPasskeyCommand = { @@ -63,8 +63,8 @@ export async function registerPasskeyLink( } const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); - const host = await getOriginalHost(); + const { serviceConfig } = getServiceConfig(_headers); + const host = getPublicHost(_headers); let session: GetSessionResponse | undefined; let createdSession: Session | undefined; @@ -74,11 +74,7 @@ export async function registerPasskeyLink( if (command.sessionId) { // Session-based flow (existing logic) const sessionCookie = await getSessionCookieById({ sessionId: command.sessionId }); - session = await getSession({ - serviceUrl, - sessionId: sessionCookie.id, - sessionToken: sessionCookie.token, - }); + session = await getSession({ serviceConfig, sessionId: sessionCookie.id, sessionToken: sessionCookie.token }); if (!session?.session?.factors?.user?.id) { return { error: "Could not determine user from session" }; @@ -89,10 +85,7 @@ export async function registerPasskeyLink( const sessionValid = isSessionValid(session.session); if (!sessionValid.valid) { - const authmethods = await listAuthenticationMethodTypes({ - serviceUrl, - userId: currentUserId, - }); + const authmethods = await listAuthenticationMethodTypes({ serviceConfig, userId: currentUserId }); // if the user has no authmethods set, we need to check if the user was verified if (authmethods.authMethodTypes.length !== 0) { @@ -117,10 +110,7 @@ export async function registerPasskeyLink( code: command.code, }; } else { - const codeResponse = await createPasskeyRegistrationLink({ - serviceUrl, - userId: currentUserId, - }); + const codeResponse = await createPasskeyRegistrationLink({ serviceConfig, userId: currentUserId }); if (!codeResponse?.code?.code) { return { error: "Could not create registration link" }; @@ -136,10 +126,7 @@ export async function registerPasskeyLink( }; // Check if user exists - const userResponse = await getUserByID({ - serviceUrl, - userId: currentUserId, - }); + const userResponse = await getUserByID({ serviceConfig, userId: currentUserId }); if (!userResponse || !userResponse.user) { return { error: "User not found" }; @@ -179,17 +166,12 @@ export async function registerPasskeyLink( throw new Error("Could not determine user"); } - return registerPasskey({ - serviceUrl, - userId: currentUserId, - code: registerCode, - domain: hostname, - }); + return registerPasskey({ serviceConfig, userId: currentUserId, code: registerCode, domain: hostname }); } export async function verifyPasskeyRegistration(command: VerifyPasskeyCommand) { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); if (!command.sessionId && !command.userId) { throw new Error("Either sessionId or userId must be provided"); @@ -214,11 +196,7 @@ export async function verifyPasskeyRegistration(command: VerifyPasskeyCommand) { const sessionCookie = await getSessionCookieById({ sessionId: command.sessionId, }); - const session = await getSession({ - serviceUrl, - sessionId: sessionCookie.id, - sessionToken: sessionCookie.token, - }); + const session = await getSession({ serviceConfig, sessionId: sessionCookie.id, sessionToken: sessionCookie.token }); const userId = session?.session?.factors?.user?.id; if (!userId) { @@ -231,18 +209,15 @@ export async function verifyPasskeyRegistration(command: VerifyPasskeyCommand) { currentUserId = command.userId!; // Verify user exists - const userResponse = await getUserByID({ - serviceUrl, - userId: currentUserId, - }); + const userResponse = await getUserByID({ serviceConfig, userId: currentUserId }); if (!userResponse || !userResponse.user) { throw new Error("User not found"); } } return zitadelVerifyPasskeyRegistration({ - serviceUrl, + serviceConfig, request: create(VerifyPasskeyRegistrationRequestSchema, { passkeyId: command.passkeyId, publicKeyCredential: command.publicKeyCredential, @@ -279,12 +254,9 @@ export async function sendPasskey(command: SendPasskeyCommand) { } const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); - const loginSettings = await getLoginSettings({ - serviceUrl, - organization, - }); + const loginSettings = await getLoginSettings({ serviceConfig, organization }); let lifetime = command.lifetime; // Use provided lifetime first @@ -318,10 +290,7 @@ export async function sendPasskey(command: SendPasskeyCommand) { let userResponse; try { - userResponse = await getUserByID({ - serviceUrl, - userId: session?.factors?.user?.id, - }); + userResponse = await getUserByID({ serviceConfig, userId: session?.factors?.user?.id }); } catch (error) { console.error("Error fetching user by ID:", error); return { error: t("verify.errors.couldNotGetUser") };
apps/login/src/lib/server/password.ts+32 −74 modified@@ -10,6 +10,7 @@ import { listAuthenticationMethodTypes, listUsers, passwordReset, + ServiceConfig, setPassword, setUserPassword, } from "@/lib/zitadel"; @@ -22,8 +23,8 @@ import { SetPasswordRequestSchema } from "@zitadel/proto/zitadel/user/v2/user_se import { headers } from "next/headers"; import { completeFlowOrGetUrl } from "../client"; import { getSessionCookieById, getSessionCookieByLoginName } from "../cookies"; -import { getServiceUrlFromHeaders } from "../service-url"; -import { getOriginalHostWithProtocol } from "./host"; +import { getServiceConfig } from "../service-url"; +import { getPublicHostWithProtocol } from "./host"; import { checkEmailVerification, checkMFAFactors, @@ -41,18 +42,14 @@ type ResetPasswordCommand = { export async function resetPassword(command: ResetPasswordCommand) { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); const t = await getTranslations("password"); // Get the original host that the user sees with protocol - const hostWithProtocol = await getOriginalHostWithProtocol(); + const hostWithProtocol = await getPublicHostWithProtocol(_headers); - const users = await listUsers({ - serviceUrl, - loginName: command.loginName, - organizationId: command.organization, - }); + const users = await listUsers({ serviceConfig, loginName: command.loginName, organizationId: command.organization }); if (!users.details || users.details.totalResult !== BigInt(1) || !users.result[0].userId) { return { error: t("errors.couldNotSendResetLink") }; @@ -62,7 +59,7 @@ export async function resetPassword(command: ResetPasswordCommand) { const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; return passwordReset({ - serviceUrl, + serviceConfig, userId, urlTemplate: `${hostWithProtocol}${basePath}/password/set?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` + @@ -79,7 +76,7 @@ export type UpdateSessionCommand = { export async function sendPassword(command: UpdateSessionCommand): Promise<{ error: string } | { redirect: string }> { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); const t = await getTranslations("password"); let sessionCookie = await getSessionCookieByLoginName({ @@ -94,11 +91,7 @@ export async function sendPassword(command: UpdateSessionCommand): Promise<{ err let loginSettings: LoginSettings | undefined; if (!sessionCookie) { - const users = await listUsers({ - serviceUrl, - loginName: command.loginName, - organizationId: command.organization, - }); + const users = await listUsers({ serviceConfig, loginName: command.loginName, organizationId: command.organization }); if (users.details?.totalResult == BigInt(1) && users.result[0].userId) { user = users.result[0]; @@ -108,10 +101,7 @@ export async function sendPassword(command: UpdateSessionCommand): Promise<{ err password: { password: command.checks.password?.password }, }); - loginSettings = await getLoginSettings({ - serviceUrl, - organization: command.organization, - }); + loginSettings = await getLoginSettings({ serviceConfig, organization: command.organization }); try { session = await createSessionAndUpdateCookie({ @@ -121,10 +111,7 @@ export async function sendPassword(command: UpdateSessionCommand): Promise<{ err }); } catch (error: any) { if ("failedAttempts" in error && error.failedAttempts) { - const lockoutSettings = await getLockoutSettings({ - serviceUrl, - orgId: command.organization, - }); + const lockoutSettings = await getLockoutSettings({ serviceConfig, orgId: command.organization }); const hasLimit = lockoutSettings?.maxPasswordAttempts !== undefined && lockoutSettings?.maxPasswordAttempts > BigInt(0); @@ -149,10 +136,7 @@ export async function sendPassword(command: UpdateSessionCommand): Promise<{ err // this is a fake error message to hide that the user does not even exist return { error: t("errors.couldNotVerifyPassword") }; } else { - loginSettings = await getLoginSettings({ - serviceUrl, - organization: sessionCookie.organization, - }); + loginSettings = await getLoginSettings({ serviceConfig, organization: sessionCookie.organization }); if (!loginSettings) { return { error: "Could not load login settings" }; @@ -177,10 +161,7 @@ export async function sendPassword(command: UpdateSessionCommand): Promise<{ err }); } catch (error: any) { if ("failedAttempts" in error && error.failedAttempts) { - const lockoutSettings = await getLockoutSettings({ - serviceUrl, - orgId: command.organization, - }); + const lockoutSettings = await getLockoutSettings({ serviceConfig, orgId: command.organization }); const hasLimit = lockoutSettings?.maxPasswordAttempts !== undefined && lockoutSettings?.maxPasswordAttempts > BigInt(0); @@ -202,10 +183,7 @@ export async function sendPassword(command: UpdateSessionCommand): Promise<{ err return { error: t("errors.couldNotCreateSessionForUser") }; } - const userResponse = await getUserByID({ - serviceUrl, - userId: session?.factors?.user?.id, - }); + const userResponse = await getUserByID({ serviceConfig, userId: session?.factors?.user?.id }); if (!userResponse.user) { return { error: t("errors.userNotFound") }; @@ -216,7 +194,7 @@ export async function sendPassword(command: UpdateSessionCommand): Promise<{ err if (!loginSettings) { loginSettings = await getLoginSettings({ - serviceUrl, + serviceConfig, organization: command.organization ?? session.factors?.user?.organizationId, }); } @@ -228,7 +206,7 @@ export async function sendPassword(command: UpdateSessionCommand): Promise<{ err const humanUser = user.type.case === "human" ? user.type.value : undefined; const expirySettings = await getPasswordExpirySettings({ - serviceUrl, + serviceConfig, orgId: command.organization ?? session.factors?.user?.organizationId, }); @@ -260,10 +238,7 @@ export async function sendPassword(command: UpdateSessionCommand): Promise<{ err // if password, check if user has MFA methods let authMethods; if (command.checks && command.checks.password && session.factors?.user?.id) { - const response = await listAuthenticationMethodTypes({ - serviceUrl, - userId: session.factors.user.id, - }); + const response = await listAuthenticationMethodTypes({ serviceConfig, userId: session.factors.user.id }); if (response.authMethodTypes && response.authMethodTypes.length) { authMethods = response.authMethodTypes; } @@ -274,7 +249,7 @@ export async function sendPassword(command: UpdateSessionCommand): Promise<{ err } const mfaFactorCheck = await checkMFAFactors( - serviceUrl, + serviceConfig, session, loginSettings, authMethods, @@ -331,14 +306,11 @@ export async function sendPassword(command: UpdateSessionCommand): Promise<{ err // this function lets users with code set a password or users with valid User Verification Check export async function changePassword(command: { code?: string; userId: string; password: string }) { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); const t = await getTranslations("password"); // check for init state - const { user } = await getUserByID({ - serviceUrl, - userId: command.userId, - }); + const { user } = await getUserByID({ serviceConfig, userId: command.userId }); if (!user || user.userId !== command.userId) { return { error: t("errors.couldNotSendResetLink") }; @@ -351,10 +323,7 @@ export async function changePassword(command: { code?: string; userId: string; p // check if the user has no password set in order to set a password if (!command.code) { - const authmethods = await listAuthenticationMethodTypes({ - serviceUrl, - userId, - }); + const authmethods = await listAuthenticationMethodTypes({ serviceConfig, userId }); // if the user has no authmethods set, we need to check if the user was verified if (authmethods.authMethodTypes.length !== 0) { @@ -371,12 +340,7 @@ export async function changePassword(command: { code?: string; userId: string; p } } - return setUserPassword({ - serviceUrl, - userId, - password: command.password, - code: command.code, - }); + return setUserPassword({ serviceConfig, userId, password: command.password, code: command.code }); } type CheckSessionAndSetPasswordCommand = { @@ -386,7 +350,7 @@ type CheckSessionAndSetPasswordCommand = { export async function checkSessionAndSetPassword({ sessionId, password }: CheckSessionAndSetPasswordCommand) { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); const t = await getTranslations("password"); let sessionCookie; @@ -400,7 +364,7 @@ export async function checkSessionAndSetPassword({ sessionId, password }: CheckS let session; try { const sessionResponse = await getSession({ - serviceUrl, + serviceConfig, sessionId: sessionCookie.id, sessionToken: sessionCookie.token, }); @@ -424,10 +388,7 @@ export async function checkSessionAndSetPassword({ sessionId, password }: CheckS // check if the user has no password set in order to set a password let authmethods; try { - authmethods = await listAuthenticationMethodTypes({ - serviceUrl, - userId: session.factors.user.id, - }); + authmethods = await listAuthenticationMethodTypes({ serviceConfig, userId: session.factors.user.id }); } catch (error) { console.error("Error getting auth methods:", error); return { error: "Could not load auth methods" }; @@ -439,10 +400,7 @@ export async function checkSessionAndSetPassword({ sessionId, password }: CheckS let loginSettings; try { - loginSettings = await getLoginSettings({ - serviceUrl, - organization: session.factors.user.organizationId, - }); + loginSettings = await getLoginSettings({ serviceConfig, organization: session.factors.user.organizationId }); } catch (error) { console.error("Error getting login settings:", error); return { error: "Could not load login settings" }; @@ -453,24 +411,24 @@ export async function checkSessionAndSetPassword({ sessionId, password }: CheckS // if the user has no MFA but MFA is enforced, we can set a password otherwise we use the token of the user if (forceMfa) { console.log("Set password using service account due to enforced MFA without existing MFA methods"); - return setPassword({ serviceUrl, payload }).catch((error) => { + return setPassword({ serviceConfig, payload }).catch((error) => { // throw error if failed precondition (ex. User is not yet initialized) if (error.code === 9 && error.message) { return { error: t("errors.failedPrecondition") }; } return { error: "Could not set password" }; }); } else { - const transport = async (serviceUrl: string, token: string) => { - return createServerTransport(token, serviceUrl); + const transport = async (serviceConfig: ServiceConfig, token: string) => { + return createServerTransport(token, serviceConfig); }; - const myUserService = async (serviceUrl: string, sessionToken: string) => { - const transportPromise = await transport(serviceUrl, sessionToken); + const myUserService = async (serviceConfig: ServiceConfig, sessionToken: string) => { + const transportPromise = await transport(serviceConfig, sessionToken); return createUserServiceClient(transportPromise); }; - const selfService = await myUserService(serviceUrl, `${sessionCookie.token}`); + const selfService = await myUserService(serviceConfig, sessionCookie.token); return selfService .setPassword(
apps/login/src/lib/server/register.ts+12 −26 modified@@ -7,7 +7,7 @@ import { Factors } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { ChecksJson, ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { cookies, headers } from "next/headers"; import { getTranslations } from "next-intl/server"; -import { getServiceUrlFromHeaders } from "../service-url"; +import { getServiceConfig } from "../service-url"; import { checkEmailVerification, checkMFAFactors } from "../verify-helper"; import { getOrSetFingerprintId } from "../fingerprint"; import crypto from "crypto"; @@ -30,11 +30,9 @@ export type RegisterUserResponse = { export async function registerUser(command: RegisterUserCommand) { const t = await getTranslations("register"); const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); - const addResponse = await addHumanUser({ - serviceUrl, - email: command.email, + const addResponse = await addHumanUser({ serviceConfig, email: command.email, firstName: command.firstName, lastName: command.lastName, password: command.password ? command.password : undefined, @@ -45,9 +43,7 @@ export async function registerUser(command: RegisterUserCommand) { return { error: t("errors.couldNotCreateUser") }; } - const loginSettings = await getLoginSettings({ - serviceUrl, - organization: command.organization, + const loginSettings = await getLoginSettings({ serviceConfig, organization: command.organization, }); let checkPayload: any = { @@ -100,9 +96,7 @@ export async function registerUser(command: RegisterUserCommand) { return { redirect: "/passkey/set?" + params }; } else { - const userResponse = await getUserByID({ - serviceUrl, - userId: session?.factors?.user?.id, + const userResponse = await getUserByID({ serviceConfig, userId: session?.factors?.user?.id, }); if (!userResponse.user) { @@ -162,11 +156,9 @@ export async function registerUserAndLinkToIDP(command: RegisterUserAndLinkToIDP const t = await getTranslations("register"); const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); - const addUserResponse = await addHumanUser({ - serviceUrl, - email: command.email, + const addUserResponse = await addHumanUser({ serviceConfig, email: command.email, firstName: command.firstName, lastName: command.lastName, organization: command.organization, @@ -176,14 +168,10 @@ export async function registerUserAndLinkToIDP(command: RegisterUserAndLinkToIDP return { error: t("errors.couldNotCreateUser") }; } - const loginSettings = await getLoginSettings({ - serviceUrl, - organization: command.organization, + const loginSettings = await getLoginSettings({ serviceConfig, organization: command.organization, }); - const idpLink = await addIDPLink({ - serviceUrl, - idp: { + const idpLink = await addIDPLink({ serviceConfig, idp: { id: command.idpId, userId: command.idpUserId, userName: command.idpUserName, @@ -207,7 +195,7 @@ export async function registerUserAndLinkToIDP(command: RegisterUserAndLinkToIDP } // const userResponse = await getUserByID({ - // serviceUrl, + // serviceConfig.baseUrl, // userId: session?.factors?.user?.id, // }); @@ -227,9 +215,7 @@ export async function registerUserAndLinkToIDP(command: RegisterUserAndLinkToIDP // check if user has MFA methods let authMethods; if (session.factors?.user?.id) { - const response = await listAuthenticationMethodTypes({ - serviceUrl, - userId: session.factors.user.id, + const response = await listAuthenticationMethodTypes({ serviceConfig, userId: session.factors.user.id, }); if (response.authMethodTypes && response.authMethodTypes.length) { authMethods = response.authMethodTypes; @@ -239,7 +225,7 @@ export async function registerUserAndLinkToIDP(command: RegisterUserAndLinkToIDP // Always check MFA factors, even if no auth methods are configured // This ensures that force MFA settings are respected const mfaFactorCheck = await checkMFAFactors( - serviceUrl, + serviceConfig, session, loginSettings, authMethods || [], // Pass empty array if no auth methods
apps/login/src/lib/server/session.ts+14 −26 modified@@ -21,8 +21,8 @@ import { getSessionCookieByLoginName, removeSessionFromCookie, } from "../cookies"; -import { getServiceUrlFromHeaders } from "../service-url"; -import { getOriginalHost } from "./host"; +import { getServiceConfig } from "../service-url"; +import { getPublicHost } from "./host"; export async function skipMFAAndContinueWithNextUrl({ userId, @@ -38,14 +38,11 @@ export async function skipMFAAndContinueWithNextUrl({ organization?: string; }): Promise<{ redirect: string } | { error: string }> { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); - const loginSettings = await getLoginSettings({ - serviceUrl, - organization: organization, - }); + const loginSettings = await getLoginSettings({ serviceConfig, organization: organization }); - await humanMFAInitSkipped({ serviceUrl, userId }); + await humanMFAInitSkipped({ serviceConfig, userId }); if (requestId && sessionId) { return completeFlowOrGetUrl( @@ -73,14 +70,11 @@ export type ContinueWithSessionCommand = Session & { requestId?: string }; export async function continueWithSession({ requestId, ...session }: ContinueWithSessionCommand) { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); const t = await getTranslations("error"); - const loginSettings = await getLoginSettings({ - serviceUrl, - organization: session.factors?.user?.organizationId, - }); + const loginSettings = await getLoginSettings({ serviceConfig, organization: session.factors?.user?.organizationId }); if (requestId && session.id && session.factors?.user) { return completeFlowOrGetUrl( @@ -130,8 +124,8 @@ export async function updateSession(options: UpdateSessionCommand) { } const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); - const host = await getOriginalHost(); + const { serviceConfig } = getServiceConfig(_headers); + const host = getPublicHost(_headers); if (!host) { return { error: "Could not get host" }; @@ -143,10 +137,7 @@ export async function updateSession(options: UpdateSessionCommand) { challenges.webAuthN.domain = hostname; } - const loginSettings = await getLoginSettings({ - serviceUrl, - organization, - }); + const loginSettings = await getLoginSettings({ serviceConfig, organization }); let lifetime = checks?.webAuthN ? loginSettings?.multiFactorCheckLifetime // TODO different lifetime for webauthn u2f/passkey @@ -177,10 +168,7 @@ export async function updateSession(options: UpdateSessionCommand) { // if password, check if user has MFA methods let authMethods; if (checks && checks.password && session.factors?.user?.id) { - const response = await listAuthenticationMethodTypes({ - serviceUrl, - userId: session.factors.user.id, - }); + const response = await listAuthenticationMethodTypes({ serviceConfig, userId: session.factors.user.id }); if (response.authMethodTypes && response.authMethodTypes.length) { authMethods = response.authMethodTypes; } @@ -200,19 +188,19 @@ type ClearSessionOptions = { export async function clearSession(options: ClearSessionOptions) { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); const { sessionId } = options; const sessionCookie = await getSessionCookieById({ sessionId }); const deleteResponse = await deleteSession({ - serviceUrl, + serviceConfig, sessionId: sessionCookie.id, sessionToken: sessionCookie.token, }); - const securitySettings = await getSecuritySettings({ serviceUrl }); + const securitySettings = await getSecuritySettings({ serviceConfig }); const iFrameEnabled = !!securitySettings?.embeddedIframe?.enabled; if (!deleteResponse) {
apps/login/src/lib/server/u2f.ts+9 −17 modified@@ -6,8 +6,8 @@ import { VerifyU2FRegistrationRequestSchema } from "@zitadel/proto/zitadel/user/ import { headers } from "next/headers"; import { userAgent } from "next/server"; import { getSessionCookieById } from "../cookies"; -import { getServiceUrlFromHeaders } from "../service-url"; -import { getOriginalHost } from "./host"; +import { getServiceConfig } from "../service-url"; +import { getPublicHost } from "./host"; type RegisterU2FCommand = { sessionId: string; @@ -22,8 +22,8 @@ type VerifyU2FCommand = { export async function addU2F(command: RegisterU2FCommand) { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); - const host = await getOriginalHost(); + const { serviceConfig } = getServiceConfig(_headers); + const host = getPublicHost(_headers); const sessionCookie = await getSessionCookieById({ sessionId: command.sessionId, @@ -33,11 +33,7 @@ export async function addU2F(command: RegisterU2FCommand) { return { error: "Could not get session" }; } - const session = await getSession({ - serviceUrl, - sessionId: sessionCookie.id, - sessionToken: sessionCookie.token, - }); + const session = await getSession({ serviceConfig, sessionId: sessionCookie.id, sessionToken: sessionCookie.token }); const [hostname] = host.split(":"); @@ -51,12 +47,12 @@ export async function addU2F(command: RegisterU2FCommand) { return { error: "Could not get session" }; } - return registerU2F({ serviceUrl, userId, domain: hostname }); + return registerU2F({ serviceConfig, userId, domain: hostname }); } export async function verifyU2F(command: VerifyU2FCommand) { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); let passkeyName = command.passkeyName; if (!passkeyName) { const headersList = await headers(); @@ -71,11 +67,7 @@ export async function verifyU2F(command: VerifyU2FCommand) { sessionId: command.sessionId, }); - const session = await getSession({ - serviceUrl, - sessionId: sessionCookie.id, - sessionToken: sessionCookie.token, - }); + const session = await getSession({ serviceConfig, sessionId: sessionCookie.id, sessionToken: sessionCookie.token }); const userId = session?.session?.factors?.user?.id; @@ -90,5 +82,5 @@ export async function verifyU2F(command: VerifyU2FCommand) { userId, }); - return verifyU2FRegistration({ serviceUrl, request }); + return verifyU2FRegistration({ serviceConfig, request }); }
apps/login/src/lib/server/verify.ts+27 −58 modified@@ -20,30 +20,26 @@ import { cookies, headers } from "next/headers"; import { completeFlowOrGetUrl } from "../client"; import { getSessionCookieByLoginName } from "../cookies"; import { getOrSetFingerprintId } from "../fingerprint"; -import { getServiceUrlFromHeaders } from "../service-url"; +import { getServiceConfig } from "../service-url"; import { loadMostRecentSession } from "../session"; import { checkMFAFactors } from "../verify-helper"; import { createSessionAndUpdateCookie } from "./cookie"; -import { getOriginalHostWithProtocol } from "./host"; +import { getPublicHostWithProtocol } from "./host"; import { getTranslations } from "next-intl/server"; export async function verifyTOTP(code: string, loginName?: string, organization?: string) { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); return loadMostRecentSession({ - serviceUrl, + serviceConfig, sessionParams: { loginName, organization, }, }).then((session) => { if (session?.factors?.user?.id) { - return verifyTOTPRegistration({ - serviceUrl, - code, - userId: session.factors.user.id, - }); + return verifyTOTPRegistration({ serviceConfig, code, userId: session.factors.user.id }); } else { throw Error("No user id found in session."); } @@ -62,22 +58,14 @@ type VerifyUserByEmailCommand = { export async function sendVerification(command: VerifyUserByEmailCommand) { const t = await getTranslations("verify"); const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); const verifyResponse = command.isInvite - ? await verifyInviteCode({ - serviceUrl, - userId: command.userId, - verificationCode: command.code, - }).catch((error) => { + ? await verifyInviteCode({ serviceConfig, userId: command.userId, verificationCode: command.code }).catch((error) => { console.warn(error); return { error: t("errors.couldNotVerifyInvite") }; }) - : await verifyEmail({ - serviceUrl, - userId: command.userId, - verificationCode: command.code, - }).catch((error) => { + : await verifyEmail({ serviceConfig, userId: command.userId, verificationCode: command.code }).catch((error) => { console.warn(error); return { error: t("errors.couldNotVerifyEmail") }; }); @@ -91,10 +79,7 @@ export async function sendVerification(command: VerifyUserByEmailCommand) { } let session: Session | undefined; - const userResponse = await getUserByID({ - serviceUrl, - userId: command.userId, - }); + const userResponse = await getUserByID({ serviceConfig, userId: command.userId }); if (!userResponse || !userResponse.user) { return { error: t("errors.couldNotLoadUser") }; @@ -110,22 +95,17 @@ export async function sendVerification(command: VerifyUserByEmailCommand) { }); if (sessionCookie) { - session = await getSession({ - serviceUrl, - sessionId: sessionCookie.id, - sessionToken: sessionCookie.token, - }).then((response) => { - if (response?.session) { - return response.session; - } - }); + session = await getSession({ serviceConfig, sessionId: sessionCookie.id, sessionToken: sessionCookie.token }).then( + (response) => { + if (response?.session) { + return response.session; + } + }, + ); } // load auth methods for user - const authMethodResponse = await listAuthenticationMethodTypes({ - serviceUrl, - userId: user.userId, - }); + const authMethodResponse = await listAuthenticationMethodTypes({ serviceConfig, userId: user.userId }); if (!authMethodResponse || !authMethodResponse.authMethodTypes) { return { error: t("errors.couldNotLoadAuthenticators") }; @@ -203,14 +183,11 @@ export async function sendVerification(command: VerifyUserByEmailCommand) { return { redirect: `/verify/success?${verifySuccessParams}` }; } - const loginSettings = await getLoginSettings({ - serviceUrl, - organization: user.details?.resourceOwner, - }); + const loginSettings = await getLoginSettings({ serviceConfig, organization: user.details?.resourceOwner }); // redirect to mfa factor if user has one, or redirect to set one up const mfaFactorCheck = await checkMFAFactors( - serviceUrl, + serviceConfig, session, loginSettings, authMethodResponse.authMethodTypes, @@ -253,14 +230,14 @@ type resendVerifyEmailCommand = { export async function resendVerification(command: resendVerifyEmailCommand) { const t = await getTranslations("verify"); const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); - const hostWithProtocol = await getOriginalHostWithProtocol(); + const { serviceConfig } = getServiceConfig(_headers); + const hostWithProtocol = await getPublicHostWithProtocol(_headers); const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; return command.isInvite ? createInviteCode({ - serviceUrl, + serviceConfig, userId: command.userId, urlTemplate: `${hostWithProtocol}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` + @@ -272,8 +249,8 @@ export async function resendVerification(command: resendVerifyEmailCommand) { return { error: t("errors.couldNotResendInvite") }; }) : zitadelSendEmailCode({ + serviceConfig, userId: command.userId, - serviceUrl, urlTemplate: `${hostWithProtocol}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` + (command.requestId ? `&requestId=${command.requestId}` : ""), @@ -287,22 +264,14 @@ type SendEmailCommand = { export async function sendEmailCode(command: SendEmailCommand) { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); - return zitadelSendEmailCode({ - serviceUrl, - userId: command.userId, - urlTemplate: command.urlTemplate, - }); + return zitadelSendEmailCode({ serviceConfig, userId: command.userId, urlTemplate: command.urlTemplate }); } export async function sendInviteEmailCode(command: SendEmailCommand) { const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); - return createInviteCode({ - serviceUrl, - userId: command.userId, - urlTemplate: command.urlTemplate, - }); + return createInviteCode({ serviceConfig, userId: command.userId, urlTemplate: command.urlTemplate }); }
apps/login/src/lib/service.ts+15 −19 modified@@ -7,7 +7,8 @@ import { SessionService } from "@zitadel/proto/zitadel/session/v2/session_servic import { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb"; import { UserService } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { systemAPIToken } from "./api"; -import { createServerTransport } from "./zitadel"; +import { hasSystemUserCredentials, hasServiceUserToken } from "./deployment"; +import { createServerTransport, ServiceConfig } from "./zitadel"; type ServiceClass = | typeof IdentityProviderService @@ -18,32 +19,27 @@ type ServiceClass = | typeof SettingsService | typeof SAMLService; -export async function createServiceForHost<T extends ServiceClass>( - service: T, - serviceUrl: string, -) { +export async function createServiceForHost<T extends ServiceClass>(service: T, serviceConfig: ServiceConfig) { let token; - // if we are running in a multitenancy context, use the system user token - if ( - process.env.AUDIENCE && - process.env.SYSTEM_USER_ID && - process.env.SYSTEM_USER_PRIVATE_KEY - ) { + // Determine authentication method based on available credentials + // Prefer system user JWT if available, fallback to service user token + if (hasSystemUserCredentials()) { token = await systemAPIToken(); - } else if (process.env.ZITADEL_SERVICE_USER_TOKEN) { + } else if (hasServiceUserToken()) { + // Use service user token authentication (self-hosted) token = process.env.ZITADEL_SERVICE_USER_TOKEN; + } else { + throw new Error( + "No authentication credentials found. Set either system user credentials (AUDIENCE, SYSTEM_USER_ID, SYSTEM_USER_PRIVATE_KEY) or ZITADEL_SERVICE_USER_TOKEN", + ); } - if (!serviceUrl) { - throw new Error("No instance url found"); + if (!serviceConfig) { + throw new Error("No service config found"); } - if (!token) { - throw new Error("No token found"); - } - - const transport = createServerTransport(token, serviceUrl); + const transport = createServerTransport(token, serviceConfig); return createClientFor<T>(service)(transport); }
apps/login/src/lib/service-url.test.ts+217 −0 added@@ -0,0 +1,217 @@ +import { describe, expect, test, beforeEach, afterEach, vi } from "vitest"; +import { getServiceConfig, constructUrl } from "./service-url"; +import { NextRequest } from "next/server"; + +describe("Service URL utilities", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe("getServiceConfig", () => { + test("should throw when ZITADEL_API_URL is not set", () => { + process.env.ZITADEL_API_URL = undefined as any; + + const mockHeaders = { + get: vi.fn(() => null), + } as any; + + expect(() => getServiceConfig(mockHeaders)).toThrow("ZITADEL_API_URL is not set"); + }); + + test("should return only baseUrl when x-zitadel-forward-host is not present (self-hosted)", () => { + process.env.ZITADEL_API_URL = "https://zitadel.mycompany.com"; + + const mockHeaders = { + get: vi.fn((key: string) => { + if (key === "x-zitadel-forward-host") return null; + if (key === "host") return "mycompany.com"; + return null; + }), + } as any; + + const result = getServiceConfig(mockHeaders); + + expect(result.serviceConfig.baseUrl).toBe("https://zitadel.mycompany.com"); + expect(result.serviceConfig.instanceHost).toBeUndefined(); + expect(result.serviceConfig.publicHost).toBe("mycompany.com"); + }); + + test("should use x-zitadel-forward-host when present (multi-tenant)", () => { + process.env.ZITADEL_API_URL = "https://api.zitadel.cloud"; + + const mockHeaders = { + get: vi.fn((key: string) => { + if (key === "x-zitadel-forward-host") return "customer.zitadel.cloud"; + if (key === "host") return "customer.zitadel.cloud"; + return null; + }), + } as any; + + const result = getServiceConfig(mockHeaders); + + expect(result.serviceConfig.baseUrl).toBe("https://api.zitadel.cloud"); + expect(result.serviceConfig.instanceHost).toBe("customer.zitadel.cloud"); + expect(result.serviceConfig.publicHost).toBe("customer.zitadel.cloud"); + }); + + test("should strip protocol from instanceHost and publicHost", () => { + process.env.ZITADEL_API_URL = "https://api.zitadel.cloud"; + + const mockHeaders = { + get: vi.fn((key: string) => { + if (key === "x-zitadel-forward-host") return "https://customer.zitadel.cloud"; + if (key === "host") return "customer.zitadel.cloud"; + return null; + }), + } as any; + + const result = getServiceConfig(mockHeaders); + + expect(result.serviceConfig.instanceHost).toBe("customer.zitadel.cloud"); + expect(result.serviceConfig.publicHost).toBe("customer.zitadel.cloud"); + }); + + test("should throw when host header is missing", () => { + process.env.ZITADEL_API_URL = "https://api.zitadel.cloud"; + + const mockHeaders = { + get: vi.fn((key: string) => { + if (key === "x-zitadel-forward-host") return null; + if (key === "host") return null; + return null; + }), + } as any; + + expect(() => getServiceConfig(mockHeaders)).toThrow("No host found in headers"); + }); + + test("should handle host with port number", () => { + process.env.ZITADEL_API_URL = "https://api.zitadel.cloud"; + + const mockHeaders = { + get: vi.fn((key: string) => { + if (key === "x-zitadel-forward-host") return "customer.zitadel.cloud:443"; + if (key === "host") return "customer.zitadel.cloud:443"; + return null; + }), + } as any; + + const result = getServiceConfig(mockHeaders); + + expect(result.serviceConfig.publicHost).toBe("customer.zitadel.cloud:443"); + }); + }); + + describe("constructUrl", () => { + test("should construct URL with x-zitadel-forward-host when present", () => { + process.env.NEXT_PUBLIC_BASE_PATH = ""; + const mockRequest = { + headers: { + get: vi.fn((key: string) => { + if (key === "x-zitadel-forward-host") return "customer.zitadel.cloud"; + if (key === "host") return "customer.zitadel.cloud"; + return null; + }), + }, + nextUrl: { + protocol: "https:", + }, + } as any; + + const result = constructUrl(mockRequest as NextRequest, "/test"); + + expect(result.hostname).toBe("customer.zitadel.cloud"); + expect(result.pathname).toBe("/test"); + expect(result.protocol).toBe("https:"); + }); + + test("should fall back to x-forwarded-host when x-zitadel-forward-host is not present", () => { + process.env.NEXT_PUBLIC_BASE_PATH = ""; + const mockRequest = { + headers: { + get: vi.fn((key: string) => { + if (key === "x-zitadel-forward-host") return null; + if (key === "x-forwarded-host") return "mycompany.com"; + return null; + }), + }, + nextUrl: { + protocol: "https:", + }, + } as any; + + const result = constructUrl(mockRequest as NextRequest, "/oauth/authorize"); + + expect(result.hostname).toBe("mycompany.com"); + expect(result.pathname).toBe("/oauth/authorize"); + }); + + test("should fall back to host header when no forwarded headers present", () => { + const mockRequest = { + headers: { + get: vi.fn((key: string) => { + if (key === "x-zitadel-forward-host") return null; + if (key === "x-forwarded-host") return null; + if (key === "host") return "localhost:3000"; + return null; + }), + }, + nextUrl: { + protocol: "http:", + }, + } as any; + + const result = constructUrl(mockRequest as NextRequest, "/test"); + + expect(result.hostname).toBe("localhost"); + expect(result.port).toBe("3000"); + }); + + test("should use protocol from nextUrl.protocol (not from headers)", () => { + const mockRequest = { + headers: { + get: vi.fn((key: string) => { + // Even if x-forwarded-proto is present, it should be ignored + if (key === "x-forwarded-proto") return "http"; + if (key === "host") return "example.com"; + return null; + }), + }, + nextUrl: { + protocol: "https:", // This should be used + }, + } as any; + + const result = constructUrl(mockRequest as NextRequest, "/test"); + + // Should use https: from nextUrl.protocol, not http from header + expect(result.protocol).toBe("https:"); + }); + + test("should include base path when NEXT_PUBLIC_BASE_PATH is set", () => { + process.env.NEXT_PUBLIC_BASE_PATH = "/login"; + + const mockRequest = { + headers: { + get: vi.fn((key: string) => { + if (key === "host") return "example.com"; + return null; + }), + }, + nextUrl: { + protocol: "https:", + }, + } as any; + + const result = constructUrl(mockRequest as NextRequest, "/test"); + + expect(result.pathname).toBe("/login/test"); + }); + }); +});
apps/login/src/lib/service-url.ts+28 −39 modified@@ -1,58 +1,47 @@ import { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers"; import { NextRequest } from "next/server"; +import { ServiceConfig } from "./zitadel"; +import { getInstanceHost, getPublicHost } from "./server/host"; /** - * Extracts the service url and region from the headers if used in a multitenant context (host, x-zitadel-forward-host header) - * or falls back to the ZITADEL_API_URL for a self hosting deployment - * or falls back to the host header for a self hosting deployment using custom domains - * @param headers - * @returns the service url and region from the headers - * @throws if the service url could not be determined + * Extracts the service URL based on deployment mode and configuration. * + * Priority: + * 1. ZITADEL_API_URL (required) - Used by both self-hosted and multi-tenant + * 2. x-zitadel-forward-host (multi-tenant only) - Set by Zitadel proxy + * 3. host header (multi-tenant fallback) - For dynamic host resolution + * + * @param headers - Request headers + * @returns Object containing the service Configuration + * @throws Error if the service Configuration could not be determined */ -export function getServiceUrlFromHeaders(headers: ReadonlyHeaders): { - serviceUrl: string; -} { - let instanceUrl; - const forwardedHost = headers.get("x-zitadel-forward-host"); - // use the forwarded host if available (multitenant), otherwise fall back to the host of the deployment itself - if (forwardedHost) { - instanceUrl = forwardedHost; - instanceUrl = instanceUrl.startsWith("http://") - ? instanceUrl - : `https://${instanceUrl}`; - } else if (process.env.ZITADEL_API_URL) { - instanceUrl = process.env.ZITADEL_API_URL; - } else { - const host = headers.get("host"); +function stripProtocol(url: string): string { + return url.replace(/^https?:\/\//, ""); +} - if (host) { - const [hostname] = host.split(":"); - if (hostname !== "localhost") { - instanceUrl = host.startsWith("http") ? host : `https://${host}`; - } - } +export function getServiceConfig(headers: ReadonlyHeaders): { serviceConfig: ServiceConfig } { + if (!process.env.ZITADEL_API_URL) { + throw new Error("ZITADEL_API_URL is not set"); } - if (!instanceUrl) { - throw new Error("Service URL could not be determined"); - } + // use forwarded host from proxy - headers are forwarded to the APIs. + const instanceHost = getInstanceHost(headers); + const publicHost = getPublicHost(headers); return { - serviceUrl: instanceUrl, + serviceConfig: { + baseUrl: process.env.ZITADEL_API_URL, + ...(instanceHost && { instanceHost: stripProtocol(instanceHost) }), + ...(publicHost && { publicHost: stripProtocol(publicHost) }), + }, }; } export function constructUrl(request: NextRequest, path: string) { - const forwardedProto = request.headers.get("x-forwarded-proto") - ? `${request.headers.get("x-forwarded-proto")}:` - : request.nextUrl.protocol; + const protocol = request.nextUrl.protocol; - const forwardedHost = - request.headers.get("x-zitadel-forward-host") ?? - request.headers.get("x-forwarded-host") ?? - request.headers.get("host"); + const forwardedHost = getPublicHost(request.headers); const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ""; - return new URL(`${basePath}${path}`, `${forwardedProto}//${forwardedHost}`); + return new URL(`${basePath}${path}`, `${protocol}//${forwardedHost}`); }
apps/login/src/lib/session.test.ts+36 −36 modified@@ -101,7 +101,7 @@ describe("isSessionValid", () => { factors: undefined, }); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(false); expect(consoleSpy).toHaveBeenCalledWith("Session has no user"); @@ -120,7 +120,7 @@ describe("isSessionValid", () => { authMethodTypes: [], } as any); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(false); expect(consoleSpy).toHaveBeenCalledWith("Session is expired", expect.any(String)); @@ -147,7 +147,7 @@ describe("isSessionValid", () => { authMethodTypes: [], } as any); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(false); }); @@ -179,7 +179,7 @@ describe("isSessionValid", () => { forceMfaLocalOnly: false, } as any); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(true); }); @@ -207,7 +207,7 @@ describe("isSessionValid", () => { forceMfaLocalOnly: false, } as any); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(true); }); @@ -242,7 +242,7 @@ describe("isSessionValid", () => { vi.mocked(verifyHelperModule.shouldEnforceMFA).mockReturnValue(true); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(false); expect(consoleSpy).toHaveBeenCalledWith( @@ -278,7 +278,7 @@ describe("isSessionValid", () => { authMethodTypes: [AuthenticationMethodType.OTP_EMAIL], } as any); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(true); }); @@ -307,7 +307,7 @@ describe("isSessionValid", () => { authMethodTypes: [AuthenticationMethodType.U2F], } as any); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(true); }); @@ -342,7 +342,7 @@ describe("isSessionValid", () => { authMethodTypes: [AuthenticationMethodType.TOTP, AuthenticationMethodType.OTP_EMAIL], } as any); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(true); }); @@ -372,7 +372,7 @@ describe("isSessionValid", () => { vi.mocked(verifyHelperModule.shouldEnforceMFA).mockReturnValue(false); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(true); }); @@ -409,7 +409,7 @@ describe("isSessionValid", () => { vi.mocked(verifyHelperModule.shouldEnforceMFA).mockReturnValue(false); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(true); }); @@ -447,7 +447,7 @@ describe("isSessionValid", () => { vi.mocked(verifyHelperModule.shouldEnforceMFA).mockReturnValue(true); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(false); expect(consoleSpy).toHaveBeenCalledWith( @@ -499,7 +499,7 @@ describe("isSessionValid", () => { vi.mocked(verifyHelperModule.shouldEnforceMFA).mockReturnValue(false); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); // This should be true - if it's false, the original bug still exists expect(result).toBe(true); @@ -539,7 +539,7 @@ describe("isSessionValid", () => { vi.mocked(verifyHelperModule.shouldEnforceMFA).mockReturnValue(false); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); // With our fix, this should be true (session is valid) // With the old logic, this would have been false (bug) @@ -576,7 +576,7 @@ describe("isSessionValid", () => { vi.mocked(verifyHelperModule.shouldEnforceMFA).mockReturnValue(false); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(true); }); @@ -611,7 +611,7 @@ describe("isSessionValid", () => { vi.mocked(verifyHelperModule.shouldEnforceMFA).mockReturnValue(true); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(false); expect(consoleSpy).toHaveBeenCalledWith("Session has no valid multifactor", expect.any(Object)); @@ -647,7 +647,7 @@ describe("isSessionValid", () => { forceMfaLocalOnly: false, } as any); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(true); }); @@ -681,7 +681,7 @@ describe("isSessionValid", () => { forceMfaLocalOnly: true, } as any); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(true); }); @@ -716,15 +716,15 @@ describe("isSessionValid", () => { vi.mocked(verifyHelperModule.shouldEnforceMFA).mockReturnValue(true); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(false); expect(zitadelModule.getLoginSettings).toHaveBeenCalledWith({ - serviceUrl: mockServiceUrl, + serviceConfig: { baseUrl: mockServiceUrl }, organization: mockOrganizationId, }); expect(zitadelModule.listAuthenticationMethodTypes).toHaveBeenCalledWith({ - serviceUrl: mockServiceUrl, + serviceConfig: { baseUrl: mockServiceUrl }, userId: mockUserId, }); consoleSpy.mockRestore(); @@ -777,7 +777,7 @@ describe("isSessionValid", () => { }, } as any); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(false); expect(consoleSpy).toHaveBeenCalledWith( @@ -831,7 +831,7 @@ describe("isSessionValid", () => { }, } as any); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(true); }); @@ -866,7 +866,7 @@ describe("isSessionValid", () => { vi.mocked(verifyHelperModule.shouldEnforceMFA).mockReturnValue(false); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(true); // getUserByID should not be called when EMAIL_VERIFICATION is disabled @@ -902,7 +902,7 @@ describe("isSessionValid", () => { forceMfaLocalOnly: false, } as any); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(true); }); @@ -938,12 +938,12 @@ describe("isSessionValid", () => { authMethodTypes: [], } as any); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(true); expect(verifyHelperModule.shouldEnforceMFA).toHaveBeenCalledWith(session, expect.any(Object)); expect(zitadelModule.getLoginSettings).toHaveBeenCalledWith({ - serviceUrl: mockServiceUrl, + serviceConfig: { baseUrl: mockServiceUrl }, organization: mockOrganizationId, }); }); @@ -979,16 +979,16 @@ describe("isSessionValid", () => { authMethodTypes: [AuthenticationMethodType.TOTP, AuthenticationMethodType.OTP_EMAIL], } as any); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(false); expect(verifyHelperModule.shouldEnforceMFA).toHaveBeenCalledWith(session, expect.any(Object)); expect(zitadelModule.getLoginSettings).toHaveBeenCalledWith({ - serviceUrl: mockServiceUrl, + serviceConfig: { baseUrl: mockServiceUrl }, organization: mockOrganizationId, }); expect(zitadelModule.listAuthenticationMethodTypes).toHaveBeenCalledWith({ - serviceUrl: mockServiceUrl, + serviceConfig: { baseUrl: mockServiceUrl }, userId: mockUserId, }); }); @@ -1024,12 +1024,12 @@ describe("isSessionValid", () => { authMethodTypes: [AuthenticationMethodType.TOTP, AuthenticationMethodType.OTP_EMAIL], } as any); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(true); expect(verifyHelperModule.shouldEnforceMFA).toHaveBeenCalledWith(session, expect.any(Object)); expect(zitadelModule.getLoginSettings).toHaveBeenCalledWith({ - serviceUrl: mockServiceUrl, + serviceConfig: { baseUrl: mockServiceUrl }, organization: mockOrganizationId, }); // Should not call listAuthenticationMethodTypes since shouldEnforceMFA returned false @@ -1067,7 +1067,7 @@ describe("isSessionValid", () => { authMethodTypes: [AuthenticationMethodType.TOTP], } as any); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(true); }); @@ -1105,12 +1105,12 @@ describe("isSessionValid", () => { authMethodTypes: [AuthenticationMethodType.TOTP], } as any); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(true); expect(verifyHelperModule.shouldEnforceMFA).toHaveBeenCalledWith(session, expect.any(Object)); expect(zitadelModule.getLoginSettings).toHaveBeenCalledWith({ - serviceUrl: mockServiceUrl, + serviceConfig: { baseUrl: mockServiceUrl }, organization: mockOrganizationId, }); // Should not call listAuthenticationMethodTypes since shouldEnforceMFA returned false @@ -1146,7 +1146,7 @@ describe("isSessionValid", () => { forceMfaLocalOnly: false, } as any); - const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); expect(result).toBe(true); });
apps/login/src/lib/session.ts+11 −27 modified@@ -6,37 +6,30 @@ import { GetSessionResponse } from "@zitadel/proto/zitadel/session/v2/session_se import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { getMostRecentCookieWithLoginname } from "./cookies"; import { shouldEnforceMFA } from "./verify-helper"; -import { getLoginSettings, getSession, getUserByID, listAuthenticationMethodTypes } from "./zitadel"; +import { getLoginSettings, getSession, getUserByID, listAuthenticationMethodTypes, ServiceConfig } from "./zitadel"; type LoadMostRecentSessionParams = { - serviceUrl: string; + serviceConfig: ServiceConfig; sessionParams: { loginName?: string; organization?: string; }; }; -export async function loadMostRecentSession({ - serviceUrl, - sessionParams, -}: LoadMostRecentSessionParams): Promise<Session | undefined> { +export async function loadMostRecentSession({ serviceConfig, sessionParams }: LoadMostRecentSessionParams): Promise<Session | undefined> { const recent = await getMostRecentCookieWithLoginname({ loginName: sessionParams.loginName, organization: sessionParams.organization, }); - return getSession({ - serviceUrl, - sessionId: recent.id, - sessionToken: recent.token, - }).then((resp: GetSessionResponse) => resp.session); + return getSession({ serviceConfig, sessionId: recent.id, sessionToken: recent.token }).then((resp: GetSessionResponse) => resp.session); } /** * mfa is required, session is not valid anymore (e.g. session expired, user logged out, etc.) * to check for mfa for automatically selected session -> const response = await listAuthenticationMethodTypes(userId); **/ -export async function isSessionValid({ serviceUrl, session }: { serviceUrl: string; session: Session }): Promise<boolean> { +export async function isSessionValid({ serviceConfig, session }: { serviceConfig: ServiceConfig; session: Session }): Promise<boolean> { // session can't be checked without user if (!session.factors?.user) { console.warn("Session has no user"); @@ -51,20 +44,14 @@ export async function isSessionValid({ serviceUrl, session }: { serviceUrl: stri const validPasskey = session?.factors?.webAuthN?.verifiedAt; // Get login settings to determine if MFA is actually required by policy - const loginSettings = await getLoginSettings({ - serviceUrl, - organization: session.factors?.user?.organizationId, - }); + const loginSettings = await getLoginSettings({ serviceConfig, organization: session.factors?.user?.organizationId }); // Use the existing shouldEnforceMFA function to determine if MFA is required const isMfaRequired = shouldEnforceMFA(session, loginSettings); // Only enforce MFA validation if MFA is required by policy if (isMfaRequired) { - const authMethodTypes = await listAuthenticationMethodTypes({ - serviceUrl, - userId: session.factors.user.id, - }); + const authMethodTypes = await listAuthenticationMethodTypes({ serviceConfig, userId: session.factors.user.id }); const authMethods = authMethodTypes.authMethodTypes; // Filter to only MFA methods (exclude PASSWORD and PASSKEY) @@ -134,10 +121,7 @@ export async function isSessionValid({ serviceUrl, session }: { serviceUrl: stri // Check email verification if EMAIL_VERIFICATION environment variable is enabled if (process.env.EMAIL_VERIFICATION === "true") { - const userResponse = await getUserByID({ - serviceUrl, - userId: session.factors.user.id, - }); + const userResponse = await getUserByID({ serviceConfig, userId: session.factors.user.id }); const humanUser = userResponse?.user?.type.case === "human" ? userResponse?.user.type.value : undefined; @@ -151,12 +135,12 @@ export async function isSessionValid({ serviceUrl, session }: { serviceUrl: stri } export async function findValidSession({ - serviceUrl, + serviceConfig, sessions, authRequest, samlRequest, }: { - serviceUrl: string; + serviceConfig: ServiceConfig; sessions: Session[]; authRequest?: AuthRequest; samlRequest?: SAMLRequest; @@ -189,7 +173,7 @@ export async function findValidSession({ // return the first valid session according to settings for (const session of sessionsWithHint) { - if (await isSessionValid({ serviceUrl, session })) { + if (await isSessionValid({ serviceConfig, session })) { return session; } }
apps/login/src/lib/verify-helper.ts+3 −6 modified@@ -8,7 +8,7 @@ import crypto from "crypto"; import moment from "moment"; import { cookies } from "next/headers"; import { getFingerprintIdCookie } from "./fingerprint"; -import { getUserByID } from "./zitadel"; +import { getUserByID, ServiceConfig } from "./zitadel"; export function checkPasswordChangeRequired( expirySettings: PasswordExpirySettings | undefined, @@ -82,7 +82,7 @@ export function checkEmailVerification(session: Session, humanUser?: HumanUser, } export async function checkMFAFactors( - serviceUrl: string, + serviceConfig: ServiceConfig, session: Session, loginSettings: LoginSettings | undefined, authMethods: AuthenticationMethodType[], @@ -179,10 +179,7 @@ export async function checkMFAFactors( session?.factors?.user?.id && shouldEnforceMFA(session, loginSettings) ) { - const userResponse = await getUserByID({ - serviceUrl, - userId: session.factors?.user?.id, - }); + const userResponse = await getUserByID({ serviceConfig, userId: session.factors?.user?.id }); const humanUser = userResponse?.user?.type.case === "human" ? userResponse?.user.type.value : undefined;
apps/login/src/lib/zitadel.ts+279 −212 modified@@ -45,15 +45,14 @@ async function cacheWrapper<T>(callback: Promise<T>) { } export async function getHostedLoginTranslation({ - serviceUrl, + serviceConfig, organization, locale, -}: { - serviceUrl: string; +}: WithServiceConfig<{ organization?: string; locale?: string; -}) { - const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceUrl); +}>) { + const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceConfig); const callback = settingsService .getHostedLoginTranslation( @@ -78,8 +77,13 @@ export async function getHostedLoginTranslation({ return useCache ? cacheWrapper(callback) : callback; } -export async function getBrandingSettings({ serviceUrl, organization }: { serviceUrl: string; organization?: string }) { - const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceUrl); +export async function getBrandingSettings({ + serviceConfig, + organization, +}: WithServiceConfig<{ + organization?: string; +}>) { + const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceConfig); const callback = settingsService .getBrandingSettings({ ctx: makeReqCtx(organization) }, {}) @@ -88,8 +92,13 @@ export async function getBrandingSettings({ serviceUrl, organization }: { servic return useCache ? cacheWrapper(callback) : callback; } -export async function getLoginSettings({ serviceUrl, organization }: { serviceUrl: string; organization?: string }) { - const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceUrl); +export async function getLoginSettings({ + serviceConfig, + organization, +}: WithServiceConfig<{ + organization?: string; +}>) { + const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceConfig); const callback = settingsService .getLoginSettings({ ctx: makeReqCtx(organization) }, {}) @@ -98,16 +107,16 @@ export async function getLoginSettings({ serviceUrl, organization }: { serviceUr return useCache ? cacheWrapper(callback) : callback; } -export async function getSecuritySettings({ serviceUrl }: { serviceUrl: string }) { - const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceUrl); +export async function getSecuritySettings({ serviceConfig }: WithServiceConfig) { + const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceConfig); const callback = settingsService.getSecuritySettings({}).then((resp) => (resp.settings ? resp.settings : undefined)); return useCache ? cacheWrapper(callback) : callback; } -export async function getLockoutSettings({ serviceUrl, orgId }: { serviceUrl: string; orgId?: string }) { - const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceUrl); +export async function getLockoutSettings({ serviceConfig, orgId }: WithServiceConfig<{ orgId?: string }>) { + const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceConfig); const callback = settingsService .getLockoutSettings({ ctx: makeReqCtx(orgId) }, {}) @@ -116,8 +125,8 @@ export async function getLockoutSettings({ serviceUrl, orgId }: { serviceUrl: st return useCache ? cacheWrapper(callback) : callback; } -export async function getPasswordExpirySettings({ serviceUrl, orgId }: { serviceUrl: string; orgId?: string }) { - const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceUrl); +export async function getPasswordExpirySettings({ serviceConfig, orgId }: WithServiceConfig<{ orgId?: string }>) { + const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceConfig); const callback = settingsService .getPasswordExpirySettings({ ctx: makeReqCtx(orgId) }, {}) @@ -126,46 +135,45 @@ export async function getPasswordExpirySettings({ serviceUrl, orgId }: { service return useCache ? cacheWrapper(callback) : callback; } -export async function listIDPLinks({ serviceUrl, userId }: { serviceUrl: string; userId: string }) { - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); +export async function listIDPLinks({ serviceConfig, userId }: WithServiceConfig<{ userId: string }>) { + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.listIDPLinks({ userId }, {}); } -export async function addOTPEmail({ serviceUrl, userId }: { serviceUrl: string; userId: string }) { - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); +export async function addOTPEmail({ serviceConfig, userId }: WithServiceConfig<{ userId: string }>) { + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.addOTPEmail({ userId }, {}); } -export async function addOTPSMS({ serviceUrl, userId }: { serviceUrl: string; userId: string }) { - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); +export async function addOTPSMS({ serviceConfig, userId }: WithServiceConfig<{ userId: string }>) { + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.addOTPSMS({ userId }, {}); } -export async function registerTOTP({ serviceUrl, userId }: { serviceUrl: string; userId: string }) { - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); +export async function registerTOTP({ serviceConfig, userId }: WithServiceConfig<{ userId: string }>) { + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.registerTOTP({ userId }, {}); } -export async function getGeneralSettings({ serviceUrl }: { serviceUrl: string }) { - const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceUrl); +export async function getGeneralSettings({ serviceConfig }: WithServiceConfig) { + const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceConfig); const callback = settingsService.getGeneralSettings({}, {}).then((resp) => resp.supportedLanguages); return useCache ? cacheWrapper(callback) : callback; } export async function getLegalAndSupportSettings({ - serviceUrl, + serviceConfig, organization, -}: { - serviceUrl: string; +}: WithServiceConfig<{ organization?: string; -}) { - const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceUrl); +}>) { + const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceConfig); const callback = settingsService .getLegalAndSupportSettings({ ctx: makeReqCtx(organization) }, {}) @@ -175,13 +183,12 @@ export async function getLegalAndSupportSettings({ } export async function getPasswordComplexitySettings({ - serviceUrl, + serviceConfig, organization, -}: { - serviceUrl: string; +}: WithServiceConfig<{ organization?: string; -}) { - const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceUrl); +}>) { + const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceConfig); const callback = settingsService .getPasswordComplexitySettings({ ctx: makeReqCtx(organization) }) @@ -191,37 +198,35 @@ export async function getPasswordComplexitySettings({ } export async function createSessionFromChecks({ - serviceUrl, + serviceConfig, checks, lifetime, -}: { - serviceUrl: string; +}: WithServiceConfig<{ checks: Checks; lifetime: Duration; -}) { - const sessionService: Client<typeof SessionService> = await createServiceForHost(SessionService, serviceUrl); +}>) { + const sessionService: Client<typeof SessionService> = await createServiceForHost(SessionService, serviceConfig); const userAgent = await getUserAgent(); return sessionService.createSession({ checks, lifetime, userAgent }, {}); } export async function createSessionForUserIdAndIdpIntent({ - serviceUrl, + serviceConfig, userId, idpIntent, lifetime, -}: { - serviceUrl: string; +}: WithServiceConfig<{ userId: string; idpIntent: { idpIntentId?: string | undefined; idpIntentToken?: string | undefined; }; lifetime: Duration; -}) { +}>) { console.log("Creating session for userId and IDP intent", { userId, idpIntent, lifetime }); - const sessionService: Client<typeof SessionService> = await createServiceForHost(SessionService, serviceUrl); + const sessionService: Client<typeof SessionService> = await createServiceForHost(SessionService, serviceConfig); const userAgent = await getUserAgent(); @@ -241,21 +246,20 @@ export async function createSessionForUserIdAndIdpIntent({ } export async function setSession({ - serviceUrl, + serviceConfig, sessionId, sessionToken, challenges, checks, lifetime, -}: { - serviceUrl: string; +}: WithServiceConfig<{ sessionId: string; sessionToken: string; challenges: RequestChallenges | undefined; checks?: Checks; lifetime: Duration; -}) { - const sessionService: Client<typeof SessionService> = await createServiceForHost(SessionService, serviceUrl); +}>) { + const sessionService: Client<typeof SessionService> = await createServiceForHost(SessionService, serviceConfig); return sessionService.setSession( { @@ -271,40 +275,37 @@ export async function setSession({ } export async function getSession({ - serviceUrl, + serviceConfig, sessionId, sessionToken, -}: { - serviceUrl: string; +}: WithServiceConfig<{ sessionId: string; sessionToken: string; -}) { - const sessionService: Client<typeof SessionService> = await createServiceForHost(SessionService, serviceUrl); +}>) { + const sessionService: Client<typeof SessionService> = await createServiceForHost(SessionService, serviceConfig); return sessionService.getSession({ sessionId, sessionToken }, {}); } export async function deleteSession({ - serviceUrl, + serviceConfig, sessionId, sessionToken, -}: { - serviceUrl: string; +}: WithServiceConfig<{ sessionId: string; sessionToken: string; -}) { - const sessionService: Client<typeof SessionService> = await createServiceForHost(SessionService, serviceUrl); +}>) { + const sessionService: Client<typeof SessionService> = await createServiceForHost(SessionService, serviceConfig); return sessionService.deleteSession({ sessionId, sessionToken }, {}); } -type ListSessionsCommand = { - serviceUrl: string; +type ListSessionsCommand = WithServiceConfig<{ ids: string[]; -}; +}>; -export async function listSessions({ serviceUrl, ids }: ListSessionsCommand) { - const sessionService: Client<typeof SessionService> = await createServiceForHost(SessionService, serviceUrl); +export async function listSessions({ serviceConfig, ids }: ListSessionsCommand) { + const sessionService: Client<typeof SessionService> = await createServiceForHost(SessionService, serviceConfig); return sessionService.listSessions( { @@ -321,17 +322,16 @@ export async function listSessions({ serviceUrl, ids }: ListSessionsCommand) { ); } -export type AddHumanUserData = { - serviceUrl: string; +export type AddHumanUserData = WithServiceConfig<{ firstName: string; lastName: string; email: string; password?: string; organization: string; -}; +}>; -export async function addHumanUser({ serviceUrl, email, firstName, lastName, password, organization }: AddHumanUserData) { - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); +export async function addHumanUser({ serviceConfig, email, firstName, lastName, password, organization }: AddHumanUserData) { + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); let addHumanUserRequest: AddHumanUserRequest = create(AddHumanUserRequestSchema, { email: { @@ -360,67 +360,69 @@ export async function addHumanUser({ serviceUrl, email, firstName, lastName, pas return userService.addHumanUser(addHumanUserRequest); } -export async function addHuman({ serviceUrl, request }: { serviceUrl: string; request: AddHumanUserRequest }) { - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); +export async function addHuman({ serviceConfig, request }: WithServiceConfig<{ request: AddHumanUserRequest }>) { + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.addHumanUser(request); } -export async function updateHuman({ serviceUrl, request }: { serviceUrl: string; request: UpdateHumanUserRequest }) { - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); +export async function updateHuman({ + serviceConfig, + request, +}: WithServiceConfig<{ + request: UpdateHumanUserRequest; +}>) { + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.updateHumanUser(request); } export async function verifyTOTPRegistration({ - serviceUrl, + serviceConfig, code, userId, -}: { - serviceUrl: string; +}: WithServiceConfig<{ code: string; userId: string; -}) { - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); +}>) { + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.verifyTOTPRegistration({ code, userId }, {}); } -export async function getUserByID({ serviceUrl, userId }: { serviceUrl: string; userId: string }) { - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); +export async function getUserByID({ serviceConfig, userId }: WithServiceConfig<{ userId: string }>) { + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.getUserByID({ userId }, {}); } -export async function humanMFAInitSkipped({ serviceUrl, userId }: { serviceUrl: string; userId: string }) { - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); +export async function humanMFAInitSkipped({ serviceConfig, userId }: WithServiceConfig<{ userId: string }>) { + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.humanMFAInitSkipped({ userId }, {}); } export async function verifyInviteCode({ - serviceUrl, + serviceConfig, userId, verificationCode, -}: { - serviceUrl: string; +}: WithServiceConfig<{ userId: string; verificationCode: string; -}) { - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); +}>) { + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.verifyInviteCode({ userId, verificationCode }, {}); } export async function sendEmailCode({ - serviceUrl, + serviceConfig, userId, urlTemplate, -}: { - serviceUrl: string; +}: WithServiceConfig<{ userId: string; urlTemplate: string; -}) { +}>) { let medium = create(SendEmailCodeRequestSchema, { userId }); medium = create(SendEmailCodeRequestSchema, { @@ -433,20 +435,19 @@ export async function sendEmailCode({ }, }); - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.sendEmailCode(medium, {}); } export async function createInviteCode({ - serviceUrl, + serviceConfig, urlTemplate, userId, -}: { - serviceUrl: string; +}: WithServiceConfig<{ urlTemplate: string; userId: string; -}) { +}>) { let medium = create(SendInviteCodeSchema, { applicationName: process.env.NEXT_PUBLIC_APPLICATION_NAME || "Zitadel Login", }); @@ -456,7 +457,7 @@ export async function createInviteCode({ urlTemplate, }; - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.createInviteCode( { @@ -470,16 +471,15 @@ export async function createInviteCode({ ); } -export type ListUsersCommand = { - serviceUrl: string; +export type ListUsersCommand = WithServiceConfig<{ loginName?: string; userName?: string; email?: string; phone?: string; organizationId?: string; -}; +}>; -export async function listUsers({ serviceUrl, loginName, userName, phone, email, organizationId }: ListUsersCommand) { +export async function listUsers({ serviceConfig, loginName, userName, phone, email, organizationId }: ListUsersCommand) { const queries: SearchQuery[] = []; // either use loginName or userName, email, phone @@ -562,18 +562,17 @@ export async function listUsers({ serviceUrl, loginName, userName, phone, email, ); } - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.listUsers({ queries }); } -export type SearchUsersCommand = { - serviceUrl: string; +export type SearchUsersCommand = WithServiceConfig<{ searchValue: string; loginSettings: LoginSettings; organizationId?: string; suffix?: string; -}; +}>; const PhoneQuery = (searchValue: string) => create(SearchQuerySchema, { @@ -612,7 +611,13 @@ const EmailQuery = (searchValue: string) => * this is a dedicated search function to search for users from the loginname page * it searches users based on the loginName or userName and org suffix combination, and falls back to email and phone if no users are found * */ -export async function searchUsers({ serviceUrl, searchValue, loginSettings, organizationId, suffix }: SearchUsersCommand) { +export async function searchUsers({ + serviceConfig, + searchValue, + loginSettings, + organizationId, + suffix, +}: SearchUsersCommand) { const queries: SearchQuery[] = []; const t = await getTranslations("zitadel"); @@ -640,7 +645,7 @@ export async function searchUsers({ serviceUrl, searchValue, loginSettings, orga ); } - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); const loginNameResult = await userService.listUsers({ queries }); @@ -723,8 +728,8 @@ export async function searchUsers({ serviceUrl, searchValue, loginSettings, orga return { result: [] }; } -export async function getDefaultOrg({ serviceUrl }: { serviceUrl: string }): Promise<Organization | null> { - const orgService: Client<typeof OrganizationService> = await createServiceForHost(OrganizationService, serviceUrl); +export async function getDefaultOrg({ serviceConfig }: WithServiceConfig): Promise<Organization | null> { + const orgService: Client<typeof OrganizationService> = await createServiceForHost(OrganizationService, serviceConfig); return orgService .listOrganizations( @@ -743,8 +748,8 @@ export async function getDefaultOrg({ serviceUrl }: { serviceUrl: string }): Pro .then((resp) => (resp?.result && resp.result[0] ? resp.result[0] : null)); } -export async function getOrgsByDomain({ serviceUrl, domain }: { serviceUrl: string; domain: string }) { - const orgService: Client<typeof OrganizationService> = await createServiceForHost(OrganizationService, serviceUrl); +export async function getOrgsByDomain({ serviceConfig, domain }: WithServiceConfig<{ domain: string }>) { + const orgService: Client<typeof OrganizationService> = await createServiceForHost(OrganizationService, serviceConfig); return orgService.listOrganizations( { @@ -762,15 +767,15 @@ export async function getOrgsByDomain({ serviceUrl, domain }: { serviceUrl: stri } export async function startIdentityProviderFlow({ - serviceUrl, + serviceConfig, idpId, urls, -}: { - serviceUrl: string; +}: WithServiceConfig<{ idpId: string; urls: RedirectURLsJson; -}): Promise<string | null> { - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); +}>): Promise<string | null> { + // Use empty publicHost to avoid issues with redirect URIs pointing to the login UI instead of the zitadel API + const userService: Client<typeof UserService> = await createServiceForHost(UserService, {...serviceConfig, publicHost: ''}); return userService .startIdentityProviderIntent({ @@ -823,17 +828,16 @@ export async function startIdentityProviderFlow({ } export async function startLDAPIdentityProviderFlow({ - serviceUrl, + serviceConfig, idpId, username, password, -}: { - serviceUrl: string; +}: WithServiceConfig<{ idpId: string; username: string; password: string; -}) { - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); +}>) { + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.startIdentityProviderIntent({ idpId, @@ -847,32 +851,41 @@ export async function startLDAPIdentityProviderFlow({ }); } -export async function getAuthRequest({ serviceUrl, authRequestId }: { serviceUrl: string; authRequestId: string }) { - const oidcService = await createServiceForHost(OIDCService, serviceUrl); +export async function getAuthRequest({ + serviceConfig, + authRequestId, +}: WithServiceConfig<{ + authRequestId: string; +}>) { + const oidcService = await createServiceForHost(OIDCService, serviceConfig); return oidcService.getAuthRequest({ authRequestId, }); } -export async function getDeviceAuthorizationRequest({ serviceUrl, userCode }: { serviceUrl: string; userCode: string }) { - const oidcService = await createServiceForHost(OIDCService, serviceUrl); +export async function getDeviceAuthorizationRequest({ + serviceConfig, + userCode, +}: WithServiceConfig<{ + userCode: string; +}>) { + const oidcService = await createServiceForHost(OIDCService, serviceConfig); return oidcService.getDeviceAuthorizationRequest({ userCode, }); } export async function authorizeOrDenyDeviceAuthorization({ - serviceUrl, + serviceConfig, deviceAuthorizationId, session, -}: { - serviceUrl: string; +}: WithServiceConfig<{ deviceAuthorizationId: string; session?: { sessionId: string; sessionToken: string }; -}) { - const oidcService = await createServiceForHost(OIDCService, serviceUrl); +}>) { + const oidcService = await createServiceForHost(OIDCService, serviceConfig); return oidcService.authorizeOrDenyDeviceAuthorization({ deviceAuthorizationId, @@ -888,36 +901,40 @@ export async function authorizeOrDenyDeviceAuthorization({ }); } -export async function createCallback({ serviceUrl, req }: { serviceUrl: string; req: CreateCallbackRequest }) { - const oidcService = await createServiceForHost(OIDCService, serviceUrl); +export async function createCallback({ serviceConfig, req }: WithServiceConfig<{ req: CreateCallbackRequest }>) { + const oidcService = await createServiceForHost(OIDCService, serviceConfig); return oidcService.createCallback(req); } -export async function getSAMLRequest({ serviceUrl, samlRequestId }: { serviceUrl: string; samlRequestId: string }) { - const samlService = await createServiceForHost(SAMLService, serviceUrl); +export async function getSAMLRequest({ + serviceConfig, + samlRequestId, +}: WithServiceConfig<{ + samlRequestId: string; +}>) { + const samlService = await createServiceForHost(SAMLService, serviceConfig); return samlService.getSAMLRequest({ samlRequestId, }); } -export async function createResponse({ serviceUrl, req }: { serviceUrl: string; req: CreateResponseRequest }) { - const samlService = await createServiceForHost(SAMLService, serviceUrl); +export async function createResponse({ serviceConfig, req }: WithServiceConfig<{ req: CreateResponseRequest }>) { + const samlService = await createServiceForHost(SAMLService, serviceConfig); return samlService.createResponse(req); } export async function verifyEmail({ - serviceUrl, + serviceConfig, userId, verificationCode, -}: { - serviceUrl: string; +}: WithServiceConfig<{ userId: string; verificationCode: string; -}) { - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); +}>) { + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.verifyEmail( { @@ -929,14 +946,13 @@ export async function verifyEmail({ } export async function resendEmailCode({ - serviceUrl, + serviceConfig, userId, urlTemplate, -}: { - serviceUrl: string; +}: WithServiceConfig<{ userId: string; urlTemplate: string; -}) { +}>) { let request: ResendEmailCodeRequest = create(ResendEmailCodeRequestSchema, { userId, }); @@ -947,33 +963,42 @@ export async function resendEmailCode({ request = { ...request, verification: { case: "sendCode", value: medium } }; - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.resendEmailCode(request, {}); } -export async function retrieveIDPIntent({ serviceUrl, id, token }: { serviceUrl: string; id: string; token: string }) { - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); +export async function retrieveIDPIntent({ + serviceConfig, + id, + token, +}: WithServiceConfig<{ + id: string; + token: string; +}>) { + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.retrieveIdentityProviderIntent({ idpIntentId: id, idpIntentToken: token }, {}); } -export async function getIDPByID({ serviceUrl, id }: { serviceUrl: string; id: string }) { - const idpService: Client<typeof IdentityProviderService> = await createServiceForHost(IdentityProviderService, serviceUrl); +export async function getIDPByID({ serviceConfig, id }: WithServiceConfig<{ id: string }>) { + const idpService: Client<typeof IdentityProviderService> = await createServiceForHost( + IdentityProviderService, + serviceConfig, + ); return idpService.getIDPByID({ id }, {}).then((resp) => resp.idp); } export async function addIDPLink({ - serviceUrl, + serviceConfig, idp, userId, -}: { - serviceUrl: string; +}: WithServiceConfig<{ idp: { id: string; userId: string; userName: string }; userId: string; -}) { - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); +}>) { + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.addIDPLink( { @@ -989,14 +1014,13 @@ export async function addIDPLink({ } export async function passwordReset({ - serviceUrl, + serviceConfig, userId, urlTemplate, -}: { - serviceUrl: string; +}: WithServiceConfig<{ userId: string; urlTemplate?: string; -}) { +}>) { let medium = create(SendPasswordResetLinkSchema, { notificationType: NotificationType.Email, }); @@ -1006,7 +1030,7 @@ export async function passwordReset({ urlTemplate, }; - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.passwordReset( { @@ -1021,16 +1045,15 @@ export async function passwordReset({ } export async function setUserPassword({ - serviceUrl, + serviceConfig, userId, password, code, -}: { - serviceUrl: string; +}: WithServiceConfig<{ userId: string; password: string; code?: string; -}) { +}>) { let payload = create(SetPasswordRequestSchema, { userId, newPassword: { @@ -1048,7 +1071,7 @@ export async function setUserPassword({ }; } - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.setPassword(payload, {}).catch((error) => { // throw error if failed precondition (ex. User is not yet initialized) @@ -1060,8 +1083,13 @@ export async function setUserPassword({ }); } -export async function setPassword({ serviceUrl, payload }: { serviceUrl: string; payload: SetPasswordRequest }) { - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); +export async function setPassword({ + serviceConfig, + payload, +}: WithServiceConfig<{ + payload: SetPasswordRequest; +}>) { + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.setPassword(payload, {}); } @@ -1072,8 +1100,13 @@ export async function setPassword({ serviceUrl, payload }: { serviceUrl: string; * @param userId the id of the user where the email should be set * @returns the newly set email */ -export async function createPasskeyRegistrationLink({ serviceUrl, userId }: { serviceUrl: string; userId: string }) { - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); +export async function createPasskeyRegistrationLink({ + serviceConfig, + userId, +}: WithServiceConfig<{ + userId: string; +}>) { + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.createPasskeyRegistrationLink({ userId, @@ -1091,8 +1124,15 @@ export async function createPasskeyRegistrationLink({ serviceUrl, userId }: { se * @param domain the domain on which the factor is registered * @returns the newly set email */ -export async function registerU2F({ serviceUrl, userId, domain }: { serviceUrl: string; userId: string; domain: string }) { - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); +export async function registerU2F({ + serviceConfig, + userId, + domain, +}: WithServiceConfig<{ + userId: string; + domain: string; +}>) { + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.registerU2F({ userId, @@ -1107,13 +1147,12 @@ export async function registerU2F({ serviceUrl, userId, domain }: { serviceUrl: * @returns the result of the verification */ export async function verifyU2FRegistration({ - serviceUrl, + serviceConfig, request, -}: { - serviceUrl: string; +}: WithServiceConfig<{ request: VerifyU2FRegistrationRequest; -}) { - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); +}>) { + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.verifyU2FRegistration(request, {}); } @@ -1126,19 +1165,18 @@ export async function verifyU2FRegistration({ * @returns the active identity providers */ export async function getActiveIdentityProviders({ - serviceUrl, + serviceConfig, orgId, linking_allowed, -}: { - serviceUrl: string; +}: WithServiceConfig<{ orgId?: string; linking_allowed?: boolean; -}) { +}>) { const props: any = { ctx: makeReqCtx(orgId) }; if (linking_allowed) { props.linkingAllowed = linking_allowed; } - const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceUrl); + const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceConfig); return settingsService.getActiveIdentityProviders(props, {}); } @@ -1150,13 +1188,12 @@ export async function getActiveIdentityProviders({ * @returns the result of the verification */ export async function verifyPasskeyRegistration({ - serviceUrl, + serviceConfig, request, -}: { - serviceUrl: string; +}: WithServiceConfig<{ request: VerifyPasskeyRegistrationRequest; -}) { - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); +}>) { + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.verifyPasskeyRegistration(request, {}); } @@ -1170,17 +1207,16 @@ export async function verifyPasskeyRegistration({ * @returns the newly set email */ export async function registerPasskey({ - serviceUrl, + serviceConfig, userId, code, domain, -}: { - serviceUrl: string; +}: WithServiceConfig<{ userId: string; code: { id: string; code: string }; domain: string; -}) { - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); +}>) { + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.registerPasskey({ userId, @@ -1195,33 +1231,64 @@ export async function registerPasskey({ * @param userId the id of the user where the email should be set * @returns the list of authentication method types */ -export async function listAuthenticationMethodTypes({ serviceUrl, userId }: { serviceUrl: string; userId: string }) { - const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceUrl); +export async function listAuthenticationMethodTypes({ + serviceConfig, + userId, +}: WithServiceConfig<{ + userId: string; +}>) { + const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig); return userService.listAuthenticationMethodTypes({ userId, }); } -export function createServerTransport(token: string, baseUrl: string) { +export interface ServiceConfig { + baseUrl: string; + instanceHost?: string; // only for multi-tenant + publicHost?: string; // only for multi-tenant +} + +/** + * Base type that all function parameters must extend to ensure serviceConfig is always required + */ +export type WithServiceConfig<T = {}> = T & { + serviceConfig: ServiceConfig; +}; + +export function createServerTransport(token: string, serviceConfig: ServiceConfig) { return libCreateServerTransport(token, { - baseUrl, - interceptors: !process.env.CUSTOM_REQUEST_HEADERS - ? undefined - : [ - (next) => { - return (req) => { - process.env.CUSTOM_REQUEST_HEADERS!.split(",").forEach((header) => { - const kv = header.indexOf(":"); - if (kv > 0) { - req.header.set(header.slice(0, kv).trim(), header.slice(kv + 1).trim()); - } else { - console.warn(`Skipping malformed header: ${header}`); + baseUrl: serviceConfig.baseUrl, + interceptors: + !process.env.CUSTOM_REQUEST_HEADERS && !serviceConfig.instanceHost && !serviceConfig.publicHost + ? undefined + : [ + (next) => { + return (req) => { + // Apply headers from serviceConfig + if (serviceConfig.instanceHost) { + req.header.set("x-zitadel-instance-host", serviceConfig.instanceHost); } - }); - return next(req); - }; - }, - ], + if (serviceConfig.publicHost) { + req.header.set("x-zitadel-public-host", serviceConfig.publicHost); + } + + // Apply headers from CUSTOM_REQUEST_HEADERS environment variable + if (process.env.CUSTOM_REQUEST_HEADERS) { + process.env.CUSTOM_REQUEST_HEADERS.split(",").forEach((header) => { + const kv = header.indexOf(":"); + if (kv > 0) { + req.header.set(header.slice(0, kv).trim(), header.slice(kv + 1).trim()); + } else { + console.warn(`Skipping malformed header: ${header}`); + } + }); + } + + return next(req); + }; + }, + ], }); }
apps/login/src/middleware.ts+20 −44 modified@@ -3,28 +3,16 @@ import { SecuritySettings } from "@zitadel/proto/zitadel/settings/v2/security_se import { headers } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; import { DEFAULT_CSP } from "../constants/csp"; -import { getServiceUrlFromHeaders } from "./lib/service-url"; +import { getServiceConfig } from "./lib/service-url"; export const config = { - matcher: [ - "/.well-known/:path*", - "/oauth/:path*", - "/oidc/:path*", - "/idps/callback/:path*", - "/saml/:path*", - "/:path*", - ], + matcher: ["/.well-known/:path*", "/oauth/:path*", "/oidc/:path*", "/idps/callback/:path*", "/saml/:path*", "/:path*"], }; -async function loadSecuritySettings( - request: NextRequest, -): Promise<SecuritySettings | null> { +async function loadSecuritySettings(request: NextRequest): Promise<SecuritySettings | null> { const securityResponse = await fetch(`${request.nextUrl.origin}/security`); if (!securityResponse.ok) { - console.error( - "Failed to fetch security settings:", - securityResponse.statusText, - ); + console.error("Failed to fetch security settings:", securityResponse.statusText); return null; } @@ -49,40 +37,28 @@ export async function middleware(request: NextRequest) { } // Only run the rest of the logic for the original matcher paths - const proxyPaths = [ - "/.well-known/", - "/oauth/", - "/oidc/", - "/idps/callback/", - "/saml/", - ]; - - const isMatched = proxyPaths.some((prefix) => - request.nextUrl.pathname.startsWith(prefix), - ); - - // escape proxy if the environment is setup for multitenancy - if ( - !isMatched || - !process.env.ZITADEL_API_URL || - !process.env.ZITADEL_SERVICE_USER_TOKEN - ) { - // For all other routes, just add the header and continue + const proxyPaths = ["/.well-known/", "/oauth/", "/oidc/", "/idps/callback/", "/saml/"]; + + const isMatched = proxyPaths.some((prefix) => request.nextUrl.pathname.startsWith(prefix)); + + // Only proxy in self-hosted mode + if (!isMatched) { + // For multi-tenant or non-proxied routes, just add the header and continue return NextResponse.next({ request: { headers: requestHeaders }, }); } const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const { serviceConfig } = getServiceConfig(_headers); - const instanceHost = `${serviceUrl}` - .replace("https://", "") - .replace("http://", ""); - - // Add additional headers as before - requestHeaders.set("x-zitadel-public-host", `${request.nextUrl.host}`); - requestHeaders.set("x-zitadel-instance-host", instanceHost); + // add additional headers if available + if (serviceConfig.publicHost) { + requestHeaders.set("x-zitadel-public-host", serviceConfig.publicHost); + } + if (serviceConfig.instanceHost) { + requestHeaders.set("x-zitadel-instance-host", serviceConfig.instanceHost); + } const responseHeaders = new Headers(); responseHeaders.set("Access-Control-Allow-Origin", "*"); @@ -98,7 +74,7 @@ export async function middleware(request: NextRequest) { responseHeaders.delete("X-Frame-Options"); } - request.nextUrl.href = `${serviceUrl}${request.nextUrl.pathname}${request.nextUrl.search}`; + request.nextUrl.href = `${serviceConfig.baseUrl}${request.nextUrl.pathname}${request.nextUrl.search}`; return NextResponse.rewrite(request.nextUrl, { request: {
apps/login/tsconfig.json+12 −2 modified@@ -28,6 +28,16 @@ ] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules", "integration", "acceptance"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules", + "integration", + "acceptance" + ] }
internal/api/oidc/auth_request.go+26 −9 modified@@ -10,10 +10,11 @@ import ( "strings" "time" + "github.com/go-jose/go-jose/v4" "github.com/zitadel/logging" + "github.com/zitadel/oidc/v3/pkg/crypto" "github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/oidc/v3/pkg/op" - "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/api/authz" http_utils "github.com/zitadel/zitadel/internal/api/http" @@ -33,6 +34,7 @@ const ( LoginPostLogoutRedirectParam = "post_logout_redirect" LoginLogoutHintParam = "logout_hint" LoginUILocalesParam = "ui_locales" + LoginLogoutTokenParam = "logout_token" LoginPath = "/login" LogoutPath = "/logout" LogoutDonePath = "/logout/done" @@ -298,7 +300,11 @@ func (o *OPStorage) TerminateSessionFromRequest(ctx context.Context, endSessionR } else { logoutURI = logoutURI.JoinPath(LogoutPath) } - return buildLoginV2LogoutURL(logoutURI, redirectURI, endSessionRequest.LogoutHint, endSessionRequest.UILocales), nil + signer, _, err := GetSignerOnce(o.query.GetActiveSigningWebKey)(ctx) + if err != nil { + return "", err + } + return buildLoginV2LogoutURL(logoutURI, redirectURI, endSessionRequest.LogoutHint, endSessionRequest.UILocales, signer) } // V1: @@ -375,7 +381,13 @@ func (o *OPStorage) federatedLogout(ctx context.Context, sessionID string, postL return login.ExternalLogoutPath(sessionID) } -func buildLoginV2LogoutURL(logoutURI *url.URL, redirectURI, logoutHint string, uiLocales []language.Tag) string { +type logoutTokenPayload struct { + PostLogoutRedirectURI string `json:"post_logout_redirect_uri,omitempty"` + LogoutHint string `json:"logout_hint,omitempty"` + UILocales oidc.Locales `json:"ui_locales,omitempty"` +} + +func buildLoginV2LogoutURL(logoutURI *url.URL, redirectURI, logoutHint string, uiLocales oidc.Locales, signer jose.Signer) (string, error) { if strings.HasSuffix(logoutURI.Path, "/") && len(logoutURI.Path) > 1 { logoutURI.Path = strings.TrimSuffix(logoutURI.Path, "/") } @@ -386,14 +398,19 @@ func buildLoginV2LogoutURL(logoutURI *url.URL, redirectURI, logoutHint string, u q.Set(LoginLogoutHintParam, logoutHint) } if len(uiLocales) > 0 { - locales := make([]string, len(uiLocales)) - for i, locale := range uiLocales { - locales[i] = locale.String() - } - q.Set(LoginUILocalesParam, strings.Join(locales, " ")) + q.Set(LoginUILocalesParam, uiLocales.String()) + } + logoutToken, err := crypto.Sign(&logoutTokenPayload{ + PostLogoutRedirectURI: redirectURI, + LogoutHint: logoutHint, + UILocales: uiLocales, + }, signer) + if err != nil { + return "", err } + q.Set(LoginLogoutTokenParam, logoutToken) logoutURI.RawQuery = q.Encode() - return logoutURI.String() + return logoutURI.String(), nil } // v2PostLogoutRedirectURI will take care that the post_logout_redirect_uri is correctly set for v2 logins.
internal/api/oidc/auth_request_test.go+43 −1 modified@@ -1,9 +1,13 @@ package oidc import ( + "crypto/rand" + "crypto/rsa" + "encoding/json" "net/url" "testing" + "github.com/go-jose/go-jose/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/text/language" @@ -18,6 +22,7 @@ func TestBuildLoginV2LogoutURL(t *testing.T) { redirectURI string logoutHint string uiLocales []language.Tag + signer jose.Signer expectedParams map[string]string }{ { @@ -26,6 +31,7 @@ func TestBuildLoginV2LogoutURL(t *testing.T) { redirectURI: "https://client/cb", expectedParams: map[string]string{ "post_logout_redirect": "https://client/cb", + "logout_token": "", // presence checked separately }, }, { @@ -36,6 +42,7 @@ func TestBuildLoginV2LogoutURL(t *testing.T) { expectedParams: map[string]string{ "post_logout_redirect": "https://client/cb", "logout_hint": "user@example.com", + "logout_token": "", // presence checked separately }, }, { @@ -46,6 +53,7 @@ func TestBuildLoginV2LogoutURL(t *testing.T) { expectedParams: map[string]string{ "post_logout_redirect": "https://client/cb", "ui_locales": "en it", + "logout_token": "", // presence checked separately }, }, { @@ -58,6 +66,7 @@ func TestBuildLoginV2LogoutURL(t *testing.T) { "post_logout_redirect": "https://client/cb", "logout_hint": "logoutme", "ui_locales": "de-CH fr", + "logout_token": "", // presence checked separately }, }, { @@ -66,6 +75,7 @@ func TestBuildLoginV2LogoutURL(t *testing.T) { redirectURI: "https://client/cb", expectedParams: map[string]string{ "post_logout_redirect": "https://client/cb", + "logout_token": "", // presence checked separately }, }, } @@ -79,9 +89,10 @@ func TestBuildLoginV2LogoutURL(t *testing.T) { require.NoError(t, err) // When - got := buildLoginV2LogoutURL(logoutURI, tc.redirectURI, tc.logoutHint, tc.uiLocales) + got, err := buildLoginV2LogoutURL(logoutURI, tc.redirectURI, tc.logoutHint, tc.uiLocales, signer) // Then + require.NoError(t, err) gotURL, err := url.Parse(got) require.NoError(t, err) require.NotContains(t, gotURL.String(), "/logout/") @@ -91,8 +102,39 @@ func TestBuildLoginV2LogoutURL(t *testing.T) { require.Len(t, q, len(tc.expectedParams)) for k, v := range tc.expectedParams { + if k == LoginLogoutTokenParam { + assertLogoutToken(t, q.Get(k), &logoutTokenPayload{ + PostLogoutRedirectURI: tc.redirectURI, + LogoutHint: tc.logoutHint, + UILocales: tc.uiLocales, + }) + continue + } assert.Equal(t, v, q.Get(k)) } }) } } + +func assertLogoutToken(t *testing.T, token string, payload *logoutTokenPayload) { + signature, err := jose.ParseSigned(token, []jose.SignatureAlgorithm{jose.RS256}) + require.NoError(t, err) + logoutToken := new(logoutTokenPayload) + err = json.Unmarshal(signature.UnsafePayloadWithoutVerification(), logoutToken) + require.NoError(t, err) + assert.Equal(t, payload, logoutToken) +} + +var ( + privKey, _ = rsa.GenerateKey(rand.Reader, 2048) + signer = func() jose.Signer { + signer, _ := jose.NewSigner( + jose.SigningKey{ + Algorithm: jose.RS256, + Key: privKey, + }, + (&jose.SignerOptions{}).WithType("JWT"), + ) + return signer + }() +)
internal/api/oidc/integration_test/auth_request_test.go+16 −4 modified@@ -551,23 +551,24 @@ func TestOPStorage_TerminateSession_refresh_grant(t *testing.T) { require.NoError(t, err) } -func buildLogoutURL(origin, logoutURLV2 string, redirectURI string, extraParams map[string]string) string { +func buildLogoutURL(origin, logoutURLV2, redirectURI string, extraParams map[string]string) *url.URL { u, _ := url.Parse(origin + logoutURLV2 + redirectURI) q := u.Query() for k, v := range extraParams { q.Set(k, v) } + q.Set("logout_token", "signed-logout-token") // placeholder u.RawQuery = q.Encode() // Append the redirect URI as a URL-escaped string - return u.String() + return u } func TestOPStorage_TerminateSession_empty_id_token_hint(t *testing.T) { tests := []struct { name string clientID string authRequestID func(t testing.TB, instance *integration.Instance, clientID, redirectURI string, scope ...string) string - logoutURL string + logoutURL *url.URL }{ { name: "login header", @@ -614,7 +615,18 @@ func TestOPStorage_TerminateSession_empty_id_token_hint(t *testing.T) { postLogoutRedirect, err := rp.EndSession(CTX, provider, "", logoutRedirectURI, "state", "hint", oidc.ParseLocales([]string{"it-IT", "en-US"})) require.NoError(t, err) - assert.Equal(t, tt.logoutURL, postLogoutRedirect.String()) + + requiredQueries := tt.logoutURL.Query() + for key, value := range requiredQueries { + if key == "logout_token" { + assert.NotEmpty(t, value[0], "logout_token must be present") + continue + } + assert.Equal(t, value[0], postLogoutRedirect.Query().Get(key)) + } + requiredURLWithoutQueries := *tt.logoutURL + requiredURLWithoutQueries.RawQuery = "" + assert.Equal(t, requiredURLWithoutQueries.String(), postLogoutRedirect.Scheme+"://"+postLogoutRedirect.Host+postLogoutRedirect.Path) // userinfo must not fail until login UI terminated session _, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
packages/zitadel-client/src/node.ts+52 −4 modified@@ -1,16 +1,31 @@ -import { createGrpcTransport, GrpcTransportOptions } from "@connectrpc/connect-node"; -import { importPKCS8, SignJWT } from "jose"; +import { + createGrpcTransport, + GrpcTransportOptions, +} from "@connectrpc/connect-node"; +import { + createRemoteJWKSet, + importPKCS8, + jwtVerify, + JWTPayload, + SignJWT, +} from "jose"; import { NewAuthorizationBearerInterceptor } from "./interceptors.js"; /** * Create a server transport using grpc with the given token and configuration options. * @param token * @param opts */ -export function createServerTransport(token: string, opts: GrpcTransportOptions) { +export function createServerTransport( + token: string, + opts: GrpcTransportOptions +) { return createGrpcTransport({ ...opts, - interceptors: [...(opts.interceptors || []), NewAuthorizationBearerInterceptor(token)], + interceptors: [ + ...(opts.interceptors || []), + NewAuthorizationBearerInterceptor(token), + ], }); } @@ -34,3 +49,36 @@ export async function newSystemToken({ .setAudience(audience) .sign(await importPKCS8(key, "RS256")); } + +/** + * Verify a signed JWT with the given keys endpoint. + * @param token + * @param keysEndpoint + * @param options + */ +export async function verifyJwt<T = JWTPayload>( + token: string, + keysEndpoint: string, + options?: { + issuer?: string; + audience?: string; + instanceHost?: string; + publicHost?: string; + }, +): Promise<T & JWTPayload> { + const headers: Record<string, string> = {}; + if (options?.instanceHost) { + headers["x-zitadel-instance-host"] = options.instanceHost; + } + if (options?.publicHost) { + headers["x-zitadel-public-host"] = options.publicHost; + } + const JWKS = createRemoteJWKSet(new URL(keysEndpoint), {headers: headers}); + + const { payload } = await jwtVerify(token, JWKS, { + issuer: options?.issuer, + audience: options?.audience, + }); + + return payload as T & JWTPayload; +}
pnpm-lock.yaml+56 −56 modified@@ -54,14 +54,14 @@ importers: specifier: ^2.29.4 version: 2.30.1 next: - specifier: 15.4.0-canary.86 - version: 15.4.0-canary.86(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.92.1) + specifier: 15.5.7 + version: 15.5.7(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.92.1) next-intl: specifier: ^3.25.1 - version: 3.26.5(next@15.4.0-canary.86(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.92.1))(react@19.1.0) + version: 3.26.5(next@15.5.7(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.92.1))(react@19.1.0) next-themes: specifier: ^0.2.1 - version: 0.2.1(next@15.4.0-canary.86(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.92.1))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 0.2.1(next@15.5.7(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.92.1))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) nice-grpc: specifier: 2.0.1 version: 2.0.1 @@ -166,8 +166,8 @@ importers: specifier: ^8.57.0 version: 8.57.1 eslint-config-next: - specifier: 15.4.0-canary.86 - version: 15.4.0-canary.86(eslint@8.57.1)(typescript@5.9.2) + specifier: 15.5.7 + version: 15.5.7(eslint@8.57.1)(typescript@5.9.2) eslint-config-prettier: specifier: ^9.1.0 version: 9.1.2(eslint@8.57.1) @@ -3415,56 +3415,56 @@ packages: resolution: {integrity: sha512-kPTF5yemdmadP/+qMDcc3p10NkZKXHXGm2BCFvB192paCNxQrSJz+qb56SO+kvSn9exg+HvhGJ0gfIcVwPjzWw==} engines: {node: ^14.14.0 || >=16.0.0} - '@next/env@15.4.0-canary.86': - resolution: {integrity: sha512-WPrEvwqHnjeLx05ncJvqizbBJJFlQGRbxzOnL/pZWKzo19auM9x5Se87P27+E/D/d6jJS801l+thF85lfobAZQ==} + '@next/env@15.5.7': + resolution: {integrity: sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==} - '@next/eslint-plugin-next@15.4.0-canary.86': - resolution: {integrity: sha512-cOlp6ajA1ptiBxiProcXaNAR88O5ck3IwGJr+A5SnNKU4iTUg4nP0K5lS4Mkage+LAMIQ8dImkLR53PpebXICA==} + '@next/eslint-plugin-next@15.5.7': + resolution: {integrity: sha512-DtRU2N7BkGr8r+pExfuWHwMEPX5SD57FeA6pxdgCHODo+b/UgIgjE+rgWKtJAbEbGhVZ2jtHn4g3wNhWFoNBQQ==} - '@next/swc-darwin-arm64@15.4.0-canary.86': - resolution: {integrity: sha512-1ofBmzjPkmoMdM+dXvybZ/Roq8HRo0sFzcwXk7/FJNOufuwyK+QKdSpLE7pHlPR7ZREqfEMj61ONO+gAK+zOJw==} + '@next/swc-darwin-arm64@15.5.7': + resolution: {integrity: sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.4.0-canary.86': - resolution: {integrity: sha512-WCKSrllvwzYi4TgrSdgxKSOF2nhieeaWWOeGucn0OXy50uOAamr0HwP5OaIBCx3oRar4w66gvs4IrdTdMedeJA==} + '@next/swc-darwin-x64@15.5.7': + resolution: {integrity: sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.4.0-canary.86': - resolution: {integrity: sha512-8qn7DJVNFjhEIDo2ts0YCsO7g+vJjPWh8Ur8lBK3XspeX0BPsF4s+YmgidrpzRXeIfoo2uYLkkXcy/57CVDblw==} + '@next/swc-linux-arm64-gnu@15.5.7': + resolution: {integrity: sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@15.4.0-canary.86': - resolution: {integrity: sha512-8MTn6N4Ja25neMLu2Bra1lqW9AWPqsYg0BVs5M/cxL0QkcN3mak/8LLX1vbzz7GigMGSA+NLwg+ol8lglfgIGA==} + '@next/swc-linux-arm64-musl@15.5.7': + resolution: {integrity: sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@15.4.0-canary.86': - resolution: {integrity: sha512-hIhzDwWDQHnH0M0Pzaqs1c5fa4+LHiLLEBuPJQvhBxQfH+Eh86DWiWHDCaoNiURvdRPg6uCuF2MjwptrMplEkg==} + '@next/swc-linux-x64-gnu@15.5.7': + resolution: {integrity: sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@15.4.0-canary.86': - resolution: {integrity: sha512-FG6SBuSeRWYMNu6tsfaZ4iDzv3BLxlpRncO2xvKKQPeUdDSQ0cehuHYnx8fRte8IOAJ3rlbRd6NXvrDarqu92Q==} + '@next/swc-linux-x64-musl@15.5.7': + resolution: {integrity: sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@15.4.0-canary.86': - resolution: {integrity: sha512-3HvZo4VuyINrNYplRhvC8ILdKwi/vFDHOcTN/I4ru039TFpu2eO6VtXsLBdOdJjGslSSSBYkX+6yRrghihAZDA==} + '@next/swc-win32-arm64-msvc@15.5.7': + resolution: {integrity: sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.4.0-canary.86': - resolution: {integrity: sha512-UO9JzGGj7GhtSJFdI0Bl0dkIIBfgbhXLsgNVmq9Z/CsUsQB6J9RS/BMhsxfVwhO+RETk13nFpNutMAhAwcuD8w==} + '@next/swc-win32-x64-msvc@15.5.7': + resolution: {integrity: sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -7869,8 +7869,8 @@ packages: engines: {node: '>=6.0'} hasBin: true - eslint-config-next@15.4.0-canary.86: - resolution: {integrity: sha512-nMQzamY2GWhvScnfkfOVeq38tCt/TfyJyHMIzVYarpfyRj286Jk8ZkpgzQT8JtyeQ39kxTDZNBrB4CrWODYg4g==} + eslint-config-next@15.5.7: + resolution: {integrity: sha512-nU/TRGHHeG81NeLW5DeQT5t6BDUqbpsNQTvef1ld/tqHT+/zTx60/TIhKnmPISTTe++DVo+DLxDmk4rnwHaZVw==} peerDependencies: eslint: ^7.23.0 || ^8.0.0 || ^9.0.0 typescript: '>=3.3.1' @@ -10615,8 +10615,8 @@ packages: react: '*' react-dom: '*' - next@15.4.0-canary.86: - resolution: {integrity: sha512-lGeO0sOvPZ7oFIklqRA863YzRL1bW+kT/OqU3N6RBquHldiucZwnZKQceZdn6WcHEFmWIHzZV+SMG1JEK7hZLg==} + next@15.5.7: + resolution: {integrity: sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: @@ -19427,34 +19427,34 @@ snapshots: read-pkg-up: 9.1.0 semver: 7.7.2 - '@next/env@15.4.0-canary.86': {} + '@next/env@15.5.7': {} - '@next/eslint-plugin-next@15.4.0-canary.86': + '@next/eslint-plugin-next@15.5.7': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@15.4.0-canary.86': + '@next/swc-darwin-arm64@15.5.7': optional: true - '@next/swc-darwin-x64@15.4.0-canary.86': + '@next/swc-darwin-x64@15.5.7': optional: true - '@next/swc-linux-arm64-gnu@15.4.0-canary.86': + '@next/swc-linux-arm64-gnu@15.5.7': optional: true - '@next/swc-linux-arm64-musl@15.4.0-canary.86': + '@next/swc-linux-arm64-musl@15.5.7': optional: true - '@next/swc-linux-x64-gnu@15.4.0-canary.86': + '@next/swc-linux-x64-gnu@15.5.7': optional: true - '@next/swc-linux-x64-musl@15.4.0-canary.86': + '@next/swc-linux-x64-musl@15.5.7': optional: true - '@next/swc-win32-arm64-msvc@15.4.0-canary.86': + '@next/swc-win32-arm64-msvc@15.5.7': optional: true - '@next/swc-win32-x64-msvc@15.4.0-canary.86': + '@next/swc-win32-x64-msvc@15.5.7': optional: true '@ngtools/webpack@16.2.16(@angular/compiler-cli@16.2.12(@angular/compiler@16.2.12(@angular/core@16.2.12(rxjs@7.8.2)(zone.js@0.13.3)))(typescript@5.1.6))(typescript@5.1.6)(webpack@5.94.0(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.18.17))': @@ -24513,9 +24513,9 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-next@15.4.0-canary.86(eslint@8.57.1)(typescript@5.9.2): + eslint-config-next@15.5.7(eslint@8.57.1)(typescript@5.9.2): dependencies: - '@next/eslint-plugin-next': 15.4.0-canary.86 + '@next/eslint-plugin-next': 15.5.7 '@rushstack/eslint-patch': 1.12.0 '@typescript-eslint/eslint-plugin': 8.43.0(@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1)(typescript@5.9.2) '@typescript-eslint/parser': 8.43.0(eslint@8.57.1)(typescript@5.9.2) @@ -28371,38 +28371,38 @@ snapshots: netmask@2.0.2: optional: true - next-intl@3.26.5(next@15.4.0-canary.86(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.92.1))(react@19.1.0): + next-intl@3.26.5(next@15.5.7(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.92.1))(react@19.1.0): dependencies: '@formatjs/intl-localematcher': 0.5.10 negotiator: 1.0.0 - next: 15.4.0-canary.86(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.92.1) + next: 15.5.7(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.92.1) react: 19.1.0 use-intl: 3.26.5(react@19.1.0) - next-themes@0.2.1(next@15.4.0-canary.86(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.92.1))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + next-themes@0.2.1(next@15.5.7(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.92.1))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: - next: 15.4.0-canary.86(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.92.1) + next: 15.5.7(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.92.1) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - next@15.4.0-canary.86(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.92.1): + next@15.5.7(@babel/core@7.28.4)(@playwright/test@1.55.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.92.1): dependencies: - '@next/env': 15.4.0-canary.86 + '@next/env': 15.5.7 '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001741 postcss: 8.4.31 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) styled-jsx: 5.1.6(@babel/core@7.28.4)(react@19.1.0) optionalDependencies: - '@next/swc-darwin-arm64': 15.4.0-canary.86 - '@next/swc-darwin-x64': 15.4.0-canary.86 - '@next/swc-linux-arm64-gnu': 15.4.0-canary.86 - '@next/swc-linux-arm64-musl': 15.4.0-canary.86 - '@next/swc-linux-x64-gnu': 15.4.0-canary.86 - '@next/swc-linux-x64-musl': 15.4.0-canary.86 - '@next/swc-win32-arm64-msvc': 15.4.0-canary.86 - '@next/swc-win32-x64-msvc': 15.4.0-canary.86 + '@next/swc-darwin-arm64': 15.5.7 + '@next/swc-darwin-x64': 15.5.7 + '@next/swc-linux-arm64-gnu': 15.5.7 + '@next/swc-linux-arm64-musl': 15.5.7 + '@next/swc-linux-x64-gnu': 15.5.7 + '@next/swc-linux-x64-musl': 15.5.7 + '@next/swc-win32-arm64-msvc': 15.5.7 + '@next/swc-win32-x64-msvc': 15.5.7 '@playwright/test': 1.55.0 sass: 1.92.1 sharp: 0.34.3
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-pfrf-9r5f-73f5ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-29067ghsaADVISORY
- github.com/zitadel/zitadel/commit/4c879b47334e01d4fcab921ac1b44eda39acdb96ghsaWEB
- github.com/zitadel/zitadel/security/advisories/GHSA-pfrf-9r5f-73f5ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.