mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-19 06:02:52 +00:00
<!-- Please inform yourself about the contribution guidelines on submitting a PR here: https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md#submit-a-pull-request-pr. Take note of how PR/commit titles should be written and replace the template texts in the sections below. Don't remove any of the sections. It is important that the commit history clearly shows what is changed and why. Important: By submitting a contribution you agree to the terms from our Licensing Policy as described here: https://github.com/zitadel/zitadel/blob/main/LICENSING.md#community-contributions. --> # Which Problems Are Solved When deploying the login application behind proxies or using Vercel rewrites (e.g., `zitadel.com/login` → `login-zitadel-qa.vercel.app`), the application was using the internal rewritten host instead of the original user-facing host. This caused several issues: 1. **Broken Password Reset Emails**: Email links contained internal hosts like `login-zitadel-qa.vercel.app` instead of `zitadel.com` 2. **Inconsistent User Experience**: Users would see different domains in various parts of the flow 3. **Security Concerns**: Internal infrastructure details were exposed to end users 4. **Scattered Logic**: Host detection logic was duplicated across multiple files with inconsistent error handling # How the Problems Are Solved Created comprehensive host detection utilities in `/lib/server/host.ts` and `/lib/client/host.ts`: **Server-side utilities:** - `getOriginalHost()` - Returns the original user-facing host - `getOriginalHostWithProtocol()` - Returns host with proper protocol (http/https)
218 lines
5.7 KiB
TypeScript
218 lines
5.7 KiB
TypeScript
"use server";
|
|
|
|
import { setSessionAndUpdateCookie } from "@/lib/server/cookie";
|
|
import {
|
|
deleteSession,
|
|
getLoginSettings,
|
|
getSecuritySettings,
|
|
humanMFAInitSkipped,
|
|
listAuthenticationMethodTypes,
|
|
} from "@/lib/zitadel";
|
|
import { Duration } from "@zitadel/client";
|
|
import { RequestChallenges } from "@zitadel/proto/zitadel/session/v2/challenge_pb";
|
|
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 { completeFlowOrGetUrl } from "../client";
|
|
import {
|
|
getMostRecentSessionCookie,
|
|
getSessionCookieById,
|
|
getSessionCookieByLoginName,
|
|
removeSessionFromCookie,
|
|
} from "../cookies";
|
|
import { getServiceUrlFromHeaders } from "../service-url";
|
|
import { getOriginalHost } from "./host";
|
|
|
|
export async function skipMFAAndContinueWithNextUrl({
|
|
userId,
|
|
requestId,
|
|
loginName,
|
|
sessionId,
|
|
organization,
|
|
}: {
|
|
userId: string;
|
|
loginName?: string;
|
|
sessionId?: string;
|
|
requestId?: string;
|
|
organization?: string;
|
|
}): Promise<{ redirect: string } | { error: string }> {
|
|
const _headers = await headers();
|
|
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
|
|
|
|
const loginSettings = await getLoginSettings({
|
|
serviceUrl,
|
|
organization: organization,
|
|
});
|
|
|
|
await humanMFAInitSkipped({ serviceUrl, userId });
|
|
|
|
if (requestId && sessionId) {
|
|
return completeFlowOrGetUrl(
|
|
{
|
|
sessionId: sessionId,
|
|
requestId: requestId,
|
|
organization: organization,
|
|
},
|
|
loginSettings?.defaultRedirectUri,
|
|
);
|
|
} else if (loginName) {
|
|
return completeFlowOrGetUrl(
|
|
{
|
|
loginName: loginName,
|
|
organization: organization,
|
|
},
|
|
loginSettings?.defaultRedirectUri,
|
|
);
|
|
}
|
|
|
|
return { error: "Could not skip MFA and continue" };
|
|
}
|
|
|
|
export type ContinueWithSessionCommand = Session & { requestId?: string };
|
|
|
|
export async function continueWithSession({ requestId, ...session }: ContinueWithSessionCommand) {
|
|
const _headers = await headers();
|
|
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
|
|
|
|
const loginSettings = await getLoginSettings({
|
|
serviceUrl,
|
|
organization: session.factors?.user?.organizationId,
|
|
});
|
|
|
|
if (requestId && session.id && session.factors?.user) {
|
|
return completeFlowOrGetUrl(
|
|
{
|
|
sessionId: session.id,
|
|
requestId: requestId,
|
|
organization: session.factors.user.organizationId,
|
|
},
|
|
loginSettings?.defaultRedirectUri,
|
|
);
|
|
} else if (session.factors?.user) {
|
|
return completeFlowOrGetUrl(
|
|
{
|
|
loginName: session.factors.user.loginName,
|
|
organization: session.factors.user.organizationId,
|
|
},
|
|
loginSettings?.defaultRedirectUri,
|
|
);
|
|
}
|
|
}
|
|
|
|
export type UpdateSessionCommand = {
|
|
loginName?: string;
|
|
sessionId?: string;
|
|
organization?: string;
|
|
checks?: Checks;
|
|
requestId?: string;
|
|
challenges?: RequestChallenges;
|
|
lifetime?: Duration;
|
|
};
|
|
|
|
export async function updateSession(options: UpdateSessionCommand) {
|
|
let { loginName, sessionId, organization, checks, requestId, challenges } = options;
|
|
const recentSession = sessionId
|
|
? await getSessionCookieById({ sessionId })
|
|
: loginName
|
|
? await getSessionCookieByLoginName({ loginName, organization })
|
|
: await getMostRecentSessionCookie();
|
|
|
|
if (!recentSession) {
|
|
return {
|
|
error: "Could not find session",
|
|
};
|
|
}
|
|
|
|
const _headers = await headers();
|
|
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
|
|
const host = await getOriginalHost();
|
|
|
|
if (!host) {
|
|
return { error: "Could not get host" };
|
|
}
|
|
|
|
if (host && challenges && challenges.webAuthN && !challenges.webAuthN.domain) {
|
|
const [hostname] = host.split(":");
|
|
|
|
challenges.webAuthN.domain = hostname;
|
|
}
|
|
|
|
const loginSettings = await getLoginSettings({
|
|
serviceUrl,
|
|
organization,
|
|
});
|
|
|
|
let lifetime = checks?.webAuthN
|
|
? loginSettings?.multiFactorCheckLifetime // TODO different lifetime for webauthn u2f/passkey
|
|
: checks?.otpEmail || checks?.otpSms
|
|
? loginSettings?.secondFactorCheckLifetime
|
|
: undefined;
|
|
|
|
if (!lifetime) {
|
|
console.warn("No lifetime provided for session, defaulting to 24 hours");
|
|
lifetime = {
|
|
seconds: BigInt(60 * 60 * 24), // default to 24 hours
|
|
nanos: 0,
|
|
} as Duration;
|
|
}
|
|
|
|
const session = await setSessionAndUpdateCookie({
|
|
recentCookie: recentSession,
|
|
checks,
|
|
challenges,
|
|
requestId,
|
|
lifetime,
|
|
});
|
|
|
|
if (!session) {
|
|
return { error: "Could not update session" };
|
|
}
|
|
|
|
// 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,
|
|
});
|
|
if (response.authMethodTypes && response.authMethodTypes.length) {
|
|
authMethods = response.authMethodTypes;
|
|
}
|
|
}
|
|
|
|
return {
|
|
sessionId: session.id,
|
|
factors: session.factors,
|
|
challenges: session.challenges,
|
|
authMethods,
|
|
};
|
|
}
|
|
|
|
type ClearSessionOptions = {
|
|
sessionId: string;
|
|
};
|
|
|
|
export async function clearSession(options: ClearSessionOptions) {
|
|
const _headers = await headers();
|
|
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
|
|
|
|
const { sessionId } = options;
|
|
|
|
const sessionCookie = await getSessionCookieById({ sessionId });
|
|
|
|
const deleteResponse = await deleteSession({
|
|
serviceUrl,
|
|
sessionId: sessionCookie.id,
|
|
sessionToken: sessionCookie.token,
|
|
});
|
|
|
|
const securitySettings = await getSecuritySettings({ serviceUrl });
|
|
const iFrameEnabled = !!securitySettings?.embeddedIframe?.enabled;
|
|
|
|
if (!deleteResponse) {
|
|
throw new Error("Could not delete session");
|
|
}
|
|
|
|
return removeSessionFromCookie({ session: sessionCookie, iFrameEnabled });
|
|
}
|