Merge commit from fork

* fix: Improve host header handling and deployment mode detection

* ZITADEL_CLOUD env

* refactor service config

* service config

* cleanup

* cleanup

* util for original and public host

* instanceHost, publicHost

* cleanup constructUrl

* cleanup middleware

* add x-zitadel-forward-host as fallback for current cloud setup

* fix build

* fix: lint

* fix: update test mocks

* fix: provide basepath to unit tests

* host handling

* unit tests

* imports

* fix: getPublicHost instead of getInstanceHost

* unit tests

* update nextjs, implement jwt verify for logout_token

* pass logout information in jwt to login v2

* handle hosts in jwks url

* point idp callbacks to the api and not the login UI

* logout_hint out of logout token, typecheck

* rm unused imports

* fix oidc integration test

---------

Co-authored-by: Livio Spring <[email protected]>
This commit is contained in:
Max Peintner
2025-12-08 10:01:49 +01:00
committed by GitHub
co-authored by Livio Spring
parent f9e3871bb3
commit df75be96ff
75 changed files with 2075 additions and 1817 deletions
+1
View File
@@ -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.
+1 -4
View File
@@ -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: {
+2 -2
View File
@@ -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",
+8 -14
View File
@@ -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();
@@ -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;
@@ -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();
+4 -8
View File
@@ -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 (
@@ -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 (
@@ -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) {
@@ -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;
@@ -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 (
@@ -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 (
+4 -8
View File
@@ -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
+4 -8
View File
@@ -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 (
+7 -17
View File
@@ -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 (
@@ -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";
@@ -8,13 +8,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 { organization } = searchParams;
const branding = await getBrandingSettings({
serviceUrl,
organization,
const branding = await getBrandingSettings({ serviceConfig, organization,
});
return (
+35 -21
View File
@@ -2,8 +2,9 @@ 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";
@@ -11,17 +12,14 @@ 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();
+9 -19
View File
@@ -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 (
+8 -18
View File
@@ -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);
@@ -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,
});
@@ -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");
+6 -12
View File
@@ -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 (
@@ -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;
@@ -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 (
+6 -14
View File
@@ -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 (
@@ -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;
+8 -20
View File
@@ -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
@@ -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,12 +18,10 @@ 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;
}
@@ -31,23 +29,15 @@ export default async function Page(props: { searchParams: Promise<Record<string
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 ? (
+9 -17
View File
@@ -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,
});
}
+5 -11
View File
@@ -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) {
+4 -8
View File
@@ -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 (
+7 -13
View File
@@ -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") {
@@ -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;
+7 -16
View File
@@ -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);
+3 -3
View File
@@ -1,5 +1,5 @@
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";
@@ -7,10 +7,10 @@ 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({})
+3 -5
View File
@@ -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,
});
+77
View File
@@ -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);
});
});
});
+24
View File
@@ -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;
}
+6 -20
View File
@@ -1,25 +1,19 @@
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);
@@ -27,10 +21,7 @@ export async function loginWithOIDCAndSession({
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 };
+6 -20
View File
@@ -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,13 +82,7 @@ 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);
@@ -96,10 +90,7 @@ export async function loginWithSAMLAndSession({
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 };
+7 -12
View File
@@ -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";
import { getServiceConfig } from "./service-url";
import { createServerTransport, getSession, ServiceConfig } from "./zitadel";
const myUserService = async (serviceUrl: string, sessionToken: string) => {
const transportPromise = await createServerTransport(
sessionToken,
serviceUrl,
);
const myUserService = async (serviceConfig: ServiceConfig, sessionToken: string) => {
const transportPromise = await createServerTransport(sessionToken, serviceConfig);
return createUserServiceClient(transportPromise);
};
@@ -22,13 +19,11 @@ 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,
});
@@ -36,7 +31,7 @@ export async function setMyPassword({
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" };
+8 -14
View File
@@ -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,
+13 -25
View File
@@ -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({
+3 -5
View File
@@ -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,
});
}
+16 -39
View File
@@ -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",
+162 -161
View File
@@ -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,151 +10,94 @@ 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;
}),
};
} as any;
vi.mocked(headers).mockResolvedValue(mockHeaders 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;
}),
};
} as any;
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");
});
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"),
};
} as any;
vi.mocked(headers).mockResolvedValue(mockHeaders 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"),
};
} as any;
vi.mocked(headers).mockResolvedValue(mockHeaders 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
@@ -167,16 +105,13 @@ describe("Host utility functions", () => {
if (key === "host") return "login-zitadel-qa.vercel.app";
return null;
}),
};
} as any;
vi.mocked(headers).mockResolvedValue(mockHeaders 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";
@@ -184,114 +119,180 @@ describe("Host utility functions", () => {
if (key === "host") return "cloudflare-worker.workers.dev";
return null;
}),
};
} as any;
vi.mocked(headers).mockResolvedValue(mockHeaders 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;
}),
};
} as any;
vi.mocked(headers).mockResolvedValue(mockHeaders 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"),
};
} as any;
vi.mocked(headers).mockResolvedValue(mockHeaders 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"),
};
} as any;
vi.mocked(headers).mockResolvedValue(mockHeaders 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"),
};
} as any;
vi.mocked(headers).mockResolvedValue(mockHeaders as any);
const result = await getOriginalHostWithProtocol();
const result = getPublicHostWithProtocol(mockHeaders);
expect(result).toBe("http://localhost:8080");
});
});
test("should handle priority order correctly", async () => {
const { headers } = await import("next/headers");
describe("getPublicHost", () => {
test("should use x-zitadel-public-host when 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 "public.zitadel.cloud";
if (key === "x-forwarded-host") return "accounts.company.com";
return null;
}),
};
} as any;
vi.mocked(headers).mockResolvedValue(mockHeaders as any);
const result = getPublicHost(mockHeaders);
expect(result).toBe("public.zitadel.cloud");
expect(mockHeaders.get).toHaveBeenCalledWith("x-zitadel-public-host");
});
const result = await getOriginalHost();
expect(result).toBe("priority1.com");
// Should only call x-forwarded-host since it's available
test("should use x-zitadel-forward-host when x-zitadel-public-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 "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");
expect(mockHeaders.get).toHaveBeenCalledTimes(1);
});
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 = getPublicHost(mockHeaders);
expect(result).toBe("localhost:3000");
expect(mockHeaders.get).toHaveBeenCalledWith("x-forwarded-host");
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);
});
});
});
+30 -30
View File
@@ -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();
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");
// 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");
}
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}`;
}
+30 -30
View File
@@ -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: "[email protected]",
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",
});
+26 -55
View File
@@ -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,11 +32,11 @@ 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;
@@ -43,16 +44,12 @@ async function resolveOrganizationForUser({
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,19 +69,15 @@ 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) {
@@ -92,9 +85,7 @@ export async function validateIDPLinkingPermissions({
}
// 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) {
+15 -11
View File
@@ -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,7 +28,8 @@ 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 () => {
@@ -35,22 +37,24 @@ describe("redirectToIdp", () => {
// 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"),
+15 -23
View File
@@ -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,
+18 -14
View File
@@ -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",
});
});
+18 -42
View File
@@ -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);
+3 -5
View File
@@ -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,
});
}
+3 -3
View File
@@ -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);
+16 -47
View File
@@ -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,10 +209,7 @@ 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");
@@ -242,7 +217,7 @@ export async function verifyPasskeyRegistration(command: VerifyPasskeyCommand) {
}
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") };
+32 -74
View File
@@ -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,7 +411,7 @@ 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") };
@@ -461,16 +419,16 @@ export async function checkSessionAndSetPassword({ sessionId, password }: CheckS
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(
+12 -26
View File
@@ -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
+14 -26
View File
@@ -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) {
+9 -17
View File
@@ -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 });
}
+24 -55
View File
@@ -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) => {
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 });
}
+217
View File
@@ -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");
});
});
});
+28 -39
View File
@@ -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}`);
}
+15 -19
View File
@@ -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);
}
+36 -36
View File
@@ -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);
});
+11 -27
View File
@@ -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;
}
}
+3 -6
View File
@@ -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;
+265 -198
View File
@@ -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,32 +135,32 @@ 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);
@@ -159,13 +168,12 @@ export async function getGeneralSettings({ serviceUrl }: { serviceUrl: string })
}
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,15 +198,14 @@ 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();
@@ -207,21 +213,20 @@ export async function createSessionFromChecks({
}
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,16 +851,26 @@ 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,
@@ -864,15 +878,14 @@ export async function getDeviceAuthorizationRequest({ serviceUrl, 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,23 +1231,52 @@ 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
baseUrl: serviceConfig.baseUrl,
interceptors:
!process.env.CUSTOM_REQUEST_HEADERS && !serviceConfig.instanceHost && !serviceConfig.publicHost
? undefined
: [
(next) => {
return (req) => {
process.env.CUSTOM_REQUEST_HEADERS!.split(",").forEach((header) => {
// Apply headers from serviceConfig
if (serviceConfig.instanceHost) {
req.header.set("x-zitadel-instance-host", serviceConfig.instanceHost);
}
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());
@@ -1219,6 +1284,8 @@ export function createServerTransport(token: string, baseUrl: string) {
console.warn(`Skipping malformed header: ${header}`);
}
});
}
return next(req);
};
},
+18 -42
View File
@@ -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 proxyPaths = ["/.well-known/", "/oauth/", "/oidc/", "/idps/callback/", "/saml/"];
const isMatched = proxyPaths.some((prefix) =>
request.nextUrl.pathname.startsWith(prefix),
);
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
// 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: {
+12 -2
View File
@@ -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"
]
}
+25 -8
View File
@@ -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, uiLocales.String())
}
q.Set(LoginUILocalesParam, strings.Join(locales, " "))
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.
+43 -1
View File
@@ -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": "[email protected]",
"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
}()
)
@@ -551,15 +551,16 @@ 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) {
@@ -567,7 +568,7 @@ func TestOPStorage_TerminateSession_empty_id_token_hint(t *testing.T) {
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)
+52 -4
View File
@@ -1,5 +1,14 @@
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";
/**
@@ -7,10 +16,16 @@ import { NewAuthorizationBearerInterceptor } from "./interceptors.js";
* @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;
}
+503 -237
View File
File diff suppressed because it is too large Load Diff