Files
zitadel/apps/login/src/lib/server/password.test.ts
T
Max PeintnerandGitHub 00481bd2dc fix(login): prevent eventual consistency issues on /password/change, missing permissions (#11371)
Closes #11345 

# Which Problems Are Solved

When changing password, users could have run into a race condition /
eventual consistency issue which resulted in:

- Verification failures in `sendPassword`
- The user receiving a `couldNotCreateSession` or
`couldNotCreateSessionForUser` error despite providing a valid password.
 
# How the Problems Are Solved

- Previously, `checkSessionAndSetPassword` was fired without await,
causing the code to proceed immediately to
`sendPassword`. This resulted in `sendPassword` trying to verify the
user's session with the new password before the password update had
actually completed on the server.
- Now the password change call is executed by the login service user
only, ommitting eventual `membership not found (AUTHZ-cdgFk)` errors
from the API by using the user session itself. The login checks for a
recent password change (within 5 minutes) as well to ensure session
freshness.
2026-01-20 09:15:13 +00:00

480 lines
17 KiB
TypeScript

import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { changePassword, checkSessionAndSetPassword, resetPassword, sendPassword } from "./password";
// Mock dependencies
vi.mock("next/headers", () => ({
headers: vi.fn(),
}));
vi.mock("@zitadel/client", () => ({
create: vi.fn(),
ConnectError: class extends Error {
code: number;
constructor(msg: string, code: number) {
super(msg);
this.code = code;
}
},
timestampDate: (ts: any) => new Date(ts.seconds * 1000),
}));
vi.mock("../service-url", () => ({
getServiceConfig: vi.fn(),
}));
vi.mock("../zitadel", () => ({
getLoginSettings: vi.fn(),
listAuthenticationMethodTypes: vi.fn(),
getSession: vi.fn(),
setPassword: vi.fn(),
createServerTransport: vi.fn(),
getLockoutSettings: vi.fn(),
passwordReset: vi.fn(),
getUserByID: vi.fn(),
setUserPassword: vi.fn(),
getPasswordExpirySettings: vi.fn(),
searchUsers: vi.fn(),
}));
vi.mock("./cookie", () => ({
createSessionAndUpdateCookie: vi.fn(),
setSessionAndUpdateCookie: vi.fn(),
}));
vi.mock("../cookies", () => ({
getSessionCookieById: vi.fn(),
getSessionCookieByLoginName: vi.fn(),
}));
vi.mock("next-intl/server", () => ({
getTranslations: vi.fn(() => (key: string) => key),
}));
vi.mock("../verify-helper", () => ({
checkEmailVerification: vi.fn(),
checkMFAFactors: vi.fn(),
checkPasswordChangeRequired: vi.fn(),
checkUserVerification: vi.fn(),
}));
vi.mock("../client", () => ({
completeFlowOrGetUrl: vi.fn(),
}));
describe("checkSessionAndSetPassword", () => {
let mockHeaders: any;
let mockGetServiceConfig: any;
let mockGetSessionCookieById: any;
let mockGetSession: any;
let mockListAuthenticationMethodTypes: any;
let mockGetLoginSettings: any;
let mockSetPassword: any; // Service account
beforeEach(async () => {
vi.clearAllMocks();
const { headers } = await import("next/headers");
const { getServiceConfig } = await import("../service-url");
const { getSessionCookieById } = await import("../cookies");
const { getSession, listAuthenticationMethodTypes, getLoginSettings, setPassword } = await import("../zitadel");
mockHeaders = vi.mocked(headers);
mockGetServiceConfig = vi.mocked(getServiceConfig);
mockGetSessionCookieById = vi.mocked(getSessionCookieById);
mockGetSession = vi.mocked(getSession);
mockListAuthenticationMethodTypes = vi.mocked(listAuthenticationMethodTypes);
mockGetLoginSettings = vi.mocked(getLoginSettings);
mockSetPassword = vi.mocked(setPassword);
mockHeaders.mockResolvedValue({});
mockGetServiceConfig.mockReturnValue({ serviceConfig: { baseUrl: "https://api.example.com" } });
// Default session setup
mockGetSessionCookieById.mockResolvedValue({ id: "session123", token: "token123" });
mockGetSession.mockResolvedValue({
session: {
factors: {
user: { id: "user123", organizationId: "org123" },
password: { verifiedAt: { seconds: Math.floor(Date.now() / 1000) } }, // Password verified recently
},
},
});
// Default: Only password method
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD],
});
// Default: No forced MFA
mockGetLoginSettings.mockResolvedValue({ forceMfa: false });
// Mock user service client
mockSetPassword.mockResolvedValue({});
});
test("should use service account when no MFA is configured", async () => {
await checkSessionAndSetPassword({ sessionId: "session123", password: "newpassword" });
expect(mockSetPassword).toHaveBeenCalled();
});
test("should use service account when MFA is configured but NOT verified in session", async () => {
// User has TOTP configured
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD, AuthenticationMethodType.TOTP],
});
// Session only has password verified (no OTP)
const now = Math.floor(Date.now() / 1000);
mockGetSession.mockResolvedValue({
session: {
factors: {
user: { id: "user123", organizationId: "org123" },
password: { verifiedAt: { seconds: now - 60 } }, // Verified 1 minute ago
// otp missing
},
},
});
await checkSessionAndSetPassword({ sessionId: "session123", password: "newpassword" });
expect(mockSetPassword).toHaveBeenCalled();
});
test("should use service account when MFA is configured AND verified in session", async () => {
// User has TOTP configured
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD, AuthenticationMethodType.TOTP],
});
// Session has both password and OTP verified
mockGetSession.mockResolvedValue({
session: {
factors: {
user: { id: "user123", organizationId: "org123" },
password: { verifiedAt: { seconds: Math.floor(Date.now() / 1000) } },
totp: { verifiedAt: { seconds: Math.floor(Date.now() / 1000) } }, // Verified
},
},
});
await checkSessionAndSetPassword({ sessionId: "session123", password: "newpassword" });
expect(mockSetPassword).toHaveBeenCalled();
});
test("should fail when MFA is configured but not verified, and password verification is too old", async () => {
// User has TOTP configured
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD, AuthenticationMethodType.TOTP],
});
// Session has password verified 10 minutes ago (600 seconds)
const now = Math.floor(Date.now() / 1000);
mockGetSession.mockResolvedValue({
session: {
factors: {
user: { id: "user123", organizationId: "org123" },
password: { verifiedAt: { seconds: now - 600 } },
// otp missing
},
},
});
const result = await checkSessionAndSetPassword({ sessionId: "session123", password: "newpassword" });
expect(result).toEqual({ error: "errors.passwordVerificationTooOld" });
expect(mockSetPassword).not.toHaveBeenCalled();
});
test("should use service account when MFA is configured but not verified, and password verification is recent", async () => {
// User has TOTP configured
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD, AuthenticationMethodType.TOTP],
});
// Session has password verified 1 minute ago (60 seconds)
const now = Math.floor(Date.now() / 1000);
mockGetSession.mockResolvedValue({
session: {
factors: {
user: { id: "user123", organizationId: "org123" },
password: { verifiedAt: { seconds: now - 60 } },
// otp missing
},
},
});
await checkSessionAndSetPassword({ sessionId: "session123", password: "newpassword" });
expect(mockSetPassword).toHaveBeenCalled();
});
});
describe("sendPassword", () => {
let mockHeaders: any;
let mockGetServiceConfig: any;
let mockGetSessionCookieByLoginName: any;
let mockSearchUsers: any;
let mockGetLoginSettings: any;
let mockCreateSessionAndUpdateCookie: any;
let mockSetSessionAndUpdateCookie: any;
let mockGetLockoutSettings: any;
beforeEach(async () => {
vi.clearAllMocks();
const { headers } = await import("next/headers");
const { getServiceConfig } = await import("../service-url");
const { getSessionCookieByLoginName } = await import("../cookies");
const { getLoginSettings, getLockoutSettings, searchUsers } = await import("../zitadel");
const { createSessionAndUpdateCookie, setSessionAndUpdateCookie } = await import("./cookie");
mockHeaders = vi.mocked(headers);
mockGetServiceConfig = vi.mocked(getServiceConfig);
mockGetSessionCookieByLoginName = vi.mocked(getSessionCookieByLoginName);
mockSearchUsers = vi.mocked(searchUsers);
mockGetLoginSettings = vi.mocked(getLoginSettings);
mockCreateSessionAndUpdateCookie = vi.mocked(createSessionAndUpdateCookie);
mockSetSessionAndUpdateCookie = vi.mocked(setSessionAndUpdateCookie);
mockCreateSessionAndUpdateCookie = vi.mocked(createSessionAndUpdateCookie);
mockSetSessionAndUpdateCookie = vi.mocked(setSessionAndUpdateCookie);
mockGetLockoutSettings = vi.mocked(getLockoutSettings);
const { completeFlowOrGetUrl } = await import("../client");
vi.mocked(completeFlowOrGetUrl).mockResolvedValue({ redirect: "https://example.com" });
// eslint-disable-next-line
const verifyHelper = await import("../verify-helper");
vi.mocked(verifyHelper.checkPasswordChangeRequired).mockReturnValue(undefined);
vi.mocked(verifyHelper.checkEmailVerification).mockReturnValue(undefined);
vi.mocked(verifyHelper.checkMFAFactors).mockResolvedValue(undefined);
mockHeaders.mockResolvedValue({});
mockGetServiceConfig.mockReturnValue({ serviceConfig: { baseUrl: "https://api.example.com" } });
});
test("should return generic error when user not found and ignoreUnknownUsernames is true", async () => {
mockGetSessionCookieByLoginName.mockResolvedValue(null);
mockSearchUsers.mockResolvedValue({ result: [] });
mockGetLoginSettings.mockResolvedValue({ ignoreUnknownUsernames: true });
const result = await sendPassword({
loginName: "[email protected]",
checks: { password: { password: "password" } } as any,
});
expect(result).toEqual({ error: "errors.failedToAuthenticateNoLimit" });
});
test("should return specific error when user not found and ignoreUnknownUsernames is false", async () => {
mockGetSessionCookieByLoginName.mockResolvedValue(null);
mockSearchUsers.mockResolvedValue({ result: [] });
mockGetLoginSettings.mockResolvedValue({ ignoreUnknownUsernames: false });
const result = await sendPassword({
loginName: "[email protected]",
checks: { password: { password: "password" } } as any,
});
expect(result).toEqual({ error: "errors.couldNotVerifyPassword" });
});
test("should return generic error when password verification fails and ignoreUnknownUsernames is true", async () => {
mockGetSessionCookieByLoginName.mockResolvedValue(null);
mockSearchUsers.mockResolvedValue({
result: [{ userId: "user123", type: { case: "human", value: {} }, state: 1 }],
});
mockGetLoginSettings.mockResolvedValue({ ignoreUnknownUsernames: true });
mockCreateSessionAndUpdateCookie.mockRejectedValue({ failedAttempts: 1 });
const result = await sendPassword({
loginName: "[email protected]",
checks: { password: { password: "wrong" } } as any,
});
expect(result).toEqual({ error: "errors.failedToAuthenticateNoLimit" });
});
test("should return generic error when session creation fails with unknown error and ignoreUnknownUsernames is true", async () => {
mockGetSessionCookieByLoginName.mockResolvedValue(null);
mockSearchUsers.mockResolvedValue({
result: [{ userId: "user123", type: { case: "human", value: {} }, state: 1 }],
});
mockGetLoginSettings.mockResolvedValue({ ignoreUnknownUsernames: true });
// Simulate an error that is NOT a failed attempt error (e.g. database error)
mockCreateSessionAndUpdateCookie.mockRejectedValue(new Error("Some internal error"));
const result = await sendPassword({
loginName: "[email protected]",
checks: { password: { password: "correct" } } as any,
});
expect(result).toEqual({ error: "errors.failedToAuthenticateNoLimit" });
});
test("should return specific error with lockout info when password verification fails and ignoreUnknownUsernames is false", async () => {
mockGetSessionCookieByLoginName.mockResolvedValue(null);
mockSearchUsers.mockResolvedValue({
result: [{ userId: "user123", type: { case: "human", value: {} }, state: 1 }],
});
mockGetLoginSettings.mockResolvedValue({ ignoreUnknownUsernames: false });
mockCreateSessionAndUpdateCookie.mockRejectedValue({ failedAttempts: 1 });
mockGetLockoutSettings.mockResolvedValue({ maxPasswordAttempts: BigInt(5) });
const result = await sendPassword({
loginName: "[email protected]",
checks: { password: { password: "wrong" } } as any,
});
expect(result).toEqual({
error: "errors.failedToAuthenticate",
});
});
test("should recreate session when session verification fails with keys/session terminated error and ignoreUnknownUsernames is true", async () => {
mockGetSessionCookieByLoginName.mockResolvedValue({
id: "session123",
token: "token123",
organization: "org123",
});
const terminatedError = { message: "session already terminated" };
mockSetSessionAndUpdateCookie.mockRejectedValue(terminatedError);
mockGetLoginSettings.mockResolvedValue({ ignoreUnknownUsernames: true });
mockSearchUsers.mockResolvedValue({
result: [
{
userId: "user123",
type: {
case: "human",
value: {
email: { email: "[email protected]", isVerified: true },
phone: { phone: "+1234567890", isVerified: true },
},
},
state: 1, // Active
preferredLoginName: "[email protected]",
},
],
});
mockCreateSessionAndUpdateCookie.mockResolvedValue({
session: { factors: { user: { id: "user123", loginName: "[email protected]" } } },
sessionCookie: { id: "newSession", token: "newToken" },
});
// Execute
await sendPassword({
loginName: "[email protected]",
checks: { password: { password: "password" } } as any,
});
expect(mockSetSessionAndUpdateCookie).toHaveBeenCalled();
expect(mockCreateSessionAndUpdateCookie).toHaveBeenCalled();
});
});
describe("resetPassword", () => {
let mockHeaders: any;
let mockGetServiceConfig: any;
let mockSearchUsers: any;
let mockGetLoginSettings: any;
let mockPasswordReset: any;
beforeEach(async () => {
vi.clearAllMocks();
const { headers } = await import("next/headers");
const { getServiceConfig } = await import("../service-url");
const { getLoginSettings, passwordReset, searchUsers } = await import("../zitadel");
mockHeaders = vi.mocked(headers);
mockGetServiceConfig = vi.mocked(getServiceConfig);
mockSearchUsers = vi.mocked(searchUsers);
mockGetLoginSettings = vi.mocked(getLoginSettings);
mockPasswordReset = vi.mocked(passwordReset);
mockHeaders.mockResolvedValue({ get: vi.fn(() => "example.com") });
mockGetServiceConfig.mockReturnValue({ serviceConfig: { baseUrl: "https://api.example.com" } });
});
test("should return generic success when user not found and ignoreUnknownUsernames is true", async () => {
mockSearchUsers.mockResolvedValue({ result: [] });
mockGetLoginSettings.mockResolvedValue({ ignoreUnknownUsernames: true });
const result = await resetPassword({
loginName: "[email protected]",
});
expect(result).toEqual({});
expect(mockPasswordReset).not.toHaveBeenCalled();
});
test("should return specific error when user not found and ignoreUnknownUsernames is false", async () => {
mockSearchUsers.mockResolvedValue({ result: [] });
mockGetLoginSettings.mockResolvedValue({ ignoreUnknownUsernames: false });
const result = await resetPassword({
loginName: "[email protected]",
});
expect(result).toEqual({ error: "errors.couldNotSendResetLink" });
expect(mockPasswordReset).not.toHaveBeenCalled();
});
});
describe("changePassword", () => {
let mockHeaders: any;
let mockGetServiceConfig: any;
let mockGetUserByID: any;
let mockGetLoginSettings: any;
let mockSetUserPassword: any;
beforeEach(async () => {
vi.clearAllMocks();
const { headers } = await import("next/headers");
const { getServiceConfig } = await import("../service-url");
const { getUserByID, getLoginSettings, setUserPassword } = await import("../zitadel");
mockHeaders = vi.mocked(headers);
mockGetServiceConfig = vi.mocked(getServiceConfig);
mockGetUserByID = vi.mocked(getUserByID);
mockGetLoginSettings = vi.mocked(getLoginSettings);
mockSetUserPassword = vi.mocked(setUserPassword);
mockHeaders.mockResolvedValue({ get: vi.fn(() => "example.com") });
mockGetServiceConfig.mockReturnValue({ serviceConfig: { baseUrl: "https://api.example.com" } });
});
test("should return generic error when user not found and ignoreUnknownUsernames is true", async () => {
mockGetUserByID.mockResolvedValue({}); // User not found
mockGetLoginSettings.mockResolvedValue({ ignoreUnknownUsernames: true });
const result = await changePassword({
userId: "unknown",
password: "newpassword",
});
expect(result).toEqual({ error: "set.errors.couldNotSetPassword" });
expect(mockSetUserPassword).not.toHaveBeenCalled();
});
test("should return specific error when user not found and ignoreUnknownUsernames is false", async () => {
mockGetUserByID.mockResolvedValue({}); // User not found
mockGetLoginSettings.mockResolvedValue({ ignoreUnknownUsernames: false });
const result = await changePassword({
userId: "unknown",
password: "newpassword",
});
expect(result).toEqual({ error: "errors.couldNotSendResetLink" });
expect(mockSetUserPassword).not.toHaveBeenCalled();
});
});