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
This commit is contained in:
Max Peintner
2026-07-17 08:23:56 +02:00
committed by GitHub
parent baf6ed501b
commit 56f4798ed3
6 changed files with 173 additions and 82 deletions
+2 -2
View File
@@ -27,7 +27,7 @@ export async function generateMetadata(): Promise<Metadata> {
return { title: t("set.title") }; return { title: t("set.title") };
} }
function isSessionValid(session: Partial<Session>): { function isSessionValidForMfaSet(session: Partial<Session>): {
valid: boolean; valid: boolean;
verifiedAt?: Timestamp; verifiedAt?: Timestamp;
} { } {
@@ -111,7 +111,7 @@ export default async function Page(props: { searchParams: Promise<Record<string
organization: sessionWithData?.factors?.user?.organizationId, organization: sessionWithData?.factors?.user?.organizationId,
}); });
const { valid } = sessionWithData ? isSessionValid(sessionWithData) : { valid: false }; const { valid } = sessionWithData ? isSessionValidForMfaSet(sessionWithData) : { valid: false };
if (force === "true" && valid && sessionWithData?.factors?.user?.loginName && loginSettings) { if (force === "true" && valid && sessionWithData?.factors?.user?.loginName && loginSettings) {
const emailVerified = sessionWithData.emailVerified ?? false; const emailVerified = sessionWithData.emailVerified ?? false;
@@ -8,7 +8,7 @@ import { useLocale } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { Avatar } from "./avatar"; import { Avatar } from "./avatar";
import { isSessionValid } from "./session-item"; import { isSessionPrimaryFactorAndLifetimeValid } from "./session-item";
import { Translated } from "./translated"; import { Translated } from "./translated";
export function SessionClearItem({ session, reload }: { session: Session; reload: () => void }) { export function SessionClearItem({ session, reload }: { session: Session; reload: () => void }) {
@@ -33,7 +33,7 @@ export function SessionClearItem({ session, reload }: { session: Session; reload
return response; return response;
} }
const { valid, verifiedAt } = isSessionValid(session); const { valid, verifiedAt } = isSessionPrimaryFactorAndLifetimeValid(session);
const [_error, setError] = useState<string | null>(null); const [_error, setError] = useState<string | null>(null);
+2 -2
View File
@@ -15,7 +15,7 @@ import { AutoSubmitForm } from "./auto-submit-form";
import { Avatar } from "./avatar"; import { Avatar } from "./avatar";
import { Translated } from "./translated"; import { Translated } from "./translated";
export function isSessionValid(session: Partial<Session>): { export function isSessionPrimaryFactorAndLifetimeValid(session: Partial<Session>): {
valid: boolean; valid: boolean;
verifiedAt?: Timestamp; verifiedAt?: Timestamp;
} { } {
@@ -53,7 +53,7 @@ export function SessionItem({ session, reload, requestId }: { session: Session;
return response; return response;
} }
const { valid, verifiedAt } = isSessionValid(session); const { valid, verifiedAt } = isSessionPrimaryFactorAndLifetimeValid(session);
const [samlData, setSamlData] = useState<{ url: string; fields: Record<string, string> } | null>(null); const [samlData, setSamlData] = useState<{ url: string; fields: Record<string, string> } | null>(null);
const [_error, setError] = useState<string | null>(null); const [_error, setError] = useState<string | null>(null);
+41 -12
View File
@@ -25,7 +25,9 @@ import {
removeSessionFromCookie, removeSessionFromCookie,
} from "../cookies"; } from "../cookies";
import { getServiceConfig } from "../service-url"; import { getServiceConfig } from "../service-url";
import { isSessionValid } from "../session";
import { getPublicHost } from "./host"; import { getPublicHost } from "./host";
import { sendLoginname } from "./loginname";
const logger = createLogger("session"); const logger = createLogger("session");
@@ -79,9 +81,39 @@ export async function continueWithSession({ requestId, ...session }: ContinueWit
const t = await getTranslations("error"); const t = await getTranslations("error");
const loginSettings = await getLoginSettings({ serviceConfig, organization: session.factors?.user?.organizationId }); if (!session.factors?.user) {
return { error: t("couldNotContinueSession") };
}
if (requestId && session.id && session.factors?.user) { 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( return completeFlowOrGetUrl(
{ {
sessionId: session.id, sessionId: session.id,
@@ -90,18 +122,15 @@ export async function continueWithSession({ requestId, ...session }: ContinueWit
}, },
loginSettings?.defaultRedirectUri, loginSettings?.defaultRedirectUri,
); );
} else if (session.factors?.user) {
return completeFlowOrGetUrl(
{
loginName: session.factors.user.loginName,
organization: session.factors.user.organizationId,
},
loginSettings?.defaultRedirectUri,
);
} }
// Fallback error if we couldn't determine where to redirect return completeFlowOrGetUrl(
return { error: t("couldNotContinueSession") }; {
loginName: session.factors.user.loginName,
organization: session.factors.user.organizationId,
},
loginSettings?.defaultRedirectUri,
);
} }
export type UpdateSessionCommand = { export type UpdateSessionCommand = {
+100 -33
View File
@@ -376,18 +376,22 @@ describe("isSessionValid", () => {
vi.mocked(verifyHelperModule.shouldEnforceMFA).mockReturnValue(false); vi.mocked(verifyHelperModule.shouldEnforceMFA).mockReturnValue(false);
// User has no MFA methods configured
vi.mocked(zitadelModule.listAuthenticationMethodTypes).mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD],
} as any);
const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session });
expect(result).toBe(true); expect(result).toBe(true);
}); });
test("should return true when user has PASSWORD and TOTP configured but only password verified and MFA not required", async () => { test("should return false when user has PASSWORD and TOTP configured but only password verified and MFA not required by policy", async () => {
// This test specifically covers the original bug scenario: // User has TOTP configured as an MFA method.
// - User has PASSWORD and TOTP configured (would show up in listAuthenticationMethodTypes) // Even though MFA is not required by policy, the user's configured MFA
// - User has only verified password, not TOTP // factor must be verified because they have it set up.
// - MFA is not required by policy
// - Session should be valid (this was the bug - it was returning false)
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const verifiedTimestamp = createMockTimestamp(); const verifiedTimestamp = createMockTimestamp();
const session = createMockSession({ const session = createMockSession({
factors: { factors: {
@@ -401,8 +405,7 @@ describe("isSessionValid", () => {
password: { password: {
verifiedAt: verifiedTimestamp, verifiedAt: verifiedTimestamp,
}, },
// TOTP is configured but NOT verified - this is the key part // TOTP is configured but NOT verified
// totp: undefined (no verifiedAt)
}, },
}); });
@@ -413,9 +416,16 @@ describe("isSessionValid", () => {
vi.mocked(verifyHelperModule.shouldEnforceMFA).mockReturnValue(false); vi.mocked(verifyHelperModule.shouldEnforceMFA).mockReturnValue(false);
// User has TOTP configured
vi.mocked(zitadelModule.listAuthenticationMethodTypes).mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD, AuthenticationMethodType.TOTP],
} as any);
const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session });
expect(result).toBe(true); expect(result).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith("[Session] MFA is required but not valid");
consoleSpy.mockRestore();
}); });
test("should return false when user has PASSWORD and TOTP configured but only password verified and MFA IS required", async () => { test("should return false when user has PASSWORD and TOTP configured but only password verified and MFA IS required", async () => {
@@ -459,10 +469,8 @@ describe("isSessionValid", () => {
}); });
test("REGRESSION TEST: user with only PASSWORD factor should be valid when MFA not required", async () => { test("REGRESSION TEST: user with only PASSWORD factor should be valid when MFA not required", async () => {
// This test specifically verifies the original bug is fixed // User has only PASSWORD configured, no MFA methods at all.
// Original bug: A user with only PASSWORD authentication would be invalid // Session should be valid since no MFA factors need verification.
// because the code checked if authMethods.length > 0 (which included PASSWORD)
// and then required MFA verification even when MFA was not required by policy
const verifiedTimestamp = createMockTimestamp(); const verifiedTimestamp = createMockTimestamp();
const session = createMockSession({ const session = createMockSession({
@@ -493,20 +501,23 @@ describe("isSessionValid", () => {
vi.mocked(verifyHelperModule.shouldEnforceMFA).mockReturnValue(false); vi.mocked(verifyHelperModule.shouldEnforceMFA).mockReturnValue(false);
// User has only PASSWORD, no MFA methods
vi.mocked(zitadelModule.listAuthenticationMethodTypes).mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD],
} as any);
const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session });
// This should be true - if it's false, the original bug still exists // This should be true - user has no MFA methods configured
expect(result).toBe(true); expect(result).toBe(true);
}); });
test("DEMONSTRATION: how the original bug would manifest with old logic", async () => { test("should return false when user has TOTP configured but not verified, even if MFA not required by policy", async () => {
// This test demonstrates the original problematic scenario: // User has TOTP configured as an MFA method.
// 1. listAuthenticationMethodTypes returns [PASSWORD, TOTP] // Even though MFA is not required by policy, the configured MFA
// 2. Old logic would check authMethods.length > 0 (true because PASSWORD is included) // factor must be verified.
// 3. Old logic would then require MFA verification regardless of policy
// 4. User has only password verified, no TOTP
// 5. Session would be marked invalid even though MFA is not required
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const verifiedTimestamp = createMockTimestamp(); const verifiedTimestamp = createMockTimestamp();
const session = createMockSession({ const session = createMockSession({
factors: { factors: {
@@ -533,10 +544,58 @@ describe("isSessionValid", () => {
vi.mocked(verifyHelperModule.shouldEnforceMFA).mockReturnValue(false); vi.mocked(verifyHelperModule.shouldEnforceMFA).mockReturnValue(false);
// User has TOTP configured
vi.mocked(zitadelModule.listAuthenticationMethodTypes).mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD, AuthenticationMethodType.TOTP],
} as any);
const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session });
// With the new logic, MFA must be verified if user has MFA methods
expect(result).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith("[Session] MFA is required but not valid");
consoleSpy.mockRestore();
});
test("should return true when user has TOTP configured and verified, even if MFA not required by policy", async () => {
// User has TOTP configured as an MFA method and it is verified on the session.
// Even though MFA is not required by policy, the configured MFA factor
// is verified so the session should be valid.
const verifiedTimestamp = createMockTimestamp();
const session = createMockSession({
factors: {
user: {
id: mockUserId,
organizationId: mockOrganizationId,
loginName: "[email protected]",
displayName: "Test User",
verifiedAt: verifiedTimestamp,
},
password: {
verifiedAt: verifiedTimestamp,
},
totp: {
verifiedAt: verifiedTimestamp,
},
},
});
// MFA is NOT required by policy
vi.mocked(zitadelModule.getLoginSettings).mockResolvedValue({
forceMfa: false,
forceMfaLocalOnly: false,
} as any);
vi.mocked(verifyHelperModule.shouldEnforceMFA).mockReturnValue(false);
// User has TOTP configured
vi.mocked(zitadelModule.listAuthenticationMethodTypes).mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD, AuthenticationMethodType.TOTP],
} as any);
const result = await isSessionValid({ serviceConfig: { baseUrl: 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)
expect(result).toBe(true); expect(result).toBe(true);
}); });
}); });
@@ -984,7 +1043,8 @@ describe("isSessionValid", () => {
}); });
}); });
test("should return true when authenticated with IDP intent and forceMfaLocalOnly (IDP bypasses local-only MFA)", async () => { test("should return false when authenticated with IDP intent and forceMfaLocalOnly but user has unverified MFA methods", async () => {
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const verifiedTimestamp = createMockTimestamp(); const verifiedTimestamp = createMockTimestamp();
const session = createMockSession({ const session = createMockSession({
factors: { factors: {
@@ -1017,14 +1077,17 @@ describe("isSessionValid", () => {
const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session });
expect(result).toBe(true); // MFA methods must be verified regardless of policy
expect(result).toBe(false);
expect(verifyHelperModule.shouldEnforceMFA).toHaveBeenCalledWith(session, expect.any(Object)); expect(verifyHelperModule.shouldEnforceMFA).toHaveBeenCalledWith(session, expect.any(Object));
expect(zitadelModule.getLoginSettings).toHaveBeenCalledWith({ expect(zitadelModule.getLoginSettings).toHaveBeenCalledWith({
serviceConfig: { baseUrl: mockServiceUrl }, serviceConfig: { baseUrl: mockServiceUrl },
organization: mockOrganizationId, organization: mockOrganizationId,
}); });
// Should not call listAuthenticationMethodTypes since shouldEnforceMFA returned false // listAuthenticationMethodTypes is always called now
expect(zitadelModule.listAuthenticationMethodTypes).not.toHaveBeenCalled(); expect(zitadelModule.listAuthenticationMethodTypes).toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith("[Session] MFA is required but not valid");
consoleSpy.mockRestore();
}); });
test("should return true when authenticated with IDP intent and MFA required and satisfied", async () => { test("should return true when authenticated with IDP intent and MFA required and satisfied", async () => {
@@ -1065,7 +1128,8 @@ describe("isSessionValid", () => {
}); });
describe("passkey authentication", () => { describe("passkey authentication", () => {
test("should return true when authenticated with passkey and MFA required (passkey satisfies MFA)", async () => { test("should return false when authenticated with passkey but user has unverified MFA methods", async () => {
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const verifiedTimestamp = createMockTimestamp(); const verifiedTimestamp = createMockTimestamp();
const session = createMockSession({ const session = createMockSession({
factors: { factors: {
@@ -1079,7 +1143,7 @@ describe("isSessionValid", () => {
webAuthN: { webAuthN: {
verifiedAt: verifiedTimestamp, verifiedAt: verifiedTimestamp,
}, },
// No password factor, no additional MFA factors // No password factor, no additional MFA factors verified
}, },
}); });
@@ -1091,21 +1155,24 @@ describe("isSessionValid", () => {
forceMfaLocalOnly: false, forceMfaLocalOnly: false,
} as any); } as any);
// User has MFA methods configured but none verified (passkey should satisfy MFA) // User has TOTP configured but not verified
vi.mocked(zitadelModule.listAuthenticationMethodTypes).mockResolvedValue({ vi.mocked(zitadelModule.listAuthenticationMethodTypes).mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.TOTP], authMethodTypes: [AuthenticationMethodType.TOTP],
} as any); } as any);
const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session });
expect(result).toBe(true); // MFA methods must be verified regardless of policy or passkey
expect(result).toBe(false);
expect(verifyHelperModule.shouldEnforceMFA).toHaveBeenCalledWith(session, expect.any(Object)); expect(verifyHelperModule.shouldEnforceMFA).toHaveBeenCalledWith(session, expect.any(Object));
expect(zitadelModule.getLoginSettings).toHaveBeenCalledWith({ expect(zitadelModule.getLoginSettings).toHaveBeenCalledWith({
serviceConfig: { baseUrl: mockServiceUrl }, serviceConfig: { baseUrl: mockServiceUrl },
organization: mockOrganizationId, organization: mockOrganizationId,
}); });
// Should not call listAuthenticationMethodTypes since shouldEnforceMFA returned false // listAuthenticationMethodTypes is always called now
expect(zitadelModule.listAuthenticationMethodTypes).not.toHaveBeenCalled(); expect(zitadelModule.listAuthenticationMethodTypes).toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith("[Session] MFA is required but not valid");
consoleSpy.mockRestore();
}); });
}); });
+26 -31
View File
@@ -80,43 +80,38 @@ export async function isSessionValid({
// Use the existing shouldEnforceMFA function to determine if MFA is required // Use the existing shouldEnforceMFA function to determine if MFA is required
const isMfaRequired = shouldEnforceMFA(session, loginSettings); const isMfaRequired = shouldEnforceMFA(session, loginSettings);
// Only enforce MFA validation if MFA is required by policy // Always check auth methods to see if the user has MFA factors configured
if (isMfaRequired) { const authMethodTypes = await listAuthenticationMethodTypes({ serviceConfig, userId: session.factors.user.id });
const authMethodTypes = await listAuthenticationMethodTypes({ serviceConfig, userId: session.factors.user.id });
const authMethods = authMethodTypes.authMethodTypes; const authMethods = authMethodTypes.authMethodTypes;
// Filter to only MFA methods (exclude PASSWORD and PASSKEY) // Filter to only MFA methods (exclude PASSWORD and PASSKEY)
const mfaMethods = authMethods?.filter( const mfaMethods = authMethods?.filter(
(method) => (method) =>
method === AuthenticationMethodType.TOTP || method === AuthenticationMethodType.TOTP ||
method === AuthenticationMethodType.OTP_EMAIL || method === AuthenticationMethodType.OTP_EMAIL ||
method === AuthenticationMethodType.OTP_SMS || method === AuthenticationMethodType.OTP_SMS ||
method === AuthenticationMethodType.U2F, method === AuthenticationMethodType.U2F,
); );
if (mfaMethods && mfaMethods.length > 0) { if (mfaMethods && mfaMethods.length > 0) {
// Check if any of the configured MFA methods have been verified // User has MFA methods configured — they must be verified regardless of policy
const totpValid = mfaMethods.includes(AuthenticationMethodType.TOTP) && !!session.factors.totp?.verifiedAt; const totpValid = mfaMethods.includes(AuthenticationMethodType.TOTP) && !!session.factors.totp?.verifiedAt;
const otpEmailValid = const otpEmailValid = mfaMethods.includes(AuthenticationMethodType.OTP_EMAIL) && !!session.factors.otpEmail?.verifiedAt;
mfaMethods.includes(AuthenticationMethodType.OTP_EMAIL) && !!session.factors.otpEmail?.verifiedAt; const otpSmsValid = mfaMethods.includes(AuthenticationMethodType.OTP_SMS) && !!session.factors.otpSms?.verifiedAt;
const otpSmsValid = mfaMethods.includes(AuthenticationMethodType.OTP_SMS) && !!session.factors.otpSms?.verifiedAt; const u2fValid = mfaMethods.includes(AuthenticationMethodType.U2F) && !!session.factors.webAuthN?.verifiedAt;
const u2fValid = mfaMethods.includes(AuthenticationMethodType.U2F) && !!session.factors.webAuthN?.verifiedAt;
mfaValid = totpValid || otpEmailValid || otpSmsValid || u2fValid; mfaValid = totpValid || otpEmailValid || otpSmsValid || u2fValid;
} else { } else if (isMfaRequired) {
// No specific MFA methods configured, but MFA is forced - check for any verified MFA factors // No MFA methods configured, but MFA is forced by policy — check for any verified MFA factors
// (excluding IDP which should be handled separately) const otpEmail = session.factors.otpEmail?.verifiedAt;
const otpEmail = session.factors.otpEmail?.verifiedAt; const otpSms = session.factors.otpSms?.verifiedAt;
const otpSms = session.factors.otpSms?.verifiedAt; const totp = session.factors.totp?.verifiedAt;
const totp = session.factors.totp?.verifiedAt; const webAuthN = session.factors.webAuthN?.verifiedAt;
const webAuthN = session.factors.webAuthN?.verifiedAt;
// Note: Removed IDP (session.factors.intent?.verifiedAt) as requested
mfaValid = !!(otpEmail || otpSms || totp || webAuthN); mfaValid = !!(otpEmail || otpSms || totp || webAuthN);
}
} }
// If MFA is not required by policy, mfaValid remains true // If user has no MFA methods and MFA is not required by policy, mfaValid remains true
const stillValid = session.expirationDate ? timestampDate(session.expirationDate).getTime() > new Date().getTime() : true; const stillValid = session.expirationDate ? timestampDate(session.expirationDate).getTime() > new Date().getTime() : true;