Files
zitadel/apps/login/src/lib/server/passkeys.test.ts
T
26f8238499 fix(login): correctly redirect to /password when ignoreUnknownUsername is set (#11130)
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]>
2026-01-14 08:24:48 +00:00

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),
}),
}),
);
});
});
});