Files
zitadel/apps/login/src/lib/server/session.ts
T
Max PeintnerandGitHub 56f4798ed3 Merge commit from fork
* add mfa check to session validity check

* change session check to check for already set up mfa methods

* lint

* improve naming

* test changes

* cleanup continueWithSession fcn
2026-07-17 08:23:56 +02:00

318 lines
9.0 KiB
TypeScript

"use server";
import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification";
import { createLogger } from "@/lib/logger";
import { createSessionAndUpdateCookie, setSessionAndUpdateCookie } from "@/lib/server/cookie";
import {
deleteSession,
getLoginSettings,
getSecuritySettings,
humanMFAInitSkipped,
listAuthenticationMethodTypes,
listUsers,
} from "@/lib/zitadel";
import { create, 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, ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { getTranslations } from "next-intl/server";
import { headers } from "next/headers";
import { completeFlowOrGetUrl } from "../client";
import {
getMostRecentSessionCookie,
getSessionCookieById,
getSessionCookieByLoginName,
removeSessionFromCookie,
} from "../cookies";
import { getServiceConfig } from "../service-url";
import { isSessionValid } from "../session";
import { getPublicHost } from "./host";
import { sendLoginname } from "./loginname";
const logger = createLogger("session");
export async function skipMFAAndContinueWithNextUrl({
userId,
requestId,
loginName,
sessionId,
organization,
}: {
userId: string;
loginName?: string;
sessionId?: string;
requestId?: string;
organization?: string;
}): Promise<{ redirect: string } | { error: string } | { samlData: { url: string; fields: Record<string, string> } }> {
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
const loginSettings = await getLoginSettings({ serviceConfig, organization: organization });
await humanMFAInitSkipped({ serviceConfig, 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 { serviceConfig } = getServiceConfig(_headers);
const t = await getTranslations("error");
if (!session.factors?.user) {
return { error: t("couldNotContinueSession") };
}
const loginSettings = await getLoginSettings({ serviceConfig, organization: session.factors.user.organizationId });
// Validate session (including MFA) before completing the flow
const valid = await isSessionValid({ serviceConfig, session: session as Session });
if (!valid) {
logger.warn("continueWithSession: session is not valid (e.g. MFA not completed), redirecting to re-authenticate", {
sessionId: session.id,
});
// Redirect user to re-authenticate (will route to MFA page if password is still valid)
const res = await sendLoginname({
loginName: session.factors.user.loginName,
organization: session.factors.user.organizationId,
requestId: requestId,
});
if (res && "redirect" in res && res.redirect) {
return { redirect: res.redirect };
}
if (res && "samlData" in res && res.samlData) {
return { samlData: res.samlData };
}
return { error: t("couldNotContinueSession") };
}
if (requestId && session.id) {
return completeFlowOrGetUrl(
{
sessionId: session.id,
requestId: requestId,
organization: session.factors.user.organizationId,
},
loginSettings?.defaultRedirectUri,
);
}
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 updateOrCreateSession(options: UpdateSessionCommand) {
let { loginName, sessionId, organization, checks, requestId, challenges, lifetime } = options;
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
const host = getPublicHost(_headers);
const t = await getTranslations("verify.errors");
if (!host) {
return { error: "Could not get host" }; // Technical error, maybe leave or translate if key exists
}
if (challenges && challenges.webAuthN && !challenges.webAuthN.domain) {
const [hostname] = host.split(":");
challenges.webAuthN.domain = hostname;
}
let recentSession = sessionId
? await getSessionCookieById({ sessionId })
: loginName
? await getSessionCookieByLoginName({ loginName, organization })
: await getMostRecentSessionCookie();
if (!recentSession) {
if (!loginName) {
return { error: t("couldNotFindSession") };
}
const checks = create(ChecksSchema, {
user: { search: { case: "loginName", value: loginName } },
});
const result = await createSessionAndUpdateCookie({
checks,
challenges,
requestId,
}).catch((error) => {
if (isClassifiedError(error) && error.isUserError) {
logger.warn("Could not create session (client error)", { grpcCode: error.code, httpStatus: error.httpStatus });
} else {
logger.error("Could not create session (server error)", { error });
}
return undefined;
});
if (result && "sessionCookie" in result) {
recentSession = result.sessionCookie;
}
if (!recentSession) {
return {
error: t("couldNotFindSession"),
};
}
}
const loginSettings = await getLoginSettings({ serviceConfig, organization });
if (!lifetime) {
lifetime = checks?.webAuthN
? loginSettings?.multiFactorCheckLifetime // TODO different lifetime for webauthn u2f/passkey
: checks?.otpEmail || checks?.otpSms
? loginSettings?.secondFactorCheckLifetime
: undefined;
}
if (!lifetime || !lifetime.seconds) {
logger.warn("No lifetime provided for session, defaulting to 24 hours");
lifetime = {
seconds: BigInt(60 * 60 * 24), // default to 24 hours
nanos: 0,
} as Duration;
}
let session;
try {
session = await setSessionAndUpdateCookie({
recentCookie: recentSession,
checks,
challenges,
requestId,
lifetime,
});
} catch (error) {
const loginNameForCreation = options.loginName || recentSession?.loginName;
const orgForCreation = options.organization || recentSession?.organization;
if (!loginNameForCreation) {
throw error;
}
const users = await listUsers({
serviceConfig,
loginName: loginNameForCreation,
organizationId: orgForCreation,
});
if (users.details?.totalResult === BigInt(1) && users.result[0].userId) {
const user = users.result[0];
const newChecks = create(ChecksSchema, {
...(checks || {}),
user: { search: { case: "userId", value: user.userId } } as any,
});
const result = await createSessionAndUpdateCookie({
checks: newChecks,
requestId,
lifetime,
challenges,
});
// @ts-ignore
session = { ...result.session, challenges: result.challenges };
} else {
throw error;
}
}
if (!session || ("error" in session && session.error)) {
return { error: t("couldNotUpdateSession") };
}
// if password, check if user has MFA methods
let authMethods;
if (checks && checks.password && session.factors?.user?.id) {
const response = await listAuthenticationMethodTypes({ serviceConfig, userId: session.factors.user.id });
if (response.authMethodTypes && response.authMethodTypes.length) {
authMethods = response.authMethodTypes;
}
}
return {
sessionId: session.id,
factors: session.factors,
// @ts-ignore
challenges: session.challenges,
authMethods,
};
}
type ClearSessionOptions = {
sessionId: string;
};
export async function clearSession(options: ClearSessionOptions) {
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
const { sessionId } = options;
const sessionCookie = await getSessionCookieById({ sessionId });
if (!sessionCookie) {
return;
}
const deleteResponse = await deleteSession({
serviceConfig,
sessionId: sessionCookie.id,
sessionToken: sessionCookie.token,
});
const securitySettings = await getSecuritySettings({ serviceConfig });
const iFrameEnabled = !!securitySettings?.embeddedIframe?.enabled;
if (!deleteResponse) {
throw new Error("Could not delete session");
}
return removeSessionFromCookie({ session: sessionCookie, iFrameEnabled });
}