mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-20 22:44:25 +00:00
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.
480 lines
17 KiB
TypeScript
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();
|
|
});
|
|
});
|