diff --git a/apps/login/src/lib/session.test.ts b/apps/login/src/lib/session.test.ts index c24b561c9..158b8aba7 100644 --- a/apps/login/src/lib/session.test.ts +++ b/apps/login/src/lib/session.test.ts @@ -956,6 +956,92 @@ describe("isSessionValid", () => { expect(result).toBe(true); }); + + test("should return true when authenticated with user-verified passkey even if TOTP is configured but not verified", async () => { + // Regression: a user with passkey + password + TOTP got stuck in a redirect loop on + // /passkey — the flow never prompts for TOTP after a passkey login (checkMFAFactors + // escapes), but isSessionValid deemed the session invalid because the configured + // TOTP factor was not verified. A user-verified passkey is inherently multi-factor. + const verifiedTimestamp = createMockTimestamp(); + const session = createMockSession({ + factors: { + user: { + id: mockUserId, + organizationId: mockOrganizationId, + loginName: "test@example.com", + displayName: "Test User", + verifiedAt: verifiedTimestamp, + }, + webAuthN: { + verifiedAt: verifiedTimestamp, + userVerified: true, + }, + // No password factor, TOTP configured but not verified in this session + }, + }); + + vi.mocked(zitadelModule.listAuthenticationMethodTypes).mockResolvedValue({ + authMethodTypes: [ + AuthenticationMethodType.PASSWORD, + AuthenticationMethodType.PASSKEY, + AuthenticationMethodType.TOTP, + ], + } as any); + + vi.mocked(zitadelModule.getLoginSettings).mockResolvedValue({ + forceMfa: false, + forceMfaLocalOnly: false, + } as any); + + vi.mocked(verifyHelperModule.shouldEnforceMFA).mockReturnValue(false); + + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); + + expect(result).toBe(true); + }); + + test("should return false when WebAuthn was presence-only (no userVerified) and configured TOTP is not verified", async () => { + // A presence-only WebAuthn assertion (U2F-style, userVerified=false) is not a passkey + // login and must not bypass the configured-MFA requirement. + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const verifiedTimestamp = createMockTimestamp(); + const session = createMockSession({ + factors: { + user: { + id: mockUserId, + organizationId: mockOrganizationId, + loginName: "test@example.com", + displayName: "Test User", + verifiedAt: verifiedTimestamp, + }, + password: { + verifiedAt: verifiedTimestamp, + }, + webAuthN: { + verifiedAt: verifiedTimestamp, + userVerified: false, + }, + // TOTP configured but not verified in this session + }, + }); + + vi.mocked(zitadelModule.listAuthenticationMethodTypes).mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.PASSWORD, AuthenticationMethodType.TOTP], + } as any); + + vi.mocked(zitadelModule.getLoginSettings).mockResolvedValue({ + forceMfa: true, + forceMfaLocalOnly: false, + } as any); + + vi.mocked(verifyHelperModule.shouldEnforceMFA).mockReturnValue(true); + + const result = await isSessionValid({ serviceConfig: { baseUrl: mockServiceUrl }, session }); + + expect(result).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith("[Session] MFA is required but not valid"); + consoleSpy.mockRestore(); + }); }); describe("IDP authentication", () => { diff --git a/apps/login/src/lib/session.ts b/apps/login/src/lib/session.ts index a710c2618..68624abe4 100644 --- a/apps/login/src/lib/session.ts +++ b/apps/login/src/lib/session.ts @@ -126,14 +126,22 @@ export async function isSessionValid({ method === AuthenticationMethodType.U2F, ); + // A user-verified passkey (possession + user verification) is inherently multi-factor. + // Mirrors the passkey escape in checkMFAFactors, which never prompts for an additional + // second factor after a passkey login. Without this, users with a passkey plus a + // configured TOTP/OTP factor get stuck in a redirect loop on /passkey, because the + // flow never asks for the second factor but this check would deem the session invalid. + const hasAuthenticatedWithPasskey = !!session.factors.webAuthN?.verifiedAt && !!session.factors.webAuthN?.userVerified; + if (mfaMethods && mfaMethods.length > 0) { - // User has MFA methods configured — they must be verified regardless of policy + // User has MFA methods configured — they must be verified regardless of policy, + // unless the session was already authenticated with a user-verified passkey const totpValid = mfaMethods.includes(AuthenticationMethodType.TOTP) && !!session.factors.totp?.verifiedAt; const otpEmailValid = mfaMethods.includes(AuthenticationMethodType.OTP_EMAIL) && !!session.factors.otpEmail?.verifiedAt; const otpSmsValid = mfaMethods.includes(AuthenticationMethodType.OTP_SMS) && !!session.factors.otpSms?.verifiedAt; const u2fValid = mfaMethods.includes(AuthenticationMethodType.U2F) && !!session.factors.webAuthN?.verifiedAt; - mfaValid = totpValid || otpEmailValid || otpSmsValid || u2fValid; + mfaValid = hasAuthenticatedWithPasskey || totpValid || otpEmailValid || otpSmsValid || u2fValid; } else if (isMfaRequired) { // No MFA methods configured, but MFA is forced by policy — check for any verified MFA factors const otpEmail = session.factors.otpEmail?.verifiedAt;