mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
Closes #11006 # Which Problems Are Solved This PR addresses an issue where the ignoreUnknownUsernames setting was not being respected in certain scenarios during the login flow. Specifically: - When a user was found but rejected due to login settings (e.g., disableLoginWithEmail), the system would return a "User not found" error instead of redirecting to the password page as dictated by ignoreUnknownUsernames. - When a user was not found, and the flow fell through the registration checks (e.g., IDP redirect failed), it would also return "User not found" without checking ignoreUnknownUsernames. # How the Problems Are Solved - Introduced a helper function `handleUserNotFound` in `apps/login/src/lib/server/loginname.ts` to centralize the logic for checking ignoreUnknownUsernames and redirecting to the password page. - Updated the user validation logic to call handleUserNotFound when a user is found but rejected by policy. - Updated the registration fallback logic to call handleUserNotFound instead of directly returning an error. - Added new test cases in `apps/login/src/lib/server/loginname.test.ts` to verify the fix and ensure no regressions. # Additional Changes - Fixed inconsistency in translation files --------- Co-authored-by: Livio Spring <[email protected]>
477 lines
14 KiB
TypeScript
477 lines
14 KiB
TypeScript
import { describe, expect, test, vi, beforeEach } from "vitest";
|
|
import { sendPasskey } from "./passkeys";
|
|
|
|
// Mock all the dependencies
|
|
vi.mock("next/headers", () => ({
|
|
headers: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@zitadel/client", () => ({
|
|
create: vi.fn(),
|
|
Duration: vi.fn(),
|
|
Timestamp: vi.fn(),
|
|
timestampDate: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../service-url", () => ({
|
|
getServiceConfig: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../zitadel", () => ({
|
|
getLoginSettings: vi.fn(),
|
|
getUserByID: vi.fn(),
|
|
listUsers: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("./cookie", () => ({
|
|
setSessionAndUpdateCookie: vi.fn(),
|
|
createSessionAndUpdateCookie: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../cookies", () => ({
|
|
getSessionCookieById: vi.fn(),
|
|
getSessionCookieByLoginName: vi.fn(),
|
|
getMostRecentSessionCookie: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../verify-helper", () => ({
|
|
checkEmailVerification: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../client", () => ({
|
|
completeFlowOrGetUrl: vi.fn(),
|
|
}));
|
|
|
|
// Mock translations - returns the key itself for testing
|
|
vi.mock("next-intl/server", () => ({
|
|
getTranslations: vi.fn(() => (key: string) => key),
|
|
}));
|
|
|
|
describe("sendPasskey", () => {
|
|
let mockHeaders: any;
|
|
let mockGetServiceUrlFromHeaders: any;
|
|
let mockGetLoginSettings: any;
|
|
let mockGetUserByID: any;
|
|
let mockListUsers: any;
|
|
let mockSetSessionAndUpdateCookie: any;
|
|
let mockCreateSessionAndUpdateCookie: any;
|
|
let mockGetSessionCookieById: any;
|
|
let mockGetSessionCookieByLoginName: any;
|
|
let mockGetMostRecentSessionCookie: any;
|
|
let mockCheckEmailVerification: any;
|
|
let mockCompleteFlowOrGetUrl: any;
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
|
|
// Import mocked modules
|
|
const { headers } = await import("next/headers");
|
|
const { getServiceConfig } = await import("../service-url");
|
|
const { getLoginSettings, getUserByID, listUsers } = await import("../zitadel");
|
|
const { setSessionAndUpdateCookie, createSessionAndUpdateCookie } = await import("./cookie");
|
|
const { getSessionCookieById, getSessionCookieByLoginName, getMostRecentSessionCookie } = await import("../cookies");
|
|
const { checkEmailVerification } = await import("../verify-helper");
|
|
const { completeFlowOrGetUrl } = await import("../client");
|
|
|
|
// Setup mocks
|
|
mockHeaders = vi.mocked(headers);
|
|
mockGetServiceUrlFromHeaders = vi.mocked(getServiceConfig);
|
|
mockGetLoginSettings = vi.mocked(getLoginSettings);
|
|
mockGetUserByID = vi.mocked(getUserByID);
|
|
mockListUsers = vi.mocked(listUsers);
|
|
mockSetSessionAndUpdateCookie = vi.mocked(setSessionAndUpdateCookie);
|
|
mockCreateSessionAndUpdateCookie = vi.mocked(createSessionAndUpdateCookie);
|
|
mockCreateSessionAndUpdateCookie.mockResolvedValue({
|
|
session: { id: "new-session", factors: { user: { id: "user-1", loginName: "user" } } } as any,
|
|
sessionCookie: { id: "new-session", token: "token", loginName: "user" },
|
|
});
|
|
mockGetSessionCookieById = vi.mocked(getSessionCookieById);
|
|
mockGetSessionCookieByLoginName = vi.mocked(getSessionCookieByLoginName);
|
|
mockGetMostRecentSessionCookie = vi.mocked(getMostRecentSessionCookie);
|
|
mockCheckEmailVerification = vi.mocked(checkEmailVerification);
|
|
mockCompleteFlowOrGetUrl = vi.mocked(completeFlowOrGetUrl);
|
|
|
|
// Default mock implementations
|
|
const headersList = new Headers();
|
|
headersList.set("host", "test.com");
|
|
mockHeaders.mockResolvedValue(headersList);
|
|
mockGetServiceUrlFromHeaders.mockReturnValue({
|
|
serviceUrl: "https://example.com",
|
|
});
|
|
mockGetLoginSettings.mockResolvedValue({
|
|
multiFactorCheckLifetime: {
|
|
seconds: BigInt(300),
|
|
nanos: 0,
|
|
},
|
|
});
|
|
});
|
|
|
|
describe("Session Cookie Retrieval", () => {
|
|
test("should return error when session cookie not found by sessionId", async () => {
|
|
mockGetSessionCookieById.mockResolvedValue(null);
|
|
|
|
const result = await sendPasskey({
|
|
sessionId: "test-session-id",
|
|
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
error: "couldNotFindSession",
|
|
});
|
|
expect(mockGetSessionCookieById).toHaveBeenCalledWith({
|
|
sessionId: "test-session-id",
|
|
});
|
|
});
|
|
|
|
test("should return error when session cookie is not found by loginName", async () => {
|
|
mockGetSessionCookieByLoginName.mockResolvedValue(null); // Not found
|
|
mockCreateSessionAndUpdateCookie.mockRejectedValue(new Error("Creation failed")); // Force creation failure
|
|
|
|
const result = await sendPasskey({
|
|
loginName: "[email protected]",
|
|
organization: "org-123",
|
|
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
error: "couldNotFindSession",
|
|
});
|
|
expect(mockGetSessionCookieByLoginName).toHaveBeenCalledWith({
|
|
loginName: "[email protected]",
|
|
organization: "org-123",
|
|
});
|
|
});
|
|
|
|
test("should return error when no session cookie found (most recent fallback)", async () => {
|
|
mockGetMostRecentSessionCookie.mockResolvedValue(null);
|
|
|
|
const result = await sendPasskey({
|
|
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
error: "couldNotFindSession",
|
|
});
|
|
expect(mockGetMostRecentSessionCookie).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("Session Update Failures", () => {
|
|
beforeEach(() => {
|
|
mockGetSessionCookieById.mockResolvedValue({
|
|
id: "session-123",
|
|
token: "session-token",
|
|
loginName: "[email protected]",
|
|
});
|
|
});
|
|
|
|
test("should return error when setSessionAndUpdateCookie fails", async () => {
|
|
mockSetSessionAndUpdateCookie.mockResolvedValue({
|
|
error: "Failed to update session",
|
|
});
|
|
|
|
const result = await sendPasskey({
|
|
sessionId: "session-123",
|
|
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
error: "couldNotUpdateSession",
|
|
});
|
|
});
|
|
|
|
test("should return error when setSessionAndUpdateCookie returns undefined", async () => {
|
|
mockSetSessionAndUpdateCookie.mockResolvedValue(undefined as any);
|
|
|
|
const result = await sendPasskey({
|
|
sessionId: "session-123",
|
|
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
error: "couldNotUpdateSession",
|
|
});
|
|
});
|
|
|
|
test("should fallback to createSessionAndUpdateCookie when setSessionAndUpdateCookie fails and checks are present", async () => {
|
|
mockSetSessionAndUpdateCookie.mockRejectedValue(new Error("session already terminated"));
|
|
|
|
mockCreateSessionAndUpdateCookie.mockResolvedValue({
|
|
session: {
|
|
id: "new-session-123",
|
|
factors: {
|
|
user: {
|
|
id: "user-123",
|
|
loginName: "[email protected]",
|
|
},
|
|
},
|
|
},
|
|
sessionCookie: {
|
|
id: "new-session-123",
|
|
token: "new-token",
|
|
},
|
|
});
|
|
|
|
mockListUsers.mockResolvedValue({
|
|
details: { totalResult: BigInt(1) },
|
|
result: [{ userId: "user-123" }],
|
|
});
|
|
|
|
mockGetUserByID.mockResolvedValue({
|
|
user: {
|
|
id: "user-123",
|
|
type: {
|
|
case: "human",
|
|
value: { email: { isVerified: true } },
|
|
},
|
|
},
|
|
});
|
|
|
|
mockCheckEmailVerification.mockResolvedValue(true);
|
|
mockCompleteFlowOrGetUrl.mockResolvedValue({ redirect: "/dashboard" });
|
|
|
|
const result = await sendPasskey({
|
|
sessionId: "session-123",
|
|
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
|
});
|
|
|
|
// It should succeed with the new session
|
|
expect(result).toEqual({
|
|
redirect: "/dashboard",
|
|
});
|
|
|
|
expect(mockCreateSessionAndUpdateCookie).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("User Validation", () => {
|
|
beforeEach(() => {
|
|
mockGetSessionCookieById.mockResolvedValue({
|
|
id: "session-123",
|
|
token: "session-token",
|
|
loginName: "[email protected]",
|
|
});
|
|
mockSetSessionAndUpdateCookie.mockResolvedValue({
|
|
sessionId: "session-123",
|
|
sessionToken: "new-token",
|
|
factors: {
|
|
user: {
|
|
id: "user-123",
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
test("should return error when getUserByID fails", async () => {
|
|
mockGetUserByID.mockRejectedValue(new Error("User not found"));
|
|
|
|
const result = await sendPasskey({
|
|
sessionId: "session-123",
|
|
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
error: "verify.errors.couldNotGetUser",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Successful Passkey Verification", () => {
|
|
beforeEach(() => {
|
|
mockGetSessionCookieById.mockResolvedValue({
|
|
id: "session-123",
|
|
token: "session-token",
|
|
loginName: "[email protected]",
|
|
});
|
|
mockSetSessionAndUpdateCookie.mockResolvedValue({
|
|
id: "session-123",
|
|
factors: {
|
|
user: {
|
|
id: "user-123",
|
|
loginName: "[email protected]",
|
|
},
|
|
},
|
|
});
|
|
mockGetUserByID.mockResolvedValue({
|
|
user: {
|
|
id: "user-123",
|
|
type: {
|
|
case: "human",
|
|
value: {
|
|
email: {
|
|
isVerified: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
mockCheckEmailVerification.mockResolvedValue(true);
|
|
});
|
|
|
|
test("should redirect on successful verification without requestId", async () => {
|
|
mockCompleteFlowOrGetUrl.mockResolvedValue({ redirect: "/dashboard" });
|
|
|
|
const result = await sendPasskey({
|
|
sessionId: "session-123",
|
|
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
redirect: "/dashboard",
|
|
});
|
|
});
|
|
|
|
test("should redirect on successful verification with requestId", async () => {
|
|
mockCompleteFlowOrGetUrl.mockResolvedValue({ redirect: "/auth/callback" });
|
|
|
|
const result = await sendPasskey({
|
|
sessionId: "session-123",
|
|
requestId: "request-123",
|
|
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
redirect: "/auth/callback",
|
|
});
|
|
});
|
|
|
|
test("should redirect for email verification when required", async () => {
|
|
mockCheckEmailVerification.mockReturnValue({ redirect: "/verify" });
|
|
|
|
const result = await sendPasskey({
|
|
sessionId: "session-123",
|
|
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
|
});
|
|
|
|
expect(result).toHaveProperty("redirect");
|
|
if ("redirect" in result) {
|
|
expect(result.redirect).toContain("/verify");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Fallback Error Handling - Critical Fix", () => {
|
|
beforeEach(() => {
|
|
mockGetSessionCookieById.mockResolvedValue({
|
|
id: "session-123",
|
|
token: "session-token",
|
|
loginName: "[email protected]",
|
|
});
|
|
mockSetSessionAndUpdateCookie.mockResolvedValue({
|
|
id: "session-123",
|
|
factors: {
|
|
user: {
|
|
id: "user-123",
|
|
loginName: "[email protected]",
|
|
},
|
|
},
|
|
});
|
|
mockGetUserByID.mockResolvedValue({
|
|
user: {
|
|
id: "user-123",
|
|
type: {
|
|
case: "human",
|
|
value: {
|
|
email: {
|
|
isVerified: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
mockCheckEmailVerification.mockResolvedValue(true);
|
|
});
|
|
|
|
test("should return fallback error when completeFlowOrGetUrl returns undefined", async () => {
|
|
mockCompleteFlowOrGetUrl.mockResolvedValue(undefined);
|
|
|
|
const result = await sendPasskey({
|
|
sessionId: "session-123",
|
|
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
error: "verify.errors.couldNotDetermineRedirect",
|
|
});
|
|
});
|
|
|
|
test("should return fallback error when completeFlowOrGetUrl returns empty string", async () => {
|
|
mockCompleteFlowOrGetUrl.mockResolvedValue("");
|
|
|
|
const result = await sendPasskey({
|
|
sessionId: "session-123",
|
|
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
error: "verify.errors.couldNotDetermineRedirect",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Custom Lifetime Handling", () => {
|
|
beforeEach(() => {
|
|
mockGetSessionCookieById.mockResolvedValue({
|
|
id: "session-123",
|
|
token: "session-token",
|
|
loginName: "[email protected]",
|
|
});
|
|
mockSetSessionAndUpdateCookie.mockResolvedValue({
|
|
id: "session-123",
|
|
factors: {
|
|
user: {
|
|
id: "user-123",
|
|
loginName: "[email protected]",
|
|
},
|
|
},
|
|
});
|
|
mockGetUserByID.mockResolvedValue({
|
|
user: {
|
|
id: "user-123",
|
|
type: {
|
|
case: "human",
|
|
value: {
|
|
email: {
|
|
isVerified: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
mockCheckEmailVerification.mockResolvedValue(true);
|
|
mockCompleteFlowOrGetUrl.mockResolvedValue({ redirect: "/dashboard" });
|
|
});
|
|
|
|
test("should use custom lifetime when provided", async () => {
|
|
await sendPasskey({
|
|
sessionId: "session-123",
|
|
lifetime: { seconds: BigInt(600), nanos: 0 } as any,
|
|
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
|
});
|
|
|
|
expect(mockSetSessionAndUpdateCookie).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
lifetime: expect.objectContaining({
|
|
seconds: BigInt(600),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("should use default lifetime from login settings when not provided", async () => {
|
|
await sendPasskey({
|
|
sessionId: "session-123",
|
|
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
|
});
|
|
|
|
expect(mockSetSessionAndUpdateCookie).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
lifetime: expect.objectContaining({
|
|
seconds: BigInt(300),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
});
|